Skip to content
Open
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 @@ -21,6 +21,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 @@ -51,6 +51,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\//;

// Startup, migration, settlement, and sidebar paths that decide whether an upgraded user's
// threads show after launch. Build runs scripts/smoke-desktop-app.ts for them, so they do not
// first fail in the release launch check.
Expand Down Expand Up @@ -186,6 +191,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
1 change: 1 addition & 0 deletions .github/scripts/ci-changes.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ export function decide(
rust: boolean;
release_smoke: boolean;
mobile_native: boolean;
desktop_preview: boolean;
};
};
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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 @@ -617,6 +618,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-27
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false

- name: Setup Vite+
uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0
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:
permissions:
contents: read
Expand Down Expand Up @@ -662,6 +695,7 @@ jobs:
test_server,
rust,
mobile_native_static_analysis,
desktop_preview,
release_smoke,
]
runs-on: blacksmith-2vcpu-ubuntu-2404
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/scripts/verify-background-preview.electron.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Electron entry for verify-background-preview.mjs. CommonJS, because an ESM
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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.");
NodeFS.writeFileSync(process.env.T3_BACKGROUND_PREVIEW_DONE, "ok");
app.exit(0);
}

main().catch((error) => {
console.error(error);
app.exit(1);
});
38 changes: 38 additions & 0 deletions apps/desktop/scripts/verify-background-preview.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Proves agent preview tabs still paint while the app window is hidden or minimized,
// using the window options the preview manager ships. Unit tests mock Electron.
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeModule from "node:module";
import * as NodeOS from "node:os";
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;
// Only a run that finished every capture writes this, so an early clean quit still fails.
const done = NodePath.join(
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-background-preview-")),
"done",
);
const child = NodeChildProcess.spawn(electronPath, [entry], {
stdio: "inherit",
env: { ...env, T3_BACKGROUND_PREVIEW_DONE: done },
});
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);
if (code === 0 && !NodeFS.existsSync(done)) {
console.error("Background preview check quit before it finished.");
process.exit(1);
}
process.exit(code ?? (signal ? 1 : 0));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getPreferredSystemLanguages,
getPrimaryPasteEnabled,
getWindowFullscreenState,
getWindowShown,
openExternal,
readMedia,
openSystemSettings,
Expand Down Expand Up @@ -103,6 +104,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handleSync(getPreferredSystemLanguages);
yield* ipc.handleSync(getPrimaryPasteEnabled);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getWindowShown);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handleSync(getLocalEnvironmentEnabled);
yield* ipc.handle(setLocalEnvironmentEnabled);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const TRACKPAD_SCROLL_BEGIN_CHANNEL = "desktop:trackpad-scroll-begin";
export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const GET_WINDOW_SHOWN_CHANNEL = "desktop:get-window-shown";
export const SYNC_HTML_FULLSCREEN_CHANNEL = "desktop:sync-html-fullscreen";
export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete";
Expand Down
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 @@ -84,9 +84,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
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
}),
});

export const getWindowShown = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_SHOWN_CHANNEL,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.getWindowShown")(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.currentMainOrFirst;
return Option.isSome(window) && window.value.isVisible() && !window.value.isMinimized();
}),
});

export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL,
result: Schema.Array(DesktopEnvironmentBootstrapSchema),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
},
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
getWindowShown: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_SHOWN_CHANNEL) !== false,
getWindowZoomFactor: () => webFrame.getZoomFactor(),
onWindowFullscreenStateChange: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => {
Expand Down Expand Up @@ -382,6 +383,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] }),
},
};
}
Loading
Loading