d9fbecf5a7
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { machineFingerprintCachePath } from './paths';
|
|
|
|
type ExecFile = (
|
|
file: string,
|
|
args: readonly string[],
|
|
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
|
|
) => string;
|
|
|
|
export type MachineFingerprintDeps = {
|
|
platform?: NodeJS.Platform;
|
|
env?: NodeJS.ProcessEnv;
|
|
/** Electron userData — для дискового кэша hashed fingerprint. */
|
|
userData?: string;
|
|
execFileSync?: ExecFile;
|
|
readFileSync?: (p: string, encoding: 'utf8') => string;
|
|
existsSync?: (p: string) => boolean;
|
|
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
|
|
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
|
|
};
|
|
|
|
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
|
|
let memoryFingerprint: string | null = null;
|
|
|
|
/** Только для тестов. */
|
|
export function clearMachineFingerprintMemoryCache(): void {
|
|
memoryFingerprint = null;
|
|
}
|
|
|
|
function isPlausiblyFingerprint(id: string): boolean {
|
|
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
|
|
}
|
|
|
|
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
|
|
|
|
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
|
|
export function hashMachineRawId(platform: string, rawId: string): string {
|
|
return createHash('sha256')
|
|
.update(HASH_PREFIX, 'utf8')
|
|
.update(platform, 'utf8')
|
|
.update('\0', 'utf8')
|
|
.update(rawId.trim().toLowerCase(), 'utf8')
|
|
.digest('hex');
|
|
}
|
|
|
|
export function parseWindowsMachineGuid(regOutput: string): string | null {
|
|
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
|
|
const id = m?.[1]?.trim();
|
|
return id && id.length >= 8 ? id : null;
|
|
}
|
|
|
|
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
|
|
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
|
|
const id = m?.[1]?.trim();
|
|
return id && id.length >= 8 ? id : null;
|
|
}
|
|
|
|
export function parseWmicUuid(wmicOutput: string): string | null {
|
|
const lines = wmicOutput
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
for (const line of lines) {
|
|
if (/^uuid$/i.test(line)) continue;
|
|
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
|
|
return line;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
|
|
try {
|
|
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readWindowsRawId(exec: ExecFile): string | null {
|
|
const regOut = tryExec(exec, 'reg', [
|
|
'query',
|
|
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
|
|
'/v',
|
|
'MachineGuid',
|
|
]);
|
|
if (regOut) {
|
|
const guid = parseWindowsMachineGuid(regOut);
|
|
if (guid) return guid;
|
|
}
|
|
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
|
|
if (wmicOut) {
|
|
const uuid = parseWmicUuid(wmicOut);
|
|
if (uuid) return uuid;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readDarwinRawId(exec: ExecFile): string | null {
|
|
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
|
|
if (!out) return null;
|
|
return parseMacIOPlatformUUID(out);
|
|
}
|
|
|
|
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
|
|
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
try {
|
|
const id = readFile(p, 'utf8').trim();
|
|
if (id.length >= 8 && !/^0+$/.test(id)) return id;
|
|
} catch {
|
|
/* try next */
|
|
}
|
|
}
|
|
try {
|
|
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
|
|
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
|
|
} catch {
|
|
/* optional, often root-only */
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
|
|
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
|
|
if (platform === 'win32') {
|
|
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
|
|
return path.join(base, 'TTRPGPlayer', 'machine.id');
|
|
}
|
|
if (platform === 'darwin') {
|
|
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
|
|
}
|
|
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
|
|
}
|
|
|
|
function readOrCreateMachineWideFallback(
|
|
platform: NodeJS.Platform,
|
|
env: NodeJS.ProcessEnv,
|
|
deps: Required<
|
|
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
|
|
>,
|
|
): string | null {
|
|
const p = machineWideIdPath(platform, env);
|
|
try {
|
|
if (deps.existsSync(p)) {
|
|
const existing = deps.readFileSync(p, 'utf8').trim();
|
|
if (existing.length >= 8) return existing;
|
|
}
|
|
} catch {
|
|
/* create below */
|
|
}
|
|
const id = createHash('sha256')
|
|
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
|
|
.digest('hex');
|
|
try {
|
|
deps.mkdirSync(path.dirname(p), { recursive: true });
|
|
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
|
|
return id;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readDiskFingerprintCache(
|
|
cacheFile: string,
|
|
readFile: (p: string, encoding: 'utf8') => string,
|
|
existsSync: (p: string) => boolean,
|
|
): string | null {
|
|
try {
|
|
if (!existsSync(cacheFile)) return null;
|
|
const cached = readFile(cacheFile, 'utf8').trim();
|
|
return isPlausiblyFingerprint(cached) ? cached : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeDiskFingerprintCache(
|
|
cacheFile: string,
|
|
fingerprint: string,
|
|
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
|
|
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
|
|
): void {
|
|
try {
|
|
mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
|
|
} catch {
|
|
/* кэш необязателен */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
|
|
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
|
|
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
|
|
*/
|
|
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
|
|
const platform = deps.platform ?? process.platform;
|
|
const env = deps.env ?? process.env;
|
|
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
|
|
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
|
|
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
|
|
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
|
|
fs.mkdirSync(p, opts);
|
|
});
|
|
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
|
|
fs.writeFileSync(p, data, opts);
|
|
});
|
|
|
|
const override = env.DND_LICENSE_DEVICE_ID?.trim();
|
|
if (override && override.length >= 8) {
|
|
memoryFingerprint = override;
|
|
return override;
|
|
}
|
|
|
|
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
|
|
return memoryFingerprint;
|
|
}
|
|
|
|
const userData = deps.userData?.trim();
|
|
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
|
|
if (cacheFile) {
|
|
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
|
|
if (fromDisk) {
|
|
memoryFingerprint = fromDisk;
|
|
return fromDisk;
|
|
}
|
|
}
|
|
|
|
let raw: string | null = null;
|
|
if (platform === 'win32') raw = readWindowsRawId(exec);
|
|
else if (platform === 'darwin') raw = readDarwinRawId(exec);
|
|
else if (platform === 'linux') raw = readLinuxRawId(readFile);
|
|
|
|
if (!raw) {
|
|
raw = readOrCreateMachineWideFallback(platform, env, {
|
|
existsSync,
|
|
readFileSync: readFile,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
});
|
|
}
|
|
|
|
if (!raw) {
|
|
throw new Error(
|
|
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
|
|
);
|
|
}
|
|
|
|
const fingerprint = hashMachineRawId(platform, raw);
|
|
memoryFingerprint = fingerprint;
|
|
if (cacheFile) {
|
|
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
|
|
}
|
|
return fingerprint;
|
|
}
|