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..2ed86f3 --- /dev/null +++ b/.github/workflows/release-v1.2.5.yml @@ -0,0 +1,68 @@ +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 + 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: 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 + 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 "${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 repository standard' \ + --notes-file docs/RELEASE-NOTES-v1.2.5.md 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..724a923 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,80 +2,76 @@ name: validate on: push: - branches: [main] + branches: + - main pull_request: +permissions: + contents: read + +concurrency: + group: validate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - validate-scripts: - runs-on: windows-latest + quality: + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - 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: + 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 - shell: pwsh + $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: powershell 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 - shell: pwsh + 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: powershell 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." + 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: Pester + shell: powershell + run: Import-Module Pester -RequiredVersion 5.9.0; Invoke-Pester -Path .\tests -CI - - name: Run Pester tests - 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." + secrets: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - run: bash scripts/run-gitleaks.sh 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/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. 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/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). diff --git a/Runtime-PROX2-AutoSwitch.ps1 b/Runtime-PROX2-AutoSwitch.ps1 index 2dce5bf..707b2a9 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 } @@ -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]@{} @@ -66,9 +66,9 @@ if (-not $Config.PSObject.Properties['DetectionMode']) { catch { } } -# Impide dos instancias del runtime para el mismo usuario. -# El worker (AUTOSWITCH_WORKER=1) es un proceso hijo legitimo del runtime y -# no debe competir por el mutex. +# Prevent two runtime instances for the same user. +# The worker (AUTOSWITCH_WORKER=1) is a legitimate child process of the runtime and +# 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 @@ -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,14 +141,14 @@ 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() } 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." } } @@ -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)." } - # El fragmento espera como mucho ReceiveTimeoutMs, nunca mas alla - # del deadline global de la peticion. + # Each fragment waits at most ReceiveTimeoutMs, never beyond + # 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, - # la respuesta buscada debe llegar antes del deadline. + # 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,22 +278,22 @@ 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. - # No agregar /Stdout aqui: /Stdout añade informacion del item y rompe el parseo. + # 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() $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 @@ -321,24 +321,24 @@ 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 $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. - - Export invalido/vacio (svcl fallo, basura) -> Unknown (no tocar nada). + - 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' } @@ -369,8 +369,8 @@ function Get-HeadsetEndpointState { Select-Object -First 1 if (-not $row) { - # Export valido pero el endpoint no aparece: para Windows significa - # que no esta presente -> Disconnected. + # Valid export but the endpoint is absent: Windows treats this as + # 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 -# el hilo de WinForms. El Timer del hilo de UI solo marca estado; el worker -# hace polling con su propio guard anti-concurrencia (no lanza un poll si el -# anterior sigue en marcha). +# 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 +# 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 { @@ -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 @@ -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 @@ -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 @@ -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' } @@ -550,10 +550,10 @@ 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 - # una columna: resolver por las dos columnas reales evita además confundir - # dos endpoints Render del mismo dispositivo. + # 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 (-not [string]::IsNullOrWhiteSpace($DeviceName) -or -not [string]::IsNullOrWhiteSpace($EndpointName))) { @@ -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') @@ -582,14 +582,14 @@ 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' } $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' } @@ -600,12 +600,12 @@ function Get-HeadsetStateForId { function Wait-ForHeadsetState { <# .SYNOPSIS - Hace polling del estado del endpoint hasta que coincida con el esperado - 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 - devuelve el Item ID observado. + Polls the endpoint state until it matches the expected state + 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 + 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 @@ -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 @@ -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 } @@ -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 } @@ -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 @@ -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) @@ -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 { @@ -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). -# La comunicacion con el proceso principal es por archivos de control: +# (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 -# 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. +# 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. @@ -1045,8 +1045,8 @@ function Stop-Worker { } catch {} } -# --- Bucle del worker (solo cuando AUTOSWITCH_WORKER=1) --- -# Se ejecuta al final del script; las funciones G HUB/audio/endpoint ya estan +# --- 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. function Start-WorkerLoop { @@ -1063,15 +1063,15 @@ 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 $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 - # 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 @@ -1103,15 +1103,15 @@ 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 } } } 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 @@ -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,8 +1180,8 @@ 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 - # en disco, caemos a SystemIcons.Application para que siempre haya uno. + # App-specific icon. If the .ico file does not exist + # 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,8 +1199,8 @@ 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 - # builds de .NET; con un Form oculto el pump es robusto y la bandeja no se cae. + # 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' $script:MainForm.ShowInTaskbar = $false @@ -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). + # Tray: change headset/fallback without reinstalling. $reconfigureItem = New-Object System.Windows.Forms.ToolStripMenuItem $reconfigureItem.Text = "Reconfigure..." $reconfigureItem.Add_Click({ Invoke-Reconfigure }) @@ -1252,9 +1252,9 @@ 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 - # en el hilo de UI; Get-EndpointFxState es una lectura COM ligera. + # Refresh the Audio Enhancements menu text periodically (the SysFx state + # can change through the installer or an external helper). Update it + # 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({ @@ -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,8 +1288,8 @@ try { } } - # Message pump principal sostenido por un Form invisible. La bandeja de - # notificaciones procesa eventos aqui; se mantiene activa hasta Exit. + # Main message pump hosted by an invisible Form. The tray + # 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/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..c1c8ebe 100644 --- a/Toggle-AudioEnhancements.ps1 +++ b/Toggle-AudioEnhancements.ps1 @@ -1,13 +1,13 @@ -#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 = no se pudo aplicar o verificar (UAC cancelado, endpoint inexistente, ...) + 1 = could not apply or verify (UAC cancelled, missing endpoint, ...) #> [CmdletBinding()] param( @@ -35,10 +35,10 @@ 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 - # un unico metodo estatico que hace Set + verifica internamente. + # a custom [ComImport] cast is unreliable in PowerShell 5.1), so this exposes + # one static method that performs Set + internal verification. $source = @' using System; using System.Runtime.InteropServices; @@ -111,13 +111,13 @@ 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; 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/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..3e01b41 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,33 @@ +# Documentation index + +## Start here + +- [README](../README.md) — product overview, installation and usage. +- [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 + +- [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) +- [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. 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. 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..ff7c89b 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])) { @@ -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, - // -1 = no se pudo leer (endpoint inexistente / COM fallo). + // Returns: 1 = SysFx disabled, 0 = SysFx enabled, + // -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 + // PKEY_AudioEndpoint_Disable_SysFx, so reading it there would always // "habilitados" aunque esten deshabilitados. public static int ReadSysFx(string deviceId) { @@ -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; @@ -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( 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/check-language.py b/scripts/check-language.py new file mode 100644 index 0000000..9f0ccda --- /dev/null +++ b/scripts/check-language.py @@ -0,0 +1,111 @@ +"""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(?:" + 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|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|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, + ), +] + +# 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", "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, +# 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: + 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: + 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") + 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) >= 2 + + +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()) diff --git a/scripts/check-repository.py b/scripts/check-repository.py new file mode 100644 index 0000000..b2fbeee --- /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 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: + 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()) 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' 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}" 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}" 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/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 →

+
+
- - diff --git a/tests/AutoSwitchCore.Tests.ps1 b/tests/AutoSwitchCore.Tests.ps1 index 31d6d65..358548a 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' @@ -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 } @@ -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..472934c --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,29 @@ +# Audio AutoSwitch + +**Stable release: v1.2.5 · Windows 10/11 x64** + +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 + +- [[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..3356c31 --- /dev/null +++ b/wiki/Inicio.md @@ -0,0 +1,22 @@ +# Audio AutoSwitch + +**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. + +## 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]]