Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
import * as DesktopObservability from "./DesktopObservability.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
Expand Down Expand Up @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () {
const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
const electronApp = yield* ElectronApp.ElectronApp;
const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
const clerk = yield* DesktopClerk.DesktopClerk;
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
Expand Down Expand Up @@ -248,6 +250,7 @@ const startup = Effect.gen(function* () {
yield* appIdentity.configure;
yield* applicationMenu.configure;
yield* updates.configure;
yield* linuxUrlHandler.register;
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
}).pipe(Effect.withSpan("desktop.startup"));

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<s
export const DesktopConfig = Config.all({
appDataDirectory: trimmedString("APPDATA"),
xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
xdgDataHome: trimmedString("XDG_DATA_HOME"),
t3Home: trimmedString("T3CODE_HOME"),
devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"),
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service<
readonly appUserModelId: string;
readonly linuxDesktopEntryName: string;
readonly linuxWmClass: string;
readonly linuxApplicationsDir: string;
readonly appImagePath: Option.Option<string>;
readonly userDataDirName: string;
readonly legacyUserDataDirName: string;
readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings;
Expand Down Expand Up @@ -162,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* (
);
const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
const linuxApplicationsDir = path.join(
Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")),
"applications",
);
const resourcesPath = input.resourcesPath;

return DesktopEnvironment.of({
Expand Down Expand Up @@ -205,6 +211,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
),
linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
linuxApplicationsDir,
appImagePath: config.appImagePath,
userDataDirName,
legacyUserDataDirName,
defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion),
Expand Down
229 changes: 229 additions & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import * as DesktopEnvironment from "./DesktopEnvironment.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> }>;
}

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

const mockProcess = (exitCode: number) =>
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});

const makeHandlerLayer = (
recorded: RecordedRegistration,
input: {
readonly environment?: Record<string, unknown>;
readonly xdgMimeExitCode?: number;
readonly writeError?: PlatformError.PlatformError;
} = {},
) =>
DesktopLinuxUrlHandler.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)),
FileSystem.layerNoop({
makeDirectory: (path) =>
Effect.sync(() => {
recorded.directories.push(path);
}),
writeFileString: (path, content) =>
input.writeError
? Effect.fail(input.writeError)
: Effect.sync(() => {
recorded.files.push({ path, content });
}),
}),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
recorded.commands.push({
command: childProcess.command,
args: childProcess.args,
});
return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0));
}),
),
),
),
);

const runRegister = (
recorded: RecordedRegistration,
input: Parameters<typeof makeHandlerLayer>[1] = {},
) =>
Effect.gen(function* () {
const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
yield* handler.register;
}).pipe(Effect.provide(makeHandlerLayer(recorded, input)));

const emptyRecording = (): RecordedRegistration => ({
directories: [],
files: [],
commands: [],
});

describe("DesktopLinuxUrlHandler", () => {
it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => {
const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
displayName: "T3 Code (Nightly)",
execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',
scheme: "t3code",
});

assert.include(entry, "[Desktop Entry]");
assert.include(entry, "Name=T3 Code (Nightly)");
// Exec composes both escaping layers: a literal backslash becomes four
// backslashes in the file, a quote three characters, a dollar sign two
// backslashes plus the sign.
assert.include(
entry,
'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U',
);
assert.include(entry, "NoDisplay=true");
assert.notInclude(entry, "StartupWMClass=");
assert.include(entry, "MimeType=x-scheme-handler/t3code;");
});

it("carries structured context on registration errors", () => {
const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({
step: "write-desktop-entry",
scheme: "t3code",
desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop",
cause: new Error("boom"),
});
assert.equal(
writeError.message,
"Failed to register the t3code:// URL handler (step: write-desktop-entry).",
);
assert.equal(
writeError.desktopEntryPath,
"/home/alice/.local/share/applications/t3code-url-handler.desktop",
);

const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({
step: "set-default-handler",
scheme: "t3code",
exitCode: 4,
});
assert.equal(
exitError.message,
"Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).",
);
});

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

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/t3code-url-handler.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", "t3code-url-handler.desktop", "x-scheme-handler/t3code"],
},
]);
});
});

it.effect("falls back to the process executable outside an AppImage", () => {
const recorded = emptyRecording();

return Effect.gen(function* () {
yield* runRegister(recorded, { environment: { appImagePath: Option.none() } });

assert.include(
recorded.files[0]?.content,
`Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`,
);
});
});

it.effect("does nothing on other platforms or unpackaged builds", () => {
const nonLinux = emptyRecording();
const unpackaged = emptyRecording();

return Effect.gen(function* () {
yield* runRegister(nonLinux, { environment: { platform: "darwin" } });
yield* runRegister(unpackaged, { environment: { isPackaged: false } });

for (const recorded of [nonLinux, unpackaged]) {
assert.deepEqual(recorded.directories, []);
assert.deepEqual(recorded.files, []);
assert.deepEqual(recorded.commands, []);
}
});
});

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

return Effect.gen(function* () {
yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });
yield* runRegister(writeFailed, {
writeError: PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "writeFileString",
description: "read-only filesystem",
pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop",
}),
});

assert.equal(xdgMimeFailed.files.length, 1);
assert.deepEqual(writeFailed.commands, []);
});
});
});
Loading
Loading