diff --git a/bun.lock b/bun.lock index b6ab28e..d397692 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "devintern", @@ -144,7 +143,7 @@ }, "packages/pm-desktop": { "name": "@devintern/pm-desktop", - "version": "0.9.11", + "version": "0.9.12", "dependencies": { "@base-ui/react": "^1.7.0", "@devintern/agent-harness": "workspace:*", @@ -155,6 +154,7 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "3.3.0", "electron-updater": "^6.6.2", "lucide-react": "^1.14.0", "posthog-node": "^5.48.0", @@ -1218,6 +1218,8 @@ "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], diff --git a/packages/pm-desktop/package.json b/packages/pm-desktop/package.json index 42c7ed8..601cda5 100644 --- a/packages/pm-desktop/package.json +++ b/packages/pm-desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devintern/pm-desktop", "productName": "DevIntern PM", - "version": "0.9.11", + "version": "0.9.12", "private": true, "description": "Desktop app for @devintern/pm — multi-ticket AI task creation for your tracker.", "author": "DevIntern ", @@ -39,6 +39,7 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "3.3.0", "electron-updater": "^6.6.2", "lucide-react": "^1.14.0", "posthog-node": "^5.48.0", diff --git a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx new file mode 100644 index 0000000..07aa179 --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx @@ -0,0 +1,237 @@ +import React from "react"; +import DOMPurify from "dompurify"; + +const DROPPED_TAGS = [ + "applet", + "audio", + "base", + "button", + "canvas", + "embed", + "form", + "frame", + "frameset", + "iframe", + "img", + "input", + "link", + "math", + "meta", + "noscript", + "object", + "picture", + "plaintext", + "script", + "select", + "source", + "style", + "svg", + "template", + "textarea", + "track", + "video", + "xmp", +]; + +const TAG_CLASSES: Record = { + article: "space-y-1.5", + blockquote: "my-2 border-l-2 border-border pl-3 italic text-muted-foreground", + code: "rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]", + del: "text-muted-foreground line-through", + div: "space-y-1.5", + em: "italic", + h1: "mt-2 text-base font-semibold text-foreground", + h2: "mt-2 text-sm font-semibold text-foreground", + h3: "mt-2 text-sm font-medium text-foreground", + h4: "mt-1.5 text-xs font-medium text-foreground", + h5: "mt-1.5 text-xs font-medium text-foreground", + h6: "mt-1.5 text-xs font-medium text-foreground", + li: "pl-0.5", + ol: "my-1.5 list-decimal space-y-0.5 pl-5", + p: "my-1.5", + pre: "my-2 overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs", + section: "space-y-1.5", + strong: "font-semibold text-foreground", + table: "my-2 w-full border-collapse text-left text-xs", + td: "border border-border px-2 py-1 align-top", + th: "border border-border bg-muted px-2 py-1 font-medium", + ul: "my-1.5 list-disc space-y-0.5 pl-5", +}; + +const ALLOWED_TAGS = [ + ...Object.keys(TAG_CLASSES), + "a", + "b", + "br", + "hr", + "i", + "s", + "span", + "tbody", + "thead", + "tr", + "u", +]; + +const ALLOWED_TAG_SET = new Set(ALLOWED_TAGS); + +const MAX_RELEASE_NOTES_HTML_LENGTH = 20_000; +const MAX_RELEASE_NOTES_DEPTH = 24; +const MAX_RELEASE_NOTES_NODES = 1_000; + +/** Release-note links are external, so only absolute HTTP(S) URLs are usable. */ +export function isSafeReleaseNotesUrl(value: string): boolean { + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) return false; + try { + const url = new URL(trimmed); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +interface RenderResult { + meaningful: boolean; + nodes: React.ReactNode[]; +} + +interface RenderBudget { + exceeded: boolean; + remainingNodes: number; +} + +function renderNodes( + nodes: NodeListOf, + keyPrefix: string, + depth: number, + budget: RenderBudget, +): RenderResult { + const output: React.ReactNode[] = []; + let meaningful = false; + + if (depth > MAX_RELEASE_NOTES_DEPTH) { + budget.exceeded = true; + return { meaningful, nodes: output }; + } + + for (let index = 0; index < nodes.length; index++) { + if (budget.remainingNodes === 0) { + budget.exceeded = true; + break; + } + budget.remainingNodes--; + + const node = nodes[index]; + if (!node) continue; + const key = `${keyPrefix}-${index}`; + if (node.nodeType === 3) { + const text = node.textContent ?? ""; + output.push(text); + meaningful ||= text.trim().length > 0; + continue; + } + if (node.nodeType !== 1) continue; + + const element = node as Element; + const tag = element.localName.toLowerCase(); + // DOMPurify owns sanitization; ignore anything outside its configured + // output contract rather than attempting to render it. + if (!ALLOWED_TAG_SET.has(tag)) continue; + + const children = renderNodes(element.childNodes, key, depth + 1, budget); + if (budget.exceeded) break; + + if (tag === "br") { + output.push(React.createElement("br", { key })); + continue; + } + if (tag === "hr") { + output.push(React.createElement("hr", { className: "my-2 border-border", key })); + meaningful = true; + continue; + } + if (tag === "a") { + const href = element.getAttribute("href")?.trim() ?? ""; + if (!isSafeReleaseNotesUrl(href)) { + output.push(...children.nodes); + meaningful ||= children.meaningful; + continue; + } + const onClick = (event: React.MouseEvent) => { + event.preventDefault(); + void window.pm.openExternal(href); + }; + output.push( + React.createElement( + "button", + { + className: + "cursor-pointer border-0 bg-transparent p-0 text-primary underline underline-offset-2 hover:text-primary/80", + key, + onClick, + title: element.getAttribute("title") ?? undefined, + type: "button", + }, + children.nodes, + ), + ); + meaningful ||= children.meaningful; + continue; + } + + const normalizedTag = tag === "b" ? "strong" : tag === "i" ? "em" : tag; + output.push( + React.createElement( + normalizedTag, + { className: TAG_CLASSES[normalizedTag], key }, + children.nodes, + ), + ); + meaningful ||= children.meaningful; + } + + return { meaningful, nodes: output }; +} + +function renderReleaseNotes(html: string | null | undefined): RenderResult { + if (!html?.trim()) return { meaningful: false, nodes: [] }; + if (html.length > MAX_RELEASE_NOTES_HTML_LENGTH) return { meaningful: false, nodes: [] }; + + try { + const sanitized = DOMPurify(window).sanitize(html, { + ALLOWED_ATTR: ["href", "title"], + ALLOWED_TAGS, + ALLOW_ARIA_ATTR: false, + ALLOW_DATA_ATTR: false, + FORBID_CONTENTS: DROPPED_TAGS, + FORBID_TAGS: DROPPED_TAGS, + RETURN_DOM_FRAGMENT: true, + }); + const budget: RenderBudget = { + exceeded: false, + remainingNodes: MAX_RELEASE_NOTES_NODES, + }; + const rendered = renderNodes(sanitized.childNodes, "release-note", 0, budget); + return budget.exceeded ? { meaningful: false, nodes: [] } : rendered; + } catch { + return { meaningful: false, nodes: [] }; + } +} + +export function ReleaseNotes({ html }: { html: string | null | undefined }) { + const rendered = renderReleaseNotes(html); + + return ( +
+ {rendered.meaningful ? ( + rendered.nodes + ) : ( +

Release notes are unavailable.

+ )} +
+ ); +} diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx index 7e74dd8..dc519e5 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx @@ -79,6 +79,7 @@ mock.module("@/components/ui/button", () => { const { formatDownloadLabel, shouldShowUpdateDialog, UpdateNotifier } = await import("./UpdateNotifier.tsx"); +const { ReleaseNotes } = await import("./ReleaseNotes.tsx"); function ok(value: T): IpcResult { return { ok: true, value }; @@ -192,6 +193,7 @@ describe("UpdateNotifier against fake window.pm", () => { let installUpdate: ReturnType; let dismissUpdateError: ReturnType; let checkForUpdates: ReturnType; + let openExternal: ReturnType; const available: UpdateStatus = { phase: "available", @@ -213,6 +215,7 @@ describe("UpdateNotifier against fake window.pm", () => { installUpdate = mock(async () => ok({ ...available, phase: "downloaded" as const })); dismissUpdateError = mock(async () => ok(available)); checkForUpdates = mock(async () => ok(available)); + openExternal = mock(async () => ok(null)); getUpdateStatus = mock(async () => ok(available)); const pm = { @@ -223,6 +226,7 @@ describe("UpdateNotifier against fake window.pm", () => { installUpdate, dismissUpdateError, checkForUpdates, + openExternal, } as unknown as PmDesktopApi; (domWindow as unknown as { pm: PmDesktopApi }).pm = pm; (globalThis.window as unknown as { pm: PmDesktopApi }).pm = pm; @@ -303,6 +307,123 @@ describe("UpdateNotifier against fake window.pm", () => { expect(snoozeUpdate).toHaveBeenCalledTimes(1); }); + test("renders formatted release notes and opens safe links without native navigation", async () => { + const notesStatus: UpdateStatus = { + ...available, + releaseNotes: ` +

Highlights

+

Fixed ticket sync. +

  • Faster startup
+ Full notes + Unsafe link + + + + `, + }; + getUpdateStatus.mockImplementation(async () => ok(notesStatus)); + queryClient.setQueryData(qk.updateStatus, notesStatus); + + await act(async () => { + root.render( + withQueryClient(createElement(UpdateNotifier, { hasBusyWork: false }), queryClient), + ); + await flushMicrotasks(); + }); + await act(async () => { + await flushMicrotasks(); + }); + + const notes = domWindow.document.querySelector('[data-testid="update-notifier-notes"]'); + expect(notes?.querySelector("h2")?.textContent).toBe("Highlights"); + expect(notes?.querySelector("strong")?.textContent).toBe("ticket sync"); + expect(notes?.querySelector("li")?.textContent).toBe("Faster startup"); + expect(notes?.querySelector("script, img, iframe")).toBeNull(); + expect(notes?.textContent).not.toContain("window.pwned"); + expect(notes?.textContent).not.toContain("embedded content"); + + const links = notes?.querySelectorAll("button"); + expect(links?.length).toBe(1); + expect(notes?.querySelector("a")).toBeNull(); + expect(notes?.textContent).toContain("Unsafe link"); + expect(links?.[0]?.getAttribute("onclick")).toBeNull(); + expect(links?.[0]?.getAttribute("onmouseover")).toBeNull(); + expect(links?.[0]?.getAttribute("type")).toBe("button"); + + const safeLink = links?.[0]; + if (!safeLink) throw new Error("Expected a safe release-note link"); + await act(async () => { + safeLink.click(); + await flushMicrotasks(); + }); + expect(openExternal).toHaveBeenCalledWith("https://example.com/releases/0.3.0"); + }); + + test("shows a fallback when release notes are missing or fully removed", async () => { + const missingNotesStatus: UpdateStatus = { ...available, releaseNotes: undefined }; + getUpdateStatus.mockImplementation(async () => ok(missingNotesStatus)); + queryClient.setQueryData(qk.updateStatus, missingNotesStatus); + + await act(async () => { + root.render( + withQueryClient(createElement(UpdateNotifier, { hasBusyWork: false }), queryClient), + ); + await flushMicrotasks(); + }); + await act(async () => { + await flushMicrotasks(); + }); + + expect( + domWindow.document.querySelector('[data-testid="update-notifier-notes"]')?.textContent, + ).toBe("Release notes are unavailable."); + + const unsafeNotesStatus: UpdateStatus = { + ...available, + releaseNotes: "", + }; + await act(async () => { + queryClient.setQueryData(qk.updateStatus, unsafeNotesStatus); + await flushMicrotasks(); + }); + + expect( + domWindow.document.querySelector('[data-testid="update-notifier-notes"]')?.textContent, + ).toBe("Release notes are unavailable."); + expect( + domWindow.document.querySelector('[data-testid="update-notifier-install"]'), + ).not.toBeNull(); + expect( + domWindow.document.querySelector('[data-testid="update-notifier-later"]'), + ).not.toBeNull(); + }); + + test("bounds release-note input size, nesting depth, and rendered node count", async () => { + await act(async () => { + root.render(createElement(ReleaseNotes, { html: `

${"x".repeat(25_000)}

` })); + await flushMicrotasks(); + }); + + const notes = domWindow.document.querySelector('[data-testid="update-notifier-notes"]'); + expect(notes?.textContent).toBe("Release notes are unavailable."); + + await act(async () => { + root.render( + createElement(ReleaseNotes, { + html: `${"
".repeat(30)}Deep${"
".repeat(30)}`, + }), + ); + await flushMicrotasks(); + }); + expect(notes?.textContent).toBe("Release notes are unavailable."); + + await act(async () => { + root.render(createElement(ReleaseNotes, { html: "x".repeat(600) })); + await flushMicrotasks(); + }); + expect(notes?.textContent).toBe("Release notes are unavailable."); + }); + test("retry on error calls downloadUpdate when a version is available", async () => { const errorStatus: UpdateStatus = { phase: "error", diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx index 8c75d35..8f9158f 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx @@ -19,19 +19,13 @@ import { } from "@/components/ui/dialog"; import { qk } from "../queries/keys.ts"; import { useUpdateStatus } from "../queries/useUpdateStatus.ts"; +import { ReleaseNotes } from "./ReleaseNotes.tsx"; export interface UpdateNotifierProps { /** True when any ticket has an agent/tracker operation in flight. */ hasBusyWork: boolean; } -function shortNotes(notes: string | null | undefined): string | null { - if (!notes) return null; - const trimmed = notes.replace(/\s+/g, " ").trim(); - if (!trimmed) return null; - return trimmed.length > 180 ? `${trimmed.slice(0, 177)}…` : trimmed; -} - /** Whether the status should show the interruptive update dialog. */ export function shouldShowUpdateDialog(status: UpdateStatus): boolean { if (status.phase === "disabled") return false; @@ -69,7 +63,6 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { return null; } - const notes = shortNotes(status.releaseNotes); const versionLine = status.availableVersion != null ? formatUpdateAvailableMessage(status.availableVersion, status.currentVersion) @@ -133,8 +126,8 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { > {title} - -
+ +
{status.phase === "error" && status.errorMessage ? (

{status.errorMessage}

) : status.phase === "downloading" ? ( @@ -142,7 +135,7 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { ) : ( <> {versionLine &&

{versionLine}

} - {notes &&

{notes}

} + {status.phase === "downloaded" && (

Restart to apply the update. Your settings and project preference are kept.