diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0facb9fe..c8da49fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Typecheck + # Primary typecheck: the Go-native compiler (tsgo, + # @typescript/native-preview, pinned exactly). ~9x faster than tsc and + # slightly stricter — it caught a generic-inference hole tsc missed. + # NOTE: tsgo's bin shim is `#!/usr/bin/env node`; always invoke it via + # `bun run` (bun runs it with no node installed) — a naked `tsgo` would + # need node on PATH. + - name: Typecheck (tsgo, Go-native — primary) run: bun run typecheck + # Safety net: stock tsc. tsgo is still a -dev preview and can have + # false negatives, so we keep tsc as a backstop until it GAs. + - name: Typecheck (tsc — safety net) + run: bun run typecheck:tsc + - name: ESLint run: bun run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cba7d77a..c844574a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,15 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Typecheck + # Primary typecheck is tsgo (Go-native, pinned); run via `bun run` so it + # works without a node install. tsc is kept as a safety-net backstop + # below since tsgo is still a -dev preview. + - name: Typecheck (tsgo, Go-native — primary) run: bun run typecheck + - name: Typecheck (tsc — safety net) + run: bun run typecheck:tsc + - name: Lint run: bun run lint diff --git a/apps/loadout-overlay/src/bun/native/deck-hidraw-watcher.ts b/apps/loadout-overlay/src/bun/native/deck-hidraw-watcher.ts index 2033bd0a..142fdceb 100644 --- a/apps/loadout-overlay/src/bun/native/deck-hidraw-watcher.ts +++ b/apps/loadout-overlay/src/bun/native/deck-hidraw-watcher.ts @@ -131,7 +131,10 @@ export async function startDeckHidrawWatcher( // button map per frame is unnecessary on the hot path — chase the one // bit and bail. (Less GC pressure than building a Map every report.) if (bound) { - const cur = (report[bound.byte] & (1 << bound.bit)) !== 0; + // splitReports only yields full REPORT_LEN (64-byte) frames and + // bound.byte is always < 64, so this index is provably in-bounds; + // ?? 0 is behaviour-identical for the bitwise test and drops the `!`. + const cur = ((report[bound.byte] ?? 0) & (1 << bound.bit)) !== 0; if (cur && !lastBitValue && !suppressNextEdge) { opts.onWake("QamToggle"); } diff --git a/apps/loadout-overlay/src/bun/native/devices.ts b/apps/loadout-overlay/src/bun/native/devices.ts index b85ddd57..2821c33b 100644 --- a/apps/loadout-overlay/src/bun/native/devices.ts +++ b/apps/loadout-overlay/src/bun/native/devices.ts @@ -164,7 +164,9 @@ export function hasCapability(caps: Uint8Array, code: number): boolean { const byteIdx = code >> 3; const bitIdx = code & 0x07; if (byteIdx >= caps.length) return false; - return (caps[byteIdx] & (1 << bitIdx)) !== 0; + // Guarded above: byteIdx < caps.length, so this index is in-bounds; ?? 0 + // is behaviour-identical for the bitwise test and drops the non-null `!`. + return ((caps[byteIdx] ?? 0) & (1 << bitIdx)) !== 0; } /** diff --git a/apps/loadout-overlay/src/bun/native/gamescope-atoms.ts b/apps/loadout-overlay/src/bun/native/gamescope-atoms.ts index 527ba3f4..b22297c8 100644 --- a/apps/loadout-overlay/src/bun/native/gamescope-atoms.ts +++ b/apps/loadout-overlay/src/bun/native/gamescope-atoms.ts @@ -286,7 +286,7 @@ export class GamescopeAtoms { for (const line of lines) { if (!line.startsWith(prefix + "(")) continue; const m = line.match(/=\s*"((?:[^"\\]|\\.)*)"/); - if (m) return m[1]; + if (m) return m[1] ?? null; } } return null; @@ -311,6 +311,9 @@ export class GamescopeAtoms { managed.push(id); } } + // The sole caller only invokes this with a non-empty candidate set + // (findSteamWindow returns early when allIds is empty), so pool is + // guaranteed non-empty and pool[0] below is always defined. const pool = managed.length > 0 ? managed : candidates; // Prefer a pool window currently claiming overlay/focus. @@ -326,7 +329,9 @@ export class GamescopeAtoms { return id; } } - return pool[0]; + // pool is non-empty (guarded above), so pool[0] is always defined; the + // ?? "" only satisfies the checker and never fires for real inputs. + return pool[0] ?? ""; } /** True if `atom` is set on `windowId`. Uses xprop output to @@ -851,7 +856,9 @@ export class GamescopeAtoms { const m = line.match(/^(\w+)\([^)]+\)\s*=\s*(-?\d+)/); if (!m) continue; const n = Number(m[2]); - if (Number.isFinite(n)) out.set(m[1], n); + // Both capture groups are mandatory in the regex, so on a match + // m[1] is always present; ?? "" just drops the `!` (unreachable). + if (Number.isFinite(n)) out.set(m[1] ?? "", n); } } catch (err) { console.warn( diff --git a/apps/loadout-overlay/src/bun/native/input-intercept.ts b/apps/loadout-overlay/src/bun/native/input-intercept.ts index b4ca3fb1..7f42b8ec 100644 --- a/apps/loadout-overlay/src/bun/native/input-intercept.ts +++ b/apps/loadout-overlay/src/bun/native/input-intercept.ts @@ -211,7 +211,11 @@ function buildBitmask(codes: readonly number[]): Uint8Array { if (byteLen < 8) byteLen = 8; const bits = new Uint8Array(byteLen); for (const code of codes) { - bits[code >> 3] |= 1 << (code & 7); + const idx = code >> 3; + // byteLen covers (maxCode >> 3) + 1 bytes and code <= maxCode, so idx is + // always in-bounds; the ?? 0 keeps the OR total for the type-checker + // without changing the value (an in-bounds Uint8Array read is a number). + bits[idx] = (bits[idx] ?? 0) | (1 << (code & 7)); } return bits; } @@ -411,7 +415,10 @@ function readModifierState(fd: number): { guide: boolean; select: boolean } { const buf = new Uint8Array(96); const rc = libc.symbols.ioctl(fd, eviocgkey(buf.length), ptr(buf)); if (rc < 0) return { guide: false, select: false }; - const bit = (code: number) => (buf[code >> 3] & (1 << (code & 7))) !== 0; + // The 96-byte buffer covers codes 0..767, past every code we query, so + // code >> 3 is always in-bounds; ?? 0 is behaviour-identical for the + // bitwise test (an in-bounds read is a number) and drops the non-null `!`. + const bit = (code: number) => ((buf[code >> 3] ?? 0) & (1 << (code & 7))) !== 0; return { guide: bit(BTN_MODE), select: bit(BTN_SELECT) }; } @@ -979,6 +986,7 @@ export async function startInputIntercept( const idx = tracked.findIndex((t) => t.path === eventPath); if (idx < 0) return; const [t] = tracked.splice(idx, 1); + if (!t) return; // Drop any deferred-grab entry before closing the fd: a stale entry would // make pollOnce ioctl a closed (possibly OS-reused) fd next tick. pendingGrabs.delete(t); diff --git a/apps/loadout-overlay/src/bun/native/ip-intercept.ts b/apps/loadout-overlay/src/bun/native/ip-intercept.ts index 25ac3354..a8f45c1e 100644 --- a/apps/loadout-overlay/src/bun/native/ip-intercept.ts +++ b/apps/loadout-overlay/src/bun/native/ip-intercept.ts @@ -203,9 +203,12 @@ export function parseInputEventLine( ): { cap: string; value: number } | null { const m = line.match(/\.InputEvent\s+\('([^']+)',\s*([-0-9.eE]+)\)/); if (!m) return null; - const value = Number.parseFloat(m[2]); + // Both capture groups are mandatory in the regex, so on a match m[1] and + // m[2] are always present; the ?? "" fallbacks are unreachable for a real + // match (parseFloat("")→NaN would be filtered) and just drop the `!`. + const value = Number.parseFloat(m[2] ?? ""); if (Number.isNaN(value)) return null; - return { cap: m[1], value }; + return { cap: m[1] ?? "", value }; } // ---- Public API ------------------------------------------------------------ diff --git a/apps/loadout-overlay/src/overlay/components/Settings.tsx b/apps/loadout-overlay/src/overlay/components/Settings.tsx index bf01a2be..e5ad2555 100644 --- a/apps/loadout-overlay/src/overlay/components/Settings.tsx +++ b/apps/loadout-overlay/src/overlay/components/Settings.tsx @@ -777,7 +777,7 @@ function SettingsInner({ : "bg-base-300/70 text-base-content/50" }`} > - {(plugin.icon ?? plugin.name)[0].toUpperCase()} + {(plugin.icon ?? plugin.name).charAt(0).toUpperCase()}
diff --git a/apps/loadout-overlay/src/overlay/components/Sidebar.tsx b/apps/loadout-overlay/src/overlay/components/Sidebar.tsx index 2db5e3a7..8a6a6e06 100644 --- a/apps/loadout-overlay/src/overlay/components/Sidebar.tsx +++ b/apps/loadout-overlay/src/overlay/components/Sidebar.tsx @@ -83,7 +83,8 @@ export function Sidebar({ } bucket[cat].push(p); } - const result = order.map((name) => ({ name, items: bucket[name] })); + // Every name in `order` was pushed alongside its bucket entry above. + const result = order.map((name) => ({ name, items: bucket[name] ?? [] })); if (favSet.size > 0) { const favPlugins = favorites .map((id) => plugins.find((p) => p.id === id)) @@ -263,7 +264,7 @@ export function Sidebar({ aria-hidden /> ) : ( - {(plugin.icon ?? plugin.name)[0].toUpperCase()} + {(plugin.icon ?? plugin.name).charAt(0).toUpperCase()} ) } label={plugin.name} diff --git a/apps/loadout-overlay/src/overlay/components/WelcomeScreen.tsx b/apps/loadout-overlay/src/overlay/components/WelcomeScreen.tsx index 5b9b19ac..fed2bc1b 100644 --- a/apps/loadout-overlay/src/overlay/components/WelcomeScreen.tsx +++ b/apps/loadout-overlay/src/overlay/components/WelcomeScreen.tsx @@ -102,7 +102,9 @@ export function WelcomeScreen({ const scrollRef = useRef(null); const [stepIndex, setStepIndex] = useState(0); - const stepId = STEPS[stepIndex].id; + // stepIndex is clamped to [0, STEPS.length) by the nav handlers, so the + // fallback to the first step's id never runs for real input. + const stepId = STEPS[stepIndex]?.id ?? "welcome"; // Theme — controlled locally during the flow so navigating around // applies the preview immediately, then we persist on completion. @@ -423,7 +425,9 @@ function StepHeader({ sub: "Press Open Loadout to head to your home dashboard. Everything below can be tweaked from the Settings page.", }, }; - const stepId = STEPS[stepIndex].id; + // stepIndex is clamped to [0, STEPS.length) by the nav handlers, so the + // fallback to the first step's id never runs for real input. + const stepId = STEPS[stepIndex]?.id ?? "welcome"; const h = headers[stepId]; return (
@@ -1375,7 +1379,7 @@ function StepPlugins({ : "bg-base-300/70 text-base-content/50" }`} > - {(plugin.icon ?? plugin.name)[0].toUpperCase()} + {(plugin.icon ?? plugin.name).charAt(0).toUpperCase()}
diff --git a/apps/loadout-overlay/src/overlay/components/WidgetPicker.tsx b/apps/loadout-overlay/src/overlay/components/WidgetPicker.tsx index db20a698..fd8c396b 100644 --- a/apps/loadout-overlay/src/overlay/components/WidgetPicker.tsx +++ b/apps/loadout-overlay/src/overlay/components/WidgetPicker.tsx @@ -153,7 +153,7 @@ export function WidgetPicker({ {!probing && availablePlugins.map((plugin) => { const isAdded = favorites.includes(plugin.id); - const initial = (plugin.icon ?? plugin.name)[0].toUpperCase(); + const initial = (plugin.icon ?? plugin.name).charAt(0).toUpperCase(); return ( void) { // Left stick → arrow keys if (gp.axes.length >= 2) { - const lx = gp.axes[0]; - const ly = gp.axes[1]; + // Guarded by length >= 2, so axes[0]/axes[1] are present; the ?? 0 + // (neutral stick) only satisfies the checker and never fires here. + const lx = gp.axes[0] ?? 0; + const ly = gp.axes[1] ?? 0; handlePress("stick-left", "ArrowLeft", lx < -STICK_DEADZONE, now); handlePress("stick-right", "ArrowRight", lx > STICK_DEADZONE, now); handlePress("stick-up", "ArrowUp", ly < -STICK_DEADZONE, now); @@ -165,8 +167,10 @@ export function useGamepadInput(onBack?: () => void) { // pairs beyond the two sticks (axes 0-3). for (let ai = 4; ai < gp.axes.length; ai += 2) { if (ai + 1 >= gp.axes.length) break; - const dx = gp.axes[ai]; - const dy = gp.axes[ai + 1]; + // Guarded above: ai and ai + 1 are both < axes.length; the ?? 0 + // (neutral) only satisfies the checker and never fires here. + const dx = gp.axes[ai] ?? 0; + const dy = gp.axes[ai + 1] ?? 0; handlePress(`daxis${ai}-left`, "ArrowLeft", dx < -STICK_DEADZONE, now); handlePress(`daxis${ai}-right`, "ArrowRight", dx > STICK_DEADZONE, now); handlePress(`daxis${ai}-up`, "ArrowUp", dy < -STICK_DEADZONE, now); @@ -175,7 +179,9 @@ export function useGamepadInput(onBack?: () => void) { // Right stick → smooth analog scroll with momentum if (gp.axes.length >= 4) { - const ry = gp.axes[3]; + // Guarded by length >= 4, so axes[3] is present; the ?? 0 (neutral + // stick) only satisfies the checker and never fires here. + const ry = gp.axes[3] ?? 0; if (Math.abs(ry) > RIGHT_STICK_DEADZONE) { scrollVelocity = ry * RIGHT_STICK_SPEED; } else { diff --git a/apps/loadout/src/injector/injector.ts b/apps/loadout/src/injector/injector.ts index 45d4529d..b9c224d2 100644 --- a/apps/loadout/src/injector/injector.ts +++ b/apps/loadout/src/injector/injector.ts @@ -191,6 +191,21 @@ export class SteamInjector { } } + /** + * The SharedJSContext CDP client, guaranteed connected. `this.cdp` is + * set by connectAndInject() before any of the inject/patch steps run, so + * for real call flows this always returns it. If it were somehow null the + * previous `this.cdp!` would have thrown a TypeError here too — this + * throws an explicit error instead, which the surrounding retry/try-catch + * paths handle identically. + */ + private requireCdp(): CDPClient { + if (!this.cdp) { + throw new Error("[injector] CDP client is not connected"); + } + return this.cdp; + } + /** Fetch from the loader API with session token auth. */ private async fetchApi(path: string): Promise { return fetch(`http://localhost:${this.loaderPort}${path}`, { @@ -240,7 +255,7 @@ export class SteamInjector { onRetry: (reason) => this.log(`[injector] ${reason}`), }; - let tab: CEFTab; + let tab: CEFTab | undefined; while (this.running) { try { tab = await findSharedJSContext(tabOptions); @@ -253,15 +268,18 @@ export class SteamInjector { } if (!this.running) return; + // The loop only breaks after assigning `tab`, so once we're still + // running it is set; the guard only satisfies the type checker. + if (!tab) return; - this.log(`[injector] Found SharedJSContext: "${tab!.title}" (${tab!.url})`); + this.log(`[injector] Found SharedJSContext: "${tab.title}" (${tab.url})`); // Step 2: Connect via CDP WebSocket (direct or through multiplexer) if (this.cdpFactory) { - this.cdp = await this.cdpFactory(tab!.webSocketDebuggerUrl); + this.cdp = await this.cdpFactory(tab.webSocketDebuggerUrl); this.log("[injector] Connected to CDP via multiplexer"); } else { - this.cdp = new CDPClient(tab!.webSocketDebuggerUrl); + this.cdp = new CDPClient(tab.webSocketDebuggerUrl); await this.cdp.connect(); this.log("[injector] Connected to CDP WebSocket"); } @@ -341,11 +359,11 @@ export class SteamInjector { // Step 1: Discover Steam's React/ReactDOM from webpack and alias __VENDOR_* globals. // This MUST run before anything that references __VENDOR_REACT for its // hooks/createElement. Two React instances = crash. - await this.cdp!.evaluate(DISCOVER_STEAM_REACT); + await this.requireCdp().evaluate(DISCOVER_STEAM_REACT); this.log("[injector] Steam React discovered and aliased"); // Step 2: Set the loaded flag - await this.cdp!.evaluate(`window.loadoutHasLoaded = true;`); + await this.requireCdp().evaluate(`window.loadoutHasLoaded = true;`); } /** @@ -374,7 +392,7 @@ export class SteamInjector { } const script = buildWebpackPatcherScript(patchEntries); - await this.cdp!.evaluate(script, { awaitPromise: true }); + await this.requireCdp().evaluate(script, { awaitPromise: true }); if (patchEntries.length > 0) { this.log("[injector] Webpack patcher installed"); @@ -430,9 +448,9 @@ export class SteamInjector { try { this.log("[injector] Running Steam component discovery..."); // Clear previous discovery flag to force re-discovery (code may have changed) - await this.cdp!.evaluate("delete window.__steamComponentsDiscovered"); + await this.requireCdp().evaluate("delete window.__steamComponentsDiscovered"); const script = buildComponentDiscoveryScript(this.loaderPort); - await this.cdp!.evaluate(script, { awaitPromise: true }); + await this.requireCdp().evaluate(script, { awaitPromise: true }); this.log("[injector] Steam component discovery complete"); } catch (err) { this.log(`[injector] Component discovery failed (non-fatal): ${err instanceof Error ? err.message : err}`); @@ -642,7 +660,7 @@ export class SteamInjector { menuPlugins.push({ pluginId: plugin.id, title: t.title ?? plugin.name, - route: t.route ?? (plugin.routes ? Object.keys(plugin.routes)[0] : ""), + route: t.route ?? (plugin.routes ? (Object.keys(plugin.routes)[0] ?? "") : ""), position: typeof t.position === "number" ? t.position : undefined, icon: t.icon, }); @@ -680,7 +698,7 @@ export class SteamInjector { if (routes.length > 0) { this.log(`[injector] Patching router with ${routes.length} route(s)...`); const routeScript = buildRoutePatchScript(routes); - await this.cdp!.evaluate(routeScript, { awaitPromise: true }); + await this.requireCdp().evaluate(routeScript, { awaitPromise: true }); this.log("[injector] Route patch applied"); } @@ -688,7 +706,7 @@ export class SteamInjector { if (menuPlugins.length > 0) { this.log(`[injector] Patching main menu with ${menuPlugins.length} item(s)...`); const menuScript = buildMenuPatchScript(menuPlugins); - await this.cdp!.evaluate(menuScript, { awaitPromise: true }); + await this.requireCdp().evaluate(menuScript, { awaitPromise: true }); this.log("[injector] Main menu patch applied"); } @@ -765,18 +783,22 @@ export class SteamInjector { // never populated (no plugin ships a panel.tsx), so it always bailed. private async monitor(): Promise { - if (!this.cdp) return; + // Capture the connected client once: within a single monitor lifetime + // this.cdp is only cleared by cleanup() (which also unsubscribes these + // listeners), so the local mirrors this.cdp without the assertion. + const cdp = this.cdp; + if (!cdp) return; return new Promise((resolve) => { // Re-inject on page reload - const unsubDom = this.cdp!.on("Page.domContentEventFired", async () => { + const unsubDom = cdp.on("Page.domContentEventFired", async () => { this.log("[injector] Page reloaded, checking if re-injection needed..."); try { // Re-injection is gated on Gaming Mode too (issue #111) — a reload // in the desktop client must not re-add plugin injection. The game // session monitor is re-armed in both modes. const gameMode = await this.isGameMode(); - const loaded = await this.cdp!.hasGlobalVar(GLOBAL_FLAG); + const loaded = await cdp.hasGlobalVar(GLOBAL_FLAG); if (gameMode && !loaded) { await this.inject(); await this.connectBPM(); @@ -789,7 +811,7 @@ export class SteamInjector { }); // Handle detach (Steam restart, etc.) - const unsubDetach = this.cdp!.on("Inspector.detached", () => { + const unsubDetach = cdp.on("Inspector.detached", () => { this.log("[injector] CEF requested detach (Steam may be restarting)"); cleanup(); resolve(); diff --git a/apps/loadout/src/injector/tabs.ts b/apps/loadout/src/injector/tabs.ts index 4c2c295a..f97e6d10 100644 --- a/apps/loadout/src/injector/tabs.ts +++ b/apps/loadout/src/injector/tabs.ts @@ -127,13 +127,16 @@ export async function findQuickAccessTab(options: GetTabsOptions): Promise `"${t.title}" (${t.url})`).join(", "); throw new Error( `QuickAccess tab not found. Available tabs: ${available}`, ); } - // Return the first one — typically the Big Picture Mode QAM - return qaTabs[0]; + return first; } diff --git a/apps/loadout/src/loader/auth.ts b/apps/loadout/src/loader/auth.ts index 1fba042b..356f9eba 100644 --- a/apps/loadout/src/loader/auth.ts +++ b/apps/loadout/src/loader/auth.ts @@ -34,7 +34,7 @@ function extractToken(req: Request): string | null { if (authHeader) { const match = authHeader.match(/^Bearer\s+(.+)$/i); if (match) { - return match[1]; + return match[1] ?? null; } } diff --git a/apps/loadout/src/loader/index.ts b/apps/loadout/src/loader/index.ts index c5f51ee1..d2899a68 100644 --- a/apps/loadout/src/loader/index.ts +++ b/apps/loadout/src/loader/index.ts @@ -174,8 +174,9 @@ async function compileTsx( }, }); - if (result.success && result.outputs.length > 0) { - const code = await result.outputs[0].text(); + const output = result.success ? result.outputs[0] : undefined; + if (output) { + const code = await output.text(); return { code, ok: true }; } diff --git a/apps/loadout/src/loader/inject-builder.ts b/apps/loadout/src/loader/inject-builder.ts index bf7af262..e38dc6de 100644 --- a/apps/loadout/src/loader/inject-builder.ts +++ b/apps/loadout/src/loader/inject-builder.ts @@ -128,8 +128,11 @@ import * as ReactDOMClient from "react-dom/client"; format: "esm", target: "browser", }); - if (vendorResult.success && vendorResult.outputs.length > 0) { - vendorBundle = await Bun.file(vendorResult.outputs[0].path).text(); + const vendorOutput = vendorResult.success + ? vendorResult.outputs[0] + : undefined; + if (vendorOutput) { + vendorBundle = await Bun.file(vendorOutput.path).text(); } else { console.error("Failed to build vendor bundle:", vendorResult.logs); } @@ -168,8 +171,9 @@ import * as ReactDOMClient from "react-dom/client"; target: "browser", }); - if (sdkResult.success && sdkResult.outputs.length > 0) { - const sdkCode = await Bun.file(sdkResult.outputs[0].path).text(); + const sdkOutput = sdkResult.success ? sdkResult.outputs[0] : undefined; + if (sdkOutput) { + const sdkCode = await Bun.file(sdkOutput.path).text(); sdkBundle = transformToIIFE(sdkCode, "__LOADOUT_SDK"); } else { console.error("Failed to build inject SDK:", sdkResult.logs); @@ -205,17 +209,23 @@ function transformToIIFE(esmCode: string, globalName: string): string { coreCode = esmCode.slice(0, exportMatch.index).trimEnd(); // Parse export names (handle `name as alias` syntax) - const names = exportMatch[1].split(",").map((s) => s.trim()); + // exportMatch is truthy and group 1 is a required capture, so it is + // always present; `?? ""` only satisfies the type checker. + const names = (exportMatch[1] ?? "").split(",").map((s) => s.trim()); for (const name of names) { const parts = name.split(/\s+as\s+/); - // Use the original name (before "as"), which is the local variable - exportNames.push(parts[0].trim()); + // Use the original name (before "as"), which is the local variable. + // split always yields at least one element; `?? ""` only satisfies + // the type checker. + exportNames.push((parts[0] ?? "").trim()); } } if (defaultExportMatch && !exportMatch) { coreCode = esmCode.slice(0, defaultExportMatch.index).trimEnd(); - exportNames.push(defaultExportMatch[1]); + // defaultExportMatch is truthy and group 1 is a required capture, so it + // is always present; `?? ""` only satisfies the type checker. + exportNames.push(defaultExportMatch[1] ?? ""); } // Build the assignments diff --git a/apps/loadout/src/loader/routes/plugins.ts b/apps/loadout/src/loader/routes/plugins.ts index b72bbf48..7cc246c2 100644 --- a/apps/loadout/src/loader/routes/plugins.ts +++ b/apps/loadout/src/loader/routes/plugins.ts @@ -49,8 +49,13 @@ export const pluginAppBundleRoute: RouteHandler = { name: "plugins.app-bundle", match: (_req, url) => APP_BUNDLE_PATTERN.test(url.pathname), async handle(_req, url, ctx) { - const match = url.pathname.match(APP_BUNDLE_PATTERN)!; - const pluginId = match[1]; + const match = url.pathname.match(APP_BUNDLE_PATTERN); + if (!match) { + return new Response("Not Found", { status: 404 }); + } + // Group 1 is a required capture in APP_BUNDLE_PATTERN, so it is always + // present; `?? ""` only satisfies the type checker. + const pluginId = match[1] ?? ""; let code = ctx.bundleCache.get(pluginId); if (!code) { const appPath = join(ctx.pluginsDir, pluginId, "app.tsx"); @@ -65,9 +70,14 @@ export const pluginAssetRoute: RouteHandler = { name: "plugins.asset", match: (_req, url) => ASSET_PATTERN.test(url.pathname), async handle(_req, url, ctx) { - const match = url.pathname.match(ASSET_PATTERN)!; - const pluginId = match[1]; - const relPath = decodeURIComponent(match[2]); + const match = url.pathname.match(ASSET_PATTERN); + if (!match) { + return new Response("Not Found", { status: 404 }); + } + // Groups 1 and 2 are required captures in ASSET_PATTERN, so they are + // always present; `?? ""` only satisfies the type checker. + const pluginId = match[1] ?? ""; + const relPath = decodeURIComponent(match[2] ?? ""); if (!ctx.plugins.has(pluginId)) { return new Response("Not Found", { status: 404 }); } diff --git a/apps/loadout/src/loader/routes/steam-grid.ts b/apps/loadout/src/loader/routes/steam-grid.ts index 2da4e9ca..736c66ef 100644 --- a/apps/loadout/src/loader/routes/steam-grid.ts +++ b/apps/loadout/src/loader/routes/steam-grid.ts @@ -200,6 +200,13 @@ export const steamGridRoute: RouteHandler = { const perUser = url.pathname.match(STEAM_GRID_PATTERN); if (perUser) { const [, idPart, userIdPart, type] = perUser; + if ( + idPart === undefined || + userIdPart === undefined || + type === undefined + ) { + return new Response("not found", { status: 404 }); + } const t = type as SteamGridType; const candidates = steamGridCandidates(idPart, t); const gridDir = join( @@ -238,6 +245,9 @@ export const steamGridRoute: RouteHandler = { const auto = url.pathname.match(STEAM_GRID_AUTO_PATTERN); if (!auto) return new Response("not found", { status: 404 }); const [, idPart, type] = auto; + if (idPart === undefined || type === undefined) { + return new Response("not found", { status: 404 }); + } const t = type as SteamGridType; const candidates = steamGridCandidates(idPart, t); const userdata = getUserdataDir(); diff --git a/apps/loadout/src/loader/services/game-detection.ts b/apps/loadout/src/loader/services/game-detection.ts index 195e21d3..79efe0ce 100644 --- a/apps/loadout/src/loader/services/game-detection.ts +++ b/apps/loadout/src/loader/services/game-detection.ts @@ -44,7 +44,12 @@ export class GameDetectionService implements PluginBackend { const endTime = Date.now(); const idx = this.recent.findIndex((s) => s.appId === appId && s.endTime === undefined); if (idx >= 0) { - this.recent[idx] = { ...this.recent[idx], endTime }; + // idx comes from findIndex and is >= 0, so the element is in bounds; + // the guard only satisfies the type checker. + const session = this.recent[idx]; + if (session) { + this.recent[idx] = { ...session, endTime }; + } } if (this.current && this.current.appId === appId) { this.current = null; diff --git a/apps/loadout/src/loader/target-user.ts b/apps/loadout/src/loader/target-user.ts index 41179d83..f5ea6115 100644 --- a/apps/loadout/src/loader/target-user.ts +++ b/apps/loadout/src/loader/target-user.ts @@ -45,8 +45,11 @@ export function resolveUser( if (f.length >= 6 && f[0] === name) { const uid = Number(f[2]); const gid = Number(f[3]); - if (Number.isFinite(uid) && Number.isFinite(gid)) { - return { uid, gid, home: f[5] }; + const home = f[5]; + // f.length was checked >= 6, so f[5] is always present; the guard + // only satisfies the type checker. + if (Number.isFinite(uid) && Number.isFinite(gid) && home !== undefined) { + return { uid, gid, home }; } } } diff --git a/bun.lock b/bun.lock index c325c0ce..a8be9fa7 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@types/bun": "^1.3.10", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-react-hooks": "^5.0.0", @@ -31,7 +32,7 @@ }, "apps/loadout": { "name": "@loadout/loadout-server", - "version": "0.0.1", + "version": "0.3.1", "dependencies": { "@loadout/exec": "workspace:*", "@loadout/steam-cdp": "workspace:*", @@ -43,7 +44,7 @@ }, "apps/loadout-overlay": { "name": "@loadout/loadout-overlay", - "version": "0.1.0", + "version": "0.3.1", "dependencies": { "@loadout/deck-hid": "workspace:*", "@loadout/exec": "workspace:*", @@ -871,6 +872,22 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm" }, "sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2", "", { "os": "linux", "cpu": "x64" }, "sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], diff --git a/package.json b/package.json index 42ddb1b9..a8bb77fb 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "build-and-install": "sh scripts/build.sh && sh scripts/install-local.sh", "restart": "sh scripts/restart-loadout.sh", "release": "sh scripts/release.sh", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo -p tsconfig.json", + "typecheck:tsc": "tsc --noEmit", "test": "bun run test:backend && bun run test:ui", "test:backend": "bun test test.ts --isolate", "test:ui": "bun test spec.tsx --preload ./test/bun-test-setup.ts --isolate", @@ -66,6 +67,7 @@ "@types/bun": "^1.3.10", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/packages/deck-hid/src/index.ts b/packages/deck-hid/src/index.ts index 6850ca25..c3615575 100644 --- a/packages/deck-hid/src/index.ts +++ b/packages/deck-hid/src/index.ts @@ -128,15 +128,29 @@ export function parseHidUEvent(content: string): HidUEvent { // Format: BUS:VENDOR:PRODUCT, all hex, vendor/product zero-padded to 8. const parts = value.split(":"); if (parts.length === 3) { - out.bus = parseInt(parts[0], 16); - out.vendor = parseInt(parts[1], 16); - out.product = parseInt(parts[2], 16); + // parts.length was checked === 3, so all three are present; the + // guard only satisfies the type checker. + const [busStr, vendorStr, productStr] = parts; + if ( + busStr !== undefined && + vendorStr !== undefined && + productStr !== undefined + ) { + out.bus = parseInt(busStr, 16); + out.vendor = parseInt(vendorStr, 16); + out.product = parseInt(productStr, 16); + } } } else if (key === "HID_PHYS") { out.hidPhys = value; // Tail "/inputN" — the kernel encodes the USB interface number here. const m = value.match(/\/input(\d+)\s*$/); - if (m) out.interfaceNum = parseInt(m[1], 10); + // group 1 is a required capture, so it is always present when m + // matched; the guard only satisfies the type checker. + if (m) { + const iface = m[1]; + if (iface !== undefined) out.interfaceNum = parseInt(iface, 10); + } } } return out; @@ -199,7 +213,7 @@ export function decodeButtons(report: Buffer): Map | null { if (report[0] !== REPORT_ID_INPUT) return null; const out = new Map(); for (const [byteIdx, defs] of DECK_BUTTONS_BY_BYTE) { - const v = report[byteIdx]; + const v = report[byteIdx] ?? 0; for (const { bit, name } of defs) { out.set(name, (v & (1 << bit)) !== 0); } diff --git a/packages/game-library/src/index.ts b/packages/game-library/src/index.ts index 3e68235a..e995844d 100644 --- a/packages/game-library/src/index.ts +++ b/packages/game-library/src/index.ts @@ -153,7 +153,7 @@ async function readUserCollections( const match = text.match(/"user-collections"\s*"((?:[^"\\]|\\.)*)"/); if (!match) return new Map(); - const escaped = match[1]; + const escaped = match[1] ?? ""; const unescaped = escaped.replace(/\\\\/g, "\\").replace(/\\"/g, '"'); let collections: Record; @@ -190,7 +190,7 @@ async function readUserCollections( function parseVdfValue(content: string, key: string): string | null { const regex = new RegExp(`"${key}"\\s+"([^"]*)"`, "i"); const match = content.match(regex); - return match ? match[1] : null; + return match ? (match[1] ?? null) : null; } /** diff --git a/packages/steam-cef-badges/src/injector.ts b/packages/steam-cef-badges/src/injector.ts index 7b19ac52..1b9da560 100644 --- a/packages/steam-cef-badges/src/injector.ts +++ b/packages/steam-cef-badges/src/injector.ts @@ -280,7 +280,7 @@ export class SteamCefBadgeInjector { "(window.tempNavStore && window.tempNavStore.m_history && window.tempNavStore.m_history.location && window.tempNavStore.m_history.location.pathname) || window.location.pathname || ''", )) as string; const match = pathname?.match?.(/^\/(?:routes\/)?library\/app\/(\d+)/); - const newAppId = match ? match[1] : null; + const newAppId = match ? (match[1] ?? null) : null; if (newAppId !== this.currentAppId) { this.currentAppId = newAppId; @@ -322,8 +322,12 @@ export class SteamCefBadgeInjector { const pushed: string[] = []; for (const t of targets) { + // targets was filtered to entries with a live conn, so this is + // always set; the guard only satisfies the type checker. + const conn = t.conn; + if (!conn) continue; try { - await this._cdpEvaluate(t.conn!, expr); + await this._cdpEvaluate(conn, expr); pushed.push(t.key); } catch (err) { this.warn(`Failed to push badge data to ${t.key}:`, err); @@ -470,9 +474,13 @@ export class SteamCefBadgeInjector { if (bpmConns.length > 0) { for (const t of bpmConns) { + // bpmConns was filtered to entries with a live conn, so this is + // always set; the guard only satisfies the type checker. + const conn = t.conn; + if (!conn) continue; try { - await this._injectCSSToTab(t.conn!, css); - await this._cdpEvaluate(t.conn!, this.cfg.bpmScript); + await this._injectCSSToTab(conn, css); + await this._cdpEvaluate(conn, this.cfg.bpmScript); this.log(`Injected badge system into ${t.key}`); } catch (err) { this.warn(`Failed to inject into ${t.key}:`, err); @@ -502,7 +510,9 @@ export class SteamCefBadgeInjector { /store\.steampowered\.com\/app\/(\d+)/, ); const badgeData = appIdMatch - ? await this.cfg.fetchBadgeData(appIdMatch[1]) + // group 1 is a required capture, so it is always present when + // appIdMatch matched; `?? ""` only satisfies the type checker. + ? await this.cfg.fetchBadgeData(appIdMatch[1] ?? "") : null; await this._injectCSSToTab(conn, css); diff --git a/packages/steam-paths/src/index.ts b/packages/steam-paths/src/index.ts index d2a625f9..c135bad3 100644 --- a/packages/steam-paths/src/index.ts +++ b/packages/steam-paths/src/index.ts @@ -56,7 +56,9 @@ export async function getLibraryPaths(): Promise { const pathRegex = /"path"\s+"([^"]+)"/gi; let match: RegExpExecArray | null; while ((match = pathRegex.exec(content)) !== null) { - const libPath = join(match[1], "steamapps"); + // Group 1 is a required capture, so it is always present; `?? ""` + // only satisfies the type checker. + const libPath = join(match[1] ?? "", "steamapps"); if (!paths.includes(libPath)) { paths.push(libPath); } @@ -120,7 +122,10 @@ export async function listInstalledGames(): Promise { const appIdMatch = content.match(/"appid"\s+"(\d+)"/); const nameMatch = content.match(/"name"\s+"([^"]+)"/); if (!appIdMatch || !nameMatch) continue; - const appId = appIdMatch[1]; + // Both matches are truthy and group 1 is a required capture in each, + // so they are always present; `?? ""` only satisfies the type checker. + const appId = appIdMatch[1] ?? ""; + const name = nameMatch[1] ?? ""; // Skip Steam itself + Steam-runtime / Proton tooling apps. // They're "installed" technically but they don't carry user // game art, HLTB times, or any other library-level metadata @@ -129,14 +134,14 @@ export async function listInstalledGames(): Promise { appId === "0" || appId === "7" || appId === "228980" || - /^steamlinuxruntime/i.test(nameMatch[1]) || - /^proton/i.test(nameMatch[1]) + /^steamlinuxruntime/i.test(name) || + /^proton/i.test(name) ) { continue; } // Library paths can overlap (rare but possible). Last write // wins — names are typically identical. - seen.set(appId, nameMatch[1]); + seen.set(appId, name); } catch { /* skip unreadable manifest */ } diff --git a/packages/ui/src/components/GameCard.tsx b/packages/ui/src/components/GameCard.tsx index 9611d012..d0ec6700 100644 --- a/packages/ui/src/components/GameCard.tsx +++ b/packages/ui/src/components/GameCard.tsx @@ -110,7 +110,10 @@ export function collectionBadgeVariant(name: string): BadgeVariant { for (let i = 0; i < name.length; i++) { h = ((h << 5) - h + name.charCodeAt(i)) | 0; } - return COLLECTION_PALETTE[Math.abs(h) % COLLECTION_PALETTE.length]; + // The index is a modulo of the non-empty const palette, so it is always + // in bounds; the fallback only satisfies the type and never runs. + const variant = COLLECTION_PALETTE[Math.abs(h) % COLLECTION_PALETTE.length]; + return variant ?? "primary"; } type Phase = "primary" | "fallback" | "placeholder"; diff --git a/packages/ui/src/keyboard.ts b/packages/ui/src/keyboard.ts index 2a1f1149..8c048a37 100644 --- a/packages/ui/src/keyboard.ts +++ b/packages/ui/src/keyboard.ts @@ -90,7 +90,9 @@ function makeStore(): OskStore { // Walk handlers end → start; first to return `true` consumes. for (let i = this.handlers.length - 1; i >= 0; i--) { try { - if (this.handlers[i](k) === true) return; + // i iterates within handlers bounds; the guard only degrades a torn list. + const handler = this.handlers[i]; + if (handler && handler(k) === true) return; } catch (err) { console.warn("[osk] handler threw", err); } diff --git a/packages/ui/src/qam.ts b/packages/ui/src/qam.ts index 610026f8..1c9dc67d 100644 --- a/packages/ui/src/qam.ts +++ b/packages/ui/src/qam.ts @@ -39,10 +39,12 @@ function findTabsArray(): TabEntry[] | null { const divs = document.querySelectorAll("div"); for (let i = 0; i < divs.length; i++) { - const keys = Object.keys(divs[i]); + const div = divs[i]; + if (!div) continue; + const keys = Object.keys(div); for (const key of keys) { if (!key.startsWith("__reactFiber")) continue; - const fiber = (divs[i] as unknown as Record)[key] as { + const fiber = (div as unknown as Record)[key] as { memoizedProps?: { tabs?: TabEntry[]; activeTab?: unknown }; child?: unknown; sibling?: unknown; @@ -105,7 +107,12 @@ export function hideTab(keyOrTitle: string | number): () => void { return () => {}; } + // idx is a valid index (checked !== -1), so splice removes one entry. const removed = tabs.splice(idx, 1)[0]; + if (!removed) { + console.warn(`[loadout:qam] Tab not found: ${keyOrTitle}`); + return () => {}; + } console.log(`[loadout:qam] Hidden tab: ${removed.strTitle ?? removed.key}`); return function unhide() { diff --git a/packages/ui/src/spatial-nav.ts b/packages/ui/src/spatial-nav.ts index 962e1b89..7e5ad676 100644 --- a/packages/ui/src/spatial-nav.ts +++ b/packages/ui/src/spatial-nav.ts @@ -290,7 +290,9 @@ export function pushBackInterceptor(fn: BackInterceptor): () => void { export function tryRunBackInterceptor(): boolean { const stack = getBackStack(); for (let i = stack.length - 1; i >= 0; i--) { - if (stack[i]()) return true; + // i iterates within stack bounds; the guard only degrades a torn stack. + const fn = stack[i]; + if (fn && fn()) return true; } return false; } diff --git a/packages/vdf/src/binary-vdf.ts b/packages/vdf/src/binary-vdf.ts index 7b97d5b0..2cd6c5c2 100644 --- a/packages/vdf/src/binary-vdf.ts +++ b/packages/vdf/src/binary-vdf.ts @@ -83,7 +83,10 @@ export function parseBinaryVdf(buf: Buffer | Uint8Array): BinaryVdfObject { function readObject(): BinaryVdfObject { const obj: BinaryVdfObject = {}; while (offset < view.length) { - const type = view[offset++]; + // offset < view.length, so the byte is present; `?? 0` is a no-op + // for the real (numeric) reads and only used for its bit/equality + // value below, never in arithmetic. + const type = view[offset++] ?? 0; if (type === TYPE_END || type === TYPE_END_ALT) { return obj; } diff --git a/packages/vdf/src/vdf.ts b/packages/vdf/src/vdf.ts index a7f2d678..811ce37d 100644 --- a/packages/vdf/src/vdf.ts +++ b/packages/vdf/src/vdf.ts @@ -29,6 +29,7 @@ export function parseVdf(content: string): VdfObject { const root: VdfObject = {}; const stack: VdfObject[] = [root]; let pendingKey: string | null = null; + let warnedUnderflow = false; for (const rawLine of lines) { const line = rawLine.trim(); @@ -40,7 +41,7 @@ export function parseVdf(content: string): VdfObject { if (line === "{") { const obj: VdfObject = {}; const parent = stack[stack.length - 1]; - if (pendingKey !== null) { + if (parent && pendingKey !== null) { parent[pendingKey] = obj; pendingKey = null; } @@ -50,6 +51,16 @@ export function parseVdf(content: string): VdfObject { // Closing brace — pop the stack if (line === "}") { + if (stack.length === 1 && !warnedUnderflow) { + // Underflow: more "}" than "{" (corrupt/truncated VDF). We keep + // parsing leniently — kv lines below this point are dropped by the + // `if (parent)` guards — but say so once, because a silent partial + // parse is indistinguishable from a complete one to callers. + console.warn( + "[vdf] brace underflow while parsing — input is malformed, result may be partial", + ); + warnedUnderflow = true; + } stack.pop(); continue; } @@ -58,14 +69,16 @@ export function parseVdf(content: string): VdfObject { const kvMatch = line.match(/^"([^"]*)"[\t\s]+"([^"]*)"$/); if (kvMatch) { const parent = stack[stack.length - 1]; - parent[kvMatch[1]] = kvMatch[2]; + // The regex guarantees both capture groups when it matches. + const [, key = "", value = ""] = kvMatch; + if (parent) parent[key] = value; continue; } // Otherwise it should be a standalone "key" (section header) const keyMatch = line.match(/^"([^"]*)"$/); if (keyMatch) { - pendingKey = keyMatch[1]; + pendingKey = keyMatch[1] ?? null; continue; } } @@ -141,7 +154,9 @@ export function patchVdfValue( let targetBraceDepth = 0; for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); + const line = lines[i]; // i < lines.length, so present + if (line === undefined) continue; + const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//")) continue; if (trimmed === "{") { @@ -184,14 +199,15 @@ export function patchVdfValue( const kvMatch = trimmed.match(/^"([^"]*)"([\t\s]+)"([^"]*)"$/); if (kvMatch && kvMatch[1] === targetKey) { // Replace just the value portion in the original (non-trimmed) line. - const original = lines[i]; + const original = line; // same in-bounds element read at the loop top + const oldValue = kvMatch[3] ?? ""; // group 3 present when regex matches // Find the last quoted value in the line and replace it. - const lastQuotePair = original.lastIndexOf(`"${kvMatch[3]}"`); + const lastQuotePair = original.lastIndexOf(`"${oldValue}"`); if (lastQuotePair !== -1) { lines[i] = original.substring(0, lastQuotePair) + `"${newValue}"` + - original.substring(lastQuotePair + kvMatch[3].length + 2); + original.substring(lastQuotePair + oldValue.length + 2); return lines.join("\n"); } } @@ -252,7 +268,9 @@ export function removeVdfKey(content: string, keyPath: string[]): string { let targetBraceDepth = 0; for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); + const line = lines[i]; // i < lines.length, so present + if (line === undefined) continue; + const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//")) continue; if (trimmed === "{") { @@ -299,7 +317,7 @@ export function removeVdfKey(content: string, keyPath: string[]): string { let j = i + 1; // Find the opening brace. while (j < lines.length) { - const t = lines[j].trim(); + const t = lines[j]?.trim(); // j < lines.length, so present if (!t || t.startsWith("//")) { j++; continue; @@ -307,12 +325,12 @@ export function removeVdfKey(content: string, keyPath: string[]): string { if (t === "{") break; break; // unexpected — bail } - if (j < lines.length && lines[j].trim() === "{") { + if (j < lines.length && lines[j]?.trim() === "{") { // Find the matching closing brace. let depth = 1; let k = j + 1; while (k < lines.length && depth > 0) { - const t = lines[k].trim(); + const t = lines[k]?.trim(); // k < lines.length, so present if (t === "{") depth++; else if (t === "}") depth--; k++; diff --git a/plugins/apex/backend.ts b/plugins/apex/backend.ts index 469e266e..2ade31ed 100644 --- a/plugins/apex/backend.ts +++ b/plugins/apex/backend.ts @@ -167,7 +167,7 @@ export default class ApexBackend implements PluginBackend { try { const t = await readFile("/etc/os-release", "utf-8"); const m = t.match(/^ID=(.*)$/m); - return m ? m[1].replace(/["']/g, "").trim() : ""; + return m?.[1] ? m[1].replace(/["']/g, "").trim() : ""; } catch { return ""; } diff --git a/plugins/apex/lib/xhci.ts b/plugins/apex/lib/xhci.ts index 7f32bfba..67e0ea3f 100644 --- a/plugins/apex/lib/xhci.ts +++ b/plugins/apex/lib/xhci.ts @@ -90,7 +90,13 @@ export function parseDeadController(dmesg: string): string | null { const re = /xhci_hcd (0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f]):.*(?:HC died|assume dead)/gi; let match: RegExpExecArray | null; let last: string | null = null; - while ((match = re.exec(dmesg)) !== null) last = match[1]; + while ((match = re.exec(dmesg)) !== null) { + // Group 1 (the PCI address) is a mandatory capture, so it's always present + // on a successful match; guard rather than assert so a would-be undefined + // leaves the previous value instead of crashing. + const addr = match[1]; + if (addr !== undefined) last = addr; + } return last; } diff --git a/plugins/battery-tracker/app.tsx b/plugins/battery-tracker/app.tsx index 90a2b1c3..ecf3574a 100644 --- a/plugins/battery-tracker/app.tsx +++ b/plugins/battery-tracker/app.tsx @@ -36,6 +36,7 @@ function percentageColor(pct: number, charging: boolean): string { * thin bars; thicker top cap for charging samples. */ function HistoryChart({ history }: { history: HistoryEntry[] }) { const entries = history.slice(-30); + const oldest = entries[0]; if (entries.length === 0) { return (
@@ -64,8 +65,8 @@ function HistoryChart({ history }: { history: HistoryEntry[] }) {
- {entries.length > 1 - ? `${Math.round((Date.now() - entries[0].timestamp) / 60000)}m ago` + {entries.length > 1 && oldest + ? `${Math.round((Date.now() - oldest.timestamp) / 60000)}m ago` : ""} now diff --git a/plugins/battery-tracker/backend.ts b/plugins/battery-tracker/backend.ts index 8d20757d..b9c68706 100644 --- a/plugins/battery-tracker/backend.ts +++ b/plugins/battery-tracker/backend.ts @@ -115,7 +115,9 @@ export default class BatteryTrackerBackend implements PluginBackend { if (candidates.length === 0) return null; candidates.sort((a, b) => b.score - a.score); + // Non-empty: the length === 0 case returned above; guard degrades identically. const picked = candidates[0]; + if (!picked) return null; if (candidates.length > 1) { console.log( `[battery-tracker] battery candidates: ${candidates @@ -147,7 +149,10 @@ export default class BatteryTrackerBackend implements PluginBackend { /** Read all battery info from sysfs */ private async _readBattery(): Promise { - const base = this.batteryPath!; + // Every caller guards batteryPath before invoking; throwing on a null + // path mirrors the sysfs read rejection the callers already catch. + const base = this.batteryPath; + if (!base) throw new Error("[battery-tracker] _readBattery called with no battery path"); const [percentage, statusRaw, powerNowMicro, voltageNowMicro, energyNowMicro, energyFullMicro, energyFullDesignMicro] = await Promise.all([ diff --git a/plugins/bluetooth/lib/parse.ts b/plugins/bluetooth/lib/parse.ts index d29f8cf7..5b30b117 100644 --- a/plugins/bluetooth/lib/parse.ts +++ b/plugins/bluetooth/lib/parse.ts @@ -61,7 +61,8 @@ export function parseBoolProp(line: string): boolean | null { export function parseStringProp(line: string): string | null { const m = line.trim().match(/^s\s+"((?:\\.|[^"\\])*)"$/); if (!m) return null; - return m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + // Group 1 always captures when the match succeeds; `?? ""` is a no-op there. + return (m[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } /** Split multi-property `busctl get-property` output into per-line value @@ -78,7 +79,8 @@ export function splitPropLines(stdout: string): string[] { export function macFromDevicePath(path: string): string | null { const m = path.match(/\/dev_([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})$/); if (!m) return null; - return m[1].replace(/_/g, ":").toUpperCase(); + // Group 1 always captures when the match succeeds; `?? ""` is a no-op there. + return (m[1] ?? "").replace(/_/g, ":").toUpperCase(); } /** `AA:BB:CC:DD:EE:0B` → `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_0B` under diff --git a/plugins/disable-controller-input/backend.ts b/plugins/disable-controller-input/backend.ts index b4a6012e..ae7adac5 100644 --- a/plugins/disable-controller-input/backend.ts +++ b/plugins/disable-controller-input/backend.ts @@ -390,6 +390,7 @@ export default class DisableControllerInputBackend implements PluginBackend { const idx = this.state.devices.findIndex((d) => d.hash === hash); if (idx === -1) return { ok: false, error: "Unknown device" }; const dev = this.state.devices[idx]; + if (!dev) return { ok: false, error: "Unknown device" }; // Don't strand a silenced target on the bus. Re-enable first if // the device is reachable. If the re-enable busctl call itself @@ -435,7 +436,10 @@ export default class DisableControllerInputBackend implements PluginBackend { */ private async _serialize(fn: () => Promise): Promise { const prev = this.opLock; - let release!: () => void; + // Initialised to a no-op so `release` is never possibly-undefined; the + // Promise executor runs synchronously and always overwrites it with the + // real resolver before we await, so the no-op is never actually called. + let release: () => void = () => {}; this.opLock = new Promise((r) => (release = r)); await prev; try { diff --git a/plugins/disable-controller-input/lib/parse.ts b/plugins/disable-controller-input/lib/parse.ts index 049ac19b..4d042f64 100644 --- a/plugins/disable-controller-input/lib/parse.ts +++ b/plugins/disable-controller-input/lib/parse.ts @@ -27,7 +27,9 @@ export function parseStringProp(stdout: string): string | null { const trimmed = stdout.trim(); const m = trimmed.match(/^s\s+"((?:\\.|[^"\\])*)"$/); if (!m) return null; - return m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + // Capture group 1 is mandatory, so on a match it is always present; + // `?? ""` is a no-op there. + return (m[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } /** Parse a `ao N "/p1" "/p2" …` busctl property line. Returns null if @@ -36,12 +38,14 @@ export function parseObjectPathArrayProp(stdout: string): string[] | null { const trimmed = stdout.trim(); const m = trimmed.match(/^ao\s+(\d+)((?:\s+"[^"]*")*)$/); if (!m) return null; - const count = parseInt(m[1], 10); + // Both capture groups are mandatory, so on a match m[1] and m[2] are + // always present; `?? ""` is a no-op there. + const count = parseInt(m[1] ?? "", 10); if (count === 0) return []; const paths: string[] = []; const re = /"([^"]*)"/g; let p: RegExpExecArray | null; - while ((p = re.exec(m[2])) !== null) paths.push(p[1]); + while ((p = re.exec(m[2] ?? "")) !== null) paths.push(p[1] ?? ""); return paths; } diff --git a/plugins/display-settings/backend.ts b/plugins/display-settings/backend.ts index d0471aac..177eb3d9 100644 --- a/plugins/display-settings/backend.ts +++ b/plugins/display-settings/backend.ts @@ -185,8 +185,9 @@ export default class DisplaySettingsBackend implements PluginBackend { try { const { readdir } = await import("node:fs/promises"); const entries = await readdir("/sys/class/backlight"); - if (entries.length > 0) { - this.backlightName = entries[0]; + const firstBacklight = entries[0]; + if (firstBacklight !== undefined) { + this.backlightName = firstBacklight; this.backlightPath = `/sys/class/backlight/${this.backlightName}`; const maxStr = await Bun.file(`${this.backlightPath}/max_brightness`).text(); this.maxBrightness = parseInt(maxStr.trim(), 10); @@ -205,8 +206,10 @@ export default class DisplaySettingsBackend implements PluginBackend { this.userCreds, ); const res = await exec(cmd, env); - if (res.ok && res.stdout.includes("=")) { - const rawVal = parseInt(res.stdout.split("=")[1].trim(), 10); + // stdout contains "=", so split yields at least two parts. + const rawStr = res.ok ? res.stdout.split("=")[1] : undefined; + if (rawStr !== undefined) { + const rawVal = parseInt(rawStr.trim(), 10); const floatVal = longToFloat(rawVal); this.state.saturation = Math.round(floatVal * 200); } diff --git a/plugins/display-settings/lib/color.ts b/plugins/display-settings/lib/color.ts index cb6d464a..14c649d0 100644 --- a/plugins/display-settings/lib/color.ts +++ b/plugins/display-settings/lib/color.ts @@ -13,14 +13,14 @@ export function floatToLong(x: number): number { const buf = new ArrayBuffer(4); new Float32Array(buf)[0] = x; - return new Uint32Array(buf)[0]; + return new Uint32Array(buf)[0] ?? 0; // 4-byte buffer, index 0 always present } /** Unpack a uint32 back to a float32 (little-endian bit-cast). */ export function longToFloat(x: number): number { const buf = new ArrayBuffer(4); new Uint32Array(buf)[0] = x; - return new Float32Array(buf)[0]; + return new Float32Array(buf)[0] ?? 0; // 4-byte buffer, index 0 always present } /** diff --git a/plugins/fan-control/app.tsx b/plugins/fan-control/app.tsx index 94d2424c..85632b0e 100644 --- a/plugins/fan-control/app.tsx +++ b/plugins/fan-control/app.tsx @@ -138,11 +138,13 @@ function usePerGameProfiles( const boundToGame = perGameEnabled && currentGame !== null; const persistGameProfile = useCallback( (mode: "auto" | "manual", speed?: number) => { - if (!boundToGame) return; + // boundToGame already implies currentGame is non-null; the explicit + // check just narrows the type without changing the early-return outcome. + if (!boundToGame || currentGame === null) return; call( "setGameProfile", - currentGame!.appId, - currentGame!.gameName, + currentGame.appId, + currentGame.gameName, { mode, speed }, ).catch(() => {}); }, @@ -205,8 +207,9 @@ function FanControl() { call("getFanInfo").then((info) => { const data = info as FanInfo; setFanInfo(data); - if (data.fans.length > 0) { - setSliderValue(data.fans[0].percent); + const primary = data.fans[0]; + if (primary) { + setSliderValue(primary.percent); } setActivePreset(data.activePreset ? (data.activePreset as Preset) : null); setCustomActive(Boolean(data.customCurveActive)); @@ -273,9 +276,10 @@ function FanControl() { const res = (await call("setCustomCurve", pts).catch(() => null)) as | { curve?: FanCurvePoint[] } | null; - if (res?.curve && Array.isArray(res.curve)) { - setCustomPoints(res.curve); - setSelectedPoint((i) => Math.min(i, res.curve!.length - 1)); + const curve = res?.curve; + if (curve && Array.isArray(curve)) { + setCustomPoints(curve); + setSelectedPoint((i) => Math.min(i, curve.length - 1)); } }, [call], @@ -571,14 +575,17 @@ function FanControl() {
{(() => { const sel = customPoints[selectedPoint] ?? customPoints[0]; - const minTemp = + if (!sel) return null; + const prev = selectedPoint > 0 - ? customPoints[selectedPoint - 1].tempC + 1 - : CURVE_TEMP_MIN; - const maxTemp = + ? customPoints[selectedPoint - 1] + : undefined; + const next = selectedPoint < customPoints.length - 1 - ? customPoints[selectedPoint + 1].tempC - 1 - : CURVE_TEMP_MAX; + ? customPoints[selectedPoint + 1] + : undefined; + const minTemp = prev ? prev.tempC + 1 : CURVE_TEMP_MIN; + const maxTemp = next ? next.tempC - 1 : CURVE_TEMP_MAX; return ( <>
@@ -845,14 +852,16 @@ export function editCurvePoint( ): FanCurvePoint[] { if (index < 0 || index >= points.length) return points; const pts = points.map((p) => ({ ...p })); - const point = pts[index]; + const point = pts[index]; // in-bounds: index checked above + if (!point) return points; if (typeof next.percent === "number") { point.percent = Math.max(0, Math.min(100, Math.round(next.percent))); } if (typeof next.tempC === "number") { - const minT = index > 0 ? pts[index - 1].tempC + 1 : CURVE_TEMP_MIN; - const maxT = - index < pts.length - 1 ? pts[index + 1].tempC - 1 : CURVE_TEMP_MAX; + const prev = index > 0 ? pts[index - 1] : undefined; + const nextPt = index < pts.length - 1 ? pts[index + 1] : undefined; + const minT = prev ? prev.tempC + 1 : CURVE_TEMP_MIN; + const maxT = nextPt ? nextPt.tempC - 1 : CURVE_TEMP_MAX; point.tempC = Math.max(minT, Math.min(maxT, Math.round(next.tempC))); } return pts; @@ -867,7 +876,10 @@ export function insertCurvePoint(points: FanCurvePoint[]): { let gapIdx = 0; let gapSize = -1; for (let i = 0; i < points.length - 1; i++) { - const size = points[i + 1].tempC - points[i].tempC; + const lo = points[i]; // both in-bounds: i < length - 1 + const hi = points[i + 1]; + if (!lo || !hi) continue; + const size = hi.tempC - lo.tempC; if (size > gapSize) { gapSize = size; gapIdx = i; @@ -875,6 +887,7 @@ export function insertCurvePoint(points: FanCurvePoint[]): { } const lo = points[gapIdx]; const hi = points[gapIdx + 1]; + if (!lo || !hi) return { points, index: gapIdx }; const mid: FanCurvePoint = { tempC: Math.round((lo.tempC + hi.tempC) / 2), percent: Math.round((lo.percent + hi.percent) / 2), @@ -940,20 +953,22 @@ function FanHomeWidget() { // Returns the backend's PWM-derived `percent` directly. Will be 0 on // ectool-only hardware where the EC's duty isn't readable. const deriveAutoDuty = useCallback((info: FanInfo): number => { - if (!info.fans || info.fans.length === 0) return 0; - return Math.max(0, Math.min(100, info.fans[0].percent)); + const first = info.fans?.[0]; + if (!first) return 0; + return Math.max(0, Math.min(100, first.percent)); }, []); useEffect(() => { call("getFanInfo").then((result) => { const info = result as FanInfo; - if (info.fans.length > 0) { - setRpm(info.fans[0].rpm); + const primary = info.fans[0]; + if (primary) { + setRpm(primary.rpm); // Seed manualSpeed from the live percent only if the backend // can actually report it (direct hwmon). On ectool we leave the // 50 default so the slider doesn't snap to 0 when the user // flips to Manual. - const reported = info.fans[0].percent; + const reported = primary.percent; if (reported > 0) setManualSpeed(reported); setAutoDuty(deriveAutoDuty(info)); } @@ -967,8 +982,9 @@ function FanHomeWidget() { event: "fan-update", handler: useCallback((data: unknown) => { const info = data as FanInfo; - if (info.fans?.length > 0) { - setRpm(info.fans[0].rpm); + const primary = info.fans?.[0]; + if (primary) { + setRpm(primary.rpm); // Always refresh the auto-duty estimate. The render-time selector // below picks whether to display it (mode === "auto") or the // user's manualSpeed (mode === "manual"). This keeps the slider @@ -996,13 +1012,17 @@ function FanHomeWidget() { const sliderDisabled = mode !== "manual"; // Match TDP's chip semantics so per-game state reads identically across // the two performance widgets. - const savedProfile = boundToGame - ? gameProfiles.find((p) => p.appId === currentGame!.appId) - : undefined; - const profileLabel = boundToGame + // boundToGame already implies currentGame is non-null; the extra check + // narrows the type without changing which branch runs. + const savedProfile = + boundToGame && currentGame + ? gameProfiles.find((p) => p.appId === currentGame.appId) + : undefined; + const profileLabel = + boundToGame && currentGame ? savedProfile - ? `Saved · ${currentGame!.gameName || `App ${currentGame!.appId}`}` - : `Set for ${currentGame!.gameName || `App ${currentGame!.appId}`}` + ? `Saved · ${currentGame.gameName || `App ${currentGame.appId}`}` + : `Set for ${currentGame.gameName || `App ${currentGame.appId}`}` : mode === "manual" ? "Manual" : mode === "full" diff --git a/plugins/fan-control/backend.ts b/plugins/fan-control/backend.ts index 15b3ddff..84413fcc 100644 --- a/plugins/fan-control/backend.ts +++ b/plugins/fan-control/backend.ts @@ -418,23 +418,30 @@ export default class FanControlBackend implements PluginBackend { // RPC Methods // ----------------------------------------------------------------------- + /** The "no controllable fan hardware" snapshot — returned when neither a + * hwmon device nor ectool is available, and as the fail-safe when a device + * we expected has gone away. */ + private unavailableFanInfo(): FanInfoResult { + return { + fans: [], + mode: "unknown", + temps: [], + cpuTempC: 0, + chipName: "none", + fanCount: 0, + available: false, + activePreset: this.activePreset, + customCurveActive: this.customCurveActive, + usingEctool: false, + warning: null, + safetyEngaged: false, + }; + } + /** Returns comprehensive fan status. */ async getFanInfo(): Promise { if (!this.activeFanDevice && !this.useEctool) { - return { - fans: [], - mode: "unknown", - temps: [], - cpuTempC: 0, - chipName: "none", - fanCount: 0, - available: false, - activePreset: this.activePreset, - customCurveActive: this.customCurveActive, - usingEctool: false, - warning: null, - safetyEngaged: false, - }; + return this.unavailableFanInfo(); } const temps = await this.getTemperatures(); @@ -477,7 +484,14 @@ export default class FanControlBackend implements PluginBackend { }; } - const device = this.activeFanDevice!; + // Unreachable: the both-null early return above and the ectool branch + // (which fires whenever hasPwmControl is false — including when + // activeFanDevice is null) guarantee a device here. Guard rather than + // assert, degrading to the unavailable snapshot instead of crashing. + const device = this.activeFanDevice; + if (!device) { + return this.unavailableFanInfo(); + } const fanReadings: FanInfoResult["fans"] = []; let mode: FanInfoResult["mode"] = "unknown"; @@ -1379,8 +1393,8 @@ export default class FanControlBackend implements PluginBackend { try { const { stdout } = await run(["ectool", "pwmgetfanrpm"]); // Output format: "Current fan RPM: 3200" - const match = stdout.match(/(\d+)/); - return match ? parseInt(match[1], 10) : 0; + const digits = stdout.match(/(\d+)/)?.[1]; + return digits ? parseInt(digits, 10) : 0; } catch { return 0; } diff --git a/plugins/fan-control/components/FanCurveGraph.tsx b/plugins/fan-control/components/FanCurveGraph.tsx index abdb4623..df4a9920 100644 --- a/plugins/fan-control/components/FanCurveGraph.tsx +++ b/plugins/fan-control/components/FanCurveGraph.tsx @@ -89,11 +89,11 @@ export function FanCurveGraph({ CURVE_TEMP_MIN + fracX * (CURVE_TEMP_MAX - CURVE_TEMP_MIN); const rawPct = (1 - fracY) * 100; - const minTemp = index > 0 ? points[index - 1].tempC + 1 : CURVE_TEMP_MIN; - const maxTemp = - index < points.length - 1 - ? points[index + 1].tempC - 1 - : CURVE_TEMP_MAX; + const prev = index > 0 ? points[index - 1] : undefined; + const next = + index < points.length - 1 ? points[index + 1] : undefined; + const minTemp = prev ? prev.tempC + 1 : CURVE_TEMP_MIN; + const maxTemp = next ? next.tempC - 1 : CURVE_TEMP_MAX; const tempC = Math.max(minTemp, Math.min(maxTemp, Math.round(rawTemp))); const percent = Math.max(0, Math.min(100, Math.round(rawPct))); @@ -128,6 +128,7 @@ export function FanCurveGraph({ // every node, then a flat hold out to the right edge. const first = points[0]; const last = points[points.length - 1]; + if (!first || !last) return null; const linePath = `M ${PAD_L} ${pctToY(first.percent)} ` + points.map((p) => `L ${tempToX(p.tempC)} ${pctToY(p.percent)}`).join(" ") + diff --git a/plugins/fan-control/lib/fan-curves.ts b/plugins/fan-control/lib/fan-curves.ts index 91c458f1..1fb74d35 100644 --- a/plugins/fan-control/lib/fan-curves.ts +++ b/plugins/fan-control/lib/fan-curves.ts @@ -51,19 +51,25 @@ export const FAN_CURVES: Record = { * percent. Pure. */ export function interpolateCurve(curve: FanCurvePoint[], tempC: number): number { - if (tempC <= curve[0].tempC) return curve[0].percent; - if (tempC >= curve[curve.length - 1].tempC) return curve[curve.length - 1].percent; + const first = curve[0]; + const last = curve[curve.length - 1]; + if (!first || !last) return 0; + if (tempC <= first.tempC) return first.percent; + if (tempC >= last.tempC) return last.percent; for (let i = 0; i < curve.length - 1; i++) { + // i < curve.length - 1, so both are in bounds for any valid curve; the + // guard only degrades a would-be undefined (impossible here) to a skip. const lo = curve[i]; const hi = curve[i + 1]; + if (!lo || !hi) continue; if (tempC >= lo.tempC && tempC <= hi.tempC) { const ratio = (tempC - lo.tempC) / (hi.tempC - lo.tempC); return Math.round(lo.percent + ratio * (hi.percent - lo.percent)); } } - return curve[curve.length - 1].percent; + return last.percent; } /** Clamp a percent into [0, 100] and round to a whole number. Pure. */ diff --git a/plugins/flatpak-manager/app.tsx b/plugins/flatpak-manager/app.tsx index 8f72f159..fd1bd4e4 100644 --- a/plugins/flatpak-manager/app.tsx +++ b/plugins/flatpak-manager/app.tsx @@ -101,12 +101,12 @@ function FlatpakManager() { */ const handleUpdateAll = useCallback(async () => { const queue = [...updates]; - if (queue.length === 0) return; + const first = queue[0]; + if (first === undefined) return; // empty queue → nothing to do setUpdatingAll(true); - setUpdateProgress({ current: 0, total: queue.length, currentName: queue[0].name }); + setUpdateProgress({ current: 0, total: queue.length, currentName: first.name }); try { - for (let i = 0; i < queue.length; i++) { - const u = queue[i]; + for (const [i, u] of queue.entries()) { setUpdateProgress({ current: i, total: queue.length, currentName: u.name }); try { await call("updateApp", u.appId); diff --git a/plugins/flatpak-manager/lib/parse.ts b/plugins/flatpak-manager/lib/parse.ts index 51494065..8ad9c92d 100644 --- a/plugins/flatpak-manager/lib/parse.ts +++ b/plugins/flatpak-manager/lib/parse.ts @@ -33,12 +33,25 @@ export function parseInstalled(output: string): InstalledApp[] { const parts = line.split("\t"); if (parts.length < 5) continue; + // Indices 0-4 are expected present (length checked >= 5 above); guard + // and skip the line if any is somehow missing, matching the length skip. + const [name, appId, version, size, origin] = parts; + if ( + name === undefined || + appId === undefined || + version === undefined || + size === undefined || + origin === undefined + ) { + console.warn("[flatpak-manager] unexpected missing field in installed list line"); + continue; + } apps.push({ - name: parts[0].trim(), - appId: parts[1].trim(), - version: parts[2].trim(), - size: parts[3].trim(), - origin: parts[4].trim(), + name: name.trim(), + appId: appId.trim(), + version: version.trim(), + size: size.trim(), + origin: origin.trim(), }); } @@ -56,10 +69,17 @@ export function parseUpdates(output: string): UpdateInfo[] { const parts = line.split("\t"); if (parts.length < 3) continue; + // Indices 0-2 are expected present (length checked >= 3 above); guard + // and skip the line if any is somehow missing, matching the length skip. + const [name, appId, newVersion] = parts; + if (name === undefined || appId === undefined || newVersion === undefined) { + console.warn("[flatpak-manager] unexpected missing field in updates list line"); + continue; + } updates.push({ - name: parts[0].trim(), - appId: parts[1].trim(), - newVersion: parts[2].trim(), + name: name.trim(), + appId: appId.trim(), + newVersion: newVersion.trim(), }); } diff --git a/plugins/hltb/app.tsx b/plugins/hltb/app.tsx index 039e00b2..706c3d7d 100644 --- a/plugins/hltb/app.tsx +++ b/plugins/hltb/app.tsx @@ -228,8 +228,9 @@ function HltbPlugin() { const idx = list.findIndex((g) => g.appId === runningAppId); if (idx <= 0) return list; const next = list.slice(); + // idx > 0 (checked above) is in bounds, so splice removes one element. const [running] = next.splice(idx, 1); - next.unshift(running); + if (running !== undefined) next.unshift(running); return next; }, [installed, currentGame, searchQuery, libraryFilter]); @@ -398,7 +399,7 @@ function HltbPlugin() {
) : ( - {sortedGames!.map((game) => ( + {(sortedGames ?? []).map((game) => ( 0 && sorted[0].score >= FUZZY_SCORE_THRESHOLD) { - match = sorted[0].obj; + const top = sorted[0]; + if (top && top.score >= FUZZY_SCORE_THRESHOLD) { + match = top.obj; } } @@ -921,6 +922,7 @@ export default class HltbBackend implements PluginBackend { return null; const game = gameDataList[0]; + if (game === undefined) return null; // unreachable: length checked !== 0 above. return this.toGameDetail(game); } catch (error) { console.warn("[hltb] Error fetching game detail:", error); @@ -1009,7 +1011,7 @@ export default class HltbBackend implements PluginBackend { const match = html.match( /\/_next\/static\/([^/]+)\/(?:_ssgManifest|_buildManifest)\.js/, ); - return match ? match[1] : null; + return match?.[1] ?? null; } catch { return null; } diff --git a/plugins/input-plumber/lib/install.ts b/plugins/input-plumber/lib/install.ts index 06b4a52e..494e4adb 100644 --- a/plugins/input-plumber/lib/install.ts +++ b/plugins/input-plumber/lib/install.ts @@ -54,7 +54,9 @@ async function inputplumberVersion(binary: string): Promise { const r = await runFull([binary, "--version"], { timeoutMs: 5_000 }); if (r.exitCode !== 0) return null; const m = r.stdout.trim().match(/(\d+\.\d+\.\d+(?:-\S+)?)/); - return m ? m[1] : r.stdout.trim() || null; + // Capture group 1 is mandatory, so on a match it is always present; ?? "" + // only drops the `!` and is unreachable for a real match. + return m ? (m[1] ?? "") : r.stdout.trim() || null; } async function isUnitActive(unit: string): Promise { diff --git a/plugins/input-plumber/lib/ipdbus.ts b/plugins/input-plumber/lib/ipdbus.ts index 0a0b6b9f..81b36a6e 100644 --- a/plugins/input-plumber/lib/ipdbus.ts +++ b/plugins/input-plumber/lib/ipdbus.ts @@ -64,7 +64,9 @@ function busctl(args: string[]): Promise { export function parseStringProp(stdout: string): string | null { const m = stdout.trim().match(/^s\s+"((?:\\.|[^"\\])*)"$/); if (!m) return null; - return m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + // Capture group 1 is mandatory, so on a match it is always present; ?? "" + // only drops the `!` and is unreachable for a real match. + return (m[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } /** Parse a `as N "a" "b" …` busctl string-array property line. Returns `[]` @@ -73,12 +75,15 @@ export function parseStringArrayProp(stdout: string): string[] | null { const trimmed = stdout.trim(); const m = trimmed.match(/^as\s+(\d+)((?:\s+"(?:\\.|[^"\\])*")*)$/); if (!m) return null; - if (parseInt(m[1], 10) === 0) return []; + // Both capture groups are mandatory, so on a match m[1] and m[2] are + // always present; each exec match's group 1 likewise. The ?? "" fallbacks + // only drop the `!` and are unreachable for a real match. + if (parseInt(m[1] ?? "", 10) === 0) return []; const out: string[] = []; const re = /"((?:\\.|[^"\\])*)"/g; let p: RegExpExecArray | null; - while ((p = re.exec(m[2])) !== null) { - out.push(p[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\")); + while ((p = re.exec(m[2] ?? "")) !== null) { + out.push((p[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\")); } return out; } @@ -91,11 +96,14 @@ export function parseStringArrayProp(stdout: string): string[] | null { export function parseObjectPathArrayProp(stdout: string): string[] | null { const m = stdout.trim().match(/^a[os]\s+(\d+)((?:\s+"[^"]*")*)$/); if (!m) return null; - if (parseInt(m[1], 10) === 0) return []; + // Both capture groups are mandatory, so on a match m[1] and m[2] are + // always present; each exec match's group 1 likewise. The ?? "" fallbacks + // only drop the `!` and are unreachable for a real match. + if (parseInt(m[1] ?? "", 10) === 0) return []; const out: string[] = []; const re = /"([^"]*)"/g; let p: RegExpExecArray | null; - while ((p = re.exec(m[2])) !== null) out.push(p[1]); + while ((p = re.exec(m[2] ?? "")) !== null) out.push(p[1] ?? ""); return out; } diff --git a/plugins/input-plumber/lib/profile.ts b/plugins/input-plumber/lib/profile.ts index 901a67b2..8184c78c 100644 --- a/plugins/input-plumber/lib/profile.ts +++ b/plugins/input-plumber/lib/profile.ts @@ -77,7 +77,10 @@ export function parseCapability(raw: string): Capability { .map((s) => s.trim()) .filter(Boolean); const category = (parts[0] ?? "").toLowerCase(); - const name = parts.length > 0 ? parts[parts.length - 1] : raw.trim(); + // parts[len-1] is defined whenever parts is non-empty; when empty the read + // is undefined and we fall back to raw.trim() — identical to the previous + // `parts.length > 0 ? …! : raw.trim()` ternary, minus the non-null `!`. + const name = parts[parts.length - 1] ?? raw.trim(); return { raw: raw.trim(), category, name }; } @@ -303,8 +306,12 @@ export function renderCaptureProfile( const recommended = buttons.filter((b) => b.recommended); const limit = Math.min(recommended.length, SENTINEL_KEYS.length); for (let i = 0; i < limit; i++) { + // limit = min(recommended.length, SENTINEL_KEYS.length), so both indexes + // are in-bounds for i < limit. The guard drops the non-null `!` and only + // skips defensively if that invariant ever changes (unreachable today). const b = recommended[i]; const sentinel = SENTINEL_KEYS[i]; + if (!b || !sentinel) continue; sentinelToRaw.set(sentinel.code, b.raw); const cap = parseCapability(b.raw); const sourceEvent = diff --git a/plugins/input-plumber/lib/wake-trigger-deck.ts b/plugins/input-plumber/lib/wake-trigger-deck.ts index 6326725b..c36ac003 100644 --- a/plugins/input-plumber/lib/wake-trigger-deck.ts +++ b/plugins/input-plumber/lib/wake-trigger-deck.ts @@ -330,7 +330,10 @@ async function captureInner(timeoutMs: number): Promise { // Only report id 0x01 carries button state; skip interleaved frames. if (report[0] !== REPORT_ID_INPUT) continue; for (const b of DECK_BUTTONS) { - const cur = (report[b.byte] & (1 << b.bit)) !== 0; + // splitReports only yields full REPORT_LEN (64-byte) frames and + // b.byte is always < 64, so this index is provably in-bounds; + // ?? 0 is behaviour-identical for the bitwise test and drops the `!`. + const cur = ((report[b.byte] ?? 0) & (1 << b.bit)) !== 0; const prevHeld = held.get(b.name) ?? false; held.set(b.name, cur); if (cur && !prevHeld && !pressed) pressed = b; diff --git a/plugins/input-plumber/lib/wake-trigger.ts b/plugins/input-plumber/lib/wake-trigger.ts index 5e861882..ed3fe6c9 100644 --- a/plugins/input-plumber/lib/wake-trigger.ts +++ b/plugins/input-plumber/lib/wake-trigger.ts @@ -192,7 +192,10 @@ function pickDevice( const byName = composites.find((d) => d.name === rememberedName); if (byName) return byName; } - return composites[0]; + // Guarded above: composites is non-empty, so composites[0] is present; the + // ?? null only satisfies the checker (return type already allows null) and + // never fires for real inputs. + return composites[0] ?? null; } // ── public operations ─────────────────────────────────────────────────────── @@ -273,7 +276,9 @@ export function findEventNode(procContent: string, name: string): string | null if (!nameMatch || nameMatch[1] !== name) continue; const handlersMatch = block.match(/^H:\s+Handlers=(.*)$/m); if (!handlersMatch) continue; - const ev = handlersMatch[1].split(/\s+/).find((h) => /^event\d+$/.test(h)); + // Capture group 1 is mandatory, so on a match it is always present; ?? "" + // only drops the `!` and is unreachable for a real match. + const ev = (handlersMatch[1] ?? "").split(/\s+/).find((h) => /^event\d+$/.test(h)); if (!ev) continue; const num = parseInt(ev.slice(5), 10); if (best === null || num > best.num) { @@ -415,7 +420,15 @@ async function captureWakeButtonInner(timeoutMs: number): Promise g.appId === runningId); if (idx <= 0) return list; - return [list[idx], ...list.slice(0, idx), ...list.slice(idx + 1)]; + // idx came from findIndex and is > 0, so list[idx] is present. + const running = list[idx]; + if (running === undefined) return list; // unreachable: idx from findIndex, > 0. + return [running, ...list.slice(0, idx), ...list.slice(idx + 1)]; }, [library, collectionFilter, searchQuery, currentGame]); const collectionOptions = useMemo(() => { diff --git a/plugins/launch-options/backend.ts b/plugins/launch-options/backend.ts index 490dd0e7..f3be29e1 100644 --- a/plugins/launch-options/backend.ts +++ b/plugins/launch-options/backend.ts @@ -375,7 +375,11 @@ export default class LaunchOptionsBackend implements PluginBackend { } // Prefer the config that already has this appId - let targetPath = configs[0]; + const firstConfig = configs[0]; // length checked !== 0 above. + if (firstConfig === undefined) { + throw new Error("No Steam userdata directories found"); + } + let targetPath = firstConfig; for (const configPath of configs) { try { const content = await readFile(configPath, "utf-8"); diff --git a/plugins/lsfg-vk/GamePicker.tsx b/plugins/lsfg-vk/GamePicker.tsx index 02886b04..eb7c3960 100644 --- a/plugins/lsfg-vk/GamePicker.tsx +++ b/plugins/lsfg-vk/GamePicker.tsx @@ -257,7 +257,10 @@ export function GamePicker({ const currentId = String(currentGame.appId); const idx = filtered.findIndex((g) => g.appId === currentId); if (idx <= 0) return filtered; - return [filtered[idx], ...filtered.slice(0, idx), ...filtered.slice(idx + 1)]; + // idx came from findIndex and is > 0, so filtered[idx] is present. + const running = filtered[idx]; + if (running === undefined) return filtered; // unreachable: idx from findIndex, > 0. + return [running, ...filtered.slice(0, idx), ...filtered.slice(idx + 1)]; }, [library, search, currentGame, collectionFilter]); return ( diff --git a/plugins/lsfg-vk/InstallCard.tsx b/plugins/lsfg-vk/InstallCard.tsx index 37391a63..4291819e 100644 --- a/plugins/lsfg-vk/InstallCard.tsx +++ b/plugins/lsfg-vk/InstallCard.tsx @@ -109,7 +109,7 @@ export function InstallCard({ Layer version
-