Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function makeDesktopWindowLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: input.flushMainWindowBounds ?? Effect.void,
preparePreviewTeardown: Effect.void,
dispatchMenuAction: () => Effect.void,
zoomMain: () => Effect.void,
syncAppearance: Effect.void,
Expand Down
64 changes: 48 additions & 16 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,15 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
function* (
afterBoundsFlush: Effect.Effect<void> = Effect.void,
): Effect.fn.Return<void, never, DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow> {
): Effect.fn.Return<
void,
DesktopWindow.DesktopWindowError,
DesktopShutdown.DesktopShutdown | DesktopWindow.DesktopWindow
> {
const shutdown = yield* DesktopShutdown.DesktopShutdown;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.flushMainWindowBounds;
yield* desktopWindow.preparePreviewTeardown;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
yield* afterBoundsFlush;
yield* shutdown.request;
yield* shutdown.awaitComplete;
Expand All @@ -114,7 +119,7 @@ function handleBeforeQuit(
}

event.preventDefault();
void runEffect(
const quit = runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
const electronWindow = yield* ElectronWindow.ElectronWindow;
Expand All @@ -128,15 +133,27 @@ function handleBeforeQuit(
),
);
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
).finally(() => {
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
});
);
void quit.then(
() => {
markQuitAllowed();
void runEffect(
Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
);
},
(error) => {
void runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, false);
yield* logLifecycleError("preview teardown blocked desktop shutdown", { error });
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}).pipe(Effect.withSpan("desktop.lifecycle.quitBlocked")),
);
},
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

function quitFromSignal(
Expand All @@ -145,7 +162,7 @@ function quitFromSignal(
effect: Effect.Effect<A, E, DesktopLifecycleRegistrationServices>,
) => Promise<A>,
): void {
void runEffect(
const quit = runEffect(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ signal });
const electronApp = yield* ElectronApp.ElectronApp;
Expand All @@ -157,6 +174,18 @@ function quitFromSignal(
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
);
void quit.catch((error) =>
runEffect(
Effect.gen(function* () {
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, false);
yield* logLifecycleError("preview teardown blocked process signal shutdown", {
error,
signal,
});
}).pipe(Effect.withSpan("desktop.lifecycle.processSignalBlocked")),
),
);
}

export const make = DesktopLifecycle.of({
Expand All @@ -179,10 +208,13 @@ export const make = DesktopLifecycle.of({
});
yield* electronApp.exit(0);
}).pipe(
Effect.catchCause((cause) => {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
return logLifecycleError(error.message, { error });
}),
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Ref.set(state.quitting, false);
const error = new DesktopLifecycleRelaunchError({ reason, cause });
yield* logLifecycleError(error.message, { error });
Comment thread
maria-rcks marked this conversation as resolved.
Outdated
}),
),
Effect.forkDetach,
Effect.asVoid,
);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function makePoolLayer(
handleBackendReady: () => Effect.void,
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
preparePreviewTeardown: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
zoomMain: () => Effect.die("unexpected zoom"),
syncAppearance: Effect.void,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancell
export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
export const PREVIEW_PREPARE_WEBVIEW_REMOVAL_CHANNEL = "desktop:preview-prepare-webview-removal";
export const PREVIEW_NAVIGATE_CHANNEL = "desktop:preview-navigate";
export const PREVIEW_GO_BACK_CHANNEL = "desktop:preview-go-back";
export const PREVIEW_GO_FORWARD_CHANNEL = "desktop:preview-go-forward";
Expand Down Expand Up @@ -83,3 +84,4 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
export const PREVIEW_CAPTURE_RECOVERY_CHANNEL = "desktop:preview-capture-recovery";
35 changes: 35 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,41 @@ describe("preview IPC methods", () => {
}),
);

effectIt.effect("returns filtered native snapshot failure detail", () =>
Effect.gen(function* () {
const safeCause = new Error("software compositor copy failed");
safeCause.name = "UnknownVizError";
const unsafeCause = new Error("capture failed at https://preview.example/secret");
unsafeCause.name = "UnknownVizError";
const failures = [safeCause, unsafeCause].map(
(cause) =>
new PreviewManager.PreviewOperationError({
operation: "automationSnapshot.capturePage",
tabId: "tab-1",
webContentsId: 42,
cause,
}),
);
let attempt = 0;
const manager = PreviewManager.PreviewManager.of({
automationSnapshot: () => Effect.fail(failures[attempt++]!),
} as unknown as PreviewManager.PreviewManager["Service"]);

const run = () =>
PreviewIpc.automationSnapshot
.handler({ tabId: "tab-1" })
.pipe(Effect.provideService(PreviewManager.PreviewManager, manager));
expect(yield* run()).toEqual({
_tag: "Failure",
error: { name: "UnknownVizError", message: safeCause.message },
});
expect(yield* run()).toEqual({
_tag: "Failure",
error: { name: "UnknownVizError", message: "Preview capture failed." },
});
}),
);

it("keeps the public automation status tab id limit", () => {
const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);
const tabId = "t".repeat(129);
Expand Down
121 changes: 108 additions & 13 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type DesktopPreviewAutomationFailure,
DesktopPreviewAnnotationThemeInputSchema,
DesktopPreviewArtifactInputSchema,
DesktopPreviewAutomationClickInputSchema,
Expand All @@ -8,10 +9,10 @@ import {
DesktopPreviewAutomationStatusSchema,
DesktopPreviewAutomationTypeInputSchema,
DesktopPreviewAutomationWaitForInputSchema,
DesktopPreviewAutomationSnapshotResultSchema,
DesktopPreviewConfigInputSchema,
DesktopPreviewNavigateInputSchema,
DesktopPreviewRecordingArtifactSchema,
DesktopPreviewRecordingSourceSchema,
DesktopPreviewRecordingSaveInputSchema,
DesktopPreviewRegisterWebviewInputSchema,
DesktopPreviewScreenshotArtifactSchema,
Expand All @@ -21,7 +22,6 @@ import {
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
PreviewAutomationSnapshot,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
Expand All @@ -33,6 +33,79 @@ import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

const SAFE_NATIVE_CAPTURE_ERROR_NAMES = new Set(["AbortError", "UnknownVizError"]);
const SAFE_NATIVE_CAPTURE_MESSAGE_WORDS = new Set([
"abort",
"aborted",
"be",
"capture",
"captured",
"compositor",
"copy",
"could",
"error",
"failed",
"frame",
"from",
"is",
"not",
"operation",
"page",
"preview",
"ready",
"request",
"software",
"surface",
"the",
"this",
"to",
"unable",
"unavailable",
"unknown",
"view",
"viz",
"was",
]);

const safeNativeCaptureMessage = (message: string): string => {
const bounded = message.trim().slice(0, 512);
const words = bounded.toLowerCase().match(/[a-z]+/g) ?? [];
return bounded.length > 0 &&
!message.includes("\n") &&
!message.includes("\r") &&
/^[a-z\s.,:;!?'-]+$/i.test(bounded) &&
words.length > 0 &&
words.every((word) => SAFE_NATIVE_CAPTURE_MESSAGE_WORDS.has(word))
? bounded
: "Preview capture failed.";
};

const snapshotFailure = (
error: PreviewManager.PreviewManagerError,
): DesktopPreviewAutomationFailure => {
if (
PreviewManager.isPreviewOperationError(error) &&
error.cause instanceof Error &&
SAFE_NATIVE_CAPTURE_ERROR_NAMES.has(error.cause.name)
) {
return {
name: error.cause.name,
message: safeNativeCaptureMessage(error.cause.message),
};
}
const record = error as unknown as Record<string, unknown>;
const name = typeof record["_tag"] === "string" ? record["_tag"] : "PreviewAutomationError";
const stage = typeof record["stage"] === "string" ? record["stage"] : undefined;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
return {
name,
message:
stage === undefined
? "Desktop preview snapshot failed."
: `Desktop preview snapshot failed during ${stage}.`,
...(stage === undefined ? {} : { stage }),
};
};

export const installPreviewEventForwarding = Effect.fn(
"desktop.ipc.preview.installEventForwarding",
)(function* () {
Expand All @@ -47,6 +120,9 @@ export const installPreviewEventForwarding = Effect.fn(
yield* manager.subscribePointerEvents((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
);
yield* manager.subscribeCaptureRecoveries((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_CAPTURE_RECOVERY_CHANNEL, event),
);
});

export const createTab = DesktopIpc.makeIpcMethod({
Expand Down Expand Up @@ -83,6 +159,19 @@ export const registerWebview = DesktopIpc.makeIpcMethod({
}),
});

export const prepareWebviewRemoval = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_PREPARE_WEBVIEW_REMOVAL_CHANNEL,
payload: DesktopPreviewRegisterWebviewInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.prepareWebviewRemoval")(function* ({
tabId,
webContentsId,
}) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.prepareWebviewRemoval(tabId, webContentsId);
}),
});

export const navigate = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL,
payload: DesktopPreviewNavigateInputSchema,
Expand Down Expand Up @@ -174,15 +263,11 @@ export const cancelPickElement = tabMethod(
"desktop.ipc.preview.cancelPickElement",
(manager, tabId) => manager.cancelPickElement(tabId),
);
export const startRecording = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
payload: DesktopPreviewTabInputSchema,
result: DesktopPreviewRecordingSourceSchema,
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.startRecording(tabId);
}),
});
export const startRecording = tabMethod(
IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
"desktop.ipc.preview.startRecording",
(manager, tabId) => manager.startRecording(tabId),
);
export const stopRecording = tabMethod(
IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
"desktop.ipc.preview.stopRecording",
Expand Down Expand Up @@ -297,10 +382,19 @@ export const automationStatus = DesktopIpc.makeIpcMethod({
export const automationSnapshot = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL,
payload: DesktopPreviewTabInputSchema,
result: PreviewAutomationSnapshot,
result: DesktopPreviewAutomationSnapshotResultSchema,
handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.automationSnapshot(tabId);
return yield* manager.automationSnapshot(tabId).pipe(
Effect.map((snapshot) => ({ _tag: "Success" as const, snapshot })),
Effect.catch((error) => {
const failure = snapshotFailure(error);
return Effect.logWarning("Desktop preview snapshot failed.", {
tabId,
...failure,
}).pipe(Effect.as({ _tag: "Failure" as const, error: failure }));
}),
);
}),
});

Expand Down Expand Up @@ -378,6 +472,7 @@ export const methods = [
createTab,
closeTab,
registerWebview,
prepareWebviewRemoval,
navigate,
goBack,
goForward,
Expand Down
Loading
Loading