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
2 changes: 1 addition & 1 deletion CLAUDE.md
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
"smoke-test": "node scripts/smoke-test.mjs"
},
"dependencies": {
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
"effect": "catalog:",
"electron": "40.6.0",
"electron-updater": "^6.6.2"
"electron-updater": "^6.6.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@t3tools/contracts": "workspace:*",
Expand Down
76 changes: 70 additions & 6 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ import {
ipcMain,
Menu,
nativeImage,
safeStorage,
nativeTheme,
protocol,
shell,
} from "electron";
import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateCheckResult,
DesktopUpdateState,
import * as Schema from "effect/Schema";
import {
VaultSecretDeleteInput,
VaultSecretUpsertInput,
type DesktopTheme,
type DesktopUpdateActionResult,
type DesktopUpdateCheckResult,
type DesktopUpdateState,
type VaultSecretsSnapshot,
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";

Expand All @@ -45,6 +50,7 @@ import {
reduceDesktopUpdateStateOnUpdateAvailable,
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
import { SecretVault } from "./secretVault";

syncShellEnvironment();

Expand All @@ -60,11 +66,18 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const LIST_VAULT_SECRETS_CHANNEL = "desktop:vault:list-secrets";
const SAVE_VAULT_SECRET_CHANNEL = "desktop:vault:save-secret";
const DELETE_VAULT_SECRET_CHANNEL = "desktop:vault:delete-secret";
const VAULT_SECRETS_CHANNEL = "desktop:vault:updated";
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";
const ROOT_DIR = Path.resolve(__dirname, "../../..");
const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
const SERVER_STATE_DIR_NAME = isDevelopment ? "dev" : "userdata";
const STATE_DIR = Path.join(BASE_DIR, SERVER_STATE_DIR_NAME);
const SECRET_VAULT_PATH = Path.join(STATE_DIR, "vault.json");
const LEGACY_SECRET_VAULT_PATH = Path.join(STATE_DIR, "managed-connectors.vault.json");
const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
const APP_USER_MODEL_ID = "com.t3tools.t3code";
const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
Expand Down Expand Up @@ -102,6 +115,7 @@ let desktopLogSink: RotatingFileSink | null = null;
let backendLogSink: RotatingFileSink | null = null;
let restoreStdIoCapture: (() => void) | null = null;
let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
let secretVault: SecretVault | null = null;

let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
Expand Down Expand Up @@ -151,6 +165,28 @@ function backendChildEnv(): NodeJS.ProcessEnv {
return env;
}

function getSecretVault(): SecretVault {
if (secretVault) {
return secretVault;
}
throw new Error("Secret vault is not ready.");
}

function getVaultSecretsSnapshot(): VaultSecretsSnapshot {
return getSecretVault().listNamedSecrets();
}

function emitVaultSecretsSnapshot(
snapshot: VaultSecretsSnapshot = getVaultSecretsSnapshot(),
): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue;
}
window.webContents.send(VAULT_SECRETS_CHANNEL, snapshot);
}
}

function writeDesktopLogHeader(message: string): void {
if (!desktopLogSink) return;
desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
Expand Down Expand Up @@ -1328,6 +1364,25 @@ function registerIpcHandlers(): void {
state: updateState,
} satisfies DesktopUpdateCheckResult;
});

ipcMain.removeHandler(LIST_VAULT_SECRETS_CHANNEL);
ipcMain.handle(LIST_VAULT_SECRETS_CHANNEL, async () => getVaultSecretsSnapshot());

ipcMain.removeHandler(SAVE_VAULT_SECRET_CHANNEL);
ipcMain.handle(SAVE_VAULT_SECRET_CHANNEL, async (_event, rawInput: unknown) => {
const input = Schema.decodeUnknownSync(VaultSecretUpsertInput)(rawInput);
const snapshot = getSecretVault().saveNamedSecret(input);
emitVaultSecretsSnapshot(snapshot);
return snapshot;
});

ipcMain.removeHandler(DELETE_VAULT_SECRET_CHANNEL);
ipcMain.handle(DELETE_VAULT_SECRET_CHANNEL, async (_event, rawInput: unknown) => {
const input = Schema.decodeUnknownSync(VaultSecretDeleteInput)(rawInput);
const snapshot = getSecretVault().deleteNamedSecret(input);
emitVaultSecretsSnapshot(snapshot);
return snapshot;
});
}

function getIconOption(): { icon: string } | Record<string, never> {
Expand Down Expand Up @@ -1441,6 +1496,15 @@ async function bootstrap(): Promise<void> {
backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);

secretVault = new SecretVault({
vaultPath: SECRET_VAULT_PATH,
legacyVaultPaths: [LEGACY_SECRET_VAULT_PATH],
safeStorage,
});
secretVault.onDidChange(() => {
emitVaultSecretsSnapshot();
});

registerIpcHandlers();
writeDesktopLogHeader("bootstrap ipc handlers registered");
startBackend();
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const LIST_VAULT_SECRETS_CHANNEL = "desktop:vault:list-secrets";
const SAVE_VAULT_SECRET_CHANNEL = "desktop:vault:save-secret";
const DELETE_VAULT_SECRET_CHANNEL = "desktop:vault:delete-secret";
const VAULT_SECRETS_CHANNEL = "desktop:vault:updated";

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => {
Expand All @@ -39,6 +43,22 @@ contextBridge.exposeInMainWorld("desktopBridge", {
checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
listVaultSecrets: () => ipcRenderer.invoke(LIST_VAULT_SECRETS_CHANNEL),
saveVaultSecret: (input) => ipcRenderer.invoke(SAVE_VAULT_SECRET_CHANNEL, input),
deleteVaultSecret: (input) => ipcRenderer.invoke(DELETE_VAULT_SECRET_CHANNEL, input),
subscribeVaultSecrets: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, snapshot: unknown) => {
if (typeof snapshot !== "object" || snapshot === null) {
return;
}
listener(snapshot as Parameters<typeof listener>[0]);
};

ipcRenderer.on(VAULT_SECRETS_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(VAULT_SECRETS_CHANNEL, wrappedListener);
};
},
onUpdateState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (typeof state !== "object" || state === null) return;
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop/src/secretVault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";

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

import { SecretVault, type SafeStorageLike } from "./secretVault";

const fakeSafeStorage: SafeStorageLike = {
isEncryptionAvailable: () => true,
encryptString: (value) => Buffer.from(`encrypted:${value}`, "utf8"),
decryptString: (value) => {
const decoded = value.toString("utf8");
if (!decoded.startsWith("encrypted:")) {
throw new Error("Unexpected ciphertext");
}
return decoded.slice("encrypted:".length);
},
};

describe("SecretVault", () => {
it("persists encrypted named secrets without exposing the raw value in snapshots", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-secret-vault-"));
const vaultPath = path.join(tempDir, "vault.json");

try {
const vault = new SecretVault({
vaultPath,
safeStorage: fakeSafeStorage,
});

const snapshot = vault.saveNamedSecret({
key: "my stripe api key",
value: "sk-live-123",
});
const persisted = readFileSync(vaultPath, "utf8");

expect(snapshot.secrets).toEqual([
expect.objectContaining({
key: "my stripe api key",
}),
]);
expect(persisted).not.toContain("sk-live-123");
expect(persisted).toContain(Buffer.from("encrypted:sk-live-123", "utf8").toString("base64"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it("deletes named secrets from the vault snapshot", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-secret-vault-"));
const vaultPath = path.join(tempDir, "vault.json");

try {
const vault = new SecretVault({
vaultPath,
safeStorage: fakeSafeStorage,
});

const savedSnapshot = vault.saveNamedSecret({
key: "my stripe api key",
value: "sk-live-123",
});
const secretId = savedSnapshot.secrets[0]?.id;
expect(secretId).toBeDefined();

const nextSnapshot = vault.deleteNamedSecret({
id: secretId!,
});

expect(nextSnapshot.secrets).toEqual([]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
Loading
Loading