Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/actions/ci-changes/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ outputs:
value: ${{ steps.detect.outputs.release_smoke }}
mobile_native:
value: ${{ steps.detect.outputs.mobile_native }}
desktop_preview:
value: ${{ steps.detect.outputs.desktop_preview }}
runs:
using: composite
steps:
Expand Down
6 changes: 6 additions & 0 deletions .github/scripts/ci-changes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] };
const MOBILE_NATIVE =
/^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//;

// Real-Electron check that agent preview tabs paint with the app window hidden.
// Electron is pinned in the desktop manifest, so its upgrades rerun it too.
const DESKTOP_PREVIEW =
/^apps\/desktop\/src\/preview\/|^apps\/desktop\/scripts\/verify-background-preview|^apps\/desktop\/package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//;

// Jobs that test every package except these, which have jobs of their own.
const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]);

Expand Down Expand Up @@ -181,6 +186,7 @@ function decide(files, packages, full) {
rust: everything || rust,
release_smoke: scriptsChanged,
mobile_native: files === null || files.some((file) => MOBILE_NATIVE.test(file)),
desktop_preview: files === null || files.some((file) => DESKTOP_PREVIEW.test(file)),
},
};
}
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
rust: ${{ steps.detect.outputs.rust }}
release_smoke: ${{ steps.detect.outputs.release_smoke }}
mobile_native: ${{ steps.detect.outputs.mobile_native }}
desktop_preview: ${{ steps.detect.outputs.desktop_preview }}
steps:
# Only the package manifests the script reads to build the dependency graph.
# Depth 2 brings the base commit the change is diffed against.
Expand Down Expand Up @@ -470,6 +471,38 @@ jobs:
- name: Lint mobile native sources
run: vp run lint:mobile

# Unit tests mock Electron, so only a real one with a real window manager can
# prove agent preview tabs still paint while the app window is hidden.
desktop_preview:
name: Desktop Background Preview
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.desktop_preview != 'false' }}
runs-on: blacksmith-6vcpu-macos-26
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: |
args:
- --filter=@t3tools/desktop

- name: Ensure Electron runtime is installed
run: vp run --filter @t3tools/desktop ensure:electron

- name: Verify background preview tabs paint
run: node apps/desktop/scripts/verify-background-preview.mjs

release_smoke:
name: Release Smoke
needs: changes
Expand Down Expand Up @@ -510,6 +543,7 @@ jobs:
test_server,
rust,
mobile_native_static_analysis,
desktop_preview,
release_smoke,
]
runs-on: blacksmith-2vcpu-ubuntu-2404
Expand Down
94 changes: 94 additions & 0 deletions apps/desktop/scripts/verify-background-preview.electron.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Electron entry for verify-background-preview.mjs. CommonJS, because an ESM
// entry delays `ready` until its top-level await settles.
const NodeFS = require("node:fs");
const NodeOS = require("node:os");
const NodePath = require("node:path");
const NodeURL = require("node:url");
const { app, BrowserWindow, nativeImage } = require("electron");

const CAPTURE_TIMEOUT_MS = 5_000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withTimeout = (promise, ms) =>
Promise.race([
promise.then((value) => ({ ok: true, value })),
sleep(ms).then(() => ({ ok: false })),
]);
const page = (html) => `data:text/html,${encodeURIComponent(html)}`;

async function captureColor(tab, rgb) {
await tab.debugger.sendCommand("Runtime.evaluate", {
expression: `document.body.style.background = 'rgb(${rgb.join(",")})'`,
});
const shot = await withTimeout(
tab.debugger.sendCommand("Page.captureScreenshot", { format: "png" }),
CAPTURE_TIMEOUT_MS,
);
if (!shot.ok) return "timed out";
const pixels = nativeImage.createFromBuffer(Buffer.from(shot.value.data, "base64")).toBitmap();
const got = [pixels[2], pixels[1], pixels[0]];
// Color management can shift a channel by a few levels.
return got.every((value, index) => Math.abs(value - rgb[index]) <= 6)
? null
: `stale: rgb(${got.join(",")})`;
}

async function main() {
const { backgroundTabWindowOptions } = await import(
NodeURL.pathToFileURL(NodePath.join(__dirname, "../src/preview/BackgroundTabWindow.ts")).href
);
const preload = NodePath.join(
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-background-preview-")),
"preload.cjs",
);
NodeFS.writeFileSync(preload, "");
// Each state closes its windows, which would otherwise quit the app.
app.on("window-all-closed", () => {});
await app.whenReady();

const failures = [];
for (const state of ["hidden", "minimized"]) {
const appWindow = new BrowserWindow({ width: 480, height: 320, show: true });
await appWindow.loadURL(page("<body>T3 Code</body>"));
await sleep(500);
if (state === "hidden") appWindow.hide();
else appWindow.minimize();
await sleep(1_500);

// Created only after the app window is gone, as an agent's tab would be.
const tabWindow = new BrowserWindow(
backgroundTabWindowOptions({
partition: `t3-background-preview-${state}`,
preload,
passkeyBridgeArgument: undefined,
}),
);
await tabWindow.loadURL(page("<body style='margin:0'></body>"));
const tab = tabWindow.webContents;
tab.debugger.attach("1.3");
for (const [step, rgb] of [
[1, [0, 200, 0]],
[2, [200, 200, 0]],
[3, [0, 200, 200]],
]) {
const failure = await captureColor(tab, rgb);
if (failure) failures.push(`app window ${state}, capture ${step}: ${failure}`);
await sleep(1_000);
}
tabWindow.destroy();
appWindow.destroy();
}

if (failures.length > 0) {
console.error("Background preview tabs stopped painting:");
for (const failure of failures) console.error(` - ${failure}`);
app.exit(1);
return;
}
console.log("Background preview tabs paint while the app window is hidden or minimized.");
app.exit(0);
}

main().catch((error) => {
console.error(error);
app.exit(1);
});
27 changes: 27 additions & 0 deletions apps/desktop/scripts/verify-background-preview.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Proves agent preview tabs still paint while the app window is hidden or
// minimized, using the exact window options the preview manager ships.
// Agents open tabs whenever they like; a tab that cannot paint makes every
// screenshot of it time out. Unit tests mock Electron, so only a real one can
// catch this.
import * as NodeChildProcess from "node:child_process";
import * as NodeModule from "node:module";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";

const RUN_TIMEOUT_MS = 90_000;
const entry = NodePath.join(
NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
"verify-background-preview.electron.cjs",
);
// The plain binary: the development launcher's renamed app bundle refuses data: URLs.
const electronPath = NodeModule.createRequire(import.meta.url)("electron");
const { ELECTRON_RUN_AS_NODE: _ignored, ...env } = process.env;
const child = NodeChildProcess.spawn(electronPath, [entry], { stdio: "inherit", env });
const timer = setTimeout(() => {
console.error(`Background preview check did not finish within ${RUN_TIMEOUT_MS}ms.`);
child.kill("SIGKILL");
}, RUN_TIMEOUT_MS);
child.on("exit", (code, signal) => {
clearTimeout(timer);
process.exit(code ?? (signal ? 1 : 0));
});
18 changes: 17 additions & 1 deletion apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,25 @@ const createTab = DesktopIpc.makeIpcMethod({
zoomFactor,
colorScheme,
serverTab,
background,
}) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
if (background === undefined) {
yield* manager.createTab(tabId, { zoomFactor, colorScheme, serverTab });
return;
}
const { scope, persistent, namespace } = resolvePartitionScope(
background.environmentId,
background.profileId,
);
// Loading the session installs its UA rewrite and permission handlers.
yield* manager.getBrowserSession(scope, persistent, namespace);
const partition = yield* manager.getBrowserPartition(scope, persistent, namespace);
yield* manager.createTab(
tabId,
{ zoomFactor, colorScheme, serverTab },
{ partition, url: background.url },
);
}),
});

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
zoomFactor: defaults?.zoomFactor,
serverTab: defaults?.serverTab,
colorScheme: defaults?.colorScheme,
background: defaults?.background,
}),
closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }),
registerWebview: (tabId, webContentsId) =>
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/preview/BackgroundTabWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { BrowserWindowConstructorOptions } from "electron";

// Kept free of runtime imports: scripts/verify-background-preview.mjs loads it
// straight into Electron to prove these options still paint while the app
// window is hidden.

// The server sizes the page through emulation; this only seeds the window.
const BACKGROUND_TAB_WIDTH = 1280;
const BACKGROUND_TAB_HEIGHT = 800;

/**
* An agent tab's never-shown window. A panel `<webview>` that attaches while
* the app window is hidden or minimized never paints, so its screenshots never
* return; a window of its own paints in every app window state.
*/
export function backgroundTabWindowOptions(input: {
readonly partition: string;
readonly preload: string;
readonly passkeyBridgeArgument: string | undefined;
}): BrowserWindowConstructorOptions {
return {
width: BACKGROUND_TAB_WIDTH,
height: BACKGROUND_TAB_HEIGHT,
show: false,
paintWhenInitiallyHidden: true,
skipTaskbar: true,
webPreferences: {
partition: input.partition,
preload: input.preload,
// Mirrors `will-attach-webview` for panel guests.
sandbox: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
contextIsolation: false,
disableHtmlFullscreenWindowResize: true,
backgroundThrottling: false,
...(input.passkeyBridgeArgument === undefined
? {}
: { additionalArguments: [input.passkeyBridgeArgument] }),
},
};
}
12 changes: 6 additions & 6 deletions apps/desktop/src/preview/DesktopBrowserHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,18 @@ describe("DesktopBrowserHost", () => {
it.effect("announces tabs already attached to a backend that starts later", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
host.attach(key, makeDebuggee().tab);
host.attach(key, makeDebuggee().tab, false);

expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key, background: false }]);
expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key, background: false }]);
}),
);

it.effect("drops replies from a relay the server released", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
const debuggee = makeDebuggee();
host.attach(key, debuggee.tab);
host.attach(key, debuggee.tab, false);
const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);

yield* Effect.yieldNow;
Expand All @@ -93,7 +93,7 @@ describe("DesktopBrowserHost", () => {
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
const debuggee = makeDebuggee();
host.attach(key, debuggee.tab);
host.attach(key, debuggee.tab, false);
const paths: Array<string> = [];
const item = {
setSavePath: (path: string) => void paths.push(path),
Expand Down Expand Up @@ -121,7 +121,7 @@ describe("DesktopBrowserHost", () => {
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
const debuggee = makeDebuggee();
host.attach(key, debuggee.tab);
host.attach(key, debuggee.tab, false);

yield* host.handleCommandLine(
encodeJson({
Expand Down
19 changes: 15 additions & 4 deletions apps/desktop/src/preview/DesktopBrowserHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const keyOf = ({ threadId, tabId }: DesktopBrowserTabKey) => `${threadId}\u0000$
interface AttachedTab {
readonly key: DesktopBrowserTabKey;
readonly debuggee: DesktopBrowserTabDebugger;
readonly background: boolean;
relay: CdpRelayConnection | null;
downloadDirectory: string | null;
pendingDownloadGuid: string | null;
Expand All @@ -59,7 +60,12 @@ export class DesktopBrowserHost extends Context.Service<
{
readonly events: Stream.Stream<Uint8Array>;
readonly handleCommandLine: (line: string) => Effect.Effect<void>;
readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
/** `background` tabs live in a never-shown window that no panel sizes. */
readonly attach: (
key: DesktopBrowserTabKey,
debuggee: DesktopBrowserTabDebugger,
background: boolean,
) => void;
readonly detach: (key: DesktopBrowserTabKey) => void;
readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
Expand Down Expand Up @@ -137,13 +143,18 @@ export const make = Effect.gen(function* () {
emit({ type: "detached", ...key });
};

const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => {
const attach = (
key: DesktopBrowserTabKey,
debuggee: DesktopBrowserTabDebugger,
background: boolean,
) => {
const id = keyOf(key);
if (tabs.get(id)?.debuggee.webContents === debuggee.webContents) return;
detach(key);
const tab: AttachedTab = {
key,
debuggee,
background,
relay: null,
downloadDirectory: null,
pendingDownloadGuid: null,
Expand All @@ -158,7 +169,7 @@ export const make = Effect.gen(function* () {
};
tabs.set(id, tab);
debuggee.debugger.on("message", tab.onMessage);
emit({ type: "attached", ...key });
emit({ type: "attached", ...key, background });
};

const handleCommandLine = (line: string) =>
Expand Down Expand Up @@ -190,7 +201,7 @@ export const make = Effect.gen(function* () {
[...tabs.values()],
(tab) => {
tab.relay = null;
return PubSub.publish(outbox, { type: "attached", ...tab.key });
return PubSub.publish(outbox, { type: "attached", ...tab.key, background: tab.background });
},
{ discard: true },
),
Expand Down
Loading
Loading