feat(phase1): rebrand to TTRPG Player and drop Git updates feed

Rename product to TTRPG Player (TTRPGPlayer / com.ttrpgplayer.app), use .ttrpg.zip for new saves while keeping .dnd.zip import, accept TTRPG- and DND- license keys on client, and remove sync-update-feed plus CI push to DndGamePlayerUpdates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-05-17 20:56:14 +08:00
parent 2c03921d23
commit 7c858ba633
27 changed files with 253 additions and 1328 deletions
+17 -11
View File
@@ -1,6 +1,14 @@
import { app, BrowserWindow, dialog, Menu, protocol } from 'electron';
import { ipcChannels, type SessionState } from '../shared/ipc/contracts';
import {
PROJECT_ZIP_OPEN_DIALOG_FILTER,
PROJECT_ZIP_SAVE_DIALOG_FILTER,
isProjectZipFileName,
normalizeSaveProjectZipPath,
projectZipFileNameFromBase,
stripProjectZipExtension,
} from '../shared/project/projectZipExtension';
import { EffectsStore } from './effects/effectsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
@@ -52,7 +60,7 @@ if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GP
}
if (process.platform === 'win32') {
app.setAppUserModelId('com.dndplayer.app');
app.setAppUserModelId('com.ttrpgplayer.app');
}
// Не вызывать app.setName() с другим именем: на Windows/macOS меняется каталог userData,
// и проекты в …/userData/projects «пропадают» из списка (остаются в старой папке).
@@ -447,7 +455,7 @@ async function main() {
registerHandler(ipcChannels.project.importZip, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
filters: [PROJECT_ZIP_OPEN_DIALOG_FILTER],
});
if (canceled || !filePaths[0]) {
return { canceled: true as const };
@@ -473,21 +481,19 @@ async function main() {
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = entry.fileName.toLowerCase().endsWith('.dnd.zip')
? entry.fileName
: `${entry.fileName}.dnd.zip`;
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [{ name: 'Проект DND (*.dnd.zip)', extensions: ['dnd.zip'] }],
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
let dest = filePath;
const lower = dest.toLowerCase();
if (!lower.endsWith('.dnd.zip')) {
dest = lower.endsWith('.zip') ? dest.replace(/\.zip$/iu, '.dnd.zip') : `${dest}.dnd.zip`;
}
const dest = normalizeSaveProjectZipPath(filePath);
emitZipProgress({ kind: 'export', stage: 'copy', percent: 0, detail: 'Экспорт…' });
await projectStore.exportProjectZipToPath(projectId, dest, (p) => {
emitZipProgress({
+3 -3
View File
@@ -7,7 +7,7 @@ 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 { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
@@ -87,7 +87,7 @@ export class LicenseService {
private deriveFallbackKey(): Buffer {
return crypto
.createHash('sha256')
.update('DNDGamePlayer.license.fallback.v1\0', 'utf8')
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
.update(this.deviceId, 'utf8')
.digest();
}
@@ -342,7 +342,7 @@ export class LicenseService {
return this.getStatusSync();
}
let trimmed = normalizeLicenseTokenInput(token);
if (isDndProductKey(trimmed)) {
if (isProductKey(trimmed)) {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
+17 -5
View File
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
PROJECT_ZIP_EXTENSION,
PROJECT_ZIP_EXTENSION_LEGACY,
isProjectZipFileName,
} from '../../shared/project/projectZipExtension';
import { readProjectJsonFromZip } from './yauzlProjectZip';
/**
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
*/
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
@@ -50,8 +56,10 @@ export async function replaceFileAtomic(completedSrc: string, finalPath: string)
await fs.unlink(backupPath).catch(() => undefined);
}
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(root);
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
return;
}
for (const name of names) {
if (!name.endsWith('.dnd.zip.tmp')) continue;
const matchedSuffix = ZIP_TMP_SUFFIXES.find((s) => name.endsWith(s));
if (!matchedSuffix) continue;
const tmpPath = path.join(root, name);
const finalName = name.slice(0, -'.tmp'.length);
if (!finalName.endsWith('.dnd.zip')) continue;
if (!isProjectZipFileName(finalName)) continue;
const finalPath = path.join(root, finalName);
try {
await fs.access(finalPath);
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
}
}
}
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
+8 -1
View File
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
export function getLegacyProjectsRootDirs(): string[] {
const cur = getProjectsRootDir();
const parent = path.dirname(app.getPath('userData'));
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
const siblingNames = [
'TTRPG Player',
'TTRPGPlayer',
'DnD Player',
'dnd-player',
'DNDGamePlayer',
'dnd_player',
];
const out: string[] = [];
for (const n of siblingNames) {
const p = path.join(parent, n, 'projects');
+2 -2
View File
@@ -13,7 +13,7 @@ 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 zipPath = path.join(tmp, 'test.ttrpg.zip');
const minimal = {
id: 'p1',
meta: {
@@ -54,7 +54,7 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const zipPath = path.join(tmp, 'test.ttrpg.zip');
const assetId = 'audio_asset_1';
const minimal = {
id: 'p1',
@@ -58,7 +58,7 @@ void test('atomicReplace: replaceFileAtomic must not rm destination before succe
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
assert.ok(i >= 0);
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /rename\(finalPath, backupPath\)/);
+22 -22
View File
@@ -8,6 +8,11 @@ import { ZipFile } from 'yazl';
import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules';
import type { ScenePatch } from '../../shared/ipc/contracts';
import {
isProjectZipFileName,
projectZipFileNameFromBase,
stripProjectZipExtension,
} from '../../shared/project/projectZipExtension';
import type {
MediaAsset,
MediaAssetType,
@@ -24,7 +29,7 @@ import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanDndZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
@@ -76,10 +81,10 @@ export class ZipProjectStore {
await fs.mkdir(getProjectsRootDir(), { recursive: true });
await fs.mkdir(getProjectsCacheRootDir(), { recursive: true });
await this.migrateLegacyProjectZipsIfNeeded();
await recoverOrphanDndZipTmpInRoot(getProjectsRootDir());
await recoverOrphanProjectZipTmpInRoot(getProjectsRootDir());
}
/** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
/** Копирует архивы проектов из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */
private async migrateLegacyProjectZipsIfNeeded(): Promise<void> {
const dest = getProjectsRootDir();
let destNames: string[];
@@ -88,7 +93,7 @@ export class ZipProjectStore {
} catch {
return;
}
const destZips = new Set(destNames.filter((n) => n.endsWith('.dnd.zip')));
const destZips = new Set(destNames.filter((n) => isProjectZipFileName(n)));
for (const legacyRoot of getLegacyProjectsRootDirs()) {
let legacyNames: string[];
try {
@@ -97,7 +102,7 @@ export class ZipProjectStore {
continue;
}
for (const name of legacyNames) {
if (!name.endsWith('.dnd.zip')) continue;
if (!isProjectZipFileName(name)) continue;
if (destZips.has(name)) continue;
const from = path.join(legacyRoot, name);
const to = path.join(dest, name);
@@ -132,7 +137,7 @@ export class ZipProjectStore {
const root = getProjectsRootDir();
const entries = await fs.readdir(root, { withFileTypes: true });
const files = entries
.filter((e) => e.isFile() && e.name.endsWith('.dnd.zip'))
.filter((e) => e.isFile() && isProjectZipFileName(e.name))
.map((e) => path.join(root, e.name));
const out: ProjectIndexEntry[] = [];
@@ -180,7 +185,7 @@ export class ZipProjectStore {
sceneGraphEdges: [],
};
const zipPath = path.join(getProjectsRootDir(), `${fileBaseName}.dnd.zip`);
const zipPath = path.join(getProjectsRootDir(), projectZipFileNameFromBase(fileBaseName));
const cacheDir = path.join(getProjectsCacheRootDir(), id);
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = { id, zipPath, cacheDir, projectPath, project };
@@ -778,7 +783,7 @@ export class ZipProjectStore {
const list = await this.listProjects();
const oldBase = open.project.meta.fileBaseName;
const nextFileName = `${sanitizedBase}.dnd.zip`;
const nextFileName = projectZipFileNameFromBase(sanitizedBase);
const nameClash = list.some(
(p) => p.id !== open.id && p.name.trim().toLowerCase() === nextName.toLowerCase(),
@@ -897,8 +902,8 @@ export class ZipProjectStore {
if (!st?.isFile()) {
throw new Error('Файл проекта не найден');
}
if (!resolved.toLowerCase().endsWith('.dnd.zip')) {
throw new Error('Ожидается файл с расширением .dnd.zip');
if (!isProjectZipFileName(resolved)) {
throw new Error('Ожидается файл проекта с расширением .ttrpg.zip или .dnd.zip');
}
const root = getProjectsRootDir();
@@ -909,11 +914,11 @@ export class ZipProjectStore {
let destPath: string;
let destFileName: string;
if (dirNorm === rootNorm && baseName.toLowerCase().endsWith('.dnd.zip')) {
if (dirNorm === rootNorm && isProjectZipFileName(baseName)) {
destPath = resolved;
destFileName = baseName;
} else {
destFileName = await uniqueDndZipFileName(root, baseName);
destFileName = await uniqueProjectZipFileNameInRoot(root, baseName);
destPath = path.join(root, destFileName);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(resolved, destPath, (pct) => {
@@ -930,7 +935,7 @@ export class ZipProjectStore {
const othersWithSameId = entries.filter((e) => e.id === project.id && e.fileName !== destFileName);
if (othersWithSameId.length > 0) {
const newId = asProjectId(this.randomId());
const stem = destFileName.replace(/\.dnd\.zip$/iu, '');
const stem = stripProjectZipExtension(destFileName);
project = {
...project,
id: newId,
@@ -1217,20 +1222,15 @@ function sanitizeFileName(name: string): string {
return safe.length > 0 ? safe : 'Untitled';
}
async function uniqueDndZipFileName(root: string, preferredBaseFileName: string): Promise<string> {
let base = preferredBaseFileName;
if (!base.toLowerCase().endsWith('.dnd.zip')) {
const stem = sanitizeFileName(path.basename(base, path.extname(base)) || 'project');
base = `${stem}.dnd.zip`;
}
const stem = base.replace(/\.dnd\.zip$/iu, '');
let candidate = base;
async function uniqueProjectZipFileNameInRoot(root: string, preferredBaseFileName: string): Promise<string> {
const stem = sanitizeFileName(stripProjectZipExtension(path.basename(preferredBaseFileName)) || 'project');
let candidate = projectZipFileNameFromBase(stem);
let n = 0;
for (;;) {
try {
await fs.access(path.join(root, candidate));
n += 1;
candidate = `${stem}_${String(n)}.dnd.zip`;
candidate = projectZipFileNameFromBase(`${stem}_${String(n)}`);
} catch {
return candidate;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { app, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import { appDisplayNameForLocale } from '../../shared/appBranding';
import {
ipcChannels,
type UpdaterCheckResponse,
@@ -110,7 +111,7 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
void dialog
.showMessageBox({
type: 'info',
title: 'DNDGamePlayer',
title: appDisplayNameForLocale(app.getLocale()),
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
+2 -1
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { appDisplayNameForLocale } from '../../shared/appBranding';
import { getAppSemanticVersion } from '../versionInfo';
import { loadBrandingWindowIcon } from './brandingIcon';
@@ -93,7 +94,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
export function applyBootWindowBranding(win: BrowserWindow): void {
if (win.isDestroyed()) return;
const name = app.getName();
const name = appDisplayNameForLocale(app.getLocale());
const version = getAppSemanticVersion();
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
void win.webContents.executeJavaScript(