diff --git a/CHANGELOG.md b/CHANGELOG.md index 34331fb86..2f05c762b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ ### Fixed - Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance. +- Redact native workspace-fetch failures at the desktop console boundary so dependency-controlled local paths, tokens, and tool diagnostics cannot be copied into routine frontend logs. +- Redact dependency-controlled YouTube import failures at the desktop bridge so URLs, local paths, tokens, cookies, or tool diagnostics cannot be surfaced directly to the UI; users receive one safe next-action message instead. +- Removed the browser-only synthetic analysis workspace and timed fake success path. Analysis execution and project-save mutations now require the native Tauri runtime; browser-only execution fails closed instead of manufacturing `ready` rehearsal results or silently reporting an unpersisted save as successful. +- Reject native `demo` analysis before bridge invocation until a licensed demo track is installed, preventing the arrangement-only test fixture from becoming buyer-visible production success while directing musicians to local audio. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..13632ce75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Per-workspace and single-test: ```bash npm run test --workspace @bandscope/desktop # desktop suite (vitest + coverage) npm --workspace @bandscope/desktop exec vitest run src/lib/export.test.ts # one frontend test file -npm run dev --workspace @bandscope/desktop # Vite dev server (browser fallback mode) +npm run dev --workspace @bandscope/desktop # Vite dev server; browser-only analysis mutations fail closed npm run storybook --workspace @bandscope/desktop # component workbench uv run --project services/analysis-engine pytest tests/test_chords.py # one Python test file (no coverage gate) @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands. Outside Tauri, analysis mutations fail closed with a stable runtime-unavailable error, workspace reads return `null`, and passive subscriptions are no-ops; browser development must never fabricate completed jobs, workspace state, or rehearsal results. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..65e60460b 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1150,7 +1150,7 @@ describe("App", () => { expect(input).not.toHaveAttribute("aria-describedby"); }); - it("handles YouTube import failure with a message", async () => { + it("redacts dependency-controlled YouTube import failure messages", async () => { tauriInvoke.mockRejectedValueOnce(new Error("This video is age restricted.")); render(); @@ -1163,14 +1163,15 @@ describe("App", () => { await waitFor(() => { const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/This video is age restricted/i); + expect(alert).toHaveTextContent(/YouTube import failed\. Try again or choose a local audio file\./i); + expect(alert).not.toHaveTextContent(/This video is age restricted/i); expect(alert).toHaveAttribute("id", "selection-error"); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", alert.id); }); }); - it("handles generic exception during YouTube import", async () => { + it("redacts generic exceptions during YouTube import", async () => { tauriInvoke.mockRejectedValueOnce(new Error("Network Error")); render(); @@ -1182,8 +1183,11 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Network Error/i)).toBeTruthy(); + expect(screen.getByRole("alert")).toHaveTextContent( + /YouTube import failed\. Try again or choose a local audio file\./i + ); }); + expect(screen.queryByText(/Network Error/i)).toBeNull(); }); it("rejects empty YouTube URL", async () => { @@ -1511,7 +1515,7 @@ describe("App", () => { promptSpy.mockRestore(); }); - it("handles YouTube import failure with a missing message falling back to generic", async () => { + it("uses the same safe message when YouTube import failure omits details", async () => { tauriInvoke.mockRejectedValueOnce(new Error("")); render(); @@ -1523,7 +1527,9 @@ describe("App", () => { fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + expect(screen.getByRole("alert")).toHaveTextContent( + /YouTube import failed\. Try again or choose a local audio file\./i + ); }); }); diff --git a/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts new file mode 100644 index 000000000..70b1db5a4 --- /dev/null +++ b/apps/desktop/src/lib/analysis.browser-fail-closed.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { + createAnalysisJobStatus, + createDemoAnalysisJobRequest, + createDemoRehearsalSong +} from "@bandscope/shared-types"; + +import { getAnalysisJobStatus, saveProject, startAnalysisJob } from "./analysis"; + +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; + __TAURI_INVOKE__?: unknown; +}; + +const tauriWindow = window as TauriWindow; + +beforeEach(() => { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; +}); + +it("fails closed instead of synthesizing browser-only analysis success", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + + expect(status).toMatchObject({ + state: "failed", + error: { + code: "engine_unavailable", + message: "BandScope analysis requires the Tauri runtime" + } + }); + expect(status.result).toBeUndefined(); +}); + +it("does not retain a synthetic browser job after fail-closed analysis rejection", async () => { + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + const lookup = await getAnalysisJobStatus(status.jobId); + + expect(lookup).toMatchObject({ + state: "failed", + error: { + code: "not_found", + message: "Analysis job was not found." + } + }); + expect(lookup.result).toBeUndefined(); +}); + +it("fails closed instead of reporting browser-only project save success", async () => { + await expect(saveProject(createDemoRehearsalSong())).rejects.toThrow( + "Project save requires the Tauri runtime." + ); +}); + +it("does not send an unlicensed synthetic demo request to the native analysis bridge", async () => { + const nativeInvoke = vi.fn(async () => + createAnalysisJobStatus({ + jobId: "synthetic-native-demo", + state: "succeeded", + progressStage: "ready", + progressPercent: 100, + result: createDemoRehearsalSong() + }) + ); + tauriWindow.__TAURI_INVOKE__ = nativeInvoke; + + const status = await startAnalysisJob(createDemoAnalysisJobRequest()); + + expect(nativeInvoke).not.toHaveBeenCalled(); + expect(status).toMatchObject({ + state: "failed", + error: { + code: "engine_unavailable", + message: "Demo analysis is unavailable until a licensed demo track is installed. Choose a local audio file." + } + }); + expect(status.result).toBeUndefined(); +}); diff --git a/apps/desktop/src/lib/analysis.test.ts b/apps/desktop/src/lib/analysis.test.ts index e3347d1f5..a9acc5fb5 100644 --- a/apps/desktop/src/lib/analysis.test.ts +++ b/apps/desktop/src/lib/analysis.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createDemoAnalysisJobRequest, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { MAX_YOUTUBE_URL_LENGTH, getAnalysisJobStatus, - importYoutubeUrl, - startAnalysisJob + importYoutubeUrl } from "./analysis"; type TauriWindow = Window & { @@ -117,52 +116,6 @@ describe("analysis bridge", () => { expect(status.result?.sections[0]?.timeRange).toEqual({ start: 0, end: 1 }); }); - it("reports staged browser fallback progress before returning the demo result", async () => { - const queued = await startAnalysisJob(createDemoAnalysisJobRequest()); - - expect(queued).toMatchObject({ - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0 - }); - - const running = await getAnalysisJobStatus(queued.jobId); - expect(running).toMatchObject({ - state: "running", - progressLabel: "Decoding audio", - progressStage: "decode", - progressPercent: 20 - }); - - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Separating stems... (45%)", - progressStage: "separate", - progressPercent: 45 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Building rehearsal cues", - progressStage: "analyze", - progressPercent: 70 - }); - expect(await getAnalysisJobStatus(queued.jobId)).toMatchObject({ - state: "running", - progressLabel: "Saving reusable features", - progressStage: "persist", - progressPercent: 90 - }); - - const ready = await getAnalysisJobStatus(queued.jobId); - expect(ready).toMatchObject({ - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100 - }); - }); - it("ignores a non-function Tauri v1 invoke shim", async () => { (window as unknown as { __TAURI_INVOKE__?: unknown }).__TAURI_INVOKE__ = "not-callable"; diff --git a/apps/desktop/src/lib/analysis.ts b/apps/desktop/src/lib/analysis.ts index bb750b34b..2fcbfb87b 100644 --- a/apps/desktop/src/lib/analysis.ts +++ b/apps/desktop/src/lib/analysis.ts @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core"; import { createAnalysisJobStatus, createDemoAnalysisJobRequest, - createDemoRehearsalSong, createProjectBootstrapSummary, parseAnalysisJobStatus, parseAnalysisJobRequest, @@ -27,13 +26,6 @@ declare global { } } -const browserJobStore = new Map(); -const BROWSER_PROGRESS_STEPS = [ - { progressLabel: "Decoding audio", progressStage: "decode", progressPercent: 20 }, - { progressLabel: "Separating stems... (45%)", progressStage: "separate", progressPercent: 45 }, - { progressLabel: "Building rehearsal cues", progressStage: "analyze", progressPercent: 70 }, - { progressLabel: "Saving reusable features", progressStage: "persist", progressPercent: 90 } -] as const; const UNSUPPORTED_LOCAL_AUDIO_MESSAGE = "Choose a WAV, MP3, FLAC, or M4A file to start analysis."; const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ UNSUPPORTED_LOCAL_AUDIO_MESSAGE, @@ -42,6 +34,10 @@ const SAFE_LOCAL_AUDIO_MESSAGES = new Set([ "Could not prepare the local cache workspace.", "Could not prepare the local temp workspace." ]); +const BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE = "BandScope analysis requires the Tauri runtime"; +const DEMO_ANALYSIS_UNAVAILABLE_MESSAGE = + "Demo analysis is unavailable until a licensed demo track is installed. Choose a local audio file."; +const YOUTUBE_IMPORT_FAILED_MESSAGE = "YouTube import failed. Try again or choose a local audio file."; const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; const MAX_YOUTUBE_URL_LENGTH = 2000; @@ -111,21 +107,24 @@ function browserJobId(prefix: string): string { return `${prefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; } -/** Documented. */ +/** + * Handle browser-preview commands without fabricating analysis success. + * + * Source-selection preview behavior stays explicit for existing UI development, + * but analysis execution itself fails closed because only the Tauri runtime owns + * the production Python subprocess and validated job lifecycle. + */ async function browserFallback(command: string, args?: Record): Promise { if (command === "start_analysis_job") { parseAnalysisJobRequest(args?.request); - const jobId = browserJobId("browser-job"); - const queued = createAnalysisJobStatus({ - jobId, - state: "queued", - progressLabel: "Queued for analysis", - progressStage: "queued", - progressPercent: 0, - cacheStatus: "disabled" + return createAnalysisJobStatus({ + jobId: browserJobId("browser-unavailable-job"), + state: "failed", + error: { + code: "engine_unavailable", + message: BROWSER_ANALYSIS_UNAVAILABLE_MESSAGE + } }); - browserJobStore.set(jobId, queued); - return queued; } if (command === "select_local_audio_source") { @@ -133,51 +132,18 @@ async function browserFallback(command: string, args?: Record): } if (command === "get_analysis_job_status") { - const jobId = String(args?.jobId ?? ""); - const existing = browserJobStore.get(jobId); - if (!existing) { - return createAnalysisJobStatus({ - jobId, - state: "failed", - error: { - code: "not_found", - message: "Analysis job was not found." - } - }); - } - if (existing.state === "queued" || existing.state === "running") { - const currentPercent = existing.progressPercent ?? 0; - const nextStep = BROWSER_PROGRESS_STEPS.find((step) => step.progressPercent > currentPercent); - if (nextStep) { - const running = createAnalysisJobStatus({ - jobId, - state: "running", - requestedAt: existing.requestedAt, - progressLabel: nextStep.progressLabel, - progressStage: nextStep.progressStage, - progressPercent: nextStep.progressPercent, - cacheStatus: "disabled" - }); - browserJobStore.set(jobId, running); - return running; + return createAnalysisJobStatus({ + jobId: String(args?.jobId ?? ""), + state: "failed", + error: { + code: "not_found", + message: "Analysis job was not found." } - } - const succeeded = createAnalysisJobStatus({ - jobId, - state: "succeeded", - progressLabel: "Analysis ready", - progressStage: "ready", - progressPercent: 100, - cacheStatus: "disabled", - requestedAt: existing.requestedAt, - result: createDemoRehearsalSong() }); - browserJobStore.set(jobId, succeeded); - return succeeded; } if (command === "save_project") { - return; + throw new Error("Project save requires the Tauri runtime."); } if (command === "import_youtube_url") { @@ -260,6 +226,17 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise { + delete tauriWindow.__TAURI_INTERNALS__; + delete tauriWindow.__TAURI_INVOKE__; +}); + +it("does not expose dependency-controlled YouTube import errors to the UI", async () => { + tauriWindow.__TAURI_INVOKE__ = vi + .fn() + .mockRejectedValue( + new Error( + "yt-dlp failed for https://youtube.com/watch?v=4ozX4yFUC34 at C:\\Users\\Alice\\Videos token=super-secret" + ) + ); + + const selection = await importYoutubeUrl("https://youtube.com/watch?v=4ozX4yFUC34"); + + expect(selection).toEqual({ + ok: false, + error: { + code: "invalid_request", + message: "YouTube import failed. Try again or choose a local audio file." + } + }); + expect(JSON.stringify(selection)).not.toContain("Alice"); + expect(JSON.stringify(selection)).not.toContain("super-secret"); + expect(JSON.stringify(selection)).not.toContain("4ozX4yFUC34"); +}); diff --git a/apps/desktop/src/lib/job_runner.error-privacy.test.ts b/apps/desktop/src/lib/job_runner.error-privacy.test.ts new file mode 100644 index 000000000..a488196df --- /dev/null +++ b/apps/desktop/src/lib/job_runner.error-privacy.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => true), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { getWorkspaceState } from "./job_runner"; + +const invokeMock = vi.mocked(invoke); + +describe("workspace diagnostic privacy boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete window.__TAURI_INVOKE__; + }); + + it("does not copy native error details into the browser console", async () => { + invokeMock.mockRejectedValueOnce( + new Error("workspace failed at C:\\Users\\Alice\\private.song token=super-secret") + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(getWorkspaceState()).resolves.toBeNull(); + + expect(consoleError).toHaveBeenCalledWith("Failed to get workspace state."); + const rendered = consoleError.mock.calls.flat().join(" "); + expect(rendered).not.toContain("Alice"); + expect(rendered).not.toContain("super-secret"); + expect(rendered).not.toContain("private.song"); + consoleError.mockRestore(); + }); +}); diff --git a/apps/desktop/src/lib/job_runner.production.test.ts b/apps/desktop/src/lib/job_runner.production.test.ts new file mode 100644 index 000000000..beb7b052e --- /dev/null +++ b/apps/desktop/src/lib/job_runner.production.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => false), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +import { + cancelSong, + enqueueSong, + getWorkspaceState, + retrySong, + subscribeToWorkspaceUpdates, +} from "./job_runner"; + +describe("production analysis bridge boundary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["enqueue", () => enqueueSong({ sourceKind: "local_audio", sourceLabel: "song.wav" })], + ["retry", () => retrySong("job-one")], + ["cancel", () => cancelSong("job-one")], + ])("fails closed for %s outside the Tauri runtime", async (_name, operation) => { + await expect(operation()).rejects.toThrow("BandScope analysis requires the Tauri runtime"); + }); + + it("does not manufacture browser workspace state", async () => { + await expect(getWorkspaceState()).resolves.toBeNull(); + }); + + it("allows a passive browser subscription without synthetic workspace events", async () => { + const callback = vi.fn(); + const unsubscribe = await subscribeToWorkspaceUpdates(callback); + + expect(callback).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe("function"); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/lib/job_runner.ts b/apps/desktop/src/lib/job_runner.ts index b024ad5a4..271e43d06 100644 --- a/apps/desktop/src/lib/job_runner.ts +++ b/apps/desktop/src/lib/job_runner.ts @@ -2,17 +2,15 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { type RehearsalWorkspace, - type SongRehearsalPack, type AnalysisJobRequest, parseRehearsalWorkspace, isRehearsalWorkspace, - createDemoRehearsalSong } from "@bandscope/shared-types"; -/** Documented. */ +/** Receives validated workspace updates emitted by the native analysis runtime. */ export type WorkspaceUpdateCallback = (workspace: RehearsalWorkspace) => void; -/** Documented. */ +/** Narrow Tauri invocation boundary used by the desktop runtime. */ type TauriInvoke = (command: string, args?: Record) => Promise; declare global { @@ -21,7 +19,7 @@ declare global { } } -/** Documented. */ +/** Return the native Tauri invocation function when the desktop runtime is present. */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined" || !isTauri()) { return null; @@ -29,173 +27,60 @@ function getInvoke(): TauriInvoke | null { return window.__TAURI_INVOKE__ ?? invoke; } -const mockWorkspace: RehearsalWorkspace = { - id: "mock-ws", - title: "Browser Mock Workspace", - songs: [], - workspaceVersion: 1 -}; - -const mockSongsById = new Map( - mockWorkspace.songs.map(song => [song.id, song]) -); - -type MockListener = (event: { payload: unknown }) => void; -const mockListeners = new Set(); - -/** Documented. */ -function getMockSong(jobId: string): SongRehearsalPack | undefined { - return mockSongsById.get(jobId); -} - -/** - * Triggers a mock workspace update to all listeners. - */ -function triggerMockUpdate() { - const payload = structuredClone(mockWorkspace); - mockListeners.forEach(listener => listener({ payload })); -} - -/** Documented. */ -async function browserFallback(command: string, args?: Record): Promise { - if (command === "get_workspace_state") { - return structuredClone(mockWorkspace); - } - - if (command === "enqueue_song") { - const request = args?.request as AnalysisJobRequest; - const packId = `pack-${Date.now()}`; - const pack: SongRehearsalPack = { - id: packId, - packState: "queued", - sourceLabel: request.sourceKind === "local_audio" ? request.sourceLabel : "Demo Song", - engineState: "queued" - }; - mockWorkspace.songs.push(pack); - mockSongsById.set(pack.id, pack); - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - - setTimeout(() => { - // We use Object.assign to mutate the cached pack reference, avoiding an O(N) lookup. - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - - return; - } - - if (command === "retry_song") { - const jobId = args?.jobId as string; - const pack = getMockSong(jobId); - if (pack) { - pack.packState = "queued"; - pack.engineState = "queued"; - - triggerMockUpdate(); - - // Simulate processing - setTimeout(() => { - pack.packState = "analyzing"; - pack.engineState = "running"; - triggerMockUpdate(); - setTimeout(() => { - Object.assign(pack, { - packState: "ready", - engineState: "succeeded", - song: createDemoRehearsalSong() - }); - triggerMockUpdate(); - }, 2000); - }, 1000); - } - return; - } - - if (command === "cancel_song") { - const jobId = args?.jobId as string; - mockWorkspace.songs = mockWorkspace.songs.filter(p => p.id !== jobId); - mockSongsById.delete(jobId); - triggerMockUpdate(); - return; - } - - throw new Error(`Unknown analysis bridge command: ${command}`); -} - -/** Documented. */ +/** Execute a native analysis command or reject the unsupported browser-only surface. */ async function invokeRunner(command: string, args?: Record): Promise { const invokeCommand = getInvoke(); - if (invokeCommand) { - return invokeCommand(command, args); + if (!invokeCommand) { + throw new Error("BandScope analysis requires the Tauri runtime"); } - return browserFallback(command, args); + return invokeCommand(command, args); } -/** Documented. */ +/** Queue one analysis job in the native BandScope runtime. */ export async function enqueueSong(request: AnalysisJobRequest): Promise { await invokeRunner("enqueue_song", { request }); } -/** Documented. */ +/** Retry one existing native analysis job. */ export async function retrySong(jobId: string): Promise { await invokeRunner("retry_song", { jobId }); } -/** Documented. */ +/** Cancel one existing native analysis job. */ export async function cancelSong(jobId: string): Promise { await invokeRunner("cancel_song", { jobId }); } -/** Documented. */ +/** Subscribe to validated native workspace events without fabricating browser state. */ export async function subscribeToWorkspaceUpdates(callback: WorkspaceUpdateCallback): Promise { const invokeCommand = getInvoke(); - - if (invokeCommand) { - return listen("workspace-updated", (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); - } else { - // eslint-disable-next-line no-console -- Warn about invalid payload structure - console.warn("Received invalid workspace update from Tauri"); - } - }); - } else { - // Browser fallback - /** - * Internal listener for fallback mock updates. - */ - const listener: MockListener = (event) => { - if (isRehearsalWorkspace(event.payload)) { - callback(parseRehearsalWorkspace(event.payload)); - } - }; - mockListeners.add(listener); - return () => { - mockListeners.delete(listener); - }; + + if (!invokeCommand) { + return () => undefined; } + + return listen("workspace-updated", (event) => { + if (isRehearsalWorkspace(event.payload)) { + callback(parseRehearsalWorkspace(event.payload)); + } else { + // eslint-disable-next-line no-console -- Warn about invalid payload structure + console.warn("Received invalid workspace update from Tauri"); + } + }); } -/** Documented. */ +/** Return native workspace state, or null when the desktop runtime is unavailable. */ export async function getWorkspaceState(): Promise { + if (!getInvoke()) { + return null; + } try { const response = await invokeRunner("get_workspace_state"); if (!response) return null; return parseRehearsalWorkspace(response); - } catch (error) { - // eslint-disable-next-line no-console -- Error logging for workspace state fetch failure - console.error("Failed to get workspace state:", error instanceof Error ? error.message : "Unknown error"); + } catch { + // eslint-disable-next-line no-console -- Stable diagnostics exclude native error payloads. + console.error("Failed to get workspace state."); return null; } }