diff --git a/src/main/clipboard-write.ts b/src/main/clipboard-write.ts new file mode 100644 index 0000000..213783e --- /dev/null +++ b/src/main/clipboard-write.ts @@ -0,0 +1,15 @@ +export const CLIPBOARD_WRITE_MAX_BYTES = 1024 * 1024; + +export function validateClipboardWriteText(value: unknown): string { + if (typeof value !== "string") { + throw new Error("text muss ein String sein"); + } + if (!value.trim()) { + throw new Error("text darf nicht leer sein"); + } + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { + throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); + } + return value; +} diff --git a/src/main/main.ts b/src/main/main.ts index 07a8d89..eb1641a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -24,6 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { migrateProductUserDataDirectory } from "./storage"; import { forceDarkNativeTheme } from "./native-theme"; +import { validateClipboardWriteText } from "./clipboard-write"; forceDarkNativeTheme(nativeTheme); @@ -42,8 +43,7 @@ function validatePlainObject(value: unknown, name: string): Record([ "realdebrid", "megadebrid-api", @@ -636,17 +636,11 @@ function registerIpcHandlers(): void { controller.updateSettings({ clipboardWatch: next }); updateClipboardWatcher(); return next; - }); - handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { - const text = validateString(rawText, "text"); - const bytes = Buffer.byteLength(text, "utf8"); - if (!text.trim()) { - throw new Error("text darf nicht leer sein"); - } - if (bytes > CLIPBOARD_WRITE_MAX_BYTES) { - throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`); - } - try { + }); + handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { + const text = validateClipboardWriteText(rawText); + const bytes = Buffer.byteLength(text, "utf8"); + try { clipboard.writeText(text); return true; } catch (error) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2c735a2..af9fb6e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -60,6 +60,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog"; import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; +import { LinkAddressesDialog } from "./ui/LinkAddressesDialog"; import { buildCollectorViewModel, type CollectorSourceTab @@ -4438,7 +4439,7 @@ export function App(): ReactElement { const onCopyOnlineBackupKey = async (): Promise => { if (!onlineBackupDialog?.key) return; try { - await navigator.clipboard.writeText(onlineBackupDialog.key); + if (!(await window.rd.writeClipboardText(onlineBackupDialog.key))) throw new Error("clipboard_write_rejected"); showToast("Online-Schlüssel kopiert", 2200); } catch { showToast("Schlüssel konnte nicht kopiert werden", 2600); @@ -4505,13 +4506,13 @@ export function App(): ReactElement { cancelLabel: "Schließen", details: details || undefined, detailsLabel: "Einträge anzeigen" - }); - if (copy && entries.length > 0) { - await navigator.clipboard.writeText(details); - showToast("Fehlerliste kopiert", 2600); - } - } catch (error) { - showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); + }); + if (copy && entries.length > 0) { + if (!(await window.rd.writeClipboardText(details))) throw new Error("clipboard_write_rejected"); + showToast("Fehlerliste kopiert", 2600); + } + } catch (error) { + showToast(String(error).includes("clipboard_write_rejected") ? "Kopieren fehlgeschlagen" : `Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); } }; @@ -4605,13 +4606,13 @@ export function App(): ReactElement { } }; - const onCopyRemoteDiagnosticsCode = async (): Promise => { + const onCopyRemoteDiagnosticsCode = async (): Promise => { if (!remoteDiag?.code) { return; - } - try { - await navigator.clipboard.writeText(remoteDiag.code); - showToast("Verbindungscode kopiert", 2200); + } + try { + if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected"); + showToast("Verbindungscode kopiert", 2200); } catch { showToast("Kopieren fehlgeschlagen", 2200); } @@ -6635,8 +6636,8 @@ export function App(): ReactElement { type="button" title={`${key.masked}\nMaskierte Kennung kopieren`} onClick={() => { - void navigator.clipboard.writeText(key.masked) - .then(() => showToast("Maskierte Kennung kopiert", 1800)) + void window.rd.writeClipboardText(key.masked) + .then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200)) .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); }} > @@ -6688,32 +6689,14 @@ export function App(): ReactElement { /> ) : null} {linkPopup ? ( - setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen"> -

{linkPopup.title}

-
- {linkPopup.links.map((link, i) => ( -
- - -
- ))} -
-
- {linkPopup.isPackage && ( - - )} - {linkPopup.isPackage && ( - - )} - -
-
+ setLinkPopup(null)} + onToast={showToast} + title={linkPopup.title} + writeClipboardText={window.rd.writeClipboardText} + /> ) : null} )} diff --git a/src/renderer/ui/LinkAddressesDialog.tsx b/src/renderer/ui/LinkAddressesDialog.tsx new file mode 100644 index 0000000..94c4cb5 --- /dev/null +++ b/src/renderer/ui/LinkAddressesDialog.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from "react"; +import { Dialog } from "./Dialog"; + +export interface LinkAddressesDialogProps { + title: string; + links: Array<{ name: string; url: string }>; + isPackage: boolean; + onClose: () => void; + writeClipboardText: (text: string) => Promise; + onToast: (message: string) => void; +} + +export function LinkAddressesDialog({ + title, + links, + isPackage, + onClose, + writeClipboardText, + onToast +}: LinkAddressesDialogProps): ReactElement { + const copy = async (text: string, successMessage: string): Promise => { + try { + const copied = await writeClipboardText(text); + onToast(copied === true ? successMessage : "Kopieren fehlgeschlagen"); + } catch { + onToast("Kopieren fehlgeschlagen"); + } + }; + + return ( + +

{title}

+
+ {links.map((link, index) => ( +
+ + +
+ ))} +
+
+ {isPackage ? ( + + ) : null} + {isPackage ? ( + + ) : null} + +
+
+ ); +} diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 137744f..c2f4836 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -9,12 +9,14 @@ import { getSnapshotRenderDelay } from "../src/renderer/App"; describe("desktop shell", () => { it("uses keyboard-focusable controls for every copy target", () => { - const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const dialogSource = readFileSync(new URL("../src/renderer/ui/LinkAddressesDialog.tsx", import.meta.url), "utf8"); + const source = `${appSource}\n${dialogSource}`; expect(source).not.toMatch(/]*className="[^"]*link-popup-click/); - expect(source.match(/]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); + expect(dialogSource.match(/className="link-popup-(?:name|url) link-popup-click"/g)).toHaveLength(2); expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); - expect(source).toContain("navigator.clipboard.writeText(key.masked)"); + expect(source).toContain("window.rd.writeClipboardText(key.masked)"); expect(source).toContain("Maskierte Kennung kopiert"); }); diff --git a/tests/clipboard-write.test.ts b/tests/clipboard-write.test.ts new file mode 100644 index 0000000..9915765 --- /dev/null +++ b/tests/clipboard-write.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { CLIPBOARD_WRITE_MAX_BYTES, validateClipboardWriteText } from "../src/main/clipboard-write"; + +describe("clipboard write validation", () => { + it("accepts complete large link packages up to one MiB", () => { + const text = "x".repeat(CLIPBOARD_WRITE_MAX_BYTES); + expect(validateClipboardWriteText(text)).toBe(text); + }); + + it("rejects empty, non-string and oversized payloads", () => { + expect(() => validateClipboardWriteText(" \n ")).toThrow(/leer/i); + expect(() => validateClipboardWriteText(4)).toThrow(/String/i); + expect(() => validateClipboardWriteText("x".repeat(CLIPBOARD_WRITE_MAX_BYTES + 1))).toThrow(/zu groß/i); + }); +}); diff --git a/tests/link-addresses-dialog.test.tsx b/tests/link-addresses-dialog.test.tsx new file mode 100644 index 0000000..d22f024 --- /dev/null +++ b/tests/link-addresses-dialog.test.tsx @@ -0,0 +1,115 @@ +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { LinkAddressesDialog, type LinkAddressesDialogProps } from "../src/renderer/ui/LinkAddressesDialog"; + +function findElements(node: ReactNode, predicate: (element: ReactElement>) => boolean): ReactElement>[] { + if (Array.isArray(node)) return node.flatMap((child) => findElements(child, predicate)); + if (!isValidElement>(node)) return []; + const matches = predicate(node) ? [node] : []; + return [...matches, ...findElements(node.props.children as ReactNode, predicate)]; +} + +function createDialog(overrides: Partial = {}): ReactElement { + return LinkAddressesDialog({ + title: "Testpaket", + links: [ + { name: "Erste Datei.mkv", url: "https://example.com/first" }, + { name: "Zweite Datei.mkv", url: "https://example.com/second" } + ], + isPackage: true, + onClose: vi.fn(), + writeClipboardText: vi.fn(async () => true), + onToast: vi.fn(), + ...overrides + }); +} + +function buttonByText(tree: ReactElement, label: string): ReactElement> { + const button = findElements(tree, (element) => element.type === "button" && element.props.children === label)[0]; + expect(button, `Button ${label} fehlt`).toBeDefined(); + return button; +} + +async function click(button: ReactElement>): Promise { + const onClick = button.props.onClick as (() => void | Promise) | undefined; + expect(onClick).toBeTypeOf("function"); + await onClick?.(); +} + +describe("LinkAddressesDialog", () => { + it("kopiert einzelne Namen und URLs ausschließlich über den sicheren Writer", async () => { + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + const firstName = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Erste Datei.mkv kopieren")[0]; + const firstUrl = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Link kopieren")[0]; + await click(firstName); + await click(firstUrl); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, "Erste Datei.mkv"); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, "https://example.com/first"); + expect(onToast).toHaveBeenNthCalledWith(1, "Name kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Link kopiert"); + }); + + it("meldet Erfolg nur bei true und behandelt false sowie Ablehnungen als Fehler", async () => { + const writeClipboardText = vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("clipboard unavailable")); + const onToast = vi.fn(); + const tree = createDialog({ writeClipboardText, onToast }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(onToast).toHaveBeenNthCalledWith(1, "Kopieren fehlgeschlagen"); + expect(onToast).toHaveBeenNthCalledWith(2, "Kopieren fehlgeschlagen"); + expect(onToast).not.toHaveBeenCalledWith("Alle Namen kopiert"); + expect(onToast).not.toHaveBeenCalledWith("Alle Links kopiert"); + }); + + it("übergibt große Pakettexte ohne Kürzung oder Normalisierung", async () => { + const longName = `Groß-${"n".repeat(300_000)}`; + const longUrl = `https://example.com/${"u".repeat(300_000)}`; + const writeClipboardText = vi.fn(async () => true); + const onToast = vi.fn(); + const tree = createDialog({ + links: [ + { name: longName, url: longUrl }, + { name: " Zeilenende ", url: "https://example.com/trailing " } + ], + writeClipboardText, + onToast + }); + + await click(buttonByText(tree, "Alle Namen kopieren")); + await click(buttonByText(tree, "Alle Links kopieren")); + + expect(writeClipboardText).toHaveBeenNthCalledWith(1, `${longName}\n Zeilenende `); + expect(writeClipboardText).toHaveBeenNthCalledWith(2, `${longUrl}\nhttps://example.com/trailing `); + expect(onToast).toHaveBeenNthCalledWith(1, "Alle Namen kopiert"); + expect(onToast).toHaveBeenNthCalledWith(2, "Alle Links kopiert"); + }); + + it("behält Dialogdesign, Paketaktionen und Schließen-Verhalten bei", async () => { + const onClose = vi.fn(); + const packageTree = createDialog({ onClose }); + const singleTree = createDialog({ isPackage: false }); + const dialog = findElements(packageTree, (element) => typeof element.type === "function")[0]; + + expect(dialog.props.className).toBe("link-popup"); + expect(dialog.props.size).toBe("wide"); + expect(dialog.props.title).toBe("Linkadressen anzeigen"); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-row")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-name link-popup-click")).toHaveLength(2); + expect(findElements(packageTree, (element) => element.props.className === "link-popup-url link-popup-click")).toHaveLength(2); + expect(buttonByText(packageTree, "Alle Namen kopieren")).toBeDefined(); + expect(buttonByText(packageTree, "Alle Links kopieren")).toBeDefined(); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Namen kopieren")).toHaveLength(0); + expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Links kopieren")).toHaveLength(0); + + await click(buttonByText(packageTree, "Schließen")); + expect(onClose).toHaveBeenCalledOnce(); + }); +});