Release 0.4.5: installer DB wizard, credential key, lean offline Setup.

Choose bundled or external PostgreSQL in the Setup wizard with connection
validation; optional NETX_CREDENTIAL_SECRET_KEY; single desktop shortcut;
faster uninstall and tray startup for offline Windows packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-07 14:44:40 +08:00
parent 86dfd41f44
commit 97393cd407
20 changed files with 1531 additions and 146 deletions

View file

@ -15,6 +15,7 @@ from .schema_patches import (
apply_topology_schema_safety_net, apply_topology_schema_safety_net,
run_alembic_upgrade_to_head, run_alembic_upgrade_to_head,
) )
from .ne_crypto import ensure_credential_secret_key
from .security_bootstrap import assert_secure_defaults_or_exit from .security_bootstrap import assert_secure_defaults_or_exit
from .ume_runtime import start_api_sideband_threads, start_device_schedulers from .ume_runtime import start_api_sideband_threads, start_device_schedulers
import netx_api.ume_support as ume_support import netx_api.ume_support as ume_support
@ -42,6 +43,8 @@ def _configure_ume_diag_logging() -> None:
def run_api_startup() -> None: def run_api_startup() -> None:
"""Full API boot sequence previously inlined in ``main.on_startup``.""" """Full API boot sequence previously inlined in ``main.on_startup``."""
assert_secure_defaults_or_exit() assert_secure_defaults_or_exit()
# Persist Fernet key for managed-NE passwords (same idea as JWT secret file).
ensure_credential_secret_key()
_configure_ume_diag_logging() _configure_ume_diag_logging()
_log.info( _log.info(
"startup: ne_exec_policy_enabled=%s", "startup: ne_exec_policy_enabled=%s",

View file

@ -80,8 +80,9 @@ class Settings(BaseSettings):
ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes" ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes"
ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks" ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks"
ume_sync_alarms_history_every_hours: int = 24 ume_sync_alarms_history_every_hours: int = 24
# Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key()) # Managed NE credentials (Fernet). Empty = auto-generate & persist to credential_secret_file.
credential_secret_key: str = "" credential_secret_key: str = ""
credential_secret_file: str = "data/auth/credential_secret"
# Shared-server worker caps (sized for multi-operator use; raise if bastion/DB allow). # Shared-server worker caps (sized for multi-operator use; raise if bastion/DB allow).
ne_connect_max_workers: int = 8 ne_connect_max_workers: int = 8
ne_connect_timeout_sec: int = 30 ne_connect_timeout_sec: int = 30

View file

@ -1,18 +1,73 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from .config import settings from .config import settings
_log = logging.getLogger("netx.crypto")
_cached_credential_key: str | None = None
class CredentialCryptoError(RuntimeError): class CredentialCryptoError(RuntimeError):
pass pass
def credential_secret_file_path() -> Path:
raw = str(getattr(settings, "credential_secret_file", None) or "data/auth/credential_secret").strip()
path = Path(raw)
if not path.is_absolute():
path = Path.cwd() / path
return path
def ensure_credential_secret_key() -> str:
"""Resolve Fernet key: env ``NETX_CREDENTIAL_SECRET_KEY`` > file > generate once.
Packaged installs should also write the key into ``.env`` via setup_first_run;
this startup/path fallback covers upgrades and missed first-run keys.
"""
global _cached_credential_key
configured = str(settings.credential_secret_key or "").strip()
if configured:
return configured
if _cached_credential_key:
return _cached_credential_key
path = credential_secret_file_path()
try:
if path.is_file():
existing = path.read_text(encoding="utf-8").strip()
if existing:
_cached_credential_key = existing
settings.credential_secret_key = existing
return existing
except Exception:
_log.exception("read credential secret file failed path=%s", path)
generated = Fernet.generate_key().decode("ascii")
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(generated + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except Exception:
pass
_log.info("wrote per-install credential Fernet key to %s", path)
except Exception:
_log.exception(
"write credential secret file failed path=%s; using in-memory key only",
path,
)
_cached_credential_key = generated
settings.credential_secret_key = generated
return generated
def _fernet() -> Fernet: def _fernet() -> Fernet:
key = str(settings.credential_secret_key or "").strip() key = ensure_credential_secret_key()
if not key:
raise CredentialCryptoError("credential_secret_key_not_configured")
try: try:
return Fernet(key.encode("ascii")) return Fernet(key.encode("ascii"))
except Exception as exc: except Exception as exc:
@ -37,4 +92,7 @@ def decrypt_secret(value: str) -> str:
def credentials_configured() -> bool: def credentials_configured() -> bool:
return bool(str(settings.credential_secret_key or "").strip()) try:
return bool(ensure_credential_secret_key())
except Exception:
return False

View file

@ -19,7 +19,7 @@ This directory builds a Windows deliverable with:
**Installer:** English + 简体中文 (language dialog). Icons use `packaging/assets/netx.ico`. **Installer:** English + 简体中文 (language dialog). Icons use `packaging/assets/netx.ico`.
**Offline:** Setup ships portable PostgreSQL, `.venv` (when built with `-CreateVenv`), and WinSW. First-run uses `-NonInteractive -DbMode bundled` — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default. **Offline:** Setup ships portable PostgreSQL, `.venv` (when built with `-CreateVenv`), and WinSW. The installer wizard chooses **built-in or external** PostgreSQL and validates external credentials (`psql SELECT 1`) before files are installed — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default.
**OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. Bundled Python in current releases is **3.13+**, which does **not** support Windows Server 2012 R2 — use Server 2016+ or a newer desktop OS. **OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. Bundled Python in current releases is **3.13+**, which does **not** support Windows Server 2012 R2 — use Server 2016+ or a newer desktop OS.
@ -54,10 +54,14 @@ Override version in the `.iss` or edit `#define MyAppVersion`.
### Setup.exe ### Setup.exe
1. Run `NetX-Setup-x.y.z.exe` (admin). 1. Run `NetX-Setup-x.y.z.exe` (admin).
2. Files → `%ProgramFiles%\NetX\` (program root). 2. On the **Database** page: choose built-in (offline) or external PostgreSQL (host/port/user/password/db; connection must succeed to continue).
3. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). 3. Files → `%ProgramFiles%\NetX\` (program root).
4. Optional post-install task runs `setup_first_run.ps1` (choose bundled vs external DB). 4. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). Post-install writes `.env` automatically.
5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI**. 5. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database** (repair only).
Silent (bundled default): `/SILENT /DbMode=bundled`
Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...`
Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate.
### Zip (portable) ### Zip (portable)

View file

@ -1,4 +1,4 @@
# Shared path helpers for Windows packaging scripts. # Shared path helpers for Windows packaging scripts.
# Dot-source: . "$PSScriptRoot\_common.ps1" # Dot-source: . "$PSScriptRoot\_common.ps1"
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -147,6 +147,7 @@ function Get-NetxDataEnvMap {
return @{ return @{
"NETX_AUTH_MCP_TOKEN_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\mcp_token")) "NETX_AUTH_MCP_TOKEN_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\mcp_token"))
"NETX_AUTH_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\jwt_secret")) "NETX_AUTH_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\jwt_secret"))
"NETX_CREDENTIAL_SECRET_FILE" = (ConvertTo-NetxFsPath (Join-Path $d "data\auth\credential_secret"))
"NETX_SCHEDULER_HEARTBEAT_PATH" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime\scheduler_heartbeat.json")) "NETX_SCHEDULER_HEARTBEAT_PATH" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime\scheduler_heartbeat.json"))
"NETX_RUN_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime")) "NETX_RUN_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\runtime"))
"NETX_BIZ_STATE_SPOOL_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\biz_state_spool")) "NETX_BIZ_STATE_SPOOL_DIR" = (ConvertTo-NetxFsPath (Join-Path $d "data\biz_state_spool"))
@ -156,6 +157,18 @@ function Get-NetxDataEnvMap {
} }
} }
function New-NetxFernetKey {
# cryptography.fernet.Fernet.generate_key() == urlsafe_b64encode(os.urandom(32))
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
} finally {
$rng.Dispose()
}
return [Convert]::ToBase64String($bytes).Replace('+', '-').Replace('/', '_')
}
function Ensure-NetxDataDirectories { function Ensure-NetxDataDirectories {
param([string]$DataRoot) param([string]$DataRoot)
$subs = @( $subs = @(
@ -201,6 +214,146 @@ function Test-NetxApiHealthy {
} }
} }
function Wait-NetxApiHealthy {
param(
[string]$HostName = "127.0.0.1",
[int]$Port = 8890,
[int]$TimeoutSec = 180,
[int]$PollMs = 500
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
if (Test-NetxApiHealthy -HostName $HostName -Port $Port -TimeoutSec 2) {
return $true
}
Start-Sleep -Milliseconds $PollMs
}
return $false
}
function ConvertTo-NetxProcessArgument {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
# Start-Process joins string[] args with spaces and does NOT quote values that
# contain spaces (e.g. C:\Program Files\NetX). Always emit one argv token.
if ($Value -match '[\s"]') {
return '"' + ($Value -replace '"', '\"') + '"'
}
return $Value
}
function ConvertTo-NetxProcessArgumentString {
param([Parameter(Mandatory = $true)][string[]]$Arguments)
return (($Arguments | ForEach-Object { ConvertTo-NetxProcessArgument -Value "$_" }) -join " ")
}
function Start-NetxPowerShell {
param(
[Parameter(Mandatory = $true)][string]$File,
[string[]]$Arguments = @(),
[string]$WorkingDirectory = "",
[ValidateSet("Hidden", "Normal", "Minimized")]
[string]$WindowStyle = "Hidden",
[switch]$Wait = $false,
[switch]$PassThru = $false
)
$parts = @(
"-NoProfile",
"-WindowStyle", $WindowStyle,
"-ExecutionPolicy", "Bypass",
"-File", $File
) + $Arguments
$sp = @{
FilePath = "powershell.exe"
ArgumentList = (ConvertTo-NetxProcessArgumentString -Arguments $parts)
WindowStyle = $WindowStyle
}
if ($WorkingDirectory) { $sp.WorkingDirectory = $WorkingDirectory }
if ($Wait) { $sp.Wait = $true }
if ($PassThru -or $Wait) { $sp.PassThru = $true }
return Start-Process @sp
}
function Get-NetxSystemPython {
# Prefer a real interpreter; skip the WindowsApps Store stub.
$candidates = @()
foreach ($cmd in @("python", "python3", "py")) {
$c = Get-Command $cmd -ErrorAction SilentlyContinue
if ($c -and $c.Source -and ($c.Source -notmatch '\\WindowsApps\\')) {
$candidates += $c.Source
}
}
foreach ($p in @(
"$env:LOCALAPPDATA\Python\pythoncore-3.14-64\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python312\python.exe",
"$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
"$env:ProgramFiles\Python312\python.exe",
"$env:ProgramFiles\Python311\python.exe"
)) {
if ($p -and (Test-Path $p)) { $candidates += $p }
}
foreach ($p in ($candidates | Select-Object -Unique)) {
try {
$v = & $p -c "import sys; print('%d.%d'%sys.version_info[:2])" 2>$null
if ($v -match '^(3\.(1[1-9]|[2-9]\d))$') { return $p }
} catch {}
}
return $null
}
function Ensure-NetxVenv {
param(
[Parameter(Mandatory = $true)][string]$ProgramRoot,
[switch]$ForcePip = $false
)
$venvPy = Join-Path $ProgramRoot ".venv\Scripts\python.exe"
$req = Join-Path $ProgramRoot "requirements.txt"
if ((Test-Path $venvPy) -and -not $ForcePip) {
return $venvPy
}
if (-not (Test-Path $venvPy)) {
$py = Get-NetxSystemPython
if (-not $py) {
throw "python_not_found: install Python 3.11+ (or ship Setup built with -CreateVenv)"
}
Write-Host "==> Creating .venv with $py"
$venvDir = Join-Path $ProgramRoot ".venv"
try {
& $py -m venv $venvDir
} catch {
throw "venv_create_failed: cannot write $venvDir (need admin / ship -CreateVenv). $($_.Exception.Message)"
}
if (-not (Test-Path $venvPy)) {
throw "venv_create_failed: missing $venvPy (Program Files may be read-only without elevation)"
}
$ForcePip = $true
}
if ($ForcePip) {
if (-not (Test-Path $req)) { throw "requirements_missing: $req" }
Write-Host "==> pip install -r requirements.txt"
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r $req
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
}
return $venvPy
}
function Stop-NetxTrayProcesses {
param([string]$ProgramRoot = "")
$hits = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and $_.CommandLine -match 'netx_tray\.ps1'
})
if ($ProgramRoot) {
$esc = [regex]::Escape($ProgramRoot)
$hits = @($hits | Where-Object { $_.CommandLine -match $esc -or $_.CommandLine -match 'netx_tray\.ps1' })
}
foreach ($p in $hits) {
try {
Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop
Write-Host "Stopped NetX tray PID=$($p.ProcessId)"
} catch {}
}
}
function ConvertTo-NetxSqlLiteral([string]$Value) { function ConvertTo-NetxSqlLiteral([string]$Value) {
# Single-quote escaping for PostgreSQL string literals. # Single-quote escaping for PostgreSQL string literals.
return ($Value -replace "'", "''") return ($Value -replace "'", "''")

View file

@ -1,10 +1,11 @@
param( param(
[string]$Version = "", [string]$Version = "",
[string]$OutDir = "", [string]$OutDir = "",
[switch]$SkipWebBuild = $false, [switch]$SkipWebBuild = $false,
[switch]$SkipPostgresDownload = $false, [switch]$SkipPostgresDownload = $false,
[switch]$SkipZip = $false, [switch]$SkipZip = $false,
[switch]$CreateVenv = $false # Default on: offline Setup must ship .venv so target PCs never pip-install.
[switch]$CreateVenv = $true
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -103,7 +104,7 @@ foreach ($name in @(
"download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1", "download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1",
"stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1", "stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1",
"launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1", "launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1",
"service_run.ps1", "install_update_task.ps1", "sign_release.ps1", "service_run.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1",
"build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json" "build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json"
)) { )) {
$src = Join-Path $PSScriptRoot $name $src = Join-Path $PSScriptRoot $name
@ -159,7 +160,7 @@ Set-Content -Path (Join-Path $stage ".portable") -Value "1" -Encoding ascii
# Root .cmd launchers (Explorer / Start Menu friendly; ASCII-only). # Root .cmd launchers (Explorer / Start Menu friendly; ASCII-only).
$cmdSrc = Join-Path $PSScriptRoot "cmd" $cmdSrc = Join-Path $PSScriptRoot "cmd"
foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd")) { foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd", "NetX-Stop.cmd")) {
$c = Join-Path $cmdSrc $cmdName $c = Join-Path $cmdSrc $cmdName
if (Test-Path $c) { if (Test-Path $c) {
Copy-Item -Path $c -Destination (Join-Path $stage $cmdName) -Force Copy-Item -Path $c -Destination (Join-Path $stage $cmdName) -Force
@ -167,10 +168,11 @@ foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd"))
} }
if ($CreateVenv) { if ($CreateVenv) {
Write-Host "==> Creating .venv in stage (requires Python 3.11+ on PATH)" Write-Host "==> Creating .venv in stage (ships with Setup; target should NOT pip-install)"
$py = (Get-Command python -ErrorAction SilentlyContinue) $pyPath = Get-NetxSystemPython
if (-not $py) { throw "python_not_found_for_venv" } if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" }
& $py.Source -m venv (Join-Path $stage ".venv") Write-Host " Using: $pyPath"
& $pyPath -m venv (Join-Path $stage ".venv")
$venvPy = Join-Path $stage ".venv\Scripts\python.exe" $venvPy = Join-Path $stage ".venv\Scripts\python.exe"
& $venvPy -m pip install --upgrade pip & $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r (Join-Path $stage "requirements.txt") & $venvPy -m pip install -r (Join-Path $stage "requirements.txt")

View file

@ -3,5 +3,26 @@ setlocal
set "ROOT=%~dp0" set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%" cd /d "%ROOT%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action setup -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" title NetX — Reconfigure database
exit /b %ERRORLEVEL% echo.
echo NetX database reconfigure / 重新配置数据库
echo Prefer the installer wizard for first-time setup.
echo 首次安装请用安装向导选择内置或外置数据库。
echo This window is for repair / reconfigure only.
echo 本窗口仅用于修复或重新配置。
echo.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\setup_first_run.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
set "EC=%ERRORLEVEL%"
if not "%EC%"=="0" (
echo.
echo Setup failed / 配置失败 exit=%EC%
pause
exit /b %EC%
)
echo.
echo Starting NetX tray... / 正在启动托盘...
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\netx_tray.ps1" -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
echo.
echo Done. You can close this window. / 完成,可关闭本窗口。
pause
exit /b 0

View file

@ -3,5 +3,5 @@ setlocal
set "ROOT=%~dp0" set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%" cd /d "%ROOT%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action start -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action start -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b %ERRORLEVEL% exit /b 0

View file

@ -0,0 +1,7 @@
@echo off
setlocal
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%"
start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action stop -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX"
exit /b 0

View file

@ -3,5 +3,5 @@ setlocal
set "ROOT=%~dp0" set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
cd /d "%ROOT%" cd /d "%ROOT%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action tray -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch start "" powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%ROOT%\packaging\launch_shortcut.ps1" -Action tray -ProgramRoot "%ROOT%" -DataRoot "%ProgramData%\NetX" -StartOnLaunch
exit /b %ERRORLEVEL% exit /b 0

View file

@ -1,6 +1,7 @@
; NetX Windows installer (Inno Setup 6+) ; NetX Windows installer (Inno Setup 6+)
; Compile after: packaging\build_release.ps1 ; Compile after: packaging\build_release.ps1
; ISCC.exe packaging\installer\netx.iss ; ISCC.exe packaging\installer\netx.iss
; Encoding: UTF-8 with BOM (required for Chinese CustomMessages)
#define MyAppName "NetX" #define MyAppName "NetX"
#ifndef MyAppVersion #ifndef MyAppVersion
@ -25,7 +26,10 @@ DefaultGroupName=NetX
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
LicenseFile={#StageDir}\LICENSE LicenseFile={#StageDir}\LICENSE
OutputDir=..\release OutputDir=..\release
OutputBaseFilename=NetX-Setup-{#MyAppVersion} #ifndef OutputBaseFilename
#define OutputBaseFilename "NetX-Setup-" + MyAppVersion
#endif
OutputBaseFilename={#OutputBaseFilename}
SetupIconFile={#IconFile} SetupIconFile={#IconFile}
UninstallDisplayIcon={app}\packaging\assets\netx.ico UninstallDisplayIcon={app}\packaging\assets\netx.ico
Compression=lzma2 Compression=lzma2
@ -34,8 +38,10 @@ WizardStyle=modern
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin PrivilegesRequired=admin
; Data lives under {commonappdata}\NetX 鈥?not overwritten by upgrades ; Data lives under {commonappdata}\NetX - not overwritten by upgrades.
CloseApplications=yes ; Do NOT use CloseApplications=yes — it can freeze/beep on the Tasks/Ready
; pages while scanning locked NetX tray/service processes with no visible dialog.
CloseApplications=no
ShowLanguageDialog=yes ShowLanguageDialog=yes
[Languages] [Languages]
@ -44,26 +50,76 @@ Name: "chinesesimplified"; MessagesFile: "Languages\ChineseSimplified.isl"
[CustomMessages] [CustomMessages]
english.CreateDesktopIcon=Create a desktop shortcut english.CreateDesktopIcon=Create a desktop shortcut
english.SetupOptions=Setup options: english.SetupOptions=After install:
english.RunFirstSetup=Run first-time setup after install (bundled database, offline)
english.StartTrayNow=Start NetX tray now english.StartTrayNow=Start NetX tray now
english.EnableAutostart=Start NetX tray at Windows logon english.EnableAutostart=Start NetX tray at Windows logon
english.EnableAutoUpdate=Enable daily silent auto-update (needs network later) english.EnableAutoUpdate=Enable daily silent auto-update (needs network later)
english.InstallService=Install as Windows Service (uses bundled WinSW, offline) english.InstallService=Install as Windows Service (uses bundled WinSW, offline)
chinesesimplified.CreateDesktopIcon=鍒涘缓妗岄潰蹇嵎鏂瑰紡 english.UninstallDeleteData=Also delete NetX data (%ProgramData%\NetX)?%n%nYes = remove database, settings, and secrets%nNo = keep data for a later reinstall
chinesesimplified.SetupOptions=瀹夎閫夐」: english.DbPageCaption=Database
chinesesimplified.RunFirstSetup=瀹夎鍚庤繍琛岄娆¢厤缃紙鍐呯疆鏁版嵁搴擄紝鍙绾匡級 english.DbPageDescription=Choose built-in or external PostgreSQL. Optional credential key: paste from an existing install, or leave empty to auto-generate.
chinesesimplified.StartTrayNow=绔嬪嵆鍚姩 NetX 鎵樼洏 english.DbBundled=Built-in PostgreSQL (portable, offline)
chinesesimplified.EnableAutostart=寮€鏈烘椂鑷姩鍚姩 NetX 鎵樼洏 english.DbExternal=External PostgreSQL (existing server)
chinesesimplified.EnableAutoUpdate=鍚敤姣忔棩闈欓粯鑷姩鏇存柊锛堜互鍚庨渶瑕佽仈缃戯級 english.DbHost=Host
chinesesimplified.InstallService=瀹夎涓?Windows 鏈嶅姟锛堜娇鐢ㄥ凡鎹嗙粦鐨?WinSW锛屽彲绂荤嚎锛? english.DbPort=Port
english.DbUser=User
english.DbPassword=Password
english.DbName=Database
english.CredKey=Credential key
english.CredKeyHint=NETX_CREDENTIAL_SECRET_KEY (optional — empty = auto-generate)
english.DbFieldsRequired=Please fill in host, port, user, password, and database name.
english.DbTesting=Testing PostgreSQL connection, please wait…
english.DbTestFailed=Cannot connect to PostgreSQL with these settings.%n%n%1%n%nFix the settings and try again.
english.DbTestExtractFailed=Could not extract PostgreSQL client tools for connection test.
english.DbSilentExternalMissing=Silent install with external DB requires /DbHost /DbUser /DbPassword /DbName (optional /DbPort).
english.DbApplyFailed=Database configuration failed after file install (exit %1). See %ProgramData%\NetX or run Start Menu → Reconfigure database.
english.ReconfigureDb=Reconfigure database
chinesesimplified.CreateDesktopIcon=创建桌面快捷方式
chinesesimplified.SetupOptions=安装完成后:
chinesesimplified.StartTrayNow=立即启动 NetX 托盘
chinesesimplified.EnableAutostart=开机时自动启动 NetX 托盘
chinesesimplified.EnableAutoUpdate=启用每日静默自动更新(以后需要联网)
chinesesimplified.InstallService=安装为 Windows 服务(使用已捆绑的 WinSW,可离线)
chinesesimplified.UninstallDeleteData=是否同时删除 NetX 数据(%ProgramData%\NetX)?%n%n是 = 删除数据库、配置和密钥%n否 = 保留数据以便以后重装
chinesesimplified.DbPageCaption=数据库
chinesesimplified.DbPageDescription=选择内置或外置 PostgreSQL。可选粘贴已有 NETX_CREDENTIAL_SECRET_KEY;留空则自动生成。
chinesesimplified.DbBundled=内置 PostgreSQL(便携,可离线)
chinesesimplified.DbExternal=外置 PostgreSQL(已有服务器)
chinesesimplified.DbHost=主机
chinesesimplified.DbPort=端口
chinesesimplified.DbUser=用户
chinesesimplified.DbPassword=密码
chinesesimplified.DbName=数据库名
chinesesimplified.CredKey=凭据密钥
chinesesimplified.CredKeyHint=NETX_CREDENTIAL_SECRET_KEY(可选,留空=自动生成)
chinesesimplified.DbFieldsRequired=请填写主机、端口、用户、密码和数据库名。
chinesesimplified.DbTesting=正在测试 PostgreSQL 连接,请稍候…
chinesesimplified.DbTestFailed=无法用当前设置连接 PostgreSQL。%n%n%1%n%n请改正后重试。
chinesesimplified.DbTestExtractFailed=无法解压 PostgreSQL 客户端以测试连接。
chinesesimplified.DbSilentExternalMissing=静默安装外置库需要参数 /DbHost /DbUser /DbPassword /DbName(可选 /DbPort)。
chinesesimplified.DbApplyFailed=文件安装后数据库配置失败(退出码 %1)。请查看 %ProgramData%\NetX,或运行开始菜单 → 重新配置数据库。
chinesesimplified.ReconfigureDb=重新配置数据库
[Tasks] [Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
Name: "firstrun"; Description: "{cm:RunFirstSetup}"; GroupDescription: "{cm:SetupOptions}"; Flags: checkedonce
[Files] [Files]
; Entire release stage 鈫?{app}. Exclude .portable so installed builds use ProgramData. ; Entire release stage -> {app}. Exclude .portable so installed builds use ProgramData.
Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: ".portable" Source: "{#StageDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: ".portable"
; Connection-test tools (wizard Next / silent PrepareToInstall only)
Source: "test_pg_conn.ps1"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\psql.exe"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libpq.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libssl-3-x64.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libcrypto-3-x64.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libintl-9.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libiconv-2.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libwinpthread-1.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\zlib1.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\liblz4.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libzstd.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libxml2.dll"; Flags: dontcopy
Source: "{#StageDir}\postgres\pgsql\bin\libxslt.dll"; Flags: dontcopy
[Dirs] [Dirs]
Name: "{commonappdata}\NetX" Name: "{commonappdata}\NetX"
@ -73,31 +129,656 @@ Name: "{commonappdata}\NetX\data\runtime"
Name: "{commonappdata}\NetX\pgdata" Name: "{commonappdata}\NetX\pgdata"
Name: "{commonappdata}\NetX\backups" Name: "{commonappdata}\NetX\backups"
[InstallDelete]
; Remove legacy desktop shortcuts from older Setup builds (keep a single NetX icon).
Type: files; Name: "{commondesktop}\NetX Start.lnk"
Type: files; Name: "{commondesktop}\NetX First-time setup.lnk"
Type: files; Name: "{commondesktop}\首次配置(内置或外置数据库).lnk"
[Icons] [Icons]
; Prefer .cmd targets so Server 2012 / classic Start Menu shows normal program entries. ; Prefer .cmd targets so Server 2012 / classic Start Menu shows normal program entries.
; Also pin First-time setup + Start to common desktop (always), not only optional tray icon.
Name: "{group}\NetX Tray"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\NetX Tray"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Start NetX"; Filename: "{app}\NetX-Start.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Start NetX"; Filename: "{app}\NetX-Start.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Stop NetX"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\packaging\launch_shortcut.ps1"" -Action stop -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Stop NetX"; Filename: "{app}\NetX-Stop.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Check for updates"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\packaging\launch_shortcut.ps1"" -Action update -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Check for updates"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\packaging\launch_shortcut.ps1"" -Action update -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Open NetX UI"; Filename: "http://127.0.0.1:8890/"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Open NetX UI"; Filename: "http://127.0.0.1:8890/"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\First-time setup"; Filename: "{app}\NetX-FirstRun.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\{cm:ReconfigureDb}"; Filename: "{app}\NetX-FirstRun.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Install Windows Service (admin)"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_service.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -Start"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Install Windows Service (admin)"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_service.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -Start"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Enable silent auto-update (daily)"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_update_task.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Enable silent auto-update (daily)"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_update_task.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{group}\Enable start at logon"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" Name: "{group}\Enable start at logon"; Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{commondesktop}\NetX First-time setup"; Filename: "{app}\NetX-FirstRun.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" ; One desktop shortcut only (tray = start + tray UI). Start Menu keeps Start/Stop entries.
Name: "{commondesktop}\NetX Start"; Filename: "{app}\NetX-Start.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"
Name: "{commondesktop}\NetX"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"; Tasks: desktopicon Name: "{commondesktop}\NetX"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"; Tasks: desktopicon
[Run] [Run]
; First-run is NonInteractive + bundled DB so offline machines succeed without prompts or downloads. Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\netx_tray.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -StartOnLaunch"; WorkingDir: "{app}"; Description: "{cm:StartTrayNow}"; Flags: postinstall runhidden nowait skipifsilent
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\setup_first_run.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -NonInteractive -DbMode bundled"; WorkingDir: "{app}"; Flags: postinstall skipifsilent; Tasks: firstrun Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}"; Description: "{cm:EnableAutostart}"; Flags: postinstall runhidden 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: "{cm:StartTrayNow}"; Flags: postinstall nowait skipifsilent unchecked Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\install_update_task.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "{cm:EnableAutoUpdate}"; Flags: postinstall runhidden skipifsilent unchecked
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_autostart.ps1"" -ProgramRoot ""{app}"""; WorkingDir: "{app}"; Description: "{cm:EnableAutostart}"; Flags: postinstall skipifsilent unchecked Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\install_service.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -Start"; WorkingDir: "{app}"; Description: "{cm:InstallService}"; Flags: postinstall runhidden skipifsilent unchecked
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_update_task.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "{cm:EnableAutoUpdate}"; Flags: postinstall skipifsilent unchecked
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -File ""{app}\packaging\install_service.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"" -Start"; WorkingDir: "{app}"; Description: "{cm:InstallService}"; Flags: postinstall skipifsilent unchecked ; Stop runtime BEFORE files are deleted. Do not also Exec prepare from
; InitializeUninstall (that previously raced/killed unins000 or hung the UI).
[UninstallRun]
Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""{app}\packaging\uninstall_prepare.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; RunOnceId: "StopNetX"; Flags: runhidden waituntilterminated
[UninstallDelete] [UninstallDelete]
; Do NOT delete {commonappdata}\NetX 鈥?preserves DB and secrets across reinstall ; Program files always removed. Data dir only when user chose Yes in InitializeUninstall.
Type: filesandordirs; Name: "{app}" Type: filesandordirs; Name: "{app}"
[Code]
var
GDeleteNetxData: Boolean;
GTempDeleteDataScript: String;
DbPage: TWizardPage;
RbBundled: TNewRadioButton;
RbExternal: TNewRadioButton;
LblHost: TNewStaticText;
LblPort: TNewStaticText;
LblUser: TNewStaticText;
LblPassword: TNewStaticText;
LblDbName: TNewStaticText;
EdHost: TNewEdit;
EdPort: TNewEdit;
EdUser: TNewEdit;
EdPassword: TNewEdit;
EdDbName: TNewEdit;
LblCredKey: TNewStaticText;
LblCredHint: TNewStaticText;
EdCredKey: TNewEdit;
GDbMode: String;
GDbUrl: String;
GDbHost: String;
GDbPort: String;
GDbUser: String;
GDbPassword: String;
GDbName: String;
GCredKey: String;
GSkipDbPage: Boolean;
GDbConfigured: Boolean;
GPgToolsReady: Boolean;
GDbConnOk: Boolean;
GNextBtnCaption: String;
function EnvHasDbMode(): Boolean;
var
Lines: TArrayOfString;
i: Integer;
Line: String;
begin
Result := False;
if not LoadStringsFromFile(ExpandConstant('{commonappdata}\NetX\.env'), Lines) then
Exit;
for i := 0 to GetArrayLength(Lines) - 1 do
begin
Line := Trim(Lines[i]);
if Pos('NETX_DB_MODE=', Line) = 1 then
begin
if Length(Line) > Length('NETX_DB_MODE=') then
begin
Result := True;
Exit;
end;
end;
end;
end;
procedure UpdateDbFieldState();
var
Ext: Boolean;
begin
Ext := RbExternal.Checked;
LblHost.Enabled := Ext;
LblPort.Enabled := Ext;
LblUser.Enabled := Ext;
LblPassword.Enabled := Ext;
LblDbName.Enabled := Ext;
EdHost.Enabled := Ext;
EdPort.Enabled := Ext;
EdUser.Enabled := Ext;
EdPassword.Enabled := Ext;
EdDbName.Enabled := Ext;
end;
procedure DbModeClick(Sender: TObject);
begin
UpdateDbFieldState();
end;
function ExtractPgClientTools(): Boolean;
begin
if GPgToolsReady and FileExists(ExpandConstant('{tmp}\psql.exe')) and
FileExists(ExpandConstant('{tmp}\test_pg_conn.ps1')) then
begin
Result := True;
Exit;
end;
Result := False;
try
ExtractTemporaryFile('test_pg_conn.ps1');
ExtractTemporaryFile('psql.exe');
ExtractTemporaryFile('libpq.dll');
ExtractTemporaryFile('libssl-3-x64.dll');
ExtractTemporaryFile('libcrypto-3-x64.dll');
ExtractTemporaryFile('libintl-9.dll');
ExtractTemporaryFile('libiconv-2.dll');
ExtractTemporaryFile('libwinpthread-1.dll');
ExtractTemporaryFile('zlib1.dll');
ExtractTemporaryFile('liblz4.dll');
ExtractTemporaryFile('libzstd.dll');
ExtractTemporaryFile('libxml2.dll');
ExtractTemporaryFile('libxslt.dll');
Result := FileExists(ExpandConstant('{tmp}\psql.exe')) and
FileExists(ExpandConstant('{tmp}\test_pg_conn.ps1'));
GPgToolsReady := Result;
except
Result := False;
GPgToolsReady := False;
end;
end;
procedure SetWizardBusy(const Busy: Boolean; const StatusText: String);
begin
if Busy then
begin
if GNextBtnCaption = '' then
GNextBtnCaption := WizardForm.NextButton.Caption;
WizardForm.NextButton.Caption := StatusText;
WizardForm.NextButton.Enabled := False;
WizardForm.BackButton.Enabled := False;
WizardForm.CancelButton.Enabled := False;
end
else
begin
if GNextBtnCaption <> '' then
WizardForm.NextButton.Caption := GNextBtnCaption;
WizardForm.NextButton.Enabled := True;
WizardForm.BackButton.Enabled := True;
WizardForm.CancelButton.Enabled := True;
end;
WizardForm.Refresh;
end;
function TestExternalDb(const Host, Port, User, Password, DbName: String; var ErrMsg: String): Boolean;
var
ResultCode: Integer;
UrlFile: String;
ErrFile: String;
PwFile: String;
Params: String;
Lines: TArrayOfString;
i: Integer;
begin
Result := False;
ErrMsg := '';
if not ExtractPgClientTools() then
begin
ErrMsg := ExpandConstant('{cm:DbTestExtractFailed}');
Exit;
end;
UrlFile := ExpandConstant('{tmp}\netx_db_url.txt');
ErrFile := ExpandConstant('{tmp}\netx_db_test_err.txt');
PwFile := ExpandConstant('{tmp}\netx_db_pw.txt');
DeleteFile(UrlFile);
DeleteFile(ErrFile);
SaveStringToFile(PwFile, Password, False);
Params :=
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{tmp}\test_pg_conn.ps1') + '"' +
' -PgHost "' + Host + '"' +
' -Port ' + Port +
' -User "' + User + '"' +
' -PasswordFile "' + PwFile + '"' +
' -Database "' + DbName + '"' +
' -PsqlPath "' + ExpandConstant('{tmp}\psql.exe') + '"' +
' -OutUrlFile "' + UrlFile + '"' +
' -OutErrFile "' + ErrFile + '"';
if not Exec('powershell.exe', Params, ExpandConstant('{tmp}'), SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
ErrMsg := 'powershell_exec_failed';
DeleteFile(PwFile);
Exit;
end;
DeleteFile(PwFile);
if ResultCode <> 0 then
begin
ErrMsg := 'psql/test exit ' + IntToStr(ResultCode);
if LoadStringsFromFile(ErrFile, Lines) then
begin
ErrMsg := '';
for i := 0 to GetArrayLength(Lines) - 1 do
begin
if ErrMsg <> '' then
ErrMsg := ErrMsg + #13#10;
ErrMsg := ErrMsg + Lines[i];
end;
if ErrMsg = '' then
ErrMsg := 'psql/test exit ' + IntToStr(ResultCode);
end;
Exit;
end;
if not LoadStringsFromFile(UrlFile, Lines) or (GetArrayLength(Lines) < 1) or (Trim(Lines[0]) = '') then
begin
ErrMsg := 'url_file_missing';
Exit;
end;
GDbUrl := Trim(Lines[0]);
Result := True;
end;
function SaveExternalFieldsFromControls(var ErrMsg: String): Boolean;
begin
Result := False;
GDbHost := Trim(EdHost.Text);
GDbPort := Trim(EdPort.Text);
GDbUser := Trim(EdUser.Text);
GDbPassword := EdPassword.Text;
GDbName := Trim(EdDbName.Text);
GDbConnOk := False;
GDbUrl := '';
if (GDbHost = '') or (GDbPort = '') or (GDbUser = '') or
(GDbPassword = '') or (GDbName = '') then
begin
ErrMsg := ExpandConstant('{cm:DbFieldsRequired}');
Exit;
end;
GDbMode := 'external';
Result := True;
end;
function ValidateExternalDbSaved(var ErrMsg: String): Boolean;
begin
Result := False;
if GDbConnOk and (GDbUrl <> '') then
begin
Result := True;
Exit;
end;
if not TestExternalDb(GDbHost, GDbPort, GDbUser, GDbPassword, GDbName, ErrMsg) then
begin
if ErrMsg = '' then
ErrMsg := 'connection_failed';
ErrMsg := FmtMessage(ExpandConstant('{cm:DbTestFailed}'), [ErrMsg]);
GDbConnOk := False;
Exit;
end;
GDbConnOk := True;
Result := True;
end;
function ApplySilentDbParams(var ErrMsg: String): Boolean;
var
Mode, Host, Port, User, Password, DbName: String;
begin
Result := False;
GCredKey := Trim(ExpandConstant('{param:CredentialSecretKey|}'));
Mode := LowerCase(ExpandConstant('{param:DbMode|bundled}'));
if Mode = '' then
Mode := 'bundled';
if Mode = 'bundled' then
begin
GDbMode := 'bundled';
GDbUrl := '';
Result := True;
Exit;
end;
if Mode <> 'external' then
begin
ErrMsg := 'invalid DbMode (use bundled or external)';
Exit;
end;
Host := ExpandConstant('{param:DbHost|}');
Port := ExpandConstant('{param:DbPort|5432}');
User := ExpandConstant('{param:DbUser|}');
Password := ExpandConstant('{param:DbPassword|}');
DbName := ExpandConstant('{param:DbName|}');
if (Host = '') or (User = '') or (Password = '') or (DbName = '') then
begin
ErrMsg := ExpandConstant('{cm:DbSilentExternalMissing}');
Exit;
end;
if not TestExternalDb(Host, Port, User, Password, DbName, ErrMsg) then
begin
if ErrMsg = '' then
ErrMsg := 'connection_failed';
ErrMsg := FmtMessage(ExpandConstant('{cm:DbTestFailed}'), [ErrMsg]);
Exit;
end;
GDbMode := 'external';
Result := True;
end;
procedure CaptureCredKeyFromControls();
begin
if EdCredKey <> nil then
GCredKey := Trim(EdCredKey.Text)
else
GCredKey := '';
end;
procedure InitializeWizard();
var
TopPos: Integer;
begin
GDbMode := 'bundled';
GDbUrl := '';
GDbHost := '';
GDbPort := '5432';
GDbUser := '';
GDbPassword := '';
GDbName := '';
GCredKey := '';
GDbConfigured := False;
GPgToolsReady := False;
GDbConnOk := False;
GSkipDbPage := EnvHasDbMode();
DbPage := CreateCustomPage(wpSelectDir,
ExpandConstant('{cm:DbPageCaption}'),
ExpandConstant('{cm:DbPageDescription}'));
RbBundled := TNewRadioButton.Create(DbPage);
RbBundled.Parent := DbPage.Surface;
RbBundled.Caption := ExpandConstant('{cm:DbBundled}');
RbBundled.Checked := True;
RbBundled.Top := ScaleY(8);
RbBundled.Left := ScaleX(0);
RbBundled.Width := DbPage.SurfaceWidth;
RbBundled.OnClick := @DbModeClick;
RbExternal := TNewRadioButton.Create(DbPage);
RbExternal.Parent := DbPage.Surface;
RbExternal.Caption := ExpandConstant('{cm:DbExternal}');
RbExternal.Top := RbBundled.Top + ScaleY(28);
RbExternal.Left := ScaleX(0);
RbExternal.Width := DbPage.SurfaceWidth;
RbExternal.OnClick := @DbModeClick;
TopPos := RbExternal.Top + ScaleY(36);
LblHost := TNewStaticText.Create(DbPage);
LblHost.Parent := DbPage.Surface;
LblHost.Caption := ExpandConstant('{cm:DbHost}');
LblHost.Top := TopPos;
LblHost.Left := ScaleX(16);
EdHost := TNewEdit.Create(DbPage);
EdHost.Parent := DbPage.Surface;
EdHost.Top := TopPos;
EdHost.Left := ScaleX(120);
EdHost.Width := ScaleX(220);
EdHost.Text := '127.0.0.1';
TopPos := TopPos + ScaleY(28);
LblPort := TNewStaticText.Create(DbPage);
LblPort.Parent := DbPage.Surface;
LblPort.Caption := ExpandConstant('{cm:DbPort}');
LblPort.Top := TopPos;
LblPort.Left := ScaleX(16);
EdPort := TNewEdit.Create(DbPage);
EdPort.Parent := DbPage.Surface;
EdPort.Top := TopPos;
EdPort.Left := ScaleX(120);
EdPort.Width := ScaleX(80);
EdPort.Text := '5432';
TopPos := TopPos + ScaleY(28);
LblUser := TNewStaticText.Create(DbPage);
LblUser.Parent := DbPage.Surface;
LblUser.Caption := ExpandConstant('{cm:DbUser}');
LblUser.Top := TopPos;
LblUser.Left := ScaleX(16);
EdUser := TNewEdit.Create(DbPage);
EdUser.Parent := DbPage.Surface;
EdUser.Top := TopPos;
EdUser.Left := ScaleX(120);
EdUser.Width := ScaleX(220);
EdUser.Text := 'netx';
TopPos := TopPos + ScaleY(28);
LblPassword := TNewStaticText.Create(DbPage);
LblPassword.Parent := DbPage.Surface;
LblPassword.Caption := ExpandConstant('{cm:DbPassword}');
LblPassword.Top := TopPos;
LblPassword.Left := ScaleX(16);
EdPassword := TNewEdit.Create(DbPage);
EdPassword.Parent := DbPage.Surface;
EdPassword.Top := TopPos;
EdPassword.Left := ScaleX(120);
EdPassword.Width := ScaleX(220);
EdPassword.PasswordChar := '*';
TopPos := TopPos + ScaleY(28);
LblDbName := TNewStaticText.Create(DbPage);
LblDbName.Parent := DbPage.Surface;
LblDbName.Caption := ExpandConstant('{cm:DbName}');
LblDbName.Top := TopPos;
LblDbName.Left := ScaleX(16);
EdDbName := TNewEdit.Create(DbPage);
EdDbName.Parent := DbPage.Surface;
EdDbName.Top := TopPos;
EdDbName.Left := ScaleX(120);
EdDbName.Width := ScaleX(220);
EdDbName.Text := 'netx';
TopPos := TopPos + ScaleY(36);
LblCredKey := TNewStaticText.Create(DbPage);
LblCredKey.Parent := DbPage.Surface;
LblCredKey.Caption := ExpandConstant('{cm:CredKey}');
LblCredKey.Top := TopPos;
LblCredKey.Left := ScaleX(0);
EdCredKey := TNewEdit.Create(DbPage);
EdCredKey.Parent := DbPage.Surface;
EdCredKey.Top := TopPos;
EdCredKey.Left := ScaleX(120);
EdCredKey.Width := DbPage.SurfaceWidth - ScaleX(120);
EdCredKey.Text := '';
TopPos := TopPos + ScaleY(24);
LblCredHint := TNewStaticText.Create(DbPage);
LblCredHint.Parent := DbPage.Surface;
LblCredHint.Caption := ExpandConstant('{cm:CredKeyHint}');
LblCredHint.Top := TopPos;
LblCredHint.Left := ScaleX(120);
LblCredHint.Width := DbPage.SurfaceWidth - ScaleX(120);
LblCredHint.AutoSize := False;
UpdateDbFieldState();
if WizardSilent then
begin
{ Params applied in PrepareToInstall }
end;
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (DbPage <> nil) and (PageID = DbPage.ID) then
Result := GSkipDbPage;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
ErrMsg: String;
begin
Result := True;
{ Only the DB page runs validation. Other pages must always proceed
(Tasks/Ready used to appear "dead" when CloseApplications hung). }
if (DbPage = nil) or (CurPageID <> DbPage.ID) or GSkipDbPage then
Exit;
CaptureCredKeyFromControls();
if RbBundled.Checked then
begin
GDbMode := 'bundled';
GDbUrl := '';
GDbConnOk := True;
Result := True;
Exit;
end;
if not SaveExternalFieldsFromControls(ErrMsg) then
begin
MsgBox(ErrMsg, mbError, MB_OK);
Result := False;
Exit;
end;
{ Connection test here so bad settings never reach Tasks/Ready.
Show busy state — ExtractTemporaryFile + psql can take a few seconds. }
SetWizardBusy(True, ExpandConstant('{cm:DbTesting}'));
try
Result := ValidateExternalDbSaved(ErrMsg);
finally
SetWizardBusy(False, '');
end;
if not Result then
MsgBox(ErrMsg, mbError, MB_OK);
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ErrMsg: String;
begin
Result := '';
NeedsRestart := False;
if GSkipDbPage then
Exit;
if WizardSilent then
begin
if not ApplySilentDbParams(ErrMsg) then
Result := ErrMsg;
Exit;
end;
if GDbMode = '' then
GDbMode := 'bundled';
{ Re-check only if user never got a successful test (should be rare). }
if (GDbMode = 'external') and (not GDbConnOk) then
begin
if not ValidateExternalDbSaved(ErrMsg) then
Result := ErrMsg;
end;
end;
function ApplyDatabaseConfig(): Boolean;
var
ResultCode: Integer;
Params: String;
UrlFile: String;
KeyFile: String;
begin
Result := True;
if GSkipDbPage then
Exit;
Params :=
'-NoProfile -ExecutionPolicy Bypass -File "' +
ExpandConstant('{app}\packaging\setup_first_run.ps1') + '"' +
' -ProgramRoot "' + ExpandConstant('{app}') + '"' +
' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"' +
' -NonInteractive -DbMode ' + GDbMode;
if GDbMode = 'external' then
begin
UrlFile := ExpandConstant('{tmp}\netx_db_url.txt');
if GDbUrl <> '' then
SaveStringToFile(UrlFile, GDbUrl, False);
Params := Params + ' -ExternalDatabaseUrlFile "' + UrlFile + '" -SkipExternalProbe';
end;
if GCredKey <> '' then
begin
KeyFile := ExpandConstant('{tmp}\netx_cred_key.txt');
SaveStringToFile(KeyFile, GCredKey, False);
Params := Params + ' -CredentialSecretKeyFile "' + KeyFile + '"';
end;
if not Exec(
'powershell.exe', Params, ExpandConstant('{app}'),
SW_HIDE, ewWaitUntilTerminated, ResultCode) then
begin
Result := False;
Exit;
end;
if ResultCode <> 0 then
begin
MsgBox(FmtMessage(ExpandConstant('{cm:DbApplyFailed}'), [IntToStr(ResultCode)]), mbError, MB_OK);
Result := False;
Exit;
end;
GDbConfigured := True;
Result := True;
end;
function InitializeUninstall(): Boolean;
var
DeleteSrc: String;
begin
Result := True;
GDeleteNetxData := False;
GTempDeleteDataScript := ExpandConstant('{tmp}\netx_uninstall_delete_data.ps1');
// Copy data-wipe helper to TEMP before program files are removed.
DeleteSrc := ExpandConstant('{app}\packaging\uninstall_delete_data.ps1');
if FileExists(DeleteSrc) then
CopyFile(DeleteSrc, GTempDeleteDataScript, False);
if UninstallSilent then
begin
// Settings often uses QuietUninstallString; keep data by default.
GDeleteNetxData := False;
end
else
begin
GDeleteNetxData :=
MsgBox(ExpandConstant('{cm:UninstallDeleteData}'), mbConfirmation, MB_YESNO) = IDYES;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
ResultCode: Integer;
DataRoot: String;
begin
if (CurUninstallStep = usPostUninstall) and GDeleteNetxData then
begin
DataRoot := ExpandConstant('{commonappdata}\NetX');
if FileExists(GTempDeleteDataScript) then
begin
Exec(
'powershell.exe',
'-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' +
GTempDeleteDataScript + '" -DataRoot "' + DataRoot + '"',
ExpandConstant('{tmp}'),
SW_HIDE,
ewWaitUntilTerminated,
ResultCode
);
end;
if DirExists(DataRoot) then
DelTree(DataRoot, True, True, True);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
UninstallKey: String;
begin
if CurStep = ssPostInstall then
begin
ApplyDatabaseConfig();
UninstallKey :=
'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001}_is1';
RegDeleteValue(HKLM, UninstallKey, 'QuietUninstallString');
end;
end;

View file

@ -0,0 +1,104 @@
param(
[Parameter(Mandatory = $true)][string]$PgHost,
[int]$Port = 5432,
[Parameter(Mandatory = $true)][string]$User,
[string]$Password = "",
[string]$PasswordFile = "",
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$PsqlPath,
[string]$OutUrlFile = "",
[string]$OutErrFile = ""
)
$ErrorActionPreference = "Stop"
function Write-ErrFile([string]$Message) {
if ($OutErrFile) {
Set-Content -LiteralPath $OutErrFile -Value $Message -Encoding utf8
}
[Console]::Error.WriteLine($Message)
}
if (-not (Test-Path -LiteralPath $PsqlPath)) {
Write-ErrFile "psql_not_found: $PsqlPath"
exit 2
}
if ($PasswordFile) {
if (-not (Test-Path -LiteralPath $PasswordFile)) {
Write-ErrFile "password_file_missing"
exit 3
}
$Password = [IO.File]::ReadAllText($PasswordFile).TrimEnd("`r", "`n")
}
if ([string]::IsNullOrWhiteSpace($PgHost)) {
Write-ErrFile "host_required"
exit 3
}
if ([string]::IsNullOrWhiteSpace($User) -or [string]::IsNullOrWhiteSpace($Database)) {
Write-ErrFile "user_and_database_required"
exit 3
}
if ([string]::IsNullOrWhiteSpace($Password)) {
Write-ErrFile "password_required"
exit 3
}
if ($Port -lt 1 -or $Port -gt 65535) {
Write-ErrFile "invalid_port: $Port"
exit 3
}
$encUser = [uri]::EscapeDataString($User)
$encPw = [uri]::EscapeDataString($Password)
$encDb = [uri]::EscapeDataString($Database)
$url = "postgresql+psycopg://${encUser}:${encPw}@${PgHost}:${Port}/${encDb}"
$psqlDir = Split-Path -Parent $PsqlPath
$prevPath = $env:PATH
$prevPw = $env:PGPASSWORD
try {
$env:PATH = "$psqlDir;$env:PATH"
$env:PGPASSWORD = $Password
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PsqlPath
$psi.Arguments = "-h `"$PgHost`" -p $Port -U `"$User`" -d `"$Database`" -v ON_ERROR_STOP=1 -t -A -c `"SELECT 1`""
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.WorkingDirectory = $psqlDir
$p = [Diagnostics.Process]::Start($psi)
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
$p.WaitForExit()
if ($p.ExitCode -ne 0) {
$msg = (($stderr + "`n" + $stdout).Trim())
if (-not $msg) { $msg = "psql_exit_$($p.ExitCode)" }
Write-ErrFile "connection_failed: $msg"
exit 1
}
if (($stdout.Trim()) -notmatch '1') {
Write-ErrFile "unexpected_result: $($stdout.Trim())"
exit 1
}
} catch {
Write-ErrFile ("connection_failed: " + $_.Exception.Message)
exit 1
} finally {
$env:PATH = $prevPath
if ($null -eq $prevPw) {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
} else {
$env:PGPASSWORD = $prevPw
}
}
if ($OutUrlFile) {
$dir = Split-Path -Parent $OutUrlFile
if ($dir -and -not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -LiteralPath $OutUrlFile -Value $url -Encoding ascii -NoNewline
}
Write-Output "ok"
exit 0

View file

@ -7,7 +7,7 @@
) )
# Visible entrypoint for Start Menu / desktop shortcuts. # Visible entrypoint for Start Menu / desktop shortcuts.
# Shows a message box on failure (explorer-launched PowerShell otherwise flashes and exits). # Nested PowerShell runs are hidden; only failures show a message box.
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
. "$PSScriptRoot\_common.ps1" . "$PSScriptRoot\_common.ps1"
@ -42,63 +42,83 @@ function Show-NetxError([string]$Msg) {
) )
} }
function Invoke-NetxHiddenPs1 {
param(
[string]$File,
[string[]]$ExtraArgs = @(),
[switch]$NoWait = $false
)
return Start-NetxPowerShell -File $File `
-Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) `
-WorkingDirectory $prog -WindowStyle Hidden -PassThru -Wait:(-not $NoWait)
}
function Ensure-NetxEnv { function Ensure-NetxEnv {
$envPath = Join-Path $data ".env" $envPath = Join-Path $data ".env"
if (Test-Path $envPath) { return } if (Test-Path $envPath) { return }
Write-LaunchLog "missing .env — running setup_first_run -NonInteractive -DbMode bundled" # Do not silently force bundled DB — external-DB users must run interactive First-time setup.
$setup = Join-Path $PSScriptRoot "setup_first_run.ps1" $cmd = Join-Path $prog "NetX-FirstRun.cmd"
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $setup ` throw (T `
-ProgramRoot $prog -DataRoot $data -NonInteractive -DbMode bundled "First-time setup not done (missing $envPath).`nDouble-click desktop/Start Menu: First-time setup`nOr run: $cmd`nThere you can choose built-in or external PostgreSQL." `
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $envPath)) { "尚未完成首次配置(缺少 $envPath)。`n请双击桌面/开始菜单的「首次配置」`n或运行: $cmd`n在向导里可选内置或外置 PostgreSQL。")
$cmd = Join-Path $prog "NetX-FirstRun.cmd"
throw (T `
"First-time setup failed.`nDouble-click: $cmd`nOr: Start Menu → All Programs → NetX → First-time setup`nLog: $log" `
"首次配置失败。`n请双击: $cmd`n或: 开始菜单 → 所有程序 → NetX → First-time setup`n日志: $log")
}
} }
try { try {
Write-LaunchLog "action=$Action prog=$prog data=$data" Write-LaunchLog "action=$Action prog=$prog data=$data"
switch ($Action) { switch ($Action) {
"setup" { "setup" {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "setup_first_run.ps1") ` # Visible console so user can pick bundled vs external DB.
-ProgramRoot $prog -DataRoot $data $p = Start-NetxPowerShell -File (Join-Path $PSScriptRoot "setup_first_run.ps1") `
if ($LASTEXITCODE -ne 0) { throw "setup_exit_$LASTEXITCODE" } -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "setup_exit_$($p.ExitCode)" }
if (Test-Path (Join-Path $data ".env")) {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
} }
"stop" { "stop" {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "stop_netx_app.ps1") ` $p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "stop_netx_app.ps1")
-ProgramRoot $prog -DataRoot $data if ($null -ne $p.ExitCode -and $p.ExitCode -ne 0) { throw "stop_exit_$($p.ExitCode)" }
} }
"update" { "update" {
Ensure-NetxEnv Ensure-NetxEnv
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "check_update.ps1") ` # Update UI may need a visible window for prompts.
-ProgramRoot $prog -DataRoot $data $p = Start-NetxPowerShell -File (Join-Path $PSScriptRoot "check_update.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
-WorkingDirectory $prog -WindowStyle Normal -Wait -PassThru
if ($null -ne $p.ExitCode -and $p.ExitCode -ne 0 -and $p.ExitCode -ne 10) {
throw "update_exit_$($p.ExitCode)"
}
} }
"start" { "start" {
Ensure-NetxEnv Ensure-NetxEnv
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "start_netx_app.ps1") ` $hostBind = "127.0.0.1"
-ProgramRoot $prog -DataRoot $data $port = 8890
if ($LASTEXITCODE -ne 0) { throw "start_exit_$LASTEXITCODE" } $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 {} }
}
$p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "start_netx_app.ps1") -ExtraArgs @("-SkipBrowser")
if ($p.ExitCode -ne 0) { throw "start_exit_$($p.ExitCode)" }
if (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 180) {
try { Start-Process "http://${hostBind}:${port}/" } catch {}
} else {
throw (T `
"NetX started but /health not ready within 180s.`nSee: $logDir\netx.err.log" `
"NetX 已启动但 /health 在 180 秒内未就绪。`n请查看: $logDir\netx.err.log")
}
} }
"tray" { "tray" {
Ensure-NetxEnv Ensure-NetxEnv
$trayArgs = @( $extra = @("-ProgramRoot", $prog, "-DataRoot", $data)
"-NoProfile", "-ExecutionPolicy", "Bypass", if ($StartOnLaunch) { $extra += "-StartOnLaunch" }
"-File", (Join-Path $PSScriptRoot "netx_tray.ps1"), # Detach tray; do not leave a success MessageBox (keeps a console open).
"-ProgramRoot", $prog, "-DataRoot", $data Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
) -Arguments $extra -WorkingDirectory $prog -WindowStyle Hidden | Out-Null
if ($StartOnLaunch) { $trayArgs += "-StartOnLaunch" }
# Detach tray; keep this process short so Start Menu returns quickly.
Start-Process -FilePath "powershell.exe" -ArgumentList $trayArgs -WorkingDirectory $prog -WindowStyle Hidden
Start-Sleep -Milliseconds 800
[void][System.Windows.Forms.MessageBox]::Show(
(T `
"NetX tray started.`nLook for the NetX icon in the system tray (click ^ if hidden).`nUI: http://127.0.0.1:8890/" `
"NetX 托盘已启动。`n请在任务栏右下角找 NetX 图标(被藏起来时点 ^)。`n界面: http://127.0.0.1:8890/"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information
)
} }
} }
Write-LaunchLog "action=$Action ok" Write-LaunchLog "action=$Action ok"

View file

@ -1,4 +1,4 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$StartOnLaunch = $true, [switch]$StartOnLaunch = $true,
@ -52,17 +52,57 @@ if (-not (Test-Path $envPath)) {
exit 1 exit 1
} }
# Only one tray icon per machine session (desktop + postinstall + autostart can race).
$script:netxTrayMutex = $null
try {
$createdNew = $false
$script:netxTrayMutex = New-Object System.Threading.Mutex($true, "Local\NetX.WinTray.SingleInstance", [ref]$createdNew)
if (-not $createdNew) {
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
try { Start-Process $uiUrl } catch {}
} elseif ($StartOnLaunch) {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "start_netx_app.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-SkipBrowser") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
}
exit 0
}
} catch {
# If mutex fails, continue with a tray (better than no UI control).
}
$startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1" $startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1"
$stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1" $stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1"
$checkPs1 = Join-Path $PSScriptRoot "check_update.ps1" $checkPs1 = Join-Path $PSScriptRoot "check_update.ps1"
function Invoke-NetxScript { function Invoke-NetxScript {
param([string]$File, [string[]]$ExtraArgs = @()) param(
$args = @( [string]$File,
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $File, [string[]]$ExtraArgs = @(),
"-ProgramRoot", $prog, "-DataRoot", $data [switch]$Wait = $false
) + $ExtraArgs )
Start-Process -FilePath "powershell.exe" -ArgumentList $args -WorkingDirectory $prog -WindowStyle Hidden return Start-NetxPowerShell -File $File -Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) `
-WorkingDirectory $prog -WindowStyle Hidden -PassThru -Wait:$Wait
}
function Open-NetxUiWhenReady {
param([int]$TimeoutSec = 180)
$notify.ShowBalloonTip(
5000,
"NetX",
(T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
if (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec $TimeoutSec) {
try { Start-Process $uiUrl } catch {}
return
}
$notify.ShowBalloonTip(
8000,
"NetX",
(T "UI not ready yet. Use tray → Open UI when the icon appears, or check data\runtime\netx.err.log." "界面尚未就绪。就绪后请用托盘「打开界面」,或查看 data\runtime\netx.err.log。"),
[System.Windows.Forms.ToolTipIcon]::Warning
)
} }
$form = New-Object System.Windows.Forms.Form $form = New-Object System.Windows.Forms.Form
@ -93,23 +133,30 @@ $miOpen.add_Click({ Start-Process $uiUrl })
$miStart = $menu.Items.Add((T "Start NetX" "启动 NetX")) $miStart = $menu.Items.Add((T "Start NetX" "启动 NetX"))
$miStart.add_Click({ $miStart.add_Click({
$notify.ShowBalloonTip(3000, "NetX", (T "Starting…" "正在启动…"), [System.Windows.Forms.ToolTipIcon]::Info) Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") Open-NetxUiWhenReady
}) })
$miStop = $menu.Items.Add((T "Stop NetX" "停止 NetX")) $miStop = $menu.Items.Add((T "Stop NetX (keep tray)" "停止 NetX(保留托盘)"))
$miStop.add_Click({ $miStop.add_Click({
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info) $notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 Invoke-NetxScript -File $stopPs1 | Out-Null
})
$miStopExit = $menu.Items.Add((T "Stop NetX and exit tray" "停止 NetX 并退出托盘"))
$miStopExit.add_Click({
$notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info)
Invoke-NetxScript -File $stopPs1 -Wait | Out-Null
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
}) })
$miUpdate = $menu.Items.Add((T "Check for updates" "检查更新")) $miUpdate = $menu.Items.Add((T "Check for updates" "检查更新"))
$miUpdate.add_Click({ $miUpdate.add_Click({
try { try {
$p = Start-Process -FilePath "powershell.exe" -ArgumentList @( $p = Start-NetxPowerShell -File $checkPs1 -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) `
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, -WorkingDirectory $prog -WindowStyle Normal -Wait -PassThru
"-ProgramRoot", $prog, "-DataRoot", $data
) -Wait -PassThru -WindowStyle Normal
if ($p.ExitCode -eq 10) { if ($p.ExitCode -eq 10) {
$ans = [System.Windows.Forms.MessageBox]::Show( $ans = [System.Windows.Forms.MessageBox]::Show(
(T "A newer NetX is available. Download and apply now?" "发现新版本,是否立即下载并更新?"), (T "A newer NetX is available. Download and apply now?" "发现新版本,是否立即下载并更新?"),
@ -118,10 +165,8 @@ $miUpdate.add_Click({
[System.Windows.Forms.MessageBoxIcon]::Question [System.Windows.Forms.MessageBoxIcon]::Question
) )
if ($ans -eq [System.Windows.Forms.DialogResult]::Yes) { if ($ans -eq [System.Windows.Forms.DialogResult]::Yes) {
Start-Process -FilePath "powershell.exe" -ArgumentList @( Start-NetxPowerShell -File $checkPs1 -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Apply") `
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, -WorkingDirectory $prog -WindowStyle Normal | Out-Null
"-ProgramRoot", $prog, "-DataRoot", $data, "-Apply"
) -WorkingDirectory $prog
} }
} elseif ($p.ExitCode -eq 0) { } elseif ($p.ExitCode -eq 0) {
[System.Windows.Forms.MessageBox]::Show((T "NetX is up to date ($ver)." "已是最新版本 ($ver)。"), (T "NetX update" "NetX 更新")) [System.Windows.Forms.MessageBox]::Show((T "NetX is up to date ($ver)." "已是最新版本 ($ver)。"), (T "NetX update" "NetX 更新"))
@ -146,20 +191,49 @@ $notify.add_DoubleClick({ Start-Process $uiUrl })
$form.add_Shown({ $form.add_Shown({
if ($AutoUpdate) { if ($AutoUpdate) {
$notify.ShowBalloonTip(4000, "NetX", (T "Checking for updates…" "正在检查更新…"), [System.Windows.Forms.ToolTipIcon]::Info) $notify.ShowBalloonTip(4000, "NetX", (T "Checking for updates…" "正在检查更新…"), [System.Windows.Forms.ToolTipIcon]::Info)
Start-Process -FilePath "powershell.exe" -ArgumentList @( Start-NetxPowerShell -File $checkPs1 `
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Apply", "-Quiet", "-AutoOnly") `
"-ProgramRoot", $prog, "-DataRoot", $data, "-Apply", "-Quiet", "-AutoOnly" -WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
) -Wait -WindowStyle Hidden
} elseif ($CheckUpdateOnLaunch) { } elseif ($CheckUpdateOnLaunch) {
Start-Process -FilePath "powershell.exe" -ArgumentList @( Start-NetxPowerShell -File $checkPs1 `
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $checkPs1, -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet") `
"-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet" -WorkingDirectory $prog -WindowStyle Hidden | Out-Null
) -WindowStyle Hidden
} }
if ($StartOnLaunch) { if ($StartOnLaunch) {
Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") # Detach start so the tray message pump stays alive; poll /health before browser.
Start-Sleep -Seconds 2 Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null
try { Start-Process $uiUrl } catch {} $script:netxUiWaitTicks = 0
$script:netxUiWaitMax = 360 # 360 * 500ms = 180s
$script:netxUiTimer = New-Object System.Windows.Forms.Timer
$script:netxUiTimer.Interval = 500
$script:netxUiTimer.add_Tick({
$script:netxUiWaitTicks++
if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
try { Start-Process $uiUrl } catch {}
return
}
if ($script:netxUiWaitTicks -eq 1) {
$notify.ShowBalloonTip(
5000,
"NetX",
(T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"),
[System.Windows.Forms.ToolTipIcon]::Info
)
}
if ($script:netxUiWaitTicks -ge $script:netxUiWaitMax) {
$script:netxUiTimer.Stop()
$script:netxUiTimer.Dispose()
$notify.ShowBalloonTip(
8000,
"NetX",
(T "UI not ready yet. Use tray → Open UI later, or check data\runtime\netx.err.log." "界面尚未就绪。请稍后用托盘「打开界面」,或查看 data\runtime\netx.err.log。"),
[System.Windows.Forms.ToolTipIcon]::Warning
)
}
})
$script:netxUiTimer.Start()
} }
}) })

View file

@ -4,9 +4,13 @@
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[string]$ExternalDatabaseUrl = "", [string]$ExternalDatabaseUrl = "",
[string]$ExternalDatabaseUrlFile = "",
[string]$CredentialSecretKey = "",
[string]$CredentialSecretKeyFile = "",
[string]$BundledPassword = "", [string]$BundledPassword = "",
[int]$BundledPort = 15432, [int]$BundledPort = 15432,
[switch]$NonInteractive = $false [switch]$NonInteractive = $false,
[switch]$SkipExternalProbe = $false
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@ -39,9 +43,10 @@ if (-not $DbMode) {
} }
} else { } else {
Write-Host "" Write-Host ""
Write-Host (T "Choose database mode:" "选择数据库模式:") Write-Host (T "Choose database mode:" "选择数据库模式:") -ForegroundColor Cyan
Write-Host (T " 1) bundled — use NetX portable PostgreSQL (default for new installs)" " 1) bundled — 使用 NetX 内置便携 PostgreSQL(新安装默认)") Write-Host (T " 1) bundled — NetX portable PostgreSQL (offline / default)" " 1) bundled — NetX 内置便携 PostgreSQL(可离线,默认)")
Write-Host (T " 2) external — connect to an existing PostgreSQL (Linux / already deployed)" " 2) external — 连接已有 PostgreSQL") Write-Host (T " 2) external — existing PostgreSQL (you provide connection URL)" " 2) external — 已有外置 PostgreSQL(需填写连接串)")
Write-Host ""
$choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2") $choice = Read-Host (T "Enter 1 or 2" "请输入 1 或 2")
if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" } if ($choice -eq "2") { $DbMode = "external" } else { $DbMode = "bundled" }
} }
@ -67,6 +72,35 @@ if (Test-Path (Join-Path $distAbs "index.html")) {
$dataEnv = Get-NetxDataEnvMap -DataRoot $data $dataEnv = Get-NetxDataEnvMap -DataRoot $data
foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] } foreach ($k in $dataEnv.Keys) { $values[$k] = $dataEnv[$k] }
# Fernet key for managed-NE password encryption (required to save SSH passwords).
# Priority: -CredentialSecretKey(File) > interactive prompt > existing .env > generate.
if ($CredentialSecretKeyFile) {
if (-not (Test-Path -LiteralPath $CredentialSecretKeyFile)) {
throw "credential_secret_key_file_missing: $CredentialSecretKeyFile"
}
$CredentialSecretKey = [IO.File]::ReadAllText($CredentialSecretKeyFile).Trim()
}
if (-not $CredentialSecretKey -and -not $NonInteractive) {
Write-Host ""
Write-Host (T `
"Optional: paste NETX_CREDENTIAL_SECRET_KEY from an existing install" `
"可选: 粘贴已有安装的 NETX_CREDENTIAL_SECRET_KEY(便于沿用已加密凭据)") -ForegroundColor Cyan
Write-Host (T `
"Leave empty to keep existing .env key, or auto-generate if none." `
"留空则保留已有 .env 中的密钥;若没有则自动生成。")
$CredentialSecretKey = (Read-Host "NETX_CREDENTIAL_SECRET_KEY").Trim()
}
if ($CredentialSecretKey) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $CredentialSecretKey.Trim()
Write-Host "==> Using provided NETX_CREDENTIAL_SECRET_KEY" -ForegroundColor Green
} elseif ($existing.ContainsKey("NETX_CREDENTIAL_SECRET_KEY") -and $existing["NETX_CREDENTIAL_SECRET_KEY"]) {
$values["NETX_CREDENTIAL_SECRET_KEY"] = $existing["NETX_CREDENTIAL_SECRET_KEY"]
Write-Host "==> Keeping existing NETX_CREDENTIAL_SECRET_KEY from .env" -ForegroundColor Green
} else {
$values["NETX_CREDENTIAL_SECRET_KEY"] = New-NetxFernetKey
Write-Host "==> Generated NETX_CREDENTIAL_SECRET_KEY (saved in .env)" -ForegroundColor Green
}
if ($DbMode -eq "bundled") { if ($DbMode -eq "bundled") {
$pgsql = Join-Path $prog "postgres\pgsql" $pgsql = Join-Path $prog "postgres\pgsql"
if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) { if (-not (Test-Path (Join-Path $pgsql "bin\initdb.exe"))) {
@ -189,23 +223,109 @@ if ($DbMode -eq "bundled") {
} }
Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green Write-Host "==> Bundled Postgres ready on 127.0.0.1:$BundledPort" -ForegroundColor Green
} else { } else {
if ($ExternalDatabaseUrlFile) {
if (-not (Test-Path -LiteralPath $ExternalDatabaseUrlFile)) {
throw "external_url_file_missing: $ExternalDatabaseUrlFile"
}
$ExternalDatabaseUrl = [IO.File]::ReadAllText($ExternalDatabaseUrlFile).Trim()
}
if (-not $ExternalDatabaseUrl) { if (-not $ExternalDatabaseUrl) {
if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) { if ($existing.ContainsKey("NETX_DATABASE_URL") -and $existing["NETX_DATABASE_URL"]) {
$ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"] $ExternalDatabaseUrl = $existing["NETX_DATABASE_URL"]
} elseif ($NonInteractive) { } elseif ($NonInteractive) {
throw "external_url_required" throw "external_url_required"
} else { } else {
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL (postgresql+psycopg://user:pass@host:5432/netx)" Write-Host ""
Write-Host (T `
"Enter PostgreSQL URL (database/role must already exist):" `
"请输入 PostgreSQL 连接串(库和用户需事先建好):") -ForegroundColor Cyan
Write-Host " postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME"
Write-Host (T `
"Example: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx" `
"示例: postgresql+psycopg://netx:secret@10.0.0.8:5432/netx")
$ExternalDatabaseUrl = Read-Host "NETX_DATABASE_URL"
} }
} }
if (-not $ExternalDatabaseUrl) { if (-not $ExternalDatabaseUrl) {
throw "external_url_required" throw "external_url_required"
} }
$ExternalDatabaseUrl = $ExternalDatabaseUrl.Trim()
$values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl $values["NETX_DATABASE_URL"] = $ExternalDatabaseUrl
Write-Host "==> External Postgres configured (ensure role/db exist; see scripts\init_pg.ps1)" -ForegroundColor Green Write-Host "==> External Postgres configured" -ForegroundColor Green
if (-not $SkipExternalProbe) {
# Probe with bundled psql when available (wizard already tested; this covers CLI reconfigure).
$psqlProbe = Join-Path $prog "postgres\pgsql\bin\psql.exe"
$testHelper = Join-Path $PSScriptRoot "installer\test_pg_conn.ps1"
if (-not (Test-Path $testHelper)) {
$testHelper = Join-Path $PSScriptRoot "test_pg_conn.ps1"
}
if ((Test-Path $psqlProbe) -and ($ExternalDatabaseUrl -match '^(?:postgresql(?:\+psycopg)?|postgres)://([^:]+):([^@]*)@([^:/]+):?(\d+)?/([^?\s]+)')) {
$u = [uri]::UnescapeDataString($Matches[1])
$pw = [uri]::UnescapeDataString($Matches[2])
$h = $Matches[3]
$po = if ($Matches[4]) { [int]$Matches[4] } else { 5432 }
$dbn = [uri]::UnescapeDataString($Matches[5])
if (Test-Path $testHelper) {
Write-Host "==> Probing external PostgreSQL..."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testHelper `
-PgHost $h -Port $po -User $u -Password $pw -Database $dbn -PsqlPath $psqlProbe
if ($LASTEXITCODE -ne 0) {
throw "external_db_probe_failed"
}
Write-Host "==> External PostgreSQL OK" -ForegroundColor Green
} else {
$env:PGPASSWORD = $pw
try {
$ping = & $psqlProbe -h $h -p $po -U $u -d $dbn -t -A -c "SELECT 1" 2>&1
if ($LASTEXITCODE -ne 0 -or (($ping | Out-String).Trim()) -notmatch '1') {
throw "external_db_probe_failed: $ping"
}
} finally {
Remove-Item Env:PGPASSWORD -ErrorAction SilentlyContinue
}
}
}
}
} }
Write-DotEnvValue -Path $envPath -Values $values Write-DotEnvValue -Path $envPath -Values $values
Write-Host "" Write-Host ""
Write-Host "Wrote $envPath" -ForegroundColor Green Write-Host "Wrote $envPath" -ForegroundColor Green
Write-Host "Next: .\packaging\start_netx_app.ps1"
# Official Setup ships .venv (build_release -CreateVenv). Only bootstrap if missing
# (e.g. zip without venv). Prefer not to pip-install on the target machine.
$venvPy = Join-Path $prog ".venv\Scripts\python.exe"
if (Test-Path $venvPy) {
Write-Host "==> Using bundled Python venv: $venvPy" -ForegroundColor Green
} else {
Write-Host "==> Bundled .venv missing — creating one (Setup should normally ship it)." -ForegroundColor Yellow
try {
$null = Ensure-NetxVenv -ProgramRoot $prog
Write-Host "==> Python venv ready: $venvPy" -ForegroundColor Green
} catch {
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Install a Setup built with: packaging\build_release.ps1 -CreateVenv" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host (T "Setup complete." "首次配置完成。") -ForegroundColor Green
if (-not $NonInteractive) {
Write-Host (T `
"Next: Start Menu → NetX Tray (or press Y below)." `
"下一步: 开始菜单 → NetX 托盘 (或在下方按 Y 立即启动)。")
$ans = Read-Host (T "Start NetX tray now? [Y/n]" "现在启动 NetX 托盘?[Y/n]")
if ($ans -notmatch '^[Nn]') {
try {
Start-NetxPowerShell -File (Join-Path $PSScriptRoot "netx_tray.ps1") `
-Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-StartOnLaunch") `
-WorkingDirectory $prog -WindowStyle Hidden | Out-Null
Write-Host (T "Tray started." "托盘已启动。") -ForegroundColor Green
} catch {
Write-Host "[WARN] $($_.Exception.Message)" -ForegroundColor Yellow
}
}
} else {
Write-Host "Next: .\packaging\start_netx_app.ps1 or NetX-Tray.cmd"
}

View file

@ -1,4 +1,4 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$SkipBrowser = $false, [switch]$SkipBrowser = $false,
@ -96,15 +96,13 @@ if ($mode -eq "bundled") {
} }
} }
$venvPy = Join-Path $prog ".venv\Scripts\python.exe" try {
if (-not (Test-Path $venvPy)) { $null = Ensure-NetxVenv -ProgramRoot $prog
Write-Host "==> Creating .venv (one-time)" } catch {
$pyCmd = Get-Command python -ErrorAction SilentlyContinue Write-Host "[ERR] $($_.Exception.Message)" -ForegroundColor Red
if (-not $pyCmd) { throw "python_not_found: install Python 3.11+ and re-run" } Write-Host " Tip: run Start Menu → NetX → First-time setup once as Administrator," -ForegroundColor Yellow
& $pyCmd.Source -m venv (Join-Path $prog ".venv") Write-Host " or install a Setup built with packaging\\build_release.ps1 -CreateVenv." -ForegroundColor Yellow
& $venvPy -m pip install --upgrade pip exit 1
& $venvPy -m pip install -r (Join-Path $prog "requirements.txt")
if ($LASTEXITCODE -ne 0) { throw "pip_install_failed" }
} }
if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) { if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) {
@ -120,18 +118,22 @@ if (-not (Test-Path $startScript)) {
throw "start_netx.ps1 not found at $startScript" throw "start_netx.ps1 not found at $startScript"
} }
$psArgs = @( $startExtra = @(
"-ExecutionPolicy", "Bypass", "-File", $startScript,
"-SkipInstall", "-Background", "-SkipInstall", "-Background",
"-BindHost", $hostBind, "-Port", "$port" "-BindHost", $hostBind, "-Port", "$port"
) )
if ($InlineSchedulers) { $psArgs += "-InlineSchedulers" } if ($InlineSchedulers) { $startExtra += "-InlineSchedulers" }
Write-Host "==> Starting NetX (API + workers; UI via API on :$port)" Write-Host "==> Starting NetX (API + workers; UI via API on :$port)"
& powershell @psArgs $startProc = Start-NetxPowerShell -File $startScript -Arguments $startExtra `
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -WorkingDirectory $prog -WindowStyle Hidden -Wait -PassThru
if ($startProc.ExitCode -ne 0) { exit $startProc.ExitCode }
$url = "http://${hostBind}:${port}/" $url = "http://${hostBind}:${port}/"
if (-not (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 30)) {
Write-Host "[ERR] NetX process exited 0 but /health not ready: $url" -ForegroundColor Red
exit 1
}
Write-Host "==> Open: $url" -ForegroundColor Green Write-Host "==> Open: $url" -ForegroundColor Green
if (-not $SkipBrowser) { if (-not $SkipBrowser) {
try { Start-Process $url } catch {} try { Start-Process $url } catch {}

View file

@ -1,7 +1,8 @@
param( param(
[string]$ProgramRoot = "", [string]$ProgramRoot = "",
[string]$DataRoot = "", [string]$DataRoot = "",
[switch]$KeepPostgres = $false [switch]$KeepPostgres = $false,
[switch]$KillTray = $false
) )
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
@ -21,11 +22,17 @@ if (-not (Test-Path $stopScript)) {
} }
if (Test-Path $stopScript) { if (Test-Path $stopScript) {
Write-Host "==> Stopping NetX processes" Write-Host "==> Stopping NetX processes"
& powershell -ExecutionPolicy Bypass -File $stopScript -Force Start-NetxPowerShell -File $stopScript -Arguments @("-Force") `
-WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null
} else { } else {
Write-Host "[WARN] stop_netx.ps1 not found" Write-Host "[WARN] stop_netx.ps1 not found"
} }
if ($KillTray) {
Write-Host "==> Stopping NetX tray icons"
Stop-NetxTrayProcesses -ProgramRoot $prog
}
$mode = Get-DbMode -EnvMap $map $mode = Get-DbMode -EnvMap $map
if ($mode -eq "bundled" -and -not $KeepPostgres) { if ($mode -eq "bundled" -and -not $KeepPostgres) {
$pgBinCandidates = @( $pgBinCandidates = @(

View file

@ -0,0 +1,53 @@
param(
[string]$DataRoot = ""
)
# Post-uninstall optional data wipe with retries (handles briefly locked runtime logs).
$ErrorActionPreference = "Continue"
if (-not $DataRoot) {
$DataRoot = Join-Path ([Environment]::GetFolderPath("CommonApplicationData")) "NetX"
}
if (-not (Test-Path -LiteralPath $DataRoot)) {
Write-Host "==> Data root already gone: $DataRoot"
exit 0
}
Write-Host "==> Deleting data root: $DataRoot"
# Kill anything still holding files under data root.
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
$_.CommandLine -and ($_.CommandLine.IndexOf($DataRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0)
} | ForEach-Object {
try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
try { & taskkill.exe /F /PID $_.ProcessId 2>$null | Out-Null } catch {}
}
$ok = $false
for ($i = 1; $i -le 5; $i++) {
try {
cmd /c "rmdir /s /q `"$DataRoot`""
} catch {}
if (-not (Test-Path -LiteralPath $DataRoot)) {
$ok = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $ok -and (Test-Path -LiteralPath $DataRoot)) {
try {
takeown /F $DataRoot /R /D Y | Out-Null
icacls $DataRoot /grant Administrators:F /T | Out-Null
cmd /c "rmdir /s /q `"$DataRoot`""
} catch {}
}
if (Test-Path -LiteralPath $DataRoot) {
Write-Host "[WARN] Could not fully delete $DataRoot"
exit 1
}
Write-Host "==> Data root deleted"
exit 0

View file

@ -0,0 +1,71 @@
param(
[string]$ProgramRoot = "",
[string]$DataRoot = ""
)
# Fast, non-blocking uninstall prep for Inno [UninstallRun].
# Must return within a few seconds and never kill unins000.exe / _unins.tmp.
$ErrorActionPreference = "Continue"
$prog = if ($ProgramRoot) { $ProgramRoot } else { "C:\Program Files\NetX" }
$data = if ($DataRoot) { $DataRoot } else { Join-Path $env:ProgramData "NetX" }
$prog = ($prog -replace '/', '\').TrimEnd('\')
$data = ($data -replace '/', '\').TrimEnd('\')
function Test-Protected([string]$Name, [string]$Cmd) {
if ($Name -match '^(unins\d*|_unins\.tmp|setup)\.exe$') { return $true }
if ($Cmd -match 'unins\d*\.exe|_unins\.tmp|uninstall_prepare\.ps1|uninstall_delete_data\.ps1') { return $true }
return $false
}
function Stop-Pid([int]$Id, [string]$Label) {
if ($Id -le 4 -or $Id -eq $PID) { return }
Write-Host "stop $Id $Label"
try { Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue } catch {}
}
Write-Host "==> uninstall_prepare fast-stop prog=$prog"
$patterns = @(
'netx_tray\.ps1',
'netx_api\.(main|worker|biz_state_worker)',
'start_netx_app\.ps1',
'stop_netx_app\.ps1',
'launch_shortcut\.ps1',
[regex]::Escape((Join-Path $prog '.venv\Scripts\python.exe')),
[regex]::Escape((Join-Path $prog 'postgres\pgsql\bin'))
)
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object {
$cl = [string]$_.CommandLine
$name = [string]$_.Name
if (-not $cl) { return }
if (Test-Protected -Name $name -Cmd $cl) { return }
foreach ($pat in $patterns) {
if ($cl -match $pat) {
Stop-Pid -Id ([int]$_.ProcessId) -Label $name
break
}
}
}
# Best-effort postgres stop (no hang: immediate + ignore)
$pgCtl = Join-Path $prog "postgres\pgsql\bin\pg_ctl.exe"
$pgData = Join-Path $data "pgdata"
if ((Test-Path $pgCtl) -and (Test-Path $pgData)) {
try {
$p = Start-Process -FilePath $pgCtl -ArgumentList @('-D', $pgData, 'stop', '-m', 'immediate') `
-WindowStyle Hidden -PassThru
if (-not $p.WaitForExit(5000)) {
try { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } catch {}
}
} catch {}
}
try {
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "NetX" -ErrorAction SilentlyContinue
} catch {}
Write-Host "==> uninstall_prepare done"
exit 0

View file

@ -1,4 +1,4 @@
param( param(
[string]$BindHost = "127.0.0.1", [string]$BindHost = "127.0.0.1",
[int]$Port = 8890, [int]$Port = 8890,
[int]$WebPort = 5173, [int]$WebPort = 5173,
@ -260,7 +260,11 @@ if ($Background) {
Show-LogTail -Path $logFile Show-LogTail -Path $logFile
exit 1 exit 1
} }
$healthWaitSec = 45 # Fresh installs run Alembic upgrades on first boot; 45s is often too short.
$healthWaitSec = 180
if ($env:NETX_START_HEALTH_WAIT_SEC) {
try { $healthWaitSec = [int]$env:NETX_START_HEALTH_WAIT_SEC } catch {}
}
if (-not (Test-NetxApiListening -HostName $BindHost -LocalPort $Port -WaitSec $healthWaitSec)) { if (-not (Test-NetxApiListening -HostName $BindHost -LocalPort $Port -WaitSec $healthWaitSec)) {
Write-Host "[ERR] Port $Port not listening /health not OK within ${healthWaitSec}s (process may be stuck on DB or schema migration)." -ForegroundColor Red Write-Host "[ERR] Port $Port not listening /health not OK within ${healthWaitSec}s (process may be stuck on DB or schema migration)." -ForegroundColor Red
Show-LogTail -Path $errFile Show-LogTail -Path $errFile