feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
+44
-3
@@ -24,7 +24,11 @@
|
||||
* would shadow every settings-page key change until restart.
|
||||
*/
|
||||
|
||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
|
||||
const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
|
||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
@@ -128,6 +132,11 @@ if (process.argv.includes("--browser-service")) {
|
||||
}
|
||||
|
||||
let serverProcess = null;
|
||||
let chatgpt = null;
|
||||
let chatgptChild = null;
|
||||
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||
let desktopAPISecret = null;
|
||||
const trustedAppWindows = new Set();
|
||||
|
||||
// A GUI shell must never die to a stray exception (EPIPE dialogs, a race
|
||||
// in an event handler): the window + child server are the product, and
|
||||
@@ -201,17 +210,23 @@ function startServer(port) {
|
||||
// (no env injection anymore: a spawn-time copy would shadow every
|
||||
// settings-page key change until restart; env wins over file).
|
||||
LH_AUTH_FILE: AUTH_FILE(),
|
||||
LH_DESKTOP_SERVER: "1",
|
||||
LH_DESKTOP_API_SECRET: desktopAPISecret,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
},
|
||||
);
|
||||
serverProcess = child;
|
||||
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||
child.stdout.on("data", fwd);
|
||||
child.stderr.on("data", fwd);
|
||||
child.on("exit", (code, signal) => {
|
||||
log(`server exited (code=${code} signal=${signal})`);
|
||||
serverProcess = null;
|
||||
chatgptChild?.dispose();
|
||||
chatgptChild = null;
|
||||
chatgpt?.close();
|
||||
});
|
||||
resolve(child);
|
||||
});
|
||||
@@ -235,6 +250,7 @@ function waitUntilUp(url, tries = 150) {
|
||||
}
|
||||
|
||||
function killServer() {
|
||||
chatgpt?.close();
|
||||
if (!serverProcess) return;
|
||||
const child = serverProcess;
|
||||
serverProcess = null;
|
||||
@@ -306,6 +322,8 @@ function createWindow(url) {
|
||||
minHeight: 600,
|
||||
});
|
||||
|
||||
trustedAppWindows.add(win);
|
||||
win.once("closed", () => trustedAppWindows.delete(win));
|
||||
const allowedOrigin = () => new URL(url).origin;
|
||||
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) and
|
||||
// clipboard-sanitized-write — the copy affordances in the transcript
|
||||
@@ -546,6 +564,7 @@ function installMenu() {
|
||||
|
||||
async function main() {
|
||||
app.on("window-all-closed", () => {
|
||||
chatgpt?.pause();
|
||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
@@ -563,6 +582,20 @@ async function main() {
|
||||
const devUrl = process.env.LH_DEV_URL;
|
||||
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
|
||||
if (!devUrl) {
|
||||
desktopAPISecret = randomBytes(32).toString("hex");
|
||||
const ownedOrigin = new URL(url).origin;
|
||||
// All URLs, not only /api: remove the header again on external redirects.
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ["<all_urls>"] }, (details, callback) => {
|
||||
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
||||
});
|
||||
chatgpt = createChatGPT({
|
||||
store: encryptedStore(path.join(app.getPath("userData"), "chatgpt-credentials.enc.json"), safeStorage),
|
||||
openExternal: (target) => shell.openExternal(target),
|
||||
onChange: (value) => {
|
||||
chatgptChild?.send(value);
|
||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
||||
},
|
||||
});
|
||||
log(`starting server: ${serverEntry()}`);
|
||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||
await startServer(new URL(url).port);
|
||||
@@ -572,6 +605,14 @@ async function main() {
|
||||
log(`dev mode: ${devUrl}`);
|
||||
}
|
||||
|
||||
// Renderer controls contain no token/URL/code operation. The separate server
|
||||
// child is the only recipient of credential leases over inherited IPC.
|
||||
ipcMain.handle("lh:chatgpt:control", async (event, command) => {
|
||||
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return { error: "Untrusted desktop request." };
|
||||
if (!chatgpt || !serverProcess?.connected) return { available: false, pending: false, activeId: null, accounts: [], error: "ChatGPT sign-in requires the desktop app's private server connection. Browser-only and externally launched development servers keep API-key connections." };
|
||||
return chatGPTControl(command, chatgpt);
|
||||
});
|
||||
|
||||
installMenu();
|
||||
|
||||
// Auto-update (Wave C): wire the updater before the window so the
|
||||
@@ -597,7 +638,7 @@ async function main() {
|
||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
|
||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
|
||||
});
|
||||
createWindow(url);
|
||||
log(`window open: ${url}`);
|
||||
|
||||
Reference in New Issue
Block a user