@kesike/reminder-dsh 0.2.0 → 0.2.1

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,38 +1,75 @@
1
- param([switch]$ValidateOnly, [switch]$ReplyFailed)
1
+ param([switch]$ValidateOnly, [switch]$ReplyFailed, [switch]$ApprovalFailed)
2
2
 
3
3
  $ErrorActionPreference = 'Stop'
4
4
  [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
5
5
  [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
6
6
 
7
- $payload = if ($ReplyFailed) {
7
+ $payload = if ($ApprovalFailed) {
8
+ ConvertFrom-Json '{"title":"DeepSeek Harness \u5ba1\u6279\u672a\u63d0\u4ea4","message":"\u8bf7\u56de\u5230 DeepSeek Harness \u67e5\u770b\u5f85\u5904\u7406\u8bf7\u6c42\u3002"}'
9
+ } elseif ($ReplyFailed) {
8
10
  ConvertFrom-Json '{"title":"DSH \u56de\u590d\u672a\u786e\u8ba4","message":"\u8bf7\u56de\u5230\u539f DSH \u4f1a\u8bdd\u68c0\u67e5\u6d88\u606f\uff0c\u6216\u5728\u90a3\u91cc\u53d1\u9001\u3002"}'
9
11
  } else { [Console]::ReadLine() | ConvertFrom-Json }
10
12
  $icon = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../assets/deepseek-harness.png'))
11
13
  if (-not [IO.File]::Exists($icon)) { throw 'The DSH notification icon is missing.' }
14
+ $desktop = $null
15
+ $protocol = [Microsoft.Win32.Registry]::ClassesRoot.OpenSubKey('dsh\shell\open\command')
16
+ if ($null -ne $protocol) {
17
+ try {
18
+ if ($protocol.GetValue('') -match '^\s*"([^"]+\\DeepSeek Harness\.exe)"' -and [IO.File]::Exists($Matches[1])) {
19
+ $desktop = $Matches[1]
20
+ }
21
+ } finally { $protocol.Dispose() }
22
+ }
12
23
 
13
24
  $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime]
14
25
  $null = [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType=WindowsRuntime]
15
26
  $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
16
27
 
17
28
  # Let the DOM escape text and remove characters that Windows cannot store in XML.
29
+ # Without a task summary the toast keeps a single text node and shows only the session title.
30
+ $message = [string]$payload.message
18
31
  $xml = [Windows.Data.Xml.Dom.XmlDocument]::new()
19
- $xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text hint-maxLines="1"/><text hint-maxLines="4"/></binding></visual></toast>')
32
+ $xml.LoadXml($(if ($message) {
33
+ '<toast><visual><binding template="ToastGeneric"><text hint-maxLines="1"/><text hint-maxLines="3"/></binding></visual></toast>'
34
+ } else {
35
+ '<toast><visual><binding template="ToastGeneric"><text hint-maxLines="1"/></binding></visual></toast>'
36
+ }))
37
+ # A body click that must open a specific session is a background activation: our activator notifies the
38
+ # backend (which navigates the GUI) and then opens DSH itself. Without a session route, keep the protocol.
39
+ if ($payload.open) {
40
+ $xml.DocumentElement.SetAttribute('activationType', 'background')
41
+ $xml.DocumentElement.SetAttribute('launch', [string]$payload.open)
42
+ } elseif ($desktop) {
43
+ $xml.DocumentElement.SetAttribute('activationType', 'protocol')
44
+ $xml.DocumentElement.SetAttribute('launch', 'dsh://open')
45
+ }
20
46
  $invalidXml = '[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]'
21
47
  $xml.SelectSingleNode('//text[1]').InnerText = ([string]$payload.title) -replace $invalidXml, ''
22
- $xml.SelectSingleNode('//text[2]').InnerText = ([string]$payload.message) -replace $invalidXml, ''
23
- if ($payload.reply) {
48
+ if ($message) { $xml.SelectSingleNode('//text[2]').InnerText = $message -replace $invalidXml, '' }
49
+ if (-not $desktop) {
50
+ $image = $xml.CreateElement('image')
51
+ $image.SetAttribute('placement', 'appLogoOverride')
52
+ $image.SetAttribute('src', ([uri]::new($icon)).AbsoluteUri)
53
+ $null = $xml.SelectSingleNode('//binding').AppendChild($image)
54
+ }
55
+ if ($payload.reply -or $payload.approval) {
24
56
  $actions = $xml.CreateElement('actions')
25
- $replyInput = $xml.CreateElement('input')
26
- $replyInput.SetAttribute('id', 'reply')
27
- $replyInput.SetAttribute('type', 'text')
28
- $replyInput.SetAttribute('placeHolderContent', $payload.replyPlaceholder)
29
- $action = $xml.CreateElement('action')
30
- $action.SetAttribute('content', $payload.replyLabel)
31
- $action.SetAttribute('arguments', $payload.reply)
32
- $action.SetAttribute('activationType', 'background')
33
- $action.SetAttribute('hint-inputId', 'reply')
34
- $null = $actions.AppendChild($replyInput)
35
- $null = $actions.AppendChild($action)
57
+ if ($payload.reply) {
58
+ $replyInput = $xml.CreateElement('input')
59
+ $replyInput.SetAttribute('id', 'reply')
60
+ $replyInput.SetAttribute('type', 'text')
61
+ $replyInput.SetAttribute('placeHolderContent', $payload.replyPlaceholder)
62
+ $null = $actions.AppendChild($replyInput)
63
+ $buttons = @(@{ label = $payload.replyLabel; argument = $payload.reply; inputId = 'reply' })
64
+ } else { $buttons = @($payload.approval) }
65
+ foreach ($button in $buttons) {
66
+ $action = $xml.CreateElement('action')
67
+ $action.SetAttribute('content', $button.label)
68
+ $action.SetAttribute('arguments', $button.argument)
69
+ $action.SetAttribute('activationType', 'background')
70
+ if ($button.inputId) { $action.SetAttribute('hint-inputId', $button.inputId) }
71
+ $null = $actions.AppendChild($action)
72
+ }
36
73
  $null = $xml.DocumentElement.AppendChild($actions)
37
74
  }
38
75
  $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
@@ -40,11 +77,33 @@ if ($ValidateOnly) { $xml.GetXml(); return }
40
77
 
41
78
  # Register a dedicated per-user sender so CLI and desktop installs use the same identity.
42
79
  $appId = 'KeS1Ke.DSH'
80
+ $displayName = 'DeepSeek Harness'
81
+ $activator = '{BD86FD58-A2BF-430E-A8D9-8D20238ED61A}'
43
82
  $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Software\Classes\AppUserModelId\$appId")
44
83
  try {
45
- $key.SetValue('DisplayName', 'DSH')
84
+ $key.SetValue('DisplayName', $displayName)
46
85
  $key.SetValue('IconUri', $icon)
47
- if ($payload.reply) { $key.SetValue('CustomActivator', '{BD86FD58-A2BF-430E-A8D9-8D20238ED61A}') }
86
+ $key.SetValue('CustomActivator', $activator)
87
+ if ($desktop) {
88
+ # Windows resolves the compact header from the app's Start-menu identity.
89
+ $programs = [Environment]::GetFolderPath('Programs')
90
+ $directory = Join-Path $programs 'DeepSeek Harness Notifications'
91
+ $link = Join-Path $directory 'DeepSeek Harness.lnk'
92
+ if (-not [IO.File]::Exists($link) -or $key.GetValue('DesktopShortcutTarget') -ne $desktop) {
93
+ $null = [IO.Directory]::CreateDirectory($directory)
94
+ Add-Type -Path (Join-Path $PSScriptRoot 'ToastIdentity.cs')
95
+ $staged = Join-Path $directory ('.register-' + [guid]::NewGuid() + '.lnk')
96
+ try {
97
+ $shell = New-Object -ComObject WScript.Shell
98
+ $shortcut = $shell.CreateShortcut($staged)
99
+ $shortcut.TargetPath = $desktop
100
+ $shortcut.IconLocation = "$desktop,0"
101
+ $shortcut.Save()
102
+ [DshToastIdentity]::RegisterShortcut($staged, $link, $appId, [guid]$activator)
103
+ $key.SetValue('DesktopShortcutTarget', $desktop)
104
+ } finally { [IO.File]::Delete($staged) }
105
+ }
106
+ }
48
107
  } finally { $key.Dispose() }
49
108
 
50
109
  $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)
@@ -0,0 +1,237 @@
1
+ #requires -Version 7.0
2
+ #requires -PSEdition Core
3
+ param([switch]$ValidateOnly)
4
+
5
+ $ErrorActionPreference = 'Stop'
6
+ $targets = @()
7
+ $badgeIcon = [IntPtr]::Zero
8
+ $exitCode = 0
9
+ try {
10
+ Add-Type -AssemblyName System.Drawing
11
+ Add-Type -TypeDefinition @'
12
+ using System;
13
+ using System.Collections.Generic;
14
+ using System.Runtime.InteropServices;
15
+ using System.Text;
16
+ using System.Text.RegularExpressions;
17
+
18
+ [ComImport, Guid("56FDF344-FD6D-11d0-958A-006097C9A090")]
19
+ [ClassInterface(ClassInterfaceType.None)]
20
+ public class TaskbarList { }
21
+
22
+ [ComImport, Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eeb0e"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
23
+ public interface ITaskbarList3 {
24
+ void HrInit();
25
+ void AddTab(IntPtr hwnd);
26
+ void DeleteTab(IntPtr hwnd);
27
+ void ActivateTab(IntPtr hwnd);
28
+ void SetActiveAlt(IntPtr hwnd);
29
+ void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen);
30
+ void SetProgressValue(IntPtr hwnd, ulong ullCompleted, ulong ullTotal);
31
+ void SetProgressState(IntPtr hwnd, int tbpFlags);
32
+ void RegisterTab(IntPtr hwndTab, IntPtr hwndMDI);
33
+ void UnregisterTab(IntPtr hwndTab);
34
+ void SetTabOrder(IntPtr hwndTab, IntPtr hwndInsertBefore);
35
+ void SetTabActive(IntPtr hwndTab, IntPtr hwndMDI, uint dwReserved);
36
+ void ThumbBarAddButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
37
+ void ThumbBarUpdateButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
38
+ void ThumbBarSetImageList(IntPtr hwnd, IntPtr himl);
39
+ void SetOverlayIcon(IntPtr hwnd, IntPtr hIcon, [MarshalAs(UnmanagedType.LPWStr)] string pszDescription);
40
+ void SetThumbnailTooltip(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string pszTip);
41
+ void SetThumbnailClip(IntPtr hwnd, IntPtr prcClip);
42
+ }
43
+
44
+ [ComImport, Guid("c43dc798-95d1-4bea-9030-bb99e2983a1a"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
45
+ public interface ITaskbarList4 {
46
+ void HrInit();
47
+ void AddTab(IntPtr hwnd);
48
+ void DeleteTab(IntPtr hwnd);
49
+ void ActivateTab(IntPtr hwnd);
50
+ void SetActiveAlt(IntPtr hwnd);
51
+ void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen);
52
+ void SetProgressValue(IntPtr hwnd, ulong ullCompleted, ulong ullTotal);
53
+ void SetProgressState(IntPtr hwnd, int tbpFlags);
54
+ void RegisterTab(IntPtr hwndTab, IntPtr hwndMDI);
55
+ void UnregisterTab(IntPtr hwndTab);
56
+ void SetTabOrder(IntPtr hwndTab, IntPtr hwndInsertBefore);
57
+ void SetTabActive(IntPtr hwndTab, IntPtr hwndMDI, uint dwReserved);
58
+ void ThumbBarAddButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
59
+ void ThumbBarUpdateButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
60
+ void ThumbBarSetImageList(IntPtr hwnd, IntPtr himl);
61
+ void SetOverlayIcon(IntPtr hwnd, IntPtr hIcon, [MarshalAs(UnmanagedType.LPWStr)] string pszDescription);
62
+ void SetThumbnailTooltip(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string pszTip);
63
+ void SetThumbnailClip(IntPtr hwnd, IntPtr prcClip);
64
+ void SetTabProperties(IntPtr hwndTab, int stpFlags);
65
+ }
66
+
67
+ public static class DshTaskbarBadge {
68
+ private static ITaskbarList3 list3;
69
+ private static ITaskbarList4 list4;
70
+
71
+ private static void EnsureList() {
72
+ if (list3 != null || list4 != null) return;
73
+ var raw = new TaskbarList();
74
+ // Windows 11 builds may expose only one of the two cumulative interfaces.
75
+ try { list4 = (ITaskbarList4)raw; list4.HrInit(); return; } catch (InvalidCastException) { }
76
+ list3 = (ITaskbarList3)raw;
77
+ list3.HrInit();
78
+ }
79
+
80
+ public static void SetOverlay(IntPtr window, IntPtr icon, string description) {
81
+ EnsureList();
82
+ if (list4 != null) list4.SetOverlayIcon(window, icon, description);
83
+ else list3.SetOverlayIcon(window, icon, description);
84
+ }
85
+
86
+ [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr icon);
87
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr window);
88
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
89
+
90
+ private delegate bool EnumProc(IntPtr window, IntPtr parameter);
91
+ [DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc callback, IntPtr parameter);
92
+ [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window);
93
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
94
+ [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
95
+
96
+ public static IntPtr[] Find(string title, int[] owners) {
97
+ var matches = new List<IntPtr>();
98
+ var owned = new List<IntPtr>();
99
+ var ancestorIds = new HashSet<int>(owners);
100
+ EnumWindows((window, parameter) => {
101
+ if (!IsWindowVisible(window)) return true;
102
+ var text = new StringBuilder(2048);
103
+ GetWindowText(window, text, text.Capacity);
104
+ var name = text.ToString();
105
+ bool match = title.Length > 0 ? name.IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0
106
+ : Regex.IsMatch(name, @"(?:^|\s[—–-]\s)(?:DeepSeek Harness|DSH(?: Local Build)?)(?:$|\s[-—–]\s)", RegexOptions.IgnoreCase);
107
+ if (!match) return true;
108
+ uint process; GetWindowThreadProcessId(window, out process);
109
+ matches.Add(window);
110
+ if (ancestorIds.Contains((int)process)) owned.Add(window);
111
+ return true;
112
+ }, IntPtr.Zero);
113
+ if (owned.Count > 0) return owned.ToArray();
114
+ if (matches.Count > 1) throw new InvalidOperationException("匹配到多个 DSH 窗口,请在提醒设置中填写更具体的目标窗口标题。");
115
+ if (matches.Count == 0) throw new InvalidOperationException("未找到 DSH 窗口。请让浏览器显示 DSH 标签页,或在提醒设置中填写目标窗口标题。");
116
+ return matches.ToArray();
117
+ }
118
+ }
119
+ '@
120
+ if (-not $ValidateOnly) {
121
+ function New-BadgeIcon([int]$value) {
122
+ $text = if ($value -gt 99) { '99+' } else { [string]$value }
123
+ $size = 32
124
+ # Windows scales the 32px overlay icon down to the taskbar badge slot, so the chip fills the canvas to
125
+ # match the native badge size (about 18px on screen) instead of relying on canvas pixels.
126
+ $height = 30
127
+ $fontSize = switch ($text.Length) { 1 { 27 } 2 { 21 } default { 15 } }
128
+ $bitmap = [System.Drawing.Bitmap]::new($size, $size)
129
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
130
+ try {
131
+ $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
132
+ $graphics.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
133
+ $graphics.Clear([System.Drawing.Color]::Transparent)
134
+ $font = [System.Drawing.Font]::new('Segoe UI', [single]$fontSize, [System.Drawing.FontStyle]::Bold, [System.Drawing.GraphicsUnit]::Pixel)
135
+ try {
136
+ # Same shape as the ChatGPT taskbar badge: a dark chip with white digits, widened into a pill only
137
+ # as far as the number needs so two- and three-digit counts stay inside the canvas.
138
+ # MeasureString pads heavily at these sizes, so the pill width uses the tight typographic metrics.
139
+ $tight = [System.Drawing.StringFormat]::GenericTypographic
140
+ $textWidth = $graphics.MeasureString($text, $font, [System.Drawing.PointF]::new(0, 0), $tight).Width
141
+ $width = [single][Math]::Min($size, [Math]::Max($height, [Math]::Ceiling($textWidth) + 6))
142
+ $x = ($size - $width) / 2
143
+ $y = ($size - $height) / 2
144
+ $shape = [System.Drawing.Drawing2D.GraphicsPath]::new()
145
+ try {
146
+ $shape.AddArc($x, $y, $height, $height, 90, 180)
147
+ $shape.AddArc($x + $width - $height, $y, $height, $height, 270, 180)
148
+ $shape.CloseFigure()
149
+ $chip = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(255, 38, 37, 45))
150
+ try { $graphics.FillPath($chip, $shape) } finally { $chip.Dispose() }
151
+ } finally { $shape.Dispose() }
152
+ $format = [System.Drawing.StringFormat]::new()
153
+ $format.Alignment = [System.Drawing.StringAlignment]::Center
154
+ $format.LineAlignment = [System.Drawing.StringAlignment]::Center
155
+ # Without NoWrap a number wider than the layout rectangle would wrap onto a second line, and GDI+
156
+ # clips the last glyph at the rectangle edge, so the slack rectangle keeps the same centre.
157
+ $format.FormatFlags = [System.Drawing.StringFormatFlags]::NoWrap
158
+ try { $graphics.DrawString($text, $font, [System.Drawing.Brushes]::White, [System.Drawing.RectangleF]::new(-4, 0, $size + 8, $size), $format) } finally { $format.Dispose() }
159
+ } finally { $font.Dispose() }
160
+ } finally { $graphics.Dispose() }
161
+ return $bitmap.GetHicon()
162
+ }
163
+
164
+ function Show-Badge([IntPtr[]]$windows, [int]$value) {
165
+ $icon = New-BadgeIcon $value
166
+ $text = if ($value -gt 99) { '99+' } else { [string]$value }
167
+ foreach ($window in $windows) {
168
+ [DshTaskbarBadge]::SetOverlay($window, $icon, "$text 个会话已完成")
169
+ }
170
+ # Replace the previous handle only after every window points at the new icon.
171
+ if ($script:badgeIcon -ne [IntPtr]::Zero) { $null = [DshTaskbarBadge]::DestroyIcon($script:badgeIcon) }
172
+ $script:badgeIcon = $icon
173
+ }
174
+
175
+ $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false))
176
+ $payload = $reader.ReadLine() | ConvertFrom-Json
177
+ $count = [int]$payload.count
178
+ if ($count -lt 1 -or $count -gt 999) { throw 'Invalid badge count' }
179
+ $minutes = [double]$payload.maxMinutes
180
+ if ($minutes -lt 1 -or $minutes -gt 1440) { throw 'Invalid badge lifetime' }
181
+ $control = $reader.ReadLineAsync()
182
+ $owners = [Collections.Generic.List[int]]::new()
183
+ $processId = [int]$payload.hostPid
184
+ for ($depth = 0; $depth -lt 12 -and $processId -gt 0; $depth++) {
185
+ if ($owners.Contains($processId)) { break }
186
+ $owners.Add($processId)
187
+ $processInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$processId"
188
+ if ($null -eq $processInfo) { break }
189
+ $processId = [int]$processInfo.ParentProcessId
190
+ }
191
+ $shouldStop = $false
192
+ if ($control.IsCompleted) {
193
+ $line = $control.GetAwaiter().GetResult()
194
+ if ($null -eq $line -or ($line | ConvertFrom-Json).type -eq 'stop') { $shouldStop = $true }
195
+ else { $control = $reader.ReadLineAsync() }
196
+ }
197
+ if (-not $shouldStop) {
198
+ $targets = @([DshTaskbarBadge]::Find([string]$payload.windowTitle, $owners.ToArray()))
199
+ # A window the user is already looking at needs no unread badge.
200
+ $targets = @($targets | Where-Object { $_ -ne [DshTaskbarBadge]::GetForegroundWindow() })
201
+ if ($targets.Count -gt 0) {
202
+ Show-Badge $targets $count
203
+ $until = [DateTime]::UtcNow.AddMinutes($minutes)
204
+ while ($targets.Count -gt 0 -and [DateTime]::UtcNow -lt $until) {
205
+ if ($targets -contains [DshTaskbarBadge]::GetForegroundWindow()) { break }
206
+ $targets = @($targets | Where-Object { [DshTaskbarBadge]::IsWindow($_) })
207
+ if ($targets.Count -eq 0) { break }
208
+ if ($control.IsCompleted) {
209
+ $line = $control.GetAwaiter().GetResult()
210
+ if ($null -eq $line) { break }
211
+ $command = $line | ConvertFrom-Json
212
+ if ($command.type -eq 'stop') { break }
213
+ if ($command.type -eq 'set') {
214
+ $count = [int]$command.count
215
+ if ($count -lt 1 -or $count -gt 999) { throw 'Invalid badge count' }
216
+ Show-Badge $targets $count
217
+ }
218
+ $control = $reader.ReadLineAsync()
219
+ }
220
+ Start-Sleep -Milliseconds 150
221
+ }
222
+ }
223
+ }
224
+ }
225
+ } catch {
226
+ [Console]::Error.WriteLine($_.Exception.Message)
227
+ $exitCode = 1
228
+ } finally {
229
+ foreach ($target in $targets) {
230
+ if ([DshTaskbarBadge]::IsWindow([IntPtr]$target)) {
231
+ [DshTaskbarBadge]::SetOverlay([IntPtr]$target, [IntPtr]::Zero, '')
232
+ }
233
+ }
234
+ if ($badgeIcon -ne [IntPtr]::Zero) { $null = [DshTaskbarBadge]::DestroyIcon($badgeIcon) }
235
+ }
236
+ exit $exitCode
237
+
@@ -2,11 +2,11 @@ import Schema from '@deepseek-ai/schemastery'
2
2
  import { createTaskbarNotifier } from './windows.js'
3
3
 
4
4
  export const Config = Schema.object({
5
- enabled: Schema.boolean().default(false).description('启用 Windows 任务栏图标闪烁').volatile(),
5
+ enabled: Schema.boolean().default(false).description('启用 Windows 任务栏完成角标').volatile(),
6
6
  windowTitle: Schema.string().max(200).default('').description('目标窗口标题包含;留空自动识别 DSH').volatile(),
7
7
  })
8
8
 
9
- /** Taskbar flashing does not create summaries or popup windows. */
9
+ /** Taskbar badges do not create summaries or popup windows. */
10
10
  export function createTaskbarModule(config, notifier = createTaskbarNotifier({
11
11
  isEnabled: () => config.enabled.get(), getWindowTitle: () => config.windowTitle.get(),
12
12
  })) {
@@ -1,11 +1,13 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { fileURLToPath } from 'node:url'
3
3
 
4
- const script = fileURLToPath(new URL('./flash.ps1', import.meta.url))
4
+ const script = fileURLToPath(new URL('./badge.ps1', import.meta.url))
5
+ // The helper clears the badge on focus; the lifetime only stops a forgotten process from living forever.
6
+ const badgeMinutes = 120
5
7
 
6
- /** Coalesce taskbar attention for the same app; stop gracefully to clear native flashing. */
8
+ /** Coalesce taskbar badges for the same app; keep one helper process and update its unread count. */
7
9
  export function createTaskbarNotifier({ isEnabled, getWindowTitle }, launch = spawn) {
8
- let active, disposed = false
10
+ let active, disposed = false, count = 0
9
11
 
10
12
  function send(state, message) {
11
13
  if (!state.child.stdin.destroyed) state.child.stdin.write(JSON.stringify(message) + '\n', 'utf8')
@@ -28,7 +30,7 @@ export function createTaskbarNotifier({ isEnabled, getWindowTitle }, launch = sp
28
30
  finished = true
29
31
  clearTimeout(state.timeout)
30
32
  clearTimeout(state.killTimer)
31
- if (active === state) active = undefined
33
+ if (active === state) { active = undefined; count = 0 }
32
34
  if (failure && !state.stopping && !disposed) reject(failure)
33
35
  else resolve()
34
36
  }
@@ -36,14 +38,10 @@ export function createTaskbarNotifier({ isEnabled, getWindowTitle }, launch = sp
36
38
  child.stderr.on('data', value => { stderr = (stderr + value).slice(-2048) })
37
39
  child.on('error', failure => finish(failure.code === 'ENOENT'
38
40
  ? new Error('找不到 pwsh.exe,请安装 PowerShell 7 并重启 DSH。') : failure))
39
- child.on('close', code => finish(error ?? (code === 0 ? undefined : new Error(`任务栏提醒失败:${stderr.trim() || code}`))))
41
+ child.on('close', code => finish(error ?? (code === 0 ? undefined : new Error(`任务栏角标失败:${stderr.trim() || code}`))))
40
42
  child.stdin.on('error', failure => { error = failure; child.kill() })
41
- state.armDeadline = () => {
42
- clearTimeout(state.timeout)
43
- state.timeout = setTimeout(() => { error = new Error('任务栏提醒进程超时'); child.kill() }, 30_000)
44
- }
45
- state.armDeadline()
46
- send(state, { hostPid: process.pid, windowTitle: getWindowTitle(), durationSeconds: 10 })
43
+ state.timeout = setTimeout(() => { error = new Error('任务栏角标进程超时'); child.kill() }, (badgeMinutes + 1) * 60_000)
44
+ send(state, { hostPid: process.pid, windowTitle: getWindowTitle(), count, maxMinutes: badgeMinutes })
47
45
  return state
48
46
  }
49
47
  function subscribe(state, signal) {
@@ -65,8 +63,13 @@ export function createTaskbarNotifier({ isEnabled, getWindowTitle }, launch = sp
65
63
  function notify({ signal } = {}) {
66
64
  if (disposed || !isEnabled() || signal?.aborted) return Promise.resolve()
67
65
  if (active?.stopping) return active.done.catch(() => {}).then(() => notify({ signal }))
68
- if (active) { active.armDeadline(); send(active, { type: 'refresh' }) }
69
- else active = start()
66
+ if (active) {
67
+ count = Math.min(count + 1, 100)
68
+ send(active, { type: 'set', count })
69
+ } else {
70
+ count = 1
71
+ active = start()
72
+ }
70
73
  return subscribe(active, signal)
71
74
  }
72
75
  return {
package/src/reminder.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
2
2
 
3
+ /**
4
+ * Read the current title text of one live session.
5
+ * The title service returns an immutable snapshot, so only its `title` string is used.
6
+ * Profiles without that bundle, or sessions without an accepted title yet, yield `undefined`.
7
+ */
8
+ export function sessionTitleOf(ctx, session) {
9
+ try {
10
+ const title = ctx.reflect.get('sessionTitle')?.get(session)?.title
11
+ return typeof title === 'string' && title !== '' ? title : undefined
12
+ } catch { return undefined }
13
+ }
14
+
3
15
  /** Register live events only: restored history and initially idle agents do not notify. */
4
16
  export function attachReminder(ctx, notify, reportError) {
5
17
  const activities = new WeakMap()
@@ -27,7 +39,7 @@ export function attachReminder(ctx, notify, reportError) {
27
39
  // Consume before launching the subprocess so repeated idle events cannot duplicate it.
28
40
  try {
29
41
  Promise.resolve(notify({
30
- sessionId: session.id, completedText: activity.text,
42
+ sessionId: session.id, sessionTitle: sessionTitleOf(ctx, session), completedText: activity.text,
31
43
  route: agent.model?.current(), signal: controller.signal,
32
44
  onReply(text) {
33
45
  if (disposed || closedAgents.has(agent) || agent.session !== session) {
@@ -1,107 +0,0 @@
1
- #requires -Version 7.0
2
- #requires -PSEdition Core
3
- param([switch]$ValidateOnly)
4
-
5
- $ErrorActionPreference = 'Stop'
6
- $targets = @()
7
- $exitCode = 0
8
- try {
9
- Add-Type -TypeDefinition @'
10
- using System;
11
- using System.Collections.Generic;
12
- using System.Runtime.InteropServices;
13
- using System.Text;
14
- using System.Text.RegularExpressions;
15
- public static class DshTaskbar {
16
- private delegate bool EnumProc(IntPtr window, IntPtr parameter);
17
- [DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc callback, IntPtr parameter);
18
- [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window);
19
- [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr window);
20
- [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
21
- [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
22
- [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
23
- [StructLayout(LayoutKind.Sequential)] private struct FlashInfo {
24
- public uint size; public IntPtr window; public uint flags; public uint count; public uint timeout;
25
- }
26
- [DllImport("user32.dll")] private static extern bool FlashWindowEx(ref FlashInfo info);
27
- public static void Flash(IntPtr window, bool enabled, uint count) {
28
- var info = new FlashInfo { size = (uint)Marshal.SizeOf<FlashInfo>(), window = window,
29
- flags = enabled ? 2u : 0u, count = count, timeout = 500 };
30
- FlashWindowEx(ref info);
31
- }
32
- public static IntPtr[] Find(string title, int[] owners) {
33
- var matches = new List<IntPtr>();
34
- var owned = new List<IntPtr>();
35
- var ancestorIds = new HashSet<int>(owners);
36
- EnumWindows((window, parameter) => {
37
- if (!IsWindowVisible(window)) return true;
38
- var text = new StringBuilder(2048);
39
- GetWindowText(window, text, text.Capacity);
40
- var name = text.ToString();
41
- bool match = title.Length > 0 ? name.IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0
42
- : Regex.IsMatch(name, @"(?:^|\s[—–-]\s)(?:DeepSeek Harness|DSH(?: Local Build)?)(?:$|\s[-—–]\s)", RegexOptions.IgnoreCase);
43
- if (!match) return true;
44
- uint process; GetWindowThreadProcessId(window, out process);
45
- matches.Add(window);
46
- if (ancestorIds.Contains((int)process)) owned.Add(window);
47
- return true;
48
- }, IntPtr.Zero);
49
- if (owned.Count > 0) return owned.ToArray();
50
- if (matches.Count > 1) throw new InvalidOperationException("匹配到多个 DSH 窗口,请在提醒设置中填写更具体的目标窗口标题。");
51
- if (matches.Count == 0) throw new InvalidOperationException("未找到 DSH 窗口。请让浏览器显示 DSH 标签页,或在提醒设置中填写目标窗口标题。");
52
- return matches.ToArray();
53
- }
54
- }
55
- '@
56
- if (-not $ValidateOnly) {
57
- $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false))
58
- $payload = $reader.ReadLine() | ConvertFrom-Json
59
- $seconds = [int]$payload.durationSeconds
60
- if ($seconds -lt 1 -or $seconds -gt 60) { throw 'Invalid flash duration' }
61
- $control = $reader.ReadLineAsync()
62
- $owners = [Collections.Generic.List[int]]::new()
63
- $processId = [int]$payload.hostPid
64
- for ($depth = 0; $depth -lt 12 -and $processId -gt 0; $depth++) {
65
- if ($owners.Contains($processId)) { break }
66
- $owners.Add($processId)
67
- $processInfo = Get-CimInstance Win32_Process -Filter "ProcessId=$processId"
68
- if ($null -eq $processInfo) { break }
69
- $processId = [int]$processInfo.ParentProcessId
70
- }
71
- $shouldStop = $false
72
- if ($control.IsCompleted) {
73
- $line = $control.GetAwaiter().GetResult()
74
- if ($null -eq $line -or ($line | ConvertFrom-Json).type -eq 'stop') { $shouldStop = $true }
75
- else { $control = $reader.ReadLineAsync() }
76
- }
77
- if (-not $shouldStop) {
78
- $targets = @([DshTaskbar]::Find([string]$payload.windowTitle, $owners.ToArray()))
79
- $targets = @($targets | Where-Object { $_ -ne [DshTaskbar]::GetForegroundWindow() })
80
- foreach ($target in $targets) { [DshTaskbar]::Flash($target, $true, [uint32]($seconds * 2)) }
81
- $until = [DateTime]::UtcNow.AddSeconds($seconds)
82
- while ($targets.Count -gt 0 -and [DateTime]::UtcNow -lt $until) {
83
- if ($targets -contains [DshTaskbar]::GetForegroundWindow()) { break }
84
- if ($control.IsCompleted) {
85
- $line = $control.GetAwaiter().GetResult()
86
- if ($null -eq $line) { break }
87
- $command = $line | ConvertFrom-Json
88
- if ($command.type -eq 'stop') { break }
89
- if ($command.type -eq 'refresh') {
90
- $until = [DateTime]::UtcNow.AddSeconds($seconds)
91
- foreach ($target in $targets) { [DshTaskbar]::Flash($target, $true, [uint32]($seconds * 2)) }
92
- }
93
- $control = $reader.ReadLineAsync()
94
- }
95
- Start-Sleep -Milliseconds 100
96
- }
97
- }
98
- }
99
- } catch {
100
- [Console]::Error.WriteLine($_.Exception.Message)
101
- $exitCode = 1
102
- } finally {
103
- foreach ($target in $targets) {
104
- if ([DshTaskbar]::IsWindow($target)) { [DshTaskbar]::Flash($target, $false, 0) }
105
- }
106
- }
107
- exit $exitCode