From 3d649c8562b27b376a019840122e0ef9d1a2faff Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 24 May 2026 14:46:53 +0800 Subject: [PATCH 1/2] Add remote controls input modal --- package-lock.json | 11 + package.json | 1 + .../components/RemoteKeyboardModal.test.tsx | 281 ++++++++++++ .../components/home/ScanControls.test.tsx | 24 + src/__tests__/unit/lib/coreApi.test.ts | 32 ++ src/components/RemoteKeyboardModal.tsx | 423 ++++++++++++++++++ src/components/home/ScanControls.tsx | 40 +- src/components/wui/Segmented.tsx | 4 +- src/index.css | 184 ++++++++ src/lib/coreApi.ts | 84 ++++ src/lib/featureGates.ts | 5 + src/lib/models.ts | 17 + src/routes/-pages/Index.tsx | 8 + src/translations/en-US.json | 32 +- 14 files changed, 1134 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/unit/components/RemoteKeyboardModal.test.tsx create mode 100644 src/components/RemoteKeyboardModal.tsx diff --git a/package-lock.json b/package-lock.json index e9f52c2c..1df4b838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "react-dom": "^19.2.5", "react-hot-toast": "^2.6.0", "react-i18next": "^17.0.2", + "react-simple-keyboard": "^3.8.209", "react-swipeable": "^7.0.2", "regenerator-runtime": "0.14.1", "rollbar": "^3.1.0", @@ -14018,6 +14019,16 @@ } } }, + "node_modules/react-simple-keyboard": { + "version": "3.8.209", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.209.tgz", + "integrity": "sha512-z92Kod9lqFIgjXKYbWyxtRDLQGwJ5tdNneehh2Cn2cfG+60v+aeoemGFgF//fkYN1XgH84vHj3f0IoMEQPeX5w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/package.json b/package.json index 18ca70f5..fa9d0db3 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-dom": "^19.2.5", "react-hot-toast": "^2.6.0", "react-i18next": "^17.0.2", + "react-simple-keyboard": "^3.8.209", "react-swipeable": "^7.0.2", "regenerator-runtime": "0.14.1", "rollbar": "^3.1.0", diff --git a/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx b/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx new file mode 100644 index 00000000..a10e19f3 --- /dev/null +++ b/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@/test-utils"; +import userEvent from "@testing-library/user-event"; +import { useStatusStore } from "@/lib/store"; +import { RemoteKeyboardModal } from "@/components/RemoteKeyboardModal"; +import { CoreAPI } from "@/lib/coreApi"; + +interface KeyboardMockProps { + layout: Record; + layoutName: string; + display: Record; + onKeyPress: (button: string) => void; +} + +vi.mock("react-simple-keyboard", () => ({ + default: ({ layout, layoutName, display, onKeyPress }: KeyboardMockProps) => ( +
+ {layout[layoutName]?.flatMap((row) => + row.split(" ").map((button) => ( + + )), + )} +
+ ), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + error: vi.fn(), + }, +})); + +vi.mock("@/lib/coreApi", () => ({ + CoreAPI: { + inputKeyboard: vi.fn().mockResolvedValue(undefined), + screenshot: vi.fn().mockResolvedValue({ + path: "/media/fat/screenshots/MiSTer.png", + data: "iVBORw0KGgo=", + size: 12, + }), + }, +})); + +describe("RemoteKeyboardModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + useStatusStore.setState({ connected: true, corePlatform: null }); + }); + + it("should not render redundant description text", () => { + render(); + + expect( + screen.queryByText("remoteKeyboard.description"), + ).not.toBeInTheDocument(); + }); + + it("should size modal to content instead of fixed height", () => { + render(); + + expect(screen.getByRole("dialog").style.height).toBe(""); + }); + + it("should default to remote mode", () => { + render(); + + expect( + screen.getByRole("button", { name: "remoteKeyboard.ok" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "q" })).not.toBeInTheDocument(); + }); + + it("should send remote directional actions", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: "remoteKeyboard.up" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{up}" }); + }); + }); + + it("should send remote OK action", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: "remoteKeyboard.ok" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{enter}" }); + }); + }); + + it("should send generic remote actions when platform is unknown", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.menu" }), + ); + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.select" }), + ); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledTimes(2); + }); + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{f12}" }); + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ + keys: "{backspace}", + }); + }); + + it("should send MiSTer remote actions when platform is MiSTer", async () => { + const user = userEvent.setup(); + useStatusStore.setState({ corePlatform: "mister" }); + + render(); + + expect( + screen.getByRole("button", { name: "remoteKeyboard.osd" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "remoteKeyboard.core" }), + ).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.osd" }), + ); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{f12}" }); + }); + }); + + it("should send Batocera remote actions when platform is Batocera", async () => { + const user = userEvent.setup(); + useStatusStore.setState({ corePlatform: "batocera" }); + + render(); + + expect( + screen.getByRole("button", { name: "remoteKeyboard.minus" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "remoteKeyboard.equals" }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.context" }), + ); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ + keys: "{backspace}", + }); + }); + }); + + it("should capture and show an inline screenshot result", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("button", { + name: "remoteKeyboard.screenshotAction", + }), + ); + + await waitFor(() => { + expect(CoreAPI.screenshot).toHaveBeenCalled(); + }); + expect( + screen.queryByRole("radio", { name: "remoteKeyboard.screenshotMode" }), + ).not.toBeInTheDocument(); + expect(screen.getByAltText("remoteKeyboard.screenshotAlt")).toHaveAttribute( + "src", + "data:image/png;base64,iVBORw0KGgo=", + ); + expect( + screen.getByRole("link", { + name: "remoteKeyboard.screenshotDownload", + }), + ).toHaveAttribute("download", "MiSTer.png"); + + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.screenshotClear" }), + ); + + expect( + screen.queryByAltText("remoteKeyboard.screenshotAlt"), + ).not.toBeInTheDocument(); + }); + + it("should send literal key presses from keyboard mode", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("radio", { name: "remoteKeyboard.keyboardMode" }), + ); + await user.click(screen.getByRole("button", { name: "q" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "q" }); + }); + }); + + it("should send special key macros from keyboard mode", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("radio", { name: "remoteKeyboard.keyboardMode" }), + ); + await user.click(screen.getByRole("button", { name: "Enter" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{enter}" }); + }); + }); + + it("should send function key macros from keyboard mode", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("radio", { name: "remoteKeyboard.keyboardMode" }), + ); + await user.click(screen.getByRole("button", { name: "Fn" })); + await user.click(screen.getByRole("button", { name: "F12" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "{f12}" }); + }); + }); + + it("should escape literal brace input from keyboard mode", async () => { + const user = userEvent.setup(); + + render(); + + await user.click( + screen.getByRole("radio", { name: "remoteKeyboard.keyboardMode" }), + ); + await user.click(screen.getByRole("button", { name: "#+=" })); + await user.click(screen.getByRole("button", { name: "{" })); + + await waitFor(() => { + expect(CoreAPI.inputKeyboard).toHaveBeenCalledWith({ keys: "\\{" }); + }); + }); + + it("should not send remote actions when disconnected", async () => { + const user = userEvent.setup(); + useStatusStore.setState({ connected: false }); + + render(); + + await user.click( + screen.getByRole("button", { name: "remoteKeyboard.menu" }), + ); + + expect(CoreAPI.inputKeyboard).not.toHaveBeenCalled(); + expect(screen.getByText("remoteKeyboard.disconnected")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/unit/components/home/ScanControls.test.tsx b/src/__tests__/unit/components/home/ScanControls.test.tsx index 2573ae3f..ccd61e24 100644 --- a/src/__tests__/unit/components/home/ScanControls.test.tsx +++ b/src/__tests__/unit/components/home/ScanControls.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "../../../../test-utils"; +import userEvent from "@testing-library/user-event"; import { ScanControls } from "../../../../components/home/ScanControls"; import { ScanResult } from "../../../../lib/models"; import { Capacitor } from "@capacitor/core"; @@ -70,6 +71,29 @@ describe("ScanControls", () => { expect(cameraButton).not.toBeDisabled(); }); + it("renders remote keyboard button when handler is provided", async () => { + const user = userEvent.setup(); + const onRemoteKeyboard = vi.fn(); + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); + + render( + , + ); + + const keyboardButton = screen.getByRole("button", { + name: /scan\.remoteKeyboard/i, + }); + expect(keyboardButton).toBeInTheDocument(); + + await user.click(keyboardButton); + + expect(onRemoteKeyboard).toHaveBeenCalledTimes(1); + }); + it("does not render scan spinner on web platform", () => { vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); diff --git a/src/__tests__/unit/lib/coreApi.test.ts b/src/__tests__/unit/lib/coreApi.test.ts index 478e751e..6c3a5122 100644 --- a/src/__tests__/unit/lib/coreApi.test.ts +++ b/src/__tests__/unit/lib/coreApi.test.ts @@ -155,6 +155,38 @@ describe("CoreAPI", () => { }); }); + it("should send input.keyboard with keys params", () => { + CoreAPI.inputKeyboard({ keys: "abc{enter}" }).catch(() => { + // Ignore timeout errors + }); + + expect(mockSend).toHaveBeenCalledOnce(); + const sentData = JSON.parse(mockSend.mock.calls[0][0]); + expect(sentData.method).toBe("input.keyboard"); + expect(sentData.params).toEqual({ keys: "abc{enter}" }); + }); + + it("should send input.gamepad with buttons params", () => { + CoreAPI.inputGamepad({ buttons: "^^vv<><>BA{start}" }).catch(() => { + // Ignore timeout errors + }); + + expect(mockSend).toHaveBeenCalledOnce(); + const sentData = JSON.parse(mockSend.mock.calls[0][0]); + expect(sentData.method).toBe("input.gamepad"); + expect(sentData.params).toEqual({ buttons: "^^vv<><>BA{start}" }); + }); + + it("should not queue input methods while disconnected", async () => { + CoreAPI.setWsInstance({ isConnected: false, send: mockSend }); + + await expect(CoreAPI.inputKeyboard({ keys: "a" })).rejects.toThrow( + "Request requires active connection", + ); + + expect(mockSend).not.toHaveBeenCalled(); + }); + it("should have readers method returning ReadersResponse type", () => { // Test that readers method exists and has proper typing expect(typeof CoreAPI.readers).toBe("function"); diff --git a/src/components/RemoteKeyboardModal.tsx b/src/components/RemoteKeyboardModal.tsx new file mode 100644 index 00000000..38756d45 --- /dev/null +++ b/src/components/RemoteKeyboardModal.tsx @@ -0,0 +1,423 @@ +import { useRef, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import toast from "react-hot-toast"; +import { + Camera, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Download, + House, + Loader2, + Undo2, + X, +} from "lucide-react"; +import Keyboard from "react-simple-keyboard"; +import "react-simple-keyboard/build/css/index.css"; +import { SlideModal } from "@/components/SlideModal"; +import { Segmented } from "@/components/wui/Segmented"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; + +type KeyboardLayoutName = "default" | "shift" | "symbols" | "fn"; +type RemoteKeyboardMode = "remote" | "keyboard"; + +type RemoteAction = { + labelKey: string; + keys: string; + ariaLabelKey?: string; + icon?: ReactElement; +}; +type ScreenshotResult = { path: string; data: string; size: number }; + +const layout: Record = { + default: [ + "1 2 3 4 5 6 7 8 9 0 {bksp}", + "q w e r t y u i o p", + "a s d f g h j k l {enter}", + "{shift} z x c v b n m , . /", + "{symbols} {fn} {space}", + ], + shift: [ + "! @ # $ % ^ & * ( ) {bksp}", + "Q W E R T Y U I O P", + "A S D F G H J K L {enter}", + "{default} Z X C V B N M ? ! {backslash}", + "{symbols} {fn} {space}", + ], + symbols: [ + "` ~ - _ = + [ ] {lbrace} {rbrace} {bksp}", + "; : {quote} {doublequote} < > {backslash} {pipe}", + "/ ? , . @ # $ % {enter}", + "{default} {shift} {fn} {space}", + ], + fn: [ + "{esc} {tab} {up} {enter}", + "{left} {down} {right} {bksp}", + "{f1} {f2} {f3} {f4} {f5} {f6}", + "{f7} {f8} {f9} {f10} {f11} {f12}", + "{default} {symbols} {space}", + ], +}; + +const display: Record = { + "{bksp}": "⌫", + "{enter}": "Enter", + "{shift}": "Shift", + "{default}": "ABC", + "{symbols}": "#+=", + "{fn}": "Fn", + "{space}": "Space", + "{esc}": "Esc", + "{tab}": "Tab", + "{up}": "↑", + "{down}": "↓", + "{left}": "←", + "{right}": "→", + "{f1}": "F1", + "{f2}": "F2", + "{f3}": "F3", + "{f4}": "F4", + "{f5}": "F5", + "{f6}": "F6", + "{f7}": "F7", + "{f8}": "F8", + "{f9}": "F9", + "{f10}": "F10", + "{f11}": "F11", + "{f12}": "F12", + "{lbrace}": "{", + "{rbrace}": "}", + "{quote}": "'", + "{doublequote}": '"', + "{backslash}": "\\", + "{pipe}": "|", +}; + +const specialKeyMap: Record = { + "{bksp}": "{backspace}", + "{enter}": "{enter}", + "{space}": "{space}", + "{esc}": "{esc}", + "{tab}": "{tab}", + "{up}": "{up}", + "{down}": "{down}", + "{left}": "{left}", + "{right}": "{right}", + "{f1}": "{f1}", + "{f2}": "{f2}", + "{f3}": "{f3}", + "{f4}": "{f4}", + "{f5}": "{f5}", + "{f6}": "{f6}", + "{f7}": "{f7}", + "{f8}": "{f8}", + "{f9}": "{f9}", + "{f10}": "{f10}", + "{f11}": "{f11}", + "{f12}": "{f12}", + "{lbrace}": "\\{", + "{rbrace}": "\\}", + "{quote}": "'", + "{doublequote}": '"', + "{backslash}": "\\\\", + "{pipe}": "|", +}; + +const layoutSwitches: Record = { + "{shift}": "shift", + "{default}": "default", + "{symbols}": "symbols", + "{fn}": "fn", +}; + +function escapeKeyboardMacro(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\{/g, "\\{") + .replace(/\}/g, "\\}"); +} + +function toKeyboardMacro(button: string): string | null { + if (button in layoutSwitches) return null; + return specialKeyMap[button] ?? escapeKeyboardMacro(button); +} + +function getScreenshotFileName(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() ?? "zaparoo-screenshot.png"; +} + +function getRemoteActions(platform: string | null): RemoteAction[] { + const normalizedPlatform = platform?.toLowerCase() ?? ""; + + if ( + normalizedPlatform.includes("mister") || + normalizedPlatform.includes("mistex") + ) { + return [ + { + labelKey: "remoteKeyboard.back", + keys: "{esc}", + icon: , + }, + { + labelKey: "remoteKeyboard.osd", + keys: "{f12}", + icon: , + }, + ]; + } + + if ( + normalizedPlatform.includes("batocera") || + normalizedPlatform.includes("emulationstation") + ) { + return [ + { + labelKey: "remoteKeyboard.back", + keys: "{esc}", + icon: , + }, + { labelKey: "remoteKeyboard.menu", keys: "{space}" }, + { labelKey: "remoteKeyboard.context", keys: "{backspace}" }, + { labelKey: "remoteKeyboard.minus", keys: "-" }, + { labelKey: "remoteKeyboard.equals", keys: "=" }, + ]; + } + + return [ + { + labelKey: "remoteKeyboard.back", + keys: "{esc}", + icon: , + }, + { labelKey: "remoteKeyboard.menu", keys: "{f12}" }, + { labelKey: "remoteKeyboard.start", keys: "{space}" }, + { labelKey: "remoteKeyboard.select", keys: "{backspace}" }, + ]; +} + +export function RemoteKeyboardModal(props: { + isOpen: boolean; + close: () => void; +}) { + const { t } = useTranslation(); + const connected = useStatusStore((state) => state.connected); + const corePlatform = useStatusStore((state) => state.corePlatform); + const [layoutName, setLayoutName] = useState("default"); + const [mode, setMode] = useState("remote"); + const [error, setError] = useState(null); + const [screenshot, setScreenshot] = useState(null); + const [capturingScreenshot, setCapturingScreenshot] = useState(false); + const sendQueueRef = useRef>(Promise.resolve()); + + const remoteActions = getRemoteActions(corePlatform); + const modeOptions: { value: RemoteKeyboardMode; label: string }[] = [ + { value: "remote", label: t("remoteKeyboard.remoteMode") }, + { value: "keyboard", label: t("remoteKeyboard.keyboardMode") }, + ]; + + const sendMacro = (keys: string) => { + if (!connected) { + const message = t("remoteKeyboard.disconnected"); + setError(null); + toast.error(message); + return; + } + + setError(null); + sendQueueRef.current = sendQueueRef.current + .catch(() => undefined) + .then(() => CoreAPI.inputKeyboard({ keys })) + .catch(() => { + const message = t("remoteKeyboard.sendError"); + setError(message); + toast.error(message); + }); + }; + + const handleKeyPress = (button: string) => { + const nextLayout = layoutSwitches[button]; + if (nextLayout) { + setLayoutName(nextLayout); + return; + } + + const macro = toKeyboardMacro(button); + if (macro) { + sendMacro(macro); + } + }; + + const handleScreenshot = () => { + if (!connected) { + const message = t("remoteKeyboard.disconnected"); + setError(null); + toast.error(message); + return; + } + + setError(null); + setCapturingScreenshot(true); + CoreAPI.screenshot() + .then((result) => { + setScreenshot(result); + }) + .catch(() => { + const message = t("remoteKeyboard.screenshotError"); + setError(message); + toast.error(message); + }) + .finally(() => setCapturingScreenshot(false)); + }; + + const screenshotUrl = screenshot + ? `data:image/png;base64,${screenshot.data}` + : null; + + return ( + +
+ {!connected && ( +

+ {t("remoteKeyboard.disconnected")} +

+ )} + {error && ( +

+ {error} +

+ )} + + {mode === "remote" ? ( +
+
+ + + + + +
+
+ {remoteActions.map((action) => ( + + ))} + +
+ {screenshot && screenshotUrl && ( +
+ {t("remoteKeyboard.screenshotAlt")} +

+ {screenshot.path} +

+
+ + + +
+
+ )} +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/home/ScanControls.tsx b/src/components/home/ScanControls.tsx index 498514b8..7f2c7cf4 100644 --- a/src/components/home/ScanControls.tsx +++ b/src/components/home/ScanControls.tsx @@ -1,6 +1,8 @@ import { useTranslation } from "react-i18next"; import { Capacitor } from "@capacitor/core"; -import { Camera } from "lucide-react"; +import { Camera, GamepadDirectional } from "lucide-react"; +import { GatedFeature } from "@/components/GatedFeature"; +import { useCoreFeature } from "@/hooks/useCoreFeature"; import { ScanResult } from "@/lib/models"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { ScanSpinner } from "../ScanSpinner"; @@ -29,6 +31,8 @@ interface ScanControlsProps { scanStatus: ScanResult; onScanButton: () => void; onCameraScan: () => void; + connected?: boolean; + onRemoteKeyboard?: () => void; } export function ScanControls({ @@ -36,10 +40,13 @@ export function ScanControls({ scanStatus, onScanButton, onCameraScan, + connected = false, + onRemoteKeyboard, }: ScanControlsProps) { const { t } = useTranslation(); const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable); const cameraAvailable = usePreferencesStore((state) => state.cameraAvailable); + const remoteInput = useCoreFeature("remoteInput"); const statusAnnouncement = getScanStatusAnnouncement( scanSession, @@ -76,14 +83,29 @@ export function ScanControls({
)} - {Capacitor.isNativePlatform() && cameraAvailable && ( -
-
)} diff --git a/src/components/wui/Segmented.tsx b/src/components/wui/Segmented.tsx index 45338065..88acdc52 100644 --- a/src/components/wui/Segmented.tsx +++ b/src/components/wui/Segmented.tsx @@ -3,6 +3,7 @@ import { useRef, type KeyboardEvent } from "react"; interface SegmentedProps { label: string; + labelHidden?: boolean; help?: string; options: { value: T; label: string }[]; value: T; @@ -11,6 +12,7 @@ interface SegmentedProps { export function Segmented({ label, + labelHidden = false, help, options, value, @@ -53,7 +55,7 @@ export function Segmented({ return (
- +
logger.warn("WebSocket send is not initialized"); } + private callConnected( + method: Method, + params?: unknown, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + return Promise.resolve({ cancelled: true }); + } + + if (!this.transport?.isConnected) { + return Promise.reject(new Error("Request requires active connection")); + } + + return this.call(method, params, signal); + } + call( method: Method, params?: unknown, @@ -634,6 +653,71 @@ class CoreApi { }); } + inputKeyboard(params: InputKeyboardRequest): Promise { + return new Promise((resolve, reject) => { + this.callConnected(Method.InputKeyboard, params) + .then(() => { + resolve(); + }) + .catch((error) => { + logger.error("Input keyboard API call failed:", error, { + category: "api", + action: "inputKeyboard", + severity: "error", + }); + reject(error); + }); + }); + } + + inputGamepad(params: InputGamepadRequest): Promise { + return new Promise((resolve, reject) => { + this.callConnected(Method.InputGamepad, params) + .then(() => { + resolve(); + }) + .catch((error) => { + logger.error("Input gamepad API call failed:", error, { + category: "api", + action: "inputGamepad", + severity: "error", + }); + reject(error); + }); + }); + } + + screenshot(): Promise { + return new Promise((resolve, reject) => { + this.callConnected(Method.Screenshot) + .then((response) => { + if ( + response && + typeof response === "object" && + "path" in response && + "data" in response && + "size" in response && + typeof response.path === "string" && + typeof response.data === "string" && + typeof response.size === "number" + ) { + resolve(response as ScreenshotResponse); + return; + } + + reject(new Error("Invalid screenshot response")); + }) + .catch((error) => { + logger.error("Screenshot API call failed:", error, { + category: "api", + action: "screenshot", + severity: "error", + }); + reject(error); + }); + }); + } + write( params: WriteRequest, signal?: AbortSignal, diff --git a/src/lib/featureGates.ts b/src/lib/featureGates.ts index 0eefdc54..065e5c0b 100644 --- a/src/lib/featureGates.ts +++ b/src/lib/featureGates.ts @@ -21,6 +21,11 @@ export const FEATURE_GATES: Record = { marquee: false, labelKey: "features.mediaScrapers", }, + remoteInput: { + since: "2.10.0", + marquee: true, + labelKey: "features.remoteInput", + }, }; export type FeatureId = keyof typeof FEATURE_GATES; diff --git a/src/lib/models.ts b/src/lib/models.ts index 951fa26c..f5ebee53 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -37,6 +37,9 @@ export enum Method { MediaScrapeStatus = "media.scrape.status", MediaScrapeCancel = "media.scrape.cancel", MediaScrapeResume = "media.scrape.resume", + InputKeyboard = "input.keyboard", + InputGamepad = "input.gamepad", + Screenshot = "screenshot", } export enum Notification { @@ -71,6 +74,20 @@ export interface WriteRequest { text: string; } +export interface InputKeyboardRequest { + keys: string; +} + +export interface InputGamepadRequest { + buttons: string; +} + +export interface ScreenshotResponse { + path: string; + data: string; + size: number; +} + export interface SearchParams { query: string; systems: string[]; diff --git a/src/routes/-pages/Index.tsx b/src/routes/-pages/Index.tsx index fa2fd3b3..ca30e7fa 100644 --- a/src/routes/-pages/Index.tsx +++ b/src/routes/-pages/Index.tsx @@ -20,6 +20,7 @@ import { LastScannedInfo } from "@/components/home/LastScannedInfo"; import { NowPlayingInfo } from "@/components/home/NowPlayingInfo"; import { HistoryModal } from "@/components/home/HistoryModal"; import { StopConfirmModal } from "@/components/home/StopConfirmModal"; +import { RemoteKeyboardModal } from "@/components/RemoteKeyboardModal"; import { useScanOperations } from "@/hooks/useScanOperations"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { usePageHeadingFocus } from "@/hooks/usePageHeadingFocus"; @@ -72,6 +73,7 @@ export function Index() { const [historyOpen, setHistoryOpen] = useState(false); const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [remoteKeyboardOpen, setRemoteKeyboardOpen] = useState(false); // Holds the deferred history-modal toggle that fires after the pro-purchase // modal closes. Tracked so we can cancel a pending toggle on unmount or // when another toggle arrives before the timer fires. @@ -201,6 +203,8 @@ export function Index() { scanStatus={scanStatus} onScanButton={handleScanButton} onCameraScan={handleCameraScan} + connected={connected} + onRemoteKeyboard={() => setRemoteKeyboardOpen(true)} />
@@ -224,6 +228,10 @@ export function Index() { historyData={history.data} /> + setRemoteKeyboardOpen(false)} + /> Date: Sun, 24 May 2026 15:17:01 +0800 Subject: [PATCH 2/2] Address remote controls review feedback --- .../integration/index-route.test.tsx | 17 ++++++ .../components/RemoteKeyboardModal.test.tsx | 53 +++++++++++++++++++ .../components/home/ScanControls.test.tsx | 23 ++++++++ src/__tests__/unit/lib/coreApi.test.ts | 42 +++++++++++++++ src/components/RemoteKeyboardModal.tsx | 15 +++++- src/components/wui/Segmented.tsx | 4 +- 6 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/__tests__/integration/index-route.test.tsx b/src/__tests__/integration/index-route.test.tsx index 46f6b6ab..187834d0 100644 --- a/src/__tests__/integration/index-route.test.tsx +++ b/src/__tests__/integration/index-route.test.tsx @@ -413,6 +413,23 @@ describe("Index Route Integration", () => { expect(mockScanOperationsState.handleCameraScan).toHaveBeenCalledTimes(1); }); + + it("should open controls modal when remote controls button is clicked", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click( + screen.getByRole("button", { name: /scan.remoteKeyboard/i }), + ); + + expect( + screen.getByRole("dialog", { name: "remoteKeyboard.title" }), + ).toBeInTheDocument(); + }); }); describe("Connection Status", () => { diff --git a/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx b/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx index a10e19f3..f5272439 100644 --- a/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx +++ b/src/__tests__/unit/components/RemoteKeyboardModal.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { useStatusStore } from "@/lib/store"; import { RemoteKeyboardModal } from "@/components/RemoteKeyboardModal"; import { CoreAPI } from "@/lib/coreApi"; +import toast from "react-hot-toast"; interface KeyboardMockProps { layout: Record; @@ -38,6 +39,7 @@ vi.mock("react-hot-toast", () => ({ vi.mock("@/lib/coreApi", () => ({ CoreAPI: { + reset: vi.fn(), inputKeyboard: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue({ path: "/media/fat/screenshots/MiSTer.png", @@ -50,6 +52,7 @@ vi.mock("@/lib/coreApi", () => ({ describe("RemoteKeyboardModal", () => { beforeEach(() => { vi.clearAllMocks(); + CoreAPI.reset(); useStatusStore.setState({ connected: true, corePlatform: null }); }); @@ -203,6 +206,56 @@ describe("RemoteKeyboardModal", () => { ).not.toBeInTheDocument(); }); + it("should show loading state while capturing screenshot", async () => { + const user = userEvent.setup(); + let resolveScreenshot: ( + value: Awaited>, + ) => void; + vi.mocked(CoreAPI.screenshot).mockReturnValueOnce( + new Promise((resolve) => { + resolveScreenshot = resolve; + }), + ); + + render(); + + const screenshotButton = screen.getByRole("button", { + name: "remoteKeyboard.screenshotAction", + }); + await user.click(screenshotButton); + + expect(screenshotButton).toBeDisabled(); + expect(screenshotButton).toHaveAttribute("aria-busy", "true"); + + resolveScreenshot!({ + path: "/media/fat/screenshots/loading.png", + data: "iVBORw0KGgo=", + size: 12, + }); + + await waitFor(() => { + expect(screenshotButton).not.toBeDisabled(); + }); + }); + + it("should show an error when screenshot capture fails", async () => { + const user = userEvent.setup(); + vi.mocked(CoreAPI.screenshot).mockRejectedValueOnce(new Error("failed")); + + render(); + + await user.click( + screen.getByRole("button", { + name: "remoteKeyboard.screenshotAction", + }), + ); + + expect( + await screen.findByText("remoteKeyboard.screenshotError"), + ).toBeInTheDocument(); + expect(toast.error).toHaveBeenCalledWith("remoteKeyboard.screenshotError"); + }); + it("should send literal key presses from keyboard mode", async () => { const user = userEvent.setup(); diff --git a/src/__tests__/unit/components/home/ScanControls.test.tsx b/src/__tests__/unit/components/home/ScanControls.test.tsx index ccd61e24..7b84a830 100644 --- a/src/__tests__/unit/components/home/ScanControls.test.tsx +++ b/src/__tests__/unit/components/home/ScanControls.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "../../../../test-utils"; import userEvent from "@testing-library/user-event"; import { ScanControls } from "../../../../components/home/ScanControls"; import { ScanResult } from "../../../../lib/models"; +import { useStatusStore } from "../../../../lib/store"; import { Capacitor } from "@capacitor/core"; // Mock Capacitor @@ -36,6 +37,7 @@ describe("ScanControls", () => { beforeEach(() => { vi.clearAllMocks(); + useStatusStore.setState({ coreVersion: null, coreVersionPending: false }); }); it("renders scan spinner when on native platform", () => { @@ -94,6 +96,27 @@ describe("ScanControls", () => { expect(onRemoteKeyboard).toHaveBeenCalledTimes(1); }); + it("disables remote keyboard button when Core is too old", () => { + const onRemoteKeyboard = vi.fn(); + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); + useStatusStore.setState({ + coreVersion: "2.9.0", + coreVersionPending: false, + }); + + render( + , + ); + + expect( + screen.getByRole("button", { name: /scan\.remoteKeyboard/i }), + ).toBeDisabled(); + }); + it("does not render scan spinner on web platform", () => { vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); diff --git a/src/__tests__/unit/lib/coreApi.test.ts b/src/__tests__/unit/lib/coreApi.test.ts index 6c3a5122..a14b66c7 100644 --- a/src/__tests__/unit/lib/coreApi.test.ts +++ b/src/__tests__/unit/lib/coreApi.test.ts @@ -177,6 +177,48 @@ describe("CoreAPI", () => { expect(sentData.params).toEqual({ buttons: "^^vv<><>BA{start}" }); }); + it("should resolve screenshot responses", async () => { + const promise = CoreAPI.screenshot(); + + expect(mockSend).toHaveBeenCalledOnce(); + const sentData = JSON.parse(mockSend.mock.calls[0][0]); + expect(sentData.method).toBe("screenshot"); + expect(sentData.params).toBeUndefined(); + + CoreAPI.processReceived({ + data: JSON.stringify({ + jsonrpc: "2.0", + id: sentData.id, + result: { + path: "/media/fat/screenshots/MiSTer.png", + data: "iVBORw0KGgo=", + size: 12, + }, + }), + } as MessageEvent).catch(() => undefined); + + await expect(promise).resolves.toEqual({ + path: "/media/fat/screenshots/MiSTer.png", + data: "iVBORw0KGgo=", + size: 12, + }); + }); + + it("should reject invalid screenshot responses", async () => { + const promise = CoreAPI.screenshot(); + const sentData = JSON.parse(mockSend.mock.calls[0][0]); + + CoreAPI.processReceived({ + data: JSON.stringify({ + jsonrpc: "2.0", + id: sentData.id, + result: { path: "/tmp/screenshot.png", data: "abc" }, + }), + } as MessageEvent).catch(() => undefined); + + await expect(promise).rejects.toThrow("Invalid screenshot response"); + }); + it("should not queue input methods while disconnected", async () => { CoreAPI.setWsInstance({ isConnected: false, send: mockSend }); diff --git a/src/components/RemoteKeyboardModal.tsx b/src/components/RemoteKeyboardModal.tsx index 38756d45..6209e2d0 100644 --- a/src/components/RemoteKeyboardModal.tsx +++ b/src/components/RemoteKeyboardModal.tsx @@ -18,6 +18,7 @@ import "react-simple-keyboard/build/css/index.css"; import { SlideModal } from "@/components/SlideModal"; import { Segmented } from "@/components/wui/Segmented"; import { CoreAPI } from "@/lib/coreApi"; +import { logger } from "@/lib/logger"; import { useStatusStore } from "@/lib/store"; type KeyboardLayoutName = "default" | "shift" | "symbols" | "fn"; @@ -230,8 +231,13 @@ export function RemoteKeyboardModal(props: { sendQueueRef.current = sendQueueRef.current .catch(() => undefined) .then(() => CoreAPI.inputKeyboard({ keys })) - .catch(() => { + .catch((error) => { const message = t("remoteKeyboard.sendError"); + logger.error(message, error, { + category: "api", + action: "remoteKeyboard.send", + severity: "error", + }); setError(message); toast.error(message); }); @@ -264,8 +270,13 @@ export function RemoteKeyboardModal(props: { .then((result) => { setScreenshot(result); }) - .catch(() => { + .catch((error) => { const message = t("remoteKeyboard.screenshotError"); + logger.error(message, error, { + category: "api", + action: "remoteKeyboard.screenshot", + severity: "error", + }); setError(message); toast.error(message); }) diff --git a/src/components/wui/Segmented.tsx b/src/components/wui/Segmented.tsx index 88acdc52..56d27ecb 100644 --- a/src/components/wui/Segmented.tsx +++ b/src/components/wui/Segmented.tsx @@ -55,7 +55,9 @@ export function Segmented({ return (
- +