Files
Planification/native-host/bin/host.ps1
T

362 lines
14 KiB
PowerShell

# 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: <share>\AAAA\MM\AAAA-MM-JJ.json (une image par jour, par annee/mois).
# Index racine <share>\_index.json : date -> savedAt (stat/list rapides).
# Anciens fichiers plats (<share>\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 <install>\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) }
}
}