import { app, dialog, BrowserWindow, ipcMain, protocol, screen, session, safeStorage, shell, powerMonitor, powerSaveBlocker, } from 'electron' import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import { Readable } from 'node:stream' import { DiagnosticLog } from './diagnostics.js' import { Controller, parseCommand } from './controller.js' import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js' import { AudioOutput } from './audio-output.js' import { parseRange } from './range.js' import { Online } from './online.js' import type { OnlineState } from '../src/types/online.js' import { PackageStore } from './package-store.js' import { packageMediaResponse } from './package-media.js' import { prepareFetchHeaders } from './response-headers.js' import { fetchNetwork } from './network-fetch.js' import { playSoundCheck, soundCheckWav } from './sound-check.js' import type { HostInfo } from '../src/types/screen.js' // Ozone выбирается до main.js; поздний appendSwitch смешивает Wayland и X11. if (process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11') { app.relaunch({ args: [...process.argv.slice(1), '--ozone-platform=x11'] }) app.exit(0) } app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') protocol.registerSchemesAsPrivileged([ { scheme: 'screen-app', privileges: { standard: true, secure: true, stream: true, supportFetchAPI: true }, }, ]) let control: BrowserWindow | null = null let output: BrowserWindow | null = null let audioDevicesWindow: BrowserWindow | null = null let audioOutput: AudioOutput let closingWindow: BrowserWindow | null = null let openingOutput = false let outputEpoch = 0 let quitting = false let startup: BrowserWindow | null = null let selectedDisplay: number | null = null let online: Online | null = null let settings: WindowSettings let sleepBlocker: number | null = null let hostNotice = '' let suspended = false let diagnostics: DiagnosticLog | null = null const lastErrors = { main: false, music: false, scene: false } let saveTimer: ReturnType | undefined const controller = new Controller((state) => { for (const channel of ['main', 'music', 'scene'] as const) { const failed = channel === 'scene' ? state.scene.status === 'error' : state.channels[channel].status === 'error' if (failed && !lastErrors[channel]) diagnostics?.record('media-error', { channel }) lastErrors[channel] = failed } for (const window of [control, output]) if (window && !window.isDestroyed()) window.webContents.send('state', state) }) const rendererRoot = () => join(app.getAppPath(), 'dist') function host(): HostInfo { const primary = screen.getPrimaryDisplay().id return { audioOutputs: audioOutput?.devices ?? [], audioOutputId: audioOutput?.selected ?? '', audioOutputNotice: audioOutput?.notice ?? '', notice: hostNotice, sleepBlocked: sleepBlocker !== null && powerSaveBlocker.isStarted(sleepBlocker), preferredDisplayId: preferredDisplayId( settings?.value.display ?? null, screen.getAllDisplays(), primary, ), displays: screen .getAllDisplays() .filter((d) => d.id !== primary) .map((d) => ({ id: d.id, label: `${d.label || 'Дисплей'} — ${d.size.width}×${d.size.height}`, })), } } function windowOptions(isOutput = false) { return { backgroundColor: '#111111', webPreferences: { ...(isOutput ? { partition: 'persist:screen-output' } : {}), preload: join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: true, }, } } async function setAudioSink(window: BrowserWindow, id: string) { await window.webContents.executeJavaScript(`(async () => { const results = await Promise.allSettled( Array.from(document.querySelectorAll('audio,video')).map(media => media.setSinkId(${JSON.stringify(id)})) ); const failed = results.find(result => result.status === 'rejected'); if (failed) throw failed.reason; })()`) } function secure(window: BrowserWindow) { window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) window.webContents.on('will-navigate', (event) => event.preventDefault()) } function requireControl(event: Electron.IpcMainInvokeEvent) { if ( !control || event.sender !== control.webContents || event.senderFrame !== control.webContents.mainFrame ) throw new Error('Недоступно') } function releaseSleepBlocker() { if (sleepBlocker === null) return powerSaveBlocker.stop(sleepBlocker) sleepBlocker = null } function closeOutput(reason = '', destroy = true) { if (reason) outputEpoch++ const window = output if (!window && (!reason || controller.state.timer.status !== 'running')) return diagnostics?.record('output-close', { reason: !reason ? 'operator' : reason.includes('сон') ? 'power' : reason.includes('полноэкран') ? 'fullscreen' : reason.includes('Процесс') ? 'renderer' : reason.includes('не отвечает') ? 'unresponsive' : reason.includes('загрузить') ? 'document' : reason.includes('Параметры') ? 'geometry' : 'display', }) output = null if (window) closingWindow = window selectedDisplay = null if (reason) hostNotice = `${reason}. Откройте показ заново; медиа остановлены, работающий таймер поставлен на паузу` controller.output(false, !!reason) releaseSleepBlocker() if (destroy && window) { // Не уничтожать native-окно повторно внутри его fullscreen/close callback. setImmediate(() => { if (!window.isDestroyed()) window.destroy() }) } } function saveWindowSettings() { clearTimeout(saveTimer) if (!settings) return if (control && !control.isDestroyed() && !control.isMinimized()) settings.value.control = { bounds: control.getNormalBounds(), maximized: control.isMaximized() } try { settings.save() } catch { diagnostics?.record('settings-error') hostNotice = 'Не удалось сохранить расположение окон; материалы игры не затронуты' } } function keepControlOnPrimary() { if (!control || control.isDestroyed()) return const before = control.getNormalBounds() const after = fitControlBounds(before, screen.getPrimaryDisplay().workArea) if (JSON.stringify(before) === JSON.stringify(after)) return const maximized = control.isMaximized() if (maximized) control.unmaximize() control.setBounds(after) if (maximized) control.maximize() } function reconcileDisplays() { if ( output && !screen .getAllDisplays() .some((d) => d.id === selectedDisplay && d.id !== screen.getPrimaryDisplay().id) ) closeOutput('Выбранный дисплей отключён или стал основным') keepControlOnPrimary() } async function show(displayId: number) { if (openingOutput) throw new Error('Подождите открытия окна показа') openingOutput = true const epoch = outputEpoch try { if (suspended || quitting) throw new Error('Дождитесь пробуждения компьютера') if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов') const display = screen.getAllDisplays().find((d) => d.id === displayId) if (!display || display.id === screen.getPrimaryDisplay().id) throw new Error('Выберите внешний дисплей; включите расширенный рабочий стол') const geometry = JSON.stringify([display.bounds, display.scaleFactor, display.rotation]) closeOutput() const closing = closingWindow if (closing && !closing.isDestroyed()) await new Promise((resolve) => closing.once('closed', () => setImmediate(resolve))) if (online?.blocksOutput) throw new Error('Подождите завершения подготовки материалов') const current = screen.getAllDisplays().find((d) => d.id === displayId) if ( epoch !== outputEpoch || suspended || quitting || !current || current.id === screen.getPrimaryDisplay().id || geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation]) ) throw new Error('Открытие отменено: изменилось состояние компьютера или дисплея') hostNotice = '' const window = new BrowserWindow({ ...windowOptions(true), ...display.bounds, show: false }) output = window selectedDisplay = display.id settings.value.display = { id: display.id, label: display.label } saveWindowSettings() secure(window) controller.output(true) diagnostics?.record('output-open') try { sleepBlocker = powerSaveBlocker.start('prevent-display-sleep') } catch { hostNotice = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС' } window.on('close', () => { if (output === window) closeOutput('', false) }) window.on('closed', () => { if (output === window) closeOutput('', false) if (closingWindow === window) closingWindow = null }) window.on('unresponsive', () => { if (output === window) closeOutput('Окно показа не отвечает') }) window.webContents.on('render-process-gone', () => { if (output === window) closeOutput('Процесс окна показа завершился') }) window.webContents.on('did-fail-load', (_event, _code, _description, _url, isMainFrame) => { if (isMainFrame && output === window) closeOutput('Не удалось загрузить окно показа') }) let enteredFullscreen = false window.on('enter-full-screen', () => { enteredFullscreen = true }) window.on('leave-full-screen', () => { if (enteredFullscreen && output === window) closeOutput('Окно показа вышло из полноэкранного режима') }) window.once('ready-to-show', () => { if (output !== window || window.isDestroyed()) return const current = screen.getAllDisplays().find((d) => d.id === display.id) if ( !current || current.id === screen.getPrimaryDisplay().id || geometry !== JSON.stringify([current.bounds, current.scaleFactor, current.rotation]) ) { closeOutput('Параметры дисплея изменились при открытии показа') return } window.show() window.setBounds(current.bounds) window.setFullScreen(true) }) void window.loadURL('screen-app://app/index.html?output=1').catch(() => { if (output === window) closeOutput('Не удалось загрузить окно показа') }) } finally { openingOutput = false } } // Две копии приложения не должны одновременно менять указатели пакетов. if (!app.requestSingleInstanceLock()) app.exit(0) app.on('second-instance', () => { const window = control ?? startup window?.show() window?.focus() }) app.on('before-quit', () => { quitting = true }) app .whenReady() .then(async () => { startup = new BrowserWindow({ width: 440, height: 200, resizable: false, title: 'Проверка файлов', webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false }, }) const startupWindow = startup startupWindow.setMenu(null) startupWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) startupWindow.webContents.on('will-navigate', (event) => event.preventDefault()) startupWindow.on('closed', () => { if (startup === startupWindow) { startup = null app.quit() } }) await startupWindow.loadURL( 'data:text/html;charset=utf-8,' + encodeURIComponent( `Проверка файлов

Проверка загруженных файлов…

Поиск и проверка пакетов…

`, ), ) diagnostics = new DiagnosticLog(join(app.getPath('userData'), 'logs')) diagnostics.record('app-start', { version: app.getVersion(), electron: process.versions.electron, platform: process.platform, arch: process.arch, }) settings = new WindowSettings(join(app.getPath('userData'), 'windows.json')) try { await settings.load() } catch (error) { diagnostics.record('settings-error') hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна' } prepareFetchHeaders(session.defaultSession) const packages = new PackageStore(join(app.getPath('userData'), 'packages'), { fetch: fetchNetwork, }) let lastProgressUpdate = 0 await packages.initialize((progress) => { if (startupWindow.isDestroyed()) return const now = Date.now() if ( progress.total !== null && progress.completed !== progress.total && now - lastProgressUpdate < 100 ) return lastProgressUpdate = now const { completed, total } = progress void startupWindow.webContents .executeJavaScript( `(() => { const bar = document.getElementById('progress'); const count = document.getElementById('count'); ${total === null ? "bar.removeAttribute('value');" : `bar.max = ${Math.max(total, 1)}; bar.value = ${completed}; count.textContent = 'Проверено файлов: ${completed} из ${total}';`} })()`, ) .catch(() => {}) }) if (quitting || startupWindow.isDestroyed()) return online = new Online( join(app.getPath('userData'), 'connection'), packages, controller, { available: () => safeStorage.isEncryptionAvailable() && (process.platform !== 'linux' || !['basic_text', 'unknown'].includes(safeStorage.getSelectedStorageBackend())), encrypt: (value) => safeStorage.encryptString(value), decrypt: (value) => safeStorage.decryptString(value), }, fetchNetwork, (failure) => diagnostics?.record('request-error', failure), async () => { const result = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'MP3', extensions: ['mp3'] }], }) const path = result.filePaths[0] if (result.canceled || !path) return null const info = await stat(path) if (!info.isFile() || !/\.mp3$/i.test(path) || !info.size || info.size > 64 * 1048576) throw new Error('Выберите MP3 размером до 64 МиБ') return { filename: basename(path), data: await readFile(path) } }, ) await online.initialize() if (quitting || startupWindow.isDestroyed()) return void online.restoreGames() let previousConnection = online.state.connection const logConnection = () => { const status = online!.state.connection if (status !== previousConnection) diagnostics?.record('connection', { status }) previousConnection = status } const onlineTick = setInterval(() => { void online?.poll().then(logConnection) void online?.refreshGameState() }, 1000) const timerTick = setInterval(() => controller.tick(), 250) app.on('before-quit', () => { quitting = true saveWindowSettings() closeOutput() releaseSleepBlocker() clearInterval(timerTick) clearInterval(onlineTick) online?.close() packages.cancel() diagnostics?.record('app-stop') }) const handleAppRequest: Parameters[1] = async (request) => { const url = new URL(request.url) const headers = { 'Content-Security-Policy': "default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'", 'X-Content-Type-Options': 'nosniff', } if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 }) if (url.pathname === '/sound-check.wav') return new Response(soundCheckWav, { headers: { ...headers, 'Content-Type': 'audio/wav' } }) if (url.pathname.startsWith('/packages/')) return packageMediaResponse(packages, request) if (url.pathname.startsWith('/media/')) { const media = url.pathname === '/media/audio' ? ['audio.wav', 'audio/wav'] : url.pathname === '/media/video' ? ['video.mp4', 'video/mp4'] : null if (!media) return new Response(null, { status: 404 }) const filename = join( app.isPackaged ? process.resourcesPath : app.getAppPath(), 'fixtures', media[0], ) try { const info = await stat(filename) const rangeHeader = request.headers.get('range') const range = parseRange(rangeHeader, info.size) if (!range) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${info.size}` }, }) const stream = createReadStream(filename, range) request.signal.addEventListener('abort', () => stream.destroy(), { once: true }) const responseHeaders: Record = { ...headers, 'Content-Type': media[1], 'Accept-Ranges': 'bytes', 'Content-Length': String(range.end - range.start + 1), } if (rangeHeader) responseHeaders['Content-Range'] = `bytes ${range.start}-${range.end}/${info.size}` return new Response(Readable.toWeb(stream) as ReadableStream, { status: rangeHeader ? 206 : 200, headers: responseHeaders, }) } catch { return new Response('Сначала выполните npm run media:generate', { status: 404 }) } } const path = url.pathname if (path !== '/index.html' && !/^\/assets\/[a-zA-Z0-9_.-]+$/.test(path)) return new Response(null, { status: 404 }) try { const { readFile } = await import('node:fs/promises') const content = await readFile(join(rendererRoot(), path.slice(1))) const type = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html; charset=utf-8' return new Response(content, { headers: { ...headers, 'Content-Type': type } }) } catch { return new Response(null, { status: 404 }) } } // Выбор выхода разрешён только renderer показа; камера и микрофон остаются запрещены. for (const target of [session.defaultSession, session.fromPartition('persist:screen-output')]) { const allowAudioOutput = (contents: Electron.WebContents | null, permission: string) => permission === 'speaker-selection' && !!contents && [audioDevicesWindow?.webContents, output?.webContents].includes(contents) && contents.getURL().startsWith('screen-app://app/index.html?') target.setPermissionRequestHandler((contents, permission, callback, details) => callback(details.isMainFrame && allowAudioOutput(contents, permission)), ) target.setPermissionCheckHandler((contents, permission, origin, details) => details.isMainFrame === true && (origin === 'screen-app://app' || origin === 'screen-app://app/') && allowAudioOutput(contents, permission), ) target.protocol.handle('screen-app', handleAppRequest) } audioDevicesWindow = new BrowserWindow({ ...windowOptions(true), show: false }) secure(audioDevicesWindow) await audioDevicesWindow.loadURL('screen-app://app/index.html?audio-devices=1') await audioDevicesWindow.webContents.executeJavaScript( "document.body.appendChild(document.createElement('audio'))", ) audioOutput = new AudioOutput( settings.value.audioOutput ?? '', () => audioDevicesWindow!.webContents.executeJavaScript(`navigator.mediaDevices.enumerateDevices().then( devices => devices.filter(d => d.kind === 'audiooutput' && d.deviceId && d.deviceId !== 'default' && d.deviceId !== 'communications') .map((d, index) => ({ id: d.deviceId, label: d.label || 'Устройство звука ' + (index + 1) })) )`), async (id) => { await setAudioSink(audioDevicesWindow!, id) const current = output if (current && !current.isDestroyed() && !current.webContents.isLoadingMainFrame()) await setAudioSink(current, id) }, (id) => { const previous = settings.value.audioOutput settings.value.audioOutput = id try { settings.save() } catch (error) { settings.value.audioOutput = previous throw error } }, ) const refreshAudioOutputs = () => audioOutput.refresh().catch(() => { audioOutput.notice = 'Не удалось обновить список устройств звука.' }) ipcMain.on('audio-devices-changed', (event) => { if (event.sender === audioDevicesWindow?.webContents && event.senderFrame === event.sender.mainFrame) void refreshAudioOutputs() }) await refreshAudioOutputs() control = new BrowserWindow({ ...windowOptions(), ...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea), show: false, }) control.once('ready-to-show', () => { keepControlOnPrimary() if (settings.value.control?.maximized) control!.maximize() control!.show() startup = null if (!startupWindow.isDestroyed()) startupWindow.destroy() }) const scheduleSave = () => { clearTimeout(saveTimer) saveTimer = setTimeout(saveWindowSettings, 250) } control.on('move', scheduleSave) control.on('resize', scheduleSave) control.on('maximize', scheduleSave) control.on('unmaximize', scheduleSave) control.on('close', saveWindowSettings) secure(control) let checkingSound = false ipcMain.handle('check-sound', async (event) => { requireControl(event) if (checkingSound) throw new Error('Проверка звука уже выполняется') checkingSound = true try { await audioOutput.prepare(async (id) => { const window = audioDevicesWindow if (!window || window.isDestroyed()) throw new Error('Проверка звука недоступна') await setAudioSink(window, id) await window.webContents.executeJavaScript(`(${playSoundCheck.toString()})()`) }) } finally { checkingSound = false } }) ipcMain.handle('select-audio-output', (event, id: unknown) => { requireControl(event) if (checkingSound) throw new Error('Подождите завершения проверки звука') return audioOutput.select(id) }) ipcMain.handle('prepare-audio-output', async (event) => { const current = output if (!current || event.sender !== current.webContents || event.senderFrame !== current.webContents.mainFrame) throw new Error('Недоступно') try { await audioOutput.prepare((id) => setAudioSink(current, id)) } catch (error) { if (output === current) closeOutput('Не удалось выбрать устройство звука') throw error } }) ipcMain.handle('state', (event) => { if ( ![control?.webContents, output?.webContents].includes(event.sender) || event.senderFrame !== event.sender.mainFrame ) throw new Error('Недоступно') return controller.state }) ipcMain.handle('plan-status', (event) => { requireControl(event) return online!.planStatus() }) ipcMain.handle('online-state', (event): OnlineState => { requireControl(event) return online!.snapshot() }) ipcMain.handle('online-action', async (event, intent: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') const action = intent && typeof intent === 'object' && 'action' in intent ? intent.action : null try { const result = await online!.act(intent) if (['download', 'check', 'remove', 'clear-games'].includes(String(action))) diagnostics?.record('package', { action, status: 'success' }) return result } catch (error) { if (['download', 'check', 'remove', 'clear-games'].includes(String(action))) diagnostics?.record('package', { action, status: 'failed' }) throw error } finally { logConnection() } }) ipcMain.handle('close-output', (event) => { requireControl(event) closeOutput() }) ipcMain.handle('host', (event) => { requireControl(event) return host() }) ipcMain.handle('show', (event, id: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') if (typeof id !== 'number') throw new Error('Некорректный дисплей') return show(id) }) let selectingPackage = false ipcMain.handle('packages', (event) => { requireControl(event) return packages .list() .map((pkg) => ({ key: pkg.key, version: pkg.version, name: pkg.package.game.name, downloadedFiles: pkg.package.assets.filter((asset) => packages.canRead(pkg.key, asset.id)).length, totalFiles: pkg.package.assets.length, })) }) ipcMain.handle('select-package', async (event, key: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') if (key !== null && typeof key !== 'string') throw new Error('Некорректный пакет') selectingPackage = true try { await online!.select(key) return controller.state } finally { selectingPackage = false } }) ipcMain.handle('open-materials-folder', async (event, key: unknown) => { requireControl(event) if (typeof key !== 'string' || key !== controller.state.catalog.key) throw new Error('Выберите текущую игру') for (const path of await packages.folders(key)) { const error = await shell.openPath(path) if (error) throw new Error('Не удалось открыть папку материалов') } }) let localSession = '' let localSequence = 0 const pendingCommands = new Set() ipcMain.handle('command', async (event, command: unknown) => { requireControl(event) if (selectingPackage) throw new Error('Подождите проверки пакета') const parsed = parseCommand(command) if ( !output && ['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action) ) throw new Error('Сначала откройте окно показа') if (parsed.session !== controller.state.session) return if (localSession !== parsed.session) { localSession = parsed.session localSequence = 0 pendingCommands.clear() } const pendingKey = `${parsed.session}:${parsed.sequence}` if (parsed.sequence <= localSequence || pendingCommands.has(pendingKey)) return if (parsed.action !== 'mute') { pendingCommands.add(pendingKey) try { const before = controller.state.sequence const operation = online!.planCommand(parsed) if (controller.state.sequence !== before) localSequence = Math.max(localSequence, parsed.sequence) await operation if (parsed.session === controller.state.session) localSequence = Math.max(localSequence, parsed.sequence) } finally { pendingCommands.delete(pendingKey) } } else { controller.command({ ...parsed, sequence: controller.state.sequence + 1 }) localSequence = parsed.sequence } }) let capturing = false let lastCapture = -Infinity ipcMain.handle('preview', async (event) => { requireControl(event) const window = output const state = controller.state if ( !window || window.isDestroyed() || window.webContents.isLoading() || !control?.isVisible() || control.isMinimized() || state.scene.status !== 'ready' || capturing || performance.now() - lastCapture < 500 ) return null const session = state.session const revision = state.scene.revision capturing = true lastCapture = performance.now() try { const image = await window.webContents.capturePage() if ( window !== output || window.isDestroyed() || session !== controller.state.session || revision !== controller.state.scene.revision || controller.state.scene.status !== 'ready' || image.isEmpty() ) return null return { session, revision, dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(), } } catch { return null } finally { capturing = false } }) ipcMain.on('scene-report', (event, report: unknown) => { if ( event.sender !== output?.webContents || event.senderFrame !== output.webContents.mainFrame ) return try { controller.reportScene(report) } catch { diagnostics?.record('invalid-report') } }) ipcMain.on('report', (event, report: unknown) => { if ( event.sender !== output?.webContents || event.senderFrame !== output.webContents.mainFrame ) return try { controller.report(report) } catch { diagnostics?.record('invalid-report') } }) screen.on('display-removed', (_event, display) => { if (display.id === selectedDisplay) closeOutput('Дисплей показа отключён') reconcileDisplays() }) screen.on('display-added', reconcileDisplays) screen.on('display-metrics-changed', (_event, display, metrics) => { if ( display.id === selectedDisplay && metrics.some((m) => ['bounds', 'scaleFactor', 'rotation'].includes(m)) ) closeOutput('Изменились разрешение, масштаб или ориентация дисплея') reconcileDisplays() }) powerMonitor.on('suspend', () => { suspended = true closeOutput('Компьютер переходит в сон') }) powerMonitor.on('resume', () => { suspended = false reconcileDisplays() }) control.on('closed', () => { app.quit() }) await control.loadURL('screen-app://app/index.html') }) .catch(() => { diagnostics?.record('startup-error') console.error('SCREEN_STARTUP_FAILED') if (startup && !startup.isDestroyed()) { void startup.webContents .executeJavaScript( `document.getElementById('message').textContent = 'Не удалось проверить файлы. Проверьте доступ к папке данных приложения и перезапустите его.'; document.getElementById('progress').hidden = true; document.getElementById('count').textContent = '';`, ) .catch(() => app.quit()) } else app.quit() }) app.on('window-all-closed', () => app.quit())