From f7fda68be32f63676c049cce5c6f45dea5bb79e4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:56:09 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0]=20=EB=8C=80=EC=9A=A9=EB=9F=89=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=B0=EC=97=B4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 4 ++++ .../src/features/score/scoreStorage.test.ts | 24 +++++++++++++++++++ .../src/features/score/scoreStorage.ts | 14 +++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index d54cf10fc..281f0ee7f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -61,3 +61,7 @@ ## 2026-07-13 - Array.from mapping optimization **Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components. **Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations. + +## 2024-08-05 - Avoid .every() on large byte arrays +**Learning:** Using `Array.prototype.every()` on very large arrays (such as IPC byte arrays) incurs significant O(N) overhead due to callback allocation and invocation per element. +**Action:** Use a standard `for` loop with an early `break` for iterating over large arrays to avoid callback overhead and improve execution speed. diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 0feec199e..8c1c144d6 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -16,6 +16,30 @@ describe("scoreStorage bridge resolution", () => { delete tauriWindow.__TAURI_INVOKE__; }); + it("throws INVALID_RESPONSE_MESSAGE when readScorePdf returns an array with non-number elements (early exit)", async () => { + const mockInvoke = vi.fn().mockResolvedValue([1, 2, "not-a-number", 4]); + const tauriWindow = { + __TAURI_INVOKE__: mockInvoke + } as unknown as TauriWindow; + vi.stubGlobal("window", tauriWindow); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow("Invalid score bridge response"); + expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1", scoreId: "score-1" }); + }); + + it("returns Uint8Array when readScorePdf returns a valid number array", async () => { + const mockInvoke = vi.fn().mockResolvedValue([1, 2, 3, 4]); + const tauriWindow = { + __TAURI_INVOKE__: mockInvoke + } as unknown as TauriWindow; + vi.stubGlobal("window", tauriWindow); + + const result = await readScorePdf("project-1", "score-1"); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1", scoreId: "score-1" }); + }); + it("fails closed on every command when there is no window (non-browser runtime)", async () => { // Simulate a runtime without a DOM window (e.g. SSR / bundler prerender): // getInvoke() must take the `typeof window === "undefined"` branch and diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 492f12591..834a17d1a 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -91,8 +91,18 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< if (response instanceof ArrayBuffer) { return new Uint8Array(response); } - if (Array.isArray(response) && response.every((byte) => typeof byte === "number")) { - return Uint8Array.from(response as number[]); + if (Array.isArray(response)) { + // Performance: Avoid O(N) callback invocation overhead from .every() on large byte arrays. + let isValid = true; + for (let i = 0; i < response.length; i++) { + if (typeof response[i] !== "number") { + isValid = false; + break; + } + } + if (isValid) { + return Uint8Array.from(response as number[]); + } } throw new Error(INVALID_RESPONSE_MESSAGE); From b554c06a6177ac01d51c9b28a5eed235489bdbe5 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:51:03 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0]=20=EB=8C=80=EC=9A=A9=EB=9F=89=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=B0=EC=97=B4=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/package.json | 3 ++- package-lock.json | 36 +++++------------------------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e7685d6f0..11b85b0c5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,7 +25,8 @@ "react-dom": "^19.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "undici": "^7.29.0" }, "devDependencies": { "@storybook/react-vite": "^10.4.6", diff --git a/package-lock.json b/package-lock.json index cf1c991c1..233ab14b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,8 @@ "react-dom": "^19.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "undici": "^7.29.0" }, "devDependencies": { "@storybook/react-vite": "^10.4.6", @@ -955,7 +956,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -973,7 +973,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -991,7 +990,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1009,7 +1007,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1027,7 +1024,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1045,7 +1041,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1063,7 +1058,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1081,7 +1075,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1099,7 +1092,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1117,7 +1109,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1135,7 +1126,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1153,7 +1143,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1171,7 +1160,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1189,7 +1177,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1207,7 +1194,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1225,7 +1211,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1243,7 +1228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1261,7 +1245,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1279,7 +1262,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1297,7 +1279,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1315,7 +1296,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1333,7 +1313,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1351,7 +1330,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1369,7 +1347,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1387,7 +1364,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1405,7 +1381,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -7179,10 +7154,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" From d916fe4990f7645940ee370c0ee440628fc2e261 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:41:11 +0900 Subject: [PATCH 3/7] chore: remove unrelated dependency and journal drift --- .jules/bolt.md | 4 ---- apps/desktop/package.json | 3 +-- package-lock.json | 36 +++++++++++++++++++++++++++++++----- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 281f0ee7f..d54cf10fc 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -61,7 +61,3 @@ ## 2026-07-13 - Array.from mapping optimization **Learning:** Using `Array.from({ length: N }).map(...)` creates an intermediate array of `undefined` values which requires memory allocation and garbage collection, adding O(N) unnecessary overhead in frequently re-rendered UI components. **Action:** Use `Array.from({ length: N }, (_, index) => ...)` to map elements directly during array creation, avoiding intermediate allocations. - -## 2024-08-05 - Avoid .every() on large byte arrays -**Learning:** Using `Array.prototype.every()` on very large arrays (such as IPC byte arrays) incurs significant O(N) overhead due to callback allocation and invocation per element. -**Action:** Use a standard `for` loop with an early `break` for iterating over large arrays to avoid callback overhead and improve execution speed. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 11b85b0c5..e7685d6f0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -25,8 +25,7 @@ "react-dom": "^19.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0", - "undici": "^7.29.0" + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@storybook/react-vite": "^10.4.6", diff --git a/package-lock.json b/package-lock.json index 233ab14b6..cf1c991c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,7 @@ "react-dom": "^19.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0", - "undici": "^7.29.0" + "tw-animate-css": "^1.4.0" }, "devDependencies": { "@storybook/react-vite": "^10.4.6", @@ -956,6 +955,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -973,6 +973,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -990,6 +991,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1007,6 +1009,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1024,6 +1027,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1041,6 +1045,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1058,6 +1063,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1075,6 +1081,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1092,6 +1099,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1109,6 +1117,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1126,6 +1135,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1143,6 +1153,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1160,6 +1171,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1177,6 +1189,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1194,6 +1207,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1211,6 +1225,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1228,6 +1243,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1245,6 +1261,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1262,6 +1279,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1279,6 +1297,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1296,6 +1315,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1313,6 +1333,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1330,6 +1351,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -1347,6 +1369,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1364,6 +1387,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1381,6 +1405,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -7154,9 +7179,10 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" From 9bda7c991200f30212e67bf2bf73ce64c4b9ec85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:43:10 +0900 Subject: [PATCH 4/7] test(score): reject non-byte numeric bridge values --- .../src/features/score/scoreStorage.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 8c1c144d6..328edd6e1 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -27,6 +27,23 @@ describe("scoreStorage bridge resolution", () => { expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1", scoreId: "score-1" }); }); + it.each([ + ["negative", -1], + ["above the byte range", 256], + ["fractional", 1.5], + ["NaN", Number.NaN], + ["infinite", Number.POSITIVE_INFINITY] + ])("rejects %s numeric values before Uint8Array coercion", async (_label, invalidByte) => { + const mockInvoke = vi.fn().mockResolvedValue([0, invalidByte, 255]); + const tauriWindow = { + __TAURI_INVOKE__: mockInvoke + } as unknown as TauriWindow; + vi.stubGlobal("window", tauriWindow); + + await expect(readScorePdf("project-1", "score-1")).rejects.toThrow("Invalid score bridge response"); + expect(mockInvoke).toHaveBeenCalledWith("read_score_pdf", { projectId: "project-1", scoreId: "score-1" }); + }); + it("returns Uint8Array when readScorePdf returns a valid number array", async () => { const mockInvoke = vi.fn().mockResolvedValue([1, 2, 3, 4]); const tauriWindow = { @@ -56,4 +73,4 @@ describe("scoreStorage bridge resolution", () => { BRIDGE_UNAVAILABLE_MESSAGE ); }); -}); +}); \ No newline at end of file From 5e825876c0f93aa1a03dd6c090ffd21d6dd0e5fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:54:39 +0900 Subject: [PATCH 5/7] fix(score): reject invalid numeric byte values --- .../src/features/score/scoreStorage.ts | 106 ++++++++++-------- 1 file changed, 57 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index 834a17d1a..c37236cb6 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -1,71 +1,78 @@ import { invoke } from "@tauri-apps/api/core"; -import type { ScoreAttachment } from "@bandscope/shared-types"; -type TauriInvoke = (command: string, args?: Record) => Promise; +const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; -type TauriBridgeWindow = Window & { - __TAURI_INTERNALS__?: { invoke?: unknown }; +type TauriInvoke = (command: string, args?: Record) => Promise; +type TauriWindow = Window & { + __TAURI_INTERNALS__?: unknown; __TAURI_INVOKE__?: TauriInvoke; }; -/** - * Attachment metadata plus the validated on-disk size reported by the - * desktop bridge when a score PDF is copied into the project workspace. - */ -export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; - -const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; -const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; - -/** - * Resolve the desktop invoke bridge following the same detection rules as - * the analysis bridge: prefer Tauri v2 internals, fall back to the legacy - * test/dev shim, and return null in plain browsers. - */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { return null; } - const bridgeWindow = window as TauriBridgeWindow; - if (bridgeWindow.__TAURI_INTERNALS__ && typeof bridgeWindow.__TAURI_INTERNALS__.invoke === "function") { - return invoke; + const tauriWindow = window as TauriWindow; + if (typeof tauriWindow.__TAURI_INVOKE__ === "function") { + return tauriWindow.__TAURI_INVOKE__; } - - if (typeof bridgeWindow.__TAURI_INVOKE__ === "function") { - return bridgeWindow.__TAURI_INVOKE__; + if (tauriWindow.__TAURI_INTERNALS__) { + return invoke; } - return null; } -/** - * Invoke a score storage command on the desktop bridge, failing closed with - * a stable error when no bridge is available (browser preview builds). - */ -async function invokeScoreCommand(command: string, args: Record): Promise { +async function invokeScoreCommand( + command: string, + args: Record +): Promise { const invokeCommand = getInvoke(); if (!invokeCommand) { throw new Error(BRIDGE_UNAVAILABLE_MESSAGE); } - return invokeCommand(command, args); } +function isValidIdentifier(value: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(value); +} + +function requireValidIdentifier(value: string, fieldName: string): void { + if (!isValidIdentifier(value)) { + throw new Error(`Invalid ${fieldName}`); + } +} + /** - * Open the native PDF picker and copy the validated score into the - * app-owned project workspace. Security Notes: the file path never crosses - * the IPC boundary from JS; the Rust command owns the dialog, validation - * (magic bytes, size cap, no symlinks), and the copy destination. + * Attach one PDF score to a song through the desktop bridge. + * + * Security Notes: project and song identifiers are validated before crossing + * the IPC boundary, and the Rust command owns path canonicalization, MIME + * validation, copying, and storage quotas. */ -export async function attachScorePdf(projectId: string, songId: string): Promise { +export async function attachScorePdf( + projectId: string, + songId: string +): Promise<{ id: string; fileName: string; fileSizeBytes: number } | null> { + requireValidIdentifier(projectId, "project identifier"); + requireValidIdentifier(songId, "song identifier"); + const response = await invokeScoreCommand("attach_score_pdf", { projectId, songId }); + if (response === null) { + return null; + } if ( typeof response !== "object" || response === null || - typeof (response as Record).scoreId !== "string" || - typeof (response as Record).fileName !== "string" || - typeof (response as Record).fileSizeBytes !== "number" + Array.isArray(response) || + !("scoreId" in response) || + !("fileName" in response) || + !("fileSizeBytes" in response) || + typeof response.scoreId !== "string" || + typeof response.fileName !== "string" || + typeof response.fileSizeBytes !== "number" ) { throw new Error(INVALID_RESPONSE_MESSAGE); } @@ -92,17 +99,18 @@ export async function readScorePdf(projectId: string, scoreId: string): Promise< return new Uint8Array(response); } if (Array.isArray(response)) { - // Performance: Avoid O(N) callback invocation overhead from .every() on large byte arrays. - let isValid = true; - for (let i = 0; i < response.length; i++) { - if (typeof response[i] !== "number") { - isValid = false; - break; + for (let index = 0; index < response.length; index += 1) { + const byte = response[index]; + if ( + typeof byte !== "number" || + !Number.isInteger(byte) || + byte < 0 || + byte > 255 + ) { + throw new Error(INVALID_RESPONSE_MESSAGE); } } - if (isValid) { - return Uint8Array.from(response as number[]); - } + return Uint8Array.from(response as number[]); } throw new Error(INVALID_RESPONSE_MESSAGE); @@ -119,4 +127,4 @@ export async function removeScorePdf(projectId: string, scoreId: string): Promis } return response; -} +} \ No newline at end of file From f9666e64b4864418303512b671f40c919b9e9c87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:55:50 +0900 Subject: [PATCH 6/7] fix(score): preserve bridge contract while validating bytes --- .../src/features/score/scoreStorage.ts | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/features/score/scoreStorage.ts b/apps/desktop/src/features/score/scoreStorage.ts index c37236cb6..e9bd89907 100644 --- a/apps/desktop/src/features/score/scoreStorage.ts +++ b/apps/desktop/src/features/score/scoreStorage.ts @@ -1,78 +1,71 @@ import { invoke } from "@tauri-apps/api/core"; - -const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; -const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; +import type { ScoreAttachment } from "@bandscope/shared-types"; type TauriInvoke = (command: string, args?: Record) => Promise; -type TauriWindow = Window & { - __TAURI_INTERNALS__?: unknown; + +type TauriBridgeWindow = Window & { + __TAURI_INTERNALS__?: { invoke?: unknown }; __TAURI_INVOKE__?: TauriInvoke; }; +/** + * Attachment metadata plus the validated on-disk size reported by the + * desktop bridge when a score PDF is copied into the project workspace. + */ +export type ScoreAttachResult = ScoreAttachment & { fileSizeBytes: number }; + +const BRIDGE_UNAVAILABLE_MESSAGE = "Score PDFs are only available in the desktop app."; +const INVALID_RESPONSE_MESSAGE = "Invalid score bridge response"; + +/** + * Resolve the desktop invoke bridge following the same detection rules as + * the analysis bridge: prefer Tauri v2 internals, fall back to the legacy + * test/dev shim, and return null in plain browsers. + */ function getInvoke(): TauriInvoke | null { if (typeof window === "undefined") { return null; } - const tauriWindow = window as TauriWindow; - if (typeof tauriWindow.__TAURI_INVOKE__ === "function") { - return tauriWindow.__TAURI_INVOKE__; - } - if (tauriWindow.__TAURI_INTERNALS__) { + const bridgeWindow = window as TauriBridgeWindow; + if (bridgeWindow.__TAURI_INTERNALS__ && typeof bridgeWindow.__TAURI_INTERNALS__.invoke === "function") { return invoke; } + + if (typeof bridgeWindow.__TAURI_INVOKE__ === "function") { + return bridgeWindow.__TAURI_INVOKE__; + } + return null; } -async function invokeScoreCommand( - command: string, - args: Record -): Promise { +/** + * Invoke a score storage command on the desktop bridge, failing closed with + * a stable error when no bridge is available (browser preview builds). + */ +async function invokeScoreCommand(command: string, args: Record): Promise { const invokeCommand = getInvoke(); if (!invokeCommand) { throw new Error(BRIDGE_UNAVAILABLE_MESSAGE); } - return invokeCommand(command, args); -} - -function isValidIdentifier(value: string): boolean { - return /^[A-Za-z0-9_-]+$/.test(value); -} -function requireValidIdentifier(value: string, fieldName: string): void { - if (!isValidIdentifier(value)) { - throw new Error(`Invalid ${fieldName}`); - } + return invokeCommand(command, args); } /** - * Attach one PDF score to a song through the desktop bridge. - * - * Security Notes: project and song identifiers are validated before crossing - * the IPC boundary, and the Rust command owns path canonicalization, MIME - * validation, copying, and storage quotas. + * Open the native PDF picker and copy the validated score into the + * app-owned project workspace. Security Notes: the file path never crosses + * the IPC boundary from JS; the Rust command owns the dialog, validation + * (magic bytes, size cap, no symlinks), and the copy destination. */ -export async function attachScorePdf( - projectId: string, - songId: string -): Promise<{ id: string; fileName: string; fileSizeBytes: number } | null> { - requireValidIdentifier(projectId, "project identifier"); - requireValidIdentifier(songId, "song identifier"); - +export async function attachScorePdf(projectId: string, songId: string): Promise { const response = await invokeScoreCommand("attach_score_pdf", { projectId, songId }); - if (response === null) { - return null; - } if ( typeof response !== "object" || response === null || - Array.isArray(response) || - !("scoreId" in response) || - !("fileName" in response) || - !("fileSizeBytes" in response) || - typeof response.scoreId !== "string" || - typeof response.fileName !== "string" || - typeof response.fileSizeBytes !== "number" + typeof (response as Record).scoreId !== "string" || + typeof (response as Record).fileName !== "string" || + typeof (response as Record).fileSizeBytes !== "number" ) { throw new Error(INVALID_RESPONSE_MESSAGE); } @@ -127,4 +120,4 @@ export async function removeScorePdf(projectId: string, scoreId: string): Promis } return response; -} \ No newline at end of file +} From 094af62a3820b6f6101aacaf01ff784fcbd72f70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:56:24 +0900 Subject: [PATCH 7/7] style(score): preserve source newline contract --- apps/desktop/src/features/score/scoreStorage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/score/scoreStorage.test.ts b/apps/desktop/src/features/score/scoreStorage.test.ts index 328edd6e1..c039e5bd5 100644 --- a/apps/desktop/src/features/score/scoreStorage.test.ts +++ b/apps/desktop/src/features/score/scoreStorage.test.ts @@ -73,4 +73,4 @@ describe("scoreStorage bridge resolution", () => { BRIDGE_UNAVAILABLE_MESSAGE ); }); -}); \ No newline at end of file +});