diff --git a/packaging/README.md b/packaging/README.md index 4f22d6f..8b5f182 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -7,7 +7,8 @@ This directory builds a Windows deliverable with: - Optional **bundled** portable PostgreSQL **or** **external** existing Postgres - API-hosted UI (`web/dist`) — no separate Vite process for end users - Program / data split so upgrades do not wipe the database -- Manual update script + auto-update **manifest contract** (fetch not implemented yet) +- Manual update script + check/apply updates (GitHub Releases or custom manifest) +- Optional system tray + start-at-logon ## What users get @@ -77,14 +78,35 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s ## Manual update ```powershell -.\packaging\update_netx.ps1 -PackagePath .\NetX-0.3.0-win64.zip +.\packaging\update_netx.ps1 -PackagePath .\NetX-0.4.0-win64.zip ``` Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postgres`, …), **keeps** the data root, restarts. Schema migrations still run via Alembic on API start. -## Auto-update (reserved) +Or reinstall a newer `NetX-Setup-*.exe` over the same program directory. -See `manifest.example.json`. Future: set `NETX_UPDATE_URL` + `NETX_UPDATE_CHANNEL`; a checker will download the zip and call `update_netx.ps1`. Not implemented in this phase. +## Check / apply updates + +Default source: latest GitHub Release for `hansjone/netx`. Optional custom manifest via `NETX_UPDATE_URL` (see `manifest.example.json`). + +```powershell +# Report only (exit 0 = up to date, 10 = update available) +.\packaging\check_update.ps1 + +# Download zip + run update_netx.ps1 +.\packaging\check_update.ps1 -Apply +``` + +## Tray & autostart + +```powershell +# System tray: Start / Stop / Open UI / Check updates +.\packaging\netx_tray.ps1 -StartOnLaunch + +# Start tray at Windows logon (current user) +.\packaging\install_autostart.ps1 +# Remove: .\packaging\install_autostart.ps1 -Remove +``` ## Layout reminder diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index a347f03..ce9960a 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -71,8 +71,9 @@ New-Item -ItemType Directory -Path $packOut -Force | Out-Null Copy-Item -Path (Join-Path $PSScriptRoot "_common.ps1") -Destination $packOut -Force foreach ($name in @( "download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1", - "stop_netx_app.ps1", "update_netx.ps1", "build_release.ps1", - "README.md", "manifest.example.json" + "stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1", + "netx_tray.ps1", "install_autostart.ps1", "build_release.ps1", + "publish_release.ps1", "README.md", "manifest.example.json" )) { $src = Join-Path $PSScriptRoot $name if (Test-Path $src) { Copy-Item $src (Join-Path $packOut $name) -Force } @@ -104,8 +105,28 @@ if ($CreateVenv) { $py = (Get-Command python -ErrorAction SilentlyContinue) if (-not $py) { throw "python_not_found_for_venv" } & $py.Source -m venv (Join-Path $stage ".venv") - & (Join-Path $stage ".venv\Scripts\python.exe") -m pip install --upgrade pip - & (Join-Path $stage ".venv\Scripts\python.exe") -m pip install -r (Join-Path $stage "requirements.txt") + $venvPy = Join-Path $stage ".venv\Scripts\python.exe" + & $venvPy -m pip install --upgrade pip + & $venvPy -m pip install -r (Join-Path $stage "requirements.txt") + if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" } + + Write-Host "==> Slimming .venv (drop tests / __pycache__ / *.pyc)" + $site = Join-Path $stage ".venv\Lib\site-packages" + if (Test-Path $site) { + Get-ChildItem -Path $site -Recurse -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -in @('__pycache__', 'tests', 'test', 'testing', 'Tests') -or + $_.FullName -match '\\pandas\\tests(\\|$)' -or + $_.FullName -match '\\numpy\\(_*tests|tests)(\\|$)' -or + $_.FullName -match '\\scipy\\(_*tests|tests)(\\|$)' + } | + Sort-Object { $_.FullName.Length } -Descending | + ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } + Get-ChildItem -Path $site -Recurse -Include *.pyc,*.pyo -Force -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + } } Write-Host "==> Stage ready: $stage" -ForegroundColor Green diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 new file mode 100644 index 0000000..eb1b8ec --- /dev/null +++ b/packaging/check_update.ps1 @@ -0,0 +1,148 @@ +param( + [string]$ProgramRoot = "", + [string]$DataRoot = "", + [string]$UpdateUrl = "", + [string]$Channel = "", + [switch]$Apply = $false, + [switch]$Quiet = $false +) + +# Check for a newer NetX Windows package. +# Sources (first that works): +# 1) -UpdateUrl / NETX_UPDATE_URL → JSON manifest (see manifest.example.json) +# 2) GitHub Releases API for hansjone/netx (default) + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot +$envPath = Join-Path $data ".env" +$map = @{} +if (Test-Path $envPath) { $map = Read-DotEnv -Path $envPath } + +if (-not $UpdateUrl) { + if ($map["NETX_UPDATE_URL"]) { $UpdateUrl = $map["NETX_UPDATE_URL"] } + elseif ($env:NETX_UPDATE_URL) { $UpdateUrl = $env:NETX_UPDATE_URL } +} +if (-not $Channel) { + if ($map["NETX_UPDATE_CHANNEL"]) { $Channel = $map["NETX_UPDATE_CHANNEL"] } + elseif ($env:NETX_UPDATE_CHANNEL) { $Channel = $env:NETX_UPDATE_CHANNEL } + else { $Channel = "stable" } +} + +$current = Get-NetxVersion -ProgramRoot $prog + +function Compare-SemVer { + param([string]$A, [string]$B) + $pa = @($A.TrimStart('v','V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) + $pb = @($B.TrimStart('v','V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) + while ($pa.Count -lt 3) { $pa += 0 } + while ($pb.Count -lt 3) { $pb += 0 } + for ($i = 0; $i -lt 3; $i++) { + if ($pa[$i] -lt $pb[$i]) { return -1 } + if ($pa[$i] -gt $pb[$i]) { return 1 } + } + return 0 +} + +function Get-ManifestFromUrl { + param([string]$Url) + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30 + return ($resp.Content | ConvertFrom-Json) +} + +function Get-FromGitHubReleases { + $api = "https://api.github.com/repos/hansjone/netx/releases/latest" + $headers = @{ "User-Agent" = "NetX-UpdateCheck"; "Accept" = "application/vnd.github+json" } + $rel = Invoke-RestMethod -Uri $api -Headers $headers -TimeoutSec 30 + $tag = [string]$rel.tag_name + $ver = $tag.TrimStart('v', 'V') + $zipAsset = $rel.assets | Where-Object { $_.name -match 'win64\.zip$' } | Select-Object -First 1 + $setupAsset = $rel.assets | Where-Object { $_.name -match 'Setup-.*\.exe$' } | Select-Object -First 1 + if (-not $zipAsset) { throw "github_release_missing_zip" } + return [pscustomobject]@{ + channel = "stable" + latest = $ver + min_compatible = $ver + notes_url = [string]$rel.html_url + windows = [pscustomobject]@{ + url = [string]$zipAsset.browser_download_url + sha256 = "" + size = [int64]$zipAsset.size + setup_url = $(if ($setupAsset) { [string]$setupAsset.browser_download_url } else { "" }) + } + } +} + +Write-Host "==> Current version: $current" +$manifest = $null +try { + if ($UpdateUrl) { + Write-Host "==> Fetching manifest: $UpdateUrl" + $manifest = Get-ManifestFromUrl -Url $UpdateUrl + if ($manifest.channel -and $Channel -and ($manifest.channel -ne $Channel)) { + Write-Host "[WARN] manifest channel=$($manifest.channel) requested=$Channel" + } + } else { + Write-Host "==> Fetching latest from GitHub Releases" + $manifest = Get-FromGitHubReleases + } +} catch { + if ($Quiet) { exit 2 } + throw "update_check_failed: $($_.Exception.Message)" +} + +$latest = [string]$manifest.latest +$cmp = Compare-SemVer -A $current -B $latest +$result = [pscustomobject]@{ + current = $current + latest = $latest + update_available = ($cmp -lt 0) + download_url = [string]$manifest.windows.url + setup_url = $(if ($manifest.windows.setup_url) { [string]$manifest.windows.setup_url } else { "" }) + notes_url = $(if ($manifest.notes_url) { [string]$manifest.notes_url } else { "" }) + sha256 = $(if ($manifest.windows.sha256) { [string]$manifest.windows.sha256 } else { "" }) +} + +if (-not $result.update_available) { + Write-Host "==> Up to date (latest=$latest)" -ForegroundColor Green + if (-not $Quiet) { + $result | ConvertTo-Json -Compress | Write-Output + } + exit 0 +} + +Write-Host "==> Update available: $current -> $latest" -ForegroundColor Cyan +if ($result.notes_url) { Write-Host " Notes: $($result.notes_url)" } + +if (-not $Apply) { + Write-Host " Download (zip): $($result.download_url)" + if ($result.setup_url) { Write-Host " Or install: $($result.setup_url)" } + Write-Host " To apply: .\packaging\check_update.ps1 -Apply" + $result | ConvertTo-Json -Compress | Write-Output + exit 10 +} + +$dlDir = Join-Path $data "backups\downloads" +if (-not (Test-Path $dlDir)) { + New-Item -ItemType Directory -Path $dlDir -Force | Out-Null +} +$zipName = "NetX-$latest-win64.zip" +$zipPath = Join-Path $dlDir $zipName +Write-Host "==> Downloading $zipName ..." +Invoke-WebRequest -Uri $result.download_url -OutFile $zipPath -UseBasicParsing +if ($result.sha256 -and $result.sha256 -notmatch 'REPLACE') { + $hash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + $expect = $result.sha256.ToLowerInvariant() + if ($hash -ne $expect) { + throw "sha256_mismatch: got $hash expected $expect" + } + Write-Host "==> SHA256 OK" +} + +Write-Host "==> Applying update via update_netx.ps1" +& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "update_netx.ps1") ` + -PackagePath $zipPath -ProgramRoot $prog -DataRoot $data +Write-Host "==> Update applied to $latest" -ForegroundColor Green +exit 0 diff --git a/packaging/install_autostart.ps1 b/packaging/install_autostart.ps1 new file mode 100644 index 0000000..d5c2ebd --- /dev/null +++ b/packaging/install_autostart.ps1 @@ -0,0 +1,25 @@ +param( + [string]$ProgramRoot = "", + [switch]$Remove = $false +) + +# Register / unregister NetX tray at current-user logon. + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$tray = Join-Path $PSScriptRoot "netx_tray.ps1" +$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" +$name = "NetX" + +if ($Remove) { + Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue + Write-Host "==> Removed NetX from startup" + exit 0 +} + +$cmd = "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$tray`" -ProgramRoot `"$prog`" -StartOnLaunch" +New-ItemProperty -Path $runKey -Name $name -Value $cmd -PropertyType String -Force | Out-Null +Write-Host "==> NetX tray will start at logon" +Write-Host " $cmd" diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index 55090ea..2d49c63 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -54,15 +54,19 @@ Name: "{commonappdata}\NetX\pgdata" Name: "{commonappdata}\NetX\backups" [Icons] +Name: "{group}\NetX Tray"; Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\netx_tray.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -StartOnLaunch"; WorkingDir: "{app}" Name: "{group}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}" Name: "{group}\Stop NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\stop_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}" +Name: "{group}\Check for updates"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\check_update.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}" Name: "{group}\Open NetX UI"; Filename: "http://127.0.0.1:8890/" Name: "{group}\First-time setup"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}" -Name: "{autodesktop}\Start NetX"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Tasks: desktopicon +Name: "{group}\Enable start at logon"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}" +Name: "{autodesktop}\NetX"; Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\netx_tray.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -StartOnLaunch"; WorkingDir: "{app}"; Tasks: desktopicon [Run] Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Flags: postinstall skipifsilent; Tasks: firstrun -Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\start_netx_app.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "Start NetX now"; Flags: postinstall nowait skipifsilent unchecked +Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\netx_tray.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -StartOnLaunch"; WorkingDir: "{app}"; Description: "Start NetX tray now"; Flags: postinstall nowait skipifsilent unchecked +Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}"; Description: "Start NetX at Windows logon"; Flags: postinstall skipifsilent unchecked [UninstallDelete] ; Do NOT delete {commonappdata}\NetX — preserves DB and secrets across reinstall diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json index 9f51c14..6d4b4fd 100644 --- a/packaging/manifest.example.json +++ b/packaging/manifest.example.json @@ -2,10 +2,11 @@ "channel": "stable", "latest": "0.3.0", "min_compatible": "0.3.0", - "notes_url": "", + "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.3.0", "windows": { - "url": "https://example.com/netx/NetX-0.2.0-win64.zip", - "sha256": "REPLACE_WITH_SHA256", + "url": "https://github.com/hansjone/netx/releases/download/v0.3.0/NetX-0.3.0-win64.zip", + "setup_url": "https://github.com/hansjone/netx/releases/download/v0.3.0/NetX-Setup-0.3.0.exe", + "sha256": "", "size": 0 } } diff --git a/packaging/netx_tray.ps1 b/packaging/netx_tray.ps1 new file mode 100644 index 0000000..f3d10b0 --- /dev/null +++ b/packaging/netx_tray.ps1 @@ -0,0 +1,133 @@ +param( + [string]$ProgramRoot = "", + [string]$DataRoot = "", + [switch]$StartOnLaunch = $true, + [switch]$CheckUpdateOnLaunch = $false +) + +# Simple system-tray controller for NetX (Windows packaged installs). +# Start / Stop / Open UI / Check update / Exit + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot +$hostBind = "127.0.0.1" +$port = 8890 +$envPath = Join-Path $data ".env" +if (Test-Path $envPath) { + $map = Read-DotEnv -Path $envPath + if ($map["NETX_HOST"]) { $hostBind = $map["NETX_HOST"] } + if ($map["NETX_PORT"]) { try { $port = [int]$map["NETX_PORT"] } catch {} } +} +$uiUrl = "http://${hostBind}:${port}/" +$ver = Get-NetxVersion -ProgramRoot $prog + +$startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1" +$stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1" +$checkPs1 = Join-Path $PSScriptRoot "check_update.ps1" + +function Invoke-NetxScript { + param([string]$File, [string[]]$ExtraArgs = @()) + $args = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $File, + "-ProgramRoot", $prog, "-DataRoot", $data + ) + $ExtraArgs + Start-Process -FilePath "powershell.exe" -ArgumentList $args -WorkingDirectory $prog -WindowStyle Hidden +} + +$form = New-Object System.Windows.Forms.Form +$form.Text = "NetX" +$form.ShowInTaskbar = $false +$form.WindowState = "Minimized" +$form.Visible = $false +$form.Size = New-Object System.Drawing.Size(0, 0) + +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Text = "NetX $ver" +$notify.Visible = $true +try { + $notify.Icon = [System.Drawing.SystemIcons]::Application +} catch {} + +$menu = New-Object System.Windows.Forms.ContextMenuStrip + +$miOpen = $menu.Items.Add("Open UI ($uiUrl)") +$miOpen.add_Click({ Start-Process $uiUrl }) + +$miStart = $menu.Items.Add("Start NetX") +$miStart.add_Click({ + $notify.ShowBalloonTip(3000, "NetX", "Starting…", [System.Windows.Forms.ToolTipIcon]::Info) + Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") +}) + +$miStop = $menu.Items.Add("Stop NetX") +$miStop.add_Click({ + $notify.ShowBalloonTip(3000, "NetX", "Stopping…", [System.Windows.Forms.ToolTipIcon]::Info) + Invoke-NetxScript -File $stopPs1 +}) + +$miUpdate = $menu.Items.Add("Check for updates") +$miUpdate.add_Click({ + try { + $p = Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, + "-ProgramRoot", $prog, "-DataRoot", $data + ) -Wait -PassThru -WindowStyle Normal + if ($p.ExitCode -eq 10) { + $ans = [System.Windows.Forms.MessageBox]::Show( + "A newer NetX is available. Download and apply now?", + "NetX update", + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Question + ) + if ($ans -eq [System.Windows.Forms.DialogResult]::Yes) { + Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, + "-ProgramRoot", $prog, "-DataRoot", $data, "-Apply" + ) -WorkingDirectory $prog + } + } elseif ($p.ExitCode -eq 0) { + [System.Windows.Forms.MessageBox]::Show("NetX is up to date ($ver).", "NetX update") + } + } catch { + [System.Windows.Forms.MessageBox]::Show("Update check failed: $($_.Exception.Message)", "NetX") + } +}) + +[void]$menu.Items.Add("-") + +$miExit = $menu.Items.Add("Exit tray (services keep running)") +$miExit.add_Click({ + $notify.Visible = $false + $form.Close() + [System.Windows.Forms.Application]::Exit() +}) + +$notify.ContextMenuStrip = $menu +$notify.add_DoubleClick({ Start-Process $uiUrl }) + +$form.add_Shown({ + if ($StartOnLaunch) { + Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") + Start-Sleep -Seconds 2 + try { Start-Process $uiUrl } catch {} + } + if ($CheckUpdateOnLaunch) { + Start-Process -FilePath "powershell.exe" -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, + "-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet" + ) -WindowStyle Hidden + } +}) + +$form.add_FormClosing({ + $notify.Visible = $false + $notify.Dispose() +}) + +[System.Windows.Forms.Application]::Run($form)