diff --git a/app/main/index.ts b/app/main/index.ts index add6e00..281df81 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -3,7 +3,8 @@ import { app, BrowserWindow, dialog, Menu, protocol } from 'electron'; import { ipcChannels, type SessionState } from '../shared/ipc/contracts'; import { EffectsStore } from './effects/effectsStore'; -import { installIpcRouter, registerHandler } from './ipc/router'; +import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router'; +import { LicenseService } from './license/licenseService'; import { ZipProjectStore } from './project/zipStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; @@ -90,6 +91,10 @@ function emitSessionState(): void { async function main() { await app.whenReady(); + const licenseService = new LicenseService(app.getPath('userData')); + setLicenseAssert(() => { + licenseService.assertForIpc(); + }); Menu.setApplicationMenu(null); registerDndAssetProtocol(projectStore); registerHandler(ipcChannels.app.quit, () => { @@ -101,6 +106,10 @@ async function main() { version: getAppSemanticVersion(), buildNumber: getOptionalBuildNumber(), })); + registerHandler(ipcChannels.license.getStatus, () => licenseService.getStatus()); + registerHandler(ipcChannels.license.setToken, ({ token }) => licenseService.setToken(token)); + registerHandler(ipcChannels.license.clearToken, () => licenseService.clearToken()); + registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version)); registerHandler(ipcChannels.windows.openMultiWindow, () => { openMultiWindow(); return { ok: true }; diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts index dc89cfe..8507901 100644 --- a/app/main/ipc/router.ts +++ b/app/main/ipc/router.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron'; -import type { IpcInvokeMap } from '../../shared/ipc/contracts'; +import { ipcChannels, type IpcInvokeMap } from '../../shared/ipc/contracts'; type Handler = ( payload: IpcInvokeMap[K]['req'], @@ -8,11 +8,33 @@ type Handler = ( const handlers = new Map Promise>(); -export function registerHandler(channel: K, handler: Handler) { - handlers.set(channel as string, async (payload: unknown) => handler(payload as IpcInvokeMap[K]['req'])); +let licenseAssert: (() => void) | undefined; + +export function setLicenseAssert(fn: () => void): void { + licenseAssert = fn; } -export function installIpcRouter() { +function channelRequiresLicense(channel: string): boolean { + if (channel.startsWith('license.')) return false; + if (channel.startsWith('app.')) return false; + if (channel === ipcChannels.windows.closeMultiWindow) return false; + if (channel === ipcChannels.windows.togglePresentationFullscreen) return false; + return true; +} + +export function registerHandler(channel: K, handler: Handler) { + const channelStr = channel as string; + const wrap = channelRequiresLicense(channelStr); + const inner = async (payload: unknown) => { + if (wrap) { + licenseAssert?.(); + } + return handler(payload as IpcInvokeMap[K]['req']); + }; + handlers.set(channelStr, inner); +} + +export function installIpcRouter(): void { for (const [channel, handler] of handlers.entries()) { ipcMain.handle(channel, async (_event, payload: unknown) => handler(payload)); } diff --git a/app/main/license/deviceId.ts b/app/main/license/deviceId.ts new file mode 100644 index 0000000..fb6b09e --- /dev/null +++ b/app/main/license/deviceId.ts @@ -0,0 +1,18 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; + +import { deviceIdPath } from './paths'; + +export function getOrCreateDeviceId(userData: string): string { + const p = deviceIdPath(userData); + try { + const existing = fs.readFileSync(p, '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; +} diff --git a/app/main/license/licenseService.ts b/app/main/license/licenseService.ts new file mode 100644 index 0000000..f55712e --- /dev/null +++ b/app/main/license/licenseService.ts @@ -0,0 +1,235 @@ +import fs from 'node:fs'; + +import { BrowserWindow, safeStorage } from 'electron'; + +import { ipcChannels } from '../../shared/ipc/contracts'; +import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; +import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; +import type { LicensePayloadV1 } from '../../shared/license/payloadV1'; + +import { getOrCreateDeviceId } from './deviceId'; +import { licenseEncryptedPath, preferencesPath } from './paths'; +import { verifyLicenseToken } from './verifyLicenseToken'; + +type Preferences = { + eulaAcceptedVersion?: number; +}; + +function readPreferences(userData: string): Preferences { + try { + const raw = fs.readFileSync(preferencesPath(userData), 'utf8'); + return JSON.parse(raw) as Preferences; + } catch { + return {}; + } +} + +function writePreferences(userData: string, prefs: Preferences): void { + fs.mkdirSync(userData, { recursive: true }); + fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8'); +} + +function emitLicenseStatusChanged(): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(ipcChannels.license.statusChanged, {}); + } +} + +export class LicenseService { + private readonly userData: string; + private readonly deviceId: string; + private lastRemoteRevokeCheckMs = 0; + private lastRemoteRevoked = false; + + constructor(userData: string) { + this.userData = userData; + this.deviceId = getOrCreateDeviceId(userData); + } + + private isSkipLicense(): boolean { + return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true'; + } + + private readSealedToken(): string | null { + const p = licenseEncryptedPath(this.userData); + if (!fs.existsSync(p)) return null; + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе'); + } + const buf = fs.readFileSync(p); + return safeStorage.decryptString(buf); + } + + private writeSealedToken(token: string): void { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе'); + } + fs.mkdirSync(this.userData, { recursive: true }); + const enc = safeStorage.encryptString(token); + fs.writeFileSync(licenseEncryptedPath(this.userData), enc); + } + + private clearSealedTokenFile(): void { + try { + fs.unlinkSync(licenseEncryptedPath(this.userData)); + } catch { + /* ok */ + } + } + + private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise { + const base = process.env.DND_LICENSE_STATUS_URL?.trim(); + if (!base) return; + const now = Date.now(); + if (now - this.lastRemoteRevokeCheckMs < 60_000) return; + this.lastRemoteRevokeCheckMs = now; + try { + const u = new URL('v1/status', base.endsWith('/') ? base : `${base}/`); + u.searchParams.set('sub', payload.sub); + const res = await fetch(u, { method: 'GET', signal: AbortSignal.timeout(8000) }); + if (!res.ok) return; + const j = (await res.json()) as { revoked?: boolean }; + this.lastRemoteRevoked = Boolean(j.revoked); + } catch { + /* offline: не блокируем */ + } + } + + getStatusSync(): LicenseSnapshot { + if (this.isSkipLicense()) { + return { + active: true, + devSkip: true, + reason: 'ok', + summary: null, + eulaAcceptedVersion: readPreferences(this.userData).eulaAcceptedVersion ?? null, + deviceId: this.deviceId, + }; + } + + const eulaAcceptedVersion = readPreferences(this.userData).eulaAcceptedVersion ?? null; + const nowSec = Math.floor(Date.now() / 1000); + let token: string | null = null; + try { + token = this.readSealedToken(); + } catch { + return { + active: false, + devSkip: false, + reason: 'none', + summary: null, + eulaAcceptedVersion, + deviceId: this.deviceId, + }; + } + if (!token?.trim()) { + return { + active: false, + devSkip: false, + reason: 'none', + summary: null, + eulaAcceptedVersion, + deviceId: this.deviceId, + }; + } + + const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId }); + if (!v.ok) { + return { + active: false, + devSkip: false, + reason: v.reason, + summary: null, + eulaAcceptedVersion, + deviceId: this.deviceId, + }; + } + + if (this.lastRemoteRevoked) { + return { + active: false, + devSkip: false, + reason: 'revoked_remote', + summary: { + sub: v.payload.sub, + pid: v.payload.pid, + exp: v.payload.exp, + did: v.payload.did, + }, + eulaAcceptedVersion, + deviceId: this.deviceId, + }; + } + + return { + active: true, + devSkip: false, + reason: 'ok', + summary: { + sub: v.payload.sub, + pid: v.payload.pid, + exp: v.payload.exp, + did: v.payload.did, + }, + eulaAcceptedVersion, + deviceId: this.deviceId, + }; + } + + async getStatus(): Promise { + if (this.isSkipLicense()) return this.getStatusSync(); + const base = this.getStatusSync(); + 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, + }); + if (!v.ok) return this.getStatusSync(); + await this.maybeRefreshRemoteRevocation(v.payload); + return this.getStatusSync(); + } + + setToken(token: string): LicenseSnapshot { + if (this.isSkipLicense()) { + return this.getStatusSync(); + } + const trimmed = token.trim(); + const nowSec = Math.floor(Date.now() / 1000); + const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId }); + if (!v.ok) { + throw new Error(`LICENSE_INVALID:${v.reason}`); + } + this.writeSealedToken(trimmed); + this.lastRemoteRevoked = false; + emitLicenseStatusChanged(); + return this.getStatusSync(); + } + + clearToken(): LicenseSnapshot { + this.clearSealedTokenFile(); + this.lastRemoteRevoked = false; + emitLicenseStatusChanged(); + return this.getStatusSync(); + } + + acceptEula(version: number): { ok: true } { + if (version !== EULA_CURRENT_VERSION) { + throw new Error('EULA_BAD_VERSION'); + } + const prefs = readPreferences(this.userData); + prefs.eulaAcceptedVersion = version; + writePreferences(this.userData, prefs); + emitLicenseStatusChanged(); + return { ok: true }; + } + + assertForIpc(): void { + if (this.isSkipLicense()) return; + const s = this.getStatusSync(); + if (!s.active) { + throw new Error(`LICENSE_REQUIRED:${s.reason}`); + } + } +} diff --git a/app/main/license/paths.ts b/app/main/license/paths.ts new file mode 100644 index 0000000..47e0fc2 --- /dev/null +++ b/app/main/license/paths.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; + +export function licenseEncryptedPath(userData: string): string { + return path.join(userData, 'license.sealed'); +} + +export function deviceIdPath(userData: string): string { + return path.join(userData, 'device.id'); +} + +export function preferencesPath(userData: string): string { + return path.join(userData, 'preferences.json'); +} diff --git a/app/main/license/verifyLicenseToken.test.ts b/app/main/license/verifyLicenseToken.test.ts new file mode 100644 index 0000000..f44bcba --- /dev/null +++ b/app/main/license/verifyLicenseToken.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import test from 'node:test'; + +import { canonicalJson } from '../../shared/license/canonicalJson'; +import { joinSignedLicenseToken } from '../../shared/license/tokenFormat'; + +import { verifyLicenseToken } from './verifyLicenseToken'; + +void test('verifyLicenseToken: валидная подпись и срок', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + const licensePayload = { + v: 1 as const, + sub: 'lic_test_1', + pid: 'dnd_player', + iat: 100, + exp: 2_000_000_000, + did: null as string | null, + }; + const body = canonicalJson(licensePayload); + 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: 'any', + publicKeyOverrideSpkiDerB64: pubB64, + }); + if (!ok.ok) assert.fail('expected ok'); + assert.equal(ok.payload.sub, 'lic_test_1'); +}); + +void test('verifyLicenseToken: неверное устройство', () => { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + const payload = { + v: 1 as const, + sub: 'lic_test_2', + pid: 'dnd_player', + iat: 100, + exp: 2_000_000_000, + did: 'device-a', + }; + 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 bad = verifyLicenseToken(token, { + nowSec: 1_700_000_000, + deviceId: 'device-b', + publicKeyOverrideSpkiDerB64: pubB64, + }); + if (bad.ok) assert.fail('expected failure'); + assert.equal(bad.reason, 'wrong_device'); +}); diff --git a/app/main/license/verifyLicenseToken.ts b/app/main/license/verifyLicenseToken.ts new file mode 100644 index 0000000..e8d17fb --- /dev/null +++ b/app/main/license/verifyLicenseToken.ts @@ -0,0 +1,61 @@ +import { createPublicKey, verify as cryptoVerify } from 'node:crypto'; + +import { LICENSE_ED25519_SPKI_DER_B64 } from '../../shared/license/bundledPublicKey'; +import { canonicalJson } from '../../shared/license/canonicalJson'; +import { isLicensePayloadV1, type LicensePayloadV1 } from '../../shared/license/payloadV1'; +import { splitSignedLicenseToken } from '../../shared/license/tokenFormat'; +import type { LicenseVerifyFailure } from '../../shared/license/verifyReasons'; + +export type LicenseVerifyResult = + | { ok: true; payload: LicensePayloadV1 } + | { ok: false; reason: LicenseVerifyFailure }; + +let cachedPublicKey: ReturnType | null = null; + +function getBundledPublicKey() { + cachedPublicKey ??= createPublicKey({ + key: Buffer.from(LICENSE_ED25519_SPKI_DER_B64, 'base64'), + format: 'der', + type: 'spki', + }); + return cachedPublicKey; +} + +export function verifyLicenseToken( + token: string, + opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string }, +): LicenseVerifyResult { + const parts = splitSignedLicenseToken(token); + if (!parts) return { ok: false, reason: 'malformed' }; + + let payload: unknown; + try { + payload = JSON.parse(parts.bodyUtf8) as unknown; + } catch { + return { ok: false, reason: 'bad_payload' }; + } + if (!isLicensePayloadV1(payload)) return { ok: false, reason: 'bad_payload' }; + + const body = canonicalJson(payload); + const msg = Buffer.from(body, 'utf8'); + const publicKey = + opts.publicKeyOverrideSpkiDerB64 !== undefined + ? createPublicKey({ + key: Buffer.from(opts.publicKeyOverrideSpkiDerB64, 'base64'), + format: 'der', + type: 'spki', + }) + : getBundledPublicKey(); + const okSig = cryptoVerify(null, msg, publicKey, Buffer.from(parts.signature)); + if (!okSig) return { ok: false, reason: 'bad_signature' }; + + if (payload.nbf !== undefined && opts.nowSec < payload.nbf) { + 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' }; + } + + return { ok: true, payload }; +} diff --git a/app/main/project/yauzlProjectZip.ts b/app/main/project/yauzlProjectZip.ts new file mode 100644 index 0000000..97964b8 --- /dev/null +++ b/app/main/project/yauzlProjectZip.ts @@ -0,0 +1,123 @@ +import fssync from 'node:fs'; +import path from 'node:path'; + +import yauzl from 'yauzl'; + +import type { Project } from '../../shared/types'; + +export function unzipToDir(zipPath: string, outDir: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) return reject(err); + const zipFile = zip; + let settled = false; + + const safeClose = (): void => { + try { + zipFile.close(); + } catch { + /* ignore */ + } + }; + + const finishOk = (): void => { + if (settled) return; + settled = true; + safeClose(); + resolve(); + }; + + const finishErr = (e: unknown): void => { + if (settled) return; + settled = true; + safeClose(); + reject(e instanceof Error ? e : new Error(String(e))); + }; + + zipFile.readEntry(); + zipFile.on('entry', (entry: yauzl.Entry) => { + if (settled) return; + const filePath = path.join(outDir, entry.fileName); + if (entry.fileName.endsWith('/')) { + fssync.mkdirSync(filePath, { recursive: true }); + zipFile.readEntry(); + return; + } + fssync.mkdirSync(path.dirname(filePath), { recursive: true }); + zipFile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr) return finishErr(streamErr); + readStream.on('error', finishErr); + const writeStream = fssync.createWriteStream(filePath); + writeStream.on('error', finishErr); + readStream.pipe(writeStream); + writeStream.on('close', () => { + if (!settled) zipFile.readEntry(); + }); + }); + }); + zipFile.on('end', () => finishOk()); + zipFile.on('error', finishErr); + }); + }); +} + +/** Читает `project.json` из zip; всегда закрывает дескриптор yauzl (иначе на Windows возможен EMFILE). */ +export async function readProjectJsonFromZip(zipPath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { + if (err) return reject(err); + const zipFile = zip; + let settled = false; + + const safeClose = (): void => { + try { + zipFile.close(); + } catch { + /* ignore */ + } + }; + + const finishOk = (project: Project): void => { + if (settled) return; + settled = true; + safeClose(); + resolve(project); + }; + + const finishErr = (e: unknown): void => { + if (settled) return; + settled = true; + safeClose(); + reject(e instanceof Error ? e : new Error(String(e))); + }; + + zipFile.readEntry(); + zipFile.on('entry', (entry: yauzl.Entry) => { + if (settled) return; + if (entry.fileName !== 'project.json') { + zipFile.readEntry(); + return; + } + zipFile.openReadStream(entry, (streamErr, readStream) => { + if (streamErr) return finishErr(streamErr); + const chunks: Buffer[] = []; + readStream.on('data', (c: Buffer) => chunks.push(c)); + readStream.on('error', finishErr); + readStream.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + const parsed = JSON.parse(raw) as unknown as Project; + finishOk(parsed); + } catch (e) { + finishErr(e instanceof Error ? e : new Error('Failed to parse project.json')); + } + }); + }); + }); + zipFile.on('error', finishErr); + zipFile.on('end', () => { + finishErr(new Error('project.json not found in zip')); + }); + }); + }); +} diff --git a/app/main/project/zipRead.test.ts b/app/main/project/zipRead.test.ts new file mode 100644 index 0000000..c118501 --- /dev/null +++ b/app/main/project/zipRead.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fssync from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { ZipFile } from 'yazl'; + +import { PROJECT_SCHEMA_VERSION } from '../../shared/types'; + +import { readProjectJsonFromZip } from './yauzlProjectZip'; + +void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-')); + const zipPath = path.join(tmp, 'test.dnd.zip'); + const minimal = { + id: 'p1', + meta: { + name: 'n', + fileBaseName: 'f', + createdAt: '2020-01-01', + updatedAt: '2020-01-01', + createdWithAppVersion: '1', + appVersion: '1', + schemaVersion: PROJECT_SCHEMA_VERSION, + }, + scenes: {}, + assets: {}, + currentSceneId: null, + currentGraphNodeId: null, + sceneGraphNodes: [], + sceneGraphEdges: [], + }; + + const zipfile = new ZipFile(); + zipfile.addBuffer(Buffer.from(JSON.stringify(minimal), 'utf8'), 'project.json', { compressionLevel: 9 }); + const out = fssync.createWriteStream(zipPath); + const done = new Promise((resolve, reject) => { + out.on('close', resolve); + out.on('error', reject); + }); + zipfile.outputStream.pipe(out); + zipfile.end(); + await done; + + for (let i = 0; i < 150; i += 1) { + const p = await readProjectJsonFromZip(zipPath); + assert.equal(p.id, 'p1'); + } + + await fs.rm(tmp, { recursive: true, force: true }); +}); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 9ad5dc2..dbeb520 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -4,7 +4,6 @@ import fs from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import yauzl from 'yauzl'; import { ZipFile } from 'yazl'; import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; @@ -26,6 +25,7 @@ import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; +import { readProjectJsonFromZip, unzipToDir } from './yauzlProjectZip'; type ProjectIndexEntry = { id: ProjectId; @@ -1096,68 +1096,6 @@ async function atomicWriteFile(filePath: string, contents: string): Promise { - return new Promise((resolve, reject) => { - yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { - if (err) return reject(err); - const zipFile = zip; - zipFile.readEntry(); - zipFile.on('entry', (entry: yauzl.Entry) => { - const filePath = path.join(outDir, entry.fileName); - if (entry.fileName.endsWith('/')) { - fssync.mkdirSync(filePath, { recursive: true }); - zipFile.readEntry(); - return; - } - fssync.mkdirSync(path.dirname(filePath), { recursive: true }); - zipFile.openReadStream(entry, (streamErr, readStream) => { - if (streamErr) return reject(streamErr); - readStream.on('error', reject); - const writeStream = fssync.createWriteStream(filePath); - writeStream.on('error', reject); - readStream.pipe(writeStream); - writeStream.on('close', () => zipFile.readEntry()); - }); - }); - zipFile.on('end', resolve); - zipFile.on('error', reject); - }); - }); -} - -async function readProjectJsonFromZip(zipPath: string): Promise { - return new Promise((resolve, reject) => { - yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => { - if (err) return reject(err); - const zipFile = zip; - zipFile.readEntry(); - zipFile.on('entry', (entry: yauzl.Entry) => { - if (entry.fileName !== 'project.json') { - zipFile.readEntry(); - return; - } - zipFile.openReadStream(entry, (streamErr, readStream) => { - if (streamErr) return reject(streamErr); - const chunks: Buffer[] = []; - readStream.on('data', (c: Buffer) => chunks.push(c)); - readStream.on('error', reject); - readStream.on('end', () => { - try { - const raw = Buffer.concat(chunks).toString('utf8'); - const parsed = JSON.parse(raw) as unknown as Project; - resolve(parsed); - } catch (e) { - reject(e instanceof Error ? e : new Error('Failed to parse project.json')); - } - }); - }); - }); - zipFile.on('error', reject); - zipFile.on('end', () => reject(new Error('project.json not found in zip'))); - }); - }); -} - /** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */ function zipOptionsForRelativeEntry(rel: string): { compressionLevel: number } { const norm = rel.replace(/\\/gu, '/').toLowerCase(); diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 6bcc6c1..ba5841f 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts'; import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; @@ -31,6 +32,7 @@ export function ControlApp() { const audioMetaRef = useRef>(new Map()); const [audioStateTick, setAudioStateTick] = useState(0); const audioLoadRunRef = useRef(0); + const audioUnmountRef = useRef(false); const previewHostRef = useRef(null); const previewVideoRef = useRef(null); const brushRef = useRef<{ @@ -80,6 +82,13 @@ export function ControlApp() { }); }, [api]); + useEffect(() => { + audioUnmountRef.current = false; + return () => { + audioUnmountRef.current = true; + }; + }, []); + const project = session?.project ?? null; const currentGraphNodeId = project?.currentGraphNodeId ?? null; const currentScene = @@ -102,21 +111,62 @@ export function ControlApp() { useEffect(() => { audioLoadRunRef.current += 1; const runId = audioLoadRunRef.current; - // Cleanup old audios on scene change. - const els = audioElsRef.current; - for (const el of els.values()) { - try { - el.pause(); - el.currentTime = 0; - } catch { - // ignore - } - } - els.clear(); + + const oldEls = new Map(audioElsRef.current); + audioElsRef.current = new Map(); audioMetaRef.current.clear(); setAudioStateTick((x) => x + 1); - if (!project || !currentScene) return; + const FADE_OUT_MS = 450; + const fadeOutCtl = { raf: 0, cancelled: false }; + const finishFadeOut = (): void => { + for (const el of oldEls.values()) { + try { + el.pause(); + el.currentTime = 0; + el.volume = 1; + } catch { + // ignore + } + } + }; + if (oldEls.size > 0) { + const startVol = new Map(); + for (const [id, el] of oldEls) { + startVol.set(id, el.volume); + } + const t0 = performance.now(); + const tickOut = (now: number): void => { + if (fadeOutCtl.cancelled || audioUnmountRef.current) { + finishFadeOut(); + return; + } + const u = Math.min(1, (now - t0) / FADE_OUT_MS); + for (const [id, el] of oldEls) { + try { + const v0 = startVol.get(id) ?? 1; + el.volume = v0 * (1 - u); + } catch { + // ignore + } + } + if (u < 1) { + fadeOutCtl.raf = window.requestAnimationFrame(tickOut); + } else { + finishFadeOut(); + } + }; + fadeOutCtl.raf = window.requestAnimationFrame(tickOut); + } + + if (!project || !currentScene) { + return () => { + fadeOutCtl.cancelled = true; + window.cancelAnimationFrame(fadeOutCtl.raf); + }; + } + + const FADE_IN_MS = 550; void (async () => { const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = []; for (const item of sceneAudioRefs) { @@ -126,6 +176,7 @@ export function ControlApp() { const el = new Audio(r.url); el.loop = item.loop; el.preload = 'auto'; + el.volume = item.autoplay ? 0 : 1; audioMetaRef.current.set(item.assetId, { lastPlayError: null }); el.addEventListener('play', () => setAudioStateTick((x) => x + 1)); el.addEventListener('pause', () => setAudioStateTick((x) => x + 1)); @@ -141,6 +192,7 @@ export function ControlApp() { try { el.pause(); el.currentTime = 0; + el.volume = 1; } catch { // ignore } @@ -157,9 +209,47 @@ export function ControlApp() { 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', }); setAudioStateTick((x) => x + 1); + try { + el.volume = 1; + } catch { + // ignore + } + continue; } + if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { + try { + el.volume = 1; + } catch { + // ignore + } + continue; + } + const tIn0 = performance.now(); + const tickIn = (now: number): void => { + if (audioLoadRunRef.current !== runId || audioUnmountRef.current) { + try { + el.volume = 1; + } catch { + // ignore + } + return; + } + const u = Math.min(1, (now - tIn0) / FADE_IN_MS); + try { + el.volume = u; + } catch { + // ignore + } + if (u < 1) window.requestAnimationFrame(tickIn); + }; + window.requestAnimationFrame(tickIn); } })(); + + return () => { + fadeOutCtl.cancelled = true; + window.cancelAnimationFrame(fadeOutCtl.raf); + }; }, [api, currentScene, project, sceneAudioRefs]); const anyPlaying = useMemo(() => { @@ -676,34 +766,8 @@ export function ControlApp() { setCursorN(p); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); if (tool.tool === 'eraser') { - const rN = tool.radiusN; - const nearest = (fxState?.instances ?? []) - .map((inst) => { - if (inst.type === 'fog') { - const d = inst.points.reduce((best, q) => { - const dx = q.x - p.x; - const dy = q.y - p.y; - const dd = dx * dx + dy * dy; - return Math.min(best, dd); - }, Number.POSITIVE_INFINITY); - return { id: inst.id, dd: d }; - } - if (inst.type === 'lightning') { - const dx = inst.end.x - p.x; - const dy = inst.end.y - p.y; - return { id: inst.id, dd: dx * dx + dy * dy }; - } - if (inst.type === 'freeze') { - const dx = inst.at.x - p.x; - const dy = inst.at.y - p.y; - return { id: inst.id, dd: dx * dx + dy * dy }; - } - return { id: inst.id, dd: Number.POSITIVE_INFINITY }; - }) - .sort((a, b) => a.dd - b.dd)[0]; - if (nearest && nearest.dd <= rN * rN) { - void fx.dispatch({ kind: 'instance.remove', id: nearest.id }); - } + const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); + if (id) void fx.dispatch({ kind: 'instance.remove', id }); return; } brushRef.current = { @@ -714,10 +778,15 @@ export function ControlApp() { setDraftFxTick((x) => x + 1); }} onPointerMove={(e) => { - const b = brushRef.current; const p = toNPoint(e); if (!p) return; setCursorN(p); + if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) { + const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN); + if (id) void fx.dispatch({ kind: 'instance.remove', id }); + return; + } + const b = brushRef.current; if (!b?.points) return; const last = b.points[b.points.length - 1]; if (!last) return; diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index ae83663..6314390 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -253,6 +253,37 @@ margin-top: 4px; } +.licenseBlockTitle { + font-weight: 900; + font-size: var(--text-lg); + margin-bottom: 8px; +} + +.licenseTextarea { + width: 100%; + min-height: 120px; + box-sizing: border-box; + padding: 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: var(--bg0); + color: var(--text0); + font: inherit; + resize: vertical; +} + +.eulaScroll { + max-height: min(52vh, 420px); + overflow: auto; + padding: 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--stroke); + background: var(--bg0); + white-space: pre-wrap; + font-size: var(--text-sm); + line-height: 1.45; +} + .fileSuffix { color: var(--text2); font-size: var(--text-xs); diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index b87eaba..64b60ea 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1,7 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels } from '../../shared/ipc/contracts'; +import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion'; +import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot'; import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types'; import { AppLogo } from '../shared/branding/AppLogo'; import { getDndApi } from '../shared/dndApi'; @@ -12,6 +14,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './EditorApp.module.css'; import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph'; +import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; import { useProjectState } from './state/projectState'; type SceneCard = { @@ -29,13 +32,22 @@ export function EditorApp() { const [query, setQuery] = useState(''); const [fileMenuOpen, setFileMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false); + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); - const [state, actions] = useProjectState(); + const [licenseSnap, setLicenseSnap] = useState(null); + const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false); + const [eulaModalOpen, setEulaModalOpen] = useState(false); + const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false); + const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false); + const licenseActive = licenseSnap?.active === true; + const [state, actions] = useProjectState(licenseActive); const fileMenuBtnRef = useRef(null); const projectMenuBtnRef = useRef(null); + const settingsMenuBtnRef = useRef(null); const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null); const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null); + const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null); const scenes = useMemo(() => { const p = state.project; if (!p) return []; @@ -134,6 +146,45 @@ export function EditorApp() { return () => window.removeEventListener('mousedown', onDown); }, [projectMenuOpen]); + useEffect(() => { + if (!settingsMenuOpen) return; + const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null; + queueMicrotask(() => { + if (r) { + setSettingsMenuPos({ left: r.left, top: r.bottom + 10 }); + } else { + setSettingsMenuPos(null); + } + }); + const onDown = (e: MouseEvent) => { + const t = e.target as HTMLElement | null; + if (!t) return; + if (t.closest('[data-settingsmenu-root="1"]')) return; + setSettingsMenuOpen(false); + }; + window.addEventListener('mousedown', onDown); + return () => window.removeEventListener('mousedown', onDown); + }, [settingsMenuOpen]); + + const reloadLicense = useCallback(() => { + void (async () => { + try { + const s = await getDndApi().invoke(ipcChannels.license.getStatus, {}); + setLicenseSnap(s); + } catch { + setLicenseSnap(null); + } + })(); + }, []); + + useEffect(() => { + reloadLicense(); + const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => { + reloadLicense(); + }); + return unsub; + }, [reloadLicense]); + useEffect(() => { void (async () => { try { @@ -148,28 +199,63 @@ export function EditorApp() { const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null; + const bodyOverlay = + licenseSnap === null ? ( +
+
Проверка лицензии…
+
Подождите.
+
+ ) : !licenseSnap.active ? ( +
+
Требуется лицензия
+
+ Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки». +
+
+ ) : undefined; + return ( <>
+
} /> + {settingsMenuOpen && settingsMenuPos + ? createPortal( +
+ + +
, + document.body, + ) + : null} + setLicenseKeyModalOpen(false)} + onSaved={() => { + reloadLicense(); + }} + /> + { + setEulaModalOpen(false); + setOpenKeyAfterEula(false); + }} + onAccepted={() => { + if (openKeyAfterEula) { + setLicenseKeyModalOpen(true); + } + setOpenKeyAfterEula(false); + }} + /> + setAboutLicenseOpen(false)} + snapshot={licenseSnap} + /> {projectMenuOpen && projectMenuPos ? createPortal(
+
, document.body, @@ -894,7 +1065,6 @@ function SceneInspector({ /> Цикл - аудио +
ВЕТВЛЕНИЯ
diff --git a/app/renderer/editor/license/EditorLicenseModals.tsx b/app/renderer/editor/license/EditorLicenseModals.tsx new file mode 100644 index 0000000..ea0c447 --- /dev/null +++ b/app/renderer/editor/license/EditorLicenseModals.tsx @@ -0,0 +1,257 @@ +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { ipcChannels } from '../../../shared/ipc/contracts'; +import { EULA_CURRENT_VERSION } from '../../../shared/license/eulaVersion'; +import type { LicenseSnapshot } from '../../../shared/license/licenseSnapshot'; +import { EULA_RU_MARKDOWN } from '../../legal/eulaRu'; +import { getDndApi } from '../../shared/dndApi'; +import { Button } from '../../shared/ui/controls'; +import styles from '../EditorApp.module.css'; + +type LicenseTokenModalProps = { + open: boolean; + onClose: () => void; + onSaved: () => void; +}; + +export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) { + const [token, setToken] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setToken(''); + setSaving(false); + setError(null); + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, open]); + + if (!open) return null; + + return createPortal( + <> + +
+
+
ЛИЦЕНЗИОННЫЙ ТОКЕН
+