forked from FroSteel/Planification
362 lines
14 KiB
PowerShell
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) }
|
|
}
|
|
}
|