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 { 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,60 +378,63 @@ function registerBridgeHandlers(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initSingleInstance(() => {
|
const hasSingleInstanceLock = initSingleInstance(() => {
|
||||||
revealMainWindowSafely("single-instance");
|
revealMainWindowSafely("single-instance");
|
||||||
})) {
|
});
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
if (!hasSingleInstanceLock) {
|
||||||
currentWindowMode = readPersistedWindowMode();
|
console.info("[desktop-main] another instance is already running; exiting");
|
||||||
registerBridgeHandlers();
|
app.exit(0);
|
||||||
await initSessionRegistry();
|
} else {
|
||||||
initAccountHealth();
|
app.whenReady().then(async () => {
|
||||||
initTransport();
|
currentWindowMode = readPersistedWindowMode();
|
||||||
initScheduler();
|
registerBridgeHandlers();
|
||||||
initProcessMetricsSampler();
|
await initSessionRegistry();
|
||||||
startHotViewReaper();
|
initAccountHealth();
|
||||||
startHiddenPlaywrightReaper();
|
initTransport();
|
||||||
onRuntimeInvalidated((event) => {
|
initScheduler();
|
||||||
syncDesktopHealthIndicator(currentMainWindow());
|
initProcessMetricsSampler();
|
||||||
if (!mainRendererContents || mainRendererContents.isDestroyed()) {
|
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;
|
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("-");
|
||||||
|
}
|
||||||
@@ -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
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user