dsh-turn-ding 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +144 -2
- package/cordis.patch.yml +4 -0
- package/index.json +53 -0
- package/lib/badge-server.ps1 +243 -0
- package/lib/index.js +168 -0
- package/package.json +45 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arron Gao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-turn-ding
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 会话跑完时,在 Windows 任务栏的 DSH 图标上显示未读数字徽标,并播放一声提示音。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-turn-ding)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-turn-ding)
|
|
8
|
+
|
|
9
|
+
DSH 没有内置「会话完成提醒」:任务栏图标不会累计未读数,也没有完成提示音。`dsh-turn-ding` 补上这个缺口——每个会话正常结束时徽标 +1 并响一声,回到窗口自动清零,效果类似 Codex 的完成提醒。
|
|
10
|
+
|
|
11
|
+
## 功能
|
|
12
|
+
|
|
13
|
+
- 任务栏图标右上角的红底白字数字徽标(超过 99 显示 `99+`)
|
|
14
|
+
- 完成时播放系统提示音,音效可替换
|
|
15
|
+
- 只在会话**正常完成**(`turn/end` + `completed`)时计数;中断、报错、耗尽 token、子会话都不计
|
|
16
|
+
- DSH 是一窗多会话,因此即使窗口在前台也照常计数
|
|
17
|
+
- 回到窗口(后台 → 前台)自动清零;一直停留时约 10 秒后自动淡出
|
|
18
|
+
- Explorer 重启、窗口隐藏后徽标可自愈重绘
|
|
19
|
+
|
|
20
|
+
## 环境要求
|
|
21
|
+
|
|
22
|
+
| | |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| 系统 | Windows(`win32`,见 `os` 字段) |
|
|
25
|
+
| DSH | `>=0.2.1-alpha.2 <0.4.0` |
|
|
26
|
+
| 运行时 | Node >= 20、PowerShell 5.1+(Windows 自带,无需 `pwsh`) |
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
### 从 npm 安装(推荐)
|
|
31
|
+
|
|
32
|
+
DSH 侧栏「插件 → 安装第三方插件」输入包名:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
dsh-turn-ding
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
或用命令行(`desktop` 换成你使用的 profile):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
dsh plugin --profile desktop add dsh-turn-ding
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> 桌面版需**完全退出 DSH** 后安装或更新,装好重新打开。
|
|
45
|
+
> 终端里找不到 `dsh` 时,先在桌面版菜单选「管理 dsh 命令」。
|
|
46
|
+
|
|
47
|
+
### 从「发现」安装(DSH 0.2.2+ 实验功能)
|
|
48
|
+
|
|
49
|
+
在「插件 → 实验性插件」启用**自定义插件索引**,再到「发现 → 添加索引源」填入本仓库 `index.json` 的公开地址:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
https://raw.githubusercontent.com/arrongao/dsh-turn-ding/main/index.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
索引只是省掉手输包名这一步,装的仍是同一个 npm 包。
|
|
56
|
+
|
|
57
|
+
### 离线 / 本地目录
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
dsh plugin --profile desktop add "D:/path/to/dsh-turn-ding"
|
|
61
|
+
dsh plugin --profile desktop add "file:D:/path/to/dsh-turn-ding-0.1.1.tgz"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 配置
|
|
65
|
+
|
|
66
|
+
在对应 profile 的 `cordis.patch.yml`(Windows 默认 `%USERPROFILE%\.dsh\profiles\desktop\cordis.patch.yml`)里,给插件行加上 `config`:
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
- id: turn-ding
|
|
70
|
+
name: 'dsh-turn-ding'
|
|
71
|
+
config:
|
|
72
|
+
sound: true
|
|
73
|
+
soundFile: 'C:\Windows\Media\Windows Notify System Generic.wav'
|
|
74
|
+
badge: true
|
|
75
|
+
ignoreSubagents: true
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| 字段 | 默认 | 说明 |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `sound` | `true` | 是否播放提示音 |
|
|
81
|
+
| `soundFile` | `C:\Windows\Media\Windows Notify System Generic.wav` | 任意本地 `.wav` 路径 |
|
|
82
|
+
| `badge` | `true` | 是否显示数字徽标 |
|
|
83
|
+
| `ignoreSubagents` | `true` | 是否忽略子会话的结束事件 |
|
|
84
|
+
|
|
85
|
+
其它可选系统音效位于 `C:\Windows\Media\`,例如 `Windows Ding.wav`、`chimes.wav`。留空即使用上表默认值。
|
|
86
|
+
|
|
87
|
+
## 工作原理
|
|
88
|
+
|
|
89
|
+
- `lib/index.js` 监听 `session/event`,识别正常完成的 turn,通过 stdin 行协议驱动一个常驻辅助进程。
|
|
90
|
+
- `lib/badge-server.ps1` 用 Win32 `ITaskbarList3.SetOverlayIcon` 绘制徽标,用 `System.Media.SoundPlayer` 播放音效,并轮询处理焦点变化与窗口句柄重绑定。
|
|
91
|
+
|
|
92
|
+
DSH 的插件 host 是独立 Node 进程,拿不到 `BrowserWindow`,因此徽标由辅助进程按窗口句柄直接绘制到任务栏按钮上。
|
|
93
|
+
|
|
94
|
+
## 开发
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm pack --dry-run # 确认将要发布的文件
|
|
98
|
+
node test/host-harness.mjs # host 事件判定逻辑
|
|
99
|
+
node test/e2e-badge.mjs # 端到端徽标绘制
|
|
100
|
+
node test/focus-regression.mjs # "前台也计数"回归测试
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
仓库 `test/` 下另有辅助进程协议、自动清零、索引校验等脚本;截图为本地生成产物,不纳入版本控制。
|
|
104
|
+
|
|
105
|
+
### 发布(维护者)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npm login --registry=https://registry.npmjs.org/
|
|
109
|
+
npm version patch
|
|
110
|
+
npm pack --dry-run
|
|
111
|
+
npm publish --registry=https://registry.npmjs.org/ --access public
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
发布后同步 `index.json` 中的 `version` 与 `publishTimestamp`(Unix 秒),用 `@deepseek-ai/dsh-plugin-market-index` 的 `parseMarketIndex()` 校验通过,再更新托管地址。
|
|
115
|
+
|
|
116
|
+
> 若 `~/.npmrc` 指向了镜像源,publish 需显式加 `--registry=https://registry.npmjs.org/`。开启两步验证的账号发布时需输入 OTP,或改用 Automation token 免去验证码。
|
|
117
|
+
|
|
118
|
+
`os: ["win32"]` 会阻止在非 Windows 机器上安装。`engines.dsh` 仅作声明:安装器按 `peerDependencies` 做兼容校验,本包不设该字段。
|
|
119
|
+
|
|
120
|
+
## 已知限制
|
|
121
|
+
|
|
122
|
+
- 仅支持 Windows;macOS / Linux 需要另一套实现。
|
|
123
|
+
- 安装或更换版本后需重启 DSH 才会加载。
|
|
124
|
+
- 窗口隐藏到托盘时任务栏按钮不存在,徽标不可见;重新显示窗口后会自愈重绘。
|
|
125
|
+
- 徽标是任务栏 **overlay 图标**,不是 Windows 通知中心里的 Toast 数字。
|
|
126
|
+
|
|
127
|
+
## 项目结构
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
dsh-turn-ding/
|
|
131
|
+
├── package.json # dsh.bundle.patch 是"这是 DSH 插件"的标记
|
|
132
|
+
├── cordis.patch.yml # 插件注入声明
|
|
133
|
+
├── index.json # 「发现」用的格式 1 索引(托管到公开 URL 后使用)
|
|
134
|
+
├── README.md
|
|
135
|
+
├── LICENSE
|
|
136
|
+
├── .gitignore
|
|
137
|
+
├── lib/
|
|
138
|
+
│ ├── index.js # host 侧:事件监听与辅助进程生命周期
|
|
139
|
+
│ └── badge-server.ps1 # Win32 侧:徽标绘制、提示音、轮询维护
|
|
140
|
+
└── test/ # 验证脚本(截图为本地生成,不入库)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 许可证
|
|
144
|
+
|
|
145
|
+
[MIT](./LICENSE)
|
package/cordis.patch.yml
ADDED
package/index.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": 1,
|
|
3
|
+
"title": {
|
|
4
|
+
"en": "DSH Completion Reminders",
|
|
5
|
+
"zh": "DSH 完成提醒插件索引"
|
|
6
|
+
},
|
|
7
|
+
"description": {
|
|
8
|
+
"en": "Community plugin index for dsh-turn-ding — taskbar badge and completion chime for DeepSeek Harness.",
|
|
9
|
+
"zh": "dsh-turn-ding 的社区插件索引——为 DeepSeek Harness 提供任务栏数字徽标与完成提示音。"
|
|
10
|
+
},
|
|
11
|
+
"metadata": {
|
|
12
|
+
"labels": {
|
|
13
|
+
"windows-only": {
|
|
14
|
+
"title": { "en": "Windows only", "zh": "仅限 Windows" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"plugins": [
|
|
19
|
+
{
|
|
20
|
+
"name": "dsh-turn-ding",
|
|
21
|
+
"title": {
|
|
22
|
+
"en": "Turn Ding — completion badge & chime",
|
|
23
|
+
"zh": "完成提示 · 任务栏徽标与提示音"
|
|
24
|
+
},
|
|
25
|
+
"description": {
|
|
26
|
+
"en": "Shows an unread number badge on the Windows taskbar icon and plays a chime whenever a DeepSeek Harness session finishes.",
|
|
27
|
+
"zh": "每当一个 DeepSeek Harness 会话完成,就在 Windows 任务栏图标右上角显示未读数字徽标,并播放提示音。"
|
|
28
|
+
},
|
|
29
|
+
"author": "Arron Gao",
|
|
30
|
+
"homepage": "https://github.com/arrongao/dsh-turn-ding#readme",
|
|
31
|
+
"repository": {
|
|
32
|
+
"url": "https://github.com/arrongao/dsh-turn-ding"
|
|
33
|
+
},
|
|
34
|
+
"metadata": {
|
|
35
|
+
"keywords": ["badge", "notification", "taskbar", "windows", "completion"],
|
|
36
|
+
"labels": ["windows-only"]
|
|
37
|
+
},
|
|
38
|
+
"versions": [
|
|
39
|
+
{
|
|
40
|
+
"version": "0.1.1",
|
|
41
|
+
"publishTimestamp": 1794650620,
|
|
42
|
+
"source": {
|
|
43
|
+
"type": "npm",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"dsh": ">=0.2.1-alpha.2 <0.4.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)][int]$TargetPid,
|
|
3
|
+
[string]$Sound = 'C:\Windows\Media\Windows Notify System Generic.wav',
|
|
4
|
+
[int]$ClearOnFocus = 1,
|
|
5
|
+
[int]$SoundEnabled = 1,
|
|
6
|
+
[int]$BadgeEnabled = 1
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
$ErrorActionPreference = 'Stop'
|
|
10
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
11
|
+
|
|
12
|
+
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
|
|
13
|
+
using System;
|
|
14
|
+
using System.Drawing;
|
|
15
|
+
using System.Drawing.Drawing2D;
|
|
16
|
+
using System.Runtime.InteropServices;
|
|
17
|
+
|
|
18
|
+
namespace DshBadge {
|
|
19
|
+
[ComImport, Guid("56FDF344-FD6D-11d0-958A-006097C9A090")]
|
|
20
|
+
public class TaskbarListCoclass { }
|
|
21
|
+
|
|
22
|
+
[ComImport, Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf"), 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, bool fFullscreen);
|
|
30
|
+
void SetProgressValue(IntPtr hwnd, ulong completed, ulong total);
|
|
31
|
+
void SetProgressState(IntPtr hwnd, int flags);
|
|
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 reserved);
|
|
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 description);
|
|
40
|
+
void SetThumbnailTooltip(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string tip);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public static class Api {
|
|
44
|
+
static ITaskbarList3 list;
|
|
45
|
+
|
|
46
|
+
static ITaskbarList3 List() {
|
|
47
|
+
if (list == null) {
|
|
48
|
+
object coclass = new TaskbarListCoclass();
|
|
49
|
+
list = (ITaskbarList3)coclass;
|
|
50
|
+
list.HrInit();
|
|
51
|
+
}
|
|
52
|
+
return list;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public static void Apply(long hwndValue, int count, string tip) {
|
|
56
|
+
IntPtr hwnd = new IntPtr(hwndValue);
|
|
57
|
+
if (count <= 0) {
|
|
58
|
+
List().SetOverlayIcon(hwnd, IntPtr.Zero, "");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
IntPtr hicon = Render(count);
|
|
62
|
+
try { List().SetOverlayIcon(hwnd, hicon, tip == null ? "" : tip); }
|
|
63
|
+
finally { DestroyIcon(hicon); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 32x32 ARGB: red disc, white rim, bold white numeral -- the shape Windows
|
|
67
|
+
// itself uses for unread counts on a taskbar button.
|
|
68
|
+
public static IntPtr Render(int count) {
|
|
69
|
+
int s = 32;
|
|
70
|
+
Bitmap bmp = new Bitmap(s, s, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
|
71
|
+
using (Graphics g = Graphics.FromImage(bmp)) {
|
|
72
|
+
g.SmoothingMode = SmoothingMode.AntiAlias;
|
|
73
|
+
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
|
|
74
|
+
g.Clear(Color.Transparent);
|
|
75
|
+
float pad = s * 0.04f;
|
|
76
|
+
using (SolidBrush fill = new SolidBrush(Color.FromArgb(255, 209, 52, 56)))
|
|
77
|
+
using (Pen edge = new Pen(Color.FromArgb(255, 255, 255, 255), s * 0.06f)) {
|
|
78
|
+
g.FillEllipse(fill, pad, pad, s - 2 * pad, s - 2 * pad);
|
|
79
|
+
g.DrawEllipse(edge, pad, pad, s - 2 * pad, s - 2 * pad);
|
|
80
|
+
}
|
|
81
|
+
string text = count > 99 ? "99+" : count.ToString();
|
|
82
|
+
float em = s * (text.Length > 2 ? 0.40f : text.Length > 1 ? 0.52f : 0.68f);
|
|
83
|
+
using (Font f = new Font("Segoe UI", em, FontStyle.Bold, GraphicsUnit.Pixel)) {
|
|
84
|
+
StringFormat sf = new StringFormat();
|
|
85
|
+
sf.Alignment = StringAlignment.Center;
|
|
86
|
+
sf.LineAlignment = StringAlignment.Center;
|
|
87
|
+
g.DrawString(text, f, Brushes.White, new RectangleF(0, s * 0.02f, s, s), sf);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return bmp.GetHicon();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
[DllImport("user32.dll")]
|
|
94
|
+
public static extern bool DestroyIcon(IntPtr hIcon);
|
|
95
|
+
[DllImport("user32.dll")]
|
|
96
|
+
public static extern IntPtr GetForegroundWindow();
|
|
97
|
+
[DllImport("user32.dll")]
|
|
98
|
+
public static extern bool IsWindow(IntPtr hWnd);
|
|
99
|
+
[DllImport("user32.dll")]
|
|
100
|
+
public static extern bool IsIconic(IntPtr hWnd);
|
|
101
|
+
[DllImport("user32.dll")]
|
|
102
|
+
public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
'@
|
|
106
|
+
|
|
107
|
+
function Write-Line([string]$text) {
|
|
108
|
+
[Console]::Out.WriteLine($text)
|
|
109
|
+
[Console]::Out.Flush()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# The DSH window is owned by the Electron main process -- our parent process.
|
|
113
|
+
# Fall back to any process carrying a DSH main window if that lookup fails.
|
|
114
|
+
function Find-Window {
|
|
115
|
+
if ($TargetPid -gt 0) {
|
|
116
|
+
$p = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
|
|
117
|
+
if ($p -and $p.MainWindowHandle -ne [IntPtr]::Zero) { return $p.MainWindowHandle }
|
|
118
|
+
}
|
|
119
|
+
foreach ($n in @('DeepSeek Harness', 'DSH')) {
|
|
120
|
+
$c = Get-Process -Name $n -ErrorAction SilentlyContinue |
|
|
121
|
+
Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } |
|
|
122
|
+
Sort-Object StartTime | Select-Object -First 1
|
|
123
|
+
if ($c) { return $c.MainWindowHandle }
|
|
124
|
+
}
|
|
125
|
+
return [IntPtr]::Zero
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Play-Ding {
|
|
129
|
+
if ($SoundEnabled -ne 1) { return }
|
|
130
|
+
if (-not (Test-Path -LiteralPath $Sound)) { return }
|
|
131
|
+
try {
|
|
132
|
+
$player = New-Object System.Media.SoundPlayer -ArgumentList $Sound
|
|
133
|
+
$player.Play()
|
|
134
|
+
} catch { }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Show-Badge([int]$n) {
|
|
138
|
+
if ($BadgeEnabled -ne 1 -or $n -le 0) { return }
|
|
139
|
+
if ($target -eq [IntPtr]::Zero) { return }
|
|
140
|
+
$tip = if ($n -eq 1) { '1 个会话已完成' } else { "$n 个会话已完成" }
|
|
141
|
+
try { [DshBadge.Api]::Apply($target.ToInt64(), $n, $tip) } catch { }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
$target = Find-Window
|
|
145
|
+
$count = 0
|
|
146
|
+
$ticks = 0
|
|
147
|
+
$wasFocused = ($target -ne [IntPtr]::Zero) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
|
|
148
|
+
$focusedTicks = 0
|
|
149
|
+
$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
|
|
150
|
+
Write-Line ("ready hwnd=" + $target.ToInt64() + " pid=" + $TargetPid)
|
|
151
|
+
|
|
152
|
+
$task = $reader.ReadLineAsync()
|
|
153
|
+
while ($true) {
|
|
154
|
+
$got = $task.AsyncWaitHandle.WaitOne(500)
|
|
155
|
+
if ($got) {
|
|
156
|
+
$line = $task.Result
|
|
157
|
+
if ($null -eq $line) { break }
|
|
158
|
+
$task = $reader.ReadLineAsync()
|
|
159
|
+
$line = $line.Trim()
|
|
160
|
+
if ($line.Length -eq 0) { continue }
|
|
161
|
+
$parts = $line.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)
|
|
162
|
+
$cmd = $parts[0].ToLowerInvariant()
|
|
163
|
+
if ($cmd -eq 'quit') { break }
|
|
164
|
+
|
|
165
|
+
if ($target -eq [IntPtr]::Zero -or -not [DshBadge.Api]::IsWindow($target)) { $target = Find-Window }
|
|
166
|
+
|
|
167
|
+
switch ($cmd) {
|
|
168
|
+
'notify' {
|
|
169
|
+
# A turn finished. Always counted: DSH is one window shared by several
|
|
170
|
+
# chats, so "the window has focus" says nothing about whether the user is
|
|
171
|
+
# watching *this* chat. Clearing happens on regaining focus instead.
|
|
172
|
+
$count += 1
|
|
173
|
+
Show-Badge $count
|
|
174
|
+
Play-Ding
|
|
175
|
+
Write-Line ("notify count=" + $count)
|
|
176
|
+
}
|
|
177
|
+
'set' {
|
|
178
|
+
$n = 1
|
|
179
|
+
if ($parts.Length -gt 1) { [int]::TryParse($parts[1], [ref]$n) | Out-Null }
|
|
180
|
+
if ($n -le 0) { $n = 1 }
|
|
181
|
+
$count = $n
|
|
182
|
+
Show-Badge $count
|
|
183
|
+
Write-Line ("set " + $count)
|
|
184
|
+
}
|
|
185
|
+
'clear' {
|
|
186
|
+
$count = 0
|
|
187
|
+
if ($target -ne [IntPtr]::Zero) { try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { } }
|
|
188
|
+
Write-Line "clear"
|
|
189
|
+
}
|
|
190
|
+
'sound' {
|
|
191
|
+
Play-Ding
|
|
192
|
+
Write-Line "sound"
|
|
193
|
+
}
|
|
194
|
+
'ping' { Write-Line "pong" }
|
|
195
|
+
default { Write-Line ("unknown " + $cmd) }
|
|
196
|
+
}
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
$ticks += 1
|
|
201
|
+
if ($target -eq [IntPtr]::Zero -or -not [DshBadge.Api]::IsWindow($target)) {
|
|
202
|
+
$old = $target
|
|
203
|
+
$target = Find-Window
|
|
204
|
+
if ($target -ne [IntPtr]::Zero) {
|
|
205
|
+
Write-Line ("rebind hwnd=" + $target.ToInt64())
|
|
206
|
+
Show-Badge $count
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ($count -gt 0) {
|
|
212
|
+
# Clear on a background -> foreground transition (the usual "I came back"
|
|
213
|
+
# case), or let a badge shown while already focused fade after ~10s so it
|
|
214
|
+
# cannot stick to a window the user never left.
|
|
215
|
+
$focusedNow = ($ClearOnFocus -eq 1) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
|
|
216
|
+
if ($focusedNow -and -not $wasFocused) {
|
|
217
|
+
$count = 0
|
|
218
|
+
$focusedTicks = 0
|
|
219
|
+
try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
|
|
220
|
+
Write-Line "autoclear"
|
|
221
|
+
} elseif ($focusedNow -and $focusedTicks -ge 20) {
|
|
222
|
+
$count = 0
|
|
223
|
+
$focusedTicks = 0
|
|
224
|
+
try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
|
|
225
|
+
Write-Line "autofade"
|
|
226
|
+
} else {
|
|
227
|
+
if ($focusedNow) { $focusedTicks += 1 } else { $focusedTicks = 0 }
|
|
228
|
+
if ($ticks % 12 -eq 0) {
|
|
229
|
+
# Heal after Explorer restarts or a hide/show cycle drops the overlay.
|
|
230
|
+
Show-Badge $count
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
$wasFocused = $focusedNow
|
|
234
|
+
} else {
|
|
235
|
+
$wasFocused = ($ClearOnFocus -eq 1) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
|
|
236
|
+
$focusedTicks = 0
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if ($count -gt 0 -and $target -ne [IntPtr]::Zero) {
|
|
241
|
+
try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
|
|
242
|
+
}
|
|
243
|
+
Write-Line "bye"
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-turn-ding — host half.
|
|
3
|
+
*
|
|
4
|
+
* Watches `session/event` for completed turns and drives a long-lived PowerShell
|
|
5
|
+
* helper that paints a numeric overlay badge on the DSH taskbar button through
|
|
6
|
+
* `ITaskbarList3.SetOverlayIcon`, optionally playing a sound.
|
|
7
|
+
*
|
|
8
|
+
* The overlay icon is the only channel Windows gives a non-Electron process for
|
|
9
|
+
* a dock badge: the desktop main process exposes no badge IPC to the renderer,
|
|
10
|
+
* and the host runs as a separate Node child process, so Win32 from the host is
|
|
11
|
+
* the right seam.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
export const name = 'dsh-turn-ding'
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
21
|
+
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
sound: true,
|
|
24
|
+
soundFile: 'C:\\Windows\\Media\\Windows Notify System Generic.wav',
|
|
25
|
+
badge: true,
|
|
26
|
+
ignoreSubagents: true,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const POWERSHELL = 'powershell.exe'
|
|
30
|
+
|
|
31
|
+
class BadgeServer {
|
|
32
|
+
constructor(options, logger) {
|
|
33
|
+
this.options = options
|
|
34
|
+
this.logger = logger
|
|
35
|
+
this.child = null
|
|
36
|
+
this.count = 0
|
|
37
|
+
this.pending = []
|
|
38
|
+
this.ready = false
|
|
39
|
+
this.broken = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Launch the helper; failures degrade to "no badge" rather than throwing. */
|
|
43
|
+
start() {
|
|
44
|
+
const script = join(here, 'badge-server.ps1')
|
|
45
|
+
if (!existsSync(script)) {
|
|
46
|
+
this.broken = true
|
|
47
|
+
this.logger.warn('dsh-turn-ding: badge-server.ps1 is missing, badge disabled')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let child
|
|
52
|
+
try {
|
|
53
|
+
child = spawn(
|
|
54
|
+
POWERSHELL,
|
|
55
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script,
|
|
56
|
+
'-TargetPid', String(process.ppid || 0),
|
|
57
|
+
'-Sound', this.options.soundFile,
|
|
58
|
+
'-SoundEnabled', this.options.sound ? '1' : '0',
|
|
59
|
+
'-BadgeEnabled', this.options.badge ? '1' : '0'],
|
|
60
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true },
|
|
61
|
+
)
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.broken = true
|
|
64
|
+
this.logger.warn(`dsh-turn-ding: could not start the badge helper (${String(error)})`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.child = child
|
|
69
|
+
child.stdout.setEncoding('utf8')
|
|
70
|
+
child.stdout.on('data', (chunk) => {
|
|
71
|
+
for (const raw of String(chunk).split(/\r?\n/)) {
|
|
72
|
+
const line = raw.trim()
|
|
73
|
+
if (!line) continue
|
|
74
|
+
if (line.startsWith('ready')) {
|
|
75
|
+
this.ready = true
|
|
76
|
+
for (const command of this.pending.splice(0)) this.write(command)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
// The helper owns the authoritative counter: it reports the number it
|
|
80
|
+
// just painted, and tells us when it clears the badge itself.
|
|
81
|
+
const counted = /^notify count=(\d+)$/.exec(line)
|
|
82
|
+
if (counted) {
|
|
83
|
+
this.count = Number(counted[1])
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (line === 'autoclear' || line === 'autofade') this.count = 0
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
child.stderr.setEncoding('utf8')
|
|
90
|
+
child.stderr.on('data', (chunk) => {
|
|
91
|
+
const text = String(chunk).trim()
|
|
92
|
+
if (text) this.logger.warn(`dsh-turn-ding: badge helper: ${text}`)
|
|
93
|
+
})
|
|
94
|
+
child.on('error', (error) => {
|
|
95
|
+
this.broken = true
|
|
96
|
+
this.logger.warn(`dsh-turn-ding: badge helper failed (${String(error)})`)
|
|
97
|
+
})
|
|
98
|
+
child.on('exit', (code) => {
|
|
99
|
+
this.child = null
|
|
100
|
+
this.ready = false
|
|
101
|
+
if (code !== 0 && code !== null) this.logger.warn(`dsh-turn-ding: badge helper exited with ${code}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
write(command) {
|
|
106
|
+
if (!this.child || this.child.killed) return
|
|
107
|
+
if (!this.ready) {
|
|
108
|
+
this.pending.push(command)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
this.child.stdin.write(`${command}\n`)
|
|
113
|
+
} catch {
|
|
114
|
+
// Helper died mid-write; the next start() call recovers.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A turn finished. `notify` lets the helper decide whether the moment is
|
|
120
|
+
* badge-worthy: it only increments while the DSH window is in the background,
|
|
121
|
+
* so a turn that ends while the user is watching never leaves a stale number.
|
|
122
|
+
*/
|
|
123
|
+
bump() {
|
|
124
|
+
if (this.broken) return
|
|
125
|
+
if (!this.child) this.start()
|
|
126
|
+
this.write('notify')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
clear() {
|
|
130
|
+
this.count = 0
|
|
131
|
+
this.write('clear')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
dispose() {
|
|
135
|
+
this.count = 0
|
|
136
|
+
if (!this.child) return
|
|
137
|
+
try {
|
|
138
|
+
this.child.stdin.write('quit\n')
|
|
139
|
+
this.child.stdin.end()
|
|
140
|
+
} catch { /* already gone */ }
|
|
141
|
+
const child = this.child
|
|
142
|
+
this.child = null
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
if (!child.killed) {
|
|
145
|
+
try { child.kill() } catch { /* already gone */ }
|
|
146
|
+
}
|
|
147
|
+
}, 1500).unref?.()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function apply(ctx, config) {
|
|
152
|
+
const options = { ...DEFAULTS, ...(config && typeof config === 'object' ? config : {}) }
|
|
153
|
+
const logger = ctx.logger ?? console
|
|
154
|
+
const server = new BadgeServer(options, logger)
|
|
155
|
+
|
|
156
|
+
ctx.effect(() => {
|
|
157
|
+
server.start()
|
|
158
|
+
return () => server.dispose()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
ctx.on('session/event', (session, event) => {
|
|
162
|
+
if (!event || event.type !== 'turn/end') return
|
|
163
|
+
const reason = event.data?.reason
|
|
164
|
+
if (!reason || reason.kind !== 'completed') return
|
|
165
|
+
if (options.ignoreSubagents && (session?.header?.origin === 'subagent' || (session?.header?.delegationDepth ?? 0) > 0)) return
|
|
166
|
+
server.bump()
|
|
167
|
+
})
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-turn-ding",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Windows taskbar unread badge and completion chime for DeepSeek Harness turns",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"index.json",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"badge",
|
|
23
|
+
"notification",
|
|
24
|
+
"windows",
|
|
25
|
+
"taskbar"
|
|
26
|
+
],
|
|
27
|
+
"author": "Arron Gao <arron.gaoxc@gmail.com>",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/arrongao/dsh-turn-ding.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/arrongao/dsh-turn-ding#readme",
|
|
34
|
+
"os": [
|
|
35
|
+
"win32"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20",
|
|
39
|
+
"dsh": ">=0.2.1-alpha.2 <0.4.0"
|
|
40
|
+
},
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
},
|
|
45
|
+
"manifestVersion": 1
|
|
46
|
+
}
|
|
47
|
+
}
|