diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..13e909d22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. The ready workspace names tonight's first playable range and lets the player copy that instrument check for the band chat. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..a836830f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,7 +82,7 @@ Last updated: 2026-03-11 - likely harmony by section and by role - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span, the next instrument check, and a copy of that check for the band chat - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..c66c5e156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Copy tonight's first instrument check from the ready rehearsal map so a player can paste it in the band chat before the first section. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..5a8c4c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, the next instrument check, and a copy of that check for the band chat. `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/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/features/workspace/Workspace.copy-request-order.test.tsx b/apps/desktop/src/features/workspace/Workspace.copy-request-order.test.tsx new file mode 100644 index 000000000..7c55b2037 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.copy-request-order.test.tsx @@ -0,0 +1,116 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; +const originalClipboard = navigator.clipboard; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} + +function setNavigatorLanguage(language: string): void { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace copy request ordering", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + vi.restoreAllMocks(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard + }); + Reflect.deleteProperty(document, "execCommand"); + }); + + it("keeps the newest overlapping copy result when an older request finishes later", async () => { + setNavigatorLanguage("en-US"); + const firstWrite = createDeferred(); + const secondWrite = createDeferred(); + const writeText = vi + .fn() + .mockImplementationOnce(() => firstWrite.promise) + .mockImplementationOnce(() => secondWrite.promise); + const execCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + writable: true + }); + + render(); + const copyButton = screen.getByRole("button", { name: "Copy tonight's first check" }); + fireEvent.click(copyButton); + fireEvent.click(copyButton); + + expect(writeText).toHaveBeenCalledTimes(2); + + secondWrite.resolve(); + await waitFor(() => { + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent( + "Copied. Paste it in the band chat before the first section." + ); + }); + + firstWrite.reject(new Error("older clipboard request blocked")); + await waitFor(() => { + expect(execCommand).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent( + "Copied. Paste it in the band chat before the first section." + ); + }); + + it("does not apply a pending copy result after the displayed sentence changes", async () => { + setNavigatorLanguage("en-US"); + const pendingWrite = createDeferred(); + const writeText = vi.fn().mockImplementationOnce(() => pendingWrite.promise); + const execCommand = vi.fn().mockReturnValue(false); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + writable: true + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy tonight's first check" })); + expect(writeText).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + expect(screen.getByTestId("first-range-squeeze")).toHaveTextContent( + "Lead Vocal sits G#3–C#5 in verse. Hear that clash on your instrument before the verse." + ); + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent(""); + + pendingWrite.reject(new Error("stale clipboard request blocked")); + await waitFor(() => { + expect(execCommand).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent(""); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..ffa21733c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalClipboard = navigator.clipboard; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -20,6 +21,10 @@ describe("Workspace", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard + }); Object.defineProperty(URL, "createObjectURL", { configurable: true, value: originalCreateObjectUrl @@ -28,6 +33,7 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + Reflect.deleteProperty(document, "execCommand"); }); it("updates practice progress immutably through onSongUpdate", () => { @@ -196,6 +202,76 @@ describe("Workspace", () => { ); }); + it("copies tonight's first check for the band chat", async () => { + setNavigatorLanguage("en-US"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy tonight's first check" })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse." + ); + }); + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent( + "Copied. Paste it in the band chat before the first section." + ); + }); + + it("names a next action when clipboard write is blocked without exposing the failure", async () => { + setNavigatorLanguage("en-US"); + const writeText = vi.fn().mockRejectedValue(new Error("/Users/md/secret-job.json")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue(false) + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy tonight's first check" })); + + await waitFor(() => { + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent( + "Clipboard is blocked in this window. Select tonight's first check and copy it before the first section." + ); + }); + expect(screen.queryByText(/secret-job/i)).toBeNull(); + }); + + it("copies the missing-range next action when no named span exists", async () => { + setNavigatorLanguage("en-US"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = song.sections[0]!.roles.map((role) => ({ + ...role, + range: { lowestNote: "", highestNote: "none" }, + overlapWarnings: [] + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy tonight's first check" })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section." + ); + }); + }); + it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); @@ -325,5 +401,27 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "오늘 첫 확인 복사" })).toBeTruthy(); + }); + + it("copies the Korean first-check sentence for the band chat", async () => { + setNavigatorLanguage("ko-KR"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "오늘 첫 확인 복사" })); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + "verse의 Bass Guitar은 C#2–E3이고 다른 파트와 겹칩니다. verse 들어가기 전에 그 충돌을 악기로 들어 보세요." + ); + }); + expect(screen.getByTestId("first-range-copy-status")).toHaveTextContent( + "복사했습니다. 첫 구간 전에 밴드 채팅에 붙여 넣으세요." + ); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..17a89c8e9 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,15 +1,16 @@ -import { useState, useMemo, memo, type MouseEvent } from "react"; +import { useState, useMemo, memo, useLayoutEffect, useRef, type MouseEvent } from "react"; import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { copyFirstRangeAction, type FirstRangeCopyResult } from "./copyFirstRangeAction"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; -import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4 } from "lucide-react"; +import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4, Copy } from "lucide-react"; interface WorkspaceProps { song: RehearsalSong; @@ -121,6 +122,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [copyStatus, setCopyStatus] = useState("idle"); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -163,6 +165,31 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const copyRequestRef = useRef({ sequence: 0, sentence: firstRangeCopy }); + + useLayoutEffect(() => { + copyRequestRef.current = { + sequence: copyRequestRef.current.sequence + 1, + sentence: firstRangeCopy + }; + setCopyStatus("idle"); + }, [firstRangeCopy]); + + /** Copy tonight's first instrument check for a band chat paste. */ + const handleCopyFirstRange = async () => { + const request = { + sequence: copyRequestRef.current.sequence + 1, + sentence: firstRangeCopy + }; + copyRequestRef.current = request; + const result = await copyFirstRangeAction(request.sentence); + if ( + copyRequestRef.current.sequence === request.sequence && + copyRequestRef.current.sentence === request.sentence + ) { + setCopyStatus(result); + } + }; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -308,6 +335,31 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp >

{t("workspaceFirstRangeTitle")}

{firstRangeCopy}

+
+ +

+ {copyStatus === "copied" + ? t("workspaceFirstRangeCopied") + : copyStatus === "unavailable" + ? t("workspaceFirstRangeCopyUnavailable") + : ""} +

+
diff --git a/apps/desktop/src/features/workspace/copyFirstRangeAction.test.ts b/apps/desktop/src/features/workspace/copyFirstRangeAction.test.ts new file mode 100644 index 000000000..104650b7d --- /dev/null +++ b/apps/desktop/src/features/workspace/copyFirstRangeAction.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyFirstRangeAction } from "./copyFirstRangeAction"; + +const firstCheck = "Bass Guitar sits C#2–E3 in verse. Hear that clash on your instrument before the verse."; + +/** jsdom no longer ships document.execCommand; install a local stub for the fallback path. */ +function stubExecCommand(implementation: (commandId: string) => boolean) { + const execCommand = vi.fn(implementation); + Object.defineProperty(document, "execCommand", { + configurable: true, + writable: true, + value: execCommand + }); + return execCommand; +} + +describe("copyFirstRangeAction", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(document, "execCommand"); + document.body.replaceChildren(); + }); + + it("fails closed on blank or non-string payloads without touching the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(true); + + expect(await copyFirstRangeAction(null, { writeText })).toBe("unavailable"); + expect(await copyFirstRangeAction("", { writeText })).toBe("unavailable"); + expect(await copyFirstRangeAction(" ", { writeText })).toBe("unavailable"); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("writes the exact first-check sentence through an injected writer", async () => { + const writeText = vi.fn().mockResolvedValue(true); + + expect(await copyFirstRangeAction(firstCheck, { writeText })).toBe("copied"); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(firstCheck); + }); + + it("treats a rejected writer as unavailable without exposing the failure", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("/Users/md/secret-job.json")); + + expect(await copyFirstRangeAction(firstCheck, { writeText })).toBe("unavailable"); + }); + + it("treats a false writer result as unavailable", async () => { + const writeText = vi.fn().mockResolvedValue(false); + + expect(await copyFirstRangeAction(firstCheck, { writeText })).toBe("unavailable"); + }); + + it("uses navigator.clipboard.writeText when no writer is injected", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + expect(await copyFirstRangeAction(firstCheck)).toBe("copied"); + expect(writeText).toHaveBeenCalledWith(firstCheck); + }); + + it("falls back to execCommand when the clipboard API is missing", async () => { + const execCommand = stubExecCommand(() => true); + vi.stubGlobal("navigator", {}); + + expect(await copyFirstRangeAction(firstCheck)).toBe("copied"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(document.body.querySelector("textarea")).toBeNull(); + }); + + it("restores the previously focused control after the execCommand fallback", async () => { + const trigger = document.createElement("button"); + trigger.textContent = "Copy tonight's first check"; + document.body.appendChild(trigger); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + + stubExecCommand(() => { + document.querySelector("textarea")?.focus(); + return true; + }); + vi.stubGlobal("navigator", {}); + + expect(await copyFirstRangeAction(firstCheck)).toBe("copied"); + expect(document.activeElement).toBe(trigger); + expect(document.body.querySelector("textarea")).toBeNull(); + }); + + it("reports unavailable when both clipboard surfaces fail", async () => { + stubExecCommand(() => false); + vi.stubGlobal("navigator", { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error("denied")) + } + }); + + expect(await copyFirstRangeAction(firstCheck)).toBe("unavailable"); + }); + + it("reports unavailable when execCommand is absent after clipboard failure", async () => { + vi.stubGlobal("navigator", { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error("denied")) + } + }); + + expect(await copyFirstRangeAction(firstCheck)).toBe("unavailable"); + }); +}); diff --git a/apps/desktop/src/features/workspace/copyFirstRangeAction.ts b/apps/desktop/src/features/workspace/copyFirstRangeAction.ts new file mode 100644 index 000000000..74cf0a45a --- /dev/null +++ b/apps/desktop/src/features/workspace/copyFirstRangeAction.ts @@ -0,0 +1,106 @@ +/** + * Copy tonight's first instrument check onto the local clipboard. + * + * The payload is the same buyer-visible sentence already shown on the ready + * map. Blank or non-string values fail closed instead of inventing a check. + * Clipboard failures stay redacted so the UI can name the next action without + * dumping implementation detail or local environment data. + */ + +/** Result of one bounded first-range clipboard request. */ +export type FirstRangeCopyResult = "copied" | "unavailable"; + +/** Narrow clipboard port so tests can inject success and failure without DOM authority. */ +export type ClipboardTextWriter = { + writeText: (text: string) => Promise; +}; + +/** Return whether untrusted copy text is a non-blank string. */ +function isNonBlankCopyText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +/** Use the platform clipboard API when a user-gesture writeText exists. */ +function clipboardApiWriter(): ClipboardTextWriter | null { + const clipboard = globalThis.navigator?.clipboard; + if (!clipboard || typeof clipboard.writeText !== "function") { + return null; + } + + return { + /** Write the exact validated buyer-visible sentence through the browser clipboard port. */ + writeText: async (text: string): Promise => { + await clipboard.writeText(text); + return true; + } + }; +} + +/** Fall back to a hidden textarea copy when the clipboard API is absent. */ +function execCommandWriter(): ClipboardTextWriter | null { + if (typeof document === "undefined" || typeof document.execCommand !== "function") { + return null; + } + + return { + /** Write the exact validated sentence through the bounded legacy DOM copy fallback. */ + writeText: async (text: string): Promise => { + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.setAttribute("aria-hidden", "true"); + textarea.tabIndex = -1; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + textarea.remove(); + if (previouslyFocused?.isConnected) { + try { + previouslyFocused.focus({ preventScroll: true }); + } catch { + // Focus restoration is best-effort and must not turn a successful copy into an error. + } + } + } + } + }; +} + +/** + * Write tonight's first-check sentence to the clipboard. + * + * Injected writers are exclusive so tests stay deterministic. Production + * tries the clipboard API first, then execCommand, and otherwise reports + * that copy is unavailable. + */ +export async function copyFirstRangeAction( + text: unknown, + writer?: ClipboardTextWriter +): Promise { + if (!isNonBlankCopyText(text)) { + return "unavailable"; + } + + const writers: ClipboardTextWriter[] = writer + ? [writer] + : [clipboardApiWriter(), execCommandWriter()].filter((candidate): candidate is ClipboardTextWriter => candidate !== null); + + for (const candidate of writers) { + try { + if (await candidate.writeText(text)) { + return "copied"; + } + } catch { + continue; + } + } + + return "unavailable"; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..55a1d6879 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,9 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceFirstRangeCopyAction": "Copy tonight's first check", + "workspaceFirstRangeCopied": "Copied. Paste it in the band chat before the first section.", + "workspaceFirstRangeCopyUnavailable": "Clipboard is blocked in this window. Select tonight's first check and copy it before the first section.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..5280326ec 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,9 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceFirstRangeCopyAction": "오늘 첫 확인 복사", + "workspaceFirstRangeCopied": "복사했습니다. 첫 구간 전에 밴드 채팅에 붙여 넣으세요.", + "workspaceFirstRangeCopyUnavailable": "이 창에서는 클립보드를 쓸 수 없습니다. 오늘 첫 확인을 선택한 다음 첫 구간 전에 복사하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 3cf5261b9..52232e02e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -23,7 +23,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c ## Exported rehearsal deliverables -- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model. +- BandScope should support cue-sheet or chart-style outputs derived from the same section and role model. The ready workspace also copies tonight's first instrument check onto the local clipboard so a player can paste that same next action in a band chat without opening a spreadsheet or JSON file. - Exported artifacts should stay compact and rehearsal-friendly rather than becoming DAW sessions or engraved scores. ## Delivery flow diff --git a/docs/doctoring/copy-first-range-action.md b/docs/doctoring/copy-first-range-action.md new file mode 100644 index 000000000..908ee3394 --- /dev/null +++ b/docs/doctoring/copy-first-range-action.md @@ -0,0 +1,51 @@ +# Copy tonight's first instrument check + +## Decision + +The ready workspace already names tonight's first playable range. A bandmate still had to retype that sentence into KakaoTalk, Discord, or Messages before rehearsal. `copyFirstRangeAction` now writes the same localized first-check sentence already shown on the map. Blank or non-string payloads fail closed. A blocked clipboard names the next action ("select the sentence and copy it") instead of inventing a check or dumping the failure. + +```mermaid +flowchart LR + A[Untrusted song payload] --> B[firstRangeSqueeze] + B --> C[Localized first-check sentence] + C --> D[copyFirstRangeAction] + D -->|named sentence| E[Clipboard write] + D -->|blank or blocked| F[Name the next copy action] +``` + +## Security Notes + +### Attack surface + +Copied text is derived from untrusted analysis payloads: role names, section labels, and scientific-pitch range labels. Clipboard write is a local user-gesture side channel. + +### Trust boundary + +`firstRangeSqueeze` remains the span authority. `copyFirstRangeAction` only writes a non-blank string that the workspace already rendered. It does not invent a span, does not read the clipboard, and does not dereference files or URLs. Clipboard errors are swallowed; the UI never renders exception text. + +### Logging and privacy + +This path does not add logging, telemetry, network transmission, or server-side retention. The copied sentence may identify a song part the user already sees on screen. Exposure follows wherever they paste it. + +### Mitigations + +- Reject blank, whitespace-only, and non-string payloads before any write. +- Prefer `navigator.clipboard.writeText` on a user click; fall back to a hidden `textarea` + `document.execCommand("copy")` only when that API is absent or rejects. +- Remove the fallback textarea immediately after the attempt. +- Do not log, toast, or render clipboard exception messages. +- Keep English and Korean next-action copy on the card; do not use implementation words such as clipboard API names in the buyer-visible sentence beyond the blocked-window fallback. + +### Test points + +- `copyFirstRangeAction.test.ts` proves fail-closed blank payloads, exact-text writes, redacted writer failures, clipboard API success, execCommand fallback, and unavailable when execCommand is absent. +- `Workspace.test.tsx` proves the button names tonight's first check, writes the clash sentence, copies the missing-range next action, localizes the Korean control, and hides clipboard errors. + +### Realistic threats + +A crafted role name could copy formula-shaped text into a chat. BandScope does not eval clipboard contents. Spreadsheet formula injection remains a CSV concern owned by the cue-sheet encoder, not this chat paste. + +A blocked WebView clipboard could otherwise fail silently. The card names the next action: select tonight's first check and copy it before the first section. + +### Remaining risk + +Some embedded WebViews still deny clipboard writes even after a user click. The fallback execCommand path is best-effort and may also fail. In that case the sentence remains visible on the card for manual copy.