dsh-notify-tray 0.1.0 → 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 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
  ![The four whale states](assets/preview.svg)
12
12
 
@@ -17,30 +17,41 @@ 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
- On macOS the whale is a menu-bar icon that follows the menu-bar colour, with a short label next to it such as `Pending 1` / `Running 2`. On Windows / Linux the state is colour-coded instead (🔵 idle / running, 🟠 waiting for you, 🔴 just failed). The Windows desktop app already ships its own tray icon, so the plugin doesn't add a second one there — it only provides notifications.
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
 
24
- **Prerequisite: Git must be installed.** DSH clones the repository with Git during installation; without it you'll see `ERR_PNPM_GIT_RESOLVE_FAILED … 'git' is not recognized as an internal or external command`.
25
-
26
- - Windows: install Git from <https://git-scm.com/download/win> (the defaults are fine), then restart DSH.
27
- - macOS: run `git --version` once in Terminal and follow the prompt to install the command-line tools.
28
+ The plugin is published on npm as [`dsh-notify-tray`](https://www.npmjs.com/package/dsh-notify-tray). Installing from npm needs nothing beyond DSH itself — no Git required.
28
29
 
29
30
  **Option 1 — from inside DSH (recommended)**
30
- Open DSH **Plugins → Add plugin**, paste `https://github.com/ROYLeXJ/dsh-notify-tray.git` into the input box and click **Install**.
31
+ Open DSH **Plugins → Add plugin**, type `dsh-notify-tray` into the input box and click **Install**.
31
32
 
32
33
  **Option 2 — command line**
33
34
 
34
35
  ```sh
35
36
  # Desktop app (the dsh command bundled with the app)
36
- dsh plugin --profile desktop add https://github.com/ROYLeXJ/dsh-notify-tray.git
37
+ dsh plugin --profile desktop add dsh-notify-tray
37
38
  # Or the web / CLI profile
38
- dsh plugin --profile web add https://github.com/ROYLeXJ/dsh-notify-tray.git
39
- # You can also download / clone the repo first and install from a local directory
40
- dsh plugin --profile desktop add /path/to/dsh-notify-tray
39
+ dsh plugin --profile web add dsh-notify-tray
40
+ # Pin a specific version
41
+ dsh plugin --profile desktop add dsh-notify-tray@0.1.2
41
42
  ```
42
43
 
43
- Restart DSH after installing. To uninstall: `dsh plugin --profile desktop remove dsh-notify-tray`.
44
+ **Option 3 — from the Git repository** (latest unreleased code)
45
+
46
+ Paste `https://github.com/ROYLeXJ/dsh-notify-tray.git` into **Plugins → Add plugin**, or run `dsh plugin --profile desktop add https://github.com/ROYLeXJ/dsh-notify-tray.git`. You can also clone the repo and install from a local directory: `dsh plugin --profile desktop add /path/to/dsh-notify-tray`.
47
+
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.
49
+
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`.
44
55
 
45
56
  ## Configuration
46
57
 
@@ -66,10 +77,11 @@ Every option can be changed under DSH **Plugins → Whale Notify Station → Con
66
77
  | `notifyCompleted` / `notifyFailed` / `notifyApproval` / `notifyChoice` / `notifyInput` | all on | Toggle each of the five notification kinds |
67
78
  | `channel` | `auto` | `auto`: the DSH window shows the notification when one is open, otherwise the OS does |
68
79
  | `suppressWhenFocused` | `true` | Don't notify while you're looking at that session (switch to another app and you will be) |
69
- | `tray` | `true` | Show the status-bar icon (takes effect after a restart) |
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) |
70
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) |
71
83
  | `showSpendInTitle` | `false` | Append today's spend to the macOS menu-bar label |
72
- | `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) |
73
85
 
74
86
  ### Spend & pricing
75
87
 
@@ -116,13 +128,13 @@ Built-in plan presets (prices are the list prices at the time of writing and may
116
128
  | GPT | ChatGPT Go / Plus / Pro 5x / Pro 20x / Business, API pay-as-you-go | No endpoint for regular keys |
117
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` |
118
130
 
119
- Anything that can't be fetched can be typed in by hand; empty fields are simply hidden. 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.
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.
120
132
 
121
133
  ## Notes
122
134
 
123
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.
124
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.
125
- - **Clicking the tray icon / a notification / "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.
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.
126
138
 
127
139
  ## Development
128
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「插件」页里直接改,包括当前模型的 API 价格和你买的套餐(Kimi / GLM / OpenCode / MiMo / Gemini / Grok / Claude / GPT / MiniMax / DeepSeek 或自定义),能从接口拿到的余额 / 剩余额度自动拿,拿不到的手填。
8
+ - **状态栏小鲸鱼**(macOS / Linux):状态栏常驻一只小鲸鱼,用姿态告诉你现在的状态;下拉菜单里是正在运行的任务、等你处理的事项、今日用量和最近结束的任务,点一下就能跳到对应会话。
9
+ - **插件设置页**:所有选项都能在 DSH「插件」页里直接改(只显示当前系统用得上的选项),包括当前模型的 API 价格和你买的套餐(Kimi / GLM / OpenCode / MiMo / Gemini / Grok / Claude / GPT / MiniMax / DeepSeek 或自定义),能从接口拿到的余额 / 剩余额度自动拿,拿不到的手填。
10
10
 
11
11
  ![四种状态](assets/preview.svg)
12
12
 
@@ -17,30 +17,41 @@ DeepSeek Harness(DSH)的通知 + 状态栏插件,支持 macOS、Windows、
17
17
  | 鼻子前冒出 `!` | 有事等你处理(批准 / 选择 / 输入) |
18
18
  | 鼻子前冒出 `×` | 刚有任务失败(默认保持 10 分钟) |
19
19
 
20
- macOS 上鲸鱼是菜单栏图标,颜色自动跟随菜单栏,旁边显示 `待处理 1` / `运行中 2` 这样的文字;Windows / Linux 上用颜色区分状态(🔵 空闲/运行中、🟠 等你处理、🔴 刚失败)。Windows 桌面版自带托盘图标,插件不再额外加一个,只提供通知。
20
+ | 系统 | 通知 | 状态栏小鲸鱼 | 设置页里的状态栏选项 |
21
+ |---|---|---|---|
22
+ | macOS | 系统通知中心 | 菜单栏图标,颜色跟随菜单栏,旁边显示 `待处理 1` / `运行中 2` | 全部(显示开关、旁边显示什么、带上今日花费、失败保持时间) |
23
+ | Linux(UOS / Deepin / 麒麟 / KDE 等) | freedesktop 通知 | 系统托盘图标,用颜色区分状态(🔵 空闲/运行中、🟠 等你处理、🔴 刚失败) | 显示开关、失败保持时间 |
24
+ | Windows | 系统通知(右下角弹窗) | 无(Windows 没有状态栏) | 无 |
21
25
 
22
26
  ## 安装
23
27
 
24
- **前提:本机要装有 Git。** 安装时 DSH 会用 Git 拉取仓库,没有 Git 会报 `ERR_PNPM_GIT_RESOLVE_FAILED … 'git' 不是内部或外部命令`。
25
-
26
- - Windows:先到 <https://git-scm.com/download/win> 下载安装 Git(一路默认即可),装完重启 DSH。
27
- - macOS:终端里运行一次 `git --version`,按提示安装命令行工具即可。
28
+ 插件已发布到 npm:[`dsh-notify-tray`](https://www.npmjs.com/package/dsh-notify-tray)。从 npm 安装只需要 DSH 本身,**不需要装 Git**。
28
29
 
29
30
  **方式一:在 DSH 里装(推荐)**
30
- 打开 DSH「插件 → 添加插件」,输入框里填 `https://github.com/ROYLeXJ/dsh-notify-tray.git`,点「安装」。
31
+ 打开 DSH「插件 → 添加插件」,输入框里填 `dsh-notify-tray`,点「安装」。
31
32
 
32
33
  **方式二:命令行**
33
34
 
34
35
  ```sh
35
36
  # 桌面版(App 内置的 dsh 命令)
36
- dsh plugin --profile desktop add https://github.com/ROYLeXJ/dsh-notify-tray.git
37
+ dsh plugin --profile desktop add dsh-notify-tray
37
38
  # 或者 Web / 命令行 profile
38
- dsh plugin --profile web add https://github.com/ROYLeXJ/dsh-notify-tray.git
39
- # 也可以先下载 / 克隆仓库,再从本地目录安装
40
- dsh plugin --profile desktop add /path/to/dsh-notify-tray
39
+ dsh plugin --profile web add dsh-notify-tray
40
+ # 指定版本
41
+ dsh plugin --profile desktop add dsh-notify-tray@0.1.2
41
42
  ```
42
43
 
43
- 装好后重启 DSH。卸载:`dsh plugin --profile desktop remove dsh-notify-tray`。
44
+ **方式三:从 Git 仓库装**(想用还没发版的最新代码时)
45
+
46
+ 在「插件 → 添加插件」里填 `https://github.com/ROYLeXJ/dsh-notify-tray.git`,或运行 `dsh plugin --profile desktop add https://github.com/ROYLeXJ/dsh-notify-tray.git`;也可以先克隆仓库再从本地目录安装:`dsh plugin --profile desktop add /path/to/dsh-notify-tray`。
47
+
48
+ 这条路**需要本机装有 Git**,没有 Git 会报 `ERR_PNPM_GIT_RESOLVE_FAILED … 'git' 不是内部或外部命令`。Windows 先到 <https://git-scm.com/download/win> 下载安装(一路默认即可),装完重启 DSH;macOS 在终端里运行一次 `git --version`,按提示安装命令行工具即可。
49
+
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`。
44
55
 
45
56
  ## 设置
46
57
 
@@ -66,10 +77,11 @@ dsh plugin --profile desktop add /path/to/dsh-notify-tray
66
77
  | `notifyCompleted` / `notifyFailed` / `notifyApproval` / `notifyChoice` / `notifyInput` | 全部开启 | 分别控制五类通知 |
67
78
  | `channel` | `auto` | `auto`:有 DSH 窗口时由窗口弹,没有窗口时由系统弹 |
68
79
  | `suppressWhenFocused` | `true` | 你正在看这个会话时不弹通知(切到别的应用去就有了) |
69
- | `tray` | `true` | 是否显示状态栏图标(修改后重启生效) |
80
+ | `sound` | `true` | 系统通知带提示音(macOS「叮」一声、Windows 通知音、Linux 视桌面环境而定) |
81
+ | `tray` | `true` | 是否显示状态栏图标(macOS / Linux,修改后重启生效;Windows 忽略) |
70
82
  | `titleStyle` | `text` | macOS 菜单栏鲸鱼旁边显示什么:`text`(待处理 1 / 运行中 2)、`count`(只有数字)、`icon`(只有鲸鱼) |
71
83
  | `showSpendInTitle` | `false` | macOS 菜单栏文字里附带今日花费 |
72
- | `errorHoldMinutes` | `10` | 任务失败后图标保持红色的分钟数 |
84
+ | `errorHoldMinutes` | `10` | 任务失败后图标保持红色 / 挂「×」的分钟数(macOS / Linux) |
73
85
 
74
86
  ### 花费与价格
75
87
 
@@ -116,13 +128,13 @@ dsh plugin --profile desktop add /path/to/dsh-notify-tray
116
128
  | GPT | ChatGPT Go / Plus / Pro 5x / Pro 20x / Business、API 按量 | 普通 Key 无接口 |
117
129
  | MiniMax | M Plan Go / Explore / Build(国内 ¥49 / 119 / 469,国际 $22 / 55 / 132)、旧版 Coding Plan | 5 小时窗口剩余 `GET /v1/coding_plan/remains` |
118
130
 
119
- 拿不到的信息就手填;留空也可以,留空的项不显示。状态栏菜单里会多一行「套餐 · 名称 · 价格 · 剩余」,子菜单里有余额、剩余额度、重置时间、接口返回的明细、「刷新额度」和用量页 / 价格页链接。
131
+ 拿不到的信息就手填;留空也可以,留空的项不显示。macOS / Linux 的状态栏菜单里会多一行「套餐 · 名称 · 价格 · 剩余」,子菜单里有余额、剩余额度、重置时间、接口返回的明细、「刷新额度」和用量页 / 价格页链接。
120
132
 
121
133
  ## 说明
122
134
 
123
135
  - **通知的触发时机**:任务跑完算「完成」;出错、超出 Token 上限、被拦截算「失败」;你自己点停止不弹通知。调用 `ask_user_question` 时,带选项的问题和计划审批算「需要选择」,不带选项的算「需要输入」;工具审批算「需要批准」。只跟踪顶层会话,子 agent 不单独弹通知。
124
136
  - **花费怎么算**:用 DeepSeek 账户登录时,按账户余额的下降累计当天花费(充值不算负花费,会受平台扣费延迟影响);同时按当天各「provider/model」的 Token 乘以单价估算(单价来源见上面的 `priceSource`),「今日用量」子菜单里按模型分行显示。账本存在 `~/.dsh/storages/dsh-notify-tray/ledger.json`,保留 31 天。
125
- - **点击托盘图标 / 通知 / 菜单里的「打开 DeepSeek Harness」**:有桌面版时唤起桌面版,没有时(`dsh web`)在浏览器里打开 Web 界面。浏览器里的通知需要先允许通知权限。
137
+ - **点击通知 / 状态栏图标 / 菜单里的「打开 DeepSeek Harness」**:有桌面版时唤起桌面版,没有时(`dsh web`)在浏览器里打开 Web 界面。浏览器里的通知需要先允许通知权限。
126
138
 
127
139
  ## 已知限制
128
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: 'macOS 系统通道的「叮」一声。',
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: 'The macOS system channel\'s chime.',
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
- exports.__settingsLive = { live, loadCatalog, loadState }
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 / Windows / Linux.
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 { desktopHosted, openUrl, showApp, systemNotify } from './system.js'
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('通知带提示音(macOS)').volatile(),
74
- tray: z.boolean().default(true).description('显示状态栏图标(修改后重启 DSH 生效;Windows 桌面版自带托盘图标,不再另加)').volatile(),
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
- // The Windows desktop app already keeps a tray icon of its own; a second one
221
- // next to it is not on. There, notifications are toasts and the icon is
222
- // skipped; `dsh web` on Windows (no app icon) still gets the whale.
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, tray?: any, action?: string, seq?: number, webOrigin?: () => string | undefined}} n
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
- // With our own tray icon up (dsh web), its balloon is a toast that can open the exact session.
89
- if (n.tray?.toast && n.tray.alive) {
90
- await n.tray.toast({ seq: n.seq ?? Date.now(), title: n.title, body: n.body, action: n.action })
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 two child-process helpers read the whale geometry from icon.json once,
11
- * then an atomically rewritten view.json for state, and report menu clicks as
12
- * JSON lines on stdout. They also watch the host pid, so a crashed host never
13
- * leaves an orphan icon behind. `{"quit": true}` in the view asks a helper to
14
- * take its icon down and exit on its own (on Windows a killed process leaves
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`. On Windows the helper reads view.json without
47
- * FILE_SHARE_DELETE, so a rename that lands during its read fails with
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', toast: undefined, get alive() { return true } }
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({ ...view, toast: lastView?.toast })
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 lives in your status bar: 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."
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.0",
4
- "description": "DeepSeek Harness 通知 + 状态栏插件:任务完成、需要输入、需要选择时弹系统通知;在 macOS / Windows / Linux(含 UOS、Deepin、麒麟)状态栏常驻小图标,显示任务进度与花费。",
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",
@@ -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()