feat: i18n control, Gitea auto-update CI, license-gated updater, fixes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-05-11 22:20:14 +08:00
parent 36776f4c5d
commit f462e65581
23 changed files with 2049 additions and 440 deletions
+153
View File
@@ -0,0 +1,153 @@
# Сборка установщиков по тегу v* и выкладка файлов обновления в публичный репозиторий (ветка `updates`).
# Secrets (имена без GITEA_* — зарезервировано на сервере): DND_UPDATE_FEED_URL, DND_UPDATES_SERVER, UPDATES_REPO, DND_UPDATES_PUSH_TOKEN — см. docs/GITEA_AUTO_UPDATE.md
name: Release
on:
push:
tags:
- 'v*'
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Version from tag
shell: bash
run: |
TAG="${GITHUB_REF_NAME:-}"
VERSION="${TAG#v}"
npm version "$VERSION" --allow-same-version --no-git-tag-version
- run: npm ci
- run: npm run build
- run: node scripts/release-win-prep.mjs
- name: electron-builder (win)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set (generic feed base URL, trailing slash)" >&2
exit 1
fi
npx electron-builder --win --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
- name: Stage win artifacts
shell: bash
run: |
mkdir -p _artifact_win
shopt -s nullglob || true
for f in release/*; do
[[ -f "$f" ]] || continue
base=$(basename "$f")
case "$base" in
*.yml|*.yaml|*.exe|*.blockmap|*.zip) cp -v "$f" _artifact_win/ ;;
esac
done
ls -la _artifact_win
- uses: actions/upload-artifact@v4
with:
name: eb-win
path: _artifact_win/
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Version from tag
shell: bash
run: |
TAG="${GITHUB_REF_NAME:-}"
VERSION="${TAG#v}"
npm version "$VERSION" --allow-same-version --no-git-tag-version
- run: npm ci
- run: npm run build
- name: electron-builder (mac)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set" >&2
exit 1
fi
npx electron-builder --mac --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
- name: Stage mac artifacts
shell: bash
run: |
mkdir -p _artifact_mac
shopt -s nullglob || true
for f in release/*; do
[[ -f "$f" ]] || continue
base=$(basename "$f")
case "$base" in
*.yml|*.yaml|*.dmg|*.blockmap|*.zip|*.pkg) cp -v "$f" _artifact_mac/ ;;
esac
done
ls -la _artifact_mac
- uses: actions/upload-artifact@v4
with:
name: eb-mac
path: _artifact_mac/
publish-update-feed:
runs-on: ubuntu-latest
needs: [build-windows, build-macos]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: eb-win
path: _win
- uses: actions/download-artifact@v4
with:
name: eb-mac
path: _mac
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Push to public updates repo
env:
DND_UPDATES_SERVER: ${{ secrets.DND_UPDATES_SERVER }}
UPDATES_REPO: ${{ secrets.UPDATES_REPO }}
DND_UPDATES_PUSH_TOKEN: ${{ secrets.DND_UPDATES_PUSH_TOKEN }}
ARTIFACT_WIN: ${{ github.workspace }}/_win
ARTIFACT_MAC: ${{ github.workspace }}/_mac
GIT_COMMIT_TAG: ${{ github.ref_name }}
run: node scripts/sync-update-feed.mjs
+2
View File
@@ -7,6 +7,7 @@ import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/route
import { LicenseService } from './license/licenseService';
import { ZipProjectStore } from './project/zipStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
import {
@@ -527,6 +528,7 @@ async function main() {
installIpcRouter();
applyDockIconIfNeeded();
installAutoUpdater(licenseService);
await runStartupAfterHandlers(licenseService);
app.on('activate', () => {
+23
View File
@@ -17,6 +17,28 @@ type Preferences = {
eulaAcceptedVersion?: number;
};
type LicenseChangeListener = () => void;
const licenseChangeListeners = new Set<LicenseChangeListener>();
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
export function addLicenseChangeListener(fn: LicenseChangeListener): () => void {
licenseChangeListeners.add(fn);
return () => {
licenseChangeListeners.delete(fn);
};
}
function notifyLicenseChangeListeners(): void {
for (const fn of licenseChangeListeners) {
try {
fn();
} catch (err) {
console.error('[license] change listener failed', err);
}
}
}
function readPreferences(userData: string): Preferences {
try {
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
@@ -35,6 +57,7 @@ function emitLicenseStatusChanged(): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.license.statusChanged, {});
}
notifyLicenseChangeListeners();
}
export class LicenseService {
+71
View File
@@ -0,0 +1,71 @@
import { app, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import { addLicenseChangeListener } from '../license/licenseService';
import type { LicenseService } from '../license/licenseService';
const STARTUP_CHECK_DELAY_MS = 12_000;
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
const RE_CHECK_COOLDOWN_MS = 30_000;
let lastCheckAt = 0;
function isLicensedForUpdates(licenseService: LicenseService): boolean {
const snap = licenseService.getStatusSync();
return snap.active;
}
function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: boolean): void {
if (!app.isPackaged) return;
if (!isLicensedForUpdates(licenseService)) return;
const now = Date.now();
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
lastCheckAt = now;
void autoUpdater.checkForUpdates().catch(() => undefined);
}
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
*/
export function installAutoUpdater(licenseService: LicenseService): void {
if (!app.isPackaged) return;
const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim();
if (feedOverride) {
const url = feedOverride.endsWith('/') ? feedOverride : `${feedOverride}/`;
autoUpdater.setFeedURL({ provider: 'generic', url });
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', (info) => {
void dialog
.showMessageBox({
type: 'info',
title: 'DNDGamePlayer',
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
cancelId: 1,
})
.then((r) => {
if (r.response === 0) {
autoUpdater.quitAndInstall(false, true);
}
});
});
autoUpdater.on('error', () => {
/* без console: в production main минифицируется с drop console */
});
addLicenseChangeListener(() => {
maybeCheckForUpdates(licenseService, false);
});
setTimeout(() => {
maybeCheckForUpdates(licenseService, true);
}, STARTUP_CHECK_DELAY_MS);
}
+15 -7
View File
@@ -13,6 +13,19 @@ const windows = new Map<WindowKind, BrowserWindow>();
let appQuitting = false;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed()) continue;
if (w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
@@ -212,9 +225,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
broadcastMultiWindowStateChanged(open);
});
windows.set(kind, win);
return win;
@@ -292,10 +303,7 @@ export function openMultiWindow() {
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
}
const open = true;
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open });
}
broadcastMultiWindowStateChanged(true);
}
export function closeMultiWindow(): void {
+74 -73
View File
@@ -4,6 +4,7 @@ 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';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
import { useEffectsState } from '../shared/effects/useEffectsState';
@@ -44,6 +45,9 @@ function playLightningEffectSound(): void {
export function ControlApp() {
const api = getDndApi();
const { t } = useEditorI18n();
const tRef = useRef(t);
tRef.current = t;
const [fxState, fx] = useEffectsState();
const [session, setSession] = useState<SessionState | null>(null);
const historyRef = useRef<GraphNodeId[]>([]);
@@ -278,8 +282,7 @@ export function ControlApp() {
const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
sceneAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError:
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setSceneAudioStateTick((x) => x + 1);
try {
@@ -382,8 +385,7 @@ export function ControlApp() {
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
campaignAudioMetaRef.current.set(ref.assetId, {
...m,
lastPlayError:
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
lastPlayError: tRef.current('control.audioAutoplayBlocked'),
});
setCampaignAudioStateTick((x) => x + 1);
try {
@@ -546,21 +548,21 @@ export function ControlApp() {
group === 'scene'
? (sceneAudioElsRef.current.get(assetId) ?? null)
: (campaignAudioElsRef.current.get(assetId) ?? null);
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') };
const meta =
group === 'scene'
? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null })
: (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null });
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError };
if (el.error)
return {
label: 'Ошибка',
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
label: t('control.audioError'),
detail: t('control.audioMediaError', { code: String(el.error.code) }),
};
if (el.readyState < 2) return { label: 'Загрузка…' };
if (!el.paused) return { label: 'Играет' };
if (el.currentTime > 0) return { label: 'Пауза' };
return { label: 'Остановлено' };
if (el.readyState < 2) return { label: t('control.audioLoading') };
if (!el.paused) return { label: t('control.audioPlaying') };
if (el.currentTime > 0) return { label: t('control.audioPaused') };
return { label: t('control.audioStopped') };
}
const nextScenes = useMemo(() => {
if (!project) return [];
@@ -955,21 +957,21 @@ export function ControlApp() {
return (
<div className={styles.page}>
<Surface className={styles.remote}>
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
<div className={styles.spacer12} />
{!isVideoPreviewScene ? (
<>
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
<div className={styles.sectionLabel}>{t('control.effects')}</div>
<div className={styles.spacer8} />
<div className={styles.effectsStack}>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Инструменты</div>
<div className={styles.subsectionLabel}>{t('control.tools')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
iconOnly
title="Ластик"
ariaLabel="Ластик"
title={t('control.eraser')}
ariaLabel={t('control.eraser')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
>
<span className={styles.iconGlyph}>🧹</span>
@@ -977,8 +979,8 @@ export function ControlApp() {
<Button
variant="ghost"
iconOnly
title="Очистить эффекты"
ariaLabel="Очистить эффекты"
title={t('control.clearEffects')}
ariaLabel={t('control.clearEffects')}
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
>
<span className={styles.clearIcon}>
@@ -999,13 +1001,13 @@ export function ControlApp() {
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Эффекты поля</div>
<div className={styles.subsectionLabel}>{t('control.fieldEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
iconOnly
title="Туман"
ariaLabel="Туман"
title={t('control.fog')}
ariaLabel={t('control.fog')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
>
<span className={styles.iconGlyph}>🌫</span>
@@ -1013,8 +1015,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
iconOnly
title="Дождь"
ariaLabel="Дождь"
title={t('control.rain')}
ariaLabel={t('control.rain')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
>
<span className={styles.iconGlyph}>🌧</span>
@@ -1022,8 +1024,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
iconOnly
title="Огонь"
ariaLabel="Огонь"
title={t('control.fire')}
ariaLabel={t('control.fire')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
>
<span className={styles.iconGlyph}>🔥</span>
@@ -1031,8 +1033,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'water' ? 'primary' : 'ghost'}
iconOnly
title="Вода"
ariaLabel="Вода"
title={t('control.water')}
ariaLabel={t('control.water')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'water' } })}
>
<span className={styles.iconGlyph}>💧</span>
@@ -1040,13 +1042,13 @@ export function ControlApp() {
</div>
</div>
<div className={styles.effectsGroup}>
<div className={styles.subsectionLabel}>Эффекты действий</div>
<div className={styles.subsectionLabel}>{t('control.actionEffects')}</div>
<div className={styles.iconRow}>
<Button
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
iconOnly
title="Молния"
ariaLabel="Молния"
title={t('control.lightning')}
ariaLabel={t('control.lightning')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })
}
@@ -1056,8 +1058,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'sunbeam' ? 'primary' : 'ghost'}
iconOnly
title="Луч света"
ariaLabel="Луч света"
title={t('control.sunbeam')}
ariaLabel={t('control.sunbeam')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'sunbeam' } })}
>
<span className={styles.iconGlyph}></span>
@@ -1065,8 +1067,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
iconOnly
title="Заморозка"
ariaLabel="Заморозка"
title={t('control.freeze')}
ariaLabel={t('control.freeze')}
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
>
<span className={styles.iconGlyph}></span>
@@ -1074,8 +1076,8 @@ export function ControlApp() {
<Button
variant={tool.tool === 'poisonCloud' ? 'primary' : 'ghost'}
iconOnly
title="Облако яда"
ariaLabel="Облако яда"
title={t('control.poisonCloud')}
ariaLabel={t('control.poisonCloud')}
onClick={() =>
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'poisonCloud' } })
}
@@ -1085,7 +1087,7 @@ export function ControlApp() {
</div>
</div>
<div className={styles.radiusRow}>
<div className={styles.radiusLabel}>Радиус кисти</div>
<div className={styles.radiusLabel}>{t('control.brushRadius')}</div>
<input
type="range"
min={0.015}
@@ -1098,7 +1100,7 @@ export function ControlApp() {
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
}}
className={styles.range}
aria-label="Радиус кисти"
aria-label={t('control.brushRadius')}
/>
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
</div>
@@ -1107,7 +1109,7 @@ export function ControlApp() {
</>
) : null}
<div className={styles.storyWrap}>
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
<div className={styles.sectionLabel}>{t('control.storyLine')}</div>
<div className={styles.spacer10} />
<div className={styles.storyScroll}>
{history.map((gnId, idx) => {
@@ -1122,7 +1124,7 @@ export function ControlApp() {
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
.filter(Boolean)
.join(' ')}
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
title={project && !isCurrent ? t('control.gotoScene') : undefined}
onClick={() => {
if (!project) return;
if (isCurrent) return;
@@ -1132,15 +1134,17 @@ export function ControlApp() {
}}
>
{isCurrent ? (
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
<div className={styles.historyBadge}>{t('control.currentSceneBadge')}</div>
) : (
<div className={styles.historyMuted}>Пройдено</div>
<div className={styles.historyMuted}>{t('control.passed')}</div>
)}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
</button>
);
})}
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
{history.length === 0 ? (
<div className={styles.emptyStory}>{t('control.noActiveScene')}</div>
) : null}
</div>
</div>
</Surface>
@@ -1148,20 +1152,15 @@ export function ControlApp() {
<div className={styles.rightStack}>
<Surface className={styles.surfacePad}>
<div className={styles.previewHeader}>
<div className={styles.previewTitle}>Предпросмотр экрана</div>
<div className={styles.previewTitle}>{t('control.screenPreview')}</div>
<div className={styles.previewActions}>
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
Выключить демонстрацию
{t('control.stopPresentation')}
</Button>
</div>
</div>
<div className={styles.spacer10} />
{isVideoPreviewScene ? (
<div className={styles.videoHint}>
Видео-превью: кисть эффектов отключена (как на экране демонстрации оверлей только для
изображения).
</div>
) : null}
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
<div className={styles.spacer10} />
<div className={styles.previewFrame}>
<div ref={previewHostRef} className={styles.previewHost}>
@@ -1258,33 +1257,33 @@ export function ControlApp() {
</Surface>
<Surface className={styles.surfacePad}>
<div className={styles.branchTitle}>Варианты ветвления</div>
<div className={styles.branchTitle}>{t('control.branches')}</div>
<div className={styles.branchGrid}>
{nextScenes.map((o, i) => (
<div key={o.graphNodeId} className={styles.branchCard}>
<div className={styles.branchCardHeader}>
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
<div className={styles.branchOption}>{t('control.option', { n: String(i + 1) })}</div>
</div>
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div>
<Button
variant="primary"
onClick={() =>
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
}
>
Переключить
{t('control.switchScene')}
</Button>
</div>
))}
{nextScenes.length === 0 ? (
<div className={styles.branchEmpty}>
<div>Нет вариантов перехода.</div>
<div>{t('control.noBranches')}</div>
<Button
variant="primary"
disabled={!session?.project?.currentGraphNodeId}
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
>
Завершить показ
{t('control.endPresentation')}
</Button>
</div>
) : null}
@@ -1293,13 +1292,13 @@ export function ControlApp() {
<Surface className={styles.surfacePad}>
<div className={styles.musicHeader}>
<div className={styles.previewTitle}>Музыка</div>
<div className={styles.previewTitle}>{t('control.music')}</div>
</div>
<div className={styles.spacer10} />
<div className={styles.sectionLabel}>МУЗЫКА СЦЕНЫ</div>
<div className={styles.sectionLabel}>{t('control.sceneMusic')}</div>
<div className={styles.spacer10} />
{sceneAudios.length === 0 ? (
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
<div className={styles.musicEmpty}>{t('control.noSceneAudio')}</div>
) : null}
{sceneAudios.length > 0 ? (
<div className={styles.audioList}>
@@ -1314,8 +1313,8 @@ export function ControlApp() {
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
</div>
<div className={styles.spacer10} />
@@ -1344,7 +1343,7 @@ export function ControlApp() {
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
@@ -1369,7 +1368,7 @@ export function ControlApp() {
({ lastPlayError: null } as const);
sceneAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: 'Не удалось запустить.',
lastPlayError: t('control.playFailed'),
});
setSceneAudioStateTick((x) => x + 1);
});
@@ -1403,10 +1402,10 @@ export function ControlApp() {
) : null}
<div className={styles.spacer12} />
<div className={styles.sectionLabel}>МУЗЫКА ИГРЫ</div>
<div className={styles.sectionLabel}>{t('control.gameMusic')}</div>
<div className={styles.spacer10} />
{campaignAudios.length === 0 ? (
<div className={styles.musicEmpty}>В игре нет аудио.</div>
<div className={styles.musicEmpty}>{t('control.noGameAudio')}</div>
) : (
<div className={styles.audioList}>
{campaignAudios.map(({ ref, asset }) => {
@@ -1420,10 +1419,12 @@ export function ControlApp() {
<div className={styles.audioMeta}>
<div className={styles.audioName}>{asset.originalName}</div>
<div className={styles.audioBadges}>
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
<div>{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}</div>
<div>{ref.loop ? t('control.loop') : t('control.once')}</div>
<div title={st.detail}>{st.label}</div>
{!allowCampaignAudio ? <div title="В сцене есть музыка">Пауза (сцена)</div> : null}
{!allowCampaignAudio ? (
<div title={t('control.pauseSceneMusicTitle')}>{t('control.pauseSceneMusic')}</div>
) : null}
</div>
<div className={styles.spacer10} />
<div
@@ -1451,7 +1452,7 @@ export function ControlApp() {
styles.audioScrub,
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
].join(' ')}
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')}
>
<div
className={styles.scrubFill}
@@ -1466,7 +1467,7 @@ export function ControlApp() {
<div className={styles.audioTransport}>
<Button
variant="primary"
title={!allowCampaignAudio ? 'Пауза: в сцене есть музыка' : undefined}
title={!allowCampaignAudio ? t('control.pauseCampaignTitle') : undefined}
onClick={() => {
if (!el) return;
const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
@@ -1484,7 +1485,7 @@ export function ControlApp() {
({ lastPlayError: null } as const);
campaignAudioMetaRef.current.set(ref.assetId, {
...mm,
lastPlayError: 'Не удалось запустить.',
lastPlayError: t('control.playFailed'),
});
setCampaignAudioStateTick((x) => x + 1);
});
+7 -5
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
@@ -23,6 +24,7 @@ function fmt(sec: number): string {
}
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState();
const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
@@ -102,7 +104,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}
>
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</video>
) : (
<div className={styles.placeholder} />
@@ -132,7 +134,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
}}
title="Клик — перемотка"
title={t('control.scrubSeek')}
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
</div>
@@ -142,7 +144,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'play' })}
title="Play"
title={t('control.transportPlay')}
>
</button>
@@ -150,7 +152,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
type="button"
className={styles.transportBtn}
onClick={() => void video.dispatch({ kind: 'pause' })}
title="Pause"
title={t('control.transportPause')}
>
</button>
@@ -161,7 +163,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
}}
title="Stop"
title={t('control.transportStop')}
>
</button>
@@ -47,28 +47,28 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes('ЭФФЕКТЫ'));
assert.ok(src.includes('Инструменты'));
assert.ok(src.includes('Эффекты поля'));
assert.ok(src.includes('Эффекты действий'));
assert.ok(src.includes('Луч света'));
assert.ok(src.includes('title="Вода"'));
assert.ok(src.includes('title="Облако яда"'));
assert.ok(src.includes('title="Туман"'));
assert.ok(src.includes('ariaLabel="Туман"'));
assert.ok(src.includes("t('control.effects')"));
assert.ok(src.includes("t('control.tools')"));
assert.ok(src.includes("t('control.fieldEffects')"));
assert.ok(src.includes("t('control.actionEffects')"));
assert.ok(src.includes("t('control.sunbeam')"));
assert.ok(src.includes("title={t('control.water')}"));
assert.ok(src.includes("title={t('control.poisonCloud')}"));
assert.ok(src.includes("title={t('control.fog')}"));
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
assert.ok(src.includes('iconOnly'));
assert.ok(src.includes('title="Очистить эффекты"'));
assert.ok(src.includes('ariaLabel="Очистить эффекты"'));
assert.ok(src.includes("title={t('control.clearEffects')}"));
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
assert.ok(src.includes('#e5484d'));
const fx = src.indexOf('ЭФФЕКТЫ');
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
const fx = src.indexOf("t('control.effects')");
const story = src.indexOf("t('control.storyLine')");
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
assert.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
@@ -86,8 +86,8 @@ void test('ControlApp: слой кисти не использует курсо
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
const src = readControlApp();
const previewLabel = src.indexOf('Предпросмотр экрана');
const radius = src.indexOf('Радиус кисти');
const previewLabel = src.indexOf("t('control.screenPreview')");
const radius = src.indexOf("t('control.brushRadius')");
assert.ok(previewLabel !== -1 && radius !== -1);
assert.ok(
radius < previewLabel,
@@ -97,8 +97,8 @@ void test('ControlApp: радиус кисти не в блоке предпро
void test('ControlApp: музыка разделена на сцену и кампанию', () => {
const src = readControlApp();
assert.ok(src.includes('МУЗЫКА СЦЕНЫ'));
assert.ok(src.includes('МУЗЫКА ИГРЫ'));
assert.ok(src.includes("t('control.sceneMusic')"));
assert.ok(src.includes("t('control.gameMusic')"));
// при музыке сцены — кампанию ставим на паузу
assert.ok(src.includes('allowCampaignAudio'));
assert.ok(
+5 -1
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { ControlApp } from './ControlApp';
const rootEl = document.getElementById('root');
@@ -11,6 +13,8 @@ if (!rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<ControlApp />
<EditorI18nProvider>
<ControlApp />
</EditorI18nProvider>
</React.StrictMode>,
);
+27
View File
@@ -243,6 +243,33 @@
font: inherit;
}
.fileMenuSubHost {
position: relative;
}
.fileMenuItemExpand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.fileMenuSub {
position: absolute;
left: calc(100% + 6px);
top: 0;
min-width: 200px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated-2);
box-shadow: var(--shadow-lg);
padding: 6px;
display: grid;
gap: 4px;
z-index: calc(var(--z-file-menu) + 1);
}
.modalBackdrop {
position: fixed;
inset: 0;
File diff suppressed because it is too large Load Diff
+161 -113
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactFlow, {
Background,
@@ -50,11 +50,53 @@ export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
const SCENE_CARD_W = 220;
const SCENE_CARD_H = 248;
/** UI strings for the scene graph (passed from editor i18n). */
export type SceneGraphUiStrings = {
badgeStart: string;
untitled: string;
videoBadge: string;
audioBadge: string;
loop: string;
autoplay: string;
previewAutostart: string;
videoLoop: string;
zoomBar: string;
zoomIn: string;
zoomOut: string;
fitAll: string;
closeMenu: string;
startScene: string;
unsetStartScene: string;
delete: string;
};
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
badgeStart: 'НАЧАЛО',
untitled: 'Без названия',
videoBadge: 'Видео',
audioBadge: 'Аудио',
loop: 'Цикл',
autoplay: 'Автостарт',
previewAutostart: 'Авто превью',
videoLoop: 'Цикл видео',
zoomBar: 'Масштаб графа',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
fitAll: 'Показать всё',
closeMenu: 'Закрыть меню',
startScene: 'Начальная сцена',
unsetStartScene: 'Снять метку «Начальная сцена»',
delete: 'Удалить',
};
const GraphUiContext = createContext<SceneGraphUiStrings>(DEFAULT_SCENE_GRAPH_UI);
export type SceneGraphProps = {
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
currentSceneId: SceneId | null;
graphUi?: SceneGraphUiStrings;
onCurrentSceneChange: (id: SceneId) => void;
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
onDisconnect: (edgeId: string) => void;
@@ -131,6 +173,7 @@ function IconVideoPreviewAutostart() {
}
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const ui = useContext(GraphUiContext);
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
const previewUrl = useAssetUrl(data.previewAssetId);
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
@@ -141,7 +184,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
<Handle type="target" position={Position.Top} className={styles.handle} />
<div className={cardClass}>
<div className={styles.previewShell}>
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
{thumbUrl ? (
<div className={styles.previewFill}>
{data.previewRotationDeg === 0 ? (
@@ -209,12 +252,12 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
{showCornerVideo || showCornerAudio ? (
<div className={styles.cornerBadges}>
{showCornerVideo ? (
<span className={styles.mediaBadge} title="Видео">
<span className={styles.mediaBadge} title={ui.videoBadge}>
<IconVideoBadge />
</span>
) : null}
{showCornerAudio ? (
<span className={styles.mediaBadge} title="Аудио">
<span className={styles.mediaBadge} title={ui.audioBadge}>
<IconAudioBadge />
</span>
) : null}
@@ -222,19 +265,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null}
</div>
<div className={styles.nodeBody}>
<div className={styles.title}>{data.title || 'Без названия'}</div>
<div className={styles.title}>{data.title || ui.untitled}</div>
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}>
{data.hasAnyAudioLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>Цикл</span>
<span>{ui.loop}</span>
</div>
) : null}
{data.hasAnyAudioAutoplay ? (
<div className={styles.musicParam}>
<IconAutoplayParam />
<span>Автостарт</span>
<span>{ui.autoplay}</span>
</div>
) : null}
</div>
@@ -244,13 +287,13 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
{data.showPreviewVideoAutostart ? (
<div className={styles.musicParam}>
<IconVideoPreviewAutostart />
<span>Авто превью</span>
<span>{ui.previewAutostart}</span>
</div>
) : null}
{data.showPreviewVideoLoop ? (
<div className={styles.musicParam}>
<IconLoopParam />
<span>Цикл видео</span>
<span>{ui.videoLoop}</span>
</div>
) : null}
</div>
@@ -265,18 +308,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
const nodeTypes = { sceneCard: SceneCardNode };
function GraphZoomToolbar() {
const ui = useContext(GraphUiContext);
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((s) => s.transform[2]);
const pct = Math.max(1, Math.round(zoom * 100));
return (
<Panel position="bottom-center" className={styles.zoomPanel}>
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
+
</button>
<span className={styles.zoomPct}>{pct}%</span>
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
</button>
<span className={styles.zoomDivider} aria-hidden />
@@ -284,8 +328,8 @@ function GraphZoomToolbar() {
type="button"
className={styles.zoomBtn}
onClick={() => fitView({ padding: 0.25 })}
aria-label="Показать всё"
title="Показать всё"
aria-label={ui.fitAll}
title={ui.fitAll}
>
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
<path
@@ -307,6 +351,7 @@ function SceneGraphCanvas({
sceneGraphEdges,
sceneCardById,
currentSceneId,
graphUi,
onCurrentSceneChange,
onConnect,
onDisconnect,
@@ -316,6 +361,7 @@ function SceneGraphCanvas({
onSetGraphNodeStart,
onDropSceneFromList,
}: SceneGraphProps) {
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
const { screenToFlowPosition } = useReactFlow();
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
@@ -446,109 +492,111 @@ function SceneGraphCanvas({
}, [menu]);
return (
<div className={styles.canvasWrap}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={(_, node) => {
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
onConnect={onConnectInternal}
onEdgesDelete={(eds) => {
for (const ed of eds) {
onDisconnect(ed.id);
}
}}
onEdgeClick={(_, edge) => {
onDisconnect(edge.id);
}}
onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}}
onNodeClick={(_, node) => {
setMenu(null);
const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}}
onPaneClick={() => {
setMenu(null);
}}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
onInit={(instance) => {
instance.fitView({ padding: 0.25 });
}}
onDragOver={onDragOver}
onDrop={onDrop}
panOnScroll
selectionOnDrag={false}
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
<GraphZoomToolbar />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
aria-label="Закрыть меню"
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: menuPosition.x, top: menuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
<GraphUiContext.Provider value={ui}>
<div className={styles.canvasWrap}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onNodeDragStop={(_, node) => {
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
}}
onEdgesChange={onEdgesChange}
isValidConnection={isValidConnection}
onConnect={onConnectInternal}
onEdgesDelete={(eds) => {
for (const ed of eds) {
onDisconnect(ed.id);
}
}}
onEdgeClick={(_, edge) => {
onDisconnect(edge.id);
}}
onNodesDelete={(nds) => {
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
}}
onNodeClick={(_, node) => {
setMenu(null);
const d = node.data as SceneCardData;
onCurrentSceneChange(d.sceneId);
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
}}
onPaneClick={() => {
setMenu(null);
}}
onPaneContextMenu={(e) => {
e.preventDefault();
setMenu(null);
}}
onInit={(instance) => {
instance.fitView({ padding: 0.25 });
}}
onDragOver={onDragOver}
onDrop={onDrop}
panOnScroll
selectionOnDrag={false}
deleteKeyCode={['Backspace', 'Delete']}
proOptions={{ hideAttribution: true }}
>
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
<GraphZoomToolbar />
</ReactFlow>
{menu && menuPosition
? createPortal(
<>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
if (menuNodeIsStart) {
onSetGraphNodeStart(null);
} else {
onSetGraphNodeStart(menu.graphNodeId);
}
setMenu(null);
aria-label={ui.closeMenu}
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.ctxMenu}
style={{ left: menuPosition.x, top: menuPosition.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
{menuNodeIsStart ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
</button>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onRemoveGraphNode(menu.graphNodeId);
setMenu(null);
}}
>
Удалить
</button>
</div>
</>,
document.body,
)
: null}
</div>
<button
type="button"
role="menuitem"
className={styles.ctxItem}
onClick={() => {
if (menuNodeIsStart) {
onSetGraphNodeStart(null);
} else {
onSetGraphNodeStart(menu.graphNodeId);
}
setMenu(null);
}}
>
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
</button>
<button
type="button"
role="menuitem"
className={styles.ctxItemDanger}
onClick={() => {
onRemoveGraphNode(menu.graphNodeId);
setMenu(null);
}}
>
{ui.delete}
</button>
</div>
</>,
document.body,
)
: null}
</div>
</GraphUiContext.Provider>
);
}
@@ -0,0 +1,55 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import {
EDITOR_LOCALE_STORAGE_KEY,
inferEditorLocaleFromSystem,
normalizeEditorLocale,
translateEditorMessage,
type EditorLocale,
} from './editorMessages';
type EditorI18nContextValue = {
locale: EditorLocale;
setLocale: (next: EditorLocale) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
};
const EditorI18nContext = createContext<EditorI18nContextValue | null>(null);
function readInitialLocale(): EditorLocale {
try {
return normalizeEditorLocale(localStorage.getItem(EDITOR_LOCALE_STORAGE_KEY));
} catch {
return inferEditorLocaleFromSystem();
}
}
export function EditorI18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<EditorLocale>(readInitialLocale);
const setLocale = useCallback((next: EditorLocale) => {
setLocaleState(next);
try {
localStorage.setItem(EDITOR_LOCALE_STORAGE_KEY, next);
} catch {
// ignore
}
}, []);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
[locale],
);
const value = useMemo<EditorI18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <EditorI18nContext.Provider value={value}>{children}</EditorI18nContext.Provider>;
}
export function useEditorI18n(): EditorI18nContextValue {
const ctx = useContext(EditorI18nContext);
if (!ctx) {
throw new Error('useEditorI18n must be used within EditorI18nProvider');
}
return ctx;
}
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
});
void test('inferEditorLocaleFromSystem: ru-* wins when listed first', () => {
assert.equal(inferEditorLocaleFromSystem(['ru-RU', 'en-US']), 'ru');
});
void test('inferEditorLocaleFromSystem: unknown tags fall back to ru', () => {
assert.equal(inferEditorLocaleFromSystem(['de-DE', 'fr']), 'ru');
});
void test('inferEditorLocaleFromSystem: empty list → ru', () => {
assert.equal(inferEditorLocaleFromSystem([]), 'ru');
});
void test('normalizeEditorLocale: trims stored en/ru', () => {
assert.equal(normalizeEditorLocale(' EN '), 'en');
assert.equal(normalizeEditorLocale('ru '), 'ru');
});
void test('normalizeEditorLocale: blank or invalid defers to infer (explicit list)', () => {
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
});
+500
View File
@@ -0,0 +1,500 @@
export type EditorLocale = 'ru' | 'en';
export const EDITOR_LOCALE_STORAGE_KEY = 'dnd_editor_locale';
function primaryLanguageTag(lang: string): string {
const trimmed = lang.trim().toLowerCase();
if (!trimmed) return '';
const sep = trimmed.search(/[-_]/);
return sep === -1 ? trimmed : trimmed.slice(0, sep);
}
/**
* Выбор `ru` / `en` по языку ОС/браузера, если пользователь ещё не сохранил язык в `localStorage`.
* В Electron совпадает с локалью системы (Chromium подставляет `navigator.languages`).
*/
export function inferEditorLocaleFromSystem(languages?: readonly string[]): EditorLocale {
let list: string[];
if (languages !== undefined) {
list = [...languages];
} else if (typeof navigator !== 'undefined') {
list = [...navigator.languages];
if (navigator.language) {
list.push(navigator.language);
}
list = list.filter((x) => x.trim() !== '');
} else {
list = [];
}
for (const lang of list) {
const tag = primaryLanguageTag(lang);
if (tag === 'en') return 'en';
if (tag === 'ru') return 'ru';
}
return 'ru';
}
export function normalizeEditorLocale(raw: string | null | undefined): EditorLocale {
if (raw == null) {
return inferEditorLocaleFromSystem();
}
const trimmed = raw.trim();
if (trimmed === '') {
return inferEditorLocaleFromSystem();
}
const s = trimmed.toLowerCase();
if (s === 'en') return 'en';
if (s === 'ru') return 'ru';
return inferEditorLocaleFromSystem();
}
/** Flat message table; `{name}` placeholders supported in `translate`. */
export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
ru: {
'common.close': 'Закрыть',
'common.cancel': 'Отмена',
'common.save': 'Сохранить',
'common.understood': 'Понятно',
'common.message': 'Сообщение',
'common.error': 'Ошибка',
'common.delete': 'Удалить',
'common.closeMenu': 'Закрыть меню',
'notice.campaignAudioEmpty': 'Аудио не добавлено. Проверьте формат файла.',
'license.checkingTitle': 'Проверка лицензии…',
'license.checkingWait': 'Подождите.',
'license.requiredTitle': 'Требуется лицензия',
'license.requiredHint':
'Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».',
'license.tokenTitle': 'Указать ключ',
'license.tokenKey': 'КЛЮЧ',
'license.tokenPlaceholder': 'Продуктовый ключ DND-...',
'license.tokenSaving': 'Сохранение…',
'license.eulaTitle': 'Лицензионное соглашение',
'license.eulaReject': 'Не принимаю',
'license.eulaAccept': 'Принимаю условия',
'license.eulaNoteEn':
'The binding legal text below is in Russian. If you need an English summary, contact support.',
'license.aboutTitle': 'О лицензии',
'license.aboutDevSkip': 'Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).',
'license.aboutStatus': 'СТАТУС',
'license.aboutProduct': 'ПРОДУКТ',
'license.aboutLicenseId': 'ID ЛИЦЕНЗИИ',
'license.aboutExpiry': 'ОКОНЧАНИЕ',
'license.aboutDevice': 'УСТРОЙСТВО',
'license.aboutNoData': 'Нет данных лицензии.',
'license.reason.ok': 'Активна',
'license.reason.none': 'Ключ не указан',
'license.reason.expired': 'Срок действия истёк',
'license.reason.bad_signature': 'Недействительная подпись',
'license.reason.bad_payload': 'Неверный формат токена',
'license.reason.malformed': 'Повреждённый токен',
'license.reason.not_yet_valid': 'Ещё не действует',
'license.reason.wrong_device': 'Другой привязанный компьютер',
'license.reason.revoked_remote': 'Отозвана на сервере',
'presentation.overlay': 'Презентация запущена',
'presentation.title': 'Презентация запущена',
'presentation.body':
'Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.',
'zip.progress': 'Прогресс операции',
'zip.importTitle': 'Импорт проекта',
'zip.exportTitle': 'Экспорт проекта',
'top.settings': 'Настройки',
'top.project': 'Проект',
'top.file': 'Файл',
'top.backToProjects': 'К списку проектов',
'top.appVersion': 'Версия приложения',
'top.run': 'Запустить',
'top.afterLicense': 'Доступно после активации лицензии',
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
'menu.enterKey': 'Указать ключ',
'menu.aboutLicense': 'О лицензии',
'menu.language': 'Язык',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'projectMenu.home': 'Начальный экран',
'projectMenu.import': 'Импорт',
'projectMenu.export': 'Экспорт',
'projectMenu.noProjects': 'Нет сохранённых проектов',
'fileMenu.rename': 'Переименовать проект',
'scenes.search': 'Поиск сцен…',
'scenes.new': '+ Новая сцена',
'scenes.inspectorGame': 'Свойства игры',
'scenes.inspectorScene': 'Свойства сцены',
'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.',
'scenes.openProjectHint': 'Откройте проект, чтобы редактировать кампанию и сцены.',
'rename.title': 'Переименовать проект',
'rename.projectName': 'НАЗВАНИЕ ПРОЕКТА',
'rename.projectPlaceholder': 'Название проекта…',
'rename.projectMin': 'Минимум 3 символа.',
'rename.projectDup': 'Проект с таким названием уже существует.',
'rename.fileName': 'НАЗВАНИЕ ФАЙЛА ПРОЕКТА',
'rename.fileInvalid': 'Минимум 3 символа, без символов <>:"/\\|?*',
'rename.fileDup': 'Файл проекта с таким названием уже существует.',
'rename.saving': 'Сохранение…',
'export.title': 'Экспорт проекта',
'export.project': 'ПРОЕКТ',
'export.hint':
'Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия архива проекта.',
'export.exporting': 'Экспорт…',
'export.saveAs': 'Сохранить как…',
'confirmDelete.title': 'Удаление проекта',
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
'confirmDelete.failedTitle': 'Не удалось удалить',
'picker.title': 'Проекты',
'picker.newPlaceholder': 'Название нового проекта…',
'picker.create': 'Создать проект',
'picker.existing': 'СУЩЕСТВУЮЩИЕ',
'picker.lockedHint':
'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.',
'picker.empty': 'Пока нет проектов.',
'picker.projectMenu': 'Меню проекта',
'picker.openDisabled': 'Открытие проекта — после активации лицензии',
'picker.defaultName': 'Моя кампания',
'campaign.label': 'АУДИО ИГРЫ',
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
'campaign.auto': 'Авто',
'campaign.loop': 'Цикл',
'campaign.removeTitle': 'Убрать из кампании',
'campaign.upload': 'Загрузить',
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
'scene.description': 'ОПИСАНИЕ',
'scene.preview': 'ПРЕВЬЮ СЦЕНЫ',
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
'scene.previewEmpty': 'Превью не задано',
'scene.previewBusy': 'Загрузка и оптимизация изображения…',
'scene.change': 'Изменить',
'scene.clear': 'Очистить',
'scene.autostart': 'Автостарт',
'scene.rotate': 'Повернуть',
'scene.audio': 'АУДИО СЦЕНЫ',
'scene.removeTitle': 'Убрать из сцены',
'scene.branching': 'ВЕТВЛЕНИЯ',
'scene.branchingHint':
'Перетащите сцену из списка на граф. С одной карточки можно задать несколько вариантов — по одной связи на каждую целевую сцену. Повторно к той же сцене (включая вторую карточку той же сцены на графе) подключить нельзя.',
'sceneCard.current': 'ТЕКУЩАЯ',
'sceneCard.menu': 'Меню сцены',
'graph.badgeStart': 'НАЧАЛО',
'graph.untitled': 'Без названия',
'graph.videoBadge': 'Видео',
'graph.audioBadge': 'Аудио',
'graph.loop': 'Цикл',
'graph.autoplay': 'Автостарт',
'graph.previewAutostart': 'Авто превью',
'graph.videoLoop': 'Цикл видео',
'graph.zoomBar': 'Масштаб графа',
'graph.zoomIn': 'Увеличить',
'graph.zoomOut': 'Уменьшить',
'graph.fitAll': 'Показать всё',
'graph.startScene': 'Начальная сцена',
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Инструменты',
'control.fieldEffects': 'Эффекты поля',
'control.actionEffects': 'Эффекты действий',
'control.eraser': 'Ластик',
'control.clearEffects': 'Очистить эффекты',
'control.fog': 'Туман',
'control.rain': 'Дождь',
'control.fire': 'Огонь',
'control.water': 'Вода',
'control.lightning': 'Молния',
'control.sunbeam': 'Луч света',
'control.freeze': 'Заморозка',
'control.poisonCloud': 'Облако яда',
'control.brushRadius': 'Радиус кисти',
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
'control.gotoScene': 'Перейти к этой сцене',
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
'control.passed': 'Пройдено',
'control.noActiveScene': 'Нет активной сцены.',
'control.screenPreview': 'Предпросмотр экрана',
'control.stopPresentation': 'Выключить демонстрацию',
'control.videoBrushHint':
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
'control.branches': 'Варианты ветвления',
'control.option': 'ОПЦИЯ {n}',
'control.unnamed': 'Без названия',
'control.switchScene': 'Переключить',
'control.noBranches': 'Нет вариантов перехода.',
'control.endPresentation': 'Завершить показ',
'control.music': 'Музыка',
'control.sceneMusic': 'МУЗЫКА СЦЕНЫ',
'control.gameMusic': 'МУЗЫКА ИГРЫ',
'control.noSceneAudio': 'В текущей сцене нет аудио.',
'control.noGameAudio': 'В игре нет аудио.',
'control.modeAuto': 'Авто',
'control.modeManual': 'Ручн.',
'control.once': 'Один раз',
'control.loop': 'Цикл',
'control.scrubSeek': 'Клик — перемотка',
'control.durationUnknown': 'Длительность неизвестна',
'control.pauseSceneMusic': 'Пауза (сцена)',
'control.pauseSceneMusicTitle': 'В сцене есть музыка',
'control.pauseCampaignTitle': 'Пауза: в сцене есть музыка',
'control.playFailed': 'Не удалось запустить.',
'control.audioAutoplayBlocked':
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
'control.audioNoUrl': 'URL не получен',
'control.audioNoUrlDetail': 'Не удалось получить dnd://asset URL для аудио.',
'control.audioBlocked': 'Ошибка/блок',
'control.audioError': 'Ошибка',
'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)',
'control.audioLoading': 'Загрузка…',
'control.audioPlaying': 'Играет',
'control.audioPaused': 'Пауза',
'control.audioStopped': 'Остановлено',
'control.previewTrackLabel': 'Превью без субтитров',
'control.transportPlay': 'Воспроизведение',
'control.transportPause': 'Пауза',
'control.transportStop': 'Стоп',
},
en: {
'common.close': 'Close',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.understood': 'OK',
'common.message': 'Message',
'common.error': 'Error',
'common.delete': 'Delete',
'common.closeMenu': 'Close menu',
'notice.campaignAudioEmpty': 'No audio was added. Check the file format.',
'license.checkingTitle': 'Checking license…',
'license.checkingWait': 'Please wait.',
'license.requiredTitle': 'License required',
'license.requiredHint':
'Enter your key via Settings → Enter license key. Until activation, only Settings is available.',
'license.tokenTitle': 'Enter license key',
'license.tokenKey': 'KEY',
'license.tokenPlaceholder': 'DND product key…',
'license.tokenSaving': 'Saving…',
'license.eulaTitle': 'End User License Agreement',
'license.eulaReject': 'Decline',
'license.eulaAccept': 'I accept the terms',
'license.eulaNoteEn':
'The binding legal text below is in Russian. If you need an English summary, contact support.',
'license.aboutTitle': 'About license',
'license.aboutDevSkip': 'Development mode: license checks are disabled (DND_SKIP_LICENSE).',
'license.aboutStatus': 'STATUS',
'license.aboutProduct': 'PRODUCT',
'license.aboutLicenseId': 'LICENSE ID',
'license.aboutExpiry': 'EXPIRES',
'license.aboutDevice': 'DEVICE',
'license.aboutNoData': 'No license data.',
'license.reason.ok': 'Active',
'license.reason.none': 'No key provided',
'license.reason.expired': 'Expired',
'license.reason.bad_signature': 'Invalid signature',
'license.reason.bad_payload': 'Invalid token format',
'license.reason.malformed': 'Malformed token',
'license.reason.not_yet_valid': 'Not yet valid',
'license.reason.wrong_device': 'Wrong bound device',
'license.reason.revoked_remote': 'Revoked on server',
'presentation.overlay': 'Presentation running',
'presentation.title': 'Presentation running',
'presentation.body': 'The editor is locked. Close the Presentation and Control windows to continue.',
'zip.progress': 'Operation progress',
'zip.importTitle': 'Import project',
'zip.exportTitle': 'Export project',
'top.settings': 'Settings',
'top.project': 'Project',
'top.file': 'File',
'top.backToProjects': 'Back to projects',
'top.appVersion': 'App version',
'top.run': 'Run',
'top.afterLicense': 'Available after license activation',
'top.setStartScene': 'Set a start scene on the graph (rightclick a node)',
'menu.enterKey': 'Enter license key',
'menu.aboutLicense': 'About license',
'menu.language': 'Language',
'menu.langRu': 'Русский',
'menu.langEn': 'English',
'projectMenu.home': 'Home',
'projectMenu.import': 'Import',
'projectMenu.export': 'Export',
'projectMenu.noProjects': 'No saved projects',
'fileMenu.rename': 'Rename project',
'scenes.search': 'Search scenes…',
'scenes.new': '+ New scene',
'scenes.inspectorGame': 'Game properties',
'scenes.inspectorScene': 'Scene properties',
'scenes.selectHint': 'Select a scene on the left to edit its properties.',
'scenes.openProjectHint': 'Open a project to edit the campaign and scenes.',
'rename.title': 'Rename project',
'rename.projectName': 'PROJECT NAME',
'rename.projectPlaceholder': 'Project name…',
'rename.projectMin': 'At least 3 characters.',
'rename.projectDup': 'A project with this name already exists.',
'rename.fileName': 'PROJECT FILE NAME',
'rename.fileInvalid': 'At least 3 characters; forbidden characters <>:"/\\|?*',
'rename.fileDup': 'A project file with this name already exists.',
'rename.saving': 'Saving…',
'export.title': 'Export project',
'export.project': 'PROJECT',
'export.hint':
'A save dialog will open: choose a name and folder for the .dnd.zip file — a copy of the project archive will be created.',
'export.exporting': 'Exporting…',
'export.saveAs': 'Save as…',
'confirmDelete.title': 'Delete project',
'confirmDelete.body':
'Permanently delete project “{name}”? The file and cache will be removed from disk.',
'confirmDelete.failedTitle': 'Could not delete',
'picker.title': 'Projects',
'picker.newPlaceholder': 'New project name…',
'picker.create': 'Create project',
'picker.existing': 'EXISTING',
'picker.lockedHint':
'Opening and creating projects require an active license. The list still shows files in the app folder.',
'picker.empty': 'No projects yet.',
'picker.projectMenu': 'Project menu',
'picker.openDisabled': 'Open project — after license activation',
'picker.defaultName': 'My campaign',
'campaign.label': 'GAME AUDIO',
'campaign.noFiles': 'No files yet. Add audio.',
'campaign.auto': 'Auto',
'campaign.loop': 'Loop',
'campaign.removeTitle': 'Remove from campaign',
'campaign.upload': 'Upload',
'scene.title': 'SCENE TITLE',
'scene.description': 'DESCRIPTION',
'scene.preview': 'SCENE PREVIEW',
'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
'scene.previewEmpty': 'No preview',
'scene.previewBusy': 'Loading and optimizing image…',
'scene.change': 'Change',
'scene.clear': 'Clear',
'scene.autostart': 'Autostart',
'scene.rotate': 'Rotate',
'scene.audio': 'SCENE AUDIO',
'scene.removeTitle': 'Remove from scene',
'scene.branching': 'BRANCHING',
'scene.branchingHint':
'Drag a scene from the list onto the graph. One card can branch to several targets — one link per target scene. You cannot link twice to the same target (including a second card of the same scene).',
'sceneCard.current': 'CURRENT',
'sceneCard.menu': 'Scene menu',
'graph.badgeStart': 'START',
'graph.untitled': 'Untitled',
'graph.videoBadge': 'Video',
'graph.audioBadge': 'Audio',
'graph.loop': 'Loop',
'graph.autoplay': 'Autoplay',
'graph.previewAutostart': 'Preview autostart',
'graph.videoLoop': 'Video loop',
'graph.zoomBar': 'Graph zoom',
'graph.zoomIn': 'Zoom in',
'graph.zoomOut': 'Zoom out',
'graph.fitAll': 'Fit view',
'graph.startScene': 'Start scene',
'graph.unsetStartScene': 'Clear start scene mark',
'control.remoteTitle': 'CONTROL PANEL',
'control.effects': 'EFFECTS',
'control.tools': 'Tools',
'control.fieldEffects': 'Field effects',
'control.actionEffects': 'Action effects',
'control.eraser': 'Eraser',
'control.clearEffects': 'Clear effects',
'control.fog': 'Fog',
'control.rain': 'Rain',
'control.fire': 'Fire',
'control.water': 'Water',
'control.lightning': 'Lightning',
'control.sunbeam': 'Sunbeam',
'control.freeze': 'Freeze',
'control.poisonCloud': 'Poison cloud',
'control.brushRadius': 'Brush radius',
'control.storyLine': 'STORYLINE',
'control.gotoScene': 'Go to this scene',
'control.currentSceneBadge': 'CURRENT SCENE',
'control.passed': 'Visited',
'control.noActiveScene': 'No active scene.',
'control.screenPreview': 'Screen preview',
'control.stopPresentation': 'Stop presentation',
'control.videoBrushHint':
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
'control.branches': 'Branch options',
'control.option': 'OPTION {n}',
'control.unnamed': 'Untitled',
'control.switchScene': 'Switch',
'control.noBranches': 'No transitions available.',
'control.endPresentation': 'End presentation',
'control.music': 'Music',
'control.sceneMusic': 'SCENE MUSIC',
'control.gameMusic': 'GAME MUSIC',
'control.noSceneAudio': 'No audio in the current scene.',
'control.noGameAudio': 'No game audio.',
'control.modeAuto': 'Auto',
'control.modeManual': 'Manual',
'control.once': 'Once',
'control.loop': 'Loop',
'control.scrubSeek': 'Click to seek',
'control.durationUnknown': 'Duration unknown',
'control.pauseSceneMusic': 'Paused (scene)',
'control.pauseSceneMusicTitle': 'Scene has music',
'control.pauseCampaignTitle': 'Paused: scene has music',
'control.playFailed': 'Could not start playback.',
'control.audioAutoplayBlocked': 'Autoplay was blocked (user gesture required) or playback failed.',
'control.audioNoUrl': 'No URL',
'control.audioNoUrlDetail': 'Could not get dnd://asset URL for audio.',
'control.audioBlocked': 'Error / blocked',
'control.audioError': 'Error',
'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)',
'control.audioLoading': 'Loading…',
'control.audioPlaying': 'Playing',
'control.audioPaused': 'Paused',
'control.audioStopped': 'Stopped',
'control.previewTrackLabel': 'Preview (no captions)',
'control.transportPlay': 'Play',
'control.transportPause': 'Pause',
'control.transportStop': 'Stop',
},
};
export function translateEditorMessage(
locale: EditorLocale,
key: string,
vars?: Record<string, string | number>,
): string {
let s = EDITOR_MESSAGES[locale][key] ?? EDITOR_MESSAGES.ru[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
s = s.split(`{${k}}`).join(String(v));
}
}
return s;
}
@@ -8,6 +8,7 @@ import { EULA_RU_MARKDOWN } from '../../legal/eulaRu';
import { getDndApi } from '../../shared/dndApi';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { useEditorI18n } from '../i18n/EditorI18nContext';
type LicenseTokenModalProps = {
open: boolean;
@@ -16,6 +17,7 @@ type LicenseTokenModalProps = {
};
export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) {
const { t } = useEditorI18n();
const [token, setToken] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -40,28 +42,38 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>Указать ключ</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
<div className={styles.modalTitle}>{t('license.tokenTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>КЛЮЧ</div>
<div className={styles.fieldLabel}>{t('license.tokenKey')}</div>
<textarea
className={styles.licenseTextarea}
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Продуктовый ключ DND-..."
placeholder={t('license.tokenPlaceholder')}
spellCheck={false}
/>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
Отмена
{t('common.cancel')}
</Button>
<Button
variant="primary"
@@ -82,7 +94,7 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
})();
}}
>
Сохранить
{t('common.save')}
</Button>
</div>
</div>
@@ -98,6 +110,7 @@ type EulaModalProps = {
};
export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
const { t, locale } = useEditorI18n();
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -113,18 +126,29 @@ export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>Лицензионное соглашение</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
<div className={styles.modalTitle}>{t('license.eulaTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
{locale === 'en' ? <div className={styles.muted}>{t('license.eulaNoteEn')}</div> : null}
<div className={styles.eulaScroll}>{EULA_RU_MARKDOWN}</div>
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving}>
Не принимаю
{t('license.eulaReject')}
</Button>
<Button
variant="primary"
@@ -144,7 +168,7 @@ export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
})();
}}
>
Принимаю условия
{t('license.eulaAccept')}
</Button>
</div>
</div>
@@ -159,32 +183,16 @@ type LicenseAboutModalProps = {
snapshot: LicenseSnapshot | null;
};
function reasonLabel(reason: LicenseSnapshot['reason']): string {
switch (reason) {
case 'ok':
return 'Активна';
case 'none':
return 'Ключ не указан';
case 'expired':
return 'Срок действия истёк';
case 'bad_signature':
return 'Недействительная подпись';
case 'bad_payload':
return 'Неверный формат токена';
case 'malformed':
return 'Повреждённый токен';
case 'not_yet_valid':
return 'Ещё не действует';
case 'wrong_device':
return 'Другой привязанный компьютер';
case 'revoked_remote':
return 'Отозвана на сервере';
default:
return reason;
}
function licenseReasonLabel(t: (key: string) => string, reason: LicenseSnapshot['reason']): string {
const key = `license.reason.${reason}`;
const label = t(key);
return label === key ? reason : label;
}
export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModalProps) {
const { t, locale } = useEditorI18n();
const dateLocale = locale === 'en' ? 'en-US' : 'ru-RU';
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -198,7 +206,7 @@ export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModal
const expText =
snapshot?.summary?.exp != null
? new Date(snapshot.summary.exp * 1000).toLocaleString('ru-RU', {
? new Date(snapshot.summary.exp * 1000).toLocaleString(dateLocale, {
dateStyle: 'long',
timeStyle: 'short',
})
@@ -206,48 +214,54 @@ export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModal
return createPortal(
<>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>О лицензии</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
<div className={styles.modalTitle}>{t('license.aboutTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
{snapshot?.devSkip ? (
<div className={styles.fieldError}>
Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).
</div>
) : null}
{snapshot?.devSkip ? <div className={styles.fieldError}>{t('license.aboutDevSkip')}</div> : null}
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>СТАТУС</div>
<div>{snapshot ? reasonLabel(snapshot.reason) : '—'}</div>
<div className={styles.fieldLabel}>{t('license.aboutStatus')}</div>
<div>{snapshot ? licenseReasonLabel(t, snapshot.reason) : '—'}</div>
</div>
{snapshot?.summary ? (
<>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>ПРОДУКТ</div>
<div className={styles.fieldLabel}>{t('license.aboutProduct')}</div>
<div>{snapshot.summary.pid}</div>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>ID ЛИЦЕНЗИИ</div>
<div className={styles.fieldLabel}>{t('license.aboutLicenseId')}</div>
<div style={{ wordBreak: 'break-all' }}>{snapshot.summary.sub}</div>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>ОКОНЧАНИЕ</div>
<div className={styles.fieldLabel}>{t('license.aboutExpiry')}</div>
<div>{expText}</div>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>УСТРОЙСТВО</div>
<div className={styles.fieldLabel}>{t('license.aboutDevice')}</div>
<div style={{ wordBreak: 'break-all' }}>{snapshot.deviceId}</div>
</div>
</>
) : (
<div className={styles.muted}>Нет данных лицензии.</div>
<div className={styles.muted}>{t('license.aboutNoData')}</div>
)}
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
Закрыть
{t('common.close')}
</Button>
</div>
</div>
+4 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorApp } from './EditorApp';
import { EditorI18nProvider } from './i18n/EditorI18nContext';
const rootEl = document.getElementById('root');
if (!rootEl) {
@@ -11,6 +12,8 @@ if (!rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<EditorApp />
<EditorI18nProvider>
<EditorApp />
</EditorI18nProvider>
</React.StrictMode>,
);
+12 -2
View File
@@ -58,8 +58,18 @@ function randomId(prefix: string): string {
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
}
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
export type ProjectNoticeCode = 'campaign_audio_empty';
export type ProjectStateOpts = {
onNotice?: (code: ProjectNoticeCode) => void;
};
export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts): readonly [State, Actions] {
const api = getDndApi();
const onNoticeRef = useRef(opts?.onNotice);
useEffect(() => {
onNoticeRef.current = opts?.onNotice;
}, [opts?.onNotice]);
const [state, setState] = useState<State>({
projects: [],
project: null,
@@ -180,7 +190,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
const res = await api.invoke(ipcChannels.project.importCampaignAudio, {});
if (res.canceled) return;
if (res.imported.length === 0) {
window.alert('Аудио не добавлено. Проверьте формат файла.');
onNoticeRef.current?.('campaign_audio_empty');
}
setState((s) => ({ ...s, project: res.project }));
await refreshProjects();
+191
View File
@@ -0,0 +1,191 @@
# Автообновление через Gitea (приватный код + публичный feed)
Исходники остаются в **закрытом** репозитории с игрой. Файлы обновлений (`latest.yml`, установщики) лежат в **отдельном публичном** репозитории — по HTTPS их скачивает `electron-updater`.
## Твой публичный репозиторий (уже есть)
- Клон: `https://git.mailib.ru/ifontosh/DndGamePlayerUpdates.git`
- Владелец/репо для секретов: **`ifontosh/DndGamePlayerUpdates`**
- Базовый URL feed (вшивается в сборку и должен совпадать с secret **`DND_UPDATE_FEED_URL`**):
**`https://git.mailib.ru/ifontosh/DndGamePlayerUpdates/raw/branch/updates/`**
Обрати внимание: слово **`branch`** в пути — это часть URL Gitea, не имя ветки. Имя ветки — в конце: **`updates`**.
В `package.json` уже указан этот же `build.publish.url` (для локального `npm run pack` и метаданных electron-builder).
---
## Шаг 0 — репозиторий сейчас пустой (важно)
На странице репо написано, что **контента нет**. Пока нет **ни одного коммита**, CI не сможет сделать `git clone`.
Сделай так (любой способ):
1. На сайте открой `ifontosh/DndGamePlayerUpdates` → кнопка вроде **«Инициализировать репозиторий»** / **«Добавить файл»** → создай файл `README.md` с парой строк и закоммить **в ветку по умолчанию** (часто `main`).
2. Либо с компьютера:
```bash
git clone https://git.mailib.ru/ifontosh/DndGamePlayerUpdates.git
cd DndGamePlayerUpdates
echo "# DndGamePlayer updates feed" > README.md
git add README.md
git commit -m "init"
git push origin main
```
(Если ветка по умолчанию у тебя `master` — подставь её вместо `main`.)
После этого репозиторий **не пустой** — workflow сможет клонировать и создать ветку **`updates`**.
---
## Шаг 1 — токен для записи в публичный репозиторий
Нужен **персональный токен** (PAT), с которым CI сможет **пушить** в `DndGamePlayerUpdates`.
В Gitea на русском обычно так (названия могут чуть отличаться в твоей теме):
1. Вверху справа **аватар****Настройки** (или «Параметры»).
2. Слева найди раздел вроде **«Приложения»** / **«Токены доступа»** / **«Токены»** (в англ. интерфейсе: **Settings → Applications → Generate New Token**).
3. Создай новый токен:
- имя: например `dnd-release-ci`;
- права: достаточно доступа к репозиторию с **записью** (для пуша в `DndGamePlayerUpdates`). Если есть галочки — включи что-то вроде **«Запись в репозиторий»** / **write:repository** / полный доступ к репо.
4. **Скопируй токен один раз** и сохрани в менеджере паролей — потом Gitea его не покажет.
Этот токен пойдёт в secret **`DND_UPDATES_PUSH_TOKEN`** (см. ниже). **Не вставляй токен в код и не коммить его.**
> На **git.mailib.ru** имена секретов **не могут начинаться с `GITEA_`** (зарезервировано). Поэтому в workflow используются `DND_UPDATES_*`, а не `GITEA_*`.
---
## Шаг 2 — секреты в приватном репозитории с кодом (`dnd_player`)
Открой **приватный** репозиторий, где лежит исходник игры (не `DndGamePlayerUpdates`).
Дальше (русский Gitea 1.26, ориентир по меню):
1. Вкладка **«Настройки»** репозитория (вверху рядом с «Код», «Задачи» — иногда шестерёнка **«Настройки»**).
2. Слева раздел **«Секреты»** / **«Действия»** → **«Секреты»** (в англ. UI: **Settings → Actions → Secrets**). Если видишь **«Переменные»** — секреты обычно рядом; нам нужны именно **секреты** (скрытые значения).
Добавь **четыре** секрета (имя **точно** как в таблице — workflow их читает):
| Имя секрета | Что вписать в значение |
| ------------------------ | -------------------------------------------------------------------------------------------------------- |
| `DND_UPDATE_FEED_URL` | `https://git.mailib.ru/ifontosh/DndGamePlayerUpdates/raw/branch/updates/` (обязательно **слэш в конце**) |
| `DND_UPDATES_SERVER` | `https://git.mailib.ru` (**без** слэша в конце) |
| `UPDATES_REPO` | `ifontosh/DndGamePlayerUpdates` |
| `DND_UPDATES_PUSH_TOKEN` | токен из шага 1 (одна длинная строка) |
Сохрани каждый секрет кнопкой вроде **«Сохранить»** / **«Добавить»**.
Если раньше создавал секреты `GITEA_SERVER` / `GITEA_TOKEN` — их workflow **не читает**; удали или оставь, но **обязательно** заведи новые имена из таблицы.
---
## Шаг 3 — включить Actions (если ещё не включены)
1. В том же **приватном** репозитории: **«Настройки»**.
2. Раздел **«Действия»** / **«Actions»** — включи использование Actions для этого репозитория, если Gitea это спрашивает.
3. Убедись, что в корне репозитория есть файл **`.gitea/workflows/release.yml`** (он уже в проекте `dnd_player`).
Бегунки Gitea должны иметь доступ в интернет (для `npm ci`, `actions/checkout` и т.д.) — это настраивает админ сервера.
---
## Шаг 4 — выпуск версии
1. В `package.json` версия должна совпасть с тегом (workflow при пуше тега делает `npm version` из имени тега — удобно).
2. Закоммить все изменения в **приватный** репо, затем:
```bash
git tag v1.0.1
git push origin main
git push origin v1.0.1
```
(ветка может быть не `main` — подставь свою.)
3. Открой в Gitea **«Действия»** / **«Actions»** у приватного репо — должен появиться запуск **Release**. Дождись зелёных галочек у job’ов Windows, macOS и **publish-update-feed**.
4. После успеха открой **публичный** `DndGamePlayerUpdates` → ветка **`updates`** — в корне должны появиться `latest.yml`, установщики и т.д.
---
## Контрольный чеклист
1. **`package.json``build.publish.url`** =
`https://git.mailib.ru/ifontosh/DndGamePlayerUpdates/raw/branch/updates/`
Совпадает с **`DND_UPDATE_FEED_URL`** (со слэшем в конце).
2. В **DndGamePlayerUpdates** есть хотя бы один коммит (не пустой репо).
3. В приватном репо заданы **все четыре** секрета из таблицы шага 2 (имена **не** начинаются с `GITEA_`).
4. В репо с кодом есть **`.gitea/workflows/release.yml`**.
5. Релиз: пуш тега `v*` → в Actions три job’а; в публичном репо появляется ветка **`updates`** с `latest.yml` и установщиками.
6. В приложении: обновления только **`app.isPackaged`** и при **активной лицензии** (см. `app/main/update/installAutoUpdater.ts`).
---
## Поведение приложения
- Проверка только в **собранной** установке (`app.isPackaged`).
- Только если **лицензия активна**.
- Первый запрос примерно через **12 с** после старта; при смене лицензии — снова (не чаще **30 с**).
- Для отладки можно задать переменную окружения **`DND_UPDATE_FEED_URL`** при запуске `.exe` — переопределит feed.
Подпись кода в CI отключена: `CSC_IDENTITY_AUTO_DISCOVERY=false`.
---
## Локальная отладка feed
Запуск установленного приложения с другим URL (без пересборки): задать **`DND_UPDATE_FEED_URL`** в ярлыке или системных переменных окружения.
---
## Как сделать так, чтобы ассистент в Cursor мог «выпустить релиз»
У ассистента **нет своего аккаунта** на `git.mailib.ru`. Релиз = **появление тега `v*`** на нужном коммите в **приватном** репозитории с кодом → Gitea Actions собирает и пушит feed. Ниже два рабочих варианта.
### Вариант A — Gitea MCP (удобно из чата)
У тебя уже подключён MCP **user-gitea-mailib** с инструментом **`create_tag`**: по API создаётся тег на сервере (аналог `git tag` + `git push` тега).
**Что сделать один раз:**
1. В Cursor MCP для Gitea должен быть **включён и залогинен** под учёткой, у которой есть право **создавать теги** в приватном репо с `dnd_player`.
2. Знать **`owner`** и **`repo`** этого репозитория (как в URL: `https://git.mailib.ru/OWNER/REPO`).
**Как просить в чате:**
«Создай тег `v1.0.2` в репозитории `OWNER/REPO` с целевой веткой `main`» (или укажи SHA коммита). Перед этим **весь код релиза уже должен быть запушен** в эту ветку — тег вешается на последний коммит (или на явный `target`).
После создания тега зайди в **Действия** репозитория и проверь workflow **Release**.
_(Отдельно: `create_release` в MCP создаёт **запись релиза** на Gitea; сборку у тебя запускает именно **тег** и `release.yml`.)_
### Вариант B — команды `git` в терминале Cursor
Ассистент может выполнить у тебя локально:
```bash
git fetch origin
git tag v1.0.2 origin/main # или другая ветка / коммит
git push origin v1.0.2
```
**Что сделать один раз:**
1. Чтобы `git push` **не спрашивал пароль** каждый раз: настроить **учётные данные** (Windows: диспетчер учётных данных / `git credential-manager`; либо **SSH-ключ** и remote `git@git.mailib.ru:...`).
2. Убедиться, что из того же окружения, где работает Cursor, `git push` в приватный репо уже проходил успешно.
Тогда в чате можно написать: «Поставь тег `v1.0.2` на `origin/main` и запушь тег» — ассистент выполнит команды в `dnd_player`.
### Ограничения
- Ассистент **не видит** твои пароли и не обходит Gitea: всё упирается в **MCP-токен** или **твои локальные git credentials**.
- Если MCP отключён и git без настроенного доступа — релиз тегом придётся пушить **тебе** вручную (как в шаге 4 выше).
---
## Про коммит и пуш кода (не тег)
Закоммитить и запушить **изменения в файлах** ассистент может через те же механизмы: либо ты делаешь `git push` после правок, либо настроенный **git** / отдельный скрипт. Без доступа к remote ассистент только правит файлы в рабочей копии.
+119 -29
View File
@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1",
"react": "^19.2.5",
@@ -81,7 +82,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -714,6 +714,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -735,6 +736,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -751,6 +753,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -765,10 +768,33 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -3496,7 +3522,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3601,7 +3626,6 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -4159,7 +4183,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4193,7 +4216,6 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4460,7 +4482,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
@@ -4803,7 +4824,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -4906,7 +4926,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -4991,7 +5010,6 @@
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -5656,7 +5674,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -5770,7 +5789,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -6083,7 +6101,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -6436,6 +6453,69 @@
"dev": true,
"license": "ISC"
},
"node_modules/electron-updater": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@@ -6443,6 +6523,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -6463,6 +6544,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -6725,7 +6807,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -6790,7 +6871,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6851,7 +6931,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -6978,7 +7057,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8155,7 +8233,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-bigints": {
@@ -9219,7 +9296,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -9350,7 +9426,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": {
@@ -9658,6 +9733,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -10038,6 +10126,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -10816,6 +10905,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10833,6 +10923,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10853,7 +10944,6 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10983,7 +11073,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10993,7 +11082,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11058,8 +11146,7 @@
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
@@ -11261,6 +11348,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -11424,7 +11512,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -12216,6 +12303,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -12302,6 +12390,12 @@
"node": ">=12"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -13016,7 +13110,6 @@
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13131,7 +13224,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -13269,7 +13361,6 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -13627,7 +13718,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+6 -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/graph/sceneCardById.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.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/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",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.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/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",
"format": "prettier . --check",
"format:write": "prettier . --write",
"release:info": "node scripts/print-release-info.mjs",
@@ -24,6 +24,7 @@
"license": "ISC",
"type": "module",
"dependencies": {
"electron-updater": "^6.6.2",
"@fontsource/inter": "^5.2.8",
"ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1",
@@ -124,6 +125,10 @@
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"publish": {
"provider": "generic",
"url": "https://git.mailib.ru/ifontosh/DndGamePlayerUpdates/raw/branch/updates/"
}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ async function buildNodeTargets() {
bundle: true,
minify: isProd,
sourcemap: !isProd,
external: ['electron', 'sharp', 'ffmpeg-static'],
external: ['electron', 'electron-updater', 'sharp', 'ffmpeg-static'],
define,
drop: isProd ? ['console', 'debugger'] : [],
};
+102
View File
@@ -0,0 +1,102 @@
/**
* Складывает артефакты electron-builder (win + mac) в публичный репозиторий,
* ветка `updates`, чтобы generic URL …/raw/branch/updates/ указывал на актуальные latest*.yml и установщики.
*
* Переменные окружения (имена без префикса GITEA_ — в Gitea секреты GITEA_* зарезервированы):
* DND_UPDATES_SERVER — https://git.example.com (без слэша в конце)
* UPDATES_REPO — owner/repo (публичный репозиторий «только релизы»)
* DND_UPDATES_PUSH_TOKEN — PAT с правом push в UPDATES_REPO
* ARTIFACT_WIN — каталог с файлами сборки Windows
* ARTIFACT_MAC — каталог с файлами сборки macOS
* GIT_COMMIT_TAG — опционально, для сообщения коммита
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ALLOWED_EXT = new Set(['.yml', '.yaml', '.exe', '.blockmap', '.zip', '.dmg', '.pkg']);
function mustEnv(name) {
const v = process.env[name]?.trim();
if (!v) throw new Error(`Missing env ${name}`);
return v;
}
function copyFlatReleaseFiles(fromDir, toDir) {
if (!fs.existsSync(fromDir)) {
console.warn(`[sync-update-feed] skip missing dir: ${fromDir}`);
return 0;
}
let n = 0;
for (const name of fs.readdirSync(fromDir)) {
const src = path.join(fromDir, name);
if (!fs.statSync(src).isFile()) continue;
const ext = path.extname(name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) continue;
fs.copyFileSync(src, path.join(toDir, name));
n += 1;
}
return n;
}
function runGit(args, cwd) {
execFileSync('git', args, { cwd, stdio: 'inherit' });
}
function emptyWorkingTreeExceptGit(cwd) {
for (const ent of fs.readdirSync(cwd)) {
if (ent === '.git') continue;
fs.rmSync(path.join(cwd, ent), { recursive: true, force: true });
}
}
function main() {
const server = mustEnv('DND_UPDATES_SERVER').replace(/\/+$/u, '');
const updatesRepo = mustEnv('UPDATES_REPO');
const token = mustEnv('DND_UPDATES_PUSH_TOKEN');
const winDir = mustEnv('ARTIFACT_WIN');
const macDir = mustEnv('ARTIFACT_MAC');
const u = new URL(server);
const host = u.host;
const cloneUrl = `https://oauth2:${encodeURIComponent(token)}@${host}/${updatesRepo}.git`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-feed-'));
const work = path.join(tmp, 'repo');
try {
execFileSync('git', ['clone', '--depth', '1', '-b', 'updates', cloneUrl, work], { stdio: 'inherit' });
} catch {
execFileSync('git', ['clone', '--depth', '1', cloneUrl, work], { stdio: 'inherit' });
runGit(['checkout', '-B', 'updates'], work);
}
runGit(['config', 'user.email', 'ci@gitea-actions.local'], work);
runGit(['config', 'user.name', 'gitea-actions'], work);
emptyWorkingTreeExceptGit(work);
const copied = copyFlatReleaseFiles(winDir, work) + copyFlatReleaseFiles(macDir, work);
if (copied === 0) {
throw new Error('[sync-update-feed] no release files copied (check ARTIFACT_WIN / ARTIFACT_MAC)');
}
const tag = process.env.GIT_COMMIT_TAG?.trim() || 'ci';
runGit(['add', '-A'], work);
const st = execFileSync('git', ['status', '--porcelain'], { cwd: work }).toString().trim();
if (st) {
runGit(['commit', '-m', `update feed ${tag}`], work);
runGit(['push', '-u', 'origin', 'updates'], work);
} else {
console.warn('[sync-update-feed] nothing to commit (identical artifacts?)');
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(`[sync-update-feed] done (${String(copied)} file(s) staged)`);
}
main();