build: bundle and sign Virtual Driver Control

This commit is contained in:
Mike Rodriguez committed 2026-09-19 03:52:14 -07:00
1 parent 3f84d49175
commit 0ebca45f75
63 files changed
+17045 -12

No files matched your search

+88 -12
View File
@@ -27,6 +27,7 @@ concurrency:
env:
BUILD_CONFIGURATION: Release
VDD_SOLUTION: Virtual Display Driver (HDR)/MttVDD.sln
CONTROL_PANEL_DIR: VirtualDriverControl
# Signing is deliberate: release tags sign automatically, and a manual run
# must explicitly opt in. Pull requests and ordinary branch pushes never
# receive the SignPath token.
@@ -51,6 +52,20 @@ jobs:
with:
submodules: true
- name: Compute release version
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$now = [DateTime]::UtcNow
$buildNumber = [int]$env:GITHUB_RUN_NUMBER
if ($buildNumber -lt 0 -or $buildNumber -gt 65535) {
throw "GITHUB_RUN_NUMBER must fit an INF version component (0-65535): $buildNumber"
}
$releaseVersion = "{0}.{1}.{2}.{3}" -f ($now.Year % 100), $now.Month, $now.Day, $buildNumber
Write-Output "Using release version $releaseVersion"
"RELEASE_VERSION=$releaseVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Setup MSBuild
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
@@ -134,11 +149,7 @@ jobs:
Write-Output "Using UMDF version: $($umdfBest.Name) (minor=$umdfMinor) from $($umdfBest.FullName)"
$now = [DateTime]::UtcNow
$buildNumber = [int]$env:GITHUB_RUN_NUMBER
if ($buildNumber -lt 0 -or $buildNumber -gt 65535) {
throw "GITHUB_RUN_NUMBER must fit an INF version component (0-65535): $buildNumber"
}
$driverVersion = "{0}.{1}.{2}.{3}" -f ($now.Year % 100), $now.Month, $now.Day, $buildNumber
$driverVersion = $env:RELEASE_VERSION
$driverDate = $now.ToString("MM/dd/yyyy", [Globalization.CultureInfo]::InvariantCulture)
Write-Output "Stamping DriverVer=$driverDate,$driverVersion"
@@ -156,6 +167,58 @@ jobs:
/p:RunApiValidator=false `
/verbosity:minimal
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
cache: npm
cache-dependency-path: VirtualDriverControl/package-lock.json
- name: Build Control Panel (${{ matrix.platform }})
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$controlArch = if ("${{ matrix.platform }}" -eq "ARM64") { "arm64" } else { "x64" }
Push-Location "${{ env.CONTROL_PANEL_DIR }}"
try {
npm ci
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Fail releases for moderate-or-higher dependency advisories. The
# remaining low advisory is isolated to the local Vite dev server
# and is not included in the packaged application.
npm audit --audit-level=moderate
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npm version $env:RELEASE_VERSION --no-git-tag-version
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npm run typecheck
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
npm run test:generated
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$parseErrors = @()
Get-ChildItem "out-ps-check\*.ps1" | ForEach-Object {
$tokens = $null
$errors = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors)
$parseErrors += $errors
}
if ($parseErrors.Count -gt 0) {
$parseErrors | Format-List
throw "Generated installer PowerShell failed to parse"
}
Add-Type -Path (Resolve-Path "out-ps-check\core-audio-interop.cs")
npm run "build-portable:$controlArch"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} finally {
Pop-Location
}
- name: Collect outputs
shell: pwsh
run: |
@@ -167,10 +230,17 @@ jobs:
New-Item -ItemType Directory -Path $dest -Force | Out-Null
Copy-Item "$outDir\*" -Destination $dest -Recurse -Force
# This is the complete installable UMDF package. SignPath receives the
# GitHub Actions ZIP containing these exact files, with no installer or
# unrelated driver payload mixed in.
$requiredFiles = @("MttVDD.dll", "MttVDD.inf", "MttVDD.cat", "vdd_settings.xml")
$controlArch = if ("${{ matrix.platform }}" -eq "ARM64") { "arm64" } else { "x64" }
$controlSource = "${{ env.CONTROL_PANEL_DIR }}\dist\Virtual Driver Control-$env:RELEASE_VERSION-$controlArch.exe"
if (-not (Test-Path $controlSource)) {
throw "Control Panel executable not found: $controlSource"
}
Copy-Item $controlSource -Destination (Join-Path $dest "Virtual Driver Control.exe") -Force
# This is the complete installable UMDF package and its matching
# Control Panel. SignPath receives the GitHub Actions ZIP containing
# these exact files, with no stale driver payload mixed in.
$requiredFiles = @("MttVDD.dll", "MttVDD.inf", "MttVDD.cat", "vdd_settings.xml", "Virtual Driver Control.exe")
foreach ($file in $requiredFiles) {
if (-not (Test-Path (Join-Path $dest $file))) {
throw "Required driver package file not found: $file"
@@ -203,8 +273,9 @@ jobs:
# The SignPath project's default artifact configuration must accept the
# GitHub artifact ZIP and preserve MttVDD.dll, MttVDD.inf, MttVDD.cat,
# and vdd_settings.xml. It should Authenticode-sign the DLL and catalog,
# while the INF and XML remain package payload.
# vdd_settings.xml, and Virtual Driver Control.exe. It should
# Authenticode-sign the DLL, catalog, and Control Panel executable while
# the INF and XML remain package payload.
- name: Submit VDD package to SignPath
id: submit_signing
if: env.SIGNPATH_SIGNING_RUN == 'true'
@@ -233,12 +304,14 @@ jobs:
$catalog = @(Get-ChildItem -Path $signedRoot -Recurse -File -Filter "MttVDD.cat")
$inf = @(Get-ChildItem -Path $signedRoot -Recurse -File -Filter "MttVDD.inf")
$settings = @(Get-ChildItem -Path $signedRoot -Recurse -File -Filter "vdd_settings.xml")
$controlPanel = @(Get-ChildItem -Path $signedRoot -Recurse -File -Filter "Virtual Driver Control.exe")
foreach ($entry in @(
@{ Name = "MttVDD.dll"; Files = $driver },
@{ Name = "MttVDD.cat"; Files = $catalog },
@{ Name = "MttVDD.inf"; Files = $inf },
@{ Name = "vdd_settings.xml"; Files = $settings }
@{ Name = "vdd_settings.xml"; Files = $settings },
@{ Name = "Virtual Driver Control.exe"; Files = $controlPanel }
)) {
if ($entry.Files.Count -ne 1) {
throw "Expected exactly one signed package file named $($entry.Name), found $($entry.Files.Count)"
@@ -257,6 +330,9 @@ jobs:
& $signtool.FullName verify /pa /v $catalog[0].FullName
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for $($catalog[0].FullName)" }
& $signtool.FullName verify /pa /v $controlPanel[0].FullName
if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for $($controlPanel[0].FullName)" }
- name: Upload signed driver package
if: env.SIGNPATH_SIGNING_RUN == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+4
View File
@@ -49,6 +49,10 @@ For an existing checkout, initialize the submodule before building:
git submodule update --init --recursive
```
The release workflow also builds the Control Panel in [`VirtualDriverControl`](VirtualDriverControl/README.md).
Its portable executable is versioned with the same `YY.M.D.GITHUB_RUN_NUMBER` value as the driver, packaged beside
the matching x64 or ARM64 driver, and submitted with the driver DLL and catalog for code signing.
## ⬇️ Download Latest Version
- [Driver Installer (Windows 10/11)](https://github.com/VirtualDrivers/Virtual-Display-Driver/releases) - Check the [Releases](https://github.com/VirtualDrivers/Virtual-Display-Driver/releases) page for the latest version and release notes.
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
out/
out-ps-check/
*.tsbuildinfo
+77
View File
@@ -0,0 +1,77 @@
# Virtual Driver Control
A modern control panel for the [Virtual Display Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver) (MttVDD).
Electron + React + TypeScript, talking to the driver over its native named pipe.
## What it does
- **Dashboard** — live driver status (PING heartbeat), an animated monitor stage, and one-click add/remove of
virtual displays via `SETDISPLAYCOUNT`. Quick toggles for HDR+, SDR 10-bit, hardware cursor, custom EDID and logging
apply instantly through the pipe.
- **Driver lifecycle** — download & install the latest signed [Virtual Display Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver/releases)
and [Virtual Audio Driver](https://github.com/VirtualDrivers/Virtual-Audio-Driver/releases) releases: checksum-verified
download, signer trust, `pnputil` staging and SetupAPI device-node creation, plus restart-device and uninstall —
one UAC prompt per operation. The audio driver supports **multiple instances** (1-4 independent speaker + mic pairs).
- **Audio** — Windows endpoint control (volume, mute, default device for every playback/recording endpoint via Core
Audio) and a live **routing matrix**: pump any microphone or the system output mix into any output device with
per-route gain and level meters. Routes persist and re-arm on launch. Covers mic → speaker, speaker → speaker
(System audio source) and speaker → mic (route into the virtual speaker; apps hear it on the virtual microphone).
- **Displays** — a live, to-scale **desktop arrangement map** of every attached monitor (physical and virtual, with
real placement, resolution, refresh rate and scale - virtual displays are flagged via PnP parent lookup), plus a
full editor for `vdd_settings.xml` resolutions: preset gallery (VGA → 8K, ultrawides, tablets), per-resolution and
global refresh rates (fractional rates supported), preferred/fallback mode, and a to-scale size comparison.
- **HDR & Color** — color format (RGB / YCbCr), HDR10 static metadata, gamma and color space, plus an interactive
CIE 1931 chromaticity diagram with draggable R/G/B/white points.
- **EDID Lab** — drag-and-drop EDID decoder (pure TypeScript: identity, timings, chromaticity, CEA-861, HDR metadata).
One click exports an IddCx `monitor_profile.xml` + `user_edid.bin` and enables EDID integration.
- **GPU** — adapter list from the driver (`GETALLGPUS`) with WMI fallback, one-click `SETGPU` assignment.
- **Console** — unified live feed: driver file logs (tailed), every pipe command's streamed response, and app events.
Filterable by severity/source, with a raw command input for power users.
- **Settings** — cursor, logging and auto-resolution options, a line-diff preview before every save, automatic
timestamped backups with restore, themes (dark/light/system) and accent colors.
## Driver integration
| Mechanism | Use |
| --- | --- |
| `\\.\pipe\MTTVirtualDisplayPipe` | Live control. Commands are sent UTF-16LE on one-shot connections; responses are read until disconnect. All calls are serialized with timeouts and a reload cooldown. |
| `C:\VirtualDisplayDriver\vdd_settings.xml` | Full typed read/write of every section, with atomic writes and automatic backups. |
| `C:\VirtualDisplayDriver\Logs\` | Daily log files are tailed into the console feed. |
| PowerShell / WMI | Fallbacks for driver presence and GPU enumeration when the pipe is down. |
| GitHub releases API | Driver lifecycle for both drivers: fetches the latest driver package (x64/ARM64 picked automatically), verifies its published SHA-256, then installs through an elevated PowerShell script (signer → TrustedPublisher, `pnputil /add-driver`, SetupAPI root-device creation). Uninstall removes the devices and driver package but keeps your configuration. |
| Core Audio (COM interop) | Audio endpoint enumeration, volume/mute (`IAudioEndpointVolume`) and default-device switching (`IPolicyConfig`) - no elevation needed. |
| WebAudio + WASAPI loopback | The in-app routing engine: capture any input device or the system mix and play it to any output. Routes are active while the app runs. |
| Electron `screen` API | Desktop arrangement map with real bounds, scale, rotation and refresh rate for every display. |
`RELOAD_DRIVER` is intentionally **never sent** (upstream undefined behavior). Saving applies changes by writing the
XML and issuing `SETDISPLAYCOUNT <current count>`, which makes the driver reload its configuration safely.
The app runs fully offline as well: with no driver installed you can still edit, preview and save configuration.
## Development
```bash
npm install
npm run dev # hot-reloading dev session
npm run typecheck # strict TS for main + preload + renderer
npm run test:generated # render embedded PowerShell/C# for syntax checks
npm run build # production bundles into out/
npm run build-portable # portable .exe via electron-builder (admin elevation)
npm run build-portable:x64 # architecture-specific release executable
npm run build-portable:arm64 # architecture-specific release executable
```
Requires Windows and Node 18+. Run elevated if you want to write to `C:\VirtualDisplayDriver`.
## Architecture
```
src/
main/ Electron main process: PipeClient, SettingsService, DriverService, LogService, IPC
preload/ contextBridge API (window.vdd) - the only door between renderer and system
renderer/ React UI: pages, components, zustand stores, design tokens
shared/ Types, vdd_settings schema defaults, EDID parser - imported by all processes
```
Security: `contextIsolation` + `sandbox` enabled, no `nodeIntegration`, every IPC input validated in the main process,
raw pipe commands restricted to a conservative charset, external links limited to https.
@@ -0,0 +1,39 @@
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
main: {
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared')
}
},
build: {
rollupOptions: {
output: { format: 'cjs' }
}
}
},
preload: {
resolve: {
alias: {
'@shared': resolve(__dirname, 'src/shared')
}
},
build: {
rollupOptions: {
output: { format: 'cjs' }
}
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve(__dirname, 'src/renderer/src'),
'@shared': resolve(__dirname, 'src/shared')
}
},
plugins: [react()]
}
})
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "virtual-driver-control",
"version": "2.0.0",
"description": "Modern control panel for the Virtual Display Driver (MttVDD)",
"main": "./out/main/index.js",
"author": "Virtual Driver Control",
"license": "MIT",
"keywords": [
"electron",
"virtual-display",
"driver-control",
"iddcx"
],
"scripts": {
"dev": "electron-vite dev",
"start": "electron-vite preview",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test:generated": "node scripts/render-ps-scripts.mjs && node scripts/render-audio-interop.mjs",
"build:win": "electron-vite build && electron-builder --win",
"build-portable": "electron-vite build && electron-builder --win portable",
"build-portable:x64": "electron-vite build && electron-builder --win portable --x64",
"build-portable:arm64": "electron-vite build && electron-builder --win portable --arm64",
"build-dir": "electron-vite build && electron-builder --dir"
},
"build": {
"appId": "com.virtualdriver.control",
"productName": "Virtual Driver Control",
"directories": {
"output": "dist"
},
"icon": "./resources/VirtualDisplayDriver.ico",
"files": [
"out/**",
"resources/**",
"package.json"
],
"win": {
"icon": "./resources/VirtualDisplayDriver.ico",
"target": "portable",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"portable": {
"requestExecutionLevel": "admin"
}
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^42.4.0",
"electron-builder": "^26.15.2",
"electron-vite": "^5.0.0",
"fast-xml-parser": "^5.8.0",
"lucide-react": "^1.17.0",
"motion": "^12.40.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "^6.0.3",
"vite": "^7.3.5",
"zustand": "^5.0.14"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -0,0 +1,19 @@
// Dev-only helper: renders the AudioService C# interop to a file so it can be
// compile-tested with Add-Type outside Electron.
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, '..', 'src', 'main', 'services', 'audio-service.ts'), 'utf8').replace(/\r\n/g, '\n')
const start = source.indexOf('const CORE_AUDIO_CSHARP = `') + 'const CORE_AUDIO_CSHARP = `'.length
const end = source.indexOf('`.trim()', start)
const template = source.slice(start, end)
// eslint-disable-next-line no-new-func
const rendered = new Function(`return \`${template}\``)().trim()
const outDir = join(here, '..', 'out-ps-check')
mkdirSync(outDir, { recursive: true })
writeFileSync(join(outDir, 'core-audio-interop.cs'), rendered)
console.log('rendered to', join(outDir, 'core-audio-interop.cs'))
@@ -0,0 +1,81 @@
// Dev-only helper: renders the InstallerService PowerShell templates to temp
// files so their syntax can be validated without running Electron.
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, '..', 'src', 'main', 'services', 'installer-service.ts'), 'utf8').replace(/\r\n/g, '\n')
// Driver specs mirrored from the service (keep in sync when validating).
const SPECS = {
display: {
title: 'Virtual Display Driver',
infName: 'MttVDD.inf',
signedBinary: 'MttVDD.dll',
hardwareId: 'Root\\MttVDD',
hardwareIdPattern: '^Root\\\\MttVDD$',
className: 'Display',
classGuid: '4D36E968-E325-11CE-BFC1-08002BE10318',
copyToBaseDir: true,
preserveFiles: ['vdd_settings.xml'],
maxInstances: 1
},
audio: {
title: 'Virtual Audio Driver',
infName: 'VirtualAudioDriver.inf',
signedBinary: 'VirtualAudioDriver.sys',
hardwareId: 'Root\\VirtualAudioDriver',
hardwareIdPattern: '^Root\\\\VirtualAudioDriver$',
className: 'MEDIA',
classGuid: '4D36E96C-E325-11CE-BFC1-08002BE10318',
copyToBaseDir: false,
preserveFiles: [],
maxInstances: 4
}
}
function extractTemplate(afterMarker, openMarker = 'return `') {
const start = source.indexOf(afterMarker)
if (start === -1) throw new Error(`marker not found: ${afterMarker}`)
const open = source.indexOf(openMarker, start) + openMarker.length
const close = source.indexOf('`\n }', open)
return source.slice(open, close)
}
function render(template, vars) {
const cleaned = template.replaceAll('this.getBaseDir()', 'getBaseDir()')
const keys = Object.keys(vars)
// eslint-disable-next-line no-new-func
return new Function(...keys, `return \`${cleaned}\``)(...keys.map((k) => vars[k]))
}
const outDir = join(here, '..', 'out-ps-check')
mkdirSync(outDir, { recursive: true })
const installTemplate = extractTemplate('private buildInstallScript(')
const copyBlockTemplate = extractTemplate('const copyBlock = spec.copyToBaseDir', '? `')
const uninstallTemplate = extractTemplate('private buildUninstallScript(')
const restartTemplate = extractTemplate('private buildRestartScript(')
const instancesTemplate = extractTemplate('private buildSetInstancesScript(')
for (const [id, spec] of Object.entries(SPECS)) {
const copyBlock = spec.copyToBaseDir
? render(copyBlockTemplate.slice(0, copyBlockTemplate.indexOf('`\n : ')), {
spec,
getBaseDir: () => 'C:\\VirtualDisplayDriver'
})
: ''
const nefconPath = 'C:\\Tools\\nefcon\\nefconc.exe'
const installFlags = spec.maxInstances === 1 ? '--no-duplicates --remove-duplicates' : '--no-duplicates'
const common = { spec, nefconPath, getBaseDir: () => 'C:\\VirtualDisplayDriver' }
writeFileSync(
join(outDir, `install-${id}.ps1`),
render(installTemplate, { ...common, packageDir: 'C:\\Temp\\pkg', instances: 2, copyBlock, installFlags })
)
writeFileSync(join(outDir, `uninstall-${id}.ps1`), render(uninstallTemplate, common))
writeFileSync(join(outDir, `restart-${id}.ps1`), render(restartTemplate, common))
writeFileSync(join(outDir, `instances-${id}.ps1`), render(instancesTemplate, { ...common, target: 2 }))
}
console.log('rendered to', outDir)
+238
View File
@@ -0,0 +1,238 @@
import { execFile } from 'child_process'
import { app, BrowserWindow, desktopCapturer, nativeImage, nativeTheme, session, shell } from 'electron'
import { writeFileSync } from 'fs'
import os from 'os'
import { join } from 'path'
import { promisify } from 'util'
import { registerIpc } from './ipc'
import { AudioService } from './services/audio-service'
import { DisplayService } from './services/display-service'
import { DriverService } from './services/driver-service'
import { InstallerService } from './services/installer-service'
import { LogService } from './services/log-service'
import { PipeClient } from './services/pipe-client'
import { PrefsService } from './services/prefs-service'
import { SettingsService } from './services/settings-service'
const HEARTBEAT_INTERVAL_MS = 5_000
const execFileAsync = promisify(execFile)
const RESOURCES_DIR = join(__dirname, '../../resources')
// Taskbar/window icon mirrors driver health: normal when the pipe answers,
// yellow when installed but not responding, red when not installed.
const STATUS_ICONS: Record<string, string> = {
online: join(RESOURCES_DIR, 'VirtualDisplayDriver.ico'),
'installed-offline': join(RESOURCES_DIR, 'VDD_Yellow.ico'),
'not-installed': join(RESOURCES_DIR, 'VDD_Red.ico'),
unknown: join(RESOURCES_DIR, 'VDD_Red.ico')
}
let currentIconLevel = ''
function updateStatusIcon(level: string): void {
if (!mainWindow || mainWindow.isDestroyed() || level === currentIconLevel) return
const icon = nativeImage.createFromPath(STATUS_ICONS[level] ?? STATUS_ICONS.unknown)
mainWindow.setIcon(icon)
currentIconLevel = level
}
// Mica needs Windows 11 22H2+; everywhere else we fall back to a solid Fluent base.
const supportsMica = process.platform === 'win32' && Number(os.release().split('.')[2] ?? 0) >= 22621
/**
* Relaunches the app elevated through a single UAC prompt. Returns false when
* the prompt is declined. A .cmd launcher preserves the dev-server URL so the
* elevated instance still gets HMR during development.
*/
async function relaunchElevated(): Promise<boolean> {
const lines = ['@echo off', `cd /d "${process.cwd()}"`]
for (const key of ['ELECTRON_RENDERER_URL', 'NODE_ENV']) {
const value = process.env[key]
if (value) lines.push(`set "${key}=${value}"`)
}
const args = process.argv
.slice(1)
.map((a) => `"${a}"`)
.join(' ')
lines.push(`start "" "${process.execPath}" ${args}`)
const cmdPath = join(app.getPath('temp'), `vdd-elevate-${Date.now()}.cmd`)
writeFileSync(cmdPath, lines.join('\r\n'), 'utf8')
try {
await execFileAsync(
'powershell.exe',
['-NoProfile', '-Command', `Start-Process -FilePath '${cmdPath}' -Verb RunAs -WindowStyle Hidden`],
{ windowsHide: true, timeout: 120_000 }
)
return true
} catch {
// UAC declined
return false
}
}
let mainWindow: BrowserWindow | null = null
let heartbeatBusy = false
const prefs = new PrefsService()
const pipe = new PipeClient()
const settings = new SettingsService(() => prefs.getBaseDir())
const driver = new DriverService(pipe, { getBaseDir: () => prefs.getBaseDir() })
const logs = new LogService(pipe, () => prefs.getBaseDir())
const installer = new InstallerService(
() => prefs.getBaseDir(),
() => driver.isAdmin(),
(progress) => send('push:install-progress', progress)
)
const audio = new AudioService()
const displays = new DisplayService()
function send(channel: string, payload: unknown): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload)
}
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1320,
height: 860,
minWidth: 1000,
minHeight: 640,
frame: false,
show: false,
...(supportsMica ? { backgroundMaterial: 'mica' as const } : { backgroundColor: '#202020' }),
icon: join(RESOURCES_DIR, 'VirtualDisplayDriver.ico'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
additionalArguments: [`--vdd-backdrop=${supportsMica ? 'mica' : 'solid'}`]
}
})
mainWindow.on('ready-to-show', () => {
// Re-assert the material - it can fail to apply on frameless windows
// when only passed through the constructor.
if (supportsMica) mainWindow?.setBackgroundMaterial('mica')
mainWindow?.show()
})
mainWindow.on('maximize', () => send('push:maximized', true))
mainWindow.on('unmaximize', () => send('push:maximized', false))
mainWindow.on('closed', () => {
mainWindow = null
})
// All external navigation goes through the system browser.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https:\/\//.test(url)) void shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('http://localhost') && !url.startsWith('file://')) event.preventDefault()
})
if (process.env.ELECTRON_RENDERER_URL) {
void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
async function heartbeat(): Promise<void> {
if (heartbeatBusy) return
heartbeatBusy = true
try {
const status = await driver.status()
updateStatusIcon(status.level)
send('push:status', status)
} finally {
heartbeatBusy = false
}
}
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
app.quit()
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
app.whenReady().then(async () => {
// Ask for elevation up front so the driver folder, VDDPATH registry and
// device operations all work without per-operation prompts. Declining
// keeps the app fully usable as a standard user.
if (!(await driver.isAdmin())) {
app.releaseSingleInstanceLock()
if (await relaunchElevated()) {
if (app.isPackaged) {
app.exit(0)
}
// In dev the parent must stay alive (windowless) so the Vite dev
// server keeps running for the elevated instance.
return
}
app.requestSingleInstanceLock()
}
// Mica's tint follows nativeTheme - align it with the saved app theme.
nativeTheme.themeSource = prefs.get().theme
registerIpc({ pipe, settings, driver, logs, prefs, installer, audio, displays }, () => mainWindow)
// The driver reads vdd_settings.xml from its VDDPATH registry value -
// follow it so the app always edits the file the driver actually loads,
// and seed the folder with defaults when it does not exist yet.
void prefs.syncBaseDirWithDriver().then(() => settings.ensureDefaults())
// Microphone capture powers the audio router; everything else stays denied.
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === 'media')
})
// getDisplayMedia with system audio loopback ("System audio" route source).
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
desktopCapturer
.getSources({ types: ['screen'] })
.then((sources) => callback({ video: sources[0], audio: 'loopback' }))
.catch(() => callback({}))
})
displays.watch(() => {
void displays.layout().then((layout) => send('push:displays', layout))
})
logs.on('events', (events) => send('push:logs', events))
pipe.on('result', (result) =>
send('push:pipe-activity', {
command: result.command,
ok: result.ok,
durationMs: result.durationMs,
at: Date.now()
})
)
logs.start()
logs.appInfo('Virtual Driver Control started')
createWindow()
void heartbeat()
setInterval(() => void heartbeat(), HEARTBEAT_INTERVAL_MS)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
logs.stop()
app.quit()
})
}
+243
View File
@@ -0,0 +1,243 @@
import { BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'
import type { AppPreferences, ManagedDriverId, PipeToggleCommand, VddSettings } from '@shared/types'
import type { AudioService } from './services/audio-service'
import type { DisplayService } from './services/display-service'
import type { DriverService } from './services/driver-service'
import type { InstallerService } from './services/installer-service'
import type { LogService } from './services/log-service'
import type { PipeClient } from './services/pipe-client'
import type { PrefsService } from './services/prefs-service'
import type { SettingsService } from './services/settings-service'
const TOGGLE_COMMANDS: ReadonlySet<string> = new Set([
'HDRPLUS',
'SDR10',
'CUSTOMEDID',
'PREVENTSPOOF',
'CEAOVERRIDE',
'HARDWARECURSOR',
'LOGGING',
'LOG_DEBUG'
])
const QUERY_COMMANDS: ReadonlySet<string> = new Set(['D3DDEVICEGPU', 'IDDCXVERSION', 'GETASSIGNEDGPU', 'GETALLGPUS', 'GETSETTINGS', 'PING'])
/** Raw console commands: known verbs only, conservative charset for arguments. */
const RAW_COMMAND_PATTERN = /^[A-Za-z0-9_]+(?: [A-Za-z0-9_." -]{1,100})?$/
export interface IpcServices {
pipe: PipeClient
settings: SettingsService
driver: DriverService
logs: LogService
prefs: PrefsService
installer: InstallerService
audio: AudioService
displays: DisplayService
}
function assertDriverId(value: unknown): asserts value is ManagedDriverId {
if (value !== 'display' && value !== 'audio') throw new Error('Unknown driver id')
}
export function registerIpc(services: IpcServices, getWindow: () => BrowserWindow | null): void {
const { pipe, settings, driver, logs, prefs, installer, audio, displays } = services
// --- Pipe -----------------------------------------------------------------
ipcMain.handle('pipe:ping', () => pipe.ping())
ipcMain.handle('pipe:set-display-count', (_e, count: unknown) => {
const n = Number(count)
if (!Number.isFinite(n) || n < 0 || n > 16) throw new Error('Display count must be between 0 and 16')
return pipe.setDisplayCount(n)
})
ipcMain.handle('pipe:toggle', (_e, name: unknown, value: unknown) => {
if (typeof name !== 'string' || !TOGGLE_COMMANDS.has(name)) throw new Error('Unknown toggle command')
return pipe.setToggle(name as PipeToggleCommand, value === true)
})
ipcMain.handle('pipe:set-gpu', (_e, name: unknown) => {
if (typeof name !== 'string' || name.trim().length === 0) throw new Error('GPU name required')
return pipe.setGpu(name.trim())
})
ipcMain.handle('pipe:query', (_e, command: unknown) => {
if (typeof command !== 'string' || !QUERY_COMMANDS.has(command)) throw new Error('Unknown query command')
return pipe.send(command)
})
ipcMain.handle('pipe:send-raw', (_e, command: unknown) => {
if (typeof command !== 'string') throw new Error('Command must be a string')
const trimmed = command.trim()
if (trimmed.length === 0 || trimmed.length > 127) throw new Error('Command must be 1-127 characters')
if (!RAW_COMMAND_PATTERN.test(trimmed)) throw new Error('Command contains unsupported characters')
return pipe.send(trimmed)
})
ipcMain.handle('pipe:get-driver-settings', () => pipe.getDriverSettings())
// --- vdd_settings.xml -------------------------------------------------------
ipcMain.handle('settings:load', () => settings.load())
ipcMain.handle('settings:save', (_e, value: unknown) => {
assertSettingsShape(value)
return settings.save(value)
})
ipcMain.handle('settings:preview', (_e, value: unknown) => {
assertSettingsShape(value)
return settings.serialize(value)
})
ipcMain.handle('settings:raw', () => settings.rawXml())
ipcMain.handle('settings:backups', () => settings.listBackups())
ipcMain.handle('settings:restore', (_e, fileName: unknown) => {
if (typeof fileName !== 'string') throw new Error('Backup file name required')
return settings.restoreBackup(fileName)
})
ipcMain.handle('settings:save-monitor-profile', (_e, xml: unknown, bytes: unknown) => {
if (typeof xml !== 'string' || !xml.includes('<IddCxMonitorConfig>')) throw new Error('Invalid monitor profile XML')
const edidBytes = bytes instanceof Uint8Array ? bytes : bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : undefined
return settings.saveMonitorProfile(xml, edidBytes)
})
// --- Driver / system --------------------------------------------------------
ipcMain.handle('driver:status', (_e, force: unknown) => driver.status(force === true))
ipcMain.handle('driver:gpus', () => driver.gpus())
ipcMain.handle('driver:iddcx-version', () => driver.iddcxVersion())
ipcMain.handle('system:info', () => driver.systemInfo())
// --- Driver lifecycle (download / install / uninstall / restart) -------------
ipcMain.handle('installer:latest-release', (_e, driverId: unknown) => {
assertDriverId(driverId)
return installer.latestRelease(driverId)
})
ipcMain.handle('installer:installed-tag', (_e, driverId: unknown) => {
assertDriverId(driverId)
return installer.installedReleaseTag(driverId)
})
ipcMain.handle('installer:device-state', (_e, driverId: unknown) => {
assertDriverId(driverId)
return installer.deviceState(driverId)
})
ipcMain.handle('installer:install', (_e, driverId: unknown, instances: unknown) => {
assertDriverId(driverId)
const n = instances === undefined ? 1 : Number(instances)
if (!Number.isFinite(n) || n < 1 || n > 4) throw new Error('Instance count must be between 1 and 4')
return installer.downloadAndInstall(driverId, n)
})
ipcMain.handle('installer:uninstall', (_e, driverId: unknown) => {
assertDriverId(driverId)
return installer.uninstall(driverId)
})
ipcMain.handle('installer:restart-device', (_e, driverId: unknown) => {
assertDriverId(driverId)
return installer.restartDevice(driverId)
})
ipcMain.handle('installer:set-instances', (_e, driverId: unknown, count: unknown) => {
assertDriverId(driverId)
const n = Number(count)
if (!Number.isFinite(n) || n < 1 || n > 4) throw new Error('Instance count must be between 1 and 4')
return installer.setInstances(driverId, n)
})
ipcMain.handle('installer:test-signing', () => installer.testSigningEnabled())
ipcMain.handle('installer:set-test-signing', (_e, enabled: unknown) => installer.setTestSigning(enabled === true))
// --- Windows audio endpoints ---------------------------------------------------
ipcMain.handle('audio:endpoints', () => audio.listEndpoints())
ipcMain.handle('audio:set-default', (_e, id: unknown) => {
if (typeof id !== 'string') throw new Error('Endpoint id required')
return audio.setDefaultEndpoint(id)
})
ipcMain.handle('audio:set-volume', (_e, id: unknown, volume: unknown) => {
if (typeof id !== 'string') throw new Error('Endpoint id required')
const v = Number(volume)
if (!Number.isFinite(v) || v < 0 || v > 1) throw new Error('Volume must be 0..1')
return audio.setVolume(id, v)
})
ipcMain.handle('audio:set-mute', (_e, id: unknown, muted: unknown) => {
if (typeof id !== 'string') throw new Error('Endpoint id required')
return audio.setMute(id, muted === true)
})
// --- Display layout --------------------------------------------------------------
ipcMain.handle('system:displays', () => displays.layout())
// --- Logs --------------------------------------------------------------------
ipcMain.handle('logs:recent', () => logs.recent())
// --- Preferences --------------------------------------------------------------
ipcMain.handle('prefs:get', () => prefs.get())
ipcMain.handle('prefs:set', (_e, patch: unknown) => {
if (typeof patch !== 'object' || patch === null) throw new Error('Invalid preferences')
// baseDir changes must go through prefs:set-base-dir so the driver's
// VDDPATH registry value always stays in sync.
const rest = { ...(patch as Partial<AppPreferences>) }
delete rest.baseDir
const next = prefs.set(rest)
// Keep the Mica backdrop tint in step with the in-app theme.
nativeTheme.themeSource = next.theme
return next
})
ipcMain.handle('prefs:set-base-dir', async (_e, baseDir: unknown) => {
if (typeof baseDir !== 'string' || baseDir.trim().length === 0) throw new Error('Path required')
const result = await prefs.setBaseDir(baseDir)
// Seed the new location with defaults so the driver finds a config there.
if (result.ok) await settings.ensureDefaults()
return result
})
// --- Shell ----------------------------------------------------------------------
ipcMain.handle('shell:open-external', (_e, url: unknown) => {
if (typeof url !== 'string' || !/^https:\/\//.test(url)) throw new Error('Only https links may be opened')
return shell.openExternal(url)
})
ipcMain.handle('shell:open-path', (_e, which: unknown) => {
const base = prefs.getBaseDir()
const targets: Record<string, string> = {
base,
logs: `${base}\\Logs`,
backups: `${base}\\Backups`,
edid: `${base}\\EDID`
}
const target = typeof which === 'string' ? targets[which] : undefined
if (!target) throw new Error('Unknown folder')
return shell.openPath(target)
})
// --- Window controls --------------------------------------------------------------
ipcMain.on('window:minimize', () => getWindow()?.minimize())
ipcMain.on('window:maximize-toggle', () => {
const win = getWindow()
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.on('window:close', () => getWindow()?.close())
}
function assertSettingsShape(value: unknown): asserts value is VddSettings {
if (typeof value !== 'object' || value === null) throw new Error('Settings payload must be an object')
const v = value as Record<string, unknown>
for (const key of [
'monitors',
'gpu',
'global',
'resolutions',
'logging',
'colour',
'cursor',
'edid',
'edidIntegration',
'hdrAdvanced',
'autoResolutions',
'colorAdvanced'
]) {
if (!(key in v)) throw new Error(`Settings payload missing section: ${key}`)
}
if (!Array.isArray(v.resolutions)) throw new Error('resolutions must be an array')
}
@@ -0,0 +1,309 @@
import { execFile } from 'child_process'
import { app } from 'electron'
import { promises as fs } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import type { AudioEndpoint } from '@shared/types'
const execFileAsync = promisify(execFile)
/** MMDevice endpoint id, e.g. {0.0.0.00000000}.{c2f56a7e-...}. */
export const ENDPOINT_ID_PATTERN = /^\{0\.0\.[01]\.00000000\}\.\{[0-9a-fA-F-]{36}\}$/
/**
* Core Audio interop (C# 5 compatible for Windows PowerShell's compiler).
* Covers endpoint enumeration, default-device switching (IPolicyConfig),
* master volume and mute (IAudioEndpointVolume).
*/
const CORE_AUDIO_CSHARP = `
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace VddAudio {
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
public class MMDeviceEnumeratorCom { }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IMMDeviceEnumerator {
int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices);
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
int GetDevice(string id, out IMMDevice device);
}
[Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IMMDeviceCollection {
int GetCount(out int count);
int Item(int index, out IMMDevice device);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IMMDevice {
int Activate(ref Guid iid, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object iface);
int OpenPropertyStore(int access, out IPropertyStore properties);
int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id);
int GetState(out int state);
}
[Guid("1BE09788-6894-4089-8586-9A2A6C265AC5"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IMMEndpoint {
int GetDataFlow(out int dataFlow);
}
[StructLayout(LayoutKind.Sequential)]
public struct PropertyKey { public Guid fmtid; public int pid; }
[StructLayout(LayoutKind.Sequential)]
public struct PropVariant {
public ushort vt;
public ushort r1; public ushort r2; public ushort r3;
public IntPtr p;
public int p2;
}
[Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IPropertyStore {
int GetCount(out int count);
int GetAt(int index, out PropertyKey key);
int GetValue(ref PropertyKey key, out PropVariant value);
int SetValue(ref PropertyKey key, ref PropVariant value);
int Commit();
}
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IAudioEndpointVolume {
int RegisterControlChangeNotify(IntPtr notify);
int UnregisterControlChangeNotify(IntPtr notify);
int GetChannelCount(out int count);
int SetMasterVolumeLevel(float levelDb, ref Guid ctx);
int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
int GetMasterVolumeLevel(out float levelDb);
int GetMasterVolumeLevelScalar(out float level);
int SetChannelVolumeLevel(int ch, float levelDb, ref Guid ctx);
int SetChannelVolumeLevelScalar(int ch, float level, ref Guid ctx);
int GetChannelVolumeLevel(int ch, out float levelDb);
int GetChannelVolumeLevelScalar(int ch, out float level);
int SetMute(bool mute, ref Guid ctx);
int GetMute(out bool mute);
}
[ComImport, Guid("870af99c-171d-4f9e-af0d-e63df40c2bc9")]
public class PolicyConfigClientCom { }
[Guid("f8679f50-850a-41cf-9c72-430f290290c8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IPolicyConfig {
int GetMixFormat(string id, IntPtr fmt);
int GetDeviceFormat(string id, bool isDefault, IntPtr fmt);
int ResetDeviceFormat(string id);
int SetDeviceFormat(string id, IntPtr a, IntPtr b);
int GetProcessingPeriod(string id, bool isDefault, IntPtr a, IntPtr b);
int SetProcessingPeriod(string id, IntPtr a);
int GetShareMode(string id, IntPtr mode);
int SetShareMode(string id, IntPtr mode);
int GetPropertyValue(string id, bool fxStore, IntPtr key, IntPtr pv);
int SetPropertyValue(string id, bool fxStore, IntPtr key, IntPtr pv);
int SetDefaultEndpoint(string id, int role);
int SetEndpointVisibility(string id, bool visible);
}
public static class AudioCtl {
static Guid IID_IAudioEndpointVolume = new Guid("5CDF2C82-841E-4546-9722-0CF74078229A");
static IMMDeviceEnumerator Enumerator() {
return (IMMDeviceEnumerator)(object)(new MMDeviceEnumeratorCom());
}
static string DefaultId(IMMDeviceEnumerator en, int flow, int role) {
IMMDevice dev;
if (en.GetDefaultAudioEndpoint(flow, role, out dev) != 0) return "";
string id;
dev.GetId(out id);
return id;
}
static string FriendlyName(IMMDevice dev) {
IPropertyStore store;
if (dev.OpenPropertyStore(0, out store) != 0) return "";
PropertyKey key = new PropertyKey();
key.fmtid = new Guid("a45c254e-df1c-4efd-8020-67d146a850e0");
key.pid = 14;
PropVariant pv;
if (store.GetValue(ref key, out pv) != 0) return "";
if (pv.vt != 31 || pv.p == IntPtr.Zero) return "";
return Marshal.PtrToStringUni(pv.p);
}
static IAudioEndpointVolume Volume(IMMDevice dev) {
object o;
if (dev.Activate(ref IID_IAudioEndpointVolume, 23, IntPtr.Zero, out o) != 0) return null;
return (IAudioEndpointVolume)o;
}
// Tab-separated: id, flow, isDefault, isDefaultComm, volume, muted, name
public static string ListTsv() {
IMMDeviceEnumerator en = Enumerator();
string defRender = DefaultId(en, 0, 1);
string defRenderComm = DefaultId(en, 0, 2);
string defCapture = DefaultId(en, 1, 1);
string defCaptureComm = DefaultId(en, 1, 2);
StringBuilder sb = new StringBuilder();
IMMDeviceCollection col;
// eAll = 2, DEVICE_STATE_ACTIVE = 1
if (en.EnumAudioEndpoints(2, 1, out col) != 0) return "";
int count;
col.GetCount(out count);
for (int i = 0; i < count; i++) {
IMMDevice dev;
if (col.Item(i, out dev) != 0) continue;
string id;
dev.GetId(out id);
int flow = 0;
((IMMEndpoint)dev).GetDataFlow(out flow);
string name = FriendlyName(dev);
float vol = 0; bool mute = false;
IAudioEndpointVolume v = Volume(dev);
if (v != null) {
v.GetMasterVolumeLevelScalar(out vol);
v.GetMute(out mute);
}
bool isDef = (flow == 0) ? (id == defRender) : (id == defCapture);
bool isDefComm = (flow == 0) ? (id == defRenderComm) : (id == defCaptureComm);
sb.Append(id).Append('\\t')
.Append(flow == 0 ? "render" : "capture").Append('\\t')
.Append(isDef ? "1" : "0").Append('\\t')
.Append(isDefComm ? "1" : "0").Append('\\t')
.Append(vol.ToString(System.Globalization.CultureInfo.InvariantCulture)).Append('\\t')
.Append(mute ? "1" : "0").Append('\\t')
.Append(name == null ? "" : name.Replace('\\t', ' '))
.Append('\\n');
}
return sb.ToString();
}
public static int SetDefault(string id) {
IPolicyConfig pc = (IPolicyConfig)(object)(new PolicyConfigClientCom());
int rc = 0;
// eConsole=0, eMultimedia=1, eCommunications=2
for (int role = 0; role <= 2; role++) {
int r = pc.SetDefaultEndpoint(id, role);
if (r != 0) rc = r;
}
return rc;
}
public static int SetVolume(string id, float level) {
IMMDeviceEnumerator en = Enumerator();
IMMDevice dev;
int r = en.GetDevice(id, out dev);
if (r != 0) return r;
IAudioEndpointVolume v = Volume(dev);
if (v == null) return -1;
Guid ctx = Guid.Empty;
return v.SetMasterVolumeLevelScalar(level, ref ctx);
}
public static int SetMute(string id, bool mute) {
IMMDeviceEnumerator en = Enumerator();
IMMDevice dev;
int r = en.GetDevice(id, out dev);
if (r != 0) return r;
IAudioEndpointVolume v = Volume(dev);
if (v == null) return -1;
Guid ctx = Guid.Empty;
return v.SetMute(mute, ref ctx);
}
}
}
`.trim()
/**
* Windows audio endpoint control. Each call runs a short PowerShell process
* compiling the Core Audio interop above - no elevation required (volume,
* mute and default-device changes are per-user operations).
*/
export class AudioService {
private interopPath: string | null = null
/** Writes the interop to a stable temp file once so scripts can dot-source it. */
private async ensureInterop(): Promise<string> {
if (this.interopPath) return this.interopPath
const path = join(app.getPath('userData'), 'core-audio-interop.cs')
await fs.writeFile(path, CORE_AUDIO_CSHARP, 'utf8')
this.interopPath = path
return path
}
private async run(psBody: string): Promise<string> {
const interop = await this.ensureInterop()
const script = [
`$ErrorActionPreference = 'Stop'`,
`Add-Type -TypeDefinition (Get-Content -Raw -LiteralPath '${interop}')`,
psBody
].join('\r\n')
const scriptPath = join(app.getPath('temp'), `vdd-audio-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`)
await fs.writeFile(scriptPath, script, 'utf8')
try {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
{ windowsHide: true, timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }
)
return stdout
} finally {
void fs.rm(scriptPath, { force: true }).catch(() => undefined)
}
}
async listEndpoints(): Promise<AudioEndpoint[]> {
const stdout = await this.run(`[VddAudio.AudioCtl]::ListTsv() | Write-Output`)
const endpoints: AudioEndpoint[] = []
for (const line of stdout.split('\n')) {
const parts = line.replace(/\r$/, '').split('\t')
if (parts.length < 7 || !ENDPOINT_ID_PATTERN.test(parts[0])) continue
const name = parts.slice(6).join(' ')
endpoints.push({
id: parts[0],
flow: parts[1] === 'capture' ? 'capture' : 'render',
isDefault: parts[2] === '1',
isDefaultComm: parts[3] === '1',
volume: Math.min(1, Math.max(0, Number(parts[4]) || 0)),
muted: parts[5] === '1',
isVirtual: /virtual audio/i.test(name),
name
})
}
endpoints.sort((a, b) => (a.flow === b.flow ? a.name.localeCompare(b.name) : a.flow === 'render' ? -1 : 1))
return endpoints
}
async setDefaultEndpoint(id: string): Promise<void> {
this.assertId(id)
const out = await this.run(`$rc = [VddAudio.AudioCtl]::SetDefault('${id}'); Write-Output "RC=$rc"`)
this.assertRc(out, 'set default device')
}
async setVolume(id: string, volume: number): Promise<void> {
this.assertId(id)
const level = Math.min(1, Math.max(0, volume))
const out = await this.run(`$rc = [VddAudio.AudioCtl]::SetVolume('${id}', ${level.toFixed(4)}); Write-Output "RC=$rc"`)
this.assertRc(out, 'set volume')
}
async setMute(id: string, muted: boolean): Promise<void> {
this.assertId(id)
const out = await this.run(`$rc = [VddAudio.AudioCtl]::SetMute('${id}', $${muted ? 'true' : 'false'}); Write-Output "RC=$rc"`)
this.assertRc(out, 'set mute')
}
private assertId(id: string): void {
if (!ENDPOINT_ID_PATTERN.test(id)) throw new Error('Invalid audio endpoint id')
}
private assertRc(stdout: string, operation: string): void {
const match = /RC=(-?\d+)/.exec(stdout)
if (!match || Number(match[1]) !== 0) {
throw new Error(`Failed to ${operation} (HRESULT ${match ? match[1] : 'unknown'})`)
}
}
}
@@ -0,0 +1,116 @@
import { execFile } from 'child_process'
import { screen } from 'electron'
import { promisify } from 'util'
import type { DisplayLayoutInfo } from '@shared/types'
const execFileAsync = promisify(execFile)
const VIRTUAL_CACHE_TTL_MS = 30_000
/**
* Enumerates every attached display (physical and virtual) with real bounds,
* scale and placement from the Electron `screen` API, and flags monitors that
* hang off the MttVDD virtual adapter via PnP parent lookup.
*/
export class DisplayService {
private virtualNames: Set<string> = new Set()
private virtualCheckedAt = 0
private refreshing: Promise<void> | null = null
private notify: (() => void) | null = null
async layout(): Promise<DisplayLayoutInfo[]> {
// Never block on the PnP lookup - heuristics cover the first paint and a
// push event refreshes the flags once the lookup lands.
void this.refreshVirtualNames()
const primaryId = screen.getPrimaryDisplay().id
return screen.getAllDisplays().map((d) => ({
id: d.id,
label: d.label || 'Display',
bounds: { x: d.bounds.x, y: d.bounds.y, width: d.bounds.width, height: d.bounds.height },
workArea: { x: d.workArea.x, y: d.workArea.y, width: d.workArea.width, height: d.workArea.height },
scaleFactor: d.scaleFactor,
rotation: d.rotation,
frequency: Math.round(d.displayFrequency || 0),
internal: d.internal,
primary: d.id === primaryId,
isVirtual: this.isVirtualLabel(d.label),
colorDepth: d.colorDepth
}))
}
/** Subscribes to display topology changes; returns an unsubscribe function. */
watch(onChange: () => void): () => void {
this.notify = onChange
const handler = (): void => {
// Topology changed - virtual adapter may have gained/lost monitors.
this.virtualCheckedAt = 0
onChange()
}
screen.on('display-added', handler)
screen.on('display-removed', handler)
screen.on('display-metrics-changed', handler)
return () => {
this.notify = null
screen.removeListener('display-added', handler)
screen.removeListener('display-removed', handler)
screen.removeListener('display-metrics-changed', handler)
}
}
private isVirtualLabel(label: string): boolean {
if (!label) return false
const norm = label.trim().toLowerCase()
for (const name of this.virtualNames) {
if (name === norm || name.includes(norm) || norm.includes(name)) return true
}
// Fallback heuristics for when the PnP lookup has not resolved names yet.
return /vdd|virtual display/i.test(label)
}
private async refreshVirtualNames(): Promise<void> {
if (Date.now() - this.virtualCheckedAt < VIRTUAL_CACHE_TTL_MS) return
if (this.refreshing) return this.refreshing
this.refreshing = (async () => {
try {
// Adapter nodes are matched by hardware ID - instance IDs depend on how
// the node was created (ROOT\MTTVDD\... vs nefcon's ROOT\DISPLAY\...).
const script = [
`$vddIds = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '^Root\\\\MttVDD$' } | ForEach-Object { $_.InstanceId })`,
`$mons = Get-PnpDevice -Class Monitor -PresentOnly -ErrorAction SilentlyContinue`,
`$out = foreach ($m in $mons) {`,
` $parent = (Get-PnpDeviceProperty -InstanceId $m.InstanceId -KeyName 'DEVPKEY_Device_Parent' -ErrorAction SilentlyContinue).Data`,
` if ($parent -and ($vddIds -contains $parent)) { $m.FriendlyName }`,
`}`,
`@($out) | ConvertTo-Json -Compress`
].join('; ')
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
windowsHide: true,
timeout: 20_000
})
const trimmed = stdout.trim()
const parsed = trimmed ? (JSON.parse(trimmed) as string | string[] | null) : null
const names = parsed === null ? [] : Array.isArray(parsed) ? parsed : [parsed]
const next = new Set<string>()
for (const raw of names) {
if (typeof raw !== 'string') continue
const full = raw.trim().toLowerCase()
if (full) next.add(full)
// PnP reports "Generic Monitor (Odyssey G95C)" while Electron labels
// are just "Odyssey G95C" - index the parenthesized name too.
const inner = /\(([^)]+)\)\s*$/.exec(raw)?.[1]?.trim().toLowerCase()
if (inner) next.add(inner)
}
const changed = next.size !== this.virtualNames.size || [...next].some((n) => !this.virtualNames.has(n))
this.virtualNames = next
this.virtualCheckedAt = Date.now()
if (changed) this.notify?.()
} catch {
// keep the previous set; heuristics still apply
this.virtualCheckedAt = Date.now()
} finally {
this.refreshing = null
}
})()
return this.refreshing
}
}
@@ -0,0 +1,254 @@
import { execFile } from 'child_process'
import { existsSync, statSync } from 'fs'
import os from 'os'
import { promisify } from 'util'
import { app } from 'electron'
import type { DriverStatus, GpuInfo, SystemInfo } from '@shared/types'
import type { PipeClient } from './pipe-client'
const execFileAsync = promisify(execFile)
const DRIVER_DLL = 'C:\\Windows\\System32\\drivers\\UMDF\\MttVDD.dll'
const DEVICE_CACHE_TTL_MS = 30_000
interface DeviceInfo {
present: boolean
name?: string
pnpStatus?: string
}
async function powershell(script: string, timeoutMs = 10_000): Promise<string> {
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
{ timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
)
return stdout.trim()
}
export class DriverService {
private deviceCache: { value: DeviceInfo; at: number } | null = null
private adminCache: boolean | null = null
constructor(
private readonly pipe: PipeClient,
private readonly paths: { getBaseDir: () => string }
) {}
async status(force = false): Promise<DriverStatus> {
const pipeConnected = await this.pipe.ping()
const device = await this.queryDevice(force)
const dllPresent = existsSync(DRIVER_DLL)
let dllDate: string | undefined
if (dllPresent) {
try {
dllDate = statSync(DRIVER_DLL).mtime.toISOString().slice(0, 10)
} catch {
// ignore
}
}
let level: DriverStatus['level'] = 'unknown'
if (pipeConnected) level = 'online'
else if (device.present || dllPresent) level = 'installed-offline'
else level = 'not-installed'
return {
level,
pipeConnected,
devicePresent: device.present,
deviceName: device.name,
devicePnpStatus: device.pnpStatus,
dllPresent,
dllDate,
checkedAt: Date.now()
}
}
/**
* GPU inventory. Prefers the driver's own enumeration (GETALLGPUS +
* GETASSIGNEDGPU); falls back to WMI when the pipe is offline.
*/
async gpus(): Promise<GpuInfo[]> {
const fromPipe = await this.gpusViaPipe()
if (fromPipe.length > 0) return fromPipe
return this.gpusViaWmi()
}
async assignedGpu(): Promise<string | null> {
const result = await this.pipe.send('GETASSIGNEDGPU')
if (!result.ok) return null
return extractGpuNames(result.lines)[0] ?? null
}
/**
* Detected IddCx framework version, e.g. "1.10".
*
* The driver's IDDCXVERSION command only echoes "IDDCX Version: 0x…" over
* the pipe when file logging AND SendLogsThroughPipe are both enabled, so
* the pipe is best-effort. The reliable fallback maps the IddCx.dll (or OS)
* build number to the published IddCx version table.
*/
async iddcxVersion(): Promise<string | null> {
const result = await this.pipe.send('IDDCXVERSION')
if (result.ok) {
const hex = result.response.match(/IDDCX[^\n]*?(0x[0-9a-fA-F]{3,8})/i)?.[1]
const decoded = hex ? decodeIddCxVersion(Number.parseInt(hex, 16)) : null
if (decoded) return decoded
}
const build = (await this.iddcxDllBuild()) ?? osBuildNumber()
return build !== null ? iddcxVersionForBuild(build) : null
}
/** Build number of the IddCx framework binary that drivers actually load. */
private async iddcxDllBuild(): Promise<number | null> {
try {
const out = await powershell(`(Get-Item 'C:\\Windows\\System32\\drivers\\UMDF\\IddCx.dll').VersionInfo.FileBuildPart`)
const build = Number.parseInt(out, 10)
return Number.isFinite(build) ? build : null
} catch {
return null
}
}
async isAdmin(): Promise<boolean> {
if (this.adminCache !== null) return this.adminCache
try {
const out = await powershell(
`([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)`
)
this.adminCache = out.toLowerCase().includes('true')
} catch {
this.adminCache = false
}
return this.adminCache
}
async systemInfo(): Promise<SystemInfo> {
const isAdmin = await this.isAdmin()
return {
windowsVersion: os.version(),
windowsBuild: os.release(),
arch: os.arch(),
isAdmin,
appVersion: app.getVersion(),
electronVersion: process.versions.electron,
settingsPath: `${this.paths.getBaseDir()}\\vdd_settings.xml`,
logsDir: `${this.paths.getBaseDir()}\\Logs`
}
}
private async queryDevice(force: boolean): Promise<DeviceInfo> {
if (!force && this.deviceCache && Date.now() - this.deviceCache.at < DEVICE_CACHE_TTL_MS) {
return this.deviceCache.value
}
let value: DeviceInfo = { present: false }
try {
const out = await powershell(
`Get-CimInstance Win32_PnPEntity | Where-Object { ($_.DeviceID -like '*MttVDD*') -or ($_.Name -like '*Virtual Display Driver*') } | Select-Object Name, Status | ConvertTo-Json -Compress`
)
if (out) {
const parsed: unknown = JSON.parse(out)
const first = Array.isArray(parsed) ? parsed[0] : parsed
if (first && typeof first === 'object') {
const rec = first as { Name?: string; Status?: string }
value = { present: true, name: rec.Name ?? 'Virtual Display Driver', pnpStatus: rec.Status }
}
}
} catch {
// WMI unavailable or no match - treat as not present.
}
this.deviceCache = { value, at: Date.now() }
return value
}
private async gpusViaPipe(): Promise<GpuInfo[]> {
const all = await this.pipe.send('GETALLGPUS')
if (!all.ok) return []
const names = extractGpuNames(all.lines)
if (names.length === 0) return []
const assigned = await this.assignedGpu()
return names.map((name) => ({
name,
source: 'pipe' as const,
assigned: assigned !== null && name.toLowerCase() === assigned.toLowerCase()
}))
}
private async gpusViaWmi(): Promise<GpuInfo[]> {
try {
const out = await powershell(
`Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion, AdapterRAM | ConvertTo-Json -Compress`
)
if (!out) return []
const parsed: unknown = JSON.parse(out)
const list = Array.isArray(parsed) ? parsed : [parsed]
return list
.filter((g): g is { Name?: string; DriverVersion?: string; AdapterRAM?: number } => !!g && typeof g === 'object')
.filter((g) => typeof g.Name === 'string' && g.Name.length > 0)
.map((g) => ({
name: g.Name as string,
source: 'wmi' as const,
assigned: false,
driverVersion: g.DriverVersion,
vramMB: typeof g.AdapterRAM === 'number' && g.AdapterRAM > 0 ? Math.round(g.AdapterRAM / 1024 / 1024) : undefined
}))
} catch {
return []
}
}
}
/**
* IddCxGetVersion values are nibble-encoded per Microsoft's IddCx versions
* table: 0x1500 → 1.5, 0x1A00/0x1A80 → 1.10, 0x1B00 → 1.11.
*/
function decodeIddCxVersion(value: number): string | null {
if (!Number.isFinite(value) || value < 0x1000) return null
return `${value >> 12}.${(value >> 8) & 0xf}`
}
/** Windows build → shipped IddCx version (learn.microsoft.com, iddcx-versions). */
const BUILD_TO_IDDCX: Array<[minBuild: number, version: string]> = [
[26100, '1.10'],
[22631, '1.10'],
[22621, '1.9'],
[22000, '1.8'],
[19041, '1.5'],
[18362, '1.4']
]
function iddcxVersionForBuild(build: number): string | null {
for (const [minBuild, version] of BUILD_TO_IDDCX) {
if (build >= minBuild) return version
}
return null
}
/** Build number from os.release(), e.g. "10.0.26200" → 26200. */
function osBuildNumber(): number | null {
const match = os.release().match(/^\d+\.\d+\.(\d+)/)
if (!match) return null
const build = Number.parseInt(match[1], 10)
return Number.isFinite(build) ? build : null
}
/**
* The pipe responds to GPU queries with free-form log lines. Pull out
* plausible GPU names: lines after "GPU:" markers or lines that look like
* adapter names.
*/
function extractGpuNames(lines: string[]): string[] {
const names: string[] = []
for (const line of lines) {
const marker = line.match(/GPU(?:\s*\d*)?\s*[:=]\s*(.+)$/i)
if (marker) {
const name = marker[1].trim()
if (name && !/^(none|null|unknown)$/i.test(name)) names.push(name)
continue
}
const adapter = line.match(/\b((?:NVIDIA|AMD|Intel|Microsoft|Qualcomm|Radeon|GeForce|Arc)\b[^|;]{2,70})/i)
if (adapter) names.push(adapter[1].trim())
}
return Array.from(new Set(names))
}
@@ -0,0 +1,716 @@
import { execFile } from 'child_process'
import { createHash } from 'crypto'
import { app } from 'electron'
import { createWriteStream, promises as fs } from 'fs'
import os from 'os'
import { join } from 'path'
import { promisify } from 'util'
import type { InstallProgress, LifecycleResult, ManagedDeviceState, ManagedDriverId, ReleaseInfo } from '@shared/types'
const execFileAsync = promisify(execFile)
const ALLOWED_DOWNLOAD_HOSTS = new Set(['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'])
/**
* Pinned nefcon release (Nefarius device console). This is the tool the
* official Virtual Display Driver setup uses for device-node creation and
* driver installation. Downloaded on demand and verified against the SHA-256
* digest GitHub publishes for the release asset.
*/
const NEFCON = {
tag: 'v1.17.40',
url: 'https://github.com/nefarius/nefcon/releases/download/v1.17.40/nefcon_v1.17.40.zip',
sha256: '812bae7ed7dfb7d6d2284bc7de2f8ccebc92ed2a0b1ae893c53b337096e50c1a'
}
interface DriverSpec {
title: string
repo: string
pickAsset: (names: string[]) => string | undefined
infName: string
/** File whose Authenticode signature gets trusted before install. */
signedBinary: string
hardwareId: string
/**
* PowerShell regex matched against Get-PnpDevice .HardwareID. Instance IDs
* vary by how the node was created (nefcon yields ROOT\DISPLAY\000x), so
* the hardware ID is the only reliable way to find our devices.
*/
hardwareIdPattern: string
/** Setup class name + GUID (brace-free, as nefcon expects). */
className: string
classGuid: string
deviceDescription: string
/** Copy the package contents to the configured base dir (display driver keeps its settings there). */
copyToBaseDir: boolean
/** Files never overwritten during the copy. */
preserveFiles: string[]
maxInstances: number
}
const DRIVERS: Record<ManagedDriverId, DriverSpec> = {
display: {
title: 'Virtual Display Driver',
repo: 'VirtualDrivers/Virtual-Display-Driver',
pickAsset: (names) => {
const wantArm = os.arch() === 'arm64'
return names.find((n) =>
wantArm ? /VirtualDisplayDriver-ARM64\.Driver\.Only\.zip/i.test(n) : /VirtualDisplayDriver-x(86|64)\.Driver\.Only\.zip/i.test(n)
)
},
infName: 'MttVDD.inf',
signedBinary: 'MttVDD.dll',
hardwareId: 'Root\\MttVDD',
hardwareIdPattern: '^Root\\\\MttVDD$',
className: 'Display',
classGuid: '4D36E968-E325-11CE-BFC1-08002BE10318',
deviceDescription: 'Virtual Display Driver',
copyToBaseDir: true,
preserveFiles: ['vdd_settings.xml'],
maxInstances: 1
},
audio: {
title: 'Virtual Audio Driver',
repo: 'VirtualDrivers/Virtual-Audio-Driver',
pickAsset: (names) => names.find((n) => /\.zip$/i.test(n)),
infName: 'VirtualAudioDriver.inf',
signedBinary: 'VirtualAudioDriver.sys',
hardwareId: 'Root\\VirtualAudioDriver',
hardwareIdPattern: '^Root\\\\VirtualAudioDriver$',
className: 'MEDIA',
classGuid: '4D36E96C-E325-11CE-BFC1-08002BE10318',
deviceDescription: 'Virtual Audio Driver',
copyToBaseDir: false,
preserveFiles: [],
maxInstances: 4
}
}
interface GithubAsset {
name: string
size: number
browser_download_url: string
digest?: string
}
interface GithubRelease {
tag_name: string
name: string
published_at: string
body: string
html_url: string
assets: GithubAsset[]
}
/**
* Downloads the latest signed driver packages from the official VirtualDrivers
* releases and manages the device lifecycle (install / uninstall / restart /
* instance count) through elevated PowerShell. Device-node creation and driver
* installation go through nefcon - the same tool the official VDD setup uses -
* which is fetched on demand from its pinned GitHub release and SHA-256
* verified. pnputil remains in use for restarts and driver-store cleanup.
*/
export class InstallerService {
private busy = false
constructor(
private readonly getBaseDir: () => string,
private readonly isAdmin: () => Promise<boolean>,
private readonly emitProgress: (progress: InstallProgress) => void
) {}
// -------------------------------------------------------------------------
// Release discovery / state
// -------------------------------------------------------------------------
async latestRelease(driver: ManagedDriverId): Promise<ReleaseInfo> {
const spec = DRIVERS[driver]
const response = await fetch(`https://api.github.com/repos/${spec.repo}/releases/latest`, {
headers: { 'User-Agent': 'Virtual-Driver-Control', Accept: 'application/vnd.github+json' }
})
if (!response.ok) throw new Error(`GitHub API responded ${response.status}`)
const release = (await response.json()) as GithubRelease
const assetName = spec.pickAsset(release.assets.map((a) => a.name))
const asset = release.assets.find((a) => a.name === assetName)
return {
tag: release.tag_name,
name: release.name,
publishedAt: release.published_at,
notes: release.body ?? '',
htmlUrl: release.html_url,
asset: asset
? {
name: asset.name,
sizeBytes: asset.size,
downloadUrl: asset.browser_download_url,
sha256: asset.digest?.startsWith('sha256:') ? asset.digest.slice(7) : undefined
}
: null
}
}
async installedReleaseTag(driver: ManagedDriverId): Promise<string | null> {
const read = async (path: string): Promise<string | null> => {
try {
const marker = JSON.parse(await fs.readFile(path, 'utf8')) as { tag?: string }
return marker.tag ?? null
} catch {
return null
}
}
const tag = await read(this.markerPath(driver))
if (tag) return tag
// Legacy location used by the first installer iteration (display only).
if (driver === 'display') return read(join(this.getBaseDir(), 'installed_release.json'))
return null
}
/** Non-elevated device presence/status query. */
async deviceState(driver: ManagedDriverId): Promise<ManagedDeviceState> {
const spec = DRIVERS[driver]
try {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' } | ForEach-Object { $_.Status } | ConvertTo-Json -Compress`
],
{ windowsHide: true, timeout: 20_000 }
)
const trimmed = stdout.trim()
if (!trimmed) return { count: 0, statuses: [] }
const parsed = JSON.parse(trimmed) as string | string[]
const statuses = Array.isArray(parsed) ? parsed : [parsed]
return { count: statuses.length, statuses }
} catch {
return { count: 0, statuses: [] }
}
}
/**
* Whether the boot configuration has test signing enabled. The Virtual
* Audio Driver is currently only test-signed, so its device will not start
* without it. Returns null when the state cannot be determined (bcdedit
* needs administrator rights).
*/
async testSigningEnabled(): Promise<boolean | null> {
try {
const { stdout } = await execFileAsync('bcdedit', ['/enum', '{current}'], { windowsHide: true, timeout: 15_000 })
return /testsigning\s+Yes/i.test(stdout)
} catch {
return null
}
}
/** Toggles Windows test signing via bcdedit (takes effect after a restart). */
async setTestSigning(enabled: boolean): Promise<LifecycleResult> {
return this.exclusive(async () => {
this.emitProgress({
phase: 'install',
percent: -1,
message: `${enabled ? 'Enabling' : 'Disabling'} Windows test signing (this may prompt for elevation)…`
})
return this.runElevated(`
Write-Output "== Windows test signing: ${enabled ? 'enable' : 'disable'} =="
$out = bcdedit /set "{current}" testsigning ${enabled ? 'on' : 'off'} 2>&1 | Out-String
Write-Output $out.Trim()
if ($LASTEXITCODE -ne 0) {
if ($out -match 'Secure Boot') {
Write-Output "RESULT: blocked by Secure Boot - disable Secure Boot in the UEFI firmware settings first"
} else {
Write-Output "RESULT: bcdedit failed with exit code $LASTEXITCODE"
}
$script:failed = $true
} else {
Write-Output "RESULT: test signing ${enabled ? 'enabled' : 'disabled'} - restart Windows for the change to take effect"
}
`)
})
}
// -------------------------------------------------------------------------
// Lifecycle operations
// -------------------------------------------------------------------------
async downloadAndInstall(driver: ManagedDriverId, instances = 1): Promise<LifecycleResult> {
const spec = DRIVERS[driver]
const target = clampInstances(instances, spec)
return this.exclusive(async () => {
const release = await this.latestRelease(driver)
if (!release.asset) return { ok: false, error: `Release ${release.tag} has no driver package for ${os.arch()}` }
const url = new URL(release.asset.downloadUrl)
if (url.protocol !== 'https:' || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) {
return { ok: false, error: `Refusing download from unexpected host: ${url.hostname}` }
}
// 1. Download with progress.
const workDir = join(app.getPath('temp'), `vdd-install-${driver}-${Date.now()}`)
await fs.mkdir(workDir, { recursive: true })
const zipPath = join(workDir, release.asset.name)
await this.downloadFile(release.asset.downloadUrl, zipPath, release.asset.sizeBytes)
// 2. Verify checksum against the digest GitHub publishes for the asset.
this.emitProgress({ phase: 'verify', percent: -1, message: 'Verifying package integrity…' })
if (release.asset.sha256) {
const actual = createHash('sha256').update(await fs.readFile(zipPath)).digest('hex')
if (actual.toLowerCase() !== release.asset.sha256.toLowerCase()) {
return { ok: false, error: `Checksum mismatch - expected ${release.asset.sha256}, got ${actual}` }
}
}
// 3. Extract.
this.emitProgress({ phase: 'extract', percent: -1, message: 'Extracting driver package…' })
const extractDir = join(workDir, 'extracted')
await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -Force -LiteralPath '${zipPath}' -DestinationPath '${extractDir}'`],
{ windowsHide: true, timeout: 60_000 }
)
const infPath = await this.findFile(extractDir, new RegExp(`^${spec.infName.replace('.', '\\.')}$`, 'i'))
if (!infPath) return { ok: false, error: `Driver package did not contain ${spec.infName}` }
const packageDir = infPath.slice(0, infPath.lastIndexOf('\\'))
// 4. Make sure the nefcon install tool is available.
const nefcon = await this.ensureNefcon()
// 5. Elevated install.
this.emitProgress({ phase: 'install', percent: -1, message: `Installing ${spec.title} (this may prompt for elevation)…` })
const result = await this.runElevated(this.buildInstallScript(driver, packageDir, target, nefcon))
if (!result.ok) return result
// 6. Record what we installed.
this.emitProgress({ phase: 'finalize', percent: -1, message: 'Finishing up…' })
try {
await fs.writeFile(
this.markerPath(driver),
JSON.stringify({ tag: release.tag, asset: release.asset.name, installedAt: new Date().toISOString() }, null, 2),
'utf8'
)
} catch {
// marker is best-effort
}
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined)
return result
})
}
async uninstall(driver: ManagedDriverId): Promise<LifecycleResult> {
return this.exclusive(async () => {
const nefcon = await this.ensureNefcon()
this.emitProgress({ phase: 'install', percent: -1, message: `Removing ${DRIVERS[driver].title} (this may prompt for elevation)…` })
const result = await this.runElevated(this.buildUninstallScript(driver, nefcon))
if (result.ok) await fs.rm(this.markerPath(driver), { force: true }).catch(() => undefined)
return result
})
}
async restartDevice(driver: ManagedDriverId): Promise<LifecycleResult> {
return this.exclusive(async () => {
this.emitProgress({ phase: 'install', percent: -1, message: 'Restarting device (this may prompt for elevation)…' })
return this.runElevated(this.buildRestartScript(driver))
})
}
/** Create or remove device nodes so exactly `count` instances exist (audio driver). */
async setInstances(driver: ManagedDriverId, count: number): Promise<LifecycleResult> {
const spec = DRIVERS[driver]
if (spec.maxInstances < 2) return { ok: false, error: `${spec.title} does not support multiple instances` }
const target = clampInstances(count, spec)
return this.exclusive(async () => {
const nefcon = await this.ensureNefcon()
this.emitProgress({ phase: 'install', percent: -1, message: `Setting ${spec.title} to ${target} device${target === 1 ? '' : 's'}…` })
return this.runElevated(this.buildSetInstancesScript(driver, target, nefcon))
})
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
private markerPath(driver: ManagedDriverId): string {
return join(app.getPath('userData'), `installed_release_${driver}.json`)
}
/**
* Ensures the pinned nefcon release is cached locally and returns the path
* to the console binary for this CPU architecture. The console flavor
* (nefconc) is used so its output lands in the elevated transcript; the
* window stays hidden either way.
*/
private async ensureNefcon(): Promise<string> {
const archDir = os.arch() === 'arm64' ? 'ARM64' : os.arch() === 'ia32' ? 'x86' : 'x64'
const toolDir = join(app.getPath('userData'), 'tools', `nefcon-${NEFCON.tag}`)
const exePath = join(toolDir, archDir, 'nefconc.exe')
if (await pathExists(exePath)) return exePath
this.emitProgress({ phase: 'download', percent: -1, message: `Downloading nefcon ${NEFCON.tag} (device install tool)…` })
const zipPath = join(app.getPath('temp'), `nefcon-${Date.now()}.zip`)
try {
await this.downloadFile(NEFCON.url, zipPath, 0)
this.emitProgress({ phase: 'verify', percent: -1, message: 'Verifying nefcon integrity…' })
const actual = createHash('sha256').update(await fs.readFile(zipPath)).digest('hex')
if (actual.toLowerCase() !== NEFCON.sha256) {
throw new Error(`nefcon download failed checksum verification (expected ${NEFCON.sha256}, got ${actual})`)
}
await fs.mkdir(toolDir, { recursive: true })
await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', `Expand-Archive -Force -LiteralPath '${zipPath}' -DestinationPath '${toolDir}'`],
{ windowsHide: true, timeout: 60_000 }
)
if (!(await pathExists(exePath))) throw new Error('nefcon package did not contain the expected binary')
return exePath
} finally {
void fs.rm(zipPath, { force: true }).catch(() => undefined)
}
}
private async exclusive(operation: () => Promise<LifecycleResult>): Promise<LifecycleResult> {
if (this.busy) return { ok: false, error: 'Another driver operation is already running' }
this.busy = true
try {
return await operation()
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
} finally {
this.busy = false
}
}
private async downloadFile(url: string, destination: string, expectedSize: number): Promise<void> {
const response = await fetch(url, { headers: { 'User-Agent': 'Virtual-Driver-Control' } })
if (!response.ok || !response.body) throw new Error(`Download failed with HTTP ${response.status}`)
const total = Number(response.headers.get('content-length')) || expectedSize || 0
const out = createWriteStream(destination)
const reader = response.body.getReader()
let received = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
received += value.byteLength
if (!out.write(Buffer.from(value))) {
await new Promise<void>((resolve) => out.once('drain', resolve))
}
this.emitProgress({
phase: 'download',
percent: total > 0 ? Math.round((received / total) * 100) : -1,
message: `Downloading ${(received / 1024).toFixed(0)} KB${total > 0 ? ` of ${(total / 1024).toFixed(0)} KB` : ''}…`
})
}
} finally {
await new Promise<void>((resolve) => out.end(resolve))
}
}
private async findFile(root: string, pattern: RegExp): Promise<string | null> {
const entries = await fs.readdir(root, { withFileTypes: true })
for (const entry of entries) {
const full = join(root, entry.name)
if (entry.isFile() && pattern.test(entry.name)) return full
if (entry.isDirectory()) {
const nested = await this.findFile(full, pattern)
if (nested) return nested
}
}
return null
}
/**
* Runs a PowerShell script with admin rights. If the app is already
* elevated it runs inline; otherwise a single UAC prompt is triggered.
* Output is captured through a temp log file in both cases.
*/
private async runElevated(script: string): Promise<LifecycleResult> {
const stamp = Date.now()
const scriptPath = join(app.getPath('temp'), `vdd-op-${stamp}.ps1`)
const logPath = join(app.getPath('temp'), `vdd-op-${stamp}.log`)
const wrapped = [
`$ErrorActionPreference = 'Continue'`,
`Start-Transcript -Path '${logPath}' -Force | Out-Null`,
`$script:failed = $false`,
script,
`Stop-Transcript | Out-Null`,
`if ($script:failed) { exit 1 } else { exit 0 }`
].join('\r\n')
await fs.writeFile(scriptPath, wrapped, 'utf8')
try {
const elevated = await this.isAdmin()
let exitCode: number
if (elevated) {
try {
await execFileAsync('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], {
windowsHide: true,
timeout: 300_000
})
exitCode = 0
} catch (error) {
exitCode = (error as { code?: number }).code ?? 1
}
} else {
// -Verb RunAs cannot capture output directly; the transcript log covers that.
const launcher = `$p = Start-Process powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${scriptPath}'; exit $p.ExitCode`
try {
await execFileAsync('powershell.exe', ['-NoProfile', '-Command', launcher], { windowsHide: true, timeout: 300_000 })
exitCode = 0
} catch (error) {
const failure = error as { code?: number; message?: string; stderr?: string }
// UAC decline surfaces as a launcher error ("canceled by the user").
if (/canceled|cancelled/i.test(`${failure.message ?? ''} ${failure.stderr ?? ''}`)) {
return { ok: false, error: 'Elevation was declined - the operation was cancelled.' }
}
exitCode = failure.code ?? 1
}
}
const log = await fs.readFile(logPath, 'utf8').catch(() => '')
const detail = summarizeTranscript(log)
if (exitCode === 0) return { ok: true, detail }
return { ok: false, error: `Operation failed (exit ${exitCode})`, detail }
} finally {
void fs.rm(scriptPath, { force: true }).catch(() => undefined)
void fs.rm(logPath, { force: true }).catch(() => undefined)
}
}
// -------------------------------------------------------------------------
// Elevated script builders
// -------------------------------------------------------------------------
private buildInstallScript(driver: ManagedDriverId, packageDir: string, instances: number, nefconPath: string): string {
const spec = DRIVERS[driver]
const copyBlock = spec.copyToBaseDir
? `
# --- Copy package to the driver folder (never overwrite user settings) ---
$base = '${this.getBaseDir()}'
New-Item -ItemType Directory -Force -Path $base | Out-Null
$preserve = @(${spec.preserveFiles.map((f) => `'${f}'`).join(',')})
foreach ($f in Get-ChildItem -File $pkg) {
if (($preserve -contains $f.Name) -and (Test-Path (Join-Path $base $f.Name))) {
Write-Output ("Keeping existing " + $f.Name)
continue
}
Copy-Item -Force $f.FullName (Join-Path $base $f.Name)
}
# --- Point the driver's settings lookup (VDDPATH) at this folder ---
New-Item -Path 'HKLM:\\SOFTWARE\\MikeTheTech\\VirtualDisplayDriver' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MikeTheTech\\VirtualDisplayDriver' -Name 'VDDPATH' -Value $base -Force
Write-Output "VDDPATH registry value set to $base"
`
: ''
// Single-instance drivers also clean up stray duplicate nodes from earlier
// failed installs; multi-instance drivers must keep their duplicates.
const installFlags = spec.maxInstances === 1 ? '--no-duplicates --remove-duplicates' : '--no-duplicates'
return `
$pkg = '${packageDir}'
$inf = Join-Path $pkg '${spec.infName}'
$bin = Join-Path $pkg '${spec.signedBinary}'
$nefcon = '${nefconPath}'
$target = ${instances}
Write-Output "== ${spec.title} install =="
${copyBlock}
# --- Trust the package signer so the driver installs silently ---
$sig = Get-AuthenticodeSignature $bin
if ($sig.SignerCertificate) {
Write-Output ("Trusting signer: " + $sig.SignerCertificate.Subject)
foreach ($storeName in @('TrustedPublisher','Root')) {
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store($storeName, 'LocalMachine')
$store.Open('ReadWrite')
$store.Add($sig.SignerCertificate)
$store.Close()
}
} else {
Write-Output "WARNING: package is unsigned"
}
# --- Create the device node and install the driver via nefcon ---
Write-Output "Installing driver via nefcon (devcon-compatible install)"
& $nefcon install "$inf" '${spec.hardwareId}' ${installFlags} 2>&1 | ForEach-Object { Write-Output $_ }
$rc = $LASTEXITCODE
if ($rc -eq 3010) {
Write-Output "NOTE: Windows reports a reboot is required to finish the install"
} elseif ($rc -ne 0) {
Write-Output "nefcon install failed with exit code $rc"
$script:failed = $true
}
# --- Create any additional device nodes up to the target count ---
$existing = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' })
Write-Output ("Existing device nodes: " + $existing.Count + ", target: " + $target)
$toCreate = $target - $existing.Count
if (-not $script:failed -and $toCreate -gt 0) {
for ($i = 0; $i -lt $toCreate; $i++) {
& $nefcon --create-device-node --hardware-id '${spec.hardwareId}' --class-name '${spec.className}' --class-guid '${spec.classGuid}' 2>&1 | ForEach-Object { Write-Output $_ }
if ($LASTEXITCODE -ne 0) {
Write-Output "Device node creation failed with exit code $LASTEXITCODE"
$script:failed = $true
break
}
Write-Output ("Device node " + ($existing.Count + $i + 1) + " created")
}
# Bind the staged driver to the freshly created nodes.
& $nefcon --install-driver --inf-path "$inf" 2>&1 | ForEach-Object { Write-Output $_ }
}
# --- Verify ---
Start-Sleep -Seconds 2
$dev = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' })
if ($dev.Count -gt 0) {
Write-Output ("RESULT: " + $dev.Count + " device(s) present, status " + (($dev | ForEach-Object { $_.Status }) -join ', '))
} else {
Write-Output "RESULT: device not found after install"
$script:failed = $true
}
`
}
private buildUninstallScript(driver: ManagedDriverId, nefconPath: string): string {
const spec = DRIVERS[driver]
return `
$nefcon = '${nefconPath}'
Write-Output "== ${spec.title} uninstall =="
# --- Remove the device node(s) and driver via nefcon ---
$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' })
if ($devices.Count -gt 0) {
Write-Output ("Removing " + $devices.Count + " device node(s) via nefcon")
& $nefcon --remove-device-node --hardware-id '${spec.hardwareId}' --class-guid '${spec.classGuid}' 2>&1 | ForEach-Object { Write-Output $_ }
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) {
Write-Output "nefcon remove-device-node failed with exit code $LASTEXITCODE"
$script:failed = $true
}
} else {
Write-Output "No ${spec.title} device present"
}
# --- Sweep any leftover staged package(s) out of the driver store ---
$enum = pnputil /enum-drivers | Out-String
$blocks = $enum -split '(?=Published Name)'
foreach ($b in $blocks) {
if ($b -match '${spec.infName.replace('.', '\\.')}') {
if ($b -match 'Published Name\\s*:\\s*(oem\\d+\\.inf)') {
$oem = $Matches[1]
Write-Output "Deleting driver package $oem"
pnputil /delete-driver $oem /uninstall /force 2>&1 | ForEach-Object { Write-Output $_ }
}
}
}
Write-Output "RESULT: uninstall complete (configuration files were kept)"
`
}
private buildRestartScript(driver: ManagedDriverId): string {
const spec = DRIVERS[driver]
return `
Write-Output "== ${spec.title} device restart =="
$devices = Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' }
if (-not $devices) {
Write-Output "RESULT: no device found"
$script:failed = $true
} else {
foreach ($d in $devices) {
Write-Output ("Restarting " + $d.InstanceId)
pnputil /disable-device $d.InstanceId 2>&1 | ForEach-Object { Write-Output $_ }
Start-Sleep -Seconds 2
pnputil /enable-device $d.InstanceId 2>&1 | ForEach-Object { Write-Output $_ }
}
Start-Sleep -Seconds 2
$after = Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' -and $_.Status -eq 'OK' }
if ($after) { Write-Output "RESULT: device restarted, status OK" }
else { Write-Output "RESULT: device did not come back healthy"; $script:failed = $true }
}
`
}
private buildSetInstancesScript(driver: ManagedDriverId, target: number, nefconPath: string): string {
const spec = DRIVERS[driver]
return `
$nefcon = '${nefconPath}'
$target = ${target}
Write-Output "== ${spec.title}: set instance count to $target =="
# --- Locate the staged INF (needed to bind newly created devices) ---
$oem = $null
$enum = pnputil /enum-drivers | Out-String
$blocks = $enum -split '(?=Published Name)'
foreach ($b in $blocks) {
if ($b -match '${spec.infName.replace('.', '\\.')}' -and $b -match 'Published Name\\s*:\\s*(oem\\d+\\.inf)') {
$oem = $Matches[1]
}
}
if (-not $oem) {
Write-Output "RESULT: driver is not installed - install it first"
$script:failed = $true
} else {
$infPath = Join-Path $env:windir ('INF\\' + $oem)
Write-Output "Using staged driver $oem"
$existing = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' } | Sort-Object InstanceId)
Write-Output ("Existing device nodes: " + $existing.Count)
if ($existing.Count -lt $target) {
for ($i = $existing.Count; $i -lt $target; $i++) {
& $nefcon --create-device-node --hardware-id '${spec.hardwareId}' --class-name '${spec.className}' --class-guid '${spec.classGuid}' 2>&1 | ForEach-Object { Write-Output $_ }
if ($LASTEXITCODE -ne 0) {
Write-Output "Device node creation failed with exit code $LASTEXITCODE"
$script:failed = $true
break
}
Write-Output ("Device node " + ($i + 1) + " created")
}
# Bind the staged driver to the freshly created nodes.
& $nefcon --install-driver --inf-path "$infPath" 2>&1 | ForEach-Object { Write-Output $_ }
} elseif ($existing.Count -gt $target) {
$toRemove = $existing | Select-Object -Last ($existing.Count - $target)
foreach ($d in $toRemove) {
Write-Output ("Removing device " + $d.InstanceId)
pnputil /remove-device $d.InstanceId 2>&1 | ForEach-Object { Write-Output $_ }
}
} else {
Write-Output "Already at target count"
}
Start-Sleep -Seconds 2
$after = @(Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.HardwareID -match '${spec.hardwareIdPattern}' })
Write-Output ("RESULT: " + $after.Count + " device(s) present, status " + (($after | ForEach-Object { $_.Status }) -join ', '))
if ($after.Count -ne $target) { $script:failed = $true }
}
`
}
}
function pathExists(path: string): Promise<boolean> {
return fs.access(path).then(
() => true,
() => false
)
}
function clampInstances(value: number, spec: DriverSpec): number {
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.round(value), 1), spec.maxInstances)
}
/** Pull the useful tail out of a PowerShell transcript. */
function summarizeTranscript(log: string): string {
const lines = log
.split(/\r?\n/)
.filter((l) => l.trim().length > 0)
.filter((l) => !/^\*{10,}|^Windows PowerShell transcript|^Start time|^End time|^Username|^RunAs User|^Configuration Name|^Machine|^Host Application|^Process ID|^PSVersion|^PSEdition|^PSCompatibleVersions|^BuildVersion|^CLRVersion|^WSManStackVersion|^PSRemotingProtocolVersion|^SerializationVersion|^Transcript started|^\s*$/.test(l))
return lines.slice(-25).join('\n')
}
@@ -0,0 +1,142 @@
import { EventEmitter } from 'events'
import { promises as fs } from 'fs'
import { existsSync } from 'fs'
import { join } from 'path'
import type { LogEvent, LogSeverity, PipeResult } from '@shared/types'
import type { PipeClient } from './pipe-client'
const POLL_INTERVAL_MS = 1_500
const RING_BUFFER_SIZE = 3_000
const INITIAL_TAIL_BYTES = 64 * 1024
/**
* Streams driver activity into a single unified feed:
* - tails the daily file log at <baseDir>\Logs\log_YYYY-MM-DD.txt
* - captures every pipe command's streamed response lines
*/
export class LogService extends EventEmitter {
private buffer: LogEvent[] = []
private nextId = 1
private timer: NodeJS.Timeout | null = null
private currentFile: string | null = null
private offset = 0
private pendingPartial = ''
constructor(
private readonly pipe: PipeClient,
private readonly getBaseDir: () => string
) {
super()
this.pipe.on('result', (result: PipeResult) => this.capturePipeResult(result))
}
start(): void {
if (this.timer) return
this.timer = setInterval(() => void this.pollFile(), POLL_INTERVAL_MS)
void this.pollFile()
}
stop(): void {
if (this.timer) clearInterval(this.timer)
this.timer = null
}
recent(): LogEvent[] {
return this.buffer
}
appInfo(message: string): void {
this.push([this.makeEvent('app', 'info', message)])
}
get logsDir(): string {
return join(this.getBaseDir(), 'Logs')
}
private todaysFile(): string {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return join(this.logsDir, `log_${y}-${m}-${d}.txt`)
}
private async pollFile(): Promise<void> {
try {
const file = this.todaysFile()
if (!existsSync(file)) {
if (this.currentFile === file) return
this.currentFile = null
return
}
const stat = await fs.stat(file)
if (file !== this.currentFile) {
// New day or first poll: tail the end of the file rather than re-emitting history.
this.currentFile = file
this.offset = Math.max(0, stat.size - INITIAL_TAIL_BYTES)
this.pendingPartial = ''
}
if (stat.size < this.offset) {
// File truncated/rotated.
this.offset = 0
this.pendingPartial = ''
}
if (stat.size === this.offset) return
const handle = await fs.open(file, 'r')
try {
const length = stat.size - this.offset
const chunk = Buffer.alloc(Math.min(length, 1024 * 1024))
const { bytesRead } = await handle.read(chunk, 0, chunk.length, this.offset)
this.offset += bytesRead
const text = this.pendingPartial + chunk.subarray(0, bytesRead).toString('utf8')
const lines = text.split(/\r?\n/)
this.pendingPartial = lines.pop() ?? ''
const events = lines
.map((l) => l.trim())
.filter((l) => l.length > 0)
.map((line) => this.makeEvent('file', classify(line), line))
if (events.length > 0) this.push(events)
} finally {
await handle.close()
}
} catch {
// Logs folder unreadable - silent, retry next poll.
}
}
private capturePipeResult(result: PipeResult): void {
const events: LogEvent[] = []
const head = result.command.split(' ')[0]
if (!result.ok) {
events.push(this.makeEvent('pipe', 'error', `${head} failed: ${result.error ?? 'unknown error'}`))
} else {
events.push(this.makeEvent('pipe', 'info', `> ${result.command} (${result.durationMs}ms)`))
for (const line of result.lines.slice(0, 200)) {
events.push(this.makeEvent('pipe', classify(line), line))
}
}
this.push(events)
}
private makeEvent(source: LogEvent['source'], severity: LogSeverity, message: string): LogEvent {
return { id: this.nextId++, timestamp: Date.now(), source, severity, message: message.slice(0, 2000) }
}
private push(events: LogEvent[]): void {
this.buffer.push(...events)
if (this.buffer.length > RING_BUFFER_SIZE) {
this.buffer = this.buffer.slice(this.buffer.length - RING_BUFFER_SIZE)
}
this.emit('events', events)
}
}
function classify(line: string): LogSeverity {
const lower = line.toLowerCase()
if (/\b(error|failed|failure|exception|crash)\b/.test(lower)) return 'error'
if (/\b(warn|warning)\b/.test(lower)) return 'warning'
if (/\b(debug|trace|verbose)\b/.test(lower)) return 'debug'
return 'info'
}
@@ -0,0 +1,308 @@
import { execFile } from 'child_process'
import { EventEmitter } from 'events'
import { promises as fs } from 'fs'
import { createConnection, Socket } from 'net'
import { join } from 'path'
import { promisify } from 'util'
import { app } from 'electron'
import type { DriverLiveSettings, PipeResult, PipeToggleCommand } from '@shared/types'
const execFileAsync = promisify(execFile)
const PIPE_PATH = '\\\\.\\pipe\\MTTVirtualDisplayPipe'
/**
* Commands the driver answers with data. These need the PowerShell round trip:
* the driver responds with WriteFile immediately followed by
* DisconnectNamedPipe, which discards anything the client has not read yet.
* Only a client with an overlapped read already pending in the kernel receives
* the data - Node's net stack reads too late by design, .NET ReadAsync works.
*/
const RESPONSE_COMMANDS = new Set(['GETSETTINGS', 'GETALLGPUS', 'GETASSIGNEDGPU', 'IDDCXVERSION'])
/** Round-trip helper: arms an overlapped read before writing the command. */
const HELPER_PS1 = `param([Parameter(Mandatory=$true)][string]$CommandB64, [int]$TimeoutMs = 10000)
$ErrorActionPreference = 'Stop'
try { if (-not (Test-Path '\\\\.\\pipe\\MTTVirtualDisplayPipe')) { exit 2 } } catch { }
$cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($CommandB64))
$pipe = New-Object System.IO.Pipes.NamedPipeClientStream('.', 'MTTVirtualDisplayPipe', [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::Asynchronous)
try {
try { $pipe.Connect(4000) } catch { exit 3 }
$buf = New-Object byte[] 65536
$mem = New-Object System.IO.MemoryStream
$read = $pipe.ReadAsync($buf, 0, $buf.Length)
Start-Sleep -Milliseconds 30
$bytes = [System.Text.Encoding]::Unicode.GetBytes($cmd)
$pipe.Write($bytes, 0, $bytes.Length)
$pipe.Flush()
if (-not $read.Wait($TimeoutMs)) { exit 4 }
$n = 0
try { $n = $read.Result } catch { $n = 0 }
while ($n -gt 0) {
$mem.Write($buf, 0, $n)
$read = $pipe.ReadAsync($buf, 0, $buf.Length)
if (-not $read.Wait(400)) { break }
try { $n = $read.Result } catch { $n = 0 }
}
[Console]::Out.Write([Convert]::ToBase64String($mem.ToArray()))
exit 0
} catch {
exit 5
} finally {
$pipe.Dispose()
}
`
/** Commands that trigger an internal driver reload (heavyweight, 2-8s). */
const RELOAD_COMMANDS = new Set([
'SETDISPLAYCOUNT',
'SETGPU',
'HDRPLUS',
'SDR10',
'CUSTOMEDID',
'PREVENTSPOOF',
'CEAOVERRIDE',
'HARDWARECURSOR'
])
const DEFAULT_TIMEOUT_MS = 10_000
const RELOAD_TIMEOUT_MS = 45_000
const CONNECT_TIMEOUT_MS = 4_000
/** Minimum spacing between reload-triggering commands (driver stability). */
const RELOAD_COOLDOWN_MS = 3_000
export interface PipeSendOptions {
timeoutMs?: number
/** Suppress activity events (used by the heartbeat PING). */
quiet?: boolean
}
/**
* Client for \\.\pipe\MTTVirtualDisplayPipe.
*
* Protocol rules implemented here:
* - one-shot connection per command (driver disconnects after responding)
* - commands written as UTF-16LE, responses read until disconnect
* - responses decoded as UTF-8 except GETSETTINGS (UTF-16LE)
* - all commands fully serialized; reload-triggering commands get a cooldown
* - RELOAD_DRIVER is never sent (upstream undefined behavior, issue #351)
* - fire-and-forget commands go through a Node socket; the server closing the
* pipe right after reading the command (EPIPE) counts as success
* - response-bearing commands go through a PowerShell helper that arms an
* overlapped read before writing, otherwise the driver's write-then-
* disconnect pattern discards the response before it can be read
*/
export class PipeClient extends EventEmitter {
private queue: Promise<unknown> = Promise.resolve()
private lastReloadFinishedAt = 0
private helperPath: string | null = null
/** Serialized send. Resolves with a PipeResult, never rejects. */
send(command: string, options: PipeSendOptions = {}): Promise<PipeResult> {
const run = this.queue.then(() => this.execute(command, options))
this.queue = run.catch(() => undefined)
return run
}
async ping(): Promise<boolean> {
// Connect + write succeeding proves the driver's pipe server handled the
// command; the PONG reply itself is unreadable without the PS helper and
// not worth a powershell spawn every heartbeat.
const result = await this.send('PING', { timeoutMs: 3_000, quiet: true })
return result.ok
}
async setDisplayCount(count: number): Promise<PipeResult> {
const n = Math.max(0, Math.min(99, Math.floor(count)))
return this.send(`SETDISPLAYCOUNT ${n}`)
}
async setToggle(name: PipeToggleCommand, value: boolean): Promise<PipeResult> {
return this.send(`${name} ${value ? 'true' : 'false'}`)
}
async setGpu(friendlyName: string): Promise<PipeResult> {
const clean = friendlyName.replace(/["\r\n]/g, '').slice(0, 100)
return this.send(`SETGPU "${clean}"`)
}
async getDriverSettings(): Promise<DriverLiveSettings | null> {
const result = await this.send('GETSETTINGS')
if (!result.ok) return null
const match = result.response.match(/SETTINGS\s+DEBUG=(true|false)\s+LOG=(true|false)/i)
if (!match) return null
return { debug: match[1].toLowerCase() === 'true', log: match[2].toLowerCase() === 'true' }
}
private isReloadCommand(command: string): boolean {
const head = command.split(' ')[0].toUpperCase()
return RELOAD_COMMANDS.has(head)
}
private async execute(command: string, options: PipeSendOptions): Promise<PipeResult> {
const started = Date.now()
const reload = this.isReloadCommand(command)
const timeoutMs = options.timeoutMs ?? (reload ? RELOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)
if (command.toUpperCase() === 'RELOAD_DRIVER') {
return this.finish(command, started, options, {
ok: false,
response: '',
error: 'RELOAD_DRIVER is blocked: it causes undefined behavior in the driver. Use SETDISPLAYCOUNT instead.'
})
}
if (command.length > 127) {
return this.finish(command, started, options, {
ok: false,
response: '',
error: 'Command exceeds the 127 character pipe buffer limit.'
})
}
if (reload) {
const wait = this.lastReloadFinishedAt + RELOAD_COOLDOWN_MS - Date.now()
if (wait > 0) await delay(wait)
}
try {
const head = command.split(' ')[0].toUpperCase()
const raw = RESPONSE_COMMANDS.has(head)
? await this.roundTripPs(command, timeoutMs)
: await this.roundTrip(command, timeoutMs)
const isUtf16Response = command.toUpperCase() === 'GETSETTINGS'
const decoded = raw
.toString(isUtf16Response ? 'utf16le' : 'utf8')
.replace(/\0+/g, '')
.trim()
return this.finish(command, started, options, { ok: true, response: decoded })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return this.finish(command, started, options, { ok: false, response: '', error: message })
} finally {
if (reload) this.lastReloadFinishedAt = Date.now()
}
}
private finish(
command: string,
started: number,
options: PipeSendOptions,
partial: { ok: boolean; response: string; error?: string }
): PipeResult {
const result: PipeResult = {
command,
ok: partial.ok,
response: partial.response,
lines: partial.response.length > 0 ? partial.response.split(/\r?\n/).filter((l) => l.trim().length > 0) : [],
durationMs: Date.now() - started,
error: partial.error
}
if (!options.quiet) this.emit('result', result)
return result
}
private roundTrip(command: string, timeoutMs: number): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
let settled = false
let wrote = false
let socket: Socket | null = null
const overallTimer = setTimeout(() => {
fail(new Error(`Pipe command timed out after ${Math.round(timeoutMs / 1000)}s`))
}, timeoutMs)
const succeed = (): void => {
if (settled) return
settled = true
clearTimeout(overallTimer)
socket?.destroy()
resolve(Buffer.concat(chunks))
}
const fail = (error: Error): void => {
if (settled) return
settled = true
clearTimeout(overallTimer)
socket?.destroy()
const friendly =
(error as NodeJS.ErrnoException).code === 'ENOENT'
? new Error('Driver pipe not available (driver not running)')
: error
reject(friendly)
}
socket = createConnection(PIPE_PATH)
socket.setTimeout(CONNECT_TIMEOUT_MS, () => {
// Only treat as failure while still connecting; once data flows we rely on the overall timer.
if (chunks.length === 0 && socket && socket.connecting) {
fail(new Error('Timed out connecting to driver pipe'))
}
})
socket.on('connect', () => {
socket?.setTimeout(0)
socket?.write(Buffer.from(command, 'utf16le'), (err) => {
if (err) fail(err)
else wrote = true
})
})
socket.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
socket.on('end', succeed)
socket.on('close', succeed)
socket.on('error', (error) => {
// The driver disconnects as soon as it has read the command, which
// surfaces as EPIPE here. The command was delivered - that's success.
if (wrote && (error as NodeJS.ErrnoException).code === 'EPIPE') succeed()
else fail(error)
})
})
}
/**
* Response-bearing round trip via PowerShell/.NET: an overlapped ReadAsync
* is pending in the kernel before the command is written, so the response
* survives the driver's immediate DisconnectNamedPipe. Returns raw bytes
* (stdout carries them base64-encoded to avoid console encoding mangling).
*/
private async roundTripPs(command: string, timeoutMs: number): Promise<Buffer> {
const helper = await this.ensureHelper()
try {
const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
helper,
'-CommandB64',
Buffer.from(command, 'utf8').toString('base64'),
'-TimeoutMs',
String(timeoutMs)
],
{ windowsHide: true, timeout: timeoutMs + 15_000, maxBuffer: 4 * 1024 * 1024 }
)
return Buffer.from(stdout.trim(), 'base64')
} catch (error) {
const code = (error as { code?: number }).code
if (code === 2) throw new Error('Driver pipe not available (driver not running)')
if (code === 3) throw new Error('Timed out connecting to driver pipe')
if (code === 4) throw new Error(`Pipe command timed out after ${Math.round(timeoutMs / 1000)}s`)
throw error instanceof Error ? error : new Error(String(error))
}
}
private async ensureHelper(): Promise<string> {
if (this.helperPath) return this.helperPath
const path = join(app.getPath('userData'), 'pipe-helper.ps1')
await fs.writeFile(path, HELPER_PS1, 'utf8')
this.helperPath = path
return path
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -0,0 +1,167 @@
import { execFile } from 'child_process'
import { app } from 'electron'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { DEFAULT_BASE_DIR, type AppPreferences, type BaseDirResult } from '@shared/types'
const execFileAsync = promisify(execFile)
/** Registry location the driver reads its settings path from (confirmed in MttVDD.dll). */
const VDD_REG_KEY = 'HKLM\\SOFTWARE\\MikeTheTech\\VirtualDisplayDriver'
const VDD_REG_VALUE = 'VDDPATH'
/** Absolute Windows path without characters that would break quoting. */
const SAFE_PATH_PATTERN = /^[A-Za-z]:\\[^"'<>|?*\r\n]*$/
const DEFAULT_PREFS: AppPreferences = {
theme: 'dark',
accent: '#4cc2ff',
baseDir: DEFAULT_BASE_DIR,
audioRoutes: []
}
export class PrefsService {
private prefs: AppPreferences
constructor() {
this.prefs = this.read()
}
get(): AppPreferences {
return this.prefs
}
getBaseDir(): string {
return this.prefs.baseDir || DEFAULT_BASE_DIR
}
set(patch: Partial<AppPreferences>): AppPreferences {
const next: AppPreferences = { ...this.prefs, ...patch }
if (typeof next.baseDir !== 'string' || next.baseDir.trim().length === 0) next.baseDir = DEFAULT_BASE_DIR
if (!/^#[0-9a-fA-F]{6}$/.test(next.accent)) next.accent = DEFAULT_PREFS.accent
if (!['dark', 'light', 'system'].includes(next.theme)) next.theme = 'dark'
if (!Array.isArray(next.audioRoutes)) next.audioRoutes = []
next.audioRoutes = next.audioRoutes.slice(0, 16).filter(
(r) => typeof r?.id === 'string' && typeof r?.sourceId === 'string' && typeof r?.sinkId === 'string'
)
this.prefs = next
this.write()
return this.prefs
}
/**
* Reads the driver's VDDPATH registry value. The driver loads
* `<VDDPATH>\vdd_settings.xml`, defaulting to C:\VirtualDisplayDriver when
* the value is absent - so the app must follow it to edit the real file.
*/
async readDriverRegistryPath(): Promise<string | null> {
try {
const { stdout } = await execFileAsync('reg.exe', ['query', VDD_REG_KEY, '/v', VDD_REG_VALUE], {
windowsHide: true,
timeout: 10_000
})
const match = new RegExp(`${VDD_REG_VALUE}\\s+REG_(?:EXPAND_)?SZ\\s+(.+)`).exec(stdout)
const value = match?.[1]?.trim()
return value && value.length > 0 ? value : null
} catch {
return null
}
}
/**
* Aligns the app's base dir with what the driver will actually read:
* the VDDPATH registry value, or C:\VirtualDisplayDriver when unset.
*/
async syncBaseDirWithDriver(): Promise<void> {
const effective = normalizePath((await this.readDriverRegistryPath()) ?? DEFAULT_BASE_DIR)
if (effective.toLowerCase() !== this.getBaseDir().toLowerCase()) {
this.set({ baseDir: effective })
}
}
/**
* Changes the driver folder: writes VDDPATH (directly when the app is
* elevated, otherwise through a single UAC prompt), verifies the registry
* took the value, and only then updates the preference - so the app and the
* driver can never point at different places.
*/
async setBaseDir(rawPath: string): Promise<BaseDirResult> {
const baseDir = normalizePath(rawPath)
if (!SAFE_PATH_PATTERN.test(baseDir)) {
return { ok: false, prefs: this.prefs, error: 'Enter an absolute path like C:\\VirtualDisplayDriver' }
}
if (!(await this.writeRegistryDirect(baseDir))) {
await this.writeRegistryElevated(baseDir)
}
const applied = normalizePath((await this.readDriverRegistryPath()) ?? DEFAULT_BASE_DIR)
if (applied.toLowerCase() !== baseDir.toLowerCase()) {
// Registry still points elsewhere (UAC declined / write failed).
await this.syncBaseDirWithDriver()
return {
ok: false,
prefs: this.prefs,
error: 'The VDDPATH registry value could not be updated (elevation declined?). Folder left unchanged.'
}
}
this.set({ baseDir })
return { ok: true, prefs: this.prefs }
}
private async writeRegistryDirect(baseDir: string): Promise<boolean> {
try {
await execFileAsync('reg.exe', ['add', VDD_REG_KEY, '/v', VDD_REG_VALUE, '/t', 'REG_SZ', '/d', baseDir, '/f'], {
windowsHide: true,
timeout: 10_000
})
return true
} catch {
return false
}
}
private async writeRegistryElevated(baseDir: string): Promise<void> {
const launcher = `$p = Start-Process reg.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList 'add','${VDD_REG_KEY}','/v','${VDD_REG_VALUE}','/t','REG_SZ','/d','"${baseDir}"','/f'; exit $p.ExitCode`
try {
await execFileAsync('powershell.exe', ['-NoProfile', '-Command', launcher], { windowsHide: true, timeout: 120_000 })
} catch {
// verified by re-reading the registry afterwards
}
}
private get filePath(): string {
return join(app.getPath('userData'), 'preferences.json')
}
private read(): AppPreferences {
try {
if (existsSync(this.filePath)) {
const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial<AppPreferences>
// Migrate the pre-WinUI default accent (mint) to the new default.
if (parsed.accent === '#36c98e') delete parsed.accent
return { ...DEFAULT_PREFS, ...parsed }
}
} catch {
// fall through to defaults
}
return { ...DEFAULT_PREFS }
}
private write(): void {
try {
mkdirSync(app.getPath('userData'), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(this.prefs, null, 2), 'utf8')
} catch {
// non-fatal
}
}
}
/** Trims and strips a trailing backslash (keeps drive roots like C:\ intact). */
function normalizePath(value: string): string {
const trimmed = value.trim()
return /^[A-Za-z]:\\$/.test(trimmed) ? trimmed : trimmed.replace(/[\\/]+$/, '')
}
@@ -0,0 +1,516 @@
import { XMLParser } from 'fast-xml-parser'
import { promises as fs } from 'fs'
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { DEFAULT_VDD_SETTINGS } from '@shared/defaults'
import type { BackupInfo, ResolutionEntry, SaveResult, SettingsLoadResult, VddSettings } from '@shared/types'
const MAX_BACKUPS = 20
type Raw = Record<string, unknown>
function toBool(value: unknown, fallback: boolean): boolean {
if (typeof value === 'string') return value.trim().toLowerCase() === 'true'
if (typeof value === 'boolean') return value
return fallback
}
function toNum(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const n = Number(value.trim())
if (Number.isFinite(n)) return n
}
return fallback
}
function toStr(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
if (typeof value === 'number') return String(value)
return fallback
}
function asRaw(value: unknown): Raw {
return typeof value === 'object' && value !== null ? (value as Raw) : {}
}
function asArray(value: unknown): unknown[] {
if (value === undefined || value === null) return []
return Array.isArray(value) ? value : [value]
}
function formatRate(rate: number): string {
return Number.isInteger(rate) ? String(rate) : String(Math.round(rate * 1000) / 1000)
}
export class SettingsService {
constructor(private readonly getBaseDir: () => string) {}
get settingsPath(): string {
return join(this.getBaseDir(), 'vdd_settings.xml')
}
get backupsDir(): string {
return join(this.getBaseDir(), 'Backups')
}
get edidDir(): string {
return join(this.getBaseDir(), 'EDID')
}
/**
* Creates the driver folder and a default vdd_settings.xml when missing,
* so the driver always finds a valid configuration at its lookup path.
*/
async ensureDefaults(): Promise<void> {
try {
mkdirSync(this.getBaseDir(), { recursive: true })
if (!existsSync(this.settingsPath)) {
await fs.writeFile(this.settingsPath, this.serialize(DEFAULT_VDD_SETTINGS))
}
} catch {
// folder not writable without elevation - app keeps working offline
}
}
async load(): Promise<SettingsLoadResult> {
try {
if (!existsSync(this.settingsPath)) {
return { ok: true, settings: structuredClone(DEFAULT_VDD_SETTINGS), isDefault: true }
}
const rawXml = await fs.readFile(this.settingsPath, 'utf8')
const settings = this.parse(rawXml)
return { ok: true, settings, rawXml, isDefault: false }
} catch (error) {
return {
ok: false,
isDefault: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
async rawXml(): Promise<string | null> {
try {
return await fs.readFile(this.settingsPath, 'utf8')
} catch {
return null
}
}
async save(settings: VddSettings): Promise<SaveResult> {
try {
const xml = this.serialize(settings)
const backupCreated = await this.backupCurrent()
const tmpPath = `${this.settingsPath}.tmp`
mkdirSync(this.getBaseDir(), { recursive: true })
await fs.writeFile(tmpPath, xml, 'utf8')
await fs.rm(this.settingsPath, { force: true })
await fs.rename(tmpPath, this.settingsPath)
return { ok: true, backupCreated }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
async listBackups(): Promise<BackupInfo[]> {
try {
const entries = await fs.readdir(this.backupsDir)
const backups: BackupInfo[] = []
for (const fileName of entries) {
if (!/^vdd_settings_[\d\-_]+\.xml$/.test(fileName)) continue
const fullPath = join(this.backupsDir, fileName)
const stat = await fs.stat(fullPath)
backups.push({ fileName, fullPath, createdAt: stat.mtimeMs, sizeBytes: stat.size })
}
return backups.sort((a, b) => b.createdAt - a.createdAt)
} catch {
return []
}
}
async restoreBackup(fileName: string): Promise<SaveResult> {
try {
if (!/^vdd_settings_[\d\-_]+\.xml$/.test(fileName)) {
return { ok: false, error: 'Invalid backup file name' }
}
const source = join(this.backupsDir, fileName)
if (!existsSync(source)) return { ok: false, error: 'Backup not found' }
const backupCreated = await this.backupCurrent()
await fs.copyFile(source, this.settingsPath)
return { ok: true, backupCreated }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
async saveMonitorProfile(profileXml: string, edidBytes?: Uint8Array): Promise<SaveResult> {
try {
mkdirSync(this.edidDir, { recursive: true })
await fs.writeFile(join(this.edidDir, 'monitor_profile.xml'), profileXml, 'utf8')
if (edidBytes && edidBytes.length >= 128) {
await fs.writeFile(join(this.getBaseDir(), 'user_edid.bin'), Buffer.from(edidBytes))
}
return { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
private async backupCurrent(): Promise<string | undefined> {
if (!existsSync(this.settingsPath)) return undefined
mkdirSync(this.backupsDir, { recursive: true })
const stamp = new Date()
.toISOString()
.replace(/[:T]/g, '-')
.replace(/\..+/, '')
const fileName = `vdd_settings_${stamp}.xml`
await fs.copyFile(this.settingsPath, join(this.backupsDir, fileName))
await this.pruneBackups()
return fileName
}
private async pruneBackups(): Promise<void> {
const backups = await this.listBackups()
for (const old of backups.slice(MAX_BACKUPS)) {
try {
await fs.rm(old.fullPath, { force: true })
} catch {
// best effort
}
}
}
// -------------------------------------------------------------------------
// XML <-> model
// -------------------------------------------------------------------------
parse(xml: string): VddSettings {
const parser = new XMLParser({
ignoreAttributes: true,
parseTagValue: false,
trimValues: true,
isArray: (name) => name === 'resolution' || name === 'g_refresh_rate' || name === 'refresh_rate'
})
const doc = asRaw(asRaw(parser.parse(xml)).vdd_settings)
const d = DEFAULT_VDD_SETTINGS
const globalNode = asRaw(doc.global)
const resolutionsNode = asRaw(doc.resolutions)
const loggingNode = asRaw(doc.logging)
const colourNode = asRaw(doc.colour)
const cursorNode = asRaw(doc.cursor)
const edidNode = asRaw(doc.edid)
const integrationNode = asRaw(doc.edid_integration)
const hdrNode = asRaw(doc.hdr_advanced)
const hdr10Node = asRaw(hdrNode.hdr10_static_metadata)
const primariesNode = asRaw(hdrNode.color_primaries)
const colorSpaceNode = asRaw(hdrNode.color_space)
const autoNode = asRaw(doc.auto_resolutions)
const filterNode = asRaw(autoNode.edid_mode_filtering)
const preferredNode = asRaw(autoNode.preferred_mode)
const advNode = asRaw(doc.color_advanced)
const bitDepthNode = asRaw(advNode.bit_depth_management)
const extFormatNode = asRaw(advNode.color_format_extended)
const resolutions: ResolutionEntry[] = asArray(resolutionsNode.resolution)
.map((node) => {
const r = asRaw(node)
const rates = asArray(r.refresh_rate)
.map((v) => toNum(v, NaN))
.filter((v) => Number.isFinite(v) && v > 0)
return {
width: toNum(r.width, 0),
height: toNum(r.height, 0),
refreshRates: rates.length > 0 ? rates : [60]
}
})
.filter((r) => r.width > 0 && r.height > 0)
const colourFormatRaw = toStr(colourNode.ColourFormat, d.colour.colourFormat)
const colourFormat = (['RGB', 'YCbCr444', 'YCbCr422', 'YCbCr420'] as const).find(
(f) => f.toLowerCase() === colourFormatRaw.toLowerCase()
)
return {
monitors: { count: toNum(asRaw(doc.monitors).count, d.monitors.count) },
gpu: { friendlyName: toStr(asRaw(doc.gpu).friendlyname, d.gpu.friendlyName) },
global: {
refreshRates: asArray(globalNode.g_refresh_rate)
.map((v) => toNum(v, NaN))
.filter((v) => Number.isFinite(v) && v > 0)
},
resolutions: resolutions.length > 0 ? resolutions : structuredClone(d.resolutions),
logging: {
sendLogsThroughPipe: toBool(loggingNode.SendLogsThroughPipe, d.logging.sendLogsThroughPipe),
logging: toBool(loggingNode.logging, d.logging.logging),
debugLogging: toBool(loggingNode.debuglogging, d.logging.debugLogging)
},
colour: {
sdr10bit: toBool(colourNode.SDR10bit, d.colour.sdr10bit),
hdrPlus: toBool(colourNode.HDRPlus, d.colour.hdrPlus),
colourFormat: colourFormat ?? d.colour.colourFormat
},
cursor: {
hardwareCursor: toBool(cursorNode.HardwareCursor, d.cursor.hardwareCursor),
cursorMaxX: toNum(cursorNode.CursorMaxX, d.cursor.cursorMaxX),
cursorMaxY: toNum(cursorNode.CursorMaxY, d.cursor.cursorMaxY),
alphaCursorSupport: toBool(cursorNode.AlphaCursorSupport, d.cursor.alphaCursorSupport),
xorCursorSupportLevel: toNum(cursorNode.XorCursorSupportLevel, d.cursor.xorCursorSupportLevel)
},
edid: {
customEdid: toBool(edidNode.CustomEdid, d.edid.customEdid),
preventSpoof: toBool(edidNode.PreventSpoof, d.edid.preventSpoof),
edidCeaOverride: toBool(edidNode.EdidCeaOverride, d.edid.edidCeaOverride)
},
edidIntegration: {
enabled: toBool(integrationNode.enabled, d.edidIntegration.enabled),
autoConfigureFromEdid: toBool(integrationNode.auto_configure_from_edid, d.edidIntegration.autoConfigureFromEdid),
edidProfilePath: toStr(integrationNode.edid_profile_path, d.edidIntegration.edidProfilePath),
overrideManualSettings: toBool(integrationNode.override_manual_settings, d.edidIntegration.overrideManualSettings),
fallbackOnError: toBool(integrationNode.fallback_on_error, d.edidIntegration.fallbackOnError)
},
hdrAdvanced: {
hdr10StaticMetadata: {
enabled: toBool(hdr10Node.enabled, d.hdrAdvanced.hdr10StaticMetadata.enabled),
maxDisplayMasteringLuminance: toNum(
hdr10Node.max_display_mastering_luminance,
d.hdrAdvanced.hdr10StaticMetadata.maxDisplayMasteringLuminance
),
minDisplayMasteringLuminance: toNum(
hdr10Node.min_display_mastering_luminance,
d.hdrAdvanced.hdr10StaticMetadata.minDisplayMasteringLuminance
),
maxContentLightLevel: toNum(hdr10Node.max_content_light_level, d.hdrAdvanced.hdr10StaticMetadata.maxContentLightLevel),
maxFrameAvgLightLevel: toNum(
hdr10Node.max_frame_avg_light_level,
d.hdrAdvanced.hdr10StaticMetadata.maxFrameAvgLightLevel
)
},
colorPrimaries: {
enabled: toBool(primariesNode.enabled, d.hdrAdvanced.colorPrimaries.enabled),
redX: toNum(primariesNode.red_x, d.hdrAdvanced.colorPrimaries.redX),
redY: toNum(primariesNode.red_y, d.hdrAdvanced.colorPrimaries.redY),
greenX: toNum(primariesNode.green_x, d.hdrAdvanced.colorPrimaries.greenX),
greenY: toNum(primariesNode.green_y, d.hdrAdvanced.colorPrimaries.greenY),
blueX: toNum(primariesNode.blue_x, d.hdrAdvanced.colorPrimaries.blueX),
blueY: toNum(primariesNode.blue_y, d.hdrAdvanced.colorPrimaries.blueY),
whiteX: toNum(primariesNode.white_x, d.hdrAdvanced.colorPrimaries.whiteX),
whiteY: toNum(primariesNode.white_y, d.hdrAdvanced.colorPrimaries.whiteY)
},
colorSpace: {
enabled: toBool(colorSpaceNode.enabled, d.hdrAdvanced.colorSpace.enabled),
gammaCorrection: toNum(colorSpaceNode.gamma_correction, d.hdrAdvanced.colorSpace.gammaCorrection),
primaryColorSpace: toStr(colorSpaceNode.primary_color_space, d.hdrAdvanced.colorSpace.primaryColorSpace),
enableMatrixTransform: toBool(colorSpaceNode.enable_matrix_transform, d.hdrAdvanced.colorSpace.enableMatrixTransform)
}
},
autoResolutions: {
enabled: toBool(autoNode.enabled, d.autoResolutions.enabled),
sourcePriority: toStr(autoNode.source_priority, d.autoResolutions.sourcePriority),
edidModeFiltering: {
minRefreshRate: toNum(filterNode.min_refresh_rate, d.autoResolutions.edidModeFiltering.minRefreshRate),
maxRefreshRate: toNum(filterNode.max_refresh_rate, d.autoResolutions.edidModeFiltering.maxRefreshRate),
excludeFractionalRates: toBool(
filterNode.exclude_fractional_rates,
d.autoResolutions.edidModeFiltering.excludeFractionalRates
),
minResolutionWidth: toNum(filterNode.min_resolution_width, d.autoResolutions.edidModeFiltering.minResolutionWidth),
minResolutionHeight: toNum(filterNode.min_resolution_height, d.autoResolutions.edidModeFiltering.minResolutionHeight),
maxResolutionWidth: toNum(filterNode.max_resolution_width, d.autoResolutions.edidModeFiltering.maxResolutionWidth),
maxResolutionHeight: toNum(filterNode.max_resolution_height, d.autoResolutions.edidModeFiltering.maxResolutionHeight)
},
preferredMode: {
useEdidPreferred: toBool(preferredNode.use_edid_preferred, d.autoResolutions.preferredMode.useEdidPreferred),
fallbackWidth: toNum(preferredNode.fallback_width, d.autoResolutions.preferredMode.fallbackWidth),
fallbackHeight: toNum(preferredNode.fallback_height, d.autoResolutions.preferredMode.fallbackHeight),
fallbackRefresh: toNum(preferredNode.fallback_refresh, d.autoResolutions.preferredMode.fallbackRefresh)
}
},
colorAdvanced: {
bitDepthManagement: {
autoSelectFromColorSpace: toBool(
bitDepthNode.auto_select_from_color_space,
d.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace
),
forceBitDepth: toNum(bitDepthNode.force_bit_depth, d.colorAdvanced.bitDepthManagement.forceBitDepth),
fp16SurfaceSupport: toBool(bitDepthNode.fp16_surface_support, d.colorAdvanced.bitDepthManagement.fp16SurfaceSupport)
},
colorFormatExtended: {
sdrWhiteLevel: toNum(extFormatNode.sdr_white_level, d.colorAdvanced.colorFormatExtended.sdrWhiteLevel)
}
}
}
}
serialize(s: VddSettings): string {
const b = (v: boolean): string => (v ? 'true' : 'false')
const lines: string[] = []
lines.push(`<?xml version='1.0' encoding='utf-8'?>`)
lines.push(`<!-- Virtual Display Driver configuration`)
lines.push(` Written by Virtual Driver Control on ${new Date().toISOString()} -->`)
lines.push(`<vdd_settings>`)
lines.push(``)
lines.push(` <!-- === BASIC DRIVER CONFIGURATION === -->`)
lines.push(` <monitors>`)
lines.push(` <count>${Math.max(0, Math.min(99, Math.floor(s.monitors.count)))}</count>`)
lines.push(` </monitors>`)
lines.push(``)
lines.push(` <gpu>`)
lines.push(` <friendlyname>${escapeXml(s.gpu.friendlyName || 'default')}</friendlyname>`)
lines.push(` </gpu>`)
lines.push(``)
lines.push(` <!-- === RESOLUTION CONFIGURATION === -->`)
lines.push(` <global>`)
for (const rate of s.global.refreshRates) {
lines.push(` <g_refresh_rate>${formatRate(rate)}</g_refresh_rate>`)
}
lines.push(` </global>`)
lines.push(``)
lines.push(` <resolutions>`)
for (const res of s.resolutions) {
lines.push(` <resolution>`)
lines.push(` <width>${Math.floor(res.width)}</width>`)
lines.push(` <height>${Math.floor(res.height)}</height>`)
for (const rate of res.refreshRates) {
lines.push(` <refresh_rate>${formatRate(rate)}</refresh_rate>`)
}
lines.push(` </resolution>`)
}
lines.push(` </resolutions>`)
lines.push(``)
lines.push(` <!-- === LOGGING CONFIGURATION === -->`)
lines.push(` <logging>`)
lines.push(` <SendLogsThroughPipe>${b(s.logging.sendLogsThroughPipe)}</SendLogsThroughPipe>`)
lines.push(` <logging>${b(s.logging.logging)}</logging>`)
lines.push(` <debuglogging>${b(s.logging.debugLogging)}</debuglogging>`)
lines.push(` </logging>`)
lines.push(``)
lines.push(` <!-- === COLOR FORMAT CONFIGURATION === -->`)
lines.push(` <colour>`)
lines.push(` <SDR10bit>${b(s.colour.sdr10bit)}</SDR10bit>`)
lines.push(` <HDRPlus>${b(s.colour.hdrPlus)}</HDRPlus>`)
lines.push(` <ColourFormat>${s.colour.colourFormat}</ColourFormat>`)
lines.push(` </colour>`)
lines.push(``)
lines.push(` <!-- === CURSOR CONFIGURATION === -->`)
lines.push(` <cursor>`)
lines.push(` <HardwareCursor>${b(s.cursor.hardwareCursor)}</HardwareCursor>`)
lines.push(` <CursorMaxX>${Math.floor(s.cursor.cursorMaxX)}</CursorMaxX>`)
lines.push(` <CursorMaxY>${Math.floor(s.cursor.cursorMaxY)}</CursorMaxY>`)
lines.push(` <AlphaCursorSupport>${b(s.cursor.alphaCursorSupport)}</AlphaCursorSupport>`)
lines.push(` <XorCursorSupportLevel>${Math.floor(s.cursor.xorCursorSupportLevel)}</XorCursorSupportLevel>`)
lines.push(` </cursor>`)
lines.push(``)
lines.push(` <!-- === CUSTOM EDID CONFIGURATION === -->`)
lines.push(` <edid>`)
lines.push(` <CustomEdid>${b(s.edid.customEdid)}</CustomEdid>`)
lines.push(` <PreventSpoof>${b(s.edid.preventSpoof)}</PreventSpoof>`)
lines.push(` <EdidCeaOverride>${b(s.edid.edidCeaOverride)}</EdidCeaOverride>`)
lines.push(` </edid>`)
lines.push(``)
lines.push(` <!-- === EDID INTEGRATION SYSTEM === -->`)
lines.push(` <edid_integration>`)
lines.push(` <enabled>${b(s.edidIntegration.enabled)}</enabled>`)
lines.push(` <auto_configure_from_edid>${b(s.edidIntegration.autoConfigureFromEdid)}</auto_configure_from_edid>`)
lines.push(` <edid_profile_path>${escapeXml(s.edidIntegration.edidProfilePath)}</edid_profile_path>`)
lines.push(` <override_manual_settings>${b(s.edidIntegration.overrideManualSettings)}</override_manual_settings>`)
lines.push(` <fallback_on_error>${b(s.edidIntegration.fallbackOnError)}</fallback_on_error>`)
lines.push(` </edid_integration>`)
lines.push(``)
lines.push(` <!-- === HDR CONFIGURATION === -->`)
lines.push(` <hdr_advanced>`)
lines.push(` <hdr10_static_metadata>`)
lines.push(` <enabled>${b(s.hdrAdvanced.hdr10StaticMetadata.enabled)}</enabled>`)
lines.push(
` <max_display_mastering_luminance>${s.hdrAdvanced.hdr10StaticMetadata.maxDisplayMasteringLuminance.toFixed(1)}</max_display_mastering_luminance>`
)
lines.push(
` <min_display_mastering_luminance>${s.hdrAdvanced.hdr10StaticMetadata.minDisplayMasteringLuminance}</min_display_mastering_luminance>`
)
lines.push(
` <max_content_light_level>${Math.floor(s.hdrAdvanced.hdr10StaticMetadata.maxContentLightLevel)}</max_content_light_level>`
)
lines.push(
` <max_frame_avg_light_level>${Math.floor(s.hdrAdvanced.hdr10StaticMetadata.maxFrameAvgLightLevel)}</max_frame_avg_light_level>`
)
lines.push(` </hdr10_static_metadata>`)
lines.push(` <color_primaries>`)
lines.push(` <enabled>${b(s.hdrAdvanced.colorPrimaries.enabled)}</enabled>`)
lines.push(` <red_x>${s.hdrAdvanced.colorPrimaries.redX.toFixed(3)}</red_x>`)
lines.push(` <red_y>${s.hdrAdvanced.colorPrimaries.redY.toFixed(3)}</red_y>`)
lines.push(` <green_x>${s.hdrAdvanced.colorPrimaries.greenX.toFixed(3)}</green_x>`)
lines.push(` <green_y>${s.hdrAdvanced.colorPrimaries.greenY.toFixed(3)}</green_y>`)
lines.push(` <blue_x>${s.hdrAdvanced.colorPrimaries.blueX.toFixed(3)}</blue_x>`)
lines.push(` <blue_y>${s.hdrAdvanced.colorPrimaries.blueY.toFixed(3)}</blue_y>`)
lines.push(` <white_x>${s.hdrAdvanced.colorPrimaries.whiteX.toFixed(4)}</white_x>`)
lines.push(` <white_y>${s.hdrAdvanced.colorPrimaries.whiteY.toFixed(4)}</white_y>`)
lines.push(` </color_primaries>`)
lines.push(` <color_space>`)
lines.push(` <enabled>${b(s.hdrAdvanced.colorSpace.enabled)}</enabled>`)
lines.push(` <gamma_correction>${s.hdrAdvanced.colorSpace.gammaCorrection}</gamma_correction>`)
lines.push(` <primary_color_space>${escapeXml(s.hdrAdvanced.colorSpace.primaryColorSpace)}</primary_color_space>`)
lines.push(` <enable_matrix_transform>${b(s.hdrAdvanced.colorSpace.enableMatrixTransform)}</enable_matrix_transform>`)
lines.push(` </color_space>`)
lines.push(` </hdr_advanced>`)
lines.push(``)
lines.push(` <!-- === AUTO RESOLUTION SYSTEM === -->`)
lines.push(` <auto_resolutions>`)
lines.push(` <enabled>${b(s.autoResolutions.enabled)}</enabled>`)
lines.push(` <source_priority>${escapeXml(s.autoResolutions.sourcePriority)}</source_priority>`)
lines.push(` <edid_mode_filtering>`)
lines.push(` <min_refresh_rate>${formatRate(s.autoResolutions.edidModeFiltering.minRefreshRate)}</min_refresh_rate>`)
lines.push(` <max_refresh_rate>${formatRate(s.autoResolutions.edidModeFiltering.maxRefreshRate)}</max_refresh_rate>`)
lines.push(
` <exclude_fractional_rates>${b(s.autoResolutions.edidModeFiltering.excludeFractionalRates)}</exclude_fractional_rates>`
)
lines.push(
` <min_resolution_width>${Math.floor(s.autoResolutions.edidModeFiltering.minResolutionWidth)}</min_resolution_width>`
)
lines.push(
` <min_resolution_height>${Math.floor(s.autoResolutions.edidModeFiltering.minResolutionHeight)}</min_resolution_height>`
)
lines.push(
` <max_resolution_width>${Math.floor(s.autoResolutions.edidModeFiltering.maxResolutionWidth)}</max_resolution_width>`
)
lines.push(
` <max_resolution_height>${Math.floor(s.autoResolutions.edidModeFiltering.maxResolutionHeight)}</max_resolution_height>`
)
lines.push(` </edid_mode_filtering>`)
lines.push(` <preferred_mode>`)
lines.push(` <use_edid_preferred>${b(s.autoResolutions.preferredMode.useEdidPreferred)}</use_edid_preferred>`)
lines.push(` <fallback_width>${Math.floor(s.autoResolutions.preferredMode.fallbackWidth)}</fallback_width>`)
lines.push(` <fallback_height>${Math.floor(s.autoResolutions.preferredMode.fallbackHeight)}</fallback_height>`)
lines.push(` <fallback_refresh>${formatRate(s.autoResolutions.preferredMode.fallbackRefresh)}</fallback_refresh>`)
lines.push(` </preferred_mode>`)
lines.push(` </auto_resolutions>`)
lines.push(``)
lines.push(` <!-- === ADVANCED COLOR PROCESSING === -->`)
lines.push(` <color_advanced>`)
lines.push(` <bit_depth_management>`)
lines.push(
` <auto_select_from_color_space>${b(s.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace)}</auto_select_from_color_space>`
)
lines.push(` <force_bit_depth>${Math.floor(s.colorAdvanced.bitDepthManagement.forceBitDepth)}</force_bit_depth>`)
lines.push(
` <fp16_surface_support>${b(s.colorAdvanced.bitDepthManagement.fp16SurfaceSupport)}</fp16_surface_support>`
)
lines.push(` </bit_depth_management>`)
lines.push(` <color_format_extended>`)
lines.push(` <sdr_white_level>${s.colorAdvanced.colorFormatExtended.sdrWhiteLevel.toFixed(1)}</sdr_white_level>`)
lines.push(` </color_format_extended>`)
lines.push(` </color_advanced>`)
lines.push(``)
lines.push(`</vdd_settings>`)
lines.push(``)
return lines.join('\n')
}
}
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+9
View File
@@ -0,0 +1,9 @@
import type { VddApi } from './index'
declare global {
interface Window {
vdd: VddApi
}
}
export {}
+113
View File
@@ -0,0 +1,113 @@
import { contextBridge, ipcRenderer } from 'electron'
import type {
AppPreferences,
AudioEndpoint,
BackupInfo,
BaseDirResult,
DisplayLayoutInfo,
DriverLiveSettings,
DriverStatus,
GpuInfo,
InstallProgress,
LifecycleResult,
LogEvent,
ManagedDeviceState,
ManagedDriverId,
PipeActivity,
PipeResult,
PipeToggleCommand,
ReleaseInfo,
SaveResult,
SettingsLoadResult,
SystemInfo,
VddSettings
} from '@shared/types'
function subscribe<T>(channel: string, callback: (payload: T) => void): () => void {
const listener = (_event: Electron.IpcRendererEvent, payload: T): void => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
const api = {
env: {
/** Window backdrop chosen by the main process (Mica on Win11 22H2+). */
backdrop: (process.argv.includes('--vdd-backdrop=mica') ? 'mica' : 'solid') as 'mica' | 'solid'
},
pipe: {
ping: (): Promise<boolean> => ipcRenderer.invoke('pipe:ping'),
setDisplayCount: (count: number): Promise<PipeResult> => ipcRenderer.invoke('pipe:set-display-count', count),
toggle: (name: PipeToggleCommand, value: boolean): Promise<PipeResult> => ipcRenderer.invoke('pipe:toggle', name, value),
setGpu: (name: string): Promise<PipeResult> => ipcRenderer.invoke('pipe:set-gpu', name),
query: (command: string): Promise<PipeResult> => ipcRenderer.invoke('pipe:query', command),
sendRaw: (command: string): Promise<PipeResult> => ipcRenderer.invoke('pipe:send-raw', command),
getDriverSettings: (): Promise<DriverLiveSettings | null> => ipcRenderer.invoke('pipe:get-driver-settings')
},
settings: {
load: (): Promise<SettingsLoadResult> => ipcRenderer.invoke('settings:load'),
save: (settings: VddSettings): Promise<SaveResult> => ipcRenderer.invoke('settings:save', settings),
preview: (settings: VddSettings): Promise<string> => ipcRenderer.invoke('settings:preview', settings),
raw: (): Promise<string | null> => ipcRenderer.invoke('settings:raw'),
backups: (): Promise<BackupInfo[]> => ipcRenderer.invoke('settings:backups'),
restore: (fileName: string): Promise<SaveResult> => ipcRenderer.invoke('settings:restore', fileName),
saveMonitorProfile: (xml: string, edidBytes?: Uint8Array): Promise<SaveResult> =>
ipcRenderer.invoke('settings:save-monitor-profile', xml, edidBytes)
},
driver: {
status: (force?: boolean): Promise<DriverStatus> => ipcRenderer.invoke('driver:status', force),
gpus: (): Promise<GpuInfo[]> => ipcRenderer.invoke('driver:gpus'),
iddcxVersion: (): Promise<string | null> => ipcRenderer.invoke('driver:iddcx-version')
},
installer: {
latestRelease: (driver: ManagedDriverId): Promise<ReleaseInfo> => ipcRenderer.invoke('installer:latest-release', driver),
installedTag: (driver: ManagedDriverId): Promise<string | null> => ipcRenderer.invoke('installer:installed-tag', driver),
deviceState: (driver: ManagedDriverId): Promise<ManagedDeviceState> => ipcRenderer.invoke('installer:device-state', driver),
install: (driver: ManagedDriverId, instances?: number): Promise<LifecycleResult> =>
ipcRenderer.invoke('installer:install', driver, instances),
uninstall: (driver: ManagedDriverId): Promise<LifecycleResult> => ipcRenderer.invoke('installer:uninstall', driver),
restartDevice: (driver: ManagedDriverId): Promise<LifecycleResult> => ipcRenderer.invoke('installer:restart-device', driver),
setInstances: (driver: ManagedDriverId, count: number): Promise<LifecycleResult> =>
ipcRenderer.invoke('installer:set-instances', driver, count),
/** Boot-config test signing state - the audio driver is test-signed and needs it. */
testSigning: (): Promise<boolean | null> => ipcRenderer.invoke('installer:test-signing'),
setTestSigning: (enabled: boolean): Promise<LifecycleResult> => ipcRenderer.invoke('installer:set-test-signing', enabled)
},
audio: {
endpoints: (): Promise<AudioEndpoint[]> => ipcRenderer.invoke('audio:endpoints'),
setDefault: (id: string): Promise<void> => ipcRenderer.invoke('audio:set-default', id),
setVolume: (id: string, volume: number): Promise<void> => ipcRenderer.invoke('audio:set-volume', id, volume),
setMute: (id: string, muted: boolean): Promise<void> => ipcRenderer.invoke('audio:set-mute', id, muted)
},
system: {
info: (): Promise<SystemInfo> => ipcRenderer.invoke('system:info'),
displays: (): Promise<DisplayLayoutInfo[]> => ipcRenderer.invoke('system:displays'),
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:open-external', url),
openPath: (which: 'base' | 'logs' | 'backups' | 'edid'): Promise<string> => ipcRenderer.invoke('shell:open-path', which)
},
logs: {
recent: (): Promise<LogEvent[]> => ipcRenderer.invoke('logs:recent')
},
prefs: {
get: (): Promise<AppPreferences> => ipcRenderer.invoke('prefs:get'),
set: (patch: Partial<AppPreferences>): Promise<AppPreferences> => ipcRenderer.invoke('prefs:set', patch),
/** Changes the driver folder - updates the VDDPATH registry value and the preference atomically. */
setBaseDir: (baseDir: string): Promise<BaseDirResult> => ipcRenderer.invoke('prefs:set-base-dir', baseDir)
},
window: {
minimize: (): void => ipcRenderer.send('window:minimize'),
maximizeToggle: (): void => ipcRenderer.send('window:maximize-toggle'),
close: (): void => ipcRenderer.send('window:close')
},
events: {
onStatus: (cb: (status: DriverStatus) => void): (() => void) => subscribe('push:status', cb),
onLogs: (cb: (events: LogEvent[]) => void): (() => void) => subscribe('push:logs', cb),
onPipeActivity: (cb: (activity: PipeActivity) => void): (() => void) => subscribe('push:pipe-activity', cb),
onMaximized: (cb: (maximized: boolean) => void): (() => void) => subscribe('push:maximized', cb),
onInstallProgress: (cb: (progress: InstallProgress) => void): (() => void) => subscribe('push:install-progress', cb),
onDisplays: (cb: (layout: DisplayLayoutInfo[]) => void): (() => void) => subscribe('push:displays', cb)
}
}
export type VddApi = typeof api
contextBridge.exposeInMainWorld('vdd', api)
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<title>Virtual Driver Control</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ws:"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,72 @@
import { useEffect } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { SaveBar } from './components/SaveBar'
import { Sidebar } from './components/Sidebar'
import { TitleBar } from './components/TitleBar'
import { Toasts } from './components/Toasts'
import { AudioPage } from './pages/AudioPage'
import { ConsolePage } from './pages/ConsolePage'
import { ColorPage } from './pages/ColorPage'
import { DashboardPage } from './pages/DashboardPage'
import { DisplaysPage } from './pages/DisplaysPage'
import { EdidPage } from './pages/EdidPage'
import { GpuPage } from './pages/GpuPage'
import { SettingsPage } from './pages/SettingsPage'
import { useAudio } from './stores/audio'
import { useDriver } from './stores/driver'
import { useLogs } from './stores/logs'
import { useSettings } from './stores/settings'
import { useUi, type PageId } from './stores/ui'
const PAGES: Record<PageId, () => React.JSX.Element> = {
dashboard: DashboardPage,
displays: DisplaysPage,
color: ColorPage,
edid: EdidPage,
gpu: GpuPage,
audio: AudioPage,
console: ConsolePage,
settings: SettingsPage
}
export default function App(): React.JSX.Element {
const page = useUi((s) => s.page)
const initPrefs = useUi((s) => s.initPrefs)
const setMaximized = useUi((s) => s.setMaximized)
useEffect(() => {
document.body.dataset.backdrop = window.vdd.env.backdrop
// Audio init waits for prefs so saved routes can be re-armed.
void initPrefs().then(() => useAudio.getState().init())
void useDriver.getState().init()
void useSettings.getState().load()
void useLogs.getState().init()
const unsubscribe = window.vdd.events.onMaximized(setMaximized)
return unsubscribe
}, [initPrefs, setMaximized])
const Page = PAGES[page]
return (
<div className="app-shell">
<TitleBar />
<Sidebar />
<main className="app-main">
<AnimatePresence mode="wait">
<motion.div
key={page}
className="page-scroll"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: [0.2, 0.9, 0.25, 1] }}
>
<Page />
</motion.div>
</AnimatePresence>
<SaveBar />
</main>
<Toasts />
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2, Minus, MonitorCheck, Plus, RefreshCw, Star } from 'lucide-react'
import type { DisplayLayoutInfo } from '@shared/types'
import { Card } from '@renderer/components/ui'
import { useDriver } from '@renderer/stores/driver'
import { useSettings } from '@renderer/stores/settings'
const PADDING = 16
const MAX_MONITORS = 16
export function useDisplayLayout(): { displays: DisplayLayoutInfo[]; refresh: () => void } {
const [displays, setDisplays] = useState<DisplayLayoutInfo[]>([])
const refresh = (): void => {
void window.vdd.system
.displays()
.then(setDisplays)
.catch(() => undefined)
}
useEffect(() => {
let cancelled = false
let attempts = 0
// A real system always has at least one display - empty means the call
// raced app startup, so retry briefly.
const fetchLayout = (): void => {
window.vdd.system
.displays()
.then((layout) => {
if (cancelled) return
if (layout.length > 0) setDisplays(layout)
else if (attempts++ < 5) window.setTimeout(fetchLayout, 1200)
})
.catch(() => {
if (!cancelled && attempts++ < 5) window.setTimeout(fetchLayout, 1200)
})
}
fetchLayout()
const unsubscribe = window.vdd.events.onDisplays(setDisplays)
return () => {
cancelled = true
unsubscribe()
}
}, [])
return { displays, refresh }
}
/**
* Unified desktop canvas: physical and virtual monitors rendered together,
* to scale, in their real Windows arrangement. Virtual monitors only appear
* when they actually exist in the layout. The footer integrates the virtual
* display count control and live legend.
*/
export function DisplayCanvas(): React.JSX.Element {
const { displays, refresh } = useDisplayLayout()
const busy = useDriver((s) => s.busy)
const online = useDriver((s) => s.status?.pipeConnected === true)
const applyDisplayCount = useDriver((s) => s.applyDisplayCount)
const count = useSettings((s) => s.draft.monitors.count)
const wrapRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(880)
useEffect(() => {
const el = wrapRef.current
if (!el) return
const observer = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 880
setWidth(Math.max(300, Math.floor(w)))
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const height = Math.round(Math.min(340, Math.max(220, width * 0.32)))
const virtualCount = displays.filter((d) => d.isVirtual).length
const physicalCount = displays.length - virtualCount
let monitors: React.JSX.Element | React.JSX.Element[]
if (displays.length === 0) {
monitors = <div className="canvas-empty faint">Reading display topology…</div>
} else {
const minX = Math.min(...displays.map((d) => d.bounds.x))
const minY = Math.min(...displays.map((d) => d.bounds.y))
const maxX = Math.max(...displays.map((d) => d.bounds.x + d.bounds.width))
const maxY = Math.max(...displays.map((d) => d.bounds.y + d.bounds.height))
const scale = Math.min((width - PADDING * 2) / (maxX - minX), (height - PADDING * 2) / (maxY - minY))
const offsetX = (width - (maxX - minX) * scale) / 2
const offsetY = (height - (maxY - minY) * scale) / 2
monitors = displays.map((d) => {
const pxW = Math.round(d.bounds.width * d.scaleFactor)
const pxH = Math.round(d.bounds.height * d.scaleFactor)
return (
<div
key={d.id}
className={`arrange-display ${d.isVirtual ? 'virtual' : ''}`}
style={{
left: offsetX + (d.bounds.x - minX) * scale,
top: offsetY + (d.bounds.y - minY) * scale,
width: Math.max(36, d.bounds.width * scale),
height: Math.max(26, d.bounds.height * scale)
}}
title={`${d.label}\n${pxW}×${pxH} @ ${d.frequency || '?'} Hz · scale ${Math.round(d.scaleFactor * 100)}%\nposition ${d.bounds.x}, ${d.bounds.y}${d.isVirtual ? '\nVirtual Display Driver' : ''}`}
>
<span className="ad-name">
{d.primary && <Star size={10} fill="currentColor" />}
{d.label}
</span>
<span className="ad-res mono">
{pxW}×{pxH}
</span>
<span className="ad-meta">
{d.frequency ? `${d.frequency} Hz` : ''}
{d.scaleFactor !== 1 ? ` · ${Math.round(d.scaleFactor * 100)}%` : ''}
</span>
{d.isVirtual && <span className="ad-badge">VIRTUAL</span>}
</div>
)
})
}
return (
<div ref={wrapRef}>
<div className="arrange-stage" style={{ width, height }}>
{busy && (
<div className="canvas-busy">
<div className="row" style={{ color: 'var(--accent-text)', fontWeight: 600 }}>
<Loader2 size={16} className="spin" />
{busy}
</div>
</div>
)}
{monitors}
</div>
<div className="canvas-bar">
<div className="canvas-legend">
<span className="chip">{physicalCount} physical</span>
<span className={`chip ${virtualCount > 0 ? 'on' : ''}`}>{virtualCount} virtual</span>
{count > 0 && virtualCount === 0 && (
<span className="canvas-hint">
{count} configured — {online ? 'applying…' : 'appears when the driver is running'}
</span>
)}
</div>
<div className="canvas-controls">
<button type="button" className="btn ghost small" onClick={refresh} title="Re-read the display layout">
<RefreshCw size={13} />
</button>
<span className="canvas-ctl-label">Virtual displays</span>
<div className="count-stepper compact">
<button
type="button"
aria-label="Remove a virtual display"
disabled={busy !== null || count <= 0}
onClick={() => void applyDisplayCount(count - 1)}
>
<Minus size={14} />
</button>
<span className="count-value">{count}</span>
<button
type="button"
aria-label="Add a virtual display"
disabled={busy !== null || count >= MAX_MONITORS}
onClick={() => void applyDisplayCount(count + 1)}
>
<Plus size={14} />
</button>
</div>
</div>
</div>
</div>
)
}
export function ArrangementMap(): React.JSX.Element {
return (
<Card icon={MonitorCheck} title="Desktop arrangement" subtitle="Physical and virtual monitors to scale - live Windows layout">
<DisplayCanvas />
</Card>
)
}
@@ -0,0 +1,202 @@
import { useEffect, useMemo, useRef, useState } from 'react'
/**
* Interactive CIE 1931 xy chromaticity diagram.
* Canvas renders the spectral horseshoe with approximate sRGB colors;
* an SVG overlay draws the gamut triangle with draggable R/G/B/white handles.
*/
// CIE 1931 2-degree observer spectral locus (wavelength, x, y), 380-700nm.
const SPECTRAL_LOCUS: Array<[number, number, number]> = [
[380, 0.1741, 0.005], [390, 0.1738, 0.0049], [400, 0.1733, 0.0048], [410, 0.1726, 0.0048],
[420, 0.1714, 0.0051], [430, 0.1689, 0.0069], [440, 0.1644, 0.0109], [450, 0.1566, 0.0177],
[460, 0.144, 0.0297], [465, 0.1355, 0.0399], [470, 0.1241, 0.0578], [475, 0.1096, 0.0868],
[480, 0.0913, 0.1327], [485, 0.0687, 0.2007], [490, 0.0454, 0.295], [495, 0.0235, 0.4127],
[500, 0.0082, 0.5384], [505, 0.0039, 0.6548], [510, 0.0139, 0.7502], [515, 0.0389, 0.812],
[520, 0.0743, 0.8338], [525, 0.1142, 0.8262], [530, 0.1547, 0.8059], [535, 0.1929, 0.7816],
[540, 0.2296, 0.7543], [545, 0.2658, 0.7243], [550, 0.3016, 0.6923], [555, 0.3373, 0.6589],
[560, 0.3731, 0.6245], [565, 0.4087, 0.5896], [570, 0.4441, 0.5547], [575, 0.4788, 0.5202],
[580, 0.5125, 0.4866], [585, 0.5448, 0.4544], [590, 0.5752, 0.4242], [595, 0.6029, 0.3965],
[600, 0.627, 0.3725], [605, 0.6482, 0.3514], [610, 0.6658, 0.334], [620, 0.6915, 0.3083],
[630, 0.7079, 0.292], [640, 0.719, 0.2809], [650, 0.726, 0.274], [660, 0.73, 0.27],
[680, 0.7334, 0.2666], [700, 0.7347, 0.2653]
]
const X_MAX = 0.8
const Y_MAX = 0.9
export interface CiePoints {
redX: number
redY: number
greenX: number
greenY: number
blueX: number
blueY: number
whiteX: number
whiteY: number
}
type HandleId = 'red' | 'green' | 'blue' | 'white'
function pointInPolygon(x: number, y: number, polygon: Array<[number, number]>): boolean {
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i]
const [xj, yj] = polygon[j]
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside
}
return inside
}
function xyToRgb(x: number, y: number): [number, number, number] {
if (y <= 0.0001) return [0, 0, 0]
const Y = 1
const X = (x * Y) / y
const Z = ((1 - x - y) * Y) / y
let r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z
let g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z
let b = 0.0557 * X - 0.204 * Y + 1.057 * Z
r = Math.max(0, r)
g = Math.max(0, g)
b = Math.max(0, b)
const max = Math.max(r, g, b)
if (max > 0) {
r /= max
g /= max
b /= max
}
const encode = (c: number): number => Math.round(255 * Math.pow(c, 1 / 2.2))
return [encode(r), encode(g), encode(b)]
}
const HANDLE_META: Record<HandleId, { label: string; fill: string }> = {
red: { label: 'R', fill: '#ff5d5d' },
green: { label: 'G', fill: '#4ade80' },
blue: { label: 'B', fill: '#60a5fa' },
white: { label: 'W', fill: '#ffffff' }
}
export function CieDiagram(props: {
value: CiePoints
onChange: (patch: Partial<CiePoints>) => void
disabled?: boolean
width?: number
}): React.JSX.Element {
const width = props.width ?? 380
const height = Math.round((width * Y_MAX) / X_MAX)
const canvasRef = useRef<HTMLCanvasElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [dragging, setDragging] = useState<HandleId | null>(null)
const toPx = useMemo(
() => ({
x: (cx: number) => (cx / X_MAX) * width,
y: (cy: number) => height - (cy / Y_MAX) * height
}),
[width, height]
)
// Render the horseshoe once per size.
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const polygon: Array<[number, number]> = SPECTRAL_LOCUS.map(([, x, y]) => [x, y])
const image = ctx.createImageData(width, height)
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
const cx = (px / width) * X_MAX
const cy = ((height - py) / height) * Y_MAX
if (!pointInPolygon(cx, cy, polygon)) continue
const [r, g, b] = xyToRgb(cx, cy)
const idx = (py * width + px) * 4
image.data[idx] = r
image.data[idx + 1] = g
image.data[idx + 2] = b
image.data[idx + 3] = 235
}
}
ctx.putImageData(image, 0, 0)
}, [width, height])
// Pointer dragging on the SVG overlay.
useEffect(() => {
if (!dragging) return
const svg = svgRef.current
if (!svg) return
const onMove = (event: PointerEvent): void => {
const rect = svg.getBoundingClientRect()
const cx = Math.max(0.001, Math.min(X_MAX, ((event.clientX - rect.left) / rect.width) * X_MAX))
const cy = Math.max(0.001, Math.min(Y_MAX, ((rect.bottom - event.clientY) / rect.height) * Y_MAX))
const rx = Math.round(cx * 10000) / 10000
const ry = Math.round(cy * 10000) / 10000
if (dragging === 'red') props.onChange({ redX: rx, redY: ry })
else if (dragging === 'green') props.onChange({ greenX: rx, greenY: ry })
else if (dragging === 'blue') props.onChange({ blueX: rx, blueY: ry })
else props.onChange({ whiteX: rx, whiteY: ry })
}
const onUp = (): void => setDragging(null)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
}, [dragging, props])
const v = props.value
const handles: Array<{ id: HandleId; x: number; y: number }> = [
{ id: 'red', x: v.redX, y: v.redY },
{ id: 'green', x: v.greenX, y: v.greenY },
{ id: 'blue', x: v.blueX, y: v.blueY },
{ id: 'white', x: v.whiteX, y: v.whiteY }
]
const trianglePoints = `${toPx.x(v.redX)},${toPx.y(v.redY)} ${toPx.x(v.greenX)},${toPx.y(v.greenY)} ${toPx.x(v.blueX)},${toPx.y(v.blueY)}`
// sRGB reference triangle for comparison.
const srgbPoints = `${toPx.x(0.64)},${toPx.y(0.33)} ${toPx.x(0.3)},${toPx.y(0.6)} ${toPx.x(0.15)},${toPx.y(0.06)}`
return (
<div style={{ position: 'relative', width, height, opacity: props.disabled ? 0.45 : 1 }}>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ position: 'absolute', inset: 0, borderRadius: 10, filter: 'saturate(0.9)' }}
/>
<svg
ref={svgRef}
width={width}
height={height}
style={{ position: 'absolute', inset: 0, touchAction: 'none' }}
role="application"
aria-label="Chromaticity diagram"
>
<polygon points={srgbPoints} fill="none" stroke="rgba(255,255,255,0.32)" strokeWidth={1} strokeDasharray="4 4" />
<polygon points={trianglePoints} fill="rgba(255,255,255,0.07)" stroke="rgba(255,255,255,0.85)" strokeWidth={1.5} />
{handles.map((h) => (
<g
key={h.id}
transform={`translate(${toPx.x(h.x)}, ${toPx.y(h.y)})`}
style={{ cursor: props.disabled ? 'default' : 'grab' }}
onPointerDown={(e) => {
if (props.disabled) return
e.preventDefault()
setDragging(h.id)
}}
>
<circle r={9} fill="rgba(0,0,0,0.45)" />
<circle r={6.5} fill={HANDLE_META[h.id].fill} stroke="#0a0d13" strokeWidth={1.5} />
<text y={-13} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff" style={{ userSelect: 'none' }}>
{HANDLE_META[h.id].label}
</text>
</g>
))}
</svg>
</div>
)
}
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import {
ArrowUpCircle,
Download,
ExternalLink,
Layers,
Loader2,
PackageCheck,
PackageOpen,
Power,
RefreshCw,
ShieldAlert,
ShieldCheck,
Trash2
} from 'lucide-react'
import type { ManagedDriverId } from '@shared/types'
import { Card, Segmented } from '@renderer/components/ui'
import { useDriver } from '@renderer/stores/driver'
import { useInstaller } from '@renderer/stores/installer'
const TITLES: Record<ManagedDriverId, string> = {
display: 'Display driver lifecycle',
audio: 'Audio driver lifecycle'
}
const REPOS: Record<ManagedDriverId, string> = {
display: 'VirtualDrivers/Virtual-Display-Driver',
audio: 'VirtualDrivers/Virtual-Audio-Driver'
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${Math.max(1, Math.round(bytes / 1024))} KB`
}
function formatDate(iso: string): string {
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleDateString()
}
export function DriverLifecycle(props: { driver: ManagedDriverId }): React.JSX.Element {
const { driver } = props
const multiInstance = driver === 'audio'
const state = useInstaller((s) => s.drivers[driver])
const working = useInstaller((s) => s.working)
const progress = useInstaller((s) => s.progress)
const init = useInstaller((s) => s.init)
const checkLatest = useInstaller((s) => s.checkLatest)
const install = useInstaller((s) => s.install)
const uninstallDriver = useInstaller((s) => s.uninstallDriver)
const restartDevice = useInstaller((s) => s.restartDevice)
const setInstances = useInstaller((s) => s.setInstances)
const testSigning = useInstaller((s) => s.testSigning)
const setTestSigning = useInstaller((s) => s.setTestSigning)
const displayStatus = useDriver((s) => s.status)
const [instanceChoice, setInstanceChoice] = useState<number | null>(null)
useEffect(() => {
init()
if (!useInstaller.getState().drivers[driver].latest && !useInstaller.getState().drivers[driver].checking) {
void checkLatest(driver)
}
}, [init, checkLatest, driver])
const { latest, installedTag, device, checking, checkError } = state
const installed = (device?.count ?? 0) > 0
const updateAvailable = installed && latest !== null && installedTag !== null && latest.tag !== installedTag
const busy = working !== null
const busyHere = working?.driver === driver
const deviceCount = device?.count ?? 0
const selectedInstances = instanceChoice ?? Math.max(deviceCount, 1)
const installLabel = !installed
? latest
? `Download & install ${latest.tag}`
: 'Download & install latest'
: updateAvailable
? `Update to ${latest?.tag}`
: 'Reinstall latest'
const installedDetail = (): string => {
if (!installed) return 'No device on this system'
const statuses = device?.statuses.join(', ') ?? ''
if (driver === 'display') {
return displayStatus?.dllDate ? `MttVDD.dll · ${displayStatus.dllDate}` : `device status ${statuses}`
}
return `${deviceCount} device${deviceCount === 1 ? '' : 's'} · status ${statuses}`
}
return (
<Card
icon={PackageOpen}
title={TITLES[driver]}
subtitle={`Official packages from ${REPOS[driver]} - downloads are checksum-verified`}
actions={
<button type="button" className="btn ghost small" disabled={checking || busy} onClick={() => void checkLatest(driver)}>
{checking ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />} Check for updates
</button>
}
>
<div className="lifecycle-grid">
<div className="lifecycle-block">
<span className="stat-label">
<PackageCheck size={11} /> Installed
</span>
<span className="stat-value" style={{ color: installed ? 'var(--success)' : 'var(--danger)' }}>
{installed ? installedTag ?? 'Installed' : 'Not installed'}
</span>
<span className="stat-sub">{installedDetail()}</span>
</div>
<div className="lifecycle-block">
<span className="stat-label">
<Download size={11} /> Latest release
</span>
<span className="stat-value">{checking ? 'Checking…' : latest?.tag ?? '—'}</span>
<span className="stat-sub">
{checkError
? `Check failed: ${checkError}`
: latest
? `${formatDate(latest.publishedAt)}${latest.asset ? ` · ${latest.asset.name} (${formatSize(latest.asset.sizeBytes)})` : ' · no driver package'}`
: 'Fetching from GitHub…'}
</span>
{latest && (
<button
type="button"
className="btn ghost small"
style={{ padding: '1px 6px', marginLeft: -6, alignSelf: 'flex-start' }}
onClick={() => void window.vdd.system.openExternal(latest.htmlUrl)}
>
Release notes <ExternalLink size={11} />
</button>
)}
</div>
</div>
{driver === 'audio' && (
<div className={`banner ${testSigning === true ? 'info' : 'warn'}`} style={{ marginTop: 12 }}>
{testSigning === true ? <ShieldCheck size={16} style={{ flexShrink: 0 }} /> : <ShieldAlert size={16} style={{ flexShrink: 0 }} />}
<span style={{ flex: 1 }}>
The Virtual Audio Driver is currently <strong>test-signed</strong>, so Windows must run in{' '}
<strong>Test Signing mode</strong> for the device to start.{' '}
{testSigning === true && 'Test signing is enabled in this PC\u2019s boot configuration.'}
{testSigning === false && 'Test signing is currently OFF on this PC.'}
{testSigning === null && 'The current test signing state could not be determined.'}
{testSigning !== null && ' Changes take effect after a Windows restart. Secure Boot must be disabled to enable it.'}
</span>
{testSigning !== null && (
<button
type="button"
className="btn small"
style={{ flexShrink: 0 }}
disabled={busy}
onClick={() => void setTestSigning(!testSigning)}
>
{busyHere && working?.op === 'testsigning' ? (
<Loader2 size={13} className="spin" />
) : testSigning ? (
<ShieldAlert size={13} />
) : (
<ShieldCheck size={13} />
)}
{busyHere && working?.op === 'testsigning'
? 'Applying…'
: testSigning
? 'Disable test signing'
: 'Enable test signing'}
</button>
)}
</div>
)}
{busyHere && progress && (
<div className="lifecycle-progress">
<div className="progress-track">
<div
className={`progress-fill ${progress.percent < 0 ? 'indeterminate' : ''}`}
style={progress.percent >= 0 ? { width: `${progress.percent}%` } : undefined}
/>
</div>
<span className="progress-message">{progress.message}</span>
</div>
)}
<div className="lifecycle-actions">
<button
type="button"
className={`btn small ${!installed || updateAvailable ? 'primary' : ''}`}
disabled={busy || checking || (latest !== null && latest.asset === null)}
onClick={() => void install(driver, multiInstance ? selectedInstances : undefined)}
>
{busyHere && working?.op === 'install' ? (
<Loader2 size={13} className="spin" />
) : updateAvailable ? (
<ArrowUpCircle size={13} />
) : (
<Download size={13} />
)}
{busyHere && working?.op === 'install' ? 'Installing…' : installLabel}
</button>
{installed && (
<>
<button type="button" className="btn ghost small" disabled={busy} onClick={() => void restartDevice(driver)}>
{busyHere && working?.op === 'restart' ? <Loader2 size={13} className="spin" /> : <Power size={13} />}
{busyHere && working?.op === 'restart' ? 'Restarting…' : 'Restart device'}
</button>
<button type="button" className="btn ghost small danger" disabled={busy} onClick={() => void uninstallDriver(driver)}>
{busyHere && working?.op === 'uninstall' ? <Loader2 size={13} className="spin" /> : <Trash2 size={13} />}
{busyHere && working?.op === 'uninstall' ? 'Removing…' : 'Uninstall'}
</button>
</>
)}
{updateAvailable && (
<span className="lifecycle-hint">
Update available: {installedTag} → {latest?.tag}
</span>
)}
</div>
{multiInstance && (
<div className="lifecycle-actions" style={{ marginTop: 10 }}>
<span className="stat-label" style={{ marginRight: 2 }}>
<Layers size={11} /> Devices
</span>
<Segmented
value={String(selectedInstances)}
options={[1, 2, 3, 4].map((n) => ({ value: String(n), label: String(n) }))}
onChange={(v) => setInstanceChoice(Number(v))}
/>
{installed && selectedInstances !== deviceCount && (
<button
type="button"
className="btn small"
disabled={busy}
onClick={() => void setInstances(driver, selectedInstances)}
>
{busyHere && working?.op === 'instances' ? <Loader2 size={13} className="spin" /> : <Layers size={13} />}
{busyHere && working?.op === 'instances' ? 'Applying…' : `Set ${selectedInstances} device${selectedInstances === 1 ? '' : 's'}`}
</button>
)}
<span className="faint" style={{ fontSize: 11.5 }}>
Each device adds an independent virtual speaker + microphone pair for routing.
</span>
</div>
)}
</Card>
)
}
@@ -0,0 +1,47 @@
import { AnimatePresence, motion } from 'motion/react'
import { Loader2, RotateCcw, Save, Zap } from 'lucide-react'
import { useDriver } from '@renderer/stores/driver'
import { useSettings } from '@renderer/stores/settings'
export function SaveBar(): React.JSX.Element {
const dirty = useSettings((s) => s.dirty)
const discard = useSettings((s) => s.discard)
const save = useSettings((s) => s.save)
const busy = useDriver((s) => s.busy)
const online = useDriver((s) => s.status?.pipeConnected === true)
const saveAndApply = useDriver((s) => s.saveAndApply)
return (
<AnimatePresence>
{dirty && (
<motion.div
className="save-bar"
initial={{ opacity: 0, y: 26 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2, ease: [0.2, 0.9, 0.25, 1] }}
>
<span className="save-bar-text">Unsaved configuration changes</span>
<button type="button" className="btn ghost small" disabled={busy !== null} onClick={discard}>
<RotateCcw size={13} />
Discard
</button>
<button type="button" className="btn small" disabled={busy !== null} onClick={() => void save()}>
<Save size={13} />
Save
</button>
<button
type="button"
className="btn primary small"
disabled={busy !== null}
title={online ? 'Write vdd_settings.xml and reload the driver' : 'Driver offline - will only save the file'}
onClick={() => void saveAndApply()}
>
{busy !== null ? <Loader2 size={13} className="spin" /> : <Zap size={13} />}
Save & Apply
</button>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,66 @@
import {
Cpu,
LayoutDashboard,
MonitorCog,
Palette,
ScanEye,
SettingsIcon,
SquareTerminal,
Volume2
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useDriver } from '@renderer/stores/driver'
import { useUi, type PageId } from '@renderer/stores/ui'
interface NavEntry {
id: PageId
label: string
icon: LucideIcon
section?: string
}
const NAV: NavEntry[] = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'displays', label: 'Displays', icon: MonitorCog, section: 'Configure' },
{ id: 'color', label: 'HDR & Color', icon: Palette },
{ id: 'edid', label: 'EDID Lab', icon: ScanEye },
{ id: 'gpu', label: 'GPU', icon: Cpu },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'console', label: 'Console', icon: SquareTerminal, section: 'Diagnostics' },
{ id: 'settings', label: 'Settings', icon: SettingsIcon }
]
export function Sidebar(): React.JSX.Element {
const page = useUi((s) => s.page)
const setPage = useUi((s) => s.setPage)
const sysInfo = useDriver((s) => s.sysInfo)
const iddcx = useDriver((s) => s.iddcx)
return (
<nav className="sidebar">
{NAV.map((entry) => {
const Icon = entry.icon
return (
<div key={entry.id}>
{entry.section && <div className="nav-section">{entry.section}</div>}
<button
type="button"
className={`nav-item ${page === entry.id ? 'active' : ''}`}
style={{ width: '100%' }}
onClick={() => setPage(entry.id)}
>
<Icon size={16} strokeWidth={2.1} />
{entry.label}
</button>
</div>
)
})}
<div className="sidebar-footer">
<span>v{sysInfo?.appVersion ?? '2.0.0'}</span>
<span className="mono" style={{ fontSize: 10 }}>
MttVDD · IddCx{iddcx ? ` ${iddcx}` : ''}
</span>
</div>
</nav>
)
}
@@ -0,0 +1,64 @@
import { Loader2, Minus, Square, X, Copy, ShieldAlert } from 'lucide-react'
import { useDriver } from '@renderer/stores/driver'
import { useUi } from '@renderer/stores/ui'
import logoOk from '../assets/logo-ok.png'
import logoWarn from '../assets/logo-warn.png'
import logoErr from '../assets/logo-err.png'
const LEVEL_META: Record<string, { className: string; label: string; logo: string }> = {
online: { className: 'online', label: 'Driver online', logo: logoOk },
'installed-offline': { className: 'offline', label: 'Installed, not responding', logo: logoWarn },
'not-installed': { className: 'missing', label: 'Driver not installed', logo: logoErr },
unknown: { className: 'unknown', label: 'Checking driver…', logo: logoErr }
}
export function TitleBar(): React.JSX.Element {
const status = useDriver((s) => s.status)
const busy = useDriver((s) => s.busy)
const sysInfo = useDriver((s) => s.sysInfo)
const maximized = useUi((s) => s.maximized)
const meta = LEVEL_META[status?.level ?? 'unknown']
return (
<header className="titlebar">
<div className="brand">
<img className="brand-mark" src={meta.logo} alt="" title={meta.label} />
Virtual Driver Control
</div>
<div className="titlebar-status" title={status?.deviceName ?? ''}>
<span className={`status-orb ${meta.className}`} />
{meta.label}
</div>
{busy && (
<div className="titlebar-status" style={{ color: 'var(--accent-text)' }}>
<Loader2 size={12} className="spin" />
{busy}
</div>
)}
{sysInfo && !sysInfo.isAdmin && (
<div className="titlebar-status" style={{ color: 'var(--warning)' }} title="Saving to C:\VirtualDisplayDriver may fail without elevation">
<ShieldAlert size={12} />
Not elevated
</div>
)}
<div className="titlebar-spacer" />
<div className="window-controls">
<button type="button" aria-label="Minimize" onClick={() => window.vdd.window.minimize()}>
<Minus size={15} />
</button>
<button type="button" aria-label="Maximize" onClick={() => window.vdd.window.maximizeToggle()}>
{maximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</button>
<button type="button" aria-label="Close" className="close" onClick={() => window.vdd.window.close()}>
<X size={16} />
</button>
</div>
</header>
)
}
@@ -0,0 +1,42 @@
import { AnimatePresence, motion } from 'motion/react'
import { AlertTriangle, CheckCircle2, Info, XCircle, X } from 'lucide-react'
import { useUi, type ToastKind } from '@renderer/stores/ui'
const ICONS: Record<ToastKind, React.JSX.Element> = {
success: <CheckCircle2 size={17} />,
error: <XCircle size={17} />,
info: <Info size={17} />,
warning: <AlertTriangle size={17} />
}
export function Toasts(): React.JSX.Element {
const toasts = useUi((s) => s.toasts)
const dismiss = useUi((s) => s.dismissToast)
return (
<div className="toast-stack">
<AnimatePresence>
{toasts.map((toast) => (
<motion.div
key={toast.id}
className={`toast ${toast.kind}`}
initial={{ opacity: 0, x: 60, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.96 }}
transition={{ duration: 0.22, ease: [0.2, 0.9, 0.25, 1] }}
layout
>
<span className="toast-icon">{ICONS[toast.kind]}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="toast-title">{toast.title}</div>
{toast.message && <div className="toast-message">{toast.message}</div>}
</div>
<button type="button" className="btn ghost small icon-only" onClick={() => dismiss(toast.id)}>
<X size={13} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,185 @@
import { AnimatePresence, motion } from 'motion/react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
export function Card(props: {
icon?: LucideIcon
title?: string
subtitle?: string
actions?: ReactNode
children: ReactNode
className?: string
}): React.JSX.Element {
const Icon = props.icon
return (
<section className={`card ${props.className ?? ''}`}>
{props.title && (
<header className="card-header">
{Icon && (
<span className="card-icon">
<Icon size={16} />
</span>
)}
<div>
<h3>{props.title}</h3>
{props.subtitle && <div className="card-sub">{props.subtitle}</div>}
</div>
{props.actions && <div className="card-actions">{props.actions}</div>}
</header>
)}
<div className="card-body">{props.children}</div>
</section>
)
}
// ---------------------------------------------------------------------------
// Toggle
// ---------------------------------------------------------------------------
export function Toggle(props: {
checked: boolean
onChange: (value: boolean) => void
label?: string
sublabel?: string
disabled?: boolean
}): React.JSX.Element {
return (
<button
type="button"
role="switch"
aria-checked={props.checked}
className={`toggle ${props.checked ? 'on' : ''} ${props.disabled ? 'disabled' : ''}`}
onClick={() => !props.disabled && props.onChange(!props.checked)}
style={{ background: 'none', border: 'none', font: 'inherit', color: 'inherit', textAlign: 'left' }}
>
<span className="track">
<span className="knob" />
</span>
{props.label && (
<span>
<span className="toggle-label">{props.label}</span>
{props.sublabel && <span className="toggle-sub">{props.sublabel}</span>}
</span>
)}
</button>
)
}
// ---------------------------------------------------------------------------
// Field wrappers
// ---------------------------------------------------------------------------
export function Field(props: { label: string; hint?: string; children: ReactNode }): React.JSX.Element {
return (
<div className="field">
<label>{props.label}</label>
{props.children}
{props.hint && <span className="hint">{props.hint}</span>}
</div>
)
}
export function NumberField(props: {
label: string
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
hint?: string
disabled?: boolean
}): React.JSX.Element {
return (
<Field label={props.label} hint={props.hint}>
<input
type="number"
value={props.value}
min={props.min}
max={props.max}
step={props.step}
disabled={props.disabled}
onChange={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n)) props.onChange(n)
}}
/>
</Field>
)
}
// ---------------------------------------------------------------------------
// Segmented control
// ---------------------------------------------------------------------------
export function Segmented<T extends string>(props: {
value: T
options: Array<{ value: T; label: string }>
onChange: (value: T) => void
}): React.JSX.Element {
return (
<div className="segmented">
{props.options.map((opt) => (
<button
key={opt.value}
type="button"
className={opt.value === props.value ? 'active' : ''}
onClick={() => props.onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
export function Modal(props: {
open: boolean
title: string
icon?: LucideIcon
onClose: () => void
footer?: ReactNode
children: ReactNode
wide?: boolean
}): React.JSX.Element {
const Icon = props.icon
return (
<AnimatePresence>
{props.open && (
<motion.div
className="modal-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
onMouseDown={(e) => {
if (e.target === e.currentTarget) props.onClose()
}}
>
<motion.div
className="modal"
style={props.wide ? { width: 'min(1020px, 100%)' } : undefined}
initial={{ opacity: 0, scale: 0.96, y: 14 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.2, 0.9, 0.25, 1] }}
>
<div className="modal-header">
{Icon && <Icon size={17} style={{ color: 'var(--accent-text)' }} />}
<h3>{props.title}</h3>
</div>
<div className="modal-body">{props.children}</div>
{props.footer && <div className="modal-footer">{props.footer}</div>}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+9
View File
@@ -0,0 +1,9 @@
declare module '*.css' {
const css: string
export default css
}
declare module '*.png' {
const src: string
export default src
}
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
@@ -0,0 +1,284 @@
import { useEffect, useRef, useState } from 'react'
import {
ArrowRight,
AudioLines,
Check,
Info,
Loader2,
Mic,
MonitorSpeaker,
Plus,
RefreshCw,
Trash2,
Volume2,
VolumeX,
Waves
} from 'lucide-react'
import type { AudioEndpoint } from '@shared/types'
import { Card, Toggle } from '@renderer/components/ui'
import { DriverLifecycle } from '@renderer/components/DriverLifecycle'
import { SYSTEM_AUDIO_SOURCE, useAudio } from '@renderer/stores/audio'
import { audioRouter } from '@renderer/utils/audio-router'
export function AudioPage(): React.JSX.Element {
const init = useAudio((s) => s.init)
useEffect(() => {
void init()
}, [init])
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">Audio</h1>
<p className="page-subtitle">Virtual audio devices, Windows endpoint control and live routing</p>
</div>
</div>
<DriverLifecycle driver="audio" />
<EndpointsCard />
<RoutingCard />
</div>
)
}
// ---------------------------------------------------------------------------
// Windows endpoints
// ---------------------------------------------------------------------------
function EndpointsCard(): React.JSX.Element {
const endpoints = useAudio((s) => s.endpoints)
const loading = useAudio((s) => s.endpointsLoading)
const error = useAudio((s) => s.endpointsError)
const refresh = useAudio((s) => s.refreshEndpoints)
const outputs = endpoints.filter((e) => e.flow === 'render')
const inputs = endpoints.filter((e) => e.flow === 'capture')
return (
<Card
icon={MonitorSpeaker}
title="Windows audio devices"
subtitle="Active playback and recording endpoints - volume, mute and default device"
actions={
<button type="button" className="btn ghost small" disabled={loading} onClick={() => void refresh()}>
{loading ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />} Refresh
</button>
}
>
{error && <div className="banner warn" style={{ marginBottom: 10 }}>{error}</div>}
{endpoints.length === 0 && !error && (
<div className="faint">{loading ? 'Enumerating audio endpoints…' : 'No active audio endpoints found.'}</div>
)}
<div className="card-grid two">
<div>
<div className="nav-section" style={{ margin: '0 0 8px' }}>
Output · {outputs.length}
</div>
<div className="endpoint-list">
{outputs.map((e) => (
<EndpointRow key={e.id} endpoint={e} />
))}
</div>
</div>
<div>
<div className="nav-section" style={{ margin: '0 0 8px' }}>
Input · {inputs.length}
</div>
<div className="endpoint-list">
{inputs.map((e) => (
<EndpointRow key={e.id} endpoint={e} />
))}
</div>
</div>
</div>
</Card>
)
}
function EndpointRow(props: { endpoint: AudioEndpoint }): React.JSX.Element {
const { endpoint } = props
const setVolume = useAudio((s) => s.setEndpointVolume)
const setMute = useAudio((s) => s.setEndpointMute)
const setDefault = useAudio((s) => s.setDefaultEndpoint)
return (
<div className={`endpoint-row ${endpoint.isVirtual ? 'virtual' : ''}`}>
<span className="ep-icon">{endpoint.flow === 'render' ? <Volume2 size={15} /> : <Mic size={15} />}</span>
<div className="ep-main">
<div className="ep-name" title={endpoint.name}>
{endpoint.name}
{endpoint.isVirtual && <span className="ep-badge accent">virtual</span>}
{endpoint.isDefault && <span className="ep-badge">default</span>}
{endpoint.isDefaultComm && <span className="ep-badge dim">comms</span>}
</div>
<div className="ep-controls">
<button
type="button"
className={`btn ghost small icon-only ${endpoint.muted ? 'danger' : ''}`}
aria-label={endpoint.muted ? 'Unmute' : 'Mute'}
onClick={() => void setMute(endpoint.id, !endpoint.muted)}
>
{endpoint.muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
</button>
<input
type="range"
min={0}
max={100}
value={Math.round(endpoint.volume * 100)}
onChange={(e) => setVolume(endpoint.id, Number(e.target.value) / 100)}
/>
<span className="ep-vol mono">{Math.round(endpoint.volume * 100)}%</span>
{!endpoint.isDefault && (
<button type="button" className="btn ghost small" onClick={() => void setDefault(endpoint.id)}>
<Check size={12} /> Default
</button>
)}
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
function RoutingCard(): React.JSX.Element {
const routes = useAudio((s) => s.routes)
const webInputs = useAudio((s) => s.webInputs)
const webOutputs = useAudio((s) => s.webOutputs)
const activeRoutes = useAudio((s) => s.activeRoutes)
const routeErrors = useAudio((s) => s.routeErrors)
const addRoute = useAudio((s) => s.addRoute)
const removeRoute = useAudio((s) => s.removeRoute)
const toggleRoute = useAudio((s) => s.toggleRoute)
const setRouteGain = useAudio((s) => s.setRouteGain)
const refreshWebDevices = useAudio((s) => s.refreshWebDevices)
const [sourceId, setSourceId] = useState('')
const [sinkId, setSinkId] = useState('')
const sourceOptions = [{ deviceId: SYSTEM_AUDIO_SOURCE, label: 'System audio (default output loopback)' }, ...webInputs]
const add = (): void => {
const source = sourceOptions.find((d) => d.deviceId === sourceId)
const sink = webOutputs.find((d) => d.deviceId === sinkId)
if (!source || !sink) return
void addRoute(source.deviceId, source.label, sink.deviceId, sink.label)
}
return (
<Card
icon={Waves}
title="Audio routing"
subtitle="Pump any input or the system mix into any output - routes run while the app is open"
actions={
<button type="button" className="btn ghost small" onClick={() => void refreshWebDevices()}>
<RefreshCw size={13} /> Rescan devices
</button>
}
>
<div className="route-add">
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)}>
<option value="">Select source…</option>
{sourceOptions.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
<ArrowRight size={15} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<select value={sinkId} onChange={(e) => setSinkId(e.target.value)}>
<option value="">Select output…</option>
{webOutputs.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
<button type="button" className="btn primary small" disabled={!sourceId || !sinkId} onClick={add}>
<Plus size={13} /> Add route
</button>
</div>
<div className="route-list">
{routes.map((route) => (
<div key={route.id} className={`route-row ${activeRoutes.has(route.id) ? 'active' : ''}`}>
<Toggle checked={route.enabled} onChange={(v) => void toggleRoute(route.id, v)} />
<div className="route-main">
<div className="route-path">
<span className="route-ep" title={route.sourceLabel}>
{route.sourceId === SYSTEM_AUDIO_SOURCE ? <AudioLines size={12} /> : <Mic size={12} />}
{route.sourceLabel}
</span>
<ArrowRight size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span className="route-ep" title={route.sinkLabel}>
<Volume2 size={12} />
{route.sinkLabel}
</span>
</div>
<div className="route-controls">
<span className="faint" style={{ fontSize: 11 }}>
Gain
</span>
<input
type="range"
min={0}
max={200}
value={Math.round(route.gain * 100)}
onChange={(e) => setRouteGain(route.id, Number(e.target.value) / 100)}
/>
<span className="ep-vol mono">{Math.round(route.gain * 100)}%</span>
<RouteMeter routeId={route.id} active={activeRoutes.has(route.id)} />
</div>
{routeErrors[route.id] && <div className="route-error">{routeErrors[route.id]}</div>}
</div>
<button
type="button"
className="btn ghost small icon-only"
aria-label="Remove route"
onClick={() => void removeRoute(route.id)}
>
<Trash2 size={14} />
</button>
</div>
))}
{routes.length === 0 && <div className="faint">No routes yet - add one above.</div>}
</div>
<div className="banner info" style={{ marginTop: 12 }}>
<Info size={15} />
<span>
<strong>Mic → speaker:</strong> pick a microphone and an output. <strong>Speaker → speaker:</strong> use{' '}
<em>System audio</em> as the source. <strong>Speaker → mic:</strong> route into{' '}
<em>Speakers (Virtual Audio Driver)</em> - apps then hear it on the matching virtual microphone.
</span>
</div>
</Card>
)
}
function RouteMeter(props: { routeId: string; active: boolean }): React.JSX.Element {
const barRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!props.active) return
let raf = 0
const tick = (): void => {
if (barRef.current) {
barRef.current.style.width = `${Math.round(audioRouter.level(props.routeId) * 100)}%`
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [props.routeId, props.active])
return (
<div className={`route-meter ${props.active ? '' : 'idle'}`}>
<div ref={barRef} className="route-meter-fill" />
</div>
)
}
@@ -0,0 +1,264 @@
import { Blend, Palette, Sparkles, SunMedium, Triangle } from 'lucide-react'
import { COLOR_SPACE_PRESETS } from '@shared/defaults'
import type { ColourFormat } from '@shared/types'
import { CieDiagram } from '@renderer/components/CieDiagram'
import { Card, Field, NumberField, Segmented, Toggle } from '@renderer/components/ui'
import { useSettings } from '@renderer/stores/settings'
const COLOUR_FORMATS: Array<{ value: ColourFormat; label: string; sub: string }> = [
{ value: 'RGB', label: 'RGB', sub: 'Full fidelity, default' },
{ value: 'YCbCr444', label: 'YCbCr 4:4:4', sub: 'No chroma subsampling' },
{ value: 'YCbCr422', label: 'YCbCr 4:2:2', sub: 'Half chroma bandwidth' },
{ value: 'YCbCr420', label: 'YCbCr 4:2:0', sub: 'Quarter chroma, streaming' }
]
export function ColorPage(): React.JSX.Element {
const draft = useSettings((s) => s.draft)
const patch = useSettings((s) => s.patch)
const primaries = draft.hdrAdvanced.colorPrimaries
const hdr10 = draft.hdrAdvanced.hdr10StaticMetadata
const colorSpace = draft.hdrAdvanced.colorSpace
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">HDR & Color</h1>
<p className="page-subtitle">Pixel format, HDR10 metadata and the color gamut your virtual displays advertise</p>
</div>
</div>
<Card icon={Palette} title="Color format" subtitle="How pixels are encoded on the virtual link">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="row wrap" style={{ gap: 9 }}>
{COLOUR_FORMATS.map((format) => (
<button
key={format.value}
type="button"
className="preset-card"
style={{
borderColor: draft.colour.colourFormat === format.value ? 'var(--accent)' : undefined,
background: draft.colour.colourFormat === format.value ? 'var(--accent-softer)' : undefined,
minWidth: 150
}}
onClick={() => patch((d) => (d.colour.colourFormat = format.value))}
>
<div className="p-dim">{format.label}</div>
<div className="p-label">{format.sub}</div>
</button>
))}
</div>
<div className="row wrap" style={{ gap: 26 }}>
<Toggle
checked={draft.colour.hdrPlus}
onChange={(v) => patch((d) => (d.colour.hdrPlus = v))}
label="HDR+"
sublabel="Requires Windows 11 23H2+"
/>
<Toggle
checked={draft.colour.sdr10bit}
onChange={(v) => patch((d) => (d.colour.sdr10bit = v))}
label="SDR 10-bit"
sublabel="10-bit output without HDR"
/>
</div>
</div>
</Card>
<Card
icon={SunMedium}
title="HDR10 static metadata"
subtitle="Mastering display luminance advertised to HDR-aware apps"
actions={<Toggle checked={hdr10.enabled} onChange={(v) => patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.enabled = v))} />}
>
<div className="grid-4" style={{ opacity: hdr10.enabled ? 1 : 0.45, pointerEvents: hdr10.enabled ? 'auto' : 'none' }}>
<NumberField
label="Max mastering (nits)"
value={hdr10.maxDisplayMasteringLuminance}
min={100}
max={10000}
step={50}
onChange={(v) => patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxDisplayMasteringLuminance = v))}
/>
<NumberField
label="Min mastering (nits)"
value={hdr10.minDisplayMasteringLuminance}
min={0.0001}
max={5}
step={0.01}
onChange={(v) => patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.minDisplayMasteringLuminance = v))}
/>
<NumberField
label="MaxCLL (nits)"
value={hdr10.maxContentLightLevel}
min={100}
max={10000}
step={50}
hint="Max content light level"
onChange={(v) => patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxContentLightLevel = v))}
/>
<NumberField
label="MaxFALL (nits)"
value={hdr10.maxFrameAvgLightLevel}
min={50}
max={5000}
step={50}
hint="Max frame-average light level"
onChange={(v) => patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxFrameAvgLightLevel = v))}
/>
</div>
</Card>
<Card
icon={Triangle}
title="Color primaries"
subtitle="Drag the gamut your virtual display claims to cover - dashed triangle is sRGB"
actions={<Toggle checked={primaries.enabled} onChange={(v) => patch((d) => (d.hdrAdvanced.colorPrimaries.enabled = v))} />}
>
<div className="row" style={{ alignItems: 'flex-start', gap: 26, flexWrap: 'wrap' }}>
<CieDiagram
value={primaries}
disabled={!primaries.enabled}
onChange={(p) =>
patch((d) => {
Object.assign(d.hdrAdvanced.colorPrimaries, p)
})
}
/>
<div style={{ flex: 1, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 14 }}>
<Field label="Preset gamut">
<div className="row wrap" style={{ gap: 7 }}>
{Object.entries(COLOR_SPACE_PRESETS).map(([name, preset]) => (
<button
key={name}
type="button"
className="chip selectable"
style={{ background: 'transparent', font: 'inherit' }}
disabled={!primaries.enabled}
onClick={() =>
patch((d) => {
Object.assign(d.hdrAdvanced.colorPrimaries, preset)
})
}
>
{name}
</button>
))}
</div>
</Field>
<table className="data-table" style={{ opacity: primaries.enabled ? 1 : 0.5 }}>
<thead>
<tr>
<th>Primary</th>
<th>x</th>
<th>y</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ color: '#ff5d5d' }}>Red</td>
<td className="mono">{primaries.redX.toFixed(4)}</td>
<td className="mono">{primaries.redY.toFixed(4)}</td>
</tr>
<tr>
<td style={{ color: '#4ade80' }}>Green</td>
<td className="mono">{primaries.greenX.toFixed(4)}</td>
<td className="mono">{primaries.greenY.toFixed(4)}</td>
</tr>
<tr>
<td style={{ color: '#60a5fa' }}>Blue</td>
<td className="mono">{primaries.blueX.toFixed(4)}</td>
<td className="mono">{primaries.blueY.toFixed(4)}</td>
</tr>
<tr>
<td>White point</td>
<td className="mono">{primaries.whiteX.toFixed(4)}</td>
<td className="mono">{primaries.whiteY.toFixed(4)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
<div className="card-grid two">
<Card
icon={Blend}
title="Color space & gamma"
actions={<Toggle checked={colorSpace.enabled} onChange={(v) => patch((d) => (d.hdrAdvanced.colorSpace.enabled = v))} />}
>
<div
style={{ display: 'flex', flexDirection: 'column', gap: 14, opacity: colorSpace.enabled ? 1 : 0.45, pointerEvents: colorSpace.enabled ? 'auto' : 'none' }}
>
<Field label={`Gamma correction: ${colorSpace.gammaCorrection.toFixed(2)}`}>
<input
type="range"
min={1.0}
max={3.0}
step={0.05}
value={colorSpace.gammaCorrection}
onChange={(e) => patch((d) => (d.hdrAdvanced.colorSpace.gammaCorrection = Number(e.target.value)))}
/>
</Field>
<Field label="Primary color space">
<select
value={colorSpace.primaryColorSpace}
onChange={(e) => patch((d) => (d.hdrAdvanced.colorSpace.primaryColorSpace = e.target.value))}
>
{['sRGB', 'DCI-P3', 'AdobeRGB', 'Rec2020', 'Rec709'].map((cs) => (
<option key={cs} value={cs}>
{cs}
</option>
))}
</select>
</Field>
<Toggle
checked={colorSpace.enableMatrixTransform}
onChange={(v) => patch((d) => (d.hdrAdvanced.colorSpace.enableMatrixTransform = v))}
label="Matrix transform"
sublabel="Apply color space conversion matrix"
/>
</div>
</Card>
<Card icon={Sparkles} title="Bit depth & SDR level">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Toggle
checked={draft.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace}
onChange={(v) => patch((d) => (d.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace = v))}
label="Auto bit depth"
sublabel="Derive bit depth from color space"
/>
<Field label="Force bit depth">
<Segmented
value={String(draft.colorAdvanced.bitDepthManagement.forceBitDepth) as '8' | '10' | '12' | '16'}
options={[
{ value: '8', label: '8-bit' },
{ value: '10', label: '10-bit' },
{ value: '12', label: '12-bit' },
{ value: '16', label: '16-bit' }
]}
onChange={(v) => patch((d) => (d.colorAdvanced.bitDepthManagement.forceBitDepth = Number(v)))}
/>
</Field>
<Toggle
checked={draft.colorAdvanced.bitDepthManagement.fp16SurfaceSupport}
onChange={(v) => patch((d) => (d.colorAdvanced.bitDepthManagement.fp16SurfaceSupport = v))}
label="FP16 surface support"
sublabel="Keep enabled for compatibility"
/>
<NumberField
label="SDR white level (nits)"
value={draft.colorAdvanced.colorFormatExtended.sdrWhiteLevel}
min={40}
max={480}
step={10}
onChange={(v) => patch((d) => (d.colorAdvanced.colorFormatExtended.sdrWhiteLevel = v))}
/>
</div>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDownToLine, Eraser, FolderOpen, SendHorizontal, SquareTerminal } from 'lucide-react'
import type { LogSeverity, LogSource } from '@shared/types'
import { useDriver } from '@renderer/stores/driver'
import { useLogs } from '@renderer/stores/logs'
import { useUi } from '@renderer/stores/ui'
const KNOWN_COMMANDS = [
'PING',
'GETSETTINGS',
'SETDISPLAYCOUNT 1',
'GETALLGPUS',
'GETASSIGNEDGPU',
'IDDCXVERSION',
'D3DDEVICEGPU',
'HDRPLUS true',
'SDR10 true',
'LOGGING true',
'LOG_DEBUG true',
'CUSTOMEDID true',
'PREVENTSPOOF true',
'CEAOVERRIDE true',
'HARDWARECURSOR true',
'SETGPU "name"'
]
const SEVERITIES: LogSeverity[] = ['error', 'warning', 'info', 'debug']
const SOURCES: LogSource[] = ['pipe', 'file', 'app']
function formatTime(timestamp: number): string {
const d = new Date(timestamp)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
export function ConsolePage(): React.JSX.Element {
const events = useLogs((s) => s.events)
const severityFilter = useLogs((s) => s.severityFilter)
const sourceFilter = useLogs((s) => s.sourceFilter)
const search = useLogs((s) => s.search)
const autoScroll = useLogs((s) => s.autoScroll)
const toggleSeverity = useLogs((s) => s.toggleSeverity)
const toggleSource = useLogs((s) => s.toggleSource)
const setSearch = useLogs((s) => s.setSearch)
const setAutoScroll = useLogs((s) => s.setAutoScroll)
const clear = useLogs((s) => s.clear)
const online = useDriver((s) => s.status?.pipeConnected === true)
const toast = useUi((s) => s.toast)
const [command, setCommand] = useState('')
const [sending, setSending] = useState(false)
const feedRef = useRef<HTMLDivElement>(null)
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase()
return events.filter(
(e) =>
severityFilter.has(e.severity) &&
sourceFilter.has(e.source) &&
(needle.length === 0 || e.message.toLowerCase().includes(needle))
)
}, [events, severityFilter, sourceFilter, search])
useEffect(() => {
if (autoScroll && feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight
}
}, [filtered, autoScroll])
const sendCommand = async (): Promise<void> => {
const cmd = command.trim()
if (cmd.length === 0 || sending) return
if (cmd.toUpperCase() === 'RELOAD_DRIVER') {
toast('warning', 'RELOAD_DRIVER is blocked', 'It causes undefined behavior in the driver (upstream issue #351). Use SETDISPLAYCOUNT N instead.')
return
}
setSending(true)
try {
const result = await window.vdd.pipe.sendRaw(cmd)
if (!result.ok) toast('error', 'Command failed', result.error)
setCommand('')
} catch (error) {
toast('error', 'Command rejected', error instanceof Error ? error.message : String(error))
} finally {
setSending(false)
}
}
return (
<div className="page-inner" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="page-header">
<div>
<h1 className="page-title">Console</h1>
<p className="page-subtitle">Unified driver activity - file logs, pipe traffic and app events</p>
</div>
<div className="row">
<button type="button" className="btn ghost small" onClick={() => void window.vdd.system.openPath('logs')}>
<FolderOpen size={13} /> Logs folder
</button>
<button type="button" className="btn ghost small" onClick={clear}>
<Eraser size={13} /> Clear
</button>
</div>
</div>
<div className="row wrap" style={{ gap: 8 }}>
{SEVERITIES.map((sev) => (
<button
key={sev}
type="button"
className={`chip selectable ${severityFilter.has(sev) ? 'on' : ''}`}
style={{ background: severityFilter.has(sev) ? undefined : 'transparent', font: 'inherit' }}
onClick={() => toggleSeverity(sev)}
>
{sev}
</button>
))}
<span style={{ width: 10 }} />
{SOURCES.map((src) => (
<button
key={src}
type="button"
className={`chip selectable ${sourceFilter.has(src) ? 'on' : ''}`}
style={{ background: sourceFilter.has(src) ? undefined : 'transparent', font: 'inherit' }}
onClick={() => toggleSource(src)}
>
{src}
</button>
))}
<input
type="text"
placeholder="Filter messages…"
value={search}
style={{ width: 200, marginLeft: 'auto' }}
onChange={(e) => setSearch(e.target.value)}
/>
<button
type="button"
className={`chip selectable ${autoScroll ? 'on' : ''}`}
style={{ background: autoScroll ? undefined : 'transparent', font: 'inherit' }}
onClick={() => setAutoScroll(!autoScroll)}
title="Auto-scroll to newest"
>
<ArrowDownToLine size={12} /> follow
</button>
</div>
<div ref={feedRef} className="console-feed" style={{ flex: 1, minHeight: 280 }}>
{filtered.length === 0 && (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-faint)' }}>
<SquareTerminal size={26} strokeWidth={1.5} style={{ marginBottom: 8 }} />
<div>No log activity yet{online ? ' - try sending PING below' : ''}</div>
</div>
)}
{filtered.map((event) => (
<div key={event.id} className="console-line">
<span className="ts">{formatTime(event.timestamp)}</span>
<span className={`src ${event.source}`}>{event.source}</span>
<span className={`msg ${event.severity}`}>{event.message}</span>
</div>
))}
</div>
<div className="row" style={{ gap: 9 }}>
<input
type="text"
className="mono"
list="pipe-commands"
placeholder={online ? 'Send a raw pipe command, e.g. PING' : 'Driver offline - pipe commands unavailable'}
value={command}
disabled={!online}
style={{ flex: 1 }}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void sendCommand()}
/>
<datalist id="pipe-commands">
{KNOWN_COMMANDS.map((cmd) => (
<option key={cmd} value={cmd} />
))}
</datalist>
<button type="button" className="btn primary" disabled={!online || sending || command.trim().length === 0} onClick={() => void sendCommand()}>
<SendHorizontal size={14} />
Send
</button>
</div>
</div>
)
}
@@ -0,0 +1,184 @@
import {
Activity,
Bug,
Cpu,
FileText,
HardDrive,
Layers,
MonitorPlay,
MousePointer2,
ScanEye,
ShieldAlert,
Sparkles,
Sun
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { PipeToggleCommand } from '@shared/types'
import { Card, Toggle } from '@renderer/components/ui'
import { DisplayCanvas, useDisplayLayout } from '@renderer/components/ArrangementMap'
import { DriverLifecycle } from '@renderer/components/DriverLifecycle'
import { useDriver } from '@renderer/stores/driver'
import { useSettings } from '@renderer/stores/settings'
import { useUi } from '@renderer/stores/ui'
interface QuickToggleDef {
command: PipeToggleCommand
label: string
sub: string
icon: LucideIcon
get: (s: ReturnType<typeof useSettings.getState>['draft']) => boolean
}
const QUICK_TOGGLES: QuickToggleDef[] = [
{ command: 'HDRPLUS', label: 'HDR+', sub: 'High dynamic range output', icon: Sparkles, get: (s) => s.colour.hdrPlus },
{ command: 'SDR10', label: 'SDR 10-bit', sub: '10-bit color in SDR mode', icon: Sun, get: (s) => s.colour.sdr10bit },
{
command: 'HARDWARECURSOR',
label: 'Hardware cursor',
sub: 'GPU-composited cursor',
icon: MousePointer2,
get: (s) => s.cursor.hardwareCursor
},
{ command: 'CUSTOMEDID', label: 'Custom EDID', sub: 'Use user_edid.bin identity', icon: ScanEye, get: (s) => s.edid.customEdid },
{ command: 'LOGGING', label: 'File logging', sub: 'Write driver log files', icon: FileText, get: (s) => s.logging.logging },
{ command: 'LOG_DEBUG', label: 'Debug logging', sub: 'Verbose troubleshooting logs', icon: Bug, get: (s) => s.logging.debugLogging }
]
const LEVEL_LABEL: Record<string, string> = {
online: 'Online',
'installed-offline': 'Not responding',
'not-installed': 'Not installed',
unknown: 'Checking…'
}
export function DashboardPage(): React.JSX.Element {
const status = useDriver((s) => s.status)
const iddcx = useDriver((s) => s.iddcx)
const busy = useDriver((s) => s.busy)
const quickToggle = useDriver((s) => s.quickToggle)
const sysInfo = useDriver((s) => s.sysInfo)
const draft = useSettings((s) => s.draft)
const isDefault = useSettings((s) => s.isDefault)
const setPage = useUi((s) => s.setPage)
const { displays } = useDisplayLayout()
const online = status?.pipeConnected === true
const attachedVirtual = displays.filter((d) => d.isVirtual).length
const attachedPhysical = displays.length - attachedVirtual
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">Dashboard</h1>
<p className="page-subtitle">Live control of your virtual displays</p>
</div>
</div>
{status?.level === 'not-installed' && (
<div className="banner warn">
<ShieldAlert size={16} />
<span>
The Virtual Display Driver is not installed on this system. You can still edit and stage configuration - use the
driver lifecycle card below to download and install the latest official release.
</span>
</div>
)}
{isDefault && status?.level !== 'not-installed' && (
<div className="banner info">
<FileText size={16} />
<span>No vdd_settings.xml found - showing defaults. Saving will create the configuration file.</span>
</div>
)}
<Card
icon={MonitorPlay}
title="Displays"
subtitle={
online ? 'Live desktop - virtual display changes apply instantly' : 'Live desktop - driver offline, virtual count is staged'
}
>
<DisplayCanvas />
</Card>
<div className="stat-tiles">
<div className="stat-tile">
<span className="stat-label">
<Activity size={11} /> Driver
</span>
<span className="stat-value" style={{ color: online ? 'var(--success)' : status?.level === 'not-installed' ? 'var(--danger)' : 'var(--warning)' }}>
{LEVEL_LABEL[status?.level ?? 'unknown']}
</span>
<span className="stat-sub">{status?.deviceName ?? 'Root\\MttVDD'}</span>
</div>
<div className="stat-tile">
<span className="stat-label">
<Layers size={11} /> Active displays
</span>
<span className="stat-value">{displays.length > 0 ? displays.length : '—'}</span>
<span className="stat-sub">
{displays.length > 0 ? `${attachedPhysical} physical · ${attachedVirtual} virtual · ` : ''}
{draft.monitors.count} configured
</span>
</div>
<div className="stat-tile">
<span className="stat-label">
<Cpu size={11} /> GPU
</span>
<span className="stat-value" title={draft.gpu.friendlyName}>
{draft.gpu.friendlyName === 'default' ? 'System default' : draft.gpu.friendlyName}
</span>
<span className="stat-sub">
<button type="button" className="btn ghost small" style={{ padding: '1px 6px', marginLeft: -6 }} onClick={() => setPage('gpu')}>
Manage →
</button>
</span>
</div>
<div className="stat-tile">
<span className="stat-label">
<HardDrive size={11} /> IddCx
</span>
<span className="stat-value">{iddcx ?? '—'}</span>
<span className="stat-sub">{status?.dllPresent ? `MttVDD.dll · ${status.dllDate ?? ''}` : 'driver DLL not found'}</span>
</div>
{sysInfo && (
<div className="stat-tile">
<span className="stat-label">
<ShieldAlert size={11} /> Session
</span>
<span className="stat-value">{sysInfo.isAdmin ? 'Administrator' : 'Standard user'}</span>
<span className="stat-sub">{sysInfo.windowsVersion}</span>
</div>
)}
</div>
<DriverLifecycle driver="display" />
<Card
icon={Sparkles}
title="Quick toggles"
subtitle={online ? 'Applied live via the driver pipe - some toggles trigger a short reload' : 'Driver offline - toggles stage into the draft configuration'}
>
<div className="quick-toggles">
{QUICK_TOGGLES.map((def) => {
const Icon = def.icon
const value = def.get(draft)
return (
<div key={def.command} className={`quick-toggle ${value ? 'on' : ''}`}>
<span className="qt-icon">
<Icon size={16} />
</span>
<div className="qt-text">
<div className="qt-title">{def.label}</div>
<div className="qt-sub">{def.sub}</div>
</div>
<Toggle checked={value} disabled={busy !== null} onChange={(v) => void quickToggle(def.command, v, def.label)} />
</div>
)
})}
</div>
</Card>
</div>
)
}
@@ -0,0 +1,336 @@
import { useMemo, useState } from 'react'
import { Gauge, LayoutGrid, MonitorCog, Plus, Proportions, Star, Trash2, X } from 'lucide-react'
import { RESOLUTION_PRESETS, REFRESH_RATE_PRESETS, aspectRatioLabel } from '@shared/presets'
import { ArrangementMap } from '@renderer/components/ArrangementMap'
import { Card, Field, Modal, NumberField, Toggle } from '@renderer/components/ui'
import { useSettings } from '@renderer/stores/settings'
import { useUi } from '@renderer/stores/ui'
const STAGE_COLORS = ['#36c98e', '#58a6ff', '#f0b34c', '#e96bb0', '#9b7bff', '#4dd4d4', '#f0566a', '#a3d65c']
function formatRate(rate: number): string {
return Number.isInteger(rate) ? String(rate) : rate.toFixed(2).replace(/0$/, '')
}
export function DisplaysPage(): React.JSX.Element {
const draft = useSettings((s) => s.draft)
const patch = useSettings((s) => s.patch)
const toast = useUi((s) => s.toast)
const [newGlobalRate, setNewGlobalRate] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [customW, setCustomW] = useState(1920)
const [customH, setCustomH] = useState(1080)
const existing = useMemo(() => new Set(draft.resolutions.map((r) => `${r.width}x${r.height}`)), [draft.resolutions])
const addResolution = (width: number, height: number): void => {
if (width < 320 || height < 240 || width > 10240 || height > 4320) {
toast('warning', 'Resolution out of range', 'Supported range is 320×240 to 10240×4320.')
return
}
if (existing.has(`${width}x${height}`)) {
toast('info', 'Already configured', `${width}×${height} is already in the list.`)
return
}
patch((d) => {
d.resolutions.push({ width, height, refreshRates: [60] })
d.resolutions.sort((a, b) => b.width * b.height - a.width * a.height)
})
}
const addGlobalRate = (): void => {
const rate = Number(newGlobalRate)
if (!Number.isFinite(rate) || rate < 1 || rate > 1000) return
if (draft.global.refreshRates.includes(rate)) return
patch((d) => {
d.global.refreshRates.push(rate)
d.global.refreshRates.sort((a, b) => a - b)
})
setNewGlobalRate('')
}
const sizeComparison = useMemo(() => {
const maxW = Math.max(...draft.resolutions.map((r) => r.width), 1)
const maxH = Math.max(...draft.resolutions.map((r) => r.height), 1)
const scale = Math.min(420 / maxW, 190 / maxH)
return draft.resolutions.map((r, i) => ({
...r,
px: Math.round(r.width * scale),
py: Math.round(r.height * scale),
color: STAGE_COLORS[i % STAGE_COLORS.length]
}))
}, [draft.resolutions])
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">Displays</h1>
<p className="page-subtitle">Resolutions and refresh rates offered by every virtual monitor</p>
</div>
</div>
<ArrangementMap />
<Card
icon={Gauge}
title="Global refresh rates"
subtitle="Applied to every resolution in addition to its own rates"
>
<div className="row wrap" style={{ marginBottom: 12 }}>
{draft.global.refreshRates.map((rate) => (
<span key={rate} className="chip on">
{formatRate(rate)} Hz
<button
type="button"
className="chip-x"
style={{ background: 'none', border: 'none', color: 'inherit' }}
aria-label={`Remove ${rate} Hz`}
onClick={() => patch((d) => (d.global.refreshRates = d.global.refreshRates.filter((r) => r !== rate)))}
>
<X size={12} />
</button>
</span>
))}
{draft.global.refreshRates.length === 0 && <span className="faint">No global rates - each resolution uses only its own.</span>}
</div>
<div className="row wrap">
{REFRESH_RATE_PRESETS.filter((r) => !draft.global.refreshRates.includes(r)).map((rate) => (
<button
key={rate}
type="button"
className="chip selectable"
style={{ background: 'transparent', font: 'inherit' }}
onClick={() =>
patch((d) => {
d.global.refreshRates.push(rate)
d.global.refreshRates.sort((a, b) => a - b)
})
}
>
<Plus size={11} /> {formatRate(rate)}
</button>
))}
<div className="row" style={{ gap: 6 }}>
<input
type="number"
placeholder="Custom Hz"
value={newGlobalRate}
min={1}
max={1000}
style={{ width: 110 }}
onChange={(e) => setNewGlobalRate(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addGlobalRate()}
/>
<button type="button" className="btn small" onClick={addGlobalRate}>
Add
</button>
</div>
</div>
</Card>
<Card
icon={MonitorCog}
title="Resolutions"
subtitle={`${draft.resolutions.length} mode profile${draft.resolutions.length === 1 ? '' : 's'} exposed to Windows`}
actions={
<button type="button" className="btn primary small" onClick={() => setAddOpen(true)}>
<Plus size={13} /> Add resolution
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{draft.resolutions.map((res, index) => (
<ResolutionRow key={`${res.width}x${res.height}`} index={index} />
))}
{draft.resolutions.length === 0 && (
<div className="banner warn">
<LayoutGrid size={15} />
At least one resolution is required for the driver to expose display modes.
</div>
)}
</div>
</Card>
<div className="card-grid two">
<Card icon={Star} title="Preferred mode" subtitle="The default mode Windows picks for new virtual displays">
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Toggle
checked={draft.autoResolutions.preferredMode.useEdidPreferred}
onChange={(v) => patch((d) => (d.autoResolutions.preferredMode.useEdidPreferred = v))}
label="Use EDID preferred mode"
sublabel="Requires EDID integration with a monitor profile"
/>
<div className="grid-3">
<NumberField
label="Fallback width"
value={draft.autoResolutions.preferredMode.fallbackWidth}
min={320}
max={10240}
onChange={(v) => patch((d) => (d.autoResolutions.preferredMode.fallbackWidth = v))}
/>
<NumberField
label="Fallback height"
value={draft.autoResolutions.preferredMode.fallbackHeight}
min={240}
max={4320}
onChange={(v) => patch((d) => (d.autoResolutions.preferredMode.fallbackHeight = v))}
/>
<NumberField
label="Refresh (Hz)"
value={draft.autoResolutions.preferredMode.fallbackRefresh}
min={1}
max={1000}
onChange={(v) => patch((d) => (d.autoResolutions.preferredMode.fallbackRefresh = v))}
/>
</div>
</div>
</Card>
<Card icon={Proportions} title="Size comparison" subtitle="Configured resolutions to scale">
<div className="size-compare">
{sizeComparison.map((r) => (
<div
key={`${r.width}x${r.height}`}
className="sc-rect"
style={{
width: r.px,
height: r.py,
borderColor: r.color,
background: `color-mix(in srgb, ${r.color} 7%, transparent)`,
zIndex: 10 - sizeComparison.indexOf(r)
}}
>
<span className="sc-tag" style={{ color: r.color }}>
{r.width}×{r.height}
</span>
</div>
))}
</div>
</Card>
</div>
<Modal
open={addOpen}
title="Add resolution"
icon={MonitorCog}
onClose={() => setAddOpen(false)}
footer={
<>
<button type="button" className="btn ghost" onClick={() => setAddOpen(false)}>
Done
</button>
</>
}
wide
>
<div className="row" style={{ gap: 10, marginBottom: 18, alignItems: 'flex-end' }}>
<Field label="Width">
<input type="number" value={customW} min={320} max={10240} style={{ width: 120 }} onChange={(e) => setCustomW(Number(e.target.value))} />
</Field>
<span style={{ paddingBottom: 8, color: 'var(--text-faint)' }}>×</span>
<Field label="Height">
<input type="number" value={customH} min={240} max={4320} style={{ width: 120 }} onChange={(e) => setCustomH(Number(e.target.value))} />
</Field>
<button type="button" className="btn primary" onClick={() => addResolution(Math.floor(customW), Math.floor(customH))}>
<Plus size={14} /> Add custom
</button>
</div>
{(['HD', 'QHD', '4K & Beyond', 'Ultrawide', 'Standard', 'Portable & Tablet'] as const).map((category) => (
<div key={category} style={{ marginBottom: 16 }}>
<div className="nav-section" style={{ margin: '0 0 8px' }}>
{category}
</div>
<div className="preset-grid">
{RESOLUTION_PRESETS.filter((p) => p.category === category).map((preset) => (
<button
key={`${preset.width}x${preset.height}`}
type="button"
className="preset-card"
disabled={existing.has(`${preset.width}x${preset.height}`)}
onClick={() => addResolution(preset.width, preset.height)}
>
<div className="p-dim">
{preset.width}×{preset.height}
</div>
<div className="p-label">
{preset.label} · {aspectRatioLabel(preset.width, preset.height)}
</div>
</button>
))}
</div>
</div>
))}
</Modal>
</div>
)
}
function ResolutionRow(props: { index: number }): React.JSX.Element | null {
const res = useSettings((s) => s.draft.resolutions[props.index])
const patch = useSettings((s) => s.patch)
const [newRate, setNewRate] = useState('')
if (!res) return null
const addRate = (): void => {
const rate = Number(newRate)
if (!Number.isFinite(rate) || rate < 1 || rate > 1000 || res.refreshRates.includes(rate)) return
patch((d) => {
d.resolutions[props.index].refreshRates.push(rate)
d.resolutions[props.index].refreshRates.sort((a, b) => a - b)
})
setNewRate('')
}
return (
<div className="res-row">
<span className="res-dim">
{res.width}×{res.height}
</span>
<span className="res-aspect">{aspectRatioLabel(res.width, res.height)}</span>
<div className="res-rates">
{res.refreshRates.map((rate) => (
<span key={rate} className="chip">
{formatRate(rate)} Hz
<button
type="button"
className="chip-x"
style={{ background: 'none', border: 'none', color: 'inherit' }}
aria-label={`Remove ${rate} Hz`}
onClick={() =>
patch((d) => {
const target = d.resolutions[props.index]
if (target.refreshRates.length > 1) {
target.refreshRates = target.refreshRates.filter((r) => r !== rate)
}
})
}
>
<X size={11} />
</button>
</span>
))}
<input
type="number"
placeholder="+Hz"
value={newRate}
style={{ width: 64, padding: '3px 8px', fontSize: 12 }}
onChange={(e) => setNewRate(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addRate()}
onBlur={() => newRate && addRate()}
/>
</div>
<button
type="button"
className="btn ghost small icon-only"
aria-label="Remove resolution"
onClick={() => patch((d) => d.resolutions.splice(props.index, 1))}
>
<Trash2 size={14} />
</button>
</div>
)
}
@@ -0,0 +1,336 @@
import { useRef, useState } from 'react'
import {
BadgeCheck,
BadgeX,
FileDown,
FileUp,
Fingerprint,
FolderOpen,
MonitorCheck,
ScanEye,
ShieldCheck,
Workflow
} from 'lucide-react'
import { generateMonitorProfileXml, parseEdid } from '@shared/edid'
import type { ParsedEdid } from '@shared/types'
import { CieDiagram } from '@renderer/components/CieDiagram'
import { Card, Field, Toggle } from '@renderer/components/ui'
import { useDriver } from '@renderer/stores/driver'
import { useSettings } from '@renderer/stores/settings'
import { useUi } from '@renderer/stores/ui'
const SOURCE_LABEL: Record<string, string> = {
detailed: 'Detailed (DTD)',
'cea-vic': 'CEA-861 VIC',
standard: 'Standard',
established: 'Established'
}
export function EdidPage(): React.JSX.Element {
const [edid, setEdid] = useState<ParsedEdid | null>(null)
const [fileName, setFileName] = useState('')
const [bytes, setBytes] = useState<Uint8Array | null>(null)
const [dragOver, setDragOver] = useState(false)
const [exporting, setExporting] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const draft = useSettings((s) => s.draft)
const patch = useSettings((s) => s.patch)
const busy = useDriver((s) => s.busy)
const quickToggle = useDriver((s) => s.quickToggle)
const toast = useUi((s) => s.toast)
const loadFile = async (file: File): Promise<void> => {
if (file.size > 4096) {
toast('warning', 'Not an EDID file', 'EDID blobs are at most a few hundred bytes.')
return
}
const buffer = new Uint8Array(await file.arrayBuffer())
const parsed = parseEdid(buffer)
setBytes(buffer)
setEdid(parsed)
setFileName(file.name)
if (!parsed.valid && parsed.errors.length > 0) {
toast('error', 'EDID parse failed', parsed.errors[0])
} else {
toast('success', 'EDID decoded', `${parsed.timings.length} display modes found`)
}
}
const exportProfile = async (): Promise<void> => {
if (!edid || !bytes) return
setExporting(true)
try {
const xml = generateMonitorProfileXml(edid)
const result = await window.vdd.settings.saveMonitorProfile(xml, bytes)
if (result.ok) {
patch((d) => {
d.edidIntegration.enabled = true
d.edidIntegration.autoConfigureFromEdid = true
})
toast('success', 'Monitor profile exported', 'monitor_profile.xml + user_edid.bin written. EDID integration enabled in the draft - save to persist.')
} else {
toast('error', 'Export failed', result.error)
}
} finally {
setExporting(false)
}
}
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">EDID Lab</h1>
<p className="page-subtitle">Decode real monitor EDIDs and teach your virtual displays to impersonate them</p>
</div>
</div>
<Card icon={ScanEye} title="EDID decoder" subtitle="Drop a .bin / .edid / .dat blob captured from a physical monitor">
<input
ref={fileInput}
type="file"
accept=".bin,.edid,.dat,.raw"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void loadFile(file)
e.target.value = ''
}}
/>
<div
className={`dropzone ${dragOver ? 'over' : ''}`}
role="button"
tabIndex={0}
onClick={() => fileInput.current?.click()}
onKeyDown={(e) => e.key === 'Enter' && fileInput.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) void loadFile(file)
}}
>
<FileUp size={28} strokeWidth={1.6} />
<div style={{ fontWeight: 600 }}>{fileName || 'Drop an EDID binary here'}</div>
<div className="faint" style={{ fontSize: 12 }}>
{fileName ? 'Drop another file to replace' : 'or click to browse - 128/256/512 byte blobs supported'}
</div>
</div>
</Card>
{edid && (
<>
<div className="card-grid two">
<Card icon={Fingerprint} title="Identity" subtitle={fileName}>
<table className="data-table">
<tbody>
<tr>
<td>Monitor name</td>
<td>{edid.displayName ?? '—'}</td>
</tr>
<tr>
<td>Manufacturer</td>
<td className="mono">
{edid.manufacturerId} · product {edid.productCode.toString(16).toUpperCase().padStart(4, '0')}h
</td>
</tr>
<tr>
<td>Serial</td>
<td className="mono">{edid.serialString ?? edid.serialNumber}</td>
</tr>
<tr>
<td>Manufactured</td>
<td>
{edid.manufactureYear}
{edid.manufactureWeek > 0 && edid.manufactureWeek <= 54 ? `, week ${edid.manufactureWeek}` : ''}
</td>
</tr>
<tr>
<td>EDID version</td>
<td>
{edid.edidVersion} · {edid.extensionCount} extension block{edid.extensionCount === 1 ? '' : 's'}
</td>
</tr>
<tr>
<td>Interface</td>
<td>
{edid.digital ? `Digital (${edid.videoInterface ?? 'unknown'})` : 'Analog'}
{edid.bitDepth ? ` · ${edid.bitDepth}-bit` : ''}
</td>
</tr>
{edid.screenWidthCm && edid.screenHeightCm && (
<tr>
<td>Physical size</td>
<td>
{edid.screenWidthCm}×{edid.screenHeightCm} cm (
{(Math.hypot(edid.screenWidthCm, edid.screenHeightCm) / 2.54).toFixed(1)}″)
</td>
</tr>
)}
<tr>
<td>Gamma</td>
<td>{edid.gamma?.toFixed(2) ?? '—'}</td>
</tr>
<tr>
<td>Checksum</td>
<td>
{edid.checksumOk ? (
<span className="badge ok">
<BadgeCheck size={11} /> valid
</span>
) : (
<span className="badge err">
<BadgeX size={11} /> invalid
</span>
)}
</td>
</tr>
</tbody>
</table>
<div className="row wrap" style={{ marginTop: 12, gap: 7 }}>
{edid.hasCeaExtension && <span className="badge neutral">CEA-861</span>}
{edid.ceaBasicAudio && <span className="badge neutral">Audio</span>}
{edid.ceaYcbcr444 && <span className="badge neutral">YCbCr 4:4:4</span>}
{edid.ceaYcbcr422 && <span className="badge neutral">YCbCr 4:2:2</span>}
{edid.hdr?.eotfPq && <span className="badge ok">HDR10 (PQ)</span>}
{edid.hdr?.eotfHlg && <span className="badge ok">HLG</span>}
{edid.hdr?.maxLuminance && <span className="badge neutral">{edid.hdr.maxLuminance} nits peak</span>}
</div>
</Card>
<Card icon={Workflow} title="Measured gamut" subtitle="Chromaticity coordinates embedded in the EDID">
{edid.chromaticity ? (
<CieDiagram value={edid.chromaticity} onChange={() => undefined} disabled width={320} />
) : (
<span className="faint">No chromaticity data</span>
)}
</Card>
</div>
<Card
icon={MonitorCheck}
title={`Display modes (${edid.timings.length})`}
subtitle={edid.preferred ? `Preferred: ${edid.preferred.width}×${edid.preferred.height} @ ${edid.preferred.refreshHz.toFixed(2)} Hz` : undefined}
actions={
<button type="button" className="btn primary small" disabled={exporting} onClick={() => void exportProfile()}>
<FileDown size={13} />
Export profile + EDID
</button>
}
>
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
<table className="data-table">
<thead>
<tr>
<th>Resolution</th>
<th>Refresh</th>
<th>Source</th>
<th>Pixel clock</th>
<th></th>
</tr>
</thead>
<tbody>
{edid.timings.map((t, i) => (
<tr key={i}>
<td className="mono">
{t.width}×{t.height}
{t.interlaced ? 'i' : ''}
</td>
<td className="mono">{t.refreshHz.toFixed(t.refreshHz % 1 === 0 ? 0 : 3)} Hz</td>
<td>
{SOURCE_LABEL[t.source]}
{t.vic ? ` ${t.vic}` : ''}
</td>
<td className="mono">{t.pixelClockMHz ? `${t.pixelClockMHz.toFixed(2)} MHz` : '—'}</td>
<td>{t === edid.preferred && <span className="badge ok">preferred</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</>
)}
<div className="card-grid two">
<Card
icon={ShieldCheck}
title="EDID identity controls"
subtitle="Applied live through the driver pipe when online"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<Toggle
checked={draft.edid.customEdid}
disabled={busy !== null}
onChange={(v) => void quickToggle('CUSTOMEDID', v, 'Custom EDID')}
label="Use custom EDID"
sublabel="Serve user_edid.bin to Windows instead of the built-in identity"
/>
<Toggle
checked={draft.edid.preventSpoof}
disabled={busy !== null}
onChange={(v) => void quickToggle('PREVENTSPOOF', v, 'Prevent spoof')}
label="Prevent manufacturer spoofing"
sublabel="Keep the original manufacturer ID in the served EDID"
/>
<Toggle
checked={draft.edid.edidCeaOverride}
disabled={busy !== null}
onChange={(v) => void quickToggle('CEAOVERRIDE', v, 'CEA override')}
label="CEA extension override"
sublabel="Replace the CEA-861 block with driver-generated data"
/>
</div>
</Card>
<Card
icon={FolderOpen}
title="EDID integration"
subtitle="Auto-configure the driver from monitor_profile.xml"
actions={
<button type="button" className="btn ghost small" onClick={() => void window.vdd.system.openPath('edid')}>
<FolderOpen size={13} /> Open folder
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<Toggle
checked={draft.edidIntegration.enabled}
onChange={(v) => patch((d) => (d.edidIntegration.enabled = v))}
label="Enable EDID integration"
/>
<Toggle
checked={draft.edidIntegration.autoConfigureFromEdid}
onChange={(v) => patch((d) => (d.edidIntegration.autoConfigureFromEdid = v))}
label="Auto-configure from profile"
sublabel="Resolutions, color and HDR come from the profile"
/>
<Toggle
checked={draft.edidIntegration.overrideManualSettings}
onChange={(v) => patch((d) => (d.edidIntegration.overrideManualSettings = v))}
label="Profile overrides manual settings"
/>
<Toggle
checked={draft.edidIntegration.fallbackOnError}
onChange={(v) => patch((d) => (d.edidIntegration.fallbackOnError = v))}
label="Fall back to manual settings on error"
/>
<Field label="Profile path" hint="Relative to the driver folder">
<input
type="text"
value={draft.edidIntegration.edidProfilePath}
onChange={(e) => patch((d) => (d.edidIntegration.edidProfilePath = e.target.value))}
/>
</Field>
</div>
</Card>
</div>
</div>
)
}
@@ -0,0 +1,149 @@
import { useEffect } from 'react'
import { Cpu, Gpu, Loader2, RefreshCw, Zap } from 'lucide-react'
import { Card } from '@renderer/components/ui'
import { useDriver } from '@renderer/stores/driver'
import { useSettings } from '@renderer/stores/settings'
import { useUi } from '@renderer/stores/ui'
export function GpuPage(): React.JSX.Element {
const gpus = useDriver((s) => s.gpus)
const gpusLoading = useDriver((s) => s.gpusLoading)
const refreshGpus = useDriver((s) => s.refreshGpus)
const assignGpu = useDriver((s) => s.assignGpu)
const busy = useDriver((s) => s.busy)
const online = useDriver((s) => s.status?.pipeConnected === true)
const assignedName = useSettings((s) => s.draft.gpu.friendlyName)
const setPage = useUi((s) => s.setPage)
useEffect(() => {
if (gpus.length === 0) void refreshGpus()
}, [gpus.length, refreshGpus])
const isDefault = assignedName.trim().toLowerCase() === 'default'
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">GPU</h1>
<p className="page-subtitle">Choose which adapter renders your virtual displays</p>
</div>
<button type="button" className="btn small" disabled={gpusLoading} onClick={() => void refreshGpus()}>
{gpusLoading ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Rescan
</button>
</div>
{!online && (
<div className="banner info">
<Cpu size={15} />
Driver offline - adapters listed from Windows (WMI). Assignments are staged into the draft configuration.
</div>
)}
<Card
icon={Gpu}
title="Available adapters"
subtitle={online ? 'Enumerated by the driver via GETALLGPUS' : 'Enumerated via WMI'}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className={`gpu-card ${isDefault ? 'assigned' : ''}`}>
<span className="gpu-icon">
<Zap size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 620 }}>System default</div>
<div className="faint" style={{ fontSize: 12 }}>
Let Windows pick the adapter (recommended for single-GPU systems)
</div>
</div>
{isDefault ? (
<span className="badge ok">assigned</span>
) : (
<button type="button" className="btn small" disabled={busy !== null} onClick={() => void assignGpu('default')}>
Assign
</button>
)}
</div>
{gpus.map((gpu) => {
const assigned = !isDefault && (gpu.assigned || gpu.name.toLowerCase() === assignedName.toLowerCase())
return (
<div key={gpu.name} className={`gpu-card ${assigned ? 'assigned' : ''}`}>
<span className="gpu-icon">
<Gpu size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 620, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{gpu.name}</div>
<div className="faint" style={{ fontSize: 12 }}>
{gpu.source === 'pipe' ? 'Reported by driver' : 'Reported by Windows'}
{gpu.driverVersion ? ` · driver ${gpu.driverVersion}` : ''}
{gpu.vramMB ? ` · ${(gpu.vramMB / 1024).toFixed(1)} GB VRAM` : ''}
</div>
</div>
{assigned ? (
<span className="badge ok">assigned</span>
) : (
<button type="button" className="btn small" disabled={busy !== null} onClick={() => void assignGpu(gpu.name)}>
Assign
</button>
)}
</div>
)
})}
{gpus.length === 0 && !gpusLoading && <span className="faint">No adapters found.</span>}
</div>
</Card>
<Card icon={Cpu} title="Diagnostics" subtitle="Query the driver directly - results stream to the console">
<div className="row wrap">
<button
type="button"
className="btn small"
disabled={!online}
onClick={() => {
void window.vdd.pipe.query('D3DDEVICEGPU')
setPage('console')
}}
>
D3D device info
</button>
<button
type="button"
className="btn small"
disabled={!online}
onClick={() => {
void window.vdd.pipe.query('GETASSIGNEDGPU')
setPage('console')
}}
>
Assigned GPU
</button>
<button
type="button"
className="btn small"
disabled={!online}
onClick={() => {
void window.vdd.pipe.query('IDDCXVERSION')
setPage('console')
}}
>
IddCx version
</button>
<button
type="button"
className="btn small"
disabled={!online}
onClick={() => {
void window.vdd.pipe.query('GETALLGPUS')
setPage('console')
}}
>
All GPUs
</button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,400 @@
import { useCallback, useEffect, useState } from 'react'
import {
Archive,
FileCode2,
FileText,
FolderOpen,
History,
MousePointer2,
Paintbrush,
RotateCcw,
Wand2
} from 'lucide-react'
import { DEFAULT_BASE_DIR, type BackupInfo } from '@shared/types'
import { Card, Field, Modal, NumberField, Segmented, Toggle } from '@renderer/components/ui'
import { useSettings } from '@renderer/stores/settings'
import { useUi } from '@renderer/stores/ui'
import { compactDiff, diffLines } from '@renderer/utils/diff'
const ACCENT_PRESETS = ['#4cc2ff', '#36c98e', '#9b7bff', '#f0a04c', '#e96bb0', '#f0566a', '#4dd4d4']
export function SettingsPage(): React.JSX.Element {
const draft = useSettings((s) => s.draft)
const dirty = useSettings((s) => s.dirty)
const patch = useSettings((s) => s.patch)
const load = useSettings((s) => s.load)
const prefs = useUi((s) => s.prefs)
const updatePrefs = useUi((s) => s.updatePrefs)
const toast = useUi((s) => s.toast)
const [xmlOpen, setXmlOpen] = useState(false)
const [diff, setDiff] = useState<ReturnType<typeof compactDiff> | null>(null)
const [rawXml, setRawXml] = useState('')
const [backups, setBackups] = useState<BackupInfo[]>([])
const refreshBackups = useCallback(async () => {
setBackups(await window.vdd.settings.backups())
}, [])
useEffect(() => {
void refreshBackups()
}, [refreshBackups])
const openXmlPreview = async (): Promise<void> => {
const [current, preview] = await Promise.all([window.vdd.settings.raw(), window.vdd.settings.preview(draft)])
setRawXml(preview)
setDiff(dirty || current === null ? compactDiff(diffLines(current ?? '', preview)) : null)
setXmlOpen(true)
}
const restoreBackup = async (backup: BackupInfo): Promise<void> => {
const result = await window.vdd.settings.restore(backup.fileName)
if (result.ok) {
await load()
await refreshBackups()
toast('success', 'Backup restored', `${backup.fileName} is now the active configuration. Apply to reload the driver.`)
} else {
toast('error', 'Restore failed', result.error)
}
}
return (
<div className="page-inner">
<div className="page-header">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-subtitle">Driver behavior, configuration file management and app preferences</p>
</div>
</div>
<div className="card-grid two">
<Card icon={MousePointer2} title="Cursor">
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<Toggle
checked={draft.cursor.hardwareCursor}
onChange={(v) => patch((d) => (d.cursor.hardwareCursor = v))}
label="Hardware cursor"
sublabel="Composite the cursor on the GPU (recommended)"
/>
<Toggle
checked={draft.cursor.alphaCursorSupport}
onChange={(v) => patch((d) => (d.cursor.alphaCursorSupport = v))}
label="Alpha-blended cursor"
/>
<div className="grid-2">
<NumberField
label="Max cursor width"
value={draft.cursor.cursorMaxX}
min={16}
max={512}
onChange={(v) => patch((d) => (d.cursor.cursorMaxX = v))}
/>
<NumberField
label="Max cursor height"
value={draft.cursor.cursorMaxY}
min={16}
max={512}
onChange={(v) => patch((d) => (d.cursor.cursorMaxY = v))}
/>
</div>
<Field label="XOR cursor support level" hint="Known upstream quirk: loaded by the driver but not applied">
<Segmented
value={String(draft.cursor.xorCursorSupportLevel) as '0' | '1' | '2'}
options={[
{ value: '0', label: 'None' },
{ value: '1', label: 'Emulated' },
{ value: '2', label: 'Full' }
]}
onChange={(v) => patch((d) => (d.cursor.xorCursorSupportLevel = Number(v)))}
/>
</Field>
</div>
</Card>
<Card icon={FileText} title="Logging">
<div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
<Toggle
checked={draft.logging.sendLogsThroughPipe}
onChange={(v) => patch((d) => (d.logging.sendLogsThroughPipe = v))}
label="Stream logs through pipe"
sublabel="Lets this app capture live responses from the driver"
/>
<Toggle
checked={draft.logging.logging}
onChange={(v) => patch((d) => (d.logging.logging = v))}
label="File logging"
sublabel="Daily log files in the Logs folder"
/>
<Toggle
checked={draft.logging.debugLogging}
onChange={(v) => patch((d) => (d.logging.debugLogging = v))}
label="Debug logging"
sublabel="Warning: verbose - creates large files quickly"
/>
</div>
</Card>
</div>
<Card
icon={Wand2}
title="Auto resolutions"
subtitle="Generate display modes from the EDID profile instead of the manual list"
actions={<Toggle checked={draft.autoResolutions.enabled} onChange={(v) => patch((d) => (d.autoResolutions.enabled = v))} />}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 15,
opacity: draft.autoResolutions.enabled ? 1 : 0.45,
pointerEvents: draft.autoResolutions.enabled ? 'auto' : 'none'
}}
>
<Field label="Source priority">
<Segmented
value={draft.autoResolutions.sourcePriority as 'manual' | 'edid' | 'combined'}
options={[
{ value: 'manual', label: 'Manual list' },
{ value: 'edid', label: 'EDID modes' },
{ value: 'combined', label: 'Combined' }
]}
onChange={(v) => patch((d) => (d.autoResolutions.sourcePriority = v))}
/>
</Field>
<div className="grid-4">
<NumberField
label="Min refresh (Hz)"
value={draft.autoResolutions.edidModeFiltering.minRefreshRate}
min={1}
max={1000}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.minRefreshRate = v))}
/>
<NumberField
label="Max refresh (Hz)"
value={draft.autoResolutions.edidModeFiltering.maxRefreshRate}
min={1}
max={1000}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.maxRefreshRate = v))}
/>
<NumberField
label="Min width"
value={draft.autoResolutions.edidModeFiltering.minResolutionWidth}
min={160}
max={10240}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.minResolutionWidth = v))}
/>
<NumberField
label="Max width"
value={draft.autoResolutions.edidModeFiltering.maxResolutionWidth}
min={160}
max={10240}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.maxResolutionWidth = v))}
/>
<NumberField
label="Min height"
value={draft.autoResolutions.edidModeFiltering.minResolutionHeight}
min={120}
max={4320}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.minResolutionHeight = v))}
/>
<NumberField
label="Max height"
value={draft.autoResolutions.edidModeFiltering.maxResolutionHeight}
min={120}
max={4320}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.maxResolutionHeight = v))}
/>
</div>
<Toggle
checked={draft.autoResolutions.edidModeFiltering.excludeFractionalRates}
onChange={(v) => patch((d) => (d.autoResolutions.edidModeFiltering.excludeFractionalRates = v))}
label="Exclude fractional refresh rates"
sublabel="Drop 59.94-style NTSC rates from generated modes"
/>
</div>
</Card>
<Card
icon={FileCode2}
title="Configuration file"
subtitle={`${prefs.baseDir}\\vdd_settings.xml`}
actions={
<>
<button type="button" className="btn ghost small" onClick={() => void window.vdd.system.openPath('base')}>
<FolderOpen size={13} /> Open folder
</button>
<button type="button" className="btn small" onClick={() => void openXmlPreview()}>
<FileCode2 size={13} /> {dirty ? 'Preview changes' : 'View XML'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="row" style={{ gap: 8 }}>
<History size={14} style={{ color: 'var(--text-faint)' }} />
<span className="muted" style={{ fontWeight: 580 }}>
Backups ({backups.length})
</span>
<span className="faint" style={{ fontSize: 12 }}>
created automatically before every save
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7, maxHeight: 220, overflowY: 'auto' }}>
{backups.map((backup) => (
<div key={backup.fileName} className="backup-row">
<Archive size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span className="mono" style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{backup.fileName}
</span>
<span className="faint">{new Date(backup.createdAt).toLocaleString()}</span>
<button type="button" className="btn ghost small" onClick={() => void restoreBackup(backup)}>
<RotateCcw size={12} /> Restore
</button>
</div>
))}
{backups.length === 0 && <span className="faint">No backups yet.</span>}
</div>
</div>
</Card>
<Card icon={Paintbrush} title="App preferences">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Field label="Theme">
<Segmented
value={prefs.theme}
options={[
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
{ value: 'system', label: 'System' }
]}
onChange={(v) => void updatePrefs({ theme: v })}
/>
</Field>
<Field label="Accent color">
<div className="row wrap" style={{ gap: 8 }}>
{ACCENT_PRESETS.map((color) => (
<button
key={color}
type="button"
aria-label={`Accent ${color}`}
style={{
width: 26,
height: 26,
borderRadius: '50%',
background: color,
border: prefs.accent === color ? '2px solid var(--text)' : '2px solid transparent',
boxShadow: prefs.accent === color ? `0 0 12px ${color}` : 'none'
}}
onClick={() => void updatePrefs({ accent: color })}
/>
))}
<input type="color" value={prefs.accent} onChange={(e) => void updatePrefs({ accent: e.target.value })} />
</div>
</Field>
<DriverFolderField />
</div>
</Card>
<Modal
open={xmlOpen}
title={diff ? 'Pending changes to vdd_settings.xml' : 'vdd_settings.xml'}
icon={FileCode2}
onClose={() => setXmlOpen(false)}
wide
footer={
<button type="button" className="btn ghost" onClick={() => setXmlOpen(false)}>
Close
</button>
}
>
{diff ? (
<div className="diff-view" style={{ maxHeight: '56vh' }}>
{diff.map((line, idx) =>
'count' in line ? (
<div key={idx} className="diff-line ctx" style={{ opacity: 0.5, fontStyle: 'italic' }}>
<span className="gutter">⋯</span>
<span> {line.count} unchanged lines</span>
</div>
) : (
<div key={idx} className={`diff-line ${line.type}`}>
<span className="gutter">{line.type === 'add' ? '+' : line.type === 'del' ? '−' : ''}</span>
<span>{line.text || ' '}</span>
</div>
)
)}
</div>
) : (
<pre
className="diff-view selectable-text"
style={{ maxHeight: '56vh', padding: 14, whiteSpace: 'pre-wrap' }}
>
{rawXml}
</pre>
)}
</Modal>
</div>
)
}
/**
* Driver folder control. The app keeps the VDDPATH registry value (which the
* driver reads its settings path from) and this preference in lockstep:
* changes are written to the registry first and only applied once verified.
*/
function DriverFolderField(): React.JSX.Element {
const prefs = useUi((s) => s.prefs)
const setBaseDir = useUi((s) => s.setBaseDir)
const toast = useUi((s) => s.toast)
const load = useSettings((s) => s.load)
const [value, setValue] = useState(prefs.baseDir)
const [applying, setApplying] = useState(false)
// Follow external changes (registry sync at startup, reset, etc.).
useEffect(() => setValue(prefs.baseDir), [prefs.baseDir])
const isDefault = prefs.baseDir.toLowerCase() === DEFAULT_BASE_DIR.toLowerCase()
const edited = value.trim().length > 0 && value.trim().toLowerCase() !== prefs.baseDir.toLowerCase()
const apply = async (target: string): Promise<void> => {
setApplying(true)
try {
const result = await setBaseDir(target)
if (result.ok) {
await load()
toast('success', 'Driver folder updated', `Registry and app now point at ${result.prefs.baseDir}. Restart the driver device to apply.`)
} else {
toast('error', 'Folder not changed', result.error)
}
} finally {
setApplying(false)
}
}
return (
<Field
label="Driver folder"
hint={`The driver reads vdd_settings.xml from this folder (registry VDDPATH, default ${DEFAULT_BASE_DIR}). The app keeps both in sync automatically - changing it may prompt for elevation. Existing files are not moved.`}
>
<div className="row" style={{ gap: 8 }}>
<input
type="text"
value={value}
className="mono"
style={{ flex: 1 }}
disabled={applying}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && edited && void apply(value.trim())}
/>
<button type="button" className="btn small primary" disabled={!edited || applying} onClick={() => void apply(value.trim())}>
{applying ? 'Applying…' : 'Apply'}
</button>
{!isDefault && (
<button type="button" className="btn ghost small" disabled={applying} onClick={() => void apply(DEFAULT_BASE_DIR)}>
Reset to default
</button>
)}
</div>
</Field>
)
}
@@ -0,0 +1,220 @@
import { create } from 'zustand'
import { SYSTEM_AUDIO_SOURCE, type AudioEndpoint, type AudioRoute } from '@shared/types'
import { audioRouter } from '@renderer/utils/audio-router'
import { useUi } from './ui'
export interface WebAudioDevice {
deviceId: string
label: string
}
interface AudioState {
endpoints: AudioEndpoint[]
endpointsLoading: boolean
endpointsError: string | null
webInputs: WebAudioDevice[]
webOutputs: WebAudioDevice[]
routes: AudioRoute[]
/** Route ids currently pumping audio. */
activeRoutes: Set<string>
routeErrors: Record<string, string>
init: () => Promise<void>
refreshEndpoints: () => Promise<void>
refreshWebDevices: () => Promise<void>
setEndpointVolume: (id: string, volume: number) => void
setEndpointMute: (id: string, muted: boolean) => Promise<void>
setDefaultEndpoint: (id: string) => Promise<void>
addRoute: (sourceId: string, sourceLabel: string, sinkId: string, sinkLabel: string) => Promise<void>
removeRoute: (routeId: string) => Promise<void>
toggleRoute: (routeId: string, enabled: boolean) => Promise<void>
setRouteGain: (routeId: string, gain: number) => void
}
let initialized = false
const volumeTimers = new Map<string, number>()
function persistRoutes(routes: AudioRoute[]): void {
void useUi.getState().updatePrefs({ audioRoutes: routes })
}
async function armRoute(route: AudioRoute, set: (fn: (s: AudioState) => Partial<AudioState>) => void): Promise<void> {
try {
await audioRouter.start(route)
set((s) => {
const active = new Set(s.activeRoutes)
active.add(route.id)
const errors = { ...s.routeErrors }
delete errors[route.id]
return { activeRoutes: active, routeErrors: errors }
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
set((s) => ({ routeErrors: { ...s.routeErrors, [route.id]: message } }))
}
}
export const useAudio = create<AudioState>((set, get) => ({
endpoints: [],
endpointsLoading: false,
endpointsError: null,
webInputs: [],
webOutputs: [],
routes: [],
activeRoutes: new Set<string>(),
routeErrors: {},
init: async () => {
if (initialized) return
initialized = true
audioRouter.setOnEnded((routeId) => {
set((s) => {
const active = new Set(s.activeRoutes)
active.delete(routeId)
return {
activeRoutes: active,
routeErrors: { ...s.routeErrors, [routeId]: 'Source device stopped (unplugged or removed)' }
}
})
})
navigator.mediaDevices.addEventListener('devicechange', () => void get().refreshWebDevices())
const routes = useUi.getState().prefs.audioRoutes ?? []
set({ routes })
void get().refreshEndpoints()
await get().refreshWebDevices()
// Re-arm persisted routes.
for (const route of routes) {
if (route.enabled) void armRoute(route, set)
}
},
refreshEndpoints: async () => {
set({ endpointsLoading: true })
try {
const endpoints = await window.vdd.audio.endpoints()
set({ endpoints, endpointsError: null })
} catch (error) {
set({ endpointsError: error instanceof Error ? error.message : String(error) })
} finally {
set({ endpointsLoading: false })
}
},
refreshWebDevices: async () => {
try {
// A one-shot capture unlocks device labels for enumerateDevices.
if (get().webInputs.every((d) => !d.label)) {
try {
const probe = await navigator.mediaDevices.getUserMedia({ audio: true })
for (const track of probe.getTracks()) track.stop()
} catch {
// no mic permission/device - labels may stay generic
}
}
const devices = await navigator.mediaDevices.enumerateDevices()
const dedupe = (kind: MediaDeviceKind): WebAudioDevice[] =>
devices
.filter((d) => d.kind === kind && d.deviceId !== 'default' && d.deviceId !== 'communications')
.map((d) => ({ deviceId: d.deviceId, label: d.label || 'Unnamed device' }))
set({ webInputs: dedupe('audioinput'), webOutputs: dedupe('audiooutput') })
} catch {
// media enumeration unavailable
}
},
setEndpointVolume: (id, volume) => {
set((s) => ({ endpoints: s.endpoints.map((e) => (e.id === id ? { ...e, volume } : e)) }))
const existing = volumeTimers.get(id)
if (existing !== undefined) window.clearTimeout(existing)
volumeTimers.set(
id,
window.setTimeout(() => {
volumeTimers.delete(id)
window.vdd.audio.setVolume(id, volume).catch((error: unknown) => {
useUi.getState().toast('error', 'Volume change failed', error instanceof Error ? error.message : String(error))
})
}, 150)
)
},
setEndpointMute: async (id, muted) => {
set((s) => ({ endpoints: s.endpoints.map((e) => (e.id === id ? { ...e, muted } : e)) }))
try {
await window.vdd.audio.setMute(id, muted)
} catch (error) {
useUi.getState().toast('error', 'Mute change failed', error instanceof Error ? error.message : String(error))
await get().refreshEndpoints()
}
},
setDefaultEndpoint: async (id) => {
try {
await window.vdd.audio.setDefault(id)
await get().refreshEndpoints()
const endpoint = get().endpoints.find((e) => e.id === id)
useUi.getState().toast('success', 'Default device changed', endpoint?.name)
} catch (error) {
useUi.getState().toast('error', 'Failed to set default device', error instanceof Error ? error.message : String(error))
}
},
addRoute: async (sourceId, sourceLabel, sinkId, sinkLabel) => {
const route: AudioRoute = {
id: `route-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
sourceId,
sourceLabel,
sinkId,
sinkLabel,
gain: 1,
enabled: true
}
const routes = [...get().routes, route]
set({ routes })
persistRoutes(routes)
await armRoute(route, set)
},
removeRoute: async (routeId) => {
audioRouter.stop(routeId)
const routes = get().routes.filter((r) => r.id !== routeId)
set((s) => {
const active = new Set(s.activeRoutes)
active.delete(routeId)
const errors = { ...s.routeErrors }
delete errors[routeId]
return { routes, activeRoutes: active, routeErrors: errors }
})
persistRoutes(routes)
},
toggleRoute: async (routeId, enabled) => {
const routes = get().routes.map((r) => (r.id === routeId ? { ...r, enabled } : r))
set({ routes })
persistRoutes(routes)
const route = routes.find((r) => r.id === routeId)
if (!route) return
if (enabled) {
await armRoute(route, set)
} else {
audioRouter.stop(routeId)
set((s) => {
const active = new Set(s.activeRoutes)
active.delete(routeId)
return { activeRoutes: active }
})
}
},
setRouteGain: (routeId, gain) => {
const routes = get().routes.map((r) => (r.id === routeId ? { ...r, gain } : r))
set({ routes })
audioRouter.setGain(routeId, gain)
persistRoutes(routes)
}
}))
export { SYSTEM_AUDIO_SOURCE }
@@ -0,0 +1,205 @@
import { create } from 'zustand'
import type { DriverStatus, GpuInfo, PipeToggleCommand, SystemInfo } from '@shared/types'
import { useSettings } from './settings'
import { useUi } from './ui'
/** Maps pipe toggle commands to their vdd_settings.xml fields (for offline staging). */
const TOGGLE_PATCHES: Record<PipeToggleCommand, (draft: import('@shared/types').VddSettings, value: boolean) => void> = {
HDRPLUS: (d, v) => (d.colour.hdrPlus = v),
SDR10: (d, v) => (d.colour.sdr10bit = v),
CUSTOMEDID: (d, v) => (d.edid.customEdid = v),
PREVENTSPOOF: (d, v) => (d.edid.preventSpoof = v),
CEAOVERRIDE: (d, v) => (d.edid.edidCeaOverride = v),
HARDWARECURSOR: (d, v) => (d.cursor.hardwareCursor = v),
LOGGING: (d, v) => (d.logging.logging = v),
LOG_DEBUG: (d, v) => (d.logging.debugLogging = v)
}
interface DriverState {
status: DriverStatus | null
iddcx: string | null
sysInfo: SystemInfo | null
gpus: GpuInfo[]
gpusLoading: boolean
/** Set while a reload-triggering pipe operation is in flight. */
busy: string | null
init: () => Promise<void>
refreshStatus: (force?: boolean) => Promise<void>
refreshGpus: () => Promise<void>
/** Apply a new virtual display count via SETDISPLAYCOUNT (or stage offline). */
applyDisplayCount: (count: number) => Promise<void>
/** Toggle a driver feature live via the pipe (or stage offline). */
quickToggle: (name: PipeToggleCommand, value: boolean, label: string) => Promise<void>
assignGpu: (name: string) => Promise<void>
/** Save the draft config and trigger a driver reload to pick it up. */
saveAndApply: () => Promise<void>
}
let initialized = false
export const useDriver = create<DriverState>((set, get) => ({
status: null,
iddcx: null,
sysInfo: null,
gpus: [],
gpusLoading: false,
busy: null,
init: async () => {
if (initialized) return
initialized = true
window.vdd.events.onStatus((status) => {
const previous = get().status
set({ status })
if (previous && previous.level !== status.level) {
const toast = useUi.getState().toast
if (status.level === 'online') {
toast('success', 'Driver online', 'The virtual display driver is responding.')
// The live pipe may report a more precise IddCx version than the
// offline build-table fallback - refresh now that it answers.
void window.vdd.driver
.iddcxVersion()
.then((iddcx) => set({ iddcx }))
.catch(() => undefined)
} else if (previous.level === 'online') {
toast('warning', 'Driver went offline')
}
}
})
void get().refreshStatus()
try {
const sysInfo = await window.vdd.system.info()
set({ sysInfo })
} catch {
// ignore
}
void window.vdd.driver
.iddcxVersion()
.then((iddcx) => set({ iddcx }))
.catch(() => undefined)
},
refreshStatus: async (force = false) => {
try {
const status = await window.vdd.driver.status(force)
set({ status })
} catch {
// main not ready yet
}
},
refreshGpus: async () => {
set({ gpusLoading: true })
try {
const gpus = await window.vdd.driver.gpus()
set({ gpus })
} finally {
set({ gpusLoading: false })
}
},
applyDisplayCount: async (count) => {
const { status } = get()
const settings = useSettings.getState()
const toast = useUi.getState().toast
if (!status?.pipeConnected) {
settings.patch((d) => (d.monitors.count = count))
toast('info', 'Driver offline', 'Display count staged - it will apply when the config is saved and the driver restarts.')
return
}
set({ busy: count === 0 ? 'Removing all virtual displays…' : `Reconfiguring to ${count} display${count === 1 ? '' : 's'}…` })
try {
const result = await window.vdd.pipe.setDisplayCount(count)
if (result.ok) {
toast('success', `Display count set to ${count}`, `Driver reloaded in ${(result.durationMs / 1000).toFixed(1)}s`)
} else {
toast('error', 'Failed to set display count', result.error)
}
} finally {
set({ busy: null })
// The driver rewrote vdd_settings.xml - resync our copy and status.
await useSettings.getState().load()
await get().refreshStatus()
}
},
quickToggle: async (name, value, label) => {
const { status } = get()
const settings = useSettings.getState()
const toast = useUi.getState().toast
if (!status?.pipeConnected) {
settings.patch((d) => TOGGLE_PATCHES[name](d, value))
toast('info', `${label} staged`, 'Driver offline - save the configuration to persist this change.')
return
}
const reloads = name !== 'LOGGING' && name !== 'LOG_DEBUG'
if (reloads) set({ busy: `Applying ${label}…` })
try {
const result = await window.vdd.pipe.toggle(name, value)
if (result.ok) {
toast('success', `${label} ${value ? 'enabled' : 'disabled'}`, reloads ? 'Driver reloaded.' : undefined)
} else {
toast('error', `Failed to toggle ${label}`, result.error)
}
} finally {
if (reloads) set({ busy: null })
await useSettings.getState().load()
}
},
assignGpu: async (name) => {
const { status } = get()
const settings = useSettings.getState()
const toast = useUi.getState().toast
if (!status?.pipeConnected) {
settings.patch((d) => (d.gpu.friendlyName = name))
toast('info', 'GPU staged', 'Driver offline - save the configuration to persist this change.')
return
}
set({ busy: `Assigning ${name}…` })
try {
const result = await window.vdd.pipe.setGpu(name)
if (result.ok) toast('success', 'GPU assigned', `Virtual displays now render on ${name}.`)
else toast('error', 'Failed to assign GPU', result.error)
} finally {
set({ busy: null })
await useSettings.getState().load()
await get().refreshGpus()
}
},
saveAndApply: async () => {
const settings = useSettings.getState()
const toast = useUi.getState().toast
const count = settings.draft.monitors.count
const saved = await settings.save()
if (!saved) return
const { status } = get()
if (!status?.pipeConnected) {
toast('info', 'Saved', 'Driver offline - the new configuration loads on next driver start.')
return
}
set({ busy: 'Reloading driver with new configuration…' })
try {
const result = await window.vdd.pipe.setDisplayCount(count)
if (result.ok) {
toast('success', 'Configuration applied', `Driver reloaded in ${(result.durationMs / 1000).toFixed(1)}s`)
} else {
toast('error', 'Reload failed', result.error)
}
} finally {
set({ busy: null })
await useSettings.getState().load()
await get().refreshStatus()
}
}
}))
@@ -0,0 +1,194 @@
import { create } from 'zustand'
import type { InstallProgress, LifecycleResult, ManagedDeviceState, ManagedDriverId, ReleaseInfo } from '@shared/types'
import { useDriver } from './driver'
import { useSettings } from './settings'
import { useUi } from './ui'
export type LifecycleOp = 'install' | 'uninstall' | 'restart' | 'instances' | 'testsigning'
interface DriverLifecycleState {
latest: ReleaseInfo | null
installedTag: string | null
device: ManagedDeviceState | null
checking: boolean
checkError: string | null
}
interface InstallerState {
drivers: Record<ManagedDriverId, DriverLifecycleState>
/** Which driver+operation is in flight (lifecycle ops are globally exclusive). */
working: { driver: ManagedDriverId; op: LifecycleOp } | null
progress: InstallProgress | null
/** Windows boot-config test signing state (audio driver is test-signed). */
testSigning: boolean | null
init: () => void
checkLatest: (driver: ManagedDriverId) => Promise<void>
refreshDevice: (driver: ManagedDriverId) => Promise<void>
install: (driver: ManagedDriverId, instances?: number) => Promise<void>
uninstallDriver: (driver: ManagedDriverId) => Promise<void>
restartDevice: (driver: ManagedDriverId) => Promise<void>
setInstances: (driver: ManagedDriverId, count: number) => Promise<void>
refreshTestSigning: () => Promise<void>
setTestSigning: (enabled: boolean) => Promise<void>
}
const emptyState = (): DriverLifecycleState => ({
latest: null,
installedTag: null,
device: null,
checking: false,
checkError: null
})
let initialized = false
export const useInstaller = create<InstallerState>((set, get) => {
const patchDriver = (driver: ManagedDriverId, patch: Partial<DriverLifecycleState>): void => {
set((s) => ({ drivers: { ...s.drivers, [driver]: { ...s.drivers[driver], ...patch } } }))
}
const refreshAfter = async (driver: ManagedDriverId): Promise<void> => {
await get().refreshDevice(driver)
const installedTag = await window.vdd.installer.installedTag(driver).catch(() => null)
patchDriver(driver, { installedTag })
if (driver === 'display') {
await useDriver.getState().refreshStatus(true)
await useSettings.getState().load()
} else {
// Audio endpoints appear/disappear with the device nodes.
const { useAudio } = await import('./audio')
void useAudio.getState().refreshEndpoints()
void useAudio.getState().refreshWebDevices()
}
}
const runOp = async (
driver: ManagedDriverId,
op: LifecycleOp,
action: () => Promise<LifecycleResult>,
successTitle: string,
successMessage: string | undefined,
failTitle: string
): Promise<void> => {
if (get().working) return
const toast = useUi.getState().toast
set({ working: { driver, op }, progress: null })
try {
const result = await action()
if (result.ok) toast('success', successTitle, successMessage)
else toast('error', failTitle, result.error ?? result.detail)
} finally {
set({ working: null, progress: null })
await refreshAfter(driver)
}
}
return {
drivers: { display: emptyState(), audio: emptyState() },
working: null,
progress: null,
testSigning: null,
init: () => {
if (initialized) return
initialized = true
window.vdd.events.onInstallProgress((progress) => set({ progress }))
for (const driver of ['display', 'audio'] as ManagedDriverId[]) {
void window.vdd.installer
.installedTag(driver)
.then((installedTag) => patchDriver(driver, { installedTag }))
.catch(() => undefined)
void get().refreshDevice(driver)
}
void get().refreshTestSigning()
},
checkLatest: async (driver) => {
patchDriver(driver, { checking: true, checkError: null })
try {
const latest = await window.vdd.installer.latestRelease(driver)
patchDriver(driver, { latest })
} catch (error) {
patchDriver(driver, { checkError: error instanceof Error ? error.message : String(error) })
} finally {
patchDriver(driver, { checking: false })
}
},
refreshDevice: async (driver) => {
try {
const device = await window.vdd.installer.deviceState(driver)
patchDriver(driver, { device })
} catch {
// main not ready
}
},
install: (driver, instances) =>
runOp(
driver,
'install',
() => window.vdd.installer.install(driver, instances),
'Driver installed',
driver === 'display'
? 'The Virtual Display Driver is now installed and starting up.'
: 'The Virtual Audio Driver is now installed - new audio devices should appear shortly.',
'Install failed'
),
uninstallDriver: (driver) =>
runOp(
driver,
'uninstall',
() => window.vdd.installer.uninstall(driver),
'Driver uninstalled',
driver === 'display' ? 'Configuration files were kept for a future reinstall.' : undefined,
'Uninstall failed'
),
restartDevice: (driver) =>
runOp(
driver,
'restart',
() => window.vdd.installer.restartDevice(driver),
'Device restarted',
'The device was disabled and re-enabled.',
'Restart failed'
),
setInstances: (driver, count) =>
runOp(
driver,
'instances',
() => window.vdd.installer.setInstances(driver, count),
'Device count updated',
`${count} virtual audio device${count === 1 ? '' : 's'} now present.`,
'Failed to change device count'
),
refreshTestSigning: async () => {
try {
set({ testSigning: await window.vdd.installer.testSigning() })
} catch {
// main not ready
}
},
setTestSigning: async (enabled) => {
if (get().working) return
const toast = useUi.getState().toast
set({ working: { driver: 'audio', op: 'testsigning' }, progress: null })
try {
const result = await window.vdd.installer.setTestSigning(enabled)
if (result.ok) {
toast('success', `Test signing ${enabled ? 'enabled' : 'disabled'}`, 'Restart Windows for the change to take effect.')
} else {
toast('error', 'Test signing change failed', result.error ?? result.detail)
}
} finally {
set({ working: null, progress: null })
await get().refreshTestSigning()
}
}
}
})
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import type { LogEvent, LogSeverity, LogSource } from '@shared/types'
const MAX_EVENTS = 3000
interface LogsState {
events: LogEvent[]
severityFilter: Set<LogSeverity>
sourceFilter: Set<LogSource>
search: string
autoScroll: boolean
init: () => Promise<void>
clear: () => void
setSearch: (value: string) => void
toggleSeverity: (severity: LogSeverity) => void
toggleSource: (source: LogSource) => void
setAutoScroll: (value: boolean) => void
}
let initialized = false
export const useLogs = create<LogsState>((set) => ({
events: [],
severityFilter: new Set<LogSeverity>(['debug', 'info', 'warning', 'error']),
sourceFilter: new Set<LogSource>(['file', 'pipe', 'app']),
search: '',
autoScroll: true,
init: async () => {
if (initialized) return
initialized = true
try {
const recent = await window.vdd.logs.recent()
set({ events: recent.slice(-MAX_EVENTS) })
} catch {
// main not ready - events will arrive via push
}
window.vdd.events.onLogs((incoming) => {
set((s) => {
const merged = [...s.events, ...incoming]
return { events: merged.length > MAX_EVENTS ? merged.slice(merged.length - MAX_EVENTS) : merged }
})
})
},
clear: () => set({ events: [] }),
setSearch: (value) => set({ search: value }),
toggleSeverity: (severity) =>
set((s) => {
const next = new Set(s.severityFilter)
if (next.has(severity)) next.delete(severity)
else next.add(severity)
return { severityFilter: next }
}),
toggleSource: (source) =>
set((s) => {
const next = new Set(s.sourceFilter)
if (next.has(source)) next.delete(source)
else next.add(source)
return { sourceFilter: next }
}),
setAutoScroll: (value) => set({ autoScroll: value })
}))
@@ -0,0 +1,84 @@
import { create } from 'zustand'
import { DEFAULT_VDD_SETTINGS } from '@shared/defaults'
import type { VddSettings } from '@shared/types'
import { useUi } from './ui'
interface SettingsState {
/** Editable draft shown in the UI. */
draft: VddSettings
/** Last state loaded from disk. */
saved: VddSettings
isDefault: boolean
loaded: boolean
dirty: boolean
loadError: string | null
load: () => Promise<void>
patch: (mutate: (draft: VddSettings) => void) => void
discard: () => void
/** Persist the draft to vdd_settings.xml. Returns success. */
save: () => Promise<boolean>
}
function clone(settings: VddSettings): VddSettings {
return structuredClone(settings)
}
function equal(a: VddSettings, b: VddSettings): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
export const useSettings = create<SettingsState>((set, get) => ({
draft: clone(DEFAULT_VDD_SETTINGS),
saved: clone(DEFAULT_VDD_SETTINGS),
isDefault: true,
loaded: false,
dirty: false,
loadError: null,
load: async () => {
try {
const result = await window.vdd.settings.load()
if (result.ok && result.settings) {
set({
draft: clone(result.settings),
saved: clone(result.settings),
isDefault: result.isDefault,
loaded: true,
dirty: false,
loadError: null
})
} else {
set({ loaded: true, loadError: result.error ?? 'Failed to load settings' })
}
} catch (error) {
set({ loaded: true, loadError: error instanceof Error ? error.message : String(error) })
}
},
patch: (mutate) => {
const next = clone(get().draft)
mutate(next)
set({ draft: next, dirty: !equal(next, get().saved) })
},
discard: () => set({ draft: clone(get().saved), dirty: false }),
save: async () => {
const { draft } = get()
try {
const result = await window.vdd.settings.save(draft)
if (result.ok) {
set({ saved: clone(draft), dirty: false, isDefault: false })
useUi
.getState()
.toast('success', 'Configuration saved', result.backupCreated ? `Backup: ${result.backupCreated}` : undefined)
return true
}
useUi.getState().toast('error', 'Save failed', result.error)
return false
} catch (error) {
useUi.getState().toast('error', 'Save failed', error instanceof Error ? error.message : String(error))
return false
}
}
}))
@@ -0,0 +1,86 @@
import { create } from 'zustand'
import type { AppPreferences, BaseDirResult } from '@shared/types'
export type PageId = 'dashboard' | 'displays' | 'color' | 'edid' | 'gpu' | 'audio' | 'console' | 'settings'
export type ToastKind = 'success' | 'error' | 'info' | 'warning'
export interface Toast {
id: number
kind: ToastKind
title: string
message?: string
}
interface UiState {
page: PageId
prefs: AppPreferences
prefsLoaded: boolean
toasts: Toast[]
maximized: boolean
setPage: (page: PageId) => void
toast: (kind: ToastKind, title: string, message?: string) => void
dismissToast: (id: number) => void
initPrefs: () => Promise<void>
updatePrefs: (patch: Partial<AppPreferences>) => Promise<void>
/** Changes the driver folder (syncs the VDDPATH registry value in main). */
setBaseDir: (baseDir: string) => Promise<BaseDirResult>
setMaximized: (value: boolean) => void
}
let toastId = 1
function applyTheme(prefs: AppPreferences): void {
const root = document.documentElement
const resolved =
prefs.theme === 'system'
? window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
: prefs.theme
root.dataset.theme = resolved
root.style.setProperty('--accent', prefs.accent)
}
export const useUi = create<UiState>((set, get) => ({
page: 'dashboard',
prefs: { theme: 'dark', accent: '#4cc2ff', baseDir: 'C:\\VirtualDisplayDriver', audioRoutes: [] },
prefsLoaded: false,
toasts: [],
maximized: false,
setPage: (page) => set({ page }),
toast: (kind, title, message) => {
const id = toastId++
set((s) => ({ toasts: [...s.toasts.slice(-3), { id, kind, title, message }] }))
window.setTimeout(() => get().dismissToast(id), kind === 'error' ? 7000 : 4200)
},
dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
initPrefs: async () => {
try {
const prefs = await window.vdd.prefs.get()
applyTheme(prefs)
set({ prefs, prefsLoaded: true })
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => applyTheme(get().prefs))
} catch {
set({ prefsLoaded: true })
}
},
updatePrefs: async (patch) => {
const prefs = await window.vdd.prefs.set(patch)
applyTheme(prefs)
set({ prefs })
},
setBaseDir: async (baseDir) => {
const result = await window.vdd.prefs.setBaseDir(baseDir)
set({ prefs: result.prefs })
return result
},
setMaximized: (value) => set({ maximized: value })
}))
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
import { SYSTEM_AUDIO_SOURCE, type AudioRoute } from '@shared/types'
interface ActiveRoute {
context: AudioContext
stream: MediaStream
gainNode: GainNode
analyser: AnalyserNode
element: HTMLAudioElement
levelBuffer: Uint8Array<ArrayBuffer>
}
/**
* In-app audio pump: captures a source (any microphone/virtual capture device,
* or the system output via WASAPI loopback) and plays it to any output device
* through WebAudio. Routes live for as long as the app runs.
*/
class AudioRouter {
private active = new Map<string, ActiveRoute>()
private onEnded: ((routeId: string) => void) | null = null
setOnEnded(handler: (routeId: string) => void): void {
this.onEnded = handler
}
isActive(routeId: string): boolean {
return this.active.has(routeId)
}
async start(route: AudioRoute): Promise<void> {
this.stop(route.id)
let stream: MediaStream
if (route.sourceId === SYSTEM_AUDIO_SOURCE) {
// Main process answers this with a screen source + 'loopback' audio.
stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true })
for (const track of stream.getVideoTracks()) track.stop()
if (stream.getAudioTracks().length === 0) {
for (const track of stream.getTracks()) track.stop()
throw new Error('System audio loopback is unavailable on this system')
}
} else {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: { exact: route.sourceId },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
}
})
}
const context = new AudioContext({ latencyHint: 'interactive' })
const source = context.createMediaStreamSource(stream)
const gainNode = context.createGain()
gainNode.gain.value = route.gain
const analyser = context.createAnalyser()
analyser.fftSize = 256
const destination = context.createMediaStreamDestination()
source.connect(gainNode)
gainNode.connect(analyser)
analyser.connect(destination)
const element = new Audio()
element.srcObject = destination.stream
element.autoplay = true
try {
await element.setSinkId(route.sinkId)
await element.play()
} catch (error) {
for (const track of stream.getTracks()) track.stop()
void context.close()
throw error instanceof Error ? error : new Error(String(error))
}
const audioTrack = stream.getAudioTracks()[0]
audioTrack.addEventListener('ended', () => {
if (this.active.has(route.id)) {
this.stop(route.id)
this.onEnded?.(route.id)
}
})
this.active.set(route.id, {
context,
stream,
gainNode,
analyser,
element,
levelBuffer: new Uint8Array(analyser.frequencyBinCount)
})
}
stop(routeId: string): void {
const entry = this.active.get(routeId)
if (!entry) return
this.active.delete(routeId)
for (const track of entry.stream.getTracks()) track.stop()
entry.element.pause()
entry.element.srcObject = null
void entry.context.close().catch(() => undefined)
}
stopAll(): void {
for (const id of [...this.active.keys()]) this.stop(id)
}
setGain(routeId: string, gain: number): void {
const entry = this.active.get(routeId)
if (entry) entry.gainNode.gain.value = gain
}
/** Current RMS level 0..1 for the route's signal (for meters). */
level(routeId: string): number {
const entry = this.active.get(routeId)
if (!entry) return 0
entry.analyser.getByteTimeDomainData(entry.levelBuffer)
let sum = 0
for (const sample of entry.levelBuffer) {
const centered = (sample - 128) / 128
sum += centered * centered
}
return Math.min(1, Math.sqrt(sum / entry.levelBuffer.length) * 2.5)
}
}
export const audioRouter = new AudioRouter()
@@ -0,0 +1,66 @@
export interface DiffLine {
type: 'add' | 'del' | 'ctx'
text: string
}
/** Simple line-based LCS diff - plenty for config-file sized inputs. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split(/\r?\n/)
const b = after.split(/\r?\n/)
const n = a.length
const m = b.length
// LCS table (n+1 x m+1).
const lcs: Uint32Array[] = []
for (let i = 0; i <= n; i++) lcs.push(new Uint32Array(m + 1))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
}
}
const out: DiffLine[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ type: 'ctx', text: a[i] })
i++
j++
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
out.push({ type: 'del', text: a[i] })
i++
} else {
out.push({ type: 'add', text: b[j] })
j++
}
}
while (i < n) out.push({ type: 'del', text: a[i++] })
while (j < m) out.push({ type: 'add', text: b[j++] })
return out
}
/** Collapse long unchanged runs, keeping `context` lines around changes. */
export function compactDiff(lines: DiffLine[], context = 3): Array<DiffLine | { type: 'skip'; count: number }> {
const keep = new Array<boolean>(lines.length).fill(false)
lines.forEach((line, idx) => {
if (line.type !== 'ctx') {
for (let k = Math.max(0, idx - context); k <= Math.min(lines.length - 1, idx + context); k++) keep[k] = true
}
})
const out: Array<DiffLine | { type: 'skip'; count: number }> = []
let skipped = 0
lines.forEach((line, idx) => {
if (keep[idx]) {
if (skipped > 0) {
out.push({ type: 'skip', count: skipped })
skipped = 0
}
out.push(line)
} else {
skipped++
}
})
if (skipped > 0) out.push({ type: 'skip', count: skipped })
return out
}
+109
View File
@@ -0,0 +1,109 @@
import type { VddSettings } from './types'
/** Mirrors the upstream default vdd_settings.xml (safe values everywhere). */
export const DEFAULT_VDD_SETTINGS: VddSettings = {
monitors: { count: 1 },
gpu: { friendlyName: 'default' },
global: { refreshRates: [60, 90, 120, 144, 165, 240] },
resolutions: [
{ width: 1920, height: 1080, refreshRates: [60] },
{ width: 2560, height: 1440, refreshRates: [60] },
{ width: 3840, height: 2160, refreshRates: [60] }
],
logging: {
sendLogsThroughPipe: true,
logging: false,
debugLogging: false
},
colour: {
sdr10bit: false,
hdrPlus: false,
colourFormat: 'RGB'
},
cursor: {
hardwareCursor: true,
cursorMaxX: 128,
cursorMaxY: 128,
alphaCursorSupport: true,
xorCursorSupportLevel: 2
},
edid: {
customEdid: false,
preventSpoof: false,
edidCeaOverride: false
},
edidIntegration: {
enabled: false,
autoConfigureFromEdid: false,
edidProfilePath: 'EDID/monitor_profile.xml',
overrideManualSettings: false,
fallbackOnError: true
},
hdrAdvanced: {
hdr10StaticMetadata: {
enabled: false,
maxDisplayMasteringLuminance: 1000.0,
minDisplayMasteringLuminance: 0.05,
maxContentLightLevel: 1000,
maxFrameAvgLightLevel: 400
},
colorPrimaries: {
enabled: false,
redX: 0.64,
redY: 0.33,
greenX: 0.3,
greenY: 0.6,
blueX: 0.15,
blueY: 0.06,
whiteX: 0.3127,
whiteY: 0.329
},
colorSpace: {
enabled: false,
gammaCorrection: 2.2,
primaryColorSpace: 'sRGB',
enableMatrixTransform: false
}
},
autoResolutions: {
enabled: false,
sourcePriority: 'manual',
edidModeFiltering: {
minRefreshRate: 24,
maxRefreshRate: 240,
excludeFractionalRates: false,
minResolutionWidth: 640,
minResolutionHeight: 480,
maxResolutionWidth: 7680,
maxResolutionHeight: 4320
},
preferredMode: {
useEdidPreferred: false,
fallbackWidth: 1920,
fallbackHeight: 1080,
fallbackRefresh: 60
}
},
colorAdvanced: {
bitDepthManagement: {
autoSelectFromColorSpace: false,
forceBitDepth: 8,
fp16SurfaceSupport: true
},
colorFormatExtended: {
sdrWhiteLevel: 80.0
}
}
}
/** Known color space presets for the chromaticity editor. */
export const COLOR_SPACE_PRESETS: Record<
string,
{ redX: number; redY: number; greenX: number; greenY: number; blueX: number; blueY: number; whiteX: number; whiteY: number }
> = {
sRGB: { redX: 0.64, redY: 0.33, greenX: 0.3, greenY: 0.6, blueX: 0.15, blueY: 0.06, whiteX: 0.3127, whiteY: 0.329 },
'DCI-P3': { redX: 0.68, redY: 0.32, greenX: 0.265, greenY: 0.69, blueX: 0.15, blueY: 0.06, whiteX: 0.314, whiteY: 0.351 },
'Display P3': { redX: 0.68, redY: 0.32, greenX: 0.265, greenY: 0.69, blueX: 0.15, blueY: 0.06, whiteX: 0.3127, whiteY: 0.329 },
AdobeRGB: { redX: 0.64, redY: 0.33, greenX: 0.21, greenY: 0.71, blueX: 0.15, blueY: 0.06, whiteX: 0.3127, whiteY: 0.329 },
'Rec. 2020': { redX: 0.708, redY: 0.292, greenX: 0.17, greenY: 0.797, blueX: 0.131, blueY: 0.046, whiteX: 0.3127, whiteY: 0.329 }
}
+445
View File
@@ -0,0 +1,445 @@
import type { EdidChromaticity, EdidHdrMetadata, EdidTiming, ParsedEdid } from './types'
/**
* Pure-TypeScript EDID parser (base block + CEA-861 extension) and
* IddCx monitor_profile.xml generator. No Node APIs - usable in any process.
*/
const EDID_HEADER = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]
// Established timings, bytes 35-37 (bit 7 -> bit 0 per byte).
const ESTABLISHED_TIMINGS: Array<[number, number, number] | null>[] = [
[
[720, 400, 70],
[720, 400, 88],
[640, 480, 60],
[640, 480, 67],
[640, 480, 72],
[640, 480, 75],
[800, 600, 56],
[800, 600, 60]
],
[
[800, 600, 72],
[800, 600, 75],
[832, 624, 75],
[1024, 768, 87], // interlaced
[1024, 768, 60],
[1024, 768, 70],
[1024, 768, 75],
[1280, 1024, 75]
],
[[1152, 870, 75], null, null, null, null, null, null, null]
]
// CEA-861 VIC table (curated, common codes).
const VIC_TABLE: Record<number, [number, number, number, boolean?]> = {
1: [640, 480, 60],
2: [720, 480, 60],
3: [720, 480, 60],
4: [1280, 720, 60],
5: [1920, 1080, 60, true],
6: [720, 480, 60, true],
7: [720, 480, 60, true],
16: [1920, 1080, 60],
17: [720, 576, 50],
18: [720, 576, 50],
19: [1280, 720, 50],
20: [1920, 1080, 50, true],
31: [1920, 1080, 50],
32: [1920, 1080, 24],
33: [1920, 1080, 25],
34: [1920, 1080, 30],
39: [1920, 1080, 50, true],
60: [1280, 720, 24],
61: [1280, 720, 25],
62: [1280, 720, 30],
63: [1920, 1080, 120],
64: [1920, 1080, 100],
90: [2560, 1080, 60],
91: [2560, 1080, 100],
92: [2560, 1080, 120],
93: [3840, 2160, 24],
94: [3840, 2160, 25],
95: [3840, 2160, 30],
96: [3840, 2160, 50],
97: [3840, 2160, 60],
98: [4096, 2160, 24],
99: [4096, 2160, 25],
100: [4096, 2160, 30],
101: [4096, 2160, 50],
102: [4096, 2160, 60],
103: [3840, 2160, 24],
104: [3840, 2160, 25],
105: [3840, 2160, 30],
106: [3840, 2160, 50],
107: [3840, 2160, 60],
117: [3840, 2160, 100],
118: [3840, 2160, 120],
219: [4096, 2160, 100],
220: [4096, 2160, 120]
}
const VIDEO_INTERFACES: Record<number, string> = {
0: 'Undefined',
1: 'DVI',
2: 'HDMI-a',
3: 'HDMI-b',
4: 'MDDI',
5: 'DisplayPort'
}
function decodeManufacturerId(b0: number, b1: number): string {
const value = (b0 << 8) | b1
const c1 = ((value >> 10) & 0x1f) + 64
const c2 = ((value >> 5) & 0x1f) + 64
const c3 = (value & 0x1f) + 64
return String.fromCharCode(c1, c2, c3)
}
function chrom10(high: number, low: number): number {
return Math.round((((high << 2) | low) / 1024) * 10000) / 10000
}
function descriptorText(bytes: Uint8Array, offset: number): string {
let text = ''
for (let i = offset + 5; i < offset + 18; i++) {
const ch = bytes[i]
if (ch === 0x0a) break
text += String.fromCharCode(ch)
}
return text.trim()
}
function parseDtd(bytes: Uint8Array, o: number): EdidTiming | null {
const pixelClock = bytes[o] | (bytes[o + 1] << 8)
if (pixelClock === 0) return null
const hActive = bytes[o + 2] | ((bytes[o + 4] >> 4) << 8)
const hBlank = bytes[o + 3] | ((bytes[o + 4] & 0x0f) << 8)
const vActive = bytes[o + 5] | ((bytes[o + 7] >> 4) << 8)
const vBlank = bytes[o + 6] | ((bytes[o + 7] & 0x0f) << 8)
const interlaced = (bytes[o + 17] & 0x80) !== 0
const totalPixels = (hActive + hBlank) * (vActive + vBlank)
if (totalPixels === 0 || hActive === 0 || vActive === 0) return null
const refresh = (pixelClock * 10000) / totalPixels
return {
width: hActive,
height: vActive * (interlaced ? 2 : 1),
refreshHz: Math.round(refresh * 1000) / 1000,
interlaced,
source: 'detailed',
pixelClockMHz: Math.round(pixelClock / 100) / 100
}
}
function parseCeaBlock(bytes: Uint8Array, base: number, result: ParsedEdid): void {
result.hasCeaExtension = true
const dtdStart = bytes[base + 2]
const flags = bytes[base + 3]
result.ceaBasicAudio = (flags & 0x40) !== 0
result.ceaYcbcr444 = (flags & 0x20) !== 0
result.ceaYcbcr422 = (flags & 0x10) !== 0
// Data block collection: from base+4 up to dtdStart.
let i = base + 4
const dataEnd = base + Math.max(dtdStart, 4)
while (i < dataEnd && i < base + 127) {
const header = bytes[i]
const tag = (header >> 5) & 0x07
const length = header & 0x1f
if (length === 0 && tag === 0) break
if (tag === 2) {
// Video data block - list of VICs
for (let v = 1; v <= length; v++) {
const raw = bytes[i + v]
const vic = raw >= 128 && raw <= 192 ? raw & 0x7f : raw
const native = raw >= 128 && raw <= 192
const mode = VIC_TABLE[vic]
if (mode) {
result.timings.push({
width: mode[0],
height: mode[1],
refreshHz: mode[2],
interlaced: mode[3] === true,
source: 'cea-vic',
vic,
native
})
}
}
} else if (tag === 7 && length >= 2) {
// Extended tag
const extTag = bytes[i + 1]
if (extTag === 6) {
// HDR static metadata data block
const eotf = bytes[i + 2]
const hdr: EdidHdrMetadata = {
eotfSdr: (eotf & 0x01) !== 0,
eotfHdr: (eotf & 0x02) !== 0,
eotfPq: (eotf & 0x04) !== 0,
eotfHlg: (eotf & 0x08) !== 0
}
// Coded luminance values (CTA-861.3): L = 50 * 2^(cv/32)
if (length >= 4 && bytes[i + 4] > 0) hdr.maxLuminance = Math.round(50 * Math.pow(2, bytes[i + 4] / 32))
if (length >= 5 && bytes[i + 5] > 0) hdr.maxFrameAvgLuminance = Math.round(50 * Math.pow(2, bytes[i + 5] / 32))
if (length >= 6 && hdr.maxLuminance) {
const cv = bytes[i + 6]
hdr.minLuminance = Math.round(hdr.maxLuminance * Math.pow(cv / 255, 2) / 100 * 10000) / 10000
}
result.hdr = hdr
}
}
i += length + 1
}
// DTDs in the CEA block.
if (dtdStart >= 4) {
let o = base + dtdStart
while (o + 18 <= base + 127) {
const dtd = parseDtd(bytes, o)
if (!dtd) break
result.timings.push(dtd)
o += 18
}
}
}
export function parseEdid(input: Uint8Array): ParsedEdid {
const bytes = input
const result: ParsedEdid = {
valid: false,
errors: [],
manufacturerId: '???',
productCode: 0,
serialNumber: 0,
manufactureWeek: 0,
manufactureYear: 0,
edidVersion: '?',
digital: false,
timings: [],
extensionCount: 0,
hasCeaExtension: false,
checksumOk: false,
rawBytes: bytes.length
}
if (bytes.length < 128) {
result.errors.push(`EDID must be at least 128 bytes (got ${bytes.length})`)
return result
}
for (let i = 0; i < 8; i++) {
if (bytes[i] !== EDID_HEADER[i]) {
result.errors.push('Invalid EDID header magic')
return result
}
}
let checksum = 0
for (let i = 0; i < 128; i++) checksum = (checksum + bytes[i]) & 0xff
result.checksumOk = checksum === 0
if (!result.checksumOk) result.errors.push('Base block checksum mismatch')
result.manufacturerId = decodeManufacturerId(bytes[8], bytes[9])
result.productCode = bytes[10] | (bytes[11] << 8)
result.serialNumber = bytes[12] | (bytes[13] << 8) | (bytes[14] << 16) | (bytes[15] << 24)
result.manufactureWeek = bytes[16]
result.manufactureYear = bytes[17] + 1990
result.edidVersion = `${bytes[18]}.${bytes[19]}`
const videoInput = bytes[20]
result.digital = (videoInput & 0x80) !== 0
if (result.digital) {
const depthCode = (videoInput >> 4) & 0x07
if (depthCode >= 1 && depthCode <= 6) result.bitDepth = 4 + depthCode * 2
result.videoInterface = VIDEO_INTERFACES[videoInput & 0x0f] ?? 'Unknown'
}
if (bytes[21] > 0) result.screenWidthCm = bytes[21]
if (bytes[22] > 0) result.screenHeightCm = bytes[22]
if (bytes[23] !== 0xff) result.gamma = Math.round((bytes[23] + 100) / 100 * 100) / 100
result.chromaticity = {
redX: chrom10(bytes[27], (bytes[25] >> 6) & 3),
redY: chrom10(bytes[28], (bytes[25] >> 4) & 3),
greenX: chrom10(bytes[29], (bytes[25] >> 2) & 3),
greenY: chrom10(bytes[30], bytes[25] & 3),
blueX: chrom10(bytes[31], (bytes[26] >> 6) & 3),
blueY: chrom10(bytes[32], (bytes[26] >> 4) & 3),
whiteX: chrom10(bytes[33], (bytes[26] >> 2) & 3),
whiteY: chrom10(bytes[34], bytes[26] & 3)
}
// Established timings.
for (let byteIdx = 0; byteIdx < 3; byteIdx++) {
const value = bytes[35 + byteIdx]
for (let bit = 0; bit < 8; bit++) {
if ((value & (0x80 >> bit)) !== 0) {
const mode = ESTABLISHED_TIMINGS[byteIdx][bit]
if (mode) {
result.timings.push({
width: mode[0],
height: mode[1],
refreshHz: mode[2],
interlaced: byteIdx === 1 && bit === 3,
source: 'established'
})
}
}
}
}
// Standard timings (bytes 38-53).
for (let i = 0; i < 8; i++) {
const o = 38 + i * 2
const b1 = bytes[o]
const b2 = bytes[o + 1]
if (b1 === 0x01 && b2 === 0x01) continue
if (b1 === 0x00) continue
const width = (b1 + 31) * 8
const aspect = (b2 >> 6) & 3
let height: number
switch (aspect) {
case 0:
height = Math.round((width * 10) / 16)
break
case 1:
height = Math.round((width * 3) / 4)
break
case 2:
height = Math.round((width * 4) / 5)
break
default:
height = Math.round((width * 9) / 16)
}
result.timings.push({
width,
height,
refreshHz: (b2 & 0x3f) + 60,
source: 'standard'
})
}
// 18-byte descriptors (bytes 54-125).
for (let d = 0; d < 4; d++) {
const o = 54 + d * 18
const isDtd = bytes[o] !== 0 || bytes[o + 1] !== 0
if (isDtd) {
const dtd = parseDtd(bytes, o)
if (dtd) {
result.timings.push(dtd)
if (!result.preferred) result.preferred = dtd
}
} else {
switch (bytes[o + 3]) {
case 0xfc:
result.displayName = descriptorText(bytes, o)
break
case 0xff:
result.serialString = descriptorText(bytes, o)
break
}
}
}
// Extension blocks.
result.extensionCount = bytes[126]
for (let ext = 1; ext <= result.extensionCount; ext++) {
const base = ext * 128
if (base + 128 > bytes.length) {
result.errors.push(`Extension block ${ext} declared but missing from file`)
break
}
if (bytes[base] === 0x02) {
try {
parseCeaBlock(bytes, base, result)
} catch {
result.errors.push(`Failed to parse CEA extension block ${ext}`)
}
}
}
// Deduplicate timings (prefer detailed > cea-vic > standard > established).
const priority: Record<string, number> = { detailed: 0, 'cea-vic': 1, standard: 2, established: 3 }
const seen = new Map<string, EdidTiming>()
for (const t of result.timings) {
const key = `${t.width}x${t.height}@${Math.round(t.refreshHz)}${t.interlaced ? 'i' : ''}`
const existing = seen.get(key)
if (!existing || priority[t.source] < priority[existing.source]) seen.set(key, t)
}
result.timings = Array.from(seen.values()).sort(
(a, b) => b.width * b.height - a.width * a.height || b.refreshHz - a.refreshHz
)
result.valid = result.errors.length === 0 || result.checksumOk
return result
}
// ---------------------------------------------------------------------------
// monitor_profile.xml generation (IddCxMonitorConfig format)
// ---------------------------------------------------------------------------
function fmt(n: number, decimals: number): string {
return n.toFixed(decimals)
}
export function generateMonitorProfileXml(edid: ParsedEdid): string {
const lines: string[] = []
lines.push(`<?xml version='1.0' encoding='utf-8'?>`)
lines.push(`<!-- Generated by Virtual Driver Control from EDID`)
lines.push(` Monitor: ${edid.displayName ?? 'Unknown'} (${edid.manufacturerId} ${edid.productCode})`)
lines.push(` Generated: ${new Date().toISOString()} -->`)
lines.push(`<IddCxMonitorConfig>`)
lines.push(` <MonitorModes>`)
const modes = edid.timings.filter((t) => !t.interlaced && t.refreshHz > 0)
for (const mode of modes) {
const nominal = Math.round(mode.refreshHz)
const isIntegral = Math.abs(mode.refreshHz - nominal) < 0.001
lines.push(` <MonitorMode>`)
lines.push(` <Width>${mode.width}</Width>`)
lines.push(` <Height>${mode.height}</Height>`)
lines.push(` <RefreshRate>${fmt(mode.refreshHz, 3)}</RefreshRate>`)
lines.push(` <RefreshRateMultiplier>${isIntegral ? 1000 : 999}</RefreshRateMultiplier>`)
lines.push(` <NominalRefreshRate>${nominal}</NominalRefreshRate>`)
lines.push(` </MonitorMode>`)
}
lines.push(` </MonitorModes>`)
lines.push(` <ColorProfile>`)
lines.push(` <PrimaryColorSpace>sRGB</PrimaryColorSpace>`)
lines.push(` <Gamma>${fmt(edid.gamma ?? 2.2, 3)}</Gamma>`)
const c: EdidChromaticity =
edid.chromaticity ?? {
redX: 0.64,
redY: 0.33,
greenX: 0.3,
greenY: 0.6,
blueX: 0.15,
blueY: 0.06,
whiteX: 0.3127,
whiteY: 0.329
}
lines.push(` <Chromaticity>`)
lines.push(` <RedX>${fmt(c.redX, 4)}</RedX>`)
lines.push(` <RedY>${fmt(c.redY, 4)}</RedY>`)
lines.push(` <GreenX>${fmt(c.greenX, 4)}</GreenX>`)
lines.push(` <GreenY>${fmt(c.greenY, 4)}</GreenY>`)
lines.push(` <BlueX>${fmt(c.blueX, 4)}</BlueX>`)
lines.push(` <BlueY>${fmt(c.blueY, 4)}</BlueY>`)
lines.push(` <WhiteX>${fmt(c.whiteX, 4)}</WhiteX>`)
lines.push(` <WhiteY>${fmt(c.whiteY, 4)}</WhiteY>`)
lines.push(` </Chromaticity>`)
lines.push(` </ColorProfile>`)
const preferred = edid.preferred ?? modes[0]
if (preferred) {
lines.push(` <PreferredMode>`)
lines.push(` <Width>${preferred.width}</Width>`)
lines.push(` <Height>${preferred.height}</Height>`)
lines.push(` <RefreshRate>${fmt(preferred.refreshHz, 3)}</RefreshRate>`)
lines.push(` </PreferredMode>`)
}
lines.push(`</IddCxMonitorConfig>`)
return lines.join('\n')
}
@@ -0,0 +1,65 @@
export interface ResolutionPreset {
width: number
height: number
label: string
category: 'Standard' | 'HD' | 'QHD' | '4K & Beyond' | 'Ultrawide' | 'Portable & Tablet'
}
/** Curated subset of the upstream option.txt preset list (640x480 - 10240x4320). */
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
{ width: 640, height: 480, label: 'VGA', category: 'Standard' },
{ width: 800, height: 600, label: 'SVGA', category: 'Standard' },
{ width: 1024, height: 768, label: 'XGA', category: 'Standard' },
{ width: 1280, height: 1024, label: 'SXGA', category: 'Standard' },
{ width: 1400, height: 1050, label: 'SXGA+', category: 'Standard' },
{ width: 1600, height: 1200, label: 'UXGA', category: 'Standard' },
{ width: 1280, height: 720, label: 'HD 720p', category: 'HD' },
{ width: 1366, height: 768, label: 'WXGA', category: 'HD' },
{ width: 1600, height: 900, label: 'HD+', category: 'HD' },
{ width: 1920, height: 1080, label: 'Full HD 1080p', category: 'HD' },
{ width: 1920, height: 1200, label: 'WUXGA', category: 'HD' },
{ width: 2560, height: 1440, label: 'QHD 1440p', category: 'QHD' },
{ width: 2560, height: 1600, label: 'WQXGA', category: 'QHD' },
{ width: 2880, height: 1620, label: 'QHD+ 3K', category: 'QHD' },
{ width: 3200, height: 1800, label: 'WQXGA+', category: 'QHD' },
{ width: 3840, height: 2160, label: '4K UHD', category: '4K & Beyond' },
{ width: 4096, height: 2160, label: 'DCI 4K', category: '4K & Beyond' },
{ width: 5120, height: 2880, label: '5K', category: '4K & Beyond' },
{ width: 6016, height: 3384, label: '6K', category: '4K & Beyond' },
{ width: 7680, height: 4320, label: '8K UHD', category: '4K & Beyond' },
{ width: 2560, height: 1080, label: 'UW-FHD 21:9', category: 'Ultrawide' },
{ width: 3440, height: 1440, label: 'UW-QHD 21:9', category: 'Ultrawide' },
{ width: 3840, height: 1600, label: 'UW-QHD+ 24:10', category: 'Ultrawide' },
{ width: 5120, height: 1440, label: 'Super UW 32:9', category: 'Ultrawide' },
{ width: 5120, height: 2160, label: '5K2K 21:9', category: 'Ultrawide' },
{ width: 1280, height: 800, label: 'WXGA Tablet', category: 'Portable & Tablet' },
{ width: 2048, height: 1536, label: 'iPad Retina 4:3', category: 'Portable & Tablet' },
{ width: 2160, height: 1440, label: 'Surface 3:2', category: 'Portable & Tablet' },
{ width: 2256, height: 1504, label: 'Surface Laptop 3:2', category: 'Portable & Tablet' },
{ width: 2736, height: 1824, label: 'Surface Pro 3:2', category: 'Portable & Tablet' },
{ width: 2880, height: 1920, label: 'Surface Pro 8+ 3:2', category: 'Portable & Tablet' }
]
/** Common refresh-rate chips offered in the editor. */
export const REFRESH_RATE_PRESETS: number[] = [24, 30, 50, 59.94, 60, 75, 90, 100, 120, 144, 165, 175, 200, 240, 360]
export function aspectRatioLabel(width: number, height: number): string {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
const g = gcd(width, height)
let w = width / g
let h = height / g
// Render familiar marketing ratios.
if (w === 8 && h === 5) [w, h] = [16, 10]
if (w === 7 && h === 3) [w, h] = [21, 9]
if (w === 64 && h === 27) [w, h] = [21, 9]
if (w === 43 && h === 18) [w, h] = [21, 9]
if (w === 12 && h === 5) [w, h] = [21, 9]
if (w === 32 && h === 10) [w, h] = [32, 10]
if (w > 40) return `${(width / height).toFixed(2)}:1`
return `${w}:${h}`
}
+419
View File
@@ -0,0 +1,419 @@
/**
* Shared type contract between the main process, preload bridge and renderer.
*/
// ---------------------------------------------------------------------------
// vdd_settings.xml model
// ---------------------------------------------------------------------------
export type ColourFormat = 'RGB' | 'YCbCr444' | 'YCbCr422' | 'YCbCr420'
export interface ResolutionEntry {
width: number
height: number
/** Per-resolution refresh rates (Hz). Fractional rates like 59.94 allowed. */
refreshRates: number[]
}
export interface VddSettings {
monitors: { count: number }
gpu: { friendlyName: string }
global: { refreshRates: number[] }
resolutions: ResolutionEntry[]
logging: {
sendLogsThroughPipe: boolean
logging: boolean
debugLogging: boolean
}
colour: {
sdr10bit: boolean
hdrPlus: boolean
colourFormat: ColourFormat
}
cursor: {
hardwareCursor: boolean
cursorMaxX: number
cursorMaxY: number
alphaCursorSupport: boolean
xorCursorSupportLevel: number
}
edid: {
customEdid: boolean
preventSpoof: boolean
edidCeaOverride: boolean
}
edidIntegration: {
enabled: boolean
autoConfigureFromEdid: boolean
edidProfilePath: string
overrideManualSettings: boolean
fallbackOnError: boolean
}
hdrAdvanced: {
hdr10StaticMetadata: {
enabled: boolean
maxDisplayMasteringLuminance: number
minDisplayMasteringLuminance: number
maxContentLightLevel: number
maxFrameAvgLightLevel: number
}
colorPrimaries: {
enabled: boolean
redX: number
redY: number
greenX: number
greenY: number
blueX: number
blueY: number
whiteX: number
whiteY: number
}
colorSpace: {
enabled: boolean
gammaCorrection: number
primaryColorSpace: string
enableMatrixTransform: boolean
}
}
autoResolutions: {
enabled: boolean
sourcePriority: string
edidModeFiltering: {
minRefreshRate: number
maxRefreshRate: number
excludeFractionalRates: boolean
minResolutionWidth: number
minResolutionHeight: number
maxResolutionWidth: number
maxResolutionHeight: number
}
preferredMode: {
useEdidPreferred: boolean
fallbackWidth: number
fallbackHeight: number
fallbackRefresh: number
}
}
colorAdvanced: {
bitDepthManagement: {
autoSelectFromColorSpace: boolean
forceBitDepth: number
fp16SurfaceSupport: boolean
}
colorFormatExtended: {
sdrWhiteLevel: number
}
}
}
// ---------------------------------------------------------------------------
// Named pipe protocol
// ---------------------------------------------------------------------------
/** Toggle commands understood by the driver pipe. */
export type PipeToggleCommand =
| 'HDRPLUS'
| 'SDR10'
| 'CUSTOMEDID'
| 'PREVENTSPOOF'
| 'CEAOVERRIDE'
| 'HARDWARECURSOR'
| 'LOGGING'
| 'LOG_DEBUG'
export interface PipeResult {
ok: boolean
command: string
/** Decoded response payload (log lines for most commands). */
response: string
lines: string[]
durationMs: number
error?: string
}
export interface DriverLiveSettings {
debug: boolean
log: boolean
}
// ---------------------------------------------------------------------------
// Driver status
// ---------------------------------------------------------------------------
export type DriverStatusLevel =
| 'online' // pipe answers PING
| 'installed-offline' // device or DLL present, pipe not answering
| 'not-installed'
| 'unknown'
export interface DriverStatus {
level: DriverStatusLevel
pipeConnected: boolean
devicePresent: boolean
deviceName?: string
devicePnpStatus?: string
dllPresent: boolean
dllDate?: string
checkedAt: number
}
export interface GpuInfo {
name: string
source: 'pipe' | 'wmi'
assigned: boolean
driverVersion?: string
vramMB?: number
}
export interface SystemInfo {
windowsVersion: string
windowsBuild: string
arch: string
isAdmin: boolean
appVersion: string
electronVersion: string
settingsPath: string
logsDir: string
}
// ---------------------------------------------------------------------------
// Logs
// ---------------------------------------------------------------------------
export type LogSeverity = 'debug' | 'info' | 'warning' | 'error'
export type LogSource = 'file' | 'pipe' | 'app'
export interface LogEvent {
id: number
timestamp: number
source: LogSource
severity: LogSeverity
message: string
}
// ---------------------------------------------------------------------------
// Settings persistence
// ---------------------------------------------------------------------------
export interface BackupInfo {
fileName: string
fullPath: string
createdAt: number
sizeBytes: number
}
export interface SettingsLoadResult {
ok: boolean
settings?: VddSettings
rawXml?: string
/** True when the file did not exist and defaults were returned. */
isDefault: boolean
error?: string
}
export interface SaveResult {
ok: boolean
backupCreated?: string
error?: string
}
// ---------------------------------------------------------------------------
// EDID
// ---------------------------------------------------------------------------
export interface EdidTiming {
width: number
height: number
refreshHz: number
interlaced?: boolean
source: 'detailed' | 'standard' | 'established' | 'cea-vic'
pixelClockMHz?: number
vic?: number
native?: boolean
}
export interface EdidChromaticity {
redX: number
redY: number
greenX: number
greenY: number
blueX: number
blueY: number
whiteX: number
whiteY: number
}
export interface EdidHdrMetadata {
eotfSdr: boolean
eotfHdr: boolean
eotfPq: boolean
eotfHlg: boolean
maxLuminance?: number
maxFrameAvgLuminance?: number
minLuminance?: number
}
export interface ParsedEdid {
valid: boolean
errors: string[]
manufacturerId: string
productCode: number
serialNumber: number
serialString?: string
displayName?: string
manufactureWeek: number
manufactureYear: number
edidVersion: string
digital: boolean
bitDepth?: number
videoInterface?: string
screenWidthCm?: number
screenHeightCm?: number
gamma?: number
chromaticity?: EdidChromaticity
timings: EdidTiming[]
preferred?: EdidTiming
extensionCount: number
hasCeaExtension: boolean
ceaYcbcr444?: boolean
ceaYcbcr422?: boolean
ceaBasicAudio?: boolean
hdr?: EdidHdrMetadata
checksumOk: boolean
rawBytes: number
}
// ---------------------------------------------------------------------------
// Driver installer (GitHub releases)
// ---------------------------------------------------------------------------
/** Drivers managed by the lifecycle installer. */
export type ManagedDriverId = 'display' | 'audio'
export interface ManagedDeviceState {
/** Number of root-enumerated device nodes present. */
count: number
/** PnP status of each device (OK, Error, ...). */
statuses: string[]
}
export interface ReleaseInfo {
tag: string
name: string
publishedAt: string
notes: string
htmlUrl: string
asset: {
name: string
sizeBytes: number
downloadUrl: string
sha256?: string
} | null
}
export type InstallPhase = 'download' | 'verify' | 'extract' | 'install' | 'finalize'
export interface InstallProgress {
phase: InstallPhase
/** 0-100 within the current phase; -1 = indeterminate. */
percent: number
message: string
}
export interface LifecycleResult {
ok: boolean
/** Tail of the elevated operation's log. */
detail?: string
error?: string
}
// ---------------------------------------------------------------------------
// Windows audio endpoints
// ---------------------------------------------------------------------------
export type AudioFlow = 'render' | 'capture'
export interface AudioEndpoint {
/** MMDevice endpoint ID, e.g. {0.0.0.00000000}.{guid}. */
id: string
name: string
flow: AudioFlow
isDefault: boolean
isDefaultComm: boolean
/** Master volume scalar 0..1. */
volume: number
muted: boolean
/** True when the endpoint belongs to the Virtual Audio Driver. */
isVirtual: boolean
}
// ---------------------------------------------------------------------------
// Audio routing (renderer-side WebAudio pump, persisted in prefs)
// ---------------------------------------------------------------------------
/** Source id for routing system output audio (WASAPI loopback of default output). */
export const SYSTEM_AUDIO_SOURCE = 'system-loopback'
export interface AudioRoute {
id: string
/** Web `MediaDeviceInfo.deviceId` of an audioinput, or SYSTEM_AUDIO_SOURCE. */
sourceId: string
sourceLabel: string
/** Web `MediaDeviceInfo.deviceId` of an audiooutput. */
sinkId: string
sinkLabel: string
/** Gain 0..2 (1 = unity). */
gain: number
enabled: boolean
}
// ---------------------------------------------------------------------------
// Display layout (physical + virtual monitors)
// ---------------------------------------------------------------------------
export interface DisplayLayoutInfo {
id: number
label: string
bounds: { x: number; y: number; width: number; height: number }
workArea: { x: number; y: number; width: number; height: number }
scaleFactor: number
rotation: number
frequency: number
internal: boolean
primary: boolean
/** True when the monitor hangs off the MttVDD virtual adapter. */
isVirtual: boolean
colorDepth: number
}
// ---------------------------------------------------------------------------
// App preferences (renderer-local, persisted via main)
// ---------------------------------------------------------------------------
export interface AppPreferences {
theme: 'dark' | 'light' | 'system'
accent: string
baseDir: string
/** Saved audio routes, re-armed on app start. */
audioRoutes: AudioRoute[]
}
export const DEFAULT_BASE_DIR = 'C:\\VirtualDisplayDriver'
/** Result of changing the driver folder (prefs + VDDPATH registry together). */
export interface BaseDirResult {
ok: boolean
prefs: AppPreferences
error?: string
}
// ---------------------------------------------------------------------------
// IPC event channel payloads (main -> renderer push)
// ---------------------------------------------------------------------------
export interface PipeActivity {
command: string
ok: boolean
durationMs: number
at: number
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"paths": {
"@shared/*": ["./src/shared/*"]
}
},
"include": [
"electron.vite.config.ts",
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"noEmit": true,
"paths": {
"@renderer/*": ["./src/renderer/src/*"],
"@shared/*": ["./src/shared/*"]
}
},
"include": [
"src/renderer/src/**/*",
"src/preload/index.d.ts",
"src/shared/**/*"
]
}