feat(desktop-client): derive stable client_id from machine identity
Generate the desktop client_id deterministically from OS machine identifiers (IOPlatformUUID / MachineGuid / /etc/machine-id) scoped per tenant+workspace+user, with a persisted UUID fallback when the OS lookup fails. The renderer now requests the id over a new IPC bridge instead of keeping a localStorage UUID, so reinstalls and storage clears no longer fork into duplicate clients. Server enforces the id as required and rejects empty/malformed UUIDs. Also harden bootstrap reveal flow and single-instance exit so a second launch focuses the existing window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { hostname } from "node:os";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
unbindRuntimeAccount,
|
||||
} from "./runtime-controller";
|
||||
import { createRuntimeSnapshot } from "./runtime-snapshot";
|
||||
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
|
||||
import { initScheduler } from "./scheduler";
|
||||
import { initSessionRegistry } from "./session-registry";
|
||||
import { initSingleInstance } from "./single-instance";
|
||||
@@ -166,7 +166,15 @@ async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
|
||||
|
||||
async function revealMainWindow(): Promise<void> {
|
||||
const window = await ensureMainWindow();
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
applyWindowMode(window, currentWindowMode);
|
||||
if (!window.isVisible()) {
|
||||
window.show();
|
||||
}
|
||||
window.focus();
|
||||
app.focus({ steal: true });
|
||||
}
|
||||
|
||||
function revealMainWindowSafely(source: string): void {
|
||||
@@ -295,11 +303,16 @@ function isSafeExternalUrl(url: string): boolean {
|
||||
|
||||
function registerBridgeHandlers(): void {
|
||||
ipcMain.handle("desktop:ping", () => "pong");
|
||||
ipcMain.handle("desktop:device-info", () => ({
|
||||
device_name: hostname() || `Desktop ${process.platform}`,
|
||||
os: process.platform,
|
||||
cpu_arch: process.arch,
|
||||
}));
|
||||
ipcMain.handle("desktop:device-info", () => resolveDesktopDeviceInfo());
|
||||
safeHandle(
|
||||
"desktop:client-id",
|
||||
(_event, scope: { tenant_id?: unknown; workspace_id?: unknown; user_id?: unknown }) =>
|
||||
resolveDesktopClientID({
|
||||
tenant_id: typeof scope?.tenant_id === "number" ? scope.tenant_id : 0,
|
||||
workspace_id: typeof scope?.workspace_id === "number" ? scope.workspace_id : 0,
|
||||
user_id: typeof scope?.user_id === "number" ? scope.user_id : 0,
|
||||
}),
|
||||
);
|
||||
ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
|
||||
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
|
||||
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
|
||||
@@ -365,60 +378,63 @@ function registerBridgeHandlers(): void {
|
||||
});
|
||||
}
|
||||
|
||||
if (!initSingleInstance(() => {
|
||||
const hasSingleInstanceLock = initSingleInstance(() => {
|
||||
revealMainWindowSafely("single-instance");
|
||||
})) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
currentWindowMode = readPersistedWindowMode();
|
||||
registerBridgeHandlers();
|
||||
await initSessionRegistry();
|
||||
initAccountHealth();
|
||||
initTransport();
|
||||
initScheduler();
|
||||
initProcessMetricsSampler();
|
||||
startHotViewReaper();
|
||||
startHiddenPlaywrightReaper();
|
||||
onRuntimeInvalidated((event) => {
|
||||
syncDesktopHealthIndicator(currentMainWindow());
|
||||
if (!mainRendererContents || mainRendererContents.isDestroyed()) {
|
||||
if (!hasSingleInstanceLock) {
|
||||
console.info("[desktop-main] another instance is already running; exiting");
|
||||
app.exit(0);
|
||||
} else {
|
||||
app.whenReady().then(async () => {
|
||||
currentWindowMode = readPersistedWindowMode();
|
||||
registerBridgeHandlers();
|
||||
await initSessionRegistry();
|
||||
initAccountHealth();
|
||||
initTransport();
|
||||
initScheduler();
|
||||
initProcessMetricsSampler();
|
||||
startHotViewReaper();
|
||||
startHiddenPlaywrightReaper();
|
||||
onRuntimeInvalidated((event) => {
|
||||
syncDesktopHealthIndicator(currentMainWindow());
|
||||
if (!mainRendererContents || mainRendererContents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
mainRendererContents.send("desktop:runtime-invalidated", event);
|
||||
});
|
||||
await ensureMainWindow();
|
||||
initTray(() => {
|
||||
revealMainWindowSafely("tray");
|
||||
});
|
||||
startDesktopHealthIndicator(() => currentMainWindow());
|
||||
}).catch((error) => {
|
||||
console.error("[desktop-main] app.whenReady failed", error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
revealMainWindowSafely("activate");
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitReleaseInFlight) {
|
||||
return;
|
||||
}
|
||||
mainRendererContents.send("desktop:runtime-invalidated", event);
|
||||
|
||||
quitReleaseInFlight = true;
|
||||
event.preventDefault();
|
||||
|
||||
void Promise.race([
|
||||
releaseRuntimeSession(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
||||
]).finally(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
await ensureMainWindow();
|
||||
initTray(() => {
|
||||
revealMainWindowSafely("tray");
|
||||
});
|
||||
startDesktopHealthIndicator(() => currentMainWindow());
|
||||
}).catch((error) => {
|
||||
console.error("[desktop-main] app.whenReady failed", error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
revealMainWindowSafely("activate");
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", (event) => {
|
||||
if (quitReleaseInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
quitReleaseInFlight = true;
|
||||
event.preventDefault();
|
||||
|
||||
void Promise.race([
|
||||
releaseRuntimeSession(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
||||
]).finally(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { hostname } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { app } from "electron/main";
|
||||
|
||||
const STABLE_CLIENT_NAMESPACE = "geo-rankly.desktop-client.v1";
|
||||
const FALLBACK_INSTALLATION_ID_FILE = "machine-id-fallback.json";
|
||||
|
||||
export interface DesktopDeviceInfo {
|
||||
device_name: string;
|
||||
os: string;
|
||||
cpu_arch: string;
|
||||
}
|
||||
|
||||
export interface DesktopClientIDScope {
|
||||
tenant_id: number;
|
||||
workspace_id: number;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export function resolveDesktopDeviceInfo(): DesktopDeviceInfo {
|
||||
return {
|
||||
device_name: hostname() || `Desktop ${process.platform}`,
|
||||
os: process.platform,
|
||||
cpu_arch: process.arch,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDesktopClientID(scope: DesktopClientIDScope): string {
|
||||
const tenantID = normalizePositiveInteger(scope.tenant_id);
|
||||
const workspaceID = normalizePositiveInteger(scope.workspace_id);
|
||||
const userID = normalizePositiveInteger(scope.user_id);
|
||||
if (tenantID === null || workspaceID === null || userID === null) {
|
||||
throw new Error("desktop_client_id_scope_invalid");
|
||||
}
|
||||
|
||||
return uuidFromSeed(
|
||||
`${STABLE_CLIENT_NAMESPACE}:${stableMachineIdentitySeed()}:account:${tenantID}:${workspaceID}:${userID}`,
|
||||
);
|
||||
}
|
||||
|
||||
function stableMachineIdentitySeed(): string {
|
||||
const machineID = resolveOSMachineID();
|
||||
return machineID
|
||||
? `machine:${machineID}`
|
||||
: `installation:${resolvePersistedInstallationID()}`;
|
||||
}
|
||||
|
||||
function resolveOSMachineID(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
return normalizeMachineID(readMacOSPlatformUUID());
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return normalizeMachineID(readWindowsMachineGuid());
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return normalizeMachineID(readLinuxMachineID());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMacOSPlatformUUID(): string | null {
|
||||
try {
|
||||
const output = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
});
|
||||
return output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowsMachineGuid(): string | null {
|
||||
try {
|
||||
const output = execFileSync("reg", [
|
||||
"query",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
|
||||
"/v",
|
||||
"MachineGuid",
|
||||
], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
});
|
||||
return output.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/i)?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLinuxMachineID(): string | null {
|
||||
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
||||
try {
|
||||
if (existsSync(path)) {
|
||||
const value = readFileSync(path, "utf8").trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the next known location.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMachineID(value: string | null): string | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (!normalized || normalized === "unknown") {
|
||||
return null;
|
||||
}
|
||||
return createHash("sha256").update(normalized).digest("hex");
|
||||
}
|
||||
|
||||
function resolvePersistedInstallationID(): string {
|
||||
const existing = readPersistedInstallationID();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const generated = randomUUID();
|
||||
persistInstallationID(generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
function readPersistedInstallationID(): string | null {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(fallbackInstallationIDPath(), "utf8")) as {
|
||||
installation_id?: unknown;
|
||||
};
|
||||
return normalizeInstallationID(data.installation_id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistInstallationID(installationID: string): void {
|
||||
const dir = app.getPath("userData");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(
|
||||
fallbackInstallationIDPath(),
|
||||
JSON.stringify({ installation_id: installationID }),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackInstallationIDPath(): string {
|
||||
return join(app.getPath("userData"), FALLBACK_INSTALLATION_ID_FILE);
|
||||
}
|
||||
|
||||
function normalizeInstallationID(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuidFromSeed(seed: string): string {
|
||||
const chars = createHash("sha256").update(seed).digest("hex").slice(0, 32).split("");
|
||||
chars[12] = "5";
|
||||
chars[16] = (((Number.parseInt(chars[16] ?? "0", 16) || 0) & 0x3) | 0x8).toString(16);
|
||||
|
||||
return [
|
||||
chars.slice(0, 8).join(""),
|
||||
chars.slice(8, 12).join(""),
|
||||
chars.slice(12, 16).join(""),
|
||||
chars.slice(16, 20).join(""),
|
||||
chars.slice(20, 32).join(""),
|
||||
].join("-");
|
||||
}
|
||||
Reference in New Issue
Block a user