Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
7a4f5b6
feat(settings): add and list 1Password secret providers behind T3CODE…
yordis Oct 29, 2026
d152784
fix(mobile): use monospace in the Android terminal preview (#23401)
only21mil Oct 29, 2026
ecea6ca
fix(web): edit from here uses a pencil icon (#21878)
voltcrash Oct 29, 2026
b4da896
fix(desktop): clear Cache Storage when deleting browser profile data …
only21mil Oct 29, 2026
3d5baaf
fix(web): respect reduced motion in thread loading controls (#22100)
saphid Oct 29, 2026
49d0142
fix(provider-acp): redact API keys with underscores in ACP stderr log…
only21mil Oct 29, 2026
67afdd5
fix(provider-core): redact compound credential keys in provider error…
only21mil Oct 29, 2026
456892d
fix(web): show rounded diff counts as 1m instead of 1000k (#23723)
only21mil Oct 29, 2026
5e35f6e
fix(desktop): a timed-out login-shell probe no longer skips the serve…
tiliakoos Oct 29, 2026
55e7b5f
fix(server): keep the previous preview browser version on a shared ho…
only21mil Oct 29, 2026
ee81ea4
fix(settings): servers check secret provider writes and keep unknown …
juliusmarminge Oct 29, 2026
bfee446
fix(web): keep a closed split terminal's tab in view (#22106)
only21mil Oct 29, 2026
69310db
fix(web): the ~ breadcrumb opens the home folder (#22412)
Onkarj012 Oct 29, 2026
505d022
fix(gitlab): accept merge request URLs from self-hosted hosts (#23848)
only21mil Oct 29, 2026
2c37797
fix(server): commits no longer land on a branch switched in mid-actio…
only21mil Oct 29, 2026
3b066da
fix(server): image attachments report their size when the T3 home is …
MarcoDotIO Oct 29, 2026
ebc34da
fix(ssh): skip directories in config includes (#25187)
maria-rcks Oct 29, 2026
80dfe47
fix(server): Authorized clients names Edge, Firefox and Opera on phon…
MarcoDotIO Oct 29, 2026
1d7af65
fix(mobile): fork rows show a readable label instead of a thread ID (…
JeremiahDJordan Oct 29, 2026
1d63134
fix(markdown): formulas ending in a comma render (#22883)
NikitaMGrimm Oct 29, 2026
2049052
fix(scripts): dev runner expands ~ in --home-dir and T3CODE_HOME (#25…
MarcoDotIO Oct 29, 2026
1dd8c67
fix(desktop): keep development protocol requests on the configured se…
only21mil Oct 29, 2026
e0f972e
fix(mobile): apply terminal toolbar modifiers to arrow keys (#22161)
only21mil Oct 29, 2026
40f6f5b
fix(server): clean old logs and artifacts under a symlinked T3 home (…
only21mil Oct 29, 2026
b02b436
fix(server): preserve URL arguments in Android device actions (#25205)
maria-rcks Oct 29, 2026
c839797
fix(server): keep terminal history after cancelled sequences (#25193)
maria-rcks Oct 29, 2026
5aa81a3
fix(auth): accept a typed pairing code regardless of letter case (#22…
only21mil Oct 29, 2026
a03e630
fix(web): find in thread opens from the thread title menu, so phones …
mkantautas Oct 29, 2026
e692729
fix(desktop): resolve executable commands when checking CLI shadowing…
only21mil Oct 29, 2026
a8e4188
fix(server): SQLite temp files no longer block the event loop (#24194)
only21mil Oct 29, 2026
849d2b5
fix(server): a branch named like a tag no longer reads as heads/<name…
MarcoDotIO Oct 29, 2026
0ed5540
fix(server): cap generated worktree folder names (#25212)
maria-rcks Oct 29, 2026
c6f2b7d
perf(server): checkpoint capture scans the index listing by record in…
josephv123 Oct 29, 2026
aaba9de
fix(server): PR discovery sweeps skip hidden subagent threads (#26983)
juliusmarminge Oct 29, 2026
8084e00
fix(shared): stop parsing CLI flags after -- (#25204)
maria-rcks Oct 29, 2026
293669d
fix(server): remove abandoned v1 database copies on startup (#25301)
maria-rcks Oct 29, 2026
6ca95bd
fix(web): Enter no longer skips an unanswered question (#23626)
mkantautas Oct 29, 2026
d152335
fix(web): keep wide characters in terminal links (#24013)
only21mil Oct 29, 2026
a49a3f7
fix(server): session stops no longer trigger storage sweeps unless a …
juliusmarminge Oct 29, 2026
443edd5
fix(provider-core): subagent threads no longer inherit the parent's p…
only21mil Oct 29, 2026
1b3e330
fix(device): report failed Android permission changes (#22358)
only21mil Oct 29, 2026
0d51375
fix(server): thread lists load beyond SQLite variable limit (#25696)
juliusmarminge Oct 29, 2026
1e3e3a8
fix(server): allow large git status metadata (#25207)
maria-rcks Oct 29, 2026
781cc55
perf(mobile): flatten large file trees without copying descendants (#…
luke2x Oct 29, 2026
80ae9b5
fix(web): show theme conflicts even when another import fails (#23223)
only21mil Oct 29, 2026
061dc79
fix(usage): reject cyclic model mappings before saving (#23963)
only21mil Oct 29, 2026
bae3c19
fix(settings): offer Use existing CLI in managed Codex settings (#23160)
only21mil Oct 29, 2026
1e95da7
fix(git): honor project provider settings (#23672)
parmartejass Oct 29, 2026
efb940d
fix(desktop): force-close stalled Linux MIME cache helpers (#25210)
maria-rcks Oct 29, 2026
bdf84df
fix(cli): auth pairing create rejects a --base-url without http(s)://…
MarcoDotIO Oct 29, 2026
4afdaba
fix(server): signed asset and upload URLs stay out of HTTP traces (#2…
only21mil Oct 29, 2026
1cc9d41
fix(server): clone errors no longer show a token from the URL's query…
only21mil Oct 29, 2026
c9d3f7c
fix(claude): pass every --add-dir launch directory to Claude (#23281)
only21mil Oct 29, 2026
4b7a347
fix(composer): retry expired catalogs after failed discovery (#23174)
only21mil Oct 29, 2026
7860ee9
fix(auth): preserve pairing codes after scope rejection (#25222)
maria-rcks Oct 29, 2026
04f3538
fix(mobile): discard unchanged queued edits after starting (#25232)
maria-rcks Oct 29, 2026
b7c7a78
fix(server): t3 project commands wait for a busy server's reply (#24090)
only21mil Oct 29, 2026
9d38d68
fix(web): wait for fresh diff pages in review guides (#23225)
only21mil Oct 29, 2026
99ff58a
fix(server): Antigravity stops retrying session startup while signed …
only21mil Oct 29, 2026
de386ad
fix(web): create tasks on a permitted connected environment (#23244)
only21mil Oct 29, 2026
fa2a2dc
fix(mobile): recognize this thread's main checkout (#23764)
only21mil Oct 29, 2026
06ded0b
fix(web): keep usage hubs that share a host (#23767)
only21mil Oct 29, 2026
f8646e0
fix(github): read text and mixed-case pull request templates (#25025)
maria-rcks Oct 29, 2026
de2b1d3
fix(web): keep HTML prototype selection rings visible (#23745)
stevan-borus Oct 29, 2026
12bd9d6
fix(mobile): list pull request repositories once per environment (#25…
maria-rcks Oct 29, 2026
4e274ae
fix(shared): Open in Zed prefers zeditor over the ZFS zed on Linux (#…
pujitha24 Oct 29, 2026
ef51594
fix(server): native Codex subagents no longer show the parent's reaso…
koushikxd Oct 29, 2026
3bd64cf
fix(server): mark unread works after an automatic compaction (#25286)
flamboh Oct 29, 2026
a1621a8
fix(server): show MCP App forms from failed tool results (#25225)
maria-rcks Oct 29, 2026
ad5ba43
fix(server): restore Linux session display for editor launches (#25228)
maria-rcks Oct 29, 2026
9e60e94
fix(usage): keep host identity across container recreation (#25220)
maria-rcks Oct 29, 2026
d796063
fix(opencode): authenticate with the resolved server password (#25230)
maria-rcks Oct 29, 2026
f06dd4b
feat(web): allow resizing the file explorer (#25737)
maria-rcks Oct 29, 2026
f4147dd
fix(server): update shared Codex installs from their owning home (#25…
maria-rcks Oct 29, 2026
8bcc991
fix(cli): keep published runtimes in place during installation (#24092)
only21mil Oct 29, 2026
e836ab1
fix(web): right panel launcher letters work on non-Latin keyboard lay…
MarMarLabs Oct 29, 2026
5b4f2aa
fix(server): report a deleted or replaced ownership lock (#21886)
ScottN-PV Oct 29, 2026
cb5fac9
fix(server): mark unread no longer brings back a dismissed wake (#24080)
fixfon Oct 29, 2026
fa4f3a2
fix(mobile): stop refreshes in hidden PR Guide panes (#25486)
saphid Oct 29, 2026
b6fbd18
fix(desktop): installing the t3 command no longer reports its own lau…
Gourav224 Oct 29, 2026
1fe8ddd
test(mobile): load the branch occupancy test without native sources (…
maria-rcks Oct 29, 2026
08a463e
fix(settings): project edits keep overrides the client does not know …
only21mil Oct 29, 2026
4092c4a
fix(client): retry the permissions check after a reconnect (#27008)
juliusmarminge Oct 29, 2026
da04ebc
fix(mobile): tab bar spans the screen on iOS before 26 (#27089)
juliusmarminge Oct 29, 2026
624e70e
perf(server): thread recovery reads are 10x faster (#27099)
juliusmarminge Oct 29, 2026
3898c32
perf(server): source control discovery no longer stalls up to 10s on …
juliusmarminge Oct 29, 2026
7f39a86
perf: huge checkpoints ship up to 75x smaller to clients (#27110)
juliusmarminge Oct 29, 2026
8bf4822
perf(server): sidebar shell loads 3x faster and startup thread counts…
juliusmarminge Oct 29, 2026
22c37a8
perf(server): preparing a run is 15x faster and uses 200x less memory…
juliusmarminge Oct 29, 2026
2f4da5d
feat(server): traces name what grew the heap, for ~0.3µs per span (#2…
juliusmarminge Oct 29, 2026
52f4ec6
perf(gitcafe): PR sync makes 2x fewer GitCafe requests for unchanged …
juliusmarminge Oct 29, 2026
facaa08
perf(shared): SQLite reads stop queueing behind each other, up to 5x …
juliusmarminge Oct 29, 2026
7c29f2d
perf(server): git status runs in parallel and repo detection spawns 3…
juliusmarminge Oct 29, 2026
65b0ab8
fix(client): dead connection routes are probed 30x less often (#27101)
juliusmarminge Oct 29, 2026
dd14848
perf(server): idle servers run 100x fewer background queries and 4.8M…
juliusmarminge Oct 29, 2026
812c0a7
feat(devices): support Android EAS Simulator sessions (#26797)
SchroederNathan Oct 29, 2026
cba3059
fix(web,mobile): image-only messages show the image, not the placehol…
maria-rcks Oct 30, 2026
b42adff
ci(mobile): production deploys use a Release Manager Expo token (#27285)
juliusmarminge Oct 30, 2026
f58fad0
ci(mobile): maintainers can deploy EAS previews for reviewed fork com…
juliusmarminge Oct 30, 2026
d7b6cf7
fix(server): agents yield CPU, tasks, and reclaim to the server under…
juliusmarminge Oct 30, 2026
44d508f
feat(server): `t3 service protect` keeps the server alive when agents…
juliusmarminge Oct 30, 2026
8ab098a
fix(server): held queues release agent and scheduled messages once th…
juliusmarminge Oct 30, 2026
36dac68
fix(server): restart continuations retry failed starts and respect a …
juliusmarminge Oct 30, 2026
6d12bb1
fix(server): Stop during a restart no longer leaves the thread stuck …
t3dotgg Oct 30, 2026
8970a32
fix(scripts): bound check:changed concurrency and step time (#27227)
juliusmarminge Oct 30, 2026
c8a606c
fix(shared): durations under 10s drop a trailing .0 (#27316)
maria-rcks Oct 30, 2026
f48fb51
feat(web): switch the composer between rich text and Markdown source …
maria-rcks Oct 30, 2026
a773dc3
fix(server): t3_thread_organize rejects unknown arguments instead of …
juliusmarminge Oct 30, 2026
aa11ef6
fix(web): composer Markdown source button shows the pilcrow again (#2…
maria-rcks Oct 30, 2026
3dd62dc
revert(web): remove the composer rich text toggle button and mod+/ sh…
maria-rcks Oct 30, 2026
2ba2426
fix(server): HTML preview browsers quit when done instead of waiting …
juliusmarminge Oct 30, 2026
05f0d9b
fix(terminal): retain and pace slow fallback process queries (#24640)
juliusmarminge Oct 30, 2026
f3b0194
fix(server): show readable provider runtime failure banners (#24614)
juliusmarminge Oct 30, 2026
2c119bd
fix(gitlab): share merge request reads across detail and checks (#24392)
juliusmarminge Oct 30, 2026
adba9a3
fix(usage): count Claude advisor calls under the advisor model (#24139)
fixfon Oct 30, 2026
d5536f6
fix(web): name the sidebar logo link T3 Code home (#24209)
shivamhwp Oct 30, 2026
0a455d7
fix(server): fail Claude turns waiting for unsupported mod input (#24…
juliusmarminge Oct 30, 2026
6b1245c
fix(server): Stop always completes, and an agent that ignores it can …
juliusmarminge Oct 30, 2026
35c1210
fix: guest links can load earlier turns (#27354)
juliusmarminge Oct 30, 2026
da8cc59
fix(ci): repair main after merge burst
juliusmarminge Oct 30, 2026
fa4e467
fix(server): let slow worktree checkouts finish (#23802)
juliusmarminge Oct 30, 2026
97e35bc
feat(settings): point provider environment variables at a secret prov…
yordis Oct 30, 2026
476590d
feat(desktop): back/forward buttons and trackpad swipe navigation (#2…
juliusmarminge Oct 30, 2026
5ef5685
feat(web): review local commits in the Diff pane (#19941)
Bil0000 Oct 30, 2026
305a90d
fix(relay): show the account's real tunnel cap in T3 Connect settings…
juliusmarminge Oct 30, 2026
5cbc207
fix(web): streaming replies no longer re-render every visible timelin…
juliusmarminge Oct 30, 2026
d8c926c
perf(web): streaming replies re-render only their last Markdown block…
juliusmarminge Oct 30, 2026
aaee774
perf(web): the git actions panel no longer re-renders on every stream…
juliusmarminge Oct 30, 2026
3cbc982
perf(web): streamed assistant text re-renders only its own row (#27419)
juliusmarminge Oct 30, 2026
32ecd71
fix(server): `t3 service protect` can ask for your sudo password (#27…
juliusmarminge Oct 30, 2026
0f6de8c
fix(web): preserve Ctrl+Arrow terminal key sequences (#23480)
UtkarshUsername Oct 30, 2026
d1f1650
fix(web): keep PowerShell prompt paths with spaces in one link (#23527)
UtkarshUsername Oct 30, 2026
8cb9182
perf(client): find unread terminal chunks from the tail (#23779)
UtkarshUsername Oct 30, 2026
6575ce3
fix(pi): explain when Pi's default model has no credentials (#27513)
StiensWout Oct 30, 2026
616c426
fix(shared): TypeScript files no longer show as videos (#27372)
maria-rcks Oct 30, 2026
e4ecaf6
feat(mobile): message media, file cards and grouped steers match web …
maria-rcks Oct 30, 2026
f012fa2
fix(web): back-to-back messages from you stack as one burst with a si…
maria-rcks Oct 30, 2026
afb8619
fix(pi): clear Pi's native queue before Stop aborts (#27514)
StiensWout Oct 30, 2026
9479168
feat(pi): import terminal Pi sessions during setup (#27515)
StiensWout Oct 30, 2026
77336a5
fix(pi): nest tools called through another tool under their caller (#…
StiensWout Oct 30, 2026
e00ebb6
fix(pi): explain when an untrusted folder makes Pi skip project resou…
StiensWout Oct 30, 2026
a077fdd
fix(mobile): Android Thread settings rows no longer vanish with anima…
shivamhwp Oct 30, 2026
35dcdd6
fix(server): phone taps, typing and scrolling reach desktop browser t…
shivamhwp Oct 30, 2026
b8397e5
feat(web): images and videos sent with text sit above the bubble as t…
maria-rcks Oct 30, 2026
1da41f9
feat(web): files sent without text show as file cards (#27324)
maria-rcks Oct 30, 2026
95f71d4
chore(coil): merge upstream/main 1da41f9499 (145 commits)
radroid Oct 30, 2026
de1789d
fix(coil): the daily sync installs before verify when a merge changes…
radroid Oct 30, 2026
06d7231
fix(coil): fork clone and Azure discovery tests follow the merged ups…
radroid Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
70 changes: 70 additions & 0 deletions apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { afterEach, beforeEach, expect, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -135,6 +136,14 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
yield* command.uninstall;
expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n");
expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone"));

const launcher = path.join(home, ".t3", "bin", "t3");
yield* fs.writeFileString(launcher, "npm's t3\n");
process.env.PATH = path.dirname(launcher);
expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher });
expect((yield* Effect.flip(command.install)).message).toContain("Could not set up");
yield* command.uninstall;
expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n");
}).pipe(Effect.scoped),
);

Expand Down Expand Up @@ -183,6 +192,67 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
}).pipe(Effect.scoped),
);

it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"skips a t3 directory or a file this user cannot execute",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const folder = path.join(home, "folder");
const plain = path.join(home, "plain");
const shadow = path.join(home, "shadow");
yield* fs.makeDirectory(path.join(folder, "t3"), { recursive: true });
yield* fs.makeDirectory(plain);
yield* fs.writeFileString(path.join(plain, "t3"), "notes\n");
// Root can execute mode 0o601; other users must honor the owner permissions.
yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601);
yield* fs.makeDirectory(shadow);
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":");

const command = yield* commandIn({ home });
expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3"));

const error = yield* Effect.flip(command.install);
expect(error.message).toContain(path.join(shadow, "t3"));

yield* fs.remove(shadow, { recursive: true });
expect(yield* command.install).toMatchObject({
onPath: true,
installedPath: path.join(home, ".local", "bin", "t3"),
});
}).pipe(Effect.scoped),
);

it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const launcher = path.join(home, ".t3", "bin", "t3");
process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":");

const command = yield* commandIn({ home });
const installed = yield* command.install;

expect(yield* fs.exists(launcher)).toBe(true);
expect(installed).toMatchObject({
onPath: true,
installedPath: path.join(home, ".local", "bin", "t3"),
});
expect(installed.shadowedBy).toBeUndefined();
expect(yield* command.state).toEqual(installed);

const removed = yield* command.uninstall;
expect(removed.installedPath).toBeNull();
expect(removed.onPath).toBe(true);
expect(yield* command.state).toEqual(removed);
expect(yield* fs.exists(installed.installedPath!)).toBe(false);
expect(yield* fs.exists(launcher)).toBe(true);
}).pipe(Effect.scoped),
);

it.effect("leaves the Windows PATH alone when it cannot be read", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
44 changes: 37 additions & 7 deletions apps/desktop/src/app/DesktopCliCommand.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.access has no execute check.
import * as NodeFSP from "node:fs/promises";

import type { DesktopCliCommandState } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -89,6 +92,19 @@ export const make = Effect.gen(function* () {
Effect.orElseSucceed(() => false),
);

// PATH can reach the regular launcher directly; removal still requires isOurLink.
const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")(
function* (candidate: string) {
if (yield* isOurLink(candidate)) return true;
const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]);
if (resolved !== ours) return false;
const info = yield* fs.stat(candidate);
if (info.type !== "File" || Number(info.size) > 16_384) return false;
return (yield* fs.readFileString(candidate)).includes(DesktopCliShim.MARKER);
},
Effect.orElseSucceed(() => false),
);

const powershell = (env: Record<string, string>) =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -118,18 +134,27 @@ export const make = Effect.gen(function* () {
Effect.mapError(() => fail("Could not update your PATH.")),
);

const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")(
function* (target: string) {
if ((yield* fs.stat(target)).type !== "File") return false;
yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFSP.constants.X_OK));
return true;
},
Effect.orElseSucceed(() => false),
);

const firstOnPath = Effect.gen(function* () {
for (const directory of pathEntries(process.env.PATH, ":")) {
const candidate = path.join(directory, "t3");
if (yield* exists(candidate)) return Option.some(candidate);
if (yield* isExecutableFile(candidate)) return Option.some(candidate);
}
return Option.none<string>();
});

const foreignFirstOnPath = Effect.gen(function* () {
if (windows) return Option.none<string>();
const first = yield* firstOnPath;
if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none<string>();
if (Option.isNone(first) || (yield* runsOurLauncher(first.value))) return Option.none<string>();
return first;
});

Expand All @@ -156,12 +181,17 @@ export const make = Effect.gen(function* () {
const installed = yield* installedAt;
const shadowedBy = yield* foreignFirstOnPath;
const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {};
if (Option.isNone(installed)) {
return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow };
}
const first = yield* firstOnPath;
const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value)));
return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow };
const onPath = windows
? Option.isSome(installed)
: Option.isSome(first) && (yield* runsOurLauncher(first.value));
return {
supported: true,
installedPath: Option.getOrNull(installed),
onPath,
launcherPath,
...shadow,
};
}).pipe(
Effect.orElseSucceed(() => ({
supported: false,
Expand Down
71 changes: 71 additions & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";
Expand Down Expand Up @@ -56,6 +62,7 @@ const layerHandler = (
recorded: RecordedRegistration,
input: {
readonly environment?: Record<string, unknown>;
readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
readonly updateDesktopDatabaseExitCode?: number;
readonly updateDesktopDatabaseStalled?: boolean;
readonly updateDesktopDatabaseStarted?: Deferred.Deferred<void>;
Expand Down Expand Up @@ -106,6 +113,7 @@ const layerHandler = (
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
if (input.processSpawner) return input.processSpawner.spawn(command);
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand Down Expand Up @@ -358,6 +366,69 @@ describe("DesktopLinuxUrlHandler", () => {
assert.deepEqual(writeFailed.commands, []);
});
});

it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
"continues registration after a timed-out cache helper ignores SIGTERM",
() =>
Effect.gen(function* () {
const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const nodeExecutable = yield* HostProcess.ExecutablePath;
const recorded = emptyRecording();
const spawned = yield* Deferred.make<ChildProcessSpawner.ChildProcessHandle>();
const ready = yield* Deferred.make<void>();
const processSpawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (!ChildProcess.isStandardCommand(command)) {
return yield* Effect.die("expected a single registration command");
}
recorded.commands.push({ command: command.command, args: command.args });
if (command.command !== "update-desktop-database") return mockProcess(0);
const child = yield* realSpawner.spawn(
ChildProcess.make(
nodeExecutable,
[
"-e",
// The watchdog makes the unfixed case exit with a distinguishable code.
"process.on('SIGTERM', () => setTimeout(() => process.exit(99), 5000));" +
"process.stdout.write('ready\\n'); setInterval(() => {}, 2147483647);",
],
{ ...command.options, stdout: "pipe" },
),
);
yield* Deferred.succeed(spawned, child);
yield* Stream.runHead(child.stdout);
yield* Deferred.succeed(ready, undefined);
return child;
}),
);
const registration = yield* runRegister(recorded, { processSpawner }).pipe(
Effect.forkScoped,
);
const child = yield* Deferred.await(spawned);
yield* Effect.addFinalizer(() =>
child.isRunning.pipe(
Effect.flatMap((running) =>
running ? child.kill({ killSignal: "SIGKILL" }) : Effect.void,
),
Effect.ignore,
),
);
yield* Deferred.await(ready);
yield* TestClock.adjust("5 seconds");
yield* Fiber.join(registration);

const exit = yield* Effect.exit(child.exitCode);
if (Exit.isFailure(exit)) {
assert.include(Cause.pretty(exit.cause), "SIGKILL");
} else {
assert.fail(`cache helper exited with ${exit.value} instead of being force-killed`);
}
assert.deepEqual(
recorded.commands.map(({ command }) => command),
["update-desktop-database", "xdg-mime"],
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});

interface ThemeIconRun {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export const make = Effect.gen(function* () {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
forceKillAfter: "1 second",
},
);
const handle = yield* spawner.spawn(command);
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,39 @@ describe("ElectronProtocol", () => {
}).pipe(Effect.provide(layerProtocol)),
);

it.effect("keeps proxied paths on the configured server", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
handleMock.mockImplementation((_scheme, nextHandler) => {
handler = nextHandler;
});
netFetchMock.mockImplementation(async () => new Response("ok"));

yield* Effect.scoped(
Effect.gen(function* () {
const protocol = yield* ElectronProtocol.ElectronProtocol;
yield* protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:5733/"),
clerkFrontendApiHostname: undefined,
});
yield* Effect.promise(() =>
handler!(new Request("t3code-dev://app//example.com/src/main.tsx?t=1")),
);
yield* Effect.promise(() => handler!(new Request("t3code-dev://app/\\example.com/")));
}),
);

assert.deepEqual(
netFetchMock.mock.calls.map((call) => call[0]),
[
"http://127.0.0.1:5733//example.com/src/main.tsx?t=1",
"http://127.0.0.1:5733//example.com/",
],
);
}).pipe(Effect.provide(layerProtocol)),
);

it.effect("rejects custom protocol requests for another host", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
return new Response(null, { status: 404 });
}

const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin);
// Assign the path so leading slashes cannot replace the configured host.
const targetUrl = new URL(targetOrigin);
targetUrl.pathname = requestUrl.pathname;
targetUrl.search = requestUrl.search;
const headers = new Headers(request.headers);
const headersToRemove: string[] = [];
for (const name of headers.keys()) {
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 @@ -14,6 +14,7 @@ export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open";
export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
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";
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 @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
},
onTrackpadScrollBegin: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler);
},
onTrackpadScrollEnd: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,16 @@ describe("BrowserSession", () => {
assert.isUndefined(sessions.get(partition));

yield* browserSessions.getSession("scope-untouched");
const other = yield* browserSessions.getSession("scope-other");
yield* browserSessions.clearCookies([partition]);

const created = sessions.get(partition);
assert.isDefined(created);
assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
assert.deepStrictEqual(created.clearStorageData.mock.calls[0]?.[0], {
storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"],
});
assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0);
}).pipe(Effect.provide(layer)),
);
});
2 changes: 1 addition & 1 deletion apps/desktop/src/preview/BrowserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export const make = Effect.gen(function* BrowserSessionMake() {
Effect.tryPromise({
try: () =>
browserSession.clearStorageData({
storages: ["cookies", "localstorage", "indexdb", "serviceworkers"],
storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"],
}),
catch: (cause) =>
new BrowserSessionStorageClearError({
Expand Down
Loading
Loading