vscode-uri-router.ps1

<#
.SYNOPSIS
    Route une URI vscode:// vers l'instance VS Code qui l'attend.

.DESCRIPTION
    Windows enregistre un seul gestionnaire pour le protocole vscode://, et
    celui livré par VS Code ne porte pas de --user-data-dir. Chaque instance ne
    dialoguant qu'avec celles qui partagent son user-data-dir, un callback
    d'authentification GitHub démarrait donc un VS Code sur le profil par
    défaut : une fenêtre inutile s'ouvrait et la fenêtre qui attendait son jeton
    ne le recevait jamais.

    Ce script s'intercale devant Code.exe et choisit la cible :

      0 instance isolée -> profil par défaut (comportement d'origine)
      1 instance isolée -> celle-là, sans autre vérification
      2 et plus -> la fenêtre VS Code la plus proche du premier plan

    Dans le dernier cas, l'ordre Z des fenêtres fait foi : au moment du callback
    c'est le navigateur qui a le focus, donc la première fenêtre VS Code
    rencontrée est celle qui était active juste avant — celle qui a lancé la
    connexion.

    Toute erreur retombe sur le comportement d'origine : un lien vscode://
    légitime ne doit jamais casser à cause de ce script.

.PARAMETER Uri
    L'URI transmise par Windows (le "%1" du registre).

.PARAMETER DryRun
    Affiche la cible retenue sans rien lancer. Pour tester le routage.

.EXAMPLE
    .\vscode-uri-router.ps1 -Uri 'vscode://file/F:/PROJECTS/Apps/demo-app/README.md' -DryRun

.NOTES
    Créé le 9 août 2026. Installé dans
    HKCU\Software\Classes\vscode\shell\open\command.
    Sauvegarde de la valeur d'origine :
    F:\Backups\vscode-uri-handler-2026-08-09\vscode-protocol-AVANT.reg
#>

[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
    Justification = 'Uri et DryRun sont lus par Get-UriSafe et Send-Uri, qui les voient par heritage de portee. La regle n inspecte que la portee du parametre.')]
param(
    [Parameter(Mandatory, Position = 0)][string]$Uri,
    [switch]$DryRun
)

# Script autonome : T est interne au module, il faut sourcer la langue soi-meme.
. (Join-Path $PSScriptRoot 'src' 'Langue.ps1')
Set-CtxLangue | Out-Null

# JAMAIS de chemin en dur : ce fichier portait un chemin de profil, donc il ne
# fonctionnait que sur une machine et publiait un nom d'utilisateur Windows.
$CodeExe = @(
    (Join-Path $env:LOCALAPPDATA 'Programs\Microsoft VS Code\Code.exe')
    (Join-Path $env:ProgramFiles 'Microsoft VS Code\Code.exe')
    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft VS Code\Code.exe')
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1
$LogFile = Join-Path $env:LOCALAPPDATA 'DevContext\vscode-uri-router.log'

# L'URI de callback OAuth porte le code d'autorisation dans sa query. On ne
# journalise donc QUE le schéma et le chemin — jamais les paramètres.
function Get-UriSafe {
    if ($Uri -match '^([^?#]*)') { return $Matches[1] }
    return '(illisible)'
}

# Le JOURNAL n'est PAS traduit, et c'est deliberé : une trace qui change de
# langue selon le poste n'est plus recherchable. Deux utilisateurs signalant le
# meme incident produiraient deux textes differents, et aucune recherche ne les
# rapprocherait. Seule la ligne AFFICHEE par -DryRun passe par T.
function Write-RouteurLog {
    param([string]$Message)
    try {
        $d = Split-Path $LogFile -Parent
        if (-not (Test-Path -LiteralPath $d)) {
            New-Item -ItemType Directory -Force -Path $d | Out-Null
        }
        if ((Test-Path -LiteralPath $LogFile) -and
            (Get-Item -LiteralPath $LogFile).Length -gt 100KB) {
            Set-Content -LiteralPath $LogFile -Value '' -Encoding UTF8
        }
        Add-Content -LiteralPath $LogFile -Encoding UTF8 `
            -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
    }
    catch {
        # Circulaire : on ne peut pas journaliser l'echec du journal. Et un
        # journal qui echoue ne doit jamais empecher une fenetre de s'ouvrir.
        $null = $_
    }
}

function Send-Uri {
    param([string]$UserDataDir)

    $cible = if ($UserDataDir) { $UserDataDir } else { T 'uri.profilDefaut' }
    if ($DryRun) {
        Write-Host " $(T 'uri.cible' $cible)" -ForegroundColor Cyan
        return
    }

    $a = @()
    if ($UserDataDir) { $a += @('--user-data-dir', $UserDataDir) }
    $a += @('--open-url', '--', $Uri)
    Start-Process -FilePath $CodeExe -ArgumentList $a -WindowStyle Hidden
}

try {
    if (-not (Test-Path -LiteralPath $CodeExe)) {
        throw (T 'uri.codeAbsent' $CodeExe)
    }

    # Instances racine uniquement. Les processus enfants d'Electron (renderer,
    # gpu, utility) portent une ligne de commande sans --user-data-dir : les
    # compter fausserait le décompte.
    $all   = @(Get-CimInstance Win32_Process -Filter "Name='Code.exe'")
    $ids   = $all.ProcessId
    $roots = @($all | Where-Object { $_.ParentProcessId -notin $ids })

    $uddByPid = @{}
    foreach ($p in $roots) {
        if ($p.CommandLine -match '--user-data-dir[=\s]+"?([A-Za-z]:[^"]*?)"?(\s+--|\s*$)') {
            $uddByPid[[uint32]$p.ProcessId] = $Matches[1].TrimEnd('\')
        }
    }

    if ($uddByPid.Count -eq 0) {
        Write-RouteurLog ('aucune instance isolee -> profil par defaut | {0}' -f (Get-UriSafe))
        Send-Uri $null
        return
    }

    $distinct = @($uddByPid.Values | Sort-Object -Unique)
    if ($distinct.Count -eq 1) {
        Write-RouteurLog ('1 instance isolee -> {0} | {1}' -f $distinct[0], (Get-UriSafe))
        Send-Uri $distinct[0]
        return
    }

    # Plusieurs contextes ouverts : départager par l'ordre Z. EnumWindows
    # parcourt les fenêtres du premier plan vers l'arrière.
    if (-not ('WinZ' -as [type])) {
        Add-Type -ErrorAction Stop -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class WinZ {
    private delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
    [DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
    [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd);
    [DllImport("user32.dll")] private static extern int GetWindowTextLength(IntPtr hWnd);
    [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
    public static uint[] PidsByZOrder() {
        List<uint> list = new List<uint>();
        EnumWindows(delegate(IntPtr h, IntPtr l) {
            if (IsWindowVisible(h) && GetWindowTextLength(h) > 0) {
                uint pid;
                GetWindowThreadProcessId(h, out pid);
                list.Add(pid);
            }
            return true;
        }, IntPtr.Zero);
        return list.ToArray();
    }
}
'@

    }

    $target = $null
    foreach ($procId in [WinZ]::PidsByZOrder()) {
        if ($uddByPid.ContainsKey($procId)) { $target = $uddByPid[$procId]; break }
    }

    if ($target) {
        Write-RouteurLog ('{0} instances -> ordre Z -> {1} | {2}' -f $distinct.Count, $target, (Get-UriSafe))
    }
    else {
        # Aucune fenêtre visible reconnue (toutes minimisées ?). Faute de mieux,
        # l'instance démarrée en dernier est le pari le moins mauvais.
        $dernier = $roots |
            Where-Object { $uddByPid.ContainsKey([uint32]$_.ProcessId) } |
            Sort-Object CreationDate -Descending | Select-Object -First 1
        $target = $uddByPid[[uint32]$dernier.ProcessId]
        Write-RouteurLog ('{0} instances, aucune fenetre visible -> derniere lancee -> {1} | {2}' -f $distinct.Count, $target, (Get-UriSafe))
    }

    Send-Uri $target
}
catch {
    Write-RouteurLog ('ERREUR: {0} -> repli profil par defaut | {1}' -f $_.Exception.Message, (Get-UriSafe))
    # Dernier recours : on est deja dans le rattrapage. Lever ici priverait
    # l'utilisateur de sa fenetre pour lui offrir une trace que personne ne lit.
    try { Send-Uri $null }
    catch { Write-RouteurLog ("repli par defaut en echec : " + $_.Exception.Message) }
}