dsh-notify-yimit 0.1.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/README.en.md +97 -0
- package/README.md +95 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +790 -0
- package/lib/index.js +716 -0
- package/lib/toast-host.ps1 +393 -0
- package/lib/typert.host.js +158 -0
- package/package.json +51 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
<#
|
|
2
|
+
dsh-notify 常驻浮窗宿主(Windows PowerShell + WPF)。
|
|
3
|
+
由 dsh-notify 宿主插件在加载时 spawn 一次并常驻:所有桌面浮窗都在本进程内创建,
|
|
4
|
+
WPF 程序集只加载一次,通知创建延迟从 ~1s 冷启动降到 ~10ms(根治"总感觉慢")。
|
|
5
|
+
|
|
6
|
+
协议(UTF-8,一行一个 JSON):
|
|
7
|
+
stdin(入站命令):
|
|
8
|
+
{"cmd":"show","key":"running:s1","instance":1,"title":"...","text":"...",
|
|
9
|
+
"bg":"#286ebd","fg":"#e6edf3","durationSec":8,"sticky":true,"offsetY":0,
|
|
10
|
+
"sessionId":"s1","baseUrl":"http://127.0.0.1:3080",
|
|
11
|
+
"browserPath":"","winTitle":"dsh-notify-running:s1"}
|
|
12
|
+
{"cmd":"text","key":...,"text":"..."} # 原地更新内容(不重弹、零延迟)
|
|
13
|
+
{"cmd":"move","key":...,"top":588} # 平滑移动(220ms QuadraticEase)
|
|
14
|
+
{"cmd":"close","key":...} # 160ms 淡出 + 20px 下坠后关闭
|
|
15
|
+
{"cmd":"shutdown"} # 关闭全部窗口并退出
|
|
16
|
+
stdout(出站报告):
|
|
17
|
+
{"type":"pos","key":...,"instance":1,"left":...,"top":...,"height":...,
|
|
18
|
+
"hwnd":...,"workBottom":...}
|
|
19
|
+
{"type":"exit","key":...,"instance":...}
|
|
20
|
+
说明:主线程(STA)跑 WPF Dispatcher;stdin 由独立 runspace 线程读取并放入
|
|
21
|
+
ConcurrentQueue,命令在 Dispatcher 定时器(50ms)中处理——窗口操作线程安全。
|
|
22
|
+
#>
|
|
23
|
+
|
|
24
|
+
Add-Type -AssemblyName PresentationFramework
|
|
25
|
+
Add-Type -AssemblyName PresentationCore
|
|
26
|
+
Add-Type -AssemblyName WindowsBase
|
|
27
|
+
|
|
28
|
+
# WPF 必须运行在 STA 主线程(Windows PowerShell 5.1 控制台主线程为 STA)。
|
|
29
|
+
if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne [System.Threading.ApartmentState]::STA) {
|
|
30
|
+
[Console]::Error.WriteLine("dsh-notify: 宿主需要 STA 主线程(当前 $([System.Threading.Thread]::CurrentThread.ApartmentState))")
|
|
31
|
+
exit 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ── 协议输出:UTF-8 无 BOM(管道传中文必须固定编码,否则乱码) ────────────────
|
|
35
|
+
$script:utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
36
|
+
$script:stdout = New-Object System.IO.StreamWriter([System.Console]::OpenStandardOutput(), $script:utf8)
|
|
37
|
+
$script:stdout.AutoFlush = $true
|
|
38
|
+
|
|
39
|
+
function Send-Report([object]$obj) {
|
|
40
|
+
try { $script:stdout.WriteLine(($obj | ConvertTo-Json -Compress)) } catch { }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# 浮窗注册表(key → @{ Win; Body; Timer; Instance }),只在 WPF 主线程访问。
|
|
44
|
+
$script:toasts = @{}
|
|
45
|
+
$script:exitRequested = $false
|
|
46
|
+
|
|
47
|
+
# 颜色解析:#RGB / #RRGGBB / #AARRGGBB → Windows.Media.Color
|
|
48
|
+
function Parse-Color([string]$hex, [string]$fallback) {
|
|
49
|
+
$h = $hex.TrimStart('#')
|
|
50
|
+
if ($h.Length -eq 3) { $h = ($h.ToCharArray() | ForEach-Object { "$_$_" }) -join '' }
|
|
51
|
+
if ($h.Length -eq 6) { $h = "FF$h" }
|
|
52
|
+
if ($h.Length -ne 8 -or $h -notmatch '^[0-9a-fA-F]{8}$') { $h = $fallback.TrimStart('#') }
|
|
53
|
+
return [System.Windows.Media.Color]::FromArgb(
|
|
54
|
+
[Convert]::ToInt32($h.Substring(0,2), 16),
|
|
55
|
+
[Convert]::ToInt32($h.Substring(2,2), 16),
|
|
56
|
+
[Convert]::ToInt32($h.Substring(4,2), 16),
|
|
57
|
+
[Convert]::ToInt32($h.Substring(6,2), 16))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# 按钮用 Border 直接实现:标准圆角矩形(10px)。忽略 = 透明底 + 细边框(fg 色);
|
|
61
|
+
# 跳转 = 浅色实心底 + 主色文字。悬停/按压用不透明压暗色反馈(避免透明窗口闪烁)。
|
|
62
|
+
$downP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(158,164,170))
|
|
63
|
+
$downI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(30,36,44))
|
|
64
|
+
$hoverP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(196,202,208))
|
|
65
|
+
$hoverI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(44,52,62))
|
|
66
|
+
|
|
67
|
+
function New-ToastButton([string]$label, [scriptblock]$onClick, [bool]$primary,
|
|
68
|
+
[System.Windows.Media.SolidColorBrush]$fg, [System.Windows.Media.SolidColorBrush]$bg) {
|
|
69
|
+
$bd = New-Object System.Windows.Controls.Border
|
|
70
|
+
$bd.CornerRadius = New-Object System.Windows.CornerRadius(10)
|
|
71
|
+
$bd.Padding = New-Object System.Windows.Thickness(18,7,18,7)
|
|
72
|
+
$bd.Margin = New-Object System.Windows.Thickness(0,0,8,0)
|
|
73
|
+
$bd.Cursor = [System.Windows.Input.Cursors]::Hand
|
|
74
|
+
$bd.Focusable = $false
|
|
75
|
+
$txt = New-Object System.Windows.Controls.TextBlock
|
|
76
|
+
$txt.Text = $label
|
|
77
|
+
$txt.FontFamily = New-Object System.Windows.Media.FontFamily("Microsoft YaHei UI")
|
|
78
|
+
$txt.FontSize = 12
|
|
79
|
+
$txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
|
|
80
|
+
$txt.IsHitTestVisible = $false # 点击穿透到 Border,确保命中与事件稳定
|
|
81
|
+
if ($primary) {
|
|
82
|
+
$bd.Background = $fg
|
|
83
|
+
$txt.Foreground = $bg
|
|
84
|
+
$bd.BorderBrush = [System.Windows.Media.Brushes]::Transparent
|
|
85
|
+
$bd.BorderThickness = New-Object System.Windows.Thickness(0)
|
|
86
|
+
} else {
|
|
87
|
+
$bd.Background = [System.Windows.Media.Brushes]::Transparent
|
|
88
|
+
$txt.Foreground = $fg
|
|
89
|
+
$bd.BorderBrush = $fg
|
|
90
|
+
$bd.BorderThickness = New-Object System.Windows.Thickness(1)
|
|
91
|
+
}
|
|
92
|
+
$bd.Child = $txt
|
|
93
|
+
# 事件回调在函数返回后触发,函数局部变量不可见:回调和主按钮标记存入 Tag(hashtable),
|
|
94
|
+
# 回调统一从 $this(事件源)的 Tag 读取。
|
|
95
|
+
$bd.Tag = @{ primary = $primary; onClick = $onClick }
|
|
96
|
+
$bd.Add_MouseEnter({
|
|
97
|
+
$info = $this.Tag
|
|
98
|
+
$this.Background = if ($info.primary) { $hoverP } else { $hoverI }
|
|
99
|
+
}) | Out-Null
|
|
100
|
+
$bd.Add_MouseLeave({
|
|
101
|
+
$info = $this.Tag
|
|
102
|
+
$this.Background = if ($info.primary) { $fg } else { [System.Windows.Media.Brushes]::Transparent }
|
|
103
|
+
}) | Out-Null
|
|
104
|
+
$bd.Add_MouseLeftButtonDown({
|
|
105
|
+
$info = $this.Tag
|
|
106
|
+
$this.Background = if ($info.primary) { $downP } else { $downI }
|
|
107
|
+
}) | Out-Null
|
|
108
|
+
$bd.Add_MouseLeftButtonUp({
|
|
109
|
+
$info = $this.Tag
|
|
110
|
+
try { if ($info.onClick -ne $null) { & $info.onClick } } catch { }
|
|
111
|
+
$this.Background = if ($info.primary) { $fg } else { [System.Windows.Media.Brushes]::Transparent }
|
|
112
|
+
}) | Out-Null
|
|
113
|
+
return $bd
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# 动画缓动(脚本级,供各处复用)。
|
|
117
|
+
# 注意:PS 5.1 的 New-Object 无法用枚举参数调构造器(会报 "overload ... argument count: 1"),
|
|
118
|
+
# 必须默认构造后设置 EasingMode 属性。
|
|
119
|
+
$easeMove = New-Object System.Windows.Media.Animation.QuadraticEase
|
|
120
|
+
$easeMove.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
|
|
121
|
+
$easeExit = New-Object System.Windows.Media.Animation.CubicEase
|
|
122
|
+
$easeExit.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseIn
|
|
123
|
+
|
|
124
|
+
# 淡出关闭:160ms EaseIn 淡出 + 20px 下坠,动画完成后 Close(Closed 事件上报 exit)。
|
|
125
|
+
# 防重入:$w.Tag='closing' 只执行一次(忽略按钮 + close 命令 + 定时器可能先后触发);
|
|
126
|
+
# 600ms 硬性兜底,确保即使动画/定时器异常也强制关闭(绝不挂起)。
|
|
127
|
+
function Close-WithFade($w) {
|
|
128
|
+
try {
|
|
129
|
+
if ($w.Tag -eq 'closing') { return }
|
|
130
|
+
$w.Tag = 'closing'
|
|
131
|
+
$fadeOut = New-Object System.Windows.Media.Animation.DoubleAnimation(1, 0, [TimeSpan]::FromMilliseconds(160))
|
|
132
|
+
$fadeOut.EasingFunction = $easeExit
|
|
133
|
+
# 事件回调延迟触发,看不到函数局部变量:GetNewClosure() 捕获 $w。
|
|
134
|
+
$fadeOut.Add_Completed({ try { $w.Close() } catch { } }.GetNewClosure()) | Out-Null
|
|
135
|
+
$w.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeOut)
|
|
136
|
+
$dropAn = New-Object System.Windows.Media.Animation.DoubleAnimation($w.Top, ($w.Top + 20), [TimeSpan]::FromMilliseconds(160))
|
|
137
|
+
$dropAn.EasingFunction = $easeExit
|
|
138
|
+
$w.BeginAnimation([System.Windows.Window]::TopProperty, $dropAn)
|
|
139
|
+
$backstop = New-Object System.Windows.Threading.DispatcherTimer
|
|
140
|
+
$backstop.Interval = [TimeSpan]::FromMilliseconds(600)
|
|
141
|
+
$backstop.Tag = $w
|
|
142
|
+
$backstop.Add_Tick({
|
|
143
|
+
$this.Stop()
|
|
144
|
+
try { if ($this.Tag.IsVisible) { $this.Tag.Close() } } catch { }
|
|
145
|
+
}) | Out-Null
|
|
146
|
+
$backstop.Start()
|
|
147
|
+
} catch {
|
|
148
|
+
try { $w.Close() } catch { }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# 建窗:按 show 命令参数创建窗口;返回 Window(由调用方 Show() 显示)。
|
|
153
|
+
function New-ToastWindow($cmd) {
|
|
154
|
+
$bg = Parse-Color ([string]$cmd.bg) "#20272f"
|
|
155
|
+
$fg = Parse-Color ([string]$cmd.fg) "#e6edf3"
|
|
156
|
+
$bgBrush = New-Object System.Windows.Media.SolidColorBrush($bg)
|
|
157
|
+
$fgBrush = New-Object System.Windows.Media.SolidColorBrush($fg)
|
|
158
|
+
|
|
159
|
+
$win = New-Object System.Windows.Window
|
|
160
|
+
$win.WindowStyle = [System.Windows.WindowStyle]::None
|
|
161
|
+
$win.AllowsTransparency = $true
|
|
162
|
+
$win.Background = [System.Windows.Media.Brushes]::Transparent
|
|
163
|
+
$win.Topmost = $true
|
|
164
|
+
$win.ShowInTaskbar = $false
|
|
165
|
+
$win.Width = 340
|
|
166
|
+
$win.SizeToContent = [System.Windows.SizeToContent]::Height
|
|
167
|
+
if (-not [string]::IsNullOrEmpty([string]$cmd.winTitle)) { $win.Title = [string]$cmd.winTitle }
|
|
168
|
+
|
|
169
|
+
# 圆角卡片容器(窗口圆角 12;按钮为圆角矩形 10px)。
|
|
170
|
+
$cornerRadius = 12
|
|
171
|
+
$border = New-Object System.Windows.Controls.Border
|
|
172
|
+
$border.Background = $bgBrush
|
|
173
|
+
$border.BorderBrush = $fgBrush
|
|
174
|
+
$border.BorderThickness = New-Object System.Windows.Thickness(1)
|
|
175
|
+
$border.CornerRadius = New-Object System.Windows.CornerRadius($cornerRadius)
|
|
176
|
+
$border.Margin = New-Object System.Windows.Thickness(12)
|
|
177
|
+
|
|
178
|
+
$root = New-Object System.Windows.Controls.StackPanel
|
|
179
|
+
$root.Margin = New-Object System.Windows.Thickness(14,12,14,12)
|
|
180
|
+
$border.Child = $root
|
|
181
|
+
|
|
182
|
+
# 标题行
|
|
183
|
+
$titleText = New-Object System.Windows.Controls.TextBlock
|
|
184
|
+
$titleText.Text = if ([string]::IsNullOrEmpty([string]$cmd.title)) { "(未命名会话)" } else { [string]$cmd.title }
|
|
185
|
+
$titleText.Foreground = $fgBrush
|
|
186
|
+
$titleText.FontFamily = New-Object System.Windows.Media.FontFamily("Microsoft YaHei UI")
|
|
187
|
+
$titleText.FontSize = 13
|
|
188
|
+
$titleText.FontWeight = [System.Windows.FontWeights]::SemiBold
|
|
189
|
+
$titleText.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis
|
|
190
|
+
$titleText.Margin = New-Object System.Windows.Thickness(0,0,0,6)
|
|
191
|
+
$root.Children.Add($titleText) | Out-Null
|
|
192
|
+
|
|
193
|
+
# 内容(保留引用:text 命令原地更新,零延迟不重弹)
|
|
194
|
+
$bodyText = New-Object System.Windows.Controls.TextBlock
|
|
195
|
+
$bodyText.Text = [string]$cmd.text
|
|
196
|
+
$bodyText.Foreground = $fgBrush
|
|
197
|
+
$bodyText.FontFamily = New-Object System.Windows.Media.FontFamily("Microsoft YaHei UI")
|
|
198
|
+
$bodyText.FontSize = 13
|
|
199
|
+
$bodyText.TextWrapping = [System.Windows.TextWrapping]::Wrap
|
|
200
|
+
$bodyText.Margin = New-Object System.Windows.Thickness(0,0,0,10)
|
|
201
|
+
$root.Children.Add($bodyText) | Out-Null
|
|
202
|
+
|
|
203
|
+
# 按钮行
|
|
204
|
+
$btnRow = New-Object System.Windows.Controls.StackPanel
|
|
205
|
+
$btnRow.Orientation = [System.Windows.Controls.Orientation]::Horizontal
|
|
206
|
+
$btnRow.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
|
|
207
|
+
|
|
208
|
+
$ignoreBtn = New-ToastButton "忽略" ({ Close-WithFade $win }.GetNewClosure()) $false $fgBrush $bgBrush
|
|
209
|
+
$btnRow.Children.Add($ignoreBtn) | Out-Null
|
|
210
|
+
|
|
211
|
+
if (-not [string]::IsNullOrEmpty([string]$cmd.sessionId)) {
|
|
212
|
+
# 跳转会始终打开浏览器(功能恒开启):用指定浏览器(空 = 系统默认)打开 DSH 并定位会话。
|
|
213
|
+
$jumpBtn = New-ToastButton "跳转会话" ({
|
|
214
|
+
$url = "$($cmd.baseUrl)/#dsh-notify/session=$($cmd.sessionId)"
|
|
215
|
+
try {
|
|
216
|
+
if (-not [string]::IsNullOrEmpty([string]$cmd.browserPath)) {
|
|
217
|
+
Start-Process -FilePath ([string]$cmd.browserPath) -ArgumentList $url
|
|
218
|
+
} else {
|
|
219
|
+
Start-Process $url
|
|
220
|
+
}
|
|
221
|
+
} catch { }
|
|
222
|
+
Close-WithFade $win
|
|
223
|
+
}.GetNewClosure()) $true $fgBrush $bgBrush
|
|
224
|
+
$btnRow.Children.Add($jumpBtn) | Out-Null
|
|
225
|
+
}
|
|
226
|
+
$root.Children.Add($btnRow) | Out-Null
|
|
227
|
+
|
|
228
|
+
$win.Content = $border
|
|
229
|
+
|
|
230
|
+
# 右下角定位 + 滑入动画 + 淡入 + pos 回传(单 Loaded 处理器)。
|
|
231
|
+
# 关键顺序:先把窗口放到"起始位置"(目标下方 32px)且透明度 0,
|
|
232
|
+
# 再启动动画滑向目标并淡入——窗口从起始位置可见地滑入,而不是瞬间显示在终点。
|
|
233
|
+
$win.Add_Loaded({
|
|
234
|
+
$work = [System.Windows.SystemParameters]::WorkArea
|
|
235
|
+
$targetLeft = $work.Right - $win.ActualWidth - 20
|
|
236
|
+
$targetTop = $work.Bottom - $win.ActualHeight - 20 - [int]$cmd.offsetY
|
|
237
|
+
$win.Left = $targetLeft
|
|
238
|
+
$win.Top = $targetTop + 32
|
|
239
|
+
$win.Opacity = 0
|
|
240
|
+
try {
|
|
241
|
+
# 滑入 + 淡入(QuadraticEase EaseOut,260ms 快出缓停;淡入 180ms 先于位移结束)。
|
|
242
|
+
$easingIn = New-Object System.Windows.Media.Animation.QuadraticEase
|
|
243
|
+
$easingIn.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
|
|
244
|
+
$slideIn = New-Object System.Windows.Media.Animation.DoubleAnimation($targetTop + 32, $targetTop, [TimeSpan]::FromMilliseconds(260))
|
|
245
|
+
$slideIn.EasingFunction = $easingIn
|
|
246
|
+
$win.BeginAnimation([System.Windows.Window]::TopProperty, $slideIn)
|
|
247
|
+
$fadeIn = New-Object System.Windows.Media.Animation.DoubleAnimation(0, 1, [TimeSpan]::FromMilliseconds(180))
|
|
248
|
+
$fadeIn.EasingFunction = $easingIn
|
|
249
|
+
$win.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeIn)
|
|
250
|
+
# 保险:400ms 后若动画异常导致不可见/未到位,强制恢复(窗口绝不能隐形挂起)。
|
|
251
|
+
$guard = New-Object System.Windows.Threading.DispatcherTimer
|
|
252
|
+
$guard.Interval = [TimeSpan]::FromMilliseconds(400)
|
|
253
|
+
$guard.Tag = @{ w = $win; top = $targetTop }
|
|
254
|
+
$guard.Add_Tick({
|
|
255
|
+
$this.Stop()
|
|
256
|
+
$g = $this.Tag
|
|
257
|
+
try {
|
|
258
|
+
if ($g.w.Opacity -lt 0.9) { $g.w.Opacity = 1 }
|
|
259
|
+
if ($g.w.Top -ne $g.top) { $g.w.Top = $g.top }
|
|
260
|
+
} catch { }
|
|
261
|
+
}) | Out-Null
|
|
262
|
+
$guard.Start()
|
|
263
|
+
} catch {
|
|
264
|
+
$win.Opacity = 1
|
|
265
|
+
$win.Top = $targetTop
|
|
266
|
+
}
|
|
267
|
+
# 位置与句柄回传(workBottom 为准确屏幕底部基准,宿主用它做自适应堆叠)。
|
|
268
|
+
try {
|
|
269
|
+
$hwnd = [System.Windows.Interop.WindowInteropHelper]::new($win).Handle
|
|
270
|
+
Send-Report @{
|
|
271
|
+
type = 'pos'; key = [string]$cmd.key; instance = [int]$cmd.instance
|
|
272
|
+
left = [Math]::Round($targetLeft); top = [Math]::Round($targetTop)
|
|
273
|
+
height = [Math]::Round($win.ActualHeight); hwnd = $hwnd.ToInt64()
|
|
274
|
+
workBottom = [Math]::Round($work.Bottom)
|
|
275
|
+
}
|
|
276
|
+
} catch { }
|
|
277
|
+
}.GetNewClosure()) | Out-Null
|
|
278
|
+
|
|
279
|
+
# 关闭(任何途径)即注销并上报 exit(宿主侧唯一出口;宿主据此重排上方浮窗)。
|
|
280
|
+
# 注意:GetNewClosure 闭包里 $script: 解析不到原脚本作用域(为 $null),必须经
|
|
281
|
+
# 闭包捕获的函数局部引用访问注册表($toastsRef)。
|
|
282
|
+
$toastsRef = $script:toasts
|
|
283
|
+
$win.Add_Closed({
|
|
284
|
+
if ($toastsRef.ContainsKey([string]$cmd.key)) {
|
|
285
|
+
$rec = $toastsRef[[string]$cmd.key]
|
|
286
|
+
if ($rec.Timer -ne $null) { try { $rec.Timer.Stop() } catch { } }
|
|
287
|
+
$toastsRef.Remove([string]$cmd.key) | Out-Null
|
|
288
|
+
}
|
|
289
|
+
Send-Report @{ type = 'exit'; key = [string]$cmd.key; instance = [int]$cmd.instance }
|
|
290
|
+
}.GetNewClosure()) | Out-Null
|
|
291
|
+
|
|
292
|
+
# 注册
|
|
293
|
+
$rec = @{ Win = $win; Body = $bodyText; Timer = $null; Instance = [int]$cmd.instance }
|
|
294
|
+
$script:toasts[[string]$cmd.key] = $rec
|
|
295
|
+
|
|
296
|
+
# 非 sticky:定时自动消失(先淡出再关闭)。
|
|
297
|
+
if (-not ($cmd.sticky -eq $true)) {
|
|
298
|
+
$dur = [Math]::Max(1, [int]$cmd.durationSec)
|
|
299
|
+
$timer = New-Object System.Windows.Threading.DispatcherTimer
|
|
300
|
+
$timer.Interval = [TimeSpan]::FromSeconds($dur)
|
|
301
|
+
$timer.Add_Tick({ $this.Stop(); Close-WithFade $win }.GetNewClosure()) | Out-Null
|
|
302
|
+
$timer.Start()
|
|
303
|
+
$rec.Timer = $timer
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return $win
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# ── 命令处理(WPF 主线程 Dispatcher 上执行,窗口操作线程安全) ───────────────
|
|
310
|
+
function Invoke-Command([string]$line) {
|
|
311
|
+
$line = $line.Trim()
|
|
312
|
+
if ([string]::IsNullOrEmpty($line)) { return }
|
|
313
|
+
$cmd = $null
|
|
314
|
+
try { $cmd = $line | ConvertFrom-Json } catch { return }
|
|
315
|
+
if ($cmd -eq $null -or [string]::IsNullOrEmpty([string]$cmd.cmd)) { return }
|
|
316
|
+
switch ([string]$cmd.cmd) {
|
|
317
|
+
'show' {
|
|
318
|
+
# 防御:同 key 已存在(极端时序,如旧窗淡出中又来新 show)→ 立即关闭旧窗再建新窗。
|
|
319
|
+
if ($script:toasts.ContainsKey([string]$cmd.key)) {
|
|
320
|
+
$old = $script:toasts[[string]$cmd.key]
|
|
321
|
+
try { $old.Win.Close() } catch { }
|
|
322
|
+
$script:toasts.Remove([string]$cmd.key) | Out-Null
|
|
323
|
+
}
|
|
324
|
+
$w = New-ToastWindow $cmd
|
|
325
|
+
if ($w -ne $null) { $w.Show() }
|
|
326
|
+
}
|
|
327
|
+
'text' {
|
|
328
|
+
$rec = $script:toasts[[string]$cmd.key]
|
|
329
|
+
if ($rec -ne $null -and $rec.Body -ne $null) { $rec.Body.Text = [string]$cmd.text }
|
|
330
|
+
}
|
|
331
|
+
'move' {
|
|
332
|
+
$rec = $script:toasts[[string]$cmd.key]
|
|
333
|
+
if ($rec -ne $null) {
|
|
334
|
+
$top = [int]$cmd.top
|
|
335
|
+
$slide = New-Object System.Windows.Media.Animation.DoubleAnimation($rec.Win.Top, $top, [TimeSpan]::FromMilliseconds(220))
|
|
336
|
+
$slide.EasingFunction = $easeMove
|
|
337
|
+
$rec.Win.BeginAnimation([System.Windows.Window]::TopProperty, $slide)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
'close' {
|
|
341
|
+
$rec = $script:toasts[[string]$cmd.key]
|
|
342
|
+
if ($rec -ne $null) { Close-WithFade $rec.Win }
|
|
343
|
+
}
|
|
344
|
+
'shutdown' { $script:exitRequested = $true }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# ── stdin 读取:独立 runspace 线程(阻塞 ReadLine 不卡 WPF 主线程) ───────────
|
|
349
|
+
$script:cmdQueue = New-Object 'System.Collections.Concurrent.ConcurrentQueue[string]'
|
|
350
|
+
$readerScript = {
|
|
351
|
+
param($q)
|
|
352
|
+
try {
|
|
353
|
+
$reader = New-Object System.IO.StreamReader([System.Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
|
|
354
|
+
while ($true) {
|
|
355
|
+
$line = $reader.ReadLine()
|
|
356
|
+
if ($null -eq $line) { break }
|
|
357
|
+
$q.Enqueue($line)
|
|
358
|
+
}
|
|
359
|
+
} catch { }
|
|
360
|
+
# stdin 关闭 → 通知主线程优雅退出。
|
|
361
|
+
$q.Enqueue('{"cmd":"shutdown"}')
|
|
362
|
+
}
|
|
363
|
+
$readerRs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
|
|
364
|
+
$readerRs.Open()
|
|
365
|
+
$readerPs = [System.Management.Automation.PowerShell]::Create()
|
|
366
|
+
$readerPs.Runspace = $readerRs
|
|
367
|
+
$readerPs.AddScript($readerScript).AddArgument($script:cmdQueue) | Out-Null
|
|
368
|
+
$readerPs.BeginInvoke() | Out-Null
|
|
369
|
+
|
|
370
|
+
# ── 主循环:50ms 排空命令队列;收到 shutdown 后关闭全部窗口并退出 ─────────────
|
|
371
|
+
$cmdTimer = New-Object System.Windows.Threading.DispatcherTimer
|
|
372
|
+
$cmdTimer.Interval = [TimeSpan]::FromMilliseconds(50)
|
|
373
|
+
$cmdTimer.Add_Tick({
|
|
374
|
+
try {
|
|
375
|
+
$line = $null
|
|
376
|
+
while ($script:cmdQueue.TryDequeue([ref]$line)) {
|
|
377
|
+
if (-not [string]::IsNullOrEmpty($line)) { Invoke-Command $line }
|
|
378
|
+
if ($script:exitRequested) { break }
|
|
379
|
+
}
|
|
380
|
+
if ($script:exitRequested) {
|
|
381
|
+
$this.Stop()
|
|
382
|
+
foreach ($key in @($script:toasts.Keys)) {
|
|
383
|
+
try { $script:toasts[$key].Win.Close() } catch { }
|
|
384
|
+
}
|
|
385
|
+
[System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown()
|
|
386
|
+
}
|
|
387
|
+
} catch { }
|
|
388
|
+
}) | Out-Null
|
|
389
|
+
$cmdTimer.Start()
|
|
390
|
+
|
|
391
|
+
[System.Windows.Threading.Dispatcher]::Run()
|
|
392
|
+
try { $readerPs.Dispose() } catch { }
|
|
393
|
+
try { $readerRs.Dispose() } catch { }
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify 的 Host 面 Typert 清单(由 typert-loader 自动扫描注册)。
|
|
3
|
+
* 手写清单,结构与 @deepseek-ai/dsh-typert-generator 产物一致:
|
|
4
|
+
* `./typert` 导出 TYPERT,invocations 的 codec 必须是 zod v4 实例。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
|
|
9
|
+
const kindColorSchema = z.object({
|
|
10
|
+
bg: z.string(),
|
|
11
|
+
fg: z.string(),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const configSchema = z.object({
|
|
15
|
+
enabled: z.boolean(),
|
|
16
|
+
mode: z.enum(['system', 'custom', 'off']),
|
|
17
|
+
maxVisible: z.number(),
|
|
18
|
+
durationMs: z.number(),
|
|
19
|
+
browser: z.string(),
|
|
20
|
+
bgColor: z.string(),
|
|
21
|
+
textColor: z.string(),
|
|
22
|
+
colors: z.object({
|
|
23
|
+
completed: kindColorSchema,
|
|
24
|
+
error: kindColorSchema,
|
|
25
|
+
running: kindColorSchema,
|
|
26
|
+
approval: kindColorSchema,
|
|
27
|
+
question: kindColorSchema,
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const notifyEventSchema = z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
sessionId: z.string(),
|
|
34
|
+
title: z.string(),
|
|
35
|
+
kind: z.enum(['completed', 'error', 'running', 'approval', 'question']),
|
|
36
|
+
text: z.string(),
|
|
37
|
+
activity: z.string().optional(),
|
|
38
|
+
at: z.number(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const getStateSchema = z.object({
|
|
42
|
+
config: configSchema,
|
|
43
|
+
events: z.array(notifyEventSchema),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const updateConfigSchema = z.object({
|
|
47
|
+
config: configSchema,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const ackSchema = z.object({
|
|
51
|
+
ok: z.boolean(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const browserSchema = z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
name: z.string(),
|
|
57
|
+
path: z.string(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const listBrowsersSchema = z.object({
|
|
61
|
+
browsers: z.array(browserSchema),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const patchSchema = z.record(z.string(), z.unknown())
|
|
65
|
+
|
|
66
|
+
const _state$codec = { mode: 'strict', typeSymbol: 'dsh-notify#NotifyState', schema: getStateSchema }
|
|
67
|
+
const _config$codec = { mode: 'strict', typeSymbol: 'dsh-notify#NotifyConfig', schema: configSchema }
|
|
68
|
+
const _patch$codec = { mode: 'strict', typeSymbol: 'dsh-notify#NotifyConfigPatch', schema: patchSchema }
|
|
69
|
+
const _ack$codec = { mode: 'strict', typeSymbol: 'dsh-notify#AckResult', schema: ackSchema }
|
|
70
|
+
const _browsers$codec = { mode: 'strict', typeSymbol: 'dsh-notify#BrowserList', schema: listBrowsersSchema }
|
|
71
|
+
|
|
72
|
+
export const TYPERT = {
|
|
73
|
+
package: 'dsh-notify',
|
|
74
|
+
face: 'host',
|
|
75
|
+
schemas: [],
|
|
76
|
+
invocations: [
|
|
77
|
+
{
|
|
78
|
+
id: 'dsh-notify#notify/getState',
|
|
79
|
+
service: 'notify',
|
|
80
|
+
namespace: 'notify',
|
|
81
|
+
method: 'getState',
|
|
82
|
+
invocation: { kind: 'direct' },
|
|
83
|
+
parameters: [],
|
|
84
|
+
result: _state$codec,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'dsh-notify#notify/updateConfig',
|
|
88
|
+
service: 'notify',
|
|
89
|
+
namespace: 'notify',
|
|
90
|
+
method: 'updateConfig',
|
|
91
|
+
invocation: { kind: 'direct' },
|
|
92
|
+
parameters: [
|
|
93
|
+
{ name: 'patch', wire: 'patch', source: 'json', codec: _patch$codec },
|
|
94
|
+
],
|
|
95
|
+
result: _config$codec,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'dsh-notify#notify/ackEvents',
|
|
99
|
+
service: 'notify',
|
|
100
|
+
namespace: 'notify',
|
|
101
|
+
method: 'ackEvents',
|
|
102
|
+
invocation: { kind: 'direct' },
|
|
103
|
+
parameters: [
|
|
104
|
+
{ name: 'ids', wire: 'ids', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-notify#EventIds', schema: z.array(z.string()) } },
|
|
105
|
+
],
|
|
106
|
+
result: _ack$codec,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'dsh-notify#notify/listBrowsers',
|
|
110
|
+
service: 'notify',
|
|
111
|
+
namespace: 'notify',
|
|
112
|
+
method: 'listBrowsers',
|
|
113
|
+
invocation: { kind: 'direct' },
|
|
114
|
+
parameters: [],
|
|
115
|
+
result: _browsers$codec,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
model: {
|
|
119
|
+
services: [
|
|
120
|
+
{
|
|
121
|
+
description: 'dsh-notify 通知服务(ctx.notify):插件配置与通知事件队列。Notification config and event queue service (ctx.notify).',
|
|
122
|
+
summary: 'dsh-notify 通知配置与事件队列服务 (dsh-notify notification config & event queue service)。',
|
|
123
|
+
tags: [],
|
|
124
|
+
jsDoc: '/** dsh-notify 通知配置与事件队列服务(ctx.notify)。dsh-notify notification config & event queue service (ctx.notify). */',
|
|
125
|
+
key: 'notify',
|
|
126
|
+
exportName: 'NotifyService',
|
|
127
|
+
members: [
|
|
128
|
+
{
|
|
129
|
+
kind: 'method',
|
|
130
|
+
name: 'getState',
|
|
131
|
+
signature: 'getState(): NotifyState',
|
|
132
|
+
summary: '读取当前配置与全部未确认通知事件。Read the current config and all unacknowledged notification events.',
|
|
133
|
+
jsDoc: '/**\n * 读取当前配置与全部未确认通知事件。\n * @returns 配置与未确认事件列表。\n * Read the current config and all unacknowledged notification events.\n * @returns The config and unacknowledged events.\n */',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
kind: 'method',
|
|
137
|
+
name: 'updateConfig',
|
|
138
|
+
signature: 'updateConfig(patch: NotifyConfigPatch): NotifyConfig',
|
|
139
|
+
summary: '深合并一份配置补丁并持久化。Deep-merge a config patch and persist it.',
|
|
140
|
+
jsDoc: '/**\n * 深合并一份配置补丁并持久化。\n * @param patch - 配置补丁。\n * @returns 更新后的配置。\n * Deep-merge a config patch and persist it.\n * @param patch - The config patch.\n * @returns The updated config.\n */',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
kind: 'method',
|
|
144
|
+
name: 'ackEvents',
|
|
145
|
+
signature: 'ackEvents(ids: string[]): AckResult',
|
|
146
|
+
summary: '回执已消费的事件 id,防止重复提醒。Acknowledge consumed event ids to prevent duplicate alerts.',
|
|
147
|
+
jsDoc: '/**\n * 回执已消费的事件 id,从队列移除。\n * @param ids - 已展示的事件 id 列表。\n * @returns 回执结果。\n * Acknowledge consumed event ids, removing them from the queue.\n * @param ids - Ids of events already shown.\n * @returns The acknowledgement result.\n */',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
types: [],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
events: [],
|
|
154
|
+
objects: [],
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default TYPERT
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-notify-yimit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness 通知插件:任务完成 / 任务出错 / 运行中(实时活动)/ 等待审批 / 等待回答 时,通过 Windows 系统通知或插件自定义样式通知提醒用户,支持点击跳转到对应会话;设置页集成在 DSH 设置界面。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./typert": {
|
|
15
|
+
"default": "./lib/typert.host.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/index.js",
|
|
29
|
+
"lib/typert.host.js",
|
|
30
|
+
"lib/client.js",
|
|
31
|
+
"lib/toast-host.ps1",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"README.md",
|
|
34
|
+
"README.en.md"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
|
|
38
|
+
"zod": "^4.4.3"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"deepseek",
|
|
42
|
+
"deepseek-harness",
|
|
43
|
+
"dsh",
|
|
44
|
+
"dsh-plugin",
|
|
45
|
+
"client-plugin",
|
|
46
|
+
"notify",
|
|
47
|
+
"notification",
|
|
48
|
+
"toast"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT"
|
|
51
|
+
}
|