From f4ab32d8ad650f4c99cb8e7c8b839faa76417f5b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 14 Sep 2026 17:13:25 +0800 Subject: [PATCH 1/2] fix(library): show virtual systems in the system list - List Core virtual systems (custom launchers and MiSTer built-ins) in the Library tab's system list, keeping a system when it has a ZapScript or indexed media so real systems with no media stay hidden. - Add an includeLaunchables opt-in to CoreAPI.systems so other system pickers keep excluding virtual systems. - Open a sheet for a virtual system with Write and Launch actions instead of linking to the browse page, which Core rejects for virtual system IDs. - Disable Launch in the Library sheets unless Core is live-connected, so a launch no longer waits out the request timeout while reconnecting. - Share NFC write availability between the Library sheets through a useNfcWriteAvailable hook, and add library.zapScript and library.launchSystemError strings for all locales. --- .../library/LibraryMediaDetailsModal.test.tsx | 20 ++- src/__tests__/unit/coreApi.internals.test.ts | 25 +++ src/__tests__/unit/lib/systemFilters.test.ts | 35 ++++ .../unit/routes/library.index.test.tsx | 159 +++++++++++++++++- .../library/LibraryLaunchableModal.tsx | 128 ++++++++++++++ .../library/LibraryMediaDetailsModal.tsx | 29 ++-- src/hooks/useNfcWriteAvailable.ts | 24 +++ src/lib/coreApi.ts | 18 +- src/lib/systemFilters.ts | 17 +- src/routes/library.$system.tsx | 4 +- src/routes/library.index.tsx | 78 +++++++-- src/translations/de-DE.json | 2 + src/translations/en-GB.json | 2 + src/translations/en-US.json | 2 + src/translations/es-ES.json | 2 + src/translations/fr-FR.json | 2 + src/translations/ja-JP.json | 2 + src/translations/ko-KR.json | 2 + src/translations/nl-NL.json | 2 + src/translations/zh-CN.json | 2 + 20 files changed, 505 insertions(+), 50 deletions(-) create mode 100644 src/components/library/LibraryLaunchableModal.tsx create mode 100644 src/hooks/useNfcWriteAvailable.ts diff --git a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx index 26ab8ac7..38f21e91 100644 --- a/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx +++ b/src/__tests__/unit/components/library/LibraryMediaDetailsModal.test.tsx @@ -4,7 +4,7 @@ import { act, render, screen, waitFor, within } from "@/test-utils"; import { CoreAPI } from "@/lib/coreApi"; import type { MediaBrowseEntry, MediaMetaResponse } from "@/lib/models"; import { usePreferencesStore } from "@/lib/preferencesStore"; -import { useStatusStore } from "@/lib/store"; +import { ConnectionState, useStatusStore } from "@/lib/store"; import { LibraryMediaDetailsModal } from "@/components/library/LibraryMediaDetailsModal"; const mockRequestLibraryImage = vi.fn(); @@ -102,6 +102,7 @@ describe("LibraryMediaDetailsModal", () => { vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false); useStatusStore.setState({ connected: true, + connectionState: ConnectionState.CONNECTED, coreVersion: "2.15.0", coreVersionPending: false, }); @@ -314,6 +315,21 @@ describe("LibraryMediaDetailsModal", () => { ).toBeInTheDocument(); }); + it("should not launch while Core is reconnecting", async () => { + const user = userEvent.setup(); + const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue(); + useStatusStore.getState().setConnectionState(ConnectionState.RECONNECTING); + renderModal(); + + const launch = await screen.findByRole("button", { + name: "library.launch", + }); + expect(launch).toBeDisabled(); + await user.click(launch); + + expect(runSpy).not.toHaveBeenCalled(); + }); + it("should offer ZapScript and relative path before NFC writing", async () => { usePreferencesStore.setState({ nfcAvailable: true }); const user = userEvent.setup(); @@ -644,7 +660,7 @@ describe("LibraryMediaDetailsModal", () => { }); it("should disable launch while disconnected", async () => { - useStatusStore.setState({ connected: false }); + useStatusStore.getState().setConnectionState(ConnectionState.DISCONNECTED); renderModal(); expect( diff --git a/src/__tests__/unit/coreApi.internals.test.ts b/src/__tests__/unit/coreApi.internals.test.ts index 28af49ea..ec3fee71 100644 --- a/src/__tests__/unit/coreApi.internals.test.ts +++ b/src/__tests__/unit/coreApi.internals.test.ts @@ -134,6 +134,31 @@ describe("CoreAPI Internals", () => { expect(callSpy).toHaveBeenCalledWith(Method.Systems, { all: true }); }); + it("should keep virtual launchables when the caller opts in", async () => { + const steam = { + id: "virtual:steam", + name: "Steam", + mediaCount: 0, + zapScript: "**launch.system:steam", + }; + const callSpy = vi.spyOn(CoreAPI, "call").mockResolvedValue({ + systems: [ + { id: "snes", name: "Super Nintendo", mediaCount: 12 }, + steam, + ], + }); + + await expect( + CoreAPI.systems(undefined, { includeLaunchables: true }), + ).resolves.toEqual({ + systems: [ + { id: "snes", name: "Super Nintendo", mediaCount: 12 }, + steam, + ], + }); + expect(callSpy).toHaveBeenCalledWith(Method.Systems, undefined); + }); + it.each([ { name: "systems", diff --git a/src/__tests__/unit/lib/systemFilters.test.ts b/src/__tests__/unit/lib/systemFilters.test.ts index 0ed3ee4c..ab014652 100644 --- a/src/__tests__/unit/lib/systemFilters.test.ts +++ b/src/__tests__/unit/lib/systemFilters.test.ts @@ -3,6 +3,7 @@ import type { System } from "@/lib/models"; import { filterSystemCatalog, systemHasIndexedMedia, + systemIsLaunchable, systemManufacturers, systemReleaseYear, systemSubtitle, @@ -62,6 +63,40 @@ describe("system filters", () => { expect(systemHasIndexedMedia({})).toBe(true); }); + it("should keep virtual systems only when launchables are included", () => { + const countedSystems: System[] = [ + { id: "SNES", name: "Super Nintendo", mediaCount: 25 }, + { id: "3DO", name: "3DO", mediaCount: 0 }, + { + id: "winamp", + name: "Winamp", + mediaCount: 0, + zapScript: "zaparoo://winamp/Winamp", + }, + ]; + + const indexed = filterSystemCatalog(countedSystems, { + category: "all", + query: "", + }); + const withLaunchables = filterSystemCatalog(countedSystems, { + category: "all", + query: "", + includeLaunchables: true, + }); + + expect(indexed.systems.map((system) => system.id)).toEqual(["SNES"]); + expect(withLaunchables.systems.map((system) => system.id)).toEqual([ + "SNES", + "winamp", + ]); + expect(systemIsLaunchable({ zapScript: "zaparoo://winamp/Winamp" })).toBe( + true, + ); + expect(systemIsLaunchable({ zapScript: " " })).toBe(false); + expect(systemIsLaunchable({})).toBe(false); + }); + it("should combine manufacturer and release-period filters", () => { const result = filterSystemCatalog(systems, { category: "all", diff --git a/src/__tests__/unit/routes/library.index.test.tsx b/src/__tests__/unit/routes/library.index.test.tsx index b4d8d469..4a039721 100644 --- a/src/__tests__/unit/routes/library.index.test.tsx +++ b/src/__tests__/unit/routes/library.index.test.tsx @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import { render, screen } from "@/test-utils"; +import { act, render, screen, waitFor, within } from "@/test-utils"; import { seedActiveDevice } from "@/test-utils/deviceRegistry"; import { deviceRegistry } from "@/lib/devices/deviceRegistry"; import { CoreAPI } from "@/lib/coreApi"; -import { useStatusStore } from "@/lib/store"; +import { ConnectionState, useStatusStore } from "@/lib/store"; import { libraryBrowseScrollKey, useLibrarySessionStore, @@ -13,7 +13,8 @@ import { useTabSessionStore } from "@/lib/tabSessionStore"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { Library } from "@/routes/library.index"; -const { mockNavigate } = vi.hoisted(() => ({ +const { mockErrorToast, mockNavigate } = vi.hoisted(() => ({ + mockErrorToast: vi.fn(), mockNavigate: vi.fn(), })); @@ -56,17 +57,35 @@ vi.mock("@/hooks/useHaptics", () => ({ useHaptics: () => ({ impact: vi.fn() }), })); +vi.mock("@/lib/toastUtils", () => ({ + showRateLimitedErrorToast: mockErrorToast, +})); + +const WINAMP = { + id: "ab3cdefghijklmnopqrstuvwxy", + name: "Winamp", + category: "Other", + mediaCount: 0, + zapScript: "zaparoo://ab3cdefghijklmnopqrstuvwxy/Winamp", +}; + describe("Library index route", () => { beforeEach(async () => { vi.restoreAllMocks(); CoreAPI.reset(); mockNavigate.mockClear(); - usePreferencesStore.setState({ systemNameRegion: "auto" }); + mockErrorToast.mockClear(); + usePreferencesStore.setState({ + systemNameRegion: "auto", + nfcAvailable: false, + }); + useStatusStore.getState().setWriteQueue(""); useLibrarySessionStore.getState().reset(); useTabSessionStore.getState().reset(); await seedActiveDevice({ recordId: "device-a" }); useStatusStore.setState({ connected: true, + connectionState: ConnectionState.CONNECTED, coreVersion: "2.15.0", coreVersionPending: false, gamesIndex: { @@ -174,6 +193,138 @@ describe("Library index route", () => { ).not.toBeInTheDocument(); }); + it("should list virtual systems as actions without listing empty systems", async () => { + const systemsSpy = vi.spyOn(CoreAPI, "systems").mockResolvedValue({ + systems: [ + { id: "SNES", name: "Super Nintendo", mediaCount: 150 }, + { id: "3DO", name: "3DO", mediaCount: 0 }, + WINAMP, + ], + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Winamp" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "SNES" })).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Winamp" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("3DO")).not.toBeInTheDocument(); + expect(systemsSpy).toHaveBeenCalledWith(undefined, { + includeLaunchables: true, + }); + }); + + it("should not show the empty state when only virtual systems exist", async () => { + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ + systems: [{ id: "3DO", name: "3DO", mediaCount: 0 }, WINAMP], + }); + + render(); + + expect( + await screen.findByRole("button", { name: "Winamp" }), + ).toBeInTheDocument(); + expect(screen.queryByText("library.noSystems")).not.toBeInTheDocument(); + }); + + it("should launch a virtual system from its actions", async () => { + const user = userEvent.setup(); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false); + const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue(); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + await user.click( + within(dialog).getByRole("button", { name: "library.launch" }), + ); + + expect(runSpy).toHaveBeenCalledWith({ text: WINAMP.zapScript }); + expect(mockErrorToast).not.toHaveBeenCalled(); + }); + + it("should not launch a virtual system while Core is reconnecting", async () => { + const user = userEvent.setup(); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false); + const runSpy = vi.spyOn(CoreAPI, "run").mockResolvedValue(); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + act(() => { + useStatusStore + .getState() + .setConnectionState(ConnectionState.RECONNECTING); + }); + const launch = within(dialog).getByRole("button", { + name: "library.launch", + }); + expect(launch).toBeDisabled(); + await user.click(launch); + + expect(runSpy).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Winamp" })).toBeInTheDocument(); + }); + + it("should report a failed virtual system launch", async () => { + const user = userEvent.setup(); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(false); + vi.spyOn(CoreAPI, "run").mockRejectedValue(new Error("launch failed")); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + await user.click( + within(dialog).getByRole("button", { name: "library.launch" }), + ); + + await waitFor(() => + expect(mockErrorToast).toHaveBeenCalledWith("library.launchSystemError"), + ); + expect( + within(dialog).getByRole("button", { name: "library.launch" }), + ).toBeEnabled(); + }); + + it("should write a virtual system's ZapScript to a token", async () => { + const user = userEvent.setup(); + usePreferencesStore.setState({ nfcAvailable: true }); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + await user.click( + within(dialog).getByRole("button", { name: "library.write" }), + ); + + expect(useStatusStore.getState().writeQueue).toBe(WINAMP.zapScript); + }); + + it("should disable writing a virtual system without a writer", async () => { + const user = userEvent.setup(); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + const writerSpy = vi + .spyOn(CoreAPI, "hasWriteCapableReader") + .mockResolvedValue(false); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + + await waitFor(() => expect(writerSpy).toHaveBeenCalled()); + expect( + within(dialog).getByRole("button", { name: "library.write" }), + ).toBeDisabled(); + expect(useStatusStore.getState().writeQueue).toBe(""); + }); + it("should use the preferred regional system names", async () => { usePreferencesStore.setState({ systemNameRegion: "eu" }); vi.spyOn(CoreAPI, "systems").mockResolvedValue({ diff --git a/src/components/library/LibraryLaunchableModal.tsx b/src/components/library/LibraryLaunchableModal.tsx new file mode 100644 index 00000000..cab3bfc2 --- /dev/null +++ b/src/components/library/LibraryLaunchableModal.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronRightIcon, PlayIcon } from "lucide-react"; +import { CoreAPI, logRunFailure } from "@/lib/coreApi"; +import type { System } from "@/lib/models"; +import { ConnectionState, useStatusStore } from "@/lib/store"; +import { showRateLimitedErrorToast } from "@/lib/toastUtils"; +import { useNfcWriteAvailable } from "@/hooks/useNfcWriteAvailable"; +import { SlideModal } from "@/components/SlideModal"; +import { Button } from "@/components/wui/Button"; +import { ModalActionRail } from "@/components/wui/ModalActionRail"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { CreateIcon } from "@/lib/images"; +import { DetailRow } from "@/components/library/LibraryMediaDetailsModal"; + +/** + * Actions for a Core virtual system. It has no media to browse, so the Library + * offers its ZapScript directly for launching or writing to a token. + */ +export function LibraryLaunchableModal(props: { + isOpen: boolean; + close: () => void; + system: System | null; + deviceKey: string; +}) { + const { t } = useTranslation(); + // `connected` stays true while reconnecting so cached data remains usable. + // Launching needs a live socket, or it would sit out the request timeout. + const liveConnected = useStatusStore( + (state) => state.connectionState === ConnectionState.CONNECTED, + ); + const setWriteQueue = useStatusStore((state) => state.setWriteQueue); + const writeAvailable = useNfcWriteAvailable(props.deviceKey, props.isOpen); + const [launching, setLaunching] = useState(false); + const zapScript = props.system?.zapScript?.trim() ?? ""; + + const launch = async () => { + if (!zapScript || launching || !liveConnected) return; + setLaunching(true); + try { + await CoreAPI.run({ text: zapScript }); + } catch (error) { + logRunFailure("Failed to launch virtual system from Library", error, { + action: "launchLibraryLaunchable", + }); + showRateLimitedErrorToast(t("library.launchSystemError")); + } finally { + setLaunching(false); + } + }; + + const write = () => { + if (!zapScript || launching || !writeAvailable) return; + props.close(); + setWriteQueue(zapScript); + }; + + const footer = props.system ? ( + } + layout="responsive" + variant="text" + className="whitespace-nowrap" + disabled={!zapScript || !writeAvailable || launching} + onClick={write} + /> + } + primaryAction={ + + ); + } + return ( + beginSystemNavigation(system.id)} + onPointerUp={handleHapticPress} + className={rowClassName} + style={rowStyle} + > + {rowContent} ); })} @@ -371,6 +407,12 @@ export function Library() { bottomOffset="calc(var(--bottom-nav-base-height) + 1rem)" /> + setLaunchableOpen(false)} + system={launchableSystem} + deviceKey={deviceKey} + /> setOptionsOpen(false)} diff --git a/src/translations/de-DE.json b/src/translations/de-DE.json index 9a540fb9..055922ca 100644 --- a/src/translations/de-DE.json +++ b/src/translations/de-DE.json @@ -135,10 +135,12 @@ "technicalDetails": "Technische Details", "path": "Pfad", "launcher": "Launcher", + "zapScript": "ZapScript", "tags": "Tags", "launching": "Wird gestartet...", "launch": "Starten", "launchError": "Dieses Medium konnte nicht gestartet werden", + "launchSystemError": "Dieses System konnte nicht gestartet werden", "write": "Auf NFC-Tag schreiben", "writeAction": "Schreiben", "preparingWrite": "NFC-Schreibvorgang wird vorbereitet...", diff --git a/src/translations/en-GB.json b/src/translations/en-GB.json index ce80d07a..3cebf74e 100644 --- a/src/translations/en-GB.json +++ b/src/translations/en-GB.json @@ -135,10 +135,12 @@ "technicalDetails": "Technical details", "path": "Path", "launcher": "Launcher", + "zapScript": "ZapScript", "tags": "Tags", "launching": "Launching...", "launch": "Launch", "launchError": "Could not launch this media", + "launchSystemError": "Could not launch this system", "write": "Write to NFC tag", "writeAction": "Write", "preparingWrite": "Preparing NFC write...", diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 346628f1..eead7d85 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -135,10 +135,12 @@ "technicalDetails": "Technical details", "path": "Path", "launcher": "Launcher", + "zapScript": "ZapScript", "tags": "Tags", "launching": "Launching...", "launch": "Launch", "launchError": "Could not launch this media", + "launchSystemError": "Could not launch this system", "write": "Write to NFC tag", "writeAction": "Write", "preparingWrite": "Preparing NFC write...", diff --git a/src/translations/es-ES.json b/src/translations/es-ES.json index 5b41db04..36eec447 100644 --- a/src/translations/es-ES.json +++ b/src/translations/es-ES.json @@ -135,10 +135,12 @@ "technicalDetails": "Detalles técnicos", "path": "Ruta", "launcher": "Lanzador", + "zapScript": "ZapScript", "tags": "Etiquetas", "launching": "Iniciando...", "launch": "Iniciar", "launchError": "No se pudo iniciar este contenido multimedia", + "launchSystemError": "No se pudo iniciar este sistema", "write": "Escribir en etiqueta NFC", "writeAction": "Escribir", "preparingWrite": "Preparando escritura NFC...", diff --git a/src/translations/fr-FR.json b/src/translations/fr-FR.json index a3c3db8c..51acfdfa 100644 --- a/src/translations/fr-FR.json +++ b/src/translations/fr-FR.json @@ -135,10 +135,12 @@ "technicalDetails": "Détails techniques", "path": "Chemin", "launcher": "Lanceur", + "zapScript": "ZapScript", "tags": "Tags", "launching": "Lancement...", "launch": "Lancer", "launchError": "Impossible de lancer ce média", + "launchSystemError": "Impossible de lancer ce système", "write": "Écrire sur un tag NFC", "writeAction": "Écrire", "preparingWrite": "Préparation de l’écriture NFC...", diff --git a/src/translations/ja-JP.json b/src/translations/ja-JP.json index 05cd5a94..e45684b2 100644 --- a/src/translations/ja-JP.json +++ b/src/translations/ja-JP.json @@ -135,10 +135,12 @@ "technicalDetails": "技術的な詳細", "path": "パス", "launcher": "ランチャー", + "zapScript": "ZapScript", "tags": "タグ", "launching": "起動中...", "launch": "起動", "launchError": "このメディアを起動できませんでした", + "launchSystemError": "このシステムを起動できませんでした", "write": "NFCタグに書き込む", "writeAction": "書き込む", "preparingWrite": "NFC書き込みを準備中...", diff --git a/src/translations/ko-KR.json b/src/translations/ko-KR.json index 7eb4f2bf..12f81c11 100644 --- a/src/translations/ko-KR.json +++ b/src/translations/ko-KR.json @@ -135,10 +135,12 @@ "technicalDetails": "기술 세부 정보", "path": "경로", "launcher": "런처", + "zapScript": "ZapScript", "tags": "태그", "launching": "실행하는 중...", "launch": "실행", "launchError": "이 미디어를 실행하지 못했습니다", + "launchSystemError": "이 시스템을 실행하지 못했습니다", "write": "NFC 태그에 쓰기", "writeAction": "쓰기", "preparingWrite": "NFC 쓰기를 준비하는 중...", diff --git a/src/translations/nl-NL.json b/src/translations/nl-NL.json index 2d05d4b5..4585a398 100644 --- a/src/translations/nl-NL.json +++ b/src/translations/nl-NL.json @@ -135,10 +135,12 @@ "technicalDetails": "Technische details", "path": "Pad", "launcher": "Launcher", + "zapScript": "ZapScript", "tags": "Tags", "launching": "Starten...", "launch": "Starten", "launchError": "Deze media konden niet worden gestart", + "launchSystemError": "Dit systeem kon niet worden gestart", "write": "Naar NFC-tag schrijven", "writeAction": "Schrijven", "preparingWrite": "NFC-schrijfactie voorbereiden...", diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index 0d7bc702..105e90b4 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -135,10 +135,12 @@ "technicalDetails": "技术详细信息", "path": "路径", "launcher": "启动器", + "zapScript": "ZapScript", "tags": "标签", "launching": "正在启动...", "launch": "启动", "launchError": "无法启动此媒体", + "launchSystemError": "无法启动此系统", "write": "写入 NFC 标签", "writeAction": "写入", "preparingWrite": "正在准备 NFC 写入...", From a86360366ddf027caac5908d99cad5fb440c71b1 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 14 Sep 2026 21:18:53 +0800 Subject: [PATCH 2/2] fix(library): address review feedback on virtual systems - Only report a Core reader as write-capable while Core is live-connected, so a cached result no longer keeps Write enabled while reconnecting or disconnected. - Exclude every virtual system from CoreAPI.systems by default, including ones with a blank ZapScript, and add only non-blank ones when includeLaunchables is set. --- src/__tests__/unit/coreApi.internals.test.ts | 22 +++++++++++++++++ .../unit/routes/library.index.test.tsx | 24 +++++++++++++++++++ src/hooks/useNfcWriteAvailable.ts | 14 +++++++---- src/lib/coreApi.ts | 14 ++++++----- 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/__tests__/unit/coreApi.internals.test.ts b/src/__tests__/unit/coreApi.internals.test.ts index ec3fee71..e0c31c74 100644 --- a/src/__tests__/unit/coreApi.internals.test.ts +++ b/src/__tests__/unit/coreApi.internals.test.ts @@ -159,6 +159,28 @@ describe("CoreAPI Internals", () => { expect(callSpy).toHaveBeenCalledWith(Method.Systems, undefined); }); + it.each([ + { name: "by default", options: undefined }, + { + name: "when the caller opts in", + options: { includeLaunchables: true }, + }, + ])( + "should drop virtual systems with a blank ZapScript $name", + async ({ options }) => { + vi.spyOn(CoreAPI, "call").mockResolvedValue({ + systems: [ + { id: "snes", name: "Super Nintendo", mediaCount: 12 }, + { id: "virtual:blank", name: "Blank", zapScript: " " }, + ], + }); + + await expect(CoreAPI.systems(undefined, options)).resolves.toEqual({ + systems: [{ id: "snes", name: "Super Nintendo", mediaCount: 12 }], + }); + }, + ); + it.each([ { name: "systems", diff --git a/src/__tests__/unit/routes/library.index.test.tsx b/src/__tests__/unit/routes/library.index.test.tsx index 4a039721..cc07bbf6 100644 --- a/src/__tests__/unit/routes/library.index.test.tsx +++ b/src/__tests__/unit/routes/library.index.test.tsx @@ -325,6 +325,30 @@ describe("Library index route", () => { expect(useStatusStore.getState().writeQueue).toBe(""); }); + it("should disable writing a virtual system to a Core reader while reconnecting", async () => { + const user = userEvent.setup(); + vi.spyOn(CoreAPI, "systems").mockResolvedValue({ systems: [WINAMP] }); + vi.spyOn(CoreAPI, "hasWriteCapableReader").mockResolvedValue(true); + + render(); + await user.click(await screen.findByRole("button", { name: "Winamp" })); + const dialog = await screen.findByRole("dialog", { name: "Winamp" }); + const write = within(dialog).getByRole("button", { + name: "library.write", + }); + await waitFor(() => expect(write).toBeEnabled()); + + act(() => { + useStatusStore + .getState() + .setConnectionState(ConnectionState.RECONNECTING); + }); + expect(write).toBeDisabled(); + await user.click(write); + + expect(useStatusStore.getState().writeQueue).toBe(""); + }); + it("should use the preferred regional system names", async () => { usePreferencesStore.setState({ systemNameRegion: "eu" }); vi.spyOn(CoreAPI, "systems").mockResolvedValue({ diff --git a/src/hooks/useNfcWriteAvailable.ts b/src/hooks/useNfcWriteAvailable.ts index 958990cb..c40a3562 100644 --- a/src/hooks/useNfcWriteAvailable.ts +++ b/src/hooks/useNfcWriteAvailable.ts @@ -1,24 +1,28 @@ import { useQuery } from "@tanstack/react-query"; import { CoreAPI } from "@/lib/coreApi"; import { usePreferencesStore } from "@/lib/preferencesStore"; -import { useStatusStore } from "@/lib/store"; +import { ConnectionState, useStatusStore } from "@/lib/store"; /** * Whether a token can be written from here: on-device NFC, or a write-capable - * reader on the connected Core. Core is only asked while `enabled`. + * reader on the live-connected Core. Core is only asked while `enabled`. */ export function useNfcWriteAvailable( deviceKey: string, enabled: boolean, ): boolean { const nfcAvailable = usePreferencesStore((state) => state.nfcAvailable); - const connected = useStatusStore((state) => state.connected); + // A cached remote result must not keep writing enabled while Core is + // reconnecting or gone; the write queue cannot reach its reader then. + const liveConnected = useStatusStore( + (state) => state.connectionState === ConnectionState.CONNECTED, + ); const writeCapabilityQuery = useQuery({ queryKey: ["nfcWriteCapability", deviceKey], queryFn: () => CoreAPI.hasWriteCapableReader(), - enabled: enabled && connected && !nfcAvailable, + enabled: enabled && liveConnected && !nfcAvailable, staleTime: 60 * 1000, retry: false, }); - return nfcAvailable || writeCapabilityQuery.data === true; + return nfcAvailable || (liveConnected && writeCapabilityQuery.data === true); } diff --git a/src/lib/coreApi.ts b/src/lib/coreApi.ts index cd2a7f32..c8fbead3 100644 --- a/src/lib/coreApi.ts +++ b/src/lib/coreApi.ts @@ -1914,12 +1914,14 @@ class CoreApi { isSystem, ); // Virtual launchables execute ZapScript directly and never own media - // rows, so only callers that can launch or write them opt in. - const systems = options?.includeLaunchables - ? response.systems - : response.systems.filter( - (system) => !systemIsLaunchable(system), - ); + // rows, so only callers that can launch or write them opt in. A + // virtual system with a blank ZapScript is never listed. + const systems = response.systems.filter( + (system) => + !system.zapScript || + (options?.includeLaunchables === true && + systemIsLaunchable(system)), + ); const filteredResponse = { ...response, systems }; logger.debug(filteredResponse); resolve(filteredResponse);