Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/desktop/src/snapShot/DesktopSnapShot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ const layerTest = (
DesktopEnvironment.DesktopEnvironment,
DesktopEnvironment.DesktopEnvironment.of({
platform,
displayName: "T3 Code (Dev)",
stateDir: "/state",
linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
appRoot: "/repo",
Expand Down Expand Up @@ -3895,6 +3896,10 @@ it.effect.each([false, true])(
if (fails) {
const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
assert.equal(error.reason, "setup-failed");
assert.equal(
error.message,
'Could not capture a test snapshot. Capturing the window failed. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.',
);
} else yield* service.setup("test-mac-capture");
assert.equal(macCaptureMock.mock.calls.length, 1);
assert.equal(macCaptureMock.mock.calls[0]?.[0], active);
Expand All @@ -3919,12 +3924,88 @@ it.effect.each([false, true])(
},
);

const macTestCaptureLayer = layerTest("darwin", {
makeTempDirectoryScoped: () => Effect.succeed("/tmp/setup-test"),
readDirectory: () => Effect.succeed([]),
});
const macTestCaptureWindow = {
platform: "macos",
id: 42,
title: "Setup",
owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" },
bounds: { x: 0, y: 0, width: 800, height: 600 },
};

it.effect("names the failed macOS window lookup without repeating what it printed", () => {
const script = 'ObjC.import("CoreGraphics"); function run() { return ""; }';
const stderr = "execution error: Error: Not authorized to send Apple events. (-1743)";
activeWindowMock
.mockReset()
.mockRejectedValue(
Object.assign(
new Error(`Command failed: /usr/bin/osascript -l JavaScript -e ${script}\n${stderr}`),
{ cmd: `/usr/bin/osascript -l JavaScript -e ${script}`, code: 1, killed: false },
),
);
macCaptureMock.mockReset();
return Effect.scoped(
Effect.gen(function* () {
const service = yield* DesktopSnapShot.make;
const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
assert.equal(
error.message,
'Could not capture a test snapshot. Finding the front window failed: osascript exited with code 1. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.',
);
assert.include(String(error.cause), stderr);
assert.equal(macCaptureMock.mock.calls.length, 0);
}),
).pipe(Effect.provide(macTestCaptureLayer));
});

it.effect("says when macOS has no window to test capture", () => {
activeWindowMock.mockReset().mockResolvedValue(undefined);
macCaptureMock.mockReset();
return Effect.scoped(
Effect.gen(function* () {
const service = yield* DesktopSnapShot.make;
const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
assert.equal(
error.message,
"Could not capture a test snapshot. No window is available to capture.",
);
assert.equal(macCaptureMock.mock.calls.length, 0);
}),
).pipe(Effect.provide(macTestCaptureLayer));
});

it.effect("says when the macOS test capture command timed out", () => {
activeWindowMock.mockReset().mockResolvedValue(macTestCaptureWindow);
macCaptureMock.mockReset().mockRejectedValue(
Object.assign(new Error("Command failed: /usr/sbin/screencapture -l 42 -o -x"), {
code: null,
killed: true,
signal: "SIGTERM",
}),
);
return Effect.scoped(
Effect.gen(function* () {
const service = yield* DesktopSnapShot.make;
const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
assert.equal(
error.message,
'Could not capture a test snapshot. Capturing the window timed out. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.',
);
}),
).pipe(Effect.provide(macTestCaptureLayer));
});

it.effect("rejects macOS test capture on other platforms", () =>
Effect.scoped(
Effect.gen(function* () {
const service = yield* DesktopSnapShot.make;
const error = yield* service.setup("test-mac-capture").pipe(Effect.flip);
assert.equal(error.reason, "unsupported-session");
assert.equal(error.message, "Test capture requires macOS.");
}),
).pipe(Effect.provide(layerTest("win32"))),
);
94 changes: 80 additions & 14 deletions apps/desktop/src/snapShot/DesktopSnapShot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Predicate from "effect/Predicate";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
Expand Down Expand Up @@ -211,10 +212,36 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError<DesktopSnapSho
Schema.Literals(["preview-config", "apply-config"]),
]),
reason: Schema.Literals(["unsupported-session", "setup-failed", "shortcut-permissions"]),
/** The part of the macOS test capture that failed, and how. */
step: Schema.optional(Schema.Literals(["find-window", "capture-window"])),
status: Schema.optional(Schema.Literals(["missing", "timed-out", "exited", "failed"])),
exitCode: Schema.optional(Schema.Number),
/** The running app, as macOS lists it under Privacy & Security. */
appName: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
if (this.action === "test-mac-capture") {
if (this.reason === "unsupported-session") return "Test capture requires macOS.";
const summary = "Could not capture a test snapshot.";
if (this.step === undefined) return summary;
if (this.status === "missing") return `${summary} No window is available to capture.`;
const [task, command] =
this.step === "find-window"
? ["Finding the front window", "osascript"]
: ["Capturing the window", "screencapture"];
const failure =
this.status === "timed-out"
? `${task} timed out.`
: this.status === "exited" && this.exitCode !== undefined
? `${task} failed: ${command} exited with code ${this.exitCode}.`
: `${task} failed.`;
// macOS can report both permissions as allowed while they belong to another install.
return this.appName === undefined
? `${summary} ${failure}`
: `${summary} ${failure} macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "${this.appName}" is allowed for both.`;
}
if (this.action === "preview-config" || this.action === "apply-config") {
if (this.reason === "unsupported-session")
return "Config setup requires a Niri or Hyprland session.";
Expand All @@ -240,6 +267,18 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError<DesktopSnapSho
}
}

/**
* How a macOS capture command ended, read from the child process error's own fields.
* What the command printed stays in the error's `cause`.
*/
function macCommandFailure(cause: unknown) {
const failure = Predicate.isObject(cause) ? cause : {};
if ("killed" in failure && failure.killed === true) return { status: "timed-out" } as const;
return "code" in failure && typeof failure.code === "number"
? ({ status: "exited", exitCode: failure.code } as const)
: ({ status: "failed" } as const);
}

type SnapShotSystemAnimationSettings = Pick<
ReturnType<typeof Electron.systemPreferences.getAnimationSettings>,
"prefersReducedMotion" | "shouldRenderRichAnimation"
Expand Down Expand Up @@ -1332,23 +1371,50 @@ export const make = Effect.gen(function* () {
// or running capture feedback. The temporary image is discarded on failure too.
yield* Effect.scoped(
Effect.gen(function* () {
const directory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-snapshot-test-",
});
yield* Effect.tryPromise(async () => {
const active = await activeWindow("darwin");
if (!active) throw new Error("No window is available to test capture.");
await captureMacWindowSnapshot(
active,
path.join(directory, "test.png"),
snapShotThumbnailSize(active),
const directory = yield* fileSystem
.makeTempDirectoryScoped({ prefix: "t3-snapshot-test-" })
.pipe(
Effect.mapError(
(cause) => new DesktopSnapShotSetupError({ action, reason: "setup-failed", cause }),
),
);
const active = yield* Effect.tryPromise({
try: () => activeWindow("darwin"),
catch: (cause) =>
new DesktopSnapShotSetupError({
action,
reason: "setup-failed",
step: "find-window",
...macCommandFailure(cause),
appName: environment.displayName,
cause,
}),
});
if (!active)
return yield* new DesktopSnapShotSetupError({
action,
reason: "setup-failed",
step: "find-window",
status: "missing",
});
yield* Effect.tryPromise({
try: () =>
captureMacWindowSnapshot(
active,
path.join(directory, "test.png"),
snapShotThumbnailSize(active),
),
catch: (cause) =>
new DesktopSnapShotSetupError({
action,
reason: "setup-failed",
step: "capture-window",
...macCommandFailure(cause),
appName: environment.displayName,
cause,
}),
});
}),
).pipe(
Effect.mapError(
(cause) => new DesktopSnapShotSetupError({ action, reason: "setup-failed", cause }),
),
);
return;
} else if (action === "install-kde-helper" || action === "remove-kde-helper") {
Expand Down
Loading