Skip to content

Commit 097333a

Browse files
authored
Merge branch 'main' into feat/explicit-chat-latex
2 parents 0c51ebb + dbd8343 commit 097333a

185 files changed

Lines changed: 4332 additions & 698 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/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@t3tools/tailscale": "workspace:*",
2525
"dbus-next": "0.10.2",
2626
"effect": "catalog:",
27-
"electron": "44.4.2",
27+
"electron": "44.4.5",
2828
"electron-store": "^8.2.0",
2929
"electron-updater": "^6.8.9",
3030
"ffi-rs": "1.3.2",

‎apps/desktop/src/app/DesktopCliCommand.test.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as NodeServices from "@effect/platform-node/NodeServices";
2-
import { expect, it } from "@effect/vitest";
2+
import { afterEach, beforeEach, expect, it } from "@effect/vitest";
33
import * as Effect from "effect/Effect";
44
import * as FileSystem from "effect/FileSystem";
55
import * as Option from "effect/Option";
@@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => {
8585
return { registry, spawner };
8686
};
8787

88+
// The machine's own PATH may already hold a t3; each test sets the PATH it means.
89+
const machinePath = process.env.PATH;
90+
beforeEach(() => {
91+
process.env.PATH = "";
92+
});
93+
afterEach(() => {
94+
process.env.PATH = machinePath;
95+
});
96+
8897
it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
8998
it.effect("links the launcher onto PATH and removes only that link", () =>
9099
Effect.gen(function* () {
@@ -165,24 +174,31 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
165174
}).pipe(Effect.scoped),
166175
);
167176

168-
it.effect("reports when another t3 earlier on PATH would run instead", () =>
177+
it.effect("refuses to install behind another t3 that runs first", () =>
169178
Effect.gen(function* () {
170179
const fs = yield* FileSystem.FileSystem;
171180
const path = yield* Path.Path;
172181
const home = yield* fs.makeTempDirectoryScoped();
173182
const shadow = path.join(home, "shadow");
174183
yield* fs.makeDirectory(shadow);
175184
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
176-
const previous = process.env.PATH;
177185
process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
178-
yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous)));
179186

180187
const command = yield* commandIn({ home });
188+
const theirs = path.join(shadow, "t3");
189+
expect((yield* command.state).shadowedBy).toBe(theirs);
190+
// A link behind it would never run, so nothing is created.
191+
const error = yield* Effect.flip(command.install);
192+
expect(error.message).toContain(theirs);
193+
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
194+
195+
yield* fs.remove(theirs);
181196
const installed = yield* command.install;
182-
expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3"));
183-
expect(installed.onPath).toBe(false);
184-
yield* fs.remove(path.join(shadow, "t3"));
185-
expect((yield* command.state).onPath).toBe(true);
197+
expect(installed).toMatchObject({
198+
onPath: true,
199+
installedPath: path.join(home, ".local", "bin", "t3"),
200+
});
201+
expect(installed.shadowedBy).toBeUndefined();
186202
}).pipe(Effect.scoped),
187203
);
188204

‎apps/desktop/src/app/DesktopCliCommand.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ export const make = Effect.gen(function* () {
146146
return Option.none<string>();
147147
});
148148

149+
/** The `t3` a new shell runs when it is not this app's, on Unix. */
150+
const foreignFirstOnPath = Effect.gen(function* () {
151+
if (windows) return Option.none<string>();
152+
const first = yield* firstOnPath;
153+
if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none<string>();
154+
return first;
155+
});
156+
149157
/** Where this app's command is installed now, if anywhere. */
150158
const installedAt = Effect.gen(function* () {
151159
if (windows) {
@@ -167,12 +175,16 @@ export const make = Effect.gen(function* () {
167175
return { supported: false, installedPath: null, onPath: false } as const;
168176
}
169177
const installed = yield* installedAt;
170-
if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false };
178+
const shadowedBy = yield* foreignFirstOnPath;
179+
const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
180+
if (Option.isNone(installed)) {
181+
return { supported: true, installedPath: null, onPath: false, ...shadow };
182+
}
171183
// On Windows only terminals opened after the change see it. On Unix the
172184
// first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it.
173185
const first = yield* firstOnPath;
174186
const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value)));
175-
return { supported: true, installedPath: installed.value, onPath };
187+
return { supported: true, installedPath: installed.value, onPath, ...shadow };
176188
}).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false })));
177189

178190
/** Writes the launcher if the app has not yet, e.g. when no local backend runs. */
@@ -209,6 +221,13 @@ export const make = Effect.gen(function* () {
209221
.remove(existing.value)
210222
.pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`)));
211223
}
224+
// A link behind another `t3` never runs, so installing one would only hide the problem.
225+
const shadowedBy = yield* foreignFirstOnPath;
226+
if (Option.isSome(shadowedBy)) {
227+
return yield* fail(
228+
`Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`,
229+
);
230+
}
212231
const onPath = pathEntries(process.env.PATH, ":");
213232
const candidates = unixCandidates(environment.homeDirectory, environment.platform);
214233
// Prefer a folder already on PATH that the user can write to without admin rights.

‎apps/desktop/src/app/DesktopLifecycle.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function layerDesktopWindow(
9696
dispatchMenuAction: () => Effect.void,
9797
dispatchSnapShotEvent: () => Effect.void,
9898
zoomMain: () => Effect.void,
99+
runMainContentsCommand: () => Effect.void,
99100
syncAppearance: Effect.void,
100101
});
101102
}

‎apps/desktop/src/backend/DesktopBackendPool.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
100100
dispatchMenuAction: () => Effect.die("unexpected menu action"),
101101
dispatchSnapShotEvent: () => Effect.void,
102102
zoomMain: () => Effect.die("unexpected zoom"),
103+
runMainContentsCommand: () => Effect.die("unexpected main contents command"),
103104
syncAppearance: Effect.void,
104105
} satisfies DesktopWindow.DesktopWindow["Service"]),
105106
),

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
9595
readonly pickFiles: (
9696
input: ElectronDialogPickFilesInput,
9797
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
98+
/** Pass `owner` to attach the box to that window so it cannot fall behind it. */
9899
readonly showMessageBox: (
99100
options: Electron.MessageBoxOptions,
101+
owner?: Electron.BrowserWindow,
100102
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
101103
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
102104
}
@@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
164166
});
165167
return result.canceled ? [] : result.filePaths;
166168
}),
167-
showMessageBox: (options) =>
169+
showMessageBox: (options, owner) =>
168170
Effect.tryPromise({
169-
try: () => Electron.dialog.showMessageBox(options),
171+
try: () =>
172+
owner === undefined
173+
? Electron.dialog.showMessageBox(options)
174+
: Electron.dialog.showMessageBox(owner, options),
170175
catch: (cause) =>
171176
new ElectronDialogShowMessageBoxError({
172177
type: options.type ?? null,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
109109
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
110110
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
111111
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
112+
export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link";
112113

113114
export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";
114115

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn(
5454
yield* manager.subscribePointerEvents((event) =>
5555
electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
5656
);
57+
yield* manager.subscribeOpenLinks((event) =>
58+
electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
59+
);
5760
});
5861

5962
export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({

‎apps/desktop/src/preload.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
DesktopBridge,
3+
DesktopPreviewOpenLinkEvent,
34
DesktopPreviewPointerEvent,
45
DesktopPreviewRecordingInputEvent,
56
DesktopPreviewRecordingFrame,
@@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
416417
return () =>
417418
ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
418419
},
420+
onOpenLink: (listener) => {
421+
const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => {
422+
if (typeof linkEvent !== "object" || linkEvent === null) return;
423+
listener(linkEvent as DesktopPreviewOpenLinkEvent);
424+
};
425+
ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
426+
return () =>
427+
ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
428+
},
419429
},
420430
} satisfies DesktopBridge);

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

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
2121
>(),
2222
}));
2323

24+
const { fromWebContents } = vi.hoisted(() => ({
25+
fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
26+
}));
27+
2428
vi.mock("electron", () => ({
29+
BrowserWindow: { fromWebContents },
2530
session: {
2631
fromPartition,
2732
},
2833
}));
2934

35+
import * as ElectronDialog from "../electron/ElectronDialog.ts";
3036
import * as BrowserSession from "./BrowserSession.ts";
3137

32-
const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));
38+
const { showMessageBox } = vi.hoisted(() => ({
39+
showMessageBox: vi.fn<
40+
(options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
41+
>(() => 1),
42+
}));
43+
const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
44+
pickFolder: () => Effect.die("unused"),
45+
pickFiles: () => Effect.die("unused"),
46+
showMessageBox: (options, owner) =>
47+
Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })),
48+
showErrorBox: () => Effect.void,
49+
});
50+
const layer = BrowserSession.layer.pipe(
51+
Layer.provide(Layer.merge(NodeServices.layer, layerDialog)),
52+
);
3353

3454
describe("BrowserSession", () => {
3555
beforeEach(() => {
@@ -168,6 +188,7 @@ describe("BrowserSession", () => {
168188
"clipboard-sanitized-write",
169189
"notifications",
170190
"geolocation",
191+
"fullscreen",
171192
]) {
172193
assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
173194
assert.isTrue(
@@ -189,6 +210,73 @@ describe("BrowserSession", () => {
189210
}).pipe(Effect.provide(layer)),
190211
);
191212

213+
it.effect("opens custom-scheme links externally only after the user confirms", () =>
214+
Effect.gen(function* () {
215+
const browserSessions = yield* BrowserSession.BrowserSession;
216+
const partition = yield* browserSessions.getPartition("scope-a");
217+
yield* browserSessions.getSession("scope-a");
218+
const requestHandler =
219+
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
220+
assert.isFunction(requestHandler);
221+
222+
const request = (externalURL: string) =>
223+
Effect.callback<boolean>((resume) => {
224+
requestHandler(
225+
null,
226+
"openExternal",
227+
(granted: boolean) => resume(Effect.succeed(granted)),
228+
{ externalURL },
229+
);
230+
});
231+
232+
showMessageBox.mockReset();
233+
showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
234+
assert.isTrue(yield* request("slack://open?team=T1"));
235+
assert.isFalse(yield* request("zoommtg://zoom.us/join"));
236+
assert.strictEqual(showMessageBox.mock.calls.length, 2);
237+
238+
for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
239+
assert.isFalse(yield* request(url), `${url} must never open externally`);
240+
}
241+
assert.strictEqual(showMessageBox.mock.calls.length, 2);
242+
}).pipe(Effect.provide(layer)),
243+
);
244+
245+
// A prompt without an owner can open behind the app window, leaving the one
246+
// prompt slot taken and every later link silently denied.
247+
it.effect("attaches the open-externally prompt to the window hosting the preview", () =>
248+
Effect.gen(function* () {
249+
const browserSessions = yield* BrowserSession.BrowserSession;
250+
const partition = yield* browserSessions.getPartition("scope-a");
251+
yield* browserSessions.getSession("scope-a");
252+
const requestHandler =
253+
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
254+
assert.isFunction(requestHandler);
255+
256+
const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow;
257+
const host = { isDestroyed: () => false };
258+
const guest = { isDestroyed: () => false, hostWebContents: host };
259+
fromWebContents.mockImplementation((webContents) =>
260+
webContents === host ? hostWindow : null,
261+
);
262+
showMessageBox.mockReset();
263+
showMessageBox.mockReturnValueOnce(1);
264+
265+
yield* Effect.callback<boolean>((resume) => {
266+
requestHandler(
267+
guest,
268+
"openExternal",
269+
(granted: boolean) => resume(Effect.succeed(granted)),
270+
{
271+
externalURL: "slack://open?team=T1",
272+
},
273+
);
274+
});
275+
276+
assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow);
277+
}).pipe(Effect.provide(layer)),
278+
);
279+
192280
it.effect("preserves partition scope and the platform failure chain", () => {
193281
const nativeCause = new Error("native digest failed");
194282
const platformCause = PlatformError.systemError({
@@ -218,7 +306,11 @@ describe("BrowserSession", () => {
218306
"Failed to derive a desktop preview browser partition for scope environment-a.",
219307
);
220308
assert.notInclude(error.message, nativeCause.message);
221-
}).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto))));
309+
}).pipe(
310+
Effect.provide(
311+
BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))),
312+
),
313+
);
222314
});
223315

224316
it.effect("preserves session scope, partition, and the Electron failure", () =>

0 commit comments

Comments
 (0)