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
175 changes: 93 additions & 82 deletions apps/desktop/src/main/host-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,100 +17,111 @@ import {
LocalGitCredentialProvider,
PskHostAuthProvider,
} from "@superset/host-service";
import {
initTerminalBaseEnv,
resolveTerminalBaseEnv,
} from "@superset/host-service/terminal-env";
import {
HOST_SERVICE_PROTOCOL_VERSION,
removeManifest,
writeManifest,
} from "main/lib/host-service-manifest";

const authToken = process.env.AUTH_TOKEN;
const cloudApiUrl = process.env.CLOUD_API_URL;
const dbPath = process.env.HOST_DB_PATH;
const deviceClientId = process.env.DEVICE_CLIENT_ID;
const deviceName = process.env.DEVICE_NAME;
const hostServiceSecret = process.env.HOST_SERVICE_SECRET;
const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null;
const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION;
const organizationId = process.env.ORGANIZATION_ID ?? "";
const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173";
const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1";
async function main(): Promise<void> {
const terminalBaseEnv = await resolveTerminalBaseEnv();
initTerminalBaseEnv(terminalBaseEnv);

const auth =
authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined;
const hostAuth = hostServiceSecret
? new PskHostAuthProvider(hostServiceSecret)
: undefined;
const authToken = process.env.AUTH_TOKEN;
const cloudApiUrl = process.env.CLOUD_API_URL;
const dbPath = process.env.HOST_DB_PATH;
const deviceClientId = process.env.DEVICE_CLIENT_ID;
const deviceName = process.env.DEVICE_NAME;
const hostServiceSecret = process.env.HOST_SERVICE_SECRET;
const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null;
const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION;
const organizationId = process.env.ORGANIZATION_ID ?? "";
const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173";
const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1";

const { app, injectWebSocket } = createApp({
credentials: new LocalGitCredentialProvider(),
auth,
hostAuth,
cloudApiUrl,
dbPath,
deviceClientId,
deviceName,
serviceVersion,
protocolVersion,
allowedOrigins: [
`http://localhost:${desktopVitePort}`,
`http://127.0.0.1:${desktopVitePort}`,
],
});
const auth =
authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined;
const hostAuth = hostServiceSecret
? new PskHostAuthProvider(hostServiceSecret)
: undefined;

const startedAt = Date.now();
const { app, injectWebSocket } = createApp({
credentials: new LocalGitCredentialProvider(),
auth,
hostAuth,
cloudApiUrl,
dbPath,
deviceClientId,
deviceName,
serviceVersion,
protocolVersion,
allowedOrigins: [
`http://localhost:${desktopVitePort}`,
`http://127.0.0.1:${desktopVitePort}`,
],
});

const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info: { port: number }) => {
if (organizationId) {
try {
writeManifest({
pid: process.pid,
endpoint: `http://127.0.0.1:${info.port}`,
authToken: hostServiceSecret ?? "",
serviceVersion: serviceVersion ?? "",
protocolVersion: protocolVersion ?? 0,
startedAt,
organizationId,
});
} catch (error) {
console.error("[host-service] Failed to write manifest:", error);
const startedAt = Date.now();
const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info: { port: number }) => {
if (organizationId) {
try {
writeManifest({
pid: process.pid,
endpoint: `http://127.0.0.1:${info.port}`,
authToken: hostServiceSecret ?? "",
serviceVersion: serviceVersion ?? "",
protocolVersion: protocolVersion ?? 0,
startedAt,
organizationId,
});
} catch (error) {
console.error("[host-service] Failed to write manifest:", error);
}
}
}
process.send?.({
type: "ready",
port: info.port,
serviceVersion,
protocolVersion,
startedAt,
});
},
);
injectWebSocket(server);
process.send?.({
type: "ready",
port: info.port,
serviceVersion,
protocolVersion,
startedAt,
});
},
);
injectWebSocket(server);

const shutdown = () => {
if (organizationId) {
removeManifest(organizationId);
}
server.close();
process.exit(0);
};
const shutdown = () => {
if (organizationId) {
removeManifest(organizationId);
}
server.close();
process.exit(0);
};

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

// Orphan cleanup: exit if parent Electron process dies.
// Disabled in keep-alive mode so the service survives app quit.
if (!keepAliveAfterParent) {
const parentPid = process.ppid;
const parentCheck = setInterval(() => {
try {
process.kill(parentPid, 0);
} catch {
clearInterval(parentCheck);
console.log("[host-service] Parent process exited, shutting down");
shutdown();
}
}, 2000);
parentCheck.unref();
if (!keepAliveAfterParent) {
const parentPid = process.ppid;
const parentCheck = setInterval(() => {
try {
process.kill(parentPid, 0);
} catch {
clearInterval(parentCheck);
console.log("[host-service] Parent process exited, shutting down");
shutdown();
}
}, 2000);
parentCheck.unref();
}
}

void main().catch((error) => {
console.error("[host-service] Failed to start:", error);
process.exit(1);
});
79 changes: 68 additions & 11 deletions apps/desktop/src/main/lib/host-service-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ class MockChildProcess extends EventEmitter {
}

const getProcessEnvWithShellPathMock = mock(
async (env: Record<string, string>) => env,
async (baseEnv?: NodeJS.ProcessEnv): Promise<Record<string, string>> => ({
...(baseEnv ? (baseEnv as Record<string, string>) : {}),
HOME: "/Users/test",
PATH: "/usr/bin:/bin",
SHELL: "/bin/zsh",
}),
);
let lastChild: MockChildProcess | null = null;
const spawnMock = mock((..._args: unknown[]) => {
Expand All @@ -51,12 +56,9 @@ describe("HostServiceManager", () => {

spyOn(childProcessModule, "spawn").mockImplementation(((..._args) =>
spawnMock(..._args)) as typeof childProcessModule.spawn);
spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation(((
baseEnv: NodeJS.ProcessEnv = process.env,
) =>
getProcessEnvWithShellPathMock(
baseEnv as Record<string, string>,
)) as typeof shellEnvModule.getProcessEnvWithShellPath);
spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation(
(baseEnv) => getProcessEnvWithShellPathMock(baseEnv),
);

mock.module("electron", () => ({
app: {
Expand All @@ -81,7 +83,12 @@ describe("HostServiceManager", () => {
beforeEach(() => {
getProcessEnvWithShellPathMock.mockReset();
getProcessEnvWithShellPathMock.mockImplementation(
async (env: Record<string, string>) => env,
async (baseEnv?: NodeJS.ProcessEnv) => ({
...(baseEnv ? (baseEnv as Record<string, string>) : {}),
HOME: "/Users/test",
PATH: "/usr/bin:/bin",
SHELL: "/bin/zsh",
}),
);
spawnMock.mockReset();
spawnMock.mockImplementation(() => {
Expand All @@ -91,7 +98,7 @@ describe("HostServiceManager", () => {
lastChild = null;
});

it("dedupes concurrent starts while shell PATH is resolving", async () => {
it("dedupes concurrent starts while shell env is resolving", async () => {
const manager = new HostServiceManager();
const pendingEnv = createDeferred<Record<string, string>>();
getProcessEnvWithShellPathMock.mockImplementation(() => pendingEnv.promise);
Expand All @@ -101,11 +108,14 @@ describe("HostServiceManager", () => {

expect(manager.getStatus("org-1")).toBe("starting");

// Flush microtasks so tryAdopt completes (no manifest → falls through to spawn)
await new Promise((resolve) => setTimeout(resolve, 0));
expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1);

pendingEnv.resolve({ PATH: "/usr/bin:/bin" });
pendingEnv.resolve({
HOME: "/Users/test",
PATH: "/usr/bin:/bin",
SHELL: "/bin/zsh",
});
await new Promise((resolve) => setTimeout(resolve, 0));

expect(spawnMock.mock.calls).toHaveLength(1);
Expand All @@ -121,6 +131,53 @@ describe("HostServiceManager", () => {
expect(manager.getPort("org-1")).toBe(4242);
});

it("spawns host-service from shell-path env plus explicit service keys", async () => {
const manager = new HostServiceManager();
manager.setAuthToken("auth-token");
manager.setCloudApiUrl("https://api.example.com");

const originalValues = {
__HOST_SERVICE_RUNTIME_ENV_TEST__:
process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__,
};
process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ = "desktop-runtime";

try {
const startPromise = manager.start("org-1");
await new Promise((resolve) => setTimeout(resolve, 0));

const spawnOptions = spawnMock.mock.calls[0]?.[2] as
| { env?: Record<string, string> }
| undefined;
const env = spawnOptions?.env;

expect(env).toBeDefined();
expect(env).toMatchObject({
HOME: "/Users/test",
PATH: "/usr/bin:/bin",
SHELL: "/bin/zsh",
__HOST_SERVICE_RUNTIME_ENV_TEST__: "desktop-runtime",
AUTH_TOKEN: "auth-token",
CLOUD_API_URL: "https://api.example.com",
ELECTRON_RUN_AS_NODE: "1",
SUPERSET_AGENT_HOOK_VERSION: expect.any(String),
SUPERSET_HOME_DIR: expect.any(String),
});
expect(env?.HOST_SERVICE_SECRET).toEqual(expect.any(String));
expect(env?.HOST_DB_PATH).toEqual(expect.any(String));

lastChild?.emit("message", { type: "ready", port: 4001 });
await startPromise;
} finally {
if (originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__ !== undefined) {
process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__ =
originalValues.__HOST_SERVICE_RUNTIME_ENV_TEST__;
} else {
delete process.env.__HOST_SERVICE_RUNTIME_ENV_TEST__;
}
}
});

it("stopAll() kills all instances", async () => {
const manager = new HostServiceManager();

Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/lib/host-service-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { randomBytes } from "node:crypto";
import { EventEmitter } from "node:events";
import path from "node:path";
import { app } from "electron";
import { env as sharedEnv } from "shared/env.shared";
import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env";
import { SUPERSET_HOME_DIR } from "./app-environment";
import { getDeviceName, getHashedDeviceId } from "./device-info";
import {
HOST_SERVICE_PROTOCOL_VERSION,
Expand All @@ -15,6 +17,7 @@ import {
readManifest,
removeManifest,
} from "./host-service-manifest";
import { HOOK_PROTOCOL_VERSION } from "./terminal/env";

export type HostServiceStatus =
| "starting"
Expand Down Expand Up @@ -123,8 +126,10 @@ async function buildHostServiceEnv(
secret: string,
): Promise<Record<string, string>> {
const orgDir = manifestDir(organizationId);

return getProcessEnvWithShellPath({
...(process.env as Record<string, string>),
// Host-service runtime keys
ELECTRON_RUN_AS_NODE: "1",
ORGANIZATION_ID: organizationId,
DEVICE_CLIENT_ID: getHashedDeviceId(),
Expand All @@ -137,6 +142,10 @@ async function buildHostServiceEnv(
HOST_MIGRATIONS_PATH: app.isPackaged
? path.join(process.resourcesPath, "resources/host-migrations")
: path.join(app.getAppPath(), "../../packages/host-service/drizzle"),
DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT),
SUPERSET_HOME_DIR: SUPERSET_HOME_DIR,
SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT),
SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
TerminalPaneData,
} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types";
import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider";
import { useTheme } from "renderer/stores/theme";
import { resolveTerminalThemeType } from "renderer/stores/theme/utils";
import { useTerminalAppearance } from "./hooks/useTerminalAppearance";

interface TerminalPaneProps {
Expand All @@ -29,13 +31,23 @@ function getConnectionState(terminalId: string): ConnectionState {
export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) {
const { terminalId } = ctx.pane.data as TerminalPaneData;
const containerRef = useRef<HTMLDivElement | null>(null);
const activeTheme = useTheme();

const appearance = useTerminalAppearance();
const appearanceRef = useRef(appearance);
appearanceRef.current = appearance;
const initialThemeTypeRef = useRef<
ReturnType<typeof resolveTerminalThemeType>
>(
resolveTerminalThemeType({
activeThemeType: activeTheme?.type,
}),
);
const initialThemeType = initialThemeTypeRef.current;

const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, {
workspaceId,
themeType: initialThemeType,
});

const connectionState = useSyncExternalStore(
Expand Down
Loading
Loading