dsh-notify-tray 0.1.1 → 0.1.2
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 +19 -10
- package/README.zh-CN.md +19 -10
- package/lib/client.js +63 -9
- package/lib/index.js +13 -12
- package/lib/system.js +5 -9
- package/lib/tray/index.js +11 -24
- package/lib/tray/linux.js +4 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/assets/tray-win32.ps1 +0 -276
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
**English** | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
A notification + status-bar plugin for DeepSeek Harness (DSH). Works on macOS, Windows and Linux.
|
|
5
|
+
A notification + status-bar plugin for DeepSeek Harness (DSH). Works on macOS, Windows and Linux. It is one npm package that adapts to the OS it is installed on: macOS / Linux get notifications plus the status-bar whale; Windows has no status bar, so it gets notifications only, and its settings page shows no status-bar options.
|
|
6
6
|
|
|
7
7
|
- **System notifications** — when a task finishes, fails, needs approval, needs you to pick an option, or waits for your input, you get a native notification written in the voice of a little whale (e.g. *"🐳 All done! 'Write the weekly report' is finished — come check it out"*). Clicking a notification jumps straight to the session.
|
|
8
|
-
- **Status-bar whale** — a small whale lives in your status bar and tells you what's going on through its posture. The drop-down menu lists running tasks, items waiting for you, today's usage and recently finished tasks; click any entry to open that session.
|
|
9
|
-
- **Settings page** — every option can be changed directly on the DSH **Plugins** page, including the API price of the model you're currently using and the plan you subscribe to (Kimi / GLM / OpenCode / MiMo / Gemini / Grok / Claude / GPT / MiniMax / DeepSeek or custom). Balance and remaining quota are fetched automatically where the provider exposes an API; anything else can be filled in by hand.
|
|
8
|
+
- **Status-bar whale** (macOS / Linux) — a small whale lives in your status bar and tells you what's going on through its posture. The drop-down menu lists running tasks, items waiting for you, today's usage and recently finished tasks; click any entry to open that session.
|
|
9
|
+
- **Settings page** — every option can be changed directly on the DSH **Plugins** page (only the options your OS can use are shown), including the API price of the model you're currently using and the plan you subscribe to (Kimi / GLM / OpenCode / MiMo / Gemini / Grok / Claude / GPT / MiniMax / DeepSeek or custom). Balance and remaining quota are fetched automatically where the provider exposes an API; anything else can be filled in by hand.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -17,7 +17,11 @@ A notification + status-bar plugin for DeepSeek Harness (DSH). Works on macOS, W
|
|
|
17
17
|
| A `!` bubble | Something is waiting for you (approval / choice / input) |
|
|
18
18
|
| A `×` bubble | A task just failed (shown for 10 minutes by default) |
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
| OS | Notifications | Status-bar whale | Status-bar options on the settings page |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| macOS | Notification Center | Menu-bar icon following the menu-bar colour, with a label such as `Pending 1` / `Running 2` | All (show/hide, label style, today's spend in the label, failure hold time) |
|
|
23
|
+
| Linux (UOS / Deepin / Kylin / KDE …) | freedesktop notifications | System-tray icon, colour-coded (🔵 idle / running, 🟠 waiting for you, 🔴 just failed) | Show/hide, failure hold time |
|
|
24
|
+
| Windows | Toast notifications | None (Windows has no status bar) | None |
|
|
21
25
|
|
|
22
26
|
## Installation
|
|
23
27
|
|
|
@@ -34,7 +38,7 @@ dsh plugin --profile desktop add dsh-notify-tray
|
|
|
34
38
|
# Or the web / CLI profile
|
|
35
39
|
dsh plugin --profile web add dsh-notify-tray
|
|
36
40
|
# Pin a specific version
|
|
37
|
-
dsh plugin --profile desktop add dsh-notify-tray@0.1.
|
|
41
|
+
dsh plugin --profile desktop add dsh-notify-tray@0.1.2
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
**Option 3 — from the Git repository** (latest unreleased code)
|
|
@@ -43,7 +47,11 @@ Paste `https://github.com/ROYLeXJ/dsh-notify-tray.git` into **Plugins → Add pl
|
|
|
43
47
|
|
|
44
48
|
This route **requires Git**; without it you'll see `ERR_PNPM_GIT_RESOLVE_FAILED … 'git' is not recognized as an internal or external command`. On Windows install Git from <https://git-scm.com/download/win> (the defaults are fine) and restart DSH; on macOS run `git --version` once in Terminal and follow the prompt to install the command-line tools.
|
|
45
49
|
|
|
46
|
-
Restart DSH after installing.
|
|
50
|
+
Restart DSH after installing. The version on the plugin page is the one installed; the latest is listed on [npm](https://www.npmjs.com/package/dsh-notify-tray).
|
|
51
|
+
|
|
52
|
+
> **Got an older version?** When the official npm registry is unreachable, DSH falls back to the npmmirror registry, which syncs a little later, so a just-published release may not be there yet. Update again in a few minutes, or pin the version: `dsh plugin --profile desktop add dsh-notify-tray@0.1.2`.
|
|
53
|
+
|
|
54
|
+
To update: `dsh plugin --profile desktop update dsh-notify-tray`. To uninstall: `dsh plugin --profile desktop remove dsh-notify-tray`.
|
|
47
55
|
|
|
48
56
|
## Configuration
|
|
49
57
|
|
|
@@ -69,10 +77,11 @@ Every option can be changed under DSH **Plugins → Whale Notify Station → Con
|
|
|
69
77
|
| `notifyCompleted` / `notifyFailed` / `notifyApproval` / `notifyChoice` / `notifyInput` | all on | Toggle each of the five notification kinds |
|
|
70
78
|
| `channel` | `auto` | `auto`: the DSH window shows the notification when one is open, otherwise the OS does |
|
|
71
79
|
| `suppressWhenFocused` | `true` | Don't notify while you're looking at that session (switch to another app and you will be) |
|
|
72
|
-
| `
|
|
80
|
+
| `sound` | `true` | Play a sound with system notifications (macOS chime, Windows toast sound, Linux where the desktop supports it) |
|
|
81
|
+
| `tray` | `true` | Show the status-bar icon (macOS / Linux, takes effect after a restart; ignored on Windows) |
|
|
73
82
|
| `titleStyle` | `text` | What to show next to the whale in the macOS menu bar: `text` (Pending 1 / Running 2), `count` (numbers only), `icon` (whale only) |
|
|
74
83
|
| `showSpendInTitle` | `false` | Append today's spend to the macOS menu-bar label |
|
|
75
|
-
| `errorHoldMinutes` | `10` | How many minutes the icon stays red after a task fails |
|
|
84
|
+
| `errorHoldMinutes` | `10` | How many minutes the icon stays red / shows the `×` after a task fails (macOS / Linux) |
|
|
76
85
|
|
|
77
86
|
### Spend & pricing
|
|
78
87
|
|
|
@@ -119,13 +128,13 @@ Built-in plan presets (prices are the list prices at the time of writing and may
|
|
|
119
128
|
| GPT | ChatGPT Go / Plus / Pro 5x / Pro 20x / Business, API pay-as-you-go | No endpoint for regular keys |
|
|
120
129
|
| MiniMax | M Plan Go / Explore / Build (CN ¥49 / 119 / 469, intl. $22 / 55 / 132), legacy Coding Plan | 5-hour window remaining `GET /v1/coding_plan/remains` |
|
|
121
130
|
|
|
122
|
-
Anything that can't be fetched can be typed in by hand; empty fields are simply hidden.
|
|
131
|
+
Anything that can't be fetched can be typed in by hand; empty fields are simply hidden. On macOS / Linux the status-bar menu gains a **Plan · name · price · remaining** row whose submenu shows balance, remaining quota, reset time, the raw details returned by the endpoint, a **Refresh quota** action and links to the usage / pricing pages.
|
|
123
132
|
|
|
124
133
|
## Notes
|
|
125
134
|
|
|
126
135
|
- **When notifications fire** — a task that runs to completion counts as *completed*; an error, hitting the token limit or being blocked counts as *failed*; stopping a task yourself doesn't notify. For `ask_user_question`, questions with options and plan approvals count as *needs a choice*, questions without options as *needs input*; tool approvals count as *needs approval*. Only top-level sessions are tracked — sub-agents don't get their own notifications.
|
|
127
136
|
- **How spend is computed** — when signed in with a DeepSeek account, today's spend is accumulated from drops in the account balance (top-ups don't count as negative spend; subject to the platform's billing delay). In parallel, it's estimated from each `provider/model` route's tokens × unit price (price sources described under `priceSource` above); the **Today's usage** submenu breaks it down per model. The ledger lives in `~/.dsh/storages/dsh-notify-tray/ledger.json` and keeps 31 days.
|
|
128
|
-
- **Clicking
|
|
137
|
+
- **Clicking a notification / the status-bar icon / "Open DeepSeek Harness" in the menu** — brings up the desktop app when there is one, otherwise (`dsh web`) opens the web UI in the browser. Browser notifications require granting notification permission first.
|
|
129
138
|
|
|
130
139
|
## Development
|
|
131
140
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | **简体中文**
|
|
4
4
|
|
|
5
|
-
DeepSeek Harness(DSH)的通知 + 状态栏插件,支持 macOS、Windows、Linux
|
|
5
|
+
DeepSeek Harness(DSH)的通知 + 状态栏插件,支持 macOS、Windows、Linux。同一个 npm 包,装上后按系统自动区分:macOS / Linux 有通知 + 状态栏小鲸鱼;Windows 没有状态栏,只有通知,设置页里也不会出现状态栏相关的选项。
|
|
6
6
|
|
|
7
7
|
- **系统通知**:任务完成、任务失败、需要批准、需要你选择、需要你输入时弹系统通知,文案是小鲸鱼的口吻(比如「🐳 搞定啦~『写周报』做完了!快来验收」)。点击通知会跳到对应会话。
|
|
8
|
-
-
|
|
9
|
-
- **插件设置页**:所有选项都能在 DSH
|
|
8
|
+
- **状态栏小鲸鱼**(macOS / Linux):状态栏常驻一只小鲸鱼,用姿态告诉你现在的状态;下拉菜单里是正在运行的任务、等你处理的事项、今日用量和最近结束的任务,点一下就能跳到对应会话。
|
|
9
|
+
- **插件设置页**:所有选项都能在 DSH「插件」页里直接改(只显示当前系统用得上的选项),包括当前模型的 API 价格和你买的套餐(Kimi / GLM / OpenCode / MiMo / Gemini / Grok / Claude / GPT / MiniMax / DeepSeek 或自定义),能从接口拿到的余额 / 剩余额度自动拿,拿不到的手填。
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -17,7 +17,11 @@ DeepSeek Harness(DSH)的通知 + 状态栏插件,支持 macOS、Windows、
|
|
|
17
17
|
| 鼻子前冒出 `!` | 有事等你处理(批准 / 选择 / 输入) |
|
|
18
18
|
| 鼻子前冒出 `×` | 刚有任务失败(默认保持 10 分钟) |
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
| 系统 | 通知 | 状态栏小鲸鱼 | 设置页里的状态栏选项 |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| macOS | 系统通知中心 | 菜单栏图标,颜色跟随菜单栏,旁边显示 `待处理 1` / `运行中 2` | 全部(显示开关、旁边显示什么、带上今日花费、失败保持时间) |
|
|
23
|
+
| Linux(UOS / Deepin / 麒麟 / KDE 等) | freedesktop 通知 | 系统托盘图标,用颜色区分状态(🔵 空闲/运行中、🟠 等你处理、🔴 刚失败) | 显示开关、失败保持时间 |
|
|
24
|
+
| Windows | 系统通知(右下角弹窗) | 无(Windows 没有状态栏) | 无 |
|
|
21
25
|
|
|
22
26
|
## 安装
|
|
23
27
|
|
|
@@ -34,7 +38,7 @@ dsh plugin --profile desktop add dsh-notify-tray
|
|
|
34
38
|
# 或者 Web / 命令行 profile
|
|
35
39
|
dsh plugin --profile web add dsh-notify-tray
|
|
36
40
|
# 指定版本
|
|
37
|
-
dsh plugin --profile desktop add dsh-notify-tray@0.1.
|
|
41
|
+
dsh plugin --profile desktop add dsh-notify-tray@0.1.2
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
**方式三:从 Git 仓库装**(想用还没发版的最新代码时)
|
|
@@ -43,7 +47,11 @@ dsh plugin --profile desktop add dsh-notify-tray@0.1.0
|
|
|
43
47
|
|
|
44
48
|
这条路**需要本机装有 Git**,没有 Git 会报 `ERR_PNPM_GIT_RESOLVE_FAILED … 'git' 不是内部或外部命令`。Windows 先到 <https://git-scm.com/download/win> 下载安装(一路默认即可),装完重启 DSH;macOS 在终端里运行一次 `git --version`,按提示安装命令行工具即可。
|
|
45
49
|
|
|
46
|
-
装好后重启 DSH
|
|
50
|
+
装好后重启 DSH。插件页上显示的版本号就是装上的版本,最新版见 [npm](https://www.npmjs.com/package/dsh-notify-tray)。
|
|
51
|
+
|
|
52
|
+
> **装到的不是最新版?** 官方 npm 源连不上时,DSH 会退到 npmmirror(淘宝镜像)去装,镜像比官方源晚一点同步,刚发的新版可能还没到。等几分钟再更新,或指定版本 `dsh plugin --profile desktop add dsh-notify-tray@0.1.2`。
|
|
53
|
+
|
|
54
|
+
更新:`dsh plugin --profile desktop update dsh-notify-tray`。卸载:`dsh plugin --profile desktop remove dsh-notify-tray`。
|
|
47
55
|
|
|
48
56
|
## 设置
|
|
49
57
|
|
|
@@ -69,10 +77,11 @@ dsh plugin --profile desktop add dsh-notify-tray@0.1.0
|
|
|
69
77
|
| `notifyCompleted` / `notifyFailed` / `notifyApproval` / `notifyChoice` / `notifyInput` | 全部开启 | 分别控制五类通知 |
|
|
70
78
|
| `channel` | `auto` | `auto`:有 DSH 窗口时由窗口弹,没有窗口时由系统弹 |
|
|
71
79
|
| `suppressWhenFocused` | `true` | 你正在看这个会话时不弹通知(切到别的应用去就有了) |
|
|
72
|
-
| `
|
|
80
|
+
| `sound` | `true` | 系统通知带提示音(macOS「叮」一声、Windows 通知音、Linux 视桌面环境而定) |
|
|
81
|
+
| `tray` | `true` | 是否显示状态栏图标(macOS / Linux,修改后重启生效;Windows 忽略) |
|
|
73
82
|
| `titleStyle` | `text` | macOS 菜单栏鲸鱼旁边显示什么:`text`(待处理 1 / 运行中 2)、`count`(只有数字)、`icon`(只有鲸鱼) |
|
|
74
83
|
| `showSpendInTitle` | `false` | macOS 菜单栏文字里附带今日花费 |
|
|
75
|
-
| `errorHoldMinutes` | `10` |
|
|
84
|
+
| `errorHoldMinutes` | `10` | 任务失败后图标保持红色 / 挂「×」的分钟数(macOS / Linux) |
|
|
76
85
|
|
|
77
86
|
### 花费与价格
|
|
78
87
|
|
|
@@ -119,13 +128,13 @@ dsh plugin --profile desktop add dsh-notify-tray@0.1.0
|
|
|
119
128
|
| GPT | ChatGPT Go / Plus / Pro 5x / Pro 20x / Business、API 按量 | 普通 Key 无接口 |
|
|
120
129
|
| MiniMax | M Plan Go / Explore / Build(国内 ¥49 / 119 / 469,国际 $22 / 55 / 132)、旧版 Coding Plan | 5 小时窗口剩余 `GET /v1/coding_plan/remains` |
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
拿不到的信息就手填;留空也可以,留空的项不显示。macOS / Linux 的状态栏菜单里会多一行「套餐 · 名称 · 价格 · 剩余」,子菜单里有余额、剩余额度、重置时间、接口返回的明细、「刷新额度」和用量页 / 价格页链接。
|
|
123
132
|
|
|
124
133
|
## 说明
|
|
125
134
|
|
|
126
135
|
- **通知的触发时机**:任务跑完算「完成」;出错、超出 Token 上限、被拦截算「失败」;你自己点停止不弹通知。调用 `ask_user_question` 时,带选项的问题和计划审批算「需要选择」,不带选项的算「需要输入」;工具审批算「需要批准」。只跟踪顶层会话,子 agent 不单独弹通知。
|
|
127
136
|
- **花费怎么算**:用 DeepSeek 账户登录时,按账户余额的下降累计当天花费(充值不算负花费,会受平台扣费延迟影响);同时按当天各「provider/model」的 Token 乘以单价估算(单价来源见上面的 `priceSource`),「今日用量」子菜单里按模型分行显示。账本存在 `~/.dsh/storages/dsh-notify-tray/ledger.json`,保留 31 天。
|
|
128
|
-
-
|
|
137
|
+
- **点击通知 / 状态栏图标 / 菜单里的「打开 DeepSeek Harness」**:有桌面版时唤起桌面版,没有时(`dsh web`)在浏览器里打开 Web 界面。浏览器里的通知需要先允许通知权限。
|
|
129
138
|
|
|
130
139
|
## 已知限制
|
|
131
140
|
|
package/lib/client.js
CHANGED
|
@@ -66,8 +66,44 @@ window.__ModuleLoader__.load({
|
|
|
66
66
|
/** How often the page re-reads the host's live state (current model, plan quota). */
|
|
67
67
|
const LIVE_MS = 5000
|
|
68
68
|
|
|
69
|
+
// ---- which OS this is --------------------------------------------------------
|
|
70
|
+
// The host reports `process.platform` in its state (authoritative: with
|
|
71
|
+
// `dsh web` the browser may run elsewhere); until that arrives the
|
|
72
|
+
// browser's own platform is the guess. Each OS only gets what it has:
|
|
73
|
+
// Windows has no status bar, so no status-bar section at all; only the
|
|
74
|
+
// macOS menu bar shows text beside the whale.
|
|
75
|
+
function normalizePlatform(p) {
|
|
76
|
+
if (p === 'darwin' || p === 'win32') return p
|
|
77
|
+
if (p === 'linux' || p === 'freebsd' || p === 'openbsd') return 'linux'
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
function guessPlatform() {
|
|
81
|
+
try {
|
|
82
|
+
const nav = window.navigator || {}
|
|
83
|
+
const text = String((nav.userAgentData && nav.userAgentData.platform) || nav.platform || nav.userAgent || '')
|
|
84
|
+
// Mac first: "Darwin" contains "win".
|
|
85
|
+
if (/mac|iphone|ipad|darwin/iu.test(text)) return 'darwin'
|
|
86
|
+
if (/win/iu.test(text)) return 'win32'
|
|
87
|
+
if (/linux|x11|bsd|cros/iu.test(text)) return 'linux'
|
|
88
|
+
} catch (e) {}
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
/** What each OS shows; an unknown OS shows everything. */
|
|
92
|
+
const FEATURES = {
|
|
93
|
+
darwin: { tray: true, menuBarTitle: true },
|
|
94
|
+
linux: { tray: true, menuBarTitle: false },
|
|
95
|
+
win32: { tray: false, menuBarTitle: false },
|
|
96
|
+
}
|
|
97
|
+
const ALL_FEATURES = { tray: true, menuBarTitle: true }
|
|
98
|
+
/** A translator that prefers `<key>_<platform>` when this page defines one. */
|
|
99
|
+
function platformT(t, platform) {
|
|
100
|
+
if (!platform) return t
|
|
101
|
+
return key => (zh[`${key}_${platform}`] !== undefined ? t(`${key}_${platform}`) : t(key))
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
const zh = {
|
|
70
105
|
summary: '小鲸鱼什么时候来找你、状态栏里显示什么、今天的花费怎么算、你在用哪家的套餐,都在这里调。',
|
|
106
|
+
summary_win32: '小鲸鱼什么时候来找你、今天的花费怎么算、你在用哪家的套餐,都在这里调。Windows 没有状态栏,小鲸鱼只用通知来找你。',
|
|
71
107
|
sectionNotify: '什么时候来找你',
|
|
72
108
|
sectionNotifyHint: '五种事情,每种都可以单独关掉。',
|
|
73
109
|
notifyCompleted: '任务做完了',
|
|
@@ -91,10 +127,14 @@ window.__ModuleLoader__.load({
|
|
|
91
127
|
suppressWhenFocused: '我正盯着这个会话时就别弹了',
|
|
92
128
|
suppressWhenFocusedHint: '关掉后,哪怕你正看着它跑完也会弹一条。',
|
|
93
129
|
sound: '带提示音',
|
|
94
|
-
soundHint: '
|
|
130
|
+
soundHint: '系统通知响一声。',
|
|
131
|
+
soundHint_darwin: 'macOS 系统通知的「叮」一声。',
|
|
132
|
+
soundHint_win32: 'Windows 系统通知(右下角弹窗)的提示音。',
|
|
133
|
+
soundHint_linux: '系统通知的提示音(要桌面环境支持)。',
|
|
95
134
|
sectionTray: '状态栏里的小鲸鱼',
|
|
96
135
|
tray: '显示状态栏小鲸鱼',
|
|
97
136
|
trayHint: '改了要重启 DSH 才生效。',
|
|
137
|
+
trayHint_linux: '显示在系统托盘里(UOS / Deepin / 麒麟 / KDE 自带;GNOME 要装 AppIndicator 扩展)。改了要重启 DSH 才生效。',
|
|
98
138
|
titleStyle: '鲸鱼旁边显示什么',
|
|
99
139
|
titleText: '文字',
|
|
100
140
|
titleCount: '只有数字',
|
|
@@ -104,6 +144,8 @@ window.__ModuleLoader__.load({
|
|
|
104
144
|
showSpendInTitleHint: '例如「运行中 1 · ¥1.82」。',
|
|
105
145
|
errorHoldMinutes: '失败后「×」保持几分钟',
|
|
106
146
|
errorHoldMinutesHint: '0 表示任务一失败就只记在菜单里,鲸鱼脸上不挂「×」。',
|
|
147
|
+
errorHoldMinutes_linux: '失败后鲸鱼保持红色几分钟',
|
|
148
|
+
errorHoldMinutesHint_linux: '0 表示任务一失败就只记在菜单里,鲸鱼不变红。',
|
|
107
149
|
sectionSpend: '今天花了多少钱',
|
|
108
150
|
spendSource: '花费怎么算',
|
|
109
151
|
spendAuto: '自动',
|
|
@@ -175,6 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
175
217
|
planQuotaUrlHint: '留空用预设地址。',
|
|
176
218
|
planAutoQuota: '自动刷新',
|
|
177
219
|
planAutoQuotaHint: '按下面的间隔定时查询,菜单里也能手动刷新。',
|
|
220
|
+
planAutoQuotaHint_win32: '按下面的间隔定时查询,也可以点下面的「立即刷新」。',
|
|
178
221
|
quotaPollMinutes: '几分钟刷新一次',
|
|
179
222
|
quotaPollMinutesHint: '',
|
|
180
223
|
refreshNow: '立即刷新',
|
|
@@ -202,6 +245,7 @@ window.__ModuleLoader__.load({
|
|
|
202
245
|
}
|
|
203
246
|
const en = {
|
|
204
247
|
summary: 'When the whale comes to find you, what the status bar shows, how today\'s spend is counted, and which plan you are on.',
|
|
248
|
+
summary_win32: 'When the whale comes to find you, how today\'s spend is counted, and which plan you are on. Windows has no status bar, so the whale reaches you through notifications only.',
|
|
205
249
|
sectionNotify: 'When to notify you',
|
|
206
250
|
sectionNotifyHint: 'Five kinds of events, each can be switched off.',
|
|
207
251
|
notifyCompleted: 'A task finished',
|
|
@@ -225,10 +269,14 @@ window.__ModuleLoader__.load({
|
|
|
225
269
|
suppressWhenFocused: 'Stay quiet while I am looking at that session',
|
|
226
270
|
suppressWhenFocusedHint: 'Off: notify even when you watch it finish.',
|
|
227
271
|
sound: 'Play a sound',
|
|
228
|
-
soundHint: '
|
|
272
|
+
soundHint: 'System notifications make a sound.',
|
|
273
|
+
soundHint_darwin: 'The macOS notification chime.',
|
|
274
|
+
soundHint_win32: 'The Windows notification (toast) sound.',
|
|
275
|
+
soundHint_linux: 'The notification sound, where the desktop supports one.',
|
|
229
276
|
sectionTray: 'The whale in the status bar',
|
|
230
277
|
tray: 'Show the status-bar whale',
|
|
231
278
|
trayHint: 'Takes effect after restarting DSH.',
|
|
279
|
+
trayHint_linux: 'Shown in the system tray (built into UOS / Deepin / Kylin / KDE; GNOME needs the AppIndicator extension). Takes effect after restarting DSH.',
|
|
232
280
|
titleStyle: 'Text beside the whale',
|
|
233
281
|
titleText: 'Words',
|
|
234
282
|
titleCount: 'Count only',
|
|
@@ -238,6 +286,8 @@ window.__ModuleLoader__.load({
|
|
|
238
286
|
showSpendInTitleHint: 'e.g. "运行中 1 · ¥1.82".',
|
|
239
287
|
errorHoldMinutes: 'Minutes the "×" stays after a failure',
|
|
240
288
|
errorHoldMinutesHint: '0: failures are only listed in the menu.',
|
|
289
|
+
errorHoldMinutes_linux: 'Minutes the whale stays red after a failure',
|
|
290
|
+
errorHoldMinutesHint_linux: '0: failures are only listed in the menu; the whale never turns red.',
|
|
241
291
|
sectionSpend: 'Today\'s spend',
|
|
242
292
|
spendSource: 'How spend is counted',
|
|
243
293
|
spendAuto: 'Auto',
|
|
@@ -309,6 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
309
359
|
planQuotaUrlHint: 'Blank: preset URL.',
|
|
310
360
|
planAutoQuota: 'Refresh automatically',
|
|
311
361
|
planAutoQuotaHint: 'Polled at the interval below; the tray menu can refresh by hand too.',
|
|
362
|
+
planAutoQuotaHint_win32: 'Polled at the interval below; "Refresh now" below fetches by hand.',
|
|
312
363
|
quotaPollMinutes: 'Refresh interval (minutes)',
|
|
313
364
|
quotaPollMinutesHint: '',
|
|
314
365
|
refreshNow: 'Refresh now',
|
|
@@ -398,7 +449,8 @@ window.__ModuleLoader__.load({
|
|
|
398
449
|
return fetchJson(ROUTE + '?after=-2').then(body => { if (body) { live.state = body; publishLive() } }).catch(() => {})
|
|
399
450
|
}
|
|
400
451
|
// Test seam: lets a test drive the loaders without React effects.
|
|
401
|
-
|
|
452
|
+
const currentPlatform = () => normalizePlatform(live.state && live.state.platform) || guessPlatform()
|
|
453
|
+
exports.__settingsLive = { live, loadCatalog, loadState, currentPlatform }
|
|
402
454
|
function useLive() {
|
|
403
455
|
const [, bump] = React.useState(0)
|
|
404
456
|
React.useEffect(() => {
|
|
@@ -649,14 +701,16 @@ window.__ModuleLoader__.load({
|
|
|
649
701
|
}
|
|
650
702
|
|
|
651
703
|
function Page(props) {
|
|
652
|
-
if (props.view === 'summary') return props.t('summary')
|
|
704
|
+
if (props.view === 'summary') return platformT(props.t, currentPlatform())('summary')
|
|
653
705
|
return h(FullPage, props)
|
|
654
706
|
}
|
|
655
707
|
|
|
656
708
|
function FullPage(props) {
|
|
657
|
-
const { t } = props
|
|
658
709
|
const state = props.useNotifyTray(snapshot => snapshot)
|
|
659
710
|
const liveData = useLive()
|
|
711
|
+
const platform = currentPlatform()
|
|
712
|
+
const features = (platform && FEATURES[platform]) || ALL_FEATURES
|
|
713
|
+
const t = platformT(props.t, platform)
|
|
660
714
|
const disabled = !state.writable
|
|
661
715
|
const common = { t, disabled, edit: props.edit, resetField: props.resetField }
|
|
662
716
|
const f = state.fields
|
|
@@ -673,11 +727,11 @@ window.__ModuleLoader__.load({
|
|
|
673
727
|
h(ChoiceRow, { name: 'channel', field: f.channel, hint: 'channelHint', ...common, options: [['auto', 'channelAuto'], ['client', 'channelClient'], ['system', 'channelSystem'], ['both', 'channelBoth'], ['off', 'channelOff']] }),
|
|
674
728
|
toggle('suppressWhenFocused'),
|
|
675
729
|
toggle('sound')),
|
|
676
|
-
h(Section, { title: t('sectionTray') },
|
|
730
|
+
features.tray ? h(Section, { title: t('sectionTray') },
|
|
677
731
|
toggle('tray'),
|
|
678
|
-
h(ChoiceRow, { name: 'titleStyle', field: f.titleStyle, hint: 'titleStyleHint', ...common, options: [['text', 'titleText'], ['count', 'titleCount'], ['icon', 'titleIcon']] }),
|
|
679
|
-
toggle('showSpendInTitle'),
|
|
680
|
-
h('div', { style: { marginTop: 12 } }, h(NumberField, { name: 'errorHoldMinutes', field: f.errorHoldMinutes, ...common }))),
|
|
732
|
+
features.menuBarTitle ? h(ChoiceRow, { name: 'titleStyle', field: f.titleStyle, hint: 'titleStyleHint', ...common, options: [['text', 'titleText'], ['count', 'titleCount'], ['icon', 'titleIcon']] }) : null,
|
|
733
|
+
features.menuBarTitle ? toggle('showSpendInTitle') : null,
|
|
734
|
+
h('div', { style: { marginTop: 12 } }, h(NumberField, { name: 'errorHoldMinutes', field: f.errorHoldMinutes, ...common }))) : null,
|
|
681
735
|
h(Section, { title: t('sectionSpend') },
|
|
682
736
|
h(ChoiceRow, { name: 'spendSource', field: f.spendSource, hint: 'spendSourceHint', ...common, options: [['auto', 'spendAuto'], ['balance', 'spendBalance'], ['estimate', 'spendEstimate']] }),
|
|
683
737
|
h(ChoiceRow, { name: 'currency', field: f.currency, ...common, options: [['CNY', 'CNY'], ['USD', 'USD']] }),
|
package/lib/index.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - Knows the user's plan (Kimi / GLM / OpenCode / MiMo / Gemini / Grok /
|
|
10
10
|
* Claude / GPT / MiniMax / DeepSeek or custom), its price list, and
|
|
11
11
|
* polls the provider's quota endpoint when it has one and an API key.
|
|
12
|
-
* - Drives a status-bar icon on macOS
|
|
12
|
+
* - Drives a status-bar icon on macOS (menu bar) and Linux (StatusNotifierItem).
|
|
13
|
+
* Windows has no status bar of the kind: there the plugin only notifies.
|
|
13
14
|
* - Serves `GET notify-tray/state` (+ `/catalog`, `POST /refresh`) to the
|
|
14
15
|
* browser half, which shows native notifications from the DSH window and
|
|
15
16
|
* renders the settings page; when no window is polling, the host falls
|
|
@@ -24,7 +25,7 @@ import { buildView, DONE_HOLD_MS, TITLES } from './format.js'
|
|
|
24
25
|
import { Ledger } from './ledger.js'
|
|
25
26
|
import { describeRoute, loadPiCatalog, makeResolver } from './pricing.js'
|
|
26
27
|
import { fetchQuota } from './quota.js'
|
|
27
|
-
import {
|
|
28
|
+
import { openUrl, showApp, systemNotify } from './system.js'
|
|
28
29
|
import { startTray } from './tray/index.js'
|
|
29
30
|
import { Tracker } from './tracker.js'
|
|
30
31
|
import { speak } from './voice.js'
|
|
@@ -40,6 +41,8 @@ const REFRESH_PATH = '/notify-tray/refresh'
|
|
|
40
41
|
const DSH_VERSION = '0.2.1-rc.2'
|
|
41
42
|
/** A window that polled within this window counts as "listening". */
|
|
42
43
|
const CLIENT_FRESH_MS = 8000
|
|
44
|
+
/** Platforms with a status bar the whale can live in. */
|
|
45
|
+
export const TRAY_PLATFORMS = new Set(['darwin', 'linux', 'freebsd'])
|
|
43
46
|
const EVENT_SWITCH = {
|
|
44
47
|
completed: 'notifyCompleted',
|
|
45
48
|
failed: 'notifyFailed',
|
|
@@ -67,11 +70,11 @@ export const Config = z.object({
|
|
|
67
70
|
z.const('client').description('只由 DSH 窗口弹'),
|
|
68
71
|
z.const('system').description('只由系统弹(适合 dsh web / 命令行)'),
|
|
69
72
|
z.const('both').description('两边都弹'),
|
|
70
|
-
z.const('off').description('
|
|
73
|
+
z.const('off').description('不弹通知(macOS / Linux 上只看状态栏)'),
|
|
71
74
|
]).default('auto').description('通知通道').volatile(),
|
|
72
75
|
suppressWhenFocused: z.boolean().default(true).description('正在看这个会话时不弹').volatile(),
|
|
73
|
-
sound: z.boolean().default(true).description('
|
|
74
|
-
tray: z.boolean().default(true).description('
|
|
76
|
+
sound: z.boolean().default(true).description('系统通知带提示音').volatile(),
|
|
77
|
+
tray: z.boolean().default(true).description('显示状态栏图标(macOS / Linux,修改后重启 DSH 生效;Windows 没有状态栏,忽略此项)').volatile(),
|
|
75
78
|
titleStyle: z.union([
|
|
76
79
|
z.const('text').description('文字:待处理 1 / 运行中 2'),
|
|
77
80
|
z.const('count').description('只显示数字'),
|
|
@@ -217,10 +220,9 @@ export function apply(ctx, config) {
|
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
const trayWanted = cfg.tray && !(process.platform === 'win32' && desktopHosted())
|
|
223
|
+
// Windows has no status bar to live in (and the desktop app keeps a tray
|
|
224
|
+
// icon of its own): there the plugin is notifications only.
|
|
225
|
+
const trayWanted = cfg.tray && TRAY_PLATFORMS.has(process.platform)
|
|
224
226
|
ctx.effect(() => {
|
|
225
227
|
let disposed = false
|
|
226
228
|
let started
|
|
@@ -259,9 +261,6 @@ export function apply(ctx, config) {
|
|
|
259
261
|
title: said.title,
|
|
260
262
|
body: said.body,
|
|
261
263
|
sound: cfg.sound,
|
|
262
|
-
seq: event.seq,
|
|
263
|
-
action: `open:${event.sessionId}`,
|
|
264
|
-
tray,
|
|
265
264
|
webOrigin,
|
|
266
265
|
onClick: () => onAction(`open:${event.sessionId}`),
|
|
267
266
|
}).then(ok => debug('system notify', ok)).catch(error => logger.warn('系统通知失败', error))
|
|
@@ -430,6 +429,8 @@ export function apply(ctx, config) {
|
|
|
430
429
|
const enabled = Object.fromEntries(Object.entries(EVENT_SWITCH).map(([type, key]) => [type, Boolean(cfg[key])]))
|
|
431
430
|
const body = {
|
|
432
431
|
boot,
|
|
432
|
+
// The settings page shows only what this OS has (no status-bar section on Windows).
|
|
433
|
+
platform: process.platform,
|
|
433
434
|
seq: tracker.seq,
|
|
434
435
|
// A first poll (after = -1) only learns the cursor; history is never replayed.
|
|
435
436
|
// Each event carries its wording, so the window shows exactly what the host would.
|
package/lib/system.js
CHANGED
|
@@ -77,7 +77,7 @@ function windowsToast(xml) {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* Show one native notification.
|
|
80
|
-
* @param {{title: string, body: string, sound?: boolean, onClick?: () => void,
|
|
80
|
+
* @param {{title: string, body: string, sound?: boolean, onClick?: () => void, webOrigin?: () => string | undefined}} n
|
|
81
81
|
*/
|
|
82
82
|
export async function systemNotify(n) {
|
|
83
83
|
if (process.platform === 'darwin') {
|
|
@@ -85,20 +85,16 @@ export async function systemNotify(n) {
|
|
|
85
85
|
return run('/usr/bin/osascript', ['-e', `display notification ${as(n.body)} with title ${as(n.title)}${sound}`])
|
|
86
86
|
}
|
|
87
87
|
if (process.platform === 'win32') {
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return true
|
|
92
|
-
}
|
|
93
|
-
// Otherwise (the desktop app, which has its own tray icon) a plain toast:
|
|
94
|
-
// clicking it launches dsh://open, which the app answers by focusing its window.
|
|
88
|
+
// A plain toast (Windows gets no status-bar whale): in the desktop app,
|
|
89
|
+
// clicking it launches dsh://open, which the app answers by focusing its
|
|
90
|
+
// window; under `dsh web` it opens the web GUI.
|
|
95
91
|
const launch = desktopHosted() ? 'dsh://open' : safeOrigin(n.webOrigin)
|
|
96
92
|
return windowsToast(toastXml({ title: n.title, body: n.body, launch, sound: n.sound !== false }))
|
|
97
93
|
}
|
|
98
94
|
// Linux / BSD: D-Bus first (clickable), notify-send as a fallback.
|
|
99
95
|
try {
|
|
100
96
|
const { linuxNotify } = await import('./tray/linux.js')
|
|
101
|
-
await linuxNotify({ title: n.title, body: n.body, onClick: n.onClick })
|
|
97
|
+
await linuxNotify({ title: n.title, body: n.body, sound: n.sound !== false, onClick: n.onClick })
|
|
102
98
|
return true
|
|
103
99
|
} catch {
|
|
104
100
|
return run('notify-send', ['-a', 'DeepSeek Harness', n.title, n.body])
|
package/lib/tray/index.js
CHANGED
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
* Tray front door: starts the right per-platform helper and feeds it views.
|
|
3
3
|
*
|
|
4
4
|
* - macOS: `osascript -l JavaScript assets/tray-darwin.js` (NSStatusItem)
|
|
5
|
-
* - Windows: `powershell -File assets/tray-win32.ps1` (WinForms NotifyIcon) —
|
|
6
|
-
* only for `dsh web`; the desktop app has a tray icon of its own
|
|
7
|
-
* and lib/index.js never asks for a second one there.
|
|
8
5
|
* - Linux: in-process StatusNotifierItem over D-Bus (lib/tray/linux.js)
|
|
6
|
+
* - Windows: none. Windows has no status bar of this kind (and the desktop
|
|
7
|
+
* app keeps a tray icon of its own); lib/index.js never asks.
|
|
9
8
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* leaves an orphan icon behind. `{"quit": true}` in the view asks
|
|
14
|
-
*
|
|
15
|
-
* a ghost icon in the notification area until the mouse passes over it).
|
|
9
|
+
* The macOS helper reads the whale geometry from icon.json once, then an
|
|
10
|
+
* atomically rewritten view.json for state, and reports menu clicks as JSON
|
|
11
|
+
* lines on stdout. It also watches the host pid, so a crashed host never
|
|
12
|
+
* leaves an orphan icon behind. `{"quit": true}` in the view asks it to take
|
|
13
|
+
* its icon down and exit on its own.
|
|
16
14
|
*/
|
|
17
15
|
import { spawn } from 'node:child_process'
|
|
18
16
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
@@ -31,21 +29,14 @@ function helperCommand(viewFile) {
|
|
|
31
29
|
if (process.platform === 'darwin') {
|
|
32
30
|
return ['/usr/bin/osascript', ['-l', 'JavaScript', join(ASSETS, 'tray-darwin.js'), viewFile, pid]]
|
|
33
31
|
}
|
|
34
|
-
if (process.platform === 'win32') {
|
|
35
|
-
const root = process.env.SystemRoot ?? 'C:\\Windows'
|
|
36
|
-
const ps = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
37
|
-
return [ps, ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
|
|
38
|
-
'-File', join(ASSETS, 'tray-win32.ps1'), viewFile, pid]]
|
|
39
|
-
}
|
|
40
32
|
return undefined
|
|
41
33
|
}
|
|
42
34
|
|
|
43
35
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
44
36
|
|
|
45
37
|
/**
|
|
46
|
-
* Replace `file` with `tmp
|
|
47
|
-
*
|
|
48
|
-
* EPERM/EBUSY; the read takes well under a millisecond, so just try again.
|
|
38
|
+
* Replace `file` with `tmp`, retrying the transient EPERM/EBUSY a rename can
|
|
39
|
+
* hit while the helper has the file open for its (sub-millisecond) read.
|
|
49
40
|
*/
|
|
50
41
|
async function replaceFile(tmp, file) {
|
|
51
42
|
for (let attempt = 0; ; attempt += 1) {
|
|
@@ -70,7 +61,7 @@ export async function startTray({ stateDir, icon, onAction, logger }) {
|
|
|
70
61
|
if (process.platform === 'linux' || process.platform === 'freebsd') {
|
|
71
62
|
const { startLinuxTray } = await import('./linux.js')
|
|
72
63
|
const tray = await startLinuxTray({ onAction, logger })
|
|
73
|
-
return { ...tray, kind: 'sni',
|
|
64
|
+
return { ...tray, kind: 'sni', get alive() { return true } }
|
|
74
65
|
}
|
|
75
66
|
|
|
76
67
|
const viewFile = join(stateDir, 'view.json')
|
|
@@ -133,12 +124,8 @@ export async function startTray({ stateDir, icon, onAction, logger }) {
|
|
|
133
124
|
return {
|
|
134
125
|
kind: process.platform,
|
|
135
126
|
update(view) {
|
|
136
|
-
return write(
|
|
127
|
+
return write(view)
|
|
137
128
|
},
|
|
138
|
-
/** Windows only: show a balloon/toast through the tray icon. */
|
|
139
|
-
toast: process.platform === 'win32'
|
|
140
|
-
? (toast) => (child ? write({ ...lastView, toast }) : undefined)
|
|
141
|
-
: undefined,
|
|
142
129
|
get alive() { return child !== undefined },
|
|
143
130
|
async stop() {
|
|
144
131
|
stopped = true
|
package/lib/tray/linux.js
CHANGED
|
@@ -306,7 +306,7 @@ export async function startLinuxTray({ onAction, logger }) {
|
|
|
306
306
|
const notifyActions = new Map()
|
|
307
307
|
|
|
308
308
|
/** Show one freedesktop notification; `onClick` runs when its default action is invoked. */
|
|
309
|
-
export async function linuxNotify({ title, body, onClick }) {
|
|
309
|
+
export async function linuxNotify({ title, body, sound = true, onClick }) {
|
|
310
310
|
const link = await connect()
|
|
311
311
|
const { dbus, bus } = link
|
|
312
312
|
if (!link.notifications) {
|
|
@@ -321,6 +321,9 @@ export async function linuxNotify({ title, body, onClick }) {
|
|
|
321
321
|
}
|
|
322
322
|
const iface = link.notifications
|
|
323
323
|
const hints = { urgency: new dbus.Variant('y', 1), 'desktop-entry': new dbus.Variant('s', 'deepseek-harness') }
|
|
324
|
+
// freedesktop sound-naming spec; servers that play sounds honour these hints.
|
|
325
|
+
if (sound) hints['sound-name'] = new dbus.Variant('s', 'message-new-instant')
|
|
326
|
+
else hints['suppress-sound'] = new dbus.Variant('b', true)
|
|
324
327
|
const id = await iface.Notify('DeepSeek Harness', 0, 'dialog-information', title, body, ['default', '打开'], hints, -1)
|
|
325
328
|
if (onClick) notifyActions.set(id, onClick)
|
|
326
329
|
return id
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Whale Notify Station",
|
|
4
|
-
"description": "(Click to open the detailed settings) A plump blue whale girl
|
|
4
|
+
"description": "(Click to open the detailed settings) A plump blue whale girl: when a task finishes, needs a choice, or waits for your words, she blows bubbles and pops a notification — and keeps today's spend in mind for you. On macOS / Linux she also lives in your status bar; Windows has no status bar, so there she only notifies."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "小鲸鱼通知站",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "(点击可进行详细设置)一只蓝色的大肥鲸鱼娘:任务做完、要你选一个、等你写几个字的时候,她会冒着泡泡弹通知来找你~ 顺便帮你记着今天花了多少钱。macOS / Linux 上她还会常驻状态栏;Windows 没有状态栏,只弹通知。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-tray",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "DeepSeek Harness 通知 +
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "DeepSeek Harness 通知 + 状态栏插件:任务完成、需要输入、需要选择时弹系统通知(macOS / Windows / Linux);macOS 与 Linux(含 UOS、Deepin、麒麟)状态栏常驻小图标,显示任务进度与花费。Windows 没有状态栏,只弹通知。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
7
7
|
"deepseek-harness",
|
package/assets/tray-win32.ps1
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
# Windows notification-area helper for dsh-notify-tray.
|
|
2
|
-
# Runs on the stock Windows PowerShell 5.1 (WinForms NotifyIcon); no binary to ship.
|
|
3
|
-
#
|
|
4
|
-
# Usage: powershell -NoProfile -STA -ExecutionPolicy Bypass -File tray-win32.ps1 <view.json> <host pid>
|
|
5
|
-
# The host rewrites view.json atomically; this script polls it. Menu clicks and
|
|
6
|
-
# balloon clicks are written to stdout as one JSON line each: {"action":"<id>"}.
|
|
7
|
-
param(
|
|
8
|
-
[Parameter(Mandatory = $true)][string]$ViewPath,
|
|
9
|
-
[int]$HostPid = 0
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
$ErrorActionPreference = 'Stop'
|
|
13
|
-
Add-Type -AssemblyName System.Windows.Forms
|
|
14
|
-
Add-Type -AssemblyName System.Drawing
|
|
15
|
-
Add-Type -Namespace 'DshTray' -Name 'Win32' -MemberDefinition @'
|
|
16
|
-
[DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr handle);
|
|
17
|
-
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
18
|
-
'@
|
|
19
|
-
# Declare (system) DPI awareness before the first window handle exists, so
|
|
20
|
-
# SmallIconSize reports the real, scaled tray icon size and the whale is drawn
|
|
21
|
-
# at that size instead of being stretched blurry on 125-200 % displays.
|
|
22
|
-
try { [void][DshTray.Win32]::SetProcessDPIAware() } catch {}
|
|
23
|
-
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
24
|
-
|
|
25
|
-
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
|
26
|
-
[Console]::OutputEncoding = $utf8
|
|
27
|
-
$stdout = [Console]::Out
|
|
28
|
-
|
|
29
|
-
function Send-Action([string]$Id) {
|
|
30
|
-
$line = (@{ action = $Id } | ConvertTo-Json -Compress)
|
|
31
|
-
$stdout.WriteLine($line)
|
|
32
|
-
$stdout.Flush()
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
# The icon is the whale from lib/whale.js, shipped as bezier paths in icon.json
|
|
36
|
-
# next to view.json (one frame list per state, plus fps / loop) and drawn here
|
|
37
|
-
# with GDI+ in the state colour.
|
|
38
|
-
$script:iconSpec = $null
|
|
39
|
-
$script:iconCache = @{}
|
|
40
|
-
|
|
41
|
-
function Read-IconSpec {
|
|
42
|
-
$path = Join-Path (Split-Path -Parent $ViewPath) 'icon.json'
|
|
43
|
-
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
|
44
|
-
try { return ([System.IO.File]::ReadAllText($path, $utf8) | ConvertFrom-Json) } catch { return $null }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function New-WhalePath($Paths, $Spec, [int]$W, [int]$H) {
|
|
48
|
-
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
49
|
-
$path.FillMode = [System.Drawing.Drawing2D.FillMode]::Winding
|
|
50
|
-
$b = $Spec.bounds
|
|
51
|
-
if ($null -eq $b) { $b = @{ x = 0; y = 0; w = $Spec.box; h = $Spec.box } }
|
|
52
|
-
$s = [Math]::Min($W / [double]$b.w, $H / [double]$b.h)
|
|
53
|
-
$ox = ($W - $b.w * $s) / 2
|
|
54
|
-
$oy = ($H - $b.h * $s) / 2
|
|
55
|
-
foreach ($sub in $Paths) {
|
|
56
|
-
$cur = $null
|
|
57
|
-
$path.StartFigure()
|
|
58
|
-
foreach ($cmd in $sub) {
|
|
59
|
-
switch ($cmd[0]) {
|
|
60
|
-
'M' { $cur = New-Object System.Drawing.PointF(($ox + ($cmd[1] - $b.x) * $s), ($oy + ($cmd[2] - $b.y) * $s)) }
|
|
61
|
-
'L' {
|
|
62
|
-
$to = New-Object System.Drawing.PointF(($ox + ($cmd[1] - $b.x) * $s), ($oy + ($cmd[2] - $b.y) * $s))
|
|
63
|
-
$path.AddLine($cur, $to); $cur = $to
|
|
64
|
-
}
|
|
65
|
-
'C' {
|
|
66
|
-
$c1 = New-Object System.Drawing.PointF(($ox + ($cmd[1] - $b.x) * $s), ($oy + ($cmd[2] - $b.y) * $s))
|
|
67
|
-
$c2 = New-Object System.Drawing.PointF(($ox + ($cmd[3] - $b.x) * $s), ($oy + ($cmd[4] - $b.y) * $s))
|
|
68
|
-
$to = New-Object System.Drawing.PointF(($ox + ($cmd[5] - $b.x) * $s), ($oy + ($cmd[6] - $b.y) * $s))
|
|
69
|
-
$path.AddBezier($cur, $c1, $c2, $to); $cur = $to
|
|
70
|
-
}
|
|
71
|
-
'Z' { $path.CloseFigure() }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return $path
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function New-StateIcon([string]$State, [int]$Frame) {
|
|
79
|
-
$size = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width
|
|
80
|
-
if ($size -lt 16) { $size = 16 }
|
|
81
|
-
$spec = $script:iconSpec
|
|
82
|
-
$bmp = New-Object System.Drawing.Bitmap($size, $size)
|
|
83
|
-
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
84
|
-
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
|
85
|
-
$g.Clear([System.Drawing.Color]::Transparent)
|
|
86
|
-
$rgb = $null
|
|
87
|
-
if ($spec -and $spec.rgb.$State) { $rgb = $spec.rgb.$State }
|
|
88
|
-
if ($null -eq $rgb) { $rgb = @(59, 83, 170) }
|
|
89
|
-
$brush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, [int]$rgb[0], [int]$rgb[1], [int]$rgb[2]))
|
|
90
|
-
if ($spec -and $spec.frames.$State) {
|
|
91
|
-
$frames = $spec.frames.$State
|
|
92
|
-
$paths = $frames[$Frame % $frames.Count]
|
|
93
|
-
$path = New-WhalePath $paths $spec $size $size
|
|
94
|
-
$g.FillPath($brush, $path)
|
|
95
|
-
$path.Dispose()
|
|
96
|
-
} else {
|
|
97
|
-
# No geometry yet (first view): a plain disc keeps the icon visible.
|
|
98
|
-
$g.FillEllipse($brush, 1, 1, $size - 3, $size - 3)
|
|
99
|
-
}
|
|
100
|
-
$brush.Dispose()
|
|
101
|
-
$g.Dispose()
|
|
102
|
-
$handle = $bmp.GetHicon()
|
|
103
|
-
$icon = [System.Drawing.Icon]::FromHandle($handle).Clone()
|
|
104
|
-
[void][DshTray.Win32]::DestroyIcon($handle)
|
|
105
|
-
$bmp.Dispose()
|
|
106
|
-
return $icon
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function Get-StateIcon([string]$State, [int]$Frame) {
|
|
110
|
-
$key = "$State`:$Frame"
|
|
111
|
-
if (-not $script:iconCache.ContainsKey($key)) { $script:iconCache[$key] = New-StateIcon $State $Frame }
|
|
112
|
-
return $script:iconCache[$key]
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
$script:iconSpec = Read-IconSpec
|
|
116
|
-
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
117
|
-
$notify.Text = 'DeepSeek Harness'
|
|
118
|
-
$notify.Icon = Get-StateIcon 'idle' 0
|
|
119
|
-
$notify.Visible = $true
|
|
120
|
-
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
121
|
-
$notify.ContextMenuStrip = $menu
|
|
122
|
-
$script:balloonAction = $null
|
|
123
|
-
|
|
124
|
-
$notify.add_MouseClick({
|
|
125
|
-
param($sender, $e)
|
|
126
|
-
if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Send-Action 'show-app' }
|
|
127
|
-
})
|
|
128
|
-
$notify.add_BalloonTipClicked({
|
|
129
|
-
if ($script:balloonAction) { Send-Action $script:balloonAction }
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
$menuClick = {
|
|
133
|
-
param($sender, $e)
|
|
134
|
-
$id = [string]$sender.Tag
|
|
135
|
-
if ($id -eq '') { return }
|
|
136
|
-
Send-Action $id
|
|
137
|
-
if ($id -eq 'hide-tray') { $notify.Visible = $false; [System.Windows.Forms.Application]::Exit() }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function Add-MenuRows($items, $rows) {
|
|
141
|
-
foreach ($row in $rows) {
|
|
142
|
-
if ($row.separator) {
|
|
143
|
-
[void]$items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
144
|
-
continue
|
|
145
|
-
}
|
|
146
|
-
$mi = New-Object System.Windows.Forms.ToolStripMenuItem(([string]$row.label).Replace('&', '&&'))
|
|
147
|
-
if ($row.children -and $row.children.Count -gt 0) {
|
|
148
|
-
Add-MenuRows $mi.DropDownItems $row.children
|
|
149
|
-
} else {
|
|
150
|
-
$mi.Tag = $row.id
|
|
151
|
-
if ($row.enabled -eq $false) { $mi.Enabled = $false }
|
|
152
|
-
$mi.add_Click($menuClick)
|
|
153
|
-
}
|
|
154
|
-
[void]$items.Add($mi)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
$script:pendingMenu = $null
|
|
159
|
-
function Set-Menu($rows) {
|
|
160
|
-
# Rebuilding the strip while it is open would yank it out from under the
|
|
161
|
-
# pointer (views refresh every few seconds while a task runs): defer instead.
|
|
162
|
-
if ($menu.Visible) { $script:pendingMenu = @{ rows = $rows }; return }
|
|
163
|
-
$script:pendingMenu = $null
|
|
164
|
-
$menu.Items.Clear()
|
|
165
|
-
Add-MenuRows $menu.Items $rows
|
|
166
|
-
}
|
|
167
|
-
$menu.add_Closed({
|
|
168
|
-
if ($script:pendingMenu) { Set-Menu $script:pendingMenu.rows }
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
$script:lastStamp = ''
|
|
172
|
-
$script:lastToast = [long]0
|
|
173
|
-
$script:state = ''
|
|
174
|
-
$script:frame = 0
|
|
175
|
-
$script:frameCount = 1
|
|
176
|
-
$script:fps = 1
|
|
177
|
-
$script:loop = $false
|
|
178
|
-
$script:clock = 0.0
|
|
179
|
-
|
|
180
|
-
function Update-FromView {
|
|
181
|
-
if (-not (Test-Path -LiteralPath $ViewPath)) { return }
|
|
182
|
-
$info = Get-Item -LiteralPath $ViewPath
|
|
183
|
-
$stamp = "$($info.LastWriteTimeUtc.Ticks):$($info.Length)"
|
|
184
|
-
if ($stamp -eq $script:lastStamp) { return }
|
|
185
|
-
$script:lastStamp = $stamp
|
|
186
|
-
try {
|
|
187
|
-
$view = [System.IO.File]::ReadAllText($ViewPath, $utf8) | ConvertFrom-Json
|
|
188
|
-
} catch { return }
|
|
189
|
-
|
|
190
|
-
if ($view.quit) {
|
|
191
|
-
# The host is shutting the icon down: leave cleanly instead of being killed
|
|
192
|
-
# (a terminated process leaves a ghost icon until the mouse passes over it).
|
|
193
|
-
$notify.Visible = $false
|
|
194
|
-
[System.Windows.Forms.Application]::Exit()
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
if ($view.icon) { $script:iconSpec = $view.icon; $script:iconCache = @{} }
|
|
198
|
-
if ($null -eq $script:iconSpec) {
|
|
199
|
-
$script:iconSpec = Read-IconSpec
|
|
200
|
-
# Anything drawn before the geometry arrived was a placeholder disc.
|
|
201
|
-
if ($script:iconSpec) { $script:iconCache = @{}; $script:state = '' }
|
|
202
|
-
}
|
|
203
|
-
$state = [string]$view.state
|
|
204
|
-
if ($state -ne $script:state) {
|
|
205
|
-
# A new state restarts its animation; the same state keeps playing.
|
|
206
|
-
$script:state = $state
|
|
207
|
-
$script:frame = 0
|
|
208
|
-
$script:clock = 0.0
|
|
209
|
-
$script:frameCount = 1
|
|
210
|
-
$script:fps = 1
|
|
211
|
-
$script:loop = $false
|
|
212
|
-
if ($script:iconSpec -and $script:iconSpec.frames.$state) {
|
|
213
|
-
$script:frameCount = @($script:iconSpec.frames.$state).Count
|
|
214
|
-
$anim = $script:iconSpec.anim.$state
|
|
215
|
-
if ($anim) { $script:fps = [double]$anim.fps; $script:loop = [bool]$anim.loop }
|
|
216
|
-
}
|
|
217
|
-
$notify.Icon = Get-StateIcon $state 0
|
|
218
|
-
}
|
|
219
|
-
# .NET Framework caps NotifyIcon.Text at 63 characters: keep whole lines,
|
|
220
|
-
# dropping from the bottom, rather than cutting a line in half.
|
|
221
|
-
$tip = ''
|
|
222
|
-
foreach ($line in ([string]$view.tooltip) -split "\n") {
|
|
223
|
-
$next = if ($tip -eq '') { $line } else { "$tip`n$line" }
|
|
224
|
-
if ($next.Length -gt 63) { if ($tip -eq '') { $tip = $line.Substring(0, 62) + [char]0x2026 }; break }
|
|
225
|
-
$tip = $next
|
|
226
|
-
}
|
|
227
|
-
$notify.Text = $tip
|
|
228
|
-
Set-Menu $view.menu
|
|
229
|
-
|
|
230
|
-
# Host-side notifications on Windows ride the tray icon's balloon, which
|
|
231
|
-
# Windows 10/11 renders as a native toast.
|
|
232
|
-
if ($view.toast -and [long]$view.toast.seq -gt $script:lastToast) {
|
|
233
|
-
$script:lastToast = [long]$view.toast.seq
|
|
234
|
-
$script:balloonAction = [string]$view.toast.action
|
|
235
|
-
$notify.ShowBalloonTip(8000, [string]$view.toast.title, [string]$view.toast.body, [System.Windows.Forms.ToolTipIcon]::Info)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function Test-HostAlive {
|
|
240
|
-
if ($HostPid -le 0) { return $true }
|
|
241
|
-
return $null -ne (Get-Process -Id $HostPid -ErrorAction SilentlyContinue)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
$script:ticks = 0
|
|
245
|
-
$timer = New-Object System.Windows.Forms.Timer
|
|
246
|
-
$timer.Interval = 100
|
|
247
|
-
$timer.add_Tick({
|
|
248
|
-
try {
|
|
249
|
-
$script:ticks += 1
|
|
250
|
-
if (($script:ticks % 10) -eq 0 -and -not (Test-HostAlive)) {
|
|
251
|
-
$notify.Visible = $false
|
|
252
|
-
[System.Windows.Forms.Application]::Exit()
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
Update-FromView
|
|
256
|
-
# Advance the state's animation at its own fps; one-shot states hold their last frame.
|
|
257
|
-
if ($script:frameCount -gt 1 -and ($script:loop -or $script:frame -lt $script:frameCount - 1)) {
|
|
258
|
-
$script:clock += $timer.Interval / 1000.0
|
|
259
|
-
$step = 1.0 / [Math]::Max(1.0, $script:fps)
|
|
260
|
-
if ($script:clock -ge $step) {
|
|
261
|
-
$script:clock -= $step
|
|
262
|
-
if ($script:loop) { $script:frame = ($script:frame + 1) % $script:frameCount } else { $script:frame += 1 }
|
|
263
|
-
$notify.Icon = Get-StateIcon $script:state $script:frame
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
} catch {
|
|
267
|
-
[Console]::Error.WriteLine($_.Exception.Message)
|
|
268
|
-
}
|
|
269
|
-
})
|
|
270
|
-
$timer.Start()
|
|
271
|
-
Update-FromView
|
|
272
|
-
|
|
273
|
-
$context = New-Object System.Windows.Forms.ApplicationContext
|
|
274
|
-
[System.Windows.Forms.Application]::Run($context)
|
|
275
|
-
$notify.Visible = $false
|
|
276
|
-
$notify.Dispose()
|