Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ed57bed
fix(web): skip unchanged shortcut modifier updates - 195x typing late…
callmemorgan Sep 28, 2026
ba79610
fix(web,mobile): say that removing a T3 Connect environment keeps its…
juliusmarminge Sep 28, 2026
72330e2
feat(models): add Claude Sonnet 5.5 (#14152)
arturict Sep 28, 2026
b528a70
fix(server): restore Windows terminal startup after node-pty upgrade …
SegFaultZero Sep 28, 2026
7733bc8
fix(desktop): make Linux URL handlers discoverable with the app icon …
limwa Sep 28, 2026
b21f3b7
fix(server): prevent Windows PTY helper crashes in watch mode (#14179)
UtkarshUsername Sep 28, 2026
38f3c62
fix(shared): local-path remotes no longer crash legacy PR link projec…
pujitha24 Sep 29, 2026
adfc924
fix(server): preserve usage in oversized transcript records (#13650)
shivamhwp Sep 29, 2026
e518866
fix(server): OpenCode stop no longer hangs when a turn is interrupted…
SunkenInTime Sep 29, 2026
5da5595
feat: save Bitbucket credentials from Source Control settings (#14103)
gabrielelpidio Sep 29, 2026
76fa23d
fix(provider): mark OpenCode v2 incompatible (#14198)
juliusmarminge Sep 29, 2026
d2c9281
fix(web): pull request badge sits on the sidebar row's baseline (#14007)
Jardo-51 Sep 29, 2026
022d416
fix(mobile): iOS trash now removes a persisted environment
macodev00 Sep 20, 2026
17a67ad
docs(mobile): add JSDoc on iOS environment-trash functions
macodev00 Sep 20, 2026
9d438cc
fix(mobile): isolate iOS confirm overlay from VoiceOver
macodev00 Sep 20, 2026
8bb5037
fix(mobile): trap VoiceOver on the iOS confirm overlay
macodev00 Sep 22, 2026
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
198 changes: 168 additions & 30 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
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 ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopAssets from "./DesktopAssets.ts";
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";

interface RecordedRegistration {
readonly directories: string[];
readonly files: Array<{ readonly path: string; readonly content: string }>;
readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
readonly copies: Array<{ readonly source: string; readonly destination: string }>;
}

const makeEnvironment = (overrides: Record<string, unknown> = {}) =>
const makeEnvironment = (path: Path.Path, overrides: Record<string, unknown> = {}) =>
DesktopEnvironment.DesktopEnvironment.of({
platform: "linux",
isPackaged: true,
Expand All @@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record<string, unknown> = {}) =>
linuxWmClass: "t3code",
linuxApplicationsDir: "/home/alice/.local/share/applications",
appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"),
path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
path,
...overrides,
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

const mockProcess = (exitCode: number) =>
const mockProcess = (exitCode: number, stalled = false) =>
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
Expand All @@ -50,16 +56,41 @@ const makeHandlerLayer = (
recorded: RecordedRegistration,
input: {
readonly environment?: Record<string, unknown>;
readonly updateDesktopDatabaseExitCode?: number;
readonly updateDesktopDatabaseStalled?: boolean;
readonly updateDesktopDatabaseStarted?: Deferred.Deferred<void>;
readonly xdgMimeExitCode?: number;
readonly writeError?: PlatformError.PlatformError;
readonly existingEntry?: string;
readonly iconSource?: string;
readonly iconCopyError?: PlatformError.PlatformError;
} = {},
) =>
DesktopLinuxUrlHandler.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)),
Layer.effect(
DesktopEnvironment.DesktopEnvironment,
Path.Path.pipe(
Effect.map((path) => makeEnvironment(path, input.environment)),
Effect.provide(Path.layer),
),
),
Layer.succeed(DesktopAssets.DesktopAssets, {
iconPaths: Effect.succeed({
png: Option.fromUndefinedOr(input.iconSource),
ico: Option.none(),
icns: Option.none(),
}),
resolveResourcePath: () => Effect.succeedNone,
}),
FileSystem.layerNoop({
copyFile: (source, destination) =>
input.iconCopyError
? Effect.fail(input.iconCopyError)
: Effect.sync(() => {
recorded.copies.push({ source, destination });
}),
readFileString: () => Effect.succeed(input.existingEntry ?? ""),
makeDirectory: (path) =>
Effect.sync(() => {
Expand All @@ -79,11 +110,31 @@ const makeHandlerLayer = (
readonly command: string;
readonly args: ReadonlyArray<string>;
};
if (childProcess.command === "update-desktop-database") {
assert.isTrue(
recorded.files.length > 0 || input.existingEntry !== undefined,
"the desktop entry must exist before refreshing the MIME cache",
);
}
recorded.commands.push({
command: childProcess.command,
args: childProcess.args,
});
return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0));
const exitCode =
childProcess.command === "update-desktop-database"
? (input.updateDesktopDatabaseExitCode ?? 0)
: (input.xdgMimeExitCode ?? 0);
const handle = mockProcess(
exitCode,
childProcess.command === "update-desktop-database" &&
input.updateDesktopDatabaseStalled === true,
);
return childProcess.command === "update-desktop-database" &&
input.updateDesktopDatabaseStarted
? Deferred.succeed(input.updateDesktopDatabaseStarted, undefined).pipe(
Effect.as(handle),
)
: Effect.succeed(handle);
}),
),
),
Expand All @@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({
directories: [],
files: [],
commands: [],
copies: [],
});

describe("DesktopLinuxUrlHandler", () => {
Expand All @@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => {
displayName: "T3 Code (Nightly)",
execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',
scheme: "t3code",
iconPath: "/home/al ice/icons/T3\\x.png",
});

assert.include(entry, "[Desktop Entry]");
Expand All @@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => {
assert.include(entry, "NoDisplay=true");
assert.notInclude(entry, "StartupWMClass=");
assert.include(entry, "MimeType=x-scheme-handler/t3code;");
assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png");
});

it("carries structured context on registration errors", () => {
Expand Down Expand Up @@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => {
);
});

it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => {
const recorded = emptyRecording();
it.effect(
"writes the handler entry, refreshes the MIME cache, and claims the scheme default",
() => {
const recorded = emptyRecording();

return Effect.gen(function* () {
yield* runRegister(recorded);
return Effect.gen(function* () {
yield* runRegister(recorded);

assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);
assert.equal(recorded.files.length, 1);
assert.equal(
recorded.files[0]?.path,
"/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
);
assert.include(
recorded.files[0]?.content,
'Exec="/home/alice/Applications/T3-Code.AppImage" %U',
);
assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");
assert.deepEqual(recorded.commands, [
{
command: "xdg-mime",
args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
},
]);
});
});
assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);
assert.equal(recorded.files.length, 1);
assert.equal(
recorded.files[0]?.path,
"/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
);
assert.include(
recorded.files[0]?.content,
'Exec="/home/alice/Applications/T3-Code.AppImage" %U',
);
assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");
assert.deepEqual(recorded.commands, [
{
command: "update-desktop-database",
args: ["/home/alice/.local/share/applications"],
},
{
command: "xdg-mime",
args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
},
]);
});
},
);

it.effect("falls back to the process executable outside an AppImage", () => {
const recorded = emptyRecording();
Expand All @@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => {
displayName: "T3 Code (Alpha)",
execTarget: "/home/alice/Applications/T3-Code.AppImage",
scheme: "t3code",
iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png",
}),
});

assert.deepEqual(recorded.files, []);
assert.deepEqual(recorded.directories, []);
assert.equal(recorded.commands.length, 1);
assert.deepEqual(recorded.commands, [
{
command: "update-desktop-database",
args: ["/home/alice/.local/share/applications"],
},
{
command: "xdg-mime",
args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
},
]);
});
});

it.effect("installs a persistent icon even when the desktop entry is already current", () => {
const recorded = emptyRecording();
const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png";
return Effect.gen(function* () {
yield* runRegister(recorded, {
iconSource: "/tmp/.mount_T3/resources/icon.png",
existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
displayName: "T3 Code (Alpha)",
execTarget: "/home/alice/Applications/T3-Code.AppImage",
scheme: "t3code",
iconPath,
}),
});
assert.deepEqual(recorded.files, []);
assert.deepEqual(recorded.copies, [
{ source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath },
]);
assert.equal(recorded.commands.at(-1)?.command, "xdg-mime");
});
});

it.effect("still registers the handler when copying its icon fails", () => {
const recorded = emptyRecording();
return Effect.gen(function* () {
yield* runRegister(recorded, {
iconSource: "/tmp/.mount_T3/resources/icon.png",
iconCopyError: PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "copyFile",
description: "read-only icon directory",
}),
});
assert.equal(recorded.files.length, 1);
assert.deepEqual(
recorded.commands.map(({ command }) => command),
["update-desktop-database", "xdg-mime"],
);
});
});

Expand All @@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => {
});

it.effect("never fails startup when registration cannot complete", () => {
const desktopDatabaseFailed = emptyRecording();
const xdgMimeFailed = emptyRecording();
const writeFailed = emptyRecording();

return Effect.gen(function* () {
yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 });
yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });
yield* runRegister(writeFailed, {
writeError: PlatformError.systemError({
Expand All @@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => {
}),
});

assert.deepEqual(
desktopDatabaseFailed.commands.map(({ command }) => command),
["update-desktop-database", "xdg-mime"],
);
assert.equal(xdgMimeFailed.files.length, 1);
assert.deepEqual(writeFailed.commands, []);
});
});

it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () =>
Effect.gen(function* () {
const recorded = emptyRecording();
const started = yield* Deferred.make<void>();
const registration = yield* runRegister(recorded, {
updateDesktopDatabaseStalled: true,
updateDesktopDatabaseStarted: started,
}).pipe(Effect.forkChild);

yield* Deferred.await(started);
yield* TestClock.adjust("5 seconds");
yield* Fiber.join(registration);

assert.deepEqual(
recorded.commands.map(({ command }) => command),
["update-desktop-database", "xdg-mime"],
);
}),
);
});
Loading
Loading