diff --git a/app/main/license/deviceId.ts b/app/main/license/deviceId.ts index fb6b09e..0b8440d 100644 --- a/app/main/license/deviceId.ts +++ b/app/main/license/deviceId.ts @@ -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(); } diff --git a/app/main/license/licenseService.ts b/app/main/license/licenseService.ts index 325b492..9a054b1 100644 --- a/app/main/license/licenseService.ts +++ b/app/main/license/licenseService.ts @@ -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 { 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}`); } diff --git a/app/main/license/machineFingerprint.test.ts b/app/main/license/machineFingerprint.test.ts new file mode 100644 index 0000000..24d8f54 --- /dev/null +++ b/app/main/license/machineFingerprint.test.ts @@ -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 + { + "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 }); +}); diff --git a/app/main/license/machineFingerprint.ts b/app/main/license/machineFingerprint.ts new file mode 100644 index 0000000..1580cf3 --- /dev/null +++ b/app/main/license/machineFingerprint.ts @@ -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 + >, +): 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); +} diff --git a/app/main/license/paths.ts b/app/main/license/paths.ts index 89f3262..210479e 100644 --- a/app/main/license/paths.ts +++ b/app/main/license/paths.ts @@ -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'); } diff --git a/app/main/license/verifyLicenseToken.test.ts b/app/main/license/verifyLicenseToken.test.ts index db53b6f..a01f642 100644 --- a/app/main/license/verifyLicenseToken.test.ts +++ b/app/main/license/verifyLicenseToken.test.ts @@ -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'); diff --git a/app/main/license/verifyLicenseToken.ts b/app/main/license/verifyLicenseToken.ts index e8d17fb..438ce76 100644 --- a/app/main/license/verifyLicenseToken.ts +++ b/app/main/license/verifyLicenseToken.ts @@ -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([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]); + if (!accepted.has(payload.did)) { + return { ok: false, reason: 'wrong_device' }; + } } return { ok: true, payload }; diff --git a/app/renderer/legal/eulaRu.ts b/app/renderer/legal/eulaRu.ts index 5816570..671fec3 100644 --- a/app/renderer/legal/eulaRu.ts +++ b/app/renderer/legal/eulaRu.ts @@ -35,7 +35,7 @@ export const EULA_RU_MARKDOWN = ` ## 4. Активация и проверка лицензии -Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор устройства (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub). +Для активации Программа отправляет на сервер лицензирования лицензионный ключ и технический идентификатор физической машины (deviceId). Для проверки отзыва лицензии Программа может отправлять идентификатор лицензии (sub). Программа не отправляет на сервер имя пользователя, адрес электронной почты, содержимое проектов, сцены, изображения, музыку, заметки, кампании или иные пользовательские материалы. diff --git a/app/shared/license/eulaVersion.ts b/app/shared/license/eulaVersion.ts index 46e55bb..06884c8 100644 --- a/app/shared/license/eulaVersion.ts +++ b/app/shared/license/eulaVersion.ts @@ -1,2 +1,2 @@ /** Версия текста EULA; при изменении текста увеличить и запросить повторное принятие. */ -export const EULA_CURRENT_VERSION = 2; +export const EULA_CURRENT_VERSION = 3; diff --git a/docs/licensing-spec.md b/docs/licensing-spec.md index 273f6cc..9a189a4 100644 --- a/docs/licensing-spec.md +++ b/docs/licensing-spec.md @@ -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 diff --git a/package.json b/package.json index d33da38..c021c0a 100644 --- a/package.json +++ b/package.json @@ -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",