diff --git a/electron/audio-output.test.ts b/electron/audio-output.test.ts new file mode 100644 index 0000000..5398a9d --- /dev/null +++ b/electron/audio-output.test.ts @@ -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((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) })) +}) diff --git a/electron/audio-output.ts b/electron/audio-output.ts new file mode 100644 index 0000000..63bed1c --- /dev/null +++ b/electron/audio-output.ts @@ -0,0 +1,52 @@ +import type { AudioOutputInfo } from '../src/types/screen.js' + +export class AudioOutput { + devices: AudioOutputInfo[] = [] + notice = '' + private pending: Promise = Promise.resolve() + constructor( + public selected: string, + private enumerate: () => Promise, + private apply: (id: string) => Promise, + private save: (id: string) => void, + ) {} + private enqueue(operation: () => Promise): Promise { + 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) { + return this.enqueue(() => apply(this.selected)) + } +} diff --git a/electron/main.ts b/electron/main.ts index d94abcd..1212ad2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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) || diff --git a/electron/preload.ts b/electron/preload.ts index 796f11f..3c743f0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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') + }) +} diff --git a/electron/window-settings.ts b/electron/window-settings.ts index cd763b0..faaed77 100644 --- a/electron/window-settings.ts +++ b/electron/window-settings.ts @@ -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) diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index 888010d..fb70b2d 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -243,12 +243,12 @@ app.whenReady().then(async () => { assert.equal((await evaluate('window.screenBridge.packages()')).length, 1) await evaluate(`window.screenBridge.show(${targetDisplay.id})`) let output = await poll( - () => BrowserWindow.getAllWindows().find((w) => w !== control), + () => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'output', ) const inspect = (source) => BrowserWindow.getAllWindows() - .find((w) => w !== control) + .find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1') .webContents.executeJavaScript(source) await poll(async () => (await state()).channels.main.status === 'stopped', 'fixture prepared') if (process.platform === 'linux') @@ -265,6 +265,19 @@ app.whenReady().then(async () => { await checkDisplay(output) await command({ action: 'play', channel: 'main' }) await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play') + const audioHost = await evaluate('window.screenBridge.host()') + assert.equal(await evaluate('!!document.querySelector("#audio-output")'), true) + assert.equal(await evaluate('window.screenBridge.selectAudioOutput("missing").then(() => false, () => true)'), true) + if (audioHost.audioOutputs.length) { + const device = audioHost.audioOutputs[0].id + const before = await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') + await evaluate(`window.screenBridge.selectAudioOutput(${JSON.stringify(device)})`) + assert.deepEqual(await inspect('Array.from(document.querySelectorAll("audio,video")).map(media => media.sinkId)'), [device, device, device, device]) + assert.equal(await inspect('document.querySelector("audio[data-channel=main-audio]").paused'), false) + assert.ok(await inspect('document.querySelector("audio[data-channel=main-audio]").currentTime') >= before) + await evaluate('window.screenBridge.selectAudioOutput("")') + assert.equal((await evaluate('window.screenBridge.host()')).audioOutputId, '') + } await command({ action: 'pause', channel: 'main' }) await command({ action: 'resume', channel: 'main' }) await command({ action: 'stop', channel: 'main' }) @@ -599,7 +612,7 @@ app.whenReady().then(async () => { const oldSession = (await state()).session output.close() await evaluate(`window.screenBridge.show(${targetDisplay.id})`) - output = await poll(() => BrowserWindow.getAllWindows().find((w) => w !== control), 'reopened') + output = await poll(() => BrowserWindow.getAllWindows().find((w) => w.webContents.getURL() === 'screen-app://app/index.html?output=1'), 'reopened') await poll(async () => { const s = await state() return ( diff --git a/src/App.vue b/src/App.vue index bf38afa..049f3a5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,10 +5,11 @@ import OutputPage from './components/OutputPage.vue' const hasBridge = !!window.screenBridge const isOutput = new URLSearchParams(location.search).has('output') +const isAudioDevices = new URLSearchParams(location.search).has('audio-devices')