dsh-notify-yimit 0.1.2 → 0.1.3

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,352 @@
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 { }
41
20
  }
42
21
 
43
- # 浮窗注册表(key → @{ Win; Body; Timer; Instance }),只在 WPF 主线程访问。
44
22
  $script:toasts = @{}
45
23
  $script:exitRequested = $false
46
24
 
47
- # 颜色解析:#RGB / #RRGGBB / #AARRGGBB → Windows.Media.Color
48
25
  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))
26
+ $h = $hex.TrimStart('#')
27
+ if ($h.Length -eq 3) { $h = ($h.ToCharArray() | ForEach-Object { "$$_" }) -join '' }
28
+ if ($h.Length -eq 6) { $h = "FF$h" }
29
+ if ($h.Length -ne 8 -or $h -notmatch '^[0-9a-fA-F]{8}$') { $h = $fallback.TrimStart('#') }
30
+ return [System.Windows.Media.Color]::FromArgb(
31
+ [Convert]::ToInt32($h.Substring(0,2), 16),
32
+ [Convert]::ToInt32($h.Substring(2,2), 16),
33
+ [Convert]::ToInt32($h.Substring(4,2), 16),
34
+ [Convert]::ToInt32($h.Substring(6,2), 16))
58
35
  }
59
36
 
60
- # 按钮用 Border 直接实现:标准圆角矩形(10px)。忽略 = 透明底 + 细边框(fg 色);
61
- # 跳转 = 浅色实心底 + 主色文字。悬停/按压用不透明压暗色反馈(避免透明窗口闪烁)。
62
37
  $downP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(158,164,170))
63
38
  $downI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(30,36,44))
64
39
  $hoverP = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(196,202,208))
65
40
  $hoverI = New-Object System.Windows.Media.SolidColorBrush([System.Windows.Media.Color]::FromRgb(44,52,62))
66
41
 
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
42
+ function New-ToastButton([string]$label, [scriptblock]$onClick, [bool]$primary, [System.Windows.Media.SolidColorBrush]$fg, [System.Windows.Media.SolidColorBrush]$bg) {
43
+ $bd = New-Object System.Windows.Controls.Border
44
+ $bd.CornerRadius = New-Object System.Windows.CornerRadius(10)
45
+ $bd.Padding = New-Object System.Windows.Thickness(18,7,18,7)
46
+ $bd.Margin = New-Object System.Windows.Thickness(0,0,8,0)
47
+ $bd.Cursor = [System.Windows.Input.Cursors]::Hand
48
+ $bd.Focusable = $false
49
+
50
+ $txt = New-Object System.Windows.Controls.TextBlock
51
+ $txt.Text = $label
52
+ $txt.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
53
+ $txt.FontSize = 12
54
+ $txt.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
55
+ $txt.IsHitTestVisible = $false
56
+
57
+ if ($primary) {
58
+ $bd.Background = $fg
59
+ $txt.Foreground = $bg
60
+ $bd.BorderBrush = [System.Windows.Media.Brushes]::Transparent
61
+ $bd.BorderThickness = New-Object System.Windows.Thickness(0)
62
+ } else {
63
+ $bd.Background = [System.Windows.Media.Brushes]::Transparent
64
+ $txt.Foreground = $fg
65
+ $bd.BorderBrush = $fg
66
+ $bd.BorderThickness = New-Object System.Windows.Thickness(1)
67
+ }
68
+ $bd.Child = $txt
69
+
70
+ $bd.Tag = @{ primary = $primary; onClick = $onClick; hoverP = $hoverP; hoverI = $hoverI; downP = $downP; downI = $downI; fg = $fg; bg = $bg }
71
+
72
+ $bd.Add_MouseEnter({
73
+ $info = $this.Tag
74
+ $this.Background = if ($info.primary) { $info.hoverP } else { $info.hoverI }
75
+ }) | Out-Null
76
+
77
+ $bd.Add_MouseLeave({
78
+ $info = $this.Tag
79
+ $this.Background = if ($info.primary) { $info.fg } else { [System.Windows.Media.Brushes]::Transparent }
80
+ }) | Out-Null
81
+
82
+ $bd.Add_MouseLeftButtonDown({
83
+ $info = $this.Tag
84
+ $this.Background = if ($info.primary) { $info.downP } else { $info.downI }
85
+ }) | Out-Null
86
+
87
+ $bd.Add_MouseLeftButtonUp({
88
+ $info = $this.Tag
89
+ try { if ($info.onClick -ne $null) { & $info.onClick } } catch { }
90
+ $this.Background = if ($info.primary) { $info.fg } else { [System.Windows.Media.Brushes]::Transparent }
91
+ }) | Out-Null
92
+
93
+ return $bd
114
94
  }
115
95
 
116
- # 动画缓动(脚本级,供各处复用)。
117
- # 注意:PS 5.1 的 New-Object 无法用枚举参数调构造器(会报 "overload ... argument count: 1"),
118
- # 必须默认构造后设置 EasingMode 属性。
119
96
  $easeMove = New-Object System.Windows.Media.Animation.QuadraticEase
120
97
  $easeMove.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
121
98
  $easeExit = New-Object System.Windows.Media.Animation.CubicEase
122
99
  $easeExit.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseIn
123
100
 
124
- # 淡出关闭:160ms EaseIn 淡出 + 20px 下坠,动画完成后 Close(Closed 事件上报 exit)。
125
- # 防重入:$w.Tag='closing' 只执行一次(忽略按钮 + close 命令 + 定时器可能先后触发);
126
- # 600ms 硬性兜底,确保即使动画/定时器异常也强制关闭(绝不挂起)。
127
101
  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
- }
102
+ try {
103
+ if ($w.Tag -eq 'closing') { return }
104
+ $w.Tag = 'closing'
105
+ $fadeOut = New-Object System.Windows.Media.Animation.DoubleAnimation(1, 0, [TimeSpan]::FromMilliseconds(160))
106
+ $fadeOut.EasingFunction = $easeExit
107
+ $fadeOut.Add_Completed({ try { $w.Close() } catch { } }.GetNewClosure()) | Out-Null
108
+ $w.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeOut)
109
+
110
+ $dropAn = New-Object System.Windows.Media.Animation.DoubleAnimation($w.Top, ($w.Top + 20), [TimeSpan]::FromMilliseconds(160))
111
+ $dropAn.EasingFunction = $easeExit
112
+ $w.BeginAnimation([System.Windows.Window]::TopProperty, $dropAn)
113
+
114
+ $backstop = New-Object System.Windows.Threading.DispatcherTimer
115
+ $backstop.Interval = [TimeSpan]::FromMilliseconds(600)
116
+ $backstop.Tag = $w
117
+ $backstop.Add_Tick({
118
+ $this.Stop()
119
+ try { if ($this.Tag.IsVisible) { $this.Tag.Close() } } catch { }
120
+ }) | Out-Null
121
+ $backstop.Start()
122
+ } catch {
123
+ try { $w.Close() } catch { }
124
+ }
150
125
  }
151
126
 
152
- # 建窗:按 show 命令参数创建窗口;返回 Window(由调用方 Show() 显示)。
153
127
  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
218
- } else {
219
- Start-Process $url
128
+ $bg = Parse-Color ([string]$cmd.bg) "#20272f"
129
+ $fg = Parse-Color ([string]$cmd.fg) "#e6edf3"
130
+ $bgBrush = New-Object System.Windows.Media.SolidColorBrush($bg)
131
+ $fgBrush = New-Object System.Windows.Media.SolidColorBrush($fg)
132
+
133
+ $win = New-Object System.Windows.Window
134
+ $win.WindowStyle = [System.Windows.WindowStyle]::None
135
+ $win.AllowsTransparency = $true
136
+ $win.Background = [System.Windows.Media.Brushes]::Transparent
137
+ $win.Topmost = $true
138
+ $win.ShowInTaskbar = $false
139
+ $win.Width = 340
140
+ $win.SizeToContent = [System.Windows.SizeToContent]::Height
141
+ if (-not [string]::IsNullOrEmpty([string]$cmd.winTitle)) { $win.Title = [string]$cmd.winTitle }
142
+
143
+ $border = New-Object System.Windows.Controls.Border
144
+ $border.Background = $bgBrush
145
+ $border.BorderBrush = $fgBrush
146
+ $border.BorderThickness = New-Object System.Windows.Thickness(1)
147
+ $border.CornerRadius = New-Object System.Windows.CornerRadius(12)
148
+ $border.Margin = New-Object System.Windows.Thickness(12)
149
+
150
+ $root = New-Object System.Windows.Controls.StackPanel
151
+ $root.Margin = New-Object System.Windows.Thickness(14,12,14,12)
152
+ $border.Child = $root
153
+
154
+ # 优化:支持多语言,接收传入的 label,如果没有则使用后备英文
155
+ $unnamedLabel = if ([string]::IsNullOrEmpty([string]$cmd.unnamedLabel)) { "(Unnamed session)" } else { [string]$cmd.unnamedLabel }
156
+
157
+ $titleText = New-Object System.Windows.Controls.TextBlock
158
+ $titleText.Text = if ([string]::IsNullOrEmpty([string]$cmd.title)) { $unnamedLabel } else { [string]$cmd.title }
159
+ $titleText.Foreground = $fgBrush
160
+ $titleText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
161
+ $titleText.FontSize = 13
162
+ $titleText.FontWeight = [System.Windows.FontWeights]::SemiBold
163
+ $titleText.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis
164
+ $titleText.Margin = New-Object System.Windows.Thickness(0,0,0,6)
165
+ $root.Children.Add($titleText) | Out-Null
166
+
167
+ $bodyText = New-Object System.Windows.Controls.TextBlock
168
+ $bodyText.Text = [string]$cmd.text
169
+ $bodyText.Foreground = $fgBrush
170
+ $bodyText.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe UI, Microsoft YaHei UI")
171
+ $bodyText.FontSize = 13
172
+ $bodyText.TextWrapping = [System.Windows.TextWrapping]::Wrap
173
+ $bodyText.Margin = New-Object System.Windows.Thickness(0,0,0,10)
174
+ $root.Children.Add($bodyText) | Out-Null
175
+
176
+ $btnRow = New-Object System.Windows.Controls.StackPanel
177
+ $btnRow.Orientation = [System.Windows.Controls.Orientation]::Horizontal
178
+ $btnRow.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Right
179
+
180
+ $ignoreLabel = if ([string]::IsNullOrEmpty([string]$cmd.ignoreLabel)) { "Ignore" } else { [string]$cmd.ignoreLabel }
181
+ $ignoreBtn = New-ToastButton $ignoreLabel ({ Close-WithFade $win }.GetNewClosure()) $false $fgBrush $bgBrush
182
+ $btnRow.Children.Add($ignoreBtn) | Out-Null
183
+
184
+ if (-not [string]::IsNullOrEmpty([string]$cmd.sessionId)) {
185
+ $jumpLabel = if ([string]::IsNullOrEmpty([string]$cmd.jumpLabel)) { "Open" } else { [string]$cmd.jumpLabel }
186
+ $jumpBtn = New-ToastButton $jumpLabel ({
187
+ $url = "$($cmd.baseUrl)/#dsh-notify-yimit/session=$($cmd.sessionId)"
188
+ try {
189
+ if (-not [string]::IsNullOrEmpty([string]$cmd.browserPath)) {
190
+ Start-Process -FilePath ([string]$cmd.browserPath) -ArgumentList $url
191
+ } else { Start-Process $url }
192
+ } catch { }
193
+ Close-WithFade $win
194
+ }.GetNewClosure()) $true $fgBrush $bgBrush
195
+ $btnRow.Children.Add($jumpBtn) | Out-Null
196
+ }
197
+ $root.Children.Add($btnRow) | Out-Null
198
+ $win.Content = $border
199
+
200
+ $win.Add_Loaded({
201
+ $work = [System.Windows.SystemParameters]::WorkArea
202
+ $targetLeft = $work.Right - $win.ActualWidth - 20
203
+ $targetTop = $work.Bottom - $win.ActualHeight - 20 - [int]$cmd.offsetY
204
+ $win.Left = $targetLeft
205
+ $win.Top = $targetTop + 32
206
+ $win.Opacity = 0
207
+ try {
208
+ $easingIn = New-Object System.Windows.Media.Animation.QuadraticEase
209
+ $easingIn.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut
210
+ $slideIn = New-Object System.Windows.Media.Animation.DoubleAnimation($targetTop + 32, $targetTop, [TimeSpan]::FromMilliseconds(260))
211
+ $slideIn.EasingFunction = $easingIn
212
+ $win.BeginAnimation([System.Windows.Window]::TopProperty, $slideIn)
213
+ $fadeIn = New-Object System.Windows.Media.Animation.DoubleAnimation(0, 1, [TimeSpan]::FromMilliseconds(180))
214
+ $fadeIn.EasingFunction = $easingIn
215
+ $win.BeginAnimation([System.Windows.Window]::OpacityProperty, $fadeIn)
216
+
217
+ $guard = New-Object System.Windows.Threading.DispatcherTimer
218
+ $guard.Interval = [TimeSpan]::FromMilliseconds(400)
219
+ $guard.Tag = @{ w = $win; top = $targetTop }
220
+ $guard.Add_Tick({
221
+ $this.Stop()
222
+ $g = $this.Tag
223
+ try {
224
+ if ($g.w.Opacity -lt 0.9) { $g.w.Opacity = 1 }
225
+ if ($g.w.Top -ne $g.top) { $g.w.Top = $g.top }
226
+ } catch { }
227
+ }) | Out-Null
228
+ $guard.Start()
229
+ } catch {
230
+ $win.Opacity = 1
231
+ $win.Top = $targetTop
220
232
  }
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
233
  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 }
234
+ $hwnd = [System.Windows.Interop.WindowInteropHelper]::new($win).Handle
235
+ Send-Report @{ type = 'pos'; key = [string]$cmd.key; instance = [int]$cmd.instance
236
+ left = [Math]::Round($targetLeft); top = [Math]::Round($targetTop)
237
+ height = [Math]::Round($win.ActualHeight); hwnd = $hwnd.ToInt64()
238
+ workBottom = [Math]::Round($work.Bottom) }
260
239
  } 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
240
+ }.GetNewClosure()) | Out-Null
241
+
242
+ $toastsRef = $script:toasts
243
+ $win.Add_Closed({
244
+ if ($toastsRef.ContainsKey([string]$cmd.key)) {
245
+ $rec = $toastsRef[[string]$cmd.key]
246
+ if ($rec.Timer -ne $null) { try { $rec.Timer.Stop() } catch { } }
247
+ $toastsRef.Remove([string]$cmd.key) | Out-Null
248
+ }
249
+ Send-Report @{ type = 'exit'; key = [string]$cmd.key; instance = [int]$cmd.instance }
250
+ }.GetNewClosure()) | Out-Null
251
+
252
+ $rec = @{ Win = $win; Body = $bodyText; Title = $titleText; Timer = $null; Instance = [int]$cmd.instance }
253
+ $script:toasts[[string]$cmd.key] = $rec
254
+
255
+ if (-not ($cmd.sticky -eq $true)) {
256
+ $dur = [Math]::Max(1, [int]$cmd.durationSec)
257
+ $timer = New-Object System.Windows.Threading.DispatcherTimer
258
+ $timer.Interval = [TimeSpan]::FromSeconds($dur)
259
+ $timer.Add_Tick({ $this.Stop(); Close-WithFade $win }.GetNewClosure()) | Out-Null
260
+ $timer.Start()
261
+ $rec.Timer = $timer
288
262
  }
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
263
+ return $win
307
264
  }
308
265
 
309
- # ── 命令处理(WPF 主线程 Dispatcher 上执行,窗口操作线程安全) ───────────────
310
266
  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 }
267
+ $line = $line.Trim()
268
+ if ([string]::IsNullOrEmpty($line)) { return }
269
+ $cmd = $null
270
+ try { $cmd = $line | ConvertFrom-Json } catch { return }
271
+ if ($cmd -eq $null -or [string]::IsNullOrEmpty([string]$cmd.cmd)) { return }
272
+
273
+ switch ([string]$cmd.cmd) {
274
+ 'show' {
275
+ if ($script:toasts.ContainsKey([string]$cmd.key)) {
276
+ $old = $script:toasts[[string]$cmd.key]
277
+ try { $old.Win.Close() } catch { }
278
+ $script:toasts.Remove([string]$cmd.key) | Out-Null
279
+ }
280
+ $w = New-ToastWindow $cmd
281
+ if ($w -ne $null) { $w.Show() }
282
+ }
283
+ 'text' {
284
+ $rec = $script:toasts[[string]$cmd.key]
285
+ if ($rec -ne $null -and $rec.Body -ne $null) { $rec.Body.Text = [string]$cmd.text }
286
+ }
287
+ 'title' {
288
+ $rec = $script:toasts[[string]$cmd.key]
289
+ if ($rec -ne $null -and $rec.Title -ne $null) { $rec.Title.Text = [string]$cmd.title }
290
+ }
291
+ 'move' {
292
+ $rec = $script:toasts[[string]$cmd.key]
293
+ if ($rec -ne $null) {
294
+ $top = [int]$cmd.top
295
+ $slide = New-Object System.Windows.Media.Animation.DoubleAnimation($rec.Win.Top, $top, [TimeSpan]::FromMilliseconds(220))
296
+ $slide.EasingFunction = $easeMove
297
+ $rec.Win.BeginAnimation([System.Windows.Window]::TopProperty, $slide)
298
+ }
299
+ }
300
+ 'close' {
301
+ $rec = $script:toasts[[string]$cmd.key]
302
+ if ($rec -ne $null) { Close-WithFade $rec.Win }
303
+ }
304
+ 'shutdown' { $script:exitRequested = $true }
343
305
  }
344
- 'shutdown' { $script:exitRequested = $true }
345
- }
346
306
  }
347
307
 
348
- # ── stdin 读取:独立 runspace 线程(阻塞 ReadLine 不卡 WPF 主线程) ───────────
349
308
  $script:cmdQueue = New-Object 'System.Collections.Concurrent.ConcurrentQueue[string]'
350
309
  $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"}')
310
+ param($q)
311
+ try {
312
+ $reader = New-Object System.IO.StreamReader([System.Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
313
+ while ($true) {
314
+ $line = $reader.ReadLine()
315
+ if ($null -eq $line) { break }
316
+ $q.Enqueue($line)
317
+ }
318
+ } catch { }
319
+ $q.Enqueue('{"cmd":"shutdown"}')
362
320
  }
321
+
363
322
  $readerRs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
364
323
  $readerRs.Open()
365
324
  $readerPs = [System.Management.Automation.PowerShell]::Create()
366
325
  $readerPs.Runspace = $readerRs
367
326
  $readerPs.AddScript($readerScript).AddArgument($script:cmdQueue) | Out-Null
368
- $readerPs.BeginInvoke() | Out-Null
327
+ $readerAsyncResult = $readerPs.BeginInvoke()
369
328
 
370
- # ── 主循环:50ms 排空命令队列;收到 shutdown 后关闭全部窗口并退出 ─────────────
371
329
  $cmdTimer = New-Object System.Windows.Threading.DispatcherTimer
372
330
  $cmdTimer.Interval = [TimeSpan]::FromMilliseconds(50)
373
331
  $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 { }
332
+ try {
333
+ $line = $null
334
+ while ($script:cmdQueue.TryDequeue([ref]$line)) {
335
+ if (-not [string]::IsNullOrEmpty($line)) { Invoke-Command $line }
336
+ if ($script:exitRequested) { break }
337
+ }
338
+ if ($script:exitRequested) {
339
+ $this.Stop()
340
+ foreach ($key in @($script:toasts.Keys)) {
341
+ try { $script:toasts[$key].Win.Close() } catch { }
342
+ }
343
+ [System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown()
344
+ }
345
+ } catch { }
388
346
  }) | Out-Null
389
347
  $cmdTimer.Start()
390
-
391
348
  [System.Windows.Threading.Dispatcher]::Run()
349
+
350
+ try { if ($readerAsyncResult -ne $null) { $readerPs.EndInvoke($readerAsyncResult) } } catch { }
392
351
  try { $readerPs.Dispose() } catch { }
393
352
  try { $readerRs.Dispose() } catch { }