diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 485ed2aaa0f7..35152c1d4d7c 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -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", @@ -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); @@ -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"))), ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 71fe47d3f6ca..d10cd6d0338e 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -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"; @@ -211,10 +212,36 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError, "prefersReducedMotion" | "shouldRenderRichAnimation" @@ -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") {