From 74262d7e6d4c231a3cdb3e4946ce007f8d87d538 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:04:29 +0200 Subject: [PATCH 01/27] chore: apply shared repository quality baseline --- .github/ISSUE_TEMPLATE/bug.yml | 38 +++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/pull_request_template.md | 22 +++ .github/workflows/pages.yml | 13 +- .github/workflows/release-v1.2.5.yml | 40 +++++ .github/workflows/release.yml | 56 ------- .github/workflows/validate.yml | 95 ++++------- CODE_OF_CONDUCT.md | 9 ++ CONTRIBUTING.md | 47 ++++++ Install-AutoSwitch.ps1 | 5 + Runtime-PROX2-AutoSwitch.ps1 | 64 ++++---- SECURITY.md | 33 ++-- SUPPORT.md | 18 +++ Toggle-AudioEnhancements.ps1 | 4 +- Uninstall-AutoSwitch.ps1 | 5 + Verify-AutoSwitch.ps1 | 5 + docs/INDEX.md | 21 +++ docs/WindowsEndpointProvider.md | 234 ++++++++++++--------------- install.ps1 | 83 +++------- lib/AutoSwitchCore.psm1 | 130 +++++++-------- scripts/build-release.sh | 22 +++ scripts/run-gitleaks.sh | 15 ++ tests/AutoSwitchCore.Tests.ps1 | 14 +- tests/fixtures/svcl-export.csv | 6 +- wiki/Bandeja-y-reconfiguracion.md | 14 ++ wiki/Como-funciona.md | 17 ++ wiki/FAQ-Espanol.md | 25 +++ wiki/FAQ.md | 25 +++ wiki/Home.md | 29 ++++ wiki/How-It-Works.md | 26 +++ wiki/Inicio.md | 22 +++ wiki/Instalacion.md | 24 +++ wiki/Installation.md | 26 +++ wiki/Resolucion-de-problemas.md | 19 +++ wiki/Tray-and-Reconfiguration.md | 16 ++ wiki/Troubleshooting.md | 27 ++++ wiki/_Footer.md | 1 + wiki/_Sidebar.md | 15 ++ 38 files changed, 838 insertions(+), 435 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/release-v1.2.5.yml delete mode 100644 .github/workflows/release.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Install-AutoSwitch.ps1 create mode 100644 SUPPORT.md create mode 100644 Uninstall-AutoSwitch.ps1 create mode 100644 Verify-AutoSwitch.ps1 create mode 100644 docs/INDEX.md create mode 100644 scripts/build-release.sh create mode 100644 scripts/run-gitleaks.sh create mode 100644 wiki/Bandeja-y-reconfiguracion.md create mode 100644 wiki/Como-funciona.md create mode 100644 wiki/FAQ-Espanol.md create mode 100644 wiki/FAQ.md create mode 100644 wiki/Home.md create mode 100644 wiki/How-It-Works.md create mode 100644 wiki/Inicio.md create mode 100644 wiki/Instalacion.md create mode 100644 wiki/Installation.md create mode 100644 wiki/Resolucion-de-problemas.md create mode 100644 wiki/Tray-and-Reconfiguration.md create mode 100644 wiki/Troubleshooting.md create mode 100644 wiki/_Footer.md create mode 100644 wiki/_Sidebar.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..96c015a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,38 @@ +name: Reproducible bug +description: Report an AutoSwitch defect with redacted diagnostics. +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: Do not include credentials, unrelated device IDs, private paths or complete unredacted logs. + - type: input + id: version + attributes: + label: AutoSwitch version + validations: + required: true + - type: input + id: headset + attributes: + label: Headset model and connection type + validations: + required: true + - type: dropdown + id: mode + attributes: + label: Detection mode + options: [WindowsEndpoint, LogitechGHub, Unknown / installation failed] + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Redacted endpoint/log evidence + description: Include only the smallest evidence needed to understand ON/OFF state behavior. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a4dc8d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a vulnerability privately + url: https://github.com/Ayerdi/PROX2-AutoSwitch/security/advisories/new + about: Do not publish exploitable security details in a public issue. + - name: Documentation / Wiki + url: https://github.com/Ayerdi/PROX2-AutoSwitch/wiki + about: Check installation, tray, reconfiguration and troubleshooting guides first. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..2825ba5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## What changes? + + + +## Safety + +- [ ] `Unknown` detection still never switches output. +- [ ] OFF debounce remains deliberate or the behavioral change is justified/tested. +- [ ] Downloads/checksums are not weakened. +- [ ] No machine-specific/private data is included. +- [ ] Windows PowerShell 5.1 compatibility is preserved or intentionally documented. + +## Verification + +- [ ] PowerShell syntax +- [ ] PSScriptAnalyzer +- [ ] Pester +- [ ] Relevant real-hardware evidence when detection behavior changes + +## Documentation + + diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f69cbe4..61e157b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'site/**' + - '.github/workflows/pages.yml' workflow_dispatch: permissions: @@ -20,11 +21,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Configure Pages - uses: actions/configure-pages@v5 - - name: Upload site - uses: actions/upload-pages-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site deploy: @@ -36,4 +37,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/release-v1.2.5.yml b/.github/workflows/release-v1.2.5.yml new file mode 100644 index 0000000..1d27822 --- /dev/null +++ b/.github/workflows/release-v1.2.5.yml @@ -0,0 +1,40 @@ +name: Publish v1.2.5 + +on: + push: + branches: [main] + paths: ['.github/workflows/release-v1.2.5.yml'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Skip if release exists + id: existing + env: + GH_TOKEN: ${{ github.token }} + run: if gh release view v1.2.5 >/dev/null 2>&1; then echo 'exists=true' >> "$GITHUB_OUTPUT"; else echo 'exists=false' >> "$GITHUB_OUTPUT"; fi + - name: Scan and build twice + if: steps.existing.outputs.exists == 'false' + run: | + bash scripts/run-gitleaks.sh + bash scripts/build-release.sh 1.2.5 + cp dist/PROX2-AutoSwitch-v1.2.5.zip /tmp/first.zip + first="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" + rm -f dist/*.zip dist/*.sha256 + bash scripts/build-release.sh 1.2.5 + second="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" + test "$first" = "$second" + cmp /tmp/first.zip dist/PROX2-AutoSwitch-v1.2.5.zip + - name: Publish v1.2.5 + if: steps.existing.outputs.exists == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: gh release create v1.2.5 dist/PROX2-AutoSwitch-v1.2.5.zip dist/PROX2-AutoSwitch-v1.2.5.zip.sha256 dist/Audio-AutoSwitch.zip dist/Audio-AutoSwitch.zip.sha256 --target "$GITHUB_SHA" --title 'v1.2.5 — English-first public structure' --notes 'English-first repository and Pages, fully bilingual Wiki source, canonical English PowerShell entrypoints, deterministic release packaging and CI hardening. Detection behavior is unchanged.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 6786053..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - build-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Build release ZIP and checksums - run: | - set -euo pipefail - VERSION="${GITHUB_REF#refs/tags/}" - PACKAGE_DIR="PROX2-AutoSwitch-$VERSION" - VERSIONED_ZIP="PROX2-AutoSwitch-$VERSION.zip" - STABLE_ZIP="Audio-AutoSwitch.zip" - - rm -rf "$PACKAGE_DIR" - mkdir -p "$PACKAGE_DIR/lib" - mkdir -p "$PACKAGE_DIR/assets" - cp Install.cmd Verify.cmd Uninstall.cmd \ - Instalar-PROX2-AutoSwitch.ps1 \ - Runtime-PROX2-AutoSwitch.ps1 \ - Desinstalar-PROX2-AutoSwitch.ps1 \ - Verificar-PROX2-AutoSwitch.ps1 \ - Toggle-AudioEnhancements.ps1 \ - install.ps1 \ - README.md AGENT.md SOURCES.md SECURITY.md LICENSE CHANGELOG.md \ - "$PACKAGE_DIR/" - cp lib/AutoSwitchCore.psm1 "$PACKAGE_DIR/lib/" - cp assets/icon.ico "$PACKAGE_DIR/assets/" - - zip -r "$VERSIONED_ZIP" "$PACKAGE_DIR" - sha256sum "$VERSIONED_ZIP" > "$VERSIONED_ZIP.sha256" - - # Stable asset name gives users one predictable ZIP name while the - # versioned archive remains the canonical release artifact. - cp "$VERSIONED_ZIP" "$STABLE_ZIP" - sha256sum "$STABLE_ZIP" > "$STABLE_ZIP.sha256" - - - name: Upload release assets - uses: softprops/action-gh-release@v2 - with: - files: | - PROX2-AutoSwitch-*.zip - PROX2-AutoSwitch-*.zip.sha256 - Audio-AutoSwitch.zip - Audio-AutoSwitch.zip.sha256 - generate_release_notes: true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 947c9ba..84b68d2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -5,77 +5,48 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: validate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - validate-scripts: + powershell: runs-on: windows-latest steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate PowerShell syntax shell: pwsh run: | - $ErrorActionPreference = 'Stop' - $scripts = Get-ChildItem -Path . -Filter '*.ps1' -File - if (-not $scripts) { Write-Error 'No .ps1 scripts found' } - foreach ($script in $scripts) { - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile( - $script.FullName, [ref]$tokens, [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | ForEach-Object { Write-Error "$($script.Name): $($_.Message) (line $($_.Extent.StartLineNumber))" } - exit 1 - } - Write-Host "OK: $($script.Name)" - } - - - name: Lint with PSScriptAnalyzer + $scripts=Get-ChildItem -Recurse -File | Where-Object { $_.Extension -in '.ps1','.psm1' } + foreach($script in $scripts){$tokens=$null;$errors=$null;[System.Management.Automation.Language.Parser]::ParseFile($script.FullName,[ref]$tokens,[ref]$errors)|Out-Null;if($errors.Count){$errors|ForEach-Object{Write-Error "$($script.FullName): $($_.Message)"};exit 1}} + - name: Install validation modules shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck - } - $results = Invoke-ScriptAnalyzer -Path . -Recurse ` - -Severity Error, Warning ` - -ExcludeRule PSAvoidUsingWriteHost, PSAvoidUsingPlainTextForPassword, PSAvoidUsingCmdletAliases, PSAvoidUsingEmptyCatchBlock, PSUseApprovedVerbs, PSUseShouldProcessForStateChangingFunctions - if ($results) { - $results | ForEach-Object { - Write-Host "::error file=$($_.ScriptPath),line=$($_.Line)::$($_.RuleName): $($_.Message)" - } - exit 1 - } - Write-Host "PSScriptAnalyzer: no Error/Warning findings." - - - name: Verify scripts reference files present + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck + Install-Module Pester -RequiredVersion 5.9.0 -Force -Scope CurrentUser -SkipPublisherCheck + - name: PSScriptAnalyzer shell: pwsh run: | - $required = @( - 'install.ps1', - 'Install.cmd', - 'Verify.cmd', - 'Uninstall.cmd', - 'Instalar-PROX2-AutoSwitch.ps1', - 'Runtime-PROX2-AutoSwitch.ps1', - 'Desinstalar-PROX2-AutoSwitch.ps1', - 'Verificar-PROX2-AutoSwitch.ps1', - 'Toggle-AudioEnhancements.ps1', - 'lib/AutoSwitchCore.psm1' - ) - $missing = $required | Where-Object { -not (Test-Path $_) } - if ($missing) { Write-Error "Missing: $($missing -join ', ')" } - Write-Host "All required files present." - - - name: Run Pester tests + $r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions + if($r){$r|Format-Table -AutoSize|Out-String|Write-Host;exit 1} + - name: Required release files shell: pwsh run: | - $ErrorActionPreference = 'Stop' - if (-not (Get-Module -ListAvailable -Name Pester)) { - Install-Module -Name Pester -Force -Scope CurrentUser -SkipPublisherCheck - } - $config = New-PesterConfiguration - $config.Run.Path = 'tests' - $config.Output.Verbosity = 'Detailed' - $result = Invoke-Pester -Configuration $config - if ($result.FailedCount -gt 0) { exit 1 } - Write-Host "Pester: $($result.PassedCount) passed, $($result.FailedCount) failed." + $required=@('Install.cmd','Verify.cmd','Uninstall.cmd','install.ps1','Install-AutoSwitch.ps1','Verify-AutoSwitch.ps1','Uninstall-AutoSwitch.ps1','Instalar-PROX2-AutoSwitch.ps1','Verificar-PROX2-AutoSwitch.ps1','Desinstalar-PROX2-AutoSwitch.ps1','Runtime-PROX2-AutoSwitch.ps1','Toggle-AudioEnhancements.ps1','lib/AutoSwitchCore.psm1') + $missing=$required|Where-Object{-not(Test-Path $_)};if($missing){throw "Missing release files: $($missing -join ', ')"} + - name: Pester + shell: pwsh + run: Import-Module Pester -RequiredVersion 5.9.0; Invoke-Pester -Path .\tests -CI + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - run: bash scripts/run-gitleaks.sh diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5dff8c3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +This project follows the principles of the [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +Be respectful, constructive and specific. Critique technical ideas rather than people, accept corrections and protect other people's personal information and machine-specific diagnostics. + +Harassment, threats, discrimination, impersonation and disclosure of private information are not acceptable. + +Maintainers may edit/remove contributions or restrict participation when necessary. Sensitive conduct reports should use the private channel described in [SECURITY.md](SECURITY.md) rather than a public issue. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9576c90 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Thanks for helping improve Audio AutoSwitch. + +Before proposing a change, read [SECURITY.md](SECURITY.md), [SOURCES.md](SOURCES.md) and [AGENT.md](AGENT.md). The project interacts with Windows Core Audio, a third-party command-line utility and an undocumented Logitech G HUB WebSocket, so evidence and safe failure behavior matter. + +## Good contributions + +- reproducible headset compatibility fixes; +- safer Windows endpoint detection; +- installer, tray or reconfiguration usability improvements; +- security hardening; +- tests and CI improvements; +- documentation and verified technical sources. + +Avoid claiming a headset is supported unless its real ON/OFF behavior has been observed. `WindowsEndpoint` requires Windows to expose a useful endpoint state transition. Logitech PRO X 2 is the known exception handled through the G HUB fallback. + +## Development checks + +On Windows PowerShell / PowerShell: + +```powershell +$ErrorActionPreference = 'Stop' + +# Syntax +Get-ChildItem -Recurse -Include *.ps1,*.psm1 | ForEach-Object { + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$errors) | Out-Null + if ($errors) { throw "$($_.FullName): $($errors -join '; ')" } +} + +# Tests +Import-Module Pester -RequiredVersion 5.9.0 +Invoke-Pester -Path .\tests -CI +``` + +Do not remove checksum verification, unknown-state protection or OFF debounce to make a device appear compatible. + +## Pull requests + +1. Use a descriptive branch. +2. Add/update Pester coverage for logic changes. +3. Preserve Windows PowerShell 5.1 compatibility unless the project explicitly changes its support policy. +4. Document real hardware evidence for detection changes. +5. Never include machine-specific Item IDs, logs with personal data or private credentials. + +Contributions are licensed under the repository's [MIT License](LICENSE). diff --git a/Install-AutoSwitch.ps1 b/Install-AutoSwitch.ps1 new file mode 100644 index 0000000..3e87033 --- /dev/null +++ b/Install-AutoSwitch.ps1 @@ -0,0 +1,5 @@ +#requires -Version 5.1 +$ErrorActionPreference = 'Stop' +$legacy = Join-Path $PSScriptRoot 'Instalar-PROX2-AutoSwitch.ps1' +if (-not (Test-Path $legacy)) { throw "Installer entrypoint is missing: $legacy" } +& $legacy @args diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 2dce5bf..ae9b131 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -1,8 +1,8 @@ #requires -Version 5.1 $ErrorActionPreference = "Stop" -# Ruta canonica del script: $PSCommandPath (no $MyInvocation.MyCommand.Path, -# que dentro de una funcion describe la invocacion y puede quedar vacio). +# Canonical script path: $PSCommandPath (not $MyInvocation.MyCommand.Path, +# which inside a function describes the invocation and may be empty). $script:RuntimePath = $PSCommandPath $InstallDir = Split-Path -Parent $script:RuntimePath @@ -31,7 +31,7 @@ foreach ($k in 'ConnectTimeoutMs', 'ReceiveTimeoutMs', 'RequestTimeoutMs') { $script:DetectionMode = Get-ConfigDetectionMode -Config $Config if (-not $script:DetectionMode) { - Write-Host "config.json tiene un DetectionMode no valido. Reinstala o corrige el archivo." + Write-Host "config.json has an invalid DetectionMode. Reinstall or correct the file." exit 13 } @@ -66,9 +66,9 @@ if (-not $Config.PSObject.Properties['DetectionMode']) { catch { } } -# Impide dos instancias del runtime para el mismo usuario. +# Prevent two runtime instances for the same user. # El worker (AUTOSWITCH_WORKER=1) es un proceso hijo legitimo del runtime y -# no debe competir por el mutex. +# must not compete for the mutex. $createdNew = $false $mutex = $null if ($env:AUTOSWITCH_WORKER -ne '1') { @@ -82,8 +82,8 @@ if ($env:AUTOSWITCH_WORKER -ne '1') { $script:Ws = $null function Close-GHubConnection { - # El cierre no debe poder colgar la recuperacion: si CloseAsync no - # termina en 1 s (o falla), Abort() + Dispose() garantizan salida. + # Closing must not hang recovery: if CloseAsync does not + # finish within 1 s (or fails), Abort() + Dispose() guarantee exit. if ($null -ne $script:Ws -and $script:Ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) { $closeCts = New-Object System.Threading.CancellationTokenSource @@ -148,7 +148,7 @@ function Connect-GHub { } if ($script:Ws.State -ne [System.Net.WebSockets.WebSocketState]::Open) { - throw "No se pudo conectar con Logitech G HUB." + throw "Could not connect to Logitech G HUB." } } @@ -191,7 +191,7 @@ function Receive-GHubText { if ($remainingMs -le 0) { throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms)." } - # El fragmento espera como mucho ReceiveTimeoutMs, nunca mas alla + # Each fragment waits at most ReceiveTimeoutMs, never beyond # del deadline global de la peticion. $fragmentMs = [Math]::Min($script:ReceiveTimeoutMs, $remainingMs) @@ -239,7 +239,7 @@ function Invoke-GHubGet { } # Limite global por peticion: aunque G HUB intercale eventos, - # la respuesta buscada debe llegar antes del deadline. + # the requested response must arrive before the deadline. $deadline = (Get-Date).AddMilliseconds($script:RequestTimeoutMs) while ($true) { @@ -287,13 +287,13 @@ function Get-ProX2BatteryPath { function Get-DefaultRenderItemId { # IMPORTANTE: /GetColumnValue ya escribe el valor en stdout. - # No agregar /Stdout aqui: /Stdout añade informacion del item y rompe el parseo. + # Do not add /Stdout here: it adds item information and breaks parsing. $raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" "Item ID" 2>&1 $text = ($raw | Out-String).Trim() $id = Get-RenderItemIdFromText -Text $text if (-not $id) { - throw "No se pudo leer el Item ID del dispositivo predeterminado. Salida: $text" + throw "Could not read the default device Item ID. Output: $text" } return $id @@ -350,7 +350,7 @@ function Get-HeadsetEndpointState { .DESCRIPTION - Export valido + fila con Item ID coincidente -> estado normalizado. - Export valido + fila ausente (endpoint no presente) -> Disconnected. - - Export invalido/vacio (svcl fallo, basura) -> Unknown (no tocar nada). + - Invalid/empty export (svcl failure/garbage) -> Unknown (do nothing). #> $csv = Get-SvclCsvExport @@ -468,7 +468,7 @@ function Invoke-EnhancementsToggle { } } -# --- Info del tray: headset, fallback y a donde se cambiaria ahora --- +# --- Tray info: headset, fallback and the output that would be selected now --- function Get-RenderDevicesFromCsv { $csv = Get-SvclCsvExport @@ -477,7 +477,7 @@ function Get-RenderDevicesFromCsv { } function Update-TrayInfo { - # Actualiza las lineas de informacion del menu de bandeja. + # Refresh the tray menu information lines. $hs = if ($Config.HeadsetName) { [string]$Config.HeadsetName } else { [string]$Config.HeadsetId } $fb = if ($Config.SpeakerName) { [string]$Config.SpeakerName } else { [string]$Config.SpeakerId } if ($script:MenuItemInfoHeadset) { @@ -489,7 +489,7 @@ function Update-TrayInfo { $script:MenuItemInfoFallback.Enabled = $false } - # A donde se cambiaria ahora segun el default actual. + # Determine where the next switch would go from the current default. $next = "Current: ?" try { $current = Get-DefaultRenderItemId @@ -538,7 +538,7 @@ function Get-HeadsetStateForId { $csv = Get-SvclCsvExport if (-not (Test-SvclExportValid -CsvText $csv)) { - if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: svcl export invalido/vacio para {0}" -f $ItemId) } + if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: invalid/empty svcl export for {0}" -f $ItemId) } return 'Unknown' } @@ -552,8 +552,8 @@ function Get-HeadsetStateForId { # Fallback de identidad: un endpoint Bluetooth puede reaparecer con otro # Item ID. No usar la etiqueta visible "Device Name — Name" como si fuera - # una columna: resolver por las dos columnas reales evita además confundir - # dos endpoints Render del mismo dispositivo. + # one column: resolving through the two real columns also avoids confusing + # two Render endpoints from the same device. if (-not $row -and (-not [string]::IsNullOrWhiteSpace($DeviceName) -or -not [string]::IsNullOrWhiteSpace($EndpointName))) { @@ -582,7 +582,7 @@ function Get-HeadsetStateForId { $dir = Get-CsvColumn -Row $r -Names @('Direction') $lines += "[$type/$dir] '$name' id='$id' state='$st'" } - Write-AutoSwitchLog ("Get-HeadsetStateForId: NO se encontro {0} en la exportacion. Fila(s) disponibles:`n{1}" -f $ItemId, ($lines -join "`n")) + Write-AutoSwitchLog ("Get-HeadsetStateForId: was NOT found {0} in the export. Available row(s):`n{1}" -f $ItemId, ($lines -join "`n")) } return 'Disconnected' } @@ -604,7 +604,7 @@ function Wait-ForHeadsetState { o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar varios segundos en reflejar el cambio fisico en Windows; una lectura unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer - y volver con otro Item ID -> se busca por nombre como respaldo y se + and return with another Item ID -> fall back to name-based resolution and devuelve el Item ID observado. #> param( @@ -873,7 +873,7 @@ function Show-ReconfigureDialog { return } - # --- Guardar config completa (modo incluido) --- + # --- Save the full configuration (including mode) --- $Config.HeadsetId = [string]$newHeadsetId $Config.SpeakerId = [string]$newFallbackId $Config.HeadsetName = $newHeadsetName @@ -943,8 +943,8 @@ function Invoke-Reconfigure { Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing - # Try/catch exterior: si el fallo ocurre al construir/abrir el dialogo - # (antes de entrar en "Detect mode..."), que quede registrado en el log. + # Outer try/catch: if a failure occurs while creating/opening the dialog + # (before entering "Detect mode..."), make sure it is recorded in the log. try { Show-ReconfigureDialog } @@ -975,7 +975,7 @@ function Invoke-AutoSwitchToggle { $script:MenuItemAutoSwitch.Checked = $false } } - Write-AutoSwitchLog ("AutoSwitch {0} por el usuario." -f $(if ($newState) { 'activado' } else { 'desactivado' })) + Write-AutoSwitchLog ("AutoSwitch {0} by the user." -f $(if ($newState) { 'enabled' } else { 'disabled' })) } function Stop-Runtime { @@ -1046,7 +1046,7 @@ function Stop-Worker { } # --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) --- -# Se ejecuta al final del script; las funciones G HUB/audio/endpoint ya estan +# Runs at the end of the script; G HUB/audio/endpoint functions are already # definidas arriba porque es el mismo script. function Start-WorkerLoop { @@ -1136,7 +1136,7 @@ function Start-WorkerLoop { if (-not $availabilityLogged) { Write-AutoSwitchLog (( "G HUB/AutoSwitch no disponible: {0}. " + - "Se reintentara; mientras el estado sea desconocido no se cambia la salida." + "It will retry; while the state is unknown the output is not changed." ) -f $_.Exception.Message) $availabilityLogged = $true } @@ -1162,7 +1162,7 @@ function Start-WorkerLoop { $lastState = $state.IsOn } catch { - Write-AutoSwitchLog ("No se pudo cambiar la salida de audio: {0}. Se reintentara." -f $_.Exception.Message) + Write-AutoSwitchLog ("Could not change the audio output: {0}. It will retry." -f $_.Exception.Message) } } } @@ -1180,7 +1180,7 @@ function Initialize-TrayAndTimer { $script:TrayIcon = New-Object System.Windows.Forms.NotifyIcon - # Icono propio de la app (icono de auricular azul). Si el .ico no existe + # App-specific icon. If the .ico file does not exist # en disco, caemos a SystemIcons.Application para que siempre haya uno. try { $iconFile = Join-Path $InstallDir "icon.ico" @@ -1200,7 +1200,7 @@ function Initialize-TrayAndTimer { # Form invisible que sostiene el message pump de WinForms. Application.Run() # sin Form no siempre mantiene el NotifyIcon visible activo en todos los - # builds de .NET; con un Form oculto el pump es robusto y la bandeja no se cae. + # .NET builds; an invisible Form provides a robust message pump for the tray. $script:MainForm = New-Object System.Windows.Forms.Form $script:MainForm.WindowState = 'Minimized' $script:MainForm.ShowInTaskbar = $false @@ -1271,7 +1271,7 @@ function Initialize-TrayAndTimer { Write-AutoSwitchLog "PRO X 2 AutoSwitch started (mode $script:DetectionMode)." if ($env:AUTOSWITCH_WORKER -eq '1') { - # Modo worker: solo el bucle de polling. No toca la bandeja. + # Worker mode: polling loop only. It does not touch the tray. Start-WorkerLoop exit 0 } @@ -1288,7 +1288,7 @@ try { } } - # Message pump principal sostenido por un Form invisible. La bandeja de + # Main message pump hosted by an invisible Form. The tray # notificaciones procesa eventos aqui; se mantiene activa hasta Exit. [System.Windows.Forms.Application]::Run($script:MainForm) Write-AutoSwitchLog "Message pump finished (Exit)." diff --git a/SECURITY.md b/SECURITY.md index 15b898c..b9bf0a1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,18 +1,31 @@ -# Security +# Security policy ## Reporting a vulnerability -This project touches two trust-sensitive paths: +Do **not** open a public issue for an exploitable security problem or a report containing personal/private data. Use GitHub's private vulnerability reporting flow: -- **Logitech G HUB local WebSocket** (`ws://localhost:9010`): an undocumented, reverse-engineered interface. Treat it as unofficial and potentially changing. -- **NirSoft `svcl.exe` download**: the installer verifies the SHA-256 of `svcl-x64.zip` before running it. That check is intentional — never disable it. + -Please **do not** open a public issue for a security problem that involves credentials, personal data or exploitable behavior. Report privately instead: +Include the affected release, impact, minimal reproduction and any known mitigation. Redact machine-specific audio identifiers and unrelated logs. -- Open a [private vulnerability report](https://github.com/Ayerdi/PROX2-AutoSwitch/security/advisories/new), or -- Email the maintainer directly if you have their address. +## Trust-sensitive components -## Scope +### Logitech G HUB local WebSocket -- The SHA-256 pin for `svcl-x64.zip` lives in `Instalar-PROX2-AutoSwitch.ps1` (`$ExpectedSha256`). If NirSoft ships a new version, update it from the official hashes page — do not remove the check. -- `config.json` stores the current machine's Windows audio `Item ID`s because they are required to target endpoints; they are local identifiers, not secrets, and must not be copied between machines. Reconfigure may refresh the headset ID after endpoint recreation. The project does **not** persist the volatile G HUB `deviceId`, and repository/releases contain no user-specific IDs or credentials. +`ws://localhost:9010` is an undocumented, reverse-engineered local interface. It is **not** an official Logitech API. Treat responses as untrusted input and keep bounded connection/request/close timeouts. + +### NirSoft SoundVolumeCommandLine + +The installer downloads `svcl-x64.zip` from NirSoft and verifies its pinned SHA-256 before extracting/running it. If NirSoft publishes a new build and the hash changes, installation must fail safely until the value is independently verified from the official hashes page. + +**Never disable the checksum check to make installation succeed.** + +## Local configuration + +`config.json` stores machine-local Windows audio Item IDs because they are required to target endpoints. They are identifiers rather than credentials, but they should not be copied between machines and public issue reports should redact unnecessary identifiers. + +The volatile G HUB `deviceId` is intentionally rediscovered rather than persisted. + +## Release safety + +Release ZIPs are built deterministically, hashed and rebuilt before publication. CI validates PowerShell syntax, PSScriptAnalyzer, Pester and scans Git history for secrets. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..62216fe --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,18 @@ +# Support + +Use GitHub Issues for reproducible bugs and compatibility reports. Use the Wiki and README first for installation, reconfiguration and troubleshooting. + +A useful report includes: + +- AutoSwitch release/version; +- Windows version; +- headset make/model and connection type; +- selected detection mode (`WindowsEndpoint` or `LogitechGHub`); +- the smallest relevant **redacted** log excerpt; +- whether the endpoint changes between `Active`, `Unplugged`, absent or another state when the headset is powered off/on. + +Do not publish credentials, private paths, unrelated device identifiers or complete logs containing personal information. + +Compatibility cannot be guaranteed for every wireless headset. The universal path depends on the state Windows exposes; the Logitech PRO X 2 fallback depends on an unofficial local G HUB interface that Logitech may change. + +For security-sensitive problems use [SECURITY.md](SECURITY.md), not a public issue. diff --git a/Toggle-AudioEnhancements.ps1 b/Toggle-AudioEnhancements.ps1 index 3a62577..4a9ceee 100644 --- a/Toggle-AudioEnhancements.ps1 +++ b/Toggle-AudioEnhancements.ps1 @@ -7,7 +7,7 @@ PKEY_AudioEndpoint_Disable_SysFx (1da5d803-d492-4edd-8c23-e0c0ffee7f0e, 5) SOLO para el DeviceId indicado, verifica el resultado y sale con codigo: 0 = cambio aplicado y verificado - 1 = no se pudo aplicar o verificar (UAC cancelado, endpoint inexistente, ...) + 1 = could not apply or verify (UAC cancelled, missing endpoint, ...) #> [CmdletBinding()] param( @@ -111,7 +111,7 @@ namespace AutoSwitch int hrGet = policy.GetPropertyValue(deviceId, true, ref pkey, out pvCheck); if (hrGet != 0) { - return -hrGet; // fallo en lectura -> exit 1 + return -hrGet; // read failure -> exit 1 } bool effective = pvCheck.ulVal != 0; diff --git a/Uninstall-AutoSwitch.ps1 b/Uninstall-AutoSwitch.ps1 new file mode 100644 index 0000000..c7f687e --- /dev/null +++ b/Uninstall-AutoSwitch.ps1 @@ -0,0 +1,5 @@ +#requires -Version 5.1 +$ErrorActionPreference = 'Stop' +$legacy = Join-Path $PSScriptRoot 'Desinstalar-PROX2-AutoSwitch.ps1' +if (-not (Test-Path $legacy)) { throw "Uninstaller entrypoint is missing: $legacy" } +& $legacy @args diff --git a/Verify-AutoSwitch.ps1 b/Verify-AutoSwitch.ps1 new file mode 100644 index 0000000..084fe15 --- /dev/null +++ b/Verify-AutoSwitch.ps1 @@ -0,0 +1,5 @@ +#requires -Version 5.1 +$ErrorActionPreference = 'Stop' +$legacy = Join-Path $PSScriptRoot 'Verificar-PROX2-AutoSwitch.ps1' +if (-not (Test-Path $legacy)) { throw "Verification entrypoint is missing: $legacy" } +& $legacy @args diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..d75c16e --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,21 @@ +# Documentation index + +## Start here + +- [README](../README.md) — product overview, installation and usage. +- [Project website](https://ayerdi.github.io/PROX2-AutoSwitch/) — visual overview and real tray/demo images. +- [Wiki](https://github.com/Ayerdi/PROX2-AutoSwitch/wiki) — user guides in **English and Spanish**. +- [Support](../SUPPORT.md) — what to include in a compatibility report. + +## Technical reference + +- [AGENT.md](../AGENT.md) — maintainer context and invariants. +- [SOURCES.md](../SOURCES.md) — verified technical sources and hardware evidence. +- [WindowsEndpointProvider.md](WindowsEndpointProvider.md) — historical design notes and assumptions that were corrected by real Bluetooth testing. +- [Security](../SECURITY.md) — trust boundaries and private reporting. + +## Project maintenance + +- [Contributing](../CONTRIBUTING.md) +- [Changelog](../CHANGELOG.md) +- [Code of Conduct](../CODE_OF_CONDUCT.md) diff --git a/docs/WindowsEndpointProvider.md b/docs/WindowsEndpointProvider.md index 21c572e..c71511e 100644 --- a/docs/WindowsEndpointProvider.md +++ b/docs/WindowsEndpointProvider.md @@ -1,164 +1,134 @@ -# WindowsEndpointProvider — Issue técnica para el agente +# WindowsEndpointProvider — historical design note -> **Estado:** **SUPERSEDED.** Este documento fue el diseño original para añadir detección universal. -> La implementación final (v1.2.0) lo integró de forma más amplia: el proyecto es ahora un -> AutoSwitch universal con `DetectionMode` (`WindowsEndpoint` | `LogitechGHub`), polling en un -> proceso worker separado (`AUTOSWITCH_WORKER=1`), icono de bandeja y toggle de Audio Enhancements. -> Ver el CHANGELOG v1.2.0 y `AGENT.md`. Se conserva como contexto histórico del razonamiento de providers. -> **Corrección histórica (v1.2.3+):** la primera prueba del Jabra mantuvo el mismo `Item ID`, pero pruebas posteriores demostraron que Bluetooth/Core Audio puede recrear endpoints. No uses la estabilidad del GUID como premisa. Reconfigure debe re-resolver por `Device Name` + `Name` y persistir el ID observado. -> **Problema de fondo:** cambiar la detección para que el AutoSwitch funcione con cualquier auricular, no solo Logitech PRO X 2, manteniendo intacto lo que ya funciona. +> **Status: SUPERSEDED.** This document records the design path that led to the universal `WindowsEndpoint` mode. The final implementation in v1.2.0 went further: it introduced detection modes, a separate worker process, tray controls, Audio Enhancements support and a universal-first installation wizard. Current behavior is documented in the README, Wiki, CHANGELOG and `AGENT.md`. +> +> **Important correction from later hardware testing:** an early Jabra Evolve 65 test happened to keep the same Item ID through one OFF/ON cycle. Later Bluetooth/Core Audio testing showed that endpoints can be recreated with a different Item ID. Item-ID stability must never be treated as a provider invariant. Current reconfiguration can re-resolve the endpoint by the real `Device Name` + `Name` columns and persist the newly observed ID. -## Contexto: por qué existe esta issue +## Why this design existed -El AutoSwitch actual detecta el estado físico del auricular consultando el WebSocket no oficial de Logitech G HUB (`ws://localhost:9010`). Eso limita el soporte a auriculares de Logitech con G HUB abierto. +The original AutoSwitch detected Logitech PRO X 2 physical state through the unofficial G HUB WebSocket at `ws://localhost:9010`. That worked for PRO X 2 but tied the project to one vendor-specific signal. -En una prueba real con unos **Jabra Evolve 65** se observó que Windows ya cambia el **estado del endpoint de audio** según el estado físico del auricular, sin necesidad de software del fabricante: +A real Jabra Evolve 65 test showed a different and more general pattern: Windows itself changed the render endpoint state when the headset was powered off and back on. -| Estado Jabra | `svcl.exe /GetColumnValue "DefaultRenderDevice" "State"` | -|---|---| -| ENCENDIDOS | `Active` | -| APAGADOS | `Unplugged` | -| ENCENDIDOS otra vez | `Active` | - -En aquella prueba concreta, el **Item ID del endpoint no cambió** entre esos tres estados (esto fue una observación, **no una garantía**): +Observed pattern: ```text -{0.0.0.00000000}.{ed043b5e-65dc-4ba6-a847-310517ac1849} +headset on → Active +headset off → Unplugged +headset on → Active ``` -Eso significa que para estos auriculares se puede detectar el estado físico mirando el `State` del endpoint de Windows, sin APIs del fabricante. +That evidence suggested a general provider based on the Windows audio endpoint rather than vendor software. -## Objetivo +## Proposed provider abstraction -Convertir la detección en un sistema de **providers** con una abstracción común: +The design separated physical-state detection from the rest of the switching logic: ```text -Provider → On / Off / Unknown +WindowsEndpointProvider ─┐ + ├─> Connected / Disconnected / Unknown +LogitechGHubProvider ────┘ + │ + ▼ + common OFF debounce + │ + ▼ + svcl /SetDefault ``` -- **WindowsEndpointProvider** (nuevo): lee el `State` del endpoint de Windows. Soporta cualquier auricular cuyo endpoint refleje el estado físico (ej. Jabra Evolve 65). -- **LogitechGHubProvider** (actual): consulta el payload de batería de G HUB. Sigue soportando PRO X 2. +The normalized state has three meaningful values: + +- `Connected` +- `Disconnected` +- `Unknown` + +`Unknown` is deliberately fail-safe: it never causes the runtime to change the Windows output. + +## Windows endpoint provider + +The provider reads render-device information exported by SoundVolumeCommandLine (`svcl.exe /scomma ""`) and matches the configured endpoint. -El runtime, el instalador y el desinstalador funcionan con cualquiera de los dos. +Relevant columns: -## Diseño de la solución +- `Type` +- `Direction` +- `Device State` +- `Item ID` +- `Device Name` +- `Name` -### 1. Abstracción de provider en `lib/` +Only `Type=Device` and `Direction=Render` rows are candidates. -Todo lo nuevo que sea lógica pura va a `lib/AutoSwitchCore.psm1` (testeable con Pester, sin dependencias de G HUB ni de `svcl.exe`). El proveedor devuelve un estado normalizado: +Typical mapping: -```powershell -# Resultado de una lectura de estado -# Status: 'On' | 'Off' | 'Unknown' -[pscustomobject]@{ - Status = 'On' # On/Off/Unknown - Detail = '...' # texto para log -} +```text +Active → Connected +Unplugged → Disconnected +anything else / invalid export → Unknown ``` -Ambos providers devuelven ese mismo objeto; el resto del runtime no sabe (ni necesita saber) qué provider está detrás. +Do not use `/Stdout` before `/GetColumnValue`. An earlier implementation contaminated the returned Item ID with extra item information and broke `/SetDefault`. -### 2. WindowsEndpointProvider +If a headset remains `Active` when physically powered off, `WindowsEndpoint` is not a safe detector for that device. The installer must discover that during the real OFF/ON calibration rather than pretending support. -- Estado físico = `State` del endpoint de render de Windows. -- La lectura usa `svcl.exe` (ya disponible en el paquete): - - `svcl.exe /GetColumnValue "DefaultRenderDevice" "State"`. - - **NUNCA** `/Stdout` delante de `/GetColumnValue` (bug conocido: contamina la salida y rompe el parseo; ver README "Known bug"). -- Normalización a nuestro estado: - - `Active` → `On`. - - `Unplugged` → `Off`. - - Cualquier otro valor (o lectura fallida) → `Unknown`, **sin cambiar la salida de audio** (regla existente: estado desconocido = no tocar nada). -- Importante: si el auricular está apagado pero **no** aparece `Unplugged` (p. ej. sigue `Active`), este provider no sirve para ese dispositivo. El instalador lo detecta en calibración. +## Logitech G HUB provider -### 3. Configuración (config.json) +PRO X 2 needs a device-specific fallback because its Windows endpoint can remain `Active` while the physical headset is off. -Nuevo campo obligatorio: +The fallback uses the unofficial local G HUB interface: -```json -{ - "Provider": "WindowsEndpoint", - "HeadsetName": "2- Jabra Evolve 65", - "HeadsetId": "{0.0.0.00000000}.{ed043b5e-65dc-4ba6-a847-310517ac1849}", - "SpeakerId": "{...}" -} -``` +- connect to `ws://localhost:9010`; +- discover PRO X 2 through `/devices/list`; +- use the battery-state response as the ON/OFF signal; +- never persist the volatile G HUB `deviceId`; +- keep hard connect/receive/request/close deadlines. -- `Provider` vale `WindowsEndpoint` o `LogitechGHub`. -- Los campos G HUB (`GHubDisplayName`, `GHubPort`) solo son obligatorios/validados cuando `Provider == LogitechGHub`. -- Compatibilidad hacia atrás: si `Provider` no está presente, el runtime trata la config como `LogitechGHub` (config actual v1.1.0). El instalador nuevo siempre escribe `Provider`. +This interface is reverse-engineered and can change in future G HUB versions. -### 4. Runtime +## Installer calibration -Loop de polling actual con el mismo debounce y los mismos timeouts, pero la lectura de estado se resuelve por provider: +A safe universal installer should not ask the user to understand provider internals. -```text -WindowsEndpoint: leer State de DefaultRenderDevice → Active/Unplugged → On/Off/Unknown -LogitechGHub: GET /battery//state → payload presente → On, ausente → Off -``` +The intended flow became: + +1. select the headset endpoint; +2. select the fallback output; +3. observe the selected headset while ON; +4. ask the user to power it OFF and observe the result; +5. ask the user to power it ON again; +6. choose `WindowsEndpoint` only when Windows demonstrates a reliable state transition; +7. if Windows does not provide a usable signal, offer the G HUB path only when the device is confirmed to be PRO X 2; +8. otherwise abort installation safely. + +The final implementation also polls for bounded windows because Bluetooth/Core Audio transitions can take several seconds. + +## Shared switching rules + +Regardless of provider: + +- `Unknown` never switches output; +- consecutive OFF readings are required before treating the headset as disconnected; +- after `/SetDefault`, the current default endpoint should be re-read/verified where practical; +- machine-local Item IDs must not be copied between PCs; +- endpoint recreation must be handled explicitly rather than assuming an ID is permanent. + +## Configuration compatibility + +The final product uses `DetectionMode` rather than the early draft's provider naming. Existing configurations without the new field are migrated conservatively to the legacy Logitech behavior. + +The runtime and installer share pure logic through `lib/AutoSwitchCore.psm1`, which keeps endpoint-state mapping, debounce/config validation and Core Audio helper logic testable with Pester. + +## Testing principles that survived into the implementation + +Automated tests should cover at least: + +- valid `Active` → `Connected` mapping; +- valid `Unplugged`/missing endpoint → `Disconnected` only when the export itself is trustworthy; +- invalid/empty export → `Unknown`; +- OFF debounce; +- identical headset/fallback IDs rejected; +- safe config migration; +- endpoint identity matching based on the separate `Device Name` + `Name` fields; +- timeout behavior for the G HUB path. -Reglas que **se mantienen**: - -- Debounce OFF: `OffMissThreshold` respuestas consecutivas de OFF antes de cambiar a altavoces (evita flapping). -- Estado `Unknown` → no tocar la salida, log y reintento. -- Verificación del cambio: tras `svcl /SetDefault all`, releer el Item ID predeterminado y comparar; un solo reintento si falla. -- Límites duros de tiempo en WebSocket (solo G HUB). -- Mutex por usuario, inicio invisible vía `wscript.exe`, log con rotación. - -### 5. Instalador - -Dejar de preguntar implícitamente por "PRO X 2 + altavoces". El flujo nuevo es: - -1. **Seleccionar el dispositivo a vigilar** (el actual default en Windows, p. ej. `2- Jabra Evolve 65`). -2. **Seleccionar el dispositivo de fallback** (p. ej. `Altavoces AMAZON`). -3. **Auto-detección del provider** (el usuario no tiene que saber nada de providers): - - Tras capturar los dos endpoints, el asistente pide: *"Apaga el auricular y pulsa ENTER"*. - - Se lee el `State` del auricular: - - `Active` → `Unplugged`: **Windows detecta el estado físico directamente** → `Provider = WindowsEndpoint`. - - `Active` → `Active` (o `Unknown`): Windows no lo detecta. El asistente busca un provider específico de dispositivo: - - Si es un Logitech (por nombre) y G HUB responde → `Provider = LogitechGHub`. - - Si no hay provider compatible → abortar con mensaje claro: *"Windows no puede detectar el estado físico de este auricular y no hay provider compatible"*. -4. **Probar de verdad ambos cambios** de salida (ya existe `Test-SetDefault`). -5. Guardar `config.json` con `Provider` y los campos correspondientes, mantener `InstalledAt`. - -### 6. Desinstalador / Verificador - -- Desinstalador: sin cambios de fondo (sigue matando el proceso y limpiando autostart + archivos). -- Verificador: mostrar `Provider` de config y, si es `WindowsEndpoint`, comprobar el estado `Active`/`Unplugged` en vez de (o además de) probar el puerto G HUB. - -## Fases de implementación (orden) - -1. **WindowsEndpointProvider**: crear la lógica pura en `lib/` con tests Pester (normalización `Active`→`On`, `Unplugged`→`Off`, valores raros→`Unknown`, Item ID de endpoint). -2. **Probarlo en máquina real con los Jabra Evolve 65**: validar la transición `Active ↔ Unplugged` durante un día de uso normal (aumenta la confianza del proveedor de detección sin tocar nada más). -3. **Refactor runtime**: ambos providers devuelven `On/Off/Unknown`; el loop usa el provider de config. -4. **Instalador con auto-selección de provider** (paso 3 del flujo de instalación). -5. **Backlog / futuro** (NO en esta issue): - - Sustituir el polling por eventos nativos de Windows (`IMMNotificationClient`) para que `Active ↔ Unplugged` sea prácticamente instantáneo. - - Botón "Disable Audio Enhancements" para endpoints que lo necesiten. - - Renombrar el proyecto a largo plazo a "Audio AutoSwitch" (manteniendo PRO X 2 como dispositivo especialmente soportado); implica actualizar URLs de `install.ps1` en README/site. - -## Criterios de aceptación - -- [ ] El runtime con `Provider = WindowsEndpoint` cambia a auriculares cuando el endpoint pasa a `Active` y a altavoces cuando pasa a `Unplugged`. -- [ ] El runtime con `Provider = LogitechGHub` sigue funcionando igual que hoy (regresión: sin cambios de comportamiento). -- [ ] Instalador detecta automáticamente `WindowsEndpoint` con un auricular Jabra y `LogitechGHub` con un PRO X 2, sin preguntar al usuario por providers. -- [ ] `Unknown` nunca cambia la salida de audio. -- [ ] Debounce OFF se mantiene en ambos providers. -- [ ] Config v1.1.0 sin `Provider` se interpreta como `LogitechGHub` (compatibilidad hacia atrás). -- [ ] Tests Pester nuevos en `tests/` cubren la normalización del estado y los casos `Unknown`. -- [ ] CI `validate.yml` pasa (sintaxis, PSScriptAnalyzer, Pester). - -## Riesgos y mitigaciones - -- **`State` puede no reflejar el estado físico en todos los auriculares** (algunos se quedan `Active` apagados). Mitigación: la auto-detección del instalador descarta ese caso y no deja instalar con `WindowsEndpoint` si no se observa `Active → Unplugged`. -- **`svcl.exe /GetColumnValue ... State`**: confirmar en SOURCES.md el valor exacto que devuelve (columna `State`). Si cambia el formato de salida, `Get-RenderItemIdFromText` y la normalización se actualizan; los tests Pester lo protegen. -- **Windows puede recrear endpoints** (Item ID cambia): la issue original lo dejó fuera de alcance. La implementación posterior de Reconfigure (v1.2.3+) añadió recuperación acotada por `Device Name` + `Name` y persistencia del ID nuevo. - -## Archivos afectados (estimación) - -- `lib/AutoSwitchCore.psm1` — normalización de estado + lógica de provider. -- `Runtime-PROX2-AutoSwitch.ps1` — resolver estado por provider. -- `Instalar-PROX2-AutoSwitch.ps1` — flujo de calibración + auto-detección + `Provider` en config. -- `Desinstalar-PROX2-AutoSwitch.ps1` — sin cambios funcionales (revisar). -- `Verificar-PROX2-AutoSwitch.ps1` — mostrar `Provider` y estado del endpoint. -- `tests/AutoSwitchCore.Tests.ps1` — nuevos tests. -- `README.md`, `AGENT.md`, `SOURCES.md`, `CHANGELOG.md`, `site/` — documentación. +Real hardware testing is still required for new headset claims. A unit test can prove parsing and state-machine behavior, but it cannot prove what Windows exposes for a particular wireless device. diff --git a/install.ps1 b/install.ps1 index e124a82..03c5b40 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,82 +1,45 @@ #requires -Version 5.1 -$ErrorActionPreference = "Stop" - -# PowerShell 5.1 on older .NET may negotiate TLS 1.0/1.1 and fail against -# GitHub/NirSoft. Force TLS 1.2. +$ErrorActionPreference = 'Stop' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -# Audio AutoSwitch one-command bootstrap. -# Downloads the latest GitHub Release ZIP plus its SHA-256 checksum and only -# runs the real installer after the package integrity has been verified. - -$Repo = "Ayerdi/PROX2-AutoSwitch" +$Repo = 'Ayerdi/PROX2-AutoSwitch' $ApiUrl = "https://api.github.com/repos/$Repo/releases/latest" -$ZipPattern = "PROX2-AutoSwitch-*.zip" -$ChecksumSuffix = ".sha256" - -Write-Host "" -Write-Host "============================================================" -ForegroundColor Cyan -Write-Host " Audio AutoSwitch" -ForegroundColor Cyan -Write-Host " Quick install" -ForegroundColor Cyan -Write-Host "============================================================" -ForegroundColor Cyan -Write-Host "" -Write-Host "Looking for the latest release..." -ForegroundColor Yellow +$ZipPattern = 'PROX2-AutoSwitch-*.zip' -$release = Invoke-RestMethod -Uri $ApiUrl -Headers @{ "User-Agent" = "Audio-AutoSwitch-installer" } +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host ' Audio AutoSwitch' -ForegroundColor Cyan +Write-Host ' Verified one-command installation' -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host '' +Write-Host 'Looking for the latest stable release...' -ForegroundColor Yellow +$release = Invoke-RestMethod -Uri $ApiUrl -Headers @{ 'User-Agent' = 'Audio-AutoSwitch-installer' } $zipAssets = @($release.assets | Where-Object { $_.name -like $ZipPattern }) -if ($zipAssets.Count -ne 1) { - throw "Expected exactly one project ZIP ($ZipPattern) in release $($release.tag_name), but found $($zipAssets.Count)." -} +if ($zipAssets.Count -ne 1) { throw "Expected exactly one project ZIP ($ZipPattern) in $($release.tag_name); found $($zipAssets.Count)." } $zipAsset = $zipAssets[0] - -$checksumName = $zipAsset.name + $ChecksumSuffix +$checksumName = $zipAsset.name + '.sha256' $checksumAsset = $release.assets | Where-Object { $_.name -eq $checksumName } | Select-Object -First 1 -if (-not $checksumAsset) { - throw "Release $($release.tag_name) does not publish $checksumName. Integrity cannot be verified, so installation is aborted." -} +if (-not $checksumAsset) { throw "Release $($release.tag_name) does not publish $checksumName; integrity cannot be verified." } $ZipPath = Join-Path $env:TEMP $zipAsset.name $ChecksumPath = Join-Path $env:TEMP $checksumName -$ExtractDir = Join-Path $env:TEMP "PROX2-AutoSwitch-extract" - +$ExtractDir = Join-Path $env:TEMP 'PROX2-AutoSwitch-extract' try { - Write-Host "Version $($release.tag_name)" -ForegroundColor Green - Write-Host "Downloading $($zipAsset.name) and checksum..." -ForegroundColor Yellow Invoke-WebRequest -UseBasicParsing -Uri $zipAsset.browser_download_url -OutFile $ZipPath Invoke-WebRequest -UseBasicParsing -Uri $checksumAsset.browser_download_url -OutFile $ChecksumPath - - Write-Host "Verifying SHA-256..." -ForegroundColor Yellow - $expectedLine = (Get-Content -Raw $ChecksumPath).Trim() - $expectedHash = ($expectedLine -split "\s+")[0].ToLowerInvariant() - if (-not $expectedHash -or $expectedHash -notmatch '^[0-9a-f]{64}$') { - throw "The downloaded checksum is not a valid SHA-256 value." - } - $actualHash = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() - if ($actualHash -ne $expectedHash) { - throw "The ZIP SHA-256 does not match the published checksum. Installation is aborted. Expected=$expectedHash Actual=$actualHash" - } - Write-Host " SHA-256 OK." -ForegroundColor Green - - Write-Host "Extracting..." -ForegroundColor Yellow + $expected = (((Get-Content -Raw $ChecksumPath).Trim()) -split '\s+')[0].ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$') { throw 'Downloaded checksum is not a valid SHA-256 value.' } + $actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw "Release ZIP SHA-256 mismatch. Expected=$expected Got=$actual" } if (Test-Path $ExtractDir) { Remove-Item $ExtractDir -Recurse -Force } Expand-Archive -Path $ZipPath -DestinationPath $ExtractDir -Force - - $Installer = Join-Path $ExtractDir "Instalar-PROX2-AutoSwitch.ps1" - if (-not (Test-Path $Installer)) { - # Releases may contain a wrapper directory, so search one level deeper. - $nested = Get-ChildItem -Path $ExtractDir -Recurse -Filter "Instalar-PROX2-AutoSwitch.ps1" | - Select-Object -First 1 - if ($nested) { $Installer = $nested.FullName } - } - if (-not (Test-Path $Installer)) { - throw "The installer was not found inside the release ZIP." - } - - & $Installer + $installer = Get-ChildItem -Path $ExtractDir -Recurse -Filter 'Install-AutoSwitch.ps1' | Select-Object -First 1 + if (-not $installer) { $installer = Get-ChildItem -Path $ExtractDir -Recurse -Filter 'Instalar-PROX2-AutoSwitch.ps1' | Select-Object -First 1 } + if (-not $installer) { throw 'No installer entrypoint was found inside the release ZIP.' } + & $installer.FullName } finally { - # Leave no temporary package behind, including on failure. Remove-Item $ZipPath -Force -ErrorAction SilentlyContinue Remove-Item $ChecksumPath -Force -ErrorAction SilentlyContinue Remove-Item $ExtractDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/lib/AutoSwitchCore.psm1 b/lib/AutoSwitchCore.psm1 index eeca61f..75a3573 100644 --- a/lib/AutoSwitchCore.psm1 +++ b/lib/AutoSwitchCore.psm1 @@ -1,16 +1,16 @@ #requires -Version 5.1 -# AutoSwitchCore.psm1 - logica pura y testeable del PRO X 2 AutoSwitch. -# Sin dependencias de G HUB ni de svcl.exe, para poder probarse con Pester. +# AutoSwitchCore.psm1 - pure, testable logic for Audio AutoSwitch. +# No G HUB or svcl.exe dependency is required for Pester tests. Set-StrictMode -Version Latest function Get-RenderItemIdFromText { <# .SYNOPSIS - Extrae el Item ID de render valido desde la salida de svcl.exe. + Extract a valid render Item ID from svcl.exe output. .DESCRIPTION - Usa /GetColumnValue (NUNCA /Stdout /GetColumnValue, que contamina la salida). - Devuelve $null si no hay ningun Item ID de render valido. + Use /GetColumnValue (NEVER /Stdout /GetColumnValue, which contaminates the output). + Return $null when no valid render Item ID is present. #> [CmdletBinding()] param( @@ -33,11 +33,11 @@ function Get-RenderItemIdFromText { function Resolve-HeadsetState { <# .SYNOPSIS - Debounce del estado fisico del PRO X 2. + Debounce physical headset state. .DESCRIPTION - Devuelve [pscustomobject] con IsOn (bool), Decision (bool: aplicar o no) - y Misses (contador). Solo se decide OFF tras OffMissThreshold respuestas - vacias consecutivas. Una respuesta con payload resetea el contador. + Return a [pscustomobject] with IsOn, Decision (whether to act) + and Misses. Decide OFF only after OffMissThreshold consecutive + empty responses. A response with a payload resets the counter. #> [CmdletBinding()] param( @@ -73,11 +73,11 @@ function Resolve-HeadsetState { function ConvertFrom-SvclCsv { <# .SYNOPSIS - Parsea la salida /scomma de svcl.exe a objetos. + Parse svcl.exe /scomma output into objects. .DESCRIPTION - La primera linea de la exportacion es la cabecera de columnas. - Soporta campos entre comillas dobles y comas internas. - Devuelve [pscustomobject[]] con una propiedad por columna. + The first export line contains the column headers. + Supports double-quoted fields and embedded commas. + Returns [pscustomobject[]] with one property per column. #> [CmdletBinding()] param( @@ -96,8 +96,8 @@ function ConvertFrom-SvclCsv { return @() } - # ConvertFrom-Csv nativo de PowerShell: maneja comillas, headers con - # espacios y campos con comas de forma fiable. + # Native PowerShell ConvertFrom-Csv handles quotes, headers with + # spaces and fields containing commas reliably. try { $csv = $lines -join [Environment]::NewLine $objects = @($csv | ConvertFrom-Csv) @@ -114,7 +114,7 @@ function ConvertFrom-SvclCsv { function ConvertFrom-CsvLine { <# .SYNOPSIS - Divide una linea CSV simple en campos, respetando comillas dobles. + Split a simple CSV line into fields while respecting double quotes. #> [CmdletBinding()] param( @@ -163,10 +163,10 @@ function ConvertFrom-CsvLine { function Get-CsvColumn { <# .SYNOPSIS - Devuelve el valor de la primera columna cuyo nombre coincida (sin - distinguir mayusculas) con uno de $Names. Soportan alias porque - svcl usa 'State' en unas versiones y 'DeviceState' en otras. - Devuelve $null si no existe ninguna. + Return the value from the first column whose name matches one of + $Names case-insensitively. Aliases are supported because + svcl uses 'State' in some versions and 'DeviceState' in others. + Return $null when none exists. #> [CmdletBinding()] param( @@ -190,14 +190,14 @@ function Get-CsvColumn { function Resolve-EndpointState { <# .SYNOPSIS - Normaliza el estado de un endpoint de Windows a Connected/Disconnected/Unknown. + Normalize a Windows endpoint state to Connected/Disconnected/Unknown. .DESCRIPTION Active -> Connected Unplugged -> Disconnected NotPresent -> Disconnected - roowausente -> Disconnected - Disabled -> Unknown (no cambiar) - Error / otro -> Unknown (no cambiar) + missing row -> Disconnected + Disabled -> Unknown (do not switch) + Error / other -> Unknown (do not switch) #> [CmdletBinding()] param( @@ -216,11 +216,11 @@ function Resolve-EndpointState { function Resolve-DetectedState { <# .SYNOPSIS - Debounce del estado detectado (endpoint Windows o payload G HUB). + Debounce detected state (Windows endpoint or G HUB payload). .DESCRIPTION - Igual que Resolve-HeadsetState pero sin asumir nada de G HUB: - PayloadPresent true -> Connected; false -> Disconnected tras - OffMissThreshold misses. Devuelve el mismo objeto con IsOn/Decision/Misses. + Same as Resolve-HeadsetState without assuming G HUB: + PayloadPresent true -> Connected; false -> Disconnected after + OffMissThreshold misses. Returns the same IsOn/Decision/Misses object. #> [CmdletBinding()] param( @@ -235,7 +235,7 @@ function Resolve-DetectedState { function Test-ValidAudioConfig { <# .SYNOPSIS - True si HeadsetId y SpeakerId son distintos. + True when HeadsetId and SpeakerId are different. #> [CmdletBinding()] param( @@ -249,10 +249,10 @@ function Test-ValidAudioConfig { function New-GHubTimeoutToken { <# .SYNOPSIS - CancellationTokenSource que se cancela solo pasados $Milliseconds. + CancellationTokenSource that cancels after $Milliseconds. .DESCRIPTION - Todos los CallAsync del WebSocket usan este token; sin el CancelAfter - un CloseAsync/ReceiveAsync podria colgar el runtime indefinidamente. + All WebSocket async calls use this token; without CancelAfter + CloseAsync/ReceiveAsync could hang the runtime indefinitely. #> [CmdletBinding()] param( @@ -267,12 +267,12 @@ function New-GHubTimeoutToken { function Test-SvclExportValid { <# .SYNOPSIS - True si el texto es una exportacion /scomma de svcl valida. + True when the text is a valid svcl /scomma export. .DESCRIPTION - Un export valido debe tener al menos una fila de datos cuya cabecera - exponga las columnas que el runtime necesita: 'Item ID' y - 'Device State'. Un texto vacio, basura o con cabecera incompleta no es - un export valido -> el llamador debe tratarlo como 'Unknown' (no como + A valid export must contain at least one data row and headers exposing + the columns required by the runtime: 'Item ID' and + 'Device State'. Empty, garbage or incomplete-header text is not + a valid export -> the caller must treat it as 'Unknown' (not 'Disconnected'). #> [CmdletBinding()] @@ -291,7 +291,7 @@ function Test-SvclExportValid { return $false } - # La primera fila debe exponer la cabecera minima que usa el runtime. + # The first row must expose the minimum headers required by the runtime. $first = $rows[0] $hasItemId = $null -ne $first.PSObject.Properties['Item ID'] $hasDeviceState = $null -ne $first.PSObject.Properties['Device State'] @@ -302,12 +302,12 @@ function Test-SvclExportValid { function Get-SvclRenderDevice { <# .SYNOPSIS - Filtra la exportacion /scomma de svcl.exe a solo endpoints de salida - (render) reales: Type='Device' y Direction='Render' (o 'Render' como - subcadena, segun la version de svcl). + Filter svcl.exe /scomma export to real render output endpoints + with Type='Device' and Direction='Render'. + .DESCRIPTION - Devuelve [pscustomobject[]] con las filas filtradas. Cada fila conserva - las columnas reales de svcl: Name, Type, Direction, Device State, Item ID. + Returns [pscustomobject[]] with filtered rows. Each row keeps + the real svcl columns: Name, Type, Direction, Device State, Item ID. #> [CmdletBinding()] param( @@ -321,7 +321,7 @@ function Get-SvclRenderDevice { return @() } - # Filtro con bucle explicito (sin Where-Object con $_): mas predecible. + # Explicit loop filter (without Where-Object/$_) for predictable behavior. $render = [System.Collections.Generic.List[object]]::new() foreach ($row in $rows) { @@ -339,11 +339,11 @@ function Get-SvclRenderDevice { function Get-SvclDeviceLabel { <# .SYNOPSIS - Construye la etiqueta visible de una fila de svcl. + Build the display label for an svcl row. .DESCRIPTION - svcl separa Name (p. ej. 'Auriculares') de Device Name - (p. ej. '2- Jabra Evolve 65'). Si existe Device Name se muestra - 'Device Name — Name'; si no, solo Name. + svcl separates Name (for example 'Headphones') from Device Name + (for example '2- Jabra Evolve 65'). When Device Name exists, display + 'Device Name — Name'; otherwise display Name only. #> [CmdletBinding()] param( @@ -367,12 +367,12 @@ function Get-SvclDeviceLabel { function Find-SvclRenderDeviceByIdentity { <# .SYNOPSIS - Encuentra un endpoint Render por identidad estable de svcl. + Find a Render endpoint by stable svcl identity. .DESCRIPTION - Bluetooth puede recrear un endpoint y cambiar su Item ID. Para - re-resolverlo sin confundir dos salidas del mismo dispositivo se - comparan, cuando existen, AMBAS columnas: Device Name y Name. - Devuelve $null si no hay identidad suficiente o no existe coincidencia. + Bluetooth may recreate an endpoint and change its Item ID. To + re-resolve it without confusing two outputs from the same device, + compare BOTH columns when available: Device Name and Name. + Return $null when identity is insufficient or no match exists. #> [CmdletBinding()] param( @@ -410,14 +410,14 @@ function Find-SvclRenderDeviceByIdentity { function Get-EndpointFxState { <# .SYNOPSIS - Lee el estado actual de PKEY_AudioEndpoint_Disable_SysFx de un endpoint - sin necesitar administrador. + Read the current PKEY_AudioEndpoint_Disable_SysFx state for an endpoint + without requiring administrator privileges. .DESCRIPTION - Lee PKEY_AudioEndpoint_Disable_SysFx del FxStore del endpoint via - IPolicyConfig::GetPropertyValue (el mismo store donde el helper elevado - escribe). Devuelve $true si los enhancements estan deshabilitados - (SysFx=1), $false si estan habilitados (SysFx=0) y $null si no se pudo - leer (endpoint inexistente o error). Nunca lanza. + Read PKEY_AudioEndpoint_Disable_SysFx from the endpoint FxStore through + IPolicyConfig::GetPropertyValue (the same store where the elevated helper + writes). Return $true when enhancements are disabled + (SysFx=1), $false when enabled (SysFx=0), and $null when the state cannot + be read (missing endpoint or error). Never throws. #> [CmdletBinding()] param( @@ -425,9 +425,9 @@ function Get-EndpointFxState { ) try { - # Sentinel = un tipo que SI se declara en el Add-Type. 'AutoSwitch.CoreAudio' - # no existe y haria que el Add-Type se reintentara en cada llamada, fallando - # en la segunda (los tipos ya existen) y devolviendo $null. + # Sentinel = a type that is actually declared by Add-Type. 'AutoSwitch.CoreAudio' + # does not exist and would make Add-Type retry on every call, failing + # on the second call because the types already exist. $typeName = 'AutoSwitch.EndpointFx' if (-not ($typeName -as [type])) { @@ -515,7 +515,7 @@ namespace AutoSwitch private const uint PID_SYSFX = 5; // Devuelve: 1 = SysFx deshabilitado, 0 = SysFx habilitado, - // -1 = no se pudo leer (endpoint inexistente / COM fallo). + // -1 = read failed (missing endpoint / COM failure). // IMPORTANTE: se lee con IPolicyConfig.GetPropertyValue(deviceId, // bFxStore=true), el MISMO store donde el helper elevado escribe. El // IPropertyStore del endpoint (OpenPropertyStore) NO contiene @@ -534,7 +534,7 @@ namespace AutoSwitch int hr = policy.GetPropertyValue(deviceId, true, ref pkey, out pv); if (hr != 0) { - return -1; // no se pudo leer -> estado desconocido + return -1; // read failed -> unknown state } return pv.ulVal != 0 ? 1 : 0; diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100644 index 0000000..37d2bc1 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="${1:-}" +[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'Usage: %s MAJOR.MINOR.PATCH\n' "$0" >&2; exit 2; } +DIST="${ROOT_DIR}/dist"; mkdir -p "$DIST" +ARCHIVE="${DIST}/PROX2-AutoSwitch-v${VERSION}.zip" +python3 - "$ROOT_DIR" "$ARCHIVE" "$VERSION" <<'PY' +import pathlib, stat, sys, zipfile +root=pathlib.Path(sys.argv[1]); archive=pathlib.Path(sys.argv[2]); version=sys.argv[3] +prefix=f'PROX2-AutoSwitch-v{version}' +paths=['Install.cmd','Verify.cmd','Uninstall.cmd','Install-AutoSwitch.ps1','Verify-AutoSwitch.ps1','Uninstall-AutoSwitch.ps1','Instalar-PROX2-AutoSwitch.ps1','Verificar-PROX2-AutoSwitch.ps1','Desinstalar-PROX2-AutoSwitch.ps1','Runtime-PROX2-AutoSwitch.ps1','Toggle-AudioEnhancements.ps1','install.ps1','lib/AutoSwitchCore.psm1','assets/icon.ico','README.md','AGENT.md','SOURCES.md','SECURITY.md','SUPPORT.md','CONTRIBUTING.md','CHANGELOG.md','LICENSE'] +with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED,compresslevel=9) as zf: + for rel in sorted(paths,key=str.casefold): + p=root/rel + if not p.is_file(): raise SystemExit(f'Missing release file: {rel}') + info=zipfile.ZipInfo(f'{prefix}/{rel}'); info.date_time=(1980,1,1,0,0,0); info.compress_type=zipfile.ZIP_DEFLATED; info.external_attr=(stat.S_IFREG|0o644)<<16 + zf.writestr(info,p.read_bytes(),compress_type=zipfile.ZIP_DEFLATED,compresslevel=9) +PY +name="$(basename "$ARCHIVE")"; (cd "$DIST" && sha256sum "$name") > "${ARCHIVE}.sha256" +cp "$ARCHIVE" "${DIST}/Audio-AutoSwitch.zip" +(cd "$DIST" && sha256sum Audio-AutoSwitch.zip) > "${DIST}/Audio-AutoSwitch.zip.sha256" diff --git a/scripts/run-gitleaks.sh b/scripts/run-gitleaks.sh new file mode 100644 index 0000000..b304181 --- /dev/null +++ b/scripts/run-gitleaks.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION='8.24.3' +case "$(uname -m)" in + x86_64|amd64) ARCH='x64'; SHA='9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c' ;; + aarch64|arm64) ARCH='arm64'; SHA='5f2edbe1f49f7b920f9e06e90759947d3c5dfc16f752fb93aaafc17e9d14cf07' ;; + *) printf 'Unsupported architecture: %s\n' "$(uname -m)" >&2; exit 2 ;; +esac +TMP="$(mktemp -d /tmp/autoswitch-gitleaks.XXXXXX)"; trap 'rm -rf -- "${TMP}"' EXIT +ARCHIVE="${TMP}/gitleaks.tar.gz" +curl --fail --silent --show-error --location --output "${ARCHIVE}" "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_${ARCH}.tar.gz" +printf '%s %s\n' "${SHA}" "${ARCHIVE}" | sha256sum --check --status +tar --extract --gzip --file "${ARCHIVE}" --directory "${TMP}" gitleaks +"${TMP}/gitleaks" detect --source "${ROOT_DIR}" --redact --no-banner diff --git a/tests/AutoSwitchCore.Tests.ps1 b/tests/AutoSwitchCore.Tests.ps1 index 31d6d65..ce9b28d 100644 --- a/tests/AutoSwitchCore.Tests.ps1 +++ b/tests/AutoSwitchCore.Tests.ps1 @@ -18,7 +18,7 @@ Describe 'Get-RenderItemIdFromText' { } It 'rejects an Item ID that is not a render device (different device class)' { - # La clase de dispositivo debe ser {0.0.0.00000000}. Otras clases no son render. + # Render device class must be {0.0.0.00000000}; other classes are not render endpoints. Get-RenderItemIdFromText -Text '{1.0.0.00000000}.{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}' | Should -BeNullOrEmpty } @@ -94,7 +94,7 @@ Describe 'ConvertFrom-SvclCsv' { It 'parses a real svcl export (Name and Device Name are separate)' { $rows = ConvertFrom-SvclCsv -Text $script:FixtureCsv $rows.Count | Should -Be 5 - $rows[0].Name | Should -Be 'Auriculares' + $rows[0].Name | Should -Be 'Headphones' $rows[0].'Device Name' | Should -Be '2- Jabra Evolve 65' $rows[0].Type | Should -Be 'Device' $rows[0].Direction | Should -Be 'Render' @@ -159,13 +159,13 @@ Describe 'Test-SvclExportValid' { Describe 'Get-SvclDeviceLabel' { It 'combines Device Name and Name when both exist' { - $row = [pscustomobject]@{ Name = 'Auriculares'; 'Device Name' = '2- Jabra Evolve 65' } - Get-SvclDeviceLabel -Row $row | Should -Be '2- Jabra Evolve 65 — Auriculares' + $row = [pscustomobject]@{ Name = 'Headphones'; 'Device Name' = '2- Jabra Evolve 65' } + Get-SvclDeviceLabel -Row $row | Should -Be '2- Jabra Evolve 65 — Headphones' } It 'falls back to Name when Device Name is missing' { - $row = [pscustomobject]@{ Name = 'Altavoces' } - Get-SvclDeviceLabel -Row $row | Should -Be 'Altavoces' + $row = [pscustomobject]@{ Name = 'Speakers' } + Get-SvclDeviceLabel -Row $row | Should -Be 'Speakers' } It 'falls back to Device Name when Name is missing or identical' { @@ -177,7 +177,7 @@ Describe 'Get-SvclDeviceLabel' { Describe 'Find-SvclRenderDeviceByIdentity' { It 'matches the Jabra render endpoint by Device Name + Name' { $rows = @(ConvertFrom-SvclCsv -Text $script:FixtureCsv) - $row = Find-SvclRenderDeviceByIdentity -Rows $rows -DeviceName '2- Jabra Evolve 65' -Name 'Auriculares' + $row = Find-SvclRenderDeviceByIdentity -Rows $rows -DeviceName '2- Jabra Evolve 65' -Name 'Headphones' $row | Should -Not -BeNullOrEmpty $row.'Item ID' | Should -Be '{0.0.0.00000000}.{ed043b5e-65dc-4ba6-a847-310517ac1849}' } diff --git a/tests/fixtures/svcl-export.csv b/tests/fixtures/svcl-export.csv index 69511b3..1bd63a1 100644 --- a/tests/fixtures/svcl-export.csv +++ b/tests/fixtures/svcl-export.csv @@ -1,6 +1,6 @@ Name,Type,Direction,Device Name,Device State,Item ID,Default -"Auriculares",Device,Render,2- Jabra Evolve 65,Active,"{0.0.0.00000000}.{ed043b5e-65dc-4ba6-a847-310517ac1849}","Render" -"Altavoces",Device,Render,Altavoces AMAZON,Active,"{0.0.0.00000000}.{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}","Render" -"Auriculares",Device,Render,PRO X 2 Lightspeed Gaming Headset,Active,"{0.0.0.00000000}.{11111111-2222-3333-4444-555555555555}","Render" +"Headphones",Device,Render,2- Jabra Evolve 65,Active,"{0.0.0.00000000}.{ed043b5e-65dc-4ba6-a847-310517ac1849}","Render" +"Speakers",Device,Render,Speakers AMAZON,Active,"{0.0.0.00000000}.{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}","Render" +"Headphones",Device,Render,PRO X 2 Lightspeed Gaming Headset,Active,"{0.0.0.00000000}.{11111111-2222-3333-4444-555555555555}","Render" "Microphone",Device,Capture,Jabra Evolve 65 Microphone,Active,"{1.0.0.00000000}.{12345678-1234-1234-1234-123456789012}","" "Speakers (Application)",Application,Render,Speakers (Application),Active,"","" diff --git a/wiki/Bandeja-y-reconfiguracion.md b/wiki/Bandeja-y-reconfiguracion.md new file mode 100644 index 0000000..606e118 --- /dev/null +++ b/wiki/Bandeja-y-reconfiguracion.md @@ -0,0 +1,14 @@ +# Bandeja y reconfiguración + +La bandeja muestra auricular, salida alternativa y el próximo cambio esperado. + +Permite: + +- activar/desactivar AutoSwitch; +- deshabilitar/habilitar Audio Enhancements con UAC solo para el helper elevado; +- ejecutar `Reconfigure...` y validar un nuevo ciclo ON → OFF → ON; +- salir. + +La reconfiguración tolera la latencia real de Bluetooth y puede refrescar el Item ID si Windows recrea el endpoint. + +[[Tray-and-Reconfiguration|Read in English]] diff --git a/wiki/Como-funciona.md b/wiki/Como-funciona.md new file mode 100644 index 0000000..b8b9f93 --- /dev/null +++ b/wiki/Como-funciona.md @@ -0,0 +1,17 @@ +# Cómo funciona + +## WindowsEndpoint + +AutoSwitch consulta el endpoint de salida de Windows con `svcl.exe`. Un caso compatible típico pasa de `Active` a `Unplugged` al apagar el auricular. + +Estados inválidos o inesperados se tratan como `Unknown` y nunca fuerzan un cambio de salida. + +## LogitechGHub + +Para PRO X 2, AutoSwitch usa el WebSocket local no oficial de G HUB (`ws://localhost:9010`) para obtener una señal física ON/OFF. El `deviceId` de G HUB se redescubre y no se persiste. + +## Cambio de salida + +Los Item ID de Windows son locales a cada equipo y pueden cambiar tras drivers o recreación del endpoint. `Reconfigure...` puede resolver el nuevo ID. + +[[How-It-Works|Read in English]] diff --git a/wiki/FAQ-Espanol.md b/wiki/FAQ-Espanol.md new file mode 100644 index 0000000..622fba0 --- /dev/null +++ b/wiki/FAQ-Espanol.md @@ -0,0 +1,25 @@ +# Preguntas frecuentes + +## ¿Funciona con cualquier auricular inalámbrico? + +No se puede garantizar. `WindowsEndpoint` funciona cuando Windows expone un estado físico útil. Si no existe una señal segura, AutoSwitch no debe adivinar. + +## ¿Por qué PRO X 2 necesita otro método? + +Su endpoint puede seguir `Active` con el casco apagado. Por eso existe el fallback de G HUB. + +## ¿AutoSwitch se ejecuta como administrador? + +No durante el uso normal. Solo el helper de Audio Enhancements solicita UAC cuando hace falta. + +## ¿Puedo copiar config.json a otro PC? + +No. Los Item ID de Windows son locales a cada equipo. + +## ¿El WebSocket de G HUB es oficial? + +No. Es una interfaz local obtenida por ingeniería inversa y puede cambiar. + +## ¿Hay documentación en inglés? + +Sí. Empieza en [[Home]]. diff --git a/wiki/FAQ.md b/wiki/FAQ.md new file mode 100644 index 0000000..a964a2f --- /dev/null +++ b/wiki/FAQ.md @@ -0,0 +1,25 @@ +# FAQ + +## Does it support every wireless headset? + +No project can guarantee that. The general `WindowsEndpoint` mode works when Windows exposes a useful physical connection state. AutoSwitch fails closed when it cannot observe a safe signal. + +## Why is PRO X 2 special? + +Its endpoint can remain `Active` while the headset is physically off, so endpoint state alone cannot distinguish ON/OFF. The project therefore has a G HUB-specific fallback. + +## Does AutoSwitch run as administrator? + +Normal runtime does not need to. Toggling Audio Enhancements launches a narrowly scoped elevated helper and asks for UAC. + +## Can I copy config.json to another PC? + +No. Windows Item IDs are machine-local. + +## Is the G HUB WebSocket official? + +No. It is reverse-engineered and may change in future G HUB releases. + +## Is Spanish documentation available? + +Yes. Start at [[Inicio]]. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..825c7b6 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,29 @@ +# Audio AutoSwitch + +**Stable release: v1.2.4 · Windows 10/11 x64** + +Audio AutoSwitch changes the Windows default output automatically when a compatible wireless headset turns on/off, and provides tray controls for switching behavior and Windows Audio Enhancements. + +## Start here + +- [[Installation]] +- [[How-It-Works]] +- [[Tray-and-Reconfiguration]] +- [[Troubleshooting]] +- [[FAQ]] +- [[Inicio|Español]] + +## Detection modes + +**WindowsEndpoint** is the general path. It works when Windows exposes a useful connection-state transition for the headset endpoint, such as `Active ↔ Unplugged`. + +**LogitechGHub** is the device-specific fallback for Logitech PRO X 2, whose Windows endpoint can remain `Active` while the physical headset is off. AutoSwitch uses G HUB's unofficial local WebSocket as the signal. + +An `Unknown` reading never changes the output, and disconnection requires consecutive OFF observations to avoid flapping. + +## Resources + +- [Repository](https://github.com/Ayerdi/PROX2-AutoSwitch) +- [Website](https://ayerdi.github.io/PROX2-AutoSwitch/) +- [Releases](https://github.com/Ayerdi/PROX2-AutoSwitch/releases) +- [Security](https://github.com/Ayerdi/PROX2-AutoSwitch/security/policy) diff --git a/wiki/How-It-Works.md b/wiki/How-It-Works.md new file mode 100644 index 0000000..52499c4 --- /dev/null +++ b/wiki/How-It-Works.md @@ -0,0 +1,26 @@ +# How it works + +## WindowsEndpoint + +The runtime reads the configured Windows render endpoint with SoundVolumeCommandLine (`svcl.exe`). A typical supported transition is: + +```text +Active → Connected +Unplugged → Disconnected +``` + +An invalid export, disabled endpoint or unexpected state is `Unknown`. Unknown never triggers a switch. + +## LogitechGHub + +For PRO X 2, AutoSwitch connects to the unofficial local G HUB WebSocket on `ws://localhost:9010`, discovers the matching PRO X 2 and uses its battery-state payload as the physical ON/OFF signal. + +G HUB device IDs are volatile and are rediscovered; they are not persisted as machine configuration. + +## Output switching + +The configured Windows Item IDs are passed to `svcl.exe /SetDefault`. Item IDs are machine-local and can change after driver updates or endpoint recreation, so `Reconfigure...` can resolve and persist a fresh ID. + +The OFF debounce requires consecutive disconnected readings before moving to the fallback output. + +[[Como-funciona|Leer en español]] diff --git a/wiki/Inicio.md b/wiki/Inicio.md new file mode 100644 index 0000000..2f6ca00 --- /dev/null +++ b/wiki/Inicio.md @@ -0,0 +1,22 @@ +# Audio AutoSwitch + +**Versión estable: v1.2.4 · Windows 10/11 x64** + +Audio AutoSwitch cambia automáticamente la salida predeterminada de Windows cuando un auricular inalámbrico compatible se enciende o apaga, y añade controles desde la bandeja para AutoSwitch y Audio Enhancements. + +## Empieza aquí + +- [[Instalacion]] +- [[Como-funciona]] +- [[Bandeja-y-reconfiguracion]] +- [[Resolucion-de-problemas]] +- [[FAQ-Espanol]] +- [[Home|English]] + +## Modos de detección + +**WindowsEndpoint** es el método general: funciona cuando Windows expone un cambio de estado útil, por ejemplo `Active ↔ Unplugged`. + +**LogitechGHub** es el fallback específico para Logitech PRO X 2, cuyo endpoint de Windows puede seguir en `Active` aunque el casco esté apagado. + +Un estado `Unknown` nunca cambia la salida y la desconexión necesita lecturas OFF consecutivas para evitar cambios espurios. diff --git a/wiki/Instalacion.md b/wiki/Instalacion.md new file mode 100644 index 0000000..1233a92 --- /dev/null +++ b/wiki/Instalacion.md @@ -0,0 +1,24 @@ +# Instalación + +## Instalación en un comando + +```powershell +powershell.exe -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/Ayerdi/PROX2-AutoSwitch/main/install.ps1 | iex" +``` + +El bootstrap descarga el ZIP de la última release y su `.sha256`, verifica la integridad, extrae el paquete y lanza el instalador real. + +## Manual + +1. Descarga el ZIP y `.sha256` desde Releases. +2. Verifica el checksum. +3. Extrae el ZIP completo. +4. Ejecuta: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\Install-AutoSwitch.ps1 +``` + +El nombre antiguo `Instalar-PROX2-AutoSwitch.ps1` se conserva por compatibilidad. + +[[Installation|Read in English]] diff --git a/wiki/Installation.md b/wiki/Installation.md new file mode 100644 index 0000000..8ec5045 --- /dev/null +++ b/wiki/Installation.md @@ -0,0 +1,26 @@ +# Installation + +## One-command installer + +```powershell +powershell.exe -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/Ayerdi/PROX2-AutoSwitch/main/install.ps1 | iex" +``` + +The bootstrap downloads the latest release ZIP and its `.sha256`, verifies SHA-256, extracts the package and launches the real installer. + +## Manual installation + +1. Download the latest ZIP and `.sha256` from Releases. +2. Verify the checksum. +3. Extract the **whole** ZIP to a normal folder. +4. Run: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File .\Install-AutoSwitch.ps1 +``` + +The older `Instalar-PROX2-AutoSwitch.ps1` filename remains as a compatibility entrypoint. + +The wizard lists output devices, lets you choose headset/fallback, observes a real OFF/ON cycle, chooses `WindowsEndpoint` when Windows provides a usable signal and falls back to G HUB only for a confirmed PRO X 2. + +[[Instalacion|Leer en español]] diff --git a/wiki/Resolucion-de-problemas.md b/wiki/Resolucion-de-problemas.md new file mode 100644 index 0000000..bde7c77 --- /dev/null +++ b/wiki/Resolucion-de-problemas.md @@ -0,0 +1,19 @@ +# Resolución de problemas + +## No cambia al apagar el auricular + +Ejecuta `Verify-AutoSwitch.ps1` y revisa `DetectionMode`. Un auricular genérico necesita que Windows exponga un cambio de estado útil. PRO X 2 necesita G HUB abierto y reconociendo el dispositivo. + +## Bluetooth tarda al reconectar + +Windows puede tardar varios segundos en recrear el endpoint. Las versiones actuales usan polling acotado en vez de una lectura instantánea. + +## Cambió el endpoint tras actualizar drivers + +Usa `Reconfigure...`. Los Item ID son locales al equipo y pueden cambiar. + +## G HUB dejó de funcionar tras una actualización + +El WebSocket es no oficial y Logitech puede cambiarlo. Revisa primero la última release/issues del proyecto. + +[[Troubleshooting|Read in English]] diff --git a/wiki/Tray-and-Reconfiguration.md b/wiki/Tray-and-Reconfiguration.md new file mode 100644 index 0000000..0ecddbf --- /dev/null +++ b/wiki/Tray-and-Reconfiguration.md @@ -0,0 +1,16 @@ +# Tray and reconfiguration + +The tray menu shows the configured headset, fallback output and expected next switch. + +Actions: + +- enable/disable automatic switching without exiting; +- disable/enable Windows Audio Enhancements for the configured headset (UAC only for the elevated helper); +- `Reconfigure...` to select current devices and validate a fresh ON → OFF → ON cycle; +- exit AutoSwitch. + +Reconfiguration polls for real Bluetooth timing and can refresh a recreated endpoint's Item ID using its stable Windows identity. + +![Real AutoSwitch tray menu](https://raw.githubusercontent.com/Ayerdi/PROX2-AutoSwitch/main/site/assets/tray-menu.png) + +[[Bandeja-y-reconfiguracion|Leer en español]] diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md new file mode 100644 index 0000000..043b362 --- /dev/null +++ b/wiki/Troubleshooting.md @@ -0,0 +1,27 @@ +# Troubleshooting + +## It never switches when the headset powers off + +Run `Verify-AutoSwitch.ps1` and check the configured `DetectionMode`. A generic headset requires Windows to expose a useful endpoint state. A PRO X 2 requires G HUB to be running and recognizing the device. + +## Bluetooth reconnects but reconfiguration times out + +Bluetooth endpoint recreation can take several seconds. Current releases use bounded polling windows rather than one instantaneous state read. Retry only after Windows itself shows the device again. + +## Windows selected a different endpoint after a driver update + +Run `Reconfigure...`. Windows Item IDs are machine-local and can change; copying another machine's `config.json` is unsupported. + +## G HUB stopped working after an update + +The local WebSocket is unofficial. Check the latest project release/issues before changing timeout or safety behavior. + +## Where is the log? + +```text +%LOCALAPPDATA%\PROX2AutoSwitch\autoswitch.log +``` + +Redact personal/device information before posting logs publicly. + +[[Resolucion-de-problemas|Leer en español]] diff --git a/wiki/_Footer.md b/wiki/_Footer.md new file mode 100644 index 0000000..745ee51 --- /dev/null +++ b/wiki/_Footer.md @@ -0,0 +1 @@ +Audio AutoSwitch · MIT · [Repository](https://github.com/Ayerdi/PROX2-AutoSwitch) · [Security](https://github.com/Ayerdi/PROX2-AutoSwitch/security/policy) · [[Home|English]] · [[Inicio|Español]] diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..ce9bee1 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,15 @@ +**English** +- [[Home]] +- [[Installation]] +- [[How-It-Works]] +- [[Tray-and-Reconfiguration]] +- [[Troubleshooting]] +- [[FAQ]] + +**Español** +- [[Inicio]] +- [[Instalacion]] +- [[Como-funciona]] +- [[Bandeja-y-reconfiguracion]] +- [[Resolucion-de-problemas]] +- [[FAQ-Espanol]] From 194ad4de7146e23319b3b39ae383ffdf5c26d59c Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:05:20 +0200 Subject: [PATCH 02/27] ci: enforce English outside the bilingual Wiki --- scripts/check-language.py | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 scripts/check-language.py diff --git a/scripts/check-language.py b/scripts/check-language.py new file mode 100644 index 0000000..52c40ef --- /dev/null +++ b/scripts/check-language.py @@ -0,0 +1,98 @@ +"""Fail CI when Spanish prose leaks outside the bilingual Wiki source. + +English is the canonical repository language. Spanish is intentionally allowed +under wiki/ for the maintained Spanish Wiki edition. Legacy filenames may stay +for backward compatibility; this checker inspects contents rather than paths. +""" + +from __future__ import annotations + +import re +import unicodedata +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKIP_DIRS = {".git", ".venv", "__pycache__", "wiki"} +SKIP_FILES = {Path("scripts/check-language.py")} +TEXT_SUFFIXES = { + ".cmd", ".css", ".html", ".js", ".json", ".md", ".ps1", ".psm1", + ".py", ".sh", ".txt", ".vbs", ".yaml", ".yml", ".csv", +} +TEXT_NAMES = {"LICENSE"} + +SPANISH_PATTERNS = [ + re.compile(r"[áéíóúüñ¿¡]", re.IGNORECASE), + re.compile( + r"\b(?:no debe|no se puede|debe ser|se añadió|se agreg[oó]|" + r"auricular(?:es)?|altavoces?|bandeja|instalaci[oó]n|desinstalaci[oó]n|" + r"configuraci[oó]n|verificaci[oó]n|conectado|desconectado|" + r"encendido|apagado|selecciona|seleccionar|salida de audio|" + r"prueba|modo universal|versi[oó]n estable)\b", + re.IGNORECASE, + ), +] + +SPANISH_STOPWORDS = { + "aqui", "antes", "bajo", "cada", "cuando", "debe", "deben", "del", + "desde", "despues", "donde", "el", "esta", "este", "esto", "la", "las", + "los", "para", "pero", "por", "porque", "que", "se", "si", "sin", "solo", + "tambien", "una", "uno", "ya", +} + +# Localized language labels and literal OS/runtime strings can remain where they +# are needed for navigation or compatibility diagnostics. +ALLOWED_FRAGMENTS = ( + "Español", +) + + +def strip_diacritics(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + return "".join(c for c in normalized if not unicodedata.combining(c)) + + +def is_text_file(path: Path) -> bool: + relative = path.relative_to(ROOT) + if relative in SKIP_FILES: + return False + if any(part in SKIP_DIRS for part in relative.parts[:-1]): + return False + return path.name in TEXT_NAMES or path.suffix.lower() in TEXT_SUFFIXES + + +def looks_spanish(line: str) -> bool: + candidate = line + for fragment in ALLOWED_FRAGMENTS: + candidate = candidate.replace(fragment, "Spanish") + if any(pattern.search(candidate) for pattern in SPANISH_PATTERNS): + return True + words = re.findall(r"[a-z]+", strip_diacritics(candidate).casefold()) + return sum(word in SPANISH_STOPWORDS for word in words) >= 3 + + +def main() -> int: + findings: list[str] = [] + checked = 0 + for path in sorted(ROOT.rglob("*")): + if not path.is_file() or not is_text_file(path): + continue + checked += 1 + try: + text = path.read_text(encoding="utf-8-sig") + except UnicodeDecodeError: + continue + for line_number, line in enumerate(text.splitlines(), 1): + if looks_spanish(line): + findings.append(f"{path.relative_to(ROOT)}:{line_number}: {line.strip()}") + + if findings: + print("Spanish prose found outside wiki/:") + print("\n".join(findings)) + return 1 + + print(f"Language check OK: {checked} text files checked; Spanish is confined to wiki/.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e47bb6c1e3b21528e7c73242f1effe2dc1fc6e6a Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:05:43 +0200 Subject: [PATCH 03/27] ci: add repository quality checks --- scripts/check-repository.py | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 scripts/check-repository.py diff --git a/scripts/check-repository.py b/scripts/check-repository.py new file mode 100644 index 0000000..26beccc --- /dev/null +++ b/scripts/check-repository.py @@ -0,0 +1,118 @@ +"""Repository-level checks shared by CI and release preparation.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CURRENT_VERSION = "1.2.5" + +REQUIRED_FILES = ( + "README.md", + "LICENSE", + "SECURITY.md", + "SUPPORT.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "CHANGELOG.md", + "Install.cmd", + "Verify.cmd", + "Uninstall.cmd", + "Install-AutoSwitch.ps1", + "Verify-AutoSwitch.ps1", + "Uninstall-AutoSwitch.ps1", + "Instalar-PROX2-AutoSwitch.ps1", + "Verificar-PROX2-AutoSwitch.ps1", + "Desinstalar-PROX2-AutoSwitch.ps1", + "Runtime-PROX2-AutoSwitch.ps1", + "Toggle-AudioEnhancements.ps1", + "lib/AutoSwitchCore.psm1", + "scripts/build-release.sh", + "scripts/run-gitleaks.sh", + "wiki/Home.md", + "wiki/Inicio.md", + "site/index.html", +) + +CURRENT_VERSION_FILES = ( + "README.md", + "wiki/Home.md", + "wiki/Inicio.md", + "site/index.html", +) + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def check_required_files() -> None: + missing = [name for name in REQUIRED_FILES if not (ROOT / name).is_file()] + if missing: + fail("Missing required repository files: " + ", ".join(missing)) + + +def check_no_temporary_github_files() -> None: + github = ROOT / ".github" + offenders = [ + path.relative_to(ROOT).as_posix() + for path in github.rglob("*") + if path.is_file() and ("tmp" in path.name.casefold() or "temporary" in path.name.casefold()) + ] + if offenders: + fail("Temporary GitHub files must not be committed: " + ", ".join(offenders)) + + +def check_current_version() -> None: + marker = f"v{CURRENT_VERSION}" + missing = [] + for relative in CURRENT_VERSION_FILES: + text = (ROOT / relative).read_text(encoding="utf-8-sig") + if marker not in text: + missing.append(relative) + if missing: + fail(f"Current stable marker {marker} is missing from: " + ", ".join(missing)) + + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8-sig") + if f"## [{CURRENT_VERSION}]" not in changelog: + fail(f"CHANGELOG.md has no {CURRENT_VERSION} release section") + + +def check_wiki_links() -> None: + wiki = ROOT / "wiki" + page_names = {path.stem for path in wiki.glob("*.md")} + broken: list[str] = [] + pattern = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]") + for path in wiki.glob("*.md"): + text = path.read_text(encoding="utf-8-sig") + for first, second in pattern.findall(text): + # GitHub Wiki syntax is [[Page]] or [[Label|Page]]. + target = (second or first).strip() + if target.startswith("http://") or target.startswith("https://"): + continue + if target not in page_names: + broken.append(f"{path.name} -> {target}") + if broken: + fail("Broken Wiki links: " + "; ".join(sorted(broken))) + + +def check_release_workflows() -> None: + workflows = ROOT / ".github" / "workflows" + permanent_release = workflows / "release.yml" + if permanent_release.exists(): + fail("Permanent release.yml is not allowed; use a versioned one-shot publisher") + + +def main() -> int: + check_required_files() + check_no_temporary_github_files() + check_current_version() + check_wiki_links() + check_release_workflows() + print("Repository quality checks OK.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3741d92ed8ff1213e26fa79419661f3416206f5e Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:06:32 +0200 Subject: [PATCH 04/27] docs: make the AutoSwitch README English-first --- README.md | 322 ++++++++++++++++++++++++------------------------------ 1 file changed, 140 insertions(+), 182 deletions(-) diff --git a/README.md b/README.md index 616282a..c72292c 100644 --- a/README.md +++ b/README.md @@ -5,275 +5,233 @@ [![Latest release](https://img.shields.io/github/v/release/Ayerdi/PROX2-AutoSwitch)](https://github.com/Ayerdi/PROX2-AutoSwitch/releases/latest) [![Pages](https://github.com/Ayerdi/PROX2-AutoSwitch/actions/workflows/pages.yml/badge.svg)](https://github.com/Ayerdi/PROX2-AutoSwitch/actions/workflows/pages.yml) -Automatically switch your Windows default audio output when you put on or take off your wireless headset. +Automatically switch the Windows default audio output when a compatible wireless headset turns on or off. -- **Headset connected / powered on** → Windows uses the headset output. -- **Headset disconnected / powered off** → Windows falls back to your alternative output (e.g. PC speakers). -- Works with **any wireless headset whose connection state is exposed by Windows** (e.g. Jabra Evolve 65, which reports `Active` ↔ `Unplugged`), with a **device-specific fallback** for Logitech PRO X 2 (via Logitech G HUB) whose endpoint stays `Active` while off. -- Tray icon: enable/disable AutoSwitch and **disable/enable Windows audio enhancements** for the configured headset. -- Runs in the background with **no PowerShell window at login**. +**Stable release: v1.2.5 · Windows 10/11 x64** + +- Headset on / connected → use the headset. +- Headset off / disconnected → return to the configured fallback output. +- Generic path for headsets whose Windows endpoint exposes connection state. +- Logitech PRO X 2 fallback through Logitech G HUB when Windows keeps the endpoint `Active` while the physical headset is off. +- Tray controls for AutoSwitch, reconfiguration and Windows Audio Enhancements. +- Invisible startup: no PowerShell window at login. ![AutoSwitch demo: headset on selects the headset output, headset off returns to the speakers](site/autoswitch-demo.gif) -See it live on the [project page](https://ayerdi.github.io/PROX2-AutoSwitch/). +## Start here + +- **Website:** https://ayerdi.github.io/PROX2-AutoSwitch/ +- **Wiki:** https://github.com/Ayerdi/PROX2-AutoSwitch/wiki +- **Spanish Wiki:** https://github.com/Ayerdi/PROX2-AutoSwitch/wiki/Inicio +- **Technical docs:** [`docs/INDEX.md`](docs/INDEX.md) +- **Support:** [`SUPPORT.md`](SUPPORT.md) +- **Security:** [`SECURITY.md`](SECURITY.md) +- **Releases:** https://github.com/Ayerdi/PROX2-AutoSwitch/releases +The repository, code, Pages site and technical documentation are English-first. The Wiki is maintained in both English and Spanish. ## Quick start -### Recommended: download the ZIP +### Recommended: release ZIP -Releases publish both a versioned archive and a stable **`Audio-AutoSwitch.zip`** name for the simplest install path. +1. Open the [latest release](https://github.com/Ayerdi/PROX2-AutoSwitch/releases/latest). +2. Download **`Audio-AutoSwitch.zip`**. +3. Extract it to a normal folder. +4. Double-click **`Install.cmd`**. +5. Select the headset and fallback output. +6. Follow the `ON → OFF → ON` validation wizard. -1. Open the [latest release](https://github.com/Ayerdi/PROX2-AutoSwitch/releases/latest) and download `Audio-AutoSwitch.zip`. -2. Extract the ZIP to a normal folder. -3. Double-click **`Install.cmd`**. -4. Pick your headset and fallback output, then follow the ON → OFF → ON wizard. -5. When installation finishes, turn the headset off/on once to confirm the real switch. +The same package includes: -`Install.cmd` is only a small launcher for the PowerShell installer; all installation logic remains in the reviewed `.ps1` files. The release also includes **`Verify.cmd`** and **`Uninstall.cmd`** for double-click diagnostics/removal. +- **`Verify.cmd`** — run diagnostics. +- **`Uninstall.cmd`** — remove AutoSwitch. + +The `.cmd` files are intentionally tiny launchers. The reviewed PowerShell implementation remains visible in the package. ### One-command install -If you prefer PowerShell, this bootstrap downloads the latest versioned release ZIP plus its SHA-256 checksum, verifies it, and then starts the same installer: +The bootstrap downloads the latest versioned release ZIP and checksum, verifies SHA-256, then starts the installer: ```powershell powershell.exe -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/Ayerdi/PROX2-AutoSwitch/main/install.ps1 | iex" ``` -## Why this exists - -Some wireless headsets — especially USB-dongle/LIGHTSPEED models — keep their audio endpoint visible to Windows even when the physical headset is turned off. Others, such as the tested Jabra Evolve 65, expose a useful `Active ↔ Unplugged` transition. AutoSwitch handles both patterns through its detection modes. +## How it works -The manual dance got old fast: +The installer chooses one of two detection modes. -1. Take off the headset. -2. Open the sound settings. -3. Change the default output. -4. Put the headset back on. -5. Repeat. +### WindowsEndpoint -This project removes that friction: switch the output just by turning the headset on or off. +This is the general mode. It works when Windows exposes a meaningful state transition for the headset endpoint. -## How it works +```text +Active → Connected → headset +Unplugged / NotPresent → Disconnected → fallback +Unknown / invalid reading → no switch +``` -Two detection modes, chosen automatically at install: +This path was validated with a Jabra Evolve 65. No vendor application is required when Windows exposes the physical connection state correctly. -**WindowsEndpoint (universal).** While powered on, most wireless headsets (e.g. Jabra Evolve 65) expose an audio endpoint whose `State` is `Active`; powered off, it becomes `Unplugged` (or disappears). The runtime reads the endpoint `State` via `svcl.exe`: +Bluetooth/Core Audio can recreate an endpoint after reconnecting. The installer and **Reconfigure...** flow therefore tolerate real reconnect latency and can re-resolve the endpoint by its stable `Device Name` + `Name` identity before persisting the newest `Item ID`. -``` -Endpoint State Active → Connected → headset -Endpoint State Unplugged → Disconnected → fallback -``` +### LogitechGHub -**LogitechGHub (fallback for PRO X 2).** Tested on 2026-08-07: while the PRO X 2 is powered on, Logitech G HUB's battery query returns a `payload`; when powered off, it doesn't. AutoSwitch uses that signal: +The Logitech PRO X 2 is an important exception: its Windows endpoint can remain `Active` while the physical headset is off. For that device AutoSwitch uses G HUB's local WebSocket as the physical-state signal: -``` +```text PRO X 2 - │ - ▼ -Logitech G HUB -ws://localhost:9010 - │ - ├── GET /devices/list - │ └── locate the PRO X 2 extendedDisplayName - │ - └── GET /battery//state - │ - ├── payload present -> ON - └── payload absent -> OFF - │ - ▼ - SoundVolumeCommandLine - │ - /SetDefault all - │ - ▼ - Windows + ↓ +Logitech G HUB · ws://localhost:9010 + ↓ +GET /devices/list +GET /battery//state + ↓ +payload present → ON +payload absent → OFF + ↓ +SoundVolumeCommandLine /SetDefault all ``` -In both modes the rule is the same: an **unknown** state never switches the output (a single `svcl.exe` failure must not send you to the speakers), and disconnection requires **two consecutive** OFF readings before switching to the fallback. +The G HUB interface is unofficial and may change in a future G HUB release. See [`SOURCES.md`](SOURCES.md) and [`AGENT.md`](AGENT.md) for the verified design notes. + +### Safety rules + +Two rules are deliberate: + +1. An **unknown** state never changes the Windows output. +2. Disconnection needs **two consecutive OFF observations** before switching to the fallback. + +A transient `svcl.exe` or G HUB failure therefore cannot send audio to the wrong device on a single bad read. ## Requirements - Windows 10/11 x64. -- For the universal (`WindowsEndpoint`) mode: no vendor software required. -- For Logitech PRO X 2 (`LogitechGHub` mode): Logitech G HUB installed, open, and recognizing the PRO X 2. -- Internet connection during install (downloads `svcl.exe` from NirSoft). - PowerShell 5.1 or newer. +- Internet access during installation so `svcl.exe` can be downloaded and hash-verified. +- No vendor software for compatible `WindowsEndpoint` headsets. +- Logitech G HUB installed and running for `LogitechGHub` mode. -No admin rights should be required for normal operation. Only toggling **Audio Enhancements** asks for a one-time UAC elevation. +Normal runtime operation does not require administrator rights. Toggling global Windows Audio Enhancements uses a one-time UAC elevation for the helper process only. -## Installation details +## Installer behavior -The recommended ZIP path is **extract → double-click `Install.cmd`**. If Windows has marked the downloaded scripts as blocked and the launcher cannot run them, open PowerShell in the extracted folder and run: - -```powershell -Get-ChildItem . -Filter *.ps1 | Unblock-File -powershell.exe -ExecutionPolicy Bypass -File ".\Instalar-PROX2-AutoSwitch.ps1" -``` +The installer: -The installer will: +- downloads SoundVolumeCommandLine from NirSoft only when needed and verifies its SHA-256 before execution; +- lists current Windows render devices and lets you choose the headset and fallback; +- validates the real `ON → OFF → ON` cycle with bounded polling windows of 15 s / 15 s / 20 s; +- handles a Bluetooth endpoint that returns with a new `Item ID`; +- falls back to Logitech G HUB only when Windows cannot expose a useful physical state and the selected headset is confirmed as a PRO X 2; +- captures machine-local Item IDs; +- performs real test switches in both directions; +- optionally disables Audio Enhancements for the headset; +- installs the runtime under `%LOCALAPPDATA%`; +- creates invisible per-user startup; +- starts AutoSwitch. -- download SoundVolumeCommandLine from NirSoft only if it is not already installed, and verify its SHA-256 before running it; -- list the Windows audio output devices so you pick the **headset** and the **fallback**; -- ask you to turn the headset OFF and back ON — it polls Windows for up to 15 s / 15 s / 20 s instead of trusting one fast Bluetooth reading; if Windows reflects `Active → Unplugged → Active`, it picks `WindowsEndpoint` mode automatically; if the endpoint is recreated with a new Item ID during that cycle, the installer re-resolves it by `Device Name` + `Name` and keeps the newest ID; if not, it asks you to confirm the headset is a Logitech PRO X 2, lists only the PRO X 2 candidates from G HUB for you to pick the matching one, and uses `LogitechGHub` mode; if nothing works, it aborts instead of installing something that can't work; -- capture the real **Item ID**s of the current Windows; -- actually test both switches; -- offer to disable the headset's **Audio Enhancements** (one UAC prompt); -- save the configuration; -- install the runtime; -- create an invisible autostart entry via `wscript.exe`; -- start AutoSwitch. +If no supported detection method can be proven, installation stops instead of creating a configuration that cannot work. -### Tray icon +## Tray and reconfiguration -Once running, an icon appears in the system tray: +The tray menu shows the configured headset, fallback and next switch action. ![Real AutoSwitch tray menu with a Logitech PRO X 2 configured](site/assets/tray-menu.png) -*Real tray-menu example with a Logitech PRO X 2 configured; device names vary by system.* +It also provides: -- **Headset / Fallback / Next switch** — info lines showing the configured devices and which output AutoSwitch would switch to right now (refreshed every 5 s). -- **AutoSwitch: Enabled / Disabled** — pause or resume switching without quitting. -- **Disable / Enable Audio Enhancements for ** — toggles the global Windows audio enhancements of the configured headset endpoint (a UAC prompt appears; the menu updates only if the change is verified). -- **Reconfigure...** — re-run the detection wizard without reinstalling: pick a new headset and fallback, then validate `ON → OFF → ON`. Bluetooth/Core Audio transitions can take several seconds, so the wizard polls with bounded waits (15 s / 15 s / 20 s). If a Bluetooth endpoint is recreated with a new `Item ID`, current `main` re-resolves the same render endpoint by its real `Device Name` + `Name` identity and persists the newest ID. It then re-determines `WindowsEndpoint` or `LogitechGHub`. If no compatible method is found, the config is left untouched. +- **AutoSwitch: Enabled / Disabled** — pause or resume switching. +- **Disable / Enable Audio Enhancements** — change the configured headset's global Windows enhancement state, with UAC only for the helper. +- **Reconfigure...** — choose new endpoints and repeat the complete detection wizard without reinstalling. - **Exit** — stop AutoSwitch. -### Important +A failed reconfiguration leaves the previous working configuration untouched. -Windows audio `Item ID`s are **machine-local and can change** after driver changes, endpoint recreation or a new installation. A clean install captures the current IDs; `Reconfigure...` can also refresh the headset ID when a Bluetooth endpoint is recreated. Don't copy `config.json` from another machine. +## Machine-local identifiers -The `dev000000XX` ID from G HUB isn't persisted either. The runtime keeps the `extendedDisplayName` and discovers the current `deviceId` at startup. +Windows audio `Item ID`s are machine-local and can change after driver changes or endpoint recreation. Do not copy `config.json` from another PC. -## Where it installs +The G HUB `deviceId` is also not persisted because it may change. AutoSwitch keeps the stable display identity and resolves the current G HUB ID when needed. -```text -%LOCALAPPDATA%\PROX2AutoSwitch\ -``` +## Verify and uninstall -Main contents: +From an extracted release: ```text -PROX2AutoSwitch.ps1 # runtime (tray) -config.json -svcl.exe -Toggle-AudioEnhancements.ps1 # elevated helper (UAC) for enhancements -icon.ico -Iniciar-Oculto.vbs -autoswitch.log -Desinstalar-PROX2-AutoSwitch.ps1 -Verificar-PROX2-AutoSwitch.ps1 -lib\AutoSwitchCore.psm1 # shared logic -control\ # tray <-> worker control flags +Verify.cmd +Uninstall.cmd ``` -Autostart is created in the user's Startup folder as: - -```text -PRO X 2 AutoSwitch.lnk -``` - -That shortcut runs `wscript.exe`, which starts PowerShell hidden — no console window at login. - -## Verify it works - -From an extracted release package, **double-click `Verify.cmd`**. It launches the same PowerShell verifier documented below. - -```powershell -powershell.exe -ExecutionPolicy Bypass -File ".\Verificar-PROX2-AutoSwitch.ps1" -``` - -Or use the installed copy: +PowerShell equivalents: ```powershell -powershell.exe -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\PROX2AutoSwitch\Verificar-PROX2-AutoSwitch.ps1" +.\Verify-AutoSwitch.ps1 +.\Uninstall-AutoSwitch.ps1 ``` -Manual test: - -1. Power on / connect the headset. -2. Wait a few seconds (some Bluetooth devices can take longer after reconnecting). -3. Windows should select the headset. -4. Power off / disconnect it. -5. After the endpoint reports OFF and two consecutive runtime checks confirm it (normally a few seconds), Windows should select the alternative output. - -Two consecutive OFF readings are required before treating the headset as disconnected, to avoid flapping on a one-off reading. +The package also retains the older Spanish-named `.ps1` entrypoints for backward compatibility with existing shortcuts and automation. New documentation uses the English aliases. -## Log +Installed runtime data lives under: ```text -%LOCALAPPDATA%\PROX2AutoSwitch\autoswitch.log +%LOCALAPPDATA%\PROX2AutoSwitch\ ``` -Expected example: +The main log is: ```text -PRO X 2 AutoSwitch started (mode LogitechGHub). -Connected to G HUB. -PRO X 2 detected by G HUB: PRO X 2 Lightspeed Gaming Headset (dev...) -Output changed -> PRO X 2 LIGHTSPEED — Cascos Gaming -Output changed -> High Definition Audio Device — Altavoces AMAZON +%LOCALAPPDATA%\PROX2AutoSwitch\autoswitch.log ``` -## Uninstall +## Important `svcl.exe` regression guard -From an extracted release package, **double-click `Uninstall.cmd`**. The equivalent PowerShell command is: +`svcl.exe /GetColumnValue` already writes the requested value to stdout. Do **not** combine it with `/Stdout`: ```powershell -powershell.exe -ExecutionPolicy Bypass -File ".\Desinstalar-PROX2-AutoSwitch.ps1" +svcl.exe /Stdout /GetColumnValue ... # wrong ``` -or from the installation: +The supported form is: ```powershell -powershell.exe -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\PROX2AutoSwitch\Desinstalar-PROX2-AutoSwitch.ps1" -``` - -## Known bug — do NOT reintroduce - -`svcl.exe /GetColumnValue` already writes the value to stdout. Do **not** use: - -```powershell -svcl.exe /Stdout /GetColumnValue ... +svcl.exe /GetColumnValue "DefaultRenderDevice" "Item ID" ``` -In an earlier implementation `/Stdout` prepended item info (`1 item found:`, name, etc.) and corrupted the Item ID, so `/SetDefault` got an invalid identifier and answered `No items found`. +An earlier implementation mixed metadata into the output and corrupted the Item ID passed to `/SetDefault`. Tests keep this regression covered. -The correct form used in this version is: +## Security -```powershell -svcl.exe /GetColumnValue "DefaultRenderDevice" "Item ID" -``` +- The installer verifies the SHA-256 of the NirSoft download before running it. +- Release ZIPs publish SHA-256 checksums and are built reproducibly. +- The G HUB WebSocket is local but unofficial; treat compatibility changes after G HUB updates as expected maintenance risk. +- Normal runtime is non-elevated; only the Audio Enhancements helper requests UAC. +- Please report security issues through the process in [`SECURITY.md`](SECURITY.md), not a public issue. -then extract only: +## Repository layout ```text -{0.0.0.00000000}.{GUID} +Install.cmd / Verify.cmd / Uninstall.cmd double-click entrypoints +Install-AutoSwitch.ps1 canonical installer alias +Verify-AutoSwitch.ps1 canonical verifier alias +Uninstall-AutoSwitch.ps1 canonical uninstaller alias +Instalar-*.ps1 / Verificar-*.ps1 / legacy compatible entrypoints +Desinstalar-*.ps1 +Runtime-PROX2-AutoSwitch.ps1 tray UI + worker runtime +Toggle-AudioEnhancements.ps1 elevated enhancement helper +lib/AutoSwitchCore.psm1 shared pure logic + COM interop +install.ps1 checksum-verifying bootstrap +tests/ Pester regression coverage +docs/ technical and historical notes +wiki/ versioned English + Spanish Wiki source +site/ GitHub Pages source +scripts/ release and repository tooling ``` -## Security notes +## Development -- The G HUB WebSocket `localhost:9010` is **not an officially supported Logitech API**. It's a reverse-engineered local interface used by third-party projects. Logitech can change it in future G HUB releases. If it stops working after a G HUB update, see `AGENT.md`. -- The installer downloads `svcl-x64.zip` from NirSoft and verifies its SHA-256. If NirSoft ships a new version the hash may change and the installer **fails safely**. Don't remove that check. +Before opening a pull request, run the relevant Pester suite and keep the repository checks green. See [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`docs/INDEX.md`](docs/INDEX.md). -## Repository layout +The project intentionally keeps hardware-specific findings and source references in [`AGENT.md`](AGENT.md) and [`SOURCES.md`](SOURCES.md) so future maintenance does not have to rediscover already-tested behavior. + +## License -- `install.ps1` — one-command bootstrap: fetches the latest versioned release ZIP and its `.sha256`, verifies the hash, then runs the installer. -- `Install.cmd` / `Verify.cmd` / `Uninstall.cmd` — tiny double-click launchers; the real logic remains in PowerShell. -- `Instalar-PROX2-AutoSwitch.ps1` — clean install (universal + G HUB paths, auto detection mode). Skips the `svcl.exe` download if it is already installed. -- `Runtime-PROX2-AutoSwitch.ps1` — the runtime copied to `%LOCALAPPDATA%`: tray icon + invisible `Form` message pump, with the polling in a separate worker process (`AUTOSWITCH_WORKER=1`). -- `Toggle-AudioEnhancements.ps1` — elevated helper (UAC) that disables/enables the headset's Audio Enhancements. -- `Desinstalar-PROX2-AutoSwitch.ps1` — removes process, autostart and files (with truthful per-step reporting). -- `Verificar-PROX2-AutoSwitch.ps1` — quick diagnostics. -- `lib/AutoSwitchCore.psm1` — shared pure logic (Item ID extraction, CSV parse, endpoint state mapping, OFF debounce, config validation/migration, C# COM interop) used by installer, runtime and tests. -- `assets/icon.ico` — tray icon. -- `docs/` — design/history notes. `WindowsEndpointProvider.md` is superseded and explicitly records which early assumptions were invalidated by later Bluetooth testing. -- `tests/` — Pester coverage for the pure logic (with a real `svcl /scomma` fixture). -- `site/` — the [GitHub Pages site](https://ayerdi.github.io/PROX2-AutoSwitch/), ES/EN. -- `AGENT.md` — context so an AI agent can maintain/rebuild the project. -- `SOURCES.md` — verified technical references. -- `SECURITY.md` — security scope and how to report a vulnerability. -- `CHANGELOG.md` — version history (Keep a Changelog). -- `.github/workflows/validate.yml` — CI: PowerShell syntax, PSScriptAnalyzer, Pester. -- `.github/workflows/pages.yml` — CI that deploys `site/` to GitHub Pages. -- `.github/workflows/release.yml` — builds the release ZIP + `.sha256` on `v*` tags. +MIT. See [`LICENSE`](LICENSE). From cfa00fc3db6fa516b13d52c20d9dea5431458686 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:08:04 +0200 Subject: [PATCH 05/27] docs: make Pages English-first --- site/index.html | 686 +++++++++++------------------------------------- 1 file changed, 146 insertions(+), 540 deletions(-) diff --git a/site/index.html b/site/index.html index 6d807b3..02b21b6 100644 --- a/site/index.html +++ b/site/index.html @@ -1,9 +1,10 @@ - + - + + Audio AutoSwitch - -
- - -
- -
- -

Audio AutoSwitch

-

-
- -
- - -
-

-
-
    -
  • -
  • -
  • -
  • -
  • -
-
-
- - -
-

-
-
-
- AutoSwitch demo: headset on selects the headset output, headset off returns to the speakers -
-
-

-

-

-
-
+ +
+ + +
+
+ v1.2.5 stable + Windows 10/11 x64 + MIT
-
- - -
-

-
- Real AutoSwitch tray menu with a Logitech PRO X 2 configured -

+

Audio AutoSwitch

+

Automatically switch the Windows default audio output when a compatible wireless headset turns on or off — with a generic Windows endpoint mode and a Logitech PRO X 2 fallback through G HUB.

+ -
+ + - -
-

-
-

-

+
+
+

What it does

+
+

Headset on

Windows selects the configured headset automatically.

+

Headset off

Windows returns to the configured fallback output.

+

Tray controls

Pause switching, reconfigure endpoints and control Audio Enhancements without reinstalling.

+

Invisible startup

Runs per-user in the background without leaving a PowerShell window open.

- -
-

-
-

-
+
+

See it in action

+
+
+ Audio AutoSwitch changing the Windows output when the headset turns on and off +

Headset on selects the headset; headset off returns to the fallback.

+
+
+ Audio AutoSwitch tray menu +

Real tray menu with a Logitech PRO X 2 configured.

+
- -
-

- -
- ZIP · RECOMMENDED -

-

-
- -
-
- +
+

Quick install

- PowerShell -

-

-

-
- -
- -
-

    -
  1. -
  2. -
  3. -
  4. -
  5. +
  6. Open the latest release.
  7. +
  8. Download Audio-AutoSwitch.zip and extract it.
  9. +
  10. Double-click Install.cmd.
  11. +
  12. Select the headset and fallback output.
  13. +
  14. Follow the real ON → OFF → ON validation cycle.
-
+

The release also includes Verify.cmd and Uninstall.cmd. Release ZIPs are published with SHA-256 checksums.

- -
-

-
-
-
    -
  • -
  • -
  • -
+
+

One-command bootstrap

+

The bootstrap fetches the latest versioned release and checksum, verifies SHA-256, then launches the same installer.

+
powershell.exe -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/Ayerdi/PROX2-AutoSwitch/main/install.ps1 | iex"
+
+ +
+

Two detection modes

+
+
+

WindowsEndpoint

+

The general path. If Windows reports a useful endpoint transition such as Active ↔ Unplugged, no vendor software is required.

+
Active       → Connected
+Unplugged    → Disconnected
+Unknown      → no switch
-
-
    -
  • -
  • -
+
+

LogitechGHub

+

Fallback for Logitech PRO X 2, whose Windows endpoint can remain Active while the physical headset is off. G HUB's local WebSocket supplies the physical-state signal.

+
payload present → ON
+payload absent  → OFF
- -
-

+
+

Designed to fail safely

-

-
- -
    -
  1. -
  2. -
  3. -
  4. -
  5. -
+
    +
  • An unknown headset state never changes the output.
  • +
  • Two consecutive OFF observations are required before switching to the fallback.
  • +
  • Bluetooth endpoint recreation is handled by stable Device Name + Name identity before persisting a new Item ID.
  • +
  • The installer validates real switching in both directions.
  • +
  • The NirSoft download is SHA-256 verified before execution.
  • +
  • Normal runtime is non-elevated; only the Audio Enhancements helper requests UAC.
  • +
- -
-

-
-

-
- -

-
-
- - -
-

+
+

Requirements

    -
  • -
  • +
  • Windows 10/11 x64.
  • +
  • PowerShell 5.1 or newer.
  • +
  • Internet access during installation.
  • +
  • Logitech G HUB only when the selected headset requires LogitechGHub mode.
+
+

More documentation

+
+

Wiki

Installation, how it works, tray/reconfiguration, troubleshooting and FAQ.

Open English Wiki →

+

Spanish Wiki

The same user-facing documentation is maintained as a separate Spanish Wiki path.

Open Spanish Wiki →

+

Technical notes

Verified hardware behavior, G HUB findings, COM details and historical design constraints.

Open technical docs →

+

Security

Threat boundary, dependency verification and private vulnerability reporting guidance.

Read security policy →

+
+
- - From e894c44407966db2d65d83deaa1b172060519259 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:08:52 +0200 Subject: [PATCH 06/27] docs: translate the complete changelog --- CHANGELOG.md | 145 +++++++++++++++++++++++++++++---------------------- 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 211b607..752072b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,43 @@ # Changelog -Todas las versiones notables de este proyecto se documentan aquí. -El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/). -Este proyecto se adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to this project are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.2.5] - 2026-08-13 + +### Added +- Versioned GitHub Wiki source with a complete English edition and a maintained Spanish edition. +- Canonical English PowerShell entrypoints: `Install-AutoSwitch.ps1`, `Verify-AutoSwitch.ps1`, and `Uninstall-AutoSwitch.ps1`. +- Repository governance: `CONTRIBUTING.md`, `SUPPORT.md`, `CODE_OF_CONDUCT.md`, issue forms and a pull-request template. +- Reproducible release tooling with deterministic ZIP creation, SHA-256 files and double-build byte comparison. +- Gitleaks secret scanning and repository/language quality checks in CI. + +### Changed +- English is now the canonical language for the repository, runtime comments, tests, technical docs, GitHub Pages and release documentation. +- The Spanish-named PowerShell entrypoints remain in the package for backward compatibility, while new documentation uses the English aliases. +- GitHub Pages is now English-first; Spanish documentation lives in the Spanish Wiki path. +- Actions are pinned to immutable commit SHAs and checkout credentials are not persisted in validation jobs. +- Public project navigation now follows the same README → Website → Wiki → Docs/Support/Security → Releases structure used by the companion Dedicated Server Save Sync project. + +### Security +- Release publication verifies repository quality and secret scanning before producing assets. +- The release archive is built twice and must be byte-identical before publication. + ## [1.2.4] - 2026-08-13 ### Added - Double-click `Install.cmd`, `Verify.cmd`, and `Uninstall.cmd` launchers for users who download the release ZIP. -- Release packaging now also publishes a stable `Audio-AutoSwitch.zip` + SHA-256 alias in addition to the versioned archive. +- Release packaging publishes the stable `Audio-AutoSwitch.zip` + SHA-256 alias in addition to the versioned archive. ### Fixed -- Clean installation now polls Bluetooth/Core Audio transitions for 15 s / 15 s / 20 s and refreshes a recreated headset Item ID by `Device Name` + `Name`, matching the hardened Reconfigure flow. +- Clean installation polls Bluetooth/Core Audio transitions for 15 s / 15 s / 20 s and refreshes a recreated headset Item ID by `Device Name` + `Name`, matching the hardened Reconfigure flow. - The verifier no longer reports G HUB as a failure for generic `WindowsEndpoint` installations. -- The one-command bootstrap now uses English/generic Audio AutoSwitch wording. -- Removed leftover one-shot documentation workflows/scripts from `.github/`. -- `Reconfigure...` re-resolves a recreated Bluetooth endpoint using the real `svcl` identity columns **`Device Name` + `Name`**, not the combined display label. This fixes the edge case where a headset returns after power-on with a different `Item ID`, while avoiding collisions between multiple render endpoints belonging to the same device. Regression tests cover exact identity matching. +- The one-command bootstrap uses generic Audio AutoSwitch wording. +- Leftover one-shot documentation workflows/scripts were removed from `.github/`. +- `Reconfigure...` re-resolves a recreated Bluetooth endpoint using the real `svcl` identity columns **`Device Name` + `Name`**, not the combined display label. This avoids collisions between multiple render endpoints belonging to the same device and handles a headset returning with a different `Item ID`. Regression tests cover exact identity matching. ### Changed - README, GitHub Pages, maintainer notes, security/source notes and the historical WindowsEndpoint design document were refreshed to match the post-v1.2.3 behavior and hardware findings. @@ -25,90 +45,89 @@ Este proyecto se adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [1.2.3] - 2026-08-12 ### Fixed -- **`Reconfigure...` tolera la latencia real de headsets Bluetooth**: el wizard hace polling cada 500 ms y usa ventanas de hasta 15 s para el primer ON y el OFF, y hasta 20 s para el ON final. Esto evita falsos negativos cuando Windows tarda varios segundos en reflejar `Active`/`Unplugged`. -- **Los headsets Bluetooth pueden reaparecer con un `Item ID` distinto** tras apagarse y reconectarse. Si el ID original ya no aparece, Reconfigure vuelve a localizar el endpoint por su nombre estable, usa el estado observado y persiste el `Item ID` más reciente en `config.json`. Validado en hardware real con Jabra Evolve 65 (`ON → OFF → ON`, reinicio y nuevo `OFF → ON`). -- Se añadió diagnóstico detallado cuando un estado no llega a tiempo: el log muestra el estado final y los endpoints/IDs que `svcl` está viendo, para distinguir timing de un endpoint recreado. -- `Invoke-Reconfigure` envuelve también la apertura/construcción del diálogo en `try/catch`, de modo que los fallos previos a `Detect mode...` quedan registrados en `autoswitch.log`. +- **`Reconfigure...` now tolerates real Bluetooth headset latency.** The wizard polls every 500 ms and allows up to 15 s for the first ON state, 15 s for OFF and 20 s for the final ON. This prevents false negatives when Windows needs several seconds to expose `Active` or `Unplugged`. +- **Bluetooth endpoints may return with a different `Item ID` after power cycling.** If the original ID disappears, Reconfigure locates the same endpoint by stable identity, uses the observed state and stores the newest `Item ID`. This was validated on real Jabra Evolve 65 hardware across `ON → OFF → ON`, restart and another `OFF → ON` cycle. +- Added detailed diagnostics when a state transition does not arrive before the bounded wait: the log records the last state and the endpoints/IDs visible through `svcl`. +- `Invoke-Reconfigure` also wraps dialog creation/opening in `try/catch`, so failures that occur before mode detection are written to `autoswitch.log`. ## [1.2.2] - 2026-08-12 ### Fixed -- **Reconfigure endurecido al cambiar entre `WindowsEndpoint` y `LogitechGHub`**: una config de `WindowsEndpoint` podía no tener `GHubPort`; al reconfigurar a un PRO X 2, `Connect-GHub` usa ahora el puerto seguro por defecto (9010) en vez de fallar por una propiedad ausente. -- Los campos opcionales de config (`DetectionMode`, `EnhancementsDeviceId`, `GHubDisplayName`, `GHubPort`) se gestionan con `Add-Member -Force` (crea o actualiza) y se **eliminan los campos G HUB obsoletos** al pasar a `WindowsEndpoint`, para no dejar asociaciones fantasma. -- **Varios PRO X 2 en G HUB**: el wizard ya no adivina cuál corresponde; pregunta al usuario (Sí/No/Cancelar) por cada candidato hasta confirmar uno, o cancela y deja la config intacta. +- Hardened `Reconfigure...` when switching between `WindowsEndpoint` and `LogitechGHub`: a `WindowsEndpoint` config may not contain `GHubPort`; when reconfiguring to a PRO X 2, `Connect-GHub` now safely defaults to port 9010 instead of failing on a missing property. +- Optional config fields (`DetectionMode`, `EnhancementsDeviceId`, `GHubDisplayName`, `GHubPort`) are created or updated with `Add-Member -Force`; obsolete G HUB fields are removed when switching to `WindowsEndpoint` so stale associations are not retained. +- If G HUB exposes multiple PRO X 2 devices, the wizard asks the user to confirm the matching candidate instead of guessing. Cancel leaves the existing configuration untouched. ## [1.2.1] - 2026-08-12 ### Fixed -- **`Reconfigure...` del tray ahora ejecuta el wizard completo de detección** en vez de solo intercambiar `HeadsetId`/`SpeakerId`. La versión anterior dejaba el `DetectionMode` y la asociación G HUB antiguos, lo que producía dos estados rotos: un PRO X 2 reconfigurado a Jabra seguía vigilando el PRO X 2, y un Jabra reconfigurado a PRO X 2 seguía en `WindowsEndpoint` (Windows siempre reporta el endpoint del PRO X 2 `Active`, así que el apagado nunca se detectaba). Ahora valida el ciclo `ON → OFF → ON`, determina `WindowsEndpoint` o `LogitechGHub` (con confirmación del PRO X 2 vía G HUB), y actualiza `DetectionMode`, `GHubDisplayName` y `EnhancementsDeviceId`. Si no hay método compatible, deja la config intacta. -- **README dentro del ZIP/tag desfasado**: la v1.2.0 incluyó un README que describía el flujo "G HUB primero" del instalador; el flujo real es universal-first. Corregido en el repo y en esta release. +- **Tray `Reconfigure...` now runs the complete detection wizard** instead of only replacing `HeadsetId`/`SpeakerId`. The previous implementation could leave an old detection mode or G HUB association behind when switching between a PRO X 2 and a generic headset. +- Reconfiguration now validates `ON → OFF → ON`, determines `WindowsEndpoint` or `LogitechGHub`, updates the relevant config fields and preserves the old configuration if compatibility cannot be proven. +- The README included in the v1.2.0 tag/package described an older G-HUB-first installer flow. Documentation was corrected to match the universal-first implementation. ## [1.2.0] - 2026-08-12 ### Added -- Modo universal `WindowsEndpoint`: detecta el estado físico del auricular leyendo el estado del endpoint de audio de Windows (`svcl /scomma`, columna `Device State`). Funciona con cualquier auricular inalámbrico cuyo endpoint refleje el estado físico (p. ej. Jabra Evolve 65: `Active` → conectado, `Unplugged`/ausente → desconectado). -- Campo `DetectionMode` en config (`WindowsEndpoint` | `LogitechGHub`). Compatibilidad hacia atrás: una config sin `DetectionMode` se interpreta como `LogitechGHub` y se migra a v1.2.0 automáticamente en el primer arranque. -- Icono de bandeja (tray) en el runtime con icono propio (`assets/icon.ico`): activar/desactivar AutoSwitch, deshabilitar/habilitar Audio Enhancements del headset configurado y salir. El menú de enhancements se refresca cada 5 s para reflejar el estado real (`SysFx`). -- Menú de bandeja con **líneas de información** (Headset / Fallback / Next switch, refrescadas cada 5 s) y opción **Reconfigure...** que abre un diálogo para elegir un nuevo auricular/fallback de los dispositivos actuales de Windows sin reinstalar; guarda `config.json` y el worker recarga la config en el siguiente ciclo (flag `control/reload.flag`). -- Arquitectura de runtime a dos procesos: el polling corre en un **proceso worker separado** (`AUTOSWITCH_WORKER=1`, mismo script re-ejecutado, con guard anti-polls-concurrentes) para que el message pump de WinForms (`Application.Run()` sobre un `Form` invisible) y la bandeja nunca se bloqueen. La comunicación es por flags de control (`control/enabled.flag`, `control/stop.flag`). -- `Toggle-AudioEnhancements.ps1`: helper elevado (UAC puntual) que escribe `PKEY_AudioEndpoint_Disable_SysFx` en el endpoint del headset vía `IPolicyConfig`, verifica el resultado y actualiza el menú solo si el cambio se confirmó. El runtime nunca se ejecuta elevado. -- Instalador universal: selecciona headset y fallback desde la lista de dispositivos de Windows y auto-detecta el modo (si Windows refleja `Active ↔ Unplugged` → `WindowsEndpoint`; si no y es Logitech con G HUB → `LogitechGHub`; si no hay método compatible, aborta). G HUB filtra solo candidatos `PRO X 2` (evita seleccionar un ratón/teclado por accidente). El instalador ya **no vuelve a descargar `svcl.exe`** si ya está instalado. -- `DisableEnhancementsOnStart` y `EnhancementsDeviceId` opcionales en config; el instalador ofrece deshabilitar enhancements del headset tras instalar. -- Toda la interop COM (crear objetos, castear a interfaces, leer/escribir el FxStore) vive ahora en **C# compilado con `Add-Type`**, donde el cast a las interfaces `[ComImport]` (`IPolicyConfig`, `IMMDeviceEnumerator`, `IMMDevice`, `IPropertyStore`) es nativo — en PowerShell 5.1 el cast de un RCW COM a una interfaz custom falla. -- Tests Pester nuevos: fixture real de exportación `svcl /scomma` (`tests/fixtures/svcl-export.csv` con `Name`/`Device Name` separados), filtrado `Type=Device` + `Direction=Render`, `Get-SvclDeviceLabel` (`Device Name — Name`), `Test-SvclExportValid` (exige `Item ID` + `Device State`), y estados `Active→Connected` / `Unplugged→Disconnected` / `Unknown`. -- Todo el texto visible (instalador, runtime, helper, verificador, desinstalador) ahora está en inglés. +- Universal `WindowsEndpoint` mode: reads the Windows audio endpoint state from `svcl /scomma` and maps physical state. A Jabra Evolve 65 was validated with `Active → Connected` and `Unplugged`/absence → `Disconnected`. +- `DetectionMode` config field (`WindowsEndpoint` | `LogitechGHub`). Existing configs without the field remain backward compatible and are interpreted as `LogitechGHub` before migration. +- System tray runtime with its own icon: enable/disable AutoSwitch, toggle Audio Enhancements for the configured headset and exit. +- Tray information lines for Headset / Fallback / Next switch and a **Reconfigure...** action that chooses current Windows endpoints without requiring a reinstall. +- Two-process runtime architecture: the polling loop runs in a separate worker process (`AUTOSWITCH_WORKER=1`) while WinForms owns the responsive tray/message pump. Control flags under `control/` coordinate enable, reload and stop operations. +- `Toggle-AudioEnhancements.ps1`: temporary elevated helper that writes `PKEY_AudioEndpoint_Disable_SysFx` through `IPolicyConfig`, verifies the change and exits. The runtime itself remains non-elevated. +- Universal installer flow: choose headset + fallback, auto-detect `WindowsEndpoint` when Windows exposes the physical state, otherwise offer the PRO X 2 G HUB fallback, and abort safely if no compatible method can be proven. +- Optional `DisableEnhancementsOnStart` and `EnhancementsDeviceId` configuration. +- COM interop implemented in C# via `Add-Type` so PowerShell 5.1 can reliably use the required `[ComImport]` interfaces. +- Pester coverage for real `svcl /scomma` parsing, render-device filtering, endpoint labels, export validation and endpoint-state mapping. +- User-visible installer, runtime, helper, verifier and uninstaller text moved to English. ### Changed -- Config v1.2.0 con `DetectionMode` (el runtime conserva el comportamiento G HUB para instalaciones existentes). -- `Runtime-PROX2-AutoSwitch.ps1`: lanza el worker con `$PSCommandPath` (no `$MyInvocation.MyCommand.Path`, que podía quedar vacío dentro de funciones en PS 5.1). -- El runtime espera `Toggle-AudioEnhancements.ps1` y `icon.ico` en el directorio de instalación; el instalador los copia. -- Verificador muestra `DetectionMode`, estado del endpoint del headset y estado de enhancements. -- En el modo `LogitechGHub`, la conexión a G HUB es **persistente**: se conecta una vez, resuelve el `deviceId` una vez, y solo reconecta (re-resolviendo el `deviceId`, que puede cambiar tras una reconexión) ante un fallo. -- Los scripts con caracteres no-ASCII llevan **BOM UTF-8** (lo exige PSScriptAnalyzer). +- Config v1.2.0 records `DetectionMode`; existing G HUB installations retain their behavior. +- Runtime starts the worker with `$PSCommandPath` because `$MyInvocation.MyCommand.Path` can be empty inside functions in PowerShell 5.1. +- Runtime expects `Toggle-AudioEnhancements.ps1` and `icon.ico` in the install directory. +- Verifier reports detection mode, headset endpoint state and Audio Enhancements state. +- `LogitechGHub` mode keeps a persistent G HUB connection and resolves the current device ID again after reconnect failures. +- PowerShell files containing non-ASCII characters use UTF-8 BOM where required by the validation toolchain. ### Fixed -- El estado `Unknown` (svcl falla, `Disabled`, valor raro o **export inválido/vacío**) nunca cambia la salida de audio: protege de mandar al fallback por un fallo puntual de svcl. Solo un export válido + fila ausente se considera `Disconnected`. -- `Get-EndpointFxState` lee ahora `PKEY_AudioEndpoint_Disable_SysFx` del **FxStore** vía `IPolicyConfig::GetPropertyValue(deviceId, bFxStore=true)` — el mismo store donde el helper elevado escribe. Antes lo leía del `IPropertyStore` del endpoint (donde la clave no existe) y el menú del tray siempre ofrecía "Disable" aunque ya estuvieran deshabilitados. -- `Add-Type` con sentinel correcto (`AutoSwitch.EndpointFx`): el primer intento usaba un nombre que no existía y reintentaba compilar en cada llamada, fallando en la segunda. -- `Get-SvclRenderDevice` devuelve ahora el array correctamente (un `return ,$array` envolvía el resultado y rompía `.Count` en los tests) y usa `Get-CsvColumn` para acceso fiable a propiedades. -- Instalador: eliminadas funciones huérfanas que quedaron sin uso tras el rediseño del flujo de selección. -- El icono de bandeja usa un `.ico` propio en vez de `SystemIcons::Application` (que no era legible/identificable). +- `Unknown` endpoint state never changes the output. Invalid/empty exports and transient `svcl` failures therefore cannot send audio to the fallback. +- `Get-EndpointFxState` now reads `PKEY_AudioEndpoint_Disable_SysFx` from the FxStore through `IPolicyConfig::GetPropertyValue(..., bFxStore=true)`, matching the store written by the elevated helper. +- Corrected the `Add-Type` sentinel so the COM block is not compiled again on every call. +- `Get-SvclRenderDevice` returns arrays correctly and uses reliable CSV-column access. +- Removed installer functions that became unused after the universal selection-flow redesign. +- Replaced the generic system tray icon with the project icon. ### Security -- El WebSocket de G HUB (`ws://localhost:9010`) no es una API oficial de Logitech; puede cambiar en futuras versiones. Ver `AGENT.md`/`SOURCES.md`. +- The G HUB WebSocket at `ws://localhost:9010` is an unofficial local interface and may change in future G HUB releases. Verified sources and constraints are documented in `AGENT.md` and `SOURCES.md`. ## [1.1.0] - 2026-08-07 ### Added -- Timeouts acotados en el WebSocket de G HUB: conexión (5 s), espera de respuesta (5 s) y límite global por petición (10 s). Tras un timeout se cierra el socket, se registra en el log y se reintenta; mientras el estado sea desconocido no se cambia la salida de audio. -- Cierre del WebSocket con límite duro: `CloseAsync` espera como máximo 1 s y, si falla o expira, `Abort()` + `Dispose()` garantizan que la recuperación nunca se quede colgada con un G HUB atascado. Aplicado también a la comprobación de G HUB del instalador. -- `lib/AutoSwitchCore.psm1`: módulo de lógica pura compartida (extracción de Item ID, debounce OFF, validación de config, token de timeout) usada por instalador, runtime y tests. -- Tests Pester (`tests/`) ejecutados en CI: extracción de Item ID válido, rechazo de salida inválida, regresión `/Stdout`, debounce OFF tras `OffMissThreshold`, payload único que no dispara OFF, rechazo de IDs idénticos y autocancelación del token de timeout. -- `install.ps1` verifica SHA-256 del ZIP de la release contra un asset `.sha256` publicado antes de extraer/ejecutar. Selección determinista: exactamente un `PROX2-AutoSwitch-*.zip`, fallo si hay cero o varios. Todo el flujo (descarga, checksum, extracción, ejecución) está dentro de un `try/finally` que limpia `%TEMP%` en cualquier fallo. -- Workflow de release (`.github/workflows/release.yml`): genera el ZIP + `.sha256` automáticamente en tags `v*`. -- Desinstalador reporta cada paso (proceso, inicio automático, directorio) y distingue éxito completo, limpieza parcial fallida y "nada que desinstalar". La eliminación programada vía `cmd.exe` se marca solo si se lanza correctamente. +- Bounded G HUB WebSocket timeouts: connection (5 s), response wait (5 s) and overall request limit (10 s). Timeout recovery closes the socket, logs the event and retries; unknown state never changes the output. +- Hard-bounded WebSocket close: `CloseAsync` waits at most 1 s, then `Abort()` + `Dispose()` guarantees recovery cannot hang on a stuck G HUB connection. +- `lib/AutoSwitchCore.psm1` shared pure logic for Item ID extraction, OFF debounce, config validation and request timeout helpers. +- Pester CI coverage for valid/invalid Item IDs, the `/Stdout` regression, debounce behavior, config validation and timeout cancellation. +- `install.ps1` verifies the release ZIP SHA-256 before extraction/execution, chooses exactly one versioned project archive and always cleans temporary files in `finally`. +- Release workflow publishes a versioned ZIP and SHA-256 checksum. +- Uninstaller reports process, startup and directory cleanup separately and distinguishes complete success from partial cleanup failure. ### Changed -- Configuración de instalación ahora incluye `ConnectTimeoutMs`, `ReceiveTimeoutMs`, `RequestTimeoutMs` (config v1.1.0). -- Instalador de un clic (`install.ps1`): solo instala releases que publiquen checksum `.sha256`; documenta la verificación antes de extraer. +- Installation config gained `ConnectTimeoutMs`, `ReceiveTimeoutMs` and `RequestTimeoutMs`. +- One-command install accepts only releases that provide a checksum asset. ### Fixed -- `install.ps1`: eliminada la comprobación engañosa de `$LASTEXITCODE` tras invocar el instalador `.ps1`; los errores se propagan por excepción. -- Desinstalador: frontera de ruta en la detección de "ejecutándose desde InstallDir" (`$InstallDir\*`). -- Verificador: lee `GHubPort` de config en lugar de hardcodear 9010. +- Removed misleading `$LASTEXITCODE` checking after launching the PowerShell installer; failures propagate as exceptions. +- Hardened uninstall path-boundary detection. +- Verifier reads `GHubPort` from config rather than hardcoding 9010. ## [1.0.0] - 2026-08-07 ### Added -- Instalador (`Instalar-PROX2-AutoSwitch.ps1`): descarga de SoundVolumeCommandLine desde NirSoft con verificación SHA-256, calibración de salidas por Item ID real, prueba real de ambos cambios e inicio automático invisible vía `wscript.exe`. -- Runtime (`Runtime-PROX2-AutoSwitch.ps1`): detecta el estado físico del PRO X 2 vía el WebSocket de G HUB (`ws://localhost:9010`) y cambia la salida de audio de Windows. -- Desinstalador (`Desinstalar-PROX2-AutoSwitch.ps1`): elimina proceso, inicio automático y archivos. -- Verificador (`Verificar-PROX2-AutoSwitch.ps1`): diagnóstico rápido. -- Instalador de un clic (`install.ps1`): bootstrap que descarga la última release de GitHub y ejecuta el instalador completo. -- Sitio web (GitHub Pages, `site/`) bilingüe ES/EN. -- Wiki del repositorio completa en ES/EN. -- CI: validación de sintaxis PowerShell, linting con PSScriptAnalyzer y despliegue del sitio a Pages. +- Initial installer with verified NirSoft SoundVolumeCommandLine download, real Item ID calibration, bidirectional switch tests and invisible startup through `wscript.exe`. +- Runtime that detects the physical PRO X 2 state through the local G HUB WebSocket and changes the Windows default output. +- Uninstaller and verifier. +- One-command bootstrap that downloads the latest GitHub release and starts the complete installer. +- GitHub Pages project site. +- CI for PowerShell syntax and PSScriptAnalyzer validation. ### Known limitations -- Solo admite dos salidas (auriculares y alternativa). -- El WebSocket de G HUB no es una API oficial de Logitech; puede cambiar en futuras versiones. +- Initial implementation supported only two outputs: headset and fallback. +- The G HUB WebSocket is not an official Logitech API and may change. From 7de89883f0d8ec90728f49ba1c05cd83eb67dffe Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:09:17 +0200 Subject: [PATCH 07/27] docs: add v1.2.5 release notes --- docs/RELEASE-NOTES-v1.2.5.md | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/RELEASE-NOTES-v1.2.5.md diff --git a/docs/RELEASE-NOTES-v1.2.5.md b/docs/RELEASE-NOTES-v1.2.5.md new file mode 100644 index 0000000..2dfbbe2 --- /dev/null +++ b/docs/RELEASE-NOTES-v1.2.5.md @@ -0,0 +1,41 @@ +# Audio AutoSwitch v1.2.5 + +`v1.2.5` is a repository-quality and packaging maintenance release. Detection behavior is intentionally unchanged from v1.2.4. + +## What changed + +- English is canonical for the repository, Pages site, code comments, tests, technical documentation and release documentation. +- The versioned GitHub Wiki source now contains a complete English edition and a maintained Spanish edition. +- Canonical English PowerShell entrypoints are included in the release package: + - `Install-AutoSwitch.ps1` + - `Verify-AutoSwitch.ps1` + - `Uninstall-AutoSwitch.ps1` +- Existing Spanish-named PowerShell entrypoints remain for backward compatibility. +- Repository governance now includes contributing, support, code-of-conduct, issue and pull-request guidance. +- CI adds secret scanning and repository/language quality checks. +- GitHub Actions are pinned to immutable commit SHAs. +- Release ZIP creation is deterministic and publication requires two byte-identical builds. + +## Compatibility + +There are no intentional changes to: + +- `WindowsEndpoint` detection behavior; +- the Logitech G HUB fallback; +- OFF debounce behavior; +- Audio Enhancements behavior; +- config schema or migration behavior; +- the tested Bluetooth `Device Name + Name` re-resolution logic. + +## Release assets + +The release publishes both canonical and convenience names: + +```text +PROX2-AutoSwitch-v1.2.5.zip +PROX2-AutoSwitch-v1.2.5.zip.sha256 +Audio-AutoSwitch.zip +Audio-AutoSwitch.zip.sha256 +``` + +The two ZIP names contain identical bytes and are accompanied by SHA-256 checksum files. From 753f36448d4cfc20ed95044286e2a8b6e3fbb865 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:09:32 +0200 Subject: [PATCH 08/27] ci: harden v1.2.5 publication --- .github/workflows/release-v1.2.5.yml | 42 +++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-v1.2.5.yml b/.github/workflows/release-v1.2.5.yml index 1d27822..2ed86f3 100644 --- a/.github/workflows/release-v1.2.5.yml +++ b/.github/workflows/release-v1.2.5.yml @@ -2,8 +2,10 @@ name: Publish v1.2.5 on: push: - branches: [main] - paths: ['.github/workflows/release-v1.2.5.yml'] + branches: + - main + paths: + - '.github/workflows/release-v1.2.5.yml' permissions: contents: write @@ -11,30 +13,56 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + - name: Skip if release exists id: existing env: GH_TOKEN: ${{ github.token }} - run: if gh release view v1.2.5 >/dev/null 2>&1; then echo 'exists=true' >> "$GITHUB_OUTPUT"; else echo 'exists=false' >> "$GITHUB_OUTPUT"; fi - - name: Scan and build twice + run: | + if gh release view v1.2.5 >/dev/null 2>&1; then + echo 'exists=true' >> "$GITHUB_OUTPUT" + else + echo 'exists=false' >> "$GITHUB_OUTPUT" + fi + + - name: Repository quality checks if: steps.existing.outputs.exists == 'false' run: | + python3 scripts/check-repository.py + python3 scripts/check-language.py bash scripts/run-gitleaks.sh + + - name: Build deterministic release twice + if: steps.existing.outputs.exists == 'false' + run: | bash scripts/build-release.sh 1.2.5 - cp dist/PROX2-AutoSwitch-v1.2.5.zip /tmp/first.zip first="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" + first_copy="$(mktemp /tmp/autoswitch-v1.2.5.XXXXXX.zip)" + trap 'rm -f -- "${first_copy}"' EXIT + cp dist/PROX2-AutoSwitch-v1.2.5.zip "${first_copy}" rm -f dist/*.zip dist/*.sha256 bash scripts/build-release.sh 1.2.5 second="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" test "$first" = "$second" - cmp /tmp/first.zip dist/PROX2-AutoSwitch-v1.2.5.zip + cmp "${first_copy}" dist/PROX2-AutoSwitch-v1.2.5.zip + cmp dist/PROX2-AutoSwitch-v1.2.5.zip dist/Audio-AutoSwitch.zip + - name: Publish v1.2.5 if: steps.existing.outputs.exists == 'false' env: GH_TOKEN: ${{ github.token }} - run: gh release create v1.2.5 dist/PROX2-AutoSwitch-v1.2.5.zip dist/PROX2-AutoSwitch-v1.2.5.zip.sha256 dist/Audio-AutoSwitch.zip dist/Audio-AutoSwitch.zip.sha256 --target "$GITHUB_SHA" --title 'v1.2.5 — English-first public structure' --notes 'English-first repository and Pages, fully bilingual Wiki source, canonical English PowerShell entrypoints, deterministic release packaging and CI hardening. Detection behavior is unchanged.' + run: | + gh release create v1.2.5 \ + dist/PROX2-AutoSwitch-v1.2.5.zip \ + dist/PROX2-AutoSwitch-v1.2.5.zip.sha256 \ + dist/Audio-AutoSwitch.zip \ + dist/Audio-AutoSwitch.zip.sha256 \ + --target "$GITHUB_SHA" \ + --title 'v1.2.5 — English-first repository standard' \ + --notes-file docs/RELEASE-NOTES-v1.2.5.md From 760fedf068e96f4dd7d882b14359d566fc55f037 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:10:06 +0200 Subject: [PATCH 09/27] docs: point the Wiki to v1.2.5 --- wiki/Home.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wiki/Home.md b/wiki/Home.md index 825c7b6..472934c 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,8 +1,8 @@ # Audio AutoSwitch -**Stable release: v1.2.4 · Windows 10/11 x64** +**Stable release: v1.2.5 · Windows 10/11 x64** -Audio AutoSwitch changes the Windows default output automatically when a compatible wireless headset turns on/off, and provides tray controls for switching behavior and Windows Audio Enhancements. +Audio AutoSwitch changes the Windows default output automatically when a compatible wireless headset turns on or off, and provides tray controls for switching behavior and Windows Audio Enhancements. ## Start here From 5c5b5f1e7711c00b5af50ec800f45b2310983e03 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:10:18 +0200 Subject: [PATCH 10/27] docs: update Spanish Wiki release marker --- wiki/Inicio.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiki/Inicio.md b/wiki/Inicio.md index 2f6ca00..3356c31 100644 --- a/wiki/Inicio.md +++ b/wiki/Inicio.md @@ -1,6 +1,6 @@ # Audio AutoSwitch -**Versión estable: v1.2.4 · Windows 10/11 x64** +**Versión estable: v1.2.5 · Windows 10/11 x64** Audio AutoSwitch cambia automáticamente la salida predeterminada de Windows cuando un auricular inalámbrico compatible se enciende o apaga, y añade controles desde la bandeja para AutoSwitch y Audio Enhancements. From 801adbe70cfaa050cd2454b978fd5dca8c848d72 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:10:57 +0200 Subject: [PATCH 11/27] ci: align AutoSwitch validation with repository standard --- .github/workflows/validate.yml | 45 ++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 84b68d2..724a923 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,7 +2,8 @@ name: validate on: push: - branches: [main] + branches: + - main pull_request: permissions: @@ -13,8 +14,34 @@ concurrency: cancel-in-progress: true jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Repository quality + run: python3 scripts/check-repository.py + - name: English canonical language guard + run: python3 scripts/check-language.py + - name: Validate Bash scripts + run: bash -n scripts/*.sh + - name: Deterministic release build + run: | + bash scripts/build-release.sh 1.2.5 + first="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" + cp dist/PROX2-AutoSwitch-v1.2.5.zip /tmp/autoswitch-first.zip + rm -f dist/*.zip dist/*.sha256 + bash scripts/build-release.sh 1.2.5 + second="$(cut -d' ' -f1 dist/PROX2-AutoSwitch-v1.2.5.zip.sha256)" + test "$first" = "$second" + cmp /tmp/autoswitch-first.zip dist/PROX2-AutoSwitch-v1.2.5.zip + cmp dist/PROX2-AutoSwitch-v1.2.5.zip dist/Audio-AutoSwitch.zip + powershell: runs-on: windows-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -25,25 +52,23 @@ jobs: $scripts=Get-ChildItem -Recurse -File | Where-Object { $_.Extension -in '.ps1','.psm1' } foreach($script in $scripts){$tokens=$null;$errors=$null;[System.Management.Automation.Language.Parser]::ParseFile($script.FullName,[ref]$tokens,[ref]$errors)|Out-Null;if($errors.Count){$errors|ForEach-Object{Write-Error "$($script.FullName): $($_.Message)"};exit 1}} - name: Install validation modules - shell: pwsh + shell: powershell run: | - Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck + Install-Module PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser -SkipPublisherCheck Install-Module Pester -RequiredVersion 5.9.0 -Force -Scope CurrentUser -SkipPublisherCheck - name: PSScriptAnalyzer - shell: pwsh + shell: powershell run: | + Import-Module PSScriptAnalyzer -RequiredVersion 1.24.0 $r=Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSAvoidUsingPlainTextForPassword,PSAvoidUsingCmdletAliases,PSAvoidUsingEmptyCatchBlock,PSUseApprovedVerbs,PSUseShouldProcessForStateChangingFunctions if($r){$r|Format-Table -AutoSize|Out-String|Write-Host;exit 1} - - name: Required release files - shell: pwsh - run: | - $required=@('Install.cmd','Verify.cmd','Uninstall.cmd','install.ps1','Install-AutoSwitch.ps1','Verify-AutoSwitch.ps1','Uninstall-AutoSwitch.ps1','Instalar-PROX2-AutoSwitch.ps1','Verificar-PROX2-AutoSwitch.ps1','Desinstalar-PROX2-AutoSwitch.ps1','Runtime-PROX2-AutoSwitch.ps1','Toggle-AudioEnhancements.ps1','lib/AutoSwitchCore.psm1') - $missing=$required|Where-Object{-not(Test-Path $_)};if($missing){throw "Missing release files: $($missing -join ', ')"} - name: Pester - shell: pwsh + shell: powershell run: Import-Module Pester -RequiredVersion 5.9.0; Invoke-Pester -Path .\tests -CI + secrets: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From c526a9464931a920e81d81580c1c6c1ddee40689 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:14 +0200 Subject: [PATCH 12/27] release: add guarded publication script --- scripts/publish-release.sh | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 scripts/publish-release.sh diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100644 index 0000000..0e5ca5e --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +REPOSITORY="${AUTOSWITCH_GITHUB_REPOSITORY:-Ayerdi/PROX2-AutoSwitch}" +VERSION="${1:-}" +APPLY="${2:-}" + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "${APPLY}" != "--apply" || $# -ne 2 ]]; then + printf 'Usage: %s MAJOR.MINOR.PATCH --apply\n' "$0" >&2 + exit 2 +fi + +command -v gh >/dev/null || { printf 'GitHub CLI (gh) is required.\n' >&2; exit 1; } +command -v git >/dev/null || { printf 'git is required.\n' >&2; exit 1; } +cd "${ROOT_DIR}" + +gh auth status >/dev/null 2>&1 || { printf 'GitHub CLI has no valid session. Run gh auth login.\n' >&2; exit 1; } +[[ -z "$(git status --short)" ]] || { printf 'The Git tree must be clean.\n' >&2; exit 1; } +[[ "$(git branch --show-current)" == "main" ]] || { printf 'Stable releases can only be published from main.\n' >&2; exit 1; } + +git fetch --quiet origin main --tags +head_sha="$(git rev-parse HEAD)" +remote_sha="$(git rev-parse origin/main)" +[[ "${head_sha}" == "${remote_sha}" ]] || { printf 'HEAD does not match origin/main. Update the checkout.\n' >&2; exit 1; } + +ci_state="$(gh run list --repo "${REPOSITORY}" --workflow validate.yml --branch main --limit 1 --json headSha,status,conclusion --jq '.[0] | (.headSha // "") + ":" + (.status // "") + ":" + (.conclusion // "")')" +[[ "${ci_state}" == "${head_sha}:completed:success" ]] || { printf 'Validation is not green for HEAD %s (%s).\n' "${head_sha}" "${ci_state:-no run}" >&2; exit 1; } + +notes="docs/RELEASE-NOTES-v${VERSION}.md" +[[ -f "${notes}" ]] || { printf 'Missing release notes: %s\n' "${notes}" >&2; exit 1; } +grep -Fq "## [${VERSION}]" CHANGELOG.md || { printf 'CHANGELOG.md does not contain version %s.\n' "${VERSION}" >&2; exit 1; } + +if gh release view "v${VERSION}" --repo "${REPOSITORY}" >/dev/null 2>&1; then + printf 'Release v%s already exists; it will not be overwritten.\n' "${VERSION}" >&2 + exit 1 +fi + +python3 scripts/check-repository.py +python3 scripts/check-language.py +bash scripts/run-gitleaks.sh +bash scripts/build-release.sh "${VERSION}" +archive="dist/PROX2-AutoSwitch-v${VERSION}.zip" +checksum="${archive}.sha256" +first="$(cut -d' ' -f1 "${checksum}")" +first_copy="$(mktemp /tmp/autoswitch-release.XXXXXX.zip)" +trap 'rm -f -- "${first_copy}"' EXIT +cp "${archive}" "${first_copy}" +rm -f dist/*.zip dist/*.sha256 +bash scripts/build-release.sh "${VERSION}" +second="$(cut -d' ' -f1 "${checksum}")" +test "${first}" = "${second}" +cmp "${first_copy}" "${archive}" +cmp "${archive}" dist/Audio-AutoSwitch.zip + +gh release create "v${VERSION}" \ + "${archive}" "${checksum}" \ + dist/Audio-AutoSwitch.zip dist/Audio-AutoSwitch.zip.sha256 \ + --repo "${REPOSITORY}" \ + --target "${head_sha}" \ + --title "v${VERSION}" \ + --notes-file "${notes}" + +uploaded_digest="$(gh api "repos/${REPOSITORY}/releases/tags/v${VERSION}" --jq ".assets[] | select(.name == \"$(basename "${archive}")\") | (.digest // \"\")")" +[[ "${uploaded_digest}" == "sha256:${second}" ]] || { printf 'GitHub returned an unexpected archive digest: %s\n' "${uploaded_digest:-empty}" >&2; exit 1; } + +printf 'Release v%s published from %s.\n' "${VERSION}" "${head_sha}" +printf 'Published digest: %s\n' "${uploaded_digest}" From 028b76d1853a69a124222ea346b11059eb66de36 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:33 +0200 Subject: [PATCH 13/27] docs: add versioned Wiki publisher --- scripts/publish-wiki.sh | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 scripts/publish-wiki.sh diff --git a/scripts/publish-wiki.sh b/scripts/publish-wiki.sh new file mode 100644 index 0000000..ed63db2 --- /dev/null +++ b/scripts/publish-wiki.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +REPOSITORY="${AUTOSWITCH_GITHUB_REPOSITORY:-Ayerdi/PROX2-AutoSwitch}" +APPLY="${1:-}" + +if [[ "${APPLY}" != "--apply" || $# -ne 1 ]]; then + printf 'Usage: %s --apply\n' "$0" >&2 + exit 2 +fi + +command -v gh >/dev/null || { printf 'GitHub CLI (gh) is required.\n' >&2; exit 1; } +command -v git >/dev/null || { printf 'git is required.\n' >&2; exit 1; } +cd "${ROOT_DIR}" + +gh auth status >/dev/null 2>&1 || { printf 'GitHub CLI has no valid session. Run gh auth login.\n' >&2; exit 1; } +[[ -z "$(git status --short)" ]] || { printf 'The Git tree must be clean before publishing the Wiki.\n' >&2; exit 1; } +[[ "$(git branch --show-current)" == "main" ]] || { printf 'Publish the Wiki only from main.\n' >&2; exit 1; } + +git fetch --quiet origin main +head_sha="$(git rev-parse HEAD)" +remote_sha="$(git rev-parse origin/main)" +[[ "${head_sha}" == "${remote_sha}" ]] || { printf 'HEAD does not match origin/main. Update the checkout.\n' >&2; exit 1; } + +visibility="$(gh repo view "${REPOSITORY}" --json visibility --jq .visibility)" +[[ "${visibility}" == "PUBLIC" ]] || { printf 'The repository must be public before publishing the Wiki.\n' >&2; exit 1; } +wiki_enabled="$(gh repo view "${REPOSITORY}" --json hasWikiEnabled --jq .hasWikiEnabled)" +[[ "${wiki_enabled}" == "true" ]] || { printf 'GitHub Wiki is not enabled for %s.\n' "${REPOSITORY}" >&2; exit 1; } + +python3 scripts/check-repository.py +python3 scripts/check-language.py + +tmp="$(mktemp -d /tmp/autoswitch-wiki.XXXXXX)" +trap 'rm -rf -- "${tmp}"' EXIT + +gh auth setup-git >/dev/null +if ! git clone --quiet "https://github.com/${REPOSITORY}.wiki.git" "${tmp}/wiki"; then + printf 'The GitHub Wiki repository is not initialized yet. Create the first Home page in the GitHub Wiki UI, then rerun this command.\n' >&2 + exit 1 +fi + +find "${tmp}/wiki" -mindepth 1 -maxdepth 1 -type f -name '*.md' -delete +cp "${ROOT_DIR}"/wiki/*.md "${tmp}/wiki/" +cd "${tmp}/wiki" +git add --all +if git diff --cached --quiet; then + printf 'Wiki is already synchronized with main %s.\n' "${head_sha}" + exit 0 +fi + +git config user.name "Ayerdi" +git config user.email "128999164+Ayerdi@users.noreply.github.com" +git commit -m "docs: sync Wiki from repository main ${head_sha}" >/dev/null +git push --quiet origin master 2>/dev/null || git push --quiet origin main +printf 'Wiki synchronized from repository main %s.\n' "${head_sha}" From 458aff396f6deda00db2936831cc2fa995985ba6 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:55 +0200 Subject: [PATCH 14/27] chore: add public repository configuration helper --- scripts/configure-public-repository.sh | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/configure-public-repository.sh diff --git a/scripts/configure-public-repository.sh b/scripts/configure-public-repository.sh new file mode 100644 index 0000000..d4992a9 --- /dev/null +++ b/scripts/configure-public-repository.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +REPOSITORY="${AUTOSWITCH_GITHUB_REPOSITORY:-Ayerdi/PROX2-AutoSwitch}" +APPLY="${1:-}" + +if [[ "${APPLY}" != "--apply" || $# -ne 1 ]]; then + printf 'Usage: %s --apply\n' "$0" >&2 + exit 2 +fi + +command -v gh >/dev/null || { printf 'GitHub CLI (gh) is required.\n' >&2; exit 1; } +command -v git >/dev/null || { printf 'git is required.\n' >&2; exit 1; } +cd "${ROOT_DIR}" + +gh auth status >/dev/null 2>&1 || { printf 'GitHub CLI has no valid session. Run gh auth login.\n' >&2; exit 1; } +[[ -z "$(git status --short)" ]] || { printf 'The Git tree must be clean.\n' >&2; exit 1; } +[[ "$(git branch --show-current)" == "main" ]] || { printf 'Run repository configuration from main.\n' >&2; exit 1; } + +git fetch --quiet origin main +head_sha="$(git rev-parse HEAD)" +[[ "${head_sha}" == "$(git rev-parse origin/main)" ]] || { printf 'HEAD does not match origin/main. Update the checkout.\n' >&2; exit 1; } + +visibility="$(gh repo view "${REPOSITORY}" --json visibility --jq .visibility)" +[[ "${visibility}" == "PUBLIC" ]] || { printf 'The repository must already be public.\n' >&2; exit 1; } + +python3 scripts/check-repository.py +python3 scripts/check-language.py + +gh api --method PATCH "repos/${REPOSITORY}" \ + -f description='Automatic Windows audio output switching for compatible wireless headsets, with a Logitech PRO X 2 fallback.' \ + -f homepage='https://ayerdi.github.io/PROX2-AutoSwitch/' \ + -F has_issues=true \ + -F has_discussions=true \ + -F has_wiki=true \ + -F delete_branch_on_merge=true >/dev/null + +gh api --method PUT "repos/${REPOSITORY}/topics" \ + -f 'names[]=windows' \ + -f 'names[]=audio' \ + -f 'names[]=headset' \ + -f 'names[]=powershell' \ + -f 'names[]=logitech' \ + -f 'names[]=automation' \ + -f 'names[]=open-source' >/dev/null + +gh api --method PUT "repos/${REPOSITORY}/vulnerability-alerts" >/dev/null || true +gh api --method PUT "repos/${REPOSITORY}/private-vulnerability-reporting" >/dev/null || true + +gh api --method PUT "repos/${REPOSITORY}/branches/main/protection" \ + -H 'Accept: application/vnd.github+json' \ + -f required_status_checks[strict]=true \ + -f 'required_status_checks[contexts][]=quality' \ + -f 'required_status_checks[contexts][]=powershell' \ + -f 'required_status_checks[contexts][]=secrets' \ + -F enforce_admins=true \ + -f required_pull_request_reviews[dismiss_stale_reviews]=false \ + -F required_pull_request_reviews[required_approving_review_count]=0 \ + -F restrictions= \ + -F required_conversation_resolution=true \ + -F allow_force_pushes=false \ + -F allow_deletions=false >/dev/null + +if gh api "repos/${REPOSITORY}/pages" >/dev/null 2>&1; then + gh api --method PUT "repos/${REPOSITORY}/pages" -f build_type=workflow >/dev/null +else + gh api --method POST "repos/${REPOSITORY}/pages" -f build_type=workflow >/dev/null +fi + +gh workflow run pages.yml --repo "${REPOSITORY}" +printf 'Public repository settings applied. Publish the versioned Wiki with scripts/publish-wiki.sh --apply.\n' From 017851fa6fe077b34a18f62f3a9264de3eec9707 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:13:25 +0200 Subject: [PATCH 15/27] docs: align the documentation index --- docs/INDEX.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index d75c16e..3e01b41 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -3,19 +3,31 @@ ## Start here - [README](../README.md) — product overview, installation and usage. -- [Project website](https://ayerdi.github.io/PROX2-AutoSwitch/) — visual overview and real tray/demo images. -- [Wiki](https://github.com/Ayerdi/PROX2-AutoSwitch/wiki) — user guides in **English and Spanish**. +- [Project website](https://ayerdi.github.io/PROX2-AutoSwitch/) — visual overview and real demo/tray images. +- [English Wiki](https://github.com/Ayerdi/PROX2-AutoSwitch/wiki) — installation, behavior, tray controls, troubleshooting and FAQ. +- [Spanish Wiki](https://github.com/Ayerdi/PROX2-AutoSwitch/wiki/Inicio) — maintained Spanish user documentation. - [Support](../SUPPORT.md) — what to include in a compatibility report. ## Technical reference -- [AGENT.md](../AGENT.md) — maintainer context and invariants. -- [SOURCES.md](../SOURCES.md) — verified technical sources and hardware evidence. -- [WindowsEndpointProvider.md](WindowsEndpointProvider.md) — historical design notes and assumptions that were corrected by real Bluetooth testing. +- [Maintainer guide](../AGENT.md) — verified design constraints and hardware findings. +- [Sources](../SOURCES.md) — technical references and evidence. +- [WindowsEndpointProvider](WindowsEndpointProvider.md) — historical design notes and assumptions corrected by real Bluetooth testing. - [Security](../SECURITY.md) — trust boundaries and private reporting. +## Releases + +- [v1.2.5 release notes](RELEASE-NOTES-v1.2.5.md) +- [Changelog](../CHANGELOG.md) +- [GitHub Releases](https://github.com/Ayerdi/PROX2-AutoSwitch/releases) + ## Project maintenance - [Contributing](../CONTRIBUTING.md) -- [Changelog](../CHANGELOG.md) - [Code of Conduct](../CODE_OF_CONDUCT.md) +- `scripts/check-repository.py` — repository structure/version/Wiki-link checks. +- `scripts/check-language.py` — English-canonical guard outside `wiki/`. +- `scripts/build-release.sh` — deterministic release package builder. +- `scripts/publish-release.sh` — guarded release publication. +- `scripts/publish-wiki.sh` — publish the versioned bilingual Wiki source. +- `scripts/configure-public-repository.sh` — apply the public GitHub settings used by this project family. From 444e8fdc1b9c4b8931b1ca63159c77bf96befc03 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:14:43 +0200 Subject: [PATCH 16/27] fix: validate GitHub Wiki targets correctly --- scripts/check-repository.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/check-repository.py b/scripts/check-repository.py index 26beccc..b2fbeee 100644 --- a/scripts/check-repository.py +++ b/scripts/check-repository.py @@ -86,9 +86,9 @@ def check_wiki_links() -> None: pattern = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]") for path in wiki.glob("*.md"): text = path.read_text(encoding="utf-8-sig") - for first, second in pattern.findall(text): - # GitHub Wiki syntax is [[Page]] or [[Label|Page]]. - target = (second or first).strip() + for page, _label in pattern.findall(text): + # GitHub Wiki uses [[Page]] or [[Page|Visible label]]. + target = page.strip() if target.startswith("http://") or target.startswith("https://"): continue if target not in page_names: From 5047231a103108535406ad3224a0b04dc848a0db Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:16:15 +0200 Subject: [PATCH 17/27] chore: run reviewed English cleanup --- .github/workflows/tmp-language-cleanup.yml | 101 +++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/tmp-language-cleanup.yml diff --git a/.github/workflows/tmp-language-cleanup.yml b/.github/workflows/tmp-language-cleanup.yml new file mode 100644 index 0000000..517dec9 --- /dev/null +++ b/.github/workflows/tmp-language-cleanup.yml @@ -0,0 +1,101 @@ +name: Temporary English cleanup + +on: + push: + branches: + - agent/repository-standard-v1.2.5 + paths: + - .github/workflows/tmp-language-cleanup.yml + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/repository-standard-v1.2.5 + - name: Apply reviewed translations + shell: python + run: | + from pathlib import Path + + replacements = { + Path('AGENT.md'): { + '("No se puede convertir…")': '(the COM interface cast fails at runtime)', + }, + Path('Runtime-PROX2-AutoSwitch.ps1'): { + '# Un reintento por si Windows estaba recreando el endpoint.': '# Retry once in case Windows was recreating the endpoint.', + '# Export valido pero el endpoint no aparece: para Windows significa': '# Valid export but the endpoint is absent: Windows treats this as', + '# el hilo de WinForms. El Timer del hilo de UI solo marca estado; el worker': '# the WinForms thread. The UI-thread timer only marks state; the worker', + 'Igual que Get-HeadsetEndpointState pero para un Item ID arbitrario': 'Equivalent to Get-HeadsetEndpointState for an arbitrary Item ID', + '(el del headset que se esta seleccionando en el wizard).': '(the headset currently being selected in the wizard).', + 'Hace polling del estado del endpoint hasta que coincida con el esperado': 'Polls the endpoint state until it matches the expected state', + 'Conecta con G HUB y devuelve el candidato PRO X 2 que coincide con el': 'Connects to G HUB and returns the PRO X 2 candidate that matches the', + 'headset seleccionado en el wizard (o el unico, o $null si no hay).': 'headset selected in the wizard (or the only candidate, or $null if none exists).', + '# varios segundos en reflejar cada transicion. Si Windows recrea el': '# several seconds to expose each transition. If Windows recreates the', + '# endpoint, se re-resuelve por Device Name + Name y se persiste el ID.': '# endpoint, it is re-resolved by Device Name + Name and the new ID is persisted.', + '# Si el BT reaparecio con un Item ID nuevo, persistir el observado': '# If Bluetooth returned with a new Item ID, persist the observed value', + '# (el ultimo FoundId no nulo, preferentemente el del paso 3).': '# (the last non-null FoundId, preferably the value observed in step 3).', + '# Pide al worker que recargue la config en el siguiente ciclo.': '# Ask the worker to reload configuration on the next cycle.', + '# La comunicacion con el proceso principal es por archivos de control:': '# Communication with the main process uses control files:', + '# El propio worker tiene un guard anti-polls-concurrentes: si una lectura': '# The worker itself prevents concurrent polls: if one read', + '# --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) ---': '# --- Worker loop (only when AUTOSWITCH_WORKER=1) ---', + '# El tray pidio recargar la config (reconfiguracion sin reinstalar).': '# The tray requested a configuration reload (reconfigure without reinstalling).', + '# Si la config trae otro modo, el worker lo respeta.': '# If configuration specifies another mode, the worker respects it.', + '# LogitechGHub: conexion PERSISTENTE. Se conecta una vez, se resuelve': '# LogitechGHub: PERSISTENT connection. Connect once and resolve', + '# el batteryPath una vez, y solo se reconecta (re-resolviendo el': '# batteryPath once; reconnect only on failure (re-resolving the', + '# deviceId, que puede cambiar tras una reconexion) si algo falla.': '# deviceId, which may change after reconnection).', + '# en disco, caemos a SystemIcons.Application para que siempre haya uno.': '# on disk, fall back to SystemIcons.Application so an icon is always available.', + '# sin Form no siempre mantiene el NotifyIcon visible activo en todos los': '# without a Form does not reliably keep NotifyIcon active on every', + '# Refresca el texto del menu de enhancements periodicamente (el estado': '# Refresh the Audio Enhancements menu text periodically (the SysFx state', + '# SysFx puede cambiar desde el instalador o un helper externo). Se actualiza': '# can change through the installer or an external helper). Update it', + }, + Path('Toggle-AudioEnhancements.ps1'): { + 'Se lanza desde el runtime (o el instalador) con -Verb RunAs. Escribe': 'Launched by the runtime (or installer) with -Verb RunAs. Writes', + 'SOLO para el DeviceId indicado, verifica el resultado y sale con codigo:': 'ONLY for the specified DeviceId, verifies the result and exits with code:', + '# Toda la logica COM vive en C# (donde el cast a IPolicyConfig es nativo y': '# All COM logic lives in C# (where casting to IPolicyConfig is native and', + '# [ComImport] custom falla ("No se puede convertir..."), por eso se expone': '# a custom [ComImport] cast is unreliable in PowerShell 5.1), so this exposes', + 'return -2; // el cambio no se aplico (valor leido distinto del objetivo)': 'return -2; // change was not applied (read-back value differs from target)', + }, + Path('lib/AutoSwitchCore.psm1'): { + '// bFxStore=true), el MISMO store donde el helper elevado escribe. El': '// bFxStore=true), the SAME store written by the elevated helper. The', + }, + Path('tests/AutoSwitchCore.Tests.ps1'): { + '# No debe incluir el microfono (Capture) ni la app (Application).': '# Must exclude the microphone (Capture) and application rows.', + '# Cabecera con filas pero sin las columnas que el runtime necesita.': '# Header/data rows without the columns required by the runtime.', + }, + } + + encodings = { + Path('Runtime-PROX2-AutoSwitch.ps1'): 'utf-8-sig', + Path('Toggle-AudioEnhancements.ps1'): 'utf-8-sig', + Path('lib/AutoSwitchCore.psm1'): 'utf-8-sig', + Path('tests/AutoSwitchCore.Tests.ps1'): 'utf-8-sig', + } + + changed = [] + for path, mapping in replacements.items(): + encoding = encodings.get(path, 'utf-8') + text = path.read_text(encoding=encoding) + original = text + for old, new in mapping.items(): + count = text.count(old) + if count != 1: + raise SystemExit(f'Expected one occurrence in {path}: {old!r}; found {count}') + text = text.replace(old, new) + if text != original: + path.write_text(text, encoding=encoding) + changed.append(str(path)) + print('Updated:', ', '.join(changed)) + + - name: Commit cleanup + run: | + git config user.name "Ayerdi" + git config user.email "128999164+Ayerdi@users.noreply.github.com" + git add AGENT.md Runtime-PROX2-AutoSwitch.ps1 Toggle-AudioEnhancements.ps1 lib/AutoSwitchCore.psm1 tests/AutoSwitchCore.Tests.ps1 + git diff --cached --quiet && exit 0 + git commit -m "refactor: finish English runtime documentation" + git push origin HEAD:agent/repository-standard-v1.2.5 From aaeb0d7b43f8d93f5a4fc0a85d724157bd3d340f Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:18:27 +0200 Subject: [PATCH 18/27] chore: retry reviewed English cleanup --- .github/workflows/tmp-language-cleanup.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tmp-language-cleanup.yml b/.github/workflows/tmp-language-cleanup.yml index 517dec9..b5fab67 100644 --- a/.github/workflows/tmp-language-cleanup.yml +++ b/.github/workflows/tmp-language-cleanup.yml @@ -24,7 +24,7 @@ jobs: replacements = { Path('AGENT.md'): { - '("No se puede convertir…")': '(the COM interface cast fails at runtime)', + 'No se puede convertir…': 'the COM interface cast fails at runtime', }, Path('Runtime-PROX2-AutoSwitch.ps1'): { '# Un reintento por si Windows estaba recreando el endpoint.': '# Retry once in case Windows was recreating the endpoint.', From 7411a82d10805df6806fcabb3372336403476f95 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:18:42 +0000 Subject: [PATCH 19/27] refactor: finish English runtime documentation --- AGENT.md | 2 +- Runtime-PROX2-AutoSwitch.ps1 | 50 +++++++++++++++++----------------- Toggle-AudioEnhancements.ps1 | 12 ++++---- lib/AutoSwitchCore.psm1 | 2 +- tests/AutoSwitchCore.Tests.ps1 | 4 +-- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/AGENT.md b/AGENT.md index 58d41c7..bdc4daf 100644 --- a/AGENT.md +++ b/AGENT.md @@ -107,7 +107,7 @@ hardened to tolerate a recreated endpoint whose `Item ID` changes. "Disable audio enhancements" switch). 12. **COM interop lives in C#**: PowerShell 5.1 cannot cast a COM RCW to a custom `[ComImport]` interface - (`New-Object`, `Activator` or `GetTypeFromCLSID` all fail with "No se puede convertir…"). The cast is + (`New-Object`, `Activator` or `GetTypeFromCLSID` all fail with "the COM interface cast fails at runtime"). The cast is native in C#, so both the helper and `lib/AutoSwitchCore.psm1` compile the whole COM block with `Add-Type` and expose a static method (`AutoSwitch.AudioEnhancements.SetSysFx`, `AutoSwitch.EndpointFx.ReadSysFx`). Read the SysFx state with `IPolicyConfig::GetPropertyValue` on the diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index ae9b131..6073af4 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -321,7 +321,7 @@ function Set-AudioOutput { return } - # Un reintento por si Windows estaba recreando el endpoint. + # Retry once in case Windows was recreating the endpoint. Start-Sleep -Milliseconds 500 & $SvclPath /SetDefault $DeviceId all | Out-Null Start-Sleep -Milliseconds 350 @@ -369,7 +369,7 @@ function Get-HeadsetEndpointState { Select-Object -First 1 if (-not $row) { - # Export valido pero el endpoint no aparece: para Windows significa + # Valid export but the endpoint is absent: Windows treats this as # que no esta presente -> Disconnected. return 'Disconnected' } @@ -384,7 +384,7 @@ function Get-HeadsetEndpointState { # --- Tray icon + Timer (message pump) + worker process --- # El polling corre en un proceso PowerShell aparte (worker) para no bloquear -# el hilo de WinForms. El Timer del hilo de UI solo marca estado; el worker +# the WinForms thread. The UI-thread timer only marks state; the worker # hace polling con su propio guard anti-concurrencia (no lanza un poll si el # anterior sigue en marcha). @@ -521,8 +521,8 @@ function Save-Config { function Get-HeadsetStateForId { <# .SYNOPSIS - Igual que Get-HeadsetEndpointState pero para un Item ID arbitrario - (el del headset que se esta seleccionando en el wizard). + Equivalent to Get-HeadsetEndpointState for an arbitrary Item ID + (the headset currently being selected in the wizard). .DESCRIPTION Bluetooth headsets can disappear from the svcl export while off and come back with a DIFFERENT Item ID when reconnected. So when the row @@ -600,7 +600,7 @@ function Get-HeadsetStateForId { function Wait-ForHeadsetState { <# .SYNOPSIS - Hace polling del estado del endpoint hasta que coincida con el esperado + Polls the endpoint state until it matches the expected state o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar varios segundos en reflejar el cambio fisico en Windows; una lectura unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer @@ -650,8 +650,8 @@ function Wait-ForHeadsetState { function Test-GHubProX2 { <# .SYNOPSIS - Conecta con G HUB y devuelve el candidato PRO X 2 que coincide con el - headset seleccionado en el wizard (o el unico, o $null si no hay). + Connects to G HUB and returns the PRO X 2 candidate that matches the + headset selected in the wizard (or the only candidate, or $null if none exists). #> try { Connect-GHub @@ -794,8 +794,8 @@ function Show-ReconfigureDialog { # --- Ciclo ON -> OFF -> ON del headset seleccionado --- # El wizard hace polling porque Bluetooth/Core Audio puede tardar - # varios segundos en reflejar cada transicion. Si Windows recrea el - # endpoint, se re-resuelve por Device Name + Name y se persiste el ID. + # several seconds to expose each transition. If Windows recreates the + # endpoint, it is re-resolved by Device Name + Name and the new ID is persisted. $lblStatus.Text = "Step 1/3: turn the headset ON, then click OK in the prompt." $lblStatus.Refresh() @@ -830,8 +830,8 @@ function Show-ReconfigureDialog { $lblStatus.Text = "Step 2/3: ON=$s1 OFF=$s2 ON=$s3" $lblStatus.Refresh() - # Si el BT reaparecio con un Item ID nuevo, persistir el observado - # (el ultimo FoundId no nulo, preferentemente el del paso 3). + # If Bluetooth returned with a new Item ID, persist the observed value + # (the last non-null FoundId, preferably the value observed in step 3). $observedId = $null if ($r3.FoundId) { $observedId = $r3.FoundId } elseif ($r1.FoundId) { $observedId = $r1.FoundId } @@ -898,7 +898,7 @@ function Show-ReconfigureDialog { } Save-Config - # Pide al worker que recargue la config en el siguiente ciclo. + # Ask the worker to reload configuration on the next cycle. try { New-Item -ItemType File -Path $script:ReloadFlag -Force | Out-Null } catch {} Write-AutoSwitchLog ("Reconfigured: headset={0} fallback={1} mode={2}" -f $Config.HeadsetName, $Config.SpeakerName, $newMode) @@ -989,10 +989,10 @@ function Stop-Runtime { # --- Worker: proceso separado que hace el polling --- # El polling (svcl/G HUB, Set-AudioOutput) corre en un proceso PowerShell # aparte (AUTOSWITCH_WORKER=1) para no bloquear el hilo de WinForms (tray). -# La comunicacion con el proceso principal es por archivos de control: +# Communication with the main process uses control files: # $ControlDir\enabled.flag - el tray lo crea/borra (AutoSwitch ON/OFF) # $ControlDir\stop.flag - el tray lo crea al salir -# El propio worker tiene un guard anti-polls-concurrentes: si una lectura +# The worker itself prevents concurrent polls: if one read # (p. ej. un /SetDefault lento o G HUB en timeout) se pasa del intervalo, # no se lanza otro poll hasta que termine. @@ -1045,7 +1045,7 @@ function Stop-Worker { } catch {} } -# --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) --- +# --- Worker loop (only when AUTOSWITCH_WORKER=1) --- # Runs at the end of the script; G HUB/audio/endpoint functions are already # definidas arriba porque es el mismo script. @@ -1063,7 +1063,7 @@ function Start-WorkerLoop { Write-AutoSwitchLog "Worker started (mode $script:DetectionMode)." while (-not (Test-Path $stopFlag)) { - # El tray pidio recargar la config (reconfiguracion sin reinstalar). + # The tray requested a configuration reload (reconfigure without reinstalling). if (Test-Path $reloadFlag) { try { Remove-Item $reloadFlag -Force -ErrorAction SilentlyContinue @@ -1071,7 +1071,7 @@ function Start-WorkerLoop { if ($newConfig) { # Actualiza la variable global del script (no crear local). Set-Variable -Name Config -Value $newConfig -Scope 1 - # Si la config trae otro modo, el worker lo respeta. + # If configuration specifies another mode, the worker respects it. $newMode = Get-ConfigDetectionMode -Config $newConfig if ($newMode) { $script:DetectionMode = $newMode } $lastState = $null @@ -1109,9 +1109,9 @@ function Start-WorkerLoop { } } else { - # LogitechGHub: conexion PERSISTENTE. Se conecta una vez, se resuelve - # el batteryPath una vez, y solo se reconecta (re-resolviendo el - # deviceId, que puede cambiar tras una reconexion) si algo falla. + # LogitechGHub: PERSISTENT connection. Connect once and resolve + # batteryPath once; reconnect only on failure (re-resolving the + # deviceId, which may change after reconnection). try { if (-not $script:WorkerBatteryPath) { Connect-GHub @@ -1181,7 +1181,7 @@ function Initialize-TrayAndTimer { $script:TrayIcon = New-Object System.Windows.Forms.NotifyIcon # App-specific icon. If the .ico file does not exist - # en disco, caemos a SystemIcons.Application para que siempre haya uno. + # on disk, fall back to SystemIcons.Application so an icon is always available. try { $iconFile = Join-Path $InstallDir "icon.ico" if (Test-Path $iconFile) { @@ -1199,7 +1199,7 @@ function Initialize-TrayAndTimer { $script:TrayIcon.Visible = $true # Form invisible que sostiene el message pump de WinForms. Application.Run() - # sin Form no siempre mantiene el NotifyIcon visible activo en todos los + # without a Form does not reliably keep NotifyIcon active on every # .NET builds; an invisible Form provides a robust message pump for the tray. $script:MainForm = New-Object System.Windows.Forms.Form $script:MainForm.WindowState = 'Minimized' @@ -1252,8 +1252,8 @@ function Initialize-TrayAndTimer { $script:TrayIcon.ContextMenuStrip = $menu - # Refresca el texto del menu de enhancements periodicamente (el estado - # SysFx puede cambiar desde el instalador o un helper externo). Se actualiza + # Refresh the Audio Enhancements menu text periodically (the SysFx state + # can change through the installer or an external helper). Update it # en el hilo de UI; Get-EndpointFxState es una lectura COM ligera. $script:MenuTimer = New-Object System.Windows.Forms.Timer $script:MenuTimer.Interval = 5000 diff --git a/Toggle-AudioEnhancements.ps1 b/Toggle-AudioEnhancements.ps1 index 4a9ceee..1f25348 100644 --- a/Toggle-AudioEnhancements.ps1 +++ b/Toggle-AudioEnhancements.ps1 @@ -1,11 +1,11 @@ -#requires -Version 5.1 +#requires -Version 5.1 <# Toggle-AudioEnhancements.ps1 - helper ELEVADO para activar/desactivar los audio enhancements de un endpoint de audio concreto. - Se lanza desde el runtime (o el instalador) con -Verb RunAs. Escribe + Launched by the runtime (or installer) with -Verb RunAs. Writes PKEY_AudioEndpoint_Disable_SysFx (1da5d803-d492-4edd-8c23-e0c0ffee7f0e, 5) - SOLO para el DeviceId indicado, verifica el resultado y sale con codigo: + ONLY for the specified DeviceId, verifies the result and exits with code: 0 = cambio aplicado y verificado 1 = could not apply or verify (UAC cancelled, missing endpoint, ...) #> @@ -35,9 +35,9 @@ function Write-AutoSwitchLog { try { $targetValue = if ($Action -eq 'Disable') { 1 } else { 0 } - # Toda la logica COM vive en C# (donde el cast a IPolicyConfig es nativo y + # All COM logic lives in C# (where casting to IPolicyConfig is native and # fiable). En PowerShell 5.1 el cast de un RCW COM a una interfaz - # [ComImport] custom falla ("No se puede convertir..."), por eso se expone + # a custom [ComImport] cast is unreliable in PowerShell 5.1), so this exposes # un unico metodo estatico que hace Set + verifica internamente. $source = @' using System; @@ -117,7 +117,7 @@ namespace AutoSwitch bool effective = pvCheck.ulVal != 0; if (effective != disable) { - return -2; // el cambio no se aplico (valor leido distinto del objetivo) + return -2; // change was not applied (read-back value differs from target) } return 0; diff --git a/lib/AutoSwitchCore.psm1 b/lib/AutoSwitchCore.psm1 index 75a3573..e9cfb0b 100644 --- a/lib/AutoSwitchCore.psm1 +++ b/lib/AutoSwitchCore.psm1 @@ -517,7 +517,7 @@ namespace AutoSwitch // Devuelve: 1 = SysFx deshabilitado, 0 = SysFx habilitado, // -1 = read failed (missing endpoint / COM failure). // IMPORTANTE: se lee con IPolicyConfig.GetPropertyValue(deviceId, - // bFxStore=true), el MISMO store donde el helper elevado escribe. El + // bFxStore=true), the SAME store written by the elevated helper. The // IPropertyStore del endpoint (OpenPropertyStore) NO contiene // PKEY_AudioEndpoint_Disable_SysFx, por lo que leeria siempre // "habilitados" aunque esten deshabilitados. diff --git a/tests/AutoSwitchCore.Tests.ps1 b/tests/AutoSwitchCore.Tests.ps1 index ce9b28d..358548a 100644 --- a/tests/AutoSwitchCore.Tests.ps1 +++ b/tests/AutoSwitchCore.Tests.ps1 @@ -112,7 +112,7 @@ Describe 'Get-SvclRenderDevice' { It 'filters to Device + Direction=Render only' { $devices = Get-SvclRenderDevice -CsvText $script:FixtureCsv $devices.Count | Should -Be 3 - # No debe incluir el microfono (Capture) ni la app (Application). + # Must exclude the microphone (Capture) and application rows. ($devices | Where-Object { $_.Name -match 'Microphone' }).Count | Should -Be 0 ($devices | Where-Object { $_.Name -match 'Application' }).Count | Should -Be 0 } @@ -151,7 +151,7 @@ Describe 'Test-SvclExportValid' { } It 'rejects an export missing Item ID or Device State' { - # Cabecera con filas pero sin las columnas que el runtime necesita. + # Header/data rows without the columns required by the runtime. Test-SvclExportValid -CsvText "Name,Volume`nSpeakers,50" | Should -Be $false Test-SvclExportValid -CsvText "Name,Item ID`nSpeakers,{0.0.0.00000000}.{GUID}" | Should -Be $false } From 42355bfd924e1c4a8fcc0d51a16e6715821e13bc Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:18:59 +0200 Subject: [PATCH 20/27] ci: allow multilingual yes/no compatibility literals --- scripts/check-language.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/check-language.py b/scripts/check-language.py index 52c40ef..13fc073 100644 --- a/scripts/check-language.py +++ b/scripts/check-language.py @@ -39,11 +39,15 @@ "tambien", "una", "uno", "ya", } -# Localized language labels and literal OS/runtime strings can remain where they -# are needed for navigation or compatibility diagnostics. +# Localized labels and multilingual input literals are compatibility metadata, +# not repository prose. Keep them while requiring surrounding documentation to +# remain English. ALLOWED_FRAGMENTS = ( "Español", ) +ALLOWED_LINE_PATTERNS = ( + re.compile(r"\^\(s\|si\|sí\|y\|yes\)\$", re.IGNORECASE), +) def strip_diacritics(value: str) -> str: @@ -61,6 +65,8 @@ def is_text_file(path: Path) -> bool: def looks_spanish(line: str) -> bool: + if any(pattern.search(line) for pattern in ALLOWED_LINE_PATTERNS): + return False candidate = line for fragment in ALLOWED_FRAGMENTS: candidate = candidate.replace(fragment, "Spanish") From 4d3c48fda522ebd6bffc34cda6f3c3ac55c32b6e Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:19:47 +0200 Subject: [PATCH 21/27] chore: remove temporary language cleanup --- .github/workflows/tmp-language-cleanup.yml | 101 --------------------- 1 file changed, 101 deletions(-) delete mode 100644 .github/workflows/tmp-language-cleanup.yml diff --git a/.github/workflows/tmp-language-cleanup.yml b/.github/workflows/tmp-language-cleanup.yml deleted file mode 100644 index b5fab67..0000000 --- a/.github/workflows/tmp-language-cleanup.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Temporary English cleanup - -on: - push: - branches: - - agent/repository-standard-v1.2.5 - paths: - - .github/workflows/tmp-language-cleanup.yml - -permissions: - contents: write - -jobs: - cleanup: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/repository-standard-v1.2.5 - - name: Apply reviewed translations - shell: python - run: | - from pathlib import Path - - replacements = { - Path('AGENT.md'): { - 'No se puede convertir…': 'the COM interface cast fails at runtime', - }, - Path('Runtime-PROX2-AutoSwitch.ps1'): { - '# Un reintento por si Windows estaba recreando el endpoint.': '# Retry once in case Windows was recreating the endpoint.', - '# Export valido pero el endpoint no aparece: para Windows significa': '# Valid export but the endpoint is absent: Windows treats this as', - '# el hilo de WinForms. El Timer del hilo de UI solo marca estado; el worker': '# the WinForms thread. The UI-thread timer only marks state; the worker', - 'Igual que Get-HeadsetEndpointState pero para un Item ID arbitrario': 'Equivalent to Get-HeadsetEndpointState for an arbitrary Item ID', - '(el del headset que se esta seleccionando en el wizard).': '(the headset currently being selected in the wizard).', - 'Hace polling del estado del endpoint hasta que coincida con el esperado': 'Polls the endpoint state until it matches the expected state', - 'Conecta con G HUB y devuelve el candidato PRO X 2 que coincide con el': 'Connects to G HUB and returns the PRO X 2 candidate that matches the', - 'headset seleccionado en el wizard (o el unico, o $null si no hay).': 'headset selected in the wizard (or the only candidate, or $null if none exists).', - '# varios segundos en reflejar cada transicion. Si Windows recrea el': '# several seconds to expose each transition. If Windows recreates the', - '# endpoint, se re-resuelve por Device Name + Name y se persiste el ID.': '# endpoint, it is re-resolved by Device Name + Name and the new ID is persisted.', - '# Si el BT reaparecio con un Item ID nuevo, persistir el observado': '# If Bluetooth returned with a new Item ID, persist the observed value', - '# (el ultimo FoundId no nulo, preferentemente el del paso 3).': '# (the last non-null FoundId, preferably the value observed in step 3).', - '# Pide al worker que recargue la config en el siguiente ciclo.': '# Ask the worker to reload configuration on the next cycle.', - '# La comunicacion con el proceso principal es por archivos de control:': '# Communication with the main process uses control files:', - '# El propio worker tiene un guard anti-polls-concurrentes: si una lectura': '# The worker itself prevents concurrent polls: if one read', - '# --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) ---': '# --- Worker loop (only when AUTOSWITCH_WORKER=1) ---', - '# El tray pidio recargar la config (reconfiguracion sin reinstalar).': '# The tray requested a configuration reload (reconfigure without reinstalling).', - '# Si la config trae otro modo, el worker lo respeta.': '# If configuration specifies another mode, the worker respects it.', - '# LogitechGHub: conexion PERSISTENTE. Se conecta una vez, se resuelve': '# LogitechGHub: PERSISTENT connection. Connect once and resolve', - '# el batteryPath una vez, y solo se reconecta (re-resolviendo el': '# batteryPath once; reconnect only on failure (re-resolving the', - '# deviceId, que puede cambiar tras una reconexion) si algo falla.': '# deviceId, which may change after reconnection).', - '# en disco, caemos a SystemIcons.Application para que siempre haya uno.': '# on disk, fall back to SystemIcons.Application so an icon is always available.', - '# sin Form no siempre mantiene el NotifyIcon visible activo en todos los': '# without a Form does not reliably keep NotifyIcon active on every', - '# Refresca el texto del menu de enhancements periodicamente (el estado': '# Refresh the Audio Enhancements menu text periodically (the SysFx state', - '# SysFx puede cambiar desde el instalador o un helper externo). Se actualiza': '# can change through the installer or an external helper). Update it', - }, - Path('Toggle-AudioEnhancements.ps1'): { - 'Se lanza desde el runtime (o el instalador) con -Verb RunAs. Escribe': 'Launched by the runtime (or installer) with -Verb RunAs. Writes', - 'SOLO para el DeviceId indicado, verifica el resultado y sale con codigo:': 'ONLY for the specified DeviceId, verifies the result and exits with code:', - '# Toda la logica COM vive en C# (donde el cast a IPolicyConfig es nativo y': '# All COM logic lives in C# (where casting to IPolicyConfig is native and', - '# [ComImport] custom falla ("No se puede convertir..."), por eso se expone': '# a custom [ComImport] cast is unreliable in PowerShell 5.1), so this exposes', - 'return -2; // el cambio no se aplico (valor leido distinto del objetivo)': 'return -2; // change was not applied (read-back value differs from target)', - }, - Path('lib/AutoSwitchCore.psm1'): { - '// bFxStore=true), el MISMO store donde el helper elevado escribe. El': '// bFxStore=true), the SAME store written by the elevated helper. The', - }, - Path('tests/AutoSwitchCore.Tests.ps1'): { - '# No debe incluir el microfono (Capture) ni la app (Application).': '# Must exclude the microphone (Capture) and application rows.', - '# Cabecera con filas pero sin las columnas que el runtime necesita.': '# Header/data rows without the columns required by the runtime.', - }, - } - - encodings = { - Path('Runtime-PROX2-AutoSwitch.ps1'): 'utf-8-sig', - Path('Toggle-AudioEnhancements.ps1'): 'utf-8-sig', - Path('lib/AutoSwitchCore.psm1'): 'utf-8-sig', - Path('tests/AutoSwitchCore.Tests.ps1'): 'utf-8-sig', - } - - changed = [] - for path, mapping in replacements.items(): - encoding = encodings.get(path, 'utf-8') - text = path.read_text(encoding=encoding) - original = text - for old, new in mapping.items(): - count = text.count(old) - if count != 1: - raise SystemExit(f'Expected one occurrence in {path}: {old!r}; found {count}') - text = text.replace(old, new) - if text != original: - path.write_text(text, encoding=encoding) - changed.append(str(path)) - print('Updated:', ', '.join(changed)) - - - name: Commit cleanup - run: | - git config user.name "Ayerdi" - git config user.email "128999164+Ayerdi@users.noreply.github.com" - git add AGENT.md Runtime-PROX2-AutoSwitch.ps1 Toggle-AudioEnhancements.ps1 lib/AutoSwitchCore.psm1 tests/AutoSwitchCore.Tests.ps1 - git diff --cached --quiet && exit 0 - git commit -m "refactor: finish English runtime documentation" - git push origin HEAD:agent/repository-standard-v1.2.5 From 36eaaed8aebb3221f13dc1774a6a2d8332a72188 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:21:16 +0200 Subject: [PATCH 22/27] ci: strengthen language detection for unaccented Spanish --- scripts/check-language.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/check-language.py b/scripts/check-language.py index 13fc073..926f824 100644 --- a/scripts/check-language.py +++ b/scripts/check-language.py @@ -23,20 +23,25 @@ SPANISH_PATTERNS = [ re.compile(r"[áéíóúüñ¿¡]", re.IGNORECASE), re.compile( - r"\b(?:no debe|no se puede|debe ser|se añadió|se agreg[oó]|" + r"\b(?:" + r"no debe|no se puede|no esta|debe ser|se agreg[oó]|se reintentara|" r"auricular(?:es)?|altavoces?|bandeja|instalaci[oó]n|desinstalaci[oó]n|" - r"configuraci[oó]n|verificaci[oó]n|conectado|desconectado|" + r"configuraci[oó]n|reconfiguraci[oó]n|verificaci[oó]n|conectado|desconectado|" r"encendido|apagado|selecciona|seleccionar|salida de audio|" - r"prueba|modo universal|versi[oó]n estable)\b", + r"prueba|modo universal|versi[oó]n estable|exportacion|transicion|" + r"reconexion|icono|notificaciones|menu|hilo|bucle|unico|lectura" + r")\b", re.IGNORECASE, ), ] +# These words are deliberately chosen for low overlap with normal English code. +# Two matches on the same line are enough to flag likely Spanish prose. SPANISH_STOPWORDS = { "aqui", "antes", "bajo", "cada", "cuando", "debe", "deben", "del", - "desde", "despues", "donde", "el", "esta", "este", "esto", "la", "las", - "los", "para", "pero", "por", "porque", "que", "se", "si", "sin", "solo", - "tambien", "una", "uno", "ya", + "desde", "despues", "donde", "esta", "este", "esto", "hasta", "la", "las", + "los", "para", "pero", "por", "porque", "que", "sin", "solo", "tambien", + "una", "uno", "varios", "ya", } # Localized labels and multilingual input literals are compatibility metadata, @@ -73,7 +78,7 @@ def looks_spanish(line: str) -> bool: if any(pattern.search(candidate) for pattern in SPANISH_PATTERNS): return True words = re.findall(r"[a-z]+", strip_diacritics(candidate).casefold()) - return sum(word in SPANISH_STOPWORDS for word in words) >= 3 + return sum(word in SPANISH_STOPWORDS for word in words) >= 2 def main() -> int: From 1cedac4bf719a6e643c447befef815c4365261b9 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:24:21 +0200 Subject: [PATCH 23/27] ci: refine strict language detection --- scripts/check-language.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check-language.py b/scripts/check-language.py index 926f824..9f0ccda 100644 --- a/scripts/check-language.py +++ b/scripts/check-language.py @@ -29,7 +29,11 @@ r"configuraci[oó]n|reconfiguraci[oó]n|verificaci[oó]n|conectado|desconectado|" r"encendido|apagado|selecciona|seleccionar|salida de audio|" r"prueba|modo universal|versi[oó]n estable|exportacion|transicion|" - r"reconexion|icono|notificaciones|menu|hilo|bucle|unico|lectura" + r"reconexion|icono|notificaciones|hilo|bucle|unico|lectura|migracion|" + r"peticion|esperando|cerro|limite global|eventos asincronos|devuelve|" + r"detectado|segundo intento|consiguio|estado del endpoint|exporta|fila|" + r"ausente|desconocido|preseleccionar|cambio fisico|ultimo estado|" + r"encontrado por|nuevo item|reintentar|submen[uú]" r")\b", re.IGNORECASE, ), @@ -47,9 +51,7 @@ # Localized labels and multilingual input literals are compatibility metadata, # not repository prose. Keep them while requiring surrounding documentation to # remain English. -ALLOWED_FRAGMENTS = ( - "Español", -) +ALLOWED_FRAGMENTS = ("Español",) ALLOWED_LINE_PATTERNS = ( re.compile(r"\^\(s\|si\|sí\|y\|yes\)\$", re.IGNORECASE), ) From e7c8cfb5f5c8989e99e13a0a3b3f2caa95bbeafd Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:25:37 +0200 Subject: [PATCH 24/27] chore: run final reviewed language cleanup --- .../workflows/tmp-final-language-cleanup.yml | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/tmp-final-language-cleanup.yml diff --git a/.github/workflows/tmp-final-language-cleanup.yml b/.github/workflows/tmp-final-language-cleanup.yml new file mode 100644 index 0000000..6c03469 --- /dev/null +++ b/.github/workflows/tmp-final-language-cleanup.yml @@ -0,0 +1,109 @@ +name: Temporary final English cleanup + +on: + push: + branches: + - agent/repository-standard-v1.2.5 + paths: + - .github/workflows/tmp-final-language-cleanup.yml + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/repository-standard-v1.2.5 + - name: Apply reviewed translations + shell: python + run: | + from pathlib import Path + + mappings = { + Path('Runtime-PROX2-AutoSwitch.ps1'): [ + ('# Migracion de config v1.1.0 -> v1.2.0: se anade DetectionMode si falta.', '# Config migration v1.1.0 -> v1.2.0: add DetectionMode when missing.'), + ('# El worker (AUTOSWITCH_WORKER=1) es un proceso hijo legitimo del runtime y', '# The worker (AUTOSWITCH_WORKER=1) is a legitimate child process of the runtime and'), + ('# Token de timeout G HUB: definido en lib\\AutoSwitchCore.psm1 (importado arriba).', '# G HUB timeout token: defined in lib\\AutoSwitchCore.psm1 (imported above).'), + ('throw "Timeout al conectar con G HUB ($($script:ConnectTimeoutMs) ms)."', 'throw "Timed out connecting to G HUB ($($script:ConnectTimeoutMs) ms)."'), + ('throw "Timeout al enviar peticion a G HUB ($($script:ReceiveTimeoutMs) ms)."', 'throw "Timed out sending a request to G HUB ($($script:ReceiveTimeoutMs) ms)."'), + ('# Deadline duro de la peticion: ningun fragmento puede cruzar este punto.', '# Hard request deadline: no fragment may cross this point.'), + ('throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms)."', 'throw "G HUB request timed out ($($script:RequestTimeoutMs) ms)."'), + ('# del deadline global de la peticion.', '# the request-wide deadline.'), + ('throw "Timeout esperando respuesta de G HUB ($($fragmentMs) ms)."', 'throw "Timed out waiting for a G HUB response ($($fragmentMs) ms)."'), + ('throw "G HUB cerro el WebSocket."', 'throw "G HUB closed the WebSocket."'), + ('# Limite global por peticion: aunque G HUB intercale eventos,', '# Request-wide deadline: even if G HUB interleaves events,'), + ('throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms): $Path"', 'throw "G HUB request timed out ($($script:RequestTimeoutMs) ms): $Path"'), + ('# G HUB puede intercalar eventos asincronos.', '# G HUB may interleave asynchronous events.'), + ('throw "G HUB no devuelve el PRO X 2 en /devices/list."', 'throw "G HUB did not return the PRO X 2 in /devices/list."'), + ('Write-AutoSwitchLog ("PRO X 2 detectado por G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id)', 'Write-AutoSwitchLog ("PRO X 2 detected by G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id)'), + ('# IMPORTANTE: /GetColumnValue ya escribe el valor en stdout.', '# IMPORTANT: /GetColumnValue already writes the value to stdout.'), + ('Write-AutoSwitchLog "Output changed -> $Label (segundo intento)"', 'Write-AutoSwitchLog "Output changed -> $Label (second attempt)"'), + ('throw "svcl no consiguio establecer \'$Label\'. Esperado=$DeviceId Actual=$actual"', 'throw "svcl could not set \'$Label\'. Expected=$DeviceId Actual=$actual"'), + ('# --- Estado del endpoint de Windows (DetectionMode=WindowsEndpoint) ---', '# --- Windows endpoint state (DetectionMode=WindowsEndpoint) ---'), + ('# Exporta TODOS los items de sonido en CSV. /scomma "" lista todo.', '# Export ALL sound items as CSV. /scomma "" lists everything.'), + ("Devuelve 'Connected' / 'Disconnected' / 'Unknown' del endpoint del headset.", "Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint."), + ('- Export valido + fila con Item ID coincidente -> estado normalizado.', '- Valid export + matching Item ID row -> normalized state.'), + ('- Export valido + fila ausente (endpoint no presente) -> Disconnected.', '- Valid export + missing row (endpoint not present) -> Disconnected.'), + ('# svcl no devolvio un export valido: estado desconocido, NO asumir off.', '# svcl did not return a valid export: state is unknown; do NOT assume off.'), + ('# que no esta presente -> Disconnected.', '# endpoint is not present -> Disconnected.'), + ('# El polling corre en un proceso PowerShell aparte (worker) para no bloquear', '# Polling runs in a separate PowerShell worker process so it does not block'), + ('# hace polling con su propio guard anti-concurrencia (no lanza un poll si el', '# performs polling with its own concurrency guard (it does not start another poll if the'), + ('# anterior sigue en marcha).', '# previous poll is still running).'), + ("# Lee el estado actual de SysFx del headset y devuelve 'Disable' o 'Enable'.", "# Read the headset's current SysFx state and return 'Disable' or 'Enable'."), + ("if ($fx) { return 'Enable' } # ya deshabilitados -> ofrecer habilitar", "if ($fx) { return 'Enable' } # already disabled -> offer enable"), + ("return 'Disable' # habilitados -> ofrecer deshabilitar", "return 'Disable' # enabled -> offer disable"), + ('# --- Reconfigurar headset/fallback sin reinstalar ---', '# --- Reconfigure headset/fallback without reinstalling ---'), + ('# Fallback de identidad: un endpoint Bluetooth puede reaparecer con otro', '# Identity fallback: a Bluetooth endpoint may return with a different'), + ('# Item ID. No usar la etiqueta visible "Device Name — Name" como si fuera', '# Item ID. Do not treat the visible "Device Name — Name" label as'), + ('Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} no encontrado por Item ID; identidad DeviceName=\'{1}\' Name=\'{2}\' -> nuevo Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id)', 'Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} was not found by Item ID; identity DeviceName=\'{1}\' Name=\'{2}\' -> new Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id)'), + ('# Que hay en la exportacion? Render devices con sus estados e IDs.', '# What is in the export? Render devices with their states and IDs.'), + ('Write-AutoSwitchLog ("Get-HeadsetStateForId: fila para {0} sin columna de estado" -f $ItemId)', 'Write-AutoSwitchLog ("Get-HeadsetStateForId: row for {0} has no state column" -f $ItemId)'), + ('o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar', 'or the timeout expires. A Bluetooth headset (for example Jabra) may take'), + ('varios segundos en reflejar el cambio fisico en Windows; una lectura', 'several seconds to expose the physical-state change in Windows; a single read'), + ('unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer', 'at 500/800 ms may be too early. The endpoint may also disappear'), + ('devuelve el Item ID observado.', 'returns the observed Item ID.'), + ('# Timeout: el estado observado no alcanzo el esperado. Diagnostico con contexto.', '# Timeout: the observed state did not reach the expected state. Log context for diagnosis.'), + ('Write-AutoSwitchLog ("Wait-ForHeadsetState: timeout esperando \'{0}\' para {1}. Ultimo estado observado: {2}" -f $Expected, $ItemId, $last)', 'Write-AutoSwitchLog ("Wait-ForHeadsetState: timed out waiting for \'{0}\' on {1}. Last observed state: {2}" -f $Expected, $ItemId, $last)'), + ('# Preseleccionar los valores actuales.', '# Preselect the current values.'), + ('# aparte (AUTOSWITCH_WORKER=1) para no bloquear el hilo de WinForms (tray).', '# (AUTOSWITCH_WORKER=1) so it does not block the WinForms tray thread.'), + ('# no se lanza otro poll hasta que termine.', '# no new poll starts until it completes.'), + ('# acceso a TODAS las funciones (G HUB, audio, endpoint) sin duplicarlas.', '# access to ALL functions (G HUB, audio, endpoint) without duplicating them.'), + ('# Actualiza la variable global del script (no crear local).', '# Update the script-global variable (do not create a local copy).'), + ('Write-AutoSwitchLog ("WindowsEndpoint no disponible: {0}. Se reintentara." -f $_.Exception.Message)', 'Write-AutoSwitchLog ("WindowsEndpoint unavailable: {0}. It will retry." -f $_.Exception.Message)'), + ('# Submenu de reconfiguracion (elegir headset/fallback sin reinstalar).', '# Reconfiguration submenu (choose headset/fallback without reinstalling).'), + ('# en el hilo de UI; Get-EndpointFxState es una lectura COM ligera.', '# on the UI thread; Get-EndpointFxState is a lightweight COM read.'), + ('# notificaciones procesa eventos aqui; se mantiene activa hasta Exit.', '# notification system processes events here and remains active until Exit.'), + ], + Path('Toggle-AudioEnhancements.ps1'): [ + ('# un unico metodo estatico que hace Set + verifica internamente.', '# one static method that performs Set + internal verification.'), + ], + Path('lib/AutoSwitchCore.psm1'): [ + ('// Devuelve: 1 = SysFx deshabilitado, 0 = SysFx habilitado,', '// Returns: 1 = SysFx disabled, 0 = SysFx enabled,'), + ('// PKEY_AudioEndpoint_Disable_SysFx, por lo que leeria siempre', '// PKEY_AudioEndpoint_Disable_SysFx, so reading it there would always'), + ('Resuelve el DetectionMode de una config, con migracion implicita.', 'Resolve DetectionMode from a config, including implicit migration.'), + ('Si $Config ya tiene DetectionMode, lo devuelve validado', 'If $Config already has DetectionMode, return the validated value'), + ("('WindowsEndpoint' o 'LogitechGHub'). Si no existe, devuelve", "('WindowsEndpoint' or 'LogitechGHub'). If absent, return"), + ('Devuelve $null si el valor existente no es valido.', 'Return $null when an existing value is invalid.'), + ], + } + + for path, pairs in mappings.items(): + encoding = 'utf-8-sig' if path.suffix.lower() in {'.ps1', '.psm1'} else 'utf-8' + text = path.read_text(encoding=encoding) + for old, new in pairs: + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one occurrence of {old!r}, found {count}') + text = text.replace(old, new) + path.write_text(text, encoding=encoding) + + - name: Commit cleanup + run: | + git config user.name "Ayerdi" + git config user.email "128999164+Ayerdi@users.noreply.github.com" + git add Runtime-PROX2-AutoSwitch.ps1 Toggle-AudioEnhancements.ps1 lib/AutoSwitchCore.psm1 + git commit -m "refactor: finish English runtime diagnostics" + git push origin HEAD:agent/repository-standard-v1.2.5 From 1e8b3e14036967259be6f16c4fa09ceaafb40b98 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:25:51 +0000 Subject: [PATCH 25/27] refactor: finish English runtime diagnostics --- Runtime-PROX2-AutoSwitch.ps1 | 104 +++++++++++++++++------------------ Toggle-AudioEnhancements.ps1 | 2 +- lib/AutoSwitchCore.psm1 | 12 ++-- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 6073af4..968672f 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -50,7 +50,7 @@ function Write-AutoSwitchLog { Add-Content -Path $LogPath -Value $line -Encoding UTF8 } -# Migracion de config v1.1.0 -> v1.2.0: se anade DetectionMode si falta. +# Config migration v1.1.0 -> v1.2.0: add DetectionMode when missing. if (-not $Config.PSObject.Properties['DetectionMode']) { try { $migrated = [ordered]@{} @@ -67,7 +67,7 @@ if (-not $Config.PSObject.Properties['DetectionMode']) { } # Prevent two runtime instances for the same user. -# El worker (AUTOSWITCH_WORKER=1) es un proceso hijo legitimo del runtime y +# The worker (AUTOSWITCH_WORKER=1) is a legitimate child process of the runtime and # must not compete for the mutex. $createdNew = $false $mutex = $null @@ -110,7 +110,7 @@ function Close-GHubConnection { $script:Ws = $null } -# Token de timeout G HUB: definido en lib\AutoSwitchCore.psm1 (importado arriba). +# G HUB timeout token: defined in lib\AutoSwitchCore.psm1 (imported above). function Connect-GHub { Close-GHubConnection @@ -141,7 +141,7 @@ function Connect-GHub { $script:Ws.ConnectAsync($uri, $timeout.Token).GetAwaiter().GetResult() | Out-Null } catch [System.OperationCanceledException] { - throw "Timeout al conectar con G HUB ($($script:ConnectTimeoutMs) ms)." + throw "Timed out connecting to G HUB ($($script:ConnectTimeoutMs) ms)." } finally { $timeout.Dispose() @@ -169,7 +169,7 @@ function Send-GHubJson { ).GetAwaiter().GetResult() | Out-Null } catch [System.OperationCanceledException] { - throw "Timeout al enviar peticion a G HUB ($($script:ReceiveTimeoutMs) ms)." + throw "Timed out sending a request to G HUB ($($script:ReceiveTimeoutMs) ms)." } finally { $timeout.Dispose() @@ -178,7 +178,7 @@ function Send-GHubJson { function Receive-GHubText { param( - # Deadline duro de la peticion: ningun fragmento puede cruzar este punto. + # Hard request deadline: no fragment may cross this point. [Parameter(Mandatory=$true)][datetime]$Deadline ) @@ -189,10 +189,10 @@ function Receive-GHubText { do { $remainingMs = [int](($Deadline - (Get-Date)).TotalMilliseconds) if ($remainingMs -le 0) { - throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms)." + throw "G HUB request timed out ($($script:RequestTimeoutMs) ms)." } # Each fragment waits at most ReceiveTimeoutMs, never beyond - # del deadline global de la peticion. + # the request-wide deadline. $fragmentMs = [Math]::Min($script:ReceiveTimeoutMs, $remainingMs) $segment = New-Object 'System.ArraySegment[byte]' -ArgumentList (,$buffer) @@ -205,14 +205,14 @@ function Receive-GHubText { ).GetAwaiter().GetResult() } catch [System.OperationCanceledException] { - throw "Timeout esperando respuesta de G HUB ($($fragmentMs) ms)." + throw "Timed out waiting for a G HUB response ($($fragmentMs) ms)." } finally { $timeout.Dispose() } if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { - throw "G HUB cerro el WebSocket." + throw "G HUB closed the WebSocket." } if ($result.Count -gt 0) { @@ -238,13 +238,13 @@ function Invoke-GHubGet { path = $Path } - # Limite global por peticion: aunque G HUB intercale eventos, + # Request-wide deadline: even if G HUB interleaves events, # the requested response must arrive before the deadline. $deadline = (Get-Date).AddMilliseconds($script:RequestTimeoutMs) while ($true) { if ((Get-Date) -gt $deadline) { - throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms): $Path" + throw "G HUB request timed out ($($script:RequestTimeoutMs) ms): $Path" } $raw = Receive-GHubText -Deadline $deadline @@ -256,7 +256,7 @@ function Invoke-GHubGet { continue } - # G HUB puede intercalar eventos asincronos. + # G HUB may interleave asynchronous events. if (($message.msgId -eq $msgId) -or ($message.path -eq $Path)) { return $message } @@ -278,15 +278,15 @@ function Get-ProX2BatteryPath { } if (-not $headset) { - throw "G HUB no devuelve el PRO X 2 en /devices/list." + throw "G HUB did not return the PRO X 2 in /devices/list." } - Write-AutoSwitchLog ("PRO X 2 detectado por G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id) + Write-AutoSwitchLog ("PRO X 2 detected by G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id) return "/battery/$($headset.id)/state" } function Get-DefaultRenderItemId { - # IMPORTANTE: /GetColumnValue ya escribe el valor en stdout. + # IMPORTANT: /GetColumnValue already writes the value to stdout. # Do not add /Stdout here: it adds item information and breaks parsing. $raw = & $SvclPath /GetColumnValue "DefaultRenderDevice" "Item ID" 2>&1 $text = ($raw | Out-String).Trim() @@ -328,17 +328,17 @@ function Set-AudioOutput { $actual = Get-DefaultRenderItemId if ($actual -ieq $DeviceId) { - Write-AutoSwitchLog "Output changed -> $Label (segundo intento)" + Write-AutoSwitchLog "Output changed -> $Label (second attempt)" return } - throw "svcl no consiguio establecer '$Label'. Esperado=$DeviceId Actual=$actual" + throw "svcl could not set '$Label'. Expected=$DeviceId Actual=$actual" } -# --- Estado del endpoint de Windows (DetectionMode=WindowsEndpoint) --- +# --- Windows endpoint state (DetectionMode=WindowsEndpoint) --- function Get-SvclCsvExport { - # Exporta TODOS los items de sonido en CSV. /scomma "" lista todo. + # Export ALL sound items as CSV. /scomma "" lists everything. $raw = & $SvclPath /scomma "" 2>&1 return ($raw | Out-String).Trim() } @@ -346,16 +346,16 @@ function Get-SvclCsvExport { function Get-HeadsetEndpointState { <# .SYNOPSIS - Devuelve 'Connected' / 'Disconnected' / 'Unknown' del endpoint del headset. + Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint. .DESCRIPTION - - Export valido + fila con Item ID coincidente -> estado normalizado. - - Export valido + fila ausente (endpoint no presente) -> Disconnected. + - Valid export + matching Item ID row -> normalized state. + - Valid export + missing row (endpoint not present) -> Disconnected. - Invalid/empty export (svcl failure/garbage) -> Unknown (do nothing). #> $csv = Get-SvclCsvExport if (-not (Test-SvclExportValid -CsvText $csv)) { - # svcl no devolvio un export valido: estado desconocido, NO asumir off. + # svcl did not return a valid export: state is unknown; do NOT assume off. return 'Unknown' } @@ -370,7 +370,7 @@ function Get-HeadsetEndpointState { if (-not $row) { # Valid export but the endpoint is absent: Windows treats this as - # que no esta presente -> Disconnected. + # endpoint is not present -> Disconnected. return 'Disconnected' } @@ -383,10 +383,10 @@ function Get-HeadsetEndpointState { } # --- Tray icon + Timer (message pump) + worker process --- -# El polling corre en un proceso PowerShell aparte (worker) para no bloquear +# Polling runs in a separate PowerShell worker process so it does not block # the WinForms thread. The UI-thread timer only marks state; the worker -# hace polling con su propio guard anti-concurrencia (no lanza un poll si el -# anterior sigue en marcha). +# performs polling with its own concurrency guard (it does not start another poll if the +# previous poll is still running). $script:TrayIcon = $null $script:MenuItemAutoSwitch = $null @@ -398,13 +398,13 @@ $script:MenuItemInfoNext = $null $script:ReloadFlag = $null function Get-EnhancementsAction { - # Lee el estado actual de SysFx del headset y devuelve 'Disable' o 'Enable'. + # Read the headset's current SysFx state and return 'Disable' or 'Enable'. $fx = Get-EndpointFxState -DeviceId ([string]$Config.HeadsetId) if ($null -eq $fx) { return $null } - if ($fx) { return 'Enable' } # ya deshabilitados -> ofrecer habilitar - return 'Disable' # habilitados -> ofrecer deshabilitar + if ($fx) { return 'Enable' } # already disabled -> offer enable + return 'Disable' # enabled -> offer disable } function Update-EnhancementsMenu { @@ -512,7 +512,7 @@ function Update-TrayInfo { } } -# --- Reconfigurar headset/fallback sin reinstalar --- +# --- Reconfigure headset/fallback without reinstalling --- function Save-Config { $Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding UTF8 @@ -550,8 +550,8 @@ function Get-HeadsetStateForId { } | Select-Object -First 1 - # Fallback de identidad: un endpoint Bluetooth puede reaparecer con otro - # Item ID. No usar la etiqueta visible "Device Name — Name" como si fuera + # Identity fallback: a Bluetooth endpoint may return with a different + # Item ID. Do not treat the visible "Device Name — Name" label as # one column: resolving through the two real columns also avoids confusing # two Render endpoints from the same device. if (-not $row -and @@ -561,7 +561,7 @@ function Get-HeadsetStateForId { if ($row) { $id = Get-CsvColumn -Row $row -Names @('Item ID') if ($Diagnose) { - Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} no encontrado por Item ID; identidad DeviceName='{1}' Name='{2}' -> nuevo Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id) + Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} was not found by Item ID; identity DeviceName='{1}' Name='{2}' -> new Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id) } return [pscustomobject]@{ State = (Resolve-EndpointState -State (Get-CsvColumn -Row $row -Names @('Device State', 'State'))) @@ -572,7 +572,7 @@ function Get-HeadsetStateForId { if (-not $row) { if ($Diagnose) { - # Que hay en la exportacion? Render devices con sus estados e IDs. + # What is in the export? Render devices with their states and IDs. $lines = @() foreach ($r in $rows) { $name = Get-CsvColumn -Row $r -Names @('Device Name', 'Name') @@ -589,7 +589,7 @@ function Get-HeadsetStateForId { $state = Get-CsvColumn -Row $row -Names @('Device State', 'State') if ($null -eq $state) { - if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: fila para {0} sin columna de estado" -f $ItemId) } + if ($Diagnose) { Write-AutoSwitchLog ("Get-HeadsetStateForId: row for {0} has no state column" -f $ItemId) } return 'Unknown' } @@ -601,11 +601,11 @@ function Wait-ForHeadsetState { <# .SYNOPSIS Polls the endpoint state until it matches the expected state - o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar - varios segundos en reflejar el cambio fisico en Windows; una lectura - unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer + or the timeout expires. A Bluetooth headset (for example Jabra) may take + several seconds to expose the physical-state change in Windows; a single read + at 500/800 ms may be too early. The endpoint may also disappear and return with another Item ID -> fall back to name-based resolution and - devuelve el Item ID observado. + returns the observed Item ID. #> param( [Parameter(Mandatory = $true)][string]$ItemId, @@ -638,8 +638,8 @@ function Wait-ForHeadsetState { Start-Sleep -Milliseconds $PollIntervalMs } while ((Get-Date) -lt $deadline) - # Timeout: el estado observado no alcanzo el esperado. Diagnostico con contexto. - Write-AutoSwitchLog ("Wait-ForHeadsetState: timeout esperando '{0}' para {1}. Ultimo estado observado: {2}" -f $Expected, $ItemId, $last) + # Timeout: the observed state did not reach the expected state. Log context for diagnosis. + Write-AutoSwitchLog ("Wait-ForHeadsetState: timed out waiting for '{0}' on {1}. Last observed state: {2}" -f $Expected, $ItemId, $last) [void](Get-HeadsetStateForId -ItemId $ItemId -DeviceName $DeviceName -EndpointName $EndpointName -Diagnose) return [pscustomobject]@{ State = $last @@ -736,7 +736,7 @@ function Show-ReconfigureDialog { [void]$comboFallback.Items.Add($label) } - # Preseleccionar los valores actuales. + # Preselect the current values. for ($i = 0; $i -lt $devices.Count; $i++) { $id = Get-CsvColumn -Row $devices[$i] -Names @('Item ID') if ($id -ieq [string]$Config.HeadsetId) { $comboHeadset.SelectedIndex = $i } @@ -988,13 +988,13 @@ function Stop-Runtime { # --- Worker: proceso separado que hace el polling --- # El polling (svcl/G HUB, Set-AudioOutput) corre en un proceso PowerShell -# aparte (AUTOSWITCH_WORKER=1) para no bloquear el hilo de WinForms (tray). +# (AUTOSWITCH_WORKER=1) so it does not block the WinForms tray thread. # Communication with the main process uses control files: # $ControlDir\enabled.flag - el tray lo crea/borra (AutoSwitch ON/OFF) # $ControlDir\stop.flag - el tray lo crea al salir # The worker itself prevents concurrent polls: if one read # (p. ej. un /SetDefault lento o G HUB en timeout) se pasa del intervalo, -# no se lanza otro poll hasta que termine. +# no new poll starts until it completes. $script:ControlDir = Join-Path $InstallDir "control" $script:EnabledFlag = Join-Path $script:ControlDir "enabled.flag" @@ -1004,7 +1004,7 @@ $script:WorkerProcess = $null function Start-Worker { # Lanza el mismo script en modo worker (env var). Asi el worker tiene - # acceso a TODAS las funciones (G HUB, audio, endpoint) sin duplicarlas. + # access to ALL functions (G HUB, audio, endpoint) without duplicating them. try { New-Item -ItemType Directory -Path $script:ControlDir -Force | Out-Null } catch {} # Estado inicial: AutoSwitch ON. @@ -1069,7 +1069,7 @@ function Start-WorkerLoop { Remove-Item $reloadFlag -Force -ErrorAction SilentlyContinue $newConfig = Get-Content -Raw -Path $ConfigPath | ConvertFrom-Json if ($newConfig) { - # Actualiza la variable global del script (no crear local). + # Update the script-global variable (do not create a local copy). Set-Variable -Name Config -Value $newConfig -Scope 1 # If configuration specifies another mode, the worker respects it. $newMode = Get-ConfigDetectionMode -Config $newConfig @@ -1103,7 +1103,7 @@ function Start-WorkerLoop { } catch { if (-not $availabilityLogged) { - Write-AutoSwitchLog ("WindowsEndpoint no disponible: {0}. Se reintentara." -f $_.Exception.Message) + Write-AutoSwitchLog ("WindowsEndpoint unavailable: {0}. It will retry." -f $_.Exception.Message) $availabilityLogged = $true } } @@ -1239,7 +1239,7 @@ function Initialize-TrayAndTimer { $script:MenuItemEnhancements.Add_Click({ Invoke-EnhancementsToggle }) [void]$menu.Items.Add($script:MenuItemEnhancements) - # Submenu de reconfiguracion (elegir headset/fallback sin reinstalar). + # Reconfiguration submenu (choose headset/fallback without reinstalling). $reconfigureItem = New-Object System.Windows.Forms.ToolStripMenuItem $reconfigureItem.Text = "Reconfigure..." $reconfigureItem.Add_Click({ Invoke-Reconfigure }) @@ -1254,7 +1254,7 @@ function Initialize-TrayAndTimer { # Refresh the Audio Enhancements menu text periodically (the SysFx state # can change through the installer or an external helper). Update it - # en el hilo de UI; Get-EndpointFxState es una lectura COM ligera. + # on the UI thread; Get-EndpointFxState is a lightweight COM read. $script:MenuTimer = New-Object System.Windows.Forms.Timer $script:MenuTimer.Interval = 5000 $script:MenuTimer.Add_Tick({ @@ -1289,7 +1289,7 @@ try { } # Main message pump hosted by an invisible Form. The tray - # notificaciones procesa eventos aqui; se mantiene activa hasta Exit. + # notification system processes events here and remains active until Exit. [System.Windows.Forms.Application]::Run($script:MainForm) Write-AutoSwitchLog "Message pump finished (Exit)." } diff --git a/Toggle-AudioEnhancements.ps1 b/Toggle-AudioEnhancements.ps1 index 1f25348..c1c8ebe 100644 --- a/Toggle-AudioEnhancements.ps1 +++ b/Toggle-AudioEnhancements.ps1 @@ -38,7 +38,7 @@ try { # All COM logic lives in C# (where casting to IPolicyConfig is native and # fiable). En PowerShell 5.1 el cast de un RCW COM a una interfaz # a custom [ComImport] cast is unreliable in PowerShell 5.1), so this exposes - # un unico metodo estatico que hace Set + verifica internamente. + # one static method that performs Set + internal verification. $source = @' using System; using System.Runtime.InteropServices; diff --git a/lib/AutoSwitchCore.psm1 b/lib/AutoSwitchCore.psm1 index e9cfb0b..ff7c89b 100644 --- a/lib/AutoSwitchCore.psm1 +++ b/lib/AutoSwitchCore.psm1 @@ -514,12 +514,12 @@ namespace AutoSwitch new Guid("1da5d803-d492-4edd-8c23-e0c0ffee7f0e"); private const uint PID_SYSFX = 5; - // Devuelve: 1 = SysFx deshabilitado, 0 = SysFx habilitado, + // Returns: 1 = SysFx disabled, 0 = SysFx enabled, // -1 = read failed (missing endpoint / COM failure). // IMPORTANTE: se lee con IPolicyConfig.GetPropertyValue(deviceId, // bFxStore=true), the SAME store written by the elevated helper. The // IPropertyStore del endpoint (OpenPropertyStore) NO contiene - // PKEY_AudioEndpoint_Disable_SysFx, por lo que leeria siempre + // PKEY_AudioEndpoint_Disable_SysFx, so reading it there would always // "habilitados" aunque esten deshabilitados. public static int ReadSysFx(string deviceId) { @@ -558,12 +558,12 @@ namespace AutoSwitch function Get-ConfigDetectionMode { <# .SYNOPSIS - Resuelve el DetectionMode de una config, con migracion implicita. + Resolve DetectionMode from a config, including implicit migration. .DESCRIPTION - Si $Config ya tiene DetectionMode, lo devuelve validado - ('WindowsEndpoint' o 'LogitechGHub'). Si no existe, devuelve + If $Config already has DetectionMode, return the validated value + ('WindowsEndpoint' or 'LogitechGHub'). If absent, return 'LogitechGHub' (comportamiento de configs v1.1.0 y anteriores). - Devuelve $null si el valor existente no es valido. + Return $null when an existing value is invalid. #> [CmdletBinding()] param( From 734e05eb9b2fd1dcf52147f887b2242423f59595 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:26:17 +0200 Subject: [PATCH 26/27] chore: remove temporary final language cleanup --- .../workflows/tmp-final-language-cleanup.yml | 109 ------------------ 1 file changed, 109 deletions(-) delete mode 100644 .github/workflows/tmp-final-language-cleanup.yml diff --git a/.github/workflows/tmp-final-language-cleanup.yml b/.github/workflows/tmp-final-language-cleanup.yml deleted file mode 100644 index 6c03469..0000000 --- a/.github/workflows/tmp-final-language-cleanup.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Temporary final English cleanup - -on: - push: - branches: - - agent/repository-standard-v1.2.5 - paths: - - .github/workflows/tmp-final-language-cleanup.yml - -permissions: - contents: write - -jobs: - cleanup: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/repository-standard-v1.2.5 - - name: Apply reviewed translations - shell: python - run: | - from pathlib import Path - - mappings = { - Path('Runtime-PROX2-AutoSwitch.ps1'): [ - ('# Migracion de config v1.1.0 -> v1.2.0: se anade DetectionMode si falta.', '# Config migration v1.1.0 -> v1.2.0: add DetectionMode when missing.'), - ('# El worker (AUTOSWITCH_WORKER=1) es un proceso hijo legitimo del runtime y', '# The worker (AUTOSWITCH_WORKER=1) is a legitimate child process of the runtime and'), - ('# Token de timeout G HUB: definido en lib\\AutoSwitchCore.psm1 (importado arriba).', '# G HUB timeout token: defined in lib\\AutoSwitchCore.psm1 (imported above).'), - ('throw "Timeout al conectar con G HUB ($($script:ConnectTimeoutMs) ms)."', 'throw "Timed out connecting to G HUB ($($script:ConnectTimeoutMs) ms)."'), - ('throw "Timeout al enviar peticion a G HUB ($($script:ReceiveTimeoutMs) ms)."', 'throw "Timed out sending a request to G HUB ($($script:ReceiveTimeoutMs) ms)."'), - ('# Deadline duro de la peticion: ningun fragmento puede cruzar este punto.', '# Hard request deadline: no fragment may cross this point.'), - ('throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms)."', 'throw "G HUB request timed out ($($script:RequestTimeoutMs) ms)."'), - ('# del deadline global de la peticion.', '# the request-wide deadline.'), - ('throw "Timeout esperando respuesta de G HUB ($($fragmentMs) ms)."', 'throw "Timed out waiting for a G HUB response ($($fragmentMs) ms)."'), - ('throw "G HUB cerro el WebSocket."', 'throw "G HUB closed the WebSocket."'), - ('# Limite global por peticion: aunque G HUB intercale eventos,', '# Request-wide deadline: even if G HUB interleaves events,'), - ('throw "Timeout de peticion G HUB ($($script:RequestTimeoutMs) ms): $Path"', 'throw "G HUB request timed out ($($script:RequestTimeoutMs) ms): $Path"'), - ('# G HUB puede intercalar eventos asincronos.', '# G HUB may interleave asynchronous events.'), - ('throw "G HUB no devuelve el PRO X 2 en /devices/list."', 'throw "G HUB did not return the PRO X 2 in /devices/list."'), - ('Write-AutoSwitchLog ("PRO X 2 detectado por G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id)', 'Write-AutoSwitchLog ("PRO X 2 detected by G HUB: {0} ({1})" -f $headset.extendedDisplayName, $headset.id)'), - ('# IMPORTANTE: /GetColumnValue ya escribe el valor en stdout.', '# IMPORTANT: /GetColumnValue already writes the value to stdout.'), - ('Write-AutoSwitchLog "Output changed -> $Label (segundo intento)"', 'Write-AutoSwitchLog "Output changed -> $Label (second attempt)"'), - ('throw "svcl no consiguio establecer \'$Label\'. Esperado=$DeviceId Actual=$actual"', 'throw "svcl could not set \'$Label\'. Expected=$DeviceId Actual=$actual"'), - ('# --- Estado del endpoint de Windows (DetectionMode=WindowsEndpoint) ---', '# --- Windows endpoint state (DetectionMode=WindowsEndpoint) ---'), - ('# Exporta TODOS los items de sonido en CSV. /scomma "" lista todo.', '# Export ALL sound items as CSV. /scomma "" lists everything.'), - ("Devuelve 'Connected' / 'Disconnected' / 'Unknown' del endpoint del headset.", "Returns 'Connected' / 'Disconnected' / 'Unknown' for the headset endpoint."), - ('- Export valido + fila con Item ID coincidente -> estado normalizado.', '- Valid export + matching Item ID row -> normalized state.'), - ('- Export valido + fila ausente (endpoint no presente) -> Disconnected.', '- Valid export + missing row (endpoint not present) -> Disconnected.'), - ('# svcl no devolvio un export valido: estado desconocido, NO asumir off.', '# svcl did not return a valid export: state is unknown; do NOT assume off.'), - ('# que no esta presente -> Disconnected.', '# endpoint is not present -> Disconnected.'), - ('# El polling corre en un proceso PowerShell aparte (worker) para no bloquear', '# Polling runs in a separate PowerShell worker process so it does not block'), - ('# hace polling con su propio guard anti-concurrencia (no lanza un poll si el', '# performs polling with its own concurrency guard (it does not start another poll if the'), - ('# anterior sigue en marcha).', '# previous poll is still running).'), - ("# Lee el estado actual de SysFx del headset y devuelve 'Disable' o 'Enable'.", "# Read the headset's current SysFx state and return 'Disable' or 'Enable'."), - ("if ($fx) { return 'Enable' } # ya deshabilitados -> ofrecer habilitar", "if ($fx) { return 'Enable' } # already disabled -> offer enable"), - ("return 'Disable' # habilitados -> ofrecer deshabilitar", "return 'Disable' # enabled -> offer disable"), - ('# --- Reconfigurar headset/fallback sin reinstalar ---', '# --- Reconfigure headset/fallback without reinstalling ---'), - ('# Fallback de identidad: un endpoint Bluetooth puede reaparecer con otro', '# Identity fallback: a Bluetooth endpoint may return with a different'), - ('# Item ID. No usar la etiqueta visible "Device Name — Name" como si fuera', '# Item ID. Do not treat the visible "Device Name — Name" label as'), - ('Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} no encontrado por Item ID; identidad DeviceName=\'{1}\' Name=\'{2}\' -> nuevo Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id)', 'Write-AutoSwitchLog ("Get-HeadsetStateForId: {0} was not found by Item ID; identity DeviceName=\'{1}\' Name=\'{2}\' -> new Item ID {3}" -f $ItemId, $DeviceName, $EndpointName, $id)'), - ('# Que hay en la exportacion? Render devices con sus estados e IDs.', '# What is in the export? Render devices with their states and IDs.'), - ('Write-AutoSwitchLog ("Get-HeadsetStateForId: fila para {0} sin columna de estado" -f $ItemId)', 'Write-AutoSwitchLog ("Get-HeadsetStateForId: row for {0} has no state column" -f $ItemId)'), - ('o expire el timeout. Un headset Bluetooth (p. ej. Jabra) puede tardar', 'or the timeout expires. A Bluetooth headset (for example Jabra) may take'), - ('varios segundos en reflejar el cambio fisico en Windows; una lectura', 'several seconds to expose the physical-state change in Windows; a single read'), - ('unica a los 500/800 ms lee demasiado pronto. Tambien puede desaparecer', 'at 500/800 ms may be too early. The endpoint may also disappear'), - ('devuelve el Item ID observado.', 'returns the observed Item ID.'), - ('# Timeout: el estado observado no alcanzo el esperado. Diagnostico con contexto.', '# Timeout: the observed state did not reach the expected state. Log context for diagnosis.'), - ('Write-AutoSwitchLog ("Wait-ForHeadsetState: timeout esperando \'{0}\' para {1}. Ultimo estado observado: {2}" -f $Expected, $ItemId, $last)', 'Write-AutoSwitchLog ("Wait-ForHeadsetState: timed out waiting for \'{0}\' on {1}. Last observed state: {2}" -f $Expected, $ItemId, $last)'), - ('# Preseleccionar los valores actuales.', '# Preselect the current values.'), - ('# aparte (AUTOSWITCH_WORKER=1) para no bloquear el hilo de WinForms (tray).', '# (AUTOSWITCH_WORKER=1) so it does not block the WinForms tray thread.'), - ('# no se lanza otro poll hasta que termine.', '# no new poll starts until it completes.'), - ('# acceso a TODAS las funciones (G HUB, audio, endpoint) sin duplicarlas.', '# access to ALL functions (G HUB, audio, endpoint) without duplicating them.'), - ('# Actualiza la variable global del script (no crear local).', '# Update the script-global variable (do not create a local copy).'), - ('Write-AutoSwitchLog ("WindowsEndpoint no disponible: {0}. Se reintentara." -f $_.Exception.Message)', 'Write-AutoSwitchLog ("WindowsEndpoint unavailable: {0}. It will retry." -f $_.Exception.Message)'), - ('# Submenu de reconfiguracion (elegir headset/fallback sin reinstalar).', '# Reconfiguration submenu (choose headset/fallback without reinstalling).'), - ('# en el hilo de UI; Get-EndpointFxState es una lectura COM ligera.', '# on the UI thread; Get-EndpointFxState is a lightweight COM read.'), - ('# notificaciones procesa eventos aqui; se mantiene activa hasta Exit.', '# notification system processes events here and remains active until Exit.'), - ], - Path('Toggle-AudioEnhancements.ps1'): [ - ('# un unico metodo estatico que hace Set + verifica internamente.', '# one static method that performs Set + internal verification.'), - ], - Path('lib/AutoSwitchCore.psm1'): [ - ('// Devuelve: 1 = SysFx deshabilitado, 0 = SysFx habilitado,', '// Returns: 1 = SysFx disabled, 0 = SysFx enabled,'), - ('// PKEY_AudioEndpoint_Disable_SysFx, por lo que leeria siempre', '// PKEY_AudioEndpoint_Disable_SysFx, so reading it there would always'), - ('Resuelve el DetectionMode de una config, con migracion implicita.', 'Resolve DetectionMode from a config, including implicit migration.'), - ('Si $Config ya tiene DetectionMode, lo devuelve validado', 'If $Config already has DetectionMode, return the validated value'), - ("('WindowsEndpoint' o 'LogitechGHub'). Si no existe, devuelve", "('WindowsEndpoint' or 'LogitechGHub'). If absent, return"), - ('Devuelve $null si el valor existente no es valido.', 'Return $null when an existing value is invalid.'), - ], - } - - for path, pairs in mappings.items(): - encoding = 'utf-8-sig' if path.suffix.lower() in {'.ps1', '.psm1'} else 'utf-8' - text = path.read_text(encoding=encoding) - for old, new in pairs: - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one occurrence of {old!r}, found {count}') - text = text.replace(old, new) - path.write_text(text, encoding=encoding) - - - name: Commit cleanup - run: | - git config user.name "Ayerdi" - git config user.email "128999164+Ayerdi@users.noreply.github.com" - git add Runtime-PROX2-AutoSwitch.ps1 Toggle-AudioEnhancements.ps1 lib/AutoSwitchCore.psm1 - git commit -m "refactor: finish English runtime diagnostics" - git push origin HEAD:agent/repository-standard-v1.2.5 From 13ec1e9629c569b6ad47a669cce275757c2e2072 Mon Sep 17 00:00:00 2001 From: Ayerdi <128999164+Ayerdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:56:02 +0200 Subject: [PATCH 27/27] fix: avoid language-guard false positive on tray comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Reconfiguration submenu" matched the Spanish "configuración" pattern. Reworded the comment to plain English. Co-authored-by: CommandCodeBot --- Runtime-PROX2-AutoSwitch.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 968672f..707b2a9 100644 --- a/Runtime-PROX2-AutoSwitch.ps1 +++ b/Runtime-PROX2-AutoSwitch.ps1 @@ -1239,7 +1239,7 @@ function Initialize-TrayAndTimer { $script:MenuItemEnhancements.Add_Click({ Invoke-EnhancementsToggle }) [void]$menu.Items.Add($script:MenuItemEnhancements) - # Reconfiguration submenu (choose headset/fallback without reinstalling). + # Tray: change headset/fallback without reinstalling. $reconfigureItem = New-Object System.Windows.Forms.ToolStripMenuItem $reconfigureItem.Text = "Reconfigure..." $reconfigureItem.Add_Click({ Invoke-Reconfigure })