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;
}
}