{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-5",
  "title": "Desktop 5: permissions setup",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The first-run permissions window: why each one is needed, Allow for ones not asked yet, the exact System Settings path for ones turned off, a re-check on return, and Continue once required ones are on.",
  "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/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/feel-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/desktop-5/desktop-5.tsx",
      "content": "// Ballmac UI: Desktop 5. https://ui.ballmac.com/blocks/desktop-5\n\"use client\"\n\nimport * as React from \"react\"\nimport { Accessibility, Bell, Check, ExternalLink, Loader2, MonitorUp, ShieldCheck, Sparkles } from \"lucide-react\"\n\nimport { AppWindow, AppWindowContent, AppWindowTitleBar } from \"@/components/ballmac/app-window\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useFeel, type FeelId } from \"@/lib/ballmac/feel\"\nimport { cn } from \"@/lib/utils\"\n\ntype Desktop5Status = \"granted\" | \"denied\" | \"prompt\"\n\ntype Desktop5Permission = {\n  id: string\n  title: string\n  /** Why the app needs it, in one sentence people can judge. */\n  description: string\n  icon?: React.ReactNode\n  /** Continue stays disabled until every required permission is granted. */\n  required?: boolean\n  /** granted, denied (turned off; only the system settings can change it) or prompt (not asked yet). */\n  status: Desktop5Status\n  /** Where it lives in the system settings, per platform, shown when it was denied. */\n  settingsPath?: Partial<Record<FeelId, string>>\n}\n\ntype Desktop5Props = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  StateProps & {\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 app icon. A generated one is drawn when you leave it out. */\n    icon?: React.ReactNode\n    /** The permissions, in the order to ask for them. */\n    permissions?: Desktop5Permission[]\n    /** Called by Allow: show the system prompt and resolve with the answer. Defaults to granting after a moment. */\n    onRequest?: (id: string) => Desktop5Status | Promise<Desktop5Status>\n    /** Called by \"Open System Settings\" (macOS) or \"Open Settings\" (Windows) for a permission that was denied. */\n    onOpenSettings?: (id: string) => void\n    /**\n     * Called when the window gets focus again, the moment people come back from the system settings. Read the real statuses and\n     * pass them back through `permissions`.\n     */\n    onRecheck?: () => void\n    /** Called by Continue, enabled once every required permission is granted. */\n    onContinue?: () => void\n    /** Called by \"Set up later\". Leave it out to hide the button. */\n    onSkip?: () => void\n  }\n\nconst sample: Desktop5Permission[] = [\n  {\n    id: \"accessibility\",\n    title: \"Accessibility\",\n    description: \"Reads the text you select so it can be summarized. Nothing leaves this computer.\",\n    icon: <Accessibility />,\n    required: true,\n    status: \"granted\",\n    settingsPath: { macos: \"Privacy & Security › Accessibility\", windows: \"Accessibility\" },\n  },\n  {\n    id: \"screen\",\n    title: \"Screen recording\",\n    description: \"Captures the window you pick when you share a screenshot.\",\n    icon: <MonitorUp />,\n    status: \"denied\",\n    settingsPath: { macos: \"Privacy & Security › Screen & System Audio Recording\", windows: \"Privacy & security › Screenshots and apps\" },\n  },\n  {\n    id: \"notifications\",\n    title: \"Notifications\",\n    description: \"Tells you when a long summary is ready.\",\n    icon: <Bell />,\n    status: \"prompt\",\n    settingsPath: { macos: \"Notifications\", windows: \"System › Notifications\" },\n  },\n]\n\nfunction AppIcon({ icon }: { icon?: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"from-primary to-primary/70 text-primary-foreground flex size-14 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    >\n      {icon ?? <Sparkles />}\n    </span>\n  )\n}\n\nfunction RowsSkeleton() {\n  return (\n    <div className=\"grid gap-2\" aria-hidden=\"true\">\n      {[0, 1, 2].map((i) => (\n        <div key={i} className=\"flex items-center gap-3 rounded-lg border p-3\">\n          <Skeleton className=\"size-9 rounded-lg\" />\n          <div className=\"grid flex-1 gap-1.5\">\n            <Skeleton className=\"h-3.5 w-1/3\" />\n            <Skeleton className=\"h-3 w-4/5\" />\n          </div>\n          <Skeleton className=\"h-8 w-20 rounded-md\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * The first-run window that asks for system permissions one at a time, says why each is needed, sends people to the right place in\n * the system settings when one was turned off, and checks again the moment they come back.\n */\nfunction Desktop5({\n  feel: feelProp,\n  frame = true,\n  appName = \"Acme\",\n  icon,\n  permissions: permissionsProp = sample,\n  onRequest,\n  onOpenSettings,\n  onRecheck,\n  onContinue,\n  onSkip,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Desktop5Props) {\n  const page = useFeel().feel\n  const feel = feelProp ?? page\n  const win = feel === \"windows\"\n  const mac = feel === \"macos\"\n  const id = React.useId()\n  const settingsApp = mac ? \"System Settings\" : \"Settings\"\n  const [list, setList] = React.useState(permissionsProp)\n  React.useEffect(() => setList(permissionsProp), [permissionsProp])\n  const [asking, setAsking] = React.useState<string | null>(null)\n\n  // coming back from the system settings focuses this window again: the moment to read the statuses\n  React.useEffect(() => {\n    if (!onRecheck) return\n    window.addEventListener(\"focus\", onRecheck)\n    return () => window.removeEventListener(\"focus\", onRecheck)\n  }, [onRecheck])\n\n  async function request(p: Desktop5Permission) {\n    setAsking(p.id)\n    try {\n      const status = onRequest ? await onRequest(p.id) : await new Promise<Desktop5Status>((r) => setTimeout(() => r(\"granted\"), 700))\n      setList((l) => l.map((x) => (x.id === p.id ? { ...x, status } : x)))\n    } finally {\n      setAsking(null)\n    }\n  }\n\n  const granted = list.filter((p) => p.status === \"granted\").length\n  const missing = list.filter((p) => p.required && p.status !== \"granted\")\n  const t = (macText: string, other: string) => (mac ? macText : other)\n\n  const content = (\n    <div className=\"flex min-h-0 flex-1 flex-col gap-5 p-6\">\n      <div className={cn(\"flex gap-4\", mac ? \"flex-col items-center text-center\" : \"items-center\")}>\n        <AppIcon icon={icon} />\n        <div>\n          <h2 id={`${id}-title`} className=\"text-lg font-semibold\">\n            {t(`Welcome to ${appName}`, `Set up ${appName}`)}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-[13px] text-pretty\">\n            {appName} needs your permission for a few things. You can change them any time in {settingsApp}.\n          </p>\n        </div>\n      </div>\n\n      <StateView\n        state={state}\n        onRetry={onRetry}\n        label=\"Checking permissions\"\n        loading={<RowsSkeleton />}\n        empty={{ title: emptyState?.title ?? \"Nothing to set up\", description: emptyState?.description ?? `${appName} is ready to use.`, icon: emptyState?.icon ?? <ShieldCheck />, action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"Permissions could not be read\", description: errorState?.description ?? `Open ${settingsApp} to check them, or try again.`, ...errorState }}\n        className=\"min-h-0 flex-1 overflow-y-auto\"\n      >\n        <ul aria-label=\"Permissions\" className={cn(\"grid gap-2\", !win && \"bg-muted/40 gap-0 divide-y overflow-hidden rounded-lg border\")}>\n          {list.map((p) => {\n            const path = p.settingsPath?.[feel] ?? p.settingsPath?.macos\n            return (\n              <li key={p.id} data-status={p.status} className={cn(\"flex flex-wrap items-center gap-3 p-3\", win && \"bg-card rounded-md border\")}>\n                <span aria-hidden=\"true\" className=\"bg-background text-foreground flex size-9 shrink-0 items-center justify-center rounded-lg border [&_svg]:size-4\">\n                  {p.icon ?? <ShieldCheck />}\n                </span>\n                <div className=\"min-w-0 flex-1 basis-48\">\n                  <p className=\"flex flex-wrap items-center gap-x-2 text-sm font-medium\">\n                    {p.title}\n                    {p.required ? <span className=\"text-muted-foreground text-xs font-normal\">Required</span> : null}\n                  </p>\n                  <p className=\"text-muted-foreground mt-0.5 text-xs text-pretty\">{p.description}</p>\n                  {p.status === \"denied\" ? (\n                    <p className=\"mt-1 text-xs text-pretty\">\n                      Turned off. Turn it on in {settingsApp}\n                      {path ? <> › <span className=\"font-medium\">{path}</span></> : null}, then come back.\n                    </p>\n                  ) : null}\n                </div>\n                <div className=\"ms-auto shrink-0\">\n                  {p.status === \"granted\" ? (\n                    <span className=\"text-muted-foreground flex h-8 items-center gap-1.5 px-2 text-[13px]\">\n                      <Check aria-hidden=\"true\" className=\"text-chart-2 size-4\" />\n                      Allowed\n                    </span>\n                  ) : p.status === \"denied\" ? (\n                    <Button variant=\"outline\" size=\"sm\" onClick={() => onOpenSettings?.(p.id)} aria-label={`${t(\"Open System Settings\", \"Open Settings\")} for ${p.title}`}>\n                      {t(\"Open System Settings\", \"Open Settings\")}\n                      <ExternalLink aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />\n                    </Button>\n                  ) : (\n                    <Button size=\"sm\" variant={p.required ? \"default\" : \"outline\"} onClick={() => request(p)} disabled={asking !== null} aria-label={`Allow ${p.title}`}>\n                      {asking === p.id ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                      Allow\n                    </Button>\n                  )}\n                </div>\n              </li>\n            )\n          })}\n        </ul>\n      </StateView>\n\n      <div className=\"flex flex-wrap items-center gap-3\">\n        <p id={`${id}-progress`} aria-live=\"polite\" className=\"text-muted-foreground text-xs tabular-nums\">\n          {state === \"ready\"\n            ? missing.length\n              ? `${granted} of ${list.length} allowed. Allow ${missing.map((m) => m.title).join(\" and \")} to continue.`\n              : `${granted} of ${list.length} allowed.`\n            : \"\"}\n        </p>\n        <div className={cn(\"ms-auto flex gap-2\", win && \"flex-row-reverse\")}>\n          {onSkip ? (\n            <Button variant=\"outline\" size=\"sm\" onClick={onSkip}>\n              {t(\"Set Up Later\", \"Set up later\")}\n            </Button>\n          ) : null}\n          <Button size=\"sm\" onClick={onContinue} disabled={state !== \"ready\" || missing.length > 0} aria-describedby={`${id}-progress`}>\n            Continue\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n\n  if (!frame)\n    return (\n      <div data-slot=\"desktop-5\" data-state={state} data-feel={feelProp} role=\"region\" aria-labelledby={`${id}-title`} className={cn(\"flex flex-col\", className)} {...frameProps(props)}>\n        {content}\n      </div>\n    )\n  return (\n    <AppWindow data-slot=\"desktop-5\" data-state={state} feel={feel} role=\"region\" aria-labelledby={`${id}-title`} className={cn(\"h-[34rem] w-full max-w-xl\", className)} {...frameProps(props)}>\n      <AppWindowTitleBar title={win ? appName : \"\"} icon={<ShieldCheck />} className=\"border-b-0\" />\n      <AppWindowContent className=\"flex flex-col\">{content}</AppWindowContent>\n    </AppWindow>\n  )\n}\n\nexport { Desktop5, type Desktop5Props, type Desktop5Permission, type Desktop5Status }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/desktop-5/desktop-5.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "permissions",
      "onboarding",
      "first run",
      "privacy",
      "accessibility",
      "screen recording",
      "tauri",
      "electron",
      "macos",
      "windows",
      "feel"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A permissions onboarding window. <Desktop5 appName permissions={[{ id, title, description, required, status: 'granted' | 'denied' | 'prompt', settingsPath: { macos, windows } }]} onRequest={async (id) => status} onOpenSettings={(id) => open(url)} onRecheck={() => refresh()} onContinue />. onRecheck runs when the window regains focus, which is when people return from System Settings.",
      "whenToUse": [
        "First run of a Tauri or Electron app that needs Accessibility, Screen Recording, Microphone, Camera, Full Disk Access or Notifications",
        "A Permissions pane that shows what is on and off"
      ],
      "whenNotToUse": [
        "Product onboarding questions (use onboarding-1)",
        "Cookie consent on a website"
      ],
      "composesWith": [
        "desktop-1",
        "desktop-4",
        "app-window",
        "feel-auto"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Each Allow or Open Settings button is named for its permission"
        },
        {
          "keys": "Continue",
          "action": "Disabled until required permissions are on; the reason is linked with aria-describedby and announced as it changes"
        }
      ],
      "customization": [
        "feel: 'macos' | 'windows' (defaults to the page's feel); the settings app name and paths follow it",
        "macOS deep links for onOpenSettings: x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility (or Privacy_ScreenCapture, Privacy_Microphone)",
        "Windows: ms-settings:privacy-microphone, ms-settings:privacy-webcam, ms-settings:notifications",
        "Tauri: tauri-plugin-macos-permissions reads and requests the macOS ones",
        "onSkip shows \"Set up later\""
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Checking",
        "example": "desktop-5-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows while the current statuses are read; Continue is disabled."
      },
      {
        "id": "error",
        "title": "Could not read",
        "example": "desktop-5-error",
        "trigger": "state=\"error\"",
        "note": "An alert that points to System Settings, with Try again (onRetry)."
      }
    ],
    "examples": [
      "desktop-5-demo",
      "desktop-5-windows",
      "desktop-5-loading",
      "desktop-5-error"
    ],
    "url": "https://ui.ballmac.com/components/desktop-5"
  },
  "categories": [
    "blocks",
    "desktop"
  ],
  "type": "registry:block"
}