netx/packaging/netx_tray.ps1
oliver 6ff10834e5 Tray: choose the update source (GitHub default, or git.avelo.top only).
Settings -> Update source writes NETX_UPDATE_SOURCES to ProgramData .env:
empty keeps the default (GitHub first, git.avelo.top mirror as fallback),
"forgejo" pins updates to git.avelo.top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:31:42 +08:00

985 lines
42 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

param(
[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"
Assert-NetxAdminOrRelaunch -ScriptPath $PSCommandPath -BoundParameters $PSBoundParameters -WindowStyle Hidden
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
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 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);
// 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 {
readonly NetxTheme t;
readonly float scale;
public NetxMenuRenderer(NetxTheme theme, float dpiScale) : base(new NetxMenuColorTable(theme)) {
t = theme;
scale = dpiScale;
RoundedEdges = false;
}
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(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(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
$envPath = Join-Path $data ".env"
$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 }
}
if (-not (Test-Path $envPath)) {
[void][System.Windows.Forms.MessageBox]::Show(
(T `
"NetX is not configured yet (missing $envPath).`nRe-run the installer and choose built-in or external DB,`nor use Start Menu → NetX → Reconfigure database." `
"尚未完成数据库配置(缺少 $envPath)。`n请重新运行安装向导并选择内置或外置数据库,`n或使用「开始菜单 → NetX → 重新配置数据库」。"),
"NetX",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
)
exit 1
}
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 (-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
}
} catch {
# If mutex fails, continue with a tray (better than no UI control).
}
$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"
# ---------------------------------------------------------------- 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: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 {
$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 {}
$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: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
$form.WindowState = "Minimized"
$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
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((Get-NetxPx 6), (Get-NetxPx 6), (Get-NetxPx 18), (Get-NetxPx 6))
$item.Margin = New-Object System.Windows.Forms.Padding(0)
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, (Get-NetxPx 3), 0, (Get-NetxPx 3))
return $sep
}
function Set-NetxDropDownChrome($DropDown) {
$DropDown.Renderer = $script:renderer
$DropDown.add_HandleCreated({
param($s, $e)
[void][NetxNative]::RoundCorners($s.Handle, $script:theme.Border)
})
}
$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"
$miSource = New-NetxMenuItem -Text (T "Update source" "更新源") -Glyph "update"
$miSourceGithub = New-NetxMenuItem -Text (T "GitHub (default)" "GitHub(默认)") -Glyph "check"
$miSourceForgejo = New-NetxMenuItem -Text "git.avelo.top" -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"
[void]$miSource.DropDownItems.AddRange(@($miSourceGithub, $miSourceForgejo))
[void]$miSettings.DropDownItems.AddRange(@(
$miAutostart, $miAutoUpdate, $miSource, (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
))
$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
$miSource.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
Set-NetxDropDownChrome $miSource.DropDown
# Update source = NETX_UPDATE_SOURCES in .env (read by check_update.ps1).
# Empty = default (GitHub first, git.avelo.top mirror as fallback); "forgejo" = mirror only.
function Get-NetxUpdateSource {
$v = [string](Read-DotEnv -Path $envPath)["NETX_UPDATE_SOURCES"]
if ($v.Trim().ToLowerInvariant() -eq "forgejo") { return "forgejo" }
return "github"
}
$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 }
})
$miSource.DropDown.add_Opening({
$dd = $miSource.DropDown
if ($dd.IsHandleCreated) { [void][NetxNative]::RoundCorners($dd.Handle, $script:theme.Border) }
$src = Get-NetxUpdateSource
$miSourceGithub.Image = if ($src -eq "github") { $script:glyphImages["check"] } else { $null }
$miSourceForgejo.Image = if ($src -eq "forgejo") { $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 {
Show-NetxToast (T "Setting change failed (exit $code)." "设置修改失败(退出码 $code)。") "Error"
}
}
}
$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." "已关闭登录自动启动。")
}
})
function Set-NetxUpdateSource([string]$Source) {
$value = if ($Source -eq "forgejo") { "forgejo" } else { "" }
try {
Write-DotEnvValue -Path $envPath -Values @{ "NETX_UPDATE_SOURCES" = $value }
} catch {
Show-NetxToast ((T "Could not save the update source: " "无法保存更新源:") + $_.Exception.Message) "Error"
return
}
Show-NetxToast $(if ($value) {
(T "Updates now come from git.avelo.top." "更新源已切换为 git.avelo.top。")
} else {
(T "Updates now come from GitHub (git.avelo.top as fallback)." "更新源已切换为 GitHub(git.avelo.top 作为备用)。")
})
}
$miSourceGithub.add_Click({ Invoke-NetxTrayDeferred { Set-NetxUpdateSource "github" } })
$miSourceForgejo.add_Click({ Invoke-NetxTrayDeferred { Set-NetxUpdateSource "forgejo" } })
$miAutoUpdate.add_Click({
Invoke-NetxTrayDeferred {
Switch-NetxTaskToggle $updateTaskPs1 "AutoUpdate" `
(T "Daily automatic updates turned on." "已开启每日自动更新。") `
(T "Daily automatic updates turned off." "已关闭每日自动更新。")
}
})
# --- 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
}
Update-NetxTrayState
}
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
}
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
}
}
$miReconfig.add_Click({
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
}
}
})
# --- 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.add_Click({
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
}
}
}
})
# --- exit ---
function Exit-NetxTray {
try { $menu.Close() } catch {}
$notify.Visible = $false
$form.Close()
[System.Windows.Forms.Application]::Exit()
}
$miExit.add_Click({ Invoke-NetxTrayDeferred { Exit-NetxTray } })
$miStopExit.add_Click({
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
# 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 } })
# ---------------------------------------------------------------- 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({
$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
}
} else {
if ($CheckUpdateOnLaunch) {
try { Start-NetxChild -File $checkPs1 -ExtraArgs @("-Quiet") | Out-Null } catch {}
}
Start-NetxOnLaunch
}
})
$form.add_FormClosing({
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 {}