186 lines
5.0 KiB
TypeScript
186 lines
5.0 KiB
TypeScript
|
|
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("-");
|
||
|
|
}
|