1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
3import {
4 isModifierPairShortcut,
5 type DesktopSnapShotSetupAction,
6 type DesktopSnapShotState,
7} from "@t3tools/contracts";
8import { useState, type ReactNode } from "react";
9import { MacAccessibilityIcon, MacScreenRecordingIcon } from "../Icons";
10import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
11import { Button } from "../ui/button";
12import { Dialog, DialogDescription } from "../ui/dialog";
13import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
14import {
15 captureSetupAccessReady,
16 captureSetupBackend,
17 captureSetupCheckMessage,
18 captureSetupDesktopName,
19 captureSetupInitialStep,
20 captureSetupShortcutReady,
21 type CaptureSetupStep,
22} from "./SnapShotSetupDialog.logic";
23
24const SETUP_STEPS = [
25 { id: "access", label: "Access" },
26 { id: "shortcut", label: "Shortcut" },
27] as const;
28
29const 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
65export 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}