Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions apps/desktop/src/snapShot/ActiveWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ function runMacLookup(): Promise<string> {
"/usr/bin/osascript",
["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT],
{ timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 },
(error, stdout) => {
if (error) reject(error);
(error, stdout, stderr) => {
if (error) reject(Object.assign(error, { stderr }));
else resolve(stdout);
},
);
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/snapShot/DesktopSnapShot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3895,6 +3895,7 @@ 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. Capture denied");
} 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 +3920,62 @@ it.effect.each([false, true])(
},
);

it.effect("reports what the macOS window lookup printed when test capture fails", () => {
const script = 'ObjC.import("CoreGraphics"); function run() { return ""; }';
activeWindowMock.mockReset().mockRejectedValue(
Object.assign(new Error(`Command failed: /usr/bin/osascript -l JavaScript -e ${script}`), {
cmd: `/usr/bin/osascript -l JavaScript -e ${script}`,
code: 1,
killed: false,
stderr: "execution error: Error: Not authorized to send Apple events. (-1743)\n",
}),
);
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. /usr/bin/osascript failed: execution error: Error: Not authorized to send Apple events. (-1743)",
);
assert.notInclude(error.message, "GNOME");
assert.notInclude(error.message, "ObjC.import");
assert.equal(macCaptureMock.mock.calls.length, 0);
}),
).pipe(
Effect.provide(
layerTest("darwin", {
makeTempDirectoryScoped: () => Effect.succeed("/tmp/setup-test"),
readDirectory: () => Effect.succeed([]),
}),
),
);
});

it("describes a macOS capture command that printed nothing", () => {
const failed = (extra: object) =>
Object.assign(new Error("Command failed: /usr/sbin/screencapture -l 42 -o -x"), {
cmd: "/usr/sbin/screencapture -l 42 -o -x",
...extra,
});
assert.equal(
DesktopSnapShot.macCaptureFailureDetail(failed({ code: 1, killed: false, stderr: "" })),
"/usr/sbin/screencapture exited with code 1.",
);
assert.equal(
DesktopSnapShot.macCaptureFailureDetail(failed({ code: null, killed: true, stderr: "" })),
"/usr/sbin/screencapture timed out.",
);
});

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"))),
);
74 changes: 60 additions & 14 deletions apps/desktop/src/snapShot/DesktopSnapShot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,18 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError<DesktopSnapSho
Schema.Literals(["preview-config", "apply-config"]),
]),
reason: Schema.Literals(["unsupported-session", "setup-failed", "shortcut-permissions"]),
/** What the failed command reported, shown after the summary. */
detail: 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.";
return this.detail
? `Could not capture a test snapshot. ${this.detail}`
: "Could not capture a test snapshot.";
}
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 +248,33 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError<DesktopSnapSho
}
}

const MAC_CAPTURE_FAILURE_DETAIL_MAX_LENGTH = 400;

/**
* Names the command that failed and what it printed. A child process error's own
* message repeats the whole command line, which for the window lookup is a script.
*/
export function macCaptureFailureDetail(error: unknown): string | undefined {
if (!(error instanceof Error)) return undefined;
const failure = error as Error & {
readonly cmd?: unknown;
readonly stderr?: unknown;
readonly killed?: unknown;
readonly code?: unknown;
};
if (typeof failure.cmd !== "string") return failure.message.trim() || undefined;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const command = failure.cmd.split(" ", 1)[0];
const output = typeof failure.stderr === "string" ? failure.stderr.trim() : "";
const outcome = output
? `failed: ${output.slice(0, MAC_CAPTURE_FAILURE_DETAIL_MAX_LENGTH)}`
: failure.killed === true
? "timed out."
: typeof failure.code === "number"
? `exited with code ${failure.code}.`
: "could not be run.";
return `${command} ${outcome}`;
}

type SnapShotSystemAnimationSettings = Pick<
ReturnType<typeof Electron.systemPreferences.getAnimationSettings>,
"prefersReducedMotion" | "shouldRenderRichAnimation"
Expand Down Expand Up @@ -1332,23 +1367,34 @@ 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 }),
),
);
yield* Effect.tryPromise({
try: 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),
);
},
catch: (cause) => {
const detail = macCaptureFailureDetail(cause);
return new DesktopSnapShotSetupError({
action,
reason: "setup-failed",
...(detail === undefined ? {} : { detail }),
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
4 changes: 2 additions & 2 deletions apps/desktop/src/snapShot/MacSnapShot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ function runMacSnapShot(windowId: number, outputPath: string): Promise<void> {
MAC_SCREEN_CAPTURE_PATH,
macSnapShotArguments(windowId, outputPath),
{ timeout: MAC_SCREEN_CAPTURE_TIMEOUT_MS },
(error) => {
if (error) reject(error);
(error, _stdout, stderr) => {
if (error) reject(Object.assign(error, { stderr }));
else resolve();
},
);
Expand Down
Loading