# Planification - native messaging host // 2026.1.4 - QRO # Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud. # Licence MIT - voir le fichier LICENSE. # # Pont entre l'extension et un dossier reseau partage pour le cache du planning. # # Layout: \AAAA\MM\AAAA-MM-JJ.json (une image par jour, par annee/mois). # Index racine \_index.json : date -> savedAt (stat/list rapides). # Anciens fichiers plats (\AAAA-MM-JJ.json) restent lus (compat). # # Protocole: stdio = prefixe longueur 4 octets (little-endian) + corps JSON UTF-8. # Boucle jusqu'a EOF (marche pour sendNativeMessage et connectNative). # # Actions: # {action:"version"} -> {ok,version} # {action:"stat", path, date} -> {ok,exists,savedAt} # {action:"read", path, date, offset, note} -> {ok,exists,total,offset,chunk,more} # {action:"save", path, date, json, savedAt, note} -> {ok} # {action:"list", path} -> {ok,days:[{date,savedAt}]} # {action:"log", verbose} -> {ok,version,log} # {action:"logmsg",level,message} -> {ok} # # Journal d'activite (active.log) : lignes "JJ/MM/AAAA HH:MM:SS [NIVEAU] message". # Niveaux: INFO (evenements utiles), DEBUG (detail technique), ERREUR. # Les messages par defaut de l'hote sont SANS accent (encodage .ps1 PS 5.1) ; # les "note" envoyees par l'extension transitent en UTF-8 et gardent les accents. # # IMPORTANT: rien ne doit atteindre stdout sauf via Write-Message. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $HOST_VERSION = '2026.1.7' # v2026.1.7 - le host n'est plus persistant a vie : s'il ne recoit AUCUN message # pendant ce delai, il se ferme proprement (le navigateur le relance a la # prochaine activite de l'extension). Il lance aussi le tray a la demande. $IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000 # 2 heures $CHUNK_BYTES = 600000 $DATE_RE = '^\d{4}-\d{2}-\d{2}$' $INDEX_NAME = '_index.json' $MAX_IN = 67108864 $stdin = [Console]::OpenStandardInput() $stdout = [Console]::OpenStandardOutput() $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) # host.ps1 vit dans \bin -> la racine de l'install est le parent. $LOG_DIR = if ($PSScriptRoot) { Split-Path -Parent $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA 'Planification' } $LOG_FILE = Join-Path $LOG_DIR 'activity.log' function Format-DateFR([string]$iso) { if ($iso -match '^(\d{4})-(\d{2})-(\d{2})$') { return "$($Matches[3])/$($Matches[2])/$($Matches[1])" } return $iso } function Write-Activity([string]$level, [string]$msg) { try { if ((Test-Path -LiteralPath $LOG_FILE) -and ((Get-Item -LiteralPath $LOG_FILE).Length -gt 1048576)) { Move-Item -LiteralPath $LOG_FILE -Destination ($LOG_FILE + '.1') -Force } $ts = (Get-Date).ToString('dd/MM/yyyy HH:mm:ss') [System.IO.File]::AppendAllText($LOG_FILE, "$ts [$level] $msg`r`n", $Utf8NoBom) } catch {} } function Read-Exactly([int]$n) { $buf = New-Object byte[] $n $off = 0 while ($off -lt $n) { $r = $stdin.Read($buf, $off, $n - $off) if ($r -le 0) { return $null } $off += $r } return $buf } # v2026.1.7 - lit les 4 octets de prefixe de longueur AVEC un timeout sur le # 1er octet (= attente du prochain message). Renvoie le byte[], $null sur EOF, # ou la chaine 'TIMEOUT' si rien n'arrive dans le delai -> le host se ferme. function Read-LengthOrTimeout([int]$timeoutMs) { $buf = New-Object byte[] 4 $off = 0 while ($off -lt 4) { $ar = $stdin.BeginRead($buf, $off, 4 - $off, $null, $null) if ($off -eq 0) { if (-not $ar.AsyncWaitHandle.WaitOne($timeoutMs)) { return 'TIMEOUT' } } else { [void]$ar.AsyncWaitHandle.WaitOne() } $r = $stdin.EndRead($ar) if ($r -le 0) { return $null } $off += $r } return $buf } function Read-Message { $lenBytes = Read-LengthOrTimeout $IDLE_TIMEOUT_MS if ($lenBytes -is [string]) { return $lenBytes } # 'TIMEOUT' -> arret propre if ($null -eq $lenBytes) { return $null } $len = [BitConverter]::ToInt32($lenBytes, 0) if ($len -le 0 -or $len -gt $MAX_IN) { return $null } $payload = Read-Exactly $len if ($null -eq $payload) { return $null } $json = [System.Text.Encoding]::UTF8.GetString($payload) return ($json | ConvertFrom-Json) } function Write-Message($obj) { $json = $obj | ConvertTo-Json -Compress -Depth 50 $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $lenBytes = [BitConverter]::GetBytes([int]$bytes.Length) $stdout.Write($lenBytes, 0, 4) $stdout.Write($bytes, 0, $bytes.Length) $stdout.Flush() } function Test-DateStr([string]$d) { return ($d -match $DATE_RE) } function Resolve-Share([string]$path) { if ([string]::IsNullOrWhiteSpace($path)) { throw 'empty_path' } if (-not (Test-Path -LiteralPath $path)) { New-Item -ItemType Directory -Path $path -Force | Out-Null } return (Get-Item -LiteralPath $path).FullName } function Get-DayDir([string]$share, [string]$date) { $yyyy = $date.Substring(0,4) $mm = $date.Substring(5,2) return (Join-Path (Join-Path $share $yyyy) $mm) } function Get-DayFileWrite([string]$share, [string]$date) { $dir = Get-DayDir $share $date if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } return (Join-Path $dir ($date + '.json')) } function Get-DayFileRead([string]$share, [string]$date) { $ym = Join-Path (Get-DayDir $share $date) ($date + '.json') if (Test-Path -LiteralPath $ym) { return $ym } $flat = Join-Path $share ($date + '.json') if (Test-Path -LiteralPath $flat) { return $flat } return $ym } function Read-Index([string]$share) { $idx = Join-Path $share $INDEX_NAME if (Test-Path -LiteralPath $idx) { try { return (Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json) } catch { return $null } } return $null } function Update-Index([string]$share, [string]$date, [long]$savedAt) { $idx = Join-Path $share $INDEX_NAME $map = @{} if (Test-Path -LiteralPath $idx) { try { $existing = Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json foreach ($p in $existing.PSObject.Properties) { $map[$p.Name] = $p.Value } } catch {} } $map[$date] = $savedAt $tmp = $idx + '.tmp' [System.IO.File]::WriteAllText($tmp, ($map | ConvertTo-Json -Compress -Depth 5), $Utf8NoBom) Move-Item -LiteralPath $tmp -Destination $idx -Force Write-Activity 'DEBUG' "Index du dossier mis a jour pour le $(Format-DateFR $date)" } function Get-SavedAt([string]$share, [string]$date, [string]$file) { $ix = Read-Index $share if ($ix -and ($ix.PSObject.Properties.Name -contains $date)) { try { return [long]$ix.$date } catch {} } try { $o = Get-Content -LiteralPath $file -Raw -Encoding UTF8 | ConvertFrom-Json return [long]$o.savedAt } catch { return 0 } } # v2026.1.7 - le host vient d'etre lance par le navigateur (connexion de # l'extension). On (re)lance le tray s'il n'est pas deja la, et on horodate # l'activite (sert aussi de battement de coeur au tray fraichement lance). # Best-effort, tout en try/catch : ne JAMAIS perturber le protocole stdio. try { $trayRunning = $false try { $tm = [System.Threading.Mutex]::OpenExisting('Local\PlanificationTraySingleton'); $tm.Dispose(); $trayRunning = $true } catch { $trayRunning = $false } if (-not $trayRunning -and $PSScriptRoot) { $trayVbs = Join-Path $PSScriptRoot 'tray.vbs' if (Test-Path -LiteralPath $trayVbs) { Start-Process wscript.exe -ArgumentList "`"$trayVbs`"" } } } catch {} Write-Activity 'INFO' "Host demarre (v$HOST_VERSION) - connexion de l'extension." while ($true) { $msg = Read-Message # v2026.1.7 - inactivite > 2 h : arret propre (le navigateur relancera le host # a la prochaine activite de l'extension, qui relancera aussi le tray). if ($msg -is [string] -and $msg -eq 'TIMEOUT') { Write-Activity 'INFO' 'Aucune communication depuis 2 h - arret du host (relance auto a la prochaine activite).' break } if ($null -eq $msg) { break } try { $action = [string]$msg.action switch ($action) { 'version' { Write-Message @{ ok = $true; version = $HOST_VERSION } } 'stat' { if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break } $share = Resolve-Share $msg.path $f = Get-DayFileRead $share $msg.date $fr = Format-DateFR $msg.date if (Test-Path -LiteralPath $f) { $sa = Get-SavedAt $share $msg.date $f Write-Activity 'DEBUG' "Verification du planning du $fr : present dans le dossier" Write-Message @{ ok=$true; exists=$true; savedAt=$sa } } else { Write-Activity 'DEBUG' "Verification du planning du $fr : absent du dossier" Write-Message @{ ok=$true; exists=$false } } } 'read' { if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break } $share = Resolve-Share $msg.path $f = Get-DayFileRead $share $msg.date $fr = Format-DateFR $msg.date if (-not (Test-Path -LiteralPath $f)) { Write-Message @{ ok=$true; exists=$false }; break } $offset = 0 if ($null -ne $msg.offset) { $offset = [int]$msg.offset } $fs = [System.IO.File]::OpenRead($f) try { $total = [int]$fs.Length if ($offset -lt 0) { $offset = 0 } if ($offset -gt $total) { $offset = $total } $len = [Math]::Min($CHUNK_BYTES, $total - $offset) $b64 = '' if ($len -gt 0) { $buf = New-Object byte[] $len $fs.Position = $offset $got = 0 while ($got -lt $len) { $r = $fs.Read($buf, $got, $len - $got) if ($r -le 0) { break } $got += $r } $b64 = [Convert]::ToBase64String($buf, 0, $got) } $more = (($offset + $len) -lt $total) if ($offset -eq 0) { if ($msg.note) { Write-Activity 'INFO' ([string]$msg.note) } else { Write-Activity 'INFO' "Lecture du planning du $fr depuis le dossier ($([Math]::Round($total/1024)) Ko)" } } else { Write-Activity 'DEBUG' "Transfert d'une tranche du planning du $fr (position $offset)" } Write-Message @{ ok=$true; exists=$true; total=$total; offset=$offset; chunk=$b64; more=$more } } finally { $fs.Close() } } 'save' { # v2026.1.6 - QRO : ecriture compare-and-swap + verrou (merge concurrent). # Si expectedSavedAt fourni, on n'ecrit QUE si le savedAt du dossier est # toujours celui-la (sinon conflit -> l'extension refusionne et reessaie). if (-not (Test-DateStr $msg.date)) { Write-Message @{ ok=$false; error='bad_date' }; break } $share = Resolve-Share $msg.path $f = Get-DayFileWrite $share $msg.date $fr = Format-DateFR $msg.date $json = [string]$msg.json $hasExpect = ($null -ne $msg.expectedSavedAt) $expect = if ($hasExpect) { [long]$msg.expectedSavedAt } else { 0 } $lock = $f + '.lock' # Verrou inter-machines : creation exclusive ; vol si perime (> 10 s). $haveLock = $false try { [System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Close(); $haveLock = $true } catch { try { $age = ((Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime).TotalSeconds if ($age -gt 10) { Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue [System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Close(); $haveLock = $true } } catch {} } if (-not $haveLock) { Write-Message @{ ok=$false; locked=$true }; break } try { $proceed = $true if ($hasExpect) { $cur = 0 $existing = Get-DayFileRead $share $msg.date if (Test-Path -LiteralPath $existing) { try { $o = Get-Content -LiteralPath $existing -Raw -Encoding UTF8 | ConvertFrom-Json; $cur = [long]$o.savedAt } catch { $cur = 0 } } if ($cur -ne $expect) { Write-Activity 'DEBUG' "Conflit d'ecriture sur le $fr (attendu $expect, dossier $cur) - refusion cote extension" Write-Message @{ ok=$false; conflict=$true; currentSavedAt=$cur } $proceed = $false } } if ($proceed) { $tmp = $f + '.tmp' [System.IO.File]::WriteAllText($tmp, $json, $Utf8NoBom) Move-Item -LiteralPath $tmp -Destination $f -Force $savedAt = 0 if ($null -ne $msg.savedAt) { $savedAt = [long]$msg.savedAt } try { Update-Index $share $msg.date $savedAt } catch {} if ($msg.note) { Write-Activity 'INFO' ([string]$msg.note) } else { Write-Activity 'INFO' "Sauvegarde du planning du $fr dans le dossier ($([Math]::Round($json.Length/1024)) Ko)" } Write-Message @{ ok=$true; savedAt=$savedAt } } } finally { Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue } } 'list' { $share = Resolve-Share $msg.path $days = @() $ix = Read-Index $share if ($ix) { foreach ($p in $ix.PSObject.Properties) { if ($p.Name -match $DATE_RE) { $days += @{ date=$p.Name; savedAt=[long]$p.Value } } } } Write-Activity 'DEBUG' "Liste des plannings disponibles dans le dossier ($($days.Count) jour(s))" Write-Message @{ ok=$true; days=@($days) } } 'log' { $txt = '' if (Test-Path -LiteralPath $LOG_FILE) { try { $all = Get-Content -LiteralPath $LOG_FILE -Encoding UTF8 -ErrorAction SilentlyContinue if ($all) { $txt = ($all | Select-Object -Last 400) -join "`n" } } catch {} } Write-Message @{ ok=$true; version=$HOST_VERSION; log=$txt } } 'logmsg' { $lvl = if ($msg.level) { [string]$msg.level } else { 'INFO' } if ($msg.message) { Write-Activity $lvl ([string]$msg.message) } Write-Message @{ ok=$true } } default { Write-Message @{ ok=$false; error=('unknown_action:' + $action) } } } } catch { try { Write-Activity 'ERREUR' "$action : $($_.Exception.Message)" } catch {} Write-Message @{ ok=$false; error=([string]$_.Exception.Message) } } }