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
51 changes: 37 additions & 14 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,6 @@ await waitForResources({
tcpPort: port,
});

const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;
const devProtocolClient = resolveDevProtocolClient();
if (devProtocolClient) {
childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
}

let shuttingDown = false;
let restartTimer = null;
let currentApp = null;
Expand All @@ -75,6 +67,37 @@ function cleanupStaleDevApps() {
NodeChildProcess.spawnSync("pkill", ["-f", "--", `--t3code-dev-root=${desktopDir}`], {
stdio: "ignore",
});
NodeChildProcess.spawnSync(
"pkill",
["-f", "--", `${NodePath.join(desktopDir, ".electron-runtime")}/T3 Code (Dev).app`],
{
stdio: "ignore",
},
);
}

function isShellScript(path) {
try {
const buffer = Buffer.alloc(2);
const fd = NodeFS.openSync(path, "r");
try {
NodeFS.readSync(fd, buffer, 0, buffer.length, 0);
return buffer[0] === 0x23 && buffer[1] === 0x21;
} finally {
NodeFS.closeSync(fd);
}
} catch {
return false;
}
}

cleanupStaleDevApps();
const childEnv = { ...process.env };
delete childEnv.ELECTRON_RUN_AS_NODE;
const devProtocolClient = resolveDevProtocolClient();
if (devProtocolClient) {
childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
}

function startApp() {
Expand All @@ -85,11 +108,12 @@ function startApp() {
const electronArgs = remoteDebuggingPort
? [`--remote-debugging-port=${remoteDebuggingPort}`]
: [];
const launchArgs = devProtocolClient
? electronArgs
: [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
const electronCommand = resolveElectronLaunchCommand(launchArgs);
const app = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, {
const electronCommand = resolveElectronLaunchCommand(electronArgs);
const launchArgs =
devProtocolClient && isShellScript(electronCommand.electronPath)
? electronCommand.args
: [...electronCommand.args, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
const app = NodeChildProcess.spawn(electronCommand.electronPath, launchArgs, {
cwd: desktopDir,
env: childEnv,
stdio: "inherit",
Expand Down Expand Up @@ -233,7 +257,6 @@ async function shutdown(exitCode) {
}

startWatchers();
cleanupStaleDevApps();
startApp();

process.once("SIGINT", () => {
Expand Down
15 changes: 11 additions & 4 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment
? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
: "com.t3tools.t3code";
const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
const LAUNCHER_VERSION = 12;
const LAUNCHER_VERSION = 13;
const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns");
const developmentMacIconPngPath = NodePath.join(
repoRoot,
Expand Down Expand Up @@ -100,9 +100,8 @@ function shellSingleQuote(value) {
return `'${value.replaceAll("'", "'\\''")}'`;
}

function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs");
const envEntries = [
function resolveDevelopmentLauncherEnvEntries() {
return [
["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL],
["T3CODE_PORT", process.env.T3CODE_PORT],
["T3CODE_HOME", process.env.T3CODE_HOME],
Expand All @@ -111,6 +110,11 @@ function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS],
["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
}

function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
const mainEntryPath = NodePath.join(desktopDir, "dist-electron", "main.cjs");
const envEntries = resolveDevelopmentLauncherEnvEntries();
NodeFS.writeFileSync(
targetBinaryPath,
[
Expand Down Expand Up @@ -278,6 +282,9 @@ function buildMacLauncher(electronBinaryPath) {
iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs,
appBundleId: APP_BUNDLE_ID,
appProtocolSchemes: APP_PROTOCOL_SCHEMES,
...(isDevelopment
? { launcherEnvEntries: Object.fromEntries(resolveDevelopmentLauncherEnvEntries()) }
: {}),
};

const currentMetadata = readJson(metadataPath);
Expand Down
15 changes: 11 additions & 4 deletions apps/desktop/src/app/DesktopBackendOutputLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";

import { ensureConsoleStreamGuard, isIgnorableConsoleStreamError } from "./DesktopConsole.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

export const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
Expand Down Expand Up @@ -267,12 +268,18 @@ const writeDevelopmentConsoleOutput = (
streamName: "stdout" | "stderr",
chunk: Uint8Array,
): Effect.Effect<void> =>
Effect.try({
try: () => {
Effect.suspend(() => {
try {
const output = streamName === "stderr" ? process.stderr : process.stdout;
ensureConsoleStreamGuard(output);
if (!output.writable || output.destroyed || output.writableEnded) return Effect.void;
output.write(chunk);
},
catch: (cause) => new DesktopBackendConsoleWriteError({ streamName, cause }),
return Effect.void;
} catch (cause) {
return isIgnorableConsoleStreamError(cause)
? Effect.void
: Effect.fail(new DesktopBackendConsoleWriteError({ streamName, cause }));
}
}).pipe(
Effect.catchTags({
DesktopBackendConsoleWriteError: (error) => Effect.logError(error.message, { error }),
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/app/DesktopConsole.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { assert, describe, it } from "@effect/vitest";

import "./DesktopConsole.ts";

describe("DesktopConsole", () => {
it("ignores EPIPE thrown by console stdout writes", () => {
const originalWrite = process.stdout.write;
process.stdout.write = function () {
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
} as typeof process.stdout.write;

try {
const guardedLog: (...data: Array<unknown>) => void = console["log"].bind(console);
assert.doesNotThrow(() => guardedLog("ignored broken stdout"));
} finally {
process.stdout.write = originalWrite;
}
});

it("ignores EPIPE emitted by console stdout writes", async () => {
const originalWrite = process.stdout.write;
process.stdout.write = function (...args: Parameters<typeof process.stdout.write>) {
const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
queueMicrotask(() => process.stdout.emit("error", error));
for (const arg of args) {
if (typeof arg === "function") arg();
}
return false;
} as typeof process.stdout.write;

try {
const guardedLog: (...data: Array<unknown>) => void = console["log"].bind(console);
guardedLog("ignored broken stdout");
await new Promise<void>((resolve) => setImmediate(resolve));
} finally {
process.stdout.write = originalWrite;
}
});
});
40 changes: 40 additions & 0 deletions apps/desktop/src/app/DesktopConsole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const guardedConsoleStreams = new WeakSet<NodeJS.WriteStream>();

export function isIgnorableConsoleStreamError(cause: unknown): boolean {
if (!(cause instanceof Error)) return false;
const errorCode = "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
return errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED";
}

export function ensureConsoleStreamGuard(output: NodeJS.WriteStream): void {
if (guardedConsoleStreams.has(output)) return;
guardedConsoleStreams.add(output);
output.on("error", (cause) => {
if (isIgnorableConsoleStreamError(cause)) return;
throw cause;
});
}

function guardConsoleMethod<T extends (...args: Array<unknown>) => void>(method: T): T {
return ((...args: Parameters<T>) => {
try {
method(...args);
} catch (cause) {
if (!isIgnorableConsoleStreamError(cause)) {
throw cause;
}
}
}) as T;
}

export function installDesktopConsoleGuards(): void {
ensureConsoleStreamGuard(process.stdout);
ensureConsoleStreamGuard(process.stderr);
console.log = guardConsoleMethod(console.log.bind(console));
console.info = guardConsoleMethod(console.info.bind(console));
console.warn = guardConsoleMethod(console.warn.bind(console));
console.error = guardConsoleMethod(console.error.bind(console));
console.debug = guardConsoleMethod(console.debug.bind(console));
}

installDesktopConsoleGuards();
48 changes: 48 additions & 0 deletions apps/desktop/src/app/DesktopObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,52 @@ describe("DesktopObservability", () => {
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
),
);

it.effect("ignores a broken development console pipe while persisting backend child output", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-output-epipe-test-",
});
const environmentLayer = makeEnvironmentLayer(baseDir);
const logPath = yield* Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
return environment.path.join(environment.logDir, "server-child.log");
}).pipe(Effect.provide(environmentLayer));

const originalWrite = process.stdout.write;
process.stdout.write = function (...args: Parameters<typeof process.stdout.write>) {
const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
queueMicrotask(() => process.stdout.emit("error", error));
for (const arg of args) {
if (typeof arg === "function") arg();
}
return false;
} as typeof process.stdout.write;

try {
yield* Effect.gen(function* () {
const outputLog = yield* DesktopObservability.DesktopBackendOutputLog;
yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n"));
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
}).pipe(
Effect.annotateLogs({ runId: "test-run" }),
Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
);
} finally {
process.stdout.write = originalWrite;
}

const log = yield* fileSystem.readFileString(logPath);
const lines = log.trimEnd().split("\n");
const output = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");

assert.equal(output.message, "backend child process output");
assert.equal(output.annotations.stream, "stdout");
assert.equal(output.annotations.text, "hello server\n");
}).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
),
);
});
64 changes: 57 additions & 7 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import { beforeEach, vi } from "vite-plus/test";

const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
handleMock: vi.fn(),
netFetchMock: vi.fn(),
unhandleMock: vi.fn(),
}));
const { handleMock, netFetchMock, registerSchemesAsPrivilegedMock, unhandleMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
netFetchMock: vi.fn(),
registerSchemesAsPrivilegedMock: vi.fn(),
unhandleMock: vi.fn(),
}),
);

vi.mock("electron", () => ({
net: { fetch: netFetchMock },
protocol: { handle: handleMock, unhandle: unhandleMock },
protocol: {
handle: handleMock,
registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock,
unhandle: unhandleMock,
},
}));

import * as ElectronProtocol from "./ElectronProtocol.ts";
Expand All @@ -23,6 +30,37 @@ describe("ElectronProtocol", () => {
unhandleMock.mockReset();
});

it("registers desktop URL schemes with browser-compatible privileges before app ready", () => {
assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [
[
[
{
scheme: "t3code",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
codeCache: true,
},
},
{
scheme: "t3code-dev",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
codeCache: true,
},
},
],
],
]);
});

it.effect("proxies the stable renderer origin to the current app server", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
Expand All @@ -43,7 +81,16 @@ describe("ElectronProtocol", () => {
assert.isDefined(handler);

const response = yield* Effect.promise(() =>
handler!(new Request("t3code-dev://app/api/health?verbose=1")),
handler!(
new Request("t3code-dev://app/api/health?verbose=1", {
headers: {
Accept: "application/json",
Origin: "t3code-dev://app",
Referer: "t3code-dev://app/",
"Sec-Fetch-Site": "same-origin",
},
}),
),
);
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
Expand All @@ -70,6 +117,9 @@ describe("ElectronProtocol", () => {
["t3code-dev"],
);
assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1");
assert.deepEqual(Array.from(netFetchMock.mock.calls[0]?.[1]?.headers ?? []), [
["accept", "application/json"],
]);
assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
}).pipe(Effect.provide(ElectronProtocol.layer)),
);
Expand Down
Loading
Loading