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
21 changes: 12 additions & 9 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth

The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:

| CLI flag | Env var | Notes |
| ----------------------- | --------------------- | ---------------------------------- |
| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |
| CLI flag | Env var | Notes |
| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |

> TIP: Use the `--help` flag to see all available options and their descriptions.
## Security First

- Always set `--auth-token` before exposing the server outside localhost.
- When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
- Treat the token like a password.
- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.

Expand Down
59 changes: 42 additions & 17 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}

function backendChildEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.T3CODE_PORT;
delete env.T3CODE_AUTH_TOKEN;
delete env.T3CODE_MODE;
delete env.T3CODE_NO_BROWSER;
delete env.T3CODE_HOST;
delete env.T3CODE_DESKTOP_WS_URL;
return env;
}

function writeDesktopLogHeader(message: string): void {
if (!desktopLogSink) return;
desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
Expand Down Expand Up @@ -918,17 +930,6 @@ function configureAutoUpdater(): void {
}, AUTO_UPDATE_POLL_INTERVAL_MS);
updatePollTimer.unref();
}
function backendEnv(): NodeJS.ProcessEnv {
return {
...process.env,
T3CODE_MODE: "desktop",
T3CODE_NO_BROWSER: "1",
T3CODE_PORT: String(backendPort),
T3CODE_HOME: BASE_DIR,
T3CODE_AUTH_TOKEN: backendAuthToken,
};
}

function scheduleBackendRestart(reason: string): void {
if (isQuitting || restartTimer) return;

Expand All @@ -952,16 +953,35 @@ function startBackend(): void {
}

const captureBackendLogs = app.isPackaged && backendLogSink !== null;
const child = ChildProcess.spawn(process.execPath, [backendEntry], {
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
cwd: resolveBackendCwd(),
// In Electron main, process.execPath points to the Electron binary.
// Run the child in Node mode so this backend process does not become a GUI app instance.
env: {
...backendEnv(),
...backendChildEnv(),
ELECTRON_RUN_AS_NODE: "1",
},
stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
stdio: captureBackendLogs
? ["ignore", "pipe", "pipe", "pipe"]
: ["ignore", "inherit", "inherit", "pipe"],
});
const bootstrapStream = child.stdio[3];
if (bootstrapStream && "write" in bootstrapStream) {
bootstrapStream.write(
`${JSON.stringify({
mode: "desktop",
noBrowser: true,
port: backendPort,
t3Home: BASE_DIR,
authToken: backendAuthToken,
})}\n`,
);
bootstrapStream.end();
} else {
child.kill("SIGTERM");
scheduleBackendRestart("missing desktop bootstrap pipe");
return;
}
backendProcess = child;
let backendSessionClosed = false;
const closeBackendSession = (details: string) => {
Expand Down Expand Up @@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
}

function registerIpcHandlers(): void {
ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
event.returnValue = backendWsUrl;
});

ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
Expand Down Expand Up @@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> {
);
writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
backendAuthToken = Crypto.randomBytes(24).toString("hex");
backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
const baseUrl = `ws://127.0.0.1:${backendPort}`;
backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);

registerIpcHandlers();
writeDesktopLogHeader("bootstrap ipc handlers registered");
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => wsUrl,
getWsUrl: () => {
const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason we're using ipcRenderer.sendSync to get the WebSocket URL here? Since it blocks the renderer's main thread until the main process responds, it could cause some visible stutter or 'jank' during the app's startup sequence. It might be smoother for the user if we switch this to an async ipcRenderer.invoke call or handle it during the load process unless you have a better solution

return typeof result === "string" ? result : null;
},
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
Expand Down
96 changes: 96 additions & 0 deletions apps/server/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as NFS from "node:fs";
import * as path from "node:path";
import { execFileSync, spawn } from "node:child_process";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { FileSystem, Schema } from "effect";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import { TestClock } from "effect/testing";

import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";
import { assertNone, assertSome } from "@effect/vitest/utils";

const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });

it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
it.effect("uses platform-specific fd paths", () =>
Effect.sync(() => {
assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3");
assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3");
assert.equal(resolveFdPath(3, "win32"), undefined);
}),
);

it.effect("reads a bootstrap envelope from a provided fd", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });

yield* fs.writeFileString(
filePath,
`${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({
mode: "desktop",
})}\n`,
);

const fd = yield* Effect.acquireRelease(
Effect.sync(() => NFS.openSync(filePath, "r")),
(fd) => Effect.sync(() => NFS.closeSync(fd)),
);

const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
assertSome(payload, {
mode: "desktop",
});
}),
);

it.effect("returns none when the fd is unavailable", () =>
Effect.gen(function* () {
const fd = NFS.openSync("/dev/null", "r");
NFS.closeSync(fd);

const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
assertNone(payload);
}),
);

it.effect("returns none when the bootstrap read times out before any value arrives", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" });
const fifoPath = path.join(tempDir, "bootstrap.pipe");

yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath]));

const _writer = yield* Effect.acquireRelease(
Effect.sync(() =>
spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], {
stdio: ["ignore", "ignore", "ignore"],
}),
),
(writer) =>
Effect.sync(() => {
writer.kill("SIGKILL");
}),
);

const fd = yield* Effect.acquireRelease(
Effect.sync(() => NFS.openSync(fifoPath, "r")),
(fd) => Effect.sync(() => NFS.closeSync(fd)),
);

const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
timeoutMs: 100,
}).pipe(Effect.forkScoped);

yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.millis(100));

const payload = yield* Fiber.join(fiber);
assertNone(payload);
}).pipe(Effect.provide(TestClock.layer())),
);
});
Loading