2ce1e02753
- Make license status snapshot non-blocking (revocation check in background) - Speed boot by not awaiting license network and capping editor ready wait - Stop disabling GPU by default on Win packaged builds - Remove external font fetch; bundle local Inter Made-with: Cursor
284 lines
9.0 KiB
TypeScript
284 lines
9.0 KiB
TypeScript
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 { isDndProductKey } from '../../shared/license/productKey';
|
|
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
|
|
|
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 */
|
|
}
|
|
}
|
|
|
|
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
|
private resolveLicenseActivateBaseUrl(): string {
|
|
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
|
|
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
|
|
return 'https://license.mailib.ru/';
|
|
}
|
|
|
|
private async activateWithProductKey(productKey: string): Promise<string> {
|
|
const base = this.resolveLicenseActivateBaseUrl();
|
|
const url = new URL('v1/activate', base);
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
const text = await res.text();
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text) as unknown;
|
|
} catch {
|
|
throw new Error(`LICENSE_ACTIVATE_FAILED:${text.slice(0, 200)}`);
|
|
}
|
|
const obj = parsed as { token?: string; error?: string; message?: string };
|
|
if (!res.ok) {
|
|
throw new Error(`LICENSE_ACTIVATE_FAILED:${obj.error ?? obj.message ?? String(res.status)}`);
|
|
}
|
|
const token = obj.token;
|
|
if (!token || typeof token !== 'string') {
|
|
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
|
|
}
|
|
return normalizeLicenseTokenInput(token);
|
|
}
|
|
|
|
/**
|
|
* Онлайн-проверка отзыва. Не вызывать через `await` из UI-пути: без VPN/DNS до сервера
|
|
* лицензий TCP может висеть до таймаута (см. fetch), из‑за чего главное окно долго «чёрное».
|
|
*/
|
|
private async maybeRefreshRemoteRevocation(payload: LicensePayloadV1): Promise<void> {
|
|
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;
|
|
const wasRevoked = this.lastRemoteRevoked;
|
|
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: не блокируем */
|
|
}
|
|
if (wasRevoked !== this.lastRemoteRevoked) {
|
|
emitLicenseStatusChanged();
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Снимок для UI/IPC: без ожидания сети (проверка отзыва уходит в фон). */
|
|
getStatus(): LicenseSnapshot {
|
|
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();
|
|
void this.maybeRefreshRemoteRevocation(v.payload);
|
|
return this.getStatusSync();
|
|
}
|
|
|
|
async setToken(token: string): Promise<LicenseSnapshot> {
|
|
if (this.isSkipLicense()) {
|
|
return this.getStatusSync();
|
|
}
|
|
let trimmed = normalizeLicenseTokenInput(token);
|
|
if (isDndProductKey(trimmed)) {
|
|
trimmed = await this.activateWithProductKey(trimmed);
|
|
}
|
|
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}`);
|
|
}
|
|
}
|
|
}
|