dsh-notify-yimit 0.1.2 → 0.1.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,393 +1,560 @@
1
- <#
2
- dsh-notify-yimit 常驻浮窗宿主(Windows PowerShell + WPF)。
3
- 由 dsh-notify-yimit 宿主插件在加载时 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-yimit-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
-
1
+ <# dsh-notify-yimit 常驻浮窗宿主(Windows PowerShell + WPF)。
2
+ 优化点:JSON序列化深度增加、Tag闭包变量传递优化、BeginInvoke资源回收、接收外部Label参数实现多语言。 #>
24
3
  Add-Type -AssemblyName PresentationFramework
25
4
  Add-Type -AssemblyName PresentationCore
26
5
  Add-Type -AssemblyName WindowsBase
27
6
 
28
- # WPF 必须运行在 STA 主线程(Windows PowerShell 5.1 控制台主线程为 STA)。
29
7
  if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne [System.Threading.ApartmentState]::STA) {
30
- [Console]::Error.WriteLine("dsh-notify-yimit: 宿主需要 STA 主线程(当前 $([System.Threading.Thread]::CurrentThread.ApartmentState))")
31
- exit 1
8
+ [Console]::Error.WriteLine("dsh-notify-yimit: Host requires STA main thread (current $([System.Threading.Thread]::CurrentThread.ApartmentState))")
9
+ exit 1
32
10
  }
33
11
 
34
- # ── 协议输出:UTF-8 无 BOM(管道传中文必须固定编码,否则乱码) ────────────────
35
12
  $script:utf8 = New-Object System.Text.UTF8Encoding($false)
36
13
  $script:stdout = New-Object System.IO.StreamWriter([System.Console]::OpenStandardOutput(), $script:utf8)
37
14
  $script:stdout.AutoFlush = $true
38
15
 
39
16
  function Send-Report([object]$obj) {
40
- try { $script:stdout.WriteLine(($obj | ConvertTo-Json -Compress)) } catch { }
17
+ try {
18
+ $script:stdout.WriteLine(($obj | ConvertTo-Json -Compress -Depth 10))
19
+ } catch { }
20
+ }
21
+
22
+ # 编辑模式几何回传:样板窗当前 left/top/width/height(工作区相对坐标)。
23
+ function Send-Geometry($w) {
24
+ try {
25
+ $work = [System.Windows.SystemParameters]::WorkArea
26
+ Send-Report @{
27
+ type = 'geometry'
28
+ left = [Math]::Round($w.Left - $work.Left)
29
+ top = [Math]::Round($w.Top - $work.Top)
30
+ width = [Math]::Round($w.ActualWidth)
31
+ height = [Math]::Round($w.ActualHeight)
32
+ }
33
+ } catch { }
34
+ }
35
+
36
+ # 编辑模式样板窗:拖拽移动 + 右下角手柄缩放;拖拽/缩放结束后回传 geometry。
37
+ # 位置与宽度写回配置后,所有后续浮窗按新几何弹出。
38
+ function New-EditSample($cmd) {
39
+ $bg = Parse-Color ([string]$cmd.bg) "#203a5c"
40
+ $fg = Parse-Color ([string]$cmd.fg) "#e8f0fb"
41
+ $bgBrush = New-Object System.Windows.Media.SolidColorBrush($bg)
42
+ $fgBrush = New-Object System.Windows.Media.SolidColorBrush($fg)
43
+
44
+ $win = New-Object System.Windows.Window
45
+ $win.WindowStyle = [System.Windows.WindowStyle]::None
46
+ $win.AllowsTransparency = $true
47
+ $win.Background = [System.Windows.Media.Brushes]::Transparent
48
+ $win.Topmost = $true
49
+ $win.ShowInTaskbar = $false
50
+ if ($null -ne $cmd.width) { $win.Width = [double]$cmd.width } else { $win.Width = 340 }
51
+ $win.SizeToContent = [System.Windows.SizeToContent]::Height
52
+
53
+ $border = New-Object System.Windows.Controls.Border
54
+ $border.Background = $bgBrush
55
+ $border.BorderBrush = $fgBrush
56
+ $border.BorderThickness = New-Object System.Windows.Thickness(1)
57
+ $border.CornerRadius = New-Object System.Windows.CornerRadius(12)
58
+ $border.Margin = New-Object System.Windows.Thickness(12)
59
+
60
+ $root = New-Object System.Windows.Controls.Grid
61
+ $root.Margin = New-Object System.Windows.Thickness(14,12,14,12)
62
+ $border.Child = $root
63
+
64
+ $stack = New-Object System.Windows.Controls.StackPanel
65
+ $titleText = New-Object System.Windows.Controls.TextBlock
66
+ $titleText.Text = if ([string]::IsNullOrEmpty([string]$cmd.title)) { "Notification preview" } else { [string]$cmd.title }
67
+ $titleText.Foreground = $fgBrush
68
+ $titleText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
69
+ $titleText.FontSize = 13
70
+ $titleText.FontWeight = [System.Windows.FontWeights]::SemiBold
71
+ $titleText.Margin = New-Object System.Windows.Thickness(0,0,0,6)
72
+ $stack.Children.Add($titleText) | Out-Null
73
+ $bodyText = New-Object System.Windows.Controls.TextBlock
74
+ $bodyText.Text = "拖动移动 · 拖拽窗口左右边缘调整宽度 · 完成后在设置页点「完成编辑」"
75
+ $bodyText.Foreground = $fgBrush
76
+ $bodyText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
77
+ $bodyText.FontSize = 12
78
+ $bodyText.TextWrapping = [System.Windows.TextWrapping]::Wrap
79
+ $stack.Children.Add($bodyText) | Out-Null
80
+ $root.Children.Add($stack) | Out-Null
81
+
82
+ # 左右边缘缩放条(像 Windows 窗口一样拖拽边缘调宽度):8px 宽、通高,
83
+ # 半透明文字色填充提示可拖拽区域,光标为左右双向箭头。
84
+ $edgeBrush = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromArgb(38, $fg.R, $fg.G, $fg.B))
85
+ $leftStrip = New-Object System.Windows.Controls.Border
86
+ $leftStrip.Width = 8
87
+ $leftStrip.Cursor = [System.Windows.Input.Cursors]::SizeWE
88
+ $leftStrip.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Left
89
+ $leftStrip.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
90
+ $leftStrip.Background = $edgeBrush
91
+ $leftStrip.CornerRadius = New-Object System.Windows.CornerRadius(12,0,0,12)
92
+ $rightStrip = New-Object System.Windows.Controls.Border
93
+ $rightStrip.Width = 8
94
+ $rightStrip.Cursor = [System.Windows.Input.Cursors]::SizeWE
95
+ $rightStrip.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
96
+ $rightStrip.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
97
+ $rightStrip.Background = $edgeBrush
98
+ $rightStrip.CornerRadius = New-Object System.Windows.CornerRadius(0,12,12,0)
99
+
100
+ # 外层 Grid:边框 + 左右缩放条叠放(缩放条覆盖窗口左右边缘)
101
+ $outer = New-Object System.Windows.Controls.Grid
102
+ $outer.Children.Add($border) | Out-Null
103
+ $outer.Children.Add($leftStrip) | Out-Null
104
+ $outer.Children.Add($rightStrip) | Out-Null
105
+
106
+ # 共享拖拽/缩放状态(事件回调看不到函数局部变量,放 Tag)
107
+ $win.Tag = @{ resizing = $false; startX = 0.0; startWidth = 0.0; startLeft = 0.0; edge = 'right' }
108
+
109
+ $win.Add_Loaded({
110
+ $work = [System.Windows.SystemParameters]::WorkArea
111
+ $pos = $cmd.position
112
+ if ($null -ne $pos -and $null -ne $pos.left -and $null -ne $pos.top) {
113
+ $win.Left = [double]$pos.left + $work.Left
114
+ $win.Top = [double]$pos.top + $work.Top
115
+ } else {
116
+ $win.Left = $work.Right - $win.ActualWidth - 20
117
+ $win.Top = $work.Bottom - $win.ActualHeight - 20
118
+ }
119
+ Send-Geometry $win
120
+ }.GetNewClosure()) | Out-Null
121
+
122
+ # 拖拽移动(缩放条区域除外):DragMove 阻塞至松开,结束后收敛到工作区内并回传几何。
123
+ $win.Add_MouseLeftButtonDown({
124
+ if ($win.Tag.resizing) { $win.Tag.resizing = $false; return }
125
+ try {
126
+ $win.DragMove()
127
+ } catch { }
128
+ try {
129
+ $work = [System.Windows.SystemParameters]::WorkArea
130
+ $win.Left = [Math]::Max($work.Left, [Math]::Min($win.Left, $work.Right - $win.ActualWidth))
131
+ $win.Top = [Math]::Max($work.Top, [Math]::Min($win.Top, $work.Bottom - $win.ActualHeight))
132
+ } catch { }
133
+ Send-Geometry $win
134
+ }.GetNewClosure()) | Out-Null
135
+
136
+ # 拖拽边缘缩放宽度(260–520):右缘拖右变宽;左缘拖左变宽且右缘固定(窗口左移)。
137
+ # 注意:$strip/$edge/$win 都作为函数参数,闭包才能可靠捕获。
138
+ function Add-EdgeResize($strip, [string]$edge, $w) {
139
+ $strip.Add_MouseLeftButtonDown({
140
+ $w.Tag.resizing = $true
141
+ $w.Tag.edge = $edge
142
+ $w.Tag.startX = $_.GetPosition($w).X
143
+ $w.Tag.startWidth = $w.ActualWidth
144
+ $w.Tag.startLeft = $w.Left
145
+ $strip.CaptureMouse()
146
+ $_.Handled = $true
147
+ }.GetNewClosure()) | Out-Null
148
+ $strip.Add_MouseMove({
149
+ if ($w.Tag.resizing) {
150
+ $dx = $_.GetPosition($w).X - $w.Tag.startX
151
+ if ($w.Tag.edge -eq 'left') {
152
+ $nw = [Math]::Max(260, [Math]::Min(520, [Math]::Round($w.Tag.startWidth - $dx)))
153
+ $w.Width = $nw
154
+ $w.Left = $w.Tag.startLeft + ($w.Tag.startWidth - $w.Width)
155
+ } else {
156
+ $nw = [Math]::Max(260, [Math]::Min(520, [Math]::Round($w.Tag.startWidth + $dx)))
157
+ $w.Width = $nw
158
+ }
159
+ $_.Handled = $true
160
+ }
161
+ }.GetNewClosure()) | Out-Null
162
+ $strip.Add_MouseLeftButtonUp({
163
+ if ($w.Tag.resizing) {
164
+ $w.Tag.resizing = $false
165
+ if ($strip.IsMouseCaptured) { $strip.ReleaseMouseCapture() }
166
+ Send-Geometry $w
167
+ $_.Handled = $true
168
+ }
169
+ }.GetNewClosure()) | Out-Null
170
+ }
171
+ Add-EdgeResize $leftStrip 'left' $win
172
+ Add-EdgeResize $rightStrip 'right' $win
173
+
174
+ $win.Add_Closed({
175
+ if ($script:editSample -eq $win) { $script:editSample = $null }
176
+ }.GetNewClosure()) | Out-Null
177
+
178
+ $win.Content = $outer
179
+ return $win
41
180
  }
42
181
 
43
- # 浮窗注册表(key → @{ Win; Body; Timer; Instance }),只在 WPF 主线程访问。
44
182
  $script:toasts = @{}
45
183
  $script:exitRequested = $false
184
+ # 编辑模式样板窗(不进入 toasts 注册表,不参与堆叠;edit-end 时关闭)。
185
+ $script:editSample = $null
46
186
 
47
- # 颜色解析:#RGB / #RRGGBB / #AARRGGBB → Windows.Media.Color
48
187
  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))
188
+ $h = $hex.TrimStart('#')
189
+ if ($h.Length -eq 3) { $h = ($h.ToCharArray() | ForEach-Object { "$_$_" }) -join '' }
190
+ if ($h.Length -eq 6) { $h = "FF$h" }
191
+ if ($h.Length -ne 8 -or $h -notmatch '^[0-9a-fA-F]{8}$') {
192
+ # 非法/缺失输入:改用回退色并同样做 3/6 位展开 + 补 FF(此前 FF 步骤在回退赋值之前,
193
+ # 回退为 6 位时未补前缀,Substring 越界抛异常)。
194
+ $h = $fallback.TrimStart('#')
195
+ if ($h.Length -eq 3) { $h = ($h.ToCharArray() | ForEach-Object { "$_$_" }) -join '' }
196
+ if ($h.Length -eq 6) { $h = "FF$h" }
197
+ }
198
+ return [System.Windows.Media.Color]::FromArgb(
199
+ [Convert]::ToInt32($h.Substring(0,2), 16),
200
+ [Convert]::ToInt32($h.Substring(2,2), 16),
201
+ [Convert]::ToInt32($h.Substring(4,2), 16),
202
+ [Convert]::ToInt32($h.Substring(6,2), 16))
58
203
  }
59
204
 
60
- # 按钮用 Border 直接实现:标准圆角矩形(10px)。忽略 = 透明底 + 细边框(fg 色);
61
- # 跳转 = 浅色实心底 + 主色文字。悬停/按压用不透明压暗色反馈(避免透明窗口闪烁)。
62
205
  $downP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(158,164,170))
63
206
  $downI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(30,36,44))
64
207
  $hoverP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(196,202,208))
65
208
  $hoverI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(44,52,62))
66
209
 
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
210
+ function New-ToastButton([string]$label, [scriptblock]$onClick, [bool]$primary, [System.Windows.Media.SolidColorBrush]$fg, [System.Windows.Media.SolidColorBrush]$bg) {
211
+ $bd = New-Object System.Windows.Controls.Border
212
+ $bd.CornerRadius = New-Object System.Windows.CornerRadius(10)
213
+ $bd.Padding = New-Object System.Windows.Thickness(18,7,18,7)
214
+ $bd.Margin = New-Object System.Windows.Thickness(0,0,8,0)
215
+ $bd.Cursor = [System.Windows.Input.Cursors]::Hand
216
+ $bd.Focusable = $false
217
+
218
+ $txt = New-Object System.Windows.Controls.TextBlock
219
+ $txt.Text = $label
220
+ $txt.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
221
+ $txt.FontSize = 12
222
+ $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
223
+ $txt.IsHitTestVisible = $false
224
+
225
+ if ($primary) {
226
+ $bd.Background = $fg
227
+ $txt.Foreground = $bg
228
+ $bd.BorderBrush = [System.Windows.Media.Brushes]::Transparent
229
+ $bd.BorderThickness = New-Object System.Windows.Thickness(0)
230
+ } else {
231
+ $bd.Background = [System.Windows.Media.Brushes]::Transparent
232
+ $txt.Foreground = $fg
233
+ $bd.BorderBrush = $fg
234
+ $bd.BorderThickness = New-Object System.Windows.Thickness(1)
235
+ }
236
+ $bd.Child = $txt
237
+
238
+ $bd.Tag = @{ primary = $primary; onClick = $onClick; hoverP = $hoverP; hoverI = $hoverI; downP = $downP; downI = $downI; fg = $fg; bg = $bg }
239
+
240
+ $bd.Add_MouseEnter({
241
+ $info = $this.Tag
242
+ $this.Background = if ($info.primary) { $info.hoverP } else { $info.hoverI }
243
+ }) | Out-Null
244
+
245
+ $bd.Add_MouseLeave({
246
+ $info = $this.Tag
247
+ $this.Background = if ($info.primary) { $info.fg } else { [System.Windows.Media.Brushes]::Transparent }
248
+ }) | Out-Null
249
+
250
+ $bd.Add_MouseLeftButtonDown({
251
+ $info = $this.Tag
252
+ $this.Background = if ($info.primary) { $info.downP } else { $info.downI }
253
+ }) | Out-Null
254
+
255
+ $bd.Add_MouseLeftButtonUp({
256
+ $info = $this.Tag
257
+ try { if ($info.onClick -ne $null) { & $info.onClick } } catch { }
258
+ $this.Background = if ($info.primary) { $info.fg } else { [System.Windows.Media.Brushes]::Transparent }
259
+ }) | Out-Null
260
+
261
+ return $bd
114
262
  }
115
263
 
116
- # 动画缓动(脚本级,供各处复用)。
117
- # 注意:PS 5.1 的 New-Object 无法用枚举参数调构造器(会报 "overload ... argument count: 1"),
118
- # 必须默认构造后设置 EasingMode 属性。
119
264
  $easeMove = New-Object System.Windows.Media.Animation.QuadraticEase
120
265
  $easeMove.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
121
266
  $easeExit = New-Object System.Windows.Media.Animation.CubicEase
122
267
  $easeExit.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseIn
123
268
 
124
- # 淡出关闭:160ms EaseIn 淡出 + 20px 下坠,动画完成后 Close(Closed 事件上报 exit)。
125
- # 防重入:$w.Tag='closing' 只执行一次(忽略按钮 + close 命令 + 定时器可能先后触发);
126
- # 600ms 硬性兜底,确保即使动画/定时器异常也强制关闭(绝不挂起)。
127
269
  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
- }
270
+ try {
271
+ if ($w.Tag -eq 'closing') { return }
272
+ $w.Tag = 'closing'
273
+ $fadeOut = New-Object System.Windows.Media.Animation.DoubleAnimation(1, 0, [TimeSpan]::FromMilliseconds(160))
274
+ $fadeOut.EasingFunction = $easeExit
275
+ $fadeOut.Add_Completed({ try { $w.Close() } catch { } }.GetNewClosure()) | Out-Null
276
+ $w.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeOut)
277
+
278
+ $dropAn = New-Object System.Windows.Media.Animation.DoubleAnimation($w.Top, ($w.Top + 20), [TimeSpan]::FromMilliseconds(160))
279
+ $dropAn.EasingFunction = $easeExit
280
+ $w.BeginAnimation([System.Windows.Window]::TopProperty, $dropAn)
281
+
282
+ $backstop = New-Object System.Windows.Threading.DispatcherTimer
283
+ $backstop.Interval = [TimeSpan]::FromMilliseconds(600)
284
+ $backstop.Tag = $w
285
+ $backstop.Add_Tick({
286
+ $this.Stop()
287
+ try { if ($this.Tag.IsVisible) { $this.Tag.Close() } } catch { }
288
+ }) | Out-Null
289
+ $backstop.Start()
290
+ } catch {
291
+ try { $w.Close() } catch { }
292
+ }
150
293
  }
151
294
 
152
- # 建窗:按 show 命令参数创建窗口;返回 Window(由调用方 Show() 显示)。
153
295
  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-yimit/session=$($cmd.sessionId)"
215
- try {
216
- if (-not [string]::IsNullOrEmpty([string]$cmd.browserPath)) {
217
- Start-Process -FilePath ([string]$cmd.browserPath) -ArgumentList $url
296
+ $bg = Parse-Color ([string]$cmd.bg) "#20272f"
297
+ $fg = Parse-Color ([string]$cmd.fg) "#e6edf3"
298
+ $bgBrush = New-Object System.Windows.Media.SolidColorBrush($bg)
299
+ $fgBrush = New-Object System.Windows.Media.SolidColorBrush($fg)
300
+
301
+ $win = New-Object System.Windows.Window
302
+ $win.WindowStyle = [System.Windows.WindowStyle]::None
303
+ $win.AllowsTransparency = $true
304
+ $win.Background = [System.Windows.Media.Brushes]::Transparent
305
+ $win.Topmost = $true
306
+ $win.ShowInTaskbar = $false
307
+ # 通知窗口样式:宽度来自配置(默认 340);高度自适应内容。
308
+ if ($null -ne $cmd.width) { $win.Width = [double]$cmd.width } else { $win.Width = 340 }
309
+ $win.SizeToContent = [System.Windows.SizeToContent]::Height
310
+ if (-not [string]::IsNullOrEmpty([string]$cmd.winTitle)) { $win.Title = [string]$cmd.winTitle }
311
+
312
+ $border = New-Object System.Windows.Controls.Border
313
+ $border.Background = $bgBrush
314
+ $border.BorderBrush = $fgBrush
315
+ $border.BorderThickness = New-Object System.Windows.Thickness(1)
316
+ $border.CornerRadius = New-Object System.Windows.CornerRadius(12)
317
+ $border.Margin = New-Object System.Windows.Thickness(12)
318
+
319
+ $root = New-Object System.Windows.Controls.StackPanel
320
+ $root.Margin = New-Object System.Windows.Thickness(14,12,14,12)
321
+ $border.Child = $root
322
+
323
+ # 优化:支持多语言,接收传入的 label,如果没有则使用后备英文
324
+ $unnamedLabel = if ([string]::IsNullOrEmpty([string]$cmd.unnamedLabel)) { "(Unnamed session)" } else { [string]$cmd.unnamedLabel }
325
+
326
+ $titleText = New-Object System.Windows.Controls.TextBlock
327
+ $titleText.Text = if ([string]::IsNullOrEmpty([string]$cmd.title)) { $unnamedLabel } else { [string]$cmd.title }
328
+ $titleText.Foreground = $fgBrush
329
+ $titleText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
330
+ $titleText.FontSize = 13
331
+ $titleText.FontWeight = [System.Windows.FontWeights]::SemiBold
332
+ $titleText.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis
333
+ $titleText.Margin = New-Object System.Windows.Thickness(0,0,0,6)
334
+ $root.Children.Add($titleText) | Out-Null
335
+
336
+ $bodyText = New-Object System.Windows.Controls.TextBlock
337
+ $bodyText.Text = [string]$cmd.text
338
+ $bodyText.Foreground = $fgBrush
339
+ $bodyText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
340
+ $bodyText.FontSize = 13
341
+ $bodyText.TextWrapping = [System.Windows.TextWrapping]::Wrap
342
+ $bodyText.Margin = New-Object System.Windows.Thickness(0,0,0,10)
343
+ $root.Children.Add($bodyText) | Out-Null
344
+
345
+ $btnRow = New-Object System.Windows.Controls.StackPanel
346
+ $btnRow.Orientation = [System.Windows.Controls.Orientation]::Horizontal
347
+ $btnRow.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
348
+
349
+ $ignoreLabel = if ([string]::IsNullOrEmpty([string]$cmd.ignoreLabel)) { "Ignore" } else { [string]$cmd.ignoreLabel }
350
+ $ignoreBtn = New-ToastButton $ignoreLabel ({ Close-WithFade $win }.GetNewClosure()) $false $fgBrush $bgBrush
351
+ $btnRow.Children.Add($ignoreBtn) | Out-Null
352
+
353
+ if (-not [string]::IsNullOrEmpty([string]$cmd.sessionId)) {
354
+ $jumpLabel = if ([string]::IsNullOrEmpty([string]$cmd.jumpLabel)) { "Open" } else { [string]$cmd.jumpLabel }
355
+ $jumpBtn = New-ToastButton $jumpLabel ({
356
+ $url = "$($cmd.baseUrl)/#dsh-notify-yimit/session=$($cmd.sessionId)"
357
+ try {
358
+ if (-not [string]::IsNullOrEmpty([string]$cmd.browserPath)) {
359
+ Start-Process -FilePath ([string]$cmd.browserPath) -ArgumentList $url
360
+ } else { Start-Process $url }
361
+ } catch { }
362
+ Close-WithFade $win
363
+ }.GetNewClosure()) $true $fgBrush $bgBrush
364
+ $btnRow.Children.Add($jumpBtn) | Out-Null
365
+ }
366
+ $root.Children.Add($btnRow) | Out-Null
367
+ $win.Content = $border
368
+
369
+ $win.Add_Loaded({
370
+ $work = [System.Windows.SystemParameters]::WorkArea
371
+ # 通知窗口位置:配置了自定义位置(position)则按其坐标(工作区相对)+ 堆叠偏移;
372
+ # 否则默认右下贴边(右/下留边 20)。
373
+ $pos = $cmd.position
374
+ if ($null -ne $pos -and $null -ne $pos.left -and $null -ne $pos.top) {
375
+ $targetLeft = [double]$pos.left + $work.Left
376
+ $targetTop = [double]$pos.top + $work.Top - [int]$cmd.offsetY
218
377
  } else {
219
- Start-Process $url
378
+ $targetLeft = $work.Right - $win.ActualWidth - 20
379
+ $targetTop = $work.Bottom - $win.ActualHeight - 20 - [int]$cmd.offsetY
220
380
  }
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
381
+ $win.Left = $targetLeft
382
+ $win.Top = $targetTop + 32
383
+ $win.Opacity = 0
257
384
  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 }
385
+ $easingIn = New-Object System.Windows.Media.Animation.QuadraticEase
386
+ $easingIn.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
387
+ $slideIn = New-Object System.Windows.Media.Animation.DoubleAnimation($targetTop + 32, $targetTop, [TimeSpan]::FromMilliseconds(260))
388
+ $slideIn.EasingFunction = $easingIn
389
+ $win.BeginAnimation([System.Windows.Window]::TopProperty, $slideIn)
390
+ $fadeIn = New-Object System.Windows.Media.Animation.DoubleAnimation(0, 1, [TimeSpan]::FromMilliseconds(180))
391
+ $fadeIn.EasingFunction = $easingIn
392
+ $win.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeIn)
393
+
394
+ $guard = New-Object System.Windows.Threading.DispatcherTimer
395
+ $guard.Interval = [TimeSpan]::FromMilliseconds(400)
396
+ $guard.Tag = @{ w = $win; top = $targetTop }
397
+ $guard.Add_Tick({
398
+ $this.Stop()
399
+ $g = $this.Tag
400
+ try {
401
+ if ($g.w.Opacity -lt 0.9) { $g.w.Opacity = 1 }
402
+ if ($g.w.Top -ne $g.top) { $g.w.Top = $g.top }
403
+ } catch { }
404
+ }) | Out-Null
405
+ $guard.Start()
406
+ } catch {
407
+ $win.Opacity = 1
408
+ $win.Top = $targetTop
409
+ }
410
+ try {
411
+ $hwnd = [System.Windows.Interop.WindowInteropHelper]::new($win).Handle
412
+ Send-Report @{ type = 'pos'; key = [string]$cmd.key; instance = [int]$cmd.instance
413
+ left = [Math]::Round($targetLeft); top = [Math]::Round($targetTop)
414
+ height = [Math]::Round($win.ActualHeight); hwnd = $hwnd.ToInt64()
415
+ workBottom = [Math]::Round($work.Bottom) }
260
416
  } 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
417
+ }.GetNewClosure()) | Out-Null
418
+
419
+ $toastsRef = $script:toasts
420
+ $win.Add_Closed({
421
+ if ($toastsRef.ContainsKey([string]$cmd.key)) {
422
+ $rec = $toastsRef[[string]$cmd.key]
423
+ if ($rec.Timer -ne $null) { try { $rec.Timer.Stop() } catch { } }
424
+ $toastsRef.Remove([string]$cmd.key) | Out-Null
425
+ }
426
+ Send-Report @{ type = 'exit'; key = [string]$cmd.key; instance = [int]$cmd.instance }
427
+ }.GetNewClosure()) | Out-Null
428
+
429
+ $rec = @{ Win = $win; Body = $bodyText; Title = $titleText; Timer = $null; Instance = [int]$cmd.instance }
430
+ $script:toasts[[string]$cmd.key] = $rec
431
+
432
+ if (-not ($cmd.sticky -eq $true)) {
433
+ $dur = [Math]::Max(1, [int]$cmd.durationSec)
434
+ $timer = New-Object System.Windows.Threading.DispatcherTimer
435
+ $timer.Interval = [TimeSpan]::FromSeconds($dur)
436
+ $timer.Add_Tick({ $this.Stop(); Close-WithFade $win }.GetNewClosure()) | Out-Null
437
+ $timer.Start()
438
+ $rec.Timer = $timer
288
439
  }
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
440
+ return $win
307
441
  }
308
442
 
309
- # ── 命令处理(WPF 主线程 Dispatcher 上执行,窗口操作线程安全) ───────────────
310
443
  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 }
444
+ $line = $line.Trim()
445
+ if ([string]::IsNullOrEmpty($line)) { return }
446
+ $cmd = $null
447
+ try { $cmd = $line | ConvertFrom-Json } catch { return }
448
+ if ($cmd -eq $null -or [string]::IsNullOrEmpty([string]$cmd.cmd)) { return }
449
+
450
+ switch ([string]$cmd.cmd) {
451
+ 'show' {
452
+ if ($script:toasts.ContainsKey([string]$cmd.key)) {
453
+ $old = $script:toasts[[string]$cmd.key]
454
+ try { $old.Win.Close() } catch { }
455
+ $script:toasts.Remove([string]$cmd.key) | Out-Null
456
+ }
457
+ $w = New-ToastWindow $cmd
458
+ if ($w -ne $null) { $w.Show() }
459
+ }
460
+ 'text' {
461
+ $rec = $script:toasts[[string]$cmd.key]
462
+ if ($rec -ne $null -and $rec.Body -ne $null) { $rec.Body.Text = [string]$cmd.text }
463
+ }
464
+ 'title' {
465
+ $rec = $script:toasts[[string]$cmd.key]
466
+ if ($rec -ne $null -and $rec.Title -ne $null) { $rec.Title.Text = [string]$cmd.title }
467
+ }
468
+ 'move' {
469
+ $rec = $script:toasts[[string]$cmd.key]
470
+ if ($rec -ne $null) {
471
+ $top = [int]$cmd.top
472
+ $slide = New-Object System.Windows.Media.Animation.DoubleAnimation($rec.Win.Top, $top, [TimeSpan]::FromMilliseconds(220))
473
+ $slide.EasingFunction = $easeMove
474
+ $rec.Win.BeginAnimation([System.Windows.Window]::TopProperty, $slide)
475
+ # 可选横向定位:left 数字 = 绝对 X(工作区相对);left 'default' = 默认右下贴边
476
+ # (工作区右缘 - 宽度 - 20)。用于编辑调整位置/恢复默认后,在屏浮窗立即到位。
477
+ if ($null -ne $cmd.left) {
478
+ $newLeft = 0.0
479
+ if ($cmd.left -eq 'default') {
480
+ $work = [System.Windows.SystemParameters]::WorkArea
481
+ $newLeft = $work.Right - $rec.Win.ActualWidth - 20
482
+ } else {
483
+ $newLeft = [double]$cmd.left
484
+ }
485
+ $slideL = New-Object System.Windows.Media.Animation.DoubleAnimation($rec.Win.Left, $newLeft, [TimeSpan]::FromMilliseconds(220))
486
+ $slideL.EasingFunction = $easeMove
487
+ $rec.Win.BeginAnimation([System.Windows.Window]::LeftProperty, $slideL)
488
+ }
489
+ }
490
+ }
491
+ 'close' {
492
+ $rec = $script:toasts[[string]$cmd.key]
493
+ if ($rec -ne $null) { Close-WithFade $rec.Win }
494
+ }
495
+ 'size' {
496
+ $rec = $script:toasts[[string]$cmd.key]
497
+ if ($rec -ne $null -and $null -ne $cmd.width) { $rec.Win.Width = [double]$cmd.width }
498
+ }
499
+ 'edit' {
500
+ # 编辑模式:关闭旧样板(如有),弹新样板窗(可拖拽/缩放)。
501
+ if ($script:editSample -ne $null) { try { $script:editSample.Close() } catch { } ; $script:editSample = $null }
502
+ $w = New-EditSample $cmd
503
+ if ($w -ne $null) { $w.Show(); $script:editSample = $w }
504
+ }
505
+ 'edit-end' {
506
+ if ($script:editSample -ne $null) { try { $script:editSample.Close() } catch { } ; $script:editSample = $null }
507
+ }
508
+ 'shutdown' { $script:exitRequested = $true }
343
509
  }
344
- 'shutdown' { $script:exitRequested = $true }
345
- }
346
510
  }
347
511
 
348
- # ── stdin 读取:独立 runspace 线程(阻塞 ReadLine 不卡 WPF 主线程) ───────────
349
512
  $script:cmdQueue = New-Object 'System.Collections.Concurrent.ConcurrentQueue[string]'
350
513
  $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"}')
514
+ param($q)
515
+ try {
516
+ $reader = New-Object System.IO.StreamReader([System.Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
517
+ while ($true) {
518
+ $line = $reader.ReadLine()
519
+ if ($null -eq $line) { break }
520
+ $q.Enqueue($line)
521
+ }
522
+ } catch { }
523
+ $q.Enqueue('{"cmd":"shutdown"}')
362
524
  }
525
+
363
526
  $readerRs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
364
527
  $readerRs.Open()
365
528
  $readerPs = [System.Management.Automation.PowerShell]::Create()
366
529
  $readerPs.Runspace = $readerRs
367
530
  $readerPs.AddScript($readerScript).AddArgument($script:cmdQueue) | Out-Null
368
- $readerPs.BeginInvoke() | Out-Null
531
+ $readerAsyncResult = $readerPs.BeginInvoke()
369
532
 
370
- # ── 主循环:50ms 排空命令队列;收到 shutdown 后关闭全部窗口并退出 ─────────────
371
533
  $cmdTimer = New-Object System.Windows.Threading.DispatcherTimer
372
534
  $cmdTimer.Interval = [TimeSpan]::FromMilliseconds(50)
373
535
  $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 { }
536
+ try {
537
+ $line = $null
538
+ while ($script:cmdQueue.TryDequeue([ref]$line)) {
539
+ if (-not [string]::IsNullOrEmpty($line)) { Invoke-Command $line }
540
+ if ($script:exitRequested) { break }
541
+ }
542
+ if ($script:exitRequested) {
543
+ $this.Stop()
544
+ foreach ($key in @($script:toasts.Keys)) {
545
+ try { $script:toasts[$key].Win.Close() } catch { }
546
+ }
547
+ if ($script:editSample -ne $null) {
548
+ try { $script:editSample.Close() } catch { }
549
+ $script:editSample = $null
550
+ }
551
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown()
552
+ }
553
+ } catch { }
388
554
  }) | Out-Null
389
555
  $cmdTimer.Start()
390
-
391
556
  [System.Windows.Threading.Dispatcher]::Run()
557
+
558
+ try { if ($readerAsyncResult -ne $null) { $readerPs.EndInvoke($readerAsyncResult) } } catch { }
392
559
  try { $readerPs.Dispose() } catch { }
393
560
  try { $readerRs.Dispose() } catch { }