Skip to content
Draft
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
6 changes: 5 additions & 1 deletion build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { join } from "node:path";
// JS raised from 2_800_000: posthog-js (anonymous analytics, src/platform/analytics.ts,
// the no-external build so nothing is injected past Trusted Types) added about 385 kB;
// measured 3_171_143 bytes with it.
// Raised again to 3_230_000 for custom games (dialog, Bridge calls), the game
// profile Apply now button, Host bunny hop, the side buttons card, custom game
// pictures, renaming a custom game and a report rate per connection, with their
// strings in 12 languages. Measured 3_209_919 after the last change.
const BUDGET_BYTES: Record<string, number> = {
".css": 165_000,
".js": 3_200_000,
".js": 3_230_000,
};

const ASSETS = join("dist", "assets");
Expand Down
374 changes: 361 additions & 13 deletions src/device/controller.ts

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions src/device/game-profile-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,41 @@ test("stored snapshots lose fields this build cannot apply", () => {
assert.deepEqual(sanitizeSnapshot([1]), {});
});

test("HITS tuning is part of a game profile", () => {
const tuning = (actuation: number): MouseStatus["analogButtonTuning"] => ({
maxActuation: 10,
maxRapidTrigger: 10,
maxHaptics: 10,
buttons: [
{ actuation, rapidTrigger: 2, haptics: 3, rapidTriggerEnabled: true },
{ actuation: 4, rapidTrigger: 2, haptics: 3, rapidTriggerEnabled: false },
],
});
const before = status({ analogButtonTuning: tuning(4) });
const after = status({ analogButtonTuning: tuning(7) });
assert.deepEqual(editableProfileFields(before, after), ["analogButtonTuning"]);
assert.deepEqual(snapshotDiff(before, after), { analogButtonTuning: tuning(7) });
assert.deepEqual(sanitizeSnapshot({ analogButtonTuning: tuning(7) }), { analogButtonTuning: tuning(7) });
});

test("side button assignments are part of a game profile", () => {
const before = status({ hostButtons: {} });
const after = status({ hostButtons: { 16: { key: 0x68, modifiers: 0 }, 8: { key: 0x04, modifiers: 3 } } });
assert.deepEqual(editableProfileFields(before, after), ["hostButtons"]);
assert.deepEqual(snapshotDiff(before, after), { hostButtons: after.hostButtons });
assert.deepEqual(pickSnapshot(before, { hostButtons: after.hostButtons }), { hostButtons: {} }, "the prior is no assignments");
assert.deepEqual(sanitizeSnapshot({ hostButtons: after.hostButtons }), { hostButtons: after.hostButtons });
});

test("Host bunny hop is part of a game profile", () => {
const before = status({ hostBunnyHopMs: 0 });
const after = status({ hostBunnyHopMs: 100 });
assert.deepEqual(editableProfileFields(before, after), ["hostBunnyHopMs"]);
assert.deepEqual(snapshotDiff(before, after), { hostBunnyHopMs: 100 });
assert.deepEqual(pickSnapshot(before, { hostBunnyHopMs: 100 }), { hostBunnyHopMs: 0 });
assert.deepEqual(sanitizeSnapshot({ hostBunnyHopMs: 100 }), { hostBunnyHopMs: 100 });
});

test("the comparison key ignores field order", () => {
assert.equal(snapshotKey({ dpi: 800, pollingRateHz: 1000 }), snapshotKey({ pollingRateHz: 1000, dpi: 800 }));
assert.notEqual(snapshotKey({ dpi: 800 }), snapshotKey({ dpi: 1600 }));
Expand Down Expand Up @@ -114,3 +149,11 @@ test("switching Games profiles restores previous controls and retains every orig
assert.deepEqual(firstPrior, { lunafury: { lightningMode: 0, leftDebounceMs: 3 } });
assert.deepEqual(mergeSnapshots({ dpi: 800 }, { pollingRateHz: 2000, dpi: 1600 }), { dpi: 1600, pollingRateHz: 2000 });
});

test("a profile keeps a report rate for each connection and restores only the one in use", () => {
const wired = status({ connectionType: "Wired", pollingRateHz: 1000, pollingRateWiredHz: 1000 });
const edited = { ...wired, pollingRateWirelessHz: 8000, pollingRateWiredHz: 2000 };
const profile = snapshotDiff(wired, edited);
assert.deepEqual(profile, { pollingRateWirelessHz: 8000, pollingRateWiredHz: 2000 });
assert.deepEqual(pickSnapshot(wired, profile), { pollingRateWiredHz: 1000 }, "only the connection in use has a value to put back");
});
5 changes: 5 additions & 0 deletions src/device/game-profile-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const GAME_PROFILE_FIELDS = [
"activeDpiStage",
"dpiStageColors",
"pollingRateHz",
"pollingRateWirelessHz",
"pollingRateWiredHz",
"liftOffDistance",
"liftOffScale",
"asymmetricLiftOff",
Expand Down Expand Up @@ -47,6 +49,9 @@ export const GAME_PROFILE_FIELDS = [
"hiResScroll",
"invertScroll",
"thumbWheelInverted",
"analogButtonTuning",
"hostBunnyHopMs",
"hostButtons",
"hapticIntensity",
"hapticEnabled",
"hapticBatterySaving",
Expand Down
18 changes: 18 additions & 0 deletions src/device/pending-changes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
pendingChangeBatches,
pendingChanges,
restorePendingChanges,
setStatusOverlay,
stagePendingChange,
stashPendingChanges,
withPendingChanges,
Expand Down Expand Up @@ -145,3 +146,20 @@ test("stashed changes come back in their original order after another set was st
assert.deepEqual(pendingChanges().map((entry) => entry.key), ["a", "b"]);
clearPendingChanges();
});

test("an overlay shows on top of the status and of the staged changes, and outlives them", () => {
clearPendingChanges();
const deviceStatus = status();
stagePendingChange({ key: "dpi", label: "DPI 1600", command: "", progress: "", preview: (value) => { value.dpi = 1600; }, apply: async () => {} });
setStatusOverlay((value) => { value.hostBunnyHopMs = 40; });

const shown = withPendingChanges(deviceStatus);
assert.equal(shown.dpi, 1600);
assert.equal(shown.hostBunnyHopMs, 40);
assert.equal(deviceStatus.hostBunnyHopMs, undefined, "the status itself is left alone");

clearPendingChanges();
assert.equal(withPendingChanges(deviceStatus).hostBunnyHopMs, 40, "nothing is staged, the overlay still shows");
setStatusOverlay(null);
assert.equal(withPendingChanges(deviceStatus), deviceStatus);
});
14 changes: 13 additions & 1 deletion src/device/pending-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,24 @@ export function clearPendingChanges(): void {
* refreshes cannot snap the controls back to the values still on the device.
*/
export function withPendingChanges(status: MouseStatus): MouseStatus {
if (changes.size === 0) return status;
if (changes.size === 0 && !overlay) return status;
const preview = structuredClone(status);
for (const change of changes.values()) change.preview?.(preview);
overlay?.(preview);
return preview;
}

let overlay: ((status: MouseStatus) => void) | null = null;

/**
* Values the mouse cannot take right now (a game profile's side buttons, or a
* Host bunny hop while the mouse is on Onboard) still have to show as set, so a
* game profile draft puts them on top of every preview. `null` removes it.
*/
export function setStatusOverlay(next: ((status: MouseStatus) => void) | null): void {
overlay = next;
}

export function stashPendingChanges(): PendingChange[] {
const stashed = [...changes.values()];
clearPendingChanges();
Expand Down
2 changes: 2 additions & 0 deletions src/device/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ export interface ControlSnapshot {
pending: PendingView;
/** True while the Games page is editing a game profile: edits are staged into a draft that is never flashed. */
gameProfileDraft: boolean;
/** The report rate a game profile being edited keeps for each connection. */
gameProfileRates: { wireless?: number; wired?: number };
diagnostics: DiagnosticsView;
diagnosticsOpen: boolean;
captureAvailable: boolean;
Expand Down
40 changes: 40 additions & 0 deletions src/features/bridge/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ export async function bridgeGames(signal?: AbortSignal): Promise<BridgeGame[]> {
return bridgeRequest<BridgeGame[]>("/v1/games", undefined, signal);
}

/** Games the user added by hand; `bridgeGames` already includes them. */
export async function bridgeCustomGames(signal?: AbortSignal): Promise<BridgeGame[]> {
return bridgeRequest<BridgeGame[]>("/v1/custom-games", undefined, signal);
}

/** Where Bridge serves a custom game's image; `version` makes the browser ask again after it changes. */
export function customGameImageUrl(name: string, version = 0): string {
return `${BRIDGE_URL}/v1/custom-games/image?name=${encodeURIComponent(name)}&v=${version}`;
}

export async function saveBridgeCustomGameImage(name: string, image: Blob): Promise<void> {
await bridgeRequest(`/v1/custom-games/image?name=${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": image.type || "application/octet-stream" },
body: image,
});
}

/** The image a link points at, downloaded by Bridge because most sites block a page from doing it. */
export async function bridgeImageFromUrl(link: string): Promise<Blob> {
const response = await fetch(`${BRIDGE_URL}/v1/custom-games/image-from-url?url=${encodeURIComponent(link)}`, {
signal: AbortSignal.timeout(BRIDGE_NATIVE_TIMEOUT_MS),
});
if (!response.ok) throw new Error(await response.text());
return response.blob();
}

export async function saveBridgeCustomGames(games: BridgeGame[]): Promise<BridgeGame[]> {
return bridgeRequest<BridgeGame[]>("/v1/custom-games", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(games),
});
}

export async function saveBridgeProfiles(profiles: BridgeProfile[]): Promise<void> {
await bridgeRequest("/v1/profiles", {
method: "PUT",
Expand All @@ -98,6 +133,11 @@ export async function saveBridgeProfiles(profiles: BridgeProfile[]): Promise<voi
});
}

/** The profile Bridge uses whenever no game's is active, or null when none is saved. */
export async function bridgeDefaultProfile(signal?: AbortSignal): Promise<BridgeProfile | null> {
return bridgeRequest<BridgeProfile | null>("/v1/default-profile", undefined, signal);
}

export async function saveBridgeDefaultProfile(profile: BridgeProfile): Promise<void> {
await bridgeRequest("/v1/default-profile", {
method: "PUT",
Expand Down
Loading
Loading