diff --git a/build/lunafury-controller.test.ts b/build/lunafury-controller.test.ts new file mode 100644 index 00000000..dd48371f --- /dev/null +++ b/build/lunafury-controller.test.ts @@ -0,0 +1,1271 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { before, test } from "node:test"; +import { build } from "vite"; +import type { BridgeProfile } from "../src/features/bridge/bridge.ts"; +import { getDfuInfo, type FirmwareManifest } from "../src/device/firmware-updates.ts"; + +let controller: any; +const controllerPath = fileURLToPath(new URL("../src/device/controller.ts", import.meta.url)).replaceAll("\\", "/"); +const bridgeHookPath = fileURLToPath(new URL("../src/features/bridge/useBridgeProfileApplier.ts", import.meta.url)).replaceAll("\\", "/"); +const dpiEditorPath = fileURLToPath(new URL("../src/features/devices/cards/LunaFuryDpiEditor.tsx", import.meta.url)).replaceAll("\\", "/"); + +before(async () => { + const storage = { getItem: () => null, setItem: () => {}, removeItem: () => {} }; + // No browser, native Bridge or real HID device is opened by this harness. + (globalThis as any).localStorage = storage; + (globalThis as any).window = { localStorage: storage, location: { search: "" }, + setTimeout: (fn: () => void, ms = 0) => setTimeout(fn, Math.min(ms, 1)), clearTimeout, + matchMedia: () => ({ matches: false }) }; + (globalThis as any).document = { title: "OpenMouse test" }; + const result: any = await build({ + configFile: false, logLevel: "silent", + define: { __BUILD_CHANNEL__: '"stable"', __APP_VERSION__: '"test"', __BUILD_ID__: '"test"' }, + plugins: [{ name: "controller-test-state", enforce: "pre", + resolveId(id, importer) { + if (id === "test:bridge-hooks" || id === "react") return "\0test:bridge-hooks"; + if (id === "../../../shared/ui" && importer?.replaceAll("\\", "/") === dpiEditorPath) return "\0test:dpi-ui"; + if (id === "test:bridge-status" || (id === "./bridge-status-store" && importer?.replaceAll("\\", "/") === bridgeHookPath)) { + return "\0test:bridge-status"; + } + }, + load(id) { + // Run the real hook's subscription callback without a DOM renderer or + // native Bridge. Refs persist across renders; the effect mounts once. + if (id === "\0test:bridge-hooks") return ` + let refs = [], cursor = 0, mounted = false, cleanup; + export function useRef(initial) { return refs[cursor++] ??= { current: initial }; } + export function useEffect(setup) { if (!mounted) { mounted = true; cleanup = setup(); } } + export function useState(initial) { return [typeof initial === "function" ? initial() : initial, () => {}]; } + export function beginRender() { cursor = 0; } + export function resetHook() { cleanup?.(); refs = []; cursor = 0; mounted = false; cleanup = null; } + export function hookWriting() { return refs.at(-1)?.current === true; } + `; + // Keep the editor's real event handlers; shared widgets need no DOM here. + if (id === "\0test:dpi-ui") return `export const OptionMenu = "OptionMenu", StepperSlider = "StepperSlider", SwitchRow = "SwitchRow";`; + if (id === "\0test:bridge-status") return ` + const listeners = new Set(); + export function subscribeBridgeStatus(listener) { listeners.add(listener); listener(null); return () => listeners.delete(listener); } + export function emitBridge(activeProfile) { for (const listener of listeners) listener({ activeProfile }); } + `; + }, + transform(source, id) { + if (id.replaceAll("\\", "/") !== controllerPath) return null; + // Only inject device setup/observability. All staging, Games apply and + // flash code below is the real controller, bundled by the normal toolchain. + return source.replace( + "export function pushToast(kind: ToastKind, title: string, detail?: string, options: ToastOptions = {}): void {", + "$&\n testToastLog.push({ kind, title, detail });", + ) + ` + import { useBridgeProfileApplier } from "../features/bridge/useBridgeProfileApplier"; + import { beginRender, resetHook, hookWriting } from "test:bridge-hooks"; + import { emitBridge } from "test:bridge-status"; + export const testToastLog = []; + export async function testConnect(device, preserveSessions = false) { + if (!preserveSessions) resetHook(); + testToastLog.length = 0; + clearPendingChanges(); gameProfileDraft = null; + active = new LamzuHidClient(device); activeDevice = device; + dpiOptions = active.getDpiOptions(); lastDiagnosticError = null; + interfacePreferences = { ...DEFAULT_INTERFACE_PREFERENCES, reducedMotion: true }; + applyStatus(await active.readStatus()); + return latestDeviceStatus; + } + export function testState() { + return { status: latestDeviceStatus, preview: withPendingChanges(latestDeviceStatus), + pending: pendingChanges().map(change => change.key), error: lastDiagnosticError }; + } + export function testBridgePoll(profile) { + beginRender(); useBridgeProfileApplier(getSnapshot()); emitBridge(profile); + } + export function testRejectNextStatusRead() { + const read = active.readStatus.bind(active); + active.readStatus = async () => { + active.readStatus = read; + throw new Error("Injected post-write status failure."); + }; + } + export function testFailStatusReads() { + const read = active.readStatus.bind(active); + let failing = true, attempts = 0; + active.readStatus = async () => { + attempts++; + if (failing) throw new Error("Injected persistent status failure."); + return read(); + }; + return { allow: () => { failing = false; }, attempts: () => attempts }; + } + export function testDeferNextStatusRead() { + const client = active, read = client.readStatus.bind(client); + let release, started = false; + const gate = new Promise(resolve => { release = resolve; }); + client.readStatus = async () => { + client.readStatus = read; started = true; + await gate; + return read(); + }; + return { release: () => release(), started: () => started }; + } + export async function testConnectGeneric(device) { + const template = await testConnect(device); + const stored = { ...template, brand: "Generic", name: "Generic test mouse", + dpi: 800, dpiY: 800, dpiStages: undefined, dpiStagesY: undefined, + activeDpiStage: undefined, lunafury: undefined, ui: undefined }; + const writes = []; + let reads = 0; + active = { device, getDpiOptions: () => [400, 800, 1200, 1600], + readStatus: async () => { reads++; return structuredClone(stored); }, + setDpi: async (dpi) => { writes.push(dpi); stored.dpi = stored.dpiY = dpi; }, + setPollingRate: async (rate) => { stored.pollingRateHz = rate; } }; + applyStatus(await active.readStatus()); + return { stored, writes, reads: () => reads }; + } + export async function testConnectGenericStages(device) { + const generic = await testConnectGeneric(device), stored = generic.stored; + stored.dpiStages = [400, 800, 1600, 3200, 6400, 8000]; + stored.activeDpiStage = 1; + stored.ui = { dpiStageEditor: { maxStages: 6, countEditable: true } }; + dpiOptions = [400, 800, 1200, 1600, 2400, 3200, 4800, 6400, 8000]; + active.setDpiStageCount = async count => { + generic.writes.push({ count }); + stored.dpiStages = count <= stored.dpiStages.length + ? stored.dpiStages.slice(0, count) + : [...stored.dpiStages, ...Array(count - stored.dpiStages.length).fill(3200)]; + stored.activeDpiStage = Math.min(stored.activeDpiStage, count - 1); + stored.dpi = stored.dpiStages[stored.activeDpiStage]; + }; + active.setDpiStageValue = async (stage, dpi) => { + if (stage >= stored.dpiStages.length) throw new Error("Generic stage is not enabled."); + generic.writes.push({ stage, dpi }); + stored.dpiStages[stage] = dpi; + }; + applyStatus(await active.readStatus()); + return generic; + } + export { refreshStatus as testRefreshStatus }; + export function testClientOnly() { activeDevice = null; } + export { hookWriting as testBridgeWriting }; + export { LunaFuryDpiEditor as testDpiEditor } from "../features/devices/cards/LunaFuryDpiEditor"; + export { snapshotDiff as testSnapshotDiff, pickSnapshot as testPickSnapshot, + mergeSnapshots as testMergeSnapshots } from "./game-profile-snapshot.ts"; + `; + } }], + build: { write: false, minify: false, lib: { entry: controllerPath, formats: ["es"] }, + rollupOptions: { output: { inlineDynamicImports: true } } }, + }); + const output = (Array.isArray(result) ? result : [result]).flatMap(entry => entry.output); + const code = output.find((entry: any) => entry.type === "chunk").code + "\n//# sourceURL=openmouse-controller-test.mjs"; + controller = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`); +}); + +function fixture(productId = 0x0033) { + let profile = 1; + const banks = Array.from({ length: 3 }, () => ({ count: 3, active: 2, axes: true, + table: [400, 800, 800, 1600, 1600, 3200, 3200, 6400, 6400, 6400, 30000, 30000] })); + const sent: Uint8Array[] = []; + const device = { vendorId: 0x373e, productId, productName: "LUNA33", opened: true, collections: [], + open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, report: Uint8Array) => { + const packet = new Uint8Array(report); sent.push(packet); + const key = `${packet[4]}:${packet[5]}`; + if (key === "0:5") profile = packet[6]; + const bank = banks[packet[6] - 1]; + if (!bank) return; + if (key === "1:2") bank.active = packet[7]; + if (key === "1:13") bank.axes = packet[7] === 1; + if (key === "1:1") { + bank.count = packet[7]; + bank.table = Array.from({ length: 12 }, (_, i) => (packet[8 + i * 2] << 8) | packet[9 + i * 2]); + } + }, + receiveFeatureReport: async () => { + const packet = sent.at(-1)!; + const key = `${packet[4]}:${packet[5]}`, requested = packet[6]; + const bank = banks[requested - 1] ?? banks[profile - 1]; + let payload = [requested, 0]; + if (key === "0:129") payload = [0, 0, 26, 0]; + if (key === "0:131") payload = [0, 90]; + if (key === "0:133") payload = [profile]; + if (key === "0:135") payload = [requested, 1, 44]; + if (key === "0:159") payload = [requested, 1]; + if (key === "1:128" || key === "1:136") payload = [requested, 1]; + if (key === "1:129") payload = [requested, bank.count, ...bank.table.flatMap(value => [value >> 8, value & 255])]; + if (key === "1:130") payload = [requested, bank.active]; + if (key === "1:141") payload = [requested, +bank.axes]; + const reply = new Uint8Array(64); + reply[0] = 0xa1; reply[3] = payload.length; reply[4] = packet[4]; reply[5] = packet[5]; reply.set(payload, 6); + return new DataView(reply.buffer); + }, + }; + return { device, banks, sent }; +} + +function ignoreWrites(mouse: ReturnType, command = 1) { + let ignored = true; + const send = mouse.device.sendFeatureReport; + mouse.device.sendFeatureReport = async (id, report) => { + const original = structuredClone(mouse.banks[0]); + await send(id, report); + if (ignored && report[4] === 1 && report[5] === command) Object.assign(mouse.banks[0], original); + }; + return () => { ignored = false; }; +} + +function game(name: string, snapshot: BridgeProfile["settings"]["snapshot"], deviceName: string): BridgeProfile { + return { application: { name, executable: `${name}.exe`, path: `${name}.exe` }, + device: { id: "test-mouse", name: deviceName }, settings: { dpi: null, pollingRateHz: null, snapshot } }; +} + +async function bridgePoll(profile: BridgeProfile | null): Promise { + controller.testBridgePoll(profile); + for (let step = 0; step < 1000 && controller.testBridgeWriting(); step++) { + await new Promise(resolve => setTimeout(resolve, 1)); + } + assert.equal(controller.testBridgeWriting(), false, "the hook must release its writing guard"); +} + +function bridgeSuccessNotices() { + return controller.testToastLog.filter((toast: { title: string }) => + toast.title === "Applied game profile" || toast.title === "Restored your previous mouse settings"); +} + +function dpiEditorNodes(status: unknown): any[] { + const root = controller.testDpiEditor({ snapshot: { status, preferences: { locale: "en" }, + capabilities: { dpiStagesWritable: true, activeDpiStageWritable: true } } }); + const nodes: any[] = []; + const visit = (node: any) => { + if (Array.isArray(node)) { node.forEach(visit); return; } + if (!node?.props) return; + nodes.push(node); visit(node.props.children); + }; + visit(root); + return nodes; +} + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`firmware manifest matches actual protocol readouts on ${pid.toString(16)}`, async () => { + const manifest = JSON.parse(readFileSync(new URL("../public/firmware-manifest.json", import.meta.url), "utf8")) as FirmwareManifest; + const wireless = pid === 0x33 || pid === 0x84; + const latest = pid === 0x32 || pid === 0x33 ? [25, 0] : [1, 47]; + const older = latest[1] > 0 ? [latest[0], latest[1] - 1] : [latest[0] - 1, 0]; + const newer = [latest[0] + 1, latest[1]]; + for (const [mouseVersion, dongleVersion, entity] of [ + [latest, latest, null], [newer, newer, null], + [older, latest, "Mouse"], [latest, older, wireless ? "Dongle" : null], + ] as const) { + const mouse = fixture(pid), receive = mouse.device.receiveFeatureReport; + mouse.device.receiveFeatureReport = async () => { + const reply = await receive(), packet = mouse.sent.at(-1)!; + if (packet[4] === 0 && packet[5] === 0x81) { + const version = wireless && packet[2] === 0 ? dongleVersion : mouseVersion; + new Uint8Array(reply.buffer, reply.byteOffset, reply.byteLength).set([0, 0, ...version], 6); + } + return reply; + }; + const status = await controller.testConnect(mouse.device); + assert.deepEqual(status.firmware, wireless + ? [`Mouse ${mouseVersion.join(".")}`, `Dongle ${dongleVersion.join(".")}`] + : [`Mouse ${mouseVersion.join(".")}`]); + const info = getDfuInfo({ ...status, interfaceId: `373e_${pid.toString(16).padStart(4, "0")}` }, manifest); + assert.equal(info?.latestVersion, latest.join(".")); + assert.equal(info?.dfuAvailable, entity !== null); + if (entity !== null) assert.equal(info?.entityName, entity); + } + }); + + for (const input of ["slider", "number"]) { + test(`DPI editor ${input} on ${pid.toString(16)} preserves Y after an axis-mode read failure`, async () => { + const mouse = fixture(pid), receive = mouse.device.receiveFeatureReport; + mouse.device.receiveFeatureReport = async () => { + const request = mouse.sent.at(-1)!; + if (request[4] === 1 && request[5] === 0x8d) throw new Error("Injected axis-mode read failure."); + return receive(); + }; + const before = await controller.testConnect(mouse.device); + assert.equal(before.lunafury.separateDpiAxes, undefined); + const original = [...mouse.banks[0].table]; + const nodes = dpiEditorNodes(before); + if (input === "slider") nodes.find(node => node.props.id === "lunafury-dpi-x").props.onCommit(1200); + else nodes.filter(node => node.type === "input")[1].props.onfocusout({ currentTarget: { value: "1200" } }); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(mouse.banks[0].table, [...original.slice(0, 2), 1200, ...original.slice(3)]); + }); + } + + test(`numeric DPI stage edits on ${pid.toString(16)} respect LunaFury axis mode`, async () => { + for (const separate of [false, true, undefined]) { + const mouse = fixture(pid), receive = mouse.device.receiveFeatureReport; + mouse.banks[0].axes = separate !== false; + mouse.device.receiveFeatureReport = async () => { + const request = mouse.sent.at(-1)!; + if (separate === undefined && request[4] === 1 && request[5] === 0x8d) { + throw new Error("Injected axis-mode read failure."); + } + return receive(); + }; + const before = await controller.testConnect(mouse.device); + assert.equal(before.lunafury.separateDpiAxes, separate); + const y = separate === false ? 1200 : before.dpiStagesY[1]; + controller.applyDpiStageValue(1, 1200); + const preview = controller.testState().preview; + assert.deepEqual([preview.dpiStages[1], preview.dpiStagesY[1]], [1200, y]); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(mouse.banks[0].table.slice(2, 4), [1200, y]); + } + }); + + test(`Bridge on ${pid.toString(16)} applies and restores Games separately for each onboard bank`, async () => { + const mouse = fixture(pid); + mouse.banks[1].table[2] = 1000; mouse.banks[1].table[3] = 2000; + const original = structuredClone(mouse.banks); + const before = await controller.testConnect(mouse.device); + const profile = game("A", { dpi: 1200 }, before.name); + await bridgePoll(profile); + assert.equal(mouse.banks[0].table[2], 1200); + controller.applyProfileSelection(2); + assert.equal(await controller.flashPendingChanges(), "written"); + await bridgePoll(profile); + assert.equal(mouse.banks[1].table[2], 1200, "the same game must also apply to the newly selected bank"); + assert.equal(mouse.banks[1].table[3], 2000); + await bridgePoll(null); + assert.deepEqual(mouse.banks[1], original[1]); + assert.equal(mouse.banks[0].table[2], 1200, "do not switch the selected bank during restoration"); + controller.applyProfileSelection(1); + assert.equal(await controller.flashPendingChanges(), "written"); + await bridgePoll(null); + assert.deepEqual(mouse.banks, original, "the inactive bank's backup must survive until it is selected again"); + assert.equal(controller.testState().error, null); + assert.equal(bridgeSuccessNotices().length, 4); + }); +} + +for (const separate of [false, true]) { + test(`DPI editor respects a confirmed ${separate ? "independent" : "linked"} axis mode`, async () => { + const mouse = fixture(); mouse.banks[0].axes = separate; + const before = await controller.testConnect(mouse.device); + dpiEditorNodes(before).find(node => node.props.id === "lunafury-dpi-x").props.onCommit(1200); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(mouse.banks[0].table.slice(2, 4), [1200, separate ? 1600 : 1200]); + }); +} + +test("Bridge keeps bank backups through a failed restore and a visit to another bank", async () => { + const mouse = fixture(), original = structuredClone(mouse.banks); + const before = await controller.testConnect(mouse.device); + const profile = game("A", { dpi: 1200 }, before.name); + await bridgePoll(profile); + const allowWrites = ignoreWrites(mouse); + await bridgePoll(null); + assert.equal(mouse.banks[0].table[2], 1200); + controller.applyProfileSelection(2); + assert.equal(await controller.flashPendingChanges(), "written"); + await bridgePoll(profile); + assert.equal(mouse.banks[1].table[2], 1200); + await bridgePoll(null); + assert.deepEqual(mouse.banks[1], original[1]); + controller.applyProfileSelection(1); + assert.equal(await controller.flashPendingChanges(), "written"); + allowWrites(); + await bridgePoll(null); + assert.deepEqual(mouse.banks, original); +}); + +test("Bridge keeps separate backups for two mice with the same name and bank number", async () => { + const first = fixture(), second = fixture(); + second.banks[0].table[2] = 1000; second.banks[0].table[3] = 2000; + const originals = [structuredClone(first.banks), structuredClone(second.banks)]; + const before = await controller.testConnect(first.device); + const profile = game("A", { dpi: 1200 }, before.name); + await bridgePoll(profile); + await controller.testConnect(second.device, true); + await bridgePoll(null); + assert.deepEqual(second.banks, originals[1], "the first mouse's backup must not reach the second mouse"); + await bridgePoll(profile); + assert.equal(second.banks[0].table[2], 1200); + await bridgePoll(null); + assert.deepEqual(second.banks, originals[1]); + await controller.testConnect(first.device, true); + await bridgePoll(null); + assert.deepEqual(first.banks, originals[0]); + assert.equal(controller.testState().error, null); +}); + +test("Games rejects a saved scope after the selected bank or device changes", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + const scope = controller.currentGameProfileScope(); + controller.applyProfileSelection(2); + assert.equal(await controller.flashPendingChanges(), "written"); + const sent = mouse.sent.length; + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }, scope), "busy"); + assert.equal(mouse.sent.length, sent); + const other = fixture(); + await controller.testConnect(other.device, true); + const otherSent = other.sent.length; + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }, scope), "busy"); + assert.equal(other.sent.length, otherSent); +}); + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`controller Games selection/restore on ${pid.toString(16)} never writes either DPI table`, async () => { + const { device, banks, sent } = fixture(pid); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const target = { ...before, activeDpiStage: 2, dpi: 1600, dpiY: 3200 }; + const saved = controller.testSnapshotDiff(before, target); + assert.deepEqual(saved, { activeDpiStage: 2 }); + const prior = controller.testPickSnapshot(before, saved); + assert.equal(await controller.flashGameProfileSnapshot(saved), "written"); + assert.equal(banks[0].active, 3); assert.deepEqual(banks[0].table, original); + assert.equal(await controller.flashGameProfileSnapshot(prior), "written"); + assert.equal(banks[0].active, 2); assert.deepEqual(banks[0].table, original); + assert.equal(sent.some(packet => packet[4] === 1 && packet[5] === 1), false); + assert.equal(controller.testState().error, null); + }); +} + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`controller on ${pid.toString(16)} reports a rejected Games table and retries without losing user edits`, async () => { + const mouse = fixture(pid); + const before = await controller.testConnect(mouse.device); + const first = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + const second = { dpiStages: [400, 1400, 1600], dpiStagesY: [800, 2800, 3200] }; + const prior = controller.testPickSnapshot(before, first); + assert.equal(await controller.flashGameProfileSnapshot(first), "written"); + const toWrite = controller.testMergeSnapshots(prior, second, controller.testState().status); + controller.applyLunaFuryDpiStageAxes(0, 600, 1200); + const allowWrites = ignoreWrites(mouse); + assert.equal(await controller.flashGameProfileSnapshot(toWrite), "failed"); + assert.match(controller.testState().error, /did not confirm the complete stored DPI table/); + assert.deepEqual(controller.testState().status.dpiStages, first.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, first.dpiStagesY); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.deepEqual(controller.testState().preview.dpiStages, [600, 1200, 1600]); + allowWrites(); + assert.equal(await controller.flashGameProfileSnapshot(toWrite), "written"); + assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(mouse.banks[0].table[0], 600); + assert.equal(await controller.flashPendingChanges(), "unchanged"); + }); + + test(`controller on ${pid.toString(16)} reports failed restore and retries the complete original storage`, async () => { + const mouse = fixture(pid); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const target = { dpiStages: [400, 1200, 1600, 2000], dpiStagesY: [800, 2400, 3200, 4000], activeDpiStage: 3 }; + const prior = controller.testPickSnapshot(before, target); + assert.equal(await controller.flashGameProfileSnapshot(target), "written"); + const allowWrites = ignoreWrites(mouse); + assert.equal(await controller.flashGameProfileSnapshot(prior), "failed"); + assert.match(controller.testState().error, /did not confirm/); + assert.notEqual(mouse.banks[0].active, original.active, "a driver clamp is not the original selection being restored"); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(controller.testState().pending, []); + allowWrites(); + assert.equal(await controller.flashGameProfileSnapshot(prior), "written"); + assert.deepEqual(mouse.banks[0], original); + assert.equal(await controller.flashGameProfileSnapshot(prior), "unchanged"); + }); +} + +test("controller keeps a failed batch failed when another flash was queued", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + controller.applyLunaFuryDpiStageAxes(1, 1200, 2400); + const allowWrites = ignoreWrites(mouse); + const send = mouse.device.sendFeatureReport; + let queued: unknown; + let didQueue = false; + mouse.device.sendFeatureReport = async (id, report) => { + if (report[4] === 1 && report[5] === 1 && !didQueue) { + didQueue = true; + queued = await controller.flashPendingChanges(); + } + await send(id, report); + }; + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.equal(queued, "busy"); + assert.deepEqual(controller.testState().pending, ["dpi-stage-1"]); + assert.equal(mouse.sent.filter(packet => packet[4] === 1 && packet[5] === 1).length, 1); + allowWrites(); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(controller.testState().pending, []); +}); + +for (const games of [false, true]) { + test(`controller reports post-write status failure for ${games ? "Games" : "manual Apply"} and permits retry`, async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + controller.testRejectNextStatusRead(); + const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + if (!games) controller.applyLunaFuryDpiStageAxes(1, 1200, 2400); + assert.equal(await (games ? controller.flashGameProfileSnapshot(target) : controller.flashPendingChanges()), "failed"); + assert.equal(mouse.banks[0].table[2], 1200, "the write may land before readback fails"); + assert.match(controller.testState().error, /Injected post-write status failure/); + assert.equal(controller.getSnapshot().settingInProgress, false); + assert.equal(controller.testToastLog.some((toast: { kind: string }) => toast.kind === "success"), false); + const start = mouse.sent.length; + assert.equal(await controller.flashGameProfileSnapshot(target), "unchanged", + "fresh read confirms the first write landed, so retry needs no duplicate write"); + assert.equal(mouse.sent.slice(start).some(packet => packet[5] < 0x80), false); + assert.deepEqual(controller.testState().status.dpiStages, target.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, target.dpiStagesY); + }); +} + +test("controller restores the user's stash even if Games staging unexpectedly throws", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + controller.applyLunaFuryDpiStageAxes(0, 600, 1200); + const target = { dpiStages: [400, 1200, 1600], get pollingRateHz() { throw new Error("Injected staging failure."); } }; + assert.equal(await controller.flashGameProfileSnapshot(target), "failed"); + assert.deepEqual(mouse.banks[0], original); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.deepEqual(controller.testState().preview.dpiStages, [600, 800, 1600]); + assert.match(controller.testState().error, /Injected staging failure/); +}); + +test("Bridge retries the same failed game switch and deduplicates only confirmed success", async () => { + const mouse = fixture(); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const first = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name); + const second = game("B", { dpiStages: [400, 1400, 1600], dpiStagesY: [800, 2800, 3200] }, before.name); + await bridgePoll(first); + controller.testToastLog.length = 0; + const allowWrites = ignoreWrites(mouse); + await bridgePoll(second); + assert.equal(mouse.banks[0].table[2], 1200); + assert.match(controller.testState().error, /did not confirm/); + assert.deepEqual(bridgeSuccessNotices(), []); + allowWrites(); + await bridgePoll(second); + assert.equal(mouse.banks[0].table[2], 1400); + assert.equal(bridgeSuccessNotices().length, 1); + const sent = mouse.sent.length; + await bridgePoll(second); + assert.equal(mouse.sent.length, sent); + assert.equal(bridgeSuccessNotices().length, 1); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); +}); + +test("Bridge keeps its original backup after restore fails and retries on the next poll", async () => { + const mouse = fixture(); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + await bridgePoll(game("A", { dpiStages: [400, 1200, 1600, 2000], dpiStagesY: [800, 2400, 3200, 4000], activeDpiStage: 3 }, before.name)); + const allowWrites = ignoreWrites(mouse); + controller.testToastLog.length = 0; + await bridgePoll(null); + assert.equal(mouse.banks[0].count, 4); + assert.notEqual(mouse.banks[0].active, original.active); + assert.deepEqual(bridgeSuccessNotices(), []); + allowWrites(); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + assert.equal(bridgeSuccessNotices().length, 1); + const sent = mouse.sent.length; + await bridgePoll(null); + assert.equal(mouse.sent.length, sent, "clear the backup only after a confirmed restore"); +}); + +test("Bridge restores a partial first application even when no game signature was confirmed", async () => { + const mouse = fixture(); + mouse.banks[0].axes = false; + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const allowWrites = ignoreWrites(mouse); + await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }, before.name)); + assert.equal(mouse.banks[0].axes, true, "the first batch landed before the DPI write failed"); + assert.equal(mouse.banks[0].table[2], 800); + assert.match(controller.testState().error, /did not confirm/); + assert.deepEqual(bridgeSuccessNotices(), []); + allowWrites(); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + assert.equal(bridgeSuccessNotices().length, 1); +}); + +test("Bridge retains backup through a partial restore and retries its remaining axis change", async () => { + const mouse = fixture(); + mouse.banks[0].axes = false; + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }, before.name)); + const allowWrites = ignoreWrites(mouse, 13); + controller.testToastLog.length = 0; + await bridgePoll(null); + assert.deepEqual(mouse.banks[0].table, original.table, "the DPI restore landed before the axis-mode failure"); + assert.equal(mouse.banks[0].axes, true); + assert.deepEqual(bridgeSuccessNotices(), []); + allowWrites(); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + assert.equal(bridgeSuccessNotices().length, 1); +}); + +test("Bridge invalidates the previous signature after a partial switch fails", async () => { + const mouse = fixture(); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const first = game("A", { dpiStages: [400, 1200, 1600] }, before.name); + const second = game("B", { dpiStages: [400, 1400, 1600], lunafury: { separateDpiAxes: false } }, before.name); + await bridgePoll(first); + const allowWrites = ignoreWrites(mouse, 13); + controller.testToastLog.length = 0; + await bridgePoll(second); + assert.equal(mouse.banks[0].table[2], 1400, "the DPI write landed before B's axis-mode failure"); + assert.deepEqual(bridgeSuccessNotices(), []); + allowWrites(); + await bridgePoll(first); + assert.equal(mouse.banks[0].table[2], 1200, "A must not be skipped using its old confirmed signature"); + assert.equal(bridgeSuccessNotices().length, 1); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); +}); + +test("Bridge retries a busy game application after the draft closes", async () => { + const mouse = fixture(); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const target = game("A", { dpiStages: [400, 1200, 1600] }, before.name); + assert.equal(controller.beginGameProfileDraft({}), true); + await bridgePoll(target); + assert.deepEqual(mouse.banks[0], original); + assert.deepEqual(bridgeSuccessNotices(), []); + controller.endGameProfileDraft(); + await bridgePoll(target); + assert.equal(mouse.banks[0].table[2], 1200); + assert.equal(bridgeSuccessNotices().length, 1); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); +}); + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`Bridge on ${pid.toString(16)} restores after apply lands but final read fails`, async () => { + const mouse = fixture(pid); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + controller.testRejectNextStatusRead(); + await bridgePoll(game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name)); + assert.equal(mouse.banks[0].table[2], 1200); + assert.equal(controller.testState().status.dpiStages[1], 800, "cache predates the unconfirmed write"); + assert.deepEqual(bridgeSuccessNotices(), []); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original, "do not clear backup using the stale original-looking cache"); + const sent = mouse.sent.length; + await controller.testRefreshStatus(); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false); + assert.equal(bridgeSuccessNotices().length, 1); + }); + + test(`Bridge on ${pid.toString(16)} reapplies after restore lands but final read fails`, async () => { + const mouse = fixture(pid); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const target = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name); + await bridgePoll(target); + controller.testToastLog.length = 0; + controller.testRejectNextStatusRead(); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + assert.equal(controller.testState().status.dpiStages[1], 1200, "cache still looks like game A"); + assert.deepEqual(bridgeSuccessNotices(), []); + await bridgePoll(target); + assert.equal(mouse.banks[0].table[2], 1200, "do not record A's signature using the stale game-looking cache"); + assert.equal(mouse.banks[0].table[3], 2400); + const sent = mouse.sent.length; + await controller.testRefreshStatus(); + await bridgePoll(target); + assert.equal(mouse.banks[0].table[2], 1200); + assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false); + assert.equal(bridgeSuccessNotices().length, 1); + await bridgePoll(null); + assert.deepEqual(mouse.banks[0], original); + }); +} + +for (const failedRestore of [false, true]) { + test(`Bridge preserves retry state while refresh keeps failing after ${failedRestore ? "restore" : "apply"}`, async () => { + const mouse = fixture(); + const before = await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + const target = game("A", { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }, before.name); + if (failedRestore) await bridgePoll(target); + const fault = controller.testFailStatusReads(); + controller.testToastLog.length = 0; + await bridgePoll(failedRestore ? null : target); + const afterUnconfirmedWrite = structuredClone(mouse.banks[0]); + const sent = mouse.sent.length; + for (let poll = 0; poll < 3; poll++) { + await controller.testRefreshStatus(); + await bridgePoll(failedRestore ? target : null); + assert.deepEqual(mouse.banks[0], afterUnconfirmedWrite); + assert.deepEqual(bridgeSuccessNotices(), []); + assert.equal(mouse.sent.length, sent, "no speculative write without a successful status refresh"); + } + assert.equal(fault.attempts(), 7, "one final read plus three background and three foreground retries"); + fault.allow(); + await bridgePoll(failedRestore ? target : null); + if (failedRestore) { + assert.equal(mouse.banks[0].table[2], 1200); + assert.equal(mouse.banks[0].table[3], 2400); + await bridgePoll(null); + } + assert.deepEqual(mouse.banks[0], original, "retained originals remain restorable after recovery"); + }); +} + +test("controller does not report an empty manual Apply unchanged until status is confirmed", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + const fault = controller.testFailStatusReads(); + controller.applyLunaFuryDpiStageAxes(1, 1200, 2400); + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.deepEqual(controller.testState().pending, []); + assert.equal(await controller.flashPendingChanges(), "failed"); + fault.allow(); + const sent = mouse.sent.length; + assert.equal(await controller.flashPendingChanges(), "unchanged"); + assert.equal(controller.testState().status.dpiStages[1], 1200); + assert.equal(mouse.sent.slice(sent).some(packet => packet[5] < 0x80), false); +}); + +test("a same-JSON background read confirms an untrusted cache without another read or write", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + const original = structuredClone(mouse.banks[0]); + ignoreWrites(mouse); + const fault = controller.testFailStatusReads(); + assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 1200, 1600] }), "failed"); + fault.allow(); + await controller.testRefreshStatus(); + assert.deepEqual(mouse.banks[0], original); + const attempts = fault.attempts(), sent = mouse.sent.length; + assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 800, 1600] }), "unchanged"); + assert.equal(fault.attempts(), attempts, "a successful background read restores trust even when JSON did not change"); + assert.equal(mouse.sent.length, sent); +}); + +test("Bridge captures newly touched fields from the refreshed status after an unconfirmed write", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGeneric(mouse.device); + const name = generic.stored.name; + controller.testRejectNextStatusRead(); + await bridgePoll(game("A", { dpi: 1200 }, name)); + assert.equal(generic.stored.dpi, 1200); + assert.equal(controller.testState().status.dpi, 800); + generic.stored.pollingRateHz = 2000; // A did not touch this externally changed field. + assert.equal(controller.testState().status.pollingRateHz, 1000); + await bridgePoll(game("B", { dpi: 1200, pollingRateHz: 1000 }, name)); + assert.equal(generic.stored.pollingRateHz, 1000); + await bridgePoll(null); + assert.equal(generic.stored.dpi, 800, "A's accumulated original still wins for DPI"); + assert.equal(generic.stored.pollingRateHz, 2000, "B captures its newly touched field after refresh, not from stale cache"); +}); + +test("generic scalar-DPI Games writes obey status trust and avoid redundant confirmed writes", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGeneric(mouse.device); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written"); + assert.deepEqual(generic.writes, [1200]); + const reads = generic.reads(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "unchanged"); + assert.equal(generic.reads(), reads); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "failed"); + assert.equal(generic.stored.dpi, 800); + const fault = controller.testFailStatusReads(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed"); + assert.deepEqual(generic.writes, [1200, 800]); + fault.allow(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written"); + assert.deepEqual(generic.writes, [1200, 800, 1200]); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "written"); +}); + +test("manual staging does not drop an edit matching only the untrusted old cache", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGeneric(mouse.device); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed"); + assert.equal(controller.testState().status.dpi, 800); + assert.equal(generic.stored.dpi, 1200); + controller.applyDpiValue(800); + assert.deepEqual(controller.testState().pending, ["dpi"]); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(generic.stored.dpi, 800); +}); + +test("failed foreground refresh preserves pending edits, then confirmed recovery rebases their preview", async () => { + const mouse = fixture(); + await controller.testConnect(mouse.device); + controller.applyLunaFuryDpiStageAxes(0, 600, 1200); + const fault = controller.testFailStatusReads(); + const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + assert.equal(await controller.flashGameProfileSnapshot(target), "failed"); + assert.equal(await controller.flashGameProfileSnapshot(target), "failed"); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.deepEqual(controller.testState().preview.dpiStages, [600, 800, 1600]); + fault.allow(); + assert.equal(await controller.flashGameProfileSnapshot(target), "unchanged"); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.deepEqual(controller.testState().preview.dpiStages, [600, 1200, 1600]); +}); + +test("an in-flight foreground recovery blocks another Games attempt until the read completes", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGeneric(mouse.device); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "failed"); + const gate = controller.testDeferNextStatusRead(); + const first = controller.flashGameProfileSnapshot({ dpi: 1200 }); + assert.equal(gate.started(), true); + try { + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "busy"); + assert.deepEqual(generic.writes, [1200]); + } finally { + gate.release(); + } + assert.equal(await first, "unchanged"); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 800 }), "written"); +}); + +function dpiTableWrites(mouse: ReturnType, start = 0) { + return mouse.sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1); +} + +async function unconfirmedCount(mouse: ReturnType, from: number, to: number) { + mouse.banks[0].count = from; + await controller.testConnect(mouse.device); + controller.applyDpiStageCount(to); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.equal(mouse.banks[0].count, to); + assert.equal(controller.testState().status.dpiStages.length, from); +} + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + const max = pid === 0x32 || pid === 0x33 ? 6 : 5; + for (const background of [false, true]) { + test(`manual count recovery on ${pid.toString(16)} expands before slot edits ${background ? "after background refresh" : "before refresh"}`, async () => { + const mouse = fixture(pid); + await unconfirmedCount(mouse, max, 3); + const original = [...mouse.banks[0].table]; + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + if (background) await controller.testRefreshStatus(); + const start = mouse.sent.length; + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(mouse.banks[0].table, [...original.slice(0, 6), 2400, 4800, ...original.slice(8)]); + assert.deepEqual(dpiTableWrites(mouse, start).map(packet => packet[7]), [4, 4]); + assert.equal((dpiTableWrites(mouse, start)[0][20] << 8) | dpiTableWrites(mouse, start)[0][21], original[6], + "the first write expands storage before the fourth-slot value changes"); + assert.deepEqual(controller.testState().pending, []); + assert.equal(await controller.flashPendingChanges(), "unchanged"); + }); + } + + test(`manual count recovery on ${pid.toString(16)} edits before a recovered reduction`, async () => { + const mouse = fixture(pid); + await unconfirmedCount(mouse, 3, max); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(1, 2400, 4800); + const start = mouse.sent.length; + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(dpiTableWrites(mouse, start).map(packet => packet[7]), [max, 4]); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(mouse.banks[0].table.slice(2, 4), [2400, 4800]); + }); + + test(`manual count recovery on ${pid.toString(16)} preserves nonempty staging while reads keep failing`, async () => { + const mouse = fixture(pid); + await unconfirmedCount(mouse, max, 3); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + const fault = controller.testFailStatusReads(); + const start = mouse.sent.length; + controller.testToastLog.length = 0; + for (let retry = 0; retry < 3; retry++) { + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]); + assert.equal(mouse.banks[0].count, 3); + assert.equal(dpiTableWrites(mouse, start).length, 0); + assert.equal(controller.getSnapshot().settingInProgress, false); + assert.equal(controller.testToastLog.some((toast: { kind: string }) => toast.kind === "success"), false); + } + assert.equal(fault.attempts(), 3); + fault.allow(); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); + }); + + test(`manual count recovery on ${pid.toString(16)} retries a failed slot write after expansion landed`, async () => { + const mouse = fixture(pid); + await unconfirmedCount(mouse, max, 3); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + const send = mouse.device.sendFeatureReport; + let tables = 0, rejectSlot = true; + mouse.device.sendFeatureReport = async (id, report) => { + const original = structuredClone(mouse.banks[0]); + await send(id, report); + if (report[4] === 1 && report[5] === 1 && ++tables === 2 && rejectSlot) { + Object.assign(mouse.banks[0], original); + } + }; + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.equal(mouse.banks[0].count, 4, "expansion landed before the rejected slot edit"); + assert.deepEqual(controller.testState().pending, ["dpi-stage-3"]); + rejectSlot = false; + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); + assert.deepEqual(controller.testState().pending, []); + }); + + test(`manual restaging on ${pid.toString(16)} drops edits and selection outside the final count`, async () => { + const mouse = fixture(pid); + await controller.testConnect(mouse.device); + controller.applyDpiStageCount(max); + controller.applyLunaFuryDpiStageAxes(max - 1, 2400, 4800); + controller.applyActiveDpiStage(max - 1); + controller.applyDpiStageCount(4); + assert.deepEqual(controller.testState().pending, ["dpi-stage-count"]); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + controller.applyActiveDpiStage(3); + // Re-selecting the same count must not discard a valid stage selection. + controller.applyDpiStageCount(4); + assert.equal(controller.testState().pending.includes("dpi-active-stage"), true); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(mouse.banks[0].count, 4); + assert.equal(mouse.banks[0].active, 4); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); + }); + + test(`manual priorities on ${pid.toString(16)} survive a Games stash while hardware count changes`, async () => { + const mouse = fixture(pid); + mouse.banks[0].count = max; + await controller.testConnect(mouse.device); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + assert.equal(await controller.flashGameProfileSnapshot({ + dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200], + }), "written"); + assert.equal(mouse.banks[0].count, 3); + assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); + }); +} + +test("manual preflight retains its queue after an in-flight background read and foreground recovery both fail", async () => { + const mouse = fixture(); + await unconfirmedCount(mouse, 6, 3); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + const fault = controller.testFailStatusReads(); + const gate = controller.testDeferNextStatusRead(); + const start = mouse.sent.length; + const background = controller.testRefreshStatus(); + const apply = controller.flashPendingChanges(); + try { + assert.equal(await controller.flashPendingChanges(), "busy"); + } finally { + gate.release(); + } + await background; + assert.equal(await apply, "failed"); + assert.equal(fault.attempts(), 2, "no post-write read or queued auto-retry when preflight failed"); + assert.equal(dpiTableWrites(mouse, start).length, 0); + assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]); + assert.equal(controller.getSnapshot().settingInProgress, false); + fault.allow(); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); +}); + +test("nonempty manual Apply waits for an in-flight background read before resolving priorities", async () => { + const mouse = fixture(); + await unconfirmedCount(mouse, 6, 3); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + const gate = controller.testDeferNextStatusRead(); + const background = controller.testRefreshStatus(); + const apply = controller.flashPendingChanges(); + assert.equal(gate.started(), true); + assert.equal(controller.getSnapshot().settingInProgress, true); + try { + assert.equal(await controller.flashPendingChanges(), "busy"); + controller.applyLunaFuryDpiStageAxes(3, 3200, 6400); + } finally { + gate.release(); + } + await background; + assert.equal(await apply, "written"); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800], "the write guard protects the staged plan while waiting"); +}); + +test("nonempty manual Apply locks foreground recovery and leaves Games/user staging intact", async () => { + const mouse = fixture(); + await unconfirmedCount(mouse, 6, 3); + controller.applyDpiStageCount(4); + controller.applyLunaFuryDpiStageAxes(3, 2400, 4800); + const gate = controller.testDeferNextStatusRead(); + const apply = controller.flashPendingChanges(); + try { + assert.equal(gate.started(), true); + assert.equal(controller.getSnapshot().settingInProgress, true); + assert.equal(await controller.flashGameProfileSnapshot({ dpiStages: [400, 800, 1600] }), "busy"); + assert.equal(controller.beginGameProfileDraft({}), false); + controller.applyDpiStageCount(2); + assert.deepEqual(controller.testState().pending, ["dpi-stage-count", "dpi-stage-3"]); + } finally { + gate.release(); + await apply; + } + assert.equal(await apply, "written"); + assert.equal(mouse.banks[0].count, 4); + assert.deepEqual(mouse.banks[0].table.slice(6, 8), [2400, 4800]); +}); + +test("generic staged-DPI manual Apply also replans expansion against recovered status", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGenericStages(mouse.device); + controller.applyDpiStageCount(3); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashPendingChanges(), "failed"); + controller.applyDpiStageCount(4); + controller.applyDpiStageValue(3, 2400); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.deepEqual(generic.writes, [{ count: 3 }, { count: 4 }, { stage: 3, dpi: 2400 }]); + assert.deepEqual(generic.stored.dpiStages, [400, 800, 1600, 2400]); +}); + +test("client-only stand-ins still support manual Apply and recovery without a physical HID selection", async () => { + const mouse = fixture(); + const generic = await controller.testConnectGeneric(mouse.device); + controller.testClientOnly(); + controller.applyDpiValue(1200); + assert.equal(await controller.flashPendingChanges(), "written"); + controller.applyDpiValue(800); + controller.testRejectNextStatusRead(); + assert.equal(await controller.flashPendingChanges(), "failed"); + assert.equal(await controller.flashGameProfileSnapshot({ dpi: 1200 }), "written"); + controller.applyDpiValue(400); + assert.equal(await controller.flashPendingChanges(), "written"); + assert.equal(generic.stored.dpi, 400); +}); + +test("controller restores legacy target-stage edits and preserves unrelated pending user changes", async () => { + const { device, banks } = fixture(); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + controller.applyLunaFuryDpiStageAxes(0, 1200, 2400); + const target = { dpi: 2000, dpiY: 4000, activeDpiStage: 2 }; + const prior = controller.testPickSnapshot(before, target); + await controller.flashGameProfileSnapshot(target); + assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 2000, 4000, ...original.slice(6)]); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + await controller.flashGameProfileSnapshot(prior); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2); + assert.deepEqual(controller.testState().pending, ["dpi-stage-0"]); + assert.deepEqual(controller.testState().preview.dpiStages, [1200, 800, 1600]); + assert.equal(controller.testState().error, null); +}); + +test("controller applies and restores old selection snapshots without DPI-table writes", async () => { + const { device, banks, sent } = fixture(); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const target = { dpi: 1600, dpiY: 3200, activeDpiStage: 2 }; + const prior = controller.testPickSnapshot(before, target); + await controller.flashGameProfileSnapshot(target); + assert.equal(banks[0].active, 3); + await controller.flashGameProfileSnapshot(prior); + assert.equal(banks[0].active, 2); assert.deepEqual(banks[0].table, original); + assert.equal(sent.some(packet => packet[4] === 1 && packet[5] === 1), false); + assert.equal(controller.testState().error, null); +}); + +test("controller restores a Y-only edit and a stage-count shrink with the original selected stage", async () => { + const { device, banks } = fixture(); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const target = { dpiStagesY: [1200, 1600, 3200] }; + await controller.flashGameProfileSnapshot(target); + assert.deepEqual(banks[0].table, [400, 1200, ...original.slice(2)]); + await controller.flashGameProfileSnapshot(controller.testPickSnapshot(before, target)); + assert.deepEqual(banks[0].table, original); + const fewer = { dpiStages: [400] }; + const prior = controller.testPickSnapshot(before, fewer); + await controller.flashGameProfileSnapshot(fewer); + assert.equal(banks[0].count, 1); assert.equal(banks[0].active, 1); + await controller.flashGameProfileSnapshot(prior); + assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2); + assert.deepEqual(banks[0].table, original); + assert.equal(controller.testState().error, null); +}); + +test("controller drafts distinguish pure selection from edits to X/Y on another stage", async () => { + const { device, banks } = fixture(); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + assert.equal(controller.beginGameProfileDraft({}), true); + controller.applyActiveDpiStage(2); + assert.deepEqual(controller.gameProfileDraftSnapshot(), { activeDpiStage: 2 }); + controller.applyLunaFuryDpiStageAxes(2, 2000, 4000); + const saved = controller.gameProfileDraftSnapshot(); + assert.deepEqual(saved, { dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000], activeDpiStage: 2 }); + controller.endGameProfileDraft(); + assert.deepEqual(banks[0].table, original, "drafts must not send writes"); + await controller.flashGameProfileSnapshot(saved); + assert.deepEqual(banks[0].table.slice(0, 6), [400, 800, 800, 1600, 2000, 4000]); + await controller.flashGameProfileSnapshot(controller.testPickSnapshot(before, saved)); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2); + assert.equal(controller.testState().error, null); +}); + +test("controller restores the axis mode and dormant Y table after a combined X edit", async () => { + const { device, banks } = fixture(); + banks[0].axes = false; + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const target = { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }; + const prior = controller.testPickSnapshot(before, target); + await controller.flashGameProfileSnapshot(target); + assert.equal(banks[0].axes, true); + assert.deepEqual(banks[0].table, [400, 800, 1200, 1600, ...original.slice(4)]); + await controller.flashGameProfileSnapshot(prior); + assert.equal(banks[0].axes, false); assert.deepEqual(banks[0].table, original); + assert.equal(controller.testState().error, null); +}); + +for (const reverse of [false, true]) { + test(`controller switches ${reverse ? "table to legacy" : "legacy to table"} Games snapshots and restores the originals`, async () => { + const { device, banks } = fixture(); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const legacy = { dpi: 1600, dpiY: 3200, activeDpiStage: 2 }; + const table = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + const first = reverse ? table : legacy, second = reverse ? legacy : table; + const prior = controller.testPickSnapshot(before, first); + await controller.flashGameProfileSnapshot(first); + const afterFirst = controller.testState().status; + const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst); + const toWrite = controller.testMergeSnapshots(prior, second, afterFirst); + await controller.flashGameProfileSnapshot(toWrite); + assert.deepEqual(controller.testState().status.dpiStages, reverse ? before.dpiStages : table.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, reverse ? before.dpiStagesY : table.dpiStagesY); + await controller.flashGameProfileSnapshot(allPrior); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].active, 2); + assert.equal(controller.testState().error, null); + }); +} + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`controller Games expansion on ${pid.toString(16)} restores hidden slots before re-enabling them`, async () => { + const { device, banks } = fixture(pid); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const target = { dpiStages: [400, 800, 1600, 2000], dpiStagesY: [800, 1600, 3200, 4000] }; + const prior = controller.testPickSnapshot(before, target); + await controller.flashGameProfileSnapshot(target); + assert.equal(banks[0].count, 4); assert.equal(banks[0].table[6], 2000); + await controller.flashGameProfileSnapshot(prior); + assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2); + assert.deepEqual(banks[0].table, original, "restore must include disabled slots and TYPE33 trailing bytes"); + controller.applyDpiStageCount(4); + await controller.flashPendingChanges(); + assert.equal(controller.testState().status.dpiStages[3], 3200); + assert.equal(controller.testState().status.dpiStagesY[3], 6400); + assert.deepEqual(banks[0].table, original); + assert.equal(controller.testState().error, null); + }); +} + +for (const pid of [0x32, 0x33, 0x54, 0x84]) { + test(`controller game switch on ${pid.toString(16)} keeps DPI shared by both games`, async () => { + const { device, banks, sent } = fixture(pid); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const first = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + const second = { ...first, activeDpiStage: 2 }; + const prior = controller.testPickSnapshot(before, first); + await controller.flashGameProfileSnapshot(first); + const afterFirst = controller.testState().status; + const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst); + const toWrite = controller.testMergeSnapshots(prior, second, afterFirst); + const start = sent.length; + assert.equal(await controller.flashGameProfileSnapshot(toWrite), "written"); + assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY); + assert.equal(banks[0].active, 3); + assert.equal(sent.slice(start).some(packet => packet[4] === 1 && packet[5] === 1), false, + "a shared final table needs no restore-then-rewrite"); + assert.equal(controller.testState().error, null); + await controller.flashGameProfileSnapshot(allPrior); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2); + assert.equal(controller.testState().error, null); + }); + + test(`controller game switch on ${pid.toString(16)} retains four stages while editing the fourth`, async () => { + const { device, banks, sent } = fixture(pid); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const first = { dpiStages: [400, 800, 1600, 2000], dpiStagesY: [800, 1600, 3200, 4000] }; + const second = { dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 }; + const prior = controller.testPickSnapshot(before, first); + await controller.flashGameProfileSnapshot(first); + const afterFirst = controller.testState().status; + const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst); + const start = sent.length; + await controller.flashGameProfileSnapshot(controller.testMergeSnapshots(prior, second, afterFirst)); + assert.equal(controller.testState().error, null); + assert.equal(banks[0].count, 4); assert.equal(banks[0].active, 4); + assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY); + assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 1600, 3200, 2400, 4800, ...original.slice(8)]); + assert.deepEqual(sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1).map(packet => packet[7]), [4], + "write the final count/table without an intermediate three-stage restore"); + await controller.flashGameProfileSnapshot(allPrior); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2); + assert.equal(controller.testState().error, null); + }); + + test(`controller game switch on ${pid.toString(16)} reduces max stages to four without writing disabled slots`, async () => { + const { device, banks, sent } = fixture(pid); + const before = await controller.testConnect(device); + const original = [...banks[0].table]; + const max = before.ui.dpiStageEditor.maxStages; + const first = { dpiStages: [400, 800, 1600, 2000, 4000, 8000].slice(0, max), + dpiStagesY: [800, 1600, 3200, 4000, 8000, 16000].slice(0, max) }; + const second = { dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 }; + const prior = controller.testPickSnapshot(before, first); + await controller.flashGameProfileSnapshot(first); + assert.equal(banks[0].count, max); + const afterFirst = controller.testState().status; + const allPrior = controller.testMergeSnapshots(controller.testPickSnapshot(afterFirst, second), prior, afterFirst); + const start = sent.length; + await controller.flashGameProfileSnapshot(controller.testMergeSnapshots(prior, second, afterFirst)); + assert.equal(controller.testState().error, null); + assert.equal(banks[0].count, 4); assert.equal(banks[0].active, 4); + assert.deepEqual(controller.testState().status.dpiStages, second.dpiStages); + assert.deepEqual(controller.testState().status.dpiStagesY, second.dpiStagesY); + assert.deepEqual(banks[0].table, [400, 800, 800, 1600, 1600, 3200, 2400, 4800, ...original.slice(8)]); + assert.deepEqual(sent.slice(start).filter(packet => packet[4] === 1 && packet[5] === 1).map(packet => packet[7]), [4]); + await controller.flashGameProfileSnapshot(allPrior); + assert.deepEqual(banks[0].table, original); assert.equal(banks[0].count, 3); assert.equal(banks[0].active, 2); + assert.equal(controller.testState().error, null); + }); +} diff --git a/docs/lunafury-development.md b/docs/lunafury-development.md new file mode 100644 index 00000000..862bface --- /dev/null +++ b/docs/lunafury-development.md @@ -0,0 +1,42 @@ +# LunaFury setup and release + +These controls use `@openmouse/protocol` `0.45.1`, pinned in the app's manifest +and lockfile. The published package includes the APIs from +[protocol PR #205](https://github.com/OpenMouse-Project/mouse-protocol/pull/205). + +## Local setup + +Use Node.js 24 and install dependencies from the npm registry: + +```bash +git clone --branch codex/lunafury-onboard-controls https://github.com/sekiranunn/openmouse.git openmouse +cd openmouse +npm ci +npm run check +npm run size +npm audit --audit-level=high +npm run dev +``` + +No local protocol checkout or tarball override is needed. Keep the protocol +dependency pinned to an exact stable npm version. + +## Validation scope + +Games keeps originals separately for each device connection and onboard bank. +Switching banks lets the same game apply to the newly selected bank without +reusing another bank's backup. After the game exits, the selected bank is restored; +inactive banks retain their in-memory backups until selected again. Restoration +does not switch the active bank. Failed restores retain that bank's backup for retry. + +Run the build, tests, bundle checks and audit after a registry-only clean install. +The integration suite uses synthetic HID and Bridge callbacks to exercise the +published protocol through the controller and UI callbacks. + +The owner verified LUNA33 internal firmware `0.0.26.0` over cable and 8K: +button polling, profiles, DPI, mappings, sleep, receiver lighting and reconnect. +TYPE33, bottom-button behavior and Games apply/restore have no separate hardware result. +See the [protocol notes](https://github.com/sekiranunn/mouse-protocol/blob/codex/lunafury-onboard-controls/docs/lunafury-testing.md). + +For public firmware versions, device matching and manifest maintenance, see +the [firmware notes](./lunafury-firmware.md). diff --git a/docs/lunafury-firmware.md b/docs/lunafury-firmware.md new file mode 100644 index 00000000..a2f9cb07 --- /dev/null +++ b/docs/lunafury-firmware.md @@ -0,0 +1,60 @@ +# LunaFury firmware manifest + +The entries in `scripts/firmware-manifest.extra.json` use the public production +[LunaFury driver](https://mouse.lunafury.games/), checked on 2026-10-14. +The model table in [app.9b5df071.js](https://mouse.lunafury.games/js/app.9b5df071.js) +provides the runtime VID/PIDs and firmware filenames. Its update dialog reads +the mouse version from `DeviceFWFile_00` and the receiver version from +`Receiver4K8KFWFile_00`. + +| Model | Mouse interfaces | Receiver interface | Public version | Driver readout | +| --- | --- | --- | --- | --- | +| LUNA33 | `373e_0032`, `373e_0033` | `373e_0033` | `0.0.25.0` | `25.0` | +| TYPE33 | `373e_0054`, `373e_0084` | `373e_0084` | `0.0.1.47` | `1.47` | + +Each model has separate `Mouse` and `Dongle` entries. The connected interface +identifies the model; the firmware label identifies which component is compared. +Mouse entries include both cable and receiver interfaces because the receiver +also returns the paired mouse's version. Dongle entries match only receivers. +Bootloader PIDs and name-only matches are excluded. + +Protocol 0.45.1's CompX decoder formats the last two version bytes, so +`latestVersion` uses the two-part driver readout. Both public versions above +have zero first and second bytes. Recheck the decoder if those bytes change; +do not discard nonzero version components. A newer internal version, such as +LUNA33 `26.0`, does not trigger an update or downgrade prompt. + +## Published files + +All six URLs returned HTTP 200 with binary content on the verification date. +The official updater uses `fwfiles//`; TYPE33's `FWFolder` +value (`TYPE33_54H`) is not the download directory used by its update dialog. + +| Model | Component | File under `https://mouse.lunafury.games/fwfiles/` | +| --- | --- | --- | +| LUNA33 | Mouse | `LUNA33/LunaFury33_Mouse_840_App_v0.0.25.0_20260202.xvi` | +| LUNA33 | Receiver 00 | `LUNA33/LunaFury33_8K_Dongle_AT405_App_v0.0.25.0_20260202.xvi` | +| LUNA33 | Receiver 01 | `LUNA33/LunaFury33_8K_Dongle_820_App_v0.0.25.0_20260202.xvi` | +| TYPE33 | Mouse | `TYPE33/TYPE33_Mouse_54H20_App_v0.0.1.47_20261009.xvi` | +| TYPE33 | Receiver 00 | `TYPE33/TYPE33_Dongle_305_App_v0.0.1.47_20261009.xvi` | +| TYPE33 | Receiver 01 | `TYPE33/TYPE33_Dongle_54L15_App_v0.0.1.47_20261009.xvi` | + +The updater accepts both receiver files. OpenMouse reads target 0 as `Dongle` +and does not expose separate version verdicts for both receiver components. +The manifest follows the official dialog's Receiver 00 comparison and links +to the official driver, which selects and updates the appropriate files. +It does not offer direct binary downloads or perform flashing. + +## Maintenance + +These entries are maintained manually. For a public release, read the current +production page's linked app script, verify the model table and file URLs, +and update the matching entries in `scripts/firmware-manifest.extra.json`. +Then regenerate with `node scripts/generate-firmware-manifest.mjs` and review +`public/firmware-manifest.json`. Preserve model and component boundaries even +when two files share a version. Test or internal firmware must not become the +public baseline. + +Tests cover actual protocol readouts on all four runtime PIDs, component +isolation, missing versions, unknown and bootloader IDs, and older/current/newer +firmware. They do not flash hardware. diff --git a/package-lock.json b/package-lock.json index 59bf3f9f..f333397b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "preact": "^10.29.8" }, "devDependencies": { + "terser": "^5.51.2", "typescript": "^5.8.3", "vite": "^6.3.5" } @@ -461,6 +462,56 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -469,9 +520,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -591,9 +639,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -608,9 +653,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +667,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -642,9 +681,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -659,9 +695,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -676,9 +709,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -693,9 +723,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -710,9 +737,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +751,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -744,9 +765,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -761,9 +779,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +793,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -795,9 +807,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -895,6 +904,33 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1121,6 +1157,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -1131,6 +1177,36 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index f3a43fc8..7684639e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "preact": "^10.29.8" }, "devDependencies": { + "terser": "^5.51.2", "typescript": "^5.8.3", "vite": "^6.3.5" } diff --git a/public/firmware-manifest.json b/public/firmware-manifest.json index d8257cf1..235e036d 100644 --- a/public/firmware-manifest.json +++ b/public/firmware-manifest.json @@ -1,8 +1,8 @@ { - "_comment": "AUTO-GENERATED — do not edit by hand. Regenerate with: node scripts/generate-firmware-manifest.mjs. Sources: Logitech update CDN depots listed in scripts/firmware-depots.seed.json (plain HTTPS, no auth). Versions/hashes/blockers are re-derived from each depot's dfu.json every run.", + "_comment": "AUTO-GENERATED — do not edit by hand. Regenerate with: node scripts/generate-firmware-manifest.mjs. Sources: Logitech update CDN depots listed in scripts/firmware-depots.seed.json (plain HTTPS, no auth), plus verified vendor entries in scripts/firmware-manifest.extra.json. Versions/hashes/blockers are re-derived from each depot's dfu.json every run.", "manifestVersion": 3, - "updatedAt": "2026-10-10T19:45:56.998Z", - "source": "scripts/generate-firmware-manifest.mjs over updates.ghub.logitechg.com", + "updatedAt": "2026-10-14T00:55:55.535Z", + "source": "Logitech update CDN and verified vendor entries (scripts/firmware-manifest.extra.json)", "devices": [ { "id": "c53a_dfu@40.3.14:046d_c53a", @@ -402,6 +402,56 @@ "downloadUrl": "https://www.logitechg.com/software/ghub", "releaseNotes": "Entity MPM 38.00 == package 38.0.5, confirmed current in G HUB on 2026-10-09." }, + { + "id": "lunafury-luna33-dongle", + "brand": "LunaFury", + "interfaceIds": [ + "373e_0033" + ], + "firmwareName": "Dongle", + "latestVersion": "25.0", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.25.0 (reported as 25.0). Use the LUNA33 8K receiver update in the official LunaFury web driver." + }, + { + "id": "lunafury-luna33-mouse", + "brand": "LunaFury", + "interfaceIds": [ + "373e_0032", + "373e_0033" + ], + "firmwareName": "Mouse", + "latestVersion": "25.0", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.25.0 (reported as 25.0). Connect the LUNA33 by USB and use the official LunaFury web driver to update." + }, + { + "id": "lunafury-type33-dongle", + "brand": "LunaFury", + "interfaceIds": [ + "373e_0084" + ], + "firmwareName": "Dongle", + "latestVersion": "1.47", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.1.47 (reported as 1.47). Use the TYPE33 8K receiver update in the official LunaFury web driver." + }, + { + "id": "lunafury-type33-mouse", + "brand": "LunaFury", + "interfaceIds": [ + "373e_0054", + "373e_0084" + ], + "firmwareName": "Mouse", + "latestVersion": "1.47", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.1.47 (reported as 1.47). Connect the TYPE33 by USB and use the official LunaFury web driver to update." + }, { "id": "pro_mouse_hero_dfu@128.02.07:046d_c08c", "depot": "pro_mouse_hero_dfu", diff --git a/scripts/firmware-manifest.extra.json b/scripts/firmware-manifest.extra.json index 3958ac96..029b2049 100644 --- a/scripts/firmware-manifest.extra.json +++ b/scripts/firmware-manifest.extra.json @@ -1,5 +1,5 @@ { - "_comment": "Hand-maintained entries merged into public/firmware-manifest.json by scripts/generate-firmware-manifest.mjs (extra wins on id clash). Use for devices whose firmware Logitech only ships outside versioned depots, or values confirmed against vendor software. Prefer generated entries whenever a depot exists. Versions here are in device-reported (entity) numbering, e.g. HID++ MPM 38.00, which G HUB displays as package 38.0.5 \u2014 see releaseNotes. Receiver entries are deliberately absent: HID++ 1.0 register 0xF1 was proven (pre/post-flash identical bytes) to report per-MCU bootloader identifiers, not app versions, so no honest version verdict exists yet. See docs/logitech-firmware-handoff.md.", + "_comment": "Hand-maintained entries merged into public/firmware-manifest.json by scripts/generate-firmware-manifest.mjs (use unique ids; generated ids take priority). Use for devices without usable depots, with versions confirmed against vendor software or public firmware directories. Prefer generated entries whenever a depot exists. Versions here are in device-reported (entity) numbering, e.g. HID++ MPM 38.00, which G HUB displays as package 38.0.5 \u2014 see releaseNotes. Logitech receiver entries are deliberately absent: HID++ 1.0 register 0xF1 was proven (pre/post-flash identical bytes) to report per-MCU bootloader identifiers, not app versions, so no honest version verdict exists yet. See docs/logitech-firmware-handoff.md.", "devices": [ { "id": "logitech-pro-x-superlight-2c-mpm", @@ -72,6 +72,46 @@ "firmwareName": "Dongle USB", "latestVersion": "8.0.3", "releaseNotes": "Dongle USB 8.0.3 == xpanel 2.6.2 DONGLE_USB_LATEST. Check-only; see mouse entry." + }, + { + "id": "lunafury-luna33-mouse", + "brand": "LunaFury", + "interfaceIds": ["373e_0032", "373e_0033"], + "firmwareName": "Mouse", + "latestVersion": "25.0", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.25.0 (reported as 25.0). Connect the LUNA33 by USB and use the official LunaFury web driver to update." + }, + { + "id": "lunafury-luna33-dongle", + "brand": "LunaFury", + "interfaceIds": ["373e_0033"], + "firmwareName": "Dongle", + "latestVersion": "25.0", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.25.0 (reported as 25.0). Use the LUNA33 8K receiver update in the official LunaFury web driver." + }, + { + "id": "lunafury-type33-mouse", + "brand": "LunaFury", + "interfaceIds": ["373e_0054", "373e_0084"], + "firmwareName": "Mouse", + "latestVersion": "1.47", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.1.47 (reported as 1.47). Connect the TYPE33 by USB and use the official LunaFury web driver to update." + }, + { + "id": "lunafury-type33-dongle", + "brand": "LunaFury", + "interfaceIds": ["373e_0084"], + "firmwareName": "Dongle", + "latestVersion": "1.47", + "required": false, + "downloadUrl": "https://mouse.lunafury.games/", + "releaseNotes": "Public firmware 0.0.1.47 (reported as 1.47). Use the TYPE33 8K receiver update in the official LunaFury web driver." } ] } diff --git a/scripts/generate-firmware-manifest.mjs b/scripts/generate-firmware-manifest.mjs index aaa73d07..2789b9e2 100644 --- a/scripts/generate-firmware-manifest.mjs +++ b/scripts/generate-firmware-manifest.mjs @@ -1,5 +1,5 @@ -// Builds public/firmware-manifest.json from Logitech's own update CDN — -// no vendor software involved at any step. +// Builds public/firmware-manifest.json from Logitech's update CDN and +// verified vendor entries in firmware-manifest.extra.json. // // Each firmware depot is a small archive published at: // https://updates.ghub.logitechg.com/depots//.depot @@ -30,7 +30,7 @@ // such devices via firmware-manifest.extra.json instead. // // Hand entries: scripts/firmware-manifest.extra.json holds confirmed values -// for devices without usable depots (merged last, extra wins on id clash). +// for devices without usable depots. Use unique ids; generated ids take priority. // // Safety: downloads are verified (archive parses end-to-end; binaries are // cross-checked against dfu.json hashes where present). A depot that fails @@ -170,7 +170,7 @@ for (const entry of seed) { } -// Hand-maintained entries (confirmed values, non-depot devices). Extra wins. +// Hand-maintained entries (confirmed values, non-depot devices). const extraPath = join(here, 'firmware-manifest.extra.json'); try { const extra = JSON.parse(readFileSync(extraPath, 'utf8')); @@ -208,11 +208,12 @@ devices.sort((a, b) => a.id.localeCompare(b.id)); const manifest = { _comment: "AUTO-GENERATED — do not edit by hand. Regenerate with: node scripts/generate-firmware-manifest.mjs. " + - "Sources: Logitech update CDN depots listed in scripts/firmware-depots.seed.json (plain HTTPS, no auth). " + + "Sources: Logitech update CDN depots listed in scripts/firmware-depots.seed.json (plain HTTPS, no auth), " + + "plus verified vendor entries in scripts/firmware-manifest.extra.json. " + "Versions/hashes/blockers are re-derived from each depot's dfu.json every run.", manifestVersion: MANIFEST_VERSION, updatedAt: new Date().toISOString(), - source: "scripts/generate-firmware-manifest.mjs over updates.ghub.logitechg.com", + source: "Logitech update CDN and verified vendor entries (scripts/firmware-manifest.extra.json)", devices, }; diff --git a/src/device/controller.ts b/src/device/controller.ts index bb82e689..78c505b0 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -21,6 +21,9 @@ import { import { closestDpiOption } from "./dpi-presets"; import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts"; import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts"; +import { canEditLunaFuryMapping, pickLunaFuryMappings } from "./lunafury-buttons.ts"; +import { stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts"; +import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu"; import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "./hid-diagnostics"; import { clearPendingChanges, @@ -379,6 +382,9 @@ const deviceStatuses = new Map(); let latestDiagnosticsSnapshot: Record | null = null; let latestDiagnosticStatus: MouseStatus | null = null; let latestDeviceStatus: MouseStatus | null = null; +// Keep the last snapshot visible after a read failure, but do not use it to +// confirm no-op writes or capture Games originals after an unconfirmed write. +let deviceStatusNeedsRefresh = false; let lastDiagnosticCommand: string | null = null; let lastDiagnosticError: string | null = null; let reconnectInFlight = false; @@ -1402,6 +1408,11 @@ function stageChange(change: PendingChange): void { setReadStatus(st("ctl.waitFlash")); return; } + if (latestDeviceStatus?.brand === "LunaFury" && change.key !== "onboard-profile" && isPendingChange("onboard-profile")) { + setReadStatus(st("luna.profileSwitchPending")); + emit(); + return; + } if (gameProfileDraft) { const fields = gameProfileFieldsOf(change); if (!fields) { @@ -1448,7 +1459,7 @@ function queueInstantFlash(): void { let matchesBaseline: { status: MouseStatus; json: string } | null = null; function matchesDeviceStatus(change: PendingChange): boolean { - if (!latestDeviceStatus) return false; + if (!latestDeviceStatus || deviceStatusNeedsRefresh) return false; if (!change.preview) return false; if (matchesBaseline?.status !== latestDeviceStatus) { matchesBaseline = { status: latestDeviceStatus, json: JSON.stringify(latestDeviceStatus) }; @@ -1531,23 +1542,79 @@ export function isGameProfileDraftActive(): boolean { return gameProfileDraft !== null; } +export type FlashResult = "written" | "unchanged" | "busy" | "failed"; + +export interface GameProfileScope { + device: object; + profile: number | null; +} + +/** Backups belong to a physical connection and, for LunaFury, an onboard bank. */ +export function currentGameProfileScope(status = latestDeviceStatus): GameProfileScope | null { + const client = activeSettingsClient(); + if (!client || !status) return null; + const profile = status.brand === "LunaFury" ? status.activeProfile ?? null : null; + if (status.brand === "LunaFury" && (profile === null || profile < 1 || profile > 3)) return null; + return { device: client.device ?? client, profile }; +} + +/** Refresh an unconfirmed post-write cache before Games planning or deduplication. */ +export async function prepareGameProfileStatus(): Promise { + if (gameProfileDraft || settingInProgress || activationInProgress || !latestDeviceStatus) return "busy"; + if (!deviceStatusNeedsRefresh) return latestDeviceStatus; + if (refreshInProgress) return "busy"; + const client = activeSettingsClient(); + if (!client) return "busy"; + const device = activeDevice; + refreshInProgress = true; + try { + const status = await statusAfterWrite(client); + if (client !== activeSettingsClient() || device !== activeDevice) return "busy"; + applyStatus(status); + return latestDeviceStatus!; + } catch (error) { + recordDiagnosticError(error, st("ctl.unableRefresh")); + const failure = diagnosticErrorMessage(error, st("ctl.unableRefresh")); + setReadStatus(failure); + pushToast("error", st("ctl.flashFailed"), failure); + return "failed"; + } finally { + refreshInProgress = false; + } +} + /** * Writes `snapshot` to the mouse now (a game starting or stopping). Only the * snapshot is flashed: the user's own unflashed edits are set aside for the * write and staged again afterwards. "busy" means nothing was attempted (a * draft is open or another write is running) and the caller should retry; - * "unchanged" means the mouse already had every value. + * "unchanged" means a confirmed status already had every value. "failed" means the + * attempt did not complete; earlier batches may already have landed. */ export async function flashGameProfileSnapshot( snapshot: GameProfileSnapshot, -): Promise<"written" | "unchanged" | "busy"> { + scope = currentGameProfileScope(), +): Promise { + const status = await prepareGameProfileStatus(); + if (typeof status === "string") return status; + const currentScope = currentGameProfileScope(status); + if (!scope || scope.device !== currentScope?.device || scope.profile !== currentScope.profile) return "busy"; + // Another writer or a draft may start while the refresh yields. if (gameProfileDraft || settingInProgress || !latestDeviceStatus) return "busy"; const stash = stashPendingChanges(); - applyGameProfileSnapshot(snapshot); - const staged = hasPendingChanges(); - if (staged) await flashPendingChanges(); - restorePendingChanges(stash); - return staged ? "written" : "unchanged"; + try { + applyGameProfileSnapshot(snapshot); + return hasPendingChanges() ? await flashPendingChanges() : "unchanged"; + } catch (error) { + recordDiagnosticError(error, st("ctl.unableFlash")); + const failure = diagnosticErrorMessage(error, st("ctl.unableFlash")); + setReadStatus(failure); + pushToast("error", st("ctl.flashFailed"), failure); + return "failed"; + } finally { + // Failed game writes must not replace the user's unrelated staged edits. + restorePendingChanges(stash); + } } /** Writes that bypass staging must not reach the mouse while a game profile is being edited. */ @@ -1795,18 +1862,29 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { // the snapshot: a plain DPI change on a stage-aware mouse also rewrites the // active entry of dpiStages, so both fields can be present. const stageEditor = status.ui?.dpiStageEditor; - if (stageEditor && status.dpiStages?.length) { + // LunaFury also accepts older snapshots carrying a selected stage's scalars. + const lunaDpi = stageLunaFuryGameDpi(status, s, { + storage: stageLunaFuryDpiStorage, + count: applyDpiStageCount, axes: applyLunaFuryDpiStageAxes, active: applyActiveDpiStage, + }); + if (!lunaDpi && stageEditor && status.dpiStages?.length) { if (s.dpiStages?.length) { if (stageEditor.countEditable === true && s.dpiStages.length !== status.dpiStages.length) { applyDpiStageCount(s.dpiStages.length); } - s.dpiStages.forEach((value, stage) => applyDpiStageValue(stage, value)); + s.dpiStages.forEach((value, stage) => { + if (status.brand === "LunaFury" && s.dpiStagesY?.[stage] != null) applyLunaFuryDpiStageAxes(stage, value, s.dpiStagesY[stage]); + else applyDpiStageValue(stage, value); + }); + } else if (status.brand === "LunaFury" && s.dpiStagesY?.length) { + s.dpiStagesY.forEach((value, stage) => applyLunaFuryDpiStageAxes(stage, status.dpiStages![stage], value)); } else if (s.dpi != null) { - applyDpiValue(s.dpi); + if (status.brand === "LunaFury" && s.dpiY != null) applyLunaFuryDpiStageAxes(status.activeDpiStage ?? 0, s.dpi, s.dpiY); + else applyDpiValue(s.dpi); } s.dpiStageColors?.forEach((color, stage) => applyDpiStageColor(stage, color)); if (s.activeDpiStage != null) applyActiveDpiStage(s.activeDpiStage); - } else if (s.dpi != null) { + } else if (!lunaDpi && s.dpi != null) { if (s.dpiY != null && s.dpiY !== s.dpi && status.supportsSeparateDpiAxes) applyLogitechAxisDpi(s.dpi, s.dpiY); else applyDpiValue(s.dpi); } @@ -1844,6 +1922,8 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { lightning: applyLunaFuryLightningMode, button: applyLunaFuryButtonDebounce, wheel: applyLunaFuryWheelGuard, + buttonRate: applyLunaFuryButtonPollingRate, + separateAxes: applyLunaFurySeparateDpiAxes, }); if (typeof s.wheelAcceleration === "boolean") applyProSetting("wheelAcceleration", s.wheelAcceleration); @@ -1886,7 +1966,8 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { if (typeof s.eggForceMaxFps === "boolean") applyEggForceMaxFps(s.eggForceMaxFps); if (typeof s.eggLedLiftOffDisabled === "boolean") applyEggLedLiftOff(s.eggLedLiftOffDisabled); - for (const [button, action] of Object.entries(s.buttonMappings ?? {})) applyDeviceButtonMapping(button, action); + const mappings = status.brand === "LunaFury" ? pickLunaFuryMappings(status, s.buttonMappings) : s.buttonMappings ?? {}; + for (const [button, action] of Object.entries(mappings)) applyDeviceButtonMapping(button, action); for (const [control, mapping] of Object.entries(s.razerButtonMappings ?? {})) { applyRazerButtonMapping(control as RazerButtonControl, mapping as RazerButtonMapping); } @@ -1918,6 +1999,24 @@ export function applyGameProfileSnapshot(snapshot: GameProfileSnapshot): void { }); } +/** Final Games DPI storage, composed from its backup and target; never saved/imported. */ +function stageLunaFuryDpiStorage(storage: LunaFuryDpiStorage): void { + const saved = structuredClone(storage); + stageChange({ + key: "lunafury-dpi-storage", priority: -20, + label: "Restore stored DPI slots", command: "Restore complete onboard DPI storage", + progress: "Restoring DPI storage…", + preview: (status) => { + status.dpiStages = saved.stages.slice(0, saved.count).map(stage => stage.x); + status.dpiStagesY = saved.stages.slice(0, saved.count).map(stage => stage.y); + status.activeDpiStage = Math.min(status.activeDpiStage ?? 0, saved.count - 1); + status.dpi = status.dpiStages[status.activeDpiStage]; status.dpiY = status.dpiStagesY[status.activeDpiStage]; + status.lunafury = { ...status.lunafury, dpiStorage: structuredClone(saved) }; + }, + apply: () => callClientMethod("setLunaFuryDpiStorage", "stored DPI slots", saved), + }); +} + export async function requestHostSwitch(slot: number): Promise { if (blockedByGameProfileDraft()) return; const client = logitechClient(); @@ -2178,19 +2277,24 @@ async function flashPause(milliseconds = FLASH_STEP_DELAY_MS): Promise { await wait(milliseconds); } -export async function flashPendingChanges(): Promise { - if (gameProfileDraft) return; - const batches = pendingChangeBatches(); - if (batches.length === 0) return; +export async function flashPendingChanges(): Promise { + if (gameProfileDraft || activationInProgress) return "busy"; if (settingInProgress) { flashQueued = true; setReadStatus(st("ctl.waitFlash")); - return; + return "busy"; } + if (!hasPendingChanges() && !deviceStatusNeedsRefresh) return "unchanged"; + const client = activeSettingsClient(); + if (!client || !latestDeviceStatus) return "busy"; + const device = activeDevice; + // Lock staging before waiting/reading and bind this attempt to its client. + // Batch ordering must use the recovered status, not a pre-await plan. settingInProgress = true; - pendingBusy = true; + pendingBusy = hasPendingChanges(); emit(); let written = 0; + let writeAttempted = false; let failure: string | null = null; if (refreshInProgress) { pendingStatusText = st("ctl.waitRefresh"); @@ -2205,6 +2309,17 @@ export async function flashPendingChanges(): Promise { } } try { + if (!failure) { + if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone")); + if (deviceStatusNeedsRefresh) { + const status = await statusAfterWrite(client); + if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone")); + applyStatus(status); + } + } + // Resolve on every Apply, including when a background read already + // recovered trust but the user's pending operations predate that read. + const batches = failure ? [] : pendingChangeBatches(latestDeviceStatus!); for (const batch of batches) { if (failure) break; const writer = batch[batch.length - 1]; @@ -2213,7 +2328,9 @@ export async function flashPendingChanges(): Promise { readStatus = writer.progress; emit(); await flashPause(); + if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone")); for (const change of batch) recordDiagnosticCommand(change.command); + writeAttempted = true; await writer.apply(); for (const change of batch) dropPendingChange(change.key); written += batch.length; @@ -2222,28 +2339,47 @@ export async function flashPendingChanges(): Promise { recordDiagnosticError(error, st("ctl.unableFlash")); failure = error instanceof Error ? error.message : "Unable to flash the staged changes."; } - await flashPause(FLASH_SETTLE_MS); - const client = activeSettingsClient(); - const status = client ? await statusAfterWrite(client).catch(() => null) : null; - if (status) applyStatus(status); - endDeviceWrite(); - pendingBusy = false; + try { + // A failed preflight must leave staging intact and perform no speculative + // writes or second status read. Actual writes still require final readback. + if (writeAttempted) { + await flashPause(FLASH_SETTLE_MS); + if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone")); + const status = await statusAfterWrite(client); + if (client !== activeSettingsClient() || device !== activeDevice) throw new Error(st("ctl.gone")); + applyStatus(status); + } + } catch (error) { + deviceStatusNeedsRefresh = true; + lastRenderedStatusKey = null; + matchesBaseline = null; + if (!failure) { + recordDiagnosticError(error, st("ctl.unableFlash")); + failure = diagnosticErrorMessage(error, st("ctl.unableFlash")); + } + } finally { + pendingBusy = false; + endDeviceWrite(); + } if (flashQueued) { flashQueued = false; - if (hasPendingChanges()) return flashPendingChanges(); + // A queued retry must not turn this failed attempt into reported success. + if (!failure && hasPendingChanges()) return flashPendingChanges(); } if (failure) { pendingStatusText = failure; setReadStatus(failure); pushToast("error", st("ctl.flashFailed"), failure); - return; + return "failed"; } pendingStatusText = null; + if (written === 0) return "unchanged"; const flashed = written === 1 ? st("ctl.flashedOne") : st("ctl.flashedMany", { n: written }); setReadStatus(flashed); pushToast("success", flashed); + return "written"; } function endDeviceWrite(): void { @@ -2626,8 +2762,11 @@ function setPageTitle(prefix?: string): void { document.title = prefix ? `${prefix} - ${DEFAULT_TITLE}` : DEFAULT_TITLE; } -function applyStatus(deviceStatus: MouseStatus, statusKey?: string): void { - batch(() => applyStatusInner(deviceStatus, statusKey)); +function applyStatus(deviceStatus: MouseStatus, statusKey?: string, confirmed = true): void { + batch(() => { + if (confirmed) deviceStatusNeedsRefresh = false; + applyStatusInner(deviceStatus, statusKey); + }); } function applyStatusInner(deviceStatus: MouseStatus, statusKey?: string): void { @@ -3350,6 +3489,7 @@ async function activateClientNow(client: SupportedClient): Promise { if (active && active !== client) await active.close().catch(() => undefined); clearPendingChanges(); latestDeviceStatus = null; + deviceStatusNeedsRefresh = false; clearActiveClients(); clearActiveKeyboard(); if (switchingDevice) { @@ -3560,6 +3700,7 @@ function showDisconnectedState(): void { capabilities = null; clearPendingChanges(); latestDeviceStatus = null; + deviceStatusNeedsRefresh = false; buttons = null; clearNapeKeymaps(); setPageTitle(); @@ -3920,6 +4061,13 @@ export function cancelCustomDpi(dpi?: number): void { export function applyDpiValue(dpi: number): boolean { if (!hasActiveClient() || !dpiOptions.includes(dpi)) return false; + const staged = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; + if (staged?.brand === "LunaFury" && staged.dpiStagesY && staged.activeDpiStage != null) { + const stage = staged.activeDpiStage; + const y = staged.lunafury?.separateDpiAxes !== false ? staged.dpiStagesY[stage] ?? dpi : dpi; + applyLunaFuryDpiStageAxes(stage, dpi, y); + return true; + } stageChange({ key: "dpi", label: `DPI ${dpi.toLocaleString()}`, @@ -3933,6 +4081,7 @@ export function applyDpiValue(dpi: number): boolean { status.dpiStages = status.dpiStages.map((value, index) => ( index === status.activeDpiStage ? dpi : value )); + if (status.dpiStagesY) status.dpiStagesY = status.dpiStagesY.map((value, index) => index === status.activeDpiStage ? dpi : value); } }, apply: async () => { @@ -3943,22 +4092,28 @@ export function applyDpiValue(dpi: number): boolean { } export function applyDpiStageCount(count: number): void { - if (!hasActiveClient()) return; + if (!hasActiveClient() || settingInProgress) return; const editor = latestDeviceStatus?.ui?.dpiStageEditor; if (!editor || editor.countEditable !== true) return; if (!Number.isInteger(count) || count < 1 || count > editor.maxStages) return; if (!("setDpiStageCount" in requireSettingsClient())) return; - const currentCount = latestDeviceStatus?.dpiStages?.length ?? count; - if (count < currentCount) { - for (const change of pendingChanges()) { - const match = /^dpi-stage(?:-color)?-(\d+)$/.exec(change.key); - if (match && Number(match[1]) >= count) dropPendingChange(change.key); + // The user's final count bounds all staged slots, even when both the + // previous and new requested counts exceed the current hardware count. + for (const change of pendingChanges()) { + const match = /^dpi-stage(?:-color)?-(\d+)$/.exec(change.key); + if (match && Number(match[1]) >= count) dropPendingChange(change.key); + if (change.key === "dpi-active-stage" && latestDeviceStatus) { + const target = structuredClone(latestDeviceStatus); + change.preview?.(target); + if ((target.activeDpiStage ?? 0) >= count) dropPendingChange(change.key); } - dropPendingChange("dpi-active-stage"); } stageChange({ key: "dpi-stage-count", - priority: count > currentCount ? -1 : count < currentCount ? 1 : 0, + resolvePriority: (status) => { + const currentCount = status.dpiStages?.length ?? count; + return count > currentCount ? -1 : count < currentCount ? 1 : 0; + }, label: `${count} DPI stage${count === 1 ? "" : "s"}`, command: `Set DPI stage count to ${count}`, progress: `Setting ${count} DPI stages…`, @@ -3967,6 +4122,11 @@ export function applyDpiStageCount(count: number): void { status.dpiStages = count <= current.length ? current.slice(0, count) : [...current, ...Array.from({ length: count - current.length }, () => current.at(-1) ?? status.dpi)]; + if (status.dpiStagesY) { + const ys = status.dpiStagesY.slice(); + status.dpiStagesY = count <= ys.length ? ys.slice(0, count) + : [...ys, ...Array.from({ length: count - ys.length }, () => ys.at(-1) ?? status.dpiY ?? status.dpi)]; + } if (status.dpiStageColors) { status.dpiStageColors = count <= status.dpiStageColors.length ? status.dpiStageColors.slice(0, count) @@ -3978,6 +4138,7 @@ export function applyDpiStageCount(count: number): void { if ((status.activeDpiStage ?? 0) >= count) { status.activeDpiStage = count - 1; status.dpi = status.dpiStages[count - 1] ?? status.dpi; + if (status.dpiStagesY) status.dpiY = status.dpiStagesY[count - 1]; } }, apply: async () => { @@ -4019,6 +4180,7 @@ export function applyActiveDpiStage(stage: number): void { preview: (status) => { status.activeDpiStage = stage; status.dpi = status.dpiStages?.[stage] ?? status.dpi; + if (status.dpiStagesY) status.dpiY = status.dpiStagesY[stage]; }, apply: async () => { await requireClientMethod("setActiveDpiStage", "DPI stage").setActiveDpiStage(stage); @@ -4028,13 +4190,14 @@ export function applyActiveDpiStage(stage: number): void { export function applyDpiStageValue(stage: number, rawDpi: number): void { if (!hasActiveClient()) return; + const stagedStatus = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; // A client that can address the axes separately must not take the numeric // route: that one mirrors X into Y and would discard the stage's Y. - if (dpiStageAxesWriterName() !== null) { + // LunaFury uses its model-specific path below to honor linked-axis mode. + if (stagedStatus?.brand !== "LunaFury" && dpiStageAxesWriterName() !== null) { applyDpiStageAxes(stage, { x: rawDpi }); return; } - const stagedStatus = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; const stages = stagedStatus?.dpiStages ?? latestDeviceStatus?.dpiStages; if (!stages || !Number.isInteger(stage) || stage < 0 || stage >= stages.length) return; const dpi = closestDpiOption(dpiOptions, rawDpi) ?? rawDpi; @@ -4043,6 +4206,11 @@ export function applyDpiStageValue(stage: number, rawDpi: number): void { emit(); return; } + if (stagedStatus?.brand === "LunaFury" && stagedStatus.dpiStagesY) { + const y = stagedStatus.lunafury?.separateDpiAxes !== false ? stagedStatus.dpiStagesY[stage] ?? dpi : dpi; + applyLunaFuryDpiStageAxes(stage, dpi, y); + return; + } stageChange({ key: `dpi-stage-${stage}`, label: `Stage ${stage + 1} · ${dpi.toLocaleString()} DPI`, @@ -5014,7 +5182,7 @@ export async function switchNapeLayer(layer: number): Promise { const status = latestDeviceStatus; if (!status) return; editedNapeLayer = layer; - applyStatus({ ...status, napeLayer: layer }); + applyStatus({ ...status, napeLayer: layer }, undefined, false); installPreviewNapeKeymap(layer); setReadStatus(st("ctl.previewSwitched", { label: layerLabel(interfacePreferences.locale, keychronLayerLabel(layer)) })); return; @@ -6102,6 +6270,8 @@ export function applyPowerMode(mode: string): void { * above, which reassigns a Logitech control by id. */ export function applyDeviceButtonMapping(button: string, action: string): void { + if (latestDeviceStatus?.brand === "LunaFury" + && !canEditLunaFuryMapping(withPendingChanges(latestDeviceStatus), button, action)) return; stageChange({ key: `button-${button}`, label: `${button}: ${action}`, @@ -6171,6 +6341,14 @@ export function resetKsnakeButtonMappings(): void { * rather than previewing a value that is about to be replaced wholesale. */ export function applyProfileSelection(profile: number): void { + if (latestDeviceStatus?.brand === "LunaFury") { + if (!Number.isInteger(profile) || profile < 1 || profile > (latestDeviceStatus.profileCount ?? 0)) return; + if (blockedByGameProfileDraft()) return; + if (hasPendingChanges()) { + if (!window.confirm(st("luna.discardBeforeSwitch"))) return; + clearPendingChanges(); + } + } stageChange({ key: "onboard-profile", label: st("adv.profileOpt", { n: profile }), @@ -6553,6 +6731,60 @@ export function applyIncottFireKey(times: number, intervalMs: number): void { }); } +export function applyLunaFuryButtonPollingRate(hertz: 1000 | 2000 | 4000 | 8000): void { + const settings = latestDeviceStatus?.lunafury; + if (settings?.buttonPollingRateHz == null || !settings.supportedButtonPollingRates?.includes(hertz)) return; + stageChange({ + key: "lunafury-button-rate", + label: `${st("luna.buttonPollingRate")} · ${hertz} Hz`, + command: `Set button polling rate to ${hertz} Hz`, + progress: `Setting button polling rate to ${hertz} Hz…`, + preview: (status) => { if (status.lunafury) status.lunafury.buttonPollingRateHz = hertz; }, + apply: () => callClientMethod("setLunaFuryButtonPollingRate", "button polling rate", hertz), + }); +} + +export function applyLunaFurySeparateDpiAxes(enabled: boolean): void { + if (latestDeviceStatus?.lunafury?.separateDpiAxes == null) return; + stageChange({ + key: "lunafury-dpi-axes", + priority: enabled ? -5 : 5, + label: `${st("luna.separateAxes")} · ${st(enabled ? "common.on" : "common.off")}`, + command: `Set separate DPI axes to ${enabled}`, + progress: "Setting DPI axis mode…", + preview: (status) => { if (status.lunafury) status.lunafury.separateDpiAxes = enabled; }, + apply: () => callClientMethod("setLunaFurySeparateDpiAxes", "DPI axis mode", enabled), + }); +} + +export function applyLunaFuryDpiStageAxes(stage: number, x: number, y: number): void { + const status = latestDeviceStatus ? withPendingChanges(latestDeviceStatus) : null; + if (status?.brand !== "LunaFury" || !status.dpiStagesY || !status.dpiStages || + !Number.isInteger(stage) || stage < 0 || stage >= status.dpiStages.length || + !dpiOptions.includes(x) || !dpiOptions.includes(y)) return; + stageChange({ + key: `dpi-stage-${stage}`, + label: `Stage ${stage + 1} · X ${x} / Y ${y} DPI`, + command: `Set DPI stage ${stage + 1} to X ${x} / Y ${y}`, + progress: `Setting DPI stage ${stage + 1}…`, + preview: (target) => { + const xs = target.dpiStages?.slice() ?? []; + const ys = target.dpiStagesY?.slice() ?? []; + while (xs.length <= stage) xs.push(xs.at(-1) ?? target.dpi); + while (ys.length <= stage) ys.push(ys.at(-1) ?? target.dpiY ?? target.dpi); + xs[stage] = x; ys[stage] = y; + target.dpiStages = xs; target.dpiStagesY = ys; + if (target.activeDpiStage === stage) { target.dpi = x; target.dpiY = y; } + }, + apply: async () => { + const client = requireClientMethod("setLunaFuryDpiStageAxes", "DPI stage axes") as unknown as { + setLunaFuryDpiStageAxes(stage: number, x: number, y: number): Promise; + }; + await client.setLunaFuryDpiStageAxes(stage, x, y); + }, + }); +} + export function applyLunaFuryLightningMode(mode: 0 | 1 | 2): void { if (latestDeviceStatus?.lunafury?.lightningMode == null) return; stageChange({ @@ -6584,6 +6816,34 @@ export function applyLunaFuryButtonDebounce(button: "left" | "right" | "middle", }); } +export function applyLunaFuryBottomButtonMode(mode: 1 | 2): void { + if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury" + || latestDeviceStatus.lunafury?.bottomButtonMode == null || (mode !== 1 && mode !== 2)) return; + const label = st(mode === 1 ? "luna.bottomPower" : "luna.bottomPowerDpi"); + stageChange({ + key: "lunafury-bottom-button", + label: `${st("luna.bottomButton")} · ${label}`, + command: `Set bottom-button mode to ${mode}`, + progress: "Setting bottom-button mode…", + preview: (status) => { if (status.lunafury) status.lunafury.bottomButtonMode = mode; }, + apply: () => callClientMethod("setLunaFuryBottomButtonMode", "bottom-button behavior", mode), + }); +} + +export function applyLunaFuryReceiverLightMode(mode: 0 | 2 | 6 | 11): void { + if (gameProfileDraft || latestDeviceStatus?.brand !== "LunaFury" + || latestDeviceStatus.lunafury?.receiverLightMode == null || ![0, 2, 6, 11].includes(mode)) return; + const label = st(mode === 2 ? "luna.lightMixed" : mode === 6 ? "luna.lightBattery" : mode === 11 ? "luna.lightDpi" : "common.off"); + stageChange({ + key: "lunafury-receiver-light", + label: `${st("luna.receiverLight")} · ${label}`, + command: `Set receiver light mode to ${mode}`, + progress: "Setting receiver light mode…", + preview: (status) => { if (status.lunafury) status.lunafury.receiverLightMode = mode; }, + apply: () => callClientMethod("setLunaFuryReceiverLightMode", "receiver lighting", mode), + }); +} + export function applyLunaFuryWheelGuard(guard: { enabled: boolean; windowMs: number }): void { if (!latestDeviceStatus?.lunafury?.wheelGuard) return; stageChange({ @@ -6623,7 +6883,7 @@ async function refreshStatus(): Promise { const currentClient = activeSettingsClient(); if (client !== currentClient || client.device !== activeDevice) return; const key = JSON.stringify(status); - if (key !== lastRenderedStatusKey) applyStatus(status, key); + if (deviceStatusNeedsRefresh || key !== lastRenderedStatusKey) applyStatus(status, key); } catch (error) { lastRenderedStatusKey = null; deviceStatusText = st("ctl.waitingRefresh"); @@ -7011,6 +7271,7 @@ async function showFixturePreview(name: PreviewMode): Promise { capabilities = readCapabilities(); if (name === "finalmouse" || name === "finalmouse-slx") active = finalmousePreviewClient(fixture.status); else if (fixture.status.magneticButtons) active = magneticPreviewClient(fixture.status); + if (name === "lunafury") capabilities = { ...capabilities, sleepOptions: [0, ...Array.from({ length: 30 }, (_, i) => (i + 1) * 60)] }; applyStatus(fixture.status); if (name === "nape-pro") { const layer = fixture.status.napeLayer ?? 1; diff --git a/src/device/firmware-updates.test.ts b/src/device/firmware-updates.test.ts index afdd6d58..54d6aeb7 100644 --- a/src/device/firmware-updates.test.ts +++ b/src/device/firmware-updates.test.ts @@ -49,6 +49,9 @@ test("extractVersionToken finds the dotted token inside driver strings", () => { assert.equal(extractVersionToken("Mouse v27.1.2"), "27.1.2"); assert.equal(extractVersionToken("MPM 39.00.B0004"), "39.00"); assert.equal(extractVersionToken(" "), null); + assert.equal(extractVersionToken("Mouse firmware unavailable"), null); + assert.equal(extractVersionToken("Dongle firmware unavailable"), null); + assert.equal(extractVersionToken("42"), "42"); }); test("compareVersions compares numerically, not lexicographically", () => { diff --git a/src/device/firmware-updates.ts b/src/device/firmware-updates.ts index 4af51243..63abe0c7 100644 --- a/src/device/firmware-updates.ts +++ b/src/device/firmware-updates.ts @@ -101,7 +101,7 @@ export function extractVersionToken(raw: string): string | null { const match = raw.match(/\d+(?:\.\d+)+/); if (match) return match[0]; const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : null; + return /^\d+$/.test(trimmed) ? trimmed : null; } function splitNumeric(version: string): number[] { diff --git a/src/device/game-profile-snapshot.test.ts b/src/device/game-profile-snapshot.test.ts index 4844d5fe..a0cd9f32 100644 --- a/src/device/game-profile-snapshot.test.ts +++ b/src/device/game-profile-snapshot.test.ts @@ -32,6 +32,18 @@ test("nested values are compared by content", () => { assert.deepEqual(snapshotDiff(before, status({ dpiStages: [400, 1600] })), { dpiStages: [400, 1600] }); }); +test("LunaFury game profiles capture and restore Y stages independently", () => { + const before = status({ brand: "LunaFury", dpiStages: [400, 800], dpiStagesY: [800, 1600], + lunafury: { separateDpiAxes: false, buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000, 2000, 4000, 8000] } }); + const after = { ...before, dpiStagesY: [1200, 1600], lunafury: { ...before.lunafury, separateDpiAxes: true } }; + const saved = snapshotDiff(before, after); + assert.deepEqual(saved, { dpiStagesY: [1200, 1600], lunafury: { separateDpiAxes: true } }); + assert.deepEqual(pickSnapshot(before, saved), { dpiStagesY: [800, 1600], lunafury: { separateDpiAxes: false } }); + assert.deepEqual(editableProfileFields(before, after), ["dpiStagesY", "lunafury.separateDpiAxes"]); + after.dpiStagesY[0] = 2400; + assert.deepEqual(saved.dpiStagesY, [1200, 1600]); +}); + test("picking the prior values covers exactly the fields the profile sets", () => { const live = status({ dpi: 800, gamingSurfaceMode: "Auto" }); assert.deepEqual(pickSnapshot(live, { dpi: 1600, gamingSurfaceMode: "On" }), { dpi: 800, gamingSurfaceMode: "Auto" }); @@ -114,3 +126,20 @@ 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("LunaFury Games saves and restores only touched writable buttons", () => { + const before = status({ brand: "LunaFury", buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", + Back: "Keyboard:16:6", Forward: "Macro:16:1" }, fixedButtons: ["Left", "Forward"] }); + const after = { ...before, buttonMappings: { ...before.buttonMappings, Right: "DPI Cycle" } }; + const saved = snapshotDiff(before, after); + assert.deepEqual(saved, { buttonMappings: { Right: "DPI Cycle" } }); + assert.deepEqual(pickSnapshot(before, saved), { buttonMappings: { Right: "Right Click" } }); + assert.deepEqual(editableProfileFields(before, after), ["buttonMappings.Right"]); + assert.deepEqual(matchingProfileFields(after, saved), ["buttonMappings.Right"]); + assert.deepEqual(draftProfileSnapshot(before, before, ["buttonMappings.Back"]), + { buttonMappings: { Back: "Keyboard:16:6" } }); + assert.deepEqual(draftProfileSnapshot(before, before, ["buttonMappings.Left", "buttonMappings.Forward"]), {}); + assert.deepEqual(mergeSnapshots({ buttonMappings: { Back: "Back" } }, saved), + { buttonMappings: { Back: "Back", Right: "DPI Cycle" } }); + assert.deepEqual(snapshotDiff(before, { ...before, buttonMappings: { ...before.buttonMappings, + Left: "Disabled", Forward: "DPI Up", Middle: "Keyboard:0:0" } }), {}); +}); diff --git a/src/device/game-profile-snapshot.ts b/src/device/game-profile-snapshot.ts index 189a9d39..555cfd1e 100644 --- a/src/device/game-profile-snapshot.ts +++ b/src/device/game-profile-snapshot.ts @@ -1,5 +1,8 @@ import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu"; import { diffLunaFurySettings, isLunaFuryProfileField, pickLunaFurySettings, sanitizeLunaFurySettings } from "./lunafury-profile.ts"; +import { pickLunaFuryMappings } from "./lunafury-buttons.ts"; +import { canonicalLunaFuryDpiSnapshot, preserveLunaFuryDpiTables, withoutDerivedLunaFuryDpi } from "./lunafury-game-dpi.ts"; /** * The device settings a per-game profile can carry. A game profile is stored @@ -17,6 +20,7 @@ export const GAME_PROFILE_FIELDS = [ "dpi", "dpiY", "dpiStages", + "dpiStagesY", "activeDpiStage", "dpiStageColors", "pollingRateHz", @@ -89,7 +93,10 @@ export const GAME_PROFILE_FIELDS = [ ] as const satisfies readonly (keyof MouseStatus)[]; export type GameProfileField = (typeof GAME_PROFILE_FIELDS)[number]; -export type GameProfileSnapshot = Partial>; +export type GameProfileSnapshot = Partial> & { + /** In-memory restoration only; sanitized imports and saved drafts exclude it. */ + lunaFuryDpiStorage?: LunaFuryDpiStorage; +}; const FIELD_SET: ReadonlySet = new Set(GAME_PROFILE_FIELDS); @@ -113,15 +120,25 @@ export function snapshotDiff(before: MouseStatus, after: MouseStatus): GameProfi if (field === "lunafury") { const settings = diffLunaFurySettings(before.lunafury, after.lunafury); if (Object.keys(settings).length) diff.lunafury = settings; + } else if (field === "buttonMappings" && before.brand === "LunaFury") { + const mappings = pickLunaFuryMappings(before, after.buttonMappings); + for (const button of Object.keys(mappings)) if (before.buttonMappings?.[button] === mappings[button]) delete mappings[button]; + if (Object.keys(mappings).length) diff.buttonMappings = mappings; } else if (!same(before[field], after[field])) diff[field] = structuredClone(after[field]); } - return diff as GameProfileSnapshot; + return withoutDerivedLunaFuryDpi(after, diff as GameProfileSnapshot); } +/** Prior values for the profile's writes; LunaFury scalar/count edits also back up their table storage. */ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot { const picked: Record = {}; for (const field of Object.keys(snapshot)) { if (!isGameProfileField(field)) continue; + if (field === "buttonMappings" && status.brand === "LunaFury") { + const mappings = pickLunaFuryMappings(status, snapshot.buttonMappings, true); + if (Object.keys(mappings).length) picked.buttonMappings = mappings; + continue; + } if (field === "lunafury") { const settings = pickLunaFurySettings(status.lunafury, snapshot.lunafury); if (Object.keys(settings).length) picked.lunafury = settings; @@ -130,13 +147,18 @@ export function pickSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot) const value = status[field]; if (value !== undefined) picked[field] = structuredClone(value); } + preserveLunaFuryDpiTables(status, snapshot, picked as GameProfileSnapshot); return picked as GameProfileSnapshot; } /** Merge per-control namespaces when games switch, not entire device settings. */ -export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot): GameProfileSnapshot { +export function mergeSnapshots(base: GameProfileSnapshot, override: GameProfileSnapshot, status?: MouseStatus): GameProfileSnapshot { + // Normalize the lower-priority legacy values before merging a newer table. + // Override scalars still apply after the base table when switching the other way. + if (status) base = canonicalLunaFuryDpiSnapshot(status, base); const merged = { ...base, ...override }; if (base.lunafury || override.lunafury) merged.lunafury = { ...base.lunafury, ...override.lunafury }; + if (base.buttonMappings || override.buttonMappings) merged.buttonMappings = { ...base.buttonMappings, ...override.buttonMappings }; return merged; } @@ -161,12 +183,21 @@ export function sanitizeSnapshot(raw: unknown): GameProfileSnapshot { export function editableProfileFields(before: MouseStatus, after: MouseStatus): string[] | null { const fields = changedFields(before, after); if (!fields.every(isGameProfileField)) return null; - return fields.flatMap((field) => field === "lunafury" - ? Object.keys(diffLunaFurySettings(before.lunafury, after.lunafury)).map((key) => `lunafury.${key}`) : [field]); + return fields.flatMap((field) => { + if (field === "lunafury") return Object.keys(diffLunaFurySettings(before.lunafury, after.lunafury)).map((key) => `lunafury.${key}`); + if (field === "buttonMappings" && before.brand === "LunaFury") { + return Object.keys(snapshotDiff(before, after).buttonMappings ?? {}).map((button) => `buttonMappings.${button}`); + } + return [field]; + }); } export function matchingProfileFields(status: MouseStatus, snapshot: GameProfileSnapshot): string[] { return Object.entries(snapshot).flatMap(([field, value]) => { + if (field === "buttonMappings" && status.brand === "LunaFury") { + return Object.entries(pickLunaFuryMappings(status, value)) + .filter(([button, action]) => status.buttonMappings?.[button] === action).map(([button]) => `buttonMappings.${button}`); + } if (field === "lunafury") { const target = sanitizeLunaFurySettings(value); return Object.keys(target).filter((key) => isLunaFuryProfileField(key) && same(status.lunafury?.[key], target[key])) @@ -179,7 +210,11 @@ export function matchingProfileFields(status: MouseStatus, snapshot: GameProfile export function draftProfileSnapshot(before: MouseStatus, preview: MouseStatus, touched: Iterable): GameProfileSnapshot { const snapshot = snapshotDiff(before, preview); for (const field of touched) { - if (field.startsWith("lunafury.")) { + if (before.brand === "LunaFury" && field.startsWith("buttonMappings.")) { + const button = field.slice("buttonMappings.".length); + const mappings = pickLunaFuryMappings(before, { [button]: preview.buttonMappings?.[button] }); + if (Object.keys(mappings).length) snapshot.buttonMappings = { ...snapshot.buttonMappings, ...mappings }; + } else if (field.startsWith("lunafury.")) { const key = field.slice("lunafury.".length); if (isLunaFuryProfileField(key) && preview.lunafury?.[key] !== undefined) { snapshot.lunafury = { ...snapshot.lunafury, [key]: structuredClone(preview.lunafury[key]) }; @@ -188,5 +223,5 @@ export function draftProfileSnapshot(before: MouseStatus, preview: MouseStatus, Object.assign(snapshot, { [field]: structuredClone(preview[field]) }); } } - return snapshot; + return withoutDerivedLunaFuryDpi(preview, snapshot); } diff --git a/src/device/lunafury-buttons.test.ts b/src/device/lunafury-buttons.test.ts new file mode 100644 index 00000000..ee63b1fe --- /dev/null +++ b/src/device/lunafury-buttons.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LUNAFURY_BUTTON_ACTIONS, LUNAFURY_BUTTON_IDS } from "@openmouse/protocol/lamzu"; +import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import { ensureLocale, t } from "../i18n/index.ts"; +import { LOCALES } from "../features/settings/interface-preferences.ts"; +import { LUNAFURY_KEY_CHOICES, canEditLunaFuryMapping, lunaFuryKeyAction, lunaFuryKeyboardLabel, + lunaFuryMappingLabel, pickLunaFuryMappings } from "./lunafury-buttons.ts"; + +const status = { brand: "LunaFury", buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Keyboard:32:4", + Back: "Back", Forward: "Macro:16:1" }, fixedButtons: ["Left", "Forward"] } as MouseStatus; + +test("LunaFury profile imports cannot target protected, unreadable or unknown assignments", () => { + assert.deepEqual(pickLunaFuryMappings(status, { Left: "Disabled", Right: "Keyboard:129:4", Middle: "Disabled", + Back: "Unknown:255:", Forward: "DPI Up", Receiver: "Back" }), { Right: "Keyboard:129:4", Middle: "Disabled" }); + assert.deepEqual(pickLunaFuryMappings(status, { Right: "Mute", Middle: "Disabled" }, true), + { Right: "Right Click", Middle: "Keyboard:32:4" }); + for (const raw of [null, [], "Right", 2, { Right: 4 }, JSON.parse('{"__proto__":"Mute"}')]) { + assert.deepEqual(pickLunaFuryMappings(status, raw), {}); + } + assert.equal(canEditLunaFuryMapping({ ...status, brand: "Lamzu" }, "Right", "Mute"), false); + assert.equal(canEditLunaFuryMapping({ ...status, buttonMappings: {} }, "Right", "Mute"), false); +}); + +test("removed extension actions cannot be imported as button assignments", () => { + for (const action of ["Left Double Click", "Browser Refresh", "Open Browser"]) { + assert.equal(canEditLunaFuryMapping(status, "Back", action), false); + assert.deepEqual(pickLunaFuryMappings(status, { Back: action }), {}); + } +}); + +test("physical keyboard codes and left/right modifiers are preserved", () => { + assert.equal(lunaFuryKeyAction("KeyC", 1), "Keyboard:1:6"); + assert.equal(lunaFuryKeyAction("NumpadEnter", 128), "Keyboard:128:88"); + assert.equal(lunaFuryKeyAction("Tab", 0), "Keyboard:0:43"); + assert.equal(lunaFuryKeyAction("MetaLeft", 0), undefined); + assert.equal(lunaFuryKeyAction("Unidentified", 0), undefined); + assert.equal(lunaFuryKeyAction("__proto__", 0), undefined); + assert.equal(lunaFuryKeyboardLabel("Keyboard:129:4"), "Ctrl L + Win R + A"); + assert.equal(lunaFuryKeyboardLabel("Keyboard:32:40"), "Shift R + Enter"); + assert.equal(lunaFuryKeyboardLabel("Unknown:255:"), undefined); + for (const key of LUNAFURY_KEY_CHOICES) assert.ok(lunaFuryKeyAction(key.code, 255)); +}); + +test("every locale labels the complete physical-button and action vocabularies", async () => { + for (const { code: locale } of LOCALES) { + await ensureLocale(locale); + assert.equal(t(locale, "luna.physicalButtons").split("|").length, Object.keys(LUNAFURY_BUTTON_IDS).length); + assert.equal(t(locale, "luna.buttonActions").split("|").length, LUNAFURY_BUTTON_ACTIONS.length); + for (const action of LUNAFURY_BUTTON_ACTIONS) assert.ok(lunaFuryMappingLabel(locale, action)); + for (const action of ["Macro:16:1", "Macro:17:1", "Unknown:255:"]) { + assert.equal(lunaFuryMappingLabel(locale, action), action); + } + assert.equal(lunaFuryMappingLabel(locale, "Keyboard:129:4"), "Ctrl L + Win R + A"); + } + assert.equal(lunaFuryMappingLabel("zh", "Right", true), "右键"); + assert.equal(lunaFuryMappingLabel("zh", "DPI Cycle"), "循环切换 DPI"); +}); diff --git a/src/device/lunafury-buttons.ts b/src/device/lunafury-buttons.ts new file mode 100644 index 00000000..19f62480 --- /dev/null +++ b/src/device/lunafury-buttons.ts @@ -0,0 +1,68 @@ +import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import { LUNAFURY_BUTTON_ACTIONS, LUNAFURY_BUTTON_IDS, lunafuryButtonActionWritable, + lunafuryIsMappedButton, lunafuryKeyboardAction, lunafuryParseKeyboardAction } from "@openmouse/protocol/lamzu"; +import { t } from "../i18n/index.ts"; +import type { InterfaceLocale } from "../features/settings/interface-preferences.ts"; + +export function canEditLunaFuryMapping(status: MouseStatus, button: string, action: string): boolean { + const current = status.buttonMappings?.[button]; + return status.brand === "LunaFury" && lunafuryIsMappedButton(button) && button !== "Left" + && !status.fixedButtons?.includes(button) && typeof current === "string" + && lunafuryButtonActionWritable(current) && lunafuryButtonActionWritable(action); +} + +/** Only already-readable, supported assignments can be applied and restored. */ +export function pickLunaFuryMappings(status: MouseStatus, raw: unknown, prior = false): Record { + const picked: Record = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return picked; + for (const [button, action] of Object.entries(raw)) { + if (typeof action === "string" && canEditLunaFuryMapping(status, button, action)) { + picked[button] = prior ? status.buttonMappings![button] : action; + } + } + return picked; +} + +export const LUNAFURY_MODIFIERS = ["Ctrl L", "Shift L", "Alt L", "Win L", "Ctrl R", "Shift R", "Alt R", "Win R"] as const; + +// KeyboardEvent.code -> USB HID usage. The physical key, not the text produced +// by the current keyboard layout, determines a saved mouse assignment. +const KEY_CODES: Record = {}; +for (let index = 0; index < 26; index++) KEY_CODES[`Key${String.fromCharCode(65 + index)}`] = 4 + index; +for (let index = 1; index <= 9; index++) KEY_CODES[`Digit${index}`] = 29 + index; +KEY_CODES.Digit0 = 39; +for (let index = 1; index <= 12; index++) KEY_CODES[`F${index}`] = 57 + index; +Object.assign(KEY_CODES, { Enter: 40, Escape: 41, Backspace: 42, Tab: 43, Space: 44, + Minus: 45, Equal: 46, BracketLeft: 47, BracketRight: 48, Backslash: 49, Semicolon: 51, + Quote: 52, Backquote: 53, Comma: 54, Period: 55, Slash: 56, CapsLock: 57, + PrintScreen: 70, ScrollLock: 71, Pause: 72, Insert: 73, Home: 74, PageUp: 75, + Delete: 76, End: 77, PageDown: 78, ArrowRight: 79, ArrowLeft: 80, ArrowDown: 81, ArrowUp: 82, + NumLock: 83, NumpadDivide: 84, NumpadMultiply: 85, NumpadSubtract: 86, NumpadAdd: 87, + NumpadEnter: 88, Numpad0: 98, NumpadDecimal: 99, + ControlLeft: 224, ShiftLeft: 225, AltLeft: 226, ControlRight: 228, ShiftRight: 229, AltRight: 230 }); +for (let index = 1; index <= 9; index++) KEY_CODES[`Numpad${index}`] = 88 + index; + +export const LUNAFURY_KEY_CHOICES = Object.entries(KEY_CODES).map(([code, usage]) => ({ code, usage })); +export const LUNAFURY_MODIFIER_CODES: Readonly> = { + ControlLeft: 1, ShiftLeft: 2, AltLeft: 4, MetaLeft: 8, + ControlRight: 16, ShiftRight: 32, AltRight: 64, MetaRight: 128, +}; + +export function lunaFuryKeyAction(code: string, modifiers: number): string | undefined { + return Object.hasOwn(KEY_CODES, code) ? lunafuryKeyboardAction(modifiers, KEY_CODES[code]) : undefined; +} + +export function lunaFuryKeyboardLabel(action: string): string | undefined { + const key = lunafuryParseKeyboardAction(action); + if (!key) return undefined; + const name = LUNAFURY_KEY_CHOICES.find((choice) => choice.usage === key.usage)?.code ?? String(key.usage); + return [...LUNAFURY_MODIFIERS.filter((_, bit) => key.modifiers & (1 << bit)), name.replace(/^Key|^Digit/, "")].join(" + "); +} + +export function lunaFuryMappingLabel(locale: InterfaceLocale, value: string, physical = false): string { + // These two fixed-order vocabularies share the codec's order. Tests check every locale. + const names = physical ? Object.keys(LUNAFURY_BUTTON_IDS) : LUNAFURY_BUTTON_ACTIONS; + const index = names.indexOf(value); + if (index >= 0) return t(locale, physical ? "luna.physicalButtons" : "luna.buttonActions").split("|")[index] ?? value; + return lunaFuryKeyboardLabel(value) ?? value; +} diff --git a/src/device/lunafury-dpi.test.ts b/src/device/lunafury-dpi.test.ts new file mode 100644 index 00000000..29c8e7e6 --- /dev/null +++ b/src/device/lunafury-dpi.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { lunaFuryDpiInput } from "./lunafury-dpi.ts"; + +test("vendor DPI inputs round down to 50 and stay within the sensor range", () => { + for (const [raw, expected] of [["", 50], ["0", 50], ["-100", 50], ["849", 800], + ["850", 850], ["30001", 30000], ["Infinity", 50], ["bad", 50]] as const) { + assert.equal(lunaFuryDpiInput(raw), expected); + } +}); diff --git a/src/device/lunafury-dpi.ts b/src/device/lunafury-dpi.ts new file mode 100644 index 00000000..50a69ebd --- /dev/null +++ b/src/device/lunafury-dpi.ts @@ -0,0 +1,5 @@ +/** Official numeric inputs clamp to the sensor range and round down to 50 DPI. */ +export function lunaFuryDpiInput(raw: string, max = 30000): number { + const value = Number(raw); + return Math.min(max, Math.max(50, Number.isFinite(value) ? Math.floor(value / 50) * 50 : 50)); +} diff --git a/src/device/lunafury-firmware.test.ts b/src/device/lunafury-firmware.test.ts new file mode 100644 index 00000000..4ee55ad8 --- /dev/null +++ b/src/device/lunafury-firmware.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { describeFirmwareEntities, getDfuInfo, type FirmwareDeviceRef, type FirmwareManifest } from "./firmware-updates.ts"; + +const published = JSON.parse(readFileSync(new URL("../../public/firmware-manifest.json", import.meta.url), "utf8")) as FirmwareManifest; +const extra = JSON.parse(readFileSync(new URL("../../scripts/firmware-manifest.extra.json", import.meta.url), "utf8")) as Pick; +const models = [ + { model: "LUNA33", id: "luna33", cable: "373e_0032", receiver: "373e_0033", latest: "25.0", older: "24.0", newer: "26.0" }, + { model: "TYPE33", id: "type33", cable: "373e_0054", receiver: "373e_0084", latest: "1.47", older: "1.46", newer: "1.48" }, +] as const; + +test("published LunaFury entries match their maintained source", () => { + const entries = (manifest: Pick) => manifest.devices + .filter(entry => entry.brand === "LunaFury") + .sort((a, b) => a.id.localeCompare(b.id)); + assert.equal(entries(published).length, 4); + assert.deepEqual(entries(published), entries(extra)); +}); + +for (const model of models) { + const ref = (interfaceId: string, mouse: string = model.latest, dongle: string = model.latest): FirmwareDeviceRef => ({ + brand: "LunaFury", name: `LunaFury ${model.model} EL`, interfaceId, + firmware: [`Mouse ${mouse}`, `Dongle ${dongle}`], + }); + + test(`${model.model} firmware entries pin exact runtime IDs and entities`, () => { + const mouse = published.devices.find(entry => entry.id === `lunafury-${model.id}-mouse`)!; + const dongle = published.devices.find(entry => entry.id === `lunafury-${model.id}-dongle`)!; + assert.deepEqual(mouse.interfaceIds, [model.cable, model.receiver]); + assert.deepEqual(dongle.interfaceIds, [model.receiver]); + assert.equal(mouse.firmwareName, "Mouse"); + assert.equal(dongle.firmwareName, "Dongle"); + for (const entry of [mouse, dongle]) { + assert.equal(entry.latestVersion, model.latest); + assert.equal(entry.downloadUrl, "https://mouse.lunafury.games/"); + assert.equal(entry.required, false); + assert.equal(entry.nameMatch, undefined); + } + }); + + test(`${model.model} cable and receiver compare only the entities they expose`, () => { + for (const interfaceId of [model.cable, model.receiver]) { + const device = { ...ref(interfaceId), name: "Different product label" }; + const info = describeFirmwareEntities(device, published); + assert.equal(info[0]?.entryId, `lunafury-${model.id}-mouse`); + assert.equal(info[0]?.available, false); + assert.equal(info[1]?.entryId, interfaceId === model.receiver ? `lunafury-${model.id}-dongle` : null); + assert.equal(info[1]?.available, interfaceId === model.receiver ? false : null); + assert.equal(getDfuInfo(device, published)?.latestVersion, model.latest); + } + assert.equal(getDfuInfo({ ...ref(model.cable), firmware: [`Dongle ${model.older}`] }, published), null); + }); + + test(`${model.model} mouse and receiver updates remain independent`, () => { + const mouse = getDfuInfo(ref(model.receiver, model.older, model.newer), published); + assert.equal(mouse?.dfuAvailable, true); + assert.equal(mouse?.entityName, "Mouse"); + assert.equal(mouse?.currentVersion, model.older); + + const dongle = getDfuInfo(ref(model.receiver, model.newer, model.older), published); + assert.equal(dongle?.dfuAvailable, true); + assert.equal(dongle?.entityName, "Dongle"); + assert.equal(dongle?.currentVersion, model.older); + }); + + test(`${model.model} current and newer internal firmware never prompt a downgrade`, () => { + for (const version of [model.latest, model.newer]) { + for (const interfaceId of [model.cable, model.receiver]) { + const info = getDfuInfo(ref(interfaceId, version, version), published); + assert.equal(info?.dfuAvailable, false); + assert.equal(info?.dfuRequired, false); + } + } + }); + + test(`${model.model} names cannot match another model, bootloader or foreign VID`, () => { + const ownManifest = { ...published, devices: published.devices.filter(entry => entry.id.startsWith(`lunafury-${model.id}-`)) }; + const other = models.find(entry => entry.model !== model.model)!; + for (const interfaceId of [other.cable, other.receiver, "373e_b032", "373e_b033", "373e_b054", "373e_b084", "373e_ffff", "046d_0033", ""]) { + assert.equal(getDfuInfo(ref(interfaceId, model.older, model.older), ownManifest), null, interfaceId); + } + }); + + test(`${model.model} missing firmware readings stay unknown`, () => { + const device = { ...ref(model.receiver), firmware: ["Mouse firmware unavailable", "Dongle firmware unavailable"] }; + assert.equal(getDfuInfo(device, published), null); + assert.deepEqual(describeFirmwareEntities(device, published).map(info => info.available), [null, null]); + assert.equal(getDfuInfo({ ...device, firmware: [] }, published), null); + }); +} diff --git a/src/device/lunafury-game-dpi.test.ts b/src/device/lunafury-game-dpi.test.ts new file mode 100644 index 00000000..ed814673 --- /dev/null +++ b/src/device/lunafury-game-dpi.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import { draftProfileSnapshot, mergeSnapshots, pickSnapshot, sanitizeSnapshot, snapshotDiff } from "./game-profile-snapshot.ts"; +import { lunaFuryGameDpiPlan, stageLunaFuryGameDpi } from "./lunafury-game-dpi.ts"; + +function status(): MouseStatus { + return { brand: "LunaFury", name: "LUNA33", batteryPercent: 90, batteryState: "Discharging", firmware: [], + dpi: 800, dpiY: 1600, activeDpiStage: 1, dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200], + pollingRateHz: 1000, lunafury: { separateDpiAxes: true }, + ui: { dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 } } }; +} + +test("a LunaFury selection diff and touched draft save only the stage, not derived DPI", () => { + const before = status(); + const after = { ...before, activeDpiStage: 2, dpi: 1600, dpiY: 3200 }; + assert.deepEqual(snapshotDiff(before, after), { activeDpiStage: 2 }); + assert.deepEqual(draftProfileSnapshot(before, after, ["dpi", "dpiY", "activeDpiStage"]), { activeDpiStage: 2 }); + assert.deepEqual(pickSnapshot(before, snapshotDiff(before, after)), { activeDpiStage: 1 }); + assert.deepEqual(lunaFuryGameDpiPlan(before, { dpi: 1600, dpiY: 3200, activeDpiStage: 2 }), { stages: [], activeStage: 2 }); +}); + +test("legacy DPI scalars edit and back up the target stage when selecting another stage", () => { + const before = status(); + const target = { dpi: 2000, dpiY: 4000, activeDpiStage: 2 }; + assert.deepEqual(lunaFuryGameDpiPlan(before, target), { stages: [{ stage: 2, x: 2000, y: 4000 }], activeStage: 2 }); + assert.deepEqual(pickSnapshot(before, target), { dpiStages: [400, 800, 1600], dpiStagesY: [800, 1600, 3200], activeDpiStage: 1 }); + const after = { ...before, dpi: 2000, dpiY: 4000, activeDpiStage: 2, dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000] }; + assert.deepEqual(lunaFuryGameDpiPlan(after, pickSnapshot(before, target)), { stages: [{ stage: 2, x: 1600, y: 3200 }], activeStage: 1 }); +}); + +test("Y-only legacy edits restore their table without copying X over unrelated Y", () => { + const before = status(); + assert.deepEqual(lunaFuryGameDpiPlan(before, { dpiY: 2400 }), { stages: [{ stage: 1, x: 800, y: 2400 }] }); + assert.deepEqual(pickSnapshot(before, { dpiY: 2400 }), { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY }); + assert.deepEqual(lunaFuryGameDpiPlan(before, { dpiStagesY: [1200, 1600, 3200] }), { stages: [{ stage: 0, x: 400, y: 1200 }] }); +}); + +test("table edits keep their explicit stage target and omit redundant displayed DPI", () => { + const before = status(); + const after = { ...before, dpiStages: [400, 800, 2000], dpiStagesY: [800, 1600, 4000], activeDpiStage: 2, dpi: 2000, dpiY: 4000 }; + const saved = snapshotDiff(before, after); + assert.deepEqual(saved, { dpiStages: after.dpiStages, dpiStagesY: after.dpiStagesY, activeDpiStage: 2 }); + assert.deepEqual(lunaFuryGameDpiPlan(before, saved), { stages: [{ stage: 2, x: 2000, y: 4000 }], activeStage: 2 }); + const switched = mergeSnapshots(pickSnapshot(before, saved), { dpi: 2400, dpiY: 4800, activeDpiStage: 0 }); + assert.deepEqual(lunaFuryGameDpiPlan(after, switched), { + stages: [{ stage: 0, x: 2400, y: 4800 }, { stage: 2, x: 1600, y: 3200 }], activeStage: 0, + }); +}); + +test("stage-count restore includes both axes and the old active stage", () => { + const before = status(); + const saved = { dpiStages: [400] }; + assert.deepEqual(pickSnapshot(before, saved), { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY, activeDpiStage: 1 }); + const calls: unknown[] = []; + assert.equal(stageLunaFuryGameDpi(before, { dpiStages: [400, 800, 1600, 2000], activeDpiStage: 3 }, { + storage: (storage) => calls.push(["storage", storage]), + count: (count) => calls.push(["count", count]), axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]), + }), true); + assert.deepEqual(calls, [["count", 4], ["axes", 3, 2000, 2000], ["active", 3]]); +}); + +test("linked scalar edits update X/Y together without changing peers; other brands keep their policy", () => { + const before = { ...status(), lunafury: { separateDpiAxes: false } }; + assert.deepEqual(lunaFuryGameDpiPlan(before, { dpi: 1200 }), { stages: [{ stage: 1, x: 1200, y: 1200 }] }); + const other = { ...before, brand: "Logitech" }; + assert.equal(lunaFuryGameDpiPlan(other, { dpi: 1600 }), null); + assert.deepEqual(snapshotDiff(other, { ...other, dpi: 1600, activeDpiStage: 2 }), { dpi: 1600, activeDpiStage: 2 }); +}); + +test("combined axis-mode and X-table changes restore dormant Y values under axis lock", () => { + const before = { ...status(), lunafury: { separateDpiAxes: false } }; + const target = { dpiStages: [400, 1200, 1600], lunafury: { separateDpiAxes: true } }; + assert.deepEqual(lunaFuryGameDpiPlan(before, target), { stages: [{ stage: 1, x: 1200, y: 1600 }] }); + const prior = pickSnapshot(before, target); + assert.deepEqual(prior, { dpiStages: before.dpiStages, dpiStagesY: before.dpiStagesY, lunafury: { separateDpiAxes: false } }); + const after = { ...before, dpiStages: target.dpiStages, lunafury: target.lunafury }; + assert.deepEqual(lunaFuryGameDpiPlan(after, prior), { stages: [{ stage: 1, x: 800, y: 1600 }] }); +}); + +test("LunaFury merges normalize base scalars but keep override scalars above restored tables", () => { + const before = status(); + const base = { dpi: 800, dpiY: 1600, activeDpiStage: 1 }; + const target = { dpiStages: [400, 1200, 1600], dpiStagesY: [800, 2400, 3200] }; + const saved = structuredClone(base); + assert.deepEqual(lunaFuryGameDpiPlan(before, mergeSnapshots(base, target, before)), { + stages: [{ stage: 1, x: 1200, y: 2400 }], activeStage: 1, + }); + assert.deepEqual(lunaFuryGameDpiPlan(before, mergeSnapshots(target, { dpi: 2000, dpiY: 4000, activeDpiStage: 0 }, before)), { + stages: [{ stage: 0, x: 2000, y: 4000 }, { stage: 1, x: 1200, y: 2400 }], activeStage: 0, + }); + assert.deepEqual(base, saved); + assert.deepEqual(mergeSnapshots(base, target, { ...before, brand: "Logitech" }), { ...base, ...target }); +}); + +test("LunaFury restoration metadata stays out of sanitized imports and saved drafts", () => { + const before = status(); + before.lunafury!.dpiStorage = { profile: 1, count: 3, + stages: [400, 800, 1600, 3200, 6400, 30000].map((x, index) => ({ x, y: [800, 1600, 3200, 6400, 6400, 30000][index] })) }; + const target = { dpiStages: [400, 800, 1600, 2000] }; + const picked = pickSnapshot(before, target); + assert.deepEqual(picked.lunaFuryDpiStorage, before.lunafury!.dpiStorage); + assert.notEqual(picked.lunaFuryDpiStorage, before.lunafury!.dpiStorage); + assert.equal(sanitizeSnapshot(picked).lunaFuryDpiStorage, undefined); + assert.deepEqual(sanitizeSnapshot({ lunafury: { dpiStorage: before.lunafury!.dpiStorage } }), {}); + const after = structuredClone(before); + after.lunafury!.dpiStorage!.stages[3].x = 2000; + assert.deepEqual(draftProfileSnapshot(before, after, []), {}); +}); + +test("complete Games backups and targets stage one final table with dormant slots intact", () => { + const before = status(); + const backup = { profile: 1, count: 3, + stages: [400, 800, 1600, 3200, 6400, 0].map((x, index) => ({ x, y: [800, 1600, 3200, 6400, 6400, 65535][index] })) }; + const original = structuredClone(backup); + const calls: unknown[] = []; + assert.equal(stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup, + dpiStages: [400, 800, 1600, 2400], dpiStagesY: [800, 1600, 3200, 4800], activeDpiStage: 3 }, { + storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]), + axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]), + }), true); + assert.deepEqual(calls, [["storage", { ...original, count: 4, + stages: [...original.stages.slice(0, 3), { x: 2400, y: 4800 }, ...original.stages.slice(4)] }], ["active", 3]]); + assert.deepEqual(backup, original); + assert.notEqual((calls[0] as any[])[1].stages[4], backup.stages[4]); +}); + +test("a composed Games table already on the mouse stages selection only", () => { + const before = status(); + const backup = { profile: 1, count: 3, stages: [400, 800, 1600, 3200, 6400, 30000].map(x => ({ x, y: x * 2 })) }; + before.dpiStages = [400, 1200, 1600]; before.dpiStagesY = [800, 2400, 3200]; + before.lunafury!.dpiStorage = structuredClone(backup); + before.lunafury!.dpiStorage.stages[1] = { x: 1200, y: 2400 }; + const calls: unknown[] = []; + stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup, dpiStages: before.dpiStages, + dpiStagesY: before.dpiStagesY, activeDpiStage: 2 }, { + storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]), + axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]), + }); + assert.deepEqual(calls, [["active", 2]]); +}); + +test("complete-storage targets still apply legacy scalars at the selected target stage", () => { + const before = status(); + const backup = { profile: 1, count: 3, stages: [400, 800, 1600, 3200, 6400, 30000].map(x => ({ x, y: x })) }; + const calls: unknown[] = []; + stageLunaFuryGameDpi(before, { lunaFuryDpiStorage: backup, dpi: 2400, dpiY: 4800, activeDpiStage: 2 }, { + storage: (storage) => calls.push(["storage", storage]), count: (count) => calls.push(["count", count]), + axes: (...args) => calls.push(["axes", ...args]), active: (stage) => calls.push(["active", stage]), + }); + assert.deepEqual(calls, [["storage", { ...backup, stages: backup.stages.map((stage, index) => index === 2 ? { x: 2400, y: 4800 } : stage) }], ["active", 2]]); +}); diff --git a/src/device/lunafury-game-dpi.ts b/src/device/lunafury-game-dpi.ts new file mode 100644 index 00000000..293b4149 --- /dev/null +++ b/src/device/lunafury-game-dpi.ts @@ -0,0 +1,93 @@ +import type { MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; +import type { LunaFuryDpiStorage } from "@openmouse/protocol/lamzu"; +import type { GameProfileSnapshot } from "./game-profile-snapshot.ts"; + +export interface LunaFuryGameDpiPlan { + count?: number; + stages: Array<{ stage: number; x: number; y: number }>; + activeStage?: number; +} + +/** Scalars in older snapshots describe the target stage, not the old active one. */ +export function lunaFuryGameDpiPlan(status: MouseStatus, snapshot: GameProfileSnapshot): LunaFuryGameDpiPlan | null { + if (status.brand !== "LunaFury" || !status.ui?.dpiStageEditor || !status.dpiStages?.length || !status.dpiStagesY?.length) return null; + const storage = snapshot.lunaFuryDpiStorage; + const baselineX = storage ? storage.stages.slice(0, storage.count).map(stage => stage.x) : status.dpiStages; + const baselineY = storage ? storage.stages.slice(0, storage.count).map(stage => stage.y) : status.dpiStagesY; + const baselineActive = storage ? Math.min(status.activeDpiStage ?? 0, storage.count - 1) : status.activeDpiStage ?? 0; + const separate = snapshot.lunafury?.separateDpiAxes ?? status.lunafury?.separateDpiAxes; + const xs = snapshot.dpiStages?.length ? [...snapshot.dpiStages] : [...baselineX]; + const ys = xs.map((x, stage) => snapshot.dpiStagesY?.[stage] + ?? (x !== baselineX[stage] && separate === false ? x : baselineY[stage] ?? x)); + const target = snapshot.activeDpiStage ?? baselineActive; + if (Number.isInteger(target) && target >= 0 && target < xs.length) { + if (snapshot.dpi != null) xs[target] = snapshot.dpi; + if (snapshot.dpiY != null) ys[target] = snapshot.dpiY; + else if (snapshot.dpi != null && separate === false && snapshot.dpi !== baselineX[target]) ys[target] = snapshot.dpi; + } + return { + ...(xs.length !== baselineX.length && status.ui.dpiStageEditor.countEditable === true ? { count: xs.length } : {}), + stages: xs.flatMap((x, stage) => x === baselineX[stage] && ys[stage] === baselineY[stage] + ? [] : [{ stage, x, y: ys[stage] }]), + ...(snapshot.activeDpiStage != null ? { activeStage: snapshot.activeDpiStage } : {}), + }; +} + +/** Resolve lower-priority legacy scalars at their own stage before overlaying tables. */ +export function canonicalLunaFuryDpiSnapshot(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot { + if (snapshot.dpi == null && snapshot.dpiY == null) return snapshot; + const plan = lunaFuryGameDpiPlan(status, snapshot); + if (!plan) return snapshot; + const storage = snapshot.lunaFuryDpiStorage; + const xs = snapshot.dpiStages?.slice() ?? storage?.stages.slice(0, storage.count).map(stage => stage.x) ?? status.dpiStages!.slice(); + const ys = xs.map((x, stage) => snapshot.dpiStagesY?.[stage] ?? storage?.stages[stage]?.y ?? status.dpiStagesY![stage] ?? x); + for (const { stage, x, y } of plan.stages) { xs[stage] = x; ys[stage] = y; } + const { dpi: _dpi, dpiY: _dpiY, ...rest } = snapshot; + return { ...rest, dpiStages: xs, dpiStagesY: ys }; +} + +export function stageLunaFuryGameDpi(status: MouseStatus, snapshot: GameProfileSnapshot, actions: { + storage(storage: LunaFuryDpiStorage): void; + count(count: number): void; + axes(stage: number, x: number, y: number): void; + active(stage: number): void; +}): boolean { + const plan = lunaFuryGameDpiPlan(status, snapshot); + if (!plan) return false; + if (snapshot.lunaFuryDpiStorage) { + // Compose the final table before staging. Restoring first would make the + // ordinary count/slot setters deduplicate and order against stale hardware. + const target = structuredClone(snapshot.lunaFuryDpiStorage); + if (plan.count != null) target.count = plan.count; + for (const { stage, x, y } of plan.stages) target.stages[stage] = { x, y }; + if (JSON.stringify(target) !== JSON.stringify(status.lunafury?.dpiStorage)) actions.storage(target); + } else { + if (plan.count != null) actions.count(plan.count); + for (const { stage, x, y } of plan.stages) actions.axes(stage, x, y); + } + if (plan.activeStage != null) actions.active(plan.activeStage); + return true; +} + +/** Stage selection changes the displayed DPI without editing either table. */ +export function withoutDerivedLunaFuryDpi(status: MouseStatus, snapshot: GameProfileSnapshot): GameProfileSnapshot { + if (!lunaFuryGameDpiPlan(status, {}) || !(snapshot.dpiStages?.length || snapshot.dpiStagesY?.length || snapshot.activeDpiStage != null)) return snapshot; + const { dpi: _dpi, dpiY: _dpiY, ...rest } = snapshot; + return rest; +} + +/** X/scalar edits also back up Y storage, including values dormant under axis lock. */ +export function preserveLunaFuryDpiTables(status: MouseStatus, snapshot: GameProfileSnapshot, picked: GameProfileSnapshot): void { + const plan = lunaFuryGameDpiPlan(status, snapshot); + if (!plan) return; + if ((plan.stages.length > 0 || plan.count != null) && status.lunafury?.dpiStorage) { + picked.lunaFuryDpiStorage = structuredClone(status.lunafury.dpiStorage); + } + if (((snapshot.dpi != null || snapshot.dpiY != null || snapshot.dpiStages?.length) && plan.stages.length > 0) || plan.count != null) { + picked.dpiStages = [...status.dpiStages!]; + picked.dpiStagesY = [...status.dpiStagesY!]; + delete picked.dpi; + delete picked.dpiY; + } + if (plan.count != null && status.activeDpiStage != null) picked.activeDpiStage = status.activeDpiStage; +} diff --git a/src/device/lunafury-profile.test.ts b/src/device/lunafury-profile.test.ts index 3fa937e2..cb686ded 100644 --- a/src/device/lunafury-profile.test.ts +++ b/src/device/lunafury-profile.test.ts @@ -11,6 +11,19 @@ const settings: LunaFurySettings = { wheelGuard: { enabled: false, windowMs: 0 }, }; +test("receiver-global lighting is excluded from Games and profile imports", () => { + assert.deepEqual(sanitizeLunaFurySettings({ ...settings, receiverLightMode: 6 }), settings); + const saved = pickSnapshot({ lunafury: { ...settings, receiverLightMode: 2 }, sleepTimeout: 0 } as MouseStatus, + { lunafury: { receiverLightMode: 6 } as LunaFurySettings, sleepTimeout: 300 }); + assert.deepEqual(saved, { sleepTimeout: 0 }); +}); + +test("bottom-button behavior is device-only and excluded from Games and imports", () => { + assert.deepEqual(sanitizeLunaFurySettings({ ...settings, bottomButtonMode: 1 }), settings); + assert.deepEqual(pickSnapshot({ lunafury: { ...settings, bottomButtonMode: 2 } } as MouseStatus, + { lunafury: { bottomButtonMode: 1 } as LunaFurySettings }), {}); +}); + test("profile validation accepts endpoints and the disabled zero wheel window", () => { assert.deepEqual(sanitizeLunaFurySettings(settings), settings); assert.deepEqual(sanitizeLunaFurySettings({ lightningMode: 2, leftDebounceMs: 15, middleDebounceMs: 30, @@ -30,6 +43,31 @@ test("invalid imported values never become protocol writes", () => { } }); +test("button polling and axis mode are validated without saving capability metadata", () => { + assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: 8000, separateDpiAxes: false, + supportedButtonPollingRates: [1000, 8000] }), { buttonPollingRateHz: 8000, separateDpiAxes: false }); + for (const value of [0, 125, 1000.5, "1000", NaN]) { + assert.deepEqual(sanitizeLunaFurySettings({ buttonPollingRateHz: value, separateDpiAxes: 1 }), {}); + } +}); + +test("Games use only supported button rates and restore the original axis mode", () => { + const calls: unknown[] = []; + const actions: LunaFuryProfileActions = { + lightning: () => {}, button: () => {}, wheel: () => {}, + buttonRate: (value) => { calls.push(["rate", value]); }, + separateAxes: (value) => { calls.push(["axes", value]); }, + }; + const current: LunaFurySettings = { buttonPollingRateHz: 1000, supportedButtonPollingRates: [1000], separateDpiAxes: true }; + stageLunaFuryProfile({ buttonPollingRateHz: 8000, separateDpiAxes: false }, current, actions); + assert.deepEqual(calls, [["axes", false]], "the LUNA33 wired link must not stage an 8K button rate"); + calls.length = 0; + stageLunaFuryProfile({ buttonPollingRateHz: 4000 }, { ...current, supportedButtonPollingRates: [1000, 2000, 4000, 8000] }, actions); + assert.deepEqual(calls, [["rate", 4000]]); + const saved = pickSnapshot({ lunafury: current } as MouseStatus, { lunafury: { separateDpiAxes: false } }); + assert.deepEqual(saved, { lunafury: { separateDpiAxes: true } }); +}); + test("profile staging calls only supported controls and ignores unknown commands", () => { const calls: unknown[] = []; const actions: LunaFuryProfileActions = { diff --git a/src/device/lunafury-profile.ts b/src/device/lunafury-profile.ts index d803016e..6b278566 100644 --- a/src/device/lunafury-profile.ts +++ b/src/device/lunafury-profile.ts @@ -1,7 +1,7 @@ import type { LunaFuryButton, LunaFuryLightningMode, LunaFurySettings, LunaFuryWheelGuard } from "@openmouse/protocol/lamzu"; export const LUNAFURY_PROFILE_FIELDS = [ - "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard", + "lightningMode", "leftDebounceMs", "rightDebounceMs", "middleDebounceMs", "wheelGuard", "buttonPollingRateHz", "separateDpiAxes", ] as const satisfies readonly (keyof LunaFurySettings)[]; type Field = (typeof LUNAFURY_PROFILE_FIELDS)[number]; @@ -17,6 +17,10 @@ export function sanitizeLunaFurySettings(raw: unknown): LunaFurySettings { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const value = raw as Record; const clean: LunaFurySettings = {}; + if ([1000, 2000, 4000, 8000].includes(value.buttonPollingRateHz as number)) { + clean.buttonPollingRateHz = value.buttonPollingRateHz as LunaFurySettings["buttonPollingRateHz"]; + } + if (typeof value.separateDpiAxes === "boolean") clean.separateDpiAxes = value.separateDpiAxes; if (integer(value.lightningMode, 0, 2)) clean.lightningMode = value.lightningMode as LunaFuryLightningMode; if (integer(value.leftDebounceMs, 0, 15)) clean.leftDebounceMs = value.leftDebounceMs; if (integer(value.rightDebounceMs, 0, 15)) clean.rightDebounceMs = value.rightDebounceMs; @@ -54,6 +58,8 @@ export interface LunaFuryProfileActions { lightning(mode: LunaFuryLightningMode): void; button(button: LunaFuryButton, milliseconds: number): void; wheel(guard: LunaFuryWheelGuard): void; + buttonRate?(hertz: NonNullable): void; + separateAxes?(enabled: boolean): void; } /** Disable priority before changing latencies; enable it after them. */ @@ -64,6 +70,9 @@ export function lunaFuryLightningPriority(mode: LunaFuryLightningMode): number { export function stageLunaFuryProfile(raw: unknown, current: LunaFurySettings | undefined, actions: LunaFuryProfileActions): void { if (!current) return; const target = sanitizeLunaFurySettings(raw); + if (target.buttonPollingRateHz !== undefined && current.buttonPollingRateHz !== undefined && + current.supportedButtonPollingRates?.includes(target.buttonPollingRateHz)) actions.buttonRate?.(target.buttonPollingRateHz); + if (target.separateDpiAxes !== undefined && current.separateDpiAxes !== undefined) actions.separateAxes?.(target.separateDpiAxes); if (target.lightningMode !== undefined && current.lightningMode !== undefined) actions.lightning(target.lightningMode); for (const button of ["left", "right", "middle"] as const) { const field = `${button}DebounceMs` as const; diff --git a/src/device/pending-changes.test.ts b/src/device/pending-changes.test.ts index 507f2069..d95847eb 100644 --- a/src/device/pending-changes.test.ts +++ b/src/device/pending-changes.test.ts @@ -131,6 +131,57 @@ test("pending changes preview without mutating the device status", () => { assert.equal(hasPendingChanges(), false); }); +test("state-dependent priorities re-evaluate the same staging after hardware count changes", () => { + clearPendingChanges(); + try { + stagePendingChange({ key: "count", label: "", command: "", progress: "", + resolvePriority: current => 4 > current.dpiStages!.length ? -1 : 4 < current.dpiStages!.length ? 1 : 0, + apply: async () => {} }); + stagePendingChange({ key: "slot", label: "", command: "", progress: "", apply: async () => {} }); + const current = { ...status(), dpiStages: [400, 800, 1600, 3200, 6400, 8000] }; + const staged = pendingChanges(); + assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["slot", "count"]); + current.dpiStages = current.dpiStages.slice(0, 3); + assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["count", "slot"]); + current.dpiStages.push(3200); + assert.deepEqual(pendingChangeBatches(current).map(([change]) => change?.key), ["count", "slot"]); + assert.deepEqual(pendingChanges(), staged, "resolving must not replace or reorder the stored user edits"); + } finally { + clearPendingChanges(); + } +}); + +test("dynamic ordering requires status even for a single batch", () => { + clearPendingChanges(); + try { + stagePendingChange({ key: "count", label: "", command: "", progress: "", + resolvePriority: () => -1, apply: async () => {} }); + assert.throws(() => pendingChangeBatches(), /Confirmed device status is required/); + } finally { + clearPendingChanges(); + } +}); + +test("dynamic ordering preserves grouped last-writer semantics and stable equal priorities", () => { + clearPendingChanges(); + try { + const stage = (key: string, group?: string) => stagePendingChange({ + key, group, label: "", command: "", progress: "", apply: async () => {} }); + stagePendingChange({ key: "mode-first", group: "mode", label: "", command: "", progress: "", + resolvePriority: () => 0, apply: async () => {} }); + stage("unrelated"); + stage("mode-last", "mode"); + stagePendingChange({ key: "count", label: "", command: "", progress: "", + resolvePriority: () => -1, apply: async () => {} }); + const batches = pendingChangeBatches(status()); + assert.deepEqual(batches.map(batch => batch.map(change => change.key)), + [["count"], ["mode-first", "mode-last"], ["unrelated"]]); + assert.equal(batches[1].at(-1)?.key, "mode-last"); + } finally { + clearPendingChanges(); + } +}); + test("stashed changes come back in their original order after another set was staged", () => { clearPendingChanges(); const change = (key: string) => ({ key, label: key, command: "", progress: "", apply: async () => {} }); diff --git a/src/device/pending-changes.ts b/src/device/pending-changes.ts index e8109b39..c8d90f6f 100644 --- a/src/device/pending-changes.ts +++ b/src/device/pending-changes.ts @@ -14,6 +14,10 @@ export interface PendingChange { progress: string; // Lower priorities flash first when one setting must establish storage used by another. priority?: number; + // State-dependent ordering is resolved from confirmed hardware status at + // Apply time, never frozen from the UI snapshot used when staging. + // As with priority, a group's first member determines its batch ordering. + resolvePriority?(status: MouseStatus): number; // Mirrors the staged value onto a status snapshot so the UI can render it. // Omitted when the setting is not part of MouseStatus (a Logitech onboard // profile value, say); such a change renders itself and cannot be compared @@ -52,8 +56,9 @@ export function pendingChanges(): PendingChange[] { * Changes sharing a group collapse into one batch; an ungrouped change is a * batch of its own. The last entry of a batch is the one that performs the * write, so a batch is applied by calling `apply` on `batch.at(-1)`. + * A confirmed status is required when a batch has a dynamic priority. */ -export function pendingChangeBatches(): PendingChange[][] { +export function pendingChangeBatches(status?: MouseStatus): PendingChange[][] { const batches: PendingChange[][] = []; const byGroup = new Map(); for (const change of changes.values()) { @@ -72,7 +77,15 @@ export function pendingChangeBatches(): PendingChange[][] { byGroup.set(change.group, batch); batches.push(batch); } - return batches.sort((left, right) => (left[0]?.priority ?? 0) - (right[0]?.priority ?? 0)); + return batches.map(batch => { + const first = batch[0]; + if (first?.resolvePriority && !status) { + throw new Error("Confirmed device status is required to order these pending changes."); + } + const priority = first?.resolvePriority && status + ? first.resolvePriority(status) : first?.priority ?? 0; + return { batch, priority }; + }).sort((left, right) => left.priority - right.priority).map(entry => entry.batch); } export function pendingChangeCount(): number { diff --git a/src/device/preview/preview-fixtures.test.ts b/src/device/preview/preview-fixtures.test.ts index 331e4b99..2f84e781 100644 --- a/src/device/preview/preview-fixtures.test.ts +++ b/src/device/preview/preview-fixtures.test.ts @@ -47,6 +47,30 @@ test("atk-f1 fixture lights up the offender cards", () => { assert.equal(has.atkF1Dongle, true); }); +test("LunaFury synthetic preview exposes paired stages, three banks and independent button rates", () => { + const status = PREVIEW_FIXTURES.lunafury.status; + assert.equal(status.brand, "LunaFury"); + assert.equal(status.profileCount, 3); + assert.equal(status.dpiStages?.length, 6); + assert.equal(status.dpiStagesY?.length, 6); + assert.notEqual(status.dpi, status.dpiY); + assert.notEqual(status.pollingRateHz, status.lunafury?.buttonPollingRateHz); + const has = cardAvailability({ status, traits: traitsFor(status), settingsPending: false } as ControlSnapshot); + assert.equal(has.onboardProfiles, true); + assert.equal(has.lunafuryButtons, true); + assert.equal(has.lunafuryWheelGuard, true); + assert.equal(has.lunafuryReceiverLight, true); + assert.equal(has.lunafuryBottomButton, true); + assert.equal(status.lunafury?.bottomButtonMode, 2); + assert.equal(status.sleepTimeout, 0); + for (const receiverLightMode of [0, undefined] as const) { + const variant = { ...status, lunafury: { ...status.lunafury, receiverLightMode } }; + assert.equal(cardAvailability({ status: variant, traits: traitsFor(variant) } as ControlSnapshot).lunafuryReceiverLight, + receiverLightMode !== undefined); + } + assert.equal(has.signal, false); +}); + test("the EGG fixtures carry the shape their driver actually reports", () => { // No EGG driver publishes `ui.family`, so neither fixture may claim one: the // previews have to exercise the same path real hardware takes. diff --git a/src/device/preview/preview-fixtures.ts b/src/device/preview/preview-fixtures.ts index b0d74d87..821421c0 100644 --- a/src/device/preview/preview-fixtures.ts +++ b/src/device/preview/preview-fixtures.ts @@ -1,5 +1,6 @@ import type { MouseLighting, MouseStatus } from "@openmouse/protocol/drivers/mouse-types"; import { PREVIEW_KEYS, type FixturePreviewMode } from "./preview-modes.ts"; +import { LUNAFURY_BUTTON_ACTIONS } from "@openmouse/protocol/lamzu"; export { PREVIEW_KEYS }; @@ -1085,6 +1086,23 @@ export const PREVIEW_FIXTURES: Record = { "egg-xm2we": { label: "Endgame Gear XM2we", status: EGG_XM2WE }, wlmouse: { label: "WLMouse BEAST X", status: WLMOUSE }, lamzu: { label: "Lamzu Atlantis OG V2", status: LAMZU }, + lunafury: { label: "LunaFury LUNA33 (synthetic)", status: { + ...LAMZU, brand: "LunaFury", name: "LunaFury LUNA33", + ui: { family: "lunafury", hideUnsupportedPollingRates: true, forceShowBattery: true, + dpiStageEditor: { maxStages: 6, countEditable: true, minDpi: 50, maxDpi: 30000, stepDpi: 50 } }, + dpi: 800, dpiY: 1600, dpiStages: [400, 800, 1600, 3200, 6400, 30000], + dpiStagesY: [800, 1600, 3200, 3200, 6400, 30000], activeDpiStage: 1, + activeProfile: 2, profileCount: 3, pollingRateHz: 8000, + sleepTimeout: 0, + buttonMappings: { Left: "Left Click", Right: "Right Click", Middle: "Middle Click", Back: "Keyboard:1:6", Forward: "Macro:16:1" }, + buttonOptions: [...LUNAFURY_BUTTON_ACTIONS], fixedButtons: ["Left", "Forward"], + supportedPollingRates: [125, 250, 500, 1000, 2000, 4000, 8000], + angleTuning: -12, angleSnapping: false, hyperMode: true, performanceMode: true, rippleControl: false, + lunafury: { bottomButtonMode: 2, receiverLightMode: 2, buttonPollingRateHz: 4000, supportedButtonPollingRates: [1000, 2000, 4000, 8000], + separateDpiAxes: true, lightningMode: 1, leftDebounceMs: 3, rightDebounceMs: 7, + middleDebounceMs: 10, wheelGuard: { enabled: true, windowMs: 100 } }, + firmware: ["Synthetic preview — not a hardware capture"], + } }, "attack-shark": { label: "Attack Shark R5 Ultra", status: ATTACKSHARK }, crdrako: { label: "CRDRAKO KO-ONE", status: CRDRAKO }, m3k: { label: "Zaunkoenig M3K", status: M3K }, diff --git a/src/device/preview/preview-modes.ts b/src/device/preview/preview-modes.ts index fe185d70..bba8b034 100644 --- a/src/device/preview/preview-modes.ts +++ b/src/device/preview/preview-modes.ts @@ -19,6 +19,7 @@ export const PREVIEW_KEYS = [ "egg-xm2we", "wlmouse", "lamzu", + "lunafury", "attack-shark", "crdrako", "m3k", @@ -69,6 +70,7 @@ export function parsePreviewMode(value: string | null): PreviewMode | null { case "egg-xm2we": return "egg-xm2we"; case "wlmouse": return "wlmouse"; case "lamzu": return "lamzu"; + case "lunafury": return "lunafury"; case "attack-shark": return "attack-shark"; case "crdrako": return "crdrako"; case "m3k": return "m3k"; diff --git a/src/features/bridge/useBridgeProfileApplier.ts b/src/features/bridge/useBridgeProfileApplier.ts index 729fc499..b8e05ab9 100644 --- a/src/features/bridge/useBridgeProfileApplier.ts +++ b/src/features/bridge/useBridgeProfileApplier.ts @@ -42,58 +42,89 @@ function signature(profile: BridgeProfile): string { return `${profile.application.name}|${profile.device.name}|${JSON.stringify(profile.settings)}`; } +interface GameProfileSession { + signature: string | null; + prior: GameProfileSnapshot | null; +} + export function useBridgeProfileApplier(snapshot: ControlSnapshot): void { const deviceStatusRef = useRef(snapshot.deviceStatus); deviceStatusRef.current = snapshot.deviceStatus; const localeRef = useRef(snapshot.preferences.locale); localeRef.current = snapshot.preferences.locale; - const appliedSignature = useRef(null); - // What the mouse had, before any profile, for every field a profile has - // changed since. Grows as profiles switch; written back when none is active. - const priorSettings = useRef(null); + // Keep inactive banks' originals until that device/bank is selected again. + // Never restore them into whichever mouse happens to be active now. + const sessions = useRef(new WeakMap>()); const writing = useRef(false); useEffect(() => subscribeBridgeStatus((bridge) => { if (writing.current) return; const status = deviceStatusRef.current; const profile = bridge?.activeProfile ?? null; + const scope = control.currentGameProfileScope(); + if (!status || !scope) return; + let session = sessions.current.get(scope.device)?.get(scope.profile); - if (!profile || !status || profile.device.name !== status.name) { - // Nothing (recognized) is active for this mouse right now. If we were - // the one who applied a profile, put back what was there before it. - const prior = priorSettings.current; - if (appliedSignature.current === null || !prior) return; - writing.current = true; - void control.flashGameProfileSnapshot(prior).then((result) => { + if (!profile && !session?.prior) return; + + writing.current = true; + let attemptedPrior: GameProfileSnapshot | null = null; + void (async () => { + // A failed final read leaves a display snapshot, not a reliable baseline. + // Refresh before both no-op detection and capturing newly touched fields. + const confirmed = await control.prepareGameProfileStatus(); + if (typeof confirmed === "string") { + if (confirmed === "failed" && session) session.signature = null; + return; + } + const confirmedScope = control.currentGameProfileScope(confirmed); + if (!confirmedScope || confirmedScope.device !== scope.device) return; + let banks = sessions.current.get(confirmedScope.device); + if (!banks) sessions.current.set(confirmedScope.device, banks = new Map()); + session = banks.get(confirmedScope.profile); + if (!session) banks.set(confirmedScope.profile, session = { signature: null, prior: null }); + if (!profile || profile.device.name !== confirmed.name) { + // Nothing (recognized) is active for this mouse right now. Put back + // what was there before any successful or partial profile application. + const prior = session.prior; + // A failed first application can still have changed earlier batches, + // even though it never earned an applied signature. + if (!prior) return; + const result = await control.flashGameProfileSnapshot(prior, confirmedScope); if (result === "busy") return; - appliedSignature.current = null; - priorSettings.current = null; + session.signature = null; + if (result === "failed") return; + session.prior = null; if (result === "written") control.pushToast("info", t(localeRef.current, "bridge.profileRestored")); - }).finally(() => { - writing.current = false; - }); - return; - } + return; + } - const sig = signature(profile); - if (sig === appliedSignature.current) return; + const sig = signature(profile); + if (sig === session.signature) return; - const target = profileTarget(profile); - // Capture only fields not already held: when one game's profile gives way - // to another's, the originals from before the first one still win. - const prior = mergeSnapshots(pickSnapshot(status, target), priorSettings.current ?? {}); - // Fields the previous profile changed but this one doesn't go back to - // their originals, so the new game starts from the user's own settings. - const toWrite = mergeSnapshots(priorSettings.current ?? {}, target); + const target = profileTarget(profile); + // Capture only fields not already held: when one game's profile gives way + // to another's, the originals from before the first one still win. + const prior = mergeSnapshots(pickSnapshot(confirmed, target), session.prior ?? {}, confirmed); + // Fields the previous profile changed but this one doesn't go back to + // their originals, so the new game starts from the user's own settings. + const toWrite = mergeSnapshots(session.prior ?? {}, target, confirmed); - writing.current = true; - void control.flashGameProfileSnapshot(toWrite).then((result) => { + attemptedPrior = prior; + const result = await control.flashGameProfileSnapshot(toWrite, confirmedScope); if (result === "busy") return; - priorSettings.current = prior; - appliedSignature.current = sig; + // Back up attempted fields too: an unsuccessful write is not atomic. + session.prior = prior; + session.signature = result === "failed" ? null : sig; if (result === "written") { control.pushToast("info", t(localeRef.current, "bridge.profileAppliedHere"), profile.application.name); } + })().catch(() => { + // Keep originals even if an unexpected rejection followed a partial write. + if (session) { + if (attemptedPrior) session.prior = attemptedPrior; + session.signature = null; + } }).finally(() => { writing.current = false; }); diff --git a/src/features/devices/OverviewPage.tsx b/src/features/devices/OverviewPage.tsx index a2ffe46b..a1878242 100644 --- a/src/features/devices/OverviewPage.tsx +++ b/src/features/devices/OverviewPage.tsx @@ -27,7 +27,7 @@ import { KeyboardPanel } from "./cards/KeyboardPanel"; import { FirmwareCard } from "./cards/FirmwareCard"; import { DynamicSensitivityCard, LightforceCard, PollingCard, SensorCard, SensorScanningCard } from "./cards/PerformanceCards"; import { LightingCard } from "./cards/LightingCard"; -import { LunaFuryButtonsCard, LunaFuryWheelCard } from "./cards/LunaFuryCards"; +import { LunaFuryButtonsCard, LunaFuryWheelCard, LunaFuryReceiverLightCard, LunaFuryBottomButtonCard } from "./cards/LunaFuryCards"; import { MxMasterButtonsCard, MxMasterCards } from "./cards/MxMasterCards"; import { AtkButtonCard, AtkDongleCard, AtkProfileCard, AtkReceiverCard, AtkSensorCard } from "./cards/AtkCards"; import { @@ -361,6 +361,11 @@ export function Workspace({ const show = (available: boolean, tabs: readonly WorkspaceTab[]): boolean => available && on(tab, tabs); const device = !gameProfile; const powerOverview = status.ui?.powerOverview === true; + const lunaButtonExtras = [ + show(has.lunafuryButtons, ["buttons"]) ? : null, + show(has.lunafuryWheelGuard, ["buttons"]) ? : null, + device && show(has.lunafuryBottomButton, ["buttons"]) ? : null, + ].filter((node) => node !== null); const performance = [ show(has.dpi, ["performance"]) ? : null, @@ -394,6 +399,7 @@ export function Workspace({ ? : null, show(has.ksnakeScroll, ["buttons"]) ? : null, + ...lunaButtonExtras, ].filter((node) => node !== null); const macro = [ @@ -402,6 +408,8 @@ export function Workspace({ ].filter((node) => node !== null); const advanced = [ + device && show(has.lunafuryReceiverLight, ["advanced"]) + ? : null, device && show(has.signal, ["advanced"]) ? : null, !powerOverview && show(has.sleep, ["advanced"]) ? : null, show(has.lightingAdvanced, ["advanced"]) @@ -411,8 +419,6 @@ export function Workspace({ show(has.finalmouse, ["advanced"]) ? : null, show(has.finalmouseProfiles, ["advanced"]) ? : null, show(has.incott, ["advanced"]) ? : null, - show(has.lunafuryButtons, ["buttons"]) ? : null, - show(has.lunafuryWheelGuard, ["buttons"]) ? : null, device && show(has.dongleLed, ["advanced"]) ? : null, show(has.atkProfile, ["profiles"]) ? : null, device && show(has.atkReceiver, ["advanced"]) ? : null, diff --git a/src/features/devices/cards/AdvancedCards.tsx b/src/features/devices/cards/AdvancedCards.tsx index fe93f562..1f14a37d 100644 --- a/src/features/devices/cards/AdvancedCards.tsx +++ b/src/features/devices/cards/AdvancedCards.tsx @@ -38,6 +38,8 @@ import { tmrStepsToMm, } from "../../../device/finalmouse-slx.ts"; import { lunafurySettingLabel, lunafuryTrackingVisible } from "../../../device/lunafury-labels.ts"; +import { lunaFuryMappingLabel } from "../../../device/lunafury-buttons.ts"; +import { LunaFuryKeyboardBinding } from "./LunaFuryKeyboardBinding.tsx"; import * as control from "../../../device/controller"; import { PULSAR_SLEEP_OPTIONS } from "../../../device/controller"; import { selectableValues, sleepLabel, sleepParts, sleepTotalSeconds, snapCpiToDevice, valuesWithCurrent, KEYCHRON_SLEEP_MAX_HOURS, KEYCHRON_SLEEP_MAX_SECONDS, KEYCHRON_SLEEP_MIN_SECONDS, type CpiLimits } from "../../../device/options"; @@ -273,7 +275,7 @@ export function SleepCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNod const asleep = status.sleepTimeout !== null && status.sleepTimeout !== undefined; return ( -
+

{t(locale, "adv.autoSleep")}

{canDisable ? ( @@ -1788,6 +1790,7 @@ export function OnboardProfileCard({ snapshot }: { snapshot: ControlSnapshot }): label: status.profileNames?.[value - 1] ?? tp(locale, "adv.profileOpt", { n: value }), }))} value={status.activeProfile} + disabled={snapshot.settingInProgress || snapshot.settingsPending} onChange={(next) => control.applyProfileSelection(next)} />
@@ -2644,6 +2647,9 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }): if (!status?.buttonMappings || !status.buttonOptions?.length) return null; const locale = snapshot.preferences.locale; const options = status.buttonOptions; + const luna = status.brand === "LunaFury"; + const lunaBusy = luna && (snapshot.settingInProgress || snapshot.pending.busy || snapshot.pending.keys.includes("onboard-profile")); + const display = (value: string, physical = false) => luna ? lunaFuryMappingLabel(locale, value, physical) : displayText(locale, value); const isNoirProfile = isNoirKsnakeStatus(status) && snapshot.m2nexProfiles !== null; const selectedM2NexProfile = isNoirProfile @@ -2657,7 +2663,7 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }): // builds against the published protocol until then. const fixed = new Set((status as unknown as { fixedButtons?: readonly string[] }).fixedButtons ?? []); return ( -
+

{t(locale, "map.remap")}

{Object.entries(status.buttonMappings).map(([button, deviceAssigned], index) => { @@ -2666,18 +2672,18 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }): : mappings[button] ?? deviceAssigned; const selectId = `button-${button.toLowerCase()}-select`; const isFixed = fixed.has(button) || (isNoirKsnakeStatus(status) && button === "Left"); - return ( + const row = ( ); + return luna ? ( +
+ {row} + {!isFixed && } +
+ ) : row; })}
{canResetKsnake ? ( @@ -2708,7 +2721,7 @@ export function ButtonMappingCard({ snapshot }: { snapshot: ControlSnapshot }): ) : null} {!selectedM2NexProfile ? ( -

{t(locale, "map.defaultNote")}

+

{t(locale, luna ? "luna.mappingNote" : "map.defaultNote")}

) : null}
); diff --git a/src/features/devices/cards/DpiCard.tsx b/src/features/devices/cards/DpiCard.tsx index 8bd197f4..d03a6fca 100644 --- a/src/features/devices/cards/DpiCard.tsx +++ b/src/features/devices/cards/DpiCard.tsx @@ -11,6 +11,7 @@ import { isNoirE1Status, isNoirKsnakeStatus, isNoirS1Status, NOIR_DPI_STAGE_COLO import { closestDpiOption, dpiPresetValues } from "../../../device/dpi-presets"; import { t, tp } from "../../../i18n/index"; import { LiftOffDistance, hasLiftOff } from "./PerformanceCards"; +import { LunaFuryDpiEditor } from "./LunaFuryDpiEditor"; const MAX_EDITOR_ROWS = 4; const DEFAULT_LOD = 2; @@ -86,7 +87,7 @@ function DpiStageEditor({ const stages = status.dpiStages.slice(0, rowCap); const stagesY = status.dpiStagesY; const last = stages[stages.length - 1] ?? status.dpi ?? 800; - const rows = stages.map((value, index) => ({ + const rows: StageRow[] = stages.map((value, index) => ({ enabled: true, value: String(value), valueY: String(stagesY?.[index] ?? value), @@ -541,6 +542,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode const staged = snapshot.pending.keys.includes("dpi") || snapshot.pending.keys.includes("dpi-stage-count") || snapshot.pending.keys.includes("dpi-active-stage") + || snapshot.pending.keys.includes("lunafury-dpi-axes") || snapshot.pending.keys.includes("logitech-dpi-slots") || snapshot.pending.keys.some((key) => key.startsWith("dpi-stage-")) || snapshot.pending.keys.includes("lift-off-distance"); @@ -557,14 +559,14 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode || (Boolean(status?.ui?.dpiStageEditor) && Array.isArray(status?.dpiStages) && (status?.dpiStages?.length ?? 0) > 0); - const singleDpiAvailable = !isNoirS1Status(status) && !isNoirE1Status(status); + const singleDpiAvailable = status.brand !== "LunaFury" && !isNoirS1Status(status) && !isNoirE1Status(status); const label = (source: typeof status): string => `${source.dpi.toLocaleString()} DPI`; return (
@@ -602,7 +604,7 @@ export function DpiCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode
) : null}
-
+ - : + />}
diff --git a/src/features/devices/cards/LunaFuryCards.tsx b/src/features/devices/cards/LunaFuryCards.tsx index 92483410..c6306baa 100644 --- a/src/features/devices/cards/LunaFuryCards.tsx +++ b/src/features/devices/cards/LunaFuryCards.tsx @@ -1,9 +1,53 @@ import type { ReactNode } from "react"; -import type { LunaFuryButton } from "@openmouse/protocol/lamzu"; +import type { LunaFuryButton, LunaFuryReceiverLightMode } from "@openmouse/protocol/lamzu"; import * as control from "../../../device/controller"; import type { ControlSnapshot } from "../../../device/types"; import { t, type I18nKey } from "../../../i18n/index"; -import { Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; +import { OptionMenu, RateSlider, Segmented, StepperSlider, SwitchRow } from "../../../shared/ui"; + +export function LunaFuryBottomButtonCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const mode = snapshot.status?.lunafury?.bottomButtonMode; + if (mode == null || snapshot.gameProfileDraft) return null; + const locale = snapshot.preferences.locale; + const staged = snapshot.pending.keys.includes("lunafury-bottom-button"); + return ( +
+

{t(locale, "luna.bottomButton")}

+ control.applyLunaFuryBottomButtonMode(value as 1 | 2)} /> + {t(locale, "luna.bottomButtonNote")} +
+ ); +} + +export function LunaFuryReceiverLightCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const mode = snapshot.status?.lunafury?.receiverLightMode; + if (mode == null || snapshot.gameProfileDraft) return null; + const locale = snapshot.preferences.locale; + const staged = snapshot.pending.keys.includes("lunafury-receiver-light"); + return ( +
+

{t(locale, "luna.receiverLight")}

+ control.applyLunaFuryReceiverLightMode(value as LunaFuryReceiverLightMode)} /> + {t(locale, "luna.receiverLightNote")} +
+ ); +} export function LunaFuryButtonsCard({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const settings = snapshot.status?.lunafury; @@ -18,8 +62,16 @@ export function LunaFuryButtonsCard({ snapshot }: { snapshot: ControlSnapshot }) ]; return (
+ data-pending-key="lunafury-lightning lunafury-button-left lunafury-button-right lunafury-button-middle lunafury-button-rate">

{t(locale, "luna.buttons")}

+ {settings.buttonPollingRateHz != null && settings.supportedButtonPollingRates?.length ? ( +
+ control.applyLunaFuryButtonPollingRate(value as 1000 | 2000 | 4000 | 8000)} /> + {t(locale, "luna.buttonPollingNote")} +
+ ) : null} {settings.lightningMode != null ? (
{t(locale, "luna.lightning")} diff --git a/src/features/devices/cards/LunaFuryDpiEditor.tsx b/src/features/devices/cards/LunaFuryDpiEditor.tsx new file mode 100644 index 00000000..ef435215 --- /dev/null +++ b/src/features/devices/cards/LunaFuryDpiEditor.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState, type ReactNode } from "react"; +import * as control from "../../../device/controller"; +import type { ControlSnapshot } from "../../../device/types"; +import { lunaFuryDpiInput } from "../../../device/lunafury-dpi"; +import { t, tp } from "../../../i18n/index"; +import { OptionMenu, StepperSlider, SwitchRow } from "../../../shared/ui"; + +/** Mirrors the vendor's active-stage sliders, stage tiles and count selector. */ +export function LunaFuryDpiEditor({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { + const status = snapshot.status!; + const locale = snapshot.preferences.locale; + const xs = status.dpiStages ?? []; + const ys = status.dpiStagesY ?? xs; + const separate = status.lunafury?.separateDpiAxes; + const limits = status.ui?.dpiStageEditor; + const max = limits?.maxDpi ?? 30000; + const active = Math.min(Math.max(status.activeDpiStage ?? 0, 0), Math.max(0, xs.length - 1)); + const busy = snapshot.settingsPending || snapshot.settingInProgress; + const writable = snapshot.capabilities?.dpiStagesWritable === true; + const activeWritable = snapshot.capabilities?.activeDpiStageWritable === true; + const init = () => xs.map((x, i) => ({ x: String(x), y: String(ys[i] ?? x) })); + const [rows, setRows] = useState(init); + const source = `${status.activeProfile}:${separate}:${xs.join(",")}:${ys.join(",")}`; + useEffect(() => { setRows(init()); }, [source]); + + const select = (stage: number): void => { + if (!busy && activeWritable && stage !== active) control.applyActiveDpiStage(stage); + }; + const change = (stage: number, axis: "x" | "y", raw: string): void => { + setRows((current) => current.map((row, i) => i === stage ? { ...row, [axis]: raw } : row)); + }; + const commit = (stage: number, axis: "x" | "y", raw: string): void => { + if (busy || !writable || !xs[stage]) return; + const value = lunaFuryDpiInput(raw, max); + const x = axis === "x" ? value : xs[stage]; + // An unread axis mode is not proof of axis lock; preserve stored Y. + const y = separate === false ? x : axis === "y" ? value : ys[stage] ?? x; + setRows((current) => current.map((row, i) => i === stage ? { x: String(x), y: String(y) } : row)); + if (x !== xs[stage] || y !== ys[stage]) control.applyLunaFuryDpiStageAxes(stage, x, y); + }; + + return ( +
+ {status.lunafury?.separateDpiAxes != null ? : null} + {xs.length ? (separate ? ["x", "y"] as const : ["x"] as const).map((axis) => ( + `${value.toLocaleString()} DPI`} + disabled={busy || !writable} pendingKey={`dpi-stage-${active}`} + onCommit={(value) => commit(active, axis, String(value))} /> + )) : null} +
+
+ {xs.map((x, stage) => ( +
+ + {(separate ? ["x", "y"] as const : ["x"] as const).map((axis) => ( + + ))} +
+ ))} +
+
+ {t(locale, "luna.dpiStageCount")} + ({ value: i + 1, label: String(i + 1) }))} + value={xs.length} disabled={busy || !writable || limits?.countEditable !== true} + onChange={control.applyDpiStageCount} /> +
+
+ {t(locale, "luna.dpiStageNote")} +
+ ); +} diff --git a/src/features/devices/cards/LunaFuryKeyboardBinding.tsx b/src/features/devices/cards/LunaFuryKeyboardBinding.tsx new file mode 100644 index 00000000..b11f20ae --- /dev/null +++ b/src/features/devices/cards/LunaFuryKeyboardBinding.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { lunafuryKeyboardAction, lunafuryParseKeyboardAction } from "@openmouse/protocol/lamzu"; +import { LUNAFURY_KEY_CHOICES, LUNAFURY_MODIFIERS, LUNAFURY_MODIFIER_CODES, + lunaFuryKeyboardLabel, lunaFuryKeyAction } from "../../../device/lunafury-buttons.ts"; +import { applyDeviceButtonMapping } from "../../../device/controller.ts"; +import type { InterfaceLocale } from "../../settings/interface-preferences.ts"; +import { t } from "../../../i18n/index.ts"; + +export function LunaFuryKeyboardBinding({ button, assigned, profile, disabled, locale }: { + button: string; assigned: string; profile: number | null | undefined; disabled: boolean; locale: InterfaceLocale; +}): ReactNode { + const initial = lunafuryParseKeyboardAction(assigned); + const [usage, setUsage] = useState(initial?.usage ?? 4); + const [modifiers, setModifiers] = useState(initial?.modifiers ?? 0); + const [unsupported, setUnsupported] = useState(false); + const details = useRef(null); + const held = useRef(0); + useEffect(() => { + const key = lunafuryParseKeyboardAction(assigned); + setUsage(key?.usage ?? 4); setModifiers(key?.modifiers ?? 0); setUnsupported(false); + held.current = 0; + if (details.current) details.current.open = false; + }, [assigned, profile]); + const action = lunafuryKeyboardAction(modifiers, usage); + return ( +
+ {t(locale, "prof.keyboardShortcut")} +
+ + +
+ {LUNAFURY_MODIFIERS.map((name, bit) => )} +
+ {lunaFuryKeyboardLabel(action)} + {unsupported &&

{t(locale, "luna.keyUnsupported")}

} +
+ + +
+
+
+ ); +} diff --git a/src/features/devices/cards/availability.test.ts b/src/features/devices/cards/availability.test.ts index e3b08b60..1dccd6be 100644 --- a/src/features/devices/cards/availability.test.ts +++ b/src/features/devices/cards/availability.test.ts @@ -74,6 +74,13 @@ test("LunaFury-specific cards are not offered to another CompX brand", () => { assert.equal(has.lunafuryWheelGuard, false); }); +test("the bottom-button card requires a successful LunaFury read", () => { + assert.equal(cardAvailability(snapshot({ status: { brand: "LunaFury", lunafury: { bottomButtonMode: 1 } } })).lunafuryBottomButton, true); + assert.equal(cardAvailability(snapshot({ status: { brand: "LunaFury", lunafury: {} } })).lunafuryBottomButton, false); + assert.equal(cardAvailability(snapshot({ status: { brand: "Attack Shark", lunafury: { bottomButtonMode: 2 } } })).lunafuryBottomButton, false); + assert.equal(cardAvailability(snapshot({ status: null })).lunafuryBottomButton, false); +}); + test("the button card appears only when the mouse reports controls", () => { // The driver answers with an empty list on a mouse without 0x1B04, so this // must key on the controls themselves rather than on the brand. diff --git a/src/features/devices/cards/availability.ts b/src/features/devices/cards/availability.ts index 0bbe82c7..eaa9ea08 100644 --- a/src/features/devices/cards/availability.ts +++ b/src/features/devices/cards/availability.ts @@ -29,6 +29,8 @@ export interface CardAvailability { incott: boolean; lunafuryButtons: boolean; lunafuryWheelGuard: boolean; + lunafuryReceiverLight: boolean; + lunafuryBottomButton: boolean; dongleLed: boolean; eggFilter: boolean; eggSpdt: boolean; @@ -83,6 +85,8 @@ const NOTHING: CardAvailability = { incott: false, lunafuryButtons: false, lunafuryWheelGuard: false, + lunafuryReceiverLight: false, + lunafuryBottomButton: false, dongleLed: false, eggFilter: false, eggSpdt: false, @@ -212,8 +216,12 @@ function computeCardAvailability(snapshot: ControlSnapshot): CardAvailability { lunafuryButtons: host && status.brand === "LunaFury" && ( status.lunafury?.lightningMode != null || status.lunafury?.leftDebounceMs != null || status.lunafury?.rightDebounceMs != null || status.lunafury?.middleDebounceMs != null + || status.lunafury?.buttonPollingRateHz != null ), lunafuryWheelGuard: host && status.brand === "LunaFury" && status.lunafury?.wheelGuard != null, + lunafuryReceiverLight: host && status.brand === "LunaFury" && status.lunafury?.receiverLightMode != null, + lunafuryBottomButton: host && status.brand === "LunaFury" && status.lunafury?.bottomButtonMode != null, + // Drivers report a boolean only for receivers that answer the LED command. dongleLed: typeof status.dongleLedEnabled === "boolean", eggFilter: eggs, eggSpdt: eggs, diff --git a/src/features/devices/workspace-tabs.test.ts b/src/features/devices/workspace-tabs.test.ts index f861fa46..184a9606 100644 --- a/src/features/devices/workspace-tabs.test.ts +++ b/src/features/devices/workspace-tabs.test.ts @@ -16,13 +16,19 @@ function availability(enabled: Partial): CardAvailability { } test("LunaFury response and wheel guard retain the Buttons tab without global debounce", () => { - for (const field of ["lunafuryButtons", "lunafuryWheelGuard"] as const) { + for (const field of ["lunafuryButtons", "lunafuryWheelGuard", "lunafuryBottomButton"] as const) { assert.deepEqual(availableWorkspaceTabs(true, availability({ [field]: true })), [ "overview", "performance", "buttons", "advanced", ]); } }); +test("Finalmouse TMR-DS retains the Buttons tab without other button controls", () => { + assert.deepEqual(availableWorkspaceTabs(true, availability({ tmrDs: true })), [ + "overview", "performance", "buttons", "advanced", + ]); +}); + test("a connected mouse exposes only tabs backed by available controls", () => { assert.deepEqual(availableWorkspaceTabs(true, availability({})), ["overview", "performance", "advanced"]); assert.deepEqual(availableWorkspaceTabs(true, availability({ debounce: true })), [ diff --git a/src/features/devices/workspace-tabs.ts b/src/features/devices/workspace-tabs.ts index 2a560630..e4254815 100644 --- a/src/features/devices/workspace-tabs.ts +++ b/src/features/devices/workspace-tabs.ts @@ -9,7 +9,7 @@ export function availableWorkspaceTabs( if (!connected) return WORKSPACE_TAB_ORDER; const hasButtons = has.eggButtons || has.eggHanded || has.razerButtons || has.mxMasterButtons || has.atkButtons || has.buttonMapping || has.debounce || has.lightforce || has.eggSpdt || has.superstrike || has.magnetic - || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard || has.tmrDs; + || has.ksnakeScroll || has.lunafuryButtons || has.lunafuryWheelGuard || has.lunafuryBottomButton || has.tmrDs; const hasProfiles = has.profiles || has.keychronNapeLayers || has.atkProfile || has.onboardProfiles || has.pulsarPro; return WORKSPACE_TAB_ORDER.filter((tab) => { diff --git a/src/i18n/i18n.test.ts b/src/i18n/i18n.test.ts index f68705f8..66f99916 100644 --- a/src/i18n/i18n.test.ts +++ b/src/i18n/i18n.test.ts @@ -34,6 +34,27 @@ test("tp renders provided placeholders and blanks missing ones", () => { assert.equal(tp("en", "dpi.slotX", {}), "Slot X DPI"); }); +test("Chinese DPI views and editor notes use clear localized wording", async () => { + await ensureLocale("zh"); + assert.equal(t("zh", "dpi.viewStages"), "全部档位"); + assert.equal(t("zh", "dpi.viewSingle"), "当前 DPI"); + assert.equal(t("zh", "dpi.viewSlots"), "全部档位"); + for (const key of ["dpi.stageToggle", "dpi.editorCountNote", "dpi.editorFixedNote", + "dpi.stagesReadOnly", "dpi.editorGenericNote", "dpi.singleOverwritesNote"] as const) { + const text = t("zh", key); + assert.match(text, /[\u4e00-\u9fff]/); + assert.doesNotMatch(text, /One row|Include this|These DPI stages/); + } + assert.equal(tp("zh", "dpi.stageDpi", { n: 2 }), "第 2 档 DPI"); + assert.match(tp("zh", "dpi.editorFixedNote", { total: 6 }), /6 个固定 DPI 档位/); + assert.match(tp("zh", "dpi.singleOverwritesNote", { total: 6 }), /全部 6 个档位.*全部档位/); +}); +test("LunaFury official-style DPI controls have localized count and apply guidance", async () => { + await ensureLocale("zh"); + assert.equal(t("zh", "luna.dpiStageCount"), "档位数量"); + assert.match(t("zh", "luna.dpiStageNote"), /彩色档位块.*应用.*鼠标/); +}); + test("Turkish loads on demand and renders localized labels and templates", async () => { await ensureLocale("tr"); assert.equal(t("tr", "nav.settings"), "Ayarlar"); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4cde08fc..5267b25e 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,30 @@ import type { InterfaceLocale } from "../features/settings/interface-preferences"; const en = { + "luna.bottomButton": "Bottom button", + "luna.bottomPower": "Short press: power on/off", + "luna.bottomPowerDpi": "Long press: power on/off / short press: DPI", + "luna.bottomButtonNote": "Saved in the current onboard profile. Apply to save.", + "luna.dpiStageCount": "DPI stages", + "luna.dpiStageNote": "Click a colored tile to switch DPI. Edit a stage below, then Apply to save.", + "luna.receiverLight": "Receiver lighting", + "luna.lightMixed": "Mixed mode", + "luna.lightBattery": "Battery mode", + "luna.lightDpi": "DPI mode", + "luna.receiverLightNote": "Applies to the 8K receiver, independently of onboard and game profiles.", + "luna.physicalButtons": "Left|Right|Middle|Back|Forward", + "luna.buttonActions": "Disabled|Left click|Right click|Middle click|Back|Forward|Scroll up|Scroll down|DPI cycle|DPI up|DPI down|Media player|Play / pause|Next track|Previous track|Volume up|Volume down|Mute|Stop|Calculator|My computer|Browser home|Email|Brightness up|Brightness down", + "luna.mappingNote": "Assignments belong to the current onboard profile. Left is protected; existing macros and unknown bindings are read-only. L/R identifies the left/right modifier key.", + "luna.keyHint": "Focus here and press a key, or choose the key and modifiers below", + "luna.keyLabel": "Key", + "luna.modifiers": "Modifiers", + "luna.keyUnsupported": "This key is not supported. Choose a key from the list.", + "luna.buttonPollingRate": "Button polling rate", + "luna.buttonPollingNote": "Independent of the USB report rate.", + "luna.separateAxes": "Separate X/Y DPI", + "luna.stageYDpi": "Stage {n} · Y DPI", + "luna.profileSwitchPending": "Apply or cancel the profile switch before editing settings.", + "luna.discardBeforeSwitch": "Discard unapplied changes and switch onboard profiles?", "luna.angleSnap": "Angle Snap", "luna.competitiveMode": "Competitive Mode", "luna.trackingMode": "Tracking Mode", diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index c760d046..fd699b44 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const ar: Record = { + "luna.bottomButton": "الزر السفلي", + "luna.bottomPower": "ضغطة قصيرة: تشغيل/إيقاف", + "luna.bottomPowerDpi": "ضغطة طويلة: تشغيل/إيقاف / قصيرة: DPI", + "luna.bottomButtonNote": "يُحفظ في ملف التعريف الداخلي الحالي. اضغط تطبيق للحفظ.", + "luna.dpiStageCount": "عدد مستويات DPI", + "luna.dpiStageNote": "انقر على مربع ملون لتبديل DPI. عدّل المستوى أدناه، ثم اضغط تطبيق للحفظ.", + "luna.receiverLight": "إضاءة المستقبل", + "luna.lightMixed": "الوضع المختلط", + "luna.lightBattery": "وضع البطارية", + "luna.lightDpi": "وضع DPI", + "luna.receiverLightNote": "ينطبق على مستقبل 8K، بشكل مستقل عن ملفات التعريف الداخلية وملفات الألعاب.", + "luna.physicalButtons": "الأيسر|الأيمن|الأوسط|رجوع|تقدم", + "luna.buttonActions": "معطل|نقر أيسر|نقر أيمن|نقر أوسط|رجوع|تقدم|تمرير لأعلى|تمرير لأسفل|تبديل DPI|زيادة DPI|خفض DPI|مشغل الوسائط|تشغيل / إيقاف مؤقت|المقطع التالي|المقطع السابق|رفع الصوت|خفض الصوت|كتم|إيقاف|الحاسبة|هذا الكمبيوتر|صفحة المتصفح الرئيسية|البريد|زيادة السطوع|خفض السطوع", + "luna.mappingNote": "تُحفظ التعيينات في ملف الذاكرة الحالي. الزر الأيسر محمي؛ وحدات الماكرو والتعيينات غير المعروفة للقراءة فقط. L/R يعني مفتاح التعديل الأيسر/الأيمن.", + "luna.keyHint": "ضع التركيز هنا ثم اضغط مفتاحًا أو اختصارًا", + "luna.keyLabel": "المفتاح", + "luna.modifiers": "مفاتيح التعديل", + "luna.keyUnsupported": "هذا المفتاح غير مدعوم. اختر من القائمة.", + "luna.buttonPollingRate": "معدل استطلاع الأزرار", + "luna.buttonPollingNote": "مستقل عن معدل تقارير USB.", + "luna.separateAxes": "DPI منفصل لمحوري X/Y", + "luna.stageYDpi": "المستوى {n} · DPI لمحور Y", + "luna.profileSwitchPending": "طبّق تبديل الملف أو ألغِه قبل تعديل الإعدادات.", + "luna.discardBeforeSwitch": "تجاهل التغييرات غير المطبقة وتبديل الملف المدمج؟", "set.turkish": "Türkçe", "luna.angleSnap": "تصحيح الخط المستقيم", "luna.competitiveMode": "وضع المنافسة", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index eb5b34dc..26258bb7 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const de: Record = { + "luna.bottomButton": "Unterseitentaste", + "luna.bottomPower": "Kurz drücken: ein-/ausschalten", + "luna.bottomPowerDpi": "Lang drücken: ein-/ausschalten / kurz drücken: DPI", + "luna.bottomButtonNote": "Im aktuellen Onboard-Profil gespeichert. Zum Speichern anwenden.", + "luna.dpiStageCount": "DPI-Stufen", + "luna.dpiStageNote": "Farbige Kachel zum DPI-Wechsel anklicken. Werte darunter bearbeiten und mit Anwenden speichern.", + "luna.receiverLight": "Empfängerbeleuchtung", + "luna.lightMixed": "Gemischter Modus", + "luna.lightBattery": "Akkumodus", + "luna.lightDpi": "DPI-Modus", + "luna.receiverLightNote": "Gilt für den 8K-Empfänger, unabhängig von Onboard- und Spielprofilen.", + "luna.physicalButtons": "Links|Rechts|Mitte|Zurück|Vorwärts", + "luna.buttonActions": "Deaktiviert|Linksklick|Rechtsklick|Mittelklick|Zurück|Vorwärts|Nach oben scrollen|Nach unten scrollen|DPI wechseln|DPI erhöhen|DPI verringern|Mediaplayer|Wiedergabe / Pause|Nächster Titel|Vorheriger Titel|Lauter|Leiser|Stumm|Stopp|Rechner|Dieser PC|Browser-Startseite|E-Mail|Heller|Dunkler", + "luna.mappingNote": "Belegungen gelten für das aktuelle Onboard-Profil. Links ist geschützt; vorhandene Makros und unbekannte Belegungen sind schreibgeschützt. L/R steht für linke/rechte Modifikatortaste.", + "luna.keyHint": "Hier fokussieren, dann Taste oder Tastenkürzel drücken", + "luna.keyLabel": "Taste", + "luna.modifiers": "Modifikatoren", + "luna.keyUnsupported": "Diese Taste wird nicht unterstützt. Wähle eine Taste aus der Liste.", + "luna.buttonPollingRate": "Tasten-Abfragerate", + "luna.buttonPollingNote": "Unabhängig von der USB-Abfragerate.", + "luna.separateAxes": "Getrennte X/Y-DPI", + "luna.stageYDpi": "Stufe {n} · Y-DPI", + "luna.profileSwitchPending": "Wende den Profilwechsel an oder brich ihn ab, bevor du Einstellungen änderst.", + "luna.discardBeforeSwitch": "Nicht angewendete Änderungen verwerfen und das interne Profil wechseln?", "set.turkish": "Türkçe", "luna.angleSnap": "Geradlinienkorrektur", "luna.competitiveMode": "Wettkampfmodus", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d4e9e8b1..5ec307f9 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const es: Record = { + "luna.bottomButton": "Botón inferior", + "luna.bottomPower": "Pulsación corta: encender/apagar", + "luna.bottomPowerDpi": "Pulsación larga: encender/apagar / corta: DPI", + "luna.bottomButtonNote": "Se guarda en el perfil integrado actual. Aplica para guardar.", + "luna.dpiStageCount": "Número de niveles", + "luna.dpiStageNote": "Pulsa un bloque de color para cambiar el DPI. Edita los valores debajo y pulsa Aplicar para guardar.", + "luna.receiverLight": "Iluminación del receptor", + "luna.lightMixed": "Modo mixto", + "luna.lightBattery": "Modo batería", + "luna.lightDpi": "Modo DPI", + "luna.receiverLightNote": "Se aplica al receptor 8K, independientemente de los perfiles integrados y de juego.", + "luna.physicalButtons": "Izquierdo|Derecho|Central|Atrás|Adelante", + "luna.buttonActions": "Desactivado|Clic izquierdo|Clic derecho|Clic central|Atrás|Adelante|Desplazar arriba|Desplazar abajo|Cambiar DPI|Subir DPI|Bajar DPI|Reproductor multimedia|Reproducir / pausa|Pista siguiente|Pista anterior|Subir volumen|Bajar volumen|Silenciar|Detener|Calculadora|Este equipo|Inicio del navegador|Correo|Subir brillo|Bajar brillo", + "luna.mappingNote": "Las asignaciones se guardan en el perfil integrado actual. El botón izquierdo está protegido; las macros y asignaciones desconocidas son de solo lectura. L/R indica el modificador izquierdo/derecho.", + "luna.keyHint": "Enfoca aquí y pulsa una tecla o combinación", + "luna.keyLabel": "Tecla", + "luna.modifiers": "Modificadores", + "luna.keyUnsupported": "Esta tecla no es compatible. Elige una de la lista.", + "luna.buttonPollingRate": "Frecuencia de los botones", + "luna.buttonPollingNote": "Independiente de la frecuencia de informes USB.", + "luna.separateAxes": "DPI X/Y independientes", + "luna.stageYDpi": "Nivel {n} · DPI Y", + "luna.profileSwitchPending": "Aplica o cancela el cambio de perfil antes de editar los ajustes.", + "luna.discardBeforeSwitch": "¿Descartar los cambios sin aplicar y cambiar de perfil interno?", "set.turkish": "Türkçe", "luna.angleSnap": "Corrección de línea recta", "luna.competitiveMode": "Modo competitivo", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index b27d3941..9ca4fbec 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const fr: Record = { + "luna.bottomButton": "Bouton inférieur", + "luna.bottomPower": "Appui court : marche/arrêt", + "luna.bottomPowerDpi": "Appui long : marche/arrêt / appui court : DPI", + "luna.bottomButtonNote": "Enregistré dans le profil embarqué actuel. Appliquez pour enregistrer.", + "luna.dpiStageCount": "Nombre de paliers", + "luna.dpiStageNote": "Cliquez sur une case colorée pour changer les DPI. Modifiez les valeurs dessous, puis cliquez sur Appliquer.", + "luna.receiverLight": "Éclairage du récepteur", + "luna.lightMixed": "Mode mixte", + "luna.lightBattery": "Mode batterie", + "luna.lightDpi": "Mode DPI", + "luna.receiverLightNote": "Concerne le récepteur 8K, indépendamment des profils embarqués et de jeu.", + "luna.physicalButtons": "Gauche|Droite|Milieu|Précédent|Suivant", + "luna.buttonActions": "Désactivé|Clic gauche|Clic droit|Clic milieu|Précédent|Suivant|Défilement haut|Défilement bas|Cycle DPI|Augmenter DPI|Réduire DPI|Lecteur multimédia|Lecture / pause|Piste suivante|Piste précédente|Monter le volume|Baisser le volume|Muet|Arrêt|Calculatrice|Ce PC|Accueil du navigateur|E-mail|Augmenter la luminosité|Réduire la luminosité", + "luna.mappingNote": "Les affectations sont enregistrées dans le profil embarqué actuel. Le bouton gauche est protégé ; les macros et affectations inconnues sont en lecture seule. L/R désigne le modificateur gauche/droit.", + "luna.keyHint": "Placer le focus ici, puis appuyer sur une touche ou un raccourci", + "luna.keyLabel": "Touche", + "luna.modifiers": "Modificateurs", + "luna.keyUnsupported": "Cette touche n’est pas prise en charge. Choisissez-la dans la liste.", + "luna.buttonPollingRate": "Fréquence des boutons", + "luna.buttonPollingNote": "Indépendante de la fréquence des rapports USB.", + "luna.separateAxes": "DPI X/Y séparés", + "luna.stageYDpi": "Palier {n} · DPI Y", + "luna.profileSwitchPending": "Appliquez ou annulez le changement de profil avant de modifier les réglages.", + "luna.discardBeforeSwitch": "Abandonner les modifications non appliquées et changer de profil intégré ?", "set.turkish": "Türkçe", "luna.angleSnap": "Correction de ligne droite", "luna.competitiveMode": "Mode compétitif", diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 7dfc9922..5b7cc9ed 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const ja: Record = { + "luna.bottomButton": "底面ボタン", + "luna.bottomPower": "短押しで電源オン/オフ", + "luna.bottomPowerDpi": "長押しで電源オン/オフ・短押しで DPI 切り替え", + "luna.bottomButtonNote": "現在のオンボードプロファイルに保存されます。「適用」で保存してください。", + "luna.dpiStageCount": "DPI 段階数", + "luna.dpiStageNote": "色付きのタイルをクリックして DPI を切り替えます。下の数値を編集し、適用を押して保存します。", + "luna.receiverLight": "レシーバーの照明", + "luna.lightMixed": "ミックスモード", + "luna.lightBattery": "バッテリーモード", + "luna.lightDpi": "DPI モード", + "luna.receiverLightNote": "8K レシーバーの設定です。オンボード・ゲームプロファイルとは独立しています。", + "luna.physicalButtons": "左ボタン|右ボタン|中央ボタン|戻る|進む", + "luna.buttonActions": "無効|左クリック|右クリック|中央クリック|戻る|進む|上にスクロール|下にスクロール|DPI 切替|DPI を上げる|DPI を下げる|メディアプレーヤー|再生/一時停止|次の曲|前の曲|音量を上げる|音量を下げる|ミュート|停止|電卓|PC|ブラウザーのホーム|メール|明るくする|暗くする", + "luna.mappingNote": "割り当ては現在のオンボードプロファイルに保存されます。左ボタンは保護され、既存のマクロと不明な割り当ては読み取り専用です。L/R は左/右の修飾キーです。", + "luna.keyHint": "ここにフォーカスし、キーまたはショートカットを押してください", + "luna.keyLabel": "キー", + "luna.modifiers": "修飾キー", + "luna.keyUnsupported": "このキーは未対応です。一覧から選択してください。", + "luna.buttonPollingRate": "ボタンポーリングレート", + "luna.buttonPollingNote": "USB レポートレートとは独立した設定です。", + "luna.separateAxes": "X/Y DPI を個別に設定", + "luna.stageYDpi": "ステージ {n} · Y DPI", + "luna.profileSwitchPending": "設定を編集する前にプロファイルの切り替えを適用またはキャンセルしてください。", + "luna.discardBeforeSwitch": "未適用の変更を破棄してオンボードプロファイルを切り替えますか?", "set.turkish": "Türkçe", "luna.angleSnap": "Angle Snap", "luna.competitiveMode": "Competitive Mode", diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 73534909..4c8c923d 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const ko: Record = { + "luna.bottomButton": "하단 버튼", + "luna.bottomPower": "짧게 눌러 전원 켜기/끄기", + "luna.bottomPowerDpi": "길게 눌러 전원 켜기/끄기 · 짧게 눌러 DPI 전환", + "luna.bottomButtonNote": "현재 온보드 프로필에 저장됩니다. 적용을 눌러 저장하세요.", + "luna.dpiStageCount": "DPI 단계 수", + "luna.dpiStageNote": "색상 타일을 눌러 DPI를 전환하세요. 아래 값을 수정하고 적용을 눌러 저장하세요.", + "luna.receiverLight": "수신기 조명", + "luna.lightMixed": "혼합 모드", + "luna.lightBattery": "배터리 모드", + "luna.lightDpi": "DPI 모드", + "luna.receiverLightNote": "8K 수신기에 적용되며 온보드 및 게임 프로필과 독립적입니다.", + "luna.physicalButtons": "왼쪽|오른쪽|가운데|뒤로|앞으로", + "luna.buttonActions": "사용 안 함|왼쪽 클릭|오른쪽 클릭|가운데 클릭|뒤로|앞으로|위로 스크롤|아래로 스크롤|DPI 순환|DPI 증가|DPI 감소|미디어 플레이어|재생 / 일시 정지|다음 곡|이전 곡|볼륨 증가|볼륨 감소|음소거|정지|계산기|내 PC|브라우저 홈|이메일|밝기 증가|밝기 감소", + "luna.mappingNote": "할당은 현재 온보드 프로필에 저장됩니다. 왼쪽 버튼은 보호되며 기존 매크로와 알 수 없는 할당은 읽기 전용입니다. L/R은 왼쪽/오른쪽 보조 키입니다.", + "luna.keyHint": "여기에 초점을 맞춘 뒤 키 또는 단축키를 누르세요", + "luna.keyLabel": "키", + "luna.modifiers": "보조 키", + "luna.keyUnsupported": "지원하지 않는 키입니다. 목록에서 선택하세요.", + "luna.buttonPollingRate": "버튼 폴링 레이트", + "luna.buttonPollingNote": "USB 보고 주기와 별개의 설정입니다.", + "luna.separateAxes": "X/Y DPI 개별 설정", + "luna.stageYDpi": "단계 {n} · Y DPI", + "luna.profileSwitchPending": "설정을 변경하기 전에 프로필 전환을 적용하거나 취소하세요.", + "luna.discardBeforeSwitch": "적용하지 않은 변경 사항을 버리고 온보드 프로필을 전환할까요?", "set.turkish": "Türkçe", "luna.angleSnap": "직선 보정", "luna.competitiveMode": "경쟁 모드", diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 6e67ec20..5a0e58cf 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const pt: Record = { + "luna.bottomButton": "Botão inferior", + "luna.bottomPower": "Pressão curta: ligar/desligar", + "luna.bottomPowerDpi": "Pressão longa: ligar/desligar / curta: DPI", + "luna.bottomButtonNote": "Guardado no perfil interno atual. Aplique para guardar.", + "luna.dpiStageCount": "Número de estágios", + "luna.dpiStageNote": "Clique em um bloco colorido para trocar o DPI. Edite os valores abaixo e clique em Aplicar para salvar.", + "luna.receiverLight": "Iluminação do receptor", + "luna.lightMixed": "Modo misto", + "luna.lightBattery": "Modo bateria", + "luna.lightDpi": "Modo DPI", + "luna.receiverLightNote": "Aplica-se ao receptor 8K, independentemente dos perfis internos e de jogos.", + "luna.physicalButtons": "Esquerdo|Direito|Central|Voltar|Avançar", + "luna.buttonActions": "Desativado|Clique esquerdo|Clique direito|Clique central|Voltar|Avançar|Rolar para cima|Rolar para baixo|Alternar DPI|Aumentar DPI|Diminuir DPI|Reprodutor de mídia|Reproduzir / pausar|Próxima faixa|Faixa anterior|Aumentar volume|Diminuir volume|Silenciar|Parar|Calculadora|Este computador|Página inicial|E-mail|Aumentar brilho|Diminuir brilho", + "luna.mappingNote": "As atribuições pertencem ao perfil interno atual. O botão esquerdo está protegido; macros existentes e atribuições desconhecidas são somente leitura. L/R indica o modificador esquerdo/direito.", + "luna.keyHint": "Foque aqui e pressione uma tecla ou atalho", + "luna.keyLabel": "Tecla", + "luna.modifiers": "Modificadores", + "luna.keyUnsupported": "Esta tecla não é compatível. Escolha uma na lista.", + "luna.buttonPollingRate": "Taxa de consulta dos botões", + "luna.buttonPollingNote": "Independente da taxa de relatórios USB.", + "luna.separateAxes": "DPI X/Y independentes", + "luna.stageYDpi": "Nível {n} · DPI Y", + "luna.profileSwitchPending": "Aplique ou cancele a troca de perfil antes de editar as configurações.", + "luna.discardBeforeSwitch": "Descartar alterações não aplicadas e trocar o perfil interno?", "set.turkish": "Türkçe", "luna.angleSnap": "Correção de linha reta", "luna.competitiveMode": "Modo competitivo", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3e6c766..71fdc388 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const ru: Record = { + "luna.bottomButton": "Нижняя кнопка", + "luna.bottomPower": "Короткое нажатие: включение/выключение", + "luna.bottomPowerDpi": "Долгое нажатие: питание / короткое: DPI", + "luna.bottomButtonNote": "Сохраняется в текущем встроенном профиле. Нажмите «Применить».", + "luna.dpiStageCount": "Количество ступеней", + "luna.dpiStageNote": "Нажмите на цветную плитку для смены DPI. Измените значения под ней и нажмите «Применить».", + "luna.receiverLight": "Подсветка приёмника", + "luna.lightMixed": "Смешанный режим", + "luna.lightBattery": "Режим батареи", + "luna.lightDpi": "Режим DPI", + "luna.receiverLightNote": "Настройка приёмника 8K, независимая от встроенных и игровых профилей.", + "luna.physicalButtons": "Левая|Правая|Средняя|Назад|Вперёд", + "luna.buttonActions": "Отключено|Левый щелчок|Правый щелчок|Средний щелчок|Назад|Вперёд|Прокрутка вверх|Прокрутка вниз|Смена DPI|Повысить DPI|Понизить DPI|Медиаплеер|Воспроизведение / пауза|Следующий трек|Предыдущий трек|Громче|Тише|Без звука|Стоп|Калькулятор|Этот компьютер|Главная браузера|Почта|Повысить яркость|Понизить яркость", + "luna.mappingNote": "Назначения сохраняются в текущем встроенном профиле. Левая кнопка защищена; существующие макросы и неизвестные назначения доступны только для чтения. L/R — левый/правый модификатор.", + "luna.keyHint": "Установите фокус здесь и нажмите клавишу или сочетание", + "luna.keyLabel": "Клавиша", + "luna.modifiers": "Модификаторы", + "luna.keyUnsupported": "Эта клавиша не поддерживается. Выберите клавишу из списка.", + "luna.buttonPollingRate": "Частота опроса кнопок", + "luna.buttonPollingNote": "Не зависит от частоты отчётов USB.", + "luna.separateAxes": "Раздельный DPI по X/Y", + "luna.stageYDpi": "Уровень {n} · DPI Y", + "luna.profileSwitchPending": "Примените или отмените переключение профиля перед изменением настроек.", + "luna.discardBeforeSwitch": "Отбросить неприменённые изменения и переключить встроенный профиль?", "set.turkish": "Türkçe", "luna.angleSnap": "Коррекция прямой линии", "luna.competitiveMode": "Соревновательный режим", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index ab6504dc..a139a40c 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const tr: Record = { + "luna.bottomButton": "Alt düğme", + "luna.bottomPower": "Kısa basış: aç/kapat", + "luna.bottomPowerDpi": "Uzun basış: aç/kapat / kısa basış: DPI", + "luna.bottomButtonNote": "Cihaz belleğindeki mevcut profile kaydedilir. Kaydetmek için Uygula'ya basın.", + "luna.dpiStageCount": "DPI kademeleri", + "luna.dpiStageNote": "DPI değiştirmek için renkli bir kutucuğa tıklayın. Aşağıdan bir kademeyi düzenleyip kaydetmek için Uygula'ya basın.", + "luna.receiverLight": "Alıcı aydınlatması", + "luna.lightMixed": "Karma mod", + "luna.lightBattery": "Pil modu", + "luna.lightDpi": "DPI modu", + "luna.receiverLightNote": "Cihaz belleğindeki profillerden ve oyun profillerinden bağımsız olarak 8K alıcıya uygulanır.", + "luna.physicalButtons": "Sol|Sağ|Orta|Geri|İleri", + "luna.buttonActions": "Devre dışı|Sol tıklama|Sağ tıklama|Orta tıklama|Geri|İleri|Yukarı kaydır|Aşağı kaydır|DPI döngüsü|DPI artır|DPI azalt|Medya oynatıcı|Oynat / duraklat|Sonraki parça|Önceki parça|Sesi artır|Sesi azalt|Sessize al|Durdur|Hesap makinesi|Bilgisayarım|Tarayıcı ana sayfası|E-posta|Parlaklığı artır|Parlaklığı azalt", + "luna.mappingNote": "Atamalar cihaz belleğindeki mevcut profile aittir. Sol düğme korunur; mevcut makrolar ve bilinmeyen atamalar salt okunurdur. L/R, sol/sağ değiştirici tuşunu belirtir.", + "luna.keyHint": "Buraya odaklanıp bir tuşa basın veya aşağıdan tuşu ve değiştiricileri seçin", + "luna.keyLabel": "Tuş", + "luna.modifiers": "Değiştiriciler", + "luna.keyUnsupported": "Bu tuş desteklenmiyor. Listeden bir tuş seçin.", + "luna.buttonPollingRate": "Düğme sorgulama hızı", + "luna.buttonPollingNote": "USB raporlama hızından bağımsızdır.", + "luna.separateAxes": "Ayrı X/Y DPI", + "luna.stageYDpi": "Kademe {n} · Y DPI", + "luna.profileSwitchPending": "Ayarları düzenlemeden önce profil değişikliğini uygulayın veya iptal edin.", + "luna.discardBeforeSwitch": "Uygulanmamış değişiklikler silinip cihaz belleğindeki başka bir profile geçilsin mi?", "luna.angleSnap": "Düz çizgi düzeltme", "luna.competitiveMode": "Rekabetçi mod", "luna.trackingMode": "İzleme modu", diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 2e0d7f36..b4b2eab6 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index"; export const vi: Record = { + "luna.bottomButton": "Nút dưới đáy", + "luna.bottomPower": "Nhấn ngắn: bật/tắt nguồn", + "luna.bottomPowerDpi": "Nhấn lâu: bật/tắt nguồn / nhấn ngắn: DPI", + "luna.bottomButtonNote": "Lưu trong hồ sơ trên chuột hiện tại. Nhấn Áp dụng để lưu.", + "luna.dpiStageCount": "Số mức DPI", + "luna.dpiStageNote": "Nhấp ô màu để đổi DPI. Sửa giá trị bên dưới, rồi nhấn Áp dụng để lưu.", + "luna.receiverLight": "Đèn bộ thu", + "luna.lightMixed": "Chế độ kết hợp", + "luna.lightBattery": "Chế độ pin", + "luna.lightDpi": "Chế độ DPI", + "luna.receiverLightNote": "Áp dụng cho bộ thu 8K, độc lập với hồ sơ trên chuột và hồ sơ trò chơi.", + "luna.physicalButtons": "Trái|Phải|Giữa|Lùi|Tiến", + "luna.buttonActions": "Tắt|Nhấp trái|Nhấp phải|Nhấp giữa|Lùi|Tiến|Cuộn lên|Cuộn xuống|Chuyển DPI|Tăng DPI|Giảm DPI|Trình phát media|Phát / tạm dừng|Bài tiếp theo|Bài trước|Tăng âm lượng|Giảm âm lượng|Tắt tiếng|Dừng|Máy tính|Máy tính này|Trang chủ trình duyệt|Email|Tăng độ sáng|Giảm độ sáng", + "luna.mappingNote": "Gán nút được lưu trong cấu hình tích hợp hiện tại. Nút trái được bảo vệ; macro có sẵn và gán không xác định chỉ đọc. L/R là phím bổ trợ trái/phải.", + "luna.keyHint": "Đặt tiêu điểm ở đây rồi nhấn phím hoặc tổ hợp phím", + "luna.keyLabel": "Phím", + "luna.modifiers": "Phím bổ trợ", + "luna.keyUnsupported": "Phím này không được hỗ trợ. Hãy chọn từ danh sách.", + "luna.buttonPollingRate": "Tần số quét nút", + "luna.buttonPollingNote": "Độc lập với tần số báo cáo USB.", + "luna.separateAxes": "DPI X/Y riêng biệt", + "luna.stageYDpi": "Mức {n} · DPI Y", + "luna.profileSwitchPending": "Áp dụng hoặc hủy chuyển hồ sơ trước khi sửa cài đặt.", + "luna.discardBeforeSwitch": "Bỏ các thay đổi chưa áp dụng và chuyển hồ sơ trên chuột?", "set.turkish": "Türkçe", "luna.angleSnap": "Hiệu chỉnh đường thẳng", "luna.competitiveMode": "Chế độ thi đấu", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 61e4dc26..59b9d659 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1,6 +1,30 @@ import type { I18nKey } from "../index.ts"; export const zh: Record = { + "luna.bottomButton": "底部按键", + "luna.bottomPower": "短按开关机", + "luna.bottomPowerDpi": "长按开关机/短按 DPI", + "luna.bottomButtonNote": "保存在当前板载配置中,点击应用后生效。", + "luna.dpiStageCount": "档位数量", + "luna.dpiStageNote": "点击彩色档位块切换 DPI,在下方输入框修改数值。点击应用后保存到鼠标。", + "luna.receiverLight": "接收器灯光", + "luna.lightMixed": "混合模式", + "luna.lightBattery": "电量模式", + "luna.lightDpi": "DPI 模式", + "luna.receiverLightNote": "仅控制 8K 接收器灯光,不随板载配置或游戏配置切换。", + "luna.physicalButtons": "左键|右键|中键|后退侧键|前进侧键", + "luna.buttonActions": "禁用|左键点击|右键点击|中键点击|后退|前进|向上滚动|向下滚动|循环切换 DPI|提高 DPI|降低 DPI|媒体播放器|播放/暂停|下一曲|上一曲|提高音量|降低音量|静音|停止播放|计算器|此电脑|浏览器主页|电子邮件|提高亮度|降低亮度", + "luna.mappingNote": "映射保存在当前板载配置中。左键受保护,已有宏和未知绑定只读保留。L/R 表示左/右修饰键。", + "luna.keyHint": "点击此处并按键;也可在下方选择按键和修饰键", + "luna.keyLabel": "按键", + "luna.modifiers": "修饰键", + "luna.keyUnsupported": "不支持此按键,请从列表中选择。", + "luna.buttonPollingRate": "按键轮询率", + "luna.buttonPollingNote": "此设置独立于 USB 回报率。", + "luna.separateAxes": "独立 X/Y DPI", + "luna.stageYDpi": "第 {n} 档 · Y 轴 DPI", + "luna.profileSwitchPending": "请先应用或取消配置切换,再修改设置。", + "luna.discardBeforeSwitch": "丢弃尚未应用的修改并切换板载配置?", "set.turkish": "Türkçe", "luna.angleSnap": "直线修正", "luna.competitiveMode": "竞技模式", @@ -316,31 +340,31 @@ export const zh: Record = { "dpi.noLiftOff": "此配置文件格式没有抬起高度设置", "dpi.slotReadonly": "只读:将 DPI 挡位写入配置文件的功能尚未启用,因为写入序列还没有在硬件上验证过。", "dpi.slotNote": "每个挡位存储各自的 X/Y 灵敏度和抬起高度等级。{min}–{max} DPI,步进 {step}。高亮的挡位是鼠标开机时所使用的挡位。", - "dpi.stagesInUse": "已使用阶段", - "dpi.stagesCount": "DPI 阶段数量", - "dpi.activeStage": "当前阶段", - "dpi.makeActive": "设为当前阶段", - "dpi.stageDpi": "阶段 {n} 的 DPI", - "dpi.stageDpiY": "阶段 {n} 的 Y 轴 DPI", + "dpi.stagesInUse": "已启用档位", + "dpi.stagesCount": "DPI 档位数量", + "dpi.activeStage": "当前档位", + "dpi.makeActive": "切换到此档位", + "dpi.stageDpi": "第 {n} 档 DPI", + "dpi.stageDpiY": "第 {n} 档 Y 轴 DPI", "dpi.axisX": "X 轴", "dpi.axisY": "Y 轴", "dpi.color": "颜色", - "dpi.stageColor": "阶段 {n} 的颜色", - "dpi.stageNoteCount": "预设和自定义会编辑高亮的阶段。{min}–{max} DPI。鼠标上的 DPI 按键会循环切换这些阶段。", - "dpi.stageNoteSteps": "预设和自定义会编辑高亮的阶段。{min}–{max} DPI,步进 {step}。鼠标上的 DPI 按键会循环切换全部 {total} 个阶段。", - "dpi.stageToggle": "Include this DPI stage", - "dpi.editorCountNote": "One row per DPI stage. Tick a row to include it in the DPI cycle; untick to disable it and keep the value for later. Click a number to make it the starting stage.", - "dpi.editorFixedNote": "One row per DPI stage, {total} fixed. Edit the value or slider to change it. Click a number to make it the active stage.", - "dpi.stagesReadOnly": "These DPI stages come from the mouse read-only, and cannot be edited here. Change them in the vendor software.", - "dpi.editorGenericNote": "One row per preset DPI. Tick a row (or click its number) to make it the current DPI; unticked rows are kept as picks for later.", - "dpi.singleOverwritesNote": "Save writes this DPI to all {total} stages and overwrites their values. Your old stages are kept in this browser. Switch back to Stages to restore them.", + "dpi.stageColor": "第 {n} 档的颜色", + "dpi.stageNoteCount": "预设和自定义会修改高亮档位的数值。{min}–{max} DPI。鼠标上的 DPI 按键会循环切换这些档位。", + "dpi.stageNoteSteps": "预设和自定义会修改高亮档位的数值。{min}–{max} DPI,步进 {step}。鼠标上的 DPI 按键会循环切换全部 {total} 个档位。", + "dpi.stageToggle": "启用此 DPI 档位", + "dpi.editorCountNote": "每行对应鼠标的一档 DPI。勾选或取消勾选可调整启用的档位数量;点击左侧编号选择档位。", + "dpi.editorFixedNote": "鼠标共有 {total} 个固定 DPI 档位。输入数值或拖动滑块可修改该档 DPI;点击左侧编号可切换到该档。", + "dpi.stagesReadOnly": "这些 DPI 档位只能读取,无法在此修改。请使用官方驱动调整。", + "dpi.editorGenericNote": "每行是一个快捷 DPI 预设,勾选或点击左侧编号可设置当前 DPI。这里显示的是快捷选项,不是鼠标保存的档位列表。", + "dpi.singleOverwritesNote": "保存会将此 DPI 写入全部 {total} 个档位并覆盖其数值。原来的档位保存在此浏览器中,切回“全部档位”可恢复。", "dpi.xAxis": "X 轴", "dpi.yAxis": "Y 轴", - "dpi.viewStages": "阶段", - "dpi.viewSingle": "单个", - "dpi.viewSlots": "挡位", - "dpi.viewSlotsToggle": "将 DPI 作为多个挡位或单个值编辑", - "dpi.viewToggle": "将 DPI 作为多个阶段或单个值编辑", + "dpi.viewStages": "全部档位", + "dpi.viewSingle": "当前 DPI", + "dpi.viewSlots": "全部档位", + "dpi.viewSlotsToggle": "切换全部档位或当前 DPI 视图", + "dpi.viewToggle": "切换全部档位或当前 DPI 视图", "perf.reportFrequency": "报告频率", "perf.reportRate": "回报率", "perf.sharedNote": "在此配置文件中作为一个共享区间存储,最高 {max} Hz。", diff --git a/src/shared/CardSlot.tsx b/src/shared/CardSlot.tsx index 2a379a88..b26298a5 100644 --- a/src/shared/CardSlot.tsx +++ b/src/shared/CardSlot.tsx @@ -44,6 +44,8 @@ const GLYPHS: Readonly> = { incott: Cog, "lunafury-buttons": Mouse, "lunafury-wheel": ShieldCheck, + "lunafury-bottom-button": ToggleRight, + "lunafury-receiver-light": Usb, "dongle-led": Usb, "atk-profile": Layers, "atk-receiver": Usb, diff --git a/src/styles/areas/lunafury.css b/src/styles/areas/lunafury.css new file mode 100644 index 00000000..c04a5cd5 --- /dev/null +++ b/src/styles/areas/lunafury.css @@ -0,0 +1,49 @@ +.dpi-card.lunafury-dpi-card.has-lift-off { grid-template-columns: minmax(0, 1fr); } +.dpi-card.lunafury-dpi-card.has-lift-off > .dpi-card-lift { border-inline-start: 0; border-top: 1px solid var(--line-1); padding: 16px 0 0; } +.lunafury-dpi-editor { display: grid; gap: 16px; min-width: 0; } +.lunafury-dpi-editor .stepper-slider { margin: 0; } +.lunafury-dpi-stage-bar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; } +.lunafury-dpi-stage-list { display: flex; flex-wrap: wrap; gap: 12px; min-width: 0; } +.lunafury-dpi-stage { display: grid; gap: 8px; width: 56px; min-width: 0; } +.lunafury-dpi-stage.stage-1 { --luna-stage-color: var(--luna-stage-1); } +.lunafury-dpi-stage.stage-2 { --luna-stage-color: var(--luna-stage-2); } +.lunafury-dpi-stage.stage-3 { --luna-stage-color: var(--luna-stage-3); } +.lunafury-dpi-stage.stage-4 { --luna-stage-color: var(--luna-stage-4); } +.lunafury-dpi-stage.stage-5 { --luna-stage-color: var(--luna-stage-5); } +.lunafury-dpi-stage.stage-6 { --luna-stage-color: var(--luna-stage-6); } +.lunafury-dpi-stage-tile { justify-self: center; width: 40px; height: 40px; padding: 0; border: 2px solid transparent; border-radius: var(--r-sm); background: var(--luna-stage-color); cursor: pointer; } +.lunafury-dpi-stage.is-active .lunafury-dpi-stage-tile { border-color: var(--text-1); } +.lunafury-dpi-stage-tile:hover:not(:disabled) { box-shadow: var(--focus); } +.lunafury-dpi-stage-value { display: grid; gap: 4px; min-width: 0; color: var(--text-3); font-size: var(--fs-meta); text-align: center; } +.lunafury-dpi-stage-value input { width: 100%; min-width: 0; padding: 8px 0; font-size: var(--fs-body); font-weight: 600; text-align: center; font-variant-numeric: tabular-nums; appearance: textfield; } +.lunafury-dpi-stage-value input::-webkit-inner-spin-button { appearance: none; } +.lunafury-dpi-count { display: grid; gap: 8px; flex: 0 0 80px; margin-inline-start: auto; color: var(--text-3); font-size: var(--fs-meta); } +.lunafury-dpi-count .option-menu { width: 100%; } +.lunafury-dpi-editor .setting-note { margin: 0; } +.lunafury-dpi-editor :disabled { cursor: not-allowed; } + +#button-mapping-settings.luna-button-mapping { container: luna-remap / inline-size; } +.luna-map-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; min-width: 0; padding-block: 12px; border-top: 1px solid var(--line-1); } +.luna-map-item:first-child { border-top: 0; } +#button-mapping-settings .luna-map-item .button-map-row { grid-template-columns: minmax(96px, 1fr) minmax(0, 1fr); gap: 12px; border: 0; } +.luna-key-binding { min-width: 0; color: var(--text-3); font-size: var(--fs-meta); } +.luna-key-binding summary { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; padding: 8px 12px; border-radius: var(--r-sm); background: var(--btn); box-shadow: var(--btn-shadow); color: var(--text-2); cursor: pointer; list-style: none; } +.luna-key-binding summary::after { width: 6px; height: 6px; border-inline-end: 1px solid currentColor; border-bottom: 1px solid currentColor; content: ""; transform: rotate(45deg) translateY(-2px); } +[dir="rtl"] .luna-key-binding summary::after { transform: rotate(-45deg) translateY(-2px); } +.luna-key-binding[open] summary::after { transform: rotate(225deg) translateY(-2px); } +[dir="rtl"] .luna-key-binding[open] summary::after { transform: rotate(-225deg) translateY(-2px); } +.luna-key-binding summary:hover { background: var(--btn-hover); } +.luna-key-binding fieldset { display: grid; gap: 12px; min-width: 0; margin: 12px 0 0; padding: 16px; border: 0; border-radius: var(--r-md); background: var(--well); box-shadow: var(--inset-shadow); } +.luna-key-binding fieldset > label { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +.luna-key-binding select, .luna-key-binding button { min-height: 40px; max-width: 100%; } +.luna-key-binding :disabled { opacity: .45; cursor: not-allowed; } +.luna-key-modifiers, .luna-key-actions { display: flex; flex-wrap: wrap; gap: 8px 16px; } +.luna-key-modifiers label { display: flex; align-items: center; gap: 8px; } +.luna-key-binding output { overflow-wrap: anywhere; color: var(--text-1); } +.luna-map-item:has(.luna-key-binding[open]) { grid-template-columns: minmax(0, 1fr); } +.luna-key-binding[open] { grid-column: 1 / -1; } +.luna-key-binding[open] summary { width: fit-content; margin-inline-start: auto; } +@container luna-remap (max-width: 480px) { + .luna-map-item { grid-template-columns: minmax(0, 1fr); } + .luna-key-binding summary { width: fit-content; margin-inline-start: auto; } +} diff --git a/src/styles/index.css b/src/styles/index.css index 1092564b..425f4546 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -8,6 +8,7 @@ @import "./areas/performance.css"; @import "./areas/buttons.css"; +@import "./areas/lunafury.css"; @import "./areas/library.css"; @import "./areas/advanced.css"; @import "./areas/pages.css"; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 4ff5eb7f..cc4cab22 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,4 +1,11 @@ :root { + /* Fixed LunaFury stage identifiers, matching the device's indicator colours. */ + --luna-stage-1: #9e11f5; + --luna-stage-2: #07c160; + --luna-stage-3: #2b61fe; + --luna-stage-4: #24d5e2; + --luna-stage-5: #fdc34c; + --luna-stage-6: #ff00ff; --discord: #5865f2; --r-xs: 3px; --r-sm: 6px; diff --git a/vite.config.ts b/vite.config.ts index e68fc8a8..1c7c8bdb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,9 @@ export default defineConfig({ __BUILD_ID__: JSON.stringify(buildId), }, build: { + // Keep the JavaScript budget unchanged as device support grows. + minify: "terser", + terserOptions: { compress: { passes: 2 } }, rollupOptions: { input: { main: resolve(__dirname, "index.html"),