diff --git a/.github/workflows/ci-validation.yml b/.github/workflows/ci-validation.yml index b82179c7..3b153e5a 100644 --- a/.github/workflows/ci-validation.yml +++ b/.github/workflows/ci-validation.yml @@ -5,21 +5,40 @@ on: pull_request: push: branches: [ main, master ] + tags: [ 'v*' ] paths-ignore: - '**.md' - 'docs/**' - 'LICENSE*' workflow_dispatch: + inputs: + sign: + description: Submit the built Release packages to SignPath + required: false + default: false + type: boolean concurrency: group: "${{ github.workflow }}-${{ github.ref }}" - cancel-in-progress: true + # A replacement validation build may cancel an older validation build, but a + # submitted signing request must be allowed to complete and download its result. + cancel-in-progress: ${{ !((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.sign)) }} env: BUILD_CONFIGURATION: Release VDD_SOLUTION: Virtual Display Driver (HDR)/MttVDD.sln + CONTROL_PANEL_DIR: VirtualDriverControl + # Keep the project's Windows 10-compatible released UMDF baseline. Never + # select the highest directory from the runner: WDKs can contain preview WDF + # headers (for example 2.35) that retail Windows rejects at load time. + UMDF_VERSION: '2.25' + # 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. + SIGNPATH_SIGNING_RUN: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.sign) }} permissions: + actions: read contents: read jobs: @@ -37,6 +56,23 @@ 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" + } + + $releaseBaseVersion = "{0}.{1}.{2}" -f ($now.Year % 100), $now.Month, $now.Day + $releaseVersion = "$releaseBaseVersion.$buildNumber" + Write-Output "Using release version $releaseVersion" + "RELEASE_BASE_VERSION=$releaseBaseVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_BUILD_NUMBER=$buildNumber" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "RELEASE_VERSION=$releaseVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Setup MSBuild uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 @@ -93,7 +129,7 @@ jobs: $sln = "${{ env.VDD_SOLUTION }}" if (-not (Test-Path $sln)) { throw "VDD solution file not found at: $sln" } - # Pick an available UMDF WDF header version on the runner. + # Resolve the project's explicitly supported UMDF WDF headers. # Kits can be laid out either versioned: # Include\\wdf\umdf\2.xx # or unversioned: @@ -107,17 +143,22 @@ jobs: throw "UMDF WDF include root not found under $env:WINDOWS_SDK_DIR (tried versioned + unversioned layouts)" } - $umdfBest = + $umdfDirectory = $umdfRoots | - ForEach-Object { Get-ChildItem -Path $_ -Directory -ErrorAction SilentlyContinue } | - Where-Object { $_.Name -match '^\d+\.\d+$' } | - Sort-Object -Property Name -Descending | + ForEach-Object { Get-ChildItem -Path $_ -Directory -Filter $env:UMDF_VERSION -ErrorAction SilentlyContinue } | Select-Object -First 1 - if (-not $umdfBest) { throw "No UMDF version directories found under: $($umdfRoots -join ', ')" } + if (-not $umdfDirectory) { + throw "Released UMDF $env:UMDF_VERSION headers were not found under: $($umdfRoots -join ', ')" + } - $umdfMinor = ($umdfBest.Name -split '\.')[1] - Write-Output "Using UMDF version: $($umdfBest.Name) (minor=$umdfMinor) from $($umdfBest.FullName)" + $umdfMinor = ($env:UMDF_VERSION -split '\.')[1] + Write-Output "Using pinned UMDF version: $env:UMDF_VERSION (minor=$umdfMinor) from $($umdfDirectory.FullName)" + + $now = [DateTime]::UtcNow + $driverVersion = $env:RELEASE_VERSION + $driverDate = $now.ToString("MM/dd/yyyy", [Globalization.CultureInfo]::InvariantCulture) + Write-Output "Stamping DriverVer=$driverDate,$driverVersion" msbuild $sln ` /m ` @@ -127,10 +168,70 @@ jobs: /p:WindowsSdkDir="$env:WINDOWS_SDK_DIR" ` /p:WindowsTargetPlatformVersion="$env:WINDOWS_TARGET_PLATFORM_VERSION" ` /p:UMDF_VERSION_MINOR="$umdfMinor" ` + /p:VddDriverVersion="$driverVersion" ` + /p:VddDriverDate="$driverDate" ` /p:EnableInfVerif=false ` /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 package versions are three-part SemVer. electron-builder + # combines this base with BUILD_NUMBER for the Windows four-part + # FileVersion and ${buildVersion} artifact-name macro. + npm version $env:RELEASE_BASE_VERSION --no-git-tag-version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $env:BUILD_NUMBER = $env:RELEASE_BUILD_NUMBER + $env:VDC_RELEASE_VERSION = $env:RELEASE_VERSION + + 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: | @@ -142,9 +243,132 @@ jobs: New-Item -ItemType Directory -Path $dest -Force | Out-Null Copy-Item "$outDir\*" -Destination $dest -Recurse -Force - - name: Upload artifacts + $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" + } + } + + $inf = Join-Path $dest "MttVDD.inf" + if ((Select-String -Path $inf -Pattern "^UmdfLibraryVersion=$([regex]::Escape($env:UMDF_VERSION))\.0\s*$").Count -ne 1) { + throw "Built INF does not target the required released UMDF $env:UMDF_VERSION baseline" + } + + - name: Upload unsigned driver package + id: upload_unsigned_driver_package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: VDD-${{ matrix.platform }}-${{ env.BUILD_CONFIGURATION }} path: artifacts/VDD/${{ matrix.platform }}/ if-no-files-found: error + + - name: Check SignPath configuration + if: env.SIGNPATH_SIGNING_RUN == 'true' + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORG_ID: ${{ vars.SIGNPATH_ORG_ID }} + SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }} + SIGNPATH_POLICY_SLUG: ${{ vars.SIGNPATH_POLICY_SLUG }} + run: | + $ErrorActionPreference = "Stop" + $required = @("SIGNPATH_API_TOKEN", "SIGNPATH_ORG_ID", "SIGNPATH_PROJECT_SLUG", "SIGNPATH_POLICY_SLUG") + $missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) + if ($missing.Count -gt 0) { + throw "Missing SignPath configuration: $($missing -join ', ')" + } + + # The SignPath project's default artifact configuration must accept the + # GitHub artifact ZIP and preserve MttVDD.dll, MttVDD.inf, MttVDD.cat, + # 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' + # v3.0.0 currently rejects otherwise valid GitHub artifacts with + # "User has no access to the requested GitHub resource" (upstream #18). + # v2.3 is the last Node 24-compatible release before that regression. + uses: signpath/github-action-submit-signing-request@c92b958760219087e01f8d67a1669ed57afe2627 # v2.3.0 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORG_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ vars.SIGNPATH_POLICY_SLUG }} + artifact-configuration-slug: VDD_driver_package + github-artifact-id: ${{ steps.upload_unsigned_driver_package.outputs.artifact-id }} + github-token: ${{ github.token }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 1800 + output-artifact-directory: signed-artifacts/VDD/${{ matrix.platform }}/ + + - name: Verify signed driver package + if: env.SIGNPATH_SIGNING_RUN == 'true' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $signedRoot = "signed-artifacts\VDD\${{ matrix.platform }}" + if (-not (Test-Path $signedRoot)) { throw "SignPath did not create $signedRoot" } + + $driver = @(Get-ChildItem -Path $signedRoot -Recurse -File -Filter "MttVDD.dll") + $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 = "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)" + } + } + + $signtool = Get-ChildItem -Path "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -File -Filter "signtool.exe" | + Where-Object { $_.FullName -match "\\x64\\signtool.exe$" } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe was not found in the installed Windows SDK" } + + & $signtool.FullName verify /pa /v $driver[0].FullName + if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed for $($driver[0].FullName)" } + + & $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 + with: + name: VDD-${{ matrix.platform }}-${{ env.BUILD_CONFIGURATION }}-signed + path: signed-artifacts/VDD/${{ matrix.platform }}/ + if-no-files-found: error + retention-days: 90 + + - name: Add signing summary + if: env.SIGNPATH_SIGNING_RUN == 'true' + shell: pwsh + env: + SIGNING_REQUEST_URL: ${{ steps.submit_signing.outputs.signing-request-web-url }} + run: | + "## SignPath signing (${{ matrix.platform }})" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + '- Signed package: `VDD-${{ matrix.platform }}-${{ env.BUILD_CONFIGURATION }}-signed`' | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- Signing request: $env:SIGNING_REQUEST_URL" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append diff --git a/README.md b/README.md index 83106c24..3dcc9e38 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Virtual Display Driver (HDR)/MttVDD/Driver.cpp b/Virtual Display Driver (HDR)/MttVDD/Driver.cpp index 3145b04a..093c5577 100644 --- a/Virtual Display Driver (HDR)/MttVDD/Driver.cpp +++ b/Virtual Display Driver (HDR)/MttVDD/Driver.cpp @@ -310,8 +310,23 @@ struct IndirectDeviceContextWrapper void Cleanup() { - delete pContext; + // Publish the unavailable state before destruction so a defensive + // callback check can never observe a pointer while it is being freed. + auto* context = pContext; pContext = nullptr; + delete context; + } +}; + +struct IndirectMonitorContextWrapper +{ + IndirectMonitorContext* pContext; + + void Cleanup() + { + auto* context = pContext; + pContext = nullptr; + delete context; } }; void LogQueries(const char* severity, const std::wstring& xmlName) { @@ -1633,6 +1648,21 @@ void InitializeD3DDeviceAndLogGPU() { // This macro creates the methods for accessing an IndirectDeviceContextWrapper as a context for a WDF object WDF_DECLARE_CONTEXT_TYPE(IndirectDeviceContextWrapper); +WDF_DECLARE_CONTEXT_TYPE(IndirectMonitorContextWrapper); + +namespace +{ + IndirectMonitorContext* GetMonitorContextIfReady(IDDCX_MONITOR monitorObject) + { + auto* wrapper = WdfObjectGet_IndirectMonitorContextWrapper(monitorObject); + if (wrapper == nullptr || wrapper->pContext == nullptr) + { + return nullptr; + } + + return wrapper->pContext; + } +} extern "C" BOOL WINAPI DllMain( _In_ HINSTANCE hInstance, @@ -3650,11 +3680,41 @@ void IndirectDeviceContext::CleanupExpiredDevices() } } +IndirectMonitorContext::IndirectMonitorContext( + _In_ IndirectDeviceContext* DeviceContext, + _In_ IDDCX_MONITOR Monitor, + _In_ UINT ConnectorIndex) : + m_DeviceContext(DeviceContext), + m_Monitor(Monitor), + m_ConnectorIndex(ConnectorIndex) +{ +} + +IndirectMonitorContext::~IndirectMonitorContext() +{ + // The device context owns and stops all swap-chain processors. WDF can clean + // the parent before its child monitor objects, so do not dereference the raw + // parent pointer from this child cleanup callback. +} + +IndirectDeviceContext* IndirectMonitorContext::GetDeviceContext() const +{ + return m_DeviceContext; +} + +IDDCX_MONITOR IndirectMonitorContext::GetMonitor() const +{ + return m_Monitor; +} + +UINT IndirectMonitorContext::GetConnectorIndex() const +{ + return m_ConnectorIndex; +} + IndirectDeviceContext::IndirectDeviceContext(_In_ WDFDEVICE WdfDevice) : m_WdfDevice(WdfDevice), - m_Adapter(nullptr), - m_Monitor(nullptr), - m_Monitor2(nullptr) + m_Adapter(nullptr) { // Initialize Phase 5: Final Integration and Testing NTSTATUS initStatus = InitializePhase5Integration(); @@ -3795,7 +3855,15 @@ void IndirectDeviceContext::CreateMonitor(unsigned int index) { // ============================== WDF_OBJECT_ATTRIBUTES Attr; - WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&Attr, IndirectDeviceContextWrapper); + WDF_OBJECT_ATTRIBUTES_INIT_CONTEXT_TYPE(&Attr, IndirectMonitorContextWrapper); + Attr.EvtCleanupCallback = [](WDFOBJECT Object) + { + auto* wrapper = WdfObjectGet_IndirectMonitorContextWrapper(Object); + if (wrapper != nullptr) + { + wrapper->Cleanup(); + } + }; IDDCX_MONITOR_INFO MonitorInfo = {}; MonitorInfo.Size = sizeof(MonitorInfo); @@ -3845,15 +3913,28 @@ void IndirectDeviceContext::CreateMonitor(unsigned int index) { if (NT_SUCCESS(Status)) { vddlog("d", "Monitor created successfully."); - m_Monitor = MonitorCreateOut.MonitorObject; + IDDCX_MONITOR monitorObject = MonitorCreateOut.MonitorObject; - // Associate the monitor with this device context - auto* pContext = WdfObjectGet_IndirectDeviceContextWrapper(MonitorCreateOut.MonitorObject); - pContext->pContext = this; + // Associate monitor-specific state with the WDF monitor object. + auto* monitorWrapper = WdfObjectGet_IndirectMonitorContextWrapper(monitorObject); + if (monitorWrapper == nullptr) + { + vddlog("e", "Failed to get the monitor context wrapper."); + WdfObjectDelete(monitorObject); + return; + } + + monitorWrapper->pContext = new (nothrow) IndirectMonitorContext(this, monitorObject, index); + if (monitorWrapper->pContext == nullptr) + { + vddlog("e", "Failed to allocate the monitor runtime context."); + WdfObjectDelete(monitorObject); + return; + } // Tell the OS that the monitor has been plugged in IDARG_OUT_MONITORARRIVAL ArrivalOut; - Status = IddCxMonitorArrival(m_Monitor, &ArrivalOut); + Status = IddCxMonitorArrival(monitorObject, &ArrivalOut); if (NT_SUCCESS(Status)) { vddlog("d", "Monitor arrival successfully reported."); @@ -3863,6 +3944,13 @@ void IndirectDeviceContext::CreateMonitor(unsigned int index) { stringstream ss; ss << "Failed to report monitor arrival. Status: " << Status; vddlog("e", ss.str().c_str()); + + // Arrival failure leaves no usable monitor. Tear down the context and + // WDF object so later callbacks cannot observe a half-created monitor. + IndirectMonitorContext* failedContext = monitorWrapper->pContext; + monitorWrapper->pContext = nullptr; + delete failedContext; + WdfObjectDelete(monitorObject); } } else @@ -3873,8 +3961,17 @@ void IndirectDeviceContext::CreateMonitor(unsigned int index) { } } -void IndirectDeviceContext::AssignSwapChain(IDDCX_MONITOR Monitor, IDDCX_SWAPCHAIN SwapChain, LUID RenderAdapter, HANDLE NewFrameEvent) +void IndirectDeviceContext::AssignSwapChain(IndirectMonitorContext* MonitorContext, IDDCX_SWAPCHAIN SwapChain, LUID RenderAdapter, HANDLE NewFrameEvent) { + if (MonitorContext == nullptr) + { + vddlog("e", "Cannot assign a swap chain without a monitor context."); + WdfObjectDelete(SwapChain); + return; + } + + IDDCX_MONITOR Monitor = MonitorContext->GetMonitor(); + // Only cleanup expired devices periodically, not on every assignment static int assignmentCount = 0; if (++assignmentCount % 10 == 0) { @@ -3941,7 +4038,7 @@ void IndirectDeviceContext::AssignSwapChain(IDDCX_MONITOR Monitor, IDDCX_SWAPCHA &hwCursor ); - if (FAILED(Status)) + if (!NT_SUCCESS(Status)) { CloseHandle(mouseEvent); return; @@ -3958,8 +4055,14 @@ void IndirectDeviceContext::AssignSwapChain(IDDCX_MONITOR Monitor, IDDCX_SWAPCHA } -void IndirectDeviceContext::UnassignSwapChain(IDDCX_MONITOR Monitor) +void IndirectDeviceContext::UnassignSwapChain(IndirectMonitorContext* MonitorContext) { + if (MonitorContext == nullptr) + { + return; + } + + IDDCX_MONITOR Monitor = MonitorContext->GetMonitor(); std::unique_ptr processorToStop; { @@ -3992,21 +4095,30 @@ NTSTATUS VirtualDisplayDriverAdapterInitFinished(IDDCX_ADAPTER AdapterObject, co // This is called when the OS has finished setting up the adapter for use by the IddCx driver. It's now possible // to report attached monitors. - auto* pContext = WdfObjectGet_IndirectDeviceContextWrapper(AdapterObject); - if (NT_SUCCESS(pInArgs->AdapterInitStatus)) + if (pInArgs == nullptr) { - pContext->pContext->FinishInit(); - vddlog("d", "Adapter initialization finished successfully."); + vddlog("e", "Adapter initialization callback received null input arguments."); + return STATUS_INVALID_PARAMETER; } - else + + if (!NT_SUCCESS(pInArgs->AdapterInitStatus)) { stringstream ss; ss << "Adapter initialization failed. Status: " << pInArgs->AdapterInitStatus; vddlog("e", ss.str().c_str()); + return pInArgs->AdapterInitStatus; + } + + auto* pContext = WdfObjectGet_IndirectDeviceContextWrapper(AdapterObject); + if (pContext == nullptr || pContext->pContext == nullptr) + { + vddlog("e", "Adapter initialization completed without a valid device context."); + return STATUS_INVALID_DEVICE_STATE; } - vddlog("i", "Finished Setting up adapter."); - + pContext->pContext->FinishInit(); + vddlog("d", "Adapter initialization finished successfully."); + vddlog("i", "Finished Setting up adapter."); return STATUS_SUCCESS; } @@ -4016,13 +4128,9 @@ NTSTATUS VirtualDisplayDriverAdapterCommitModes(IDDCX_ADAPTER AdapterObject, con UNREFERENCED_PARAMETER(AdapterObject); UNREFERENCED_PARAMETER(pInArgs); - // For the sample, do nothing when modes are picked - the swap-chain is taken care of by IddCx - - // ============================== - // TODO: In a real driver, this function would be used to reconfigure the device to commit the new modes. Loop - // through pInArgs->pPaths and look for IDDCX_PATH_FLAGS_ACTIVE. Any path not active is inactive (e.g. the monitor - // should be turned off). - // ============================== + // IddCx owns swap-chain lifetime and reports transitions through the + // assign/unassign callbacks. Do not tear down a swap chain from CommitModes; + // doing so races the display pipeline while the path is being committed. return STATUS_SUCCESS; } @@ -4034,6 +4142,11 @@ NTSTATUS VirtualDisplayDriverParseMonitorDescription(const IDARG_IN_PARSEMONITOR // this sample driver, we hard-code the EDID, so this function can generate known modes. // ============================== + if (pInArgs == nullptr || pOutArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + stringstream logStream; logStream << "Parsing monitor description. Input buffer count: " << pInArgs->MonitorModeBufferInputCount; vddlog("d", logStream.str().c_str()); @@ -4055,6 +4168,11 @@ NTSTATUS VirtualDisplayDriverParseMonitorDescription(const IDARG_IN_PARSEMONITOR } else { + if (pInArgs->pMonitorModes == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + // Copy the known modes to the output buffer for (DWORD ModeIndex = 0; ModeIndex < monitorModes.size(); ModeIndex++) { @@ -4073,7 +4191,11 @@ NTSTATUS VirtualDisplayDriverParseMonitorDescription(const IDARG_IN_PARSEMONITOR _Use_decl_annotations_ NTSTATUS VirtualDisplayDriverMonitorGetDefaultModes(IDDCX_MONITOR MonitorObject, const IDARG_IN_GETDEFAULTDESCRIPTIONMODES* pInArgs, IDARG_OUT_GETDEFAULTDESCRIPTIONMODES* pOutArgs) { - UNREFERENCED_PARAMETER(MonitorObject); + if (GetMonitorContextIfReady(MonitorObject) == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } + UNREFERENCED_PARAMETER(pInArgs); UNREFERENCED_PARAMETER(pOutArgs); @@ -4170,7 +4292,15 @@ void CreateTargetMode2(IDDCX_TARGET_MODE2& Mode, UINT Width, UINT Height, UINT V _Use_decl_annotations_ NTSTATUS VirtualDisplayDriverMonitorQueryModes(IDDCX_MONITOR MonitorObject, const IDARG_IN_QUERYTARGETMODES* pInArgs, IDARG_OUT_QUERYTARGETMODES* pOutArgs)//////////////////////////////////////////////////////////////////////////////// { - UNREFERENCED_PARAMETER(MonitorObject); + if (pInArgs == nullptr || pOutArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + + if (GetMonitorContextIfReady(MonitorObject) == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } vector TargetModes(monitorModes.size()); @@ -4198,18 +4328,28 @@ NTSTATUS VirtualDisplayDriverMonitorQueryModes(IDDCX_MONITOR MonitorObject, cons logStream << "Number of target modes to output: " << pOutArgs->TargetModeBufferOutputCount; vddlog("d", logStream.str().c_str()); - if (pInArgs->TargetModeBufferInputCount >= TargetModes.size()) + if (pInArgs->TargetModeBufferInputCount == 0) { - logStream.str(""); - logStream << "Copying target modes to output buffer."; - vddlog("d", logStream.str().c_str()); - copy(TargetModes.begin(), TargetModes.end(), pInArgs->pTargetModes); + return STATUS_SUCCESS; } - else { + else if (pInArgs->TargetModeBufferInputCount < TargetModes.size()) + { logStream.str(""); logStream << "Input buffer too small. Required: " << TargetModes.size() << ", Provided: " << pInArgs->TargetModeBufferInputCount; vddlog("w", logStream.str().c_str()); + return STATUS_BUFFER_TOO_SMALL; + } + else if (pInArgs->pTargetModes == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + else + { + logStream.str(""); + logStream << "Copying target modes to output buffer."; + vddlog("d", logStream.str().c_str()); + copy(TargetModes.begin(), TargetModes.end(), pInArgs->pTargetModes); } return STATUS_SUCCESS; @@ -4218,14 +4358,24 @@ NTSTATUS VirtualDisplayDriverMonitorQueryModes(IDDCX_MONITOR MonitorObject, cons _Use_decl_annotations_ NTSTATUS VirtualDisplayDriverMonitorAssignSwapChain(IDDCX_MONITOR MonitorObject, const IDARG_IN_SETSWAPCHAIN* pInArgs) { + if (pInArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + + auto* monitorContext = GetMonitorContextIfReady(MonitorObject); + if (monitorContext == nullptr || monitorContext->GetDeviceContext() == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } + stringstream logStream; logStream << "Assigning swap chain:" << "\n hSwapChain: " << pInArgs->hSwapChain << "\n RenderAdapterLuid: " << pInArgs->RenderAdapterLuid.LowPart << "-" << pInArgs->RenderAdapterLuid.HighPart << "\n hNextSurfaceAvailable: " << pInArgs->hNextSurfaceAvailable; vddlog("d", logStream.str().c_str()); - auto* pContext = WdfObjectGet_IndirectDeviceContextWrapper(MonitorObject); - pContext->pContext->AssignSwapChain(MonitorObject, pInArgs->hSwapChain, pInArgs->RenderAdapterLuid, pInArgs->hNextSurfaceAvailable); + monitorContext->GetDeviceContext()->AssignSwapChain(monitorContext, pInArgs->hSwapChain, pInArgs->RenderAdapterLuid, pInArgs->hNextSurfaceAvailable); vddlog("d", "Swap chain assigned successfully."); return STATUS_SUCCESS; } @@ -4233,11 +4383,16 @@ NTSTATUS VirtualDisplayDriverMonitorAssignSwapChain(IDDCX_MONITOR MonitorObject, _Use_decl_annotations_ NTSTATUS VirtualDisplayDriverMonitorUnassignSwapChain(IDDCX_MONITOR MonitorObject) { + auto* monitorContext = GetMonitorContextIfReady(MonitorObject); + if (monitorContext == nullptr || monitorContext->GetDeviceContext() == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } + stringstream logStream; logStream << "Unassigning swap chain for monitor object: " << MonitorObject; vddlog("d", logStream.str().c_str()); - auto* pContext = WdfObjectGet_IndirectDeviceContextWrapper(MonitorObject); - pContext->pContext->UnassignSwapChain(MonitorObject); + monitorContext->GetDeviceContext()->UnassignSwapChain(monitorContext); vddlog("d", "Swap chain unassigned successfully."); return STATUS_SUCCESS; } @@ -4287,7 +4442,15 @@ NTSTATUS VirtualDisplayDriverEvtIddCxMonitorSetDefaultHdrMetadata( const IDARG_IN_MONITOR_SET_DEFAULT_HDR_METADATA* pInArgs ) { - UNREFERENCED_PARAMETER(pInArgs); + if (pInArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + + if (GetMonitorContextIfReady(MonitorObject) == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } stringstream logStream; logStream << "=== PROCESSING HDR METADATA REQUEST ==="; @@ -4387,6 +4550,11 @@ NTSTATUS VirtualDisplayDriverEvtIddCxParseMonitorDescription2( // this sample driver, we hard-code the EDID, so this function can generate known modes. // ============================== + if (pInArgs == nullptr || pOutArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + stringstream logStream; logStream << "Parsing monitor description:" << "\n MonitorModeBufferInputCount: " << pInArgs->MonitorModeBufferInputCount @@ -4469,7 +4637,16 @@ NTSTATUS VirtualDisplayDriverEvtIddCxMonitorQueryTargetModes2( IDARG_OUT_QUERYTARGETMODES* pOutArgs ) { - //UNREFERENCED_PARAMETER(MonitorObject); + if (pInArgs == nullptr || pOutArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + + if (GetMonitorContextIfReady(MonitorObject) == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } + stringstream logStream; logStream << "Querying target modes:" @@ -4501,7 +4678,20 @@ NTSTATUS VirtualDisplayDriverEvtIddCxMonitorQueryTargetModes2( logStream << "Output target modes count: " << pOutArgs->TargetModeBufferOutputCount; vddlog("d", logStream.str().c_str()); - if (pInArgs->TargetModeBufferInputCount >= TargetModes.size()) + if (pInArgs->TargetModeBufferInputCount == 0) + { + return STATUS_SUCCESS; + } + else if (pInArgs->TargetModeBufferInputCount < TargetModes.size()) + { + vddlog("w", "Input buffer is too small for target modes."); + return STATUS_BUFFER_TOO_SMALL; + } + else if (pInArgs->pTargetModes == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + else { copy(TargetModes.begin(), TargetModes.end(), pInArgs->pTargetModes); @@ -4515,11 +4705,6 @@ NTSTATUS VirtualDisplayDriverEvtIddCxMonitorQueryTargetModes2( } vddlog("d", logStream.str().c_str()); } - else - { - vddlog("w", "Input buffer is too small for target modes."); - } - return STATUS_SUCCESS; } @@ -4532,6 +4717,9 @@ NTSTATUS VirtualDisplayDriverEvtIddCxAdapterCommitModes2( UNREFERENCED_PARAMETER(AdapterObject); UNREFERENCED_PARAMETER(pInArgs); + // Swap-chain lifetime is driven exclusively by IddCx's assign/unassign + // callbacks. CommitModes2 is notification-only for this driver. + return STATUS_SUCCESS; } @@ -4541,6 +4729,16 @@ NTSTATUS VirtualDisplayDriverEvtIddCxMonitorSetGammaRamp( const IDARG_IN_SET_GAMMARAMP* pInArgs ) { + if (pInArgs == nullptr) + { + return STATUS_INVALID_PARAMETER; + } + + if (GetMonitorContextIfReady(MonitorObject) == nullptr) + { + return STATUS_INVALID_DEVICE_STATE; + } + stringstream logStream; logStream << "=== PROCESSING GAMMA RAMP REQUEST ==="; vddlog("d", logStream.str().c_str()); diff --git a/Virtual Display Driver (HDR)/MttVDD/Driver.h b/Virtual Display Driver (HDR)/MttVDD/Driver.h index b48b97ff..89040d24 100644 --- a/Virtual Display Driver (HDR)/MttVDD/Driver.h +++ b/Virtual Display Driver (HDR)/MttVDD/Driver.h @@ -49,6 +49,8 @@ namespace Microsoft { namespace IndirectDisp { + class IndirectDeviceContext; + /// /// Manages the creation and lifetime of a Direct3D render device. /// @@ -101,6 +103,28 @@ namespace Microsoft } }; + /// + /// Owns state whose lifetime is tied to one IddCx monitor object. + /// + class IndirectMonitorContext + { + public: + IndirectMonitorContext( + _In_ IndirectDeviceContext* DeviceContext, + _In_ IDDCX_MONITOR Monitor, + _In_ UINT ConnectorIndex); + ~IndirectMonitorContext(); + + IndirectDeviceContext* GetDeviceContext() const; + IDDCX_MONITOR GetMonitor() const; + UINT GetConnectorIndex() const; + + private: + IndirectDeviceContext* m_DeviceContext; + IDDCX_MONITOR m_Monitor; + UINT m_ConnectorIndex; + }; + /// /// Provides a sample implementation of an indirect display driver. /// @@ -115,15 +139,13 @@ namespace Microsoft void CreateMonitor(unsigned int index); - void AssignSwapChain(IDDCX_MONITOR Monitor, IDDCX_SWAPCHAIN SwapChain, LUID RenderAdapter, HANDLE NewFrameEvent); - void UnassignSwapChain(IDDCX_MONITOR Monitor); + void AssignSwapChain(IndirectMonitorContext* MonitorContext, IDDCX_SWAPCHAIN SwapChain, LUID RenderAdapter, HANDLE NewFrameEvent); + void UnassignSwapChain(IndirectMonitorContext* MonitorContext); protected: WDFDEVICE m_WdfDevice; IDDCX_ADAPTER m_Adapter; - IDDCX_MONITOR m_Monitor; - IDDCX_MONITOR m_Monitor2; std::map> m_ProcessingThreads; std::mutex m_ProcessingThreadsMutex; diff --git a/Virtual Display Driver (HDR)/MttVDD/MttVDD.vcxproj b/Virtual Display Driver (HDR)/MttVDD/MttVDD.vcxproj index 6d20ef7f..833241bb 100644 --- a/Virtual Display Driver (HDR)/MttVDD/MttVDD.vcxproj +++ b/Virtual Display Driver (HDR)/MttVDD/MttVDD.vcxproj @@ -175,6 +175,12 @@ + + + $([System.DateTime]::UtcNow.ToString('yy.M.d')).1 + $([System.DateTime]::UtcNow.ToString('MM/dd/yyyy')) + DbgengRemoteDebugger true @@ -324,9 +330,6 @@ copy "$(ProjectDir)..\vdd_settings.xml" "$(TargetDir)\MttVDD" - - 10/16/2024 - @@ -348,8 +351,11 @@ copy "$(ProjectDir)..\vdd_settings.xml" "$(TargetDir)\MttVDD" + + - 10/16/2024 + $(VddDriverDate) + $(VddDriverVersion) @@ -364,4 +370,4 @@ - \ No newline at end of file + diff --git a/VirtualDriverControl/.gitignore b/VirtualDriverControl/.gitignore new file mode 100644 index 00000000..2bcdeb0c --- /dev/null +++ b/VirtualDriverControl/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +out/ +out-ps-check/ +*.tsbuildinfo diff --git a/VirtualDriverControl/README.md b/VirtualDriverControl/README.md new file mode 100644 index 00000000..41c5db70 --- /dev/null +++ b/VirtualDriverControl/README.md @@ -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 `, 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. diff --git a/VirtualDriverControl/electron.vite.config.ts b/VirtualDriverControl/electron.vite.config.ts new file mode 100644 index 00000000..fc8b85b2 --- /dev/null +++ b/VirtualDriverControl/electron.vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from 'electron-vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +export default defineConfig({ + main: { + define: { + 'process.env.VDC_RELEASE_VERSION': JSON.stringify(process.env.VDC_RELEASE_VERSION ?? '') + }, + 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()] + } +}) diff --git a/VirtualDriverControl/package-lock.json b/VirtualDriverControl/package-lock.json new file mode 100644 index 00000000..36b664fc --- /dev/null +++ b/VirtualDriverControl/package-lock.json @@ -0,0 +1,6167 @@ +{ + "name": "virtual-driver-control", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "virtual-driver-control", + "version": "2.0.0", + "license": "MIT", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.2.tgz", + "integrity": "sha512-VJuNETNPEhrmQEZezeTZO5TZMV+dobBRyJ7zHjGJWIhMS7m7W1UeClt69u4hkUxv9ZZVxuli/E9Yvc4gDNHGsg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.2.tgz", + "integrity": "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.2", + "electron-builder-squirrel-windows": "26.15.2" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.0.tgz", + "integrity": "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.2.tgz", + "integrity": "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.2", + "builder-util": "26.15.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "42.11.6", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.6.tgz", + "integrity": "sha512-IgDsjQp+CYLnArfQbzzS2VmkRgHmSU1G9nGVOtSnFpkUYR8rkeAUSDO77qtqAPX1jgAGly1kgisUmwB9CbUHXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.2.tgz", + "integrity": "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.2", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.2", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.2.tgz", + "integrity": "sha512-PNl+SSRoma9mXhxycNGxutZPgvmK19v41mn8F9oecpAU2QNAldpB4HfMuA1LwFC2j8aRzzV5M9HKlKe6dfpvNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.2", + "builder-util": "26.15.0", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.1.tgz", + "integrity": "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.0", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "framer-motion": "^12.40.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abi": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.0" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/VirtualDriverControl/package.json b/VirtualDriverControl/package.json new file mode 100644 index 00000000..91018a13 --- /dev/null +++ b/VirtualDriverControl/package.json @@ -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}-${buildVersion}-${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" + } +} diff --git a/VirtualDriverControl/resources/VDD_Red.ico b/VirtualDriverControl/resources/VDD_Red.ico new file mode 100644 index 00000000..bfd4adcf Binary files /dev/null and b/VirtualDriverControl/resources/VDD_Red.ico differ diff --git a/VirtualDriverControl/resources/VDD_Yellow.ico b/VirtualDriverControl/resources/VDD_Yellow.ico new file mode 100644 index 00000000..2e59e903 Binary files /dev/null and b/VirtualDriverControl/resources/VDD_Yellow.ico differ diff --git a/VirtualDriverControl/resources/VirtualDisplayDriver.ico b/VirtualDriverControl/resources/VirtualDisplayDriver.ico new file mode 100644 index 00000000..49ce594e Binary files /dev/null and b/VirtualDriverControl/resources/VirtualDisplayDriver.ico differ diff --git a/VirtualDriverControl/scripts/render-audio-interop.mjs b/VirtualDriverControl/scripts/render-audio-interop.mjs new file mode 100644 index 00000000..30d30333 --- /dev/null +++ b/VirtualDriverControl/scripts/render-audio-interop.mjs @@ -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')) diff --git a/VirtualDriverControl/scripts/render-ps-scripts.mjs b/VirtualDriverControl/scripts/render-ps-scripts.mjs new file mode 100644 index 00000000..9be71b70 --- /dev/null +++ b/VirtualDriverControl/scripts/render-ps-scripts.mjs @@ -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) diff --git a/VirtualDriverControl/src/main/index.ts b/VirtualDriverControl/src/main/index.ts new file mode 100644 index 00000000..3fa72b3b --- /dev/null +++ b/VirtualDriverControl/src/main/index.ts @@ -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 = { + 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 { + 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 { + 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() + }) +} diff --git a/VirtualDriverControl/src/main/ipc.ts b/VirtualDriverControl/src/main/ipc.ts new file mode 100644 index 00000000..b8dcf0d7 --- /dev/null +++ b/VirtualDriverControl/src/main/ipc.ts @@ -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 = new Set([ + 'HDRPLUS', + 'SDR10', + 'CUSTOMEDID', + 'PREVENTSPOOF', + 'CEAOVERRIDE', + 'HARDWARECURSOR', + 'LOGGING', + 'LOG_DEBUG' +]) + +const QUERY_COMMANDS: ReadonlySet = 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('')) 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) } + 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 = { + 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 + 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') +} diff --git a/VirtualDriverControl/src/main/services/audio-service.ts b/VirtualDriverControl/src/main/services/audio-service.ts new file mode 100644 index 00000000..5ece58a3 --- /dev/null +++ b/VirtualDriverControl/src/main/services/audio-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'})`) + } + } +} diff --git a/VirtualDriverControl/src/main/services/display-service.ts b/VirtualDriverControl/src/main/services/display-service.ts new file mode 100644 index 00000000..faff2abe --- /dev/null +++ b/VirtualDriverControl/src/main/services/display-service.ts @@ -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 = new Set() + private virtualCheckedAt = 0 + private refreshing: Promise | null = null + private notify: (() => void) | null = null + + async layout(): Promise { + // 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 { + 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() + 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 + } +} diff --git a/VirtualDriverControl/src/main/services/driver-service.ts b/VirtualDriverControl/src/main/services/driver-service.ts new file mode 100644 index 00000000..5151639a --- /dev/null +++ b/VirtualDriverControl/src/main/services/driver-service.ts @@ -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 { + 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 { + 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 { + const fromPipe = await this.gpusViaPipe() + if (fromPipe.length > 0) return fromPipe + return this.gpusViaWmi() + } + + async assignedGpu(): Promise { + 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 { + 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 { + 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 { + 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 { + const isAdmin = await this.isAdmin() + return { + windowsVersion: os.version(), + windowsBuild: os.release(), + arch: os.arch(), + isAdmin, + appVersion: process.env.VDC_RELEASE_VERSION || app.getVersion(), + electronVersion: process.versions.electron, + settingsPath: `${this.paths.getBaseDir()}\\vdd_settings.xml`, + logsDir: `${this.paths.getBaseDir()}\\Logs` + } + } + + private async queryDevice(force: boolean): Promise { + 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 { + 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 { + 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)) +} diff --git a/VirtualDriverControl/src/main/services/installer-service.ts b/VirtualDriverControl/src/main/services/installer-service.ts new file mode 100644 index 00000000..e3d3747e --- /dev/null +++ b/VirtualDriverControl/src/main/services/installer-service.ts @@ -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 = { + 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, + private readonly emitProgress: (progress: InstallProgress) => void + ) {} + + // ------------------------------------------------------------------------- + // Release discovery / state + // ------------------------------------------------------------------------- + + async latestRelease(driver: ManagedDriverId): Promise { + 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 { + const read = async (path: string): Promise => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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((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((resolve) => out.end(resolve)) + } + } + + private async findFile(root: string, pattern: RegExp): Promise { + 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 { + 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 { + 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') +} diff --git a/VirtualDriverControl/src/main/services/log-service.ts b/VirtualDriverControl/src/main/services/log-service.ts new file mode 100644 index 00000000..39cbc60d --- /dev/null +++ b/VirtualDriverControl/src/main/services/log-service.ts @@ -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 \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 { + 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' +} diff --git a/VirtualDriverControl/src/main/services/pipe-client.ts b/VirtualDriverControl/src/main/services/pipe-client.ts new file mode 100644 index 00000000..36dcb8cb --- /dev/null +++ b/VirtualDriverControl/src/main/services/pipe-client.ts @@ -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 = Promise.resolve() + private lastReloadFinishedAt = 0 + private helperPath: string | null = null + + /** Serialized send. Resolves with a PipeResult, never rejects. */ + send(command: string, options: PipeSendOptions = {}): Promise { + const run = this.queue.then(() => this.execute(command, options)) + this.queue = run.catch(() => undefined) + return run + } + + async ping(): Promise { + // 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 { + const n = Math.max(0, Math.min(99, Math.floor(count))) + return this.send(`SETDISPLAYCOUNT ${n}`) + } + + async setToggle(name: PipeToggleCommand, value: boolean): Promise { + return this.send(`${name} ${value ? 'true' : 'false'}`) + } + + async setGpu(friendlyName: string): Promise { + const clean = friendlyName.replace(/["\r\n]/g, '').slice(0, 100) + return this.send(`SETGPU "${clean}"`) + } + + async getDriverSettings(): Promise { + 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 { + 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 { + return new Promise((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 { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/VirtualDriverControl/src/main/services/prefs-service.ts b/VirtualDriverControl/src/main/services/prefs-service.ts new file mode 100644 index 00000000..e4219ada --- /dev/null +++ b/VirtualDriverControl/src/main/services/prefs-service.ts @@ -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 { + 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 + * `\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 { + 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 { + 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 { + 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 { + 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 { + 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 + // 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(/[\\/]+$/, '') +} diff --git a/VirtualDriverControl/src/main/services/settings-service.ts b/VirtualDriverControl/src/main/services/settings-service.ts new file mode 100644 index 00000000..3edf66f7 --- /dev/null +++ b/VirtualDriverControl/src/main/services/settings-service.ts @@ -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 + +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 { + 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 { + 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 { + try { + return await fs.readFile(this.settingsPath, 'utf8') + } catch { + return null + } + } + + async save(settings: VddSettings): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(``) + lines.push(``) + lines.push(``) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${Math.max(0, Math.min(99, Math.floor(s.monitors.count)))}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` ${escapeXml(s.gpu.friendlyName || 'default')}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + for (const rate of s.global.refreshRates) { + lines.push(` ${formatRate(rate)}`) + } + lines.push(` `) + lines.push(``) + lines.push(` `) + for (const res of s.resolutions) { + lines.push(` `) + lines.push(` ${Math.floor(res.width)}`) + lines.push(` ${Math.floor(res.height)}`) + for (const rate of res.refreshRates) { + lines.push(` ${formatRate(rate)}`) + } + lines.push(` `) + } + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.logging.sendLogsThroughPipe)}`) + lines.push(` ${b(s.logging.logging)}`) + lines.push(` ${b(s.logging.debugLogging)}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.colour.sdr10bit)}`) + lines.push(` ${b(s.colour.hdrPlus)}`) + lines.push(` ${s.colour.colourFormat}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.cursor.hardwareCursor)}`) + lines.push(` ${Math.floor(s.cursor.cursorMaxX)}`) + lines.push(` ${Math.floor(s.cursor.cursorMaxY)}`) + lines.push(` ${b(s.cursor.alphaCursorSupport)}`) + lines.push(` ${Math.floor(s.cursor.xorCursorSupportLevel)}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.edid.customEdid)}`) + lines.push(` ${b(s.edid.preventSpoof)}`) + lines.push(` ${b(s.edid.edidCeaOverride)}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.edidIntegration.enabled)}`) + lines.push(` ${b(s.edidIntegration.autoConfigureFromEdid)}`) + lines.push(` ${escapeXml(s.edidIntegration.edidProfilePath)}`) + lines.push(` ${b(s.edidIntegration.overrideManualSettings)}`) + lines.push(` ${b(s.edidIntegration.fallbackOnError)}`) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.hdrAdvanced.hdr10StaticMetadata.enabled)}`) + lines.push( + ` ${s.hdrAdvanced.hdr10StaticMetadata.maxDisplayMasteringLuminance.toFixed(1)}` + ) + lines.push( + ` ${s.hdrAdvanced.hdr10StaticMetadata.minDisplayMasteringLuminance}` + ) + lines.push( + ` ${Math.floor(s.hdrAdvanced.hdr10StaticMetadata.maxContentLightLevel)}` + ) + lines.push( + ` ${Math.floor(s.hdrAdvanced.hdr10StaticMetadata.maxFrameAvgLightLevel)}` + ) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.hdrAdvanced.colorPrimaries.enabled)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.redX.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.redY.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.greenX.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.greenY.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.blueX.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.blueY.toFixed(3)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.whiteX.toFixed(4)}`) + lines.push(` ${s.hdrAdvanced.colorPrimaries.whiteY.toFixed(4)}`) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.hdrAdvanced.colorSpace.enabled)}`) + lines.push(` ${s.hdrAdvanced.colorSpace.gammaCorrection}`) + lines.push(` ${escapeXml(s.hdrAdvanced.colorSpace.primaryColorSpace)}`) + lines.push(` ${b(s.hdrAdvanced.colorSpace.enableMatrixTransform)}`) + lines.push(` `) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.autoResolutions.enabled)}`) + lines.push(` ${escapeXml(s.autoResolutions.sourcePriority)}`) + lines.push(` `) + lines.push(` ${formatRate(s.autoResolutions.edidModeFiltering.minRefreshRate)}`) + lines.push(` ${formatRate(s.autoResolutions.edidModeFiltering.maxRefreshRate)}`) + lines.push( + ` ${b(s.autoResolutions.edidModeFiltering.excludeFractionalRates)}` + ) + lines.push( + ` ${Math.floor(s.autoResolutions.edidModeFiltering.minResolutionWidth)}` + ) + lines.push( + ` ${Math.floor(s.autoResolutions.edidModeFiltering.minResolutionHeight)}` + ) + lines.push( + ` ${Math.floor(s.autoResolutions.edidModeFiltering.maxResolutionWidth)}` + ) + lines.push( + ` ${Math.floor(s.autoResolutions.edidModeFiltering.maxResolutionHeight)}` + ) + lines.push(` `) + lines.push(` `) + lines.push(` ${b(s.autoResolutions.preferredMode.useEdidPreferred)}`) + lines.push(` ${Math.floor(s.autoResolutions.preferredMode.fallbackWidth)}`) + lines.push(` ${Math.floor(s.autoResolutions.preferredMode.fallbackHeight)}`) + lines.push(` ${formatRate(s.autoResolutions.preferredMode.fallbackRefresh)}`) + lines.push(` `) + lines.push(` `) + lines.push(``) + lines.push(` `) + lines.push(` `) + lines.push(` `) + lines.push( + ` ${b(s.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace)}` + ) + lines.push(` ${Math.floor(s.colorAdvanced.bitDepthManagement.forceBitDepth)}`) + lines.push( + ` ${b(s.colorAdvanced.bitDepthManagement.fp16SurfaceSupport)}` + ) + lines.push(` `) + lines.push(` `) + lines.push(` ${s.colorAdvanced.colorFormatExtended.sdrWhiteLevel.toFixed(1)}`) + lines.push(` `) + lines.push(` `) + lines.push(``) + lines.push(``) + lines.push(``) + return lines.join('\n') + } +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/VirtualDriverControl/src/preload/index.d.ts b/VirtualDriverControl/src/preload/index.d.ts new file mode 100644 index 00000000..8e25ca9c --- /dev/null +++ b/VirtualDriverControl/src/preload/index.d.ts @@ -0,0 +1,9 @@ +import type { VddApi } from './index' + +declare global { + interface Window { + vdd: VddApi + } +} + +export {} diff --git a/VirtualDriverControl/src/preload/index.ts b/VirtualDriverControl/src/preload/index.ts new file mode 100644 index 00000000..ec43af36 --- /dev/null +++ b/VirtualDriverControl/src/preload/index.ts @@ -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(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 => ipcRenderer.invoke('pipe:ping'), + setDisplayCount: (count: number): Promise => ipcRenderer.invoke('pipe:set-display-count', count), + toggle: (name: PipeToggleCommand, value: boolean): Promise => ipcRenderer.invoke('pipe:toggle', name, value), + setGpu: (name: string): Promise => ipcRenderer.invoke('pipe:set-gpu', name), + query: (command: string): Promise => ipcRenderer.invoke('pipe:query', command), + sendRaw: (command: string): Promise => ipcRenderer.invoke('pipe:send-raw', command), + getDriverSettings: (): Promise => ipcRenderer.invoke('pipe:get-driver-settings') + }, + settings: { + load: (): Promise => ipcRenderer.invoke('settings:load'), + save: (settings: VddSettings): Promise => ipcRenderer.invoke('settings:save', settings), + preview: (settings: VddSettings): Promise => ipcRenderer.invoke('settings:preview', settings), + raw: (): Promise => ipcRenderer.invoke('settings:raw'), + backups: (): Promise => ipcRenderer.invoke('settings:backups'), + restore: (fileName: string): Promise => ipcRenderer.invoke('settings:restore', fileName), + saveMonitorProfile: (xml: string, edidBytes?: Uint8Array): Promise => + ipcRenderer.invoke('settings:save-monitor-profile', xml, edidBytes) + }, + driver: { + status: (force?: boolean): Promise => ipcRenderer.invoke('driver:status', force), + gpus: (): Promise => ipcRenderer.invoke('driver:gpus'), + iddcxVersion: (): Promise => ipcRenderer.invoke('driver:iddcx-version') + }, + installer: { + latestRelease: (driver: ManagedDriverId): Promise => ipcRenderer.invoke('installer:latest-release', driver), + installedTag: (driver: ManagedDriverId): Promise => ipcRenderer.invoke('installer:installed-tag', driver), + deviceState: (driver: ManagedDriverId): Promise => ipcRenderer.invoke('installer:device-state', driver), + install: (driver: ManagedDriverId, instances?: number): Promise => + ipcRenderer.invoke('installer:install', driver, instances), + uninstall: (driver: ManagedDriverId): Promise => ipcRenderer.invoke('installer:uninstall', driver), + restartDevice: (driver: ManagedDriverId): Promise => ipcRenderer.invoke('installer:restart-device', driver), + setInstances: (driver: ManagedDriverId, count: number): Promise => + ipcRenderer.invoke('installer:set-instances', driver, count), + /** Boot-config test signing state - the audio driver is test-signed and needs it. */ + testSigning: (): Promise => ipcRenderer.invoke('installer:test-signing'), + setTestSigning: (enabled: boolean): Promise => ipcRenderer.invoke('installer:set-test-signing', enabled) + }, + audio: { + endpoints: (): Promise => ipcRenderer.invoke('audio:endpoints'), + setDefault: (id: string): Promise => ipcRenderer.invoke('audio:set-default', id), + setVolume: (id: string, volume: number): Promise => ipcRenderer.invoke('audio:set-volume', id, volume), + setMute: (id: string, muted: boolean): Promise => ipcRenderer.invoke('audio:set-mute', id, muted) + }, + system: { + info: (): Promise => ipcRenderer.invoke('system:info'), + displays: (): Promise => ipcRenderer.invoke('system:displays'), + openExternal: (url: string): Promise => ipcRenderer.invoke('shell:open-external', url), + openPath: (which: 'base' | 'logs' | 'backups' | 'edid'): Promise => ipcRenderer.invoke('shell:open-path', which) + }, + logs: { + recent: (): Promise => ipcRenderer.invoke('logs:recent') + }, + prefs: { + get: (): Promise => ipcRenderer.invoke('prefs:get'), + set: (patch: Partial): Promise => ipcRenderer.invoke('prefs:set', patch), + /** Changes the driver folder - updates the VDDPATH registry value and the preference atomically. */ + setBaseDir: (baseDir: string): Promise => 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) diff --git a/VirtualDriverControl/src/renderer/index.html b/VirtualDriverControl/src/renderer/index.html new file mode 100644 index 00000000..ccaf7a2f --- /dev/null +++ b/VirtualDriverControl/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + Virtual Driver Control + + + + +
+ + + diff --git a/VirtualDriverControl/src/renderer/src/App.tsx b/VirtualDriverControl/src/renderer/src/App.tsx new file mode 100644 index 00000000..4439e137 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/App.tsx @@ -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 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 ( +
+ + +
+ + + + + + +
+ +
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/assets/logo-err.png b/VirtualDriverControl/src/renderer/src/assets/logo-err.png new file mode 100644 index 00000000..b729a35b Binary files /dev/null and b/VirtualDriverControl/src/renderer/src/assets/logo-err.png differ diff --git a/VirtualDriverControl/src/renderer/src/assets/logo-ok.png b/VirtualDriverControl/src/renderer/src/assets/logo-ok.png new file mode 100644 index 00000000..3cd54cb3 Binary files /dev/null and b/VirtualDriverControl/src/renderer/src/assets/logo-ok.png differ diff --git a/VirtualDriverControl/src/renderer/src/assets/logo-warn.png b/VirtualDriverControl/src/renderer/src/assets/logo-warn.png new file mode 100644 index 00000000..fe8a2755 Binary files /dev/null and b/VirtualDriverControl/src/renderer/src/assets/logo-warn.png differ diff --git a/VirtualDriverControl/src/renderer/src/components/ArrangementMap.tsx b/VirtualDriverControl/src/renderer/src/components/ArrangementMap.tsx new file mode 100644 index 00000000..5f971ee2 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/ArrangementMap.tsx @@ -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([]) + + 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(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 =
Reading display topology…
+ } 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 ( +
+ + {d.primary && } + {d.label} + + + {pxW}×{pxH} + + + {d.frequency ? `${d.frequency} Hz` : ''} + {d.scaleFactor !== 1 ? ` · ${Math.round(d.scaleFactor * 100)}%` : ''} + + {d.isVirtual && VIRTUAL} +
+ ) + }) + } + + return ( +
+
+ {busy && ( +
+
+ + {busy} +
+
+ )} + {monitors} +
+ +
+
+ {physicalCount} physical + 0 ? 'on' : ''}`}>{virtualCount} virtual + {count > 0 && virtualCount === 0 && ( + + {count} configured — {online ? 'applying…' : 'appears when the driver is running'} + + )} +
+
+ + Virtual displays +
+ + {count} + +
+
+
+
+ ) +} + +export function ArrangementMap(): React.JSX.Element { + return ( + + + + ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/CieDiagram.tsx b/VirtualDriverControl/src/renderer/src/components/CieDiagram.tsx new file mode 100644 index 00000000..23eb3109 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/CieDiagram.tsx @@ -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 = { + 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) => 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(null) + const svgRef = useRef(null) + const [dragging, setDragging] = useState(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 ( +
+ + + + + {handles.map((h) => ( + { + if (props.disabled) return + e.preventDefault() + setDragging(h.id) + }} + > + + + + {HANDLE_META[h.id].label} + + + ))} + +
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/DriverLifecycle.tsx b/VirtualDriverControl/src/renderer/src/components/DriverLifecycle.tsx new file mode 100644 index 00000000..f41bd389 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/DriverLifecycle.tsx @@ -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 = { + display: 'Display driver lifecycle', + audio: 'Audio driver lifecycle' +} + +const REPOS: Record = { + 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(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 ( + void checkLatest(driver)}> + {checking ? : } Check for updates + + } + > +
+
+ + Installed + + + {installed ? installedTag ?? 'Installed' : 'Not installed'} + + {installedDetail()} +
+ +
+ + Latest release + + {checking ? 'Checking…' : latest?.tag ?? '—'} + + {checkError + ? `Check failed: ${checkError}` + : latest + ? `${formatDate(latest.publishedAt)}${latest.asset ? ` · ${latest.asset.name} (${formatSize(latest.asset.sizeBytes)})` : ' · no driver package'}` + : 'Fetching from GitHub…'} + + {latest && ( + + )} +
+
+ + {driver === 'audio' && ( +
+ {testSigning === true ? : } + + The Virtual Audio Driver is currently test-signed, so Windows must run in{' '} + Test Signing mode 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.'} + + {testSigning !== null && ( + + )} +
+ )} + + {busyHere && progress && ( +
+
+
= 0 ? { width: `${progress.percent}%` } : undefined} + /> +
+ {progress.message} +
+ )} + +
+ + {installed && ( + <> + + + + )} + {updateAvailable && ( + + Update available: {installedTag} → {latest?.tag} + + )} +
+ + {multiInstance && ( +
+ + Devices + + ({ value: String(n), label: String(n) }))} + onChange={(v) => setInstanceChoice(Number(v))} + /> + {installed && selectedInstances !== deviceCount && ( + + )} + + Each device adds an independent virtual speaker + microphone pair for routing. + +
+ )} + + ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/SaveBar.tsx b/VirtualDriverControl/src/renderer/src/components/SaveBar.tsx new file mode 100644 index 00000000..e41c1694 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/SaveBar.tsx @@ -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 ( + + {dirty && ( + + Unsaved configuration changes + + + + + )} + + ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/Sidebar.tsx b/VirtualDriverControl/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 00000000..bb4fba1a --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/Sidebar.tsx @@ -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 ( + + ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/TitleBar.tsx b/VirtualDriverControl/src/renderer/src/components/TitleBar.tsx new file mode 100644 index 00000000..aa859596 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/TitleBar.tsx @@ -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 = { + 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 ( +
+
+ + Virtual Driver Control +
+ +
+ + {meta.label} +
+ + {busy && ( +
+ + {busy} +
+ )} + + {sysInfo && !sysInfo.isAdmin && ( +
+ + Not elevated +
+ )} + +
+ +
+ + + +
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/Toasts.tsx b/VirtualDriverControl/src/renderer/src/components/Toasts.tsx new file mode 100644 index 00000000..ed5cfa69 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/Toasts.tsx @@ -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 = { + success: , + error: , + info: , + warning: +} + +export function Toasts(): React.JSX.Element { + const toasts = useUi((s) => s.toasts) + const dismiss = useUi((s) => s.dismissToast) + + return ( +
+ + {toasts.map((toast) => ( + + {ICONS[toast.kind]} +
+
{toast.title}
+ {toast.message &&
{toast.message}
} +
+ +
+ ))} +
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/components/ui.tsx b/VirtualDriverControl/src/renderer/src/components/ui.tsx new file mode 100644 index 00000000..b3b96a7b --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/components/ui.tsx @@ -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 ( +
+ {props.title && ( +
+ {Icon && ( + + + + )} +
+

{props.title}

+ {props.subtitle &&
{props.subtitle}
} +
+ {props.actions &&
{props.actions}
} +
+ )} +
{props.children}
+
+ ) +} + +// --------------------------------------------------------------------------- +// Toggle +// --------------------------------------------------------------------------- + +export function Toggle(props: { + checked: boolean + onChange: (value: boolean) => void + label?: string + sublabel?: string + disabled?: boolean +}): React.JSX.Element { + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Field wrappers +// --------------------------------------------------------------------------- + +export function Field(props: { label: string; hint?: string; children: ReactNode }): React.JSX.Element { + return ( +
+ + {props.children} + {props.hint && {props.hint}} +
+ ) +} + +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 ( + + { + const n = Number(e.target.value) + if (Number.isFinite(n)) props.onChange(n) + }} + /> + + ) +} + +// --------------------------------------------------------------------------- +// Segmented control +// --------------------------------------------------------------------------- + +export function Segmented(props: { + value: T + options: Array<{ value: T; label: string }> + onChange: (value: T) => void +}): React.JSX.Element { + return ( +
+ {props.options.map((opt) => ( + + ))} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + {props.open && ( + { + if (e.target === e.currentTarget) props.onClose() + }} + > + +
+ {Icon && } +

{props.title}

+
+
{props.children}
+ {props.footer &&
{props.footer}
} +
+
+ )} +
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/env.d.ts b/VirtualDriverControl/src/renderer/src/env.d.ts new file mode 100644 index 00000000..188b5b4f --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/env.d.ts @@ -0,0 +1,9 @@ +declare module '*.css' { + const css: string + export default css +} + +declare module '*.png' { + const src: string + export default src +} diff --git a/VirtualDriverControl/src/renderer/src/main.tsx b/VirtualDriverControl/src/renderer/src/main.tsx new file mode 100644 index 00000000..81c4c6a2 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/main.tsx @@ -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( + + + +) diff --git a/VirtualDriverControl/src/renderer/src/pages/AudioPage.tsx b/VirtualDriverControl/src/renderer/src/pages/AudioPage.tsx new file mode 100644 index 00000000..5d064728 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/AudioPage.tsx @@ -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 ( +
+
+
+

Audio

+

Virtual audio devices, Windows endpoint control and live routing

+
+
+ + + + +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( + void refresh()}> + {loading ? : } Refresh + + } + > + {error &&
{error}
} + {endpoints.length === 0 && !error && ( +
{loading ? 'Enumerating audio endpoints…' : 'No active audio endpoints found.'}
+ )} +
+
+
+ Output · {outputs.length} +
+
+ {outputs.map((e) => ( + + ))} +
+
+
+
+ Input · {inputs.length} +
+
+ {inputs.map((e) => ( + + ))} +
+
+
+
+ ) +} + +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 ( +
+ {endpoint.flow === 'render' ? : } +
+
+ {endpoint.name} + {endpoint.isVirtual && virtual} + {endpoint.isDefault && default} + {endpoint.isDefaultComm && comms} +
+
+ + setVolume(endpoint.id, Number(e.target.value) / 100)} + /> + {Math.round(endpoint.volume * 100)}% + {!endpoint.isDefault && ( + + )} +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( + void refreshWebDevices()}> + Rescan devices + + } + > +
+ + + + +
+ +
+ {routes.map((route) => ( +
+ void toggleRoute(route.id, v)} /> +
+
+ + {route.sourceId === SYSTEM_AUDIO_SOURCE ? : } + {route.sourceLabel} + + + + + {route.sinkLabel} + +
+
+ + Gain + + setRouteGain(route.id, Number(e.target.value) / 100)} + /> + {Math.round(route.gain * 100)}% + +
+ {routeErrors[route.id] &&
{routeErrors[route.id]}
} +
+ +
+ ))} + {routes.length === 0 &&
No routes yet - add one above.
} +
+ +
+ + + Mic → speaker: pick a microphone and an output. Speaker → speaker: use{' '} + System audio as the source. Speaker → mic: route into{' '} + Speakers (Virtual Audio Driver) - apps then hear it on the matching virtual microphone. + +
+
+ ) +} + +function RouteMeter(props: { routeId: string; active: boolean }): React.JSX.Element { + const barRef = useRef(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 ( +
+
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/ColorPage.tsx b/VirtualDriverControl/src/renderer/src/pages/ColorPage.tsx new file mode 100644 index 00000000..68cb11e1 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/ColorPage.tsx @@ -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 ( +
+
+
+

HDR & Color

+

Pixel format, HDR10 metadata and the color gamut your virtual displays advertise

+
+
+ + +
+
+ {COLOUR_FORMATS.map((format) => ( + + ))} +
+
+ patch((d) => (d.colour.hdrPlus = v))} + label="HDR+" + sublabel="Requires Windows 11 23H2+" + /> + patch((d) => (d.colour.sdr10bit = v))} + label="SDR 10-bit" + sublabel="10-bit output without HDR" + /> +
+
+
+ + patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.enabled = v))} />} + > +
+ patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxDisplayMasteringLuminance = v))} + /> + patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.minDisplayMasteringLuminance = v))} + /> + patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxContentLightLevel = v))} + /> + patch((d) => (d.hdrAdvanced.hdr10StaticMetadata.maxFrameAvgLightLevel = v))} + /> +
+
+ + patch((d) => (d.hdrAdvanced.colorPrimaries.enabled = v))} />} + > +
+ + patch((d) => { + Object.assign(d.hdrAdvanced.colorPrimaries, p) + }) + } + /> +
+ +
+ {Object.entries(COLOR_SPACE_PRESETS).map(([name, preset]) => ( + + ))} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Primaryxy
Red{primaries.redX.toFixed(4)}{primaries.redY.toFixed(4)}
Green{primaries.greenX.toFixed(4)}{primaries.greenY.toFixed(4)}
Blue{primaries.blueX.toFixed(4)}{primaries.blueY.toFixed(4)}
White point{primaries.whiteX.toFixed(4)}{primaries.whiteY.toFixed(4)}
+
+
+
+ +
+ patch((d) => (d.hdrAdvanced.colorSpace.enabled = v))} />} + > +
+ + patch((d) => (d.hdrAdvanced.colorSpace.gammaCorrection = Number(e.target.value)))} + /> + + + + + patch((d) => (d.hdrAdvanced.colorSpace.enableMatrixTransform = v))} + label="Matrix transform" + sublabel="Apply color space conversion matrix" + /> +
+
+ + +
+ patch((d) => (d.colorAdvanced.bitDepthManagement.autoSelectFromColorSpace = v))} + label="Auto bit depth" + sublabel="Derive bit depth from color space" + /> + + patch((d) => (d.colorAdvanced.bitDepthManagement.forceBitDepth = Number(v)))} + /> + + patch((d) => (d.colorAdvanced.bitDepthManagement.fp16SurfaceSupport = v))} + label="FP16 surface support" + sublabel="Keep enabled for compatibility" + /> + patch((d) => (d.colorAdvanced.colorFormatExtended.sdrWhiteLevel = v))} + /> +
+
+
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/ConsolePage.tsx b/VirtualDriverControl/src/renderer/src/pages/ConsolePage.tsx new file mode 100644 index 00000000..fa6b7cca --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/ConsolePage.tsx @@ -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(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 => { + 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 ( +
+
+
+

Console

+

Unified driver activity - file logs, pipe traffic and app events

+
+
+ + +
+
+ +
+ {SEVERITIES.map((sev) => ( + + ))} + + {SOURCES.map((src) => ( + + ))} + setSearch(e.target.value)} + /> + +
+ +
+ {filtered.length === 0 && ( +
+ +
No log activity yet{online ? ' - try sending PING below' : ''}
+
+ )} + {filtered.map((event) => ( +
+ {formatTime(event.timestamp)} + {event.source} + {event.message} +
+ ))} +
+ +
+ setCommand(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void sendCommand()} + /> + + {KNOWN_COMMANDS.map((cmd) => ( + + +
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/DashboardPage.tsx b/VirtualDriverControl/src/renderer/src/pages/DashboardPage.tsx new file mode 100644 index 00000000..ef932e33 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/DashboardPage.tsx @@ -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['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 = { + 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 ( +
+
+
+

Dashboard

+

Live control of your virtual displays

+
+
+ + {status?.level === 'not-installed' && ( +
+ + + 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. + +
+ )} + + {isDefault && status?.level !== 'not-installed' && ( +
+ + No vdd_settings.xml found - showing defaults. Saving will create the configuration file. +
+ )} + + + + + +
+
+ + Driver + + + {LEVEL_LABEL[status?.level ?? 'unknown']} + + {status?.deviceName ?? 'Root\\MttVDD'} +
+
+ + Active displays + + {displays.length > 0 ? displays.length : '—'} + + {displays.length > 0 ? `${attachedPhysical} physical · ${attachedVirtual} virtual · ` : ''} + {draft.monitors.count} configured + +
+
+ + GPU + + + {draft.gpu.friendlyName === 'default' ? 'System default' : draft.gpu.friendlyName} + + + + +
+
+ + IddCx + + {iddcx ?? '—'} + {status?.dllPresent ? `MttVDD.dll · ${status.dllDate ?? ''}` : 'driver DLL not found'} +
+ {sysInfo && ( +
+ + Session + + {sysInfo.isAdmin ? 'Administrator' : 'Standard user'} + {sysInfo.windowsVersion} +
+ )} +
+ + + + +
+ {QUICK_TOGGLES.map((def) => { + const Icon = def.icon + const value = def.get(draft) + return ( +
+ + + +
+
{def.label}
+
{def.sub}
+
+ void quickToggle(def.command, v, def.label)} /> +
+ ) + })} +
+
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/DisplaysPage.tsx b/VirtualDriverControl/src/renderer/src/pages/DisplaysPage.tsx new file mode 100644 index 00000000..fe581c37 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/DisplaysPage.tsx @@ -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 ( +
+
+
+

Displays

+

Resolutions and refresh rates offered by every virtual monitor

+
+
+ + + + +
+ {draft.global.refreshRates.map((rate) => ( + + {formatRate(rate)} Hz + + + ))} + {draft.global.refreshRates.length === 0 && No global rates - each resolution uses only its own.} +
+
+ {REFRESH_RATE_PRESETS.filter((r) => !draft.global.refreshRates.includes(r)).map((rate) => ( + + ))} +
+ setNewGlobalRate(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addGlobalRate()} + /> + +
+
+
+ + setAddOpen(true)}> + Add resolution + + } + > +
+ {draft.resolutions.map((res, index) => ( + + ))} + {draft.resolutions.length === 0 && ( +
+ + At least one resolution is required for the driver to expose display modes. +
+ )} +
+
+ +
+ +
+ patch((d) => (d.autoResolutions.preferredMode.useEdidPreferred = v))} + label="Use EDID preferred mode" + sublabel="Requires EDID integration with a monitor profile" + /> +
+ patch((d) => (d.autoResolutions.preferredMode.fallbackWidth = v))} + /> + patch((d) => (d.autoResolutions.preferredMode.fallbackHeight = v))} + /> + patch((d) => (d.autoResolutions.preferredMode.fallbackRefresh = v))} + /> +
+
+
+ + +
+ {sizeComparison.map((r) => ( +
+ + {r.width}×{r.height} + +
+ ))} +
+
+
+ + setAddOpen(false)} + footer={ + <> + + + } + wide + > +
+ + setCustomW(Number(e.target.value))} /> + + × + + setCustomH(Number(e.target.value))} /> + + +
+ + {(['HD', 'QHD', '4K & Beyond', 'Ultrawide', 'Standard', 'Portable & Tablet'] as const).map((category) => ( +
+
+ {category} +
+
+ {RESOLUTION_PRESETS.filter((p) => p.category === category).map((preset) => ( + + ))} +
+
+ ))} +
+
+ ) +} + +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 ( +
+ + {res.width}×{res.height} + + {aspectRatioLabel(res.width, res.height)} +
+ {res.refreshRates.map((rate) => ( + + {formatRate(rate)} Hz + + + ))} + setNewRate(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addRate()} + onBlur={() => newRate && addRate()} + /> +
+ +
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/EdidPage.tsx b/VirtualDriverControl/src/renderer/src/pages/EdidPage.tsx new file mode 100644 index 00000000..91585953 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/EdidPage.tsx @@ -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 = { + detailed: 'Detailed (DTD)', + 'cea-vic': 'CEA-861 VIC', + standard: 'Standard', + established: 'Established' +} + +export function EdidPage(): React.JSX.Element { + const [edid, setEdid] = useState(null) + const [fileName, setFileName] = useState('') + const [bytes, setBytes] = useState(null) + const [dragOver, setDragOver] = useState(false) + const [exporting, setExporting] = useState(false) + const fileInput = useRef(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 => { + 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 => { + 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 ( +
+
+
+

EDID Lab

+

Decode real monitor EDIDs and teach your virtual displays to impersonate them

+
+
+ + + { + const file = e.target.files?.[0] + if (file) void loadFile(file) + e.target.value = '' + }} + /> +
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) + }} + > + +
{fileName || 'Drop an EDID binary here'}
+
+ {fileName ? 'Drop another file to replace' : 'or click to browse - 128/256/512 byte blobs supported'} +
+
+
+ + {edid && ( + <> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {edid.screenWidthCm && edid.screenHeightCm && ( + + + + + )} + + + + + + + + + +
Monitor name{edid.displayName ?? '—'}
Manufacturer + {edid.manufacturerId} · product {edid.productCode.toString(16).toUpperCase().padStart(4, '0')}h +
Serial{edid.serialString ?? edid.serialNumber}
Manufactured + {edid.manufactureYear} + {edid.manufactureWeek > 0 && edid.manufactureWeek <= 54 ? `, week ${edid.manufactureWeek}` : ''} +
EDID version + {edid.edidVersion} · {edid.extensionCount} extension block{edid.extensionCount === 1 ? '' : 's'} +
Interface + {edid.digital ? `Digital (${edid.videoInterface ?? 'unknown'})` : 'Analog'} + {edid.bitDepth ? ` · ${edid.bitDepth}-bit` : ''} +
Physical size + {edid.screenWidthCm}×{edid.screenHeightCm} cm ( + {(Math.hypot(edid.screenWidthCm, edid.screenHeightCm) / 2.54).toFixed(1)}″) +
Gamma{edid.gamma?.toFixed(2) ?? '—'}
Checksum + {edid.checksumOk ? ( + + valid + + ) : ( + + invalid + + )} +
+
+ {edid.hasCeaExtension && CEA-861} + {edid.ceaBasicAudio && Audio} + {edid.ceaYcbcr444 && YCbCr 4:4:4} + {edid.ceaYcbcr422 && YCbCr 4:2:2} + {edid.hdr?.eotfPq && HDR10 (PQ)} + {edid.hdr?.eotfHlg && HLG} + {edid.hdr?.maxLuminance && {edid.hdr.maxLuminance} nits peak} +
+
+ + + {edid.chromaticity ? ( + undefined} disabled width={320} /> + ) : ( + No chromaticity data + )} + +
+ + void exportProfile()}> + + Export profile + EDID + + } + > +
+ + + + + + + + + + + + {edid.timings.map((t, i) => ( + + + + + + + + ))} + +
ResolutionRefreshSourcePixel clock
+ {t.width}×{t.height} + {t.interlaced ? 'i' : ''} + {t.refreshHz.toFixed(t.refreshHz % 1 === 0 ? 0 : 3)} Hz + {SOURCE_LABEL[t.source]} + {t.vic ? ` ${t.vic}` : ''} + {t.pixelClockMHz ? `${t.pixelClockMHz.toFixed(2)} MHz` : '—'}{t === edid.preferred && preferred}
+
+
+ + )} + +
+ +
+ void quickToggle('CUSTOMEDID', v, 'Custom EDID')} + label="Use custom EDID" + sublabel="Serve user_edid.bin to Windows instead of the built-in identity" + /> + void quickToggle('PREVENTSPOOF', v, 'Prevent spoof')} + label="Prevent manufacturer spoofing" + sublabel="Keep the original manufacturer ID in the served EDID" + /> + void quickToggle('CEAOVERRIDE', v, 'CEA override')} + label="CEA extension override" + sublabel="Replace the CEA-861 block with driver-generated data" + /> +
+
+ + void window.vdd.system.openPath('edid')}> + Open folder + + } + > +
+ patch((d) => (d.edidIntegration.enabled = v))} + label="Enable EDID integration" + /> + patch((d) => (d.edidIntegration.autoConfigureFromEdid = v))} + label="Auto-configure from profile" + sublabel="Resolutions, color and HDR come from the profile" + /> + patch((d) => (d.edidIntegration.overrideManualSettings = v))} + label="Profile overrides manual settings" + /> + patch((d) => (d.edidIntegration.fallbackOnError = v))} + label="Fall back to manual settings on error" + /> + + patch((d) => (d.edidIntegration.edidProfilePath = e.target.value))} + /> + +
+
+
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/GpuPage.tsx b/VirtualDriverControl/src/renderer/src/pages/GpuPage.tsx new file mode 100644 index 00000000..b412395f --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/GpuPage.tsx @@ -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 ( +
+
+
+

GPU

+

Choose which adapter renders your virtual displays

+
+ +
+ + {!online && ( +
+ + Driver offline - adapters listed from Windows (WMI). Assignments are staged into the draft configuration. +
+ )} + + +
+
+ + + +
+
System default
+
+ Let Windows pick the adapter (recommended for single-GPU systems) +
+
+ {isDefault ? ( + assigned + ) : ( + + )} +
+ + {gpus.map((gpu) => { + const assigned = !isDefault && (gpu.assigned || gpu.name.toLowerCase() === assignedName.toLowerCase()) + return ( +
+ + + +
+
{gpu.name}
+
+ {gpu.source === 'pipe' ? 'Reported by driver' : 'Reported by Windows'} + {gpu.driverVersion ? ` · driver ${gpu.driverVersion}` : ''} + {gpu.vramMB ? ` · ${(gpu.vramMB / 1024).toFixed(1)} GB VRAM` : ''} +
+
+ {assigned ? ( + assigned + ) : ( + + )} +
+ ) + })} + + {gpus.length === 0 && !gpusLoading && No adapters found.} +
+
+ + +
+ + + + +
+
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/pages/SettingsPage.tsx b/VirtualDriverControl/src/renderer/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..1a2f9bbd --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/pages/SettingsPage.tsx @@ -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 | null>(null) + const [rawXml, setRawXml] = useState('') + const [backups, setBackups] = useState([]) + + const refreshBackups = useCallback(async () => { + setBackups(await window.vdd.settings.backups()) + }, []) + + useEffect(() => { + void refreshBackups() + }, [refreshBackups]) + + const openXmlPreview = async (): Promise => { + 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 => { + 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 ( +
+
+
+

Settings

+

Driver behavior, configuration file management and app preferences

+
+
+ +
+ +
+ patch((d) => (d.cursor.hardwareCursor = v))} + label="Hardware cursor" + sublabel="Composite the cursor on the GPU (recommended)" + /> + patch((d) => (d.cursor.alphaCursorSupport = v))} + label="Alpha-blended cursor" + /> +
+ patch((d) => (d.cursor.cursorMaxX = v))} + /> + patch((d) => (d.cursor.cursorMaxY = v))} + /> +
+ + patch((d) => (d.cursor.xorCursorSupportLevel = Number(v)))} + /> + +
+
+ + +
+ patch((d) => (d.logging.sendLogsThroughPipe = v))} + label="Stream logs through pipe" + sublabel="Lets this app capture live responses from the driver" + /> + patch((d) => (d.logging.logging = v))} + label="File logging" + sublabel="Daily log files in the Logs folder" + /> + patch((d) => (d.logging.debugLogging = v))} + label="Debug logging" + sublabel="Warning: verbose - creates large files quickly" + /> +
+
+
+ + patch((d) => (d.autoResolutions.enabled = v))} />} + > +
+ + patch((d) => (d.autoResolutions.sourcePriority = v))} + /> + +
+ patch((d) => (d.autoResolutions.edidModeFiltering.minRefreshRate = v))} + /> + patch((d) => (d.autoResolutions.edidModeFiltering.maxRefreshRate = v))} + /> + patch((d) => (d.autoResolutions.edidModeFiltering.minResolutionWidth = v))} + /> + patch((d) => (d.autoResolutions.edidModeFiltering.maxResolutionWidth = v))} + /> + patch((d) => (d.autoResolutions.edidModeFiltering.minResolutionHeight = v))} + /> + patch((d) => (d.autoResolutions.edidModeFiltering.maxResolutionHeight = v))} + /> +
+ patch((d) => (d.autoResolutions.edidModeFiltering.excludeFractionalRates = v))} + label="Exclude fractional refresh rates" + sublabel="Drop 59.94-style NTSC rates from generated modes" + /> +
+
+ + + + + + } + > +
+
+ + + Backups ({backups.length}) + + + created automatically before every save + +
+
+ {backups.map((backup) => ( +
+ + + {backup.fileName} + + {new Date(backup.createdAt).toLocaleString()} + +
+ ))} + {backups.length === 0 && No backups yet.} +
+
+
+ + +
+ + void updatePrefs({ theme: v })} + /> + + +
+ {ACCENT_PRESETS.map((color) => ( +
+
+ +
+
+ + setXmlOpen(false)} + wide + footer={ + + } + > + {diff ? ( +
+ {diff.map((line, idx) => + 'count' in line ? ( +
+ + {line.count} unchanged lines +
+ ) : ( +
+ {line.type === 'add' ? '+' : line.type === 'del' ? '−' : ''} + {line.text || ' '} +
+ ) + )} +
+ ) : ( +
+            {rawXml}
+          
+ )} +
+
+ ) +} + +/** + * 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 => { + 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 ( + +
+ setValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && edited && void apply(value.trim())} + /> + + {!isDefault && ( + + )} +
+
+ ) +} diff --git a/VirtualDriverControl/src/renderer/src/stores/audio.ts b/VirtualDriverControl/src/renderer/src/stores/audio.ts new file mode 100644 index 00000000..72122071 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/audio.ts @@ -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 + routeErrors: Record + init: () => Promise + refreshEndpoints: () => Promise + refreshWebDevices: () => Promise + setEndpointVolume: (id: string, volume: number) => void + setEndpointMute: (id: string, muted: boolean) => Promise + setDefaultEndpoint: (id: string) => Promise + addRoute: (sourceId: string, sourceLabel: string, sinkId: string, sinkLabel: string) => Promise + removeRoute: (routeId: string) => Promise + toggleRoute: (routeId: string, enabled: boolean) => Promise + setRouteGain: (routeId: string, gain: number) => void +} + +let initialized = false +const volumeTimers = new Map() + +function persistRoutes(routes: AudioRoute[]): void { + void useUi.getState().updatePrefs({ audioRoutes: routes }) +} + +async function armRoute(route: AudioRoute, set: (fn: (s: AudioState) => Partial) => void): Promise { + 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((set, get) => ({ + endpoints: [], + endpointsLoading: false, + endpointsError: null, + webInputs: [], + webOutputs: [], + routes: [], + activeRoutes: new Set(), + 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 } diff --git a/VirtualDriverControl/src/renderer/src/stores/driver.ts b/VirtualDriverControl/src/renderer/src/stores/driver.ts new file mode 100644 index 00000000..f8300738 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/driver.ts @@ -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 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 + refreshStatus: (force?: boolean) => Promise + refreshGpus: () => Promise + /** Apply a new virtual display count via SETDISPLAYCOUNT (or stage offline). */ + applyDisplayCount: (count: number) => Promise + /** Toggle a driver feature live via the pipe (or stage offline). */ + quickToggle: (name: PipeToggleCommand, value: boolean, label: string) => Promise + assignGpu: (name: string) => Promise + /** Save the draft config and trigger a driver reload to pick it up. */ + saveAndApply: () => Promise +} + +let initialized = false + +export const useDriver = create((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() + } + } +})) diff --git a/VirtualDriverControl/src/renderer/src/stores/installer.ts b/VirtualDriverControl/src/renderer/src/stores/installer.ts new file mode 100644 index 00000000..d69b0794 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/installer.ts @@ -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 + /** 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 + refreshDevice: (driver: ManagedDriverId) => Promise + install: (driver: ManagedDriverId, instances?: number) => Promise + uninstallDriver: (driver: ManagedDriverId) => Promise + restartDevice: (driver: ManagedDriverId) => Promise + setInstances: (driver: ManagedDriverId, count: number) => Promise + refreshTestSigning: () => Promise + setTestSigning: (enabled: boolean) => Promise +} + +const emptyState = (): DriverLifecycleState => ({ + latest: null, + installedTag: null, + device: null, + checking: false, + checkError: null +}) + +let initialized = false + +export const useInstaller = create((set, get) => { + const patchDriver = (driver: ManagedDriverId, patch: Partial): void => { + set((s) => ({ drivers: { ...s.drivers, [driver]: { ...s.drivers[driver], ...patch } } })) + } + + const refreshAfter = async (driver: ManagedDriverId): Promise => { + 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, + successTitle: string, + successMessage: string | undefined, + failTitle: string + ): Promise => { + 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() + } + } + } +}) diff --git a/VirtualDriverControl/src/renderer/src/stores/logs.ts b/VirtualDriverControl/src/renderer/src/stores/logs.ts new file mode 100644 index 00000000..75eed125 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/logs.ts @@ -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 + sourceFilter: Set + search: string + autoScroll: boolean + init: () => Promise + 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((set) => ({ + events: [], + severityFilter: new Set(['debug', 'info', 'warning', 'error']), + sourceFilter: new Set(['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 }) +})) diff --git a/VirtualDriverControl/src/renderer/src/stores/settings.ts b/VirtualDriverControl/src/renderer/src/stores/settings.ts new file mode 100644 index 00000000..3ab0c95d --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/settings.ts @@ -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 + patch: (mutate: (draft: VddSettings) => void) => void + discard: () => void + /** Persist the draft to vdd_settings.xml. Returns success. */ + save: () => Promise +} + +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((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 + } + } +})) diff --git a/VirtualDriverControl/src/renderer/src/stores/ui.ts b/VirtualDriverControl/src/renderer/src/stores/ui.ts new file mode 100644 index 00000000..ca08a5a7 --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/stores/ui.ts @@ -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 + updatePrefs: (patch: Partial) => Promise + /** Changes the driver folder (syncs the VDDPATH registry value in main). */ + setBaseDir: (baseDir: string) => Promise + 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((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 }) +})) diff --git a/VirtualDriverControl/src/renderer/src/styles/global.css b/VirtualDriverControl/src/renderer/src/styles/global.css new file mode 100644 index 00000000..da790e2c --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/styles/global.css @@ -0,0 +1,1936 @@ +/* =========================================================================== + Virtual Driver Control - design system + WinUI 3 / Fluent 2 inspired. Mica backdrop on Win11, layered surfaces, + accent-pill navigation, Fluent control fills and strokes. + =========================================================================== */ + +:root { + /* Accent is set at runtime from preferences; Windows accent fallback. */ + --accent: #4cc2ff; + --accent-soft: color-mix(in srgb, var(--accent) 20%, transparent); + --accent-softer: color-mix(in srgb, var(--accent) 11%, transparent); + --accent-strong: color-mix(in srgb, var(--accent) 85%, white); + --accent-text: color-mix(in srgb, var(--accent) 80%, white); + + --font-ui: 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif; + --font-display: 'Segoe UI Variable Display', 'Segoe UI', system-ui, sans-serif; + --font-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, monospace; + + --titlebar-h: 48px; + --sidebar-w: 244px; + /* Fluent corner radii: controls ~4-5, surfaces/cards 8 */ + --radius-sm: 5px; + --radius: 8px; + --radius-lg: 8px; + + --ease-snap: cubic-bezier(0.2, 0.9, 0.25, 1); +} + +[data-theme='dark'] { + color-scheme: dark; + --bg: #202020; /* mica fallback tint */ + --bg-deep: #181818; + --layer: rgba(255, 255, 255, 0.034); /* content layer over mica */ + --surface: rgba(255, 255, 255, 0.051); /* card fill */ + --surface-2: rgba(255, 255, 255, 0.062); /* control fill */ + --surface-3: rgba(255, 255, 255, 0.088); /* control hover fill */ + --surface-solid: #2b2b2b; + --border: rgba(255, 255, 255, 0.067); /* card stroke */ + --border-strong: rgba(255, 255, 255, 0.14); + --control-strong: rgba(255, 255, 255, 0.45); /* textbox underline, toggle ring */ + --text: #ffffff; + --text-dim: rgba(255, 255, 255, 0.69); + --text-faint: rgba(255, 255, 255, 0.44); + --success: #6ccb5f; + --warning: #f9ce4d; + --danger: #ff99a4; + --info: #60cdff; + --shadow-card: 0 2px 4px rgba(0, 0, 0, 0.13); + --shadow-pop: 0 8px 16px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06); + --stage-screen: linear-gradient(150deg, color-mix(in srgb, var(--accent) 24%, #1c2227), #15191d 62%); +} + +[data-theme='light'] { + color-scheme: light; + --bg: #f3f3f3; + --bg-deep: #eaeaea; + --layer: rgba(255, 255, 255, 0.52); + --surface: rgba(255, 255, 255, 0.7); + --surface-2: rgba(255, 255, 255, 0.92); + --surface-3: rgba(0, 0, 0, 0.037); + --surface-solid: #ffffff; + --border: rgba(0, 0, 0, 0.058); + --border-strong: rgba(0, 0, 0, 0.14); + --control-strong: rgba(0, 0, 0, 0.44); + --text: #1b1b1b; + --text-dim: rgba(0, 0, 0, 0.62); + --text-faint: rgba(0, 0, 0, 0.44); + --success: #0f7b0f; + --warning: #9d5d00; + --danger: #c42b1c; + --info: #005fb8; + --shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-pop: 0 8px 16px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.05); + --stage-screen: linear-gradient(150deg, color-mix(in srgb, var(--accent) 28%, #ffffff), #e8ecef 62%); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#root { + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-ui); + font-size: 13.5px; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; + user-select: none; +} + +/* With Mica the window backdrop comes from the OS - keep the page transparent + so the desktop tint shows through the chrome (titlebar + nav pane). */ +body[data-backdrop='mica'] { + background: transparent; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 6px; + border: 2px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); + border: 2px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-corner { + background: transparent; +} + +/* ============================== Layout ================================== */ + +.app-shell { + display: grid; + grid-template-rows: var(--titlebar-h) 1fr; + grid-template-columns: var(--sidebar-w) 1fr; + grid-template-areas: + 'titlebar titlebar' + 'sidebar main'; + height: 100%; + position: relative; +} + +/* WinUI NavigationView content layer: an elevated pane with a rounded + top-left corner sitting on the mica backdrop. */ +.app-main { + grid-area: main; + position: relative; + overflow: hidden; + z-index: 1; + background: var(--layer); + border: 1px solid var(--border); + border-right: none; + border-bottom: none; + border-top-left-radius: var(--radius); +} + +.page-scroll { + height: 100%; + overflow-y: auto; + padding: 26px 30px 48px; +} + +.page-inner { + max-width: 1060px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 18px; +} + +.page-title { + font-family: var(--font-display); + font-size: 24px; + font-weight: 650; + letter-spacing: -0.02em; +} + +.page-subtitle { + color: var(--text-dim); + margin-top: 4px; + font-size: 13px; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 4px; +} + +/* ============================== Titlebar ================================ */ + +.titlebar { + grid-area: titlebar; + display: flex; + align-items: center; + gap: 12px; + padding-left: 16px; + -webkit-app-region: drag; + background: transparent; + position: relative; + z-index: 5; +} + +.titlebar .brand { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-ui); + font-weight: 400; + font-size: 12.5px; + letter-spacing: 0.01em; +} + +.brand-mark { + width: 22px; + height: 22px; + object-fit: contain; +} + +.titlebar .titlebar-status { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--text-dim); + -webkit-app-region: no-drag; + padding: 4px 11px; + border-radius: 99px; + border: 1px solid var(--border); + background: var(--surface); +} + +.titlebar-spacer { + flex: 1; +} + +/* Win11 caption buttons */ +.window-controls { + display: flex; + height: 100%; + -webkit-app-region: no-drag; +} + +.window-controls button { + width: 46px; + height: 100%; + border: none; + background: transparent; + color: var(--text); + display: grid; + place-items: center; + cursor: default; + transition: background 0.1s; +} + +.window-controls button:hover { + background: var(--surface-3); +} + +.window-controls button.close:hover { + background: #c42b1c; + color: #fff; +} + +.window-controls button.close:active { + background: #b1271b; +} + +/* ============================== Sidebar ================================= */ + +/* WinUI NavigationView pane: transparent over mica, selection pill on the + active item. */ +.sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + padding: 8px 8px 14px; + gap: 2px; + background: transparent; + position: relative; + z-index: 2; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 12px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 400; + text-align: left; + cursor: default; + position: relative; + transition: background 0.1s, color 0.1s; +} + +.nav-item svg { + color: var(--text-dim); + transition: color 0.1s; +} + +.nav-item:hover { + background: var(--surface-2); +} + +.nav-item:active { + background: var(--surface); + color: var(--text-dim); +} + +.nav-item.active { + background: var(--surface-3); + font-weight: 600; +} + +.nav-item.active svg { + color: var(--accent-text); +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 16px; + border-radius: 2px; + background: var(--accent); +} + +.nav-section { + margin: 12px 12px 5px; + font-size: 10.5px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--text-faint); +} + +.sidebar-footer { + margin-top: auto; + padding: 10px 12px 4px; + font-size: 11px; + color: var(--text-faint); + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ============================== Cards =================================== */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.card-header { + display: flex; + align-items: center; + gap: 11px; + padding: 15px 18px 0; +} + +.card-header .card-icon { + width: 30px; + height: 30px; + border-radius: 6px; + display: grid; + place-items: center; + background: var(--accent-softer); + color: var(--accent-text); + flex-shrink: 0; +} + +.card-header h3 { + font-size: 14.5px; + font-weight: 620; + font-family: var(--font-display); +} + +.card-header .card-sub { + font-size: 12px; + color: var(--text-dim); + margin-top: 1px; +} + +.card-header .card-actions { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; +} + +.card-body { + padding: 15px 18px 18px; +} + +.card-grid { + display: grid; + gap: 16px; +} + +.card-grid.two { + grid-template-columns: 1fr 1fr; +} + +@media (max-width: 1100px) { + .card-grid.two { + grid-template-columns: 1fr; + } +} + +/* ============================== Buttons ================================= */ + +/* Fluent Button: subtle fill, 1px stroke, pressed state dims content */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 6px 14px 7px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 400; + cursor: default; + white-space: nowrap; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} + +.btn:hover:not(:disabled) { + background: var(--surface-3); +} + +.btn:active:not(:disabled) { + background: var(--surface); + color: var(--text-dim); +} + +.btn:disabled { + opacity: 0.4; +} + +.btn.primary { + background: var(--accent); + border-color: color-mix(in srgb, var(--accent) 86%, black); + color: rgba(0, 0, 0, 0.87); + font-weight: 600; +} + +.btn.primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 90%, var(--bg)); +} + +.btn.primary:active:not(:disabled) { + background: color-mix(in srgb, var(--accent) 80%, var(--bg)); + color: rgba(0, 0, 0, 0.62); +} + +.btn.danger { + background: color-mix(in srgb, var(--danger) 14%, transparent); + border-color: color-mix(in srgb, var(--danger) 38%, transparent); + color: var(--danger); +} + +.btn.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 24%, transparent); +} + +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--text-dim); +} + +.btn.ghost:hover:not(:disabled) { + background: var(--surface-2); + color: var(--text); +} + +.btn.small { + padding: 4px 10px; + font-size: 12.5px; + border-radius: 6px; +} + +.btn.icon-only { + padding: 7px; +} + +/* ============================== Inputs ================================== */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field > label { + font-size: 12px; + font-weight: 560; + color: var(--text-dim); +} + +.field .hint { + font-size: 11.5px; + color: var(--text-faint); +} + +/* Fluent TextBox: subtle fill with a strong bottom hairline that turns into + a 2px accent underline on focus. */ +input[type='text'], +input[type='number'], +select, +textarea { + font: inherit; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border); + border-bottom-color: var(--control-strong); + border-radius: var(--radius-sm); + padding: 7px 10px; + outline: none; + width: 100%; + transition: border-color 0.1s, background 0.1s, box-shadow 0.1s; +} + +input:hover:not(:focus), +select:hover:not(:focus), +textarea:hover:not(:focus) { + background: var(--surface-3); +} + +input:focus, +select:focus, +textarea:focus { + background: var(--surface); + border-bottom-color: var(--accent); + box-shadow: inset 0 -1px 0 var(--accent); +} + +input[type='number']::-webkit-inner-spin-button { + opacity: 0.4; +} + +select option { + background: var(--surface-solid); + color: var(--text); +} + +input[type='range'] { + accent-color: var(--accent); + width: 100%; +} + +input[type='color'] { + appearance: none; + border: 1px solid var(--border-strong); + border-radius: 6px; + width: 34px; + height: 26px; + padding: 2px; + background: var(--surface-2); +} + +/* Toggle switch */ +.toggle { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: default; +} + +/* WinUI ToggleSwitch: outlined track when off, accent fill when on */ +.toggle .track { + width: 40px; + height: 20px; + border-radius: 99px; + background: transparent; + border: 1px solid var(--control-strong); + position: relative; + transition: background 0.15s, border-color 0.15s; + flex-shrink: 0; +} + +.toggle .knob { + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-dim); + transition: transform 0.18s var(--ease-snap), background 0.15s, width 0.1s, height 0.1s, top 0.1s; +} + +.toggle:hover .knob { + width: 14px; + height: 14px; + top: 2px; +} + +.toggle.on .track { + background: var(--accent); + border-color: var(--accent); +} + +.toggle.on .knob { + transform: translateX(20px); + background: var(--bg); +} + +.toggle.on:hover .knob { + transform: translateX(19px); +} + +.toggle.disabled { + opacity: 0.45; + pointer-events: none; +} + +.toggle .toggle-label { + font-weight: 530; +} + +.toggle .toggle-sub { + font-size: 11.5px; + color: var(--text-faint); + display: block; +} + +/* Segmented control - WinUI SelectorBar with accent underline */ +.segmented { + display: inline-flex; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 3px; + gap: 2px; +} + +.segmented button { + position: relative; + border: none; + background: transparent; + color: var(--text-dim); + font: inherit; + font-size: 12.5px; + font-weight: 400; + padding: 5px 12px; + border-radius: 4px; + cursor: default; + transition: background 0.1s, color 0.1s; +} + +.segmented button:hover { + background: var(--surface-2); + color: var(--text); +} + +.segmented button.active { + background: var(--surface-3); + color: var(--text); + font-weight: 600; +} + +.segmented button.active::after { + content: ''; + position: absolute; + left: 25%; + right: 25%; + bottom: 0; + height: 3px; + border-radius: 2px; + background: var(--accent); +} + +/* Chips */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 99px; + background: var(--surface-2); + border: 1px solid var(--border); + font-size: 12px; + font-weight: 550; + color: var(--text-dim); + transition: all 0.12s; +} + +.chip.selectable { + cursor: default; +} + +.chip.selectable:hover { + border-color: var(--border-strong); + color: var(--text); +} + +.chip.on { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + color: var(--accent-text); +} + +.chip .chip-x { + display: grid; + place-items: center; + opacity: 0.6; +} + +.chip .chip-x:hover { + opacity: 1; +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 620; + letter-spacing: 0.02em; + padding: 3px 8px; + border-radius: 99px; +} + +.badge.ok { + background: color-mix(in srgb, var(--success) 14%, transparent); + color: var(--success); +} + +.badge.warn { + background: color-mix(in srgb, var(--warning) 14%, transparent); + color: var(--warning); +} + +.badge.err { + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--danger); +} + +.badge.neutral { + background: var(--surface-2); + color: var(--text-dim); +} + +/* ============================== Status orb =============================== */ + +.status-orb { + position: relative; + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-orb.online { + background: var(--success); +} + +.status-orb.online::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1.5px solid var(--success); + animation: orb-pulse 2.2s ease-out infinite; +} + +.status-orb.offline { + background: var(--warning); +} + +.status-orb.missing { + background: var(--danger); +} + +.status-orb.unknown { + background: var(--text-faint); +} + +@keyframes orb-pulse { + 0% { + transform: scale(0.6); + opacity: 0.9; + } + 80% { + transform: scale(1.7); + opacity: 0; + } + 100% { + opacity: 0; + } +} + +/* ============================== Toasts =================================== */ + +.toast-stack { + position: fixed; + bottom: 18px; + right: 18px; + display: flex; + flex-direction: column; + gap: 9px; + z-index: 100; + width: 340px; +} + +.toast { + display: flex; + gap: 11px; + align-items: flex-start; + background: color-mix(in srgb, var(--surface-solid) 92%, transparent); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--shadow-pop); + padding: 12px 14px; + backdrop-filter: blur(18px); +} + +.toast .toast-icon { + margin-top: 1px; + flex-shrink: 0; +} + +.toast.success .toast-icon { + color: var(--success); +} +.toast.error .toast-icon { + color: var(--danger); +} +.toast.info .toast-icon { + color: var(--info); +} +.toast.warning .toast-icon { + color: var(--warning); +} + +.toast .toast-title { + font-weight: 620; + font-size: 13px; +} + +.toast .toast-message { + color: var(--text-dim); + font-size: 12.5px; + margin-top: 2px; + word-break: break-word; + user-select: text; +} + +/* ============================== Modal ===================================== */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(2, 4, 8, 0.55); + backdrop-filter: blur(4px); + z-index: 60; + display: grid; + place-items: center; + padding: 40px; +} + +.modal { + width: min(860px, 100%); + max-height: calc(100vh - 120px); + display: flex; + flex-direction: column; + background: var(--surface-solid); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-pop); + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-family: var(--font-display); + font-size: 16px; +} + +.modal-body { + padding: 18px 20px; + overflow-y: auto; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 9px; + padding: 14px 20px; + border-top: 1px solid var(--border); +} + +/* ============================== Display canvas =========================== */ + +.count-stepper { + display: inline-flex; + align-items: center; + gap: 14px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: 99px; + padding: 6px 8px; +} + +.count-stepper .count-value { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + min-width: 44px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.count-stepper button { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + display: grid; + place-items: center; + cursor: default; + transition: all 0.12s; +} + +.count-stepper button:hover:not(:disabled) { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-text); +} + +.count-stepper button:disabled { + opacity: 0.35; +} + +.count-stepper.compact { + padding: 3px 5px; + gap: 8px; +} + +.count-stepper.compact .count-value { + font-size: 15px; + min-width: 26px; +} + +.count-stepper.compact button { + width: 26px; + height: 26px; +} + +/* Stat tiles */ +.stat-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; +} + +.stat-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 13px 15px; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.stat-tile .stat-label, +.lifecycle-block .stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-faint); + font-weight: 620; + display: flex; + align-items: center; + gap: 6px; +} + +.stat-tile .stat-value, +.lifecycle-block .stat-value { + font-family: var(--font-display); + font-size: 16px; + font-weight: 640; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stat-tile .stat-sub, +.lifecycle-block .stat-sub { + font-size: 11.5px; + color: var(--text-dim); +} + +/* Quick toggle tiles */ +.quick-toggles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.quick-toggle { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 15px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.15s, background 0.15s; +} + +.quick-toggle.on { + border-color: color-mix(in srgb, var(--accent) 38%, transparent); + background: var(--accent-softer); +} + +.quick-toggle .qt-icon { + width: 34px; + height: 34px; + border-radius: 9px; + display: grid; + place-items: center; + background: var(--surface-2); + color: var(--text-dim); + flex-shrink: 0; + transition: all 0.15s; +} + +.quick-toggle.on .qt-icon { + background: var(--accent-soft); + color: var(--accent-text); +} + +.quick-toggle .qt-text { + flex: 1; + min-width: 0; +} + +.quick-toggle .qt-title { + font-weight: 580; + font-size: 13px; +} + +.quick-toggle .qt-sub { + font-size: 11.5px; + color: var(--text-faint); +} + +/* ============================== Tables / lists =========================== */ + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.data-table th { + text-align: left; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 640; + padding: 7px 10px; + border-bottom: 1px solid var(--border); +} + +.data-table td { + padding: 7px 10px; + border-bottom: 1px solid var(--border); + color: var(--text-dim); +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table td:first-child { + color: var(--text); + font-weight: 540; +} + +/* Resolution rows */ +.res-row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + transition: border-color 0.13s; +} + +.res-row:hover { + border-color: var(--border-strong); +} + +.res-row .res-dim { + font-family: var(--font-display); + font-weight: 650; + font-size: 15px; + min-width: 120px; + font-variant-numeric: tabular-nums; +} + +.res-row .res-aspect { + font-size: 11px; + color: var(--text-faint); + min-width: 44px; +} + +.res-row .res-rates { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* ============================== Console ================================== */ + +.console-feed { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.65; + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 0; + overflow-y: auto; + user-select: text; +} + +.console-line { + display: flex; + gap: 10px; + padding: 0 14px; + white-space: pre-wrap; + word-break: break-all; +} + +.console-line:hover { + background: var(--surface); +} + +.console-line .ts { + color: var(--text-faint); + flex-shrink: 0; +} + +.console-line .src { + flex-shrink: 0; + width: 38px; + text-align: center; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + align-self: center; + letter-spacing: 0.04em; +} + +.console-line .src.file { + background: color-mix(in srgb, var(--info) 16%, transparent); + color: var(--info); +} + +.console-line .src.pipe { + background: var(--accent-soft); + color: var(--accent-text); +} + +.console-line .src.app { + background: var(--surface-3); + color: var(--text-dim); +} + +.console-line .msg.info { + color: var(--text); +} +.console-line .msg.debug { + color: var(--text-faint); +} +.console-line .msg.warning { + color: var(--warning); +} +.console-line .msg.error { + color: var(--danger); +} + +/* ============================== Diff view ================================ */ + +.diff-view { + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.55; + background: var(--bg-deep); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: auto; + user-select: text; +} + +.diff-line { + display: flex; + white-space: pre; +} + +.diff-line .gutter { + width: 26px; + flex-shrink: 0; + text-align: center; + color: var(--text-faint); + user-select: none; +} + +.diff-line.add { + background: color-mix(in srgb, var(--success) 11%, transparent); + color: var(--success); +} + +.diff-line.del { + background: color-mix(in srgb, var(--danger) 11%, transparent); + color: var(--danger); +} + +.diff-line.ctx { + color: var(--text-dim); +} + +/* ============================== Misc ===================================== */ + +.banner { + display: flex; + align-items: center; + gap: 11px; + padding: 11px 15px; + border-radius: var(--radius); + border: 1px solid; + font-size: 12.5px; +} + +.banner.warn { + background: color-mix(in srgb, var(--warning) 9%, transparent); + border-color: color-mix(in srgb, var(--warning) 30%, transparent); + color: var(--warning); +} + +.banner.info { + background: color-mix(in srgb, var(--info) 9%, transparent); + border-color: color-mix(in srgb, var(--info) 30%, transparent); + color: var(--info); +} + +.row { + display: flex; + align-items: center; + gap: 10px; +} + +.row.wrap { + flex-wrap: wrap; +} + +.row.between { + justify-content: space-between; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 14px; +} + +.grid-4 { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} + +.divider { + height: 1px; + background: var(--border); + margin: 14px 0; +} + +.section-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-faint); + font-weight: 620; + margin-bottom: 10px; +} + + +.muted { + color: var(--text-dim); +} + +.faint { + color: var(--text-faint); +} + +.mono { + font-family: var(--font-mono); +} + +.selectable-text { + user-select: text; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.dropzone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + padding: 38px 24px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--text-dim); + transition: all 0.15s; +} + +.dropzone.over, +.dropzone:hover { + border-color: var(--accent); + background: var(--accent-softer); + color: var(--accent-text); +} + +.preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 9px; +} + +.preset-card { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + padding: 10px 12px; + text-align: left; + font: inherit; + color: var(--text); + cursor: default; + transition: all 0.13s; +} + +.preset-card:hover:not(:disabled) { + border-color: var(--accent); + background: var(--accent-softer); +} + +.preset-card:disabled { + opacity: 0.45; +} + +.preset-card .p-dim { + font-weight: 640; + font-variant-numeric: tabular-nums; + font-size: 13px; +} + +.preset-card .p-label { + font-size: 11px; + color: var(--text-faint); + margin-top: 2px; +} + +.gpu-card { + display: flex; + align-items: center; + gap: 14px; + padding: 15px 17px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + transition: border-color 0.14s; +} + +.gpu-card.assigned { + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + background: var(--accent-softer); +} + +.gpu-card .gpu-icon { + width: 40px; + height: 40px; + border-radius: 11px; + display: grid; + place-items: center; + background: var(--surface-2); + color: var(--text-dim); + flex-shrink: 0; +} + +.gpu-card.assigned .gpu-icon { + background: var(--accent-soft); + color: var(--accent-text); +} + +.backup-row { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 13px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + font-size: 12.5px; +} + +.size-compare { + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-deep); + height: 220px; + overflow: hidden; +} + +.size-compare .sc-rect { + position: absolute; + bottom: 14px; + left: 14px; + border: 1.5px solid; + border-radius: 3px; + display: flex; + align-items: flex-start; + justify-content: flex-end; +} + +.size-compare .sc-tag { + font-size: 9.5px; + padding: 1px 5px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.kbd { + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-bottom-width: 2px; + border-radius: 5px; + padding: 1px 6px; + color: var(--text-dim); +} + +.save-bar { + position: absolute; + bottom: 18px; + left: 0; + right: 0; + margin: 0 auto; + width: fit-content; + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px 8px 18px; + border-radius: 99px; + background: color-mix(in srgb, var(--surface-solid) 90%, transparent); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-pop); + backdrop-filter: blur(18px); + z-index: 40; +} + +.save-bar .save-bar-text { + font-size: 12.5px; + font-weight: 580; + color: var(--text-dim); + margin-right: 6px; +} + +/* --- Driver lifecycle card ---------------------------------------------- */ + +.lifecycle-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.lifecycle-block { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--surface-2); + border: 1px solid var(--border); +} + +.lifecycle-progress { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.progress-track { + height: 6px; + border-radius: 99px; + background: var(--surface-2); + border: 1px solid var(--border); + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fff)); + transition: width 0.25s ease; +} + +.progress-fill.indeterminate { + position: absolute; + width: 36%; + animation: progress-slide 1.2s ease-in-out infinite; +} + +@keyframes progress-slide { + 0% { + left: -36%; + } + 100% { + left: 100%; + } +} + +.progress-message { + font-size: 11.5px; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.lifecycle-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.lifecycle-hint { + font-size: 11.5px; + font-weight: 600; + color: var(--warning); + margin-left: 4px; +} + +/* --- Audio endpoints ------------------------------------------------------ */ + +.endpoint-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.endpoint-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 9px 11px; + border-radius: var(--radius-sm); + background: var(--surface); + border: 1px solid var(--border); +} + +.endpoint-row.virtual { + border-color: color-mix(in srgb, var(--accent) 36%, transparent); + background: var(--accent-softer); +} + +.ep-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--surface-2); + color: var(--text-dim); + flex-shrink: 0; + margin-top: 1px; +} + +.endpoint-row.virtual .ep-icon { + color: var(--accent-text); +} + +.ep-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.ep-name { + font-size: 12.5px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ep-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 99px; + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: color-mix(in srgb, var(--success) 16%, transparent); + color: var(--success); + vertical-align: 1px; +} + +.ep-badge.dim { + background: var(--surface-2); + color: var(--text-faint); +} + +.ep-badge.accent { + background: var(--accent-soft); + color: var(--accent-text); +} + +.ep-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.ep-controls input[type='range'] { + flex: 1; + min-width: 60px; + accent-color: var(--accent); +} + +.ep-vol { + font-size: 11px; + color: var(--text-dim); + width: 36px; + text-align: right; + flex-shrink: 0; +} + +/* --- Audio routing -------------------------------------------------------- */ + +.route-add { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 14px; +} + +.route-add select { + flex: 1; + min-width: 0; +} + +.route-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.route-row { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--surface); + border: 1px solid var(--border); +} + +.route-row.active { + border-color: color-mix(in srgb, var(--accent) 32%, transparent); +} + +.route-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 7px; +} + +.route-path { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.route-ep { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 99px; + background: var(--surface-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 46%; +} + +.route-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.route-controls input[type='range'] { + width: 110px; + accent-color: var(--accent); +} + +.route-meter { + flex: 1; + height: 5px; + min-width: 50px; + border-radius: 99px; + background: var(--surface-2); + overflow: hidden; +} + +.route-meter.idle { + opacity: 0.35; +} + +.route-meter-fill { + height: 100%; + width: 0%; + border-radius: 99px; + background: linear-gradient(90deg, var(--success), var(--warning) 85%, var(--danger)); + transition: width 60ms linear; +} + +.route-error { + font-size: 11px; + color: var(--danger); +} + +/* --- Desktop arrangement canvas ------------------------------------------- */ + +.arrange-stage { + position: relative; + border-radius: var(--radius); + background-color: var(--bg-deep); + background-image: + radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 1.5px), + radial-gradient(ellipse 70% 90% at 50% -20%, color-mix(in srgb, var(--accent) 6%, transparent), transparent); + background-size: + 20px 20px, + 100% 100%; + border: 1px solid var(--border); + overflow: hidden; + max-width: 100%; +} + +.canvas-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; +} + +.canvas-busy { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--bg) 55%, transparent); + backdrop-filter: blur(3px); + z-index: 3; + border-radius: inherit; +} + +.arrange-display { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1px; + padding: 4px; + border-radius: 6px; + border: 1px solid var(--border-strong); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 45%), + var(--stage-screen); + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.25), + 0 4px 14px rgba(0, 0, 0, 0.3); + overflow: hidden; + transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.arrange-display:hover { + border-color: var(--text-dim); + transform: translateY(-1px); +} + +.arrange-display.virtual { + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.25), + 0 4px 14px rgba(0, 0, 0, 0.3), + 0 0 16px var(--accent-softer); +} + +/* Canvas footer: legend + virtual display count control */ +.canvas-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: 12px; +} + +.canvas-legend { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} + +.canvas-hint { + font-size: 11.5px; + color: var(--text-faint); +} + +.canvas-controls { + display: flex; + align-items: center; + gap: 9px; +} + +.canvas-ctl-label { + font-size: 12px; + font-weight: 600; + color: var(--text-dim); +} + +.ad-name { + display: flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + font-weight: 650; + max-width: 95%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ad-name svg { + color: var(--warning); + flex-shrink: 0; +} + +.ad-res { + font-size: 10px; + color: var(--text-dim); +} + +.ad-meta { + font-size: 9px; + color: var(--text-faint); +} + +.ad-badge { + position: absolute; + top: 4px; + right: 5px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; + padding: 1px 5px; + border-radius: 99px; + background: var(--accent-soft); + color: var(--accent-text); +} diff --git a/VirtualDriverControl/src/renderer/src/utils/audio-router.ts b/VirtualDriverControl/src/renderer/src/utils/audio-router.ts new file mode 100644 index 00000000..304fea0e --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/utils/audio-router.ts @@ -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 +} + +/** + * 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() + 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 { + 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() diff --git a/VirtualDriverControl/src/renderer/src/utils/diff.ts b/VirtualDriverControl/src/renderer/src/utils/diff.ts new file mode 100644 index 00000000..d069389d --- /dev/null +++ b/VirtualDriverControl/src/renderer/src/utils/diff.ts @@ -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 { + const keep = new Array(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 = [] + 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 +} diff --git a/VirtualDriverControl/src/shared/defaults.ts b/VirtualDriverControl/src/shared/defaults.ts new file mode 100644 index 00000000..cd2c5ab9 --- /dev/null +++ b/VirtualDriverControl/src/shared/defaults.ts @@ -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 } +} diff --git a/VirtualDriverControl/src/shared/edid.ts b/VirtualDriverControl/src/shared/edid.ts new file mode 100644 index 00000000..2bab16c4 --- /dev/null +++ b/VirtualDriverControl/src/shared/edid.ts @@ -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 = { + 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 = { + 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 = { detailed: 0, 'cea-vic': 1, standard: 2, established: 3 } + const seen = new Map() + 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(``) + lines.push(``) + lines.push(``) + lines.push(` `) + + 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(` `) + lines.push(` ${mode.width}`) + lines.push(` ${mode.height}`) + lines.push(` ${fmt(mode.refreshHz, 3)}`) + lines.push(` ${isIntegral ? 1000 : 999}`) + lines.push(` ${nominal}`) + lines.push(` `) + } + + lines.push(` `) + lines.push(` `) + lines.push(` sRGB`) + lines.push(` ${fmt(edid.gamma ?? 2.2, 3)}`) + 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(` `) + lines.push(` ${fmt(c.redX, 4)}`) + lines.push(` ${fmt(c.redY, 4)}`) + lines.push(` ${fmt(c.greenX, 4)}`) + lines.push(` ${fmt(c.greenY, 4)}`) + lines.push(` ${fmt(c.blueX, 4)}`) + lines.push(` ${fmt(c.blueY, 4)}`) + lines.push(` ${fmt(c.whiteX, 4)}`) + lines.push(` ${fmt(c.whiteY, 4)}`) + lines.push(` `) + lines.push(` `) + + const preferred = edid.preferred ?? modes[0] + if (preferred) { + lines.push(` `) + lines.push(` ${preferred.width}`) + lines.push(` ${preferred.height}`) + lines.push(` ${fmt(preferred.refreshHz, 3)}`) + lines.push(` `) + } + + lines.push(``) + return lines.join('\n') +} diff --git a/VirtualDriverControl/src/shared/presets.ts b/VirtualDriverControl/src/shared/presets.ts new file mode 100644 index 00000000..b1c0c4cc --- /dev/null +++ b/VirtualDriverControl/src/shared/presets.ts @@ -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}` +} diff --git a/VirtualDriverControl/src/shared/types.ts b/VirtualDriverControl/src/shared/types.ts new file mode 100644 index 00000000..59a1b052 --- /dev/null +++ b/VirtualDriverControl/src/shared/types.ts @@ -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 +} diff --git a/VirtualDriverControl/tsconfig.json b/VirtualDriverControl/tsconfig.json new file mode 100644 index 00000000..155ebaa6 --- /dev/null +++ b/VirtualDriverControl/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} diff --git a/VirtualDriverControl/tsconfig.node.json b/VirtualDriverControl/tsconfig.node.json new file mode 100644 index 00000000..b603c8f5 --- /dev/null +++ b/VirtualDriverControl/tsconfig.node.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/**/*" + ] +} diff --git a/VirtualDriverControl/tsconfig.web.json b/VirtualDriverControl/tsconfig.web.json new file mode 100644 index 00000000..1b3224fa --- /dev/null +++ b/VirtualDriverControl/tsconfig.web.json @@ -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/**/*" + ] +}