Skip to content
Open
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
95 changes: 95 additions & 0 deletions apps/desktop/src/connection-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as FS from "node:fs";
import * as OS from "node:os";
import * as Path from "node:path";

import { describe, expect, it } from "vitest";

import {
DesktopConnectionConfigError,
buildDesktopRemoteWsUrl,
getDefaultDesktopConnectionSettings,
readDesktopConnectionSettings,
resolveDesktopConnectionConfigPath,
validateDesktopConnectionSettings,
writeDesktopConnectionSettings,
} from "./connection-config";

describe("connection-config", () => {
it("returns local defaults when no config exists", () => {
const configPath = Path.join(process.cwd(), "missing", "desktop-connection.json");

expect(readDesktopConnectionSettings(configPath)).toEqual(getDefaultDesktopConnectionSettings());
});

it("builds a websocket url from a remote http url", () => {
expect(
buildDesktopRemoteWsUrl({
mode: "remote",
remoteUrl: "http://100.64.0.10:3773",
remoteAuthToken: "secret token",
}),
).toBe("ws://100.64.0.10:3773/?token=secret+token");
});

it("builds a secure websocket url from a remote https url", () => {
expect(
buildDesktopRemoteWsUrl({
mode: "remote",
remoteUrl: "https://example.com/t3",
remoteAuthToken: "abc123",
}),
).toBe("wss://example.com/?token=abc123");
});

it("allows remote mode without an auth token", () => {
expect(
validateDesktopConnectionSettings({
mode: "remote",
remoteUrl: "http://100.64.0.10:3773",
remoteAuthToken: "",
}),
).toEqual({
mode: "remote",
remoteUrl: "http://100.64.0.10:3773/",
remoteAuthToken: "",
});
});

it("omits the token query param when no remote auth token is configured", () => {
expect(
buildDesktopRemoteWsUrl({
mode: "remote",
remoteUrl: "http://100.64.0.10:3773",
remoteAuthToken: "",
}),
).toBe("ws://100.64.0.10:3773/");
});

it("rejects missing remote url in remote mode", () => {
expect(() =>
validateDesktopConnectionSettings({
mode: "remote",
remoteUrl: "",
remoteAuthToken: "",
}),
).toThrow(DesktopConnectionConfigError);
});

it("writes validated settings to disk", () => {
const tempRoot = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3code-connection-config-"));
const configPath = resolveDesktopConnectionConfigPath(tempRoot);

const saved = writeDesktopConnectionSettings(configPath, {
mode: "remote",
remoteUrl: "http://100.64.0.10:3773",
remoteAuthToken: "abc123",
});

expect(saved).toEqual({
mode: "remote",
remoteUrl: "http://100.64.0.10:3773/",
remoteAuthToken: "abc123",
});
expect(readDesktopConnectionSettings(configPath)).toEqual(saved);
});
});
127 changes: 127 additions & 0 deletions apps/desktop/src/connection-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as FS from "node:fs";
import * as Path from "node:path";

import type {
DesktopConnectionInfo,
DesktopConnectionMode,
DesktopConnectionSettings,
} from "@t3tools/contracts";

export class DesktopConnectionConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "DesktopConnectionConfigError";
}
}

const CONNECTION_CONFIG_FILENAME = "desktop-connection.json";

export function getDefaultDesktopConnectionSettings(): DesktopConnectionSettings {
return {
mode: "local",
remoteUrl: "",
remoteAuthToken: "",
};
}

function normalizeMode(value: unknown): DesktopConnectionMode {
return value === "remote" ? "remote" : "local";
}

function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

export function sanitizeDesktopConnectionSettings(value: unknown): DesktopConnectionSettings {
if (!value || typeof value !== "object") {
return getDefaultDesktopConnectionSettings();
}

const record = value as Record<string, unknown>;
return {
mode: normalizeMode(record.mode),
remoteUrl: normalizeString(record.remoteUrl),
remoteAuthToken: normalizeString(record.remoteAuthToken),
};
}

export function validateDesktopConnectionSettings(
input: DesktopConnectionSettings,
): DesktopConnectionSettings {
const settings = sanitizeDesktopConnectionSettings(input);
if (settings.mode === "local") {
return settings;
}

if (settings.remoteUrl.length === 0) {
throw new DesktopConnectionConfigError("Remote URL is required.");
}

let remoteUrl: URL;
try {
remoteUrl = new URL(settings.remoteUrl);
} catch {
throw new DesktopConnectionConfigError("Remote URL must be a valid http:// or https:// URL.");
}

if (remoteUrl.protocol !== "http:" && remoteUrl.protocol !== "https:") {
throw new DesktopConnectionConfigError("Remote URL must use http:// or https://.");
}
if (!remoteUrl.hostname) {
throw new DesktopConnectionConfigError("Remote URL must include a host.");
}

return {
...settings,
remoteUrl: remoteUrl.toString(),
};
}

export function buildDesktopRemoteWsUrl(settings: DesktopConnectionSettings): string {
const validated = validateDesktopConnectionSettings(settings);
if (validated.mode !== "remote") {
throw new DesktopConnectionConfigError("Remote WebSocket URL is only available in remote mode.");
}

const remoteUrl = new URL(validated.remoteUrl);
remoteUrl.protocol = remoteUrl.protocol === "https:" ? "wss:" : "ws:";
remoteUrl.pathname = "/";
remoteUrl.search = "";
if (validated.remoteAuthToken.length > 0) {
remoteUrl.searchParams.set("token", validated.remoteAuthToken);
}
remoteUrl.hash = "";
return remoteUrl.toString();
}

export function getDesktopConnectionInfo(
settings: DesktopConnectionSettings,
): DesktopConnectionInfo {
return { mode: settings.mode };
}

export function resolveDesktopConnectionConfigPath(userDataPath: string): string {
return Path.join(userDataPath, CONNECTION_CONFIG_FILENAME);
}

export function readDesktopConnectionSettings(configPath: string): DesktopConnectionSettings {
try {
const raw = FS.readFileSync(configPath, "utf8");
return sanitizeDesktopConnectionSettings(JSON.parse(raw));
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return getDefaultDesktopConnectionSettings();
}
throw error;
}
}

export function writeDesktopConnectionSettings(
configPath: string,
input: DesktopConnectionSettings,
): DesktopConnectionSettings {
const settings = validateDesktopConnectionSettings(input);
FS.mkdirSync(Path.dirname(configPath), { recursive: true });
FS.writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
return settings;
}
85 changes: 76 additions & 9 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
DesktopConnectionSettings,
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateState,
Expand All @@ -43,6 +44,14 @@ import {
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
import {
buildDesktopRemoteWsUrl,
getDefaultDesktopConnectionSettings,
getDesktopConnectionInfo,
readDesktopConnectionSettings,
resolveDesktopConnectionConfigPath,
writeDesktopConnectionSettings,
} from "./connection-config";

fixPath();

Expand All @@ -56,6 +65,10 @@ 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 CONNECTION_INFO_CHANNEL = "desktop:connection-info";
const CONNECTION_SETTINGS_GET_CHANNEL = "desktop:connection-settings:get";
const CONNECTION_SETTINGS_SAVE_CHANNEL = "desktop:connection-settings:save";
const RESTART_APP_CHANNEL = "desktop:restart-app";
const STATE_DIR =
process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -83,6 +96,7 @@ let backendProcess: ChildProcess.ChildProcess | null = null;
let backendPort = 0;
let backendAuthToken = "";
let backendWsUrl = "";
let desktopConnectionSettings: DesktopConnectionSettings = getDefaultDesktopConnectionSettings();
let restartAttempt = 0;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let isQuitting = false;
Expand Down Expand Up @@ -686,6 +700,20 @@ function resolveUserDataPath(): string {
return Path.join(appDataBase, USER_DATA_DIR_NAME);
}

function resolveDesktopConnectionSettingsPath(): string {
return resolveDesktopConnectionConfigPath(app.getPath("userData"));
}

function loadDesktopConnectionSettings(): DesktopConnectionSettings {
return readDesktopConnectionSettings(resolveDesktopConnectionSettingsPath());
}

function saveDesktopConnectionSettings(
settings: DesktopConnectionSettings,
): DesktopConnectionSettings {
return writeDesktopConnectionSettings(resolveDesktopConnectionSettingsPath(), settings);
}

function configureAppIdentity(): void {
app.setName(APP_DISPLAY_NAME);
const commitHash = resolveAboutCommitHash();
Expand Down Expand Up @@ -1072,6 +1100,26 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
}

function registerIpcHandlers(): void {
ipcMain.removeHandler(CONNECTION_INFO_CHANNEL);
ipcMain.handle(CONNECTION_INFO_CHANNEL, async () => getDesktopConnectionInfo(desktopConnectionSettings));

ipcMain.removeHandler(CONNECTION_SETTINGS_GET_CHANNEL);
ipcMain.handle(CONNECTION_SETTINGS_GET_CHANNEL, async () => desktopConnectionSettings);

ipcMain.removeHandler(CONNECTION_SETTINGS_SAVE_CHANNEL);
ipcMain.handle(CONNECTION_SETTINGS_SAVE_CHANNEL, async (_event, input: unknown) => {
desktopConnectionSettings = saveDesktopConnectionSettings(
input as DesktopConnectionSettings,
);
return desktopConnectionSettings;
});

ipcMain.removeHandler(RESTART_APP_CHANNEL);
ipcMain.handle(RESTART_APP_CHANNEL, async () => {
app.relaunch();
app.exit(0);
});

ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
Expand Down Expand Up @@ -1304,15 +1352,18 @@ function createWindow(): BrowserWindow {
return window;
}

// Override Electron's userData path before the `ready` event so that
// Chromium session data uses a filesystem-friendly directory name.
// Must be called synchronously at the top level — before `app.whenReady()`.
app.setPath("userData", resolveUserDataPath());
async function resolveDesktopConnection(): Promise<void> {
desktopConnectionSettings = loadDesktopConnectionSettings();

configureAppIdentity();
if (desktopConnectionSettings.mode === "remote") {
backendPort = 0;
backendAuthToken = "";
backendWsUrl = buildDesktopRemoteWsUrl(desktopConnectionSettings);
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
writeDesktopLogHeader(`bootstrap resolved remote websocket url=${backendWsUrl}`);
return;
}

async function bootstrap(): Promise<void> {
writeDesktopLogHeader("bootstrap start");
backendPort = await Effect.service(NetService).pipe(
Effect.flatMap((net) => net.reserveLoopbackPort()),
Effect.provide(NetService.layer),
Expand All @@ -1323,11 +1374,27 @@ async function bootstrap(): Promise<void> {
backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
}

// Override Electron's userData path before the `ready` event so that
// Chromium session data uses a filesystem-friendly directory name.
// Must be called synchronously at the top level — before `app.whenReady()`.
app.setPath("userData", resolveUserDataPath());

configureAppIdentity();

async function bootstrap(): Promise<void> {
writeDesktopLogHeader("bootstrap start");
await resolveDesktopConnection();

registerIpcHandlers();
writeDesktopLogHeader("bootstrap ipc handlers registered");
startBackend();
writeDesktopLogHeader("bootstrap backend start requested");
if (desktopConnectionSettings.mode === "local") {
startBackend();
writeDesktopLogHeader("bootstrap backend start requested");
} else {
writeDesktopLogHeader("bootstrap running in remote connection mode");
}
mainWindow = createWindow();
writeDesktopLogHeader("bootstrap main window created");
}
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ 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 CONNECTION_INFO_CHANNEL = "desktop:connection-info";
const CONNECTION_SETTINGS_GET_CHANNEL = "desktop:connection-settings:get";
const CONNECTION_SETTINGS_SAVE_CHANNEL = "desktop:connection-settings:save";
const RESTART_APP_CHANNEL = "desktop:restart-app";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => wsUrl,
getConnectionInfo: () => ipcRenderer.invoke(CONNECTION_INFO_CHANNEL),
getConnectionSettings: () => ipcRenderer.invoke(CONNECTION_SETTINGS_GET_CHANNEL),
saveConnectionSettings: (settings) => ipcRenderer.invoke(CONNECTION_SETTINGS_SAVE_CHANNEL, settings),
restartApp: () => ipcRenderer.invoke(RESTART_APP_CHANNEL),
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
Expand Down
Loading