Skip to content

Commit 7bc2113

Browse files
perf(desktop): keep snapshot dependencies off the startup path
get-windows and every dbus-next module were static imports of the snapshot service, so macOS and Windows loaded a D-Bus client at boot and Linux paid for node-pre-gyp even with SnapShots off. Both now load at their use sites. The pure Linux helpers (session detection, Niri binding text, portal key mapping, PNG reading) move to linuxCaptureSession.ts so nothing D-Bus-flavoured is reachable from main.ts statically. Also stop re-registering the global shortcut on every client-settings save. Only the enabled flag, the accessibility toggle, and the shortcut itself decide which listener runs; a font-size change no longer kills and respawns the macOS poller or the Windows keyboard hook. The macOS permission-recovery path forces a re-registration explicitly. Tests: drop the palette and hidden-window cases, the icon fallback-order and error-copy tests, and assertions on executeJavaScript source strings. Add coverage that an unrelated setting keeps the shortcut and that capture fails closed while disabled without touching native APIs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent aafd0d1 commit 7bc2113

12 files changed

Lines changed: 398 additions & 672 deletions

apps/desktop/src/snapShot/DesktopSnapShot.test.ts

Lines changed: 148 additions & 428 deletions
Large diffs are not rendered by default.

apps/desktop/src/snapShot/DesktopSnapShot.ts

Lines changed: 69 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import * as Crypto from "effect/Crypto";
2424
import * as DateTime from "effect/DateTime";
2525
import * as Effect from "effect/Effect";
2626
import * as Encoding from "effect/Encoding";
27-
import * as Exit from "effect/Exit";
2827
import * as FileSystem from "effect/FileSystem";
2928
import * as Layer from "effect/Layer";
3029
import * as Option from "effect/Option";
@@ -34,7 +33,7 @@ import * as Schema from "effect/Schema";
3433
import * as Semaphore from "effect/Semaphore";
3534

3635
import * as Electron from "electron";
37-
import { activeWindow, type Result as ActiveWindow } from "get-windows";
36+
import type { Result as ActiveWindow } from "get-windows";
3837

3938
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4039
import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
@@ -44,10 +43,13 @@ import { startMacModifierPairShortcutProcess } from "./MacModifierPairShortcutPr
4443
import { captureMacWindowSnapshot, type MacSnapShotSource } from "./MacSnapShot.ts";
4544
import type { LinuxCaptureFeedback, LinuxWindowMetadata } from "./LinuxSnapShot.ts";
4645
import { niriSocketPath } from "./NiriSnapShot.ts";
47-
import { niriCaptureBinding, startNiriCaptureShortcut } from "./NiriCaptureShortcut.ts";
4846
import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts";
49-
import { GnomeCaptureSetup, isGnomeCaptureSession } from "./GnomeCaptureSetup.ts";
50-
import { PortalCaptureShortcut, portalShortcutTrigger } from "./PortalCaptureShortcut.ts";
47+
import type { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts";
48+
import {
49+
isGnomeCaptureSession,
50+
niriCaptureBinding,
51+
portalShortcutTrigger,
52+
} from "./linuxCaptureSession.ts";
5153
import {
5254
HyprlandCaptureSetup,
5355
HYPRLAND_CAPTURE_EXECUTABLE,
@@ -72,8 +74,8 @@ import { windowsAppIcon } from "./WindowsWindowIcon.ts";
7274

7375
import {
7476
boundedSnapShotString,
75-
hideAndWaitForBlur,
7677
isWaylandSession,
78+
sameSnapShotShortcut,
7779
toElectronAccelerator,
7880
snapShotShortcutRegistrationFailureMessage,
7981
snapShotShortcutSystemConflict,
@@ -180,8 +182,6 @@ export class DesktopSnapShot extends Context.Service<
180182
readonly setShortcutSuppressed: (suppressed: boolean) => Effect.Effect<void>;
181183
/** Capture the foreground window in place, including T3 Code itself. */
182184
readonly capture: Effect.Effect<void, DesktopSnapShotError>;
183-
/** Capture from the command palette, revealing the previous app first. */
184-
readonly captureNow: Effect.Effect<void, DesktopSnapShotError>;
185185
readonly listPending: Effect.Effect<
186186
ReadonlyArray<DesktopPendingSnapShot>,
187187
DesktopSnapShotError
@@ -388,10 +388,7 @@ export function snapShotImageSize(png: Buffer, fallback: Electron.Rectangle): El
388388
};
389389
}
390390

391-
type SnapShotTarget = "foreground" | "previous-app";
392-
393391
async function captureSource({
394-
target,
395392
mode,
396393
captureId,
397394
platform,
@@ -406,7 +403,6 @@ async function captureSource({
406403
prepareReveal,
407404
onLinuxFeedback,
408405
}: {
409-
target: SnapShotTarget;
410406
mode: DesktopSnapShotState["mode"];
411407
captureId: string;
412408
platform: NodeJS.Platform;
@@ -425,17 +421,15 @@ async function captureSource({
425421
let linuxWindow: LinuxWindowMetadata | undefined;
426422
let linuxFeedback: LinuxCaptureFeedback | undefined;
427423
let linuxActivationFailure: { readonly cause: unknown } | undefined;
428-
const focusedWindow = Electron.BrowserWindow.getFocusedWindow();
429-
const hiddenWindow = target === "previous-app" ? focusedWindow : undefined;
430424
const destinationWindow =
431-
focusedWindow ?? Electron.BrowserWindow.getAllWindows().find((window) => !window.isDestroyed());
425+
Electron.BrowserWindow.getFocusedWindow() ??
426+
Electron.BrowserWindow.getAllWindows().find((window) => !window.isDestroyed());
432427
const destinationWindowBounds = destinationWindow?.getBounds();
433-
let hiddenWindowRestored = false;
434-
try {
428+
{
435429
const revealPreparation =
436430
platform === "win32" ? prepareReveal().catch(() => undefined) : Promise.resolve();
437-
if (hiddenWindow) await hideAndWaitForBlur(hiddenWindow);
438431
if (mode === "direct") {
432+
const { activeWindow } = await import("get-windows");
439433
active = await activeWindow({
440434
accessibilityPermission: false,
441435
screenRecordingPermission: platform === "darwin",
@@ -526,10 +520,6 @@ async function captureSource({
526520
}
527521
const contextPromise = accessibilityRead?.result ?? Promise.resolve(undefined);
528522
await revealPreparation;
529-
if (platform !== "win32" && hiddenWindow && !hiddenWindow.isDestroyed()) {
530-
hiddenWindow.show();
531-
hiddenWindowRestored = true;
532-
}
533523
if (linuxFeedback && destinationWindow && !destinationWindow.isDestroyed()) {
534524
if (destinationWindow.isMinimized()) destinationWindow.restore();
535525
if (!destinationWindow.isVisible()) destinationWindow.show();
@@ -549,10 +539,6 @@ async function captureSource({
549539
platform,
550540
destinationWindowBounds,
551541
));
552-
if (platform === "win32" && hiddenWindow && !hiddenWindow.isDestroyed()) {
553-
hiddenWindow.show();
554-
hiddenWindowRestored = true;
555-
}
556542
return {
557543
source,
558544
active,
@@ -563,8 +549,6 @@ async function captureSource({
563549
png,
564550
imageTempReady,
565551
};
566-
} finally {
567-
if (!hiddenWindowRestored && hiddenWindow && !hiddenWindow.isDestroyed()) hiddenWindow.show();
568552
}
569553
}
570554

@@ -780,6 +764,8 @@ export const make = Effect.gen(function* () {
780764
);
781765
const accessibilityProcessPool = makeSnapShotAccessibilityProcessPool(accessibilityWorkerPath);
782766
let registeredAccelerator: string | undefined;
767+
// False until the first applySettings; the first pass must always register.
768+
let initialized = false;
783769
let portalShortcut: PortalCaptureShortcut | undefined;
784770
let shortcutGeneration = 0;
785771
let shortcutSuppressed = false;
@@ -869,7 +855,6 @@ export const make = Effect.gen(function* () {
869855

870856
const prepareCapture = Effect.fn("desktop.snapShot.prepareCapture")(function* (
871857
settings: ClientSettings,
872-
target: SnapShotTarget,
873858
) {
874859
const id = yield* crypto.randomUUIDv4.pipe(Effect.mapError((cause) => captureFailure(cause)));
875860
const mode = captureMode(environment.platform);
@@ -891,7 +876,6 @@ export const make = Effect.gen(function* () {
891876
const snapshot = yield* Effect.tryPromise({
892877
try: () =>
893878
captureSource({
894-
target,
895879
mode,
896880
captureId: id,
897881
platform: environment.platform,
@@ -918,13 +902,9 @@ export const make = Effect.gen(function* () {
918902
);
919903
}
920904
if (snapshot.animationStarted) {
921-
const action = `snap-shot-started:${id}`;
922-
const revealExit = yield* Effect.exit(desktopWindow.dispatchMenuAction(action));
923-
if (Exit.isFailure(revealExit)) {
924-
yield* desktopWindow
925-
.dispatchMenuAction(action, { reveal: false })
926-
.pipe(Effect.catchCause(() => Effect.void));
927-
}
905+
yield* desktopWindow
906+
.dispatchMenuAction(`snap-shot-started:${id}`)
907+
.pipe(Effect.catchCause(() => Effect.void));
928908
} else {
929909
yield* desktopWindow.activate.pipe(Effect.catchCause(() => Effect.void));
930910
}
@@ -987,13 +967,14 @@ export const make = Effect.gen(function* () {
987967
}).pipe(Effect.mapError((cause) => captureFailure(cause, id)));
988968
});
989969

990-
const captureTarget = Effect.fn("desktop.snapShot.captureTarget")(function* (
991-
target: SnapShotTarget,
992-
) {
970+
const capture = Effect.gen(function* () {
993971
const settings = yield* Ref.get(settingsRef);
972+
if (!settings.snapShotEnabled) {
973+
return yield* new DesktopSnapShotError({ operation: "disabled" });
974+
}
994975
// Only source acquisition and the initial handoff require exclusive access.
995976
// Each captured image can finish its own accessibility read and persistence.
996-
const prepared = yield* prepareCapture(settings, target).pipe(
977+
const prepared = yield* prepareCapture(settings).pipe(
997978
Effect.tapError((error) =>
998979
(error.captureId ? discardCapture(error.captureId) : Effect.void).pipe(
999980
Effect.andThen(setFailure(error.message, error.captureId)),
@@ -1022,17 +1003,7 @@ export const make = Effect.gen(function* () {
10221003
),
10231004
),
10241005
);
1025-
});
1026-
1027-
const captureNow = captureTarget("previous-app");
1028-
1029-
const capture = Effect.gen(function* () {
1030-
const settings = yield* Ref.get(settingsRef);
1031-
if (!settings.snapShotEnabled) {
1032-
return yield* new DesktopSnapShotError({ operation: "disabled" });
1033-
}
1034-
yield* captureTarget("foreground");
1035-
});
1006+
}).pipe(Effect.withSpan("desktop.snapShot.capture"));
10361007

10371008
const captureFromShortcut = Effect.gen(function* () {
10381009
if (shortcutSuppressed) return;
@@ -1135,21 +1106,27 @@ export const make = Effect.gen(function* () {
11351106
transition.dispose();
11361107
closeLinuxFeedback();
11371108
}
1138-
// Cosmetic capture preferences must not tear down an approved portal session.
1139-
if (
1140-
!forceShortcut &&
1141-
portalShortcut &&
1109+
// Every client-settings save lands here. Only the fields that decide which
1110+
// shortcut listener runs may tear it down; a font-size change must not
1111+
// uninstall a global keyboard hook or drop an approved portal session.
1112+
const shortcutInputsChanged =
1113+
settings.snapShotEnabled !== previousSettings.snapShotEnabled ||
1114+
settings.snapShotIncludeAccessibility !== previousSettings.snapShotIncludeAccessibility ||
1115+
!sameSnapShotShortcut(shortcut, previousSettings.snapShotShortcut);
1116+
const portalShortcutUnchanged =
1117+
portalShortcut !== undefined &&
11421118
settings.snapShotEnabled &&
11431119
previousSettings.snapShotEnabled &&
11441120
(isHyprlandCaptureSession() ||
11451121
(!isModifierPairShortcut(shortcut) &&
11461122
!isModifierPairShortcut(previousSettings.snapShotShortcut) &&
11471123
toElectronAccelerator(shortcut) ===
1148-
toElectronAccelerator(previousSettings.snapShotShortcut)))
1149-
) {
1124+
toElectronAccelerator(previousSettings.snapShotShortcut)));
1125+
if (!forceShortcut && (portalShortcutUnchanged || (initialized && !shortcutInputsChanged))) {
11501126
yield* Ref.update(stateRef, (state) => ({ ...state, shortcut }));
11511127
return;
11521128
}
1129+
initialized = true;
11531130
releaseShortcut();
11541131
shortcutVerified = false;
11551132
const generation = shortcutGeneration;
@@ -1189,13 +1166,14 @@ export const make = Effect.gen(function* () {
11891166
return;
11901167
}
11911168
if (mode === "portal" && niriSocketPath()) {
1192-
const registered = yield* Effect.tryPromise(() =>
1193-
startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => {
1169+
const registered = yield* Effect.tryPromise(async () => {
1170+
const { startNiriCaptureShortcut } = await import("./NiriCaptureShortcut.ts");
1171+
return startNiriCaptureShortcut(linuxAppId, onCurrentShortcut, () => {
11941172
void runPromise(
11951173
setShortcutFailure("The Niri capture endpoint disconnected. Restart T3 Code."),
11961174
).catch(() => undefined);
1197-
}),
1198-
).pipe(
1175+
});
1176+
}).pipe(
11991177
Effect.tap((stop) =>
12001178
Effect.sync(() => {
12011179
stopShiftShortcut = stop;
@@ -1238,32 +1216,32 @@ export const make = Effect.gen(function* () {
12381216
shortcutMessage: null,
12391217
message: null,
12401218
});
1241-
yield* Effect.try(
1242-
() =>
1243-
new PortalCaptureShortcut(
1244-
linuxAppId,
1245-
isModifierPairShortcut(shortcut)
1246-
? {
1247-
key: "2",
1248-
ctrlKey: true,
1249-
modKey: false,
1250-
altKey: false,
1251-
shiftKey: true,
1252-
metaKey: false,
1253-
}
1254-
: shortcut,
1255-
onCurrentShortcut,
1256-
() => {
1257-
if (generation !== shortcutGeneration) return;
1258-
shortcutVerified = false;
1259-
void runPromise(desktopWindow.dispatchMenuAction("snap-shot-shortcut-changed")).catch(
1260-
() => undefined,
1261-
);
1262-
},
1263-
undefined,
1264-
hyprland,
1265-
),
1266-
).pipe(
1219+
yield* Effect.tryPromise(async () => {
1220+
const { PortalCaptureShortcut } = await import("./PortalCaptureShortcut.ts");
1221+
return new PortalCaptureShortcut(
1222+
linuxAppId,
1223+
isModifierPairShortcut(shortcut)
1224+
? {
1225+
key: "2",
1226+
ctrlKey: true,
1227+
modKey: false,
1228+
altKey: false,
1229+
shiftKey: true,
1230+
metaKey: false,
1231+
}
1232+
: shortcut,
1233+
onCurrentShortcut,
1234+
() => {
1235+
if (generation !== shortcutGeneration) return;
1236+
shortcutVerified = false;
1237+
void runPromise(desktopWindow.dispatchMenuAction("snap-shot-shortcut-changed")).catch(
1238+
() => undefined,
1239+
);
1240+
},
1241+
undefined,
1242+
hyprland,
1243+
);
1244+
}).pipe(
12671245
Effect.tap((registration) =>
12681246
Effect.sync(() => {
12691247
portalShortcut = registration;
@@ -1386,6 +1364,7 @@ export const make = Effect.gen(function* () {
13861364
});
13871365
yield* Effect.tryPromise({
13881366
try: async () => {
1367+
const { GnomeCaptureSetup } = await import("./GnomeCaptureSetup.ts");
13891368
const setup = new GnomeCaptureSetup(gnomeSetupPaths);
13901369
try {
13911370
await setup.perform(action);
@@ -1527,7 +1506,7 @@ export const make = Effect.gen(function* () {
15271506
state.message !== null &&
15281507
MAC_PERMISSION_MESSAGES.has(state.message)
15291508
? yield* configurationMutex
1530-
.withPermits(1)(applySettings(settings, null))
1509+
.withPermits(1)(applySettings(settings, null, true))
15311510
.pipe(Effect.andThen(Ref.get(stateRef)))
15321511
: state;
15331512
return { ...recovered, macPermissions, ...(message ? { message } : {}) };
@@ -1550,6 +1529,7 @@ export const make = Effect.gen(function* () {
15501529
: undefined;
15511530
const gnomeExtension = hasGnomeSetup()
15521531
? yield* Effect.promise(async () => {
1532+
const { GnomeCaptureSetup } = await import("./GnomeCaptureSetup.ts");
15531533
const setup = new GnomeCaptureSetup(gnomeSetupPaths);
15541534
try {
15551535
return await setup.state();
@@ -1601,7 +1581,6 @@ export const make = Effect.gen(function* () {
16011581
checkShortcut,
16021582
setShortcutSuppressed,
16031583
capture,
1604-
captureNow,
16051584
listPending: fileSystem.readDirectory(captureDirectory).pipe(
16061585
Effect.catchTags({
16071586
PlatformError: (cause) =>

apps/desktop/src/snapShot/GnomeCaptureSetup.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Schema from "effect/Schema";
77
import type { DesktopCaptureExtensionState } from "@t3tools/contracts";
88

99
import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts";
10+
export { isGnomeCaptureSession } from "./linuxCaptureSession.ts";
1011

1112
const SHELL = "org.gnome.Shell";
1213
const SHELL_PATH = "/org/gnome/Shell";
@@ -32,16 +33,6 @@ const Metadata = Schema.Struct({
3233
});
3334
const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata));
3435

35-
export function isGnomeCaptureSession(env: NodeJS.ProcessEnv): boolean {
36-
return (
37-
!env.FLATPAK_ID &&
38-
!env.SNAP &&
39-
Boolean(
40-
env.XDG_CURRENT_DESKTOP?.split(":").some((desktop) => desktop.toLowerCase() === "gnome"),
41-
)
42-
);
43-
}
44-
4536
type SetupPaths = { readonly bundle: string; readonly dataHome: string };
4637

4738
/** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */

apps/desktop/src/snapShot/HyprlandSnapShot.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,14 @@ import * as NodePath from "node:path";
66
import * as NodeURL from "node:url";
77
import * as Schema from "effect/Schema";
88
import type { DesktopCaptureHelperState } from "@t3tools/contracts";
9-
import { readPortalPng, type LinuxWindowSnapshot } from "./LinuxSnapShot.ts";
9+
import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts";
10+
import { readPortalPng } from "./linuxCaptureSession.ts";
1011
import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts";
11-
import { HYPRLAND_CAPTURE_ACTION } from "./PortalCaptureShortcut.ts";
12+
import { HYPRLAND_CAPTURE_ACTION } from "./linuxCaptureSession.ts";
13+
export { isHyprlandCaptureSession } from "./linuxCaptureSession.ts";
1214

1315
export const HYPRLAND_CAPTURE_EXECUTABLE = "t3-hyprland-snap-shot";
1416
export type HyprlandCapturePaths = { readonly bundle: string; readonly dataHome: string };
15-
export function isHyprlandCaptureSession(env = process.env): boolean {
16-
return (
17-
!env.FLATPAK_ID &&
18-
!env.SNAP &&
19-
Boolean(env.XDG_CURRENT_DESKTOP?.split(":").some((name) => name.toLowerCase() === "hyprland"))
20-
);
21-
}
2217
export function hyprlandCaptureExecutable(paths: HyprlandCapturePaths) {
2318
return NodePath.join(paths.dataHome, "t3code", "hyprland-capture", HYPRLAND_CAPTURE_EXECUTABLE);
2419
}

0 commit comments

Comments
 (0)