mirror of
https://github.com/VirtualDrivers/Virtual-Display-Driver.git
synced 2026-10-22 22:52:29 -05:00
build: bundle and sign Virtual Driver Control
This commit is contained in:
1 parent
3f84d49175
commit
0ebca45f75
63 files changed
+17045
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
out-ps-check/
|
||||
*.tsbuildinfo
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
Generated
+6167
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import type { VddApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
vdd: VddApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user