fix(license): bind deviceId to physical machine, not OS user

Use OS machine identifiers (Windows MachineGuid, macOS IOPlatformUUID, Linux machine-id) hashed as deviceId so all accounts on one PC share one license slot. Keep legacy userData/device.id for migration and retire it on re-activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-23 10:39:46 +08:00
parent c1c332364c
commit 32a5479086
11 changed files with 399 additions and 24 deletions
+21 -8
View File
@@ -1,18 +1,31 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { resolveMachineFingerprint } from './machineFingerprint';
import { deviceIdPath } from './paths';
export function getOrCreateDeviceId(userData: string): string {
const p = deviceIdPath(userData);
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
export function readLegacyDeviceId(userData: string): string | null {
try {
const existing = fs.readFileSync(p, 'utf8').trim();
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
if (existing.length >= 8) return existing;
} catch {
/* empty */
}
const id = randomUUID();
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(p, `${id}\n`, 'utf8');
return id;
return null;
}
export function clearLegacyDeviceId(userData: string): void {
try {
fs.unlinkSync(deviceIdPath(userData));
} catch {
/* empty */
}
}
/**
* Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
*/
export function getOrCreateDeviceId(_userData?: string): string {
return resolveMachineFingerprint();
}
+32 -8
View File
@@ -10,7 +10,7 @@ import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
@@ -66,12 +66,28 @@ function emitLicenseStatusChanged(): void {
export class LicenseService {
private readonly userData: string;
private readonly deviceId: string;
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
private legacyDeviceId: string | null;
private lastRemoteRevokeCheckMs = 0;
private lastRemoteRevoked = false;
constructor(userData: string) {
this.userData = userData;
this.deviceId = getOrCreateDeviceId(userData);
const legacy = readLegacyDeviceId(userData);
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
}
private verifyOpts(nowSec: number): {
nowSec: number;
deviceId: string;
alsoAcceptDeviceIds?: readonly string[];
} {
return {
nowSec,
deviceId: this.deviceId,
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
};
}
private isSkipLicense(): boolean {
@@ -190,10 +206,17 @@ export class LicenseService {
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
const url = new URL('v1/activate', base);
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
productKey: productKey.trim(),
deviceId: this.deviceId,
};
if (this.legacyDeviceId) {
body.retireDeviceId = this.legacyDeviceId;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const text = await res.text();
@@ -211,6 +234,10 @@ export class LicenseService {
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
if (this.legacyDeviceId) {
clearLegacyDeviceId(this.userData);
this.legacyDeviceId = null;
}
return normalizeLicenseTokenInput(token);
}
@@ -278,7 +305,7 @@ export class LicenseService {
};
}
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
if (!v.ok) {
return {
active: false,
@@ -328,10 +355,7 @@ export class LicenseService {
if (!base.active || !base.summary) return base;
const token = this.readSealedToken();
if (!token?.trim()) return base;
const v = verifyLicenseToken(token, {
nowSec: Math.floor(Date.now() / 1000),
deviceId: this.deviceId,
});
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
if (!v.ok) return this.getStatusSync();
void this.maybeRefreshRemoteRevocation(v.payload);
return this.getStatusSync();
@@ -346,7 +370,7 @@ export class LicenseService {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
if (!v.ok) {
throw new Error(`LICENSE_INVALID:${v.reason}`);
}
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
hashMachineRawId,
machineWideIdPath,
parseMacIOPlatformUUID,
parseWindowsMachineGuid,
parseWmicUuid,
resolveMachineFingerprint,
} from './machineFingerprint';
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
assert.equal(a, b);
assert.equal(a.length, 64);
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
});
void test('parseWindowsMachineGuid', () => {
const out = `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWindowsMachineGuid('nope'), null);
});
void test('parseMacIOPlatformUUID', () => {
const out = `
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
{
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
}
`;
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
});
void test('parseWmicUuid', () => {
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
});
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
const id = resolveMachineFingerprint({
platform: 'linux',
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
});
assert.equal(id, 'override-device-id-12345');
});
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
const exec = () =>
`
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
assert.equal(a, b);
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
});
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
const id = resolveMachineFingerprint({
platform: 'linux',
env: {},
readFileSync: (p) => {
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
throw new Error('enoent');
},
});
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
});
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
const env = { PROGRAMDATA: tmp };
const p = machineWideIdPath('win32', env);
const id1 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
const id2 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
assert.equal(id1, id2);
assert.ok(fs.existsSync(p));
fs.rmSync(tmp, { recursive: true, force: true });
});
+193
View File
@@ -0,0 +1,193 @@
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';
type ExecFile = (
file: string,
args: readonly string[],
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
) => string;
export type MachineFingerprintDeps = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
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;
};
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;
}
}
/**
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
*/
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) return override;
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: не удалось получить идентификатор физической машины',
);
}
return hashMachineRawId(platform, raw);
}
+1
View File
@@ -9,6 +9,7 @@ export function licenseFallbackSealedPath(userData: string): string {
return path.join(userData, 'license.sealed.fallback');
}
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id');
}
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
assert.equal(bad.reason, 'wrong_device');
});
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const payload = {
v: 1 as const,
sub: 'lic_legacy',
pid: 'dnd_player',
iat: 100,
exp: 2_000_000_000,
did: 'legacy-uuid-from-userdata',
};
const body = canonicalJson(payload);
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
const ok = verifyLicenseToken(token, {
nowSec: 1_700_000_000,
deviceId: 'machine-fingerprint-hash',
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
publicKeyOverrideSpkiDerB64: pubB64,
});
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
});
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
+12 -3
View File
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
export function verifyLicenseToken(
token: string,
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
opts: {
nowSec: number;
deviceId: string;
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
alsoAcceptDeviceIds?: readonly string[];
publicKeyOverrideSpkiDerB64?: string;
},
): LicenseVerifyResult {
const parts = splitSignedLicenseToken(token);
if (!parts) return { ok: false, reason: 'malformed' };
@@ -53,8 +59,11 @@ export function verifyLicenseToken(
return { ok: false, reason: 'not_yet_valid' };
}
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
if (payload.did !== null && payload.did !== opts.deviceId) {
return { ok: false, reason: 'wrong_device' };
if (payload.did !== null) {
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
if (!accepted.has(payload.did)) {
return { ok: false, reason: 'wrong_device' };
}
}
return { ok: true, payload };
+1 -1
View File
@@ -35,7 +35,7 @@ export const EULA_RU_MARKDOWN = `
## 4. Активация и проверка лицензии
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор устройства (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор физической машины (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub).
Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы.
+1 -1
View File
@@ -1,2 +1,2 @@
/** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */
export const EULA_CURRENT_VERSION = 2;
export const EULA_CURRENT_VERSION = 3;
+4 -2
View File
@@ -11,7 +11,7 @@
3. **Срок** — поле `exp` (unix секунды) в выданном токене. Клиент отклоняет истёкший токен без сети. На сервере два формата продуктового ключа:
- **fixed** — в записи ключа задан `expiresAtSec`; при активации `exp` копируется из него (старый формат).
- **period** — задан `validDays`; при **первой** активации лицензии (`sub`) сервер фиксирует `activatedAtSec` и выставляет `exp = activatedAtSec + validDays×86400` для всех устройств. Клиенту тип ключа неизвестен — только итоговый `exp` в токене.
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`).
4. **Устройства** — поле `did` в токене: при активации сервер привязывает токен к `deviceId` клиента и ведёт учёт списка устройств на `sub` в `data.json` (`maxDevices`). `deviceId`**отпечаток физической машины** (не пользователь ОС): Windows `MachineGuid`, macOS `IOPlatformUUID`, Linux `/etc/machine-id`; клиент хеширует значение (SHA-256) и шлёт opaque-строку. На одном ПК разные учётки Windows/macOS/Linux получают один и тот же `deviceId`. Опционально `retireDeviceId` в `POST /v1/activate` снимает старый слот (миграция с per-user UUID).
5. **Отзыв** — сервер помечает `sub` в `revokedSubs`. Клиент при наличии `DND_LICENSE_STATUS_URL` запрашивает `GET /v1/status?sub=…`; при `revoked: true` лицензия считается недействительной **без обновления** приложения. Офлайн до истечения `exp` отозванный токен формально криптографически валиден — это осознанный компромисс; при необходимости сокращайте срок жизни токена или добавляйте принудительную онлайн-проверку перед критичными действиями.
## Продакшен-сборка
@@ -20,7 +20,9 @@
## Хранение на клиенте
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства вычисляется из ID машины ОС (см. выше); файл `device.id` в userData — устаревший per-user UUID, читается только для совместимости до повторной активации. Принятие EULA — `preferences.json` (версия текста).
Для тестов/отладки можно задать `DND_LICENSE_DEVICE_ID` (готовая строка deviceId, без fingerprint).
### Linux / WSL без keyring
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"postinstall": "patch-package",