| 1 | import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist"; |
| 2 | import { usePermissionStatus } from "../permissions/usePermissionStatus"; |
| 3 | import { |
| 4 | isModifierPairShortcut, |
| 5 | type DesktopSnapShotSetupAction, |
| 6 | type DesktopSnapShotState, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { useState, type ReactNode } from "react"; |
| 9 | import { MacAccessibilityIcon, MacScreenRecordingIcon } from "../Icons"; |
| 10 | import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; |
| 11 | import { Button } from "../ui/button"; |
| 12 | import { Dialog, DialogDescription } from "../ui/dialog"; |
| 13 | import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; |
| 14 | import { |
| 15 | captureSetupAccessReady, |
| 16 | captureSetupBackend, |
| 17 | captureSetupCheckMessage, |
| 18 | captureSetupDesktopName, |
| 19 | captureSetupInitialStep, |
| 20 | captureSetupShortcutReady, |
| 21 | type CaptureSetupStep, |
| 22 | } from "./SnapShotSetupDialog.logic"; |
| 23 | |
| 24 | const SETUP_STEPS = [ |
| 25 | { id: "access", label: "Access" }, |
| 26 | { id: "shortcut", label: "Shortcut" }, |
| 27 | ] as const; |
| 28 | |
| 29 | const GNOME_ACCESS_COPY = { |
| 30 | "not-installed": { |
| 31 | title: "Install the extension", |
| 32 | description: |
| 33 | "The T3 Code GNOME extension lets you capture other windows and bring them into your draft. Sign out once after installing.", |
| 34 | }, |
| 35 | "restart-required": { |
| 36 | title: "Extension installed", |
| 37 | description: "Save your work, then sign out and back in. Your setup will be waiting here.", |
| 38 | }, |
| 39 | "update-required": { |
| 40 | title: "Update the extension", |
| 41 | description: "Install the update, then sign out and back in.", |
| 42 | }, |
| 43 | "extensions-disabled": { |
| 44 | title: "Allow GNOME extensions", |
| 45 | description: "Open GNOME Extensions and turn on extensions, then check again.", |
| 46 | }, |
| 47 | disabled: { |
| 48 | title: "Enable the extension", |
| 49 | description: "Enable T3 Code SnapShots to start capturing windows.", |
| 50 | }, |
| 51 | enabled: { |
| 52 | title: "Capture is ready", |
| 53 | description: "Next, choose your shortcut.", |
| 54 | }, |
| 55 | unsupported: { |
| 56 | title: "Automatic capture isn't available", |
| 57 | description: "Use Take snapshot from the command palette to choose a window.", |
| 58 | }, |
| 59 | error: { |
| 60 | title: "Couldn't set up the extension", |
| 61 | description: "Check T3 Code SnapShots in GNOME Extensions, then try again.", |
| 62 | }, |
| 63 | }; |
| 64 | |
| 65 | export function SnapShotSetupDialog({ |
| 66 | state, |
| 67 | initialStep, |
| 68 | wasEnabled, |
| 69 | includeAccessibility, |
| 70 | busy: actionBusy, |
| 71 | error, |
| 72 | shortcutInput, |
| 73 | shortcutStatus, |
| 74 | shortcutChanged, |
| 75 | canSaveShortcut, |
| 76 | onSaveShortcut, |
| 77 | onEnable, |
| 78 | onAction, |
| 79 | onRefresh, |
| 80 | onClose, |
| 81 | onLeaveStep, |
| 82 | }: { |
| 83 | state: DesktopSnapShotState; |
| 84 | initialStep: CaptureSetupStep; |
| 85 | wasEnabled: boolean; |
| 86 | includeAccessibility: boolean; |
| 87 | busy: boolean; |
| 88 | error: string | null; |
| 89 | shortcutInput: ReactNode; |
| 90 | shortcutStatus: string | null | undefined; |
| 91 | shortcutChanged: boolean; |
| 92 | canSaveShortcut: boolean; |
| 93 | onSaveShortcut: () => Promise<boolean>; |
| 94 | onEnable: () => Promise<boolean>; |
| 95 | onAction: (action: DesktopSnapShotSetupAction) => Promise<void>; |
| 96 | onRefresh: () => Promise<DesktopSnapShotState | undefined>; |
| 97 | onClose: (completed: boolean) => Promise<void>; |
| 98 | onLeaveStep: () => void; |
| 99 | }) { |
| 100 | const [step, setStep] = useState(() => captureSetupInitialStep(state, initialStep)); |
| 101 | const [checking, setChecking] = useState(false); |
| 102 | const [checked, setChecked] = useState(false); |
| 103 | const [configBusy, setConfigBusy] = useState(false); |
| 104 | const busy = actionBusy || checking || configBusy; |
| 105 | const backend = captureSetupBackend(state); |
| 106 | const configShortcut = backend === "niri" || backend === "hyprland"; |
| 107 | const desktop = captureSetupDesktopName(state); |
| 108 | const extension = state.gnomeExtension; |
| 109 | const helper = backend === "hyprland" ? state.hyprlandHelper : state.kdeHelper; |
| 110 | const helperBackend = backend === "kde" || backend === "hyprland"; |
| 111 | const installHelper = backend === "hyprland" ? "install-hyprland-helper" : "install-kde-helper"; |
| 112 | const removeHelper = backend === "hyprland" ? "remove-hyprland-helper" : "remove-kde-helper"; |
| 113 | const accessReady = captureSetupAccessReady(state); |
| 114 | const permissionStatus = usePermissionStatus( |
| 115 | async () => { |
| 116 | const refreshed = await onRefresh(); |
| 117 | if (!refreshed?.macPermissions) throw new Error("Permission status unavailable"); |
| 118 | return refreshed.macPermissions; |
| 119 | }, |
| 120 | state.macPermissions ?? { screenRecording: false, accessibility: false }, |
| 121 | Boolean(state.macPermissions) && step === "access" && !busy, |
| 122 | ); |
| 123 | const macPermissions = state.macPermissions ? permissionStatus.status : undefined; |
| 124 | const macPermissionsReady = |
| 125 | !macPermissions || |
| 126 | permissionStatus.isReady( |
| 127 | includeAccessibility ? ["screenRecording", "accessibility"] : ["screenRecording"], |
| 128 | ); |
| 129 | const shortcutReady = captureSetupShortcutReady(state, shortcutChanged); |
| 130 | const install = extension?.status === "not-installed" || extension?.status === "update-required"; |
| 131 | const enable = extension?.status === "disabled"; |
| 132 | const changeStep = (next: CaptureSetupStep) => { |
| 133 | onLeaveStep(); |
| 134 | setChecked(false); |
| 135 | setStep(next); |
| 136 | }; |
| 137 | const checkAgain = async () => { |
| 138 | if (busy) return; |
| 139 | setChecking(true); |
| 140 | setChecked(false); |
| 141 | try { |
| 142 | setChecked((await onRefresh()) !== undefined); |
| 143 | } finally { |
| 144 | setChecking(false); |
| 145 | } |
| 146 | }; |
| 147 | const accessCopy = |
| 148 | state.message && !macPermissions |
| 149 | ? { |
| 150 | title: "Let's try that again", |
| 151 | description: "Couldn't check snapshots. Try again to continue.", |
| 152 | } |
| 153 | : backend === "gnome" && extension |
| 154 | ? extension.status === "enabled" && !accessReady |
| 155 | ? { |
| 156 | title: "Check capture access", |
| 157 | description: "The extension isn't ready yet. Try again in a moment.", |
| 158 | } |
| 159 | : GNOME_ACCESS_COPY[extension.status] |
| 160 | : helperBackend |
| 161 | ? helper?.status === "ready" |
| 162 | ? { |
| 163 | title: "Capture is ready", |
| 164 | description: "Next, choose your shortcut.", |
| 165 | } |
| 166 | : helper?.status === "error" |
| 167 | ? { |
| 168 | title: "Let's fix capture access", |
| 169 | description: "Try reinstalling the capture helper, then check again.", |
| 170 | } |
| 171 | : { |
| 172 | title: |
| 173 | helper?.status === "update-required" |
| 174 | ? "Update the capture helper" |
| 175 | : "Allow snapshots", |
| 176 | description: |
| 177 | "The Knife's capture helper lets you capture other apps and return to your draft. It's included with The Knife.", |
| 178 | } |
| 179 | : backend === "niri" |
| 180 | ? { |
| 181 | title: "Capture is ready", |
| 182 | description: "Next, choose your shortcut.", |
| 183 | } |
| 184 | : backend === "picker" |
| 185 | ? { |
| 186 | title: "Choose a window each time", |
| 187 | description: |
| 188 | "Your desktop doesn't support automatic capture. You'll choose the window to capture instead.", |
| 189 | } |
| 190 | : { |
| 191 | title: "Allow snapshots", |
| 192 | description: |
| 193 | backend === "portal" |
| 194 | ? "Your desktop may ask for permission when you first capture." |
| 195 | : macPermissions |
| 196 | ? macPermissionsReady |
| 197 | ? "Test a snapshot of the current window. If macOS asks to bypass its window picker, choose Allow. The test image is discarded." |
| 198 | : "Allow each permission, then continue." |
| 199 | : "Allow access when prompted to start capturing windows.", |
| 200 | }; |
| 201 | const title = step === "access" ? accessCopy.title : "Choose your shortcut"; |
| 202 | const description = |
| 203 | step === "access" |
| 204 | ? accessCopy.description |
| 205 | : configShortcut |
| 206 | ? "Click the shortcut, then press the keys you want." |
| 207 | : state.mode === "portal" |
| 208 | ? "Choose your keys, then approve the permission prompt if asked." |
| 209 | : "Use both Shift keys, or record a different shortcut."; |
| 210 | const stepIndex = SETUP_STEPS.findIndex(({ id }) => id === step); |
| 211 | const details = [ |
| 212 | ...new Set( |
| 213 | [ |
| 214 | error, |
| 215 | ...(step === "access" |
| 216 | ? [ |
| 217 | state.message, |
| 218 | backend === "gnome" && |
| 219 | (extension?.status === "error" || extension?.status === "unsupported") |
| 220 | ? extension.message |
| 221 | : null, |
| 222 | helperBackend && helper?.status === "error" ? helper.message : null, |
| 223 | ] |
| 224 | : []), |
| 225 | ].filter((detail) => detail !== null), |
| 226 | ), |
| 227 | ]; |
| 228 | |
| 229 | return ( |
| 230 | <Dialog |
| 231 | open |
| 232 | onOpenChange={(open) => { |
| 233 | if (!open && !busy) void onClose(false); |
| 234 | }} |
| 235 | > |
| 236 | <WizardPopup showCloseButton={!busy}> |
| 237 | <WizardHeader title={desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}> |
| 238 | <WizardSteps |
| 239 | steps={SETUP_STEPS.map((item) => item.label)} |
| 240 | currentStep={stepIndex} |
| 241 | isStepDisabled={(index) => busy || index > stepIndex} |
| 242 | onStepChange={(index) => { |
| 243 | const next = SETUP_STEPS[index]; |
| 244 | if (next && next.id !== step) changeStep(next.id); |
| 245 | }} |
| 246 | /> |
| 247 | </WizardHeader> |
| 248 | <WizardPanel> |
| 249 | <div className="space-y-4 text-sm"> |
| 250 | <div className="space-y-2" aria-live="polite"> |
| 251 | <h3 className="flex items-center gap-2 font-medium">{title}</h3> |
| 252 | <DialogDescription>{description}</DialogDescription> |
| 253 | </div> |
| 254 | {step === "access" ? ( |
| 255 | <> |
| 256 | <p |
| 257 | role="status" |
| 258 | aria-atomic="true" |
| 259 | className={ |
| 260 | checked && !busy && !error ? "text-xs text-muted-foreground" : "sr-only" |
| 261 | } |
| 262 | > |
| 263 | {checked && !busy && !error ? captureSetupCheckMessage(state) : null} |
| 264 | </p> |
| 265 | {macPermissions ? ( |
| 266 | <PermissionChecklist |
| 267 | busy={busy} |
| 268 | permissions={[ |
| 269 | { |
| 270 | id: "screenRecording", |
| 271 | icon: <MacScreenRecordingIcon className="size-8 shrink-0 drop-shadow-sm" />, |
| 272 | title: "Screen Recording", |
| 273 | description: "Capture the window you're using.", |
| 274 | granted: macPermissions.screenRecording, |
| 275 | onAllow: () => void onAction("allow-screen-recording"), |
| 276 | }, |
| 277 | { |
| 278 | id: "accessibility", |
| 279 | icon: <MacAccessibilityIcon className="size-8 shrink-0 drop-shadow-sm" />, |
| 280 | title: "Accessibility", |
| 281 | description: includeAccessibility |
| 282 | ? "Include text and controls from the captured app." |
| 283 | : "Optional. Include text and controls from the captured app.", |
| 284 | granted: macPermissions.accessibility, |
| 285 | onAllow: () => void onAction("allow-accessibility"), |
| 286 | }, |
| 287 | ]} |
| 288 | /> |
| 289 | ) : null} |
| 290 | {permissionStatus.error && macPermissions ? ( |
| 291 | <p role="status" className="text-xs text-muted-foreground"> |
| 292 | {permissionStatus.error} |
| 293 | </p> |
| 294 | ) : null} |
| 295 | {helperBackend && helper?.status === "error" ? ( |
| 296 | <Button |
| 297 | size="xs" |
| 298 | variant="outline" |
| 299 | disabled={busy} |
| 300 | onClick={() => void onAction(installHelper)} |
| 301 | > |
| 302 | Reinstall helper |
| 303 | </Button> |
| 304 | ) : null} |
| 305 | </> |
| 306 | ) : configShortcut ? ( |
| 307 | <CaptureShortcutConfig |
| 308 | state={state} |
| 309 | disabled={actionBusy || checking || !accessReady} |
| 310 | onBusyChange={setConfigBusy} |
| 311 | onSaved={onRefresh} |
| 312 | onComplete={() => onClose(true)} |
| 313 | /> |
| 314 | ) : ( |
| 315 | <div className="space-y-3"> |
| 316 | {shortcutInput} |
| 317 | {shortcutStatus ? ( |
| 318 | <p className="text-xs text-muted-foreground" role="status"> |
| 319 | {shortcutStatus} |
| 320 | </p> |
| 321 | ) : null} |
| 322 | {!shortcutChanged && |
| 323 | !state.shortcutRegistered && |
| 324 | !state.shortcutPending && |
| 325 | state.shortcutCanRetry !== false && |
| 326 | !isModifierPairShortcut(state.shortcut) ? ( |
| 327 | <Button |
| 328 | variant="ghost" |
| 329 | size="sm" |
| 330 | disabled={busy} |
| 331 | onClick={() => void onAction("retry-shortcut")} |
| 332 | > |
| 333 | {state.mode === "portal" ? "Shortcut permissions" : "Try again"} |
| 334 | </Button> |
| 335 | ) : null} |
| 336 | </div> |
| 337 | )} |
| 338 | {step === "shortcut" && !accessReady ? ( |
| 339 | <p role="alert" className="text-destructive"> |
| 340 | Capture needs attention. Go back to check access. |
| 341 | </p> |
| 342 | ) : null} |
| 343 | {error ? ( |
| 344 | <p role="alert" className="text-destructive"> |
| 345 | Couldn't finish this step. Try again or check Advanced for help. |
| 346 | </p> |
| 347 | ) : null} |
| 348 | {details.length > 0 || (step === "access" && (backend === "gnome" || helperBackend)) ? ( |
| 349 | <details className="text-xs text-muted-foreground"> |
| 350 | <summary className="cursor-pointer">Advanced</summary> |
| 351 | <div className="mt-3 space-y-3"> |
| 352 | {details.map((detail) => ( |
| 353 | <p key={detail} className="break-words"> |
| 354 | {detail} |
| 355 | </p> |
| 356 | ))} |
| 357 | {step === "access" && (backend === "gnome" || helperBackend) ? ( |
| 358 | <p>Included with The Knife. No download needed.</p> |
| 359 | ) : null} |
| 360 | {step === "access" && backend === "gnome" && extension?.status === "enabled" ? ( |
| 361 | <Button |
| 362 | size="xs" |
| 363 | variant="ghost" |
| 364 | disabled={busy} |
| 365 | onClick={() => void onAction("disable-extension")} |
| 366 | > |
| 367 | Disable extension |
| 368 | </Button> |
| 369 | ) : null} |
| 370 | {step === "access" && helperBackend && helper?.status !== "not-installed" ? ( |
| 371 | <Button |
| 372 | size="xs" |
| 373 | variant="ghost" |
| 374 | disabled={busy} |
| 375 | onClick={() => void onAction(removeHelper)} |
| 376 | > |
| 377 | Remove capture helper |
| 378 | </Button> |
| 379 | ) : null} |
| 380 | </div> |
| 381 | </details> |
| 382 | ) : null} |
| 383 | </div> |
| 384 | </WizardPanel> |
| 385 | <WizardFooter> |
| 386 | {step !== "access" ? ( |
| 387 | <Button variant="ghost" disabled={busy} onClick={() => changeStep("access")}> |
| 388 | Back |
| 389 | </Button> |
| 390 | ) : null} |
| 391 | <Button variant="ghost" disabled={busy} onClick={() => void onClose(false)}> |
| 392 | {wasEnabled ? "Close" : "Finish later"} |
| 393 | </Button> |
| 394 | {step === "access" ? ( |
| 395 | helperBackend && !accessReady && helper?.status !== "ready" ? ( |
| 396 | <Button |
| 397 | disabled={busy} |
| 398 | aria-busy={busy} |
| 399 | onClick={() => |
| 400 | void (helper?.status === "error" ? checkAgain() : onAction(installHelper)) |
| 401 | } |
| 402 | > |
| 403 | {checking |
| 404 | ? "Checking…" |
| 405 | : busy |
| 406 | ? "Installing…" |
| 407 | : helper?.status === "error" |
| 408 | ? "Check again" |
| 409 | : helper?.status === "update-required" |
| 410 | ? "Update helper" |
| 411 | : "Install helper"} |
| 412 | </Button> |
| 413 | ) : backend === "gnome" && !accessReady && extension?.status !== "enabled" ? ( |
| 414 | <Button |
| 415 | disabled={busy} |
| 416 | aria-busy={checking} |
| 417 | onClick={() => |
| 418 | void (install |
| 419 | ? onAction("install-extension") |
| 420 | : enable |
| 421 | ? onAction("enable-extension") |
| 422 | : checkAgain()) |
| 423 | } |
| 424 | > |
| 425 | {checking |
| 426 | ? "Checking…" |
| 427 | : busy |
| 428 | ? install |
| 429 | ? "Installing…" |
| 430 | : enable |
| 431 | ? "Enabling…" |
| 432 | : "Working…" |
| 433 | : install |
| 434 | ? extension?.status === "update-required" |
| 435 | ? "Update extension" |
| 436 | : "Install extension" |
| 437 | : enable |
| 438 | ? "Enable extension" |
| 439 | : "Check again"} |
| 440 | </Button> |
| 441 | ) : ( |
| 442 | <PermissionContinueButton |
| 443 | ready={macPermissionsReady} |
| 444 | busy={busy} |
| 445 | onClick={async () => { |
| 446 | if (await onEnable()) changeStep("shortcut"); |
| 447 | }} |
| 448 | > |
| 449 | {busy |
| 450 | ? "Working…" |
| 451 | : macPermissions |
| 452 | ? "Test capture and continue" |
| 453 | : backend === "direct" |
| 454 | ? "Allow capture" |
| 455 | : !accessReady && !macPermissions |
| 456 | ? "Try again" |
| 457 | : "Continue"} |
| 458 | </PermissionContinueButton> |
| 459 | ) |
| 460 | ) : !configShortcut ? ( |
| 461 | <Button |
| 462 | disabled={ |
| 463 | busy || !accessReady || (shortcutChanged ? !canSaveShortcut : !shortcutReady) |
| 464 | } |
| 465 | onClick={async () => { |
| 466 | if (!shortcutChanged || (await onSaveShortcut())) await onClose(true); |
| 467 | }} |
| 468 | > |
| 469 | {busy ? "Saving…" : shortcutChanged ? "Save and finish" : "Done"} |
| 470 | </Button> |
| 471 | ) : null} |
| 472 | </WizardFooter> |
| 473 | </WizardPopup> |
| 474 | </Dialog> |
| 475 | ); |
| 476 | } |