feat(desktop): implement workspace foundation + desktop-client skeleton
Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant, thread workspace_id through tenant monitoring quota. Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/ preload/renderer, shared Vue component package (packages/ui-shared), and server surface — desktop client registration + token rotation + heartbeat, SSE task event stream, desktop accounts/tasks/content handlers, publish job endpoint, and supporting repositories, services, sqlc queries, and migrations. Hard cutover per plan: remove browser-extension monitoring callback endpoints, stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { BrowserWindow, WebContentsView, app, ipcMain, nativeTheme } from "electron/main";
|
||||
import type {
|
||||
BrowserWindow as ElectronBrowserWindow,
|
||||
WebContentsView as ElectronWebContentsView,
|
||||
} from "electron/main";
|
||||
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/shared-types";
|
||||
|
||||
import { bindPublishAccount, openPublishAccountConsole } from "./account-binder";
|
||||
import { openTaskReview, refreshRuntimeAccounts, resolveParkedTask, syncRuntimeSession } from "./runtime-controller";
|
||||
import { createRuntimeSnapshot } from "./runtime-snapshot";
|
||||
import { initScheduler } from "./scheduler";
|
||||
import { initSessionRegistry } from "./session-registry";
|
||||
import { initSingleInstance } from "./single-instance";
|
||||
import { initTransport } from "./transport/api-client";
|
||||
import { initTray } from "./tray";
|
||||
import { STANDARD_USER_AGENT } from "./user-agent";
|
||||
|
||||
app.commandLine.appendSwitch(
|
||||
"disable-features",
|
||||
"PostQuantumKyber,EncryptedClientHello,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn",
|
||||
);
|
||||
app.commandLine.appendSwitch("disable-quic");
|
||||
app.commandLine.appendSwitch("disable-background-networking");
|
||||
app.userAgentFallback = STANDARD_USER_AGENT;
|
||||
|
||||
console.info("[desktop-main] boot", {
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
ua: STANDARD_USER_AGENT,
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("[desktop-main] unhandled rejection", reason);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[desktop-main] uncaught exception", error);
|
||||
});
|
||||
|
||||
let mainWindow: ElectronBrowserWindow | null = null;
|
||||
|
||||
function rendererURL(): string | null {
|
||||
return process.env.ELECTRON_RENDERER_URL ?? null;
|
||||
}
|
||||
|
||||
function preloadPath(): string {
|
||||
return join(__dirname, "../preload/bridge.cjs");
|
||||
}
|
||||
|
||||
function syncViewBounds(window: ElectronBrowserWindow, view: ElectronWebContentsView): void {
|
||||
const bounds = window.getContentBounds();
|
||||
view.setBounds({ x: 0, y: 0, width: bounds.width, height: bounds.height });
|
||||
}
|
||||
|
||||
async function mountRendererView(window: ElectronBrowserWindow): Promise<void> {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.contentView.addChildView(view);
|
||||
syncViewBounds(window, view);
|
||||
window.on("resize", () => syncViewBounds(window, view));
|
||||
|
||||
const devServerURL = rendererURL();
|
||||
if (devServerURL) {
|
||||
await view.webContents.loadURL(devServerURL);
|
||||
view.webContents.openDevTools({ mode: "detach" });
|
||||
return;
|
||||
}
|
||||
|
||||
await view.webContents.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<ElectronBrowserWindow> {
|
||||
const window = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
title: "GEO Rankly Desktop",
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea",
|
||||
});
|
||||
await mountRendererView(window);
|
||||
return window;
|
||||
}
|
||||
|
||||
function flattenError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
const copy = new Error(error.message || "ipc_handler_failed");
|
||||
copy.name = error.name;
|
||||
return copy;
|
||||
}
|
||||
return new Error(typeof error === "string" ? error : "ipc_handler_failed");
|
||||
}
|
||||
|
||||
function safeHandle<Args extends unknown[], Result>(
|
||||
channel: string,
|
||||
handler: (...args: Args) => Promise<Result> | Result,
|
||||
): void {
|
||||
ipcMain.handle(channel, async (...args) => {
|
||||
try {
|
||||
return await handler(...(args as unknown as Args));
|
||||
} catch (error) {
|
||||
throw flattenError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerBridgeHandlers(): void {
|
||||
ipcMain.handle("desktop:ping", () => "pong");
|
||||
ipcMain.handle("desktop:runtime-snapshot", () => createRuntimeSnapshot());
|
||||
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
|
||||
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
|
||||
await refreshRuntimeAccounts();
|
||||
return account;
|
||||
});
|
||||
safeHandle(
|
||||
"desktop:open-publish-account-console",
|
||||
async (
|
||||
_event,
|
||||
account: { id: string; platform: string; platformUid: string; displayName: string },
|
||||
) => {
|
||||
await openPublishAccountConsole(account);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
safeHandle("desktop:task-open-review", async (_event, taskId: string) => {
|
||||
await openTaskReview(taskId);
|
||||
return null;
|
||||
});
|
||||
safeHandle(
|
||||
"desktop:task-resolve-parked",
|
||||
async (_event, taskId: string, status: "succeeded" | "failed" | "unknown") => {
|
||||
await resolveParkedTask(taskId, status);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"desktop:runtime-session-sync",
|
||||
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
|
||||
syncRuntimeSession(session);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!initSingleInstance(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
})) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
registerBridgeHandlers();
|
||||
await initSessionRegistry();
|
||||
initTransport();
|
||||
initScheduler();
|
||||
mainWindow = await createMainWindow();
|
||||
initTray(() => {
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error("[desktop-main] app.whenReady failed", error);
|
||||
});
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
mainWindow = await createMainWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user