diff --git a/docs/canvas-share-protocol.md b/docs/canvas-share-protocol.md new file mode 100644 index 0000000000..b67a02df97 --- /dev/null +++ b/docs/canvas-share-protocol.md @@ -0,0 +1,138 @@ +# Canvas Share Protocol + +Canvas sharing is independent from ORG2 Cloud session replay sharing. It +publishes one immutable Canvas snapshot and never reads or serializes the +owning session, conversation, repository, or later revisions. + +## Ownership and path + +```text +Canvas toolbar click +→ getCanvasShareAvailability(selectedPayload) +→ createCanvasShareEnvelope(selectedPayload) +→ gzip + base64url +→ POST https://canvas.org2.dev/api/canvas-shares +→ https://canvas.org2.dev/#/s/ + ↳ upload unavailable: #/share/g1/ +→ public viewer validates the envelope +→ HTML/React runs in a sandbox without allow-same-origin +``` + +The ORGII generator is owned by `src/features/CanvasShare`. The public decoder, +renderer, and same-origin API proxy are owned by +`ORGII-cloud-infra/apps/canvas-share`; the authoritative snapshot API and +persistence remain in `ORGII-cloud-infra/apps/org2-cloud-web`. The service +stores only the compressed envelope and never receives session, conversation, +repository, or account data. All boundaries must keep the versioned envelope +compatible. + +## State machines + +The desktop dialog owns one operation at a time: + +```text +closed --share--> ready(cached, still valid) + \--share--> preparing --upload succeeds--> ready(short) + \--upload fails-------> ready(self-contained) + \--encode/size fails--> error --retry--> preparing +preparing/ready/error --close--> closed (supersede this dialog subscriber) +``` + +The viewer is independent: + +```text +route change --> loading --fetch/decode/validate--> ready + \--missing/expired/invalid--> error +loading --route change or unmount--> abort +``` + +## Version 1 envelope + +```ts +interface CanvasShareEnvelopeV1 { + version: 1; + canvas: { + mode: "html" | "react" | "a2ui" | "url"; + title?: string; + content?: string; + url?: string; + }; +} +``` + +`eventId`, `revisesEventId`, `streaming`, `sessionId`, events, messages, and +repository metadata are intentionally absent. + +The default viewer deployment can be replaced at build time with +`REACT_APP_CANVAS_SHARE_VIEWER_URL`. The viewer URL must use HTTPS, except for +localhost development. The upload endpoint can be replaced with +`REACT_APP_CANVAS_SHARE_API_URL`; the viewer uses the corresponding +`VITE_CANVAS_SHARE_API_URL` setting. + +The canonical ORG2-owned viewer URL is `https://canvas.org2.dev/`. Existing +`https://beruro.github.io/canvas-share/` links remain valid as frozen legacy +compatibility links, but the desktop app no longer generates them by default. + +## Limits and failure policy + +- A Canvas cannot be shared while it is streaming or being revised. +- Payloads whose mode is not one of `html`, `react`, `a2ui`, or `url` are + rejected before encoding (`unsupported-mode`), matching what the decode + validator accepts. +- URL mode accepts publicly routable HTTP(S) URLs only. Both the producing + availability gate and the decode validator reject (reason `local-url`): + non-HTTP(S) schemes; `localhost`, `*.localhost`, `*.local`, and + `*.internal` hostnames; IPv4 loopback (127.0.0.0/8), RFC 1918 ranges + (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16), + and 0.0.0.0/8 literals; and IPv6 loopback (`::1`), unspecified (`::`), + unique-local (`fc00::/7`), link-local (`fe80::/10`), and IPv4-mapped + private literals. +- Uncompressed source is limited to 512 KiB. +- Hosted compressed payloads are limited to 768 KiB and are validated again at + the server's producing boundary before storage. +- The final self-contained link is limited to 64 KiB. +- Hosted IDs contain 128 random bits, are immutable, and expire after one year. +- Anonymous writes are limited to 20 snapshots per IP hash per hour. Expired + rows and stale rate counters are removed in bounded batches during writes; + the rate check runs before JSON parsing or gzip decompression, and there is + no poller or background client work. +- Upload has an 8-second deadline. Any network, service, or rate-limit failure + falls back to the legacy self-contained link. If the snapshot fits the + hosted upload but exceeds the 64 KiB self-contained link cap while the + service is down, the dialog reports a retryable service outage + (`short-link-unavailable-too-large`) instead of claiming the Canvas is too + large. A misconfigured `REACT_APP_CANVAS_SHARE_API_URL` fails loudly with a + configuration error; it is never converted into the retryable fallback. +- A link whose envelope declares a protocol version newer than the app + supports is reported as "created by a newer version" + (`unsupported-version`), distinct from a corrupted or incomplete link. +- Closing or unmounting the dialog supersedes that UI subscriber, so stale + results cannot reopen it. The bounded app-level cache may finish the shared + in-flight generation so returning to the same Canvas tab does not upload the + same immutable snapshot again. +- The common eligibility path uses constant-time string bounds instead of + allocating an encoded copy during render. Exact UTF-8 measurement runs only + for large ambiguous inputs. +- The app runtime keeps a bounded LRU of at most 16 immutable snapshots and at + most 1 Mi retained characters, counting both the snapshot fields and, once + an entry is ready, its generated link (self-contained links can reach the + 64 KiB link cap each). The bound is re-enforced when an entry transitions + to ready, so the worst case is 1 Mi retained characters (~2 MiB of UTF-16 + string memory). Duplicate opens share one in-flight generation; failures + are removed immediately; expired short links are regenerated; eviction + aborts pending work without surfacing an error to subscribers that shared + the evicted generation. The cache is never persisted or shared across app + launches. +- Anyone with the complete link can view the snapshot. Hosted IDs are unlisted, + not encrypted; legacy fragment links remain fully self-contained and + backward compatible. + +## Failure and recovery matrix + +| Journey | Authoritative behavior | Recovery | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| Upload offline, timeout, 404, 429, or 5xx | Desktop keeps the encoded envelope and produces the legacy link; if that link would exceed the 64 KiB cap, the dialog reports a retryable outage instead of a size error | User can copy/open immediately, or retry later; retrying may produce a short link | +| Dialog closes or the Canvas tab unmounts during work | That dialog subscriber ignores completion; the bounded cache may finish and reuse the result after remount | Reopen Share for the same snapshot, or explicitly share the newly selected snapshot | +| Short ID missing or expired | Viewer shows a bounded unavailable state; it never guesses or lists IDs | Ask the sender for a new snapshot | +| Stored or embedded payload is malformed/oversized | Viewer rejects before rendering; server rejects malformed writes before persistence | Generate a fresh link from a valid Canvas | +| React/HTML runtime throws after validation | The existing sandbox/runtime error UI owns the failure | Reload or ask for a corrected Canvas | diff --git a/docs/frontend-ui-audit-2026-08-10/CanvasAppShare.md b/docs/frontend-ui-audit-2026-08-10/CanvasAppShare.md new file mode 100644 index 0000000000..d245259245 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-10/CanvasAppShare.md @@ -0,0 +1,40 @@ +# Frontend UI Audit — CanvasApp Share + +**File:** `src/engines/Simulator/apps/canvas/CanvasApp.tsx` +**Date:** 2026-08-10 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ------- | ----------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | ---------------- | +| 483 | Toolbar separator | keep with reason | Uses the shared workstation separator to preserve the existing header grouping pattern. | — | +| 493–504 | Share action | keep with reason | Uses the shared tooltip and `Button` components with the established tertiary mini-toolbar treatment. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----- | ---------------- | ------------------------------------------------------------------------- | ---------------- | +| — | — | keep with reason | The Share integration adds no arbitrary CSS-variable or raw-color values. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | -------------- | ---------------- | ------------------------------------------------------------------------------- | ---------------- | +| 498 | Icon size `12` | keep with reason | Matches the adjacent Reload action and the established mini-toolbar icon scale. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ------- | ------------ | ---------------- | ----------------------------------------------------------------------------------------------------- | ---------------- | +| 493–504 | Share action | keep with reason | The native shared button has visible localized text, a disabled state, and a reason-specific tooltip. | — | + +## D5 — Visual Patterns Observed + +- Share follows the existing Canvas toolbar action pattern and introduces no new repeated visual primitive. + +## Summary + +- 0 fixes recommended +- 5 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-08-10/CanvasShareDialog.md b/docs/frontend-ui-audit-2026-08-10/CanvasShareDialog.md new file mode 100644 index 0000000000..2dba8a9878 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-10/CanvasShareDialog.md @@ -0,0 +1,44 @@ +# Frontend UI Audit — CanvasShareDialog + +**File:** `src/features/CanvasShare/CanvasShareDialog.tsx` +**Date:** 2026-08-10 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ------- | --------------------- | ---------------- | -------------------------------------------------------------------------------- | ---------------- | +| 58–65 | Share modal | keep with reason | Uses the established `ModalSystem` surface used by other ORGII dialogs. | — | +| 91–104 | Read-only link field | keep with reason | Uses the shared `Input`; focus selection supports the manual-copy recovery path. | — | +| 134–147 | Open and copy actions | keep with reason | Both actions use the shared `Button` component with explicit semantic variants. | — | +| 159 | Retry action | keep with reason | Uses the shared secondary `Button` for the recoverable error transition. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----- | ---------------- | --------------------------------------------------------- | ---------------- | +| — | — | keep with reason | No arbitrary CSS-variable or raw-color values were found. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------ | ---------------- | +| 64 | Modal width `520` | keep with reason | The explicit dialog width matches the existing Cloud sharing surface and keeps long fallback links readable. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ------- | --------------------- | ---------------- | --------------------------------------------------------------------------------------- | ---------------- | +| 78–88 | Preparing state | keep with reason | `aria-live="polite"` announces link preparation without moving focus. | — | +| 91–104 | Share-link input | keep with reason | The read-only field has a localized accessible label and visible copy-failure recovery. | — | +| 150–162 | Error and retry state | keep with reason | Uses `role="alert"` plus a keyboard-accessible native shared button. | — | + +## D5 — Visual Patterns Observed + +- Reuses the existing ORGII modal, read-only field, and action-row patterns; no new repeated visual primitive was introduced. + +## Summary + +- 0 fixes recommended +- 10 kept with documented reason +- 0 abstract candidates diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.share.test.ts b/src/engines/Simulator/apps/canvas/CanvasApp.share.test.ts new file mode 100644 index 0000000000..3af94d6432 --- /dev/null +++ b/src/engines/Simulator/apps/canvas/CanvasApp.share.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +import { type ReactNode, act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import CanvasApp from "./CanvasApp"; + +const testState = vi.hoisted(() => ({ + appEvents: [] as SessionEvent[], + publishedHeader: null as ReactNode, + openCanvasShare: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); +vi.mock("lucide-react", () => ({ + Layout: () => null, + RefreshCw: () => null, + Share2: () => null, +})); +vi.mock("jotai", () => ({ + useAtomValue: (atom: string) => { + if (atom === "canvas-preview") return null; + if (atom === "sidebar-collapsed") return false; + if (atom === "sidebar-position") return "left"; + if (atom === "sidebar-width") return 240; + return null; + }, + useSetAtom: () => vi.fn(), +})); +vi.mock("@src/store/session/canvasPreviewAtom", () => ({ + canvasPreviewAtom: "canvas-preview", +})); +vi.mock("@src/store/ui/simulatorAtom", () => ({ + simulatorPrimarySidebarCollapsedAtom: "sidebar-collapsed", + simulatorPrimarySidebarPositionAtom: "sidebar-position", + simulatorPrimarySidebarWidthAtom: "sidebar-width", + simulatorPrimarySidebarWidthPersistAtom: "sidebar-width-persist", +})); +vi.mock("../core/useSimulatorAppState", () => ({ + useSimulatorAppState: () => ({ + appEvents: testState.appEvents, + currentEvent: null, + }), +})); +vi.mock("./canvasConfig", () => ({ CANVAS_APP_CONFIG: {} })); +vi.mock("@src/hooks/workStation", () => ({ + usePublishWorkstationTabHeader: ({ content }: { content: ReactNode }) => { + testState.publishedHeader = content; + }, +})); +vi.mock("@src/features/CanvasShare", async () => { + const { getCanvasShareAvailability } = await vi.importActual< + typeof import("@src/features/CanvasShare/canvasShareProtocol") + >("@src/features/CanvasShare/canvasShareProtocol"); + return { + CanvasShareDialog: () => null, + getCanvasShareAvailability, + useCanvasShareDialog: () => ({ + state: { phase: "closed", operationId: 0 }, + open: testState.openCanvasShare, + close: vi.fn(), + retry: vi.fn(), + retryShortLink: vi.fn(), + copy: vi.fn(), + }), + }; +}); +vi.mock("@src/components/WindowChrome", () => ({ + NoDragRegion: ({ children }: { children?: ReactNode }) => + createElement("div", null, children), +})); +vi.mock("@src/components/DiffStatsBadge", () => ({ default: () => null })); +vi.mock("@src/components/Button", () => ({ + default: ({ + children, + htmlType, + icon: _icon, + variant: _variant, + size: _size, + ...props + }: { + children?: ReactNode; + htmlType?: "button"; + icon?: ReactNode; + variant?: string; + size?: string; + } & React.ComponentProps<"button">) => + createElement("button", { type: htmlType, ...props }, children), +})); +vi.mock("@src/components/IconButton", () => ({ + default: ({ children, ...props }: React.ComponentProps<"button">) => + createElement("button", props, children), +})); +vi.mock("@src/components/TabPill", () => ({ + default: () => createElement("div", { "data-testid": "canvas-tabs" }), +})); +vi.mock( + "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface", + () => ({ default: () => createElement("div") }) +); +vi.mock( + "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer", + () => ({ SessionReplayCodeMirrorViewer: () => createElement("div") }) +); +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + Placeholder: ({ title }: { title: string }) => + createElement("div", null, title), +})); +vi.mock("@src/modules/WorkStation/shared", () => ({ + buildPrimarySidebarConfig: (config: unknown) => config, + PrimarySidebarLayoutWithSections: () => null, + SimulatorReplayChrome: ({ children }: { children?: ReactNode }) => + createElement("div", null, testState.publishedHeader, children), + WorkStationShell: ({ content }: { content: ReactNode }) => + createElement("main", null, content), + WorkstationToolbarTooltip: ({ children }: { children?: ReactNode }) => + children ?? null, + WorkstationHeaderSectionSeparator: () => + createElement("span", { "data-testid": "toolbar-separator" }), +})); + +function canvasEvent( + args: Record, + id = "canvas-event" +): SessionEvent { + return { + id, + sessionId: "session-a", + functionName: "render_inline_canvas", + displayStatus: "completed", + args, + } as unknown as SessionEvent; +} + +describe("CanvasApp share action", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + testState.appEvents = []; + testState.publishedHeader = null; + testState.openCanvasShare.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function render(): void { + act(() => { + root.render( + createElement(CanvasApp, { + state: { + currentEventId: null, + appEvents: [], + selectedItemId: null, + isReplaying: false, + }, + currentEvent: null, + selectedItemId: null, + onSelectItem: vi.fn(), + }) + ); + }); + } + + function shareButton(): HTMLButtonElement | undefined { + return [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Share" + ); + } + + it("shares only the selected completed Canvas from the visible toolbar", () => { + testState.appEvents = [ + canvasEvent({ mode: "html", content: "

Ready

", title: "Ready" }), + ]; + + render(); + + expect(shareButton()?.disabled).toBe(false); + expect( + container.querySelectorAll("[data-testid='toolbar-separator']") + ).toHaveLength(1); + act(() => shareButton()?.click()); + expect(testState.openCanvasShare).toHaveBeenCalledOnce(); + expect(testState.openCanvasShare).toHaveBeenCalledWith( + { + mode: "html", + content: "

Ready

", + title: "Ready", + streaming: false, + }, + "Ready" + ); + }); + + it("keeps Share visible but disabled for an in-progress Canvas", () => { + testState.appEvents = [ + canvasEvent({ + mode: "html", + content: "

Changing

", + title: "Changing", + streaming: true, + }), + ]; + + render(); + + expect(shareButton()).toBeDefined(); + expect(shareButton()?.disabled).toBe(true); + act(() => shareButton()?.click()); + expect(testState.openCanvasShare).not.toHaveBeenCalled(); + }); + + it("keeps Share disabled for a local URL Canvas", () => { + testState.appEvents = [ + canvasEvent({ mode: "url", url: "file:///tmp/private.html" }), + ]; + + render(); + + expect(shareButton()).toBeDefined(); + expect(shareButton()?.disabled).toBe(true); + }); +}); diff --git a/src/engines/Simulator/apps/canvas/CanvasApp.tsx b/src/engines/Simulator/apps/canvas/CanvasApp.tsx index f4d7c0d601..3e20bae87b 100644 --- a/src/engines/Simulator/apps/canvas/CanvasApp.tsx +++ b/src/engines/Simulator/apps/canvas/CanvasApp.tsx @@ -16,7 +16,7 @@ * - Source tab shows raw JSONL/HTML in a
 block
  */
 import { useAtomValue, useSetAtom } from "jotai";
-import { Layout, RefreshCw } from "lucide-react";
+import { Layout, RefreshCw, Share2 } from "lucide-react";
 import React, {
   useCallback,
   useEffect,
@@ -26,6 +26,7 @@ import React, {
 } from "react";
 import { useTranslation } from "react-i18next";
 
+import Button from "@src/components/Button";
 import DiffStatsBadge from "@src/components/DiffStatsBadge";
 import IconButton from "@src/components/IconButton";
 import TabPill from "@src/components/TabPill";
@@ -34,12 +35,19 @@ import { SIMULATOR_PRIMARY_SIDEBAR } from "@src/config/simulatorPrimarySidebar";
 import CanvasPreviewSurface from "@src/engines/ChatPanel/blocks/CanvasInlineCard/CanvasPreviewSurface";
 import type { CanvasInlineMode } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types";
 import type { SessionEvent } from "@src/engines/SessionCore/core/types";
+import {
+  CanvasShareDialog,
+  getCanvasShareAvailability,
+  useCanvasShareDialog,
+} from "@src/features/CanvasShare";
 import { usePublishWorkstationTabHeader } from "@src/hooks/workStation";
 import { SessionReplayCodeMirrorViewer } from "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel/SessionReplayCodeMirrorViewer";
 import {
   PrimarySidebarLayoutWithSections,
   SimulatorReplayChrome,
   WorkStationShell,
+  WorkstationHeaderSectionSeparator,
+  WorkstationToolbarTooltip,
   buildPrimarySidebarConfig,
 } from "@src/modules/WorkStation/shared";
 import type { PrimarySidebarTab } from "@src/modules/WorkStation/shared/PrimarySidebarLayout/PrimarySidebarLayoutWithSections";
@@ -77,7 +85,7 @@ function extractPayload(event: SessionEvent): CanvasPayload | null {
     content: args.content as string | undefined,
     url: args.url as string | undefined,
     title: args.title as string | undefined,
-    streaming: false,
+    streaming: args.streaming === true,
   };
 }
 
@@ -428,6 +436,9 @@ interface CanvasTabHeaderProps {
   isStreaming: boolean;
   onReload: () => void;
   showCompare: boolean;
+  shareEnabled: boolean;
+  shareHint: string;
+  onShare: () => void;
 }
 
 const CanvasTabHeader: React.FC = ({
@@ -437,6 +448,9 @@ const CanvasTabHeader: React.FC = ({
   isStreaming,
   onReload,
   showCompare,
+  shareEnabled,
+  shareHint,
+  onShare,
 }) => {
   const { t } = useTranslation("sessions");
 
@@ -466,6 +480,7 @@ const CanvasTabHeader: React.FC = ({
           activeTab={tab}
           onChange={(key) => onSetTab(key as ViewTab)}
         />
+        
         {tab === "canvas" && !isStreaming && (
            = ({
             
           
         )}
+        
+          
+        
       
     
   );
@@ -484,6 +511,14 @@ const CanvasTabHeader: React.FC = ({
 
 const CanvasApp: React.FC = () => {
   const { t } = useTranslation("sessions");
+  const {
+    state: canvasShareState,
+    open: openCanvasShare,
+    close: closeCanvasShare,
+    retry: retryCanvasShare,
+    retryShortLink: retryCanvasShareShortLink,
+    copy: copyCanvasShare,
+  } = useCanvasShareDialog();
 
   const { appEvents } = useSimulatorAppState({
     config: CANVAS_APP_CONFIG as never,
@@ -620,6 +655,41 @@ const CanvasApp: React.FC = () => {
   const cardTitle = selectedPayload
     ? getDefaultTitle(selectedPayload, t)
     : t("canvasCard.titleHtml", "Agent Preview");
+  const shareAvailability = useMemo(
+    () =>
+      getCanvasShareAvailability(
+        selectedPayload,
+        selectedPayload?.streaming ?? false
+      ),
+    [selectedPayload]
+  );
+  const shareHint = shareAvailability.available
+    ? t("canvasApp.shareHint", "Share this Canvas snapshot")
+    : shareAvailability.reason === "streaming"
+      ? t(
+          "canvasApp.shareWaitForRevision",
+          "Wait for the Canvas update to finish"
+        )
+      : shareAvailability.reason === "local-url"
+        ? t(
+            "canvasApp.shareLocalUrlUnavailable",
+            "Local URLs cannot be opened by other people"
+          )
+        : shareAvailability.reason === "source-too-large"
+          ? t(
+              "canvasApp.shareTooLarge",
+              "This Canvas is too large for a share link"
+            )
+          : t("canvasApp.shareEmpty", "This Canvas has no shareable content");
+  const handleShare = useCallback(() => {
+    if (!selectedPayload || !shareAvailability.available) return;
+    openCanvasShare(selectedPayload, cardTitle);
+  }, [
+    cardTitle,
+    openCanvasShare,
+    selectedPayload,
+    shareAvailability.available,
+  ]);
 
   // ── publish to SimulatorWorkstationTabHeader ─────────────────────────────
 
@@ -633,6 +703,9 @@ const CanvasApp: React.FC = () => {
           isStreaming={selectedPayload.streaming ?? false}
           onReload={handleReload}
           showCompare={compareEventIds.length === 2}
+          shareEnabled={shareAvailability.available}
+          shareHint={shareHint}
+          onShare={handleShare}
         />
       ) : null,
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -643,6 +716,9 @@ const CanvasApp: React.FC = () => {
       cardTitle,
       handleReload,
       compareEventIds.length,
+      shareAvailability.available,
+      shareHint,
+      handleShare,
     ]
   );
 
@@ -741,21 +817,30 @@ const CanvasApp: React.FC = () => {
   );
 
   return (
-     {}}
-    >
-      
- -
-
+ <> + {}} + > +
+ +
+
+ + ); }; diff --git a/src/features/CanvasShare/CanvasShareDialog.test.ts b/src/features/CanvasShare/CanvasShareDialog.test.ts new file mode 100644 index 0000000000..1ab0e96a69 --- /dev/null +++ b/src/features/CanvasShare/CanvasShareDialog.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import { type ReactNode, act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import CanvasShareDialog from "./CanvasShareDialog"; +import type { CanvasShareDialogState } from "./useCanvasShareDialog"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string | { defaultValue?: string }) => + typeof fallback === "string" ? fallback : (fallback.defaultValue ?? _key), + i18n: { language: "en" }, + }), +})); +vi.mock("@/src/scaffold/ModalSystem", () => ({ + default: ({ + visible, + children, + }: { + visible: boolean; + children: ReactNode; + }) => (visible ? createElement("div", null, children) : null), +})); +vi.mock("@src/components/Input", () => ({ + default: ({ + errorMessage: _errorMessage, + ...props + }: { + errorMessage?: string; + }) => createElement("input", props), +})); +vi.mock("@src/components/Button", () => ({ + default: ({ + children, + htmlType, + loading: _loading, + href: _href, + target: _target, + rel: _rel, + ...props + }: { + children?: ReactNode; + htmlType?: "button"; + loading?: boolean; + href?: string; + target?: string; + rel?: string; + }) => createElement("button", { type: htmlType, ...props }, children), +})); + +describe("CanvasShareDialog fallback recovery", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function render( + state: CanvasShareDialogState, + onRetryShortLink = vi.fn(), + onRetry = vi.fn() + ) { + act(() => { + root.render( + createElement(CanvasShareDialog, { + state, + onClose: vi.fn(), + onRetry, + onRetryShortLink, + onCopy: vi.fn(), + }) + ); + }); + return onRetryShortLink; + } + + function fallbackState(retryingShortLink: boolean): CanvasShareDialogState { + return { + phase: "ready", + operationId: 1, + title: "Fallback", + payload: { mode: "html", content: "

Fallback

" }, + link: "https://example.test/#/share/g1/full", + linkKind: "self-contained", + copied: false, + copyError: false, + retryingShortLink, + }; + } + + it("keeps the full link visible while retrying the short link", () => { + const retry = render(fallbackState(false)); + const retryButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Retry short link" + ); + + act(() => retryButton?.click()); + expect(retry).toHaveBeenCalledOnce(); + + render(fallbackState(true), retry); + expect(container.querySelector("input")?.getAttribute("value")).toBe( + "https://example.test/#/share/g1/full" + ); + const retryingButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Retrying…" + ); + expect(retryingButton?.hasAttribute("disabled")).toBe(true); + }); + + it("explains a service outage with an oversized fallback and offers retry", () => { + const onRetry = vi.fn(); + render( + { + phase: "error", + operationId: 1, + title: "Big", + payload: { mode: "html", content: "

Big

" }, + error: "short-unavailable-too-large", + }, + vi.fn(), + onRetry + ); + + expect(container.textContent).toContain( + "The share service is temporarily unreachable and this Canvas is too large for a self-contained link. Try again in a moment." + ); + expect(container.textContent).not.toContain( + "This Canvas is too large for a reliable self-contained link." + ); + const retryButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Retry" + ); + act(() => retryButton?.click()); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("keeps the plain too-large message for a genuinely oversized Canvas", () => { + render({ + phase: "error", + operationId: 1, + title: "Huge", + payload: { mode: "html", content: "

Huge

" }, + error: "source-too-large", + }); + + expect(container.textContent).toContain( + "This Canvas is too large for a reliable self-contained link." + ); + }); +}); diff --git a/src/features/CanvasShare/CanvasShareDialog.tsx b/src/features/CanvasShare/CanvasShareDialog.tsx new file mode 100644 index 0000000000..4c9addeeeb --- /dev/null +++ b/src/features/CanvasShare/CanvasShareDialog.tsx @@ -0,0 +1,193 @@ +import Modal from "@/src/scaffold/ModalSystem"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Input from "@src/components/Input"; + +import type { + CanvasShareDialogError, + CanvasShareDialogState, +} from "./useCanvasShareDialog"; + +interface CanvasShareDialogProps { + state: CanvasShareDialogState; + onClose: () => void; + onRetry: () => void; + onRetryShortLink: () => void; + onCopy: () => void; +} + +function errorMessage( + error: CanvasShareDialogError, + t: (key: string, fallback: string) => string +): string { + switch (error) { + case "source-too-large": + return t( + "canvasApp.shareDialogTooLarge", + "This Canvas is too large for a reliable self-contained link." + ); + case "short-unavailable-too-large": + return t( + "canvasApp.shareDialogShortUnavailable", + "The share service is temporarily unreachable and this Canvas is too large for a self-contained link. Try again in a moment." + ); + case "unsupported-runtime": + return t( + "canvasApp.shareDialogUnsupported", + "This app version cannot create compressed Canvas links." + ); + case "invalid-payload": + return t( + "canvasApp.shareDialogInvalid", + "This Canvas does not contain a publishable snapshot." + ); + default: + return t( + "canvasApp.shareDialogError", + "The Canvas link could not be created." + ); + } +} + +const CanvasShareDialog: React.FC = ({ + state, + onClose, + onRetry, + onRetryShortLink, + onCopy, +}) => { + const { t, i18n } = useTranslation("sessions"); + const visible = state.phase !== "closed"; + const title = state.phase === "closed" ? "" : state.title; + + return ( + + {state.phase !== "closed" ? ( +
+
+
{title}
+
+ {t( + "canvasApp.shareDialogScope", + "Only this Canvas snapshot is included. The conversation, repository, session, and later revisions are not shared." + )} +
+
+ + {state.phase === "preparing" ? ( +
+ + {t("canvasApp.shareDialogPreparing", "Creating link…")} +
+ ) : state.phase === "ready" ? ( +
+ event.currentTarget.select()} + errorMessage={ + state.copyError + ? t( + "canvasApp.shareDialogCopyFailed", + "Copy failed. Select the link and copy it manually." + ) + : undefined + } + /> +
+
+
+ {t( + "canvasApp.shareDialogPublic", + "Anyone with this link can view the snapshot." + )} +
+ {state.linkKind === "short" && state.expiresAt ? ( +
+ {t("canvasApp.shareDialogShortExpiry", { + defaultValue: "Short link · valid until {{date}}", + date: new Intl.DateTimeFormat(i18n.language, { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(state.expiresAt)), + })} +
+ ) : state.linkKind === "self-contained" ? ( +
+ {t( + "canvasApp.shareDialogFallback", + "The short-link service is unavailable, so a full link was created instead." + )} +
+ ) : null} +
+
+ {state.linkKind === "self-contained" ? ( + + ) : null} + + +
+
+
+ ) : ( +
+ + {errorMessage(state.error, t)} + + +
+ )} +
+ ) : null} +
+ ); +}; + +export default CanvasShareDialog; diff --git a/src/features/CanvasShare/canvasShareCache.test.ts b/src/features/CanvasShare/canvasShareCache.test.ts new file mode 100644 index 0000000000..ee6beab11e --- /dev/null +++ b/src/features/CanvasShare/canvasShareCache.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CanvasInlinePayload } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; + +import { + canvasShareCacheTestApi, + getOrCreateCanvasShareLink, +} from "./canvasShareCache"; +import type { CanvasShareLinkResult } from "./canvasShareProtocol"; + +const testState = vi.hoisted(() => ({ + build: vi.fn(), +})); + +vi.mock("./canvasShareProtocol", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildCanvasShareLink: testState.build, + }; +}); + +function selfContained(link: string): CanvasShareLinkResult { + return { link, kind: "self-contained" }; +} + +async function awaitReady(payload: CanvasInlinePayload): Promise { + const lookup = getOrCreateCanvasShareLink(payload); + if (lookup.phase === "pending") await lookup.promise; +} + +describe("canvasShareCache retained accounting", () => { + beforeEach(() => { + testState.build.mockReset(); + canvasShareCacheTestApi.reset(); + }); + + afterEach(() => { + canvasShareCacheTestApi.reset(); + }); + + it("counts ready result links against the retained-character bound", async () => { + const linkCharacters = 100 * 1024; + testState.build.mockImplementation((payload: CanvasInlinePayload) => + Promise.resolve( + selfContained( + `https://example.test/#/share/g1/${(payload.content ?? "").slice( + 0, + 1 + )}${"x".repeat(linkCharacters)}` + ) + ) + ); + const contentCharacters = 300 * 1024; + for (const character of ["a", "b", "c"]) { + await awaitReady({ + mode: "html", + content: character.repeat(contentCharacters), + }); + } + + const snapshot = canvasShareCacheTestApi.snapshot(); + // Key characters alone (3 × ~300 Ki = ~900 Ki) would fit the 1 Mi bound; + // only link accounting (3 × ~100 Ki more) forces the oldest entry out. + expect(snapshot.size).toBe(2); + expect(snapshot.retainedCharacters).toBeGreaterThan( + 2 * (contentCharacters + linkCharacters) + ); + expect(snapshot.retainedCharacters).toBeLessThanOrEqual( + canvasShareCacheTestApi.limits.retainedCharacters + ); + }); + + it("re-enforces the bound when a pending entry becomes ready", async () => { + const links = new Map([ + ["a", 1024], + ["b", 300 * 1024], + ]); + testState.build.mockImplementation((payload: CanvasInlinePayload) => { + const marker = (payload.content ?? "").slice(0, 1); + return Promise.resolve( + selfContained( + `https://example.test/#/share/g1/${"x".repeat(links.get(marker) ?? 0)}` + ) + ); + }); + + await awaitReady({ mode: "html", content: "a".repeat(400 * 1024) }); + // Insertion stays within bounds on key characters (~901 Ki); only the + // ready transition adds the 300 Ki link that exceeds the 1 Mi bound. + await awaitReady({ mode: "html", content: "b".repeat(500 * 1024) }); + + const snapshot = canvasShareCacheTestApi.snapshot(); + expect(snapshot.size).toBe(1); + expect(snapshot.retainedCharacters).toBeLessThanOrEqual( + canvasShareCacheTestApi.limits.retainedCharacters + ); + }); + + it("aborts the in-flight generation when a pending entry is evicted", () => { + const signals: AbortSignal[] = []; + testState.build.mockImplementation( + ( + _payload: CanvasInlinePayload, + _viewerUrl: string | undefined, + signal: AbortSignal + ) => { + signals.push(signal); + return new Promise(() => undefined); + } + ); + + getOrCreateCanvasShareLink({ mode: "html", content: "pending-0" }); + const abortListener = vi.fn(); + signals[0].addEventListener("abort", abortListener); + + for ( + let index = 1; + index <= canvasShareCacheTestApi.limits.entries; + index += 1 + ) { + getOrCreateCanvasShareLink({ mode: "html", content: `pending-${index}` }); + } + + expect(signals).toHaveLength(canvasShareCacheTestApi.limits.entries + 1); + expect(abortListener).toHaveBeenCalledOnce(); + expect(signals[0].aborted).toBe(true); + expect(signals.slice(1).every((signal) => !signal.aborted)).toBe(true); + expect(canvasShareCacheTestApi.snapshot().size).toBe( + canvasShareCacheTestApi.limits.entries + ); + }); +}); diff --git a/src/features/CanvasShare/canvasShareCache.ts b/src/features/CanvasShare/canvasShareCache.ts new file mode 100644 index 0000000000..beaa3d6fd6 --- /dev/null +++ b/src/features/CanvasShare/canvasShareCache.ts @@ -0,0 +1,216 @@ +import type { CanvasInlinePayload } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; + +import { + type CanvasShareLinkResult, + type CanvasShareSnapshotV1, + buildCanvasShareLink, + createCanvasShareEnvelope, +} from "./canvasShareProtocol"; + +const MAX_CACHE_ENTRIES = 16; +const MAX_RETAINED_CHARACTERS = 1024 * 1024; +const SELF_CONTAINED_CACHE_TTL_MS = 5 * 60 * 1000; + +type CanvasShareCacheKey = CanvasShareSnapshotV1; + +interface CanvasShareCacheEntryBase { + token: symbol; + key: CanvasShareCacheKey; + retainedCharacters: number; +} + +interface PendingCanvasShareCacheEntry extends CanvasShareCacheEntryBase { + phase: "pending"; + promise: Promise; + controller: AbortController; +} + +interface ReadyCanvasShareCacheEntry extends CanvasShareCacheEntryBase { + phase: "ready"; + result: CanvasShareLinkResult; + cachedAtMs: number; +} + +type CanvasShareCacheEntry = + | PendingCanvasShareCacheEntry + | ReadyCanvasShareCacheEntry; + +type CanvasShareCacheLookup = + | { phase: "pending"; promise: Promise } + | { phase: "ready"; result: CanvasShareLinkResult }; + +// App-runtime LRU. Canvas tabs intentionally unmount when inactive, so the +// successful immutable link must live above any individual tab component. +const entries: CanvasShareCacheEntry[] = []; + +function cacheKeyFor(payload: CanvasInlinePayload): CanvasShareCacheKey { + return createCanvasShareEnvelope(payload).canvas; +} + +function cacheKeysMatch( + left: CanvasShareCacheKey, + right: CanvasShareCacheKey +): boolean { + return ( + left.mode === right.mode && + left.title === right.title && + left.content === right.content && + left.url === right.url + ); +} + +function retainedCharacters(key: CanvasShareCacheKey): number { + return ( + key.mode.length + + (key.title?.length ?? 0) + + (key.content?.length ?? 0) + + (key.url?.length ?? 0) + + 1 + ); +} + +function isReusable(entry: ReadyCanvasShareCacheEntry, nowMs: number): boolean { + if (entry.result.kind === "self-contained") { + return nowMs - entry.cachedAtMs < SELF_CONTAINED_CACHE_TTL_MS; + } + const expiresAtMs = Date.parse(entry.result.expiresAt); + return Number.isFinite(expiresAtMs) && expiresAtMs > nowMs; +} + +function abortIfPending(entry: CanvasShareCacheEntry): void { + if (entry.phase === "pending") entry.controller.abort(); +} + +function removeEntry(entry: CanvasShareCacheEntry): void { + const index = entries.indexOf(entry); + if (index < 0) return; + entries.splice(index, 1); +} + +function totalRetainedCharacters(): number { + return entries.reduce((total, entry) => total + entry.retainedCharacters, 0); +} + +function enforceBounds(): void { + while ( + entries.length > MAX_CACHE_ENTRIES || + (entries.length > 1 && totalRetainedCharacters() > MAX_RETAINED_CHARACTERS) + ) { + const oldest = entries.shift(); + if (oldest) abortIfPending(oldest); + } +} + +function touch(entry: CanvasShareCacheEntry): void { + const index = entries.indexOf(entry); + if (index < 0) return; + entries.splice(index, 1); + entries.push(entry); +} + +export function getOrCreateCanvasShareLink( + payload: CanvasInlinePayload, + nowMs: number = Date.now() +): CanvasShareCacheLookup { + let key: CanvasShareCacheKey; + try { + key = cacheKeyFor(payload); + } catch (error) { + return { phase: "pending", promise: Promise.reject(error) }; + } + const existingIndex = entries.findIndex((entry) => + cacheKeysMatch(entry.key, key) + ); + if (existingIndex >= 0) { + const existing = entries[existingIndex]; + if (existing.phase === "pending") { + touch(existing); + return { phase: "pending", promise: existing.promise }; + } + if (isReusable(existing, nowMs)) { + touch(existing); + return { phase: "ready", result: existing.result }; + } + removeEntry(existing); + } + + const controller = new AbortController(); + const token = Symbol("canvas-share-cache-entry"); + const keyCharacters = retainedCharacters(key); + const promise = buildCanvasShareLink( + payload, + undefined, + controller.signal + ).then( + (result) => { + const index = entries.findIndex((entry) => entry.token === token); + if (index >= 0) { + entries[index] = { + phase: "ready", + token, + key, + // A ready entry also retains its result link (up to the 64 Ki link + // cap), so the link must count against the memory bound. Eviction + // removes the whole entry, which subtracts both parts at once. + retainedCharacters: keyCharacters + result.link.length, + result, + cachedAtMs: Date.now(), + }; + enforceBounds(); + } + return result; + }, + (error: unknown) => { + const entry = entries.find((candidate) => candidate.token === token); + if (entry) removeEntry(entry); + throw error; + } + ); + const pendingEntry: PendingCanvasShareCacheEntry = { + phase: "pending", + token, + key, + retainedCharacters: keyCharacters, + promise, + controller, + }; + entries.push(pendingEntry); + enforceBounds(); + return { phase: "pending", promise }; +} + +export function refreshCanvasShareLink( + payload: CanvasInlinePayload +): CanvasShareCacheLookup { + let key: CanvasShareCacheKey; + try { + key = cacheKeyFor(payload); + } catch { + return getOrCreateCanvasShareLink(payload); + } + const entry = entries.find((candidate) => cacheKeysMatch(candidate.key, key)); + if (entry?.phase === "pending") { + touch(entry); + return { phase: "pending", promise: entry.promise }; + } + if (entry) removeEntry(entry); + return getOrCreateCanvasShareLink(payload); +} + +export const canvasShareCacheTestApi = { + limits: { + entries: MAX_CACHE_ENTRIES, + retainedCharacters: MAX_RETAINED_CHARACTERS, + selfContainedTtlMs: SELF_CONTAINED_CACHE_TTL_MS, + }, + reset(): void { + for (const entry of entries) abortIfPending(entry); + entries.length = 0; + }, + snapshot(): { size: number; retainedCharacters: number } { + return { + size: entries.length, + retainedCharacters: totalRetainedCharacters(), + }; + }, +}; diff --git a/src/features/CanvasShare/canvasShareProtocol.test.ts b/src/features/CanvasShare/canvasShareProtocol.test.ts new file mode 100644 index 0000000000..7833a41045 --- /dev/null +++ b/src/features/CanvasShare/canvasShareProtocol.test.ts @@ -0,0 +1,511 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; + +import { + CANVAS_SHARE_API_URL, + CANVAS_SHARE_HASH_PREFIX, + CANVAS_SHARE_SHORT_HASH_PREFIX, + CANVAS_SHARE_VIEWER_URL, + MAX_CANVAS_SHARE_SOURCE_BYTES, + buildCanvasShareLink, + buildSelfContainedCanvasShareLink, + createCanvasShareEnvelope, + encodeCanvasSharePayload, + getCanvasShareAvailability, + isCanvasShareEnvelope, + parseCanvasShareHash, +} from "./canvasShareProtocol"; + +/** Builds a raw share hash from an arbitrary envelope, bypassing producers. */ +async function craftShareHash(envelope: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(envelope)); + const stream = new Blob([bytes]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + const compressed = new Uint8Array(await new Response(stream).arrayBuffer()); + let binary = ""; + for (const byte of compressed) binary += String.fromCharCode(byte); + const encoded = btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + return `${CANVAS_SHARE_HASH_PREFIX}${encoded}`; +} + +describe("Canvas share protocol", () => { + it("uses the ORG2-owned origin for hosted and fallback links", async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: "abcdefghijklmnopqrstuv", + expiresAt: "2027-08-09T00:00:00.000Z", + }), + { status: 201, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchSpy); + try { + expect(CANVAS_SHARE_API_URL).toBe( + "https://canvas.org2.dev/api/canvas-shares" + ); + expect(CANVAS_SHARE_VIEWER_URL).toBe("https://canvas.org2.dev/"); + await expect( + buildCanvasShareLink({ mode: "html", content: "

Hosted

" }) + ).resolves.toEqual({ + link: "https://canvas.org2.dev/#/s/abcdefghijklmnopqrstuv", + kind: "short", + expiresAt: "2027-08-09T00:00:00.000Z", + }); + expect(String(fetchSpy.mock.calls[0][0])).toBe(CANVAS_SHARE_API_URL); + expect(buildSelfContainedCanvasShareLink("encoded-payload")).toBe( + "https://canvas.org2.dev/#/share/g1/encoded-payload" + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("round-trips only the selected Canvas snapshot", async () => { + const controller = new AbortController(); + const payloadWithPrivateFields = { + mode: "react" as const, + title: "Interactive prototype", + content: "function App(){ return ; }", + eventId: "event-secret", + revisesEventId: "event-older", + streaming: false, + }; + const encoded = await encodeCanvasSharePayload( + payloadWithPrivateFields, + controller.signal + ); + const link = buildSelfContainedCanvasShareLink( + encoded, + "https://example.test/viewer/" + ); + + const hash = new URL(link).hash; + expect(hash.startsWith(CANVAS_SHARE_HASH_PREFIX)).toBe(true); + await expect(parseCanvasShareHash(hash)).resolves.toEqual({ + version: 1, + canvas: { + mode: "react", + title: "Interactive prototype", + content: "function App(){ return ; }", + }, + }); + expect(link).not.toContain("event-secret"); + expect(link).not.toContain("event-older"); + }); + + it("round-trips a realistic large interactive Canvas", async () => { + const content = + `function App(){const [step,setStep]=React.useState(0);return ;}`.repeat( + 180 + ); + const encoded = await encodeCanvasSharePayload({ + mode: "react", + title: "Large prototype", + content, + }); + const link = buildSelfContainedCanvasShareLink( + encoded, + "https://example.test/viewer/" + ); + + const decoded = await parseCanvasShareHash(new URL(link).hash); + expect(decoded.canvas.content).toBe(content); + expect(link.length).toBeLessThan(64 * 1024); + }); + + it("does not allow incomplete, streaming, local URL, or oversized Canvases", () => { + expect(getCanvasShareAvailability(null, false)).toEqual({ + available: false, + reason: "empty", + }); + expect( + getCanvasShareAvailability( + { mode: "html", content: "

Still changing

" }, + true + ) + ).toEqual({ available: false, reason: "streaming" }); + expect( + getCanvasShareAvailability( + { mode: "url", url: "file:///tmp/a.html" }, + false + ) + ).toEqual({ available: false, reason: "local-url" }); + expect( + getCanvasShareAvailability( + { + mode: "html", + content: "x".repeat(MAX_CANVAS_SHARE_SOURCE_BYTES + 1), + }, + false + ) + ).toEqual({ available: false, reason: "source-too-large" }); + }); + + it("avoids UTF-8 allocation for ordinary Canvas eligibility checks", () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, "encode"); + try { + expect( + getCanvasShareAvailability( + { + mode: "html", + content: "x".repeat(Math.floor(MAX_CANVAS_SHARE_SOURCE_BYTES / 3)), + }, + false + ) + ).toEqual({ available: true }); + expect(encodeSpy).not.toHaveBeenCalled(); + + expect( + getCanvasShareAvailability( + { + mode: "html", + content: "你".repeat( + Math.floor(MAX_CANVAS_SHARE_SOURCE_BYTES / 3) + 1 + ), + }, + false + ) + ).toEqual({ available: false, reason: "source-too-large" }); + expect(encodeSpy).toHaveBeenCalledOnce(); + } finally { + encodeSpy.mockRestore(); + } + }); + + it("rejects malformed public links at the decoding boundary", async () => { + await expect( + parseCanvasShareHash(`${CANVAS_SHARE_HASH_PREFIX}not-a-gzip-payload`) + ).rejects.toMatchObject({ code: "invalid-payload" }); + }); + + it("rejects a compressed oversized payload at the decoding boundary", async () => { + const content = "x".repeat(MAX_CANVAS_SHARE_SOURCE_BYTES + 1); + await expect( + buildCanvasShareLink( + { mode: "html", content }, + "https://example.test/viewer/" + ) + ).rejects.toMatchObject({ code: "source-too-large" }); + }); + + it("does no encoding when generation is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const encodeSpy = vi.spyOn(TextEncoder.prototype, "encode"); + try { + await expect( + encodeCanvasSharePayload( + { mode: "html", content: "

Cancelled

" }, + controller.signal + ) + ).rejects.toMatchObject({ name: "AbortError" }); + expect(encodeSpy).not.toHaveBeenCalled(); + } finally { + encodeSpy.mockRestore(); + } + }); + + it("prefers a compact hosted link when the upload succeeds", async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: "abcdefghijklmnopqrstuv", + expiresAt: "2027-08-09T00:00:00.000Z", + }), + { status: 201, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchSpy); + try { + const result = await buildCanvasShareLink( + { mode: "html", content: "

Short

" }, + "https://example.test/viewer/", + undefined, + "https://api.example.test/canvas-shares" + ); + + expect(result).toEqual({ + link: `https://example.test/viewer/${CANVAS_SHARE_SHORT_HASH_PREFIX}abcdefghijklmnopqrstuv`, + kind: "short", + expiresAt: "2027-08-09T00:00:00.000Z", + }); + expect(fetchSpy).toHaveBeenCalledOnce(); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(requestBody.payload).toMatch(/^[A-Za-z0-9_-]+$/); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("falls back to a self-contained link when the upload is unavailable", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + try { + const result = await buildCanvasShareLink( + { mode: "html", content: "

Still shareable

" }, + "https://example.test/viewer/", + undefined, + "https://api.example.test/canvas-shares" + ); + + expect(result.kind).toBe("self-contained"); + expect(result.link).toContain(CANVAS_SHARE_HASH_PREFIX); + await expect( + parseCanvasShareHash(new URL(result.link).hash) + ).resolves.toMatchObject({ + canvas: { content: "

Still shareable

" }, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + describe("private and loopback URL rejection", () => { + const localUrls = [ + "http://localhost/dashboard", + "http://localhost:3000/app", + "http://app.localhost/preview", + "http://myhost.local/panel", + "http://service.internal/api", + "http://127.0.0.1/", + "http://127.8.9.10/loopback-block", + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://172.31.255.255/", + "http://192.168.1.10/router", + "http://169.254.169.254/latest/meta-data", + "http://0.0.0.0/", + "http://[::1]/", + "http://[fd12:3456::1]/", + "http://[fe80::1]/", + "http://[::ffff:7f00:1]/", + ]; + const publicUrls = [ + "https://example.com/page", + "http://example.com/page", + "https://172.15.0.1/", + "https://172.32.0.1/", + "https://11.22.33.44/", + "https://internal.example.com/", + "https://localhost.example.com/", + "https://[2001:db8::1]/", + ]; + + it("rejects local, loopback, and private-range URLs at the producer", () => { + for (const url of localUrls) { + expect( + getCanvasShareAvailability({ mode: "url", url }, false), + url + ).toEqual({ available: false, reason: "local-url" }); + } + }); + + it("still accepts publicly routable URLs at the producer", () => { + for (const url of publicUrls) { + expect( + getCanvasShareAvailability({ mode: "url", url }, false), + url + ).toEqual({ available: true }); + } + }); + + it("rejects the same hosts at the decode validator", async () => { + for (const url of localUrls) { + expect( + isCanvasShareEnvelope({ version: 1, canvas: { mode: "url", url } }), + url + ).toBe(false); + } + for (const url of publicUrls) { + expect( + isCanvasShareEnvelope({ version: 1, canvas: { mode: "url", url } }), + url + ).toBe(true); + } + const craftedHash = await craftShareHash({ + version: 1, + canvas: { mode: "url", url: "http://192.168.1.10/panel" }, + }); + await expect(parseCanvasShareHash(craftedHash)).rejects.toMatchObject({ + code: "invalid-payload", + }); + }); + }); + + describe("upload outage with an oversized fallback", () => { + // Random base64 text stays incompressible enough that the gzip+base64url + // fallback fragment exceeds the 64 Ki link cap while the raw source and + // the hosted 768 Ki upload cap are both respected. + const incompressibleContent = randomBytes(96 * 1024).toString("base64"); + + it("reports a retryable outage instead of claiming the Canvas is too large", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + try { + await expect( + buildCanvasShareLink( + { mode: "html", content: incompressibleContent }, + "https://example.test/viewer/", + undefined, + "https://api.example.test/canvas-shares" + ) + ).rejects.toMatchObject({ + code: "short-link-unavailable-too-large", + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps reporting a genuinely oversized source as too large", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + try { + await expect( + buildCanvasShareLink( + { + mode: "html", + content: "x".repeat(MAX_CANVAS_SHARE_SOURCE_BYTES + 1), + }, + "https://example.test/viewer/", + undefined, + "https://api.example.test/canvas-shares" + ) + ).rejects.toMatchObject({ code: "source-too-large" }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("still uploads the same snapshot once the service recovers", async () => { + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: "abcdefghijklmnopqrstuv", + expiresAt: "2027-08-09T00:00:00.000Z", + }), + { status: 201, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchSpy); + try { + await expect( + buildCanvasShareLink( + { mode: "html", content: incompressibleContent }, + "https://example.test/viewer/", + undefined, + "https://api.example.test/canvas-shares" + ) + ).resolves.toMatchObject({ kind: "short" }); + } finally { + vi.unstubAllGlobals(); + } + }); + }); + + describe("mode validation at the producing boundary", () => { + it("reports an unsupported mode as unavailable", () => { + expect( + getCanvasShareAvailability( + { mode: "pdf" as never, content: "binary" }, + false + ) + ).toEqual({ available: false, reason: "unsupported-mode" }); + }); + + it("rejects envelope creation for an unsupported mode with a typed error", () => { + expect(() => + createCanvasShareEnvelope({ mode: "pdf" as never, content: "binary" }) + ).toThrowError( + expect.objectContaining({ + name: "CanvasShareProtocolError", + code: "invalid-payload", + }) + ); + }); + }); + + it("fails loudly on a misconfigured share API URL instead of falling back", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + try { + await expect( + buildCanvasShareLink( + { mode: "html", content: "

Misconfigured

" }, + "https://example.test/viewer/", + undefined, + "not a valid absolute url" + ) + ).rejects.toMatchObject({ code: "invalid-payload" }); + await expect( + buildCanvasShareLink( + { mode: "html", content: "

Misconfigured

" }, + "https://example.test/viewer/", + undefined, + "http://insecure.example/api" + ) + ).rejects.toMatchObject({ code: "invalid-payload" }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("distinguishes a newer-version envelope from corruption when decoding", async () => { + const newerHash = await craftShareHash({ + version: 2, + canvas: { mode: "html", content: "

From the future

" }, + }); + await expect(parseCanvasShareHash(newerHash)).rejects.toMatchObject({ + code: "unsupported-version", + message: expect.stringContaining("newer version"), + }); + }); + + describe("title truncation at code-point boundaries", () => { + it("never bisects an emoji surrogate pair at the 200-unit cap", async () => { + const envelope = createCanvasShareEnvelope({ + mode: "html", + title: `${"x".repeat(199)}😀`, + content: "

Emoji title

", + }); + expect(envelope.canvas.title).toBe("x".repeat(199)); + expect(envelope.canvas.title).not.toContain("�"); + + const encoded = await encodeCanvasSharePayload({ + mode: "html", + title: `${"x".repeat(199)}😀`, + content: "

Emoji title

", + }); + const link = buildSelfContainedCanvasShareLink( + encoded, + "https://example.test/viewer/" + ); + const decoded = await parseCanvasShareHash(new URL(link).hash); + expect(decoded.canvas.title).toBe("x".repeat(199)); + }); + + it("never bisects a CJK extension character at the cap", () => { + const envelope = createCanvasShareEnvelope({ + mode: "html", + title: `a${"\u{20000}".repeat(100)}`, + content: "

CJK title

", + }); + expect(envelope.canvas.title).toBe(`a${"\u{20000}".repeat(99)}`); + expect(envelope.canvas.title).not.toContain("�"); + expect(envelope.canvas.title?.length).toBe(199); + }); + + it("keeps a title that ends exactly on a pair boundary intact", () => { + const envelope = createCanvasShareEnvelope({ + mode: "html", + title: "\u{20000}".repeat(100), + content: "

Exact fit

", + }); + expect(envelope.canvas.title).toBe("\u{20000}".repeat(100)); + expect(envelope.canvas.title?.length).toBe(200); + }); + }); +}); diff --git a/src/features/CanvasShare/canvasShareProtocol.ts b/src/features/CanvasShare/canvasShareProtocol.ts new file mode 100644 index 0000000000..4b85b62629 --- /dev/null +++ b/src/features/CanvasShare/canvasShareProtocol.ts @@ -0,0 +1,609 @@ +import type { CanvasInlinePayload } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; + +export const CANVAS_SHARE_PROTOCOL_VERSION = 1 as const; +export const CANVAS_SHARE_HASH_PREFIX = "#/share/g1/"; +export const CANVAS_SHARE_SHORT_HASH_PREFIX = "#/s/"; +export const MAX_CANVAS_SHARE_SOURCE_BYTES = 512 * 1024; +export const MAX_CANVAS_SHARE_LINK_CHARACTERS = 64 * 1024; +export const MAX_CANVAS_SHARE_UPLOAD_CHARACTERS = 768 * 1024; +const MAX_CANVAS_SHARE_URL_CHARACTERS = 4_096; +const MAX_UTF8_BYTES_PER_CODE_UNIT = 3; +const CANVAS_SHARE_UPLOAD_TIMEOUT_MS = 8_000; +const MAX_CANVAS_SHARE_ENVELOPE_BYTES = + MAX_CANVAS_SHARE_SOURCE_BYTES * 2 + 16 * 1024; + +export const CANVAS_SHARE_VIEWER_URL = "https://canvas.org2.dev/"; +export const CANVAS_SHARE_API_URL = "https://canvas.org2.dev/api/canvas-shares"; +const CANVAS_SHARE_MODES = new Set(["html", "react", "a2ui", "url"]); +const CANVAS_SHARE_SHORT_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/; +const MAX_CANVAS_SHARE_TITLE_CHARACTERS = 200; +const LOCAL_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"]; + +export interface CanvasShareSnapshotV1 { + mode: CanvasInlinePayload["mode"]; + title?: string; + content?: string; + url?: string; +} + +export interface CanvasShareEnvelopeV1 { + version: typeof CANVAS_SHARE_PROTOCOL_VERSION; + canvas: CanvasShareSnapshotV1; +} + +export type CanvasShareLinkResult = + | { link: string; kind: "short"; expiresAt: string } + | { link: string; kind: "self-contained" }; + +export type CanvasShareAvailability = + | { available: true } + | { + available: false; + reason: + | "empty" + | "streaming" + | "local-url" + | "source-too-large" + | "unsupported-mode"; + }; + +export class CanvasShareProtocolError extends Error { + constructor( + public readonly code: + | "invalid-payload" + | "unsupported-runtime" + | "source-too-large" + | "link-too-large" + | "short-link-unavailable" + | "short-link-unavailable-too-large" + | "unsupported-version", + message: string + ) { + super(message); + this.name = "CanvasShareProtocolError"; + } +} + +function exceedsUtf8ByteLimit(source: string, limit: number): boolean { + if (source.length > limit) return true; + if (source.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= limit) return false; + return new TextEncoder().encode(source).byteLength > limit; +} + +function parseIpv4Octets(hostname: string): number[] | null { + const parts = hostname.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + octets.push(octet); + } + return octets; +} + +function isPrivateIpv4(hostname: string): boolean { + const octets = parseIpv4Octets(hostname); + if (!octets) return false; + const [first, second] = octets; + return ( + first === 127 || // loopback + first === 10 || // RFC 1918 + (first === 172 && second >= 16 && second <= 31) || // RFC 1918 + (first === 192 && second === 168) || // RFC 1918 + (first === 169 && second === 254) || // link-local + first === 0 // "this network" + ); +} + +function isPrivateIpv6(hostname: string): boolean { + // The WHATWG URL parser serializes IPv6 hosts in brackets, e.g. "[::1]". + if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false; + const address = hostname.slice(1, -1).toLowerCase(); + if (address === "::" || address === "::1") return true; // unspecified / loopback + if (address.startsWith("::ffff:")) { + // IPv4-mapped address; the URL parser canonicalizes it into hex groups. + const groups = address.slice("::ffff:".length).split(":"); + if (groups.length === 2) { + const high = Number.parseInt(groups[0], 16); + const low = Number.parseInt(groups[1], 16); + if (Number.isFinite(high) && Number.isFinite(low)) { + return isPrivateIpv4( + `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}` + ); + } + } + return false; + } + const firstGroup = address.split(":", 1)[0]; + const value = firstGroup === "" ? 0 : Number.parseInt(firstGroup, 16); + if (!Number.isFinite(value)) return false; + return ( + (value & 0xfe00) === 0xfc00 || // unique-local fc00::/7 + (value & 0xffc0) === 0xfe80 // link-local fe80::/10 + ); +} + +function isLocalHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + if (normalized === "localhost") return true; + return LOCAL_HOSTNAME_SUFFIXES.some((suffix) => normalized.endsWith(suffix)); +} + +/** + * Shared by the producing availability gate and the decode validator: a + * shareable URL must be HTTP(S) *and* resolve to a publicly routable host. + * Loopback, RFC 1918, link-local, and `.local`/`.internal` hosts would leak a + * link that only works on the author's machine or network, so both boundaries + * reject them (availability reason `local-url`). + */ +function isPublicWebUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return false; + return ( + !isLocalHostname(url.hostname) && + !isPrivateIpv4(url.hostname) && + !isPrivateIpv6(url.hostname) + ); + } catch { + return false; + } +} + +export function getCanvasShareAvailability( + payload: CanvasInlinePayload | null, + isStreaming: boolean +): CanvasShareAvailability { + if (!payload) return { available: false, reason: "empty" }; + if (isStreaming || payload.streaming) { + return { available: false, reason: "streaming" }; + } + // The upstream extract casts unknown tool output, so an unrecognized mode + // can reach this producer boundary; reject it before it is encoded into an + // envelope the decode validator would refuse. + if (!CANVAS_SHARE_MODES.has(payload.mode)) { + return { available: false, reason: "unsupported-mode" }; + } + + if (payload.mode === "url") { + if (!isPublicWebUrl(payload.url)) { + return { available: false, reason: "local-url" }; + } + if (payload.url.length > MAX_CANVAS_SHARE_URL_CHARACTERS) { + return { available: false, reason: "source-too-large" }; + } + } else if (!payload.content) { + return { available: false, reason: "empty" }; + } else if ( + exceedsUtf8ByteLimit(payload.content, MAX_CANVAS_SHARE_SOURCE_BYTES) + ) { + return { available: false, reason: "source-too-large" }; + } + return { available: true }; +} + +export function createCanvasShareEnvelope( + payload: CanvasInlinePayload +): CanvasShareEnvelopeV1 { + const availability = getCanvasShareAvailability(payload, false); + if (!availability.available) { + const code = + availability.reason === "source-too-large" + ? "source-too-large" + : "invalid-payload"; + throw new CanvasShareProtocolError( + code, + `Canvas cannot be shared: ${availability.reason}` + ); + } + + const title = payload.title?.trim(); + const canvas: CanvasShareSnapshotV1 = { + mode: payload.mode, + ...(title + ? { + title: truncateAtCodePointBoundary( + title, + MAX_CANVAS_SHARE_TITLE_CHARACTERS + ), + } + : {}), + ...(payload.mode === "url" + ? { url: payload.url } + : { content: payload.content }), + }; + + return { version: CANVAS_SHARE_PROTOCOL_VERSION, canvas }; +} + +/** + * Truncates to at most `maxUnits` UTF-16 code units without bisecting a + * surrogate pair; a bisected pair would round-trip through UTF-8 as U+FFFD in + * the shared title. + */ +function truncateAtCodePointBoundary(value: string, maxUnits: number): string { + if (value.length <= maxUnits) return value; + const cut = value.slice(0, maxUnits); + const lastUnit = cut.charCodeAt(cut.length - 1); + const bisectsSurrogatePair = lastUnit >= 0xd800 && lastUnit <= 0xdbff; + return bisectsSurrogatePair ? cut.slice(0, -1) : cut; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + chunkSize) + ); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function toBufferSource(bytes: Uint8Array): Uint8Array { + const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength)); + copy.set(bytes); + return copy; +} + +async function gzip( + bytes: Uint8Array, + signal?: AbortSignal +): Promise { + if (typeof CompressionStream === "undefined") { + throw new CanvasShareProtocolError( + "unsupported-runtime", + "This WebView cannot create compressed Canvas links." + ); + } + const compressed = new Blob([toBufferSource(bytes)]) + .stream() + .pipeThrough(new CompressionStream("gzip"), { signal }); + return new Uint8Array(await new Response(compressed).arrayBuffer()); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("Canvas share generation was cancelled.", "AbortError"); +} + +async function gunzip(bytes: Uint8Array): Promise { + if (typeof DecompressionStream === "undefined") { + throw new CanvasShareProtocolError( + "unsupported-runtime", + "This runtime cannot open compressed Canvas links." + ); + } + const decompressed = new Blob([toBufferSource(bytes)]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + const reader = decompressed.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let done = false; + while (!done) { + const result = await reader.read(); + done = result.done; + if (result.done) continue; + const { value } = result; + total += value.byteLength; + if (total > MAX_CANVAS_SHARE_ENVELOPE_BYTES) { + await reader.cancel(); + throw new CanvasShareProtocolError( + "source-too-large", + "Canvas share payload exceeds the supported size." + ); + } + chunks.push(value); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function resolveViewerUrl(viewerUrl?: string): URL { + const configured = + viewerUrl ?? + process.env.REACT_APP_CANVAS_SHARE_VIEWER_URL ?? + CANVAS_SHARE_VIEWER_URL; + const url = new URL(configured); + if (url.protocol !== "https:" && url.hostname !== "localhost") { + throw new CanvasShareProtocolError( + "invalid-payload", + "Canvas share viewer must use HTTPS." + ); + } + url.search = ""; + url.hash = ""; + return url; +} + +function resolveApiUrl(apiUrl?: string): URL { + const configured = + apiUrl ?? + process.env.REACT_APP_CANVAS_SHARE_API_URL ?? + CANVAS_SHARE_API_URL; + let url: URL; + try { + url = new URL(configured); + } catch { + throw new CanvasShareProtocolError( + "invalid-payload", + "Canvas share API URL is not a valid absolute URL." + ); + } + if (url.protocol !== "https:" && url.hostname !== "localhost") { + throw new CanvasShareProtocolError( + "invalid-payload", + "Canvas share API must use HTTPS." + ); + } + url.hash = ""; + return url; +} + +function buildViewerLink(hash: string, viewerUrl?: string): string { + const url = resolveViewerUrl(viewerUrl); + url.hash = hash.slice(1); + return url.toString(); +} + +export async function encodeCanvasSharePayload( + payload: CanvasInlinePayload, + signal?: AbortSignal +): Promise { + throwIfAborted(signal); + const envelope = createCanvasShareEnvelope(payload); + const encoded = bytesToBase64Url( + await gzip(new TextEncoder().encode(JSON.stringify(envelope)), signal) + ); + throwIfAborted(signal); + return encoded; +} + +export function buildSelfContainedCanvasShareLink( + encoded: string, + viewerUrl?: string +): string { + const link = buildViewerLink( + `${CANVAS_SHARE_HASH_PREFIX}${encoded}`, + viewerUrl + ); + if (link.length > MAX_CANVAS_SHARE_LINK_CHARACTERS) { + throw new CanvasShareProtocolError( + "link-too-large", + "Canvas is too large to fit in a reliable share link." + ); + } + return link; +} + +interface ShortCanvasShareResponse { + id: string; + expiresAt: string; +} + +function isShortCanvasShareResponse( + value: unknown +): value is ShortCanvasShareResponse { + if (!value || typeof value !== "object") return false; + const response = value as Record; + return ( + typeof response.id === "string" && + CANVAS_SHARE_SHORT_ID_PATTERN.test(response.id) && + typeof response.expiresAt === "string" && + Number.isFinite(Date.parse(response.expiresAt)) + ); +} + +async function uploadCanvasSharePayload( + encoded: string, + apiUrl: string | undefined, + signal?: AbortSignal +): Promise { + if (encoded.length > MAX_CANVAS_SHARE_UPLOAD_CHARACTERS) { + throw new CanvasShareProtocolError( + "source-too-large", + "Compressed Canvas snapshot exceeds the upload limit." + ); + } + throwIfAborted(signal); + + // Resolve outside the failure-tolerant region below: a misconfigured API + // URL is a build/deployment defect and must fail loudly with its own error + // instead of being laundered into the retryable "service unavailable" path. + const target = resolveApiUrl(apiUrl); + + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(signal?.reason); + signal?.addEventListener("abort", abortFromCaller, { once: true }); + const timeout = globalThis.setTimeout( + () => controller.abort(new Error("Canvas short-link upload timed out.")), + CANVAS_SHARE_UPLOAD_TIMEOUT_MS + ); + + try { + const response = await fetch(target, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ payload: encoded }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Canvas share API returned ${response.status}.`); + } + const value: unknown = await response.json(); + if (!isShortCanvasShareResponse(value)) { + throw new Error("Canvas share API returned an invalid response."); + } + return value; + } catch (error) { + throwIfAborted(signal); + throw new CanvasShareProtocolError( + "short-link-unavailable", + error instanceof Error + ? error.message + : "Canvas short-link service is unavailable." + ); + } finally { + globalThis.clearTimeout(timeout); + signal?.removeEventListener("abort", abortFromCaller); + } +} + +/** + * Builds an immutable public Canvas link. It prefers the compact hosted form; + * if the upload boundary is unavailable, it falls back to the original + * self-contained fragment without making sharing dependent on cloud uptime. + */ +export async function buildCanvasShareLink( + payload: CanvasInlinePayload, + viewerUrl?: string, + signal?: AbortSignal, + apiUrl?: string +): Promise { + const encoded = await encodeCanvasSharePayload(payload, signal); + try { + const uploaded = await uploadCanvasSharePayload(encoded, apiUrl, signal); + return { + link: buildViewerLink( + `${CANVAS_SHARE_SHORT_HASH_PREFIX}${uploaded.id}`, + viewerUrl + ), + kind: "short", + expiresAt: uploaded.expiresAt, + }; + } catch (error) { + throwIfAborted(signal); + if ( + error instanceof CanvasShareProtocolError && + error.code !== "short-link-unavailable" + ) { + throw error; + } + try { + return { + link: buildSelfContainedCanvasShareLink(encoded, viewerUrl), + kind: "self-contained", + }; + } catch (fallbackError) { + if ( + fallbackError instanceof CanvasShareProtocolError && + fallbackError.code === "link-too-large" + ) { + // The snapshot fits the hosted upload but not a self-contained link. + // Reporting "too large" here would mask a transient service outage, + // so surface a retryable outage-specific error instead. + throw new CanvasShareProtocolError( + "short-link-unavailable-too-large", + "The Canvas short-link service is unavailable and this snapshot does not fit in a self-contained link." + ); + } + throw fallbackError; + } + } +} + +export async function parseCanvasShareHash( + hash: string +): Promise { + if (!hash.startsWith(CANVAS_SHARE_HASH_PREFIX)) { + throw new CanvasShareProtocolError( + "invalid-payload", + "Canvas share link has an unknown format." + ); + } + const encoded = hash.slice(CANVAS_SHARE_HASH_PREFIX.length); + try { + const json = new TextDecoder().decode( + await gunzip(base64UrlToBytes(encoded)) + ); + const value: unknown = JSON.parse(json); + if (isNewerVersionEnvelope(value)) { + throw new CanvasShareProtocolError( + "unsupported-version", + "This Canvas share link was created by a newer version and cannot be opened here." + ); + } + if (!isCanvasShareEnvelope(value)) throw new Error("Invalid envelope"); + return value; + } catch (error) { + if (error instanceof CanvasShareProtocolError) throw error; + throw new CanvasShareProtocolError( + "invalid-payload", + "Canvas share link is incomplete or invalid." + ); + } +} + +/** + * A structurally sound envelope stamped with a version above the supported + * one is not corruption — it was created by a newer producer. Detecting it + * lets the decoder report "created by a newer version" instead of the generic + * "incomplete or invalid" error. + */ +function isNewerVersionEnvelope(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const envelope = value as Record; + return ( + typeof envelope.version === "number" && + Number.isInteger(envelope.version) && + envelope.version > CANVAS_SHARE_PROTOCOL_VERSION + ); +} + +export function isCanvasShareEnvelope( + value: unknown +): value is CanvasShareEnvelopeV1 { + if (!value || typeof value !== "object") return false; + const envelope = value as Record; + if (envelope.version !== CANVAS_SHARE_PROTOCOL_VERSION) return false; + if (!envelope.canvas || typeof envelope.canvas !== "object") return false; + const canvas = envelope.canvas as Record; + if (typeof canvas.mode !== "string" || !CANVAS_SHARE_MODES.has(canvas.mode)) { + return false; + } + if (canvas.title !== undefined && typeof canvas.title !== "string") { + return false; + } + if ( + typeof canvas.title === "string" && + canvas.title.length > MAX_CANVAS_SHARE_TITLE_CHARACTERS + ) { + return false; + } + if (canvas.mode === "url") { + return ( + typeof canvas.url === "string" && + canvas.url.length <= MAX_CANVAS_SHARE_URL_CHARACTERS && + isPublicWebUrl(canvas.url) + ); + } + return ( + typeof canvas.content === "string" && + canvas.content.length > 0 && + !exceedsUtf8ByteLimit(canvas.content, MAX_CANVAS_SHARE_SOURCE_BYTES) + ); +} diff --git a/src/features/CanvasShare/index.ts b/src/features/CanvasShare/index.ts new file mode 100644 index 0000000000..3b79b3e2aa --- /dev/null +++ b/src/features/CanvasShare/index.ts @@ -0,0 +1,3 @@ +export { default as CanvasShareDialog } from "./CanvasShareDialog"; +export { getCanvasShareAvailability } from "./canvasShareProtocol"; +export { useCanvasShareDialog } from "./useCanvasShareDialog"; diff --git a/src/features/CanvasShare/useCanvasShareDialog.test.ts b/src/features/CanvasShare/useCanvasShareDialog.test.ts new file mode 100644 index 0000000000..3a9f26b2ea --- /dev/null +++ b/src/features/CanvasShare/useCanvasShareDialog.test.ts @@ -0,0 +1,646 @@ +// @vitest-environment jsdom +import { + type RefObject, + act, + createElement, + createRef, + forwardRef, + useImperativeHandle, +} from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + canvasShareCacheTestApi, + refreshCanvasShareLink, +} from "./canvasShareCache"; +import { CanvasShareProtocolError } from "./canvasShareProtocol"; +import { useCanvasShareDialog } from "./useCanvasShareDialog"; + +const testState = vi.hoisted(() => ({ + build: vi.fn(), + copy: vi.fn(), +})); + +vi.mock("./canvasShareProtocol", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildCanvasShareLink: testState.build, + }; +}); + +vi.mock("@src/util/data/clipboard", () => ({ copyText: testState.copy })); + +type ShareController = ReturnType; +type LinkResult = + | { link: string; kind: "self-contained" } + | { link: string; kind: "short"; expiresAt: string }; + +function fullLink(link: string): LinkResult { + return { link, kind: "self-contained" }; +} + +const Probe = forwardRef(function Probe(_props, ref) { + const controller = useCanvasShareDialog(); + useImperativeHandle(ref, () => controller, [controller]); + return null; +}); + +describe("useCanvasShareDialog", () => { + let container: HTMLDivElement; + let root: Root; + let mounted: boolean; + let controllerRef: RefObject; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + testState.build.mockReset(); + testState.copy.mockReset(); + canvasShareCacheTestApi.reset(); + mountProbe(); + }); + + function mountProbe(): void { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + mounted = true; + controllerRef = createRef(); + act(() => root.render(createElement(Probe, { ref: controllerRef }))); + } + + afterEach(() => { + if (mounted) act(() => root.unmount()); + canvasShareCacheTestApi.reset(); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function controller(): ShareController { + if (!controllerRef.current) throw new Error("Share controller not mounted"); + return controllerRef.current; + } + + it("moves from preparing to ready and copies the generated link", async () => { + testState.build.mockResolvedValue( + fullLink("https://example.test/#/share/g1/link") + ); + testState.copy.mockResolvedValue(undefined); + + await act(async () => { + controller().open({ mode: "html", content: "

Hello

" }, "Hello"); + await Promise.resolve(); + }); + + expect(controller().state).toMatchObject({ + phase: "ready", + title: "Hello", + link: "https://example.test/#/share/g1/link", + linkKind: "self-contained", + copied: false, + }); + + await act(async () => controller().copy()); + expect(testState.copy).toHaveBeenCalledWith( + "https://example.test/#/share/g1/link" + ); + expect(controller().state).toMatchObject({ + phase: "ready", + copied: true, + }); + }); + + it("does not reopen after a pending encode completes behind a close", async () => { + let resolveLink: (result: LinkResult) => void = () => undefined; + let generationSignal: AbortSignal | undefined; + testState.build.mockImplementation( + ( + _payload: unknown, + _viewerUrl: unknown, + signal: AbortSignal | undefined + ) => { + generationSignal = signal; + return new Promise((resolve) => { + resolveLink = resolve; + }); + } + ); + + act(() => { + controller().open({ mode: "html", content: "

Old

" }, "Old"); + }); + expect(controller().state.phase).toBe("preparing"); + + act(() => controller().close()); + expect(generationSignal?.aborted).toBe(false); + await act(async () => resolveLink(fullLink("https://example.test/stale"))); + + expect(controller().state.phase).toBe("closed"); + + act(() => + controller().open( + { mode: "html", content: "

Old

" }, + "Cached after close" + ) + ); + expect(testState.build).toHaveBeenCalledOnce(); + expect(controller().state).toMatchObject({ + phase: "ready", + link: "https://example.test/stale", + }); + }); + + it("reuses the last successful link after the Canvas tab remounts", async () => { + const payload = { mode: "html" as const, content: "

Cached

" }; + testState.build.mockResolvedValue(fullLink("https://example.test/cached")); + + await act(async () => { + controller().open(payload, "Cached"); + await Promise.resolve(); + }); + act(() => root.unmount()); + mounted = false; + container.remove(); + mountProbe(); + act(() => controller().open(payload, "Cached again")); + + expect(testState.build).toHaveBeenCalledOnce(); + expect(controller().state).toMatchObject({ + phase: "ready", + title: "Cached again", + link: "https://example.test/cached", + linkKind: "self-contained", + }); + }); + + it("retries a cached fallback without hiding the usable full link", async () => { + let resolveRetry: (result: LinkResult) => void = () => undefined; + const payload = { mode: "html" as const, content: "

Recover

" }; + testState.build + .mockResolvedValueOnce(fullLink("https://example.test/full")) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }) + ); + + await act(async () => { + controller().open(payload, "Recover"); + await Promise.resolve(); + }); + + act(() => controller().retryShortLink()); + expect(controller().state).toMatchObject({ + phase: "ready", + link: "https://example.test/full", + linkKind: "self-contained", + retryingShortLink: true, + }); + act(() => controller().retryShortLink()); + expect(testState.build).toHaveBeenCalledTimes(2); + + await act(async () => + resolveRetry({ + link: "https://example.test/#/s/recoveredrecoveredreco", + kind: "short", + expiresAt: "2099-01-01T00:00:00.000Z", + }) + ); + + expect(controller().state).toMatchObject({ + phase: "ready", + link: "https://example.test/#/s/recoveredrecoveredreco", + linkKind: "short", + retryingShortLink: false, + }); + }); + + it("shares one in-flight retry across concurrent consumers", async () => { + let resolveRetry: (result: LinkResult) => void = () => undefined; + const payload = { mode: "html" as const, content: "

Concurrent

" }; + testState.build + .mockResolvedValueOnce(fullLink("https://example.test/full")) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetry = resolve; + }) + ); + + await act(async () => { + controller().open(payload, "Concurrent"); + await Promise.resolve(); + }); + + const first = refreshCanvasShareLink(payload); + const second = refreshCanvasShareLink(payload); + expect(first.phase).toBe("pending"); + expect(second.phase).toBe("pending"); + if (first.phase !== "pending" || second.phase !== "pending") { + throw new Error("Expected a shared pending retry"); + } + expect(second.promise).toBe(first.promise); + expect(testState.build).toHaveBeenCalledTimes(2); + + await act(async () => + resolveRetry({ + link: "https://example.test/#/s/concurrentconcurrentco", + kind: "short", + expiresAt: "2099-01-01T00:00:00.000Z", + }) + ); + }); + + it("regenerates a fallback after its recovery TTL", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); + const payload = { mode: "html" as const, content: "

Fallback

" }; + testState.build + .mockResolvedValueOnce(fullLink("https://example.test/old-full")) + .mockResolvedValueOnce({ + link: "https://example.test/#/s/recoveredrecoveredreco", + kind: "short", + expiresAt: "2099-01-01T00:00:00.000Z", + } satisfies LinkResult); + + await act(async () => { + controller().open(payload, "Fallback"); + await Promise.resolve(); + }); + act(() => controller().close()); + nowSpy.mockReturnValue( + 1_000 + canvasShareCacheTestApi.limits.selfContainedTtlMs + 1 + ); + await act(async () => { + controller().open(payload, "Recovered"); + await Promise.resolve(); + }); + + expect(testState.build).toHaveBeenCalledTimes(2); + expect(controller().state).toMatchObject({ + phase: "ready", + linkKind: "short", + }); + nowSpy.mockRestore(); + }); + + it("keys the cache by the normalized public snapshot", async () => { + testState.build.mockResolvedValue( + fullLink("https://example.test/normalized") + ); + + await act(async () => { + controller().open( + { + mode: "html", + title: " Normalized ", + content: "

Same

", + streaming: false, + }, + "First" + ); + await Promise.resolve(); + }); + act(() => controller().close()); + act(() => + controller().open( + { mode: "html", title: "Normalized", content: "

Same

" }, + "Hydrated" + ) + ); + + expect(testState.build).toHaveBeenCalledOnce(); + expect(controller().state).toMatchObject({ + phase: "ready", + title: "Hydrated", + link: "https://example.test/normalized", + }); + }); + + it("shares one in-flight generation for duplicate opens", async () => { + let resolveLink: (result: LinkResult) => void = () => undefined; + testState.build.mockReturnValue( + new Promise((resolve) => { + resolveLink = resolve; + }) + ); + const payload = { mode: "html" as const, content: "

Same

" }; + + act(() => { + controller().open(payload, "First"); + controller().open(payload, "Latest"); + }); + expect(testState.build).toHaveBeenCalledOnce(); + + await act(async () => resolveLink(fullLink("https://example.test/same"))); + expect(controller().state).toMatchObject({ + phase: "ready", + title: "Latest", + link: "https://example.test/same", + }); + }); + + it("keeps older cache work from overwriting a newer Canvas snapshot", async () => { + const signals: AbortSignal[] = []; + const resolvers = new Map void>(); + testState.build.mockImplementation( + ( + payload: { content?: string }, + _viewerUrl: unknown, + signal: AbortSignal + ) => { + signals.push(signal); + return new Promise((resolve) => { + resolvers.set(payload.content ?? "", resolve); + }); + } + ); + + act(() => { + controller().open({ mode: "html", content: "

First

" }, "First"); + controller().open({ mode: "html", content: "

Second

" }, "Second"); + }); + + expect(testState.build).toHaveBeenCalledTimes(2); + expect(signals[0]?.aborted).toBe(false); + expect(signals[1]?.aborted).toBe(false); + expect(controller().state).toMatchObject({ + phase: "preparing", + title: "Second", + }); + + await act(async () => + resolvers.get("

Second

")?.(fullLink("https://example.test/second")) + ); + await act(async () => + resolvers.get("

First

")?.(fullLink("https://example.test/first")) + ); + + expect(controller().state).toMatchObject({ + phase: "ready", + title: "Second", + link: "https://example.test/second", + }); + }); + + it("reuses pending generation after the Canvas tab unmounts and remounts", async () => { + let resolveLink: (result: LinkResult) => void = () => undefined; + testState.build.mockImplementation( + () => + new Promise((resolve) => { + resolveLink = resolve; + }) + ); + const payload = { mode: "html" as const, content: "

Remount

" }; + + act(() => { + controller().open(payload, "Before switch"); + }); + act(() => root.unmount()); + mounted = false; + container.remove(); + mountProbe(); + + act(() => controller().open(payload, "After switch")); + expect(testState.build).toHaveBeenCalledOnce(); + + await act(async () => + resolveLink(fullLink("https://example.test/remounted")) + ); + expect(controller().state).toMatchObject({ + phase: "ready", + title: "After switch", + link: "https://example.test/remounted", + }); + }); + + it("separates a service outage with an oversized fallback from a too-large Canvas", async () => { + testState.build + .mockRejectedValueOnce( + new CanvasShareProtocolError( + "short-link-unavailable-too-large", + "Service unavailable and the snapshot does not fit in a link." + ) + ) + .mockResolvedValueOnce({ + link: "https://example.test/#/s/recoveredrecoveredreco", + kind: "short", + expiresAt: "2099-01-01T00:00:00.000Z", + } satisfies LinkResult); + + await act(async () => { + controller().open({ mode: "html", content: "

Big

" }, "Big"); + await Promise.resolve(); + }); + expect(controller().state).toMatchObject({ + phase: "error", + error: "short-unavailable-too-large", + }); + + await act(async () => { + controller().retry(); + await Promise.resolve(); + }); + + expect(testState.build).toHaveBeenCalledTimes(2); + expect(controller().state).toMatchObject({ + phase: "ready", + linkKind: "short", + link: "https://example.test/#/s/recoveredrecoveredreco", + }); + }); + + it("still reports a genuinely oversized Canvas as too large", async () => { + testState.build.mockRejectedValue( + new CanvasShareProtocolError( + "source-too-large", + "Compressed Canvas snapshot exceeds the upload limit." + ) + ); + + await act(async () => { + controller().open({ mode: "html", content: "

Huge

" }, "Huge"); + await Promise.resolve(); + }); + + expect(controller().state).toMatchObject({ + phase: "error", + error: "source-too-large", + }); + }); + + it("does not flash an error when shared in-flight work is abort-evicted", async () => { + testState.build.mockRejectedValue( + new DOMException("The generation was evicted.", "AbortError") + ); + + await act(async () => { + controller().open({ mode: "html", content: "

Evicted

" }, "Evicted"); + await Promise.resolve(); + }); + + expect(controller().state).toMatchObject({ phase: "preparing" }); + }); + + it("retries after a generation failure without caching the error", async () => { + testState.build + .mockRejectedValueOnce(new Error("encode failed")) + .mockResolvedValueOnce(fullLink("https://example.test/recovered")); + + await act(async () => { + controller().open({ mode: "html", content: "

Retry

" }, "Retry"); + await Promise.resolve(); + }); + expect(controller().state).toMatchObject({ + phase: "error", + error: "unknown", + }); + + await act(async () => { + controller().retry(); + await Promise.resolve(); + }); + + expect(testState.build).toHaveBeenCalledTimes(2); + expect(controller().state).toMatchObject({ + phase: "ready", + link: "https://example.test/recovered", + }); + }); + + it("keeps short-link metadata with the cached result", async () => { + const payload = { mode: "html" as const, content: "

Hosted

" }; + testState.build.mockResolvedValue({ + link: "https://example.test/#/s/abcdefghijklmnopqrstuv", + kind: "short", + expiresAt: "2027-08-09T00:00:00.000Z", + } satisfies LinkResult); + + await act(async () => { + controller().open(payload, "Hosted"); + await Promise.resolve(); + }); + act(() => controller().close()); + act(() => controller().open(payload, "Hosted again")); + + expect(testState.build).toHaveBeenCalledOnce(); + expect(controller().state).toMatchObject({ + phase: "ready", + linkKind: "short", + expiresAt: "2027-08-09T00:00:00.000Z", + }); + }); + + it("regenerates an expired short link", async () => { + const payload = { mode: "html" as const, content: "

Expired

" }; + testState.build + .mockResolvedValueOnce({ + link: "https://example.test/#/s/expiredexpiredexpiredex", + kind: "short", + expiresAt: "2000-01-01T00:00:00.000Z", + } satisfies LinkResult) + .mockResolvedValueOnce({ + link: "https://example.test/#/s/freshfreshfreshfreshfr", + kind: "short", + expiresAt: "2099-01-01T00:00:00.000Z", + } satisfies LinkResult); + + await act(async () => { + controller().open(payload, "Expired"); + await Promise.resolve(); + }); + act(() => controller().close()); + await act(async () => { + controller().open(payload, "Fresh"); + await Promise.resolve(); + }); + + expect(testState.build).toHaveBeenCalledTimes(2); + expect(controller().state).toMatchObject({ + phase: "ready", + link: "https://example.test/#/s/freshfreshfreshfreshfr", + }); + }); + + it("bounds the cross-tab cache and evicts the least-recent snapshot", async () => { + testState.build.mockImplementation((payload: { content?: string }) => + Promise.resolve(fullLink(`https://example.test/${payload.content}`)) + ); + + for ( + let index = 0; + index <= canvasShareCacheTestApi.limits.entries; + index += 1 + ) { + await act(async () => { + controller().open( + { mode: "html", content: `snapshot-${index}` }, + `Snapshot ${index}` + ); + await Promise.resolve(); + }); + act(() => controller().close()); + } + + expect(canvasShareCacheTestApi.snapshot()).toMatchObject({ + size: canvasShareCacheTestApi.limits.entries, + }); + await act(async () => { + controller().open( + { mode: "html", content: "snapshot-0" }, + "Evicted snapshot" + ); + await Promise.resolve(); + }); + + expect(testState.build).toHaveBeenCalledTimes( + canvasShareCacheTestApi.limits.entries + 2 + ); + }); + + it("bounds retained Canvas source characters independently of entry count", async () => { + testState.build.mockImplementation((payload: { title?: string }) => + Promise.resolve(fullLink(`https://example.test/${payload.title}`)) + ); + const contentSize = 480 * 1024; + const contents = ["a", "b", "c"].map((character) => + character.repeat(contentSize) + ); + + for (const [index, content] of contents.entries()) { + await act(async () => { + controller().open( + { mode: "html", title: `Large ${index}`, content }, + `Large ${index}` + ); + await Promise.resolve(); + }); + act(() => controller().close()); + } + + const snapshot = canvasShareCacheTestApi.snapshot(); + expect(snapshot.size).toBe(2); + expect(snapshot.retainedCharacters).toBeLessThanOrEqual( + canvasShareCacheTestApi.limits.retainedCharacters + ); + }); +}); diff --git a/src/features/CanvasShare/useCanvasShareDialog.ts b/src/features/CanvasShare/useCanvasShareDialog.ts new file mode 100644 index 0000000000..b0debf3e8c --- /dev/null +++ b/src/features/CanvasShare/useCanvasShareDialog.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { CanvasInlinePayload } from "@src/engines/ChatPanel/blocks/CanvasInlineCard/types"; +import { copyText } from "@src/util/data/clipboard"; + +import { + getOrCreateCanvasShareLink, + refreshCanvasShareLink, +} from "./canvasShareCache"; +import { + type CanvasShareLinkResult, + CanvasShareProtocolError, +} from "./canvasShareProtocol"; + +export type CanvasShareDialogError = + | "source-too-large" + | "short-unavailable-too-large" + | "unsupported-runtime" + | "invalid-payload" + | "copy-failed" + | "unknown"; + +export type CanvasShareDialogState = + | { phase: "closed"; operationId: number } + | { phase: "preparing"; operationId: number; title: string } + | { + phase: "ready"; + operationId: number; + title: string; + payload: CanvasInlinePayload; + link: string; + linkKind: CanvasShareLinkResult["kind"]; + expiresAt?: string; + copied: boolean; + copyError: boolean; + retryingShortLink: boolean; + } + | { + phase: "error"; + operationId: number; + title: string; + payload: CanvasInlinePayload; + error: CanvasShareDialogError; + }; + +function classifyError(error: unknown): CanvasShareDialogError { + if (!(error instanceof CanvasShareProtocolError)) return "unknown"; + switch (error.code) { + case "link-too-large": + case "source-too-large": + return "source-too-large"; + case "short-link-unavailable-too-large": + // Retry-later outage, not a permanently oversized Canvas: the snapshot + // fits the hosted upload and only the fallback link form is too large. + return "short-unavailable-too-large"; + case "unsupported-runtime": + case "invalid-payload": + return error.code; + default: + return "unknown"; + } +} + +function isAbortError(error: unknown): boolean { + // DOMException does not extend Error in every runtime (e.g. jsdom), so + // check both shapes. + if (error instanceof Error && error.name === "AbortError") return true; + return ( + typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "AbortError" + ); +} + +export function useCanvasShareDialog() { + const operationRef = useRef(0); + const [state, setState] = useState({ + phase: "closed", + operationId: 0, + }); + + const prepare = useCallback((payload: CanvasInlinePayload, title: string) => { + const operationId = ++operationRef.current; + const cached = getOrCreateCanvasShareLink(payload); + + if (cached.phase === "ready") { + setState({ + phase: "ready", + operationId, + title, + payload, + link: cached.result.link, + linkKind: cached.result.kind, + ...(cached.result.kind === "short" + ? { expiresAt: cached.result.expiresAt } + : {}), + copied: false, + copyError: false, + retryingShortLink: false, + }); + return; + } + + setState({ phase: "preparing", operationId, title }); + void cached.promise.then( + (result) => { + if (operationRef.current !== operationId) return; + setState({ + phase: "ready", + operationId, + title, + payload, + link: result.link, + linkKind: result.kind, + ...(result.kind === "short" ? { expiresAt: result.expiresAt } : {}), + copied: false, + copyError: false, + retryingShortLink: false, + }); + }, + (error: unknown) => { + if (operationRef.current !== operationId) return; + // Cache eviction aborts shared in-flight work. That is bookkeeping, + // not a user-facing failure, so do not flash an error state at + // subscribers that happen to share the evicted generation. + if (isAbortError(error)) return; + setState({ + phase: "error", + operationId, + title, + payload, + error: classifyError(error), + }); + } + ); + }, []); + + const open = useCallback( + (payload: CanvasInlinePayload, title: string) => { + prepare(payload, title); + }, + [prepare] + ); + + const close = useCallback(() => { + const operationId = ++operationRef.current; + setState({ phase: "closed", operationId }); + }, []); + + // The app-level cache owns in-flight requests across tab remounts. This + // cleanup only invalidates the unmounted dialog's promise subscriber. + useEffect( + () => () => { + operationRef.current += 1; + }, + [] + ); + + const retry = useCallback(() => { + if (state.phase !== "error") return; + prepare(state.payload, state.title); + }, [prepare, state]); + + const retryShortLink = useCallback(() => { + if ( + state.phase !== "ready" || + state.linkKind !== "self-contained" || + state.retryingShortLink + ) { + return; + } + + const previous = state; + const operationId = ++operationRef.current; + const cached = refreshCanvasShareLink(state.payload); + + if (cached.phase === "ready") { + setState({ + phase: "ready", + operationId, + title: state.title, + payload: state.payload, + link: cached.result.link, + linkKind: cached.result.kind, + ...(cached.result.kind === "short" + ? { expiresAt: cached.result.expiresAt } + : {}), + copied: false, + copyError: false, + retryingShortLink: false, + }); + return; + } + + setState({ ...state, operationId, retryingShortLink: true }); + void cached.promise.then( + (result) => { + if (operationRef.current !== operationId) return; + setState({ + phase: "ready", + operationId, + title: state.title, + payload: state.payload, + link: result.link, + linkKind: result.kind, + ...(result.kind === "short" ? { expiresAt: result.expiresAt } : {}), + copied: false, + copyError: false, + retryingShortLink: false, + }); + }, + () => { + if (operationRef.current !== operationId) return; + setState({ + ...previous, + operationId, + retryingShortLink: false, + }); + } + ); + }, [state]); + + const copy = useCallback(async () => { + if (state.phase !== "ready") return; + const { operationId, link } = state; + try { + await copyText(link); + if (operationRef.current !== operationId) return; + setState((current) => + current.phase === "ready" && current.operationId === operationId + ? { ...current, copied: true, copyError: false } + : current + ); + } catch { + if (operationRef.current !== operationId) return; + setState((current) => + current.phase === "ready" && current.operationId === operationId + ? { ...current, copied: false, copyError: true } + : current + ); + } + }, [state]); + + return { state, open, close, retry, retryShortLink, copy }; +} diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index ad52d717a8..a1a7c51a17 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2932,6 +2932,31 @@ "canvasApp": { "empty": "No canvas rendered yet", "sidebarTitle": "Canvases", - "noCanvases": "No canvases yet" + "noCanvases": "No canvases yet", + "share": "Share", + "shareHint": "Share this Canvas snapshot", + "shareWaitForRevision": "Wait for the Canvas update to finish", + "shareLocalUrlUnavailable": "Local URLs cannot be opened by other people", + "shareTooLarge": "This Canvas is too large for a share link", + "shareEmpty": "This Canvas has no shareable content", + "shareDialogTitle": "Share Canvas", + "shareDialogScope": "Only this Canvas snapshot is included. The conversation, repository, session, and later revisions are not shared.", + "shareDialogPreparing": "Creating link…", + "shareDialogLink": "Canvas share link", + "shareDialogCopyFailed": "Copy failed. Select the link and copy it manually.", + "shareDialogPublic": "Anyone with this link can view the snapshot.", + "shareDialogShortExpiry": "Short link · valid until {{date}}", + "shareDialogFallback": "The short-link service is unavailable, so a full link was created instead.", + "shareDialogOpen": "Open", + "shareDialogCopied": "Copied", + "shareDialogCopy": "Copy link", + "shareDialogTooLarge": "This Canvas is too large for a reliable self-contained link.", + "shareDialogShortUnavailable": "The share service is temporarily unreachable and this Canvas is too large for a self-contained link. Try again in a moment.", + "shareDialogUnsupported": "This app version cannot create compressed Canvas links.", + "shareDialogInvalid": "This Canvas does not contain a publishable snapshot.", + "shareDialogError": "The Canvas link could not be created.", + "shareDialogRetryShort": "Retry short link", + "shareDialogRetryingShort": "Retrying…", + "retry": "Retry" } } diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 27e81f61db..f56373c081 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2820,5 +2820,32 @@ "summarize": "讓 Agent 總結", "waiting": "等待內容中…", "empty": "無內容" + }, + "canvasApp": { + "share": "分享", + "shareHint": "分享這個 Canvas 快照", + "shareWaitForRevision": "請等待 Canvas 更新完成", + "shareLocalUrlUnavailable": "其他人無法開啟本機 URL", + "shareTooLarge": "這個 Canvas 太大,無法產生分享連結", + "shareEmpty": "這個 Canvas 沒有可分享的內容", + "shareDialogTitle": "分享 Canvas", + "shareDialogScope": "只包含這個 Canvas 快照。對話、儲存庫、會話以及後續修訂都不會被分享。", + "shareDialogPreparing": "正在建立連結…", + "shareDialogLink": "Canvas 分享連結", + "shareDialogCopyFailed": "複製失敗。請選取連結後手動複製。", + "shareDialogPublic": "任何擁有此連結的人都可以檢視該快照。", + "shareDialogShortExpiry": "短連結 · 有效期至 {{date}}", + "shareDialogFallback": "短連結服務不可用,已改為建立完整連結。", + "shareDialogOpen": "開啟", + "shareDialogCopied": "已複製", + "shareDialogCopy": "複製連結", + "shareDialogTooLarge": "這個 Canvas 太大,無法產生可靠的自包含連結。", + "shareDialogShortUnavailable": "分享服務暫時無法連線,而這個 Canvas 太大,無法產生自包含連結。請稍後再試。", + "shareDialogUnsupported": "目前應用版本無法建立壓縮的 Canvas 連結。", + "shareDialogInvalid": "這個 Canvas 不包含可發佈的快照。", + "shareDialogError": "無法建立 Canvas 連結。", + "shareDialogRetryShort": "重試短連結", + "shareDialogRetryingShort": "正在重試…", + "retry": "重試" } } diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index a32a0a7438..d50a00841d 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -2919,5 +2919,32 @@ "waiting": "等待内容中…", "empty": "无内容", "viewInSimulator": "在 Simulator 中查看" + }, + "canvasApp": { + "share": "分享", + "shareHint": "分享这个 Canvas 快照", + "shareWaitForRevision": "请等待 Canvas 更新完成", + "shareLocalUrlUnavailable": "其他人无法打开本地 URL", + "shareTooLarge": "这个 Canvas 太大,无法生成分享链接", + "shareEmpty": "这个 Canvas 没有可分享的内容", + "shareDialogTitle": "分享 Canvas", + "shareDialogScope": "只包含这个 Canvas 快照。对话、仓库、会话以及后续修订都不会被分享。", + "shareDialogPreparing": "正在创建链接…", + "shareDialogLink": "Canvas 分享链接", + "shareDialogCopyFailed": "复制失败。请选中链接后手动复制。", + "shareDialogPublic": "任何拥有此链接的人都可以查看该快照。", + "shareDialogShortExpiry": "短链接 · 有效期至 {{date}}", + "shareDialogFallback": "短链接服务不可用,已改为创建完整链接。", + "shareDialogOpen": "打开", + "shareDialogCopied": "已复制", + "shareDialogCopy": "复制链接", + "shareDialogTooLarge": "这个 Canvas 太大,无法生成可靠的自包含链接。", + "shareDialogShortUnavailable": "分享服务暂时无法访问,而这个 Canvas 太大,无法生成自包含链接。请稍后重试。", + "shareDialogUnsupported": "当前应用版本无法创建压缩的 Canvas 链接。", + "shareDialogInvalid": "这个 Canvas 不包含可发布的快照。", + "shareDialogError": "无法创建 Canvas 链接。", + "shareDialogRetryShort": "重试短链接", + "shareDialogRetryingShort": "正在重试…", + "retry": "重试" } }