diff --git a/packaging/README.md b/packaging/README.md index 2045204..42c6b6d 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -61,7 +61,10 @@ Optional local zip only: `build_release.ps1 -CreateZip` (not for release upload) - **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe). 4. Files → `%ProgramFiles%\NetX\` (program root). 5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). -6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**. +6. **Tasks page — run mode (exclusive):** *Desktop tray* (default; optional *start when I sign in* → `\NetX\NetXTray` logon task for local Administrators, quiet start without a browser) or *Windows service* (WinSW, runs at boot). Optional *daily automatic updates* (`\NetX\NetXUpdate`). On upgrades the page pre-selects what is actually installed; silent installs leave these alone unless `/TASKS=` is passed (e.g. `/TASKS="runservice,autoupdate"`). +7. Start menu: **NetX** (tray) / **Start NetX** / **Stop NetX** / **Check for updates** / **Reconfigure database** (names follow the Setup language). Autostart and auto-update can also be toggled later from tray → Settings. + +Start/stop from the tray or shortcuts go through `packaging\runtime_ctl.ps1`, which drives the Windows service / SYSTEM task when one is installed (stopping its children directly would just get them restarted). Uninstall removes the service and all `\NetX\` scheduled tasks. **Elevation (required on fresh PCs):** all end-user entry points (`NetX-Start.cmd`, `NetX-Tray.cmd`, `NetX-Stop.cmd`, `NetX-FirstRun.cmd`, tray, start/stop/setup/update) **self-elevate via UAC**. Program Files installs cannot start reliably as a normal user (`.venv` repair / writes). Approve the UAC prompt once per launch (tray stays elevated afterward). diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 index 194e8f7..085306b 100644 --- a/packaging/_common.ps1 +++ b/packaging/_common.ps1 @@ -282,6 +282,37 @@ function Test-NetxApiHealthy { } } +function Get-NetxClientHost { + param([string]$BindHost = "") + # A wildcard bind address is not connectable on Windows; probe/open via loopback. + $h = "$BindHost".Trim() + if (-not $h -or $h -in @("0.0.0.0", "::", "[::]", "*")) { return "127.0.0.1" } + return $h +} + +function Open-NetxUrl { + param([Parameter(Mandatory = $true)][string]$Url) + # Packaged scripts run elevated; Start-Process would launch the browser + # elevated too. explorer.exe hands the URL to the user's non-elevated shell. + try { + Start-Process -FilePath (Join-Path $env:SystemRoot "explorer.exe") -ArgumentList "`"$Url`"" | Out-Null + return + } catch {} + try { Start-Process $Url | Out-Null } catch {} +} + +function Get-NetxManagedRunner { + # "service" (WinSW) / "task" (SYSTEM startup task) own the runtime when present; + # killing their children directly would just make service_run.ps1 restart them. + $svc = Get-Service -Name "NetX" -ErrorAction SilentlyContinue + if ($svc -and [string]$svc.StartType -ne "Disabled") { return "service" } + try { + $t = Get-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -ErrorAction Stop + if ([string]$t.State -ne "Disabled") { return "task" } + } catch {} + return "" +} + function Wait-NetxApiHealthy { param( [string]$HostName = "127.0.0.1", diff --git a/packaging/assets/README.md b/packaging/assets/README.md index 2310893..27b599c 100644 --- a/packaging/assets/README.md +++ b/packaging/assets/README.md @@ -1,6 +1,6 @@ -# NetX packaging assets +# NetX packaging assets -- `netx.ico` — installer / shortcuts / tray (regenerate with `python gen_icon.py`) -- `netx-256.png` / `netx-64.png` — previews +- `netx.ico` 鈥?installer / shortcuts / tray (regenerate with `python gen_icon.py`) +- `netx-256.png` / `netx-64.png` 鈥?previews -Mark matches `web/public/favicon.svg` (network “N” nodes on navy). +Mark matches `web/public/favicon.svg` (network 鈥淣鈥?nodes on navy). diff --git a/packaging/assets/gen_icon.py b/packaging/assets/gen_icon.py index f6e556c..e5b96d5 100644 --- a/packaging/assets/gen_icon.py +++ b/packaging/assets/gen_icon.py @@ -1,54 +1,113 @@ -"""Generate NetX Windows .ico from brand mark (matches web/public/favicon.svg).""" +"""Generate NetX Windows .ico/.png from the brand mark. + +Mirrors web/public/favicon.svg and web/src/layout/BrandMark.tsx (24x24 viewBox): +blue gradient tile, an "X" of links, four nodes around a hub, one node lit cyan. +""" from __future__ import annotations import os from PIL import Image, ImageDraw OUT_DIR = os.path.dirname(os.path.abspath(__file__)) -BG = (15, 39, 68, 255) -LINE = (126, 182, 232, 255) -NODE = (232, 242, 252, 255) -ACCENT = (61, 130, 247, 255) +GRAD_A = (0x3B, 0x82, 0xF6) # #3b82f6 (top-left) +GRAD_B = (0x1D, 0x4E, 0xD8) # #1d4ed8 (bottom-right) +WHITE = (255, 255, 255, 255) +LINK = (255, 255, 255, int(255 * 0.55)) +LIVE = (0x67, 0xE8, 0xF9, 255) # #67e8f9 +SS = 4 # supersample factor -def make(size: int) -> Image.Image: - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - d = ImageDraw.Draw(img) - radius = max(2, int(size * 0.22)) - d.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=BG) - s = size / 32.0 - - def xy(x: float, y: float) -> tuple[float, float]: - return (x * s, y * s) - - stroke = max(2, int(round(2.2 * s))) - for a, b in ( - (xy(9.5, 8.5), xy(9.5, 23.5)), - (xy(9.5, 8.5), xy(22.5, 23.5)), - (xy(22.5, 8.5), xy(22.5, 23.5)), - ): - d.line([a, b], fill=LINE, width=stroke) - - def circle(cx: float, cy: float, rad: float, fill: tuple[int, int, int, int]) -> None: - x, y = xy(cx, cy) - rr = rad * s - d.ellipse([x - rr, y - rr, x + rr, y + rr], fill=fill) - - circle(9.5, 8.5, 2.35, NODE) - circle(9.5, 23.5, 2.35, NODE) - circle(22.5, 8.5, 2.35, NODE) - circle(22.5, 23.5, 2.35, NODE) - circle(16, 16, 1.7, ACCENT) +def _gradient(px: int) -> Image.Image: + # Diagonal gradient along (0,0)->(24,24), like the SVG userSpaceOnUse gradient. + img = Image.new("RGBA", (px, px)) + pix = img.load() + span = 2 * (px - 1) or 1 + for y in range(px): + for x in range(px): + t = (x + y) / span + pix[x, y] = tuple(round(a + (b - a) * t) for a, b in zip(GRAD_A, GRAD_B)) + (255,) return img +def make(size: int) -> Image.Image: + px = size * SS + u = px / 24.0 + + tile = _gradient(px) + mask = Image.new("L", (px, px), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, px - 1, px - 1], radius=6.5 * u, fill=255) + img = Image.new("RGBA", (px, px), (0, 0, 0, 0)) + img.paste(tile, (0, 0), mask) + + links = Image.new("RGBA", (px, px), (0, 0, 0, 0)) + ld = ImageDraw.Draw(links) + w = max(1, round(1.6 * u)) + for (x1, y1), (x2, y2) in (((7, 7), (17, 17)), ((17, 7), (7, 17))): + ld.line([(x1 * u, y1 * u), (x2 * u, y2 * u)], fill=LINK, width=w) + for cx, cy in ((x1, y1), (x2, y2)): # round caps + r = w / 2 + ld.ellipse([cx * u - r, cy * u - r, cx * u + r, cy * u + r], fill=LINK) + img = Image.alpha_composite(img, links) + + d = ImageDraw.Draw(img) + + def node(cx: float, cy: float, r: float, fill: tuple[int, int, int, int]) -> None: + d.ellipse([(cx - r) * u, (cy - r) * u, (cx + r) * u, (cy + r) * u], fill=fill) + + node(12, 12, 2.6, WHITE) + node(7, 7, 1.9, WHITE) + node(7, 17, 1.9, WHITE) + node(17, 17, 1.9, WHITE) + node(17, 7, 2.1, LIVE) + return img.resize((size, size), Image.LANCZOS) + + +WIZ_TOP = (0x0A, 0x12, 0x22) # near-black navy +WIZ_BOTTOM = (0x10, 0x2A, 0x55) # deep blue + + +def make_wizard(w: int, h: int) -> Image.Image: + """Inno Setup side image: dark navy gradient, faint link mesh, brand mark.""" + img = Image.new("RGBA", (w, h)) + pix = img.load() + for y in range(h): + t = y / max(1, h - 1) + row = tuple(round(a + (b - a) * t) for a, b in zip(WIZ_TOP, WIZ_BOTTOM)) + (255,) + for x in range(w): + pix[x, y] = row + + mesh = Image.new("RGBA", (w * SS, h * SS), (0, 0, 0, 0)) + md = ImageDraw.Draw(mesh) + pts = [(0.12, 0.62), (0.42, 0.70), (0.80, 0.60), (0.28, 0.86), (0.66, 0.90), (0.95, 0.80), (0.05, 0.95)] + pts = [(x * w * SS, y * h * SS) for x, y in pts] + line = (0x60, 0xA5, 0xFA, 46) + for a, b in ((0, 1), (1, 2), (0, 3), (1, 3), (1, 4), (2, 4), (2, 5), (4, 5), (3, 6)): + md.line([pts[a], pts[b]], fill=line, width=max(1, SS)) + for i, (x, y) in enumerate(pts): + r = (2.4 if i != 2 else 3.0) * SS * w / 164 + fill = (0x67, 0xE8, 0xF9, 150) if i == 2 else (0xBF, 0xDB, 0xFE, 90) + md.ellipse([x - r, y - r, x + r, y + r], fill=fill) + img = Image.alpha_composite(img, mesh.resize((w, h), Image.LANCZOS)) + + side = round(w * 0.42) + mark = make(side) + img.alpha_composite(mark, ((w - side) // 2, round(h * 0.26))) + return img.convert("RGB") + + def main() -> None: - sizes = [16, 24, 32, 48, 64, 128, 256] - images = [make(s) for s in sizes] + sizes = [16, 20, 24, 32, 40, 48, 64, 128, 256] + images = {s: make(s) for s in sizes} ico_path = os.path.join(OUT_DIR, "netx.ico") - images[-1].save(ico_path, format="ICO", sizes=[(s, s) for s in sizes]) - images[-1].save(os.path.join(OUT_DIR, "netx-256.png")) - make(64).save(os.path.join(OUT_DIR, "netx-64.png")) + images[256].save(ico_path, format="ICO", sizes=[(s, s) for s in sizes], bitmap_format="bmp", + append_images=[images[s] for s in sizes[:-1]]) + images[256].save(os.path.join(OUT_DIR, "netx-256.png")) + images[64].save(os.path.join(OUT_DIR, "netx-64.png")) + # Inno Setup wizard images at 100/125/150/200% DPI (modern style base sizes). + for pct in (100, 125, 150, 200): + k = pct / 100 + make_wizard(round(164 * k), round(314 * k)).save(os.path.join(OUT_DIR, f"wizard-{pct}.png")) + make(round(55 * k)).save(os.path.join(OUT_DIR, f"wizard-small-{pct}.png")) print(f"wrote {ico_path} ({os.path.getsize(ico_path)} bytes)") diff --git a/packaging/assets/netx-256.png b/packaging/assets/netx-256.png index 167327b..073acea 100644 Binary files a/packaging/assets/netx-256.png and b/packaging/assets/netx-256.png differ diff --git a/packaging/assets/netx-64.png b/packaging/assets/netx-64.png index afeb76f..0bbcfd1 100644 Binary files a/packaging/assets/netx-64.png and b/packaging/assets/netx-64.png differ diff --git a/packaging/assets/netx.ico b/packaging/assets/netx.ico index 179e51b..295a96d 100644 Binary files a/packaging/assets/netx.ico and b/packaging/assets/netx.ico differ diff --git a/packaging/assets/wizard-100.png b/packaging/assets/wizard-100.png new file mode 100644 index 0000000..040de08 Binary files /dev/null and b/packaging/assets/wizard-100.png differ diff --git a/packaging/assets/wizard-125.png b/packaging/assets/wizard-125.png new file mode 100644 index 0000000..f4e211f Binary files /dev/null and b/packaging/assets/wizard-125.png differ diff --git a/packaging/assets/wizard-150.png b/packaging/assets/wizard-150.png new file mode 100644 index 0000000..94cc820 Binary files /dev/null and b/packaging/assets/wizard-150.png differ diff --git a/packaging/assets/wizard-200.png b/packaging/assets/wizard-200.png new file mode 100644 index 0000000..a2da6b3 Binary files /dev/null and b/packaging/assets/wizard-200.png differ diff --git a/packaging/assets/wizard-small-100.png b/packaging/assets/wizard-small-100.png new file mode 100644 index 0000000..65ba4ea Binary files /dev/null and b/packaging/assets/wizard-small-100.png differ diff --git a/packaging/assets/wizard-small-125.png b/packaging/assets/wizard-small-125.png new file mode 100644 index 0000000..a67a6a2 Binary files /dev/null and b/packaging/assets/wizard-small-125.png differ diff --git a/packaging/assets/wizard-small-150.png b/packaging/assets/wizard-small-150.png new file mode 100644 index 0000000..531f9a1 Binary files /dev/null and b/packaging/assets/wizard-small-150.png differ diff --git a/packaging/assets/wizard-small-200.png b/packaging/assets/wizard-small-200.png new file mode 100644 index 0000000..eb82868 Binary files /dev/null and b/packaging/assets/wizard-small-200.png differ diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index 4a83bea..7c3158b 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -110,7 +110,7 @@ foreach ($name in @( "download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1", "stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1", "launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1", - "service_run.ps1", "service_lifecycle.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1", + "service_run.ps1", "service_lifecycle.ps1", "install_update_task.ps1", "repair_venv.ps1", "runtime_ctl.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" )) { $src = Join-Path $PSScriptRoot $name diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index 99be3ec..cce64ff 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -440,6 +440,10 @@ try { # (see installer/netx.iss /InstallMode=update). Write-Host "==> Applying update via silent Setup.exe" $setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /InstallMode=update" + # Setup stops the tray (StopForUpdate -KillTray); remember to bring it back. + $trayWasRunning = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.CommandLine -and $_.CommandLine -match 'netx_tray\.ps1' + }).Count -gt 0 $sp = Start-Process -FilePath $pkgPath -ArgumentList $setupArgs -Wait -PassThru if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) { $code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode } @@ -475,6 +479,14 @@ try { -ProgramRoot $prog -DataRoot $data -SkipBrowser if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + if ($trayWasRunning) { + $trayPs1 = Join-Path $prog "packaging\netx_tray.ps1" + if (Test-Path -LiteralPath $trayPs1) { + Write-Host "==> Restarting NetX tray" + Start-NetxPowerShell -File $trayPs1 -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-QuietStart") ` + -WorkingDirectory $prog -WindowStyle Hidden | Out-Null + } + } } else { Write-Host "==> Applying legacy zip update via update_netx.ps1" & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "update_netx.ps1") ` diff --git a/packaging/install_autostart.ps1 b/packaging/install_autostart.ps1 index 4cba106..b0ac9ef 100644 --- a/packaging/install_autostart.ps1 +++ b/packaging/install_autostart.ps1 @@ -28,11 +28,18 @@ if ($Remove) { exit 0 } -$arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$tray`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -StartOnLaunch" +# -QuietStart: bring NetX up at logon without popping a browser window. +$arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$tray`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -StartOnLaunch -QuietStart" $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog -$trig = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME -$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest +# Any user logon, run for the local Administrators group: Setup runs elevated +# (often as a different admin account), so $env:USERNAME is not the desktop user. +$trig = New-ScheduledTaskTrigger -AtLogOn +$trig.Delay = "PT10S" +$admins = (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544")).Translate( + [System.Security.Principal.NTAccount]).Value +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances Parallel +$principal = New-ScheduledTaskPrincipal -GroupId $admins -RunLevel Highest Register-ScheduledTask -TaskName $taskName -TaskPath $taskPath ` -Action $action -Trigger $trig -Settings $settings -Principal $principal -Force | Out-Null diff --git a/packaging/install_service.ps1 b/packaging/install_service.ps1 index 02ea09c..434013c 100644 --- a/packaging/install_service.ps1 +++ b/packaging/install_service.ps1 @@ -129,7 +129,9 @@ if ($Mode -eq "winsw") { $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arg -WorkingDirectory $prog $trigger = New-ScheduledTaskTrigger -AtStartup $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest - $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) + # ExecutionTimeLimit 0: the default (72h) would kill the long-running supervisor. + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null if ($Start) { Start-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" diff --git a/packaging/install_update_task.ps1 b/packaging/install_update_task.ps1 index afcf712..abdecce 100644 --- a/packaging/install_update_task.ps1 +++ b/packaging/install_update_task.ps1 @@ -22,6 +22,11 @@ $checkPs1 = Join-Path $PSScriptRoot "check_update.ps1" if ($Remove) { Unregister-ScheduledTask -TaskName $taskName -TaskPath $taskPath -Confirm:$false -ErrorAction SilentlyContinue + # Tray also applies updates on launch when NETX_UPDATE_AUTO=true; turn that off too. + $envFile = Join-Path $data ".env" + if (Test-Path $envFile) { + Write-DotEnvValue -Path $envFile -Values @{ "NETX_UPDATE_AUTO" = "false" } + } Write-Host "==> Update task removed" exit 0 } diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index d195fae..488e976 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -14,11 +14,16 @@ ; Stage directory produced by build_release.ps1 (relative to this .iss) #define StageDir "..\release\netx-win64" #define IconFile "..\assets\netx.ico" +#define AssetsDir "..\assets" [Setup] AppId={{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001} AppName={#MyAppName} AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +VersionInfoVersion={#MyAppVersion} +VersionInfoProductName={#MyAppName} +UninstallDisplayName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} DefaultDirName={autopf}\NetX @@ -34,7 +39,11 @@ SetupIconFile={#IconFile} UninstallDisplayIcon={app}\packaging\assets\netx.ico Compression=lzma2 SolidCompression=yes -WizardStyle=modern +; Follows the Windows light/dark setting; windows11 = flat Fluent-style controls. +WizardStyle=modern dynamic windows11 hidebevels +WizardImageFile={#AssetsDir}\wizard-100.png,{#AssetsDir}\wizard-125.png,{#AssetsDir}\wizard-150.png,{#AssetsDir}\wizard-200.png +WizardSmallImageFile={#AssetsDir}\wizard-small-100.png,{#AssetsDir}\wizard-small-125.png,{#AssetsDir}\wizard-small-150.png,{#AssetsDir}\wizard-small-200.png +WizardImageBackColor=#0a1222 ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=admin @@ -42,7 +51,8 @@ PrivilegesRequired=admin ; 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 +; Pick from the Windows UI language; no extra dialog. +ShowLanguageDialog=auto [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -51,10 +61,17 @@ Name: "chinesesimplified"; MessagesFile: "Languages\ChineseSimplified.isl" [CustomMessages] english.CreateDesktopIcon=Create a desktop shortcut english.SetupOptions=After install: -english.StartTrayNow=Start NetX tray now -english.EnableAutostart=Start NetX tray at Windows logon -english.EnableAutoUpdate=Enable daily silent auto-update (needs network later) -english.InstallService=Install as Windows Service (uses bundled WinSW, offline) +english.StartTrayNow=Start NetX now +english.RunModeGroup=How NetX runs: +english.RunModeTray=Desktop tray (recommended) - control NetX from the notification area +english.EnableAutostart=Start automatically when I sign in to Windows +english.RunModeService=Windows service - runs at boot without anyone signed in (servers) +english.MaintenanceGroup=Maintenance: +english.EnableAutoUpdate=Install updates automatically every day (needs network) +english.ServiceInstallFailed=NetX was installed, but the Windows service could not be registered (exit %1).%n%nYou can still start NetX from the Start Menu. Log: %2 +english.IconStart=Start NetX +english.IconStop=Stop NetX +english.IconUpdate=Check for updates english.UninstallDeleteData=Also delete NetX data (%ProgramData%\NetX)?%n%nYes = remove database, settings, and secrets%nNo = keep data for a later reinstall english.DbPageCaption=Database english.DbPageDescription=Choose built-in or external PostgreSQL. Optional credential key: paste from an existing install, or leave empty to auto-generate. @@ -86,10 +103,17 @@ english.ModeUpdateHint=Replaces program files only. ProgramData\.env and databas english.ModeReinstallHint=Shows the Database page again. Does not delete ProgramData unless you uninstall and choose to. chinesesimplified.CreateDesktopIcon=创建桌面快捷方式 chinesesimplified.SetupOptions=安装完成后: -chinesesimplified.StartTrayNow=立即启动 NetX 托盘 -chinesesimplified.EnableAutostart=开机时自动启动 NetX 托盘 -chinesesimplified.EnableAutoUpdate=启用每日静默自动更新(以后需要联网) -chinesesimplified.InstallService=安装为 Windows 服务(使用已捆绑的 WinSW,可离线) +chinesesimplified.StartTrayNow=立即启动 NetX +chinesesimplified.RunModeGroup=运行方式: +chinesesimplified.RunModeTray=桌面托盘(推荐)— 在任务栏通知区域控制 NetX +chinesesimplified.EnableAutostart=登录 Windows 时自动启动 +chinesesimplified.RunModeService=Windows 服务 — 开机即运行,无需登录(适合服务器) +chinesesimplified.MaintenanceGroup=维护: +chinesesimplified.EnableAutoUpdate=每天自动安装更新(需要联网) +chinesesimplified.ServiceInstallFailed=NetX 已安装,但 Windows 服务注册失败(退出码 %1)。%n%n仍可从开始菜单启动 NetX。日志:%2 +chinesesimplified.IconStart=启动 NetX +chinesesimplified.IconStop=停止 NetX +chinesesimplified.IconUpdate=检查更新 chinesesimplified.UninstallDeleteData=是否同时删除 NetX 数据(%ProgramData%\NetX)?%n%n是 = 删除数据库、配置和密钥%n否 = 保留数据以便以后重装 chinesesimplified.DbPageCaption=数据库 chinesesimplified.DbPageDescription=选择内置或外置 PostgreSQL。可选粘贴已有 NETX_CREDENTIAL_SECRET_KEY;留空则自动生成。 @@ -121,6 +145,12 @@ chinesesimplified.ModeUpdateHint=仅替换程序文件,保留 ProgramData\.env chinesesimplified.ModeReinstallHint=再次显示数据库页。不会删除 ProgramData(除非卸载时选择删除)。 [Tasks] +; Run mode is exclusive: tray + service together would race for the same port. +; On upgrades CurPageChanged pre-selects whatever is actually installed. +Name: "runtray"; Description: "{cm:RunModeTray}"; GroupDescription: "{cm:RunModeGroup}"; Flags: exclusive +Name: "runtray\autostart"; Description: "{cm:EnableAutostart}" +Name: "runservice"; Description: "{cm:RunModeService}"; GroupDescription: "{cm:RunModeGroup}"; Flags: exclusive unchecked +Name: "autoupdate"; Description: "{cm:EnableAutoUpdate}"; GroupDescription: "{cm:MaintenanceGroup}"; Flags: unchecked Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce [Files] @@ -163,26 +193,30 @@ Type: filesandordirs; Name: "{app}\.venv" Type: files; Name: "{commondesktop}\NetX Start.lnk" Type: files; Name: "{commondesktop}\NetX First-time setup.lnk" Type: files; Name: "{commondesktop}\首次配置(内置或外置数据库).lnk" +; Start Menu entries from <= 0.4.16 (English-only names; toggles moved into the tray / Tasks page). +Type: files; Name: "{group}\NetX Tray.lnk" +Type: files; Name: "{group}\Start NetX.lnk" +Type: files; Name: "{group}\Stop NetX.lnk" +Type: files; Name: "{group}\Check for updates.lnk" +Type: files; Name: "{group}\Open NetX UI.url" +Type: files; Name: "{group}\Install Windows Service (admin).lnk" +Type: files; Name: "{group}\Enable silent auto-update (daily).lnk" +Type: files; Name: "{group}\Enable start at logon.lnk" [Icons] ; Prefer .cmd targets so Server 2012 / classic Start Menu shows normal program entries. -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}\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}\Open NetX UI"; Filename: "http://127.0.0.1:8890/"; IconFilename: "{app}\packaging\assets\netx.ico" +; Autostart / auto-update / service toggles live in the tray Settings menu and the Tasks page. +Name: "{group}\NetX"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" +Name: "{group}\{cm:IconStart}"; Filename: "{app}\NetX-Start.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" +Name: "{group}\{cm:IconStop}"; Filename: "{app}\NetX-Stop.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico" +Name: "{group}\{cm:IconUpdate}"; Filename: "powershell.exe"; Parameters: "-NoProfile -WindowStyle Hidden -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}\{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}\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" -; One desktop shortcut only (tray = start + tray UI). Start Menu keeps Start/Stop entries. +; One desktop shortcut only (tray = start + tray UI). Name: "{commondesktop}\NetX"; Filename: "{app}\NetX-Tray.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\packaging\assets\netx.ico"; Tasks: desktopicon [Run] +; Autostart / service / auto-update are applied from the Tasks page in CurStepChanged. 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; Check: NetxShouldStartTray -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\install_update_task.ps1"" -ProgramRoot ""{app}"" -DataRoot ""{commonappdata}\NetX"""; WorkingDir: "{app}"; Description: "{cm:EnableAutoUpdate}"; Flags: postinstall runhidden 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 ; Stop runtime BEFORE files are deleted. Do not also Exec prepare from ; InitializeUninstall (that previously raced/killed unins000 or hung the UI). @@ -232,6 +266,7 @@ var GPgToolsReady: Boolean; GDbConnOk: Boolean; GNextBtnCaption: String; + GTasksSynced: Boolean; function EnvHasDbMode(): Boolean; var @@ -822,6 +857,7 @@ begin EdCredKey.Left := ScaleX(120); EdCredKey.Width := DbPage.SurfaceWidth - ScaleX(120); EdCredKey.Text := ''; + EdCredKey.PasswordChar := '*'; TopPos := TopPos + ScaleY(24); LblCredHint := TNewStaticText.Create(DbPage); @@ -1120,6 +1156,106 @@ begin Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); end; +function NetxTaskExists(const Name: String): Boolean; +var + RC: Integer; +begin + Result := Exec(ExpandConstant('{sys}\schtasks.exe'), '/Query /TN "\NetX\' + Name + '"', '', + SW_HIDE, ewWaitUntilTerminated, RC) and (RC = 0); +end; + +function NetxServiceExists(): Boolean; +var + RC: Integer; +begin + Result := Exec(ExpandConstant('{sys}\sc.exe'), 'query NetX', '', SW_HIDE, ewWaitUntilTerminated, RC) and (RC = 0); +end; + +procedure CurPageChanged(CurPageID: Integer); +begin + { Existing install: show the run mode / toggles that are really in place + (older versions set them from Finish-page checkboxes Inno never recorded). } + if (CurPageID = wpSelectTasks) and (not GTasksSynced) then + begin + GTasksSynced := True; + if FileExists(ExpandConstant('{app}\packaging\netx_tray.ps1')) then + begin + if NetxServiceExists() then + WizardSelectTasks('runservice') + else + WizardSelectTasks('runtray'); + if NetxTaskExists('NetXTray') then + WizardSelectTasks('runtray\autostart') + else + WizardSelectTasks('!runtray\autostart'); + if NetxTaskExists('NetXUpdate') then + WizardSelectTasks('autoupdate') + else + WizardSelectTasks('!autoupdate'); + end; + end; +end; + +function RunNetxScript(const Script, Extra: String): Integer; +var + Params: String; +begin + Params := + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\packaging\' + Script) + '"' + + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"' + Extra; + if not Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, Result) then + Result := -1; +end; + +function TasksGivenOnCommandLine(): Boolean; +var + i: Integer; + P: String; +begin + Result := False; + for i := 1 to ParamCount do + begin + P := Uppercase(ParamStr(i)); + if (Pos('/TASKS=', P) = 1) or (Pos('/MERGETASKS=', P) = 1) then + begin + Result := True; + Exit; + end; + end; +end; + +procedure ApplyRunModeTasks(); +var + Code: Integer; +begin + { Silent auto-updates keep whatever is installed unless /TASKS is passed. } + if WizardSilent and (not TasksGivenOnCommandLine()) then + Exit; + if not FileExists(ExpandConstant('{commonappdata}\NetX\.env')) then + Exit; + + if WizardIsTaskSelected('runservice') then + begin + Code := RunNetxScript('install_service.ps1', ' -Start'); + if Code <> 0 then + SuppressibleMsgBox(FmtMessage(ExpandConstant('{cm:ServiceInstallFailed}'), [IntToStr(Code), + ExpandConstant('{commonappdata}\NetX\data\runtime')]), mbError, MB_OK, IDOK); + end + else if NetxServiceExists() then + RunNetxScript('install_service.ps1', ' -Uninstall'); + + if WizardIsTaskSelected('runtray\autostart') then + RunNetxScript('install_autostart.ps1', '') + else + RunNetxScript('install_autostart.ps1', ' -Remove'); + + if WizardIsTaskSelected('autoupdate') then + RunNetxScript('install_update_task.ps1', '') + else if NetxTaskExists('NetXUpdate') then + RunNetxScript('install_update_task.ps1', ' -Remove'); +end; + procedure CurStepChanged(CurStep: TSetupStep); var UninstallKey: String; @@ -1138,6 +1274,7 @@ begin UninstallKey := 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001}_is1'; RegDeleteValue(HKLM, UninstallKey, 'QuietUninstallString'); + ApplyRunModeTasks(); ResumeNetxServiceAfterInstall(); end; end; diff --git a/packaging/launch_shortcut.ps1 b/packaging/launch_shortcut.ps1 index 7f13188..fcc5fa2 100644 --- a/packaging/launch_shortcut.ps1 +++ b/packaging/launch_shortcut.ps1 @@ -79,7 +79,7 @@ try { } } "stop" { - $p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "stop_netx_app.ps1") + $p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "runtime_ctl.ps1") -ExtraArgs @("-Action", "stop") if ($null -ne $p.ExitCode -and $p.ExitCode -ne 0) { throw "stop_exit_$($p.ExitCode)" } } "update" { @@ -102,10 +102,11 @@ try { 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") + $hostBind = Get-NetxClientHost -BindHost $hostBind + $p = Invoke-NetxHiddenPs1 -File (Join-Path $PSScriptRoot "runtime_ctl.ps1") -ExtraArgs @("-Action", "start") 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 {} + Open-NetxUrl -Url "http://${hostBind}:${port}/" } else { throw (T ` "NetX started but /health not ready within 180s.`nSee: $logDir\netx.err.log" ` diff --git a/packaging/netx_tray.ps1 b/packaging/netx_tray.ps1 index bffcc9c..21364b0 100644 --- a/packaging/netx_tray.ps1 +++ b/packaging/netx_tray.ps1 @@ -2,12 +2,16 @@ [string]$ProgramRoot = "", [string]$DataRoot = "", [switch]$StartOnLaunch = $true, + # Logon autostart / post-update relaunch: bring NetX up but never pop a browser. + [switch]$QuietStart = $false, [switch]$CheckUpdateOnLaunch = $false, [switch]$AutoUpdate = $false ) # System-tray controller for NetX (Windows packaged installs). # Always elevated: Start/Stop/Update touch Program Files + services. +# The UI thread never blocks: /health and scheduled-task state are polled in a +# background runspace, child scripts are watched by a timer. $ErrorActionPreference = "Stop" . "$PSScriptRoot\_common.ps1" @@ -16,102 +20,141 @@ Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundP Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing -# Custom tray menu: hover/selected highlight + flat light chrome (default OS menu looks dead). if (-not ("NetxMenuRenderer" -as [type])) { Add-Type -ReferencedAssemblies System.Windows.Forms, System.Drawing -TypeDefinition @" +using System; using System.Drawing; using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; using System.Windows.Forms; -public sealed class NetxMenuColorTable : ProfessionalColorTable { - static readonly Color Hover = Color.FromArgb(232, 240, 252); - static readonly Color Press = Color.FromArgb(214, 228, 248); - static readonly Color Border = Color.FromArgb(170, 198, 240); - static readonly Color Edge = Color.FromArgb(210, 214, 220); - static readonly Color Sep = Color.FromArgb(228, 230, 235); - static readonly Color Bg = Color.FromArgb(252, 252, 253); +public static class NetxNative { + [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr handle); + [DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); - public override Color MenuItemSelected { get { return Hover; } } - public override Color MenuItemSelectedGradientBegin { get { return Hover; } } - public override Color MenuItemSelectedGradientEnd { get { return Hover; } } - public override Color MenuItemBorder { get { return Border; } } - public override Color MenuItemPressedGradientBegin { get { return Press; } } - public override Color MenuItemPressedGradientMiddle { get { return Press; } } - public override Color MenuItemPressedGradientEnd { get { return Press; } } - public override Color MenuBorder { get { return Edge; } } - public override Color ToolStripDropDownBackground { get { return Bg; } } - public override Color ImageMarginGradientBegin { get { return Bg; } } - public override Color ImageMarginGradientMiddle { get { return Bg; } } - public override Color ImageMarginGradientEnd { get { return Bg; } } - public override Color SeparatorDark { get { return Sep; } } - public override Color SeparatorLight { get { return Sep; } } + // Windows 11: rounded popup corners + themed 1px border drawn by DWM. + public static bool RoundCorners(IntPtr hwnd, Color border) { + try { + int pref = 2; // DWMWCP_ROUND + if (DwmSetWindowAttribute(hwnd, 33, ref pref, 4) != 0) return false; + int c = ColorTranslator.ToWin32(border); + DwmSetWindowAttribute(hwnd, 34, ref c, 4); + return true; + } catch { return false; } + } +} + +public sealed class NetxTheme { + public Color Back, Hover, Text, Muted, Sep, Border; + public bool DwmRounded; +} + +public sealed class NetxMenuColorTable : ProfessionalColorTable { + readonly NetxTheme t; + public NetxMenuColorTable(NetxTheme theme) { t = theme; UseSystemColors = false; } + public override Color ToolStripDropDownBackground { get { return t.Back; } } + public override Color ImageMarginGradientBegin { get { return t.Back; } } + public override Color ImageMarginGradientMiddle { get { return t.Back; } } + public override Color ImageMarginGradientEnd { get { return t.Back; } } + public override Color MenuBorder { get { return t.Border; } } + public override Color MenuItemBorder { get { return t.Hover; } } + public override Color MenuItemSelected { get { return t.Hover; } } + public override Color MenuItemSelectedGradientBegin { get { return t.Hover; } } + public override Color MenuItemSelectedGradientEnd { get { return t.Hover; } } + public override Color MenuItemPressedGradientBegin { get { return t.Hover; } } + public override Color MenuItemPressedGradientMiddle { get { return t.Hover; } } + public override Color MenuItemPressedGradientEnd { get { return t.Hover; } } + public override Color SeparatorDark { get { return t.Sep; } } + public override Color SeparatorLight { get { return t.Sep; } } } public sealed class NetxMenuRenderer : ToolStripProfessionalRenderer { - public NetxMenuRenderer() : base(new NetxMenuColorTable()) { + readonly NetxTheme t; + readonly float scale; + + public NetxMenuRenderer(NetxTheme theme, float dpiScale) : base(new NetxMenuColorTable(theme)) { + t = theme; + scale = dpiScale; RoundedEdges = false; } - protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) { - ToolStripMenuItem item = e.Item as ToolStripMenuItem; - if (item == null || !item.Selected || !item.Enabled) { - base.OnRenderMenuItemBackground(e); - return; - } - Rectangle r = new Rectangle(2, 0, e.Item.Width - 4, e.Item.Height); - using (SolidBrush brush = new SolidBrush(Color.FromArgb(232, 240, 252))) - using (Pen pen = new Pen(Color.FromArgb(170, 198, 240))) { - e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; - e.Graphics.FillRectangle(brush, r); - e.Graphics.DrawRectangle(pen, r.X, r.Y, r.Width - 1, r.Height - 1); - } + int S(int px) { return (int)Math.Round(px * scale); } + + static GraphicsPath Round(Rectangle r, int radius) { + int d = radius * 2; + GraphicsPath p = new GraphicsPath(); + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; } + protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e) { + using (SolidBrush b = new SolidBrush(t.Back)) e.Graphics.FillRectangle(b, e.AffectedBounds); + } + + protected override void OnRenderImageMargin(ToolStripRenderEventArgs e) { } + protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e) { + if (t.DwmRounded) return; // DWM draws the rounded border on Windows 11 Rectangle r = new Rectangle(0, 0, e.AffectedBounds.Width - 1, e.AffectedBounds.Height - 1); - using (Pen pen = new Pen(Color.FromArgb(210, 214, 220))) { - e.Graphics.DrawRectangle(pen, r); + using (Pen pen = new Pen(t.Border)) e.Graphics.DrawRectangle(pen, r); + } + + protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) { + ToolStripItem item = e.Item; + if (!item.Selected || !item.Enabled) return; + Rectangle r = new Rectangle(S(4), S(1), item.Width - S(8), item.Height - S(2)); + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using (GraphicsPath path = Round(r, S(4))) + using (SolidBrush b = new SolidBrush(t.Hover)) e.Graphics.FillPath(b, path); + } + + protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e) { + // Drawn directly: the base renderer forces grey on disabled (header) items. + string role = e.Item.Tag as string; + Color c; + if (role == "header") c = t.Text; + else if (role == "status") c = t.Muted; + else c = e.Item.Enabled ? t.Text : t.Muted; + TextRenderer.DrawText(e.Graphics, e.Text, e.TextFont, e.TextRectangle, c, e.TextFormat); + } + + protected override void OnRenderItemImage(ToolStripItemImageRenderEventArgs e) { + string role = e.Item.Tag as string; + if (e.Image != null && (e.Item.Enabled || role == "status" || role == "header")) { + e.Graphics.DrawImage(e.Image, e.ImageRectangle); + return; } + base.OnRenderItemImage(e); + } + + protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e) { + e.ArrowColor = t.Muted; + base.OnRenderArrow(e); } protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e) { int y = e.Item.Height / 2; - using (Pen pen = new Pen(Color.FromArgb(228, 230, 235))) { - e.Graphics.DrawLine(pen, 10, y, e.Item.Width - 10, y); - } + using (Pen pen = new Pen(t.Sep)) e.Graphics.DrawLine(pen, S(12), y, e.Item.Width - S(12), y); } } "@ } +# Crisp text on high-DPI screens (powershell.exe is DPI-unaware by default). +try { [void][NetxNative]::SetProcessDPIAware() } catch {} +[System.Windows.Forms.Application]::EnableVisualStyles() + $prog = Get-NetxProgramRoot -Override $ProgramRoot $data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot -$hostBind = "127.0.0.1" -$port = 8890 $envPath = Join-Path $data ".env" -$map = @{} -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 {} } -} -$autoVal = "" -if ($map["NETX_UPDATE_AUTO"]) { $autoVal = $map["NETX_UPDATE_AUTO"] } -elseif ($env:NETX_UPDATE_AUTO) { $autoVal = $env:NETX_UPDATE_AUTO } -if ($autoVal -match '^(1|true|yes|on)$') { - $AutoUpdate = $true - $CheckUpdateOnLaunch = $true -} -$uiUrl = "http://${hostBind}:${port}/" -$script:ver = Get-NetxVersion -ProgramRoot $prog -$ver = $script:ver -$dbMode = Get-DbMode -EnvMap $map -$dbModeLabel = if ($dbMode -eq "bundled") { - if (([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')) { "内置库" } else { "built-in DB" } -} else { - if (([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)')) { "外置库" } else { "external DB" } -} -$zh = ([cultureinfo]::CurrentUICulture.Name -match '^(zh|zh-)') +$logDir = Join-Path $data "data\runtime" + +$zh = ([cultureinfo]::CurrentUICulture.Name -match '^zh') function T([string]$En, [string]$Zh) { if ($zh) { return $Zh } else { return $En } } @@ -128,18 +171,39 @@ if (-not (Test-Path $envPath)) { exit 1 } -# Only one tray icon per machine session (desktop + postinstall + autostart can race). +function Read-NetxTrayConfig { + $map = Read-DotEnv -Path $envPath + $bind = if ($map["NETX_HOST"]) { $map["NETX_HOST"] } else { "127.0.0.1" } + $p = 8890 + if ($map["NETX_PORT"]) { try { $p = [int]$map["NETX_PORT"] } catch {} } + $script:clientHost = Get-NetxClientHost -BindHost $bind + $script:port = $p + $script:uiUrl = "http://$($script:clientHost):$p/" + $script:dbModeLabel = if ((Get-DbMode -EnvMap $map) -eq "bundled") { (T "Built-in DB" "内置库") } else { (T "External DB" "外置库") } + $script:autoUpdateEnv = ([string]$map["NETX_UPDATE_AUTO"] -match '^(1|true|yes|on)$') -or + ([string]$env:NETX_UPDATE_AUTO -match '^(1|true|yes|on)$') +} +Read-NetxTrayConfig +if ($script:autoUpdateEnv) { + $AutoUpdate = $true + $CheckUpdateOnLaunch = $true +} +$script:ver = Get-NetxVersion -ProgramRoot $prog + +# Only one tray icon per logon 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 + if (-not $QuietStart) { + if (Test-NetxApiHealthy -HostName $script:clientHost -Port $script:port -TimeoutSec 2) { + Open-NetxUrl -Url $script:uiUrl + } elseif ($StartOnLaunch) { + Start-NetxPowerShell -File (Join-Path $PSScriptRoot "runtime_ctl.ps1") ` + -Arguments @("-Action", "start", "-ProgramRoot", $prog, "-DataRoot", $data) ` + -WorkingDirectory $prog -WindowStyle Hidden | Out-Null + } } exit 0 } @@ -147,211 +211,196 @@ try { # If mutex fails, continue with a tray (better than no UI control). } -$startPs1 = Join-Path $PSScriptRoot "start_netx_app.ps1" -$stopPs1 = Join-Path $PSScriptRoot "stop_netx_app.ps1" +$runtimePs1 = Join-Path $PSScriptRoot "runtime_ctl.ps1" $checkPs1 = Join-Path $PSScriptRoot "check_update.ps1" $setupPs1 = Join-Path $PSScriptRoot "setup_first_run.ps1" +$autostartPs1 = Join-Path $PSScriptRoot "install_autostart.ps1" +$updateTaskPs1 = Join-Path $PSScriptRoot "install_update_task.ps1" -function Invoke-NetxScript { - param( - [string]$File, - [string[]]$ExtraArgs = @(), - [switch]$Wait = $false - ) - return Start-NetxPowerShell -File $File -Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) ` - -WorkingDirectory $prog -WindowStyle Hidden -PassThru -Wait:$Wait -} - -function Update-NetxTrayTip { - # Refresh version after in-place updates (tray process may outlive version.json). - try { - $nowVer = Get-NetxVersion -ProgramRoot $prog - if ($nowVer -and $nowVer -ne $script:ver) { - $script:ver = $nowVer - if ($null -ne $script:miHeader) { $script:miHeader.Text = "NetX $script:ver" } - } - } catch {} - $running = Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 1 - $state = if ($running) { - (T "Running" "运行中") - } else { - (T "Stopped" "已停止") - } - $v = $script:ver - $tip = "NetX $v · $state · $dbModeLabel`n$uiUrl" - if ($tip.Length -gt 63) { - # NotifyIcon.Text max ~63 chars on older Windows. - $tip = "NetX $v · $state · $dbModeLabel" - } - $notify.Text = $tip -} - -function Close-NetxTrayMenu { - # ContextMenuStrip stays modal if Click handlers block; close before dialogs/waits. - try { $menu.Close() } catch {} - try { [System.Windows.Forms.Application]::DoEvents() } catch {} -} - -function Start-NetxTrayDeferred { - param([scriptblock]$Work) - Close-NetxTrayMenu - $timer = New-Object System.Windows.Forms.Timer - $timer.Interval = 50 - $script:netxTrayDeferredWork = $Work - $timer.add_Tick({ - $t = $script:netxTrayDeferredTimer - try { if ($t) { $t.Stop(); $t.Dispose() } } catch {} - $script:netxTrayDeferredTimer = $null - $w = $script:netxTrayDeferredWork - $script:netxTrayDeferredWork = $null - if ($w) { & $w } +# ---------------------------------------------------------------- background probe +# /health + runner/task state off the UI thread; the UI timer only reads $sync. +$script:sync = [hashtable]::Synchronized(@{ + HealthUrl = "$($script:uiUrl)health" + Healthy = $false + Seen = $false + Runner = "" + Autostart = $false + AutoUpdate = $false + Stop = $false }) - $script:netxTrayDeferredTimer = $timer - $timer.Start() -} - -function Start-NetxTrayWatchProcess { - # Never Start-Process -Wait on the WinForms UI thread — it freezes the tray menu. - param( - [Parameter(Mandatory = $true)]$Process, - [scriptblock]$OnExit - ) - if ($null -eq $Process) { - if ($OnExit) { & $OnExit $null } - return - } - $script:netxWatchProc = $Process - $script:netxWatchOnExit = $OnExit - if ($script:netxWatchTimer) { - try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {} - } - $timer = New-Object System.Windows.Forms.Timer - $timer.Interval = 400 - $timer.add_Tick({ - $p = $script:netxWatchProc - if ($null -eq $p) { - try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {} - $script:netxWatchTimer = $null - return - } - $done = $false - try { $done = [bool]$p.HasExited } catch { $done = $true } - if (-not $done) { return } - try { $script:netxWatchTimer.Stop(); $script:netxWatchTimer.Dispose() } catch {} - $script:netxWatchTimer = $null - $cb = $script:netxWatchOnExit - $script:netxWatchOnExit = $null - $script:netxWatchProc = $null - if ($cb) { & $cb $p } - }) - $script:netxWatchTimer = $timer - $timer.Start() -} - -function Restart-NetxTraySelf { - # Fresh process picks up new version.json / scripts after in-place update. - try { - $notify.ShowBalloonTip( - 2500, "NetX", - (T "Restarting tray…" "正在重启托盘…"), - [System.Windows.Forms.ToolTipIcon]::Info - ) - } catch {} - $trayFile = Join-Path $PSScriptRoot "netx_tray.ps1" - $arg = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$trayFile`" -ProgramRoot `"$prog`" -DataRoot `"$data`"" - try { - Start-Process -FilePath "powershell.exe" -ArgumentList $arg -WindowStyle Hidden | Out-Null - } catch {} - try { $notify.Visible = $false } catch {} - try { $form.Close() } catch {} - [System.Windows.Forms.Application]::Exit() -} - -function Test-NetxSetupCancelled { - param([AllowNull()][Nullable[int]]$ExitCode) - if ($null -eq $ExitCode) { return $false } - # STATUS_CONTROL_C_EXIT (0xC000013A): console closed / Ctrl+C — not a setup failure. - return ([int]$ExitCode -eq -1073741510) -} - -function Complete-NetxTrayReconfig { - # Reload .env after reconfigure (host/port/mode may change). - if (Test-Path $envPath) { - $map = Read-DotEnv -Path $envPath - if ($map["NETX_HOST"]) { $script:hostBind = $map["NETX_HOST"]; $hostBind = $script:hostBind } - if ($map["NETX_PORT"]) { +$script:probeRunspace = [runspacefactory]::CreateRunspace() +$script:probeRunspace.Open() +$script:probeRunspace.SessionStateProxy.SetVariable("sync", $script:sync) +$script:probePs = [powershell]::Create() +$script:probePs.Runspace = $script:probeRunspace +[void]$script:probePs.AddScript({ + $i = 0 + while (-not $sync.Stop) { + $ok = $false try { - $script:port = [int]$map["NETX_PORT"] - $port = $script:port + $r = Invoke-WebRequest -Uri $sync.HealthUrl -UseBasicParsing -TimeoutSec 2 -MaximumRedirection 0 + if ($r.StatusCode -eq 200) { $ok = (($r.Content | ConvertFrom-Json).status -eq "ok") } } catch {} - } - $script:uiUrl = "http://${hostBind}:${port}/" - $uiUrl = $script:uiUrl - $dbMode = Get-DbMode -EnvMap $map - $script:dbModeLabel = if ($dbMode -eq "bundled") { - (T "built-in DB" "内置库") - } else { - (T "external DB" "外置库") - } - $dbModeLabel = $script:dbModeLabel - if ($miSub) { $miSub.Text = "$dbModeLabel · ${hostBind}:$port" } - } - - $restart = [System.Windows.Forms.MessageBox]::Show( - (T "Database configuration saved.`nStart NetX now?" "数据库配置已保存。`n是否立即启动 NetX?"), - "NetX", - [System.Windows.Forms.MessageBoxButtons]::YesNo, - [System.Windows.Forms.MessageBoxIcon]::Information - ) - if ($restart -eq [System.Windows.Forms.DialogResult]::Yes) { - Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null - Open-NetxUiWhenReady - } else { - Update-NetxTrayTip - } -} - -function Open-NetxUiWhenReady { - param([int]$TimeoutSec = 180) - $notify.ShowBalloonTip( - 5000, - "NetX", - (T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"), - [System.Windows.Forms.ToolTipIcon]::Info - ) - # Poll on a timer — do not block the tray UI thread with Wait-NetxApiHealthy. - if ($script:netxUiReadyTimer) { - try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {} - } - $script:netxUiReadyTicks = 0 - $script:netxUiReadyMax = [Math]::Max(1, [int]($TimeoutSec * 2)) - $timer = New-Object System.Windows.Forms.Timer - $timer.Interval = 500 - $timer.add_Tick({ - $script:netxUiReadyTicks++ - if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) { - try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {} - $script:netxUiReadyTimer = $null - try { Start-Process $uiUrl } catch {} - Update-NetxTrayTip - return - } - if ($script:netxUiReadyTicks -ge $script:netxUiReadyMax) { - try { $script:netxUiReadyTimer.Stop(); $script:netxUiReadyTimer.Dispose() } catch {} - $script:netxUiReadyTimer = $null - $notify.ShowBalloonTip( - 8000, - "NetX", - (T "UI not ready yet. Use tray → Open UI when ready, or check data\runtime\netx.err.log." "界面尚未就绪。就绪后请用托盘「打开界面」,或查看 data\runtime\netx.err.log。"), - [System.Windows.Forms.ToolTipIcon]::Warning - ) - Update-NetxTrayTip + $sync.Healthy = $ok + $sync.Seen = $true + if (($i % 6) -eq 0) { + $runner = "" + $svc = Get-Service -Name "NetX" -ErrorAction SilentlyContinue + if ($svc -and [string]$svc.StartType -ne "Disabled") { $runner = "service" } + $tasks = @{} + try { + Get-ScheduledTask -TaskPath "\NetX\" -ErrorAction Stop | ForEach-Object { $tasks[$_.TaskName] = [string]$_.State } + } catch {} + if (-not $runner -and $tasks["NetXService"] -and $tasks["NetXService"] -ne "Disabled") { $runner = "task" } + $sync.Runner = $runner + $sync.Autostart = [bool]($tasks["NetXTray"] -and $tasks["NetXTray"] -ne "Disabled") + $sync.AutoUpdate = [bool]($tasks["NetXUpdate"] -and $tasks["NetXUpdate"] -ne "Disabled") + } + $i++ + for ($k = 0; $k -lt 15 -and -not $sync.Stop -and -not $sync.Poke; $k++) { Start-Sleep -Milliseconds 100 } + $sync.Poke = $false } }) - $script:netxUiReadyTimer = $timer - $timer.Start() +$script:probeHandle = $script:probePs.BeginInvoke() + +function Request-NetxProbe { $script:sync.Poke = $true } + +# ---------------------------------------------------------------- theme + drawing +$script:dpiScale = 1.0 +try { + $g = [System.Drawing.Graphics]::FromHwnd([IntPtr]::Zero) + $script:dpiScale = $g.DpiX / 96.0 + $g.Dispose() +} catch {} +function Get-NetxPx([int]$Px) { return [int][Math]::Round($Px * $script:dpiScale) } + +$script:glyphFont = "Segoe MDL2 Assets" +try { + $installed = (New-Object System.Drawing.Text.InstalledFontCollection).Families | ForEach-Object { $_.Name } + if ($installed -contains "Segoe Fluent Icons") { $script:glyphFont = "Segoe Fluent Icons" } +} catch {} +$script:uiFontName = if ($zh) { "Microsoft YaHei UI" } else { "Segoe UI" } + +function Test-NetxDarkMode { + try { + $v = Get-ItemPropertyValue -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -Name "AppsUseLightTheme" -ErrorAction Stop + return ([int]$v -eq 0) + } catch { return $false } } +function New-NetxTheme([bool]$Dark) { + $t = New-Object NetxTheme + $c = { param($r, $g, $b) [System.Drawing.Color]::FromArgb($r, $g, $b) } + if ($Dark) { + $t.Back = & $c 28 32 40 + $t.Hover = & $c 44 54 72 + $t.Text = & $c 232 237 244 + $t.Muted = & $c 140 150 166 + $t.Sep = & $c 50 56 68 + $t.Border = & $c 58 66 82 + } else { + $t.Back = & $c 251 252 253 + $t.Hover = & $c 228 237 251 + $t.Text = & $c 26 31 38 + $t.Muted = & $c 108 117 130 + $t.Sep = & $c 229 232 237 + $t.Border = & $c 212 218 226 + } + return $t +} + +$script:stateColors = @{ + running = [System.Drawing.Color]::FromArgb(34, 197, 94) + stopped = [System.Drawing.Color]::FromArgb(148, 160, 178) + starting = [System.Drawing.Color]::FromArgb(245, 166, 35) + stopping = [System.Drawing.Color]::FromArgb(245, 166, 35) + checking = [System.Drawing.Color]::FromArgb(148, 160, 178) +} + +function New-NetxGlyph([char]$Glyph, [System.Drawing.Color]$Color) { + $sz = Get-NetxPx 16 + $bmp = New-Object System.Drawing.Bitmap($sz, $sz) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit + $font = New-Object System.Drawing.Font($script:glyphFont, [float]($sz * 0.78), [System.Drawing.GraphicsUnit]::Pixel) + $brush = New-Object System.Drawing.SolidBrush($Color) + $fmt = New-Object System.Drawing.StringFormat + $fmt.Alignment = [System.Drawing.StringAlignment]::Center + $fmt.LineAlignment = [System.Drawing.StringAlignment]::Center + $g.DrawString([string]$Glyph, $font, $brush, (New-Object System.Drawing.RectangleF(0, 1, $sz, $sz)), $fmt) + $fmt.Dispose(); $brush.Dispose(); $font.Dispose(); $g.Dispose() + return $bmp +} + +function New-NetxDot([System.Drawing.Color]$Color) { + $sz = Get-NetxPx 16 + $bmp = New-Object System.Drawing.Bitmap($sz, $sz) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $d = [Math]::Max(6, [int]($sz * 0.5)) + $o = [int](($sz - $d) / 2) + $brush = New-Object System.Drawing.SolidBrush($Color) + $g.FillEllipse($brush, $o, $o, $d, $d) + $brush.Dispose(); $g.Dispose() + return $bmp +} + +$script:glyphs = @{ + open = [char]0xE8A7 + start = [char]0xE768 + stop = [char]0xE71A + restart = [char]0xE72C + settings = [char]0xE713 + update = [char]0xE895 + repair = [char]0xE90F + folder = [char]0xE8B7 + log = [char]0xE8A5 + check = [char]0xE73E + exit = [char]0xE8BB + power = [char]0xE7E8 +} + +# Brand mark from the 256px PNG (small .ico frames do not convert cleanly). +$script:brandPng = $null +try { $script:brandPng = [System.Drawing.Image]::FromFile((Join-Path $PSScriptRoot "assets\netx-256.png")) } catch {} +function New-NetxBrandBitmap([int]$Size) { + $bmp = New-Object System.Drawing.Bitmap($Size, $Size) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $rect = New-Object System.Drawing.Rectangle(0, 0, $Size, $Size) + if ($script:brandPng) { $g.DrawImage($script:brandPng, $rect) } + else { $g.DrawIcon([System.Drawing.SystemIcons]::Application, $rect) } + $g.Dispose() + return $bmp +} + +# Tray icons with a status dot, built once per state. +$script:iconHandles = @() +function New-NetxStateIcon([System.Drawing.Color]$Dot) { + $sz = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width + $bmp = New-NetxBrandBitmap $sz + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $d = [Math]::Max(5, [int][Math]::Round($sz * 0.42)) + $x = $sz - $d + $y = $sz - $d + $ring = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(15, 23, 36)) + $g.FillEllipse($ring, $x - 1, $y - 1, $d + 1, $d + 1) + $brush = New-Object System.Drawing.SolidBrush($Dot) + $g.FillEllipse($brush, $x + 1, $y + 1, $d - 3, $d - 3) + $ring.Dispose(); $brush.Dispose(); $g.Dispose() + $h = $bmp.GetHicon() + $bmp.Dispose() + $script:iconHandles += $h + return [System.Drawing.Icon]::FromHandle($h) +} +$script:trayIcons = @{} +foreach ($k in $script:stateColors.Keys) { + try { $script:trayIcons[$k] = New-NetxStateIcon $script:stateColors[$k] } catch {} +} + +# ---------------------------------------------------------------- window + icon $form = New-Object System.Windows.Forms.Form $form.Text = "NetX" $form.ShowInTaskbar = $false @@ -360,390 +409,539 @@ $form.Visible = $false $form.Size = New-Object System.Drawing.Size(0, 0) $notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = if ($script:trayIcons["checking"]) { $script:trayIcons["checking"] } else { [System.Drawing.SystemIcons]::Application } +$notify.Text = "NetX" $notify.Visible = $true -$iconPath = Join-Path $PSScriptRoot "assets\netx.ico" -try { - if (Test-Path $iconPath) { - $notify.Icon = New-Object System.Drawing.Icon $iconPath - } else { - $notify.Icon = [System.Drawing.SystemIcons]::Application - } -} catch { - try { $notify.Icon = [System.Drawing.SystemIcons]::Application } catch {} -} -Update-NetxTrayTip -function New-NetxMenuItem { - param( - [string]$Text, - [switch]$Header, - [switch]$Primary, - [switch]$Muted +function Show-NetxToast([string]$Text, [string]$Kind = "Info", [int]$Ms = 4000) { + try { $notify.ShowBalloonTip($Ms, "NetX", $Text, [System.Windows.Forms.ToolTipIcon]::$Kind) } catch {} +} + +function Show-NetxDialog([string]$Text, [string]$Kind = "Information", [string]$Buttons = "OK") { + return [System.Windows.Forms.MessageBox]::Show( + $Text, "NetX", + [System.Windows.Forms.MessageBoxButtons]::$Buttons, + [System.Windows.Forms.MessageBoxIcon]::$Kind ) +} + +# ---------------------------------------------------------------- child processes +# Never Start-Process -Wait on the WinForms UI thread — it freezes the tray menu. +$script:watches = New-Object System.Collections.ArrayList +$script:watchTimer = New-Object System.Windows.Forms.Timer +$script:watchTimer.Interval = 300 +$script:watchTimer.add_Tick({ + foreach ($w in @($script:watches)) { + $done = $true + try { $done = [bool]$w.Proc.HasExited } catch {} + if (-not $done) { continue } + [void]$script:watches.Remove($w) + if ($w.OnExit) { + try { & $w.OnExit $w.Proc $w.Context } catch { Show-NetxToast $_.Exception.Message "Warning" } + } + } + if ($script:watches.Count -eq 0) { $script:watchTimer.Stop() } + }) + +function Watch-NetxProcess($Process, [scriptblock]$OnExit, [hashtable]$Context = @{}) { + # OnExit runs later from the timer: pass anything it needs via -Context + # (function locals are gone by then). + if ($null -eq $Process) { + if ($OnExit) { & $OnExit $null $Context } + return + } + [void]$script:watches.Add(@{ Proc = $Process; OnExit = $OnExit; Context = $Context }) + $script:watchTimer.Start() +} + +function Start-NetxChild([string]$File, [string[]]$ExtraArgs = @(), [string]$WindowStyle = "Hidden") { + return Start-NetxPowerShell -File $File -Arguments (@("-ProgramRoot", $prog, "-DataRoot", $data) + $ExtraArgs) ` + -WorkingDirectory $prog -WindowStyle $WindowStyle -PassThru +} + +function Get-NetxExitCode($Process) { + if ($null -eq $Process) { return $null } + try { return $Process.ExitCode } catch { return $null } +} + +function Test-NetxSetupCancelled($ExitCode) { + # STATUS_CONTROL_C_EXIT (0xC000013A): console closed / Ctrl+C — not a failure. + return ($null -ne $ExitCode -and [int]$ExitCode -eq -1073741510) +} + +# ---------------------------------------------------------------- runtime state +# pending = an in-flight start/stop/restart; it clears once the child exits and +# /health matches the target (or on timeout). +$script:pending = $null +$script:openWhenReady = $null +$script:lastState = "" + +function Invoke-NetxRuntime([ValidateSet("start", "stop", "restart")][string]$Action, [switch]$OpenUi, [scriptblock]$Then) { + if ($script:pending) { return } + $kind = if ($Action -eq "stop") { "stopping" } else { "starting" } + $timeout = if ($Action -eq "stop") { 90 } else { 200 } + $proc = $null + try { + $proc = Start-NetxChild -File $runtimePs1 -ExtraArgs @("-Action", $Action) + } catch { + Show-NetxToast ((T "Could not run NetX control script: " "无法执行 NetX 控制脚本:") + $_.Exception.Message) "Error" + return + } + $script:pending = @{ + Kind = $kind + Target = ($Action -ne "stop") + Done = $false + Until = (Get-Date).AddSeconds($timeout) + Then = $Then + } + if ($OpenUi) { $script:openWhenReady = (Get-Date).AddSeconds($timeout) } + Update-NetxTrayState + Watch-NetxProcess $proc -Context @{ Action = $Action } -OnExit { + param($p, $ctx) + $code = Get-NetxExitCode $p + $pend = $script:pending + if ($null -ne $code -and $code -ne 0) { + $script:pending = $null + $script:openWhenReady = $null + Show-NetxToast ((T "NetX $($ctx.Action) failed (exit $code). Logs: " "NetX 操作失败(退出码 $code)。日志:") + $logDir) "Error" 8000 + } elseif ($pend) { + $pend.Done = $true + } + Request-NetxProbe + Update-NetxTrayState + if ($pend -and $pend.Then) { & $pend.Then $code } + } +} + +function Get-NetxTrayState { + $healthy = [bool]$script:sync.Healthy + $p = $script:pending + if ($p) { + if ((Get-Date) -gt $p.Until) { + $script:pending = $null + } elseif ($p.Done -and ($healthy -eq $p.Target)) { + $script:pending = $null + } else { + return $p.Kind + } + } + if (-not $script:sync.Seen) { return "checking" } + if ($healthy) { return "running" } + return "stopped" +} + +$script:stateText = @{ + running = (T "Running" "运行中") + stopped = (T "Stopped" "已停止") + starting = (T "Starting…" "正在启动…") + stopping = (T "Stopping…" "正在停止…") + checking = (T "Checking…" "检测中…") +} + +function Update-NetxTrayState { + $state = Get-NetxTrayState + if ($script:openWhenReady) { + if ($state -eq "running") { + $script:openWhenReady = $null + Open-NetxUrl -Url $script:uiUrl + } elseif ((Get-Date) -gt $script:openWhenReady -or ($state -eq "stopped" -and -not $script:pending)) { + $script:openWhenReady = $null + Show-NetxToast (T "NetX did not become ready. Check data\runtime\netx.err.log." "NetX 未能就绪,请查看 data\runtime\netx.err.log。") "Warning" 8000 + } + } + $runner = [string]$script:sync.Runner + $key = "$state|$runner|$($script:ver)" + if ($key -eq $script:lastState) { return } + $prevState = ($script:lastState -split '\|')[0] + $script:lastState = $key + + $text = $script:stateText[$state] + $miStatus.Text = $text + $miStatus.Image = $script:dots[$state] + $mode = if ($runner -eq "service") { (T "Service" "服务") } elseif ($runner -eq "task") { (T "Startup task" "启动任务") } else { $script:dbModeLabel } + $miStatus.ShortcutKeyDisplayString = "$mode · $($script:clientHost):$($script:port)" + $miHeader.ShortcutKeyDisplayString = "v$($script:ver)" + + $busy = ($state -in @("starting", "stopping", "checking")) + $miStart.Enabled = (-not $busy) -and ($state -eq "stopped") + $miStop.Enabled = (-not $busy) -and ($state -eq "running") + $miRestart.Enabled = (-not $busy) -and ($state -eq "running") + $miOpen.Enabled = ($state -ne "stopping") + + if ($script:trayIcons[$state]) { $notify.Icon = $script:trayIcons[$state] } + $tip = "NetX $($script:ver) · $text" + if ($tip.Length -gt 63) { $tip = $tip.Substring(0, 63) } + $notify.Text = $tip + + # Requested stops pass through "stopping"; a direct running→stopped is external. + if ($prevState -eq "running" -and $state -eq "stopped") { + Show-NetxToast (T "NetX has stopped." "NetX 已停止运行。") "Warning" + } +} + +# ---------------------------------------------------------------- menu +$menu = New-Object System.Windows.Forms.ContextMenuStrip +$menu.ShowImageMargin = $true +$menu.ShowCheckMargin = $false +$menu.ImageScalingSize = New-Object System.Drawing.Size((Get-NetxPx 16), (Get-NetxPx 16)) +$menu.Font = New-Object System.Drawing.Font($script:uiFontName, 9.0) +$menu.Padding = New-Object System.Windows.Forms.Padding((Get-NetxPx 4), (Get-NetxPx 6), (Get-NetxPx 4), (Get-NetxPx 6)) +$menu.DropShadowEnabled = $true + +$script:itemGlyph = @{} +function New-NetxMenuItem([string]$Text, [string]$Glyph = "", [string]$Role = "", [switch]$Bold) { $item = New-Object System.Windows.Forms.ToolStripMenuItem $item.Text = $Text + $item.Tag = $Role $item.AutoSize = $true - $item.Padding = New-Object System.Windows.Forms.Padding(10, 5, 28, 5) + $item.Padding = New-Object System.Windows.Forms.Padding((Get-NetxPx 6), (Get-NetxPx 6), (Get-NetxPx 18), (Get-NetxPx 6)) $item.Margin = New-Object System.Windows.Forms.Padding(0) - if ($Header) { - $item.Enabled = $false - $item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0, [System.Drawing.FontStyle]::Bold) - $item.ForeColor = [System.Drawing.Color]::FromArgb(55, 65, 80) - $item.Padding = New-Object System.Windows.Forms.Padding(10, 6, 28, 2) - } elseif ($Muted) { - $item.Enabled = $false - $item.Font = New-Object System.Drawing.Font("Segoe UI", 8.25) - $item.ForeColor = [System.Drawing.Color]::FromArgb(120, 128, 140) - $item.Padding = New-Object System.Windows.Forms.Padding(10, 1, 28, 6) - } elseif ($Primary) { - $item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0, [System.Drawing.FontStyle]::Bold) - $item.ForeColor = [System.Drawing.Color]::FromArgb(20, 40, 70) - } else { - $item.Font = New-Object System.Drawing.Font("Segoe UI", 9.0) - $item.ForeColor = [System.Drawing.Color]::FromArgb(35, 40, 48) - } + if ($Glyph) { $script:itemGlyph[$item] = $Glyph } + $style = if ($Bold) { [System.Drawing.FontStyle]::Bold } else { [System.Drawing.FontStyle]::Regular } + $size = if ($Role -eq "status") { 8.5 } else { 9.0 } + $item.Font = New-Object System.Drawing.Font($script:uiFontName, [float]$size, $style) + if ($Role -in @("header", "status")) { $item.Enabled = $false } return $item } function New-NetxMenuSeparator { $sep = New-Object System.Windows.Forms.ToolStripSeparator - $sep.Margin = New-Object System.Windows.Forms.Padding(0, 3, 0, 3) - $sep.Padding = New-Object System.Windows.Forms.Padding(0) + $sep.Margin = New-Object System.Windows.Forms.Padding(0, (Get-NetxPx 3), 0, (Get-NetxPx 3)) return $sep } -$menu = New-Object System.Windows.Forms.ContextMenuStrip -$menu.ShowImageMargin = $false -$menu.ShowCheckMargin = $false -$menu.Font = New-Object System.Drawing.Font("Segoe UI", 9.0) -$menu.BackColor = [System.Drawing.Color]::FromArgb(252, 252, 253) -$menu.ForeColor = [System.Drawing.Color]::FromArgb(35, 40, 48) -$menu.Padding = New-Object System.Windows.Forms.Padding(4, 4, 4, 4) -$menu.Renderer = New-Object NetxMenuRenderer +function Set-NetxDropDownChrome($DropDown) { + $DropDown.Renderer = $script:renderer + $DropDown.add_HandleCreated({ + param($s, $e) + [void][NetxNative]::RoundCorners($s.Handle, $script:theme.Border) + }) +} -# --- header (info only) --- -$script:miHeader = New-NetxMenuItem -Text "NetX $script:ver" -Header -$miHeader = $script:miHeader -[void]$menu.Items.Add($miHeader) +$miHeader = New-NetxMenuItem -Text "NetX" -Role "header" -Bold +$miStatus = New-NetxMenuItem -Text $script:stateText["checking"] -Role "status" +$miOpen = New-NetxMenuItem -Text (T "Open NetX" "打开界面") -Glyph "open" -Bold +$miStart = New-NetxMenuItem -Text (T "Start" "启动") -Glyph "start" +$miStop = New-NetxMenuItem -Text (T "Stop" "停止") -Glyph "stop" +$miRestart = New-NetxMenuItem -Text (T "Restart" "重启") -Glyph "restart" +$miSettings = New-NetxMenuItem -Text (T "Settings" "设置") -Glyph "settings" +$miAutostart = New-NetxMenuItem -Text (T "Start with Windows" "登录 Windows 时自动启动") -Glyph "check" +$miAutoUpdate = New-NetxMenuItem -Text (T "Daily automatic updates" "每日自动更新") -Glyph "check" +$miReconfig = New-NetxMenuItem -Text (T "Reconfigure database…" "重新配置数据库…") -Glyph "repair" +$miLogs = New-NetxMenuItem -Text (T "Open log folder" "打开日志目录") -Glyph "log" +$miData = New-NetxMenuItem -Text (T "Open data folder" "打开数据目录") -Glyph "folder" +$miUpdate = New-NetxMenuItem -Text (T "Check for updates…" "检查更新…") -Glyph "update" +$miExit = New-NetxMenuItem -Text (T "Exit tray (keep NetX running)" "退出托盘(NetX 保持运行)") -Glyph "exit" +$miStopExit = New-NetxMenuItem -Text (T "Stop NetX and exit" "停止 NetX 并退出") -Glyph "power" -$miSub = New-NetxMenuItem -Text "$dbModeLabel · ${hostBind}:$port" -Muted -[void]$menu.Items.Add($miSub) +[void]$miSettings.DropDownItems.AddRange(@( + $miAutostart, $miAutoUpdate, (New-NetxMenuSeparator), + $miReconfig, $miLogs, $miData + )) +[void]$menu.Items.AddRange(@( + $miHeader, $miStatus, (New-NetxMenuSeparator), + $miOpen, (New-NetxMenuSeparator), + $miStart, $miStop, $miRestart, (New-NetxMenuSeparator), + $miSettings, $miUpdate, (New-NetxMenuSeparator), + $miExit, $miStopExit + )) -[void]$menu.Items.Add((New-NetxMenuSeparator)) +$script:themeDark = $null +function Set-NetxTrayTheme { + $dark = Test-NetxDarkMode + if ($dark -eq $script:themeDark) { return } + $script:themeDark = $dark + $script:theme = New-NetxTheme $dark + $script:theme.DwmRounded = ([Environment]::OSVersion.Version.Build -ge 22000) + $script:renderer = New-Object NetxMenuRenderer($script:theme, [float]$script:dpiScale) + $menu.Renderer = $script:renderer + $miSettings.DropDown.Renderer = $script:renderer + $script:glyphImages = @{} + foreach ($k in $script:glyphs.Keys) { + $script:glyphImages[$k] = New-NetxGlyph $script:glyphs[$k] $script:theme.Text + } + $script:glyphImages["open"] = New-NetxGlyph $script:glyphs["open"] ([System.Drawing.Color]::FromArgb(64, 140, 255)) + foreach ($it in $script:itemGlyph.Keys) { + $gk = $script:itemGlyph[$it] + if ($gk -ne "check") { $it.Image = $script:glyphImages[$gk] } + } + $script:dots = @{} + foreach ($k in $script:stateColors.Keys) { $script:dots[$k] = New-NetxDot $script:stateColors[$k] } + if (-not $miHeader.Image) { $miHeader.Image = New-NetxBrandBitmap (Get-NetxPx 16) } + $script:lastState = "" +} +Set-NetxTrayTheme +Set-NetxDropDownChrome $menu +Set-NetxDropDownChrome $miSettings.DropDown -# --- primary --- -$miOpen = New-NetxMenuItem -Text (T "Open UI" "打开界面") -Primary -$miOpen.add_Click({ - Start-NetxTrayDeferred { - if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) { - Start-Process $uiUrl +$menu.add_Opening({ + Set-NetxTrayTheme + if ($menu.IsHandleCreated) { [void][NetxNative]::RoundCorners($menu.Handle, $script:theme.Border) } + Update-NetxTrayState + }) +$miSettings.DropDown.add_Opening({ + $dd = $miSettings.DropDown + if ($dd.IsHandleCreated) { [void][NetxNative]::RoundCorners($dd.Handle, $script:theme.Border) } + $miAutostart.Image = if ($script:sync.Autostart) { $script:glyphImages["check"] } else { $null } + $miAutoUpdate.Image = if ($script:sync.AutoUpdate) { $script:glyphImages["check"] } else { $null } + }) + +function Invoke-NetxTrayDeferred([scriptblock]$Work) { + # Close the menu first: ContextMenuStrip stays modal while a Click handler runs. + try { $menu.Close() } catch {} + $script:deferredWork = $Work + $t = New-Object System.Windows.Forms.Timer + $t.Interval = 30 + $t.add_Tick({ + param($s, $e) + $s.Stop(); $s.Dispose() + $w = $script:deferredWork + $script:deferredWork = $null + if ($w) { & $w } + }) + $t.Start() +} + +function Open-NetxUi { + if ($script:sync.Healthy) { + Open-NetxUrl -Url $script:uiUrl + } elseif ($script:pending -and $script:pending.Kind -eq "starting") { + $script:openWhenReady = $script:pending.Until + } else { + Show-NetxToast (T "NetX is not running. Starting…" "NetX 未运行,正在启动…") + Invoke-NetxRuntime -Action start -OpenUi + } +} + +$miOpen.add_Click({ Invoke-NetxTrayDeferred { Open-NetxUi } }) +$miStart.add_Click({ Invoke-NetxTrayDeferred { Invoke-NetxRuntime -Action start -OpenUi:(-not $QuietStart) } }) +$miStop.add_Click({ Invoke-NetxTrayDeferred { Invoke-NetxRuntime -Action stop } }) +$miRestart.add_Click({ Invoke-NetxTrayDeferred { Invoke-NetxRuntime -Action restart } }) +$miLogs.add_Click({ Invoke-NetxTrayDeferred { Open-NetxUrl -Url $logDir } }) +$miData.add_Click({ Invoke-NetxTrayDeferred { Open-NetxUrl -Url $data } }) + +function Switch-NetxTaskToggle([string]$Script, [string]$SyncKey, [string]$OnText, [string]$OffText) { + $enable = -not [bool]$script:sync[$SyncKey] + $extra = if ($enable) { @() } else { @("-Remove") } + try { + $p = Start-NetxChild -File $Script -ExtraArgs $extra + } catch { + Show-NetxToast $_.Exception.Message "Error" + return + } + $ctx = @{ Key = $SyncKey; Enable = $enable; Text = $(if ($enable) { $OnText } else { $OffText }) } + Watch-NetxProcess $p -Context $ctx -OnExit { + param($proc, $c) + $code = Get-NetxExitCode $proc + if ($null -eq $code -or $code -eq 0) { + $script:sync[$c.Key] = $c.Enable + Show-NetxToast $c.Text } else { - $notify.ShowBalloonTip(4000, "NetX", (T "NetX is not running. Starting…" "NetX 未运行,正在启动…"), [System.Windows.Forms.ToolTipIcon]::Info) - Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null - Open-NetxUiWhenReady + Show-NetxToast (T "Setting change failed (exit $code)." "设置修改失败(退出码 $code)。") "Error" } } -}) -[void]$menu.Items.Add($miOpen) +} -[void]$menu.Items.Add((New-NetxMenuSeparator)) +$miAutostart.add_Click({ + Invoke-NetxTrayDeferred { + Switch-NetxTaskToggle $autostartPs1 "Autostart" ` + (T "NetX will start when you sign in." "已开启:登录 Windows 时自动启动 NetX。") ` + (T "Start with Windows turned off." "已关闭登录自动启动。") + } + }) +$miAutoUpdate.add_Click({ + Invoke-NetxTrayDeferred { + Switch-NetxTaskToggle $updateTaskPs1 "AutoUpdate" ` + (T "Daily automatic updates turned on." "已开启每日自动更新。") ` + (T "Daily automatic updates turned off." "已关闭每日自动更新。") + } + }) -# --- service control --- -$miStart = New-NetxMenuItem -Text (T "Start" "启动") -$miStart.add_Click({ - Start-NetxTrayDeferred { - Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null - Open-NetxUiWhenReady +# --- reconfigure database (console wizard) --- +function Complete-NetxTrayReconfig { + Read-NetxTrayConfig + $script:sync.HealthUrl = "$($script:uiUrl)health" + $script:lastState = "" + $ans = Show-NetxDialog (T "Database configuration saved.`nStart NetX now?" "数据库配置已保存。`n是否立即启动 NetX?") "Information" "YesNo" + if ($ans -eq [System.Windows.Forms.DialogResult]::Yes) { + Invoke-NetxRuntime -Action start -OpenUi } -}) -[void]$menu.Items.Add($miStart) + Update-NetxTrayState +} -$miStop = New-NetxMenuItem -Text (T "Stop" "停止") -$miStop.add_Click({ - Start-NetxTrayDeferred { - $notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info) - Invoke-NetxScript -File $stopPs1 | Out-Null - Update-NetxTrayTip +function Start-NetxReconfigWizard { + $null = Ensure-NetxDataAcl -DataRoot $data -Quiet + try { + $p = Start-NetxChild -File $setupPs1 -WindowStyle Normal + } catch { + Show-NetxDialog ((T "Database setup failed: " "数据库配置失败:") + $_.Exception.Message) "Error" | Out-Null + return } -}) -[void]$menu.Items.Add($miStop) + Watch-NetxProcess $p { + param($sp) + $code = Get-NetxExitCode $sp + if (Test-NetxSetupCancelled $code) { + Show-NetxToast (T "Database setup cancelled." "已取消数据库配置。") + return + } + if ($null -ne $code -and $code -ne 0) { + Show-NetxDialog ((T "Database setup failed (exit $code).`nLog: " "数据库配置失败(退出码 $code)。`n日志:") + $logDir) "Error" | Out-Null + return + } + Complete-NetxTrayReconfig + } +} -[void]$menu.Items.Add((New-NetxMenuSeparator)) - -# --- setup / update --- -$miReconfig = New-NetxMenuItem -Text (T "Reconfigure database…" "重新配置数据库…") $miReconfig.add_Click({ - Start-NetxTrayDeferred { - $ans = [System.Windows.Forms.MessageBox]::Show( - (T ` - "NetX will stop while you reconfigure the database.`n`nChoose built-in or external PostgreSQL in the console window.`nContinue?" ` - "重新配置数据库时会先停止 NetX。`n`n请在随后的控制台窗口中选择内置或外置 PostgreSQL。`n是否继续?"), - (T "Reconfigure database" "重新配置数据库"), - [System.Windows.Forms.MessageBoxButtons]::YesNo, - [System.Windows.Forms.MessageBoxIcon]::Question - ) - if ($ans -ne [System.Windows.Forms.DialogResult]::Yes) { return } - - $notify.ShowBalloonTip(3000, "NetX", (T "Stopping…" "正在停止…"), [System.Windows.Forms.ToolTipIcon]::Info) - Invoke-NetxScript -File $stopPs1 | Out-Null - - # ProgramData\.env is often admin-owned after Setup; repair ACL before reconfigure. - $null = Ensure-NetxDataAcl -DataRoot $data -Quiet - - try { - $p = Start-NetxPowerShell -File $setupPs1 ` - -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) ` - -WorkingDirectory $prog -WindowStyle Normal -PassThru - Start-NetxTrayWatchProcess -Process $p -OnExit { - param($sp) - if ($null -eq $sp) { Update-NetxTrayTip; return } - if (Test-NetxSetupCancelled -ExitCode $sp.ExitCode) { - $notify.ShowBalloonTip( - 4000, "NetX", - (T "Database setup cancelled." "已取消数据库配置。"), - [System.Windows.Forms.ToolTipIcon]::Info - ) - Update-NetxTrayTip - return - } - if ($null -ne $sp.ExitCode -and $sp.ExitCode -ne 0) { - $elev = [System.Windows.Forms.MessageBox]::Show( - (T ` - "Database setup failed (exit $($sp.ExitCode)).`n`nIf this was a permission error on %ProgramData%\NetX\.env, click Yes to retry as Administrator." ` - "数据库配置失败(退出码 $($sp.ExitCode))。`n`n若是 %ProgramData%\NetX\.env 权限问题,点「是」将以管理员身份重试。"), - "NetX", - [System.Windows.Forms.MessageBoxButtons]::YesNo, - [System.Windows.Forms.MessageBoxIcon]::Warning - ) - if ($elev -ne [System.Windows.Forms.DialogResult]::Yes) { - Update-NetxTrayTip - return - } - $arg = "-NoProfile -ExecutionPolicy Bypass -File `"$setupPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`"" - try { - $ep = Start-Process -FilePath "powershell.exe" -ArgumentList $arg ` - -WorkingDirectory $prog -Verb RunAs -PassThru - } catch { - Update-NetxTrayTip - return - } - Start-NetxTrayWatchProcess -Process $ep -OnExit { - param($ep2) - if ($null -eq $ep2 -or (Test-NetxSetupCancelled -ExitCode $ep2.ExitCode)) { - Update-NetxTrayTip - return - } - if ($null -ne $ep2.ExitCode -and $ep2.ExitCode -ne 0) { - [System.Windows.Forms.MessageBox]::Show( - (T "Elevated database setup still failed (exit $($ep2.ExitCode))." "管理员配置仍失败(退出码 $($ep2.ExitCode))。"), - "NetX", - [System.Windows.Forms.MessageBoxButtons]::OK, - [System.Windows.Forms.MessageBoxIcon]::Error - ) - Update-NetxTrayTip - return - } - Complete-NetxTrayReconfig - } - return - } - Complete-NetxTrayReconfig + Invoke-NetxTrayDeferred { + $ans = Show-NetxDialog (T ` + "NetX will stop while you reconfigure the database.`n`nChoose built-in or external PostgreSQL in the console window that opens.`nContinue?" ` + "重新配置数据库时会先停止 NetX。`n`n请在随后打开的控制台窗口中选择内置或外置 PostgreSQL。`n是否继续?") "Question" "YesNo" + if ($ans -ne [System.Windows.Forms.DialogResult]::Yes) { return } + if ($script:sync.Healthy -or $script:sync.Runner) { + Invoke-NetxRuntime -Action stop -Then { param($c) Start-NetxReconfigWizard } + } else { + Start-NetxReconfigWizard } - } catch { - [System.Windows.Forms.MessageBox]::Show( - (T "Database setup failed: $($_.Exception.Message)" "数据库配置失败:$($_.Exception.Message)"), - "NetX", - [System.Windows.Forms.MessageBoxButtons]::OK, - [System.Windows.Forms.MessageBoxIcon]::Error - ) - Update-NetxTrayTip } - } -}) -[void]$menu.Items.Add($miReconfig) + }) + +# --- updates --- +function Restart-NetxTraySelf { + $trayFile = Join-Path $PSScriptRoot "netx_tray.ps1" + try { + Start-NetxPowerShell -File $trayFile -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-QuietStart") ` + -WorkingDirectory $prog -WindowStyle Hidden | Out-Null + } catch {} + Exit-NetxTray +} + +function Start-NetxUpdateApply { + Show-NetxToast (T "Downloading and applying the update… keep the console open." "正在下载并应用更新…请勿关闭控制台窗口。") "Info" 5000 + try { + $ap = Start-NetxChild -File $checkPs1 -ExtraArgs @("-Apply") -WindowStyle Normal + } catch { + Show-NetxToast (T "Update cancelled." "已取消更新。") + return + } + Watch-NetxProcess $ap { + param($p) + $code = Get-NetxExitCode $p + if (Test-NetxSetupCancelled $code) { + Show-NetxToast (T "Update cancelled." "已取消更新。") + return + } + if ($code -eq 0) { + $newVer = Get-NetxVersion -ProgramRoot $prog + Show-NetxDialog (T "Updated to $newVer.`nThe tray will restart." "已更新到 $newVer。`n托盘即将重启。") | Out-Null + Restart-NetxTraySelf + return + } + Show-NetxDialog (T "Update failed (exit $code). Retry Check for updates, or run NetX-Setup manually." "更新失败(退出码 $code)。请重试「检查更新」,或手动运行 Setup 安装包。") "Warning" | Out-Null + } +} -$miUpdate = New-NetxMenuItem -Text (T "Check for updates…" "检查更新…") $miUpdate.add_Click({ - Start-NetxTrayDeferred { - try { - $notify.ShowBalloonTip( - 3000, "NetX", - (T "Checking for updates…" "正在检查更新…"), - [System.Windows.Forms.ToolTipIcon]::Info - ) - # No -Wait on UI thread (freezes ContextMenuStrip / tray). - $p = Start-NetxPowerShell -File $checkPs1 -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data) ` - -WorkingDirectory $prog -WindowStyle Normal -PassThru - Start-NetxTrayWatchProcess -Process $p -OnExit { - param($cp) - try { - if ($null -eq $cp) { return } - if (Test-NetxSetupCancelled -ExitCode $cp.ExitCode) { return } - if ($cp.ExitCode -eq 10) { - $ans = [System.Windows.Forms.MessageBox]::Show( - (T "A newer NetX is available. Download and apply now?" "发现新版本,是否立即下载并更新?"), - (T "NetX update" "NetX 更新"), - [System.Windows.Forms.MessageBoxButtons]::YesNo, - [System.Windows.Forms.MessageBoxIcon]::Question - ) - if ($ans -ne [System.Windows.Forms.DialogResult]::Yes) { return } - $arg = "-NoProfile -ExecutionPolicy Bypass -File `"$checkPs1`" -ProgramRoot `"$prog`" -DataRoot `"$data`" -Apply" - $notify.ShowBalloonTip( - 5000, "NetX", - (T "Downloading / applying update… keep the console open." "正在下载/应用更新…请勿关闭控制台窗口。"), - [System.Windows.Forms.ToolTipIcon]::Info - ) - try { - $ap = Start-Process -FilePath "powershell.exe" -ArgumentList $arg ` - -WorkingDirectory $prog -Verb RunAs -PassThru - } catch { - $notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled (UAC)." "已取消更新(UAC)。"), [System.Windows.Forms.ToolTipIcon]::Info) - return - } - Start-NetxTrayWatchProcess -Process $ap -OnExit { - param($ap2) - if ($null -eq $ap2) { return } - if (Test-NetxSetupCancelled -ExitCode $ap2.ExitCode) { - $notify.ShowBalloonTip(4000, "NetX", (T "Update cancelled." "已取消更新。"), [System.Windows.Forms.ToolTipIcon]::Info) - return - } - if ($null -ne $ap2.ExitCode -and $ap2.ExitCode -eq 0) { - $newVer = Get-NetxVersion -ProgramRoot $prog - [System.Windows.Forms.MessageBox]::Show( - (T "Updated to $newVer.`nTray will restart." "已更新到 $newVer。`n即将重启托盘。"), - (T "NetX update" "NetX 更新"), - [System.Windows.Forms.MessageBoxButtons]::OK, - [System.Windows.Forms.MessageBoxIcon]::Information - ) - Restart-NetxTraySelf - return - } - [System.Windows.Forms.MessageBox]::Show( - (T "Update failed (exit $($ap2.ExitCode)). Re-run Check for updates, or install NetX-Setup manually." "更新失败(退出码 $($ap2.ExitCode))。请重试「检查更新」,或手动运行 Setup 安装包。"), - (T "NetX update" "NetX 更新"), - [System.Windows.Forms.MessageBoxButtons]::OK, - [System.Windows.Forms.MessageBoxIcon]::Warning - ) - Update-NetxTrayTip - } - } elseif ($cp.ExitCode -eq 0) { - [System.Windows.Forms.MessageBox]::Show( - (T "NetX is up to date ($script:ver)." "已是最新版本 ($script:ver)。"), - (T "NetX update" "NetX 更新") - ) - } - } catch { - [System.Windows.Forms.MessageBox]::Show( - (T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"), - "NetX" - ) + Invoke-NetxTrayDeferred { + Show-NetxToast (T "Checking for updates…" "正在检查更新…") "Info" 3000 + try { + $cp = Start-NetxChild -File $checkPs1 -WindowStyle Normal + } catch { + Show-NetxDialog ((T "Update check failed: " "检查更新失败:") + $_.Exception.Message) "Warning" | Out-Null + return + } + Watch-NetxProcess $cp { + param($p) + $code = Get-NetxExitCode $p + if (Test-NetxSetupCancelled $code) { return } + if ($code -eq 10) { + $ans = Show-NetxDialog (T "A newer NetX is available. Download and apply now?" "发现新版本,是否立即下载并更新?") "Question" "YesNo" + if ($ans -eq [System.Windows.Forms.DialogResult]::Yes) { Start-NetxUpdateApply } + } elseif ($code -eq 0) { + Show-NetxDialog (T "NetX is up to date ($($script:ver))." "已是最新版本($($script:ver))。") | Out-Null + } else { + Show-NetxDialog (T "Update check failed (exit $code)." "检查更新失败(退出码 $code)。") "Warning" | Out-Null } } - } catch { - [System.Windows.Forms.MessageBox]::Show( - (T "Update check failed: $($_.Exception.Message)" "检查更新失败:$($_.Exception.Message)"), - "NetX" - ) } - } -}) -[void]$menu.Items.Add($miUpdate) - -[void]$menu.Items.Add((New-NetxMenuSeparator)) + }) # --- exit --- -$miExit = New-NetxMenuItem -Text (T "Exit tray" "退出托盘") -$miExit.ToolTipText = (T "Leave NetX services running" "NetX 服务继续运行") -$miExit.add_Click({ - Close-NetxTrayMenu +function Exit-NetxTray { + try { $menu.Close() } catch {} $notify.Visible = $false $form.Close() [System.Windows.Forms.Application]::Exit() -}) -[void]$menu.Items.Add($miExit) - -$miStopExit = New-NetxMenuItem -Text (T "Stop and exit" "停止并退出") +} +$miExit.add_Click({ Invoke-NetxTrayDeferred { Exit-NetxTray } }) $miStopExit.add_Click({ - Start-NetxTrayDeferred { - $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() - } -}) -[void]$menu.Items.Add($miStopExit) + Invoke-NetxTrayDeferred { + if (-not $script:sync.Healthy -and -not $script:sync.Runner) { Exit-NetxTray; return } + Show-NetxToast (T "Stopping NetX…" "正在停止 NetX…") "Info" 3000 + $script:pending = $null + Invoke-NetxRuntime -Action stop -Then { param($c) Exit-NetxTray } + } + }) $notify.ContextMenuStrip = $menu -$notify.add_DoubleClick({ - if (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2) { - Start-Process $uiUrl - } else { - Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null - Open-NetxUiWhenReady - } -}) +# Single left click opens the menu like modern tray apps; double click opens the UI. +$notify.add_MouseUp({ + param($s, $e) + if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { + $mi = [System.Windows.Forms.NotifyIcon].GetMethod("ShowContextMenu", [System.Reflection.BindingFlags]"Instance,NonPublic") + if ($mi) { $mi.Invoke($notify, $null) } + } + }) +$notify.add_DoubleClick({ Invoke-NetxTrayDeferred { Open-NetxUi } }) -# Refresh tooltip periodically (running / stopped). -$script:tipTimer = New-Object System.Windows.Forms.Timer -$script:tipTimer.Interval = 5000 -$script:tipTimer.add_Tick({ Update-NetxTrayTip }) -$script:tipTimer.Start() +# ---------------------------------------------------------------- lifecycle +$script:uiTimer = New-Object System.Windows.Forms.Timer +$script:uiTimer.Interval = 400 +$script:uiTimer.add_Tick({ + # Pick up in-place updates of version.json. + if (((Get-Date) - $script:verCheckedAt).TotalSeconds -gt 30) { + $script:verCheckedAt = Get-Date + try { $script:ver = Get-NetxVersion -ProgramRoot $prog } catch {} + } + Update-NetxTrayState + }) +$script:verCheckedAt = Get-Date + +function Start-NetxOnLaunch { + if (-not $StartOnLaunch) { return } + Invoke-NetxRuntime -Action start -OpenUi:(-not $QuietStart) + if (-not $QuietStart) { + Show-NetxToast (T "Starting NetX… the first run may take a minute." "正在启动 NetX…首次运行可能需要一分钟。") + } +} $form.add_Shown({ - if ($AutoUpdate) { - $notify.ShowBalloonTip(4000, "NetX", (T "Checking for updates…" "正在检查更新…"), [System.Windows.Forms.ToolTipIcon]::Info) - Start-NetxPowerShell -File $checkPs1 ` - -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Apply", "-Quiet", "-AutoOnly") ` - -WorkingDirectory $prog -WindowStyle Hidden -Wait | Out-Null - } elseif ($CheckUpdateOnLaunch) { - Start-NetxPowerShell -File $checkPs1 ` - -Arguments @("-ProgramRoot", $prog, "-DataRoot", $data, "-Quiet") ` - -WorkingDirectory $prog -WindowStyle Hidden | Out-Null - } - if ($StartOnLaunch) { - Invoke-NetxScript -File $startPs1 -ExtraArgs @("-SkipBrowser") | Out-Null - $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 {} - Update-NetxTrayTip - return + $script:uiTimer.Start() + if ($AutoUpdate) { + # Apply silently first, then start; watched so the tray stays responsive. + try { + $up = Start-NetxChild -File $checkPs1 -ExtraArgs @("-Apply", "-Quiet", "-AutoOnly") + Watch-NetxProcess $up { param($p) Start-NetxOnLaunch } + } catch { + Start-NetxOnLaunch } - if ($script:netxUiWaitTicks -eq 1) { - $notify.ShowBalloonTip( - 5000, - "NetX", - (T "Starting… first run may take a minute." "正在启动…首次迁移可能需要一分钟。"), - [System.Windows.Forms.ToolTipIcon]::Info - ) + } else { + if ($CheckUpdateOnLaunch) { + try { Start-NetxChild -File $checkPs1 -ExtraArgs @("-Quiet") | Out-Null } catch {} } - 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 - ) - Update-NetxTrayTip - } - }) - $script:netxUiTimer.Start() - } -}) + Start-NetxOnLaunch + } + }) $form.add_FormClosing({ - try { $script:tipTimer.Stop(); $script:tipTimer.Dispose() } catch {} - $notify.Visible = $false - $notify.Dispose() -}) + try { $script:uiTimer.Stop() } catch {} + try { $script:watchTimer.Stop() } catch {} + $script:sync.Stop = $true + $notify.Visible = $false + $notify.Dispose() + foreach ($h in $script:iconHandles) { try { [void][NetxNative]::DestroyIcon($h) } catch {} } + }) [System.Windows.Forms.Application]::Run($form) +try { $script:probePs.EndInvoke($script:probeHandle) } catch {} +try { $script:probePs.Dispose(); $script:probeRunspace.Close() } catch {} diff --git a/packaging/runtime_ctl.ps1 b/packaging/runtime_ctl.ps1 new file mode 100644 index 0000000..6a7e5f3 --- /dev/null +++ b/packaging/runtime_ctl.ps1 @@ -0,0 +1,52 @@ +param( + [ValidateSet("start", "stop", "restart")] + [string]$Action = "start", + [string]$ProgramRoot = "", + [string]$DataRoot = "" +) + +# Single start/stop/restart entry for tray + shortcuts. Routes through the +# Windows service / SYSTEM task when one is installed, else the plain scripts. + +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" +Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Hidden + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot + +function Invoke-NetxChildScript([string]$Name, [string[]]$Extra = @()) { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot $Name) ` + -ProgramRoot $prog -DataRoot $data @Extra + return $LASTEXITCODE +} + +$runner = Get-NetxManagedRunner +switch ($runner) { + "service" { + switch ($Action) { + "start" { Start-Service -Name "NetX" } + "stop" { Stop-Service -Name "NetX" -Force } + "restart" { Restart-Service -Name "NetX" -Force } + } + } + "task" { + if ($Action -ne "start") { + Stop-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" + $null = Invoke-NetxChildScript "stop_netx_app.ps1" + } + if ($Action -ne "stop") { + Start-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" + } + } + default { + if ($Action -ne "start") { + $null = Invoke-NetxChildScript "stop_netx_app.ps1" + } + if ($Action -ne "stop") { + $code = Invoke-NetxChildScript "start_netx_app.ps1" @("-SkipBrowser") + if ($code) { exit $code } + } + } +} +exit 0 diff --git a/packaging/service_lifecycle.ps1 b/packaging/service_lifecycle.ps1 index 1014ca2..c3f7e4f 100644 --- a/packaging/service_lifecycle.ps1 +++ b/packaging/service_lifecycle.ps1 @@ -84,11 +84,12 @@ try { 'ResumeAfterUpdate' { if (-not (Test-Path -LiteralPath $stateFile)) { break } $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json - if ($state.serviceWasRunning) { + # Setup may have switched run mode (service -> tray) in between: skip what is gone. + if ($state.serviceWasRunning -and (Get-Service -Name 'NetX' -ErrorAction SilentlyContinue)) { Write-LifecycleLog 'Restarting Windows service NetX after update' Start-Service -Name 'NetX' -ErrorAction Stop } - if ($state.taskWasRunning) { + if ($state.taskWasRunning -and (Get-NetxTask)) { Write-LifecycleLog 'Restarting scheduled task NetXService after update' Start-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -ErrorAction Stop } @@ -96,6 +97,17 @@ try { } 'RemoveForUninstall' { $null = Stop-NetxRunners -Remove + # Logon tray + daily update tasks would otherwise outlive the program files. + foreach ($t in @('NetXTray', 'NetXUpdate')) { + Unregister-ScheduledTask -TaskName $t -TaskPath '\NetX\' -Confirm:$false -ErrorAction SilentlyContinue + } + try { + $sched = New-Object -ComObject Schedule.Service + $sched.Connect() + $root = $sched.GetFolder('\') + $nx = $root.GetFolder('\NetX') + if ($nx.GetTasks(1).Count -eq 0 -and $nx.GetFolders(0).Count -eq 0) { $root.DeleteFolder('NetX', 0) } + } catch {} $stopScript = Join-Path $prog 'packaging\stop_netx_app.ps1' if (Test-Path -LiteralPath $stopScript) { & powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $stopScript ` diff --git a/packaging/start_netx_app.ps1 b/packaging/start_netx_app.ps1 index 55b8d6d..d25a0d4 100644 --- a/packaging/start_netx_app.ps1 +++ b/packaging/start_netx_app.ps1 @@ -43,15 +43,16 @@ if (Test-Path (Join-Path $dist "index.html")) { $mode = Get-DbMode -EnvMap $map $hostBind = if ($env:NETX_HOST) { $env:NETX_HOST } else { "127.0.0.1" } $port = if ($env:NETX_PORT) { [int]$env:NETX_PORT } else { 8890 } +$clientHost = Get-NetxClientHost -BindHost $hostBind Write-Host "==> Program: $prog" Write-Host "==> Data: $data" Write-Host "==> DB mode: $mode" -if (-not $Force -and (Test-NetxApiHealthy -HostName $hostBind -Port $port)) { - Write-Host "==> NetX already healthy at http://${hostBind}:${port}/ — skip start (use -Force to restart)" -ForegroundColor Green +if (-not $Force -and (Test-NetxApiHealthy -HostName $clientHost -Port $port)) { + Write-Host "==> NetX already healthy at http://${clientHost}:${port}/ — skip start (use -Force to restart)" -ForegroundColor Green if (-not $SkipBrowser) { - try { Start-Process "http://${hostBind}:${port}/" } catch {} + Open-NetxUrl -Url "http://${clientHost}:${port}/" } exit 0 } @@ -106,8 +107,8 @@ try { exit 1 } -if (-not (Test-NetxTcpPortFree -HostName $hostBind -Port $port)) { - if (Test-NetxApiHealthy -HostName $hostBind -Port $port) { +if (-not (Test-NetxTcpPortFree -HostName $clientHost -Port $port)) { + if (Test-NetxApiHealthy -HostName $clientHost -Port $port) { Write-Host "==> Port $port already serves NetX — skip start" -ForegroundColor Green exit 0 } @@ -132,14 +133,14 @@ if ($InlineSchedulers) { $startArgs["InlineSchedulers"] = $true } & $startScript @startArgs if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$url = "http://${hostBind}:${port}/" -if (-not (Test-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 2)) { - if (-not (Wait-NetxApiHealthy -HostName $hostBind -Port $port -TimeoutSec 30)) { +$url = "http://${clientHost}:${port}/" +if (-not (Test-NetxApiHealthy -HostName $clientHost -Port $port -TimeoutSec 2)) { + if (-not (Wait-NetxApiHealthy -HostName $clientHost -Port $port -TimeoutSec 30)) { Write-Host "[ERR] NetX started but /health not ready: $url" -ForegroundColor Red exit 1 } } Write-Host "==> Open: $url" -ForegroundColor Green if (-not $SkipBrowser) { - try { Start-Process $url } catch {} + Open-NetxUrl -Url $url } diff --git a/scripts/start_netx.ps1 b/scripts/start_netx.ps1 index 9faf35c..50cb267 100644 --- a/scripts/start_netx.ps1 +++ b/scripts/start_netx.ps1 @@ -147,6 +147,8 @@ function Show-LogTail { function Test-NetxApiListening { param([string]$HostName, [int]$LocalPort, [int]$WaitSec = 45) $deadline = (Get-Date).AddSeconds($WaitSec) + # Wildcard binds are not connectable on Windows; probe via loopback. + if ($HostName -in @("0.0.0.0", "::", "[::]", "*")) { $HostName = "127.0.0.1" } $healthUrl = "http://${HostName}:${LocalPort}/health" while ((Get-Date) -lt $deadline) { try {