This commit is contained in:
2026-10-18 19:17:40 +07:00
parent 9542330125
commit c10ed2db01
10 changed files with 283 additions and 9 deletions
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { AudioOutput } from './audio-output.js'
import { parseWindowPreferences } from './window-settings.js'
const devices = [{ id: 'hdmi', label: 'HDMI' }, { id: 'usb', label: 'USB' }]
test('выбор сохраняется, применяется до запуска проигрывания и проверяет устройство', async () => {
const applied: string[] = []
const saved: string[] = []
const output = new AudioOutput('', async () => devices, async (id) => { applied.push(id) }, (id) => { saved.push(id) })
await output.refresh()
await output.select('hdmi')
await output.prepare(async (id) => { applied.push(`prepare:${id}`) })
assert.deepEqual(applied, ['hdmi', 'prepare:hdmi'])
assert.deepEqual(saved, ['hdmi'])
assert.equal(output.selected, 'hdmi')
await assert.rejects(output.select('missing'), /недоступно/)
await assert.rejects(output.select({ id: 'usb' }), /недоступно/)
assert.equal(output.selected, 'hdmi')
})
test('ошибка переключения или сохранения возвращает прежний выход', async () => {
for (const failure of ['apply', 'save']) {
const applied: string[] = []
const output = new AudioOutput('hdmi', async () => devices, async (id) => {
applied.push(id)
if (id === 'usb' && failure === 'apply') throw new Error('switch failed')
}, () => { if (failure === 'save') throw new Error('save failed') })
await output.refresh()
await assert.rejects(output.select('usb'), /failed/)
assert.equal(output.selected, 'hdmi')
assert.deepEqual(applied, ['usb', 'hdmi'])
}
})
test('отключение устройства сбрасывает выбор, повторное подключение не меняет его', async () => {
let available = devices
const saved: string[] = []
const output = new AudioOutput('hdmi', async () => available, async () => {}, (id) => { saved.push(id) })
await output.refresh()
assert.equal(output.selected, 'hdmi')
available = []
await output.refresh()
assert.equal(output.selected, '')
assert.match(output.notice, /отключено/)
assert.deepEqual(saved, [''])
available = devices
await output.refresh()
assert.equal(output.selected, '')
await output.select('usb')
assert.equal(output.notice, '')
})
test('быстрые переключения и подготовка нового окна выполняются по порядку', async () => {
const calls: string[] = []
let release: () => void = () => {}
const held = new Promise<void>((resolve) => { release = resolve })
const output = new AudioOutput('', async () => devices, async (id) => {
calls.push(id)
if (id === 'hdmi') await held
}, () => {})
await output.refresh()
const first = output.select('hdmi')
const second = output.select('usb')
const prepare = output.prepare(async (id) => { calls.push(`prepare:${id}`) })
await Promise.resolve()
assert.deepEqual(calls, ['hdmi'])
release()
await Promise.all([first, second, prepare])
assert.deepEqual(calls, ['hdmi', 'usb', 'prepare:usb'])
})
test('старые настройки совместимы, выбранный аудиовыход читается и валидируется', () => {
const old = { version: 1, control: null, display: null }
assert.equal(parseWindowPreferences(old).audioOutput, undefined)
assert.equal(parseWindowPreferences({ ...old, audioOutput: 'hdmi' }).audioOutput, 'hdmi')
assert.throws(() => parseWindowPreferences({ ...old, audioOutput: 123 }))
assert.throws(() => parseWindowPreferences({ ...old, audioOutput: 'a'.repeat(1025) }))
})
+52
View File
@@ -0,0 +1,52 @@
import type { AudioOutputInfo } from '../src/types/screen.js'
export class AudioOutput {
devices: AudioOutputInfo[] = []
notice = ''
private pending: Promise<unknown> = Promise.resolve()
constructor(
public selected: string,
private enumerate: () => Promise<AudioOutputInfo[]>,
private apply: (id: string) => Promise<void>,
private save: (id: string) => void,
) {}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.pending.then(operation)
this.pending = result.catch(() => {})
return result
}
refresh() {
return this.enqueue(async () => {
this.devices = await this.enumerate()
if (this.selected && !this.devices.some((device) => device.id === this.selected)) {
await this.apply('')
this.selected = ''
this.notice = 'Устройство звука отключено. Используется устройство по умолчанию в системе.'
this.save('')
}
})
}
select(id: unknown) {
return this.enqueue(async () => {
if (typeof id !== 'string' || (id !== '' && !this.devices.some((d) => d.id === id)))
throw new Error('Устройство вывода звука недоступно')
const previous = this.selected
try {
await this.apply(id)
this.save(id)
} catch (error) {
try {
await this.apply(previous)
} catch {
this.notice = 'Не удалось восстановить устройство звука. Выберите устройство заново.'
}
throw error
}
this.selected = id
this.notice = ''
})
}
prepare(apply: (id: string) => Promise<void>) {
return this.enqueue(() => apply(this.selected))
}
}
+83 -5
View File
@@ -19,6 +19,7 @@ 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'
@@ -40,6 +41,8 @@ protocol.registerSchemesAsPrivileged([
])
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
@@ -70,6 +73,9 @@ 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(
@@ -90,7 +96,7 @@ function windowOptions(isOutput = false) {
return {
backgroundColor: '#111111',
webPreferences: {
...(isOutput ? { partition: 'screen-output' } : {}),
...(isOutput ? { partition: 'persist:screen-output' } : {}),
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
@@ -98,6 +104,15 @@ function windowOptions(isOutput = false) {
},
}
}
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())
@@ -462,12 +477,60 @@ app
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)
// Выбор выхода разрешён только 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),
@@ -490,6 +553,21 @@ app
control.on('unmaximize', scheduleSave)
control.on('close', saveWindowSettings)
secure(control)
ipcMain.handle('select-audio-output', (event, id: unknown) => {
requireControl(event)
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) ||
+8
View File
@@ -3,6 +3,8 @@ import { contextBridge, ipcRenderer } from 'electron'
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
contextBridge.exposeInMainWorld('screenBridge', {
selectAudioOutput: (id: string) => ipcRenderer.invoke('select-audio-output', id),
prepareAudioOutput: () => ipcRenderer.invoke('prepare-audio-output'),
planStatus: () => ipcRenderer.invoke('plan-status'),
onlineState: () => ipcRenderer.invoke('online-state'),
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
@@ -23,3 +25,9 @@ contextBridge.exposeInMainWorld('screenBridge', {
selectPackage: (key: string | null) => ipcRenderer.invoke('select-package', key),
openMaterialsFolder: (key: string) => ipcRenderer.invoke('open-materials-folder', key),
})
if (new URLSearchParams(location.search).has('audio-devices')) {
navigator.mediaDevices.addEventListener('devicechange', () => {
ipcRenderer.send('audio-devices-changed')
})
}
+6
View File
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
import type { Rectangle } from 'electron'
export interface WindowPreferences {
audioOutput?: string
control: { bounds: Rectangle; maximized: boolean } | null
display: { id: number; label: string } | null
}
@@ -22,6 +23,11 @@ export function parseWindowPreferences(value: unknown): WindowPreferences {
const data = object(value)
if (data.version !== 1) throw new Error('Неизвестная версия настроек окон')
const result = defaults()
if (data.audioOutput !== undefined) {
if (typeof data.audioOutput !== 'string' || data.audioOutput.length > 1024)
throw new Error('Некорректное устройство звука')
result.audioOutput = data.audioOutput
}
if (data.control !== null) {
const c = object(data.control)
const b = object(c.bounds)