1import { useAtomValue } from "@effect/atom-react";
2import {
3 isModifierPairShortcut,
4 type ClientSettingsPatch,
5 type DesktopSnapShotShortcutAvailability,
6 type DesktopSnapShotState,
7 type DesktopSnapShotSetupAction,
8 type SnapShotShortcut,
9} from "@t3tools/contracts";
10import { PlayIcon } from "lucide-react";
11import { useCallback, useEffect, useRef, useState } from "react";
12
13import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
14import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot";
15import {
16 readSnapShotSetupResume,
17 saveSnapShotSetupResume,
18 clearSnapShotSetupResume,
19} from "../../lib/snapShotSetupResume";
20import { sameSnapShotShortcut, snapShotKeybindingConflict } from "../../lib/snapShotShortcut";
21import { playSnapShotSound } from "../../lib/snapShotSound";
22import { primaryServerKeybindingsAtom } from "../../state/server";
23import { commandLabel } from "./KeybindingsSettings.logic";
24import {
25 snapShotStatus,
26 snapShotShortcutStatus,
27 snapShotSetupButtonLabel,
28 snapShotUnavailableMessage,
29 snapShotSoundPatch,
30 snapShotFeedbackUnavailableMessage,
31 snapShotDescription,
32 snapShotAccessibilityUnavailableMessage,
33 snapShotSetupComplete,
34 type SnapShotSoundSelection,
35} from "./SnapShotSettings.logic";
36import {
37 SettingsUnavailableGroup,
38 SettingsPageContainer,
39 SettingsRow,
40 SettingsSection,
41} from "./settingsLayout";
42import { searchableSetting } from "./settingsSearch";
43import { Button } from "../ui/button";
44import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu";
45import { SelectButton } from "../ui/select";
46import { Switch } from "../ui/switch";
47import { toastManager } from "../ui/toast";
48import { SnapShotSetupDialog } from "./SnapShotSetupDialog";
49import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder";
50import {
51 captureSetupAccessReady,
52 captureSetupInitialStep,
53 captureSetupShouldDisableOnClose,
54 type CaptureSetupStep,
55} from "./SnapShotSetupDialog.logic";
56
57const soundOptionRowClassName = "grid grid-cols-[1fr_auto]";
58
59function captureSettingsError(title: string, error: unknown) {
60 return { title, message: error instanceof Error ? error.message : "Try again." };
61}
62
63type ShortcutCheck =
64 | { readonly status: "idle"; readonly availability: null }
65 | { readonly status: "checking"; readonly availability: null }
66 | {
67 readonly status: "checked";
68 readonly availability: DesktopSnapShotShortcutAvailability;
69 };
70
71export function SnapShotSettings() {
72 const settings = useClientSettings();
73 const updateSettings = useUpdateClientSettings();
74 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
75 const bridge = getDesktopSnapShotBridge();
76 const [state, setState] = useState<DesktopSnapShotState | null>(null);
77 const [setupBusy, setSetupBusy] = useState(false);
78 const [setupError, setSetupError] = useState<ReturnType<typeof captureSettingsError> | null>(
79 null,
80 );
81 const [wizard, setWizard] = useState<{
82 initialStep: CaptureSetupStep;
83 wasEnabled: boolean;
84 } | null>(null);
85 const [candidate, setCandidate] = useState<SnapShotShortcut>(settings.snapShotShortcut);
86 const [shortcutCheck, setShortcutCheck] = useState<ShortcutCheck>({
87 status: "idle",
88 availability: null,
89 });
90 const shortcutCheckIdRef = useRef(0);
91 const stateRequestIdRef = useRef(0);
92 const unavailableMessage = snapShotUnavailableMessage(Boolean(bridge));
93 const captureAvailable = Boolean(bridge) && state !== null && state.mode !== "unavailable";
94 const feedbackUnavailable = snapShotFeedbackUnavailableMessage(state);
95 const savedShortcut = settings.snapShotShortcut;
96 const managedShortcut = state?.linuxBackend === "niri" || state?.linuxBackend === "hyprland";
97 const shortcutChanged = !managedShortcut && !sameSnapShotShortcut(candidate, savedShortcut);
98 const displayShortcut = shortcutChanged ? candidate : (state?.shortcut ?? savedShortcut);
99 const candidateConflict = shortcutChanged
100 ? snapShotKeybindingConflict(candidate, keybindings)
101 : null;
102 const canSaveShortcut =
103 shortcutChanged && candidateConflict === null && shortcutCheck.availability?.available === true;
104 const soundSelection = settings.snapShotPlaySound ? settings.snapShotSound : "off";
105 const soundLabel =
106 soundSelection === "off" ? "Off" : soundSelection === "soft-pop" ? "Whoosh (Default)" : "Click";
107
108 const refreshState = useCallback(async () => {
109 const requestId = ++stateRequestIdRef.current;
110 try {
111 if (bridge) {
112 const nextState = await bridge.getSnapShotState();
113 if (requestId === stateRequestIdRef.current) setState(nextState);
114 return nextState;
115 }
116 } catch (error) {
117 if (requestId === stateRequestIdRef.current)
118 setSetupError(captureSettingsError("Couldn't check capture setup", error));
119 }
120 }, [bridge]);
121
122 const setup = useCallback(
123 async (action: DesktopSnapShotSetupAction) => {
124 if (!bridge?.setupSnapShot || setupBusy) return;
125 setSetupBusy(true);
126 setSetupError(null);
127 try {
128 if (
129 state?.macPermissions &&
130 wizard &&
131 (action === "allow-screen-recording" || action === "allow-accessibility")
132 ) {
133 // macOS can quit the app from its permission prompt.
134 saveSnapShotSetupResume(wizard.wasEnabled);
135 }
136 await bridge.setupSnapShot(action);
137 await refreshState();
138 } catch (error) {
139 setSetupError(
140 captureSettingsError(
141 action === "retry-shortcut"
142 ? "Couldn't open shortcut permissions"
143 : "Couldn't complete capture setup",
144 error,
145 ),
146 );
147 } finally {
148 setSetupBusy(false);
149 }
150 },
151 [bridge, refreshState, setupBusy, state, wizard],
152 );
153
154 useEffect(() => {
155 if (!setupError || wizard) return;
156 toastManager.add({
157 type: "error",
158 title: setupError.title,
159 description: setupError.message,
160 });
161 setSetupError(null);
162 }, [setupError, wizard]);
163
164 useEffect(() => {
165 let cancelled = false;
166 void refreshState().then((current) => {
167 const resume = readSnapShotSetupResume();
168 if (!cancelled && current?.macPermissions && resume) {
169 setWizard({ initialStep: "access", wasEnabled: resume.wasEnabled });
170 }
171 });
172 window.addEventListener("focus", refreshState);
173 return () => {
174 cancelled = true;
175 window.removeEventListener("focus", refreshState);
176 };
177 }, [refreshState]);
178
179 useEffect(
180 () =>
181 bridge?.onSnapShotEvent((event) => {
182 if (event.type === "shortcut-changed") void refreshState();
183 }),
184 [bridge, refreshState],
185 );
186
187 useEffect(() => {
188 shortcutCheckIdRef.current++;
189 setCandidate(savedShortcut);
190 setShortcutCheck({ status: "idle", availability: null });
191 }, [savedShortcut]);
192
193 const save = useCallback(
194 async (patch: ClientSettingsPatch) => {
195 setSetupError(null);
196 try {
197 await updateSettings(patch);
198 return await refreshState();
199 } catch (error) {
200 setSetupError(captureSettingsError("Couldn't save capture settings", error));
201 }
202 },
203 [refreshState, updateSettings],
204 );
205
206 const saveIncludeAccessibility = useCallback(
207 async (includeAccessibility: boolean) => {
208 try {
209 if (includeAccessibility && settings.snapShotEnabled)
210 await bridge?.requestSnapShotPermissions(true);
211 await save({ snapShotIncludeAccessibility: includeAccessibility });
212 } catch (error) {
213 setSetupError(captureSettingsError("Couldn't allow app text capture", error));
214 }
215 },
216 [bridge, save, settings.snapShotEnabled],
217 );
218
219 const checkShortcut = useCallback(
220 async (shortcut: SnapShotShortcut) => {
221 const checkId = ++shortcutCheckIdRef.current;
222 setCandidate(shortcut);
223 const conflict = snapShotKeybindingConflict(shortcut, keybindings);
224 if (conflict || !bridge) return;
225 setShortcutCheck({ status: "checking", availability: null });
226 try {
227 const availability = await bridge.checkSnapShotShortcut(shortcut);
228 if (checkId === shortcutCheckIdRef.current) {
229 setShortcutCheck({ status: "checked", availability });
230 }
231 } catch (error) {
232 if (checkId !== shortcutCheckIdRef.current) return;
233 setShortcutCheck({
234 status: "checked",
235 availability: {
236 available: false,
237 message: error instanceof Error ? error.message : "Could not check this shortcut.",
238 },
239 });
240 }
241 },
242 [bridge, keybindings],
243 );
244
245 const {
246 recording,
247 stopRecording,
248 input: shortcutInput,
249 } = useSnapShotShortcutRecorder({
250 shortcut: displayShortcut,
251 shortcutLabel: !shortcutChanged ? state?.shortcutLabel : undefined,
252 disabled: setupBusy,
253 allowModifierPairs: state?.mode !== "portal",
254 onRecord: (shortcut) => void checkShortcut(shortcut),
255 onStart: () => {
256 shortcutCheckIdRef.current++;
257 setShortcutCheck({ status: "idle", availability: null });
258 },
259 onError: (message) =>
260 setShortcutCheck({ status: "checked", availability: { available: false, message } }),
261 });
262
263 const shortcutStatus = recording
264 ? "Press your shortcut. Esc cancels."
265 : candidateConflict
266 ? `The Knife already uses this for "${commandLabel(candidateConflict)}".`
267 : shortcutCheck.status === "checking"
268 ? "Checking shortcut…"
269 : shortcutCheck.availability
270 ? shortcutCheck.availability.available
271 ? "Ready to save."
272 : shortcutCheck.availability.message
273 : state?.mode === "portal" &&
274 !state.shortcutLabel &&
275 isModifierPairShortcut(displayShortcut)
276 ? "Try a shortcut such as Ctrl+Shift+2."
277 : snapShotShortcutStatus(state);
278
279 const openSetup = async (requested: CaptureSetupStep | "resume" = "resume") => {
280 if (!state || setupBusy) return;
281 stopRecording();
282 shortcutCheckIdRef.current++;
283 setCandidate(savedShortcut);
284 setShortcutCheck({ status: "idle", availability: null });
285 setSetupError(null);
286 setSetupBusy(true);
287 try {
288 let current = await refreshState();
289 if (!current) return;
290 if (current.macPermissions) requested = "access";
291 if (!settings.snapShotEnabled && captureSetupInitialStep(current, requested) !== "access") {
292 // Opening setup is the opt-in. Restore registration before resuming a
293 // later step, just as Continue does on the access step.
294 current = await save({ snapShotEnabled: true });
295 if (!current) {
296 // A settings write can succeed even if the following status check
297 // fails. Keep Finish later available to turn capture back off.
298 setWizard({ initialStep: "access", wasEnabled: false });
299 return;
300 }
301 }
302 setWizard({
303 initialStep: captureSetupInitialStep(current, requested),
304 wasEnabled: settings.snapShotEnabled,
305 });
306 } finally {
307 setSetupBusy(false);
308 }
309 };
310
311 const enableForSetup = async () => {
312 if (setupBusy) return false;
313 setSetupBusy(true);
314 setSetupError(null);
315 try {
316 if (state?.macPermissions) {
317 saveSnapShotSetupResume(wizard?.wasEnabled ?? settings.snapShotEnabled);
318 if (!bridge?.setupSnapShot) throw new Error("Restart The Knife to finish capture setup.");
319 await bridge.setupSnapShot("test-mac-capture");
320 }
321 if (state?.mode === "direct")
322 await bridge?.requestSnapShotPermissions(settings.snapShotIncludeAccessibility);
323 const nextState =
324 settings.snapShotEnabled && !state?.message
325 ? await refreshState()
326 : await save({ snapShotEnabled: true });
327 return nextState !== undefined && captureSetupAccessReady(nextState);
328 } catch (error) {
329 setSetupError(captureSettingsError("Couldn't verify capture access", error));
330 return false;
331 } finally {
332 setSetupBusy(false);
333 }
334 };
335
336 const closeSetup = async (completed: boolean) => {
337 if (!wizard || setupBusy) return;
338 setSetupBusy(true);
339 try {
340 if (
341 settings.snapShotEnabled &&
342 captureSetupShouldDisableOnClose(wizard.wasEnabled, completed)
343 ) {
344 if (!(await save({ snapShotEnabled: false }))) return;
345 }
346 stopRecording();
347 setSetupError(null);
348 clearSnapShotSetupResume();
349 setWizard(null);
350 } catch (error) {
351 setSetupError(captureSettingsError("Couldn't close capture setup", error));
352 } finally {
353 setSetupBusy(false);
354 }
355 };
356
357 const saveShortcut = async () => {
358 if (!canSaveShortcut || setupBusy) return false;
359 setSetupBusy(true);
360 try {
361 const saved = await save({ snapShotShortcut: candidate });
362 return Boolean(saved?.shortcutRegistered || saved?.shortcutPending);
363 } finally {
364 setSetupBusy(false);
365 }
366 };
367
368 return (
369 <SettingsPageContainer>
370 <SettingsSection id="snap-shot" title="SnapShots">
371 <SettingsUnavailableGroup message={unavailableMessage}>
372 <SettingsRow
373 {...searchableSetting("snap-shot-enabled")}
374 description={snapShotDescription(state)}
375 status={
376 bridge
377 ? setupBusy && !wizard
378 ? "Updating capture settings…"
379 : snapShotStatus(state, settings.snapShotEnabled)
380 : undefined
381 }
382 control={
383 <>
384 {settings.snapShotEnabled &&
385 !snapShotSetupComplete(state, settings.snapShotIncludeAccessibility) ? (
386 <Button
387 size="xs"
388 variant="outline"
389 disabled={setupBusy}
390 onClick={() => void openSetup()}
391 >
392 {snapShotSetupButtonLabel(state)}
393 </Button>
394 ) : null}
395 <Switch
396 checked={settings.snapShotEnabled || Boolean(wizard)}
397 disabled={!captureAvailable || setupBusy}
398 aria-label="Enable snapshots"
399 onCheckedChange={(checked) => {
400 if (!checked) void save({ snapShotEnabled: false });
401 else if (state?.windows) void save({ snapShotEnabled: true });
402 else void openSetup();
403 }}
404 />
405 </>
406 }
407 />
408 {settings.snapShotEnabled && captureAvailable ? (
409 <>
410 <SettingsRow
411 {...searchableSetting("snap-shot-accessibility")}
412 description="Include text and controls when the app makes them available."
413 status={snapShotAccessibilityUnavailableMessage(state)}
414 control={
415 <Switch
416 checked={
417 !snapShotAccessibilityUnavailableMessage(state) &&
418 settings.snapShotIncludeAccessibility
419 }
420 disabled={
421 !captureAvailable || Boolean(snapShotAccessibilityUnavailableMessage(state))
422 }
423 aria-label="Include app text in snapshots"
424 onCheckedChange={(checked) => void saveIncludeAccessibility(checked)}
425 />
426 }
427 />
428 <SettingsRow
429 {...searchableSetting("snap-shot-shortcut")}
430 description={
431 state?.linuxBackend === "picker"
432 ? "Choose a window to capture from any app."
433 : "Capture the window you're using without switching apps."
434 }
435 status={managedShortcut ? undefined : shortcutStatus}
436 control={
437 managedShortcut ? (
438 <Button
439 size="xs"
440 variant="outline"
441 disabled={setupBusy}
442 onClick={() => void openSetup("shortcut")}
443 >
444 Change shortcut
445 </Button>
446 ) : (
447 <>
448 {shortcutInput}
449 {shortcutChanged ? (
450 <>
451 <Button
452 size="xs"
453 disabled={!canSaveShortcut || setupBusy}
454 onClick={() => void saveShortcut()}
455 >
456 {setupBusy ? "Saving…" : "Save"}
457 </Button>
458 <Button
459 size="xs"
460 variant="ghost"
461 disabled={setupBusy}
462 onClick={() => {
463 stopRecording();
464 shortcutCheckIdRef.current++;
465 setCandidate(savedShortcut);
466 setShortcutCheck({ status: "idle", availability: null });
467 }}
468 >
469 Cancel
470 </Button>
471 </>
472 ) : state?.mode === "portal" &&
473 state.shortcutCanRetry !== false &&
474 !isModifierPairShortcut(savedShortcut) ? (
475 <Button
476 size="xs"
477 variant="ghost"
478 disabled={setupBusy || state.shortcutPending}
479 onClick={() => void setup("retry-shortcut")}
480 >
481 Shortcut permissions
482 </Button>
483 ) : null}
484 </>
485 )
486 }
487 />
488 <SettingsRow
489 {...searchableSetting("snap-shot-sound")}
490 description="Choose the sound played when capture starts."
491 control={
492 <Menu>
493 <MenuTrigger
494 aria-label={"Snapshot sound: " + soundLabel}
495 render={<SelectButton size="sm" />}
496 className="w-auto min-w-0"
497 disabled={!captureAvailable}
498 >
499 {soundSelection === "off" ? (
500 "Off"
501 ) : soundSelection === "soft-pop" ? (
502 <>
503 Whoosh <span className="text-muted-foreground">(Default)</span>
504 </>
505 ) : (
506 "Click"
507 )}
508 </MenuTrigger>
509 <MenuPopup align="end">
510 <MenuRadioGroup
511 onValueChange={(value) =>
512 void save(snapShotSoundPatch(value as SnapShotSoundSelection))
513 }
514 value={soundSelection}
515 >
516 <MenuRadioItem closeOnClick value="off">
517 Off
518 </MenuRadioItem>
519 <div className={soundOptionRowClassName}>
520 <MenuRadioItem closeOnClick value="soft-pop">
521 Whoosh <span className="text-muted-foreground">(Default)</span>
522 </MenuRadioItem>
523 <MenuItem
524 aria-label="Play Whoosh"
525 closeOnClick={false}
526 onClick={() => playSnapShotSound("soft-pop")}
527 >
528 <PlayIcon />
529 </MenuItem>
530 </div>
531 <div className={soundOptionRowClassName}>
532 <MenuRadioItem closeOnClick value="camera-shutter">
533 Click
534 </MenuRadioItem>
535 <MenuItem
536 aria-label="Play Click"
537 closeOnClick={false}
538 onClick={() => playSnapShotSound("camera-shutter")}
539 >
540 <PlayIcon />
541 </MenuItem>
542 </div>
543 </MenuRadioGroup>
544 </MenuPopup>
545 </Menu>
546 }
547 />
548 <SettingsRow
549 {...searchableSetting("snap-shot-flash")}
550 description="Show a gentle cue on the captured window."
551 status={feedbackUnavailable}
552 control={
553 <Switch
554 checked={!feedbackUnavailable && settings.snapShotFlash}
555 disabled={!captureAvailable || Boolean(feedbackUnavailable)}
556 aria-label="Flash captured window"
557 onCheckedChange={(checked) => void save({ snapShotFlash: checked })}
558 />
559 }
560 />
561 <SettingsRow
562 {...searchableSetting("snap-shot-animations")}
563 description="Animate captured windows into your draft."
564 status={feedbackUnavailable}
565 control={
566 <Switch
567 checked={!feedbackUnavailable && settings.snapShotAnimations}
568 disabled={!captureAvailable || Boolean(feedbackUnavailable)}
569 aria-label="Animate snapshots"
570 onCheckedChange={(checked) => void save({ snapShotAnimations: checked })}
571 />
572 }
573 />
574 </>
575 ) : null}
576 </SettingsUnavailableGroup>
577 </SettingsSection>
578 {wizard && state ? (
579 <SnapShotSetupDialog
580 state={state}
581 initialStep={wizard.initialStep}
582 wasEnabled={wizard.wasEnabled}
583 includeAccessibility={settings.snapShotIncludeAccessibility}
584 busy={setupBusy}
585 error={setupError?.message ?? null}
586 shortcutInput={shortcutInput}
587 shortcutStatus={shortcutStatus}
588 shortcutChanged={shortcutChanged}
589 canSaveShortcut={canSaveShortcut}
590 onSaveShortcut={saveShortcut}
591 onEnable={enableForSetup}
592 onAction={setup}
593 onRefresh={refreshState}
594 onClose={closeSetup}
595 onLeaveStep={stopRecording}
596 />
597 ) : null}
598 </SettingsPageContainer>
599 );
600}