@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.
- package/README.md +23 -13
- package/README.zh-CN.md +36 -22
- package/example.patch.yml +1 -0
- package/lib/client.js +165 -57
- package/package.json +2 -2
- package/src/index.js +5 -1
- package/src/modules/popup/ToastActivator.cs +33 -5
- package/src/modules/popup/ToastIdentity.cs +71 -0
- package/src/modules/popup/approval.js +60 -0
- package/src/modules/popup/index.js +77 -8
- package/src/modules/popup/open.js +33 -0
- package/src/modules/popup/reply.js +49 -18
- package/src/modules/popup/summary.js +16 -3
- package/src/modules/popup/toast.js +30 -10
- package/src/modules/popup/toast.ps1 +77 -18
- package/src/modules/taskbar/badge.ps1 +237 -0
- package/src/modules/taskbar/index.js +2 -2
- package/src/modules/taskbar/windows.js +16 -13
- package/src/reminder.js +13 -1
- package/src/modules/taskbar/flash.ps1 +0 -107
|
@@ -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 ($
|
|
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(
|
|
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 =
|
|
23
|
-
if ($
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
$
|
|
33
|
-
$
|
|
34
|
-
|
|
35
|
-
|
|
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',
|
|
84
|
+
$key.SetValue('DisplayName', $displayName)
|
|
46
85
|
$key.SetValue('IconUri', $icon)
|
|
47
|
-
|
|
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
|
|
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
|
|
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('./
|
|
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
|
|
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(
|
|
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.
|
|
42
|
-
|
|
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) {
|
|
69
|
-
|
|
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
|