From 543fe0635f5a226f0233f6d9e4194d549185ce02 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 27 Apr 2026 20:06:37 +0800 Subject: [PATCH] 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) --- apps/desktop-client/src/main/bootstrap.ts | 132 +++++++------ apps/desktop-client/src/main/device-id.ts | 185 ++++++++++++++++++ apps/desktop-client/src/preload/bridge.ts | 8 + .../renderer/composables/useDesktopSession.ts | 135 +++++++------ apps/desktop-client/src/renderer/env.d.ts | 11 +- packages/shared-types/src/index.ts | 2 +- .../tenant/app/desktop_client_service.go | 16 +- 7 files changed, 358 insertions(+), 131 deletions(-) create mode 100644 apps/desktop-client/src/main/device-id.ts diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 91185db..53a05c1 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -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 { async function revealMainWindow(): Promise { 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((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((resolve) => setTimeout(resolve, 1500)), - ]).finally(() => { - app.quit(); - }); -}); +} diff --git a/apps/desktop-client/src/main/device-id.ts b/apps/desktop-client/src/main/device-id.ts new file mode 100644 index 0000000..97e5336 --- /dev/null +++ b/apps/desktop-client/src/main/device-id.ts @@ -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("-"); +} diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index f046c7c..6e31958 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -16,6 +16,12 @@ interface DesktopDeviceInfo { cpu_arch: string; } +interface DesktopClientIDScope { + tenant_id: number; + workspace_id: number; + user_id: number; +} + const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request"; const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response"; @@ -82,6 +88,8 @@ const desktopBridge = { app: { ping: () => ipcRenderer.invoke("desktop:ping") as Promise, deviceInfo: () => ipcRenderer.invoke("desktop:device-info") as Promise, + clientId: (scope: DesktopClientIDScope) => + ipcRenderer.invoke("desktop:client-id", scope) as Promise, runtimeSnapshot: () => ipcRenderer.invoke("desktop:runtime-snapshot") as Promise, bindPublishAccount: (platformId: string) => diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index 39d2c96..6d23dc7 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -25,9 +25,14 @@ interface DesktopSession { desktopClient: DesktopClientInfo | null; } +interface ResolvedDeviceInfo { + device_name: string; + os: string; + cpu_arch: string; +} + const sessionStorageKey = "geo.desktop.session.v1"; const apiBaseURLStorageKey = "geo.desktop.api-base-url"; -const clientRegistrationStoragePrefix = "geo.desktop.client-registration.v1"; const session = shallowRef(readStoredSession()); const apiBaseURL = shallowRef(readStoredApiBaseURL()); @@ -126,47 +131,6 @@ function createAuthenticatedClient() { }); } -function clientRegistrationStorageKey(user: UserInfo): string { - return `${clientRegistrationStoragePrefix}:${user.primary_workspace_id}:${user.id}`; -} - -function readStoredClientRegistrationID(user: UserInfo): string | null { - if (typeof window === "undefined") { - return null; - } - return window.localStorage.getItem(clientRegistrationStorageKey(user)); -} - -function persistClientRegistrationID(user: UserInfo, clientID: string): void { - if (typeof window === "undefined") { - return; - } - window.localStorage.setItem(clientRegistrationStorageKey(user), clientID); -} - -function generateUUID(): string { - if (globalThis.crypto?.randomUUID) { - return globalThis.crypto.randomUUID(); - } - - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { - const random = Math.floor(Math.random() * 16); - const value = char === "x" ? random : (random & 0x3) | 0x8; - return value.toString(16); - }); -} - -function getOrCreateClientRegistrationID(user: UserInfo): string { - const existing = readStoredClientRegistrationID(user)?.trim(); - if (existing) { - return existing; - } - - const generated = generateUUID(); - persistClientRegistrationID(user, generated); - return generated; -} - function resolveFallbackPlatform(): string { const currentNavigator = globalThis.navigator as Navigator & { userAgentData?: { platform?: string }; @@ -202,14 +166,7 @@ function resolveFallbackDeviceName(platform: string): string { return "Current Desktop"; } -async function resolveDeviceInfo(): Promise<{ device_name: string; os: string; cpu_arch: string }> { - const platform = resolveFallbackPlatform(); - const fallback = { - device_name: resolveFallbackDeviceName(platform), - os: resolveFallbackOS(platform), - cpu_arch: "unknown", - }; - +async function resolveDeviceInfo(): Promise { try { const info = await window.desktopBridge?.app?.deviceInfo?.(); if (info?.device_name && info.os && info.cpu_arch) { @@ -219,21 +176,60 @@ async function resolveDeviceInfo(): Promise<{ device_name: string; os: string; c console.warn("[desktop-session] deviceInfo bridge failed", err); } - return fallback; + const platform = resolveFallbackPlatform(); + return { + device_name: resolveFallbackDeviceName(platform), + os: resolveFallbackOS(platform), + cpu_arch: "unknown", + }; +} + +async function resolveDesktopClientID(user: UserInfo): Promise { + const clientID = await window.desktopBridge?.app?.clientId?.({ + tenant_id: user.tenant_id, + workspace_id: user.primary_workspace_id, + user_id: user.id, + }); + if (!clientID) { + throw new Error("无法生成本机账号 client_id,请重启桌面客户端后重试"); + } + return clientID; } async function syncStoredDesktopClientDeviceInfo(): Promise { const current = session.value; - if (typeof window === "undefined" || current?.mode !== "authenticated" || !current.desktopClient) { + if (typeof window === "undefined" || current?.mode !== "authenticated" || !current.desktopClient || !current.user) { return; } - const device = await resolveDeviceInfo(); + const [device, clientID] = await Promise.all([ + resolveDeviceInfo(), + resolveDesktopClientID(current.user), + ]); if (session.value !== current) { return; } const desktopClient = current.desktopClient; + if (desktopClient.id !== clientID) { + try { + const registered = await registerDesktopClient(current.user, device); + const latest = session.value; + if (latest?.mode !== "authenticated" || latest.user?.id !== current.user?.id) { + return; + } + persistSession({ + ...latest, + clientToken: registered.client_token, + clientTokenExpiresAt: registered.expires_at, + desktopClient: registered.client, + }); + } catch (err) { + console.warn("[desktop-session] stable client migration failed", err); + } + return; + } + if ( desktopClient.device_name === device.device_name && desktopClient.os === device.os @@ -253,18 +249,32 @@ async function syncStoredDesktopClientDeviceInfo(): Promise { }); } -async function registerPayload(user: UserInfo): Promise { - const device = await resolveDeviceInfo(); +async function registerPayload( + user: UserInfo, + device?: ResolvedDeviceInfo, +): Promise { + const resolvedDevice = device ?? await resolveDeviceInfo(); return { - client_id: getOrCreateClientRegistrationID(user), - device_name: device.device_name, - os: device.os, - cpu_arch: device.cpu_arch, + client_id: await resolveDesktopClientID(user), + device_name: resolvedDevice.device_name, + os: resolvedDevice.os, + cpu_arch: resolvedDevice.cpu_arch, client_version: "0.1.0-dev", channel: "dev", }; } +async function registerDesktopClient( + user: UserInfo, + device?: ResolvedDeviceInfo, +): Promise { + const resolvedDevice = device ?? await resolveDeviceInfo(); + return createAuthenticatedClient().post< + DesktopClientRegisterResponse, + DesktopClientRegisterRequest + >("/api/desktop/clients/register", await registerPayload(user, resolvedDevice)); +} + function createPreviewUser(): UserInfo { return { id: 0, @@ -345,12 +355,7 @@ async function login(payload: LoginRequest): Promise { desktopClient: null, }); - const registered = await createAuthenticatedClient().post< - DesktopClientRegisterResponse, - DesktopClientRegisterRequest - >("/api/desktop/clients/register", await registerPayload(authData.user)); - - persistClientRegistrationID(authData.user, registered.client.id); + const registered = await registerDesktopClient(authData.user); persistSession({ mode: "authenticated", diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 0caf0d7..3b43b9b 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -15,7 +15,16 @@ declare global { desktopBridge: { app: { ping(): Promise; - deviceInfo(): Promise<{ device_name: string; os: string; cpu_arch: string }>; + deviceInfo(): Promise<{ + device_name: string; + os: string; + cpu_arch: string; + }>; + clientId(scope: { + tenant_id: number; + workspace_id: number; + user_id: number; + }): Promise; runtimeSnapshot(): Promise; bindPublishAccount(platformId: string): Promise; refreshRuntimeAccounts(): Promise; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 033a4a9..86b347b 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -83,7 +83,7 @@ export interface DesktopClientInfo { } export interface DesktopClientRegisterRequest { - client_id?: string; + client_id: string; device_name: string; os: string; cpu_arch?: string; diff --git a/server/internal/tenant/app/desktop_client_service.go b/server/internal/tenant/app/desktop_client_service.go index 0ca7285..4326610 100644 --- a/server/internal/tenant/app/desktop_client_service.go +++ b/server/internal/tenant/app/desktop_client_service.go @@ -36,7 +36,7 @@ func (s *DesktopClientService) WithTaskService(taskSvc *DesktopTaskService) *Des } type RegisterDesktopClientRequest struct { - ClientID string `json:"client_id"` + ClientID string `json:"client_id" binding:"required"` DeviceName string `json:"device_name" binding:"required"` OS string `json:"os" binding:"required"` CPUArch string `json:"cpu_arch"` @@ -107,7 +107,11 @@ func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, r return nil, response.ErrInternal(50071, "desktop_client_token_failed", "failed to generate desktop client token") } - clientID := resolveRegisterClientID(req.ClientID) + clientID, err := parseRegisterClientID(req.ClientID) + if err != nil { + return nil, response.ErrBadRequest(40033, "invalid_desktop_client_id", "desktop client_id must be a non-empty uuid generated by the desktop client") + } + client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{ ID: clientID, TenantID: actor.TenantID, @@ -298,10 +302,10 @@ func parseDesktopAccountIDs(values []string) []uuid.UUID { return accountIDs } -func resolveRegisterClientID(value string) uuid.UUID { +func parseRegisterClientID(value string) (uuid.UUID, error) { parsed, err := uuid.Parse(strings.TrimSpace(value)) - if err == nil && parsed != uuid.Nil { - return parsed + if err != nil || parsed == uuid.Nil { + return uuid.Nil, fmt.Errorf("invalid desktop client id") } - return uuid.New() + return parsed, nil }