diff --git a/assets/app_icon.ico b/assets/app_icon.ico index 48ca0bf..aab201b 100644 Binary files a/assets/app_icon.ico and b/assets/app_icon.ico differ diff --git a/assets/app_icon.png b/assets/app_icon.png index 0e05484..f4a3a38 100644 Binary files a/assets/app_icon.png and b/assets/app_icon.png differ diff --git a/docs/images/multi-debrid-downloader.png b/docs/images/multi-debrid-downloader.png index fed4a4c..239e6fd 100644 Binary files a/docs/images/multi-debrid-downloader.png and b/docs/images/multi-debrid-downloader.png differ diff --git a/src/main/main.ts b/src/main/main.ts index 656db10..1dd3608 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -37,6 +37,7 @@ function validatePlainObject(value: unknown, name: string): Record([ "realdebrid", @@ -611,6 +612,23 @@ function registerIpcHandlers(): void { 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 { + clipboard.writeText(text); + return true; + } catch (error) { + logger.warn(`Zwischenablage-Schreibfehler: bytes=${bytes}, error=${String(error)}`); + throw error; + } + }); handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { const options = { properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 6d4f940..f32207f 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -65,6 +65,7 @@ const api: ElectronApi = { exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), + writeClipboardText: (text: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text), pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dce63d8..e0d9246 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4922,6 +4922,11 @@ export function App(): ReactElement { const row = accountRowBindings.get(rowId); if (row) setAccountContextMenu({ x, y, rowId }); }, + onCopyIdentity: (label, value) => { + void window.rd.writeClipboardText(value) + .then(() => showToast(`${label} kopiert`)) + .catch(() => showToast("Kopieren fehlgeschlagen")); + }, onAdd: openCreateAccountDialog, onRemoveSelected: () => { const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null; diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index de28da7..b4ac392 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -74,6 +74,7 @@ export interface AccountWorkspaceActions { onToggleEnabled: (rowId: string) => void; onEdit: (rowId: string) => void; onContextMenu: (rowId: string, x: number, y: number) => void; + onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void; onAdd: () => void; onRemoveSelected: () => void; onCheckAll: () => void; @@ -91,6 +92,36 @@ export interface AccountWorkspaceActions { onRoutingAdd?: (hosterId: string) => void; } +function renderAccountIdentityCell({ + className, + label, + value, + onCopy +}: { + className: string; + label: "Benutzername" | "E-Mail"; + value: string; + onCopy: (label: "Benutzername" | "E-Mail", value: string) => void; +}): ReactElement { + const copyable = value.trim() !== "" && value !== "—"; + return ( + + {copyable ? ( + + ) : value} + + ); +} + export interface AccountWorkspaceProps { model: AccountWorkspaceViewModel; actions: AccountWorkspaceActions; @@ -240,8 +271,8 @@ function AccountRow({ {row.status.text} {row.traffic} - {row.username} - {row.email} + {renderAccountIdentityCell({ className: "settings-account-username", label: "Benutzername", onCopy: actions.onCopyIdentity, value: row.username })} + {renderAccountIdentityCell({ className: "settings-account-email", label: "E-Mail", onCopy: actions.onCopyIdentity, value: row.email })} {row.expires} {row.credential} diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index 328d736..ff831fc 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -732,6 +732,27 @@ white-space: nowrap; } +.settings-account-copy-button { + display: block; + width: 100%; + padding: 0; + overflow: hidden; + border: 0; + background: transparent; + color: inherit; + cursor: copy; + font: inherit; + text-align: left; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +} + +.settings-account-copy-button:hover, +.settings-account-copy-button:focus-visible { + color: var(--ui-accent); +} + .settings-account-action-button { display: grid; width: 30px; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e82d35e..140929a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -35,6 +35,7 @@ export const IPC_CHANNELS = { STATE_UPDATE: "state:update", CLIPBOARD_DETECTED: "clipboard:detected", TOGGLE_CLIPBOARD: "clipboard:toggle", + WRITE_CLIPBOARD_TEXT: "clipboard:write-text", GET_SESSION_STATS: "stats:get-session-stats", RESET_SESSION_STATS: "stats:reset-session", RESET_DOWNLOAD_STATS: "stats:reset-download", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 4dca7ff..1e9f433 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -62,6 +62,7 @@ export interface ElectronApi { exportQueue: () => Promise<{ saved: boolean }>; importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; toggleClipboard: () => Promise; + writeClipboardText: (text: string) => Promise; pickFolder: () => Promise; pickContainers: () => Promise; getSessionStats: () => Promise; diff --git a/tests/app-icon.test.ts b/tests/app-icon.test.ts index fed9458..bf1269a 100644 --- a/tests/app-icon.test.ts +++ b/tests/app-icon.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveAppIconPath } from "../src/main/app-icon"; @@ -14,4 +15,20 @@ describe("application icon path", () => { path.join("C:\\repo", "assets", "app_icon.ico") ); }); + + it("ships a high-resolution transparent PNG and complete Windows icon sizes", () => { + const png = fs.readFileSync(path.resolve("assets/app_icon.png")); + expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(png.readUInt32BE(16)).toBeGreaterThanOrEqual(1024); + expect(png.readUInt32BE(20)).toBeGreaterThanOrEqual(1024); + expect([4, 6]).toContain(png[25]); + + const ico = fs.readFileSync(path.resolve("assets/app_icon.ico")); + const frameCount = ico.readUInt16LE(4); + const sizes = Array.from({ length: frameCount }, (_, index) => { + const offset = 6 + index * 16; + return ico[offset] || 256; + }); + expect(sizes).toEqual(expect.arrayContaining([16, 32, 48, 256])); + }); }); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 4aeca1f..9185025 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -301,6 +301,7 @@ function workspaceActions(overrides: Partial = {}): Acc onToggleEnabled: () => {}, onEdit: () => {}, onContextMenu: () => {}, + onCopyIdentity: () => {}, onAdd: () => {}, onRemoveSelected: () => {}, onCheckAll: () => {}, @@ -740,6 +741,31 @@ describe("account workspace", () => { ]); }); + it("copies only populated username and email cells without triggering the row", () => { + const copies: string[] = []; + const tree = AccountWorkspace({ + model: workspaceModel(), + actions: workspaceActions({ + onCopyIdentity: (label, value) => copies.push(`${label}:${value}`) + }) + }); + const buttons = findElements(tree, (element) => String(element.props.className || "").includes("settings-account-copy-button")); + const username = buttons.find((button) => button.props["aria-label"] === "Benutzername kopieren"); + const email = buttons.find((button) => button.props["aria-label"] === "E-Mail kopieren"); + let stopped = 0; + + username?.props.onClick({ stopPropagation: () => { stopped += 1; } }); + email?.props.onClick({ stopPropagation: () => { stopped += 1; } }); + username?.props.onDoubleClick({ stopPropagation: () => { stopped += 1; } }); + + expect(buttons.some((button) => button.props.children === "—")).toBe(false); + expect(copies).toEqual([ + "Benutzername:stored-user", + "E-Mail:verified@example.test" + ]); + expect(stopped).toBe(3); + }); + it("keeps overview and rules in the same workspace while only one panel is active", () => { const html = renderToStaticMarkup(); expect(count(html, "class=\"settings-account-panel\"")).toBe(2); diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 9822b57..aa277c1 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -139,6 +139,7 @@ export function createVisualElectronApi( fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive; return fixture.snapshot.clipboardActive; }, + writeClipboardText: async () => true, pickFolder: async () => "C:\\Visual\\Selected", pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"], getSessionStats: async () => ({