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:
@@ -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<DesktopSession | null>(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<ResolvedDeviceInfo> {
|
||||
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<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> {
|
||||
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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function registerPayload(user: UserInfo): Promise<DesktopClientRegisterRequest> {
|
||||
const device = await resolveDeviceInfo();
|
||||
async function registerPayload(
|
||||
user: UserInfo,
|
||||
device?: ResolvedDeviceInfo,
|
||||
): Promise<DesktopClientRegisterRequest> {
|
||||
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<DesktopClientRegisterResponse> {
|
||||
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<void> {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user