{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-3",
  "title": "Desktop 3: software update",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The update window of a desktop app, the whole cycle: checking, a new version with release notes, downloading with progress, ready to relaunch, up to date and failed. Buttons follow each platform's order and wording.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/app-window.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/checkbox.json",
    "https://ui.ballmac.com/r/progress.json",
    "https://ui.ballmac.com/r/spinner.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/use-resource.json",
    "https://ui.ballmac.com/r/feel-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/desktop-3/desktop-3.tsx",
      "content": "// Ballmac UI: Desktop 3. https://ui.ballmac.com/blocks/desktop-3\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, CircleCheck, Download, RotateCw, Sparkles } from \"lucide-react\"\n\nimport { AppWindow, AppWindowContent, AppWindowTitleBar } from \"@/components/ballmac/app-window\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Checkbox } from \"@/components/ballmac/checkbox\"\nimport { Progress } from \"@/components/ballmac/progress\"\nimport { Spinner } from \"@/components/ballmac/spinner\"\nimport { frameProps, type ErrorConfig } from \"@/components/ballmac/state-view\"\nimport { useFeel, type FeelId } from \"@/lib/ballmac/feel\"\nimport type { BlockState } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype Desktop3Release = {\n  version: string\n  /** Already written for people (\"12 October 2026\"). */\n  date?: string\n  changes: { type?: \"new\" | \"improved\" | \"fixed\"; text: string }[]\n}\n\ntype Desktop3Props = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Which platform the window handles like. Defaults to the page's feel. */\n  feel?: FeelId\n  /** Draw the window frame. Turn off inside a real Tauri or Electron window that already has one. */\n  frame?: boolean\n  appName?: string\n  /** The version running now. */\n  currentVersion?: string\n  /** The releases since the current version, newest first. The first one is the version offered. */\n  releases?: Desktop3Release[]\n  /** The download size in bytes, shown while downloading. */\n  size?: number\n  /** The app icon. A generated one is drawn when you leave it out. */\n  icon?: React.ReactNode\n  /**\n   * Where the update is. Leave it out and the window runs the whole flow itself (offer, download with progress, ready to relaunch).\n   * `loading` is checking, `empty` is up to date, `submitting` is downloading (with `progress`), `success` is ready to relaunch, `error` failed.\n   */\n  state?: BlockState\n  /** Download progress from 0 to 1 while `state` is `submitting`. */\n  progress?: number\n  /** Copy for the error state. */\n  errorState?: ErrorConfig\n  /** Whether \"Automatically download and install updates\" is ticked (controlled). */\n  automatic?: boolean\n  onAutomaticChange?: (automatic: boolean) => void\n  /**\n   * Called by Install. Report progress from 0 to 1 with `report`; resolve when the update is downloaded and ready, throw to show the\n   * error. Defaults to a short simulated download.\n   */\n  onInstall?: (report: (progress: number) => void) => void | Promise<void>\n  /** Called by \"Install and relaunch\" once the update is ready. */\n  onRelaunch?: () => void\n  /** Called by \"Remind me later\" and Close. */\n  onRemindLater?: () => void\n  /** Called by \"Skip this version\" with the version skipped. */\n  onSkip?: (version: string) => void\n  /** Called by \"Try again\" in the error state, and by \"Check again\" when up to date. */\n  onRetry?: () => void\n}\n\nconst sample: Desktop3Release[] = [\n  {\n    version: \"2.5.0\",\n    date: \"14 October 2026\",\n    changes: [\n      { type: \"new\", text: \"Folders can be shared with a link that expires.\" },\n      { type: \"new\", text: \"Quick look: press Space on any item to preview it.\" },\n      { type: \"improved\", text: \"Search is up to three times faster in large libraries.\" },\n      { type: \"fixed\", text: \"The window remembers its size on displays with different scaling.\" },\n    ],\n  },\n  {\n    version: \"2.4.2\",\n    date: \"2 October 2026\",\n    changes: [{ type: \"fixed\", text: \"Sync no longer pauses after the computer wakes from sleep.\" }],\n  },\n]\n\nconst changeLabel = { new: \"New\", improved: \"Improved\", fixed: \"Fixed\" } as const\n\nconst mb = (bytes: number) => `${(bytes / 1_000_000).toFixed(1)} MB`\n\nfunction AppIcon({ icon, className }: { icon?: React.ReactNode; className?: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"from-primary to-primary/70 text-primary-foreground flex shrink-0 items-center justify-center rounded-[22%] bg-linear-to-b shadow-[0_1px_2px_rgb(0_0_0/0.2),inset_0_1px_0_rgb(255_255_255/0.25)] [&_svg]:size-1/2\",\n        className\n      )}\n    >\n      {icon ?? <Sparkles />}\n    </span>\n  )\n}\n\n/** The buttons along the bottom, in the platform's order: macOS puts the default button last, Windows first. */\nfunction Actions({ feel, start, children }: { feel: FeelId; start?: React.ReactNode; children: React.ReactNode[] }) {\n  const ordered = feel === \"windows\" ? [...children].reverse() : children\n  return (\n    <div data-slot=\"desktop-3-actions\" className=\"flex flex-wrap items-center gap-2\">\n      {start}\n      <div className=\"ms-auto flex flex-wrap justify-end gap-2\">{ordered}</div>\n    </div>\n  )\n}\n\nfunction Desktop3({\n  feel: feelProp,\n  frame = true,\n  appName = \"Acme\",\n  currentVersion = \"2.4.1\",\n  releases = sample,\n  size = 42_600_000,\n  icon,\n  state: stateProp,\n  progress: progressProp,\n  errorState,\n  automatic: automaticProp,\n  onAutomaticChange,\n  onInstall,\n  onRelaunch,\n  onRemindLater,\n  onSkip,\n  onRetry,\n  className,\n  ...props\n}: Desktop3Props) {\n  const page = useFeel().feel\n  const feel = feelProp ?? page\n  const win = feel === \"windows\"\n  const id = React.useId()\n  const [internal, setInternal] = React.useState<BlockState>(\"ready\")\n  const [internalProgress, setInternalProgress] = React.useState(0)\n  const [autoLocal, setAutoLocal] = React.useState(false)\n  const state = stateProp ?? internal\n  const progress = Math.min(1, Math.max(0, progressProp ?? internalProgress))\n  const automatic = automaticProp ?? autoLocal\n  const offered = releases[0]\n  const mounted = React.useRef(true)\n  React.useEffect(() => () => void (mounted.current = false), [])\n\n  async function install() {\n    setInternal(\"submitting\")\n    setInternalProgress(0)\n    const report = (p: number) => mounted.current && setInternalProgress(p)\n    try {\n      if (onInstall) await onInstall(report)\n      else\n        for (let i = 1; i <= 20; i++) {\n          await new Promise((r) => setTimeout(r, 90))\n          report(i / 20)\n        }\n      if (mounted.current) setInternal(\"success\")\n    } catch {\n      if (mounted.current) setInternal(\"error\")\n    }\n  }\n\n  const heading =\n    state === \"loading\"\n      ? \"Checking for updates…\"\n      : state === \"empty\" || !offered\n        ? `${appName} is up to date`\n        : state === \"error\"\n          ? \"The update could not be downloaded\"\n          : state === \"submitting\"\n            ? `Downloading ${appName} ${offered.version}…`\n            : state === \"success\"\n              ? `${appName} ${offered.version} is ready to install`\n              : `A new version of ${appName} is available`\n\n  const detail =\n    state === \"loading\"\n      ? `You have ${appName} ${currentVersion}.`\n      : state === \"empty\" || !offered\n        ? `${currentVersion} is the newest version.`\n        : state === \"error\"\n          ? (errorState?.description ?? \"Check your internet connection and try again. Nothing was changed.\")\n          : state === \"submitting\"\n            ? `${mb(size * progress)} of ${mb(size)}`\n            : state === \"success\"\n              ? `${appName} will quit, install the update and open again. Unsaved work is kept.`\n              : `${appName} ${offered.version} is available. You have ${currentVersion}.`\n\n  const showNotes = offered && (state === \"ready\" || state === \"submitting\" || state === \"success\")\n  const status = state === \"loading\" ? <Spinner className=\"size-4\" /> : state === \"error\" ? <CircleAlert className=\"text-destructive size-4\" /> : state === \"empty\" || state === \"success\" ? <CircleCheck className=\"text-chart-2 size-4\" /> : null\n\n  const content = (\n    <div data-slot=\"desktop-3-body\" className=\"flex min-h-0 flex-1 flex-col gap-4 p-5\">\n      <div className={cn(\"flex gap-4\", win ? \"items-center\" : \"items-start\")}>\n        <AppIcon icon={icon} className={win ? \"size-10\" : \"size-16\"} />\n        <div className=\"min-w-0 flex-1\">\n          {/* only the heading is live: the byte count under it changes many times a second */}\n          <h2 aria-live=\"polite\" aria-atomic=\"true\" id={`${id}-title`} className={cn(\"flex items-center gap-2 font-semibold text-pretty\", win ? \"text-lg\" : \"text-[15px]\")}>\n            {status ? <span aria-hidden=\"true\">{status}</span> : null}\n            {errorState?.title && state === \"error\" ? errorState.title : heading}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-[13px] tabular-nums text-pretty\">{detail}</p>\n        </div>\n      </div>\n\n      {state === \"submitting\" ? <Progress value={progress * 100} aria-label={`Downloading ${appName} ${offered?.version ?? \"\"}`} /> : null}\n\n      {showNotes ? (\n        <div className=\"flex min-h-0 flex-1 flex-col gap-2\">\n          <p className=\"text-[13px] font-medium\">{win ? \"What’s new\" : \"Release notes:\"}</p>\n          <div tabIndex={0} aria-label=\"Release notes\" role=\"region\" className=\"bg-background min-h-0 flex-1 overflow-y-auto rounded-md border p-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n            {releases.map((r) => (\n              <section key={r.version} className=\"mb-4 last:mb-0\">\n                <h3 className=\"text-sm font-semibold\">\n                  {appName} {r.version}\n                  {r.date ? <span className=\"text-muted-foreground ms-2 text-xs font-normal\">{r.date}</span> : null}\n                </h3>\n                <ul className=\"mt-2 grid gap-1.5\">\n                  {r.changes.map((c, i) => (\n                    <li key={i} className=\"flex gap-2 text-[13px]\">\n                      {c.type ? (\n                        <span\n                          className={cn(\n                            \"mt-px h-fit shrink-0 rounded px-1.5 text-[10px] leading-4 font-medium uppercase\",\n                            c.type === \"new\" ? \"bg-primary/10 text-primary\" : c.type === \"fixed\" ? \"bg-chart-2/15 text-foreground\" : \"bg-muted text-foreground\"\n                          )}\n                        >\n                          {changeLabel[c.type]}\n                        </span>\n                      ) : null}\n                      <span className=\"text-pretty\">{c.text}</span>\n                    </li>\n                  ))}\n                </ul>\n              </section>\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex-1\" />\n      )}\n\n      {state === \"ready\" && offered ? (\n        <div className=\"flex items-center gap-2\">\n          <Checkbox\n            id={`${id}-auto`}\n            checked={automatic}\n            onCheckedChange={(c) => {\n              setAutoLocal(c === true)\n              onAutomaticChange?.(c === true)\n            }}\n          />\n          <label htmlFor={`${id}-auto`} className=\"text-[13px]\">\n            Automatically download and install updates in the future\n          </label>\n        </div>\n      ) : null}\n\n      {state === \"ready\" && offered ? (\n        <Actions\n          feel={feel}\n          start={\n            <Button variant={win ? \"link\" : \"outline\"} size=\"sm\" className={cn(win && \"px-0\")} onClick={() => onSkip?.(offered.version)}>\n              {win ? \"Skip this version\" : \"Skip This Version\"}\n            </Button>\n          }\n        >\n          {[\n            <Button key=\"later\" variant=\"outline\" size=\"sm\" onClick={onRemindLater}>\n              {win ? \"Remind me later\" : \"Remind Me Later\"}\n            </Button>,\n            <Button key=\"install\" size=\"sm\" onClick={install}>\n              <Download aria-hidden=\"true\" />\n              {win ? \"Install now\" : \"Install Update\"}\n            </Button>,\n          ]}\n        </Actions>\n      ) : state === \"submitting\" ? (\n        <Actions feel={feel}>\n          {[\n            <Button key=\"hide\" variant=\"outline\" size=\"sm\" onClick={onRemindLater}>\n              Hide\n            </Button>,\n          ]}\n        </Actions>\n      ) : state === \"success\" ? (\n        <Actions feel={feel}>\n          {[\n            <Button key=\"later\" variant=\"outline\" size=\"sm\" onClick={onRemindLater}>\n              {win ? \"Install on quit\" : \"Install on Quit\"}\n            </Button>,\n            <Button key=\"relaunch\" size=\"sm\" onClick={onRelaunch}>\n              <RotateCw aria-hidden=\"true\" />\n              {win ? \"Restart now\" : \"Install and Relaunch\"}\n            </Button>,\n          ]}\n        </Actions>\n      ) : state === \"error\" ? (\n        <Actions feel={feel}>\n          {[\n            <Button key=\"close\" variant=\"outline\" size=\"sm\" onClick={onRemindLater}>\n              {win ? \"Close\" : \"Cancel\"}\n            </Button>,\n            <Button key=\"retry\" size=\"sm\" onClick={onRetry ?? (stateProp === undefined ? install : undefined)}>\n              {errorState?.retryLabel ?? (win ? \"Try again\" : \"Try Again\")}\n            </Button>,\n          ]}\n        </Actions>\n      ) : state === \"empty\" || !offered ? (\n        <Actions feel={feel}>\n          {[\n            <Button key=\"ok\" size=\"sm\" onClick={onRemindLater}>\n              OK\n            </Button>,\n          ]}\n        </Actions>\n      ) : (\n        <Actions feel={feel}>\n          {[\n            <Button key=\"cancel\" variant=\"outline\" size=\"sm\" onClick={onRemindLater}>\n              Cancel\n            </Button>,\n          ]}\n        </Actions>\n      )}\n    </div>\n  )\n\n  const busy = state === \"loading\" || state === \"submitting\"\n  if (!frame)\n    return (\n      <div data-slot=\"desktop-3\" data-state={state} data-feel={feelProp} aria-busy={busy || undefined} aria-labelledby={`${id}-title`} role=\"region\" className={cn(\"flex flex-col\", className)} {...frameProps(props)}>\n        {content}\n      </div>\n    )\n  return (\n    <AppWindow\n      data-slot=\"desktop-3\"\n      data-state={state}\n      feel={feel}\n      onClose={onRemindLater}\n      aria-busy={busy || undefined}\n      aria-labelledby={`${id}-title`}\n      role=\"region\"\n      className={cn(\"h-[28rem] w-full max-w-xl\", className)}\n      {...frameProps(props)}\n    >\n      <AppWindowTitleBar title={win ? `${appName} update` : \"Software Update\"} icon={<RotateCw />} className=\"border-b-0\" />\n      <AppWindowContent className=\"flex flex-col\">{content}</AppWindowContent>\n    </AppWindow>\n  )\n}\n\nexport { Desktop3, type Desktop3Props, type Desktop3Release }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/desktop-3/desktop-3.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "update",
      "updater",
      "sparkle",
      "release notes",
      "tauri",
      "electron",
      "macos",
      "windows",
      "progress",
      "feel"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "An updater window. Leave state out and it runs itself: <Desktop3 currentVersion releases={[{ version, date, changes: [{ type: 'new' | 'improved' | 'fixed', text }] }]} onInstall={async (report) => { …; report(0.5) }} onRelaunch onSkip onRemindLater />. Or drive it from your updater with state (loading = checking, submitting + progress = downloading, success = ready, empty = up to date, error). macOS: Title Case buttons, default button last. Windows: sentence case, default button first.",
      "whenToUse": [
        "The update prompt of a Tauri app (tauri-plugin-updater) or an Electron app (electron-updater)",
        "A changelog-style \"what's new\" window after an update"
      ],
      "whenNotToUse": [
        "A public changelog page (use changelog-1)",
        "A small in-app banner that an update exists (use banner)"
      ],
      "composesWith": [
        "desktop-1",
        "app-window",
        "feel-auto"
      ],
      "a11y": [
        {
          "keys": "Heading",
          "action": "Each stage's heading is announced politely; the byte count is not, so it does not chatter"
        },
        {
          "keys": "Tab to the release notes",
          "action": "The notes region scrolls with the arrow keys"
        },
        {
          "keys": "Tab",
          "action": "Buttons are in the platform's visual order, so focus order matches what people see"
        }
      ],
      "customization": [
        "feel: 'macos' | 'windows' (defaults to the page's feel); frame={false} inside a real native window",
        "Tauri: const update = await check(); onInstall={(report) => update.downloadAndInstall((e) => …report(done / total))}; onRelaunch={() => relaunch()}",
        "Electron: map autoUpdater's checking-for-update, download-progress, update-downloaded and error events onto state and progress; onRelaunch={() => autoUpdater.quitAndInstall()}",
        "automatic + onAutomaticChange control the checkbox; size is the download size in bytes"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Checking",
        "example": "desktop-3-checking",
        "trigger": "state=\"loading\"",
        "note": "A spinner and \"Checking for updates…\" with the current version; marked busy."
      },
      {
        "id": "submitting",
        "title": "Downloading",
        "example": "desktop-3-downloading",
        "trigger": "state=\"submitting\" progress={0.4}",
        "note": "A progress bar with megabytes done of the total; the release notes stay readable and Hide keeps it going in the background."
      },
      {
        "id": "success",
        "title": "Ready to relaunch",
        "example": "desktop-3-ready",
        "trigger": "state=\"success\"",
        "note": "\"Install and Relaunch\" (macOS) or \"Restart now\" (Windows) calls onRelaunch; the other button installs on quit."
      },
      {
        "id": "empty",
        "title": "Up to date",
        "example": "desktop-3-up-to-date",
        "trigger": "state=\"empty\"",
        "note": "Says the current version is the newest, with OK."
      },
      {
        "id": "error",
        "title": "Download failed",
        "example": "desktop-3-error",
        "trigger": "state=\"error\"",
        "note": "Says nothing was changed and offers Try Again (onRetry); errorState sets the copy."
      }
    ],
    "examples": [
      "desktop-3-demo",
      "desktop-3-windows",
      "desktop-3-checking",
      "desktop-3-downloading",
      "desktop-3-ready",
      "desktop-3-up-to-date",
      "desktop-3-error"
    ],
    "url": "https://ui.ballmac.com/components/desktop-3"
  },
  "categories": [
    "blocks",
    "desktop"
  ],
  "type": "registry:block"
}