dsh-windows-tray 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,419 +1,430 @@
1
- # dsh-tray.ps1 —— DSH 系统托盘 (状态图标 + 右键菜单)
2
- # 启动方式:
3
- # 1. 双击 dsh-tray.bat
4
- # 2. 由 dsh-control.ps1 启动/重启 Web 时自动拉起
5
- # 3. 手动: pwsh -NoProfile -WindowStyle Hidden -File dsh-tray.ps1
6
- # 功能:
7
- # - 图标 = 状态: 品牌蓝鲸鱼 = Web 运行中; 灰鲸鱼 = 未运行 (官方 favicon 渲染)
8
- # - 悬停提示: 当前状态与 PID
9
- # - 双击: 打开 Web UI
10
- # - 右键菜单: 顶部 DSH/TUI 双状态行 + DSH/TUI 子菜单 + 日志目录/版本信息/退出托盘
11
- # - 状态变化气泡通知; 状态/动作日志: ~/.dsh/logs/dsh-tray.log
12
- # - TUI 状态独立检测: 菜单项按 TUI 是否运行自动禁用/启用
13
- # - 浅色圆角菜单(借鉴社区 dsh-tray 方案) + npm 自更新(借鉴 dsh-tray-launcher 方案)
14
- param(
15
- [int]$Port = 3088,
16
- [switch]$NoBalloon
17
- )
18
- $ErrorActionPreference = 'Stop'
19
-
20
- # ---- 单实例保护: 命名互斥锁 (被强杀遗留的锁自动接管, 无竞态) ----
21
- $mutex = New-Object System.Threading.Mutex($false, 'dsh-tray-singleton')
22
- $gotMutex = $false
23
- try { $gotMutex = $mutex.WaitOne(0) } catch { $gotMutex = $true } # AbandonedMutexException -> 接管
24
- if (-not $gotMutex) { exit 0 }
25
-
26
- . (Join-Path $PSScriptRoot 'dsh-lib.ps1')
27
- $PORT = $Port
28
- $URL = "http://127.0.0.1:$PORT"
29
-
30
- Add-Type -AssemblyName System.Windows.Forms
31
- Add-Type -AssemblyName System.Drawing
32
-
33
- $logFile = Join-Path $LOG_DIR 'dsh-tray.log'
34
- function Log([string]$msg) {
35
- $line = ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $msg)
36
- Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
37
- }
38
-
39
- # ---- 图标: 官方鲸鱼 .ico; 缺失时回退为纯色圆点 ----
40
- $iconsDir = Join-Path $PSScriptRoot 'icons'
41
- $icoRun = Join-Path $iconsDir 'dsh-tray-running.ico'
42
- $icoStop = Join-Path $iconsDir 'dsh-tray-stopped.ico'
43
-
44
- function New-FallbackIcon([System.Drawing.Color]$color) {
45
- $bmp = New-Object System.Drawing.Bitmap 16, 16, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
46
- $g = [System.Drawing.Graphics]::FromImage($bmp)
47
- $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
48
- $brush = New-Object System.Drawing.SolidBrush($color)
49
- $g.FillEllipse($brush, 2, 2, 12, 12)
50
- $g.Dispose(); $brush.Dispose()
51
- $icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
52
- $bmp.Dispose()
53
- return $icon
54
- }
55
-
56
- $iconRun = if (Test-Path $icoRun) { New-Object System.Drawing.Icon($icoRun) } else { New-FallbackIcon ([System.Drawing.Color]::FromArgb(0x41, 0x76, 0xE6)) }
57
- $iconStop = if (Test-Path $icoStop) { New-Object System.Drawing.Icon($icoStop) } else { New-FallbackIcon ([System.Drawing.Color]::FromArgb(0x81, 0x85, 0x8C)) }
58
-
59
- # ---- 版本烙印 (npm 包安装时优先读 package.json) ----
60
- $script:TrayVersion = $TRAY_VERSION
61
- try {
62
- $pk = Join-Path $PSScriptRoot 'package.json'
63
- if (Test-Path $pk) { $pv = (Get-Content $pk -Raw | ConvertFrom-Json).version; if ($pv) { $script:TrayVersion = $pv } }
64
- } catch { }
65
-
66
- # ---- 圆角浅色菜单 (白底 + 浅蓝 hover + 圆角窗口 + 投影阴影, 借鉴社区 dsh-tray) ----
67
- $winFormsAsm = [System.Windows.Forms.Application].Assembly.Location
68
- $drawingAsm = [System.Drawing.Bitmap].Assembly.Location
69
- $drawingPrimAsm = [System.Drawing.Rectangle].Assembly.Location # .NET 5+ 中 Rectangle 等转发到 System.Drawing.Primitives
70
- Add-Type -TypeDefinition @'
71
- using System;
72
- using System.Drawing;
73
- using System.Drawing.Drawing2D;
74
- using System.Runtime.InteropServices;
75
- using System.Windows.Forms;
76
-
77
- public static class MenuRounded {
78
- [DllImport("user32.dll")] public static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
79
- [DllImport("gdi32.dll")] public static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int w, int h);
80
- [DllImport("user32.dll")] public static extern int GetClassLong(IntPtr hWnd, int nIndex);
81
- [DllImport("user32.dll")] public static extern int SetClassLong(IntPtr hWnd, int nIndex, int dwNewLong);
82
- [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
83
- [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);
84
- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
85
- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
86
- }
87
-
88
- public class SoftColors : ProfessionalColorTable {
89
- public override Color ToolStripDropDownBackground { get { return Color.White; } }
90
- public override Color ImageMarginGradientBegin { get { return Color.White; } }
91
- public override Color ImageMarginGradientMiddle { get { return Color.White; } }
92
- public override Color ImageMarginGradientEnd { get { return Color.White; } }
93
- public override Color MenuBorder { get { return Color.FromArgb(228, 232, 242); } }
94
- public override Color MenuItemSelected { get { return Color.Transparent; } }
95
- public override Color MenuItemBorder { get { return Color.Transparent; } }
96
- public override Color SeparatorDark { get { return Color.Transparent; } }
97
- public override Color SeparatorLight { get { return Color.Transparent; } }
98
- }
99
-
100
- public class SoftMenuRenderer : ToolStripProfessionalRenderer {
101
- public SoftMenuRenderer() : base(new SoftColors()) { }
102
- private static GraphicsPath Rounded(Rectangle r, int radius) {
103
- GraphicsPath p = new GraphicsPath();
104
- int d = radius * 2;
105
- p.AddArc(r.X, r.Y, d, d, 180, 90);
106
- p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
107
- p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
108
- p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
109
- p.CloseFigure();
110
- return p;
111
- }
112
- protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) {
113
- if (e.Item.Selected || e.Item.Pressed) {
114
- Rectangle r = new Rectangle(5, 3, e.Item.Width - 10, e.Item.Height - 6);
115
- using (GraphicsPath p = Rounded(r, 8))
116
- using (SolidBrush b = new SolidBrush(Color.FromArgb(238, 243, 255)))
117
- e.Graphics.FillPath(b, p);
118
- }
119
- }
120
- protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e) {
121
- using (SolidBrush b = new SolidBrush(Color.FromArgb(240, 242, 247)))
122
- e.Graphics.FillRectangle(b, 16, 4, e.Item.Width - 32, 1);
123
- }
124
- protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e) {
125
- using (Pen p = new Pen(Color.FromArgb(226, 230, 240)))
126
- e.Graphics.DrawRectangle(p, 1, 1, e.AffectedBounds.Width - 3, e.AffectedBounds.Height - 3);
127
- }
128
- }
129
- '@ -ReferencedAssemblies @($winFormsAsm, $drawingAsm, $drawingPrimAsm)
130
-
131
- # ---- 右键菜单 (浅色圆角) ----
132
- $menu = New-Object System.Windows.Forms.ContextMenuStrip
133
- $menu.ShowImageMargin = $false
134
- $menu.Renderer = New-Object SoftMenuRenderer
135
- $menu.Font = New-Object System.Drawing.Font('Segoe UI', 8.25)
136
-
137
- # 圆角窗口 + 投影阴影 (主菜单与子菜单通用); 子菜单额外对齐父项
138
- # 背景: WinForms 中带箭头的子菜单父项会被拉伸到菜单宽度后再加箭头预留,
139
- # 导致父项宽度超过菜单窗口, 子菜单锚在父项右缘 -> 与菜单之间出现空隙
140
- function Round-Menu($m) {
141
- try {
142
- $h = $m.Handle
143
- $style = [MenuRounded]::GetClassLong($h, -26)
144
- [void][MenuRounded]::SetClassLong($h, -26, $style -bor 0x20000) # CS_DROPSHADOW 投影
145
- $r = [MenuRounded]::CreateRoundRectRgn(0, 0, $m.Width, $m.Height, 18, 18)
146
- [void][MenuRounded]::SetWindowRgn($h, $r, $true)
147
- $pi = $m.OwnerItem
148
- if ($pi -and $pi.Owner) {
149
- # 子菜单: 左缘对齐父菜单窗口右缘 (-3px 标准重叠), 垂直对齐父项顶部 (-3px)
150
- $pt = $pi.Owner.PointToScreen($pi.Bounds.Location)
151
- $ow = New-Object MenuRounded+RECT
152
- [void][MenuRounded]::GetWindowRect($pi.Owner.Handle, [ref]$ow)
153
- [void][MenuRounded]::MoveWindow($h, $ow.Right - 3, $pt.Y - 3, $m.Width, $m.Height, $true)
154
- }
155
- [void][MenuRounded]::SetForegroundWindow($h)
156
- } catch { }
157
- }
158
- $menu.add_Opened({ Round-Menu $this }) # $this = 事件发送者 (避免闭包捕获函数局部变量的坑)
159
- $menu.add_Opening({ Update-State -ForceTui }) # 打开菜单时强制刷新 (含 TUI 状态)
160
-
161
- function New-MenuItem([string]$text, [scriptblock]$action) {
162
- $it = New-Object System.Windows.Forms.ToolStripMenuItem($text)
163
- $it.ForeColor = [System.Drawing.Color]::FromArgb(32, 36, 43)
164
- $it.Padding = New-Object System.Windows.Forms.Padding(10, 4, 14, 4)
165
- if ($action) { $it.Add_Click($action) }
166
- return $it
167
- }
168
-
169
- function New-SubMenu([string]$text) {
170
- $it = New-Object System.Windows.Forms.ToolStripMenuItem($text)
171
- $it.ForeColor = [System.Drawing.Color]::FromArgb(32, 36, 43)
172
- $it.Padding = New-Object System.Windows.Forms.Padding(10, 4, 14, 4)
173
- $it.DropDown.Renderer = New-Object SoftMenuRenderer
174
- $it.DropDown.add_Opened({ Round-Menu $this }) # $this = 子菜单 DropDown (闭包捕获 $it 会得 $null, 勿用)
175
- return $it
176
- }
177
-
178
- # ---- 顶部双状态 ----
179
- $itemDshStatus = New-MenuItem 'Web UI 状态: 检测中…' $null; $itemDshStatus.Enabled = $false
180
- $itemTuiStatus = New-MenuItem 'TUI 状态: 检测中…' $null; $itemTuiStatus.Enabled = $false
181
- $menu.Items.Add($itemDshStatus) | Out-Null
182
- $menu.Items.Add($itemTuiStatus) | Out-Null
183
- $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
184
-
185
- # ---- Web UI 子菜单 ----
186
- $itemDsh = New-SubMenu 'Web UI'
187
- $itemOpen = New-MenuItem '打开 Web UI' { try { Start-Process $URL } catch { Log "打开 Web 失败: $($_.Exception.Message)" } }
188
- $itemStart = New-MenuItem '启动 Web UI' { Invoke-DshAction 'start' }
189
- $itemStop = New-MenuItem '停止 Web UI' {
190
- $r = [System.Windows.Forms.MessageBox]::Show('确定停止 Web UI 服务?Web 界面与当前会话将关闭。', 'DSH 系统托盘', 'YesNo', 'Question', 'DefaultButton2')
191
- if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { Invoke-DshAction 'stop' }
192
- }
193
- $itemRestart = New-MenuItem '重启 Web UI' { Invoke-DshAction 'restart' }
194
- $itemDsh.DropDownItems.AddRange(@($itemOpen, $itemStart, $itemStop, $itemRestart))
195
- $menu.Items.Add($itemDsh) | Out-Null
196
-
197
- # ---- TUI 子菜单 ----
198
- $itemTui = New-SubMenu 'TUI'
199
- $itemTuiStart = New-MenuItem '启动 TUI' { Invoke-DshAction 'tui-start' }
200
- $itemTuiStop = New-MenuItem '停止 TUI' { Invoke-DshAction 'tui-stop' }
201
- $itemTui.DropDownItems.AddRange(@($itemTuiStart, $itemTuiStop))
202
- $menu.Items.Add($itemTui) | Out-Null
203
-
204
- $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
205
- $itemLogs = New-MenuItem '打开日志目录' { try { Start-Process $LOG_DIR } catch { Log "打开日志目录失败: $($_.Exception.Message)" } }
206
- $menu.Items.Add($itemLogs) | Out-Null
207
-
208
- # ---- 版本信息 (非日常功能, 放在退出前; npm 安装版支持一键更新) ----
209
- $itemUpdate = New-SubMenu '版本信息'
210
- $itemUpdateStatus = New-MenuItem ("版本: v$($script:TrayVersion)") $null; $itemUpdateStatus.Enabled = $false
211
- $itemUpdateCheck = New-MenuItem '检查更新' { Update-Check }
212
- $itemUpdateDo = New-MenuItem '更新到最新版' { Update-Do }; $itemUpdateDo.Enabled = $false
213
- $itemUpdate.DropDownItems.AddRange(@($itemUpdateStatus, $itemUpdateCheck, $itemUpdateDo))
214
- $menu.Items.Add($itemUpdate) | Out-Null
215
- $itemExit = New-MenuItem '退出托盘' {
216
- Log '退出托盘'
217
- $notify.Visible = $false
218
- $timer.Stop()
219
- $notify.Dispose()
220
- $appContext.ExitThread()
221
- }
222
- $menu.Items.Add($itemExit) | Out-Null
223
-
224
- # ---- 动作在独立进程执行, 不阻塞托盘 UI ----
225
- function Invoke-DshAction([string]$action) {
226
- $scriptPath = Join-Path $PSScriptRoot 'dsh-actions.ps1'
227
- $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $scriptPath, '-Action', $action)
228
- try {
229
- if (Test-Path $PWSH) { Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null }
230
- else { Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null }
231
- Log "已触发动作: $action"
232
- } catch { Log "触发动作 $action 失败: $($_.Exception.Message)" }
233
- }
234
-
235
- # ---- 自更新: 版本对比 / npm 定位 / 检查 / 执行 ----
236
- function Compare-Version($a, $b) {
237
- $pa = @(); $pb = @()
238
- foreach ($x in ($a -split '\.')) { try { $pa += [int]$x } catch { $pa += 0 } }
239
- foreach ($x in ($b -split '\.')) { try { $pb += [int]$x } catch { $pb += 0 } }
240
- for ($i = 0; $i -lt 3; $i++) {
241
- $va = if ($i -lt $pa.Count) { $pa[$i] } else { 0 }
242
- $vb = if ($i -lt $pb.Count) { $pb[$i] } else { 0 }
243
- if ($va -gt $vb) { return 1 }
244
- if ($va -lt $vb) { return -1 }
245
- }
246
- return 0
247
- }
248
-
249
- function Find-NpmCmd {
250
- if ($NODE -and (Test-Path $NODE)) {
251
- $p = Join-Path (Split-Path $NODE -Parent) 'npm.cmd'
252
- if (Test-Path $p) { return $p }
253
- }
254
- $c = Get-Command npm.cmd -ErrorAction SilentlyContinue
255
- if ($c) { return $c.Source }
256
- $c2 = Get-Command npm -ErrorAction SilentlyContinue
257
- if ($c2) { return $c2.Source }
258
- return $null
259
- }
260
-
261
- function Get-LatestTrayVersion {
262
- $npm = Find-NpmCmd
263
- if (-not $npm) { Log '自更新: npm 未找到'; return '' }
264
- $tmp = Join-Path $LOG_DIR 'tray-npm-view.txt'
265
- Remove-Item $tmp -Force -ErrorAction SilentlyContinue
266
- $cmdLine = '/c ""' + $npm + '" view ' + $TRAY_PKG + ' version > "' + $tmp + '" 2>nul "'
267
- $psi = New-Object System.Diagnostics.ProcessStartInfo
268
- $psi.FileName = $env:ComSpec
269
- $psi.Arguments = $cmdLine
270
- $psi.UseShellExecute = $false
271
- $psi.CreateNoWindow = $true
272
- try {
273
- $p = [System.Diagnostics.Process]::Start($psi)
274
- $p.WaitForExit(20000) | Out-Null
275
- if (-not $p.HasExited) { $p.Kill() }
276
- } catch { Log "npm view 执行失败: $($_.Exception.Message)"; return '' }
277
- try {
278
- if (Test-Path $tmp) {
279
- $v = (Get-Content $tmp -Raw).Trim()
280
- Remove-Item $tmp -Force -ErrorAction SilentlyContinue
281
- if ($v -match '^[0-9]+\.[0-9]+\.[0-9]+') { return $Matches[0] }
282
- }
283
- } catch { }
284
- return ''
285
- }
286
-
287
- function Update-Check {
288
- $itemUpdateStatus.Text = '检查中…'
289
- $latest = Get-LatestTrayVersion
290
- if (-not $latest) {
291
- $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 检查失败(网络/npm)"
292
- Show-Balloon 'DSH' '检查更新失败(网络或 npm 不可用),详见日志'
293
- return
294
- }
295
- if ((Compare-Version $latest $script:TrayVersion) -gt 0) {
296
- $script:latestVersion = $latest
297
- $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 最新 v$latest"
298
- $itemUpdateDo.Text = "更新到 v$latest"
299
- $itemUpdateDo.Enabled = $true
300
- Show-Balloon 'DSH' "托盘有新版本 v$latest,可一键更新"
301
- } else {
302
- $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 已是最新"
303
- $itemUpdateDo.Enabled = $false
304
- Show-Balloon 'DSH' "已是最新版 v$($script:TrayVersion)"
305
- }
306
- }
307
-
308
- function Update-Do {
309
- $updater = Join-Path $PSScriptRoot 'dsh-update.ps1'
310
- if (-not (Test-Path $updater)) { Show-Balloon 'DSH' '缺少 dsh-update.ps1,无法更新'; return }
311
- $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $updater, '-OldPid', "$PID")
312
- try {
313
- if (Test-Path $PWSH) { Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null }
314
- else { Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null }
315
- Log '已触发自更新'
316
- $itemUpdateStatus.Text = '更新中…'
317
- Show-Balloon 'DSH' '正在更新… 完成后托盘会自动重启(约 1 分钟)'
318
- } catch { Log "触发自更新失败: $($_.Exception.Message)" }
319
- }
320
-
321
- # ---- 托盘本体 ----
322
- $notify = New-Object System.Windows.Forms.NotifyIcon
323
- $notify.Icon = $iconStop
324
- $notify.Text = "DSH 托盘 · 检测中 (端口 $PORT)"
325
- $notify.ContextMenuStrip = $menu
326
- $notify.Visible = $true
327
- $notify.add_DoubleClick({
328
- if (Test-Port $PORT) { Start-Process $URL } else { Show-Balloon 'DSH' 'Web UI 未运行,右键选择「启动 Web UI」' }
329
- })
330
-
331
- function Show-Balloon([string]$title, [string]$text) {
332
- if ($NoBalloon) { return }
333
- try {
334
- $notify.BalloonTipTitle = $title
335
- $notify.BalloonTipText = $text
336
- $notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info
337
- $notify.ShowBalloonTip(4000)
338
- } catch { }
339
- }
340
-
341
- # ---- 状态轮询 ----
342
- $script:prevState = $null # $null=初始; $true=运行; $false=停止
343
- $script:prevTui = $null # TUI 状态 ($null=初始; $true=运行; $false=停止)
344
- $script:firstRun = $true # 首次刷新只设图标, 不弹变化通知
345
- $script:lastTuiCheck = 0 # TUI 检测节流 (常规 20s 一次, 打开菜单时强制)
346
-
347
- function Update-State {
348
- param([switch]$ForceTui)
349
- try {
350
- # TUI 状态独立检测 (不随 Web 状态提前返回); WMI 查询节流, 减少周期开销
351
- if ($ForceTui -or ([Environment]::TickCount - $script:lastTuiCheck) -gt 20000) {
352
- $script:lastTuiCheck = [Environment]::TickCount
353
- $tuiProcs = @(Get-TuiProcesses)
354
- $tuiRunning = $tuiProcs.Count -gt 0
355
- if ($tuiRunning) {
356
- $itemTuiStatus.Text = "TUI 状态: 运行中 (PID $(($tuiProcs.ProcessId) -join ','))"
357
- } else {
358
- $itemTuiStatus.Text = 'TUI 状态: 未运行'
359
- }
360
- if ($tuiRunning -ne $script:prevTui) {
361
- $script:prevTui = $tuiRunning
362
- $itemTuiStart.Enabled = -not $tuiRunning
363
- $itemTuiStop.Enabled = $tuiRunning
364
- if ($tuiRunning) {
365
- Log "TUI: 运行中 (PID $(($tuiProcs.ProcessId) -join ','))"
366
- } else {
367
- Log 'TUI: 未运行'
368
- }
369
- }
370
- }
371
- $running = Test-Port $PORT
372
- if ($running -eq $script:prevState -and -not $script:firstRun) { return } # 无变化
373
- $script:prevState = $running
374
- if ($running) {
375
- $pids = @(Get-WebPid)
376
- $notify.Icon = $iconRun
377
- $notify.Text = "Web UI 运行中 · 127.0.0.1:$PORT"
378
- $itemDshStatus.Text = "Web UI 状态: 运行中 (PID $($pids -join ','))"
379
- $itemOpen.Enabled = $true
380
- $itemStart.Enabled = $false
381
- $itemStop.Enabled = $true
382
- $itemRestart.Enabled = $true
383
- Log "状态: 运行中 (PID $($pids -join ','))"
384
- if (-not $script:firstRun) { Show-Balloon 'DSH' "Web UI 已启动 ($URL)" }
385
- } else {
386
- $notify.Icon = $iconStop
387
- $notify.Text = 'Web UI 未运行 · 右键可启动'
388
- $itemDshStatus.Text = 'Web UI 状态: 未运行'
389
- $itemOpen.Enabled = $false
390
- $itemStart.Enabled = $true
391
- $itemStop.Enabled = $false
392
- $itemRestart.Enabled = $false
393
- Log '状态: 未运行'
394
- if (-not $script:firstRun) { Show-Balloon 'DSH' 'Web UI 已停止' }
395
- }
396
- $script:firstRun = $false
397
- } catch { Log "轮询异常: $($_.Exception.Message)" }
398
- }
399
-
400
- $timer = New-Object System.Windows.Forms.Timer
401
- $timer.Interval = 2000
402
- $timer.add_Tick({ Update-State })
403
- $timer.Start()
404
-
405
- # 启动即刷新一次状态(不弹变化通知), 并提示就绪
406
- Update-State
407
- if (-not $NoBalloon) { Show-Balloon 'DSH' "托盘已就绪 · 双击打开 Web · 右键控制 (端口 $PORT) · 若图标在 ^ 溢出区, 请拖到任务栏" }
408
- Log "托盘启动 (v$($script:TrayVersion), PID $PID, 端口 $PORT)"
409
-
410
- $appContext = New-Object System.Windows.Forms.ApplicationContext
411
- [System.Windows.Forms.Application]::Run($appContext)
412
-
413
- # 退出清理
414
- $timer.Dispose()
415
- $menu.Dispose()
416
- $iconRun.Dispose(); $iconStop.Dispose()
417
- try { $mutex.ReleaseMutex() } catch { }
418
- Log '托盘已退出'
1
+ # dsh-tray.ps1 —— DSH 系统托盘 (状态图标 + 右键菜单)
2
+ # 启动方式:
3
+ # 1. 双击 dsh-tray.bat
4
+ # 2. 由 dsh-control.ps1 启动/重启 Web 时自动拉起
5
+ # 3. 手动: pwsh -NoProfile -WindowStyle Hidden -File dsh-tray.ps1
6
+ # 功能:
7
+ # - 图标 = 状态: 品牌蓝鲸鱼 = Web 运行中; 灰鲸鱼 = 未运行 (官方 favicon 渲染)
8
+ # - 悬停提示: 当前状态与 PID
9
+ # - 双击: 打开 Web UI
10
+ # - 右键菜单: 顶部 DSH/TUI 双状态行 + DSH/TUI 子菜单 + 日志目录/版本信息/退出托盘
11
+ # - 状态变化气泡通知; 状态/动作日志: ~/.dsh/logs/dsh-tray.log
12
+ # - TUI 状态独立检测: 菜单项按 TUI 是否运行自动禁用/启用
13
+ # - 浅色圆角菜单(借鉴社区 dsh-tray 方案) + npm 自更新(借鉴 dsh-tray-launcher 方案)
14
+ param(
15
+ [int]$Port = 3088,
16
+ [switch]$NoBalloon
17
+ )
18
+ $ErrorActionPreference = 'Stop'
19
+
20
+ # ---- 单实例保护: 命名互斥锁 (被强杀遗留的锁自动接管, 无竞态) ----
21
+ $mutex = New-Object System.Threading.Mutex($false, 'dsh-tray-singleton')
22
+ $gotMutex = $false
23
+ try { $gotMutex = $mutex.WaitOne(0) } catch { $gotMutex = $true } # AbandonedMutexException -> 接管
24
+ if (-not $gotMutex) { exit 0 }
25
+
26
+ . (Join-Path $PSScriptRoot 'dsh-lib.ps1')
27
+ $PORT = $Port
28
+ $URL = "http://127.0.0.1:$PORT"
29
+
30
+ Add-Type -AssemblyName System.Windows.Forms
31
+ Add-Type -AssemblyName System.Drawing
32
+
33
+ # ---- 通知身份 (v1.0.3): 注册本进程 AUMID, 气泡通知归属「DSH 系统托盘」快捷方式而非 PowerShell ----
34
+ # Win11 升级会重置应用通知授权; 配合 install.ps1 写入的带 AUMID 开始菜单快捷方式生效。
35
+ # 必须在创建任何窗口/NotifyIcon 之前调用。
36
+ if (-not ('DSH.NativeShell' -as [type])) {
37
+ Add-Type -Namespace DSH -Name NativeShell -MemberDefinition @'
38
+ [DllImport("shell32.dll")]
39
+ public static extern int SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string AppID);
40
+ '@
41
+ }
42
+ [void][DSH.NativeShell]::SetCurrentProcessExplicitAppUserModelID('DSH.WindowsTray')
43
+
44
+ $logFile = Join-Path $LOG_DIR 'dsh-tray.log'
45
+ function Log([string]$msg) {
46
+ $line = ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $msg)
47
+ Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
48
+ }
49
+
50
+ # ---- 图标: 官方鲸鱼 .ico; 缺失时回退为纯色圆点 ----
51
+ $iconsDir = Join-Path $PSScriptRoot 'icons'
52
+ $icoRun = Join-Path $iconsDir 'dsh-tray-running.ico'
53
+ $icoStop = Join-Path $iconsDir 'dsh-tray-stopped.ico'
54
+
55
+ function New-FallbackIcon([System.Drawing.Color]$color) {
56
+ $bmp = New-Object System.Drawing.Bitmap 16, 16, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
57
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
58
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
59
+ $brush = New-Object System.Drawing.SolidBrush($color)
60
+ $g.FillEllipse($brush, 2, 2, 12, 12)
61
+ $g.Dispose(); $brush.Dispose()
62
+ $icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
63
+ $bmp.Dispose()
64
+ return $icon
65
+ }
66
+
67
+ $iconRun = if (Test-Path $icoRun) { New-Object System.Drawing.Icon($icoRun) } else { New-FallbackIcon ([System.Drawing.Color]::FromArgb(0x41, 0x76, 0xE6)) }
68
+ $iconStop = if (Test-Path $icoStop) { New-Object System.Drawing.Icon($icoStop) } else { New-FallbackIcon ([System.Drawing.Color]::FromArgb(0x81, 0x85, 0x8C)) }
69
+
70
+ # ---- 版本烙印 (npm 包安装时优先读 package.json) ----
71
+ $script:TrayVersion = $TRAY_VERSION
72
+ try {
73
+ $pk = Join-Path $PSScriptRoot 'package.json'
74
+ if (Test-Path $pk) { $pv = (Get-Content $pk -Raw | ConvertFrom-Json).version; if ($pv) { $script:TrayVersion = $pv } }
75
+ } catch { }
76
+
77
+ # ---- 圆角浅色菜单 (白底 + 浅蓝 hover + 圆角窗口 + 投影阴影, 借鉴社区 dsh-tray) ----
78
+ $winFormsAsm = [System.Windows.Forms.Application].Assembly.Location
79
+ $drawingAsm = [System.Drawing.Bitmap].Assembly.Location
80
+ $drawingPrimAsm = [System.Drawing.Rectangle].Assembly.Location # .NET 5+ 中 Rectangle 等转发到 System.Drawing.Primitives
81
+ Add-Type -TypeDefinition @'
82
+ using System;
83
+ using System.Drawing;
84
+ using System.Drawing.Drawing2D;
85
+ using System.Runtime.InteropServices;
86
+ using System.Windows.Forms;
87
+
88
+ public static class MenuRounded {
89
+ [DllImport("user32.dll")] public static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
90
+ [DllImport("gdi32.dll")] public static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int w, int h);
91
+ [DllImport("user32.dll")] public static extern int GetClassLong(IntPtr hWnd, int nIndex);
92
+ [DllImport("user32.dll")] public static extern int SetClassLong(IntPtr hWnd, int nIndex, int dwNewLong);
93
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
94
+ [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);
95
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
96
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
97
+ }
98
+
99
+ public class SoftColors : ProfessionalColorTable {
100
+ public override Color ToolStripDropDownBackground { get { return Color.White; } }
101
+ public override Color ImageMarginGradientBegin { get { return Color.White; } }
102
+ public override Color ImageMarginGradientMiddle { get { return Color.White; } }
103
+ public override Color ImageMarginGradientEnd { get { return Color.White; } }
104
+ public override Color MenuBorder { get { return Color.FromArgb(228, 232, 242); } }
105
+ public override Color MenuItemSelected { get { return Color.Transparent; } }
106
+ public override Color MenuItemBorder { get { return Color.Transparent; } }
107
+ public override Color SeparatorDark { get { return Color.Transparent; } }
108
+ public override Color SeparatorLight { get { return Color.Transparent; } }
109
+ }
110
+
111
+ public class SoftMenuRenderer : ToolStripProfessionalRenderer {
112
+ public SoftMenuRenderer() : base(new SoftColors()) { }
113
+ private static GraphicsPath Rounded(Rectangle r, int radius) {
114
+ GraphicsPath p = new GraphicsPath();
115
+ int d = radius * 2;
116
+ p.AddArc(r.X, r.Y, d, d, 180, 90);
117
+ p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
118
+ p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
119
+ p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
120
+ p.CloseFigure();
121
+ return p;
122
+ }
123
+ protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e) {
124
+ if (e.Item.Selected || e.Item.Pressed) {
125
+ Rectangle r = new Rectangle(5, 3, e.Item.Width - 10, e.Item.Height - 6);
126
+ using (GraphicsPath p = Rounded(r, 8))
127
+ using (SolidBrush b = new SolidBrush(Color.FromArgb(238, 243, 255)))
128
+ e.Graphics.FillPath(b, p);
129
+ }
130
+ }
131
+ protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e) {
132
+ using (SolidBrush b = new SolidBrush(Color.FromArgb(240, 242, 247)))
133
+ e.Graphics.FillRectangle(b, 16, 4, e.Item.Width - 32, 1);
134
+ }
135
+ protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e) {
136
+ using (Pen p = new Pen(Color.FromArgb(226, 230, 240)))
137
+ e.Graphics.DrawRectangle(p, 1, 1, e.AffectedBounds.Width - 3, e.AffectedBounds.Height - 3);
138
+ }
139
+ }
140
+ '@ -ReferencedAssemblies @($winFormsAsm, $drawingAsm, $drawingPrimAsm)
141
+
142
+ # ---- 右键菜单 (浅色圆角) ----
143
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
144
+ $menu.ShowImageMargin = $false
145
+ $menu.Renderer = New-Object SoftMenuRenderer
146
+ $menu.Font = New-Object System.Drawing.Font('Segoe UI', 8.25)
147
+
148
+ # 圆角窗口 + 投影阴影 (主菜单与子菜单通用); 子菜单额外对齐父项
149
+ # 背景: WinForms 中带箭头的子菜单父项会被拉伸到菜单宽度后再加箭头预留,
150
+ # 导致父项宽度超过菜单窗口, 子菜单锚在父项右缘 -> 与菜单之间出现空隙
151
+ function Round-Menu($m) {
152
+ try {
153
+ $h = $m.Handle
154
+ $style = [MenuRounded]::GetClassLong($h, -26)
155
+ [void][MenuRounded]::SetClassLong($h, -26, $style -bor 0x20000) # CS_DROPSHADOW 投影
156
+ $r = [MenuRounded]::CreateRoundRectRgn(0, 0, $m.Width, $m.Height, 18, 18)
157
+ [void][MenuRounded]::SetWindowRgn($h, $r, $true)
158
+ $pi = $m.OwnerItem
159
+ if ($pi -and $pi.Owner) {
160
+ # 子菜单: 左缘对齐父菜单窗口右缘 (-3px 标准重叠), 垂直对齐父项顶部 (-3px)
161
+ $pt = $pi.Owner.PointToScreen($pi.Bounds.Location)
162
+ $ow = New-Object MenuRounded+RECT
163
+ [void][MenuRounded]::GetWindowRect($pi.Owner.Handle, [ref]$ow)
164
+ [void][MenuRounded]::MoveWindow($h, $ow.Right - 3, $pt.Y - 3, $m.Width, $m.Height, $true)
165
+ }
166
+ [void][MenuRounded]::SetForegroundWindow($h)
167
+ } catch { }
168
+ }
169
+ $menu.add_Opened({ Round-Menu $this }) # $this = 事件发送者 (避免闭包捕获函数局部变量的坑)
170
+ $menu.add_Opening({ Update-State -ForceTui }) # 打开菜单时强制刷新 (含 TUI 状态)
171
+
172
+ function New-MenuItem([string]$text, [scriptblock]$action) {
173
+ $it = New-Object System.Windows.Forms.ToolStripMenuItem($text)
174
+ $it.ForeColor = [System.Drawing.Color]::FromArgb(32, 36, 43)
175
+ $it.Padding = New-Object System.Windows.Forms.Padding(10, 4, 14, 4)
176
+ if ($action) { $it.Add_Click($action) }
177
+ return $it
178
+ }
179
+
180
+ function New-SubMenu([string]$text) {
181
+ $it = New-Object System.Windows.Forms.ToolStripMenuItem($text)
182
+ $it.ForeColor = [System.Drawing.Color]::FromArgb(32, 36, 43)
183
+ $it.Padding = New-Object System.Windows.Forms.Padding(10, 4, 14, 4)
184
+ $it.DropDown.Renderer = New-Object SoftMenuRenderer
185
+ $it.DropDown.add_Opened({ Round-Menu $this }) # $this = 子菜单 DropDown (闭包捕获 $it 会得 $null, 勿用)
186
+ return $it
187
+ }
188
+
189
+ # ---- 顶部双状态 ----
190
+ $itemDshStatus = New-MenuItem 'Web UI 状态: 检测中…' $null; $itemDshStatus.Enabled = $false
191
+ $itemTuiStatus = New-MenuItem 'TUI 状态: 检测中…' $null; $itemTuiStatus.Enabled = $false
192
+ $menu.Items.Add($itemDshStatus) | Out-Null
193
+ $menu.Items.Add($itemTuiStatus) | Out-Null
194
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
195
+
196
+ # ---- Web UI 子菜单 ----
197
+ $itemDsh = New-SubMenu 'Web UI'
198
+ $itemOpen = New-MenuItem '打开 Web UI' { try { Start-Process $URL } catch { Log "打开 Web 失败: $($_.Exception.Message)" } }
199
+ $itemStart = New-MenuItem '启动 Web UI' { Invoke-DshAction 'start' }
200
+ $itemStop = New-MenuItem '停止 Web UI' {
201
+ $r = [System.Windows.Forms.MessageBox]::Show('确定停止 Web UI 服务?Web 界面与当前会话将关闭。', 'DSH 系统托盘', 'YesNo', 'Question', 'Button2')
202
+ if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { Invoke-DshAction 'stop' }
203
+ }
204
+ $itemRestart = New-MenuItem '重启 Web UI' { Invoke-DshAction 'restart' }
205
+ $itemDsh.DropDownItems.AddRange(@($itemOpen, $itemStart, $itemStop, $itemRestart))
206
+ $menu.Items.Add($itemDsh) | Out-Null
207
+
208
+ # ---- TUI 子菜单 ----
209
+ $itemTui = New-SubMenu 'TUI'
210
+ $itemTuiStart = New-MenuItem '启动 TUI' { Invoke-DshAction 'tui-start' }
211
+ $itemTuiStop = New-MenuItem '停止 TUI' { Invoke-DshAction 'tui-stop' }
212
+ $itemTui.DropDownItems.AddRange(@($itemTuiStart, $itemTuiStop))
213
+ $menu.Items.Add($itemTui) | Out-Null
214
+
215
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
216
+ $itemLogs = New-MenuItem '打开日志目录' { try { Start-Process $LOG_DIR } catch { Log "打开日志目录失败: $($_.Exception.Message)" } }
217
+ $menu.Items.Add($itemLogs) | Out-Null
218
+
219
+ # ---- 版本信息 (非日常功能, 放在退出前; npm 安装版支持一键更新) ----
220
+ $itemUpdate = New-SubMenu '版本信息'
221
+ $itemUpdateStatus = New-MenuItem ("版本: v$($script:TrayVersion)") $null; $itemUpdateStatus.Enabled = $false
222
+ $itemUpdateCheck = New-MenuItem '检查更新' { Update-Check }
223
+ $itemUpdateDo = New-MenuItem '更新到最新版' { Update-Do }; $itemUpdateDo.Enabled = $false
224
+ $itemUpdate.DropDownItems.AddRange(@($itemUpdateStatus, $itemUpdateCheck, $itemUpdateDo))
225
+ $menu.Items.Add($itemUpdate) | Out-Null
226
+ $itemExit = New-MenuItem '退出托盘' {
227
+ Log '退出托盘'
228
+ $notify.Visible = $false
229
+ $timer.Stop()
230
+ $notify.Dispose()
231
+ $appContext.ExitThread()
232
+ }
233
+ $menu.Items.Add($itemExit) | Out-Null
234
+
235
+ # ---- 动作在独立进程执行, 不阻塞托盘 UI ----
236
+ function Invoke-DshAction([string]$action) {
237
+ $scriptPath = Join-Path $PSScriptRoot 'dsh-actions.ps1'
238
+ $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $scriptPath, '-Action', $action)
239
+ try {
240
+ if (Test-Path $PWSH) { Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null }
241
+ else { Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null }
242
+ Log "已触发动作: $action"
243
+ } catch { Log "触发动作 $action 失败: $($_.Exception.Message)" }
244
+ }
245
+
246
+ # ---- 自更新: 版本对比 / npm 定位 / 检查 / 执行 ----
247
+ function Compare-Version($a, $b) {
248
+ $pa = @(); $pb = @()
249
+ foreach ($x in ($a -split '\.')) { try { $pa += [int]$x } catch { $pa += 0 } }
250
+ foreach ($x in ($b -split '\.')) { try { $pb += [int]$x } catch { $pb += 0 } }
251
+ for ($i = 0; $i -lt 3; $i++) {
252
+ $va = if ($i -lt $pa.Count) { $pa[$i] } else { 0 }
253
+ $vb = if ($i -lt $pb.Count) { $pb[$i] } else { 0 }
254
+ if ($va -gt $vb) { return 1 }
255
+ if ($va -lt $vb) { return -1 }
256
+ }
257
+ return 0
258
+ }
259
+
260
+ function Find-NpmCmd {
261
+ if ($NODE -and (Test-Path $NODE)) {
262
+ $p = Join-Path (Split-Path $NODE -Parent) 'npm.cmd'
263
+ if (Test-Path $p) { return $p }
264
+ }
265
+ $c = Get-Command npm.cmd -ErrorAction SilentlyContinue
266
+ if ($c) { return $c.Source }
267
+ $c2 = Get-Command npm -ErrorAction SilentlyContinue
268
+ if ($c2) { return $c2.Source }
269
+ return $null
270
+ }
271
+
272
+ function Get-LatestTrayVersion {
273
+ $npm = Find-NpmCmd
274
+ if (-not $npm) { Log '自更新: npm 未找到'; return '' }
275
+ $tmp = Join-Path $LOG_DIR 'tray-npm-view.txt'
276
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
277
+ $cmdLine = '/c ""' + $npm + '" view ' + $TRAY_PKG + ' version > "' + $tmp + '" 2>nul "'
278
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
279
+ $psi.FileName = $env:ComSpec
280
+ $psi.Arguments = $cmdLine
281
+ $psi.UseShellExecute = $false
282
+ $psi.CreateNoWindow = $true
283
+ try {
284
+ $p = [System.Diagnostics.Process]::Start($psi)
285
+ $p.WaitForExit(20000) | Out-Null
286
+ if (-not $p.HasExited) { $p.Kill() }
287
+ } catch { Log "npm view 执行失败: $($_.Exception.Message)"; return '' }
288
+ try {
289
+ if (Test-Path $tmp) {
290
+ $v = (Get-Content $tmp -Raw).Trim()
291
+ Remove-Item $tmp -Force -ErrorAction SilentlyContinue
292
+ if ($v -match '^[0-9]+\.[0-9]+\.[0-9]+') { return $Matches[0] }
293
+ }
294
+ } catch { }
295
+ return ''
296
+ }
297
+
298
+ function Update-Check {
299
+ $itemUpdateStatus.Text = '检查中…'
300
+ $latest = Get-LatestTrayVersion
301
+ if (-not $latest) {
302
+ $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 检查失败(网络/npm)"
303
+ Show-Balloon 'DSH' '检查更新失败(网络或 npm 不可用),详见日志'
304
+ return
305
+ }
306
+ if ((Compare-Version $latest $script:TrayVersion) -gt 0) {
307
+ $script:latestVersion = $latest
308
+ $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 最新 v$latest"
309
+ $itemUpdateDo.Text = "更新到 v$latest"
310
+ $itemUpdateDo.Enabled = $true
311
+ Show-Balloon 'DSH' "托盘有新版本 v$latest,可一键更新"
312
+ } else {
313
+ $itemUpdateStatus.Text = "版本: v$($script:TrayVersion) · 已是最新"
314
+ $itemUpdateDo.Enabled = $false
315
+ Show-Balloon 'DSH' "已是最新版 v$($script:TrayVersion)"
316
+ }
317
+ }
318
+
319
+ function Update-Do {
320
+ $updater = Join-Path $PSScriptRoot 'dsh-update.ps1'
321
+ if (-not (Test-Path $updater)) { Show-Balloon 'DSH' '缺少 dsh-update.ps1,无法更新'; return }
322
+ $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $updater, '-OldPid', "$PID")
323
+ try {
324
+ if (Test-Path $PWSH) { Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null }
325
+ else { Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null }
326
+ Log '已触发自更新'
327
+ $itemUpdateStatus.Text = '更新中…'
328
+ Show-Balloon 'DSH' '正在更新… 完成后托盘会自动重启(约 1 分钟)'
329
+ } catch { Log "触发自更新失败: $($_.Exception.Message)" }
330
+ }
331
+
332
+ # ---- 托盘本体 ----
333
+ $notify = New-Object System.Windows.Forms.NotifyIcon
334
+ $notify.Icon = $iconStop
335
+ $notify.Text = "DSH 托盘 · 检测中 (端口 $PORT)"
336
+ $notify.ContextMenuStrip = $menu
337
+ $notify.Visible = $true
338
+ $notify.add_DoubleClick({
339
+ if (Test-Port $PORT) { Start-Process $URL } else { Show-Balloon 'DSH' 'Web UI 未运行,右键选择「启动 Web UI」' }
340
+ })
341
+
342
+ function Show-Balloon([string]$title, [string]$text) {
343
+ if ($NoBalloon) { return }
344
+ try {
345
+ $notify.BalloonTipTitle = $title
346
+ $notify.BalloonTipText = $text
347
+ $notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info
348
+ $notify.ShowBalloonTip(4000)
349
+ } catch { }
350
+ }
351
+
352
+ # ---- 状态轮询 ----
353
+ $script:prevState = $null # $null=初始; $true=运行; $false=停止
354
+ $script:prevTui = $null # TUI 状态 ($null=初始; $true=运行; $false=停止)
355
+ $script:firstRun = $true # 首次刷新只设图标, 不弹变化通知
356
+ $script:lastTuiCheck = 0 # TUI 检测节流 (常规 20s 一次, 打开菜单时强制)
357
+
358
+ function Update-State {
359
+ param([switch]$ForceTui)
360
+ try {
361
+ # TUI 状态独立检测 (不随 Web 状态提前返回); WMI 查询节流, 减少周期开销
362
+ if ($ForceTui -or ([Environment]::TickCount - $script:lastTuiCheck) -gt 20000) {
363
+ $script:lastTuiCheck = [Environment]::TickCount
364
+ $tuiProcs = @(Get-TuiProcesses)
365
+ $tuiRunning = $tuiProcs.Count -gt 0
366
+ if ($tuiRunning) {
367
+ $itemTuiStatus.Text = "TUI 状态: 运行中 (PID $(($tuiProcs.ProcessId) -join ','))"
368
+ } else {
369
+ $itemTuiStatus.Text = 'TUI 状态: 未运行'
370
+ }
371
+ if ($tuiRunning -ne $script:prevTui) {
372
+ $script:prevTui = $tuiRunning
373
+ $itemTuiStart.Enabled = -not $tuiRunning
374
+ $itemTuiStop.Enabled = $tuiRunning
375
+ if ($tuiRunning) {
376
+ Log "TUI: 运行中 (PID $(($tuiProcs.ProcessId) -join ','))"
377
+ } else {
378
+ Log 'TUI: 未运行'
379
+ }
380
+ }
381
+ }
382
+ $running = Test-Port $PORT
383
+ if ($running -eq $script:prevState -and -not $script:firstRun) { return } # 无变化
384
+ $script:prevState = $running
385
+ if ($running) {
386
+ $pids = @(Get-WebPid)
387
+ $notify.Icon = $iconRun
388
+ $notify.Text = "Web UI 运行中 · 127.0.0.1:$PORT"
389
+ $itemDshStatus.Text = "Web UI 状态: 运行中 (PID $($pids -join ','))"
390
+ $itemOpen.Enabled = $true
391
+ $itemStart.Enabled = $false
392
+ $itemStop.Enabled = $true
393
+ $itemRestart.Enabled = $true
394
+ Log "状态: 运行中 (PID $($pids -join ','))"
395
+ if (-not $script:firstRun) { Show-Balloon 'DSH' "Web UI 已启动 ($URL)" }
396
+ } else {
397
+ $notify.Icon = $iconStop
398
+ $notify.Text = 'Web UI 未运行 · 右键可启动'
399
+ $itemDshStatus.Text = 'Web UI 状态: 未运行'
400
+ $itemOpen.Enabled = $false
401
+ $itemStart.Enabled = $true
402
+ $itemStop.Enabled = $false
403
+ $itemRestart.Enabled = $false
404
+ Log '状态: 未运行'
405
+ if (-not $script:firstRun) { Show-Balloon 'DSH' 'Web UI 已停止' }
406
+ }
407
+ $script:firstRun = $false
408
+ } catch { Log "轮询异常: $($_.Exception.Message)" }
409
+ }
410
+
411
+ $timer = New-Object System.Windows.Forms.Timer
412
+ $timer.Interval = 2000
413
+ $timer.add_Tick({ Update-State })
414
+ $timer.Start()
415
+
416
+ # 启动即刷新一次状态(不弹变化通知), 并提示就绪
417
+ Update-State
418
+ if (-not $NoBalloon) { Show-Balloon 'DSH' "托盘已就绪 · 双击打开 Web · 右键控制 (端口 $PORT) · 若图标在 ^ 溢出区, 请拖到任务栏" }
419
+ Log "托盘启动 (v$($script:TrayVersion), PID $PID, 端口 $PORT)"
420
+
421
+ $appContext = New-Object System.Windows.Forms.ApplicationContext
422
+ [System.Windows.Forms.Application]::Run($appContext)
423
+
424
+ # 退出清理
425
+ $timer.Dispose()
426
+ $menu.Dispose()
427
+ $iconRun.Dispose(); $iconStop.Dispose()
428
+ try { $mutex.ReleaseMutex() } catch { }
429
+ Log '托盘已退出'
419
430