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:
2026-04-27 20:06:37 +08:00
parent fee16d3ea9
commit 543fe0635f
7 changed files with 358 additions and 131 deletions
+35 -19
View File
@@ -1,4 +1,3 @@
import { hostname } from "node:os";
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -31,6 +30,7 @@ import {
unbindRuntimeAccount, unbindRuntimeAccount,
} from "./runtime-controller"; } from "./runtime-controller";
import { createRuntimeSnapshot } from "./runtime-snapshot"; import { createRuntimeSnapshot } from "./runtime-snapshot";
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
import { initScheduler } from "./scheduler"; import { initScheduler } from "./scheduler";
import { initSessionRegistry } from "./session-registry"; import { initSessionRegistry } from "./session-registry";
import { initSingleInstance } from "./single-instance"; import { initSingleInstance } from "./single-instance";
@@ -166,7 +166,15 @@ async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
async function revealMainWindow(): Promise<void> { async function revealMainWindow(): Promise<void> {
const window = await ensureMainWindow(); const window = await ensureMainWindow();
if (window.isMinimized()) {
window.restore();
}
applyWindowMode(window, currentWindowMode); applyWindowMode(window, currentWindowMode);
if (!window.isVisible()) {
window.show();
}
window.focus();
app.focus({ steal: true });
} }
function revealMainWindowSafely(source: string): void { function revealMainWindowSafely(source: string): void {
@@ -295,11 +303,16 @@ function isSafeExternalUrl(url: string): boolean {
function registerBridgeHandlers(): void { function registerBridgeHandlers(): void {
ipcMain.handle("desktop:ping", () => "pong"); ipcMain.handle("desktop:ping", () => "pong");
ipcMain.handle("desktop:device-info", () => ({ ipcMain.handle("desktop:device-info", () => resolveDesktopDeviceInfo());
device_name: hostname() || `Desktop ${process.platform}`, safeHandle(
os: process.platform, "desktop:client-id",
cpu_arch: process.arch, (_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()); ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => { safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo; const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
@@ -365,13 +378,15 @@ function registerBridgeHandlers(): void {
}); });
} }
if (!initSingleInstance(() => { const hasSingleInstanceLock = initSingleInstance(() => {
revealMainWindowSafely("single-instance"); revealMainWindowSafely("single-instance");
})) { });
app.quit();
}
app.whenReady().then(async () => { if (!hasSingleInstanceLock) {
console.info("[desktop-main] another instance is already running; exiting");
app.exit(0);
} else {
app.whenReady().then(async () => {
currentWindowMode = readPersistedWindowMode(); currentWindowMode = readPersistedWindowMode();
registerBridgeHandlers(); registerBridgeHandlers();
await initSessionRegistry(); await initSessionRegistry();
@@ -393,21 +408,21 @@ app.whenReady().then(async () => {
revealMainWindowSafely("tray"); revealMainWindowSafely("tray");
}); });
startDesktopHealthIndicator(() => currentMainWindow()); startDesktopHealthIndicator(() => currentMainWindow());
}).catch((error) => { }).catch((error) => {
console.error("[desktop-main] app.whenReady failed", error); console.error("[desktop-main] app.whenReady failed", error);
}); });
app.on("activate", () => { app.on("activate", () => {
revealMainWindowSafely("activate"); revealMainWindowSafely("activate");
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); app.quit();
} }
}); });
app.on("before-quit", (event) => { app.on("before-quit", (event) => {
if (quitReleaseInFlight) { if (quitReleaseInFlight) {
return; return;
} }
@@ -421,4 +436,5 @@ app.on("before-quit", (event) => {
]).finally(() => { ]).finally(() => {
app.quit(); app.quit();
}); });
}); });
}
+185
View File
@@ -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("-");
}
@@ -16,6 +16,12 @@ interface DesktopDeviceInfo {
cpu_arch: string; cpu_arch: string;
} }
interface DesktopClientIDScope {
tenant_id: number;
workspace_id: number;
user_id: number;
}
const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request"; const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request";
const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response"; const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response";
@@ -82,6 +88,8 @@ const desktopBridge = {
app: { app: {
ping: () => ipcRenderer.invoke("desktop:ping") as Promise<string>, ping: () => ipcRenderer.invoke("desktop:ping") as Promise<string>,
deviceInfo: () => ipcRenderer.invoke("desktop:device-info") as Promise<DesktopDeviceInfo>, deviceInfo: () => ipcRenderer.invoke("desktop:device-info") as Promise<DesktopDeviceInfo>,
clientId: (scope: DesktopClientIDScope) =>
ipcRenderer.invoke("desktop:client-id", scope) as Promise<string>,
runtimeSnapshot: () => runtimeSnapshot: () =>
ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>, ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>,
bindPublishAccount: (platformId: string) => bindPublishAccount: (platformId: string) =>
@@ -25,9 +25,14 @@ interface DesktopSession {
desktopClient: DesktopClientInfo | null; desktopClient: DesktopClientInfo | null;
} }
interface ResolvedDeviceInfo {
device_name: string;
os: string;
cpu_arch: string;
}
const sessionStorageKey = "geo.desktop.session.v1"; const sessionStorageKey = "geo.desktop.session.v1";
const apiBaseURLStorageKey = "geo.desktop.api-base-url"; const apiBaseURLStorageKey = "geo.desktop.api-base-url";
const clientRegistrationStoragePrefix = "geo.desktop.client-registration.v1";
const session = shallowRef<DesktopSession | null>(readStoredSession()); const session = shallowRef<DesktopSession | null>(readStoredSession());
const apiBaseURL = shallowRef(readStoredApiBaseURL()); 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 { function resolveFallbackPlatform(): string {
const currentNavigator = globalThis.navigator as Navigator & { const currentNavigator = globalThis.navigator as Navigator & {
userAgentData?: { platform?: string }; userAgentData?: { platform?: string };
@@ -202,14 +166,7 @@ function resolveFallbackDeviceName(platform: string): string {
return "Current Desktop"; return "Current Desktop";
} }
async function resolveDeviceInfo(): Promise<{ device_name: string; os: string; cpu_arch: string }> { async function resolveDeviceInfo(): Promise<ResolvedDeviceInfo> {
const platform = resolveFallbackPlatform();
const fallback = {
device_name: resolveFallbackDeviceName(platform),
os: resolveFallbackOS(platform),
cpu_arch: "unknown",
};
try { try {
const info = await window.desktopBridge?.app?.deviceInfo?.(); const info = await window.desktopBridge?.app?.deviceInfo?.();
if (info?.device_name && info.os && info.cpu_arch) { 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); 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<string> {
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<void> { async function syncStoredDesktopClientDeviceInfo(): Promise<void> {
const current = session.value; 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; return;
} }
const device = await resolveDeviceInfo(); const [device, clientID] = await Promise.all([
resolveDeviceInfo(),
resolveDesktopClientID(current.user),
]);
if (session.value !== current) { if (session.value !== current) {
return; return;
} }
const desktopClient = current.desktopClient; 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 ( if (
desktopClient.device_name === device.device_name desktopClient.device_name === device.device_name
&& desktopClient.os === device.os && desktopClient.os === device.os
@@ -253,18 +249,32 @@ async function syncStoredDesktopClientDeviceInfo(): Promise<void> {
}); });
} }
async function registerPayload(user: UserInfo): Promise<DesktopClientRegisterRequest> { async function registerPayload(
const device = await resolveDeviceInfo(); user: UserInfo,
device?: ResolvedDeviceInfo,
): Promise<DesktopClientRegisterRequest> {
const resolvedDevice = device ?? await resolveDeviceInfo();
return { return {
client_id: getOrCreateClientRegistrationID(user), client_id: await resolveDesktopClientID(user),
device_name: device.device_name, device_name: resolvedDevice.device_name,
os: device.os, os: resolvedDevice.os,
cpu_arch: device.cpu_arch, cpu_arch: resolvedDevice.cpu_arch,
client_version: "0.1.0-dev", client_version: "0.1.0-dev",
channel: "dev", channel: "dev",
}; };
} }
async function registerDesktopClient(
user: UserInfo,
device?: ResolvedDeviceInfo,
): Promise<DesktopClientRegisterResponse> {
const resolvedDevice = device ?? await resolveDeviceInfo();
return createAuthenticatedClient().post<
DesktopClientRegisterResponse,
DesktopClientRegisterRequest
>("/api/desktop/clients/register", await registerPayload(user, resolvedDevice));
}
function createPreviewUser(): UserInfo { function createPreviewUser(): UserInfo {
return { return {
id: 0, id: 0,
@@ -345,12 +355,7 @@ async function login(payload: LoginRequest): Promise<void> {
desktopClient: null, desktopClient: null,
}); });
const registered = await createAuthenticatedClient().post< const registered = await registerDesktopClient(authData.user);
DesktopClientRegisterResponse,
DesktopClientRegisterRequest
>("/api/desktop/clients/register", await registerPayload(authData.user));
persistClientRegistrationID(authData.user, registered.client.id);
persistSession({ persistSession({
mode: "authenticated", mode: "authenticated",
+10 -1
View File
@@ -15,7 +15,16 @@ declare global {
desktopBridge: { desktopBridge: {
app: { app: {
ping(): Promise<string>; ping(): Promise<string>;
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<string>;
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>; runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>; bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
refreshRuntimeAccounts(): Promise<null>; refreshRuntimeAccounts(): Promise<null>;
+1 -1
View File
@@ -83,7 +83,7 @@ export interface DesktopClientInfo {
} }
export interface DesktopClientRegisterRequest { export interface DesktopClientRegisterRequest {
client_id?: string; client_id: string;
device_name: string; device_name: string;
os: string; os: string;
cpu_arch?: string; cpu_arch?: string;
@@ -36,7 +36,7 @@ func (s *DesktopClientService) WithTaskService(taskSvc *DesktopTaskService) *Des
} }
type RegisterDesktopClientRequest struct { type RegisterDesktopClientRequest struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id" binding:"required"`
DeviceName string `json:"device_name" binding:"required"` DeviceName string `json:"device_name" binding:"required"`
OS string `json:"os" binding:"required"` OS string `json:"os" binding:"required"`
CPUArch string `json:"cpu_arch"` 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") 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{ client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{
ID: clientID, ID: clientID,
TenantID: actor.TenantID, TenantID: actor.TenantID,
@@ -298,10 +302,10 @@ func parseDesktopAccountIDs(values []string) []uuid.UUID {
return accountIDs return accountIDs
} }
func resolveRegisterClientID(value string) uuid.UUID { func parseRegisterClientID(value string) (uuid.UUID, error) {
parsed, err := uuid.Parse(strings.TrimSpace(value)) parsed, err := uuid.Parse(strings.TrimSpace(value))
if err == nil && parsed != uuid.Nil { if err != nil || parsed == uuid.Nil {
return parsed return uuid.Nil, fmt.Errorf("invalid desktop client id")
} }
return uuid.New() return parsed, nil
} }