Skip to content
Closed
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
71 changes: 68 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,76 @@ jobs:
- name: Typecheck
run: vp run typecheck

# Keep tests on their own runners, using the same package split and server
# shards as CI, so the release check job does not spend its budget on tests.
test:
name: Release tests
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}
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: true

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

- uses: ./.github/actions/setup-apt-mirrors

- name: Install browser secret helper build libraries
run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential

- name: Test
run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test

test_server:
name: Release server tests ${{ matrix.shard }}
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}
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: true

# No Electron setup here: `t3` (apps/server) has no Electron dependency
# and none of its tests touch the runtime. Only the non-server `test`
# job, which covers @t3tools/desktop, needs the download.
- name: Test
run: vp run test
run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}

relay_public_config:
name: Resolve T3 Connect public config
Expand Down Expand Up @@ -630,13 +693,15 @@ jobs:
preflight,
relay_public_config,
quality,
test,
test_server,
desktop_mac_arm64,
desktop_linux_x64,
desktop_linux_arm64,
desktop_win_x64,
desktop_win_arm64,
]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }}
runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 15
permissions:
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("ElectronProtocol", () => {
);
assert.include(
response.headers.get("content-security-policy") ?? "",
"connect-src 'self' http: https: ws: wss:",
"connect-src 'self' blob: http: https: ws: wss:",
);
assert.include(
response.headers.get("content-security-policy") ?? "",
Expand Down Expand Up @@ -255,7 +255,14 @@ describe("ElectronProtocol", () => {
"https://clerk.t3.codes",
"https://challenges.cloudflare.com",
]);
assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]);
assert.deepEqual(directives["connect-src"], [
"'self'",
"blob:",
"http:",
"https:",
"ws:",
"wss:",
]);
assert.deepEqual(directives["img-src"], [
"'self'",
"t3code:",
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
// the build-configured Clerk, relay, and OTLP endpoints. Those environment
// origins are not known when this response policy is created, so restrict
// connections by the network schemes the client supports instead of by host.
const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"];
// GLTFLoader fetches embedded textures through blob URLs after parsing the model.
const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"];

return [
"default-src 'self'",
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 @@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
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 DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
},
onTrackpadScrollEnd: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
},
getSystemLocale: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record<SnapShotModifier, readonly [number,
meta: [0x8, 0x10],
};

// The CoreGraphics query connects osascript to the window server, which registers it as a
// foreground app attributed to T3 Code. Go background-only first so it never gets a Dock tile.
const POLLER_SCRIPT = `
ObjC.import("AppKit");
$.NSApplication.sharedApplication.setActivationPolicy($.NSApplicationActivationPolicyProhibited);
ObjC.import("CoreGraphics");
ObjC.import("unistd");
function run(argv) {
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import {
MENU_ACTION_CHANNEL,
SNAP_SHOT_EVENT_CHANNEL,
TRACKPAD_SCROLL_END_CHANNEL,
WINDOW_FULLSCREEN_STATE_CHANNEL,
} from "../ipc/channels.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
Expand Down Expand Up @@ -709,6 +710,30 @@ describe("DesktopWindow", () => {
}),
);

it.effect("forwards native trackpad release to the renderer", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
const send = vi.spyOn(fakeWindow.window.webContents, "send");
const createCount = yield* Ref.make(0);
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow });

yield* Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
const onInput = fakeWindow.webContentsListeners.get("input-event");
if (!onInput) return yield* Effect.die("input-event listener was not registered");
onInput({}, { type: "gestureScrollUpdate" });
assert.notInclude(
send.mock.calls.map(([channel]) => channel),
TRACKPAD_SCROLL_END_CHANNEL,
);
onInput({}, { type: "gestureScrollEnd" });
assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL));
}).pipe(Effect.provide(layer));
}),
);

// Chromium hands the main window's zoom level down to embedded preview
// guests, so every app zoom has to put the preview browser back at its own
// zoom or zooming the UI drags the previewed page with it.
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
MENU_ACTION_CHANNEL,
QUIT_SHORTCUT_CHANNEL,
SNAP_SHOT_EVENT_CHANNEL,
TRACKPAD_SCROLL_END_CHANNEL,
WINDOW_FULLSCREEN_STATE_CHANNEL,
} from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";
Expand Down Expand Up @@ -661,6 +662,9 @@ export const make = Effect.gen(function* () {
event.preventDefault();
}
});
window.webContents.on("input-event", (_event, input) => {
if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL);
});

window.on("page-title-updated", (event) => {
event.preventDefault();
Expand Down
126 changes: 126 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
environmentId: scope.environmentId,
connectionId: "connection-stale",
focused: true,
liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }],
});
expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
"second",
Expand Down Expand Up @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang
),
);

it.effect("prefers the live tab owner for new sessions without moving existing leases", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const connections = new Map<string, string>();
for (const clientId of ["owner", "other"]) {
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId })),
(connectionId) => connections.set(clientId, connectionId),
);
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: clientId,
}),
).pipe(Effect.forkScoped);
}
yield* Effect.yieldNow;
yield* broker.focusHost({
clientId: "owner",
environmentId: scope.environmentId,
connectionId: connections.get("owner")!,
focused: false,
liveTabs: [
{ threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },
],
});
yield* broker.focusHost({
clientId: "other",
environmentId: scope.environmentId,
connectionId: connections.get("other")!,
focused: true,
liveTabs: [
{ threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false },
{
threadId: ThreadId.make("another-thread"),
tabId: PreviewTabId.make("different-tab"),
visible: true,
},
],
});
expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe(
"owner",
);
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "explicit-owner" },
tabId: PreviewTabId.make("signed-in"),
operation: "snapshot",
input: {},
}),
).toBe("owner");
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "other-tab" },
tabId: PreviewTabId.make("different-tab"),
operation: "evaluate",
input: {},
}),
).toBe("other");

yield* broker.focusHost({
clientId: "owner",
environmentId: scope.environmentId,
connectionId: connections.get("owner")!,
focused: false,
liveTabs: [],
});
expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe(
"owner",
);
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "after-tab-closed" },
operation: "evaluate",
input: {},
}),
).toBe("other");
}),
),
);

it.effect("prefers a focused host over unrelated extra capabilities for a new session", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
let focusedConnectionId = "";
for (const [clientId, supportedOperations] of [
["focused", ["status"]],
["background", ["status", "resize"]],
] as const) {
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId, supportedOperations })),
(connectionId) => {
if (clientId === "focused") focusedConnectionId = connectionId;
},
);
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: clientId,
}),
).pipe(Effect.forkScoped);
}
yield* Effect.yieldNow;
yield* broker.focusHost({
clientId: "focused",
environmentId: scope.environmentId,
connectionId: focusedConnectionId,
focused: true,
});
expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
"focused",
);
}),
),
);

it.effect("does not route new operations to legacy hosts that did not advertise support", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
environmentId: scope.environmentId,
connectionId: firstConnectionId,
focused: true,
liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }],
});
expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
host: "first",
Expand Down
Loading