Public Access
v2026.5.46 — Couche données v2, vue « échelle de temps exacte », session fiabilisée, réservations partagées
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
# 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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
# Planification - gestionnaire graphique du helper de sync. // 2026.1.5 - QRO
|
||||
# Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud.
|
||||
# Licence MIT - voir le fichier LICENSE.
|
||||
#
|
||||
# Fenetre avec : Installer / Mettre a jour, Reparer, Desinstaller.
|
||||
# Per-user (HKCU), sans admin, sans signature. Lance par Gerer.cmd.
|
||||
#
|
||||
# Structure du paquet : Gerer.cmd (racine), bin\ (manage/host/tray/.vbs),
|
||||
# assets\ (icon.png). Structure installee (%LOCALAPPDATA%\Planification) :
|
||||
# bin\ (host.ps1, host.bat, tray.ps1, tray.vbs), assets\ (icon.png),
|
||||
# manifests\ (nm-*.json), activity.log (a la racine).
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$HostName = 'ch.netaplaid.planification'
|
||||
$ExtIdChromium = 'hlkepgcngeckdbapafkheondiagjgphc'
|
||||
$ExtIdFirefox = '[email protected]'
|
||||
|
||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } # paquet\bin
|
||||
$PkgRoot = Split-Path -Parent $ScriptDir # paquet\
|
||||
|
||||
$InstallDir = Join-Path $env:LOCALAPPDATA 'Planification'
|
||||
$InstallBin = Join-Path $InstallDir 'bin'
|
||||
$InstallAssets = Join-Path $InstallDir 'assets'
|
||||
$InstallManifests = Join-Path $InstallDir 'manifests'
|
||||
|
||||
function Get-HostVersion([string]$psFile) {
|
||||
if (Test-Path -LiteralPath $psFile) {
|
||||
$m = Select-String -LiteralPath $psFile -Pattern "HOST_VERSION\s*=\s*'([^']*)'" -ErrorAction SilentlyContinue
|
||||
if ($m) { return $m.Matches[0].Groups[1].Value }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Stop-Tray {
|
||||
Get-CimInstance Win32_Process -Filter "Name='wscript.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like '*tray.vbs*' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like '*tray.ps1*' } |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
function Install-Host {
|
||||
Stop-Tray
|
||||
foreach ($d in @($InstallBin, $InstallAssets, $InstallManifests)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
|
||||
|
||||
# Scripts -> bin\ (ecriture fraiche = pas de Mark of the Web).
|
||||
# v2026.1.7 - on copie AUSSI manage.ps1 dans l'install pour que le gestionnaire
|
||||
# (Reparer / Desinstaller) reste accessible via le Menu Demarrer meme apres
|
||||
# suppression du dossier du paquet.
|
||||
foreach ($name in @('host.ps1', 'tray.ps1', 'manage.ps1')) {
|
||||
$src = Join-Path $ScriptDir $name
|
||||
if (Test-Path -LiteralPath $src) {
|
||||
Get-Content -LiteralPath $src -Raw | Set-Content -LiteralPath (Join-Path $InstallBin $name) -Encoding UTF8
|
||||
Unblock-File -LiteralPath (Join-Path $InstallBin $name) -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
$srcVbs = Join-Path $ScriptDir 'tray.vbs'
|
||||
if (Test-Path -LiteralPath $srcVbs) {
|
||||
Get-Content -LiteralPath $srcVbs -Raw | Set-Content -LiteralPath (Join-Path $InstallBin 'tray.vbs') -Encoding ASCII
|
||||
Unblock-File -LiteralPath (Join-Path $InstallBin 'tray.vbs') -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Icone -> assets\ (binaire : Copy-Item).
|
||||
$srcIcon = Join-Path $PkgRoot 'assets\icon.png'
|
||||
if (Test-Path -LiteralPath $srcIcon) { Copy-Item -LiteralPath $srcIcon -Destination (Join-Path $InstallAssets 'icon.png') -Force }
|
||||
|
||||
# Wrapper .bat dans bin\ (host.ps1 est dans le meme dossier -> %~dp0host.ps1).
|
||||
$dstBat = Join-Path $InstallBin 'host.bat'
|
||||
Set-Content -LiteralPath $dstBat -Encoding ASCII -Value @(
|
||||
'@echo off',
|
||||
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0host.ps1" %*'
|
||||
)
|
||||
|
||||
# Manifests natifs -> manifests\ (path = bin\host.bat).
|
||||
$manChromium = Join-Path $InstallManifests 'nm-chromium.json'
|
||||
$manFirefox = Join-Path $InstallManifests 'nm-firefox.json'
|
||||
(([ordered]@{ name=$HostName; description='Planification cache sync host'; path=$dstBat; type='stdio'; allowed_origins=@("chrome-extension://$ExtIdChromium/") }) | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $manChromium -Encoding UTF8
|
||||
(([ordered]@{ name=$HostName; description='Planification cache sync host'; path=$dstBat; type='stdio'; allowed_extensions=@($ExtIdFirefox) }) | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $manFirefox -Encoding UTF8
|
||||
|
||||
function Reg([string]$base, [string]$manifest) {
|
||||
$key = Join-Path $base $HostName
|
||||
New-Item -Path $key -Force | Out-Null
|
||||
Set-Item -Path $key -Value $manifest
|
||||
}
|
||||
Reg 'HKCU:\Software\Google\Chrome\NativeMessagingHosts' $manChromium
|
||||
Reg 'HKCU:\Software\Microsoft\Edge\NativeMessagingHosts' $manChromium
|
||||
Reg 'HKCU:\Software\Mozilla\NativeMessagingHosts' $manFirefox
|
||||
|
||||
# v2026.1.7 - le tray ne demarre PLUS au login et n'est PLUS lance a l'install.
|
||||
# Il est demarre A LA DEMANDE par host.ps1 quand l'extension se connecte, et
|
||||
# s'auto-ferme apres 2 h sans activite. On retire l'ancien autostart s'il
|
||||
# existe (installs precedentes) pour que "de base = eteint".
|
||||
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'PlanificationTray' -ErrorAction SilentlyContinue
|
||||
|
||||
# v2026.1.7 - Menu Demarrer : raccourci vers le gestionnaire installe
|
||||
# (Installer / Reparer / Desinstaller), retrouvable sans le dossier du paquet.
|
||||
# 1) lanceur .vbs (fenetre PowerShell masquee) du manage.ps1 installe.
|
||||
$dstGererVbs = Join-Path $InstallBin 'gerer.vbs'
|
||||
Set-Content -LiteralPath $dstGererVbs -Encoding ASCII -Value @(
|
||||
"' Lance le gestionnaire Planification (fenetre PowerShell masquee).",
|
||||
'Set sh = CreateObject("WScript.Shell")',
|
||||
'Set fso = CreateObject("Scripting.FileSystemObject")',
|
||||
'dir = fso.GetParentFolderName(WScript.ScriptFullName)',
|
||||
'sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & dir & "\manage.ps1""", 0, False'
|
||||
)
|
||||
# 2) icone .ico (depuis icon.png) pour le raccourci, best-effort.
|
||||
$icoPath = Join-Path $InstallAssets 'icon.ico'
|
||||
try {
|
||||
$pngForIco = Join-Path $InstallAssets 'icon.png'
|
||||
if (Test-Path -LiteralPath $pngForIco) {
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bmp = New-Object System.Drawing.Bitmap($pngForIco)
|
||||
$ico = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
|
||||
$fs = [System.IO.File]::Create($icoPath)
|
||||
$ico.Save($fs); $fs.Close()
|
||||
}
|
||||
} catch { $icoPath = $null }
|
||||
# 3) raccourcis .lnk dans le Menu Demarrer (deux entrees) :
|
||||
# - "gerer" -> manage.ps1 (installer / reparer / desinstaller)
|
||||
# - "demarrer" -> tray.vbs (relance/affiche la sync a la demande)
|
||||
$dstTrayVbs = Join-Path $InstallBin 'tray.vbs'
|
||||
try {
|
||||
$startDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
||||
New-Item -ItemType Directory -Path $startDir -Force | Out-Null
|
||||
# Nettoyage de l'ancien nom de raccourci (installs precedentes).
|
||||
Remove-Item -LiteralPath (Join-Path $startDir 'Planification - synchronisation.lnk') -Force -ErrorAction SilentlyContinue
|
||||
$ws = New-Object -ComObject WScript.Shell
|
||||
function New-Lnk([string]$name, [string]$targetVbs, [string]$desc) {
|
||||
$sc = $ws.CreateShortcut((Join-Path $startDir $name))
|
||||
$sc.TargetPath = 'wscript.exe'
|
||||
$sc.Arguments = '"' + $targetVbs + '"'
|
||||
$sc.WorkingDirectory = $InstallBin
|
||||
$sc.Description = $desc
|
||||
if ($icoPath -and (Test-Path -LiteralPath $icoPath)) { $sc.IconLocation = "$icoPath,0" }
|
||||
$sc.Save()
|
||||
}
|
||||
New-Lnk 'Planification - gerer.lnk' $dstGererVbs 'Gerer la synchronisation Planification (installer / reparer / desinstaller)'
|
||||
New-Lnk 'Planification - demarrer la synchro.lnk' $dstTrayVbs 'Demarrer / afficher la synchronisation Planification'
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Uninstall-Host {
|
||||
Stop-Tray
|
||||
Remove-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'PlanificationTray' -ErrorAction SilentlyContinue
|
||||
foreach ($base in @(
|
||||
'HKCU:\Software\Google\Chrome\NativeMessagingHosts',
|
||||
'HKCU:\Software\Microsoft\Edge\NativeMessagingHosts',
|
||||
'HKCU:\Software\Mozilla\NativeMessagingHosts')) {
|
||||
Remove-Item -Path (Join-Path $base $HostName) -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path -LiteralPath $InstallDir) { Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
# v2026.1.7 - retirer les raccourcis du Menu Demarrer (hors InstallDir).
|
||||
$startDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
||||
foreach ($lnk in @('Planification - synchronisation.lnk', 'Planification - gerer.lnk', 'Planification - demarrer la synchro.lnk')) {
|
||||
Remove-Item -LiteralPath (Join-Path $startDir $lnk) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ----------------------------- Interface -----------------------------------
|
||||
$available = Get-HostVersion (Join-Path $ScriptDir 'host.ps1')
|
||||
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = 'Planification - synchronisation'
|
||||
$form.Size = New-Object System.Drawing.Size(460, 320)
|
||||
$form.StartPosition = 'CenterScreen'
|
||||
$form.FormBorderStyle = 'FixedSingle'
|
||||
$form.MaximizeBox = $false
|
||||
try {
|
||||
$ic = Join-Path $PkgRoot 'assets\icon.png'
|
||||
if (Test-Path -LiteralPath $ic) { $form.Icon = [System.Drawing.Icon]::FromHandle((New-Object System.Drawing.Bitmap($ic)).GetHicon()) }
|
||||
} catch {}
|
||||
|
||||
$title = New-Object System.Windows.Forms.Label
|
||||
$title.Text = 'Synchronisation du planning entre postes'
|
||||
$title.Font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
|
||||
$title.AutoSize = $true
|
||||
$title.Location = New-Object System.Drawing.Point(18, 16)
|
||||
$form.Controls.Add($title)
|
||||
|
||||
$status = New-Object System.Windows.Forms.Label
|
||||
$status.AutoSize = $false
|
||||
$status.Size = New-Object System.Drawing.Size(420, 40)
|
||||
$status.Location = New-Object System.Drawing.Point(18, 48)
|
||||
$form.Controls.Add($status)
|
||||
|
||||
function Refresh-Status {
|
||||
$installed = Get-HostVersion (Join-Path $InstallBin 'host.ps1')
|
||||
if ($installed) { $status.Text = "Installe : version $installed`r`nDisponible (ce dossier) : version $available" }
|
||||
else { $status.Text = "Non installe sur ce poste.`r`nDisponible (ce dossier) : version $available" }
|
||||
}
|
||||
Refresh-Status
|
||||
|
||||
$result = New-Object System.Windows.Forms.Label
|
||||
$result.AutoSize = $false
|
||||
$result.Size = New-Object System.Drawing.Size(420, 30)
|
||||
$result.Location = New-Object System.Drawing.Point(18, 248)
|
||||
$result.ForeColor = [System.Drawing.Color]::DarkGreen
|
||||
|
||||
function Add-Button([string]$text, [int]$y, [scriptblock]$action) {
|
||||
$b = New-Object System.Windows.Forms.Button
|
||||
$b.Text = $text
|
||||
$b.Size = New-Object System.Drawing.Size(420, 38)
|
||||
$b.Location = New-Object System.Drawing.Point(18, $y)
|
||||
$b.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
||||
$b.add_Click($action)
|
||||
$form.Controls.Add($b)
|
||||
}
|
||||
|
||||
Add-Button 'Installer / Mettre a jour' 100 {
|
||||
try { Install-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Installation / mise a jour terminee.'; Refresh-Status }
|
||||
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
|
||||
}
|
||||
Add-Button 'Reparer (reinstaller proprement)' 144 {
|
||||
try { Install-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Reparation terminee.'; Refresh-Status }
|
||||
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
|
||||
}
|
||||
Add-Button 'Desinstaller' 188 {
|
||||
$r = [System.Windows.Forms.MessageBox]::Show('Desinstaller le helper de synchronisation ? (le cache du dossier partage n''est pas touche)', 'Confirmer', 'YesNo', 'Question')
|
||||
if ($r -eq 'Yes') {
|
||||
try { Uninstall-Host; $result.ForeColor=[System.Drawing.Color]::DarkGreen; $result.Text='Desinstalle. Pense a retirer l''extension du navigateur.'; Refresh-Status }
|
||||
catch { $result.ForeColor=[System.Drawing.Color]::Firebrick; $result.Text=('Erreur : ' + $_.Exception.Message) }
|
||||
}
|
||||
}
|
||||
|
||||
$form.Controls.Add($result)
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
[void]$form.ShowDialog()
|
||||
@@ -0,0 +1,196 @@
|
||||
# Planification - icone barre des taches (best-effort, optionnel) // 2026.1.4 - QRO
|
||||
# Developpe par Quentin Rouiller (QRO) - DGNSI, Canton de Vaud.
|
||||
# Licence MIT - voir le fichier LICENSE.
|
||||
#
|
||||
# Indique que le helper de sync est installe. Clic / menu -> fenetre du journal
|
||||
# (aspect application : journal colore par niveau, auto-rafraichi, bouton
|
||||
# "Tout afficher"). Lance par tray.vbs (fenetre masquee), au login.
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
# Instance unique.
|
||||
$mutex = New-Object System.Threading.Mutex($false, 'Local\PlanificationTraySingleton')
|
||||
if (-not $mutex.WaitOne(0)) { exit }
|
||||
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
# tray.ps1 vit dans <install>\bin -> racine = parent ; assets et log a la racine.
|
||||
$root = if ($PSScriptRoot) { Split-Path -Parent $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA 'Planification' }
|
||||
$logFile = Join-Path $root 'activity.log'
|
||||
$iconPng = Join-Path (Join-Path $root 'assets') 'icon.png'
|
||||
|
||||
function Get-TrayIcon {
|
||||
try {
|
||||
if (Test-Path -LiteralPath $iconPng) {
|
||||
$bmp = New-Object System.Drawing.Bitmap($iconPng)
|
||||
return [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
|
||||
}
|
||||
} catch {}
|
||||
return [System.Drawing.SystemIcons]::Information
|
||||
}
|
||||
|
||||
$script:logForm = $null
|
||||
$script:logRtb = $null
|
||||
$script:logChk = $null
|
||||
$script:logSig = ''
|
||||
|
||||
$colInfo = [System.Drawing.Color]::FromArgb(40, 70, 120)
|
||||
$colDebug = [System.Drawing.Color]::FromArgb(150, 150, 150)
|
||||
$colErr = [System.Drawing.Color]::FromArgb(200, 40, 40)
|
||||
$colTs = [System.Drawing.Color]::FromArgb(120, 120, 120)
|
||||
|
||||
function Update-LogText {
|
||||
if (-not $script:logRtb -or $script:logRtb.IsDisposed) { return }
|
||||
$lines = @()
|
||||
if (Test-Path -LiteralPath $logFile) {
|
||||
try {
|
||||
$all = Get-Content -LiteralPath $logFile -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ($all) {
|
||||
if ($script:logChk -and -not $script:logChk.Checked) {
|
||||
$all = $all | Where-Object { $_ -notmatch '\[DEBUG\]' }
|
||||
}
|
||||
$lines = @($all | Select-Object -Last 500)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$sig = ($lines -join "`n")
|
||||
if ($sig -eq $script:logSig) { return }
|
||||
$script:logSig = $sig
|
||||
|
||||
$rtb = $script:logRtb
|
||||
$rtb.SuspendLayout()
|
||||
$rtb.Clear()
|
||||
if ($lines.Count -eq 0) {
|
||||
$rtb.SelectionColor = $colDebug
|
||||
$rtb.AppendText("(aucune activite de synchronisation pour le moment)")
|
||||
} else {
|
||||
foreach ($line in $lines) {
|
||||
$col = $colInfo
|
||||
if ($line -match '\[ERREUR\]') { $col = $colErr }
|
||||
elseif ($line -match '\[DEBUG\]') { $col = $colDebug }
|
||||
if ($line -match '^(\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2})(.*)$') {
|
||||
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $colTs
|
||||
$rtb.AppendText($Matches[1])
|
||||
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $col
|
||||
$rtb.AppendText($Matches[2] + "`n")
|
||||
} else {
|
||||
$rtb.SelectionStart = $rtb.TextLength; $rtb.SelectionColor = $col
|
||||
$rtb.AppendText($line + "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
$rtb.ResumeLayout()
|
||||
$rtb.SelectionStart = $rtb.TextLength; $rtb.ScrollToCaret()
|
||||
}
|
||||
|
||||
function Show-LogWindow {
|
||||
if ($script:logForm -and -not $script:logForm.IsDisposed) {
|
||||
$script:logForm.WindowState = 'Normal'; $script:logForm.Activate(); return
|
||||
}
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = 'Planification - journal de synchronisation'
|
||||
$form.Size = New-Object System.Drawing.Size(760, 540)
|
||||
$form.StartPosition = 'CenterScreen'
|
||||
$form.BackColor = [System.Drawing.Color]::FromArgb(245, 246, 248)
|
||||
try { $form.Icon = Get-TrayIcon } catch {}
|
||||
|
||||
# 1) zone de texte (Fill) ajoutee en premier
|
||||
$rtb = New-Object System.Windows.Forms.RichTextBox
|
||||
$rtb.Dock = 'Fill'
|
||||
$rtb.ReadOnly = $true
|
||||
$rtb.BorderStyle = 'None'
|
||||
$rtb.BackColor = [System.Drawing.Color]::White
|
||||
$rtb.Font = New-Object System.Drawing.Font('Consolas', 9.5)
|
||||
$rtb.DetectUrls = $false
|
||||
$form.Controls.Add($rtb)
|
||||
|
||||
# 2) barre d'outils claire avec le BOUTON bascule "Tout afficher" bien visible
|
||||
$bar = New-Object System.Windows.Forms.Panel
|
||||
$bar.Dock = 'Top'; $bar.Height = 40
|
||||
$bar.BackColor = [System.Drawing.Color]::FromArgb(238, 240, 243)
|
||||
$chk = New-Object System.Windows.Forms.CheckBox
|
||||
$chk.Appearance = 'Button'
|
||||
$chk.Text = 'Tout afficher (mode detaille)'
|
||||
$chk.TextAlign = 'MiddleCenter'
|
||||
$chk.AutoSize = $false
|
||||
$chk.Size = New-Object System.Drawing.Size(200, 28)
|
||||
$chk.Location = New-Object System.Drawing.Point(10, 6)
|
||||
$chk.FlatStyle = 'System'
|
||||
$chk.add_CheckedChanged({ $script:logSig = '~'; Update-LogText })
|
||||
$bar.Controls.Add($chk)
|
||||
$hint = New-Object System.Windows.Forms.Label
|
||||
$hint.Text = 'Par defaut : l''essentiel. Activez pour voir tout le detail technique.'
|
||||
$hint.ForeColor = [System.Drawing.Color]::FromArgb(90, 90, 90)
|
||||
$hint.AutoSize = $true
|
||||
$hint.Location = New-Object System.Drawing.Point(220, 12)
|
||||
$bar.Controls.Add($hint)
|
||||
$form.Controls.Add($bar)
|
||||
|
||||
# 3) bandeau titre (bleu) ajoute en dernier -> tout en haut
|
||||
$hdrBar = New-Object System.Windows.Forms.Panel
|
||||
$hdrBar.Dock = 'Top'; $hdrBar.Height = 32
|
||||
$hdrBar.BackColor = [System.Drawing.Color]::FromArgb(40, 70, 120)
|
||||
$hdr = New-Object System.Windows.Forms.Label
|
||||
$hdr.Text = 'Activite de synchronisation - Planification'
|
||||
$hdr.ForeColor = [System.Drawing.Color]::White
|
||||
$hdr.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
|
||||
$hdr.AutoSize = $true
|
||||
$hdr.Location = New-Object System.Drawing.Point(10, 7)
|
||||
$hdrBar.Controls.Add($hdr)
|
||||
$form.Controls.Add($hdrBar)
|
||||
|
||||
$script:logForm = $form
|
||||
$script:logRtb = $rtb
|
||||
$script:logChk = $chk
|
||||
$script:logSig = ''
|
||||
Update-LogText
|
||||
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = 2000
|
||||
$timer.add_Tick({ Update-LogText })
|
||||
$timer.Start()
|
||||
$form.add_FormClosed({ $timer.Stop(); $timer.Dispose() })
|
||||
|
||||
$form.Show(); $form.Activate()
|
||||
}
|
||||
|
||||
$ni = New-Object System.Windows.Forms.NotifyIcon
|
||||
$ni.Icon = Get-TrayIcon
|
||||
$ni.Text = 'Planification - synchronisation active'
|
||||
$ni.Visible = $true
|
||||
|
||||
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
||||
$miLog = $menu.Items.Add('Voir le journal de synchronisation')
|
||||
$miLog.add_Click({ Show-LogWindow })
|
||||
$miQuit = $menu.Items.Add('Quitter')
|
||||
$miQuit.add_Click({ $ni.Visible = $false; $ni.Dispose(); [System.Windows.Forms.Application]::Exit() })
|
||||
$ni.ContextMenuStrip = $menu
|
||||
$ni.add_MouseClick({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Show-LogWindow } })
|
||||
|
||||
# v2026.1.7 - auto-extinction apres 2 h SANS activite de synchronisation. On se
|
||||
# base sur la date de derniere ecriture du journal (le host y ecrit a chaque
|
||||
# operation). Le navigateur relancera le host a la prochaine activite, qui
|
||||
# relancera ce tray. -> "de base eteint, s'active a l'usage, se ferme au repos".
|
||||
$IDLE_LIMIT_MIN = 120
|
||||
$idleTimer = New-Object System.Windows.Forms.Timer
|
||||
$idleTimer.Interval = 300000 # verifie toutes les 5 min
|
||||
$idleTimer.add_Tick({
|
||||
try {
|
||||
$age = $null
|
||||
if (Test-Path -LiteralPath $logFile) {
|
||||
$age = ((Get-Date) - (Get-Item -LiteralPath $logFile).LastWriteTime).TotalMinutes
|
||||
}
|
||||
# Pas de journal, ou journal inactif depuis >= 2 h -> on s'eteint.
|
||||
if (($null -eq $age) -or ($age -ge $IDLE_LIMIT_MIN)) {
|
||||
$ni.Visible = $false; $ni.Dispose()
|
||||
[System.Windows.Forms.Application]::Exit()
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
$idleTimer.Start()
|
||||
|
||||
[System.Windows.Forms.Application]::EnableVisualStyles()
|
||||
[System.Windows.Forms.Application]::Run()
|
||||
$idleTimer.Stop()
|
||||
$ni.Dispose()
|
||||
@@ -0,0 +1,5 @@
|
||||
' Lance tray.ps1 en arriere-plan, fenetre PowerShell masquee (pas de console).
|
||||
Set sh = CreateObject("WScript.Shell")
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
dir = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & dir & "\tray.ps1""", 0, False
|
||||
Reference in New Issue
Block a user