Skip to content
Closed
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
156 changes: 152 additions & 4 deletions apps/desktop/src/app/DesktopAppActivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import {
EnvironmentId,
ProjectId,
ThreadId,
type DesktopAppActivationRequest,
type DesktopAppActivationResponse,
type DesktopAppControlResponse,
} from "@t3tools/contracts";
import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -44,8 +45,22 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva
};
}

function exchange(address: string, payload: DesktopAppActivationRequest) {
return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
function openThreadRequest(
requestId: string,
platform: NodeJS.Platform,
): DesktopAppActivationRequest {
return {
version: 1,
requestId,
type: "open-thread",
platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux",
environmentId: EnvironmentId.make("primary"),
threadId: ThreadId.make("thread-1"),
};
}

function exchange(address: string, payload: unknown) {
return new Promise<DesktopAppControlResponse>((resolve, reject) => {
const socket = NodeNet.createConnection(address);
socket.setEncoding("utf8");
let buffer = "";
Expand All @@ -56,7 +71,7 @@ function exchange(address: string, payload: DesktopAppActivationRequest) {
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.destroy();
resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse);
resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppControlResponse);
});
});
}
Expand Down Expand Up @@ -137,4 +152,137 @@ describe("desktop app control server", () => {
});
}),
);

it.effect("answers a capabilities probe without invoking activation", () =>
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-probe-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, {
version: 1,
requestId: "probe-1",
type: "get-capabilities",
});

expect(received).toHaveLength(0);
expect(response).toEqual({
version: 1,
requestId: "probe-1",
ok: true,
type: "capabilities",
operations: ["open-workspace", "open-thread"],
environmentScope: "primary",
});

await server.close();
openServers.splice(openServers.indexOf(server), 1);
await NodeFSP.rm(root, { recursive: true, force: true });
});
}),
);

it.effect("roundtrips an existing-thread request", () =>
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-thread-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,
environmentId: EnvironmentId.make("primary"),
projectId: ProjectId.make("project-1"),
threadId: ThreadId.make("thread-1"),
};
},
cancel: () => undefined,
});
openServers.push(server);

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

expect(received).toEqual([openThreadRequest("thread-1", platform)]);
expect(response).toMatchObject({
ok: true,
requestId: "thread-1",
environmentId: "primary",
projectId: "project-1",
threadId: "thread-1",
});

await server.close();
openServers.splice(openServers.indexOf(server), 1);
await NodeFSP.rm(root, { recursive: true, force: true });
});
}),
);

it.effect("rejects a malformed control request", () =>
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-invalid-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, {
version: 1,
requestId: "bad-1",
type: "open-thread",
});

expect(received).toHaveLength(0);
expect(response).toMatchObject({ ok: false, code: "invalid-request" });

await server.close();
openServers.splice(openServers.indexOf(server), 1);
await NodeFSP.rm(root, { recursive: true, force: true });
});
}),
);
});
31 changes: 30 additions & 1 deletion apps/desktop/src/app/DesktopAppActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import * as NodeOS from "node:os";
import {
DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
DesktopAppActivationRequest,
DesktopAppControlRequest,
type DesktopAppActivationResponse,
type DesktopAppCapabilitiesSuccess,
type DesktopAppControlResponse,
} from "@t3tools/contracts";
import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
import { HostProcessUserId } from "@t3tools/shared/hostProcess";
Expand All @@ -29,6 +32,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts";

const MAX_REQUEST_BYTES = 64 * 1024;
const REQUEST_TIMEOUT_MS = 15_000;
const isDesktopAppControlRequest = Schema.is(DesktopAppControlRequest);
const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest);

export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAppActivationStartError>()(
Expand Down Expand Up @@ -57,6 +61,17 @@ function invalidResponse(requestId: string, message: string): DesktopAppActivati
};
}

function capabilitiesResponse(requestId: string): DesktopAppCapabilitiesSuccess {
return {
version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
requestId,
ok: true,
type: "capabilities",
operations: ["open-workspace", "open-thread"],
environmentScope: "primary",
};
}

function requestIdFromUnknown(value: unknown): string {
if (
typeof value === "object" &&
Expand Down Expand Up @@ -115,7 +130,7 @@ export async function startDesktopAppControlServer(input: {

socket.setTimeout(5_000, () => socket.destroy());

const finish = (response: DesktopAppActivationResponse) => {
const finish = (response: DesktopAppControlResponse) => {
responseSent = true;
if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`);
};
Expand All @@ -142,6 +157,18 @@ export async function startDesktopAppControlServer(input: {
return;
}

if (!isDesktopAppControlRequest(parsed)) {
finish(
invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."),
);
return;
}
if (parsed.type === "get-capabilities") {
// Answer probes immediately: no window focus, no renderer wait, and no
// request bookkeeping, so a capability check never activates anything.
finish(capabilitiesResponse(parsed.requestId));
return;
}
if (!isDesktopAppActivationRequest(parsed)) {
finish(
invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."),
Expand Down Expand Up @@ -208,6 +235,7 @@ export class DesktopAppActivation extends Context.Service<
readonly start: Effect.Effect<void, DesktopAppActivationStartError, Scope.Scope>;
readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect<void>;
readonly isRequestActive: (requestId: string) => boolean;
}
>()("@t3tools/desktop/app/DesktopAppActivation") {}

Expand Down Expand Up @@ -301,6 +329,7 @@ export const make = Effect.gen(function* () {
});
}),
complete: (response) => Effect.sync(() => broker.complete(response)),
isRequestActive: (requestId) => broker.isRequestActive(requestId),
});
});

Expand Down
Loading
Loading