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