Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppActivation from "./DesktopAppActivation.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
Expand Down Expand Up @@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
yield* logBootstrapInfo("bootstrap start");

if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
Expand Down Expand Up @@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () {
}
yield* primaryBackend.start;
yield* logBootstrapInfo("bootstrap backend start requested");
yield* appActivation.start.pipe(
Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
);
// Bring up the WSL backend if the user previously enabled it. The
// primary is already starting; reconcile fires off the WSL register
// in parallel rather than blocking primary readiness on a possibly
Expand Down
140 changes: 140 additions & 0 deletions apps/desktop/src/app/DesktopAppActivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup.
import * as NodeFSP from "node:fs/promises";
import * as NodeNet from "node:net";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import {
ProjectId,
ThreadId,
type DesktopAppActivationRequest,
type DesktopAppActivationResponse,
} from "@t3tools/contracts";
import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { afterEach, describe, expect } from "vite-plus/test";

import { startDesktopAppControlServer } from "./DesktopAppActivation.ts";

const openServers: Array<{ close: () => Promise<void> }> = [];

afterEach(async () => {
await Promise.all(openServers.splice(0).map((server) => server.close()));
});

function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) {
return resolveDesktopAppControlAddress({
stateDir,
platform,
tempDir: NodeOS.tmpdir(),
userId,
joinPath: NodePath.join,
});
}

function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest {
return {
version: 1,
requestId,
type: "open-workspace",
workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"),
platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux",
};
}

function exchange(address: string, payload: DesktopAppActivationRequest) {
return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
const socket = NodeNet.createConnection(address);
socket.setEncoding("utf8");
let buffer = "";
socket.once("error", reject);
socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`));
socket.on("data", (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.destroy();
resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse);
});
});
}

describe("desktop app control server", () => {
it.effect("roundtrips a request and removes its socket on shutdown", () =>
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
const userId = yield* HostProcessUserId;
yield* Effect.promise(async () => {
const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-"));
const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
const received: DesktopAppActivationRequest[] = [];
const server = await startDesktopAppControlServer({
...target,
userId,
handle: async (input) => {
received.push(input);
return {
version: 1,
requestId: input.requestId,
ok: true,
projectId: ProjectId.make("project-1"),
threadId: ThreadId.make("thread-1"),
};
},
cancel: () => undefined,
});
openServers.push(server);

const response = await exchange(target.address, request("request-1", platform));

expect(received).toHaveLength(1);
expect(response).toMatchObject({ ok: true, requestId: "request-1" });
await server.close();
openServers.splice(openServers.indexOf(server), 1);
if (target.directory !== null) {
await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" });
}
await NodeFSP.rm(root, { recursive: true, force: true });
});
}),
);

it.effect("cancels a queued request when the client disconnects", () =>
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
const userId = yield* HostProcessUserId;
yield* Effect.promise(async () => {
const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-"));
const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
let resolveCanceled: (requestId: string) => void = () => undefined;
const canceled = new Promise<string>((resolve) => {
resolveCanceled = resolve;
});
const server = await startDesktopAppControlServer({
...target,
userId,
handle: () => new Promise(() => undefined),
cancel: resolveCanceled,
});
openServers.push(server);
const socket = NodeNet.createConnection(target.address);
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
socket.once("connect", () => {
socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => {
socket.destroy();
resolve();
});
});
});

await expect(canceled).resolves.toBe("request-canceled");
await server.close();
openServers.splice(openServers.indexOf(server), 1);
await NodeFSP.rm(root, { recursive: true, force: true });
});
}),
);
});
Loading
Loading