Skip to content
Merged
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
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion apps/loadout-overlay/src/bun/native/deck-hidraw-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
4 changes: 3 additions & 1 deletion apps/loadout-overlay/src/bun/native/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
13 changes: 10 additions & 3 deletions apps/loadout-overlay/src/bun/native/gamescope-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions apps/loadout-overlay/src/bun/native/input-intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) };
}

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions apps/loadout-overlay/src/bun/native/ip-intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion apps/loadout-overlay/src/overlay/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-base-content truncate">
Expand Down
5 changes: 3 additions & 2 deletions apps/loadout-overlay/src/overlay/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -263,7 +264,7 @@ export function Sidebar({
aria-hidden
/>
) : (
<span>{(plugin.icon ?? plugin.name)[0].toUpperCase()}</span>
<span>{(plugin.icon ?? plugin.name).charAt(0).toUpperCase()}</span>
)
}
label={plugin.name}
Expand Down
10 changes: 7 additions & 3 deletions apps/loadout-overlay/src/overlay/components/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export function WelcomeScreen({
const scrollRef = useRef<HTMLDivElement>(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.
Expand Down Expand Up @@ -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 (
<div className="px-9 pt-8 pb-4">
Expand Down Expand Up @@ -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()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-base-content truncate">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Focusable
key={plugin.id}
Expand Down
16 changes: 11 additions & 5 deletions apps/loadout-overlay/src/overlay/hooks/useGamepadInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,10 @@ export function useGamepadInput(onBack?: () => 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);
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
Loading
Loading