This commit is contained in:
2026-10-17 20:57:16 +07:00
parent 40456eebf1
commit 1d4028b183
13 changed files with 849 additions and 48 deletions
+223 -34
View File
@@ -1,9 +1,20 @@
import { app, BrowserWindow, ipcMain, protocol, screen, session, safeStorage } from 'electron'
import {
app,
BrowserWindow,
ipcMain,
protocol,
screen,
session,
safeStorage,
powerMonitor,
powerSaveBlocker,
} from 'electron'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { Controller, parseCommand } from './controller.js'
import { WindowSettings, fitControlBounds, preferredDisplayId } from './window-settings.js'
import { parseRange } from './range.js'
import { Online } from './online.js'
import type { OnlineState } from '../src/types/online.js'
@@ -22,8 +33,17 @@ protocol.registerSchemesAsPrivileged([
])
let control: BrowserWindow | null = null
let output: BrowserWindow | null = null
let closingWindow: BrowserWindow | null = null
let openingOutput = false
let outputEpoch = 0
let quitting = false
let selectedDisplay: number | null = null
let online: Online | null = null
let settings: WindowSettings
let sleepBlocker: number | null = null
let hostNotice = ''
let suspended = false
let saveTimer: ReturnType<typeof setTimeout> | undefined
const controller = new Controller((state) => {
for (const window of [control, output])
if (window && !window.isDestroyed()) window.webContents.send('state', state)
@@ -32,6 +52,13 @@ const rendererRoot = () => join(app.getAppPath(), 'dist')
function host(): HostInfo {
const primary = screen.getPrimaryDisplay().id
return {
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)
@@ -41,10 +68,11 @@ function host(): HostInfo {
})),
}
}
function windowOptions() {
function windowOptions(isOutput = false) {
return {
backgroundColor: '#111111',
webPreferences: {
...(isOutput ? { partition: 'screen-output' } : {}),
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
@@ -64,31 +92,144 @@ function requireControl(event: Electron.IpcMainInvokeEvent) {
)
throw new Error('Недоступно')
}
function show(displayId: number) {
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('Выберите внешний дисплей; включите расширенный рабочий стол')
if (output) output.destroy()
selectedDisplay = display.id
output = new BrowserWindow({ ...windowOptions(), ...display.bounds, show: false })
secure(output)
controller.output(true)
function releaseSleepBlocker() {
if (sleepBlocker === null) return
powerSaveBlocker.stop(sleepBlocker)
sleepBlocker = null
}
function closeOutput(reason = '', destroy = true) {
if (reason) outputEpoch++
const window = output
output.on('closed', () => {
if (output !== window) return
output = null
selectedDisplay = null
controller.output(false)
})
output.once('ready-to-show', () => {
if (!window.isDestroyed()) {
window.show()
window.setBounds(display.bounds)
window.setFullScreen(true)
if (!window && (!reason || controller.state.timer.status !== 'running')) return
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 {
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<void>((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)
try {
sleepBlocker = powerSaveBlocker.start('prevent-display-sleep')
} catch {
hostNotice = 'Не удалось заблокировать автоматический сон. Проверьте настройки питания ОС'
}
})
void output.loadURL('screen-app://app/index.html?output=1')
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)
@@ -100,6 +241,12 @@ app.on('second-instance', () => {
app
.whenReady()
.then(async () => {
settings = new WindowSettings(join(app.getPath('userData'), 'windows.json'))
try {
await settings.load()
} catch (error) {
hostNotice = error instanceof Error ? error.message : 'Не удалось восстановить окна'
}
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
online = new Online(join(app.getPath('userData'), 'connection'), packages, controller, {
@@ -116,16 +263,16 @@ app
}, 1000)
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
quitting = true
saveWindowSettings()
closeOutput()
releaseSleepBlocker()
clearInterval(timerTick)
clearInterval(onlineTick)
online?.close()
packages.cancel()
})
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
callback(false),
)
session.defaultSession.setPermissionCheckHandler(() => false)
protocol.handle('screen-app', async (request) => {
const handleAppRequest: Parameters<typeof protocol.handle>[1] = async (request) => {
const url = new URL(request.url)
const headers = {
'Content-Security-Policy':
@@ -189,8 +336,32 @@ app
} catch {
return new Response(null, { status: 404 })
}
}
// Разные сессии изолируют renderer показа от пульта, протокол и запреты одинаковы.
for (const target of [session.defaultSession, session.fromPartition('screen-output')]) {
target.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
target.setPermissionCheckHandler(() => false)
target.protocol.handle('screen-app', handleAppRequest)
}
control = new BrowserWindow({
...windowOptions(),
...fitControlBounds(settings.value.control?.bounds, screen.getPrimaryDisplay().workArea),
show: false,
})
control = new BrowserWindow({ ...windowOptions(), width: 1000, height: 800 })
control.once('ready-to-show', () => {
keepControlOnPrimary()
if (settings.value.control?.maximized) control!.maximize()
control!.show()
})
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)
ipcMain.handle('state', (event) => {
if (
@@ -211,7 +382,7 @@ app
})
ipcMain.handle('close-output', (event) => {
requireControl(event)
output?.close()
closeOutput()
})
ipcMain.handle('host', (event) => {
requireControl(event)
@@ -221,7 +392,7 @@ app
requireControl(event)
if (selectingPackage) throw new Error('Подождите проверки пакета')
if (typeof id !== 'number') throw new Error('Некорректный дисплей')
show(id)
return show(id)
})
let selectingPackage = false
ipcMain.handle('packages', (event) => {
@@ -330,7 +501,25 @@ app
}
})
screen.on('display-removed', (_event, display) => {
if (display.id === selectedDisplay) output?.close()
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()