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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/dsh-tray.js +100 -0
- package/build/build-tray-icons.ps1 +98 -0
- package/build/dsh-tray-running-preview.png +0 -0
- package/build/dsh-tray-stopped-preview.png +0 -0
- package/build/whale-512.png +0 -0
- package/build/whale-render.html +9 -0
- package/docs/system-tray-guide.md +102 -0
- package/docs/tray-dev-archive.md +57 -0
- package/install.ps1 +110 -0
- package/launcher/bh-menu.bat +4 -0
- package/launcher/bh-menu.ps1 +56 -0
- package/launcher/dsh-actions.ps1 +50 -0
- package/launcher/dsh-all-menu.bat +4 -0
- package/launcher/dsh-all-menu.ps1 +106 -0
- package/launcher/dsh-control.bat +10 -0
- package/launcher/dsh-control.ps1 +40 -0
- package/launcher/dsh-lib.ps1 +165 -0
- package/launcher/dsh-menu.bat +11 -0
- package/launcher/dsh-menu.ps1 +93 -0
- package/launcher/dsh-tray.bat +10 -0
- package/launcher/dsh-tray.ps1 +418 -0
- package/launcher/dsh-update.ps1 +116 -0
- package/launcher/icons/dsh-tray-running.ico +0 -0
- package/launcher/icons/dsh-tray-stopped.ico +0 -0
- package/package.json +36 -0
|
@@ -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
|
|
Binary file
|
|
Binary file
|
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
|
+
}
|