Skip to content

Commit fa75fd3

Browse files
committed
fix(preview): preserve actionable native capture diagnostics
1 parent b05f9e4 commit fa75fd3

14 files changed

Lines changed: 409 additions & 11 deletions

File tree

‎FORK.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,7 @@ The tracked accessory note is [PREVIEW_RECORDING_START.md](PREVIEW_RECORDING_STA
884884

885885
**Worktree branch:** `fix/preview-capture-diagnostics`
886886

887-
The standalone worktree is `E:/Projects/t3code.worktrees/preview-capture-diagnostics`, at final HEAD `14f3cbc4f53d4753f50393d5ec64dc6273e84b4c`. Its rebased baseline `64fe72af4faa77a76b73835c2acd16a2405522d3` is directly above fixed `base/main` `26285ab80513260d1ff29d2c3fbe18f21990cb62`. The clean source branch tracks `origin/fix/preview-capture-diagnostics`; `base/main` remains its comparison base. Its source commits are published.
887+
The standalone worktree is `E:/Projects/t3code.worktrees/preview-capture-diagnostics`, at HEAD `8d8196e4d2df519519e94ce1e01abba4dd77cd8d` directly above fixed `base/main` `48b71f0eb323d08c8fcadfe54adf34becb2984da`. The clean source branch tracks `origin/fix/preview-capture-diagnostics`; `base/main` remains its comparison base. Its source commit is published and retains all thirteen customization files unchanged. Historical locked-state proof below retains its original source attribution.
888888

889889
Status is VALID ADAPTED with PASSING actual Windows locked/off acquisition-error diagnostics, cleanup and same-original-guest retry. Incoming agent browser automation moved to the environment server and supersedes old desktop agent/broker propagation. Surviving diagnostics preserve safe typed native screenshot/recording failures through IPC and the direct primary cause through the recording wrapper/UI. Capture, fallback, permissions, power behavior and server-browser CDP are outside this customization; no new CDP proof was produced.
890890

‎apps/desktop/src/ipc/DesktopIpcHandlers.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
158158
yield* ipc.handle(installCliCommand);
159159
yield* ipc.handle(uninstallCliCommand);
160160
for (const previewMethod of PreviewIpc.methods) {
161-
yield* ipc.handle(previewMethod);
161+
// Explicit types preserve the error/service union of these heterogeneous preview methods.
162+
yield* ipc.handle<
163+
Effect.Error<ReturnType<(typeof PreviewIpc.methods)[number]["handler"]>>,
164+
Effect.Services<ReturnType<(typeof PreviewIpc.methods)[number]["handler"]>>
165+
>(previewMethod);
162166
}
163167
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
164168
yield* ipc.handle(PreviewIpc.importBrowserCookies);

‎apps/desktop/src/ipc/methods/preview.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
DEFAULT_BROWSER_PROFILE_ID,
44
INCOGNITO_BROWSER_PROFILE_ID,
55
PreviewAutomationStatus,
6+
PreviewCaptureFailure,
67
} from "@t3tools/contracts";
78
import * as Cause from "effect/Cause";
89
import * as Effect from "effect/Effect";
@@ -14,6 +15,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1415
import * as PreviewManager from "../../preview/Manager.ts";
1516
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
1617
import * as PreviewIpc from "./preview.ts";
18+
import * as DesktopIpc from "../DesktopIpc.ts";
19+
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
1720

1821
const { fromPartition } = vi.hoisted(() => ({
1922
fromPartition: vi.fn(() => {
@@ -33,7 +36,59 @@ vi.mock("electron", () => ({
3336
},
3437
}));
3538

39+
const decodeCaptureFailure = Schema.decodeUnknownEffect(PreviewCaptureFailure);
40+
3641
describe("preview IPC methods", () => {
42+
const invokeRecording = (
43+
startRecording: PreviewManager.PreviewManager["Service"]["startRecording"],
44+
) =>
45+
Effect.scoped(
46+
Effect.gen(function* () {
47+
let listener: DesktopIpc.DesktopIpcHandleListener | undefined;
48+
const ipc = DesktopIpc.make({
49+
removeHandler: vi.fn(),
50+
handle: (_channel, registered) => {
51+
listener = registered;
52+
},
53+
removeAllListeners: vi.fn(),
54+
on: vi.fn(),
55+
});
56+
yield* ipc.handle(PreviewIpc.startRecording);
57+
return yield* Effect.promise(async () =>
58+
listener!({ sender: { id: 7 } }, { tabId: "closed-tab" }),
59+
);
60+
}).pipe(
61+
Effect.provideService(PreviewManager.PreviewManager, {
62+
startRecording,
63+
} as PreviewManager.PreviewManager["Service"]),
64+
Effect.provideService(DesktopClientSettings.DesktopClientSettings, {
65+
get: Effect.succeedNone,
66+
set: () => Effect.void,
67+
}),
68+
),
69+
);
70+
71+
effectIt.effect("encodes a native recording arm failure through the registered IPC handler", () =>
72+
Effect.gen(function* () {
73+
const result = yield* invokeRecording((tabId) =>
74+
Effect.fail(new PreviewManager.PreviewTabNotFoundError({ tabId })),
75+
);
76+
expect(result).toEqual({
77+
_tag: "PreviewCaptureFailure",
78+
reason: "renderer-unavailable",
79+
stage: "recording-start",
80+
});
81+
const failure = yield* decodeCaptureFailure(result);
82+
expect(failure.message).toContain("Reopen the preview tab");
83+
}),
84+
);
85+
86+
effectIt.effect("keeps a successful native recording arm encoded as undefined", () =>
87+
Effect.gen(function* () {
88+
expect(yield* invokeRecording(() => Effect.void)).toBeUndefined();
89+
}),
90+
);
91+
3792
beforeEach(() => {
3893
fromPartition.mockClear();
3994
});

‎apps/desktop/src/ipc/methods/preview.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
DesktopPreviewTabInputSchema,
2020
DesktopPreviewWebviewConfigSchema,
2121
PreviewAnnotationSubmissionResultSchema,
22+
PreviewCaptureFailure,
2223
DEFAULT_BROWSER_PROFILE_ID,
2324
INCOGNITO_BROWSER_PROFILE_ID,
2425
PreviewForwardedShortcut,
@@ -32,6 +33,7 @@ import * as NodeURL from "node:url";
3233
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
3334
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
3435
import * as PreviewManager from "../../preview/Manager.ts";
36+
import { normalizeCaptureFailure } from "../../preview/captureErrors.ts";
3537
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
3638
import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
3739
import * as IpcChannels from "../channels.ts";
@@ -204,7 +206,8 @@ export const cancelPickElement = tabMethod(
204206
export const startRecording = DesktopIpc.makeIpcMethod({
205207
channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
206208
payload: DesktopPreviewTabInputSchema,
207-
result: Schema.Void,
209+
// Void encodes any value as undefined, so the tagged failure must be tried first.
210+
result: Schema.Union([PreviewCaptureFailure, Schema.Void]),
208211
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
209212
const manager = yield* PreviewManager.PreviewManager;
210213
const store = yield* DesktopClientSettings.DesktopClientSettings;
@@ -213,7 +216,11 @@ export const startRecording = DesktopIpc.makeIpcMethod({
213216
showKeyPresses: value.browserRecordingShowKeyPresses,
214217
showMousePresses: value.browserRecordingShowMousePresses,
215218
}));
216-
yield* manager.startRecording(tabId, Option.getOrUndefined(options));
219+
return yield* manager
220+
.startRecording(tabId, Option.getOrUndefined(options))
221+
.pipe(
222+
Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "recording-start"))),
223+
);
217224
}),
218225
});
219226
export const stopRecording = tabMethod(
@@ -394,10 +401,12 @@ export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({
394401
export const captureScreenshot = DesktopIpc.makeIpcMethod({
395402
channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
396403
payload: DesktopPreviewTabInputSchema,
397-
result: DesktopPreviewScreenshotArtifactSchema,
404+
result: Schema.Union([DesktopPreviewScreenshotArtifactSchema, PreviewCaptureFailure]),
398405
handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) {
399406
const manager = yield* PreviewManager.PreviewManager;
400-
return yield* manager.captureScreenshot(tabId);
407+
return yield* manager
408+
.captureScreenshot(tabId)
409+
.pipe(Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "screenshot"))));
401410
}),
402411
});
403412

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expect, it } from "vite-plus/test";
2+
import { PreviewCaptureFailure } from "@t3tools/contracts";
3+
import * as Cause from "effect/Cause";
4+
import * as Schema from "effect/Schema";
5+
import { normalizeCaptureFailure } from "./captureErrors.ts";
6+
const encodeCaptureFailure = Schema.encodeSync(PreviewCaptureFailure);
7+
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
8+
9+
it("preserves a native pixel capture timeout across the encoded IPC result", () => {
10+
const failure = normalizeCaptureFailure(
11+
{
12+
_tag: "PreviewOperationError",
13+
operation: "captureScreenshot.capturePage",
14+
cause: new Cause.TimeoutError(),
15+
},
16+
"screenshot",
17+
);
18+
const encoded = encodeCaptureFailure(failure);
19+
expect(encoded).toEqual({
20+
_tag: "PreviewCaptureFailure",
21+
reason: "capture-timeout",
22+
stage: "capture-page",
23+
});
24+
expect(encodeJson(encoded)).not.toContain("stack");
25+
});
26+
27+
it.each([
28+
["PreviewTabNotFoundError", "renderer-unavailable"],
29+
["PreviewWebContentsNotFoundError", "renderer-unavailable"],
30+
["PreviewMainWindowClosedError", "renderer-unavailable"],
31+
["PreviewWebviewNotInitializedError", "capture-unavailable"],
32+
["PreviewRecordingCaptureUnavailableError", "capture-unavailable"],
33+
["PreviewRecordingArmConflictError", "recording-conflict"],
34+
])("classifies %s without transporting its arbitrary fields", (tag, reason) => {
35+
const failure = normalizeCaptureFailure(
36+
{ _tag: tag, message: "secret page content", stack: "secret stack" },
37+
"recording-start",
38+
);
39+
expect(failure.reason).toBe(reason);
40+
expect(encodeJson(encodeCaptureFailure(failure))).not.toContain("secret");
41+
});
42+
43+
it.each(["captureScreenshot.capturePage"])(
44+
"classifies the exact Electron surface rejection for %s",
45+
(operation) => {
46+
const failure = normalizeCaptureFailure(
47+
{
48+
_tag: "PreviewOperationError",
49+
operation,
50+
cause: new Error("Current display surface not available for capture"),
51+
},
52+
"screenshot",
53+
);
54+
expect(encodeCaptureFailure(failure)).toEqual({
55+
_tag: "PreviewCaptureFailure",
56+
reason: "capture-unavailable",
57+
stage: "capture-page",
58+
});
59+
},
60+
);
61+
62+
it("keeps extended messages and unrelated operations generic with the screenshot stage", () => {
63+
for (const [operation, message] of [
64+
["captureScreenshot.capturePage", "Current display surface not available for capture secret"],
65+
["captureScreenshot.writeFile", "Current display surface not available for capture"],
66+
]) {
67+
const failure = normalizeCaptureFailure(
68+
{ _tag: "PreviewOperationError", operation, cause: new Error(message) },
69+
"screenshot",
70+
);
71+
expect(failure.reason).toBe("capture-failed");
72+
expect(failure.message).not.toContain("secret");
73+
if (operation === "captureScreenshot.writeFile") {
74+
expect(failure.stage).toBe("screenshot");
75+
expect(failure.message).toContain("during screenshot");
76+
}
77+
}
78+
});
79+
80+
it("does not classify an arbitrary message as a timeout", () => {
81+
const failure = normalizeCaptureFailure(
82+
new Error("TimeoutError secret page content"),
83+
"screenshot",
84+
);
85+
expect(failure.reason).toBe("capture-failed");
86+
expect(failure.message).not.toContain("secret");
87+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { PreviewCaptureFailure, type PreviewCaptureDiagnostic } from "@t3tools/contracts";
2+
import * as Cause from "effect/Cause";
3+
import * as Schema from "effect/Schema";
4+
5+
const isNativeFailure = Schema.is(
6+
Schema.Struct({
7+
_tag: Schema.String,
8+
operation: Schema.optional(Schema.String),
9+
cause: Schema.optional(Schema.Unknown),
10+
}),
11+
);
12+
13+
/** Classify before Electron replaces a rejected IPC Error with its message. */
14+
export function normalizeCaptureFailure(
15+
error: unknown,
16+
stage: PreviewCaptureDiagnostic["stage"],
17+
): PreviewCaptureFailure {
18+
let reason: PreviewCaptureDiagnostic["reason"] = "capture-failed";
19+
if (isNativeFailure(error)) {
20+
switch (error._tag) {
21+
case "PreviewTabNotFoundError":
22+
case "PreviewWebContentsNotFoundError":
23+
case "PreviewMainWindowClosedError":
24+
reason = "renderer-unavailable";
25+
break;
26+
case "PreviewWebviewNotInitializedError":
27+
case "PreviewRecordingCaptureUnavailableError":
28+
reason = "capture-unavailable";
29+
break;
30+
case "PreviewRecordingArmConflictError":
31+
reason = "recording-conflict";
32+
break;
33+
case "PreviewOperationError":
34+
if (error.operation === "captureScreenshot.capturePage") {
35+
stage = "capture-page";
36+
if (
37+
error.cause instanceof Error &&
38+
error.cause.message === "Current display surface not available for capture"
39+
) {
40+
reason = "capture-unavailable";
41+
}
42+
}
43+
if (Cause.isTimeoutError(error.cause)) reason = "capture-timeout";
44+
break;
45+
}
46+
}
47+
return new PreviewCaptureFailure({ reason, stage });
48+
}

‎apps/web/src/browser/browserRecording.test.ts‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER,
33
EnvironmentId,
44
ThreadId,
5+
PreviewCaptureFailure,
56
} from "@t3tools/contracts";
67
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
78

@@ -705,11 +706,13 @@ describe("browser recording", () => {
705706
});
706707

707708
await startBrowserRecording("recording-tab");
708-
FakeMediaRecorder.stopError = new Error("stop failed");
709+
const cleanupCause = new DOMException("Could not start video source", "NotReadableError");
710+
FakeMediaRecorder.stopError = cleanupCause;
709711

710712
await expect(stopBrowserRecording("recording-tab")).rejects.toMatchObject({
711713
operation: "cleanup",
712714
tabId: "recording-tab",
715+
message: "Browser recording operation cleanup failed for tab recording-tab.",
713716
});
714717
expect(stopTrack).toHaveBeenCalledOnce();
715718
});
@@ -777,6 +780,81 @@ describe("browser recording", () => {
777780
expect(events.at(-1)).toBe("clear");
778781
});
779782

783+
it.each([false, true])(
784+
"explains fallback video-source rejection and permits retry when cleanup fails: %s",
785+
async (cleanupFails) => {
786+
const cause = new DOMException("Could not start video source", "NotReadableError");
787+
const cleanupCause = new Error("native stop failed");
788+
const frames = prepareFrameFallback();
789+
frames.canvas.captureStream.mockImplementationOnce(() => {
790+
throw cause;
791+
});
792+
if (cleanupFails) stopScreencast.mockRejectedValueOnce(cleanupCause);
793+
794+
const failed = startBrowserRecording("recording-tab").catch((failure: unknown) => failure);
795+
await frames.ready;
796+
await frames.receive();
797+
const error = await failed;
798+
expect(error).toMatchObject({
799+
_tag: "BrowserRecordingOperationError",
800+
operation: "capture-media-stream",
801+
tabId: "recording-tab",
802+
message:
803+
"Unable to start the preview video source. Make sure the capture host is unlocked " +
804+
"and its display is on, then retry. NotReadableError: Could not start video source",
805+
cause: cleanupFails ? { cause, errors: [cause, cleanupCause] } : cause,
806+
});
807+
expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
808+
expect(FakeMediaRecorder.instances).toHaveLength(0);
809+
expect(save).not.toHaveBeenCalled();
810+
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
811+
expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();
812+
813+
const recoveredFrames = prepareFrameFallback();
814+
const restarted = startBrowserRecording("recording-tab");
815+
await recoveredFrames.ready;
816+
await recoveredFrames.receive();
817+
await restarted;
818+
await stopBrowserRecording("recording-tab");
819+
expect(getUserMedia).toHaveBeenCalledTimes(2);
820+
},
821+
);
822+
823+
it("keeps unrecognized acquisition failures generic", async () => {
824+
const cause = new Error("capture failed");
825+
cause.name = "NotReadableError";
826+
getUserMedia.mockRejectedValueOnce(cause);
827+
828+
await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({
829+
operation: "capture-media-stream",
830+
cause,
831+
message: "Browser recording operation capture-media-stream failed for tab recording-tab.",
832+
});
833+
});
834+
835+
it("reports a native arm diagnostic immediately and frees the tab for a retry", async () => {
836+
const cause = new PreviewCaptureFailure({
837+
reason: "renderer-unavailable",
838+
stage: "recording-start",
839+
});
840+
startScreencast.mockRejectedValueOnce(cause);
841+
842+
await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({
843+
_tag: "BrowserRecordingOperationError",
844+
operation: "start-screencast",
845+
tabId: "recording-tab",
846+
cause,
847+
message: cause.message,
848+
});
849+
expect(getUserMedia).not.toHaveBeenCalled();
850+
expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
851+
expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();
852+
853+
await startBrowserRecording("recording-tab");
854+
await stopBrowserRecording("recording-tab");
855+
expect(getUserMedia).toHaveBeenCalledOnce();
856+
});
857+
780858
it("times out stalled stream acquisition and stops a late stream", async () => {
781859
vi.useFakeTimers();
782860
let finishCapture!: (stream: MediaStream) => void;

0 commit comments

Comments
 (0)