Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
261253d
test(desktop): reject browser-only synthetic analysis
seonghobae Aug 9, 2026
bddd841
fix(desktop): fail closed outside the Tauri analysis runtime
seonghobae Aug 9, 2026
e5e1b7b
docs(changelog): record truthful desktop analysis boundary
seonghobae Aug 9, 2026
6d6c7fd
fix(deps): align secure dependency baseline
seonghobae Aug 11, 2026
432da8a
fix(deps): align secure dependency baseline
seonghobae Aug 11, 2026
9aaff0e
fix(desktop): remove unrelated dependency drift
seonghobae Aug 14, 2026
a9063cb
docs(desktop): align browser runtime contract
seonghobae Aug 14, 2026
5066f18
test(desktop): reject synthetic analysis in active browser bridge
seonghobae Aug 16, 2026
380bac0
fix(desktop): fail closed in active browser analysis bridge
seonghobae Aug 16, 2026
6a976f5
test(desktop): retire obsolete synthetic browser progress contract
seonghobae Aug 16, 2026
038f05c
test(desktop): reject synthetic browser save success
seonghobae Aug 16, 2026
acd5d3d
fix(desktop): fail closed on browser-only project save
seonghobae Aug 16, 2026
fd16cbc
docs(changelog): record fail-closed browser project save
seonghobae Aug 16, 2026
66fe40f
chore(changelog): preserve unrelated release text
seonghobae Aug 16, 2026
d971e35
chore(changelog): restore release history ordering
seonghobae Aug 16, 2026
86b2426
test(desktop): redact YouTube import dependency errors
seonghobae Aug 16, 2026
c58ff8c
fix(desktop): redact YouTube import dependency errors
seonghobae Aug 16, 2026
52d7782
docs(changelog): record YouTube import error redaction
seonghobae Aug 16, 2026
66eb2d8
fix(changelog): restore historical release entry count
seonghobae Aug 16, 2026
76a9e64
test(desktop): redact workspace fetch diagnostics
seonghobae Aug 16, 2026
b722edd
fix(desktop): redact workspace fetch diagnostics
seonghobae Aug 16, 2026
f8112be
docs(changelog): record workspace diagnostic redaction
seonghobae Aug 16, 2026
f8d23d4
test(desktop): keep workspace privacy mock hoist-safe
seonghobae Aug 16, 2026
e169e6b
test(desktop): align YouTube error assertions with privacy boundary
seonghobae Aug 16, 2026
e42049a
test(desktop): reject unlicensed native demo analysis
seonghobae Aug 21, 2026
b0971e4
fix(desktop): fail closed unlicensed native demo analysis
seonghobae Aug 21, 2026
a2e5d26
docs(changelog): record unlicensed demo fail-closed boundary
seonghobae Aug 21, 2026
94612e3
repair(desktop): adopt protected dependency and workflow baseline
seonghobae Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.

Expand Down
18 changes: 12 additions & 6 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
Expand All @@ -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(<App />);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(<App />);
Expand All @@ -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
);
});
});

Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/lib/analysis.browser-fail-closed.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
51 changes: 2 additions & 49 deletions apps/desktop/src/lib/analysis.test.ts
Original file line number Diff line number Diff line change
@@ -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 & {
Expand Down Expand Up @@ -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";

Expand Down
100 changes: 38 additions & 62 deletions apps/desktop/src/lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { invoke } from "@tauri-apps/api/core";
import {
createAnalysisJobStatus,
createDemoAnalysisJobRequest,
createDemoRehearsalSong,
createProjectBootstrapSummary,
parseAnalysisJobStatus,
parseAnalysisJobRequest,
Expand All @@ -27,13 +26,6 @@ declare global {
}
}

const browserJobStore = new Map<string, AnalysisJobStatus>();
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,
Expand All @@ -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;

Expand Down Expand Up @@ -111,73 +107,43 @@ 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<string, unknown>): Promise<unknown> {
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") {
throw new Error(UNSUPPORTED_LOCAL_AUDIO_MESSAGE);
}

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") {
Expand Down Expand Up @@ -260,6 +226,17 @@ export async function startAnalysisJob(request: AnalysisJobRequest): Promise<Ana
});
}

if (parsedRequest.sourceKind === "demo" && getInvoke()) {
return createAnalysisJobStatus({
jobId: browserJobId("demo-unavailable-job"),
state: "failed",
error: {
code: "engine_unavailable",
message: DEMO_ANALYSIS_UNAVAILABLE_MESSAGE
}
});
}

const response = await invokeAnalysis("start_analysis_job", {
request: parsedRequest
});
Expand Down Expand Up @@ -330,13 +307,12 @@ export async function importYoutubeUrl(url: string): Promise<LocalAudioSelection
ok: true,
bootstrap: parseProjectBootstrapSummary(response)
};
} catch (error) {
const message = error instanceof Error ? error.message : (typeof error === "string" ? error : "YouTube import failed.");
} catch {
return {
ok: false,
error: {
code: "invalid_request",
message
message: YOUTUBE_IMPORT_FAILED_MESSAGE
}
};
}
Expand Down
Loading
Loading