dsh-windows-tray 1.0.0

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.
@@ -0,0 +1,418 @@
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 '托盘已退出'
@@ -0,0 +1,116 @@
1
+ # dsh-update.ps1 —— 托盘自更新执行器 (由托盘菜单「更新到最新版」触发, 独立隐藏进程)
2
+ # 流程: npm install -g 最新包 -> 运行新包 install.ps1 (重新部署+改写路径+快捷方式) -> 杀旧托盘 -> 起新托盘
3
+ # 失败时旧托盘不受影响 (旧托盘在最后一步才被终止)
4
+ param(
5
+ [int]$OldPid = 0
6
+ )
7
+ $ErrorActionPreference = 'Stop'
8
+ . (Join-Path $PSScriptRoot 'dsh-lib.ps1')
9
+
10
+ $logFile = Join-Path $LOG_DIR 'dsh-tray-actions.log'
11
+ function Log([string]$msg) {
12
+ Add-Content -Path $logFile -Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $msg) -ErrorAction SilentlyContinue
13
+ }
14
+
15
+ function Find-NpmCmd {
16
+ if ($NODE -and (Test-Path $NODE)) {
17
+ $p = Join-Path (Split-Path $NODE -Parent) 'npm.cmd'
18
+ if (Test-Path $p) { return $p }
19
+ }
20
+ $c = Get-Command npm.cmd -ErrorAction SilentlyContinue
21
+ if ($c) { return $c.Source }
22
+ $c2 = Get-Command npm -ErrorAction SilentlyContinue
23
+ if ($c2) { return $c2.Source }
24
+ return $null
25
+ }
26
+
27
+ # 隐藏执行 cmd /c (CreateNoWindow: 不弹黑窗), 可选超时
28
+ function Run-HiddenCmd([string]$cmdLine, [int]$timeoutMs = 0) {
29
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
30
+ $psi.FileName = $env:ComSpec
31
+ $psi.Arguments = $cmdLine
32
+ $psi.UseShellExecute = $false
33
+ $psi.CreateNoWindow = $true
34
+ $p = [System.Diagnostics.Process]::Start($psi)
35
+ if ($timeoutMs -gt 0) {
36
+ $p.WaitForExit($timeoutMs) | Out-Null
37
+ if (-not $p.HasExited) { $p.Kill(); return -1 }
38
+ } else { $p.WaitForExit() }
39
+ return $p.ExitCode
40
+ }
41
+
42
+ # 结果气泡 (临时 NotifyIcon, 更新脚本自身无托盘)
43
+ function Show-Balloon([string]$text, [string]$icon = 'Info') {
44
+ try {
45
+ Add-Type -AssemblyName System.Windows.Forms
46
+ Add-Type -AssemblyName System.Drawing
47
+ $n = New-Object System.Windows.Forms.NotifyIcon
48
+ $n.Icon = [System.Drawing.SystemIcons]::Information
49
+ $n.Visible = $true
50
+ $n.BalloonTipTitle = 'DSH 系统托盘'
51
+ $n.BalloonTipText = $text
52
+ $n.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::$icon
53
+ $n.ShowBalloonTip(5000)
54
+ Start-Sleep -Seconds 6
55
+ $n.Visible = $false
56
+ $n.Dispose()
57
+ } catch { }
58
+ }
59
+
60
+ Log '自更新开始'
61
+ $npm = Find-NpmCmd
62
+ if (-not $npm) {
63
+ Log '自更新失败: npm 未找到'
64
+ Show-Balloon '自更新失败:npm 未找到' 'Error'
65
+ exit 1
66
+ }
67
+
68
+ # 1) 安装最新包 (全局)
69
+ $code = Run-HiddenCmd ('/c ""' + $npm + '" install -g ' + $TRAY_PKG + ' 2>nul "', 180000)
70
+ if ($code -ne 0) {
71
+ Log "自更新失败: npm install 退出码 $code"
72
+ Show-Balloon '自更新失败:npm install 出错,详见日志' 'Error'
73
+ exit 1
74
+ }
75
+ Log 'npm install -g 完成'
76
+
77
+ # 2) 定位新包根目录
78
+ $rootTmp = Join-Path $LOG_DIR 'tray-npm-root.txt'
79
+ Remove-Item $rootTmp -Force -ErrorAction SilentlyContinue
80
+ Run-HiddenCmd ('/c ""' + $npm + '" root -g > "' + $rootTmp + '" 2>nul "') | Out-Null
81
+ $globalRoot = ''
82
+ if (Test-Path $rootTmp) {
83
+ $globalRoot = (Get-Content $rootTmp -Raw).Trim()
84
+ Remove-Item $rootTmp -Force -ErrorAction SilentlyContinue
85
+ }
86
+ $newInstall = Join-Path $globalRoot ($TRAY_PKG + '\install.ps1')
87
+ if (-not (Test-Path $newInstall)) {
88
+ Log "自更新失败: 未找到新包 $newInstall"
89
+ Show-Balloon '自更新失败:未找到新包(npm 全局目录)' 'Error'
90
+ exit 1
91
+ }
92
+
93
+ # 3) 运行新包 install.ps1 (重新部署 + 改写路径 + 快捷方式, 不启动托盘)
94
+ $code = Run-HiddenCmd ('/c ""' + $PWSH + '" -NoProfile -ExecutionPolicy Bypass -File "' + $newInstall + '" -NoTrayStart 2>nul "', 120000)
95
+ if ($code -ne 0) {
96
+ Log "自更新失败: install.ps1 退出码 $code"
97
+ Show-Balloon '自更新失败:部署出错,详见日志' 'Error'
98
+ exit 1
99
+ }
100
+ Log '新版本部署完成'
101
+
102
+ # 4) 杀旧托盘 -> 起新托盘
103
+ if ($OldPid -gt 0) {
104
+ Stop-Process -Id $OldPid -Force -ErrorAction SilentlyContinue
105
+ Log "旧托盘已停止 (PID $OldPid)"
106
+ }
107
+ Start-Sleep -Milliseconds 500
108
+ $newTray = Join-Path $env:USERPROFILE '.dsh\launcher\dsh-tray.ps1'
109
+ if (Test-Path $PWSH) {
110
+ Start-Process -FilePath $PWSH -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $newTray) -WindowStyle Hidden | Out-Null
111
+ } else {
112
+ Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $newTray) -WindowStyle Hidden | Out-Null
113
+ }
114
+ Log '新托盘已启动'
115
+ Show-Balloon '托盘已更新并自动重启 ✓'
116
+ exit 0
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "dsh-windows-tray",
3
+ "version": "1.0.0",
4
+ "description": "DSH (DeepSeek Harness) Windows 系统托盘: 蓝/灰鲸鱼状态图标 + Web/TUI 一键启停控制。纯 PowerShell + WinForms, 零依赖。",
5
+ "license": "MIT",
6
+ "author": "jankin_lv",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek",
10
+ "harness",
11
+ "tray",
12
+ "notifyicon",
13
+ "windows",
14
+ "powershell",
15
+ "system-tray"
16
+ ],
17
+ "os": [
18
+ "win32"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "files": [
24
+ "launcher/",
25
+ "build/",
26
+ "docs/",
27
+ "bin/",
28
+ "install.ps1",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "bin": {
33
+ "dsh-tray": "bin/dsh-tray.js"
34
+ },
35
+ "private": false
36
+ }