From d94a11d466b6767d21af3baa53a0bbc9cc8619c2 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Fri, 24 Apr 2026 07:04:42 +0800 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80:=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D0=BE=D1=80=D0=BE=D1=82=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B,=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20zip,=20dev-?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RotatedImage: размер контейнера через clientWidth/Height (не getBoundingClientRect), чтобы cover при 90°/270° работал под zoom React Flow; убраны отладочные логи. Главное меню в dev: пункт «Вид» с DevTools (Ctrl+Shift+I без пустого application menu). Список проектов: project.list без лицензии; список подгружается при неактивной лицензии; ProjectPicker с подсказками; listProjects пропускает битые zip. Сохранение проектов: atomicReplace — замена zip без rm до commit; восстановление *.dnd.zip.tmp при старте; тесты. EditorApp: блокировка UI при открытых окнах презентации и пульта; стили оверлея. Made-with: Cursor --- app/main/index.ts | 35 ++++++- app/main/ipc/router.ts | 2 + app/main/project/atomicReplace.ts | 82 ++++++++++++++++ app/main/project/replaceFileAtomic.test.ts | 28 ++++++ .../project/zipStore.legacyContract.test.ts | 19 ++++ app/main/project/zipStore.ts | 51 ++++------ app/main/windows/createWindows.ts | 17 ++++ app/renderer/editor/EditorApp.module.css | 37 +++++++ app/renderer/editor/EditorApp.tsx | 97 ++++++++++++++++--- .../editor/state/projectState.race.test.ts | 7 +- app/renderer/editor/state/projectState.ts | 45 ++++++--- app/renderer/shared/RotatedImage.tsx | 47 ++++++--- app/shared/ipc/contracts.ts | 7 ++ package.json | 2 +- 14 files changed, 395 insertions(+), 81 deletions(-) create mode 100644 app/main/project/atomicReplace.ts create mode 100644 app/main/project/replaceFileAtomic.test.ts diff --git a/app/main/index.ts b/app/main/index.ts index 3153a36..5b1aebe 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -21,6 +21,7 @@ import { createEditorWindowDeferred, createWindows, focusEditorWindow, + isMultiWindowOpen, markAppQuitting, openMultiWindow, togglePresentationFullscreen, @@ -77,6 +78,35 @@ if (!gotTheLock) { } const projectStore = new ZipProjectStore(); + +/** Без меню Electron не вешает горячие клавиши DevTools (Ctrl+Shift+I / F12). */ +function wantsDevToolsMenu(): boolean { + return ( + process.env.NODE_ENV === 'development' || + Boolean(process.env.VITE_DEV_SERVER_URL) || + process.env.DND_OPEN_DEVTOOLS === '1' + ); +} + +function installAppMenuForSession(): void { + if (!wantsDevToolsMenu()) { + Menu.setApplicationMenu(null); + return; + } + const template: Electron.MenuItemConstructorOptions[] = []; + if (process.platform === 'darwin') { + template.push({ + label: app.name, + submenu: [{ role: 'about' }, { type: 'separator' }, { role: 'quit' }], + }); + } + template.push({ + label: 'Вид', + submenu: [{ role: 'reload' }, { role: 'forceReload' }, { type: 'separator' }, { role: 'toggleDevTools' }], + }); + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + const effectsStore = new EffectsStore(); const videoStore = new VideoPlaybackStore(); @@ -211,7 +241,7 @@ async function main() { setLicenseAssert(() => { licenseService.assertForIpc(); }); - Menu.setApplicationMenu(null); + installAppMenuForSession(); registerDndAssetProtocol(projectStore); registerHandler(ipcChannels.app.quit, () => { markAppQuitting(); @@ -238,6 +268,9 @@ async function main() { const isFullScreen = togglePresentationFullscreen(); return { ok: true, isFullScreen }; }); + registerHandler(ipcChannels.windows.getMultiWindowState, () => { + return { open: isMultiWindowOpen() }; + }); registerHandler(ipcChannels.project.list, async () => { const projects = await projectStore.listProjects(); diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts index 8507901..8f879da 100644 --- a/app/main/ipc/router.ts +++ b/app/main/ipc/router.ts @@ -19,6 +19,8 @@ function channelRequiresLicense(channel: string): boolean { if (channel.startsWith('app.')) return false; if (channel === ipcChannels.windows.closeMultiWindow) return false; if (channel === ipcChannels.windows.togglePresentationFullscreen) return false; + // Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать». + if (channel === ipcChannels.project.list) return false; return true; } diff --git a/app/main/project/atomicReplace.ts b/app/main/project/atomicReplace.ts new file mode 100644 index 0000000..c86acd3 --- /dev/null +++ b/app/main/project/atomicReplace.ts @@ -0,0 +1,82 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { readProjectJsonFromZip } from './yauzlProjectZip'; + +/** + * Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`). + * Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус). + */ +export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise { + if (completedSrc === finalPath) return; + + const stSrc = await fs.stat(completedSrc).catch(() => null); + if (!stSrc?.isFile() || stSrc.size === 0) { + throw new Error(`replaceFileAtomic: нет или пустой источник: ${completedSrc}`); + } + + try { + await fs.rename(completedSrc, finalPath); + return; + } catch (first: unknown) { + const c = (first as NodeJS.ErrnoException).code; + if (c === 'EXDEV') { + await fs.copyFile(completedSrc, finalPath); + await fs.unlink(completedSrc).catch(() => undefined); + return; + } + // Windows: чаще всего EEXIST — целевой файл есть, rename не перезаписывает; идём через backup ниже. + } + + const stFinal = await fs.stat(finalPath).catch(() => null); + if (!stFinal?.isFile()) { + try { + await fs.rename(completedSrc, finalPath); + return; + } catch (again: unknown) { + throw again instanceof Error ? again : new Error(String(again)); + } + } + + const backupPath = `${finalPath}.bak.${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`; + await fs.rename(finalPath, backupPath); + try { + await fs.rename(completedSrc, finalPath); + } catch (place: unknown) { + await fs.rename(backupPath, finalPath).catch(() => undefined); + throw place instanceof Error ? place : new Error(String(place)); + } + await fs.unlink(backupPath).catch(() => undefined); +} + +/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */ +export async function recoverOrphanDndZipTmpInRoot(root: string): Promise { + let names: string[]; + try { + names = await fs.readdir(root); + } catch { + return; + } + for (const name of names) { + if (!name.endsWith('.dnd.zip.tmp')) continue; + const tmpPath = path.join(root, name); + const finalName = name.slice(0, -'.tmp'.length); + if (!finalName.endsWith('.dnd.zip')) continue; + const finalPath = path.join(root, finalName); + try { + await fs.access(finalPath); + continue; + } catch { + /* final отсутствует — пробуем поднять из tmp */ + } + try { + const st = await fs.stat(tmpPath); + if (!st.isFile() || st.size < 22) continue; + await readProjectJsonFromZip(tmpPath); + await fs.rename(tmpPath, finalPath); + } catch { + /* битый tmp не трогаем */ + } + } +} diff --git a/app/main/project/replaceFileAtomic.test.ts b/app/main/project/replaceFileAtomic.test.ts new file mode 100644 index 0000000..4ffa1cb --- /dev/null +++ b/app/main/project/replaceFileAtomic.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { replaceFileAtomic } from './atomicReplace'; + +void test('replaceFileAtomic: replaces existing file without deleting before rename succeeds', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-replace-')); + const finalPath = path.join(dir, 'out.bin'); + const tmpPath = path.join(dir, 'out.bin.tmp'); + await fs.writeFile(finalPath, 'previous', 'utf8'); + await fs.writeFile(tmpPath, 'updated-content', 'utf8'); + await replaceFileAtomic(tmpPath, finalPath); + assert.equal(await fs.readFile(finalPath, 'utf8'), 'updated-content'); + await assert.rejects(() => fs.stat(tmpPath)); +}); + +void test('replaceFileAtomic: rejects empty source', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-replace-')); + const finalPath = path.join(dir, 'out.bin'); + const tmpPath = path.join(dir, 'out.bin.tmp'); + await fs.writeFile(finalPath, 'x', 'utf8'); + await fs.writeFile(tmpPath, '', 'utf8'); + await assert.rejects(() => replaceFileAtomic(tmpPath, finalPath)); + assert.equal(await fs.readFile(finalPath, 'utf8'), 'x'); +}); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 1bfd6b9..637f540 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -45,3 +45,22 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje assert.match(src, /previewThumbAssetId/); assert.match(src, /function normalizeScene\(/); }); + +void test('zipStore: listProjects skips unreadable archives', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + assert.match( + src, + /async listProjects[\s\S]+?for \(const filePath of files\)[\s\S]+?try \{[\s\S]+?readProjectJsonFromZip/, + ); +}); + +void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { + const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); + const i = src.indexOf('export async function replaceFileAtomic'); + assert.ok(i >= 0); + const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i); + assert.ok(j > i); + const block = src.slice(i, j); + assert.match(block, /rename\(finalPath, backupPath\)/); + assert.doesNotMatch(block, /\.rm\(\s*finalPath/); +}); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 03427c9..4a00749 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -24,6 +24,7 @@ import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids'; import { getAppSemanticVersion } from '../versionInfo'; import { reconcileAssetFiles } from './assetPrune'; +import { recoverOrphanDndZipTmpInRoot, replaceFileAtomic } from './atomicReplace'; import { rmWithRetries } from './fsRetry'; import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs'; import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths'; @@ -75,6 +76,7 @@ export class ZipProjectStore { await fs.mkdir(getProjectsRootDir(), { recursive: true }); await fs.mkdir(getProjectsCacheRootDir(), { recursive: true }); await this.migrateLegacyProjectZipsIfNeeded(); + await recoverOrphanDndZipTmpInRoot(getProjectsRootDir()); } /** Копирует .dnd.zip из каталогов с «чужим» app name, если в текущем каталоге такого файла ещё нет. */ @@ -135,13 +137,17 @@ export class ZipProjectStore { const out: ProjectIndexEntry[] = []; for (const filePath of files) { - const project = await readProjectJsonFromZip(filePath); - out.push({ - id: project.id, - name: project.meta.name, - updatedAt: project.meta.updatedAt, - fileName: path.basename(filePath), - }); + try { + const project = await readProjectJsonFromZip(filePath); + out.push({ + id: project.id, + name: project.meta.name, + updatedAt: project.meta.updatedAt, + fileName: path.basename(filePath), + }); + } catch { + // Один битый архив не должен скрывать остальные проекты в списке. + } } out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return out; @@ -868,6 +874,11 @@ export class ZipProjectStore { const tmpPath = `${zipPath}.tmp`; await fs.mkdir(path.dirname(zipPath), { recursive: true }); await zipDir(cacheDir, tmpPath); + const st = await fs.stat(tmpPath).catch(() => null); + if (!st?.isFile() || st.size < 22) { + await fs.unlink(tmpPath).catch(() => undefined); + throw new Error('Сборка архива проекта не удалась (пустой или повреждённый временный файл)'); + } await replaceFileAtomic(tmpPath, zipPath); } @@ -1226,20 +1237,6 @@ async function uniqueDndZipFileName(root: string, preferredBaseFileName: string) } } -async function replaceFileAtomic(srcPath: string, destPath: string): Promise { - try { - await fs.rename(srcPath, destPath); - } catch { - try { - await fs.rm(destPath, { force: true }); - await fs.rename(srcPath, destPath); - } catch (second: unknown) { - await fs.unlink(srcPath).catch(() => undefined); - throw second instanceof Error ? second : new Error(String(second)); - } - } -} - async function copyFileWithProgress( src: string, dest: string, @@ -1339,17 +1336,7 @@ async function atomicWriteFile(filePath: string, contents: string): Promise undefined); - throw second instanceof Error ? second : new Error(String(second)); - } - } + await replaceFileAtomic(tmp, filePath); } /** Уже сжатые контейнеры/кодеки — в ZIP кладём без deflate, качество не трогаем; project.json и сырьё — deflate 9. */ diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index a2861ef..486995f 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import { app, BrowserWindow, nativeImage, screen } from 'electron'; +import { ipcChannels } from '../../shared/ipc/contracts'; + import { getBootSplashWindow } from './bootWindow'; type WindowKind = 'editor' | 'presentation' | 'control'; @@ -207,6 +209,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow }); } win.on('closed', () => windows.delete(kind)); + 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 }); + } + }); windows.set(kind, win); return win; } @@ -283,6 +292,10 @@ 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 }); + } } export function closeMultiWindow(): void { @@ -292,6 +305,10 @@ export function closeMultiWindow(): void { if (ctrl) ctrl.close(); } +export function isMultiWindowOpen(): boolean { + return windows.has('presentation') || windows.has('control'); +} + export function togglePresentationFullscreen(): boolean { const pres = windows.get('presentation'); if (!pres) return false; diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 6a721b8..401ad3e 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -134,6 +134,38 @@ z-index: 10000; } +.editorLockOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 11000; +} + +.editorLockModal { + width: min(520px, calc(100vw - 32px)); + border-radius: 12px; + padding: 14px; + background: rgba(25, 28, 38, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55); + display: grid; + gap: 8px; +} + +.editorLockTitle { + font-weight: 800; + opacity: 0.95; +} + +.editorLockText { + opacity: 0.85; + font-size: 12px; + line-height: 1.45; +} + .progressModal { width: min(520px, calc(100vw - 32px)); border-radius: 12px; @@ -465,6 +497,11 @@ position: relative; } +.previewFill { + position: absolute; + inset: 0; +} + .previewBusyOverlay { position: absolute; inset: 0; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 661d65b..5dfe636 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -65,6 +65,7 @@ export function EditorApp() { const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [previewBusy, setPreviewBusy] = useState(false); + const [presentationOpen, setPresentationOpen] = useState(false); const [licenseSnap, setLicenseSnap] = useState(null); const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false); const [eulaModalOpen, setEulaModalOpen] = useState(false); @@ -216,6 +217,24 @@ export function EditorApp() { return () => window.removeEventListener('mousedown', onDown); }, [settingsMenuOpen]); + useEffect(() => { + let off: (() => void) | null = null; + void (async () => { + try { + const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {}); + setPresentationOpen(snap.open); + } catch { + // ignore + } + off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { + setPresentationOpen(open); + }); + })(); + return () => { + off?.(); + }; + }, []); + const reloadLicense = useCallback(() => { void (async () => { try { @@ -266,6 +285,19 @@ export function EditorApp() { return ( <> + {presentationOpen + ? createPortal( +
+
+
Презентация запущена
+
+ Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить. +
+
+
, + document.body, + ) + : null} {state.zipProgress ? createPortal(
@@ -413,6 +445,7 @@ export function EditorApp() { ) : ( Promise; onOpen: (id: ProjectId) => Promise; onDelete: (id: ProjectId) => Promise; }; -function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) { +function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) { const [name, setName] = useState('Моя кампания'); const [rowMenuFor, setRowMenuFor] = useState(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); @@ -956,23 +990,46 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr
Проекты
-
СУЩЕСТВУЮЩИЕ
+ {!licenseActive && projects.length > 0 ? ( + <> +
+ Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения. +
+
+ + ) : null}
{projects.map((p) => (
void onOpen(p.id)} + onClick={() => { + if (!licenseActive) return; + void onOpen(p.id); + }} role="button" tabIndex={0} + title={!licenseActive ? 'Открытие проекта — после активации лицензии' : undefined} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id); + if (e.key === 'Enter' || e.key === ' ') { + if (!licenseActive) return; + void onOpen(p.id); + } }} >
{p.name}
@@ -985,8 +1042,11 @@ function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerPr aria-label="Меню проекта" aria-haspopup="menu" aria-expanded={rowMenuFor === p.id} + disabled={!licenseActive} + title={!licenseActive ? 'Доступно после активации лицензии' : undefined} onClick={(e) => { e.stopPropagation(); + if (!licenseActive) return; const r = e.currentTarget.getBoundingClientRect(); const menuW = 220; const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8)); @@ -1189,17 +1249,26 @@ function SceneInspector({
Файл изображения (PNG, JPG, WebP, GIF и т.д.).
{previewUrl && previewAssetType === 'image' ? ( - +
+ +
) : previewUrl && previewAssetType === 'video' ? ( -