Skip to content

Commit cd3bd3d

Browse files
Merge remote-tracking branch 'origin/main' into HEAD
2 parents 9238ed6 + f34ba43 commit cd3bd3d

219 files changed

Lines changed: 7862 additions & 1570 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.

‎apps/desktop/src/electron/ElectronProtocol.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ describe("ElectronProtocol", () => {
322322
]);
323323
assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]);
324324
assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]);
325+
assert.deepEqual(directives["object-src"], ["'self'", "blob:", "http:", "https:"]);
325326
assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
326327
});
327328
});

‎apps/desktop/src/electron/ElectronProtocol.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
9595
`font-src 'self' ${input.scheme}: data:`,
9696
"worker-src 'self' blob:",
9797
"frame-src 'self' blob: http: https:",
98+
"object-src 'self' blob: http: https:",
9899
"form-action 'self'",
99100
].join("; ");
100101
}

‎apps/desktop/src/preview/BrowserSession.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ describe("BrowserSession", () => {
180180
for (const permission of [
181181
"clipboard-read",
182182
"clipboard-sanitized-write",
183-
"notifications",
184183
"geolocation",
185184
"fullscreen",
186185
]) {
@@ -194,7 +193,7 @@ describe("BrowserSession", () => {
194193
// `clipboard-write` is not a real Electron permission — the async write API
195194
// uses `clipboard-sanitized-write` — so the stale name must not be granted,
196195
// and unrelated permissions stay denied.
197-
for (const permission of ["clipboard-write", "midi"]) {
196+
for (const permission of ["clipboard-write", "notifications", "midi"]) {
198197
assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`);
199198
assert.isFalse(
200199
checkHandler(null, permission) as boolean,

‎apps/desktop/src/preview/BrowserSession.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export type BrowserSessionPartitionNamespace = "profile";
3333
const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
3434
"clipboard-read",
3535
"clipboard-sanitized-write",
36-
"notifications",
3736
"geolocation",
3837
// `DesktopWindow` disables HTML fullscreen window resizing for the app window
3938
// and every preview guest, so a page that goes fullscreen fills its own
@@ -42,6 +41,8 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
4241
// Deliberately NOT local-fonts: preview sessions run untrusted web content,
4342
// and silently granting it would hand every page the user's installed-font
4443
// fingerprint (and font file bytes via FontData.blob()).
44+
// Deliberately NOT notifications: a page could post OS notifications branded
45+
// as T3 Code. T3's own come from the app window on the default session.
4546
]);
4647

4748
const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set([

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,8 @@ describe("PreviewManager", () => {
18861886
const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>(
18871887
async () => undefined,
18881888
);
1889+
const detachListeners = new Set<() => void>();
1890+
let chromiumDetaches = 0;
18891891
let destroyed = false;
18901892
const wc = {
18911893
id,
@@ -1914,19 +1916,30 @@ describe("PreviewManager", () => {
19141916
setIgnoreMenuShortcuts: vi.fn(),
19151917
setWindowOpenHandler: vi.fn(),
19161918
debugger: {
1917-
isAttached: () => attach.mock.calls.length > detach.mock.calls.length,
1919+
isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches,
19181920
attach,
19191921
detach,
19201922
sendCommand,
1921-
on: vi.fn(),
1922-
off: vi.fn(),
1923+
on: (event: string, listener: () => void) => {
1924+
if (event === "detach") detachListeners.add(listener);
1925+
},
1926+
off: (event: string, listener: () => void) => {
1927+
if (event === "detach") detachListeners.delete(listener);
1928+
},
19231929
},
19241930
};
1931+
/** Chromium dropping the debugger without a detach() call. */
1932+
const chromiumDetach = () => {
1933+
chromiumDetaches += 1;
1934+
// Snapshot like an EventEmitter, so a listener added during emit waits for the next one.
1935+
for (const listener of Array.from(detachListeners)) listener();
1936+
};
19251937
return {
19261938
wc: wc as unknown as Electron.WebContents,
19271939
attach,
19281940
detach,
19291941
sendCommand,
1942+
chromiumDetach,
19301943
destroy: () => {
19311944
destroyed = true;
19321945
listeners.get("destroyed")?.();
@@ -1965,6 +1978,24 @@ describe("PreviewManager", () => {
19651978
),
19661979
);
19671980

1981+
effectIt.effect("reattaches a debugger that Chromium dropped on its own", () =>
1982+
withManager((manager) =>
1983+
Effect.gen(function* () {
1984+
const guest = makeAttachingGuest(48);
1985+
fromId.mockReturnValue(guest.wc);
1986+
yield* manager.createTab("tab_detached");
1987+
yield* manager.registerWebview("tab_detached", 48);
1988+
yield* Effect.yieldNow;
1989+
expect(guest.attach).toHaveBeenCalledTimes(1);
1990+
1991+
guest.chromiumDetach();
1992+
yield* Effect.yieldNow;
1993+
yield* Effect.yieldNow;
1994+
expect(guest.attach).toHaveBeenCalledTimes(2);
1995+
}),
1996+
),
1997+
);
1998+
19681999
effectIt.effect("skips a guest destroyed while another guest's session opens", () =>
19692000
withManager((manager) =>
19702001
Effect.gen(function* () {

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,13 +1023,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10231023
copy.delete(webContentsId);
10241024
}),
10251025
]);
1026-
if (control) {
1027-
if (closedServerTab) browserHost.detach(closedServerTab);
1028-
for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) {
1029-
if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab);
1026+
if (control) yield* releaseControlSession(control, closedServerTab);
1027+
});
1028+
1029+
// Closes a session already removed from `controlSessionsRef`. The server can
1030+
// only drive a tab while the desktop holds its debugger.
1031+
const releaseControlSession = Effect.fnUntraced(function* (
1032+
control: BrowserControlSession,
1033+
closedServerTab?: PreviewTabState["serverTab"],
1034+
) {
1035+
if (closedServerTab) browserHost.detach(closedServerTab);
1036+
for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) {
1037+
if (tab.webContentsId === control.webContentsId && tab.serverTab) {
1038+
browserHost.detach(tab.serverTab);
10301039
}
1031-
yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
10321040
}
1041+
yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
10331042
});
10341043

10351044
const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
@@ -1141,23 +1150,36 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
11411150
const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
11421151
runFork(handleDebuggerMessage(method, params));
11431152
};
1144-
yield* Scope.addFinalizer(
1145-
scope,
1146-
attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1147-
wcDebugger.off("message", onMessage);
1148-
if (wcDebugger.isAttached()) wcDebugger.detach();
1149-
}).pipe(Effect.ignore),
1150-
);
11511153
const control: BrowserControlSession = {
11521154
webContentsId: wc.id,
11531155
debugger: wcDebugger,
11541156
semaphore,
11551157
scope,
11561158
onMessage,
11571159
};
1160+
// Electron emits `detach` when Chromium closes the debugger session on its
1161+
// own. The cached session would then fail every command until the webview
1162+
// is replaced.
1163+
let dropped = false;
1164+
const onDetach = () => {
1165+
dropped = true;
1166+
runFork(recoverDetachedControlSession(wc, control));
1167+
};
1168+
// The listener comes off before detach() so a detach this manager requested
1169+
// never looks like a dropped one. A dropped session has nothing left to
1170+
// detach: `wc.debugger` is shared, so detaching here would hit a successor.
1171+
yield* Scope.addFinalizer(
1172+
scope,
1173+
attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => {
1174+
wcDebugger.off("message", onMessage);
1175+
wcDebugger.off("detach", onDetach);
1176+
if (!dropped && wcDebugger.isAttached()) wcDebugger.detach();
1177+
}).pipe(Effect.ignore),
1178+
);
11581179
const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
11591180
yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => {
11601181
wcDebugger.on("message", onMessage);
1182+
wcDebugger.on("detach", onDetach);
11611183
wcDebugger.attach("1.3");
11621184
});
11631185
// Without this, dark-scheme pages with no background of their own (text/plain, e.g.
@@ -2356,6 +2378,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
23562378
}
23572379
}).pipe(Effect.ignore);
23582380

2381+
// Only the session that saw the detach is replaced: by the time the event is
2382+
// handled the guest may already hold a newer one, which must stay.
2383+
const recoverDetachedControlSession = Effect.fn("PreviewManager.recoverDetachedControlSession")(
2384+
function* (wc: Electron.WebContents, detached: BrowserControlSession) {
2385+
const removed = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) =>
2386+
sessions.get(wc.id) === detached
2387+
? ([
2388+
true,
2389+
replaceMap(sessions, (copy) => {
2390+
copy.delete(wc.id);
2391+
}),
2392+
] as const)
2393+
: ([false, sessions] as const),
2394+
);
2395+
if (!removed) return;
2396+
yield* releaseControlSession(detached);
2397+
if (wc.isDestroyed()) return;
2398+
const tabId = yield* tabIdForWebContents(wc.id);
2399+
if (tabId === null) return;
2400+
yield* restoreControlSession(tabId, wc);
2401+
},
2402+
(recover) => Effect.ignore(recover),
2403+
);
2404+
23592405
const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* (
23602406
tabId: string,
23612407
colorScheme: DesktopPreviewColorScheme,

‎apps/desktop/src/snapShot/SnapShotTransition.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ window.prepareCaptureTransition=async destination=>{
175175
const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)};
176176
const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2};
177177
const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")";
178-
const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight);
178+
const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),fit=Math.min(inner.width/imageWidth,inner.height/imageHeight);
179179
Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"});
180180
Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"});
181181
Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor});
@@ -185,7 +185,7 @@ window.prepareCaptureTransition=async destination=>{
185185
const animations=[
186186
card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options),
187187
content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options),
188-
snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options),
188+
snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*fit)/inner.width)+","+((imageHeight*fit)/inner.height)+")"}],options),
189189
border.animate([{opacity:0},{opacity:1}],options),
190190
details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"})
191191
];

‎apps/desktop/src/wsl/DesktopWslEnvironment.test.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@
22
import { describe, it } from "@effect/vitest";
33
import { afterAll, expect } from "vite-plus/test";
44
import * as NodeChildProcess from "node:child_process";
5+
import * as Deferred from "effect/Deferred";
56
import * as Duration from "effect/Duration";
67
import * as Effect from "effect/Effect";
78
import * as Fiber from "effect/Fiber";
9+
import * as FileSystem from "effect/FileSystem";
810
import * as Layer from "effect/Layer";
11+
import * as Option from "effect/Option";
12+
import * as Path from "effect/Path";
913
import * as Sink from "effect/Sink";
1014
import * as Stream from "effect/Stream";
1115
import * as TestClock from "effect/testing/TestClock";
1216
import { ChildProcessSpawner } from "effect/process";
1317

18+
import * as DesktopConfig from "../app/DesktopConfig.ts";
19+
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1420
import {
1521
buildWslRuntimeInstallScript,
1622
buildWslRuntimeInvalidateScript,
1723
buildWslRuntimePruneScript,
1824
buildWslRuntimeProbeScript,
1925
DesktopWslDistroListError,
26+
DesktopWslEnvironment,
2027
formatMissingToolsReason,
28+
layer as wslEnvironmentLayer,
2129
parseNodePath,
2230
parseNodeVersion,
2331
parseResolvedPath,
@@ -158,6 +166,102 @@ describe("probeWslDistros", () => {
158166
});
159167
});
160168

169+
describe("WSL command deadlines", () => {
170+
const makeControlledEnvironment = Effect.gen(function* () {
171+
const started = yield* Deferred.make<void>();
172+
const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
173+
const released = yield* Deferred.make<void>();
174+
const spawner = ChildProcessSpawner.make(() =>
175+
Effect.acquireRelease(
176+
Deferred.succeed(started, undefined).pipe(
177+
Effect.as(
178+
ChildProcessSpawner.makeHandle({
179+
pid: ChildProcessSpawner.ProcessId(1),
180+
exitCode: Deferred.await(exited),
181+
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
182+
kill: () => Effect.void,
183+
unref: Effect.succeed(Effect.void),
184+
stdin: Sink.drain,
185+
stdout: Stream.empty,
186+
stderr: Stream.empty,
187+
all: Stream.empty,
188+
getInputFd: () => Sink.drain,
189+
getOutputFd: () => Stream.empty,
190+
}),
191+
),
192+
),
193+
() => Deferred.succeed(released, undefined),
194+
),
195+
);
196+
const environment = yield* DesktopWslEnvironment.pipe(
197+
Effect.provide(
198+
wslEnvironmentLayer.pipe(
199+
Layer.provide([
200+
DesktopEnvironment.layer({
201+
dirname: "/repo/apps/desktop",
202+
homeDirectory: "/home/test",
203+
platform: "win32",
204+
processArch: "x64",
205+
appVersion: "1.2.3",
206+
appPath: "/repo",
207+
isPackaged: true,
208+
resourcesPath: "/repo/resources",
209+
runningUnderArm64Translation: false,
210+
}).pipe(Layer.provide([Path.layer, DesktopConfig.layerTest({})])),
211+
FileSystem.layerNoop({ exists: () => Effect.succeed(true) }),
212+
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
213+
]),
214+
),
215+
),
216+
);
217+
return { environment, started, exited, released };
218+
});
219+
220+
it.effect("waits for a cold distro to finish starting after 15 seconds", () =>
221+
Effect.gen(function* () {
222+
const { environment, started, exited, released } = yield* makeControlledEnvironment;
223+
const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped);
224+
yield* Deferred.await(started);
225+
yield* TestClock.adjust(Duration.seconds(15));
226+
expect(yield* Deferred.isDone(released)).toBe(false);
227+
228+
yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0));
229+
yield* Fiber.join(fiber);
230+
expect(yield* Deferred.isDone(released)).toBe(true);
231+
}),
232+
);
233+
234+
it.effect("releases a stalled warmup process at the 30-second deadline", () =>
235+
Effect.gen(function* () {
236+
const { environment, started, released } = yield* makeControlledEnvironment;
237+
const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped);
238+
yield* Deferred.await(started);
239+
yield* TestClock.adjust(Duration.seconds(29));
240+
expect(yield* Deferred.isDone(released)).toBe(false);
241+
242+
yield* TestClock.adjust(Duration.seconds(1));
243+
yield* Fiber.join(fiber);
244+
expect(yield* Deferred.isDone(released)).toBe(true);
245+
}),
246+
);
247+
248+
it.effect("still gives path conversion only 10 seconds and releases its process", () =>
249+
Effect.gen(function* () {
250+
const { environment, started, released } = yield* makeControlledEnvironment;
251+
const fiber = yield* environment
252+
.windowsToWslPath("Ubuntu", "C:\\project")
253+
.pipe(Effect.forkScoped);
254+
yield* Deferred.await(started);
255+
yield* TestClock.adjust(Duration.seconds(9));
256+
expect(yield* Deferred.isDone(released)).toBe(false);
257+
258+
yield* TestClock.adjust(Duration.seconds(1));
259+
expect(yield* Fiber.join(fiber)).toEqual(Option.none());
260+
expect(yield* Deferred.isDone(released)).toBe(true);
261+
}),
262+
);
263+
});
264+
161265
describe("WSL runtime cache", () => {
162266
it.each([
163267
[

‎apps/desktop/src/wsl/DesktopWslEnvironment.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";
1717

1818
const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
1919
const LIST_TIMEOUT = Duration.seconds(8);
20-
const PRE_WARM_TIMEOUT = Duration.seconds(10);
20+
// Booting a stopped distro needs more time than converting paths in a running one.
21+
const PRE_WARM_TIMEOUT = Duration.seconds(30);
2122
const WSLPATH_TIMEOUT = Duration.seconds(10);
2223
const PROBE_TIMEOUT = Duration.seconds(10);
2324
const TOOLCHAIN_TIMEOUT = Duration.seconds(10);

‎apps/mobile/app.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ const config: ExpoConfig = {
317317
},
318318
plugins: [
319319
"expo-asset",
320+
"react-native-pdf-jsi",
320321
[
321322
"expo-font",
322323
{

0 commit comments

Comments
 (0)