Skip to content

Commit af20032

Browse files
committed
Merge remote-tracking branch 'origin/main' into t3/project-menu-scroll-shift
2 parents 31a2022 + 79bca43 commit af20032

162 files changed

Lines changed: 6319 additions & 1061 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/mobile-showcase-screenshots.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ jobs:
8888
axe --version
8989
9090
- name: Install foldable simulator controls
91-
run: npm install --global [email protected].25
91+
run: npm install --global [email protected].36
9292

9393
- name: Capture iOS showcase
9494
run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}"

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ apps/mobile/.android-cache/
3838
artifacts/app-store/screenshots/
3939
artifacts/mobile-e2e/
4040
artifacts/mobile-behavior/
41+
scripts/.e2e/
4142
.github/pr-assets/
4243
native/**/target/
4344
node_modules/

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
108108
- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together.
109109
- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
110110
- Backend behavior changes ship with focused tests for that behavior.
111-
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript.
111+
- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md).
112112
- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely:
113113
- `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures.
114114
- No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker.

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"electron-updater": "^6.8.9",
2828
"electron-webauthn": "catalog:",
2929
"ffi-rs": "1.3.2",
30-
"playwright-core": "1.60.0",
30+
"playwright-core": "1.64.1",
3131
"react-grab": "^0.1.32",
3232
"tldts": "catalog:"
3333
},
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as Effect from "effect/Effect";
2+
import * as Layer from "effect/Layer";
3+
import * as Schema from "effect/Schema";
4+
import { assert, it } from "@effect/vitest";
5+
import { vi } from "vite-plus/test";
6+
7+
vi.mock("electron", () => ({}));
8+
9+
import * as ElectronWindow from "../electron/ElectronWindow.ts";
10+
import * as DesktopIpc from "./DesktopIpc.ts";
11+
12+
it.effect("answers only the main window's top frame", () =>
13+
Effect.gen(function* () {
14+
const mainFrame = {};
15+
const invoke = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
16+
const sync = new Map<string, DesktopIpc.DesktopIpcSyncListener>();
17+
const ipc = yield* DesktopIpc.DesktopIpc.pipe(
18+
Effect.provide(
19+
DesktopIpc.layer({
20+
removeHandler: (channel) => void invoke.delete(channel),
21+
handle: (channel, listener) => void invoke.set(channel, listener),
22+
removeAllListeners: (channel) => void sync.delete(channel),
23+
on: (channel, listener) => void sync.set(channel, listener),
24+
}).pipe(
25+
Layer.provide(
26+
Layer.mock(ElectronWindow.ElectronWindow, {
27+
main: Effect.succeedSome({
28+
webContents: { id: 1, mainFrame },
29+
} as unknown as Electron.BrowserWindow),
30+
}),
31+
),
32+
),
33+
),
34+
);
35+
yield* ipc.handle(
36+
DesktopIpc.makeIpcMethod({
37+
channel: "token",
38+
payload: Schema.Void,
39+
result: Schema.String,
40+
handler: () => Effect.succeed("secret"),
41+
}),
42+
);
43+
yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") });
44+
45+
const senders = {
46+
app: { sender: { id: 1 }, senderFrame: mainFrame },
47+
appSubframe: { sender: { id: 1 }, senderFrame: {} },
48+
previewGuest: { sender: { id: 2 }, senderFrame: {} },
49+
};
50+
const results: Record<string, unknown> = {};
51+
for (const [name, sender] of Object.entries(senders)) {
52+
const event = { ...sender, returnValue: "unset" as unknown };
53+
sync.get("token-sync")!(event);
54+
results[name] = [
55+
yield* Effect.promise(() =>
56+
Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"),
57+
),
58+
event.returnValue,
59+
];
60+
}
61+
assert.deepEqual(results, {
62+
app: ["secret", "secret"],
63+
appSubframe: ["rejected", undefined],
64+
previewGuest: ["rejected", undefined],
65+
});
66+
}).pipe(Effect.scoped),
67+
);

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

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import * as Context from "effect/Context";
22
import * as Effect from "effect/Effect";
33
import * as Layer from "effect/Layer";
4+
import * as Option from "effect/Option";
45
import * as Schema from "effect/Schema";
56
import * as Scope from "effect/Scope";
67

8+
import * as ElectronWindow from "../electron/ElectronWindow.ts";
9+
710
export interface DesktopIpcInvokeEvent {
811
readonly sender: { readonly id: number };
12+
readonly senderFrame?: unknown;
913
}
1014

11-
export interface DesktopIpcSyncEvent {
15+
export interface DesktopIpcSyncEvent extends DesktopIpcInvokeEvent {
1216
returnValue: unknown;
1317
}
1418

19+
export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect<boolean>;
20+
1521
export type DesktopIpcHandleListener = (
1622
event: DesktopIpcInvokeEvent,
1723
raw: unknown,
@@ -52,6 +58,15 @@ export class DesktopIpcUnregistrationError extends Schema.TaggedError<DesktopIpc
5258
}
5359
}
5460

61+
export class DesktopIpcUntrustedSenderError extends Schema.TaggedError<DesktopIpcUntrustedSenderError>()(
62+
"DesktopIpcUntrustedSenderError",
63+
{ channel: Schema.String },
64+
) {
65+
override get message(): string {
66+
return `Rejected ${this.channel} from a sender that is not the app window.`;
67+
}
68+
}
69+
5570
export const DesktopIpcError = Schema.Union([
5671
DesktopIpcRegistrationError,
5772
DesktopIpcUnregistrationError,
@@ -80,7 +95,11 @@ export class DesktopIpc extends Context.Service<
8095
}
8196
>()("@t3tools/desktop/ipc/DesktopIpc") {}
8297

83-
export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
98+
// ipcMain is shared by every webContents, including preview guests that load arbitrary pages.
99+
export const make = (
100+
ipcMain: DesktopIpcMain,
101+
isTrustedSender: DesktopIpcSenderGuard,
102+
): DesktopIpc["Service"] =>
84103
DesktopIpc.of({
85104
handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({
86105
channel,
@@ -98,6 +117,9 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
98117
runPromise(
99118
Effect.gen(function* () {
100119
yield* Effect.annotateCurrentSpan({ channel });
120+
if (!(yield* isTrustedSender(event))) {
121+
return yield* new DesktopIpcUntrustedSenderError({ channel });
122+
}
101123
return yield* handler(raw, event);
102124
}).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
103125
),
@@ -131,6 +153,11 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
131153
event.returnValue = runSync(
132154
Effect.gen(function* () {
133155
yield* Effect.annotateCurrentSpan({ channel });
156+
// Throwing here would surface as an uncaught main-process exception.
157+
if (!(yield* isTrustedSender(event))) {
158+
yield* Effect.logWarning("Rejected sync IPC from an untrusted sender.");
159+
return undefined;
160+
}
134161
return yield* handler();
135162
}).pipe(
136163
Effect.annotateLogs({ channel }),
@@ -152,7 +179,28 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
152179
}),
153180
});
154181

155-
export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain));
182+
export const layer = (ipcMain: DesktopIpcMain) =>
183+
Layer.effect(
184+
DesktopIpc,
185+
Effect.gen(function* () {
186+
const electronWindow = yield* ElectronWindow.ElectronWindow;
187+
return make(ipcMain, (event) =>
188+
Effect.map(
189+
electronWindow.main,
190+
(main) =>
191+
Option.isSome(main) &&
192+
main.value.webContents.id === event.sender.id &&
193+
main.value.webContents.mainFrame === event.senderFrame,
194+
),
195+
);
196+
}),
197+
);
198+
199+
export const layerTest = (ipcMain: DesktopIpcMain) =>
200+
Layer.succeed(
201+
DesktopIpc,
202+
make(ipcMain, () => Effect.succeed(true)),
203+
);
156204

157205
export interface DesktopIpcMethodRegistration<
158206
Payload,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
8080
Effect.provideService(HostProcess.Platform, "linux"),
8181
Effect.provide([
8282
ElectronApp.layer,
83-
DesktopIpc.layer({
83+
DesktopIpc.layerTest({
8484
handle: (channel, handler) => {
8585
handlers.set(channel, handler);
8686
},

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,17 @@ describe("preview IPC methods", () => {
4242
Effect.scoped(
4343
Effect.gen(function* () {
4444
let listener: DesktopIpc.DesktopIpcHandleListener | undefined;
45-
const ipc = DesktopIpc.make({
46-
removeHandler: vi.fn(),
47-
handle: (_channel, registered) => {
48-
listener = registered;
45+
const ipc = DesktopIpc.make(
46+
{
47+
removeHandler: vi.fn(),
48+
handle: (_channel, registered) => {
49+
listener = registered;
50+
},
51+
removeAllListeners: vi.fn(),
52+
on: vi.fn(),
4953
},
50-
removeAllListeners: vi.fn(),
51-
on: vi.fn(),
52-
});
54+
() => Effect.succeed(true),
55+
);
5356
yield* ipc.handle(method);
5457
return yield* Effect.promise(async () =>
5558
structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ describe("installRelayTelemetryEnabled", () => {
202202
yield* installRelayTelemetryEnabled().pipe(
203203
Effect.provideService(HostProcess.Environment, env),
204204
Effect.provide(
205-
Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)),
205+
Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)),
206206
),
207207
);
208208
const event = { returnValue: undefined as unknown };

‎apps/desktop/src/main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ const layerElectron = Layer.mergeAll(
139139
ElectronTheme.layer,
140140
ElectronUpdater.layer,
141141
ElectronWindow.layer,
142-
DesktopIpc.layer(Electron.ipcMain),
142+
DesktopIpc.layer(Electron.ipcMain).pipe(Layer.provide(ElectronWindow.layer)),
143143
);
144144

145145
const layerDesktopFoundation = Layer.mergeAll(

0 commit comments

Comments
 (0)