pi-windows-notifier 0.2.0 → 0.3.0

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,15 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md)
4
4
 
5
- Get a Windows notification when Pi needs your attention—or when it's finished responding. The extension shows a toast and plays a system sound for permission requests, structured questions, and completed, interrupted, or failed responses, even while you're looking at another window.
5
+ **Built specifically for Windows. Zero dependencies. Security-first alert sound and notification plugin.**
6
+
7
+ Get a Windows notification when Pi needs your attention—or when it's finished responding. Shows a toast and plays a system sound or your own WAV file for permission requests, structured questions, and completed responses, even while you're looking at another window.
6
8
 
7
9
  Notifications stay on your machine. They don't pull questions, commands, file paths, or Pi's replies from your session; you can set your own static notification text. The extension doesn't approve permissions or answer questions for you.
8
10
 
9
- [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
11
+ Uses Pi-provided APIs, Node.js built-ins, and the built-in Windows notification and sound APIs—no extra notification library or external audio player to install. The design avoids risky patterns present in many other similar plugins. See [Privacy and process safety](#privacy-and-process-safety) for the protections and their limits.
12
+
13
+ [Pi package page](https://pi.dev/packages/pi-windows-notifier) · [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
10
14
 
11
15
  ## Install
12
16
 
@@ -37,7 +41,7 @@ pi remove npm:pi-windows-notifier
37
41
  |---|---|---|
38
42
  | `permission` | Pi needs you to review a permission request | Exclamation |
39
43
  | `question` | A supported question tool is waiting for your answer | Exclamation |
40
- | `completed` | Pi has finished responding | Asterisk |
44
+ | `completed` | Pi has finished responding | Hand |
41
45
  | `aborted` | The response was interrupted | Exclamation |
42
46
  | `failed` | The response ended in an error | Exclamation |
43
47
 
@@ -61,11 +65,13 @@ Some question packages also ring the terminal bell themselves. If you hear an ex
61
65
 
62
66
  ## Settings
63
67
 
64
- To change the defaults, create `~/.pi/agent/pi-windows-notifier/config.json`. On Windows, that's `.pi\agent\pi-windows-notifier\config.json` inside your user home folder. The extension doesn't create or edit this file, and it doesn't read project-level settings.
68
+ To change the defaults, create `~/.pi/agent/extensions/pi-windows-notifier/config.json`. On Windows, that's `.pi\agent\extensions\pi-windows-notifier\config.json` inside your user home folder. The extension doesn't create or edit this file, and it doesn't read project-level settings.
69
+
70
+ The new path takes priority. Only when it doesn't exist does the extension read the old `~/.pi/agent/pi-windows-notifier/config.json`; the two files aren't merged. An invalid or unreadable new file disables notifications instead of falling back. To move your settings, place your existing config at the new path and reload. The extension won't move or rewrite either file.
65
71
 
66
72
  ### Complete config
67
73
 
68
- This example includes every supported field and all five events. You can copy it as a starting point, but **you only need to keep the fields you want to change**, along with `schemaVersion: 2`. Each event overrides the shared message below, so the example behaves like the built-in defaults.
74
+ This example includes the system-sound fields and all five events; file sounds are shown separately below. You can copy it as a starting point, but **you only need to keep the fields you want to change**, along with `schemaVersion: 2`. Each event overrides the shared message below, so the example behaves like the built-in defaults.
69
75
 
70
76
  ```json
71
77
  {
@@ -99,7 +105,7 @@ This example includes every supported field and all five events. You can copy it
99
105
  "completed": {
100
106
  "enabled": true,
101
107
  "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
102
- "sound": { "enabled": true, "source": { "type": "system", "name": "Asterisk" } }
108
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Hand" } }
103
109
  },
104
110
  "aborted": {
105
111
  "enabled": true,
@@ -130,22 +136,50 @@ Settings are applied in this order: **built-in event defaults → shared `defaul
130
136
  | `toast.title` | Static notification title |
131
137
  | `toast.message` | Static notification message |
132
138
  | `sound.enabled` | Turns the sound on or off |
133
- | `sound.source.type` | Only `"system"` is supported |
134
- | `sound.source.name` | `Asterisk`, `Beep`, `Exclamation`, `Hand`, or `Question`; case-sensitive |
139
+ | `sound.source.type` | `"system"` for Windows sound events, or `"file"` for a local WAV |
140
+ | `sound.source.name` | Required for `"system"`: `Asterisk`, `Beep`, `Exclamation`, `Hand`, or `Question`; case-sensitive |
141
+ | `sound.source.path` | Required for `"file"`: an absolute local WAV path, up to 1024 UTF-16 code units |
135
142
 
136
- The `toast` and `sound` fields can appear under either `defaults` or an individual event. **The complete example explicitly sets every event field, so changing `defaults` alone won't change those overrides.** Remove the corresponding event fields if you want them to inherit shared settings. When setting `sound.source`, include both `type` and `name`; it replaces the source as a whole.
143
+ The `toast` and `sound` fields can appear under either `defaults` or an individual event. **The complete example explicitly sets every event field, so changing `defaults` alone won't change those overrides.** Remove the corresponding event fields if you want them to inherit shared settings. When setting `sound.source`, include `type` and either `name` (system) or `path` (file), never both; it replaces the source as a whole.
137
144
 
138
145
  - **Toast only**: set `sound.enabled` to `false` and `toast.enabled` to `true`.
139
146
  - **Sound only**: set `toast.enabled` to `false` and `sound.enabled` to `true`.
140
147
  - If both channels are off, no helper starts. Event settings can override shared channel switches, but can't override a disabled master or event switch.
141
148
 
142
- Without a config file, all events and channels are enabled, the title is Pi, and messages are in English. Completion uses Asterisk; the other events use Exclamation.
149
+ Without a config file, all events and channels are enabled, the title is Pi, and messages are in English. Completion uses Hand; the other events use Exclamation.
143
150
 
144
151
  ### Text and sound limits
145
152
 
146
153
  Text is used exactly as written—there are no templates or substitutions from your session. Titles can contain up to 128 UTF-16 code units and messages up to 512; an emoji may count as two. Both must be nonblank, single-line strings without control characters or invalid XML characters. Your text appears in Windows notifications, so don't put secrets in it. It isn't shown by `status` or error messages.
147
154
 
148
- Sound names select **Windows system sound events**, not separate audio files bundled with the package. The sound you hear depends on your Windows sound scheme: different events can use the same sound, or have no sound assigned. You can review or change these mappings in the Windows Sound settings; changes also affect other apps using those events. Custom sound files aren't supported.
155
+ Sound names select **Windows system sound events**, not separate audio files bundled with the package. The sound you hear depends on your Windows sound scheme: different events can use the same sound, or have no sound assigned. You can review or change these mappings in the Windows Sound settings; changes also affect other apps using those events. Choosing a file source changes only this extension, not your Windows sound scheme.
156
+
157
+ ### Custom WAV sounds
158
+
159
+ For example, use your own sound when a response completes:
160
+
161
+ ```json
162
+ {
163
+ "schemaVersion": 2,
164
+ "events": {
165
+ "completed": {
166
+ "sound": {
167
+ "source": { "type": "file", "path": "C:/Users/you/Sounds/done.wav" }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Use the same `sound.source` object under `defaults` to share a file across events, or choose different files for each event. Remove existing event-specific sources if you want them to inherit `defaults`.
175
+
176
+ > The format, path, size, and duration limits are deliberate safety trade-offs: they keep file access and playback bounded without external codec/player selection or network audio sources. This extension favors short notification sounds over a general-purpose media player.
177
+ - Supply an absolute path on a local **fixed drive**, such as `C:/Sounds/done.wav`. Forward slashes work and avoid JSON backslash escaping; with backslashes, write `C:\Sounds\done.wav` as `"C:\\Sounds\\done.wav"` in JSON.
178
+ - Relative paths, `~`, environment-variable expansion, URLs, UNC paths, mapped network drives, device paths, alternate data streams, and reparse points (including ancestor junctions/symlinks) are not supported.
179
+ - Files must be RIFF PCM WAV: mono or stereo, 8- or 16-bit, 8–48 kHz, at most **5 seconds** and **5 MiB**. MP3, compressed WAV, and WAV extensible are not supported. Renaming an MP3 to `.wav` won't work.
180
+ - There is no per-sound volume setting, looping, external player, or bundled audio. Use audio you trust and have permission to use.
181
+ - Paths are validated on reload; files are read and checked only when an enabled sound is actually submitted. A missing, unreadable, unsupported, or oversized file returns `SOUND_FAILED`; the toast is still attempted. There is no fallback to a system sound.
182
+ - File paths are sent to the fixed helper through stdin, not command arguments, and aren't printed by `status` or error messages.
149
183
 
150
184
  ### Apply changes and older configs
151
185
 
@@ -155,7 +189,7 @@ The old unversioned format, with a boolean such as `events.completed.sound: fals
155
189
 
156
190
  ## Commands
157
191
 
158
- Run these inside Pi. After `/windows-notifier `, press **Tab** to complete `status`, `reload`, or `test`. After `test `, Tab completes the event name; partial prefixes such as `test co` also work.
192
+ Run these inside Pi. After `/windows-notifier `, press **Tab** to show `status`, `reload`, or `test`, use **↑/↓** to choose, then press **Tab** to accept. Completing `test` adds a space automatically, so you can keep pressing **Tab** to show and accept an event name without typing a space. Partial prefixes such as `test co` also work.
159
193
 
160
194
  ```text
161
195
  /windows-notifier status
@@ -164,7 +198,7 @@ Run these inside Pi. After `/windows-notifier `, press **Tab** to complete `stat
164
198
  /windows-notifier test permission
165
199
  ```
166
200
 
167
- - `status` shows channel switches, sound choices, backend status, queue counters, and diagnostic codes—not your custom text or session content.
201
+ - `status` shows channel switches, sound choices, backend status, queue counters, and diagnostic codes—not your custom text, file paths, or session content.
168
202
  - `reload` reads the config again and cancels old notification work.
169
203
  - `test` sends a completion notification by default. You can also choose `permission`, `question`, `completed`, `aborted`, or `failed`.
170
204
 
@@ -176,7 +210,7 @@ Run these inside Pi. After `/windows-notifier `, press **Tab** to complete `stat
176
210
 
177
211
  Windows still has the final say. Do Not Disturb, notification settings, your sound scheme, and muted audio can suppress a toast or sound even after the API accepts it.
178
212
 
179
- The sender may appear as **PowerShell** because the extension uses the `Microsoft.Windows.PowerShell` AppID. The toast title defaults to **Pi**, or the title you choose. Toast audio is disabled and the system sound is played separately to avoid a duplicate sound from this backend. If one fails, the other is still attempted. There is no fallback notification or audio player.
213
+ The sender may appear as **PowerShell** because the extension uses the `Microsoft.Windows.PowerShell` AppID. The toast title defaults to **Pi**, or the title you choose. Toast audio is disabled and the selected sound is played separately to avoid a duplicate sound from this backend. If one fails, the other is still attempted. There is no fallback notification or audio player.
180
214
 
181
215
  Use `/windows-notifier status` to check these codes:
182
216
 
@@ -195,13 +229,16 @@ Automatic errors show at most one terminal warning per minute. Status uses fixed
195
229
 
196
230
  ## Privacy and process safety
197
231
 
198
- This extension uses Windows' built-in notification and sound APIs through a fixed PowerShell script. You don't need a separate notification server or audio player. There are no network notifications, telemetry, or custom sound files.
232
+ This extension uses Windows' built-in notification and sound APIs through a fixed PowerShell script. You don't need a separate notification server or audio player. There are no network notifications, telemetry, downloads, or bundled sound files.
233
+
234
+ Several feature restrictions are intentional security decisions, not just platform limitations. The safeguards below address command injection, executable lookup hijacking, accidental credential exposure, network-path access, unbounded process creation, and interference with unrelated applications. They are not a guarantee that the extension is vulnerability-free.
199
235
 
200
- - PowerShell is located using an absolute path under the startup environment's `SystemRoot` or `windir`, never the project directory or `PATH`.
201
- - The helper runs without a shell. It receives only the event type, validated channel settings, and static config text through stdin. Text is inserted using DOM text nodes, not interpolated into PowerShell commands or XML. No session content is passed to it.
202
- - The child process gets a limited set of Windows environment variables, not Pi's full environment or API tokens. `-ExecutionPolicy Bypass` applies only to that child; it doesn't grant administrator access or change permanent settings. Execution Policy isn't treated as a security boundary.
203
- - Only one helper runs at a time. The queue holds at most 16 notifications, launches are at least a second apart, queued work expires after 30 seconds, and the helper has a 10-second timeout. stdout and stderr are each capped at 8 KiB. Permission requests and questions take priority over response notifications.
204
- - It only attempts to stop its own child process, never every process with the same name. If Windows refuses to stop that process, new helpers wait for it to close instead of piling up.
236
+ - To reduce executable lookup hijacking, PowerShell is located using an absolute path under the startup environment's `SystemRoot` or `windir`, never the project directory or `PATH`.
237
+ - To avoid command/XML injection from config values, the helper runs without a shell. It receives only the event type, validated channel settings (including a local WAV path when configured), and static config text through stdin. Text is inserted using DOM text nodes, not interpolated into PowerShell commands or XML. No session content is passed to it.
238
+ - To reduce accidental credential exposure, the child process gets a limited set of Windows environment variables, not Pi's full environment or API tokens. `-ExecutionPolicy Bypass` applies only to that child; it doesn't grant administrator access or change permanent settings. Execution Policy isn't treated as a security boundary.
239
+ - WAV files are checked for local-drive paths and reparse points, read into size-limited memory, and validated before playback through `System.Media.SoundPlayer`. Playback stays in the same helper and is covered by its timeout and cancellation. File checks are not a sandbox against an attacker concurrently changing filesystem paths.
240
+ - To limit process storms and resource consumption, only one helper runs at a time. The queue holds at most 16 notifications, launches are at least a second apart, queued work expires after 30 seconds, and the helper has a 10-second timeout. stdout and stderr are each capped at 8 KiB. Permission requests and questions take priority over response notifications.
241
+ - To avoid terminating unrelated applications, it only attempts to stop its own child process, never every process with the same name. If Windows refuses to stop that process, new helpers wait for it to close instead of piling up.
205
242
  - Decisions, finished questions, new runs, reloads, session changes, and shutdown cancel the relevant old work. A toast already shown can't be taken back, and cancellation can race with submission to Windows.
206
243
 
207
244
  These safeguards assume that Windows' system directories, Pi's startup environment, and installed packages are trustworthy. They don't protect against a malicious extension running in the same process or a compromised user account. Pi's permission system doesn't sandbox extensions at the OS level.
@@ -214,7 +251,7 @@ npm run verify
214
251
  npm pack --dry-run
215
252
  ```
216
253
 
217
- Tests use a fake clock, event bus, and launcher. On Windows, they also check PowerShell syntax and invalid input handling. Normal `npm test` runs don't show toasts or play sounds. See the [verification notes (繁體中文)](docs/verification.md) for the test scope and remaining checks.
254
+ Tests use a fake clock, event bus, and launcher. On Windows, they also check PowerShell syntax, invalid input handling, PCM WAV validation, bounded file reads, and junction rejection. Normal `npm test` runs don't show toasts or play sounds. See the [verification notes (繁體中文)](docs/verification.md) for the test scope and remaining checks.
218
255
 
219
256
  ## License
220
257
 
package/README.zh-TW.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md)
4
4
 
5
- 不用一直盯著 Pi。需要你確認權限、回答問題,或模型回應結束時,這個 extension 會跳出 Windows 通知並播放提示音。就算你正在看別的視窗,也會提醒。
5
+ **專為 Windows 設計。0 依賴套件。以安全為優先的提示音與通知插件。**
6
+
7
+ 需要你確認權限、回答問題,或模型回應結束時,會自動跳出 Windows 通知,並播放系統提示音或你自訂的 WAV 音效。就算你正在看別的視窗也會提醒,讓你不用一直盯著 Pi。
6
8
 
7
9
  通知不會從 session 帶出問題、命令、檔案路徑或模型回答,但你可以設定自己的固定提醒文字。它也不會替你批准權限或回答問題,所有提醒都在本機處理。
8
10
 
9
- [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
11
+ 使用 Pi 提供的 API、Node.js built-ins 與 Windows 內建通知和音效 API,不必另裝通知函式庫或外部播放器。設計上避開了許多其他同類套件的高風險做法;具體防護與限制見[隱私與程序安全](#隱私與程序安全)。
12
+
13
+ [Pi 套件頁](https://pi.dev/packages/pi-windows-notifier) · [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
10
14
 
11
15
  ## 安裝
12
16
 
@@ -37,7 +41,7 @@ pi remove npm:pi-windows-notifier
37
41
  |---|---|---|
38
42
  | `permission` | Permission approval needed | Exclamation |
39
43
  | `question` | Waiting for your answer | Exclamation |
40
- | `completed` | Response complete | Asterisk |
44
+ | `completed` | Response complete | Hand |
41
45
  | `aborted` | Response interrupted | Exclamation |
42
46
  | `failed` | Response failed | Exclamation |
43
47
 
@@ -61,11 +65,13 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
61
65
 
62
66
  ## 設定
63
67
 
64
- 想改預設行為時,請自行建立 `~/.pi/agent/pi-windows-notifier/config.json`。在 Windows 上,就是使用者家目錄裡的 `.pi\agent\pi-windows-notifier\config.json`。套件不會替你建立或修改這個檔案,也不讀專案內的設定。
68
+ 想改預設行為時,請自行建立 `~/.pi/agent/extensions/pi-windows-notifier/config.json`。在 Windows 上,就是使用者家目錄裡的 `.pi\agent\extensions\pi-windows-notifier\config.json`。套件不會替你建立或修改這個檔案,也不讀專案內的設定。
69
+
70
+ 新路徑優先;只有新檔案不存在時,才讀舊的 `~/.pi/agent/pi-windows-notifier/config.json`,兩份設定不會合併。新檔案若無效或無法讀取,會停用通知,不會改讀舊檔。想搬移設定時,請自行將原本的設定檔放到新路徑後 reload;套件不會搬動或改寫任何一份檔案。
65
71
 
66
72
  ### 完整設定範例
67
73
 
68
- 下面列出所有支援的欄位和五種事件,可以直接複製作為起點。不過,**實際只要保留想修改的欄位,加上 `schemaVersion: 2` 就好**。每個事件都覆寫了共用訊息,因此這份範例的效果等同內建預設。
74
+ 下面列出系統音效的欄位和五種事件,可以直接複製作為起點;檔案音效範例另列於下方。不過,**實際只要保留想修改的欄位,加上 `schemaVersion: 2` 就好**。每個事件都覆寫了共用訊息,因此這份範例的效果等同內建預設。
69
75
 
70
76
  ```json
71
77
  {
@@ -99,7 +105,7 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
99
105
  "completed": {
100
106
  "enabled": true,
101
107
  "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
102
- "sound": { "enabled": true, "source": { "type": "system", "name": "Asterisk" } }
108
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Hand" } }
103
109
  },
104
110
  "aborted": {
105
111
  "enabled": true,
@@ -130,22 +136,50 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
130
136
  | `toast.title` | 固定的通知標題 |
131
137
  | `toast.message` | 固定的通知訊息 |
132
138
  | `sound.enabled` | 音效開關 |
133
- | `sound.source.type` | 目前只支援 `"system"` |
134
- | `sound.source.name` | `Asterisk`、`Beep`、`Exclamation`、`Hand`、`Question`,大小寫要一致 |
139
+ | `sound.source.type` | `"system"` 使用 Windows 系統音效,`"file"` 使用本機 WAV |
140
+ | `sound.source.name` | `"system"` 必填:`Asterisk`、`Beep`、`Exclamation`、`Hand`、`Question`,大小寫要一致 |
141
+ | `sound.source.path` | `"file"` 必填:本機 WAV 的絕對路徑,最多 1024 個 UTF-16 code units |
135
142
 
136
- `toast` 和 `sound` 欄位都能放在 `defaults` 或個別事件底下。**完整範例已寫出每個事件的所有欄位,只改 `defaults` 不會改到已被事件覆寫的值。** 想讓事件沿用共用設定,請刪除該事件底下對應的欄位。設定 `sound.source` 時必須一起提供 `type` 和 `name`,它會整組取代原本的來源。
143
+ `toast` 和 `sound` 欄位都能放在 `defaults` 或個別事件底下。**完整範例已寫出每個事件的所有欄位,只改 `defaults` 不會改到已被事件覆寫的值。** 想讓事件沿用共用設定,請刪除該事件底下對應的欄位。設定 `sound.source` 時必須提供 `type`,以及系統音效的 `name` 或檔案音效的 `path`,不能同時提供兩者;它會整組取代原本的來源。
137
144
 
138
145
  - **只要彈窗**:將 `sound.enabled` 設為 `false`、`toast.enabled` 設為 `true`。
139
146
  - **只要音效**:將 `toast.enabled` 設為 `false`、`sound.enabled` 設為 `true`。
140
147
  - 兩個通道都關閉時,不啟動 helper。事件可以覆寫共用通道開關,但不能繞過已關閉的總開關或事件開關。
141
148
 
142
- 沒有設定檔時,所有事件與通道都開啟,標題是 Pi,訊息是英文。完成通知使用 Asterisk,其他事件使用 Exclamation。
149
+ 沒有設定檔時,所有事件與通道都開啟,標題是 Pi,訊息是英文。完成通知使用 Hand,其他事件使用 Exclamation。
143
150
 
144
151
  ### 文字與音效限制
145
152
 
146
153
  文字會照你寫的內容顯示,不會從 session 帶入變數,也沒有模板替換。標題最多 128 個 UTF-16 code units,訊息最多 512 個;emoji 可能算兩個。兩者都必須是非空白的單行字串,不能包含控制字元或不合法的 XML 字元。自訂文字會出現在 Windows 通知裡,請不要放敏感資訊;它不會顯示在 `status` 或錯誤提示中。
147
154
 
148
- 音效名稱選的是 **Windows 系統音效事件**,不是套件內附的不同音效檔。實際聲音取決於 Windows 音效配置;不同事件可能共用同一個聲音,也可能沒有對應音效。你可以在 Windows「音效」設定中查看或修改對應,但修改也會影響使用相同事件的其他程式。目前不支援自訂音效檔。
155
+ 音效名稱選的是 **Windows 系統音效事件**,不是套件內附的不同音效檔。實際聲音取決於 Windows 音效配置;不同事件可能共用同一個聲音,也可能沒有對應音效。你可以在 Windows「音效」設定中查看或修改對應,但修改也會影響使用相同事件的其他程式。改用檔案來源只影響這個 extension,不會修改 Windows 音效配置。
156
+
157
+ ### 自訂 WAV 音效
158
+
159
+ 例如,讓回應完成時播放你自己的音效:
160
+
161
+ ```json
162
+ {
163
+ "schemaVersion": 2,
164
+ "events": {
165
+ "completed": {
166
+ "sound": {
167
+ "source": { "type": "file", "path": "C:/Users/you/Sounds/done.wav" }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ 同樣的 `sound.source` 也可以放在 `defaults`,讓多個事件共用檔案,或為每個事件選擇不同音效。想沿用 `defaults` 時,請移除原本事件底下的來源覆寫。
175
+
176
+ > 格式、路徑、大小與長度限制是刻意的安全取捨:讓檔案存取與播放保持有界,不引入外部 codec/播放器選擇或網路音訊來源。這個 extension 著重短提示音,不是通用媒體播放器。
177
+ - 使用本機**固定磁碟**上的絕對路徑,例如 `C:/Sounds/done.wav`。正斜線可以直接使用,避免 JSON 跳脫;使用反斜線時,`C:\Sounds\done.wav` 在 JSON 要寫成 `"C:\\Sounds\\done.wav"`。
178
+ - 不支援相對路徑、`~`、環境變數展開、URL、UNC 路徑、網路磁碟、裝置路徑、alternate data streams 或 reparse points(包含上層目錄的 junction/symlink)。
179
+ - 檔案必須是 RIFF PCM WAV:mono 或 stereo、8 或 16 bit、8–48 kHz,最多 **5 秒**、**5 MiB**。不支援 MP3、壓縮 WAV 或 WAV extensible;把 MP3 改名成 `.wav` 也不能播放。
180
+ - 不提供個別音量、循環播放、外部播放器或內附音效。請使用可信任且有權使用的音訊。
181
+ - reload 時驗證路徑;只有啟用的音效實際提交時,才讀取並檢查檔案。檔案不存在、無法讀取、格式不支援或超過限制時回報 `SOUND_FAILED`,彈窗仍會嘗試送出,不會改播系統音效。
182
+ - 檔案路徑透過 stdin 送入固定 helper,不放在命令參數,也不顯示在 `status` 或錯誤提示中。
149
183
 
150
184
  ### 套用修改與舊格式相容
151
185
 
@@ -155,7 +189,7 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
155
189
 
156
190
  ## 指令
157
191
 
158
- 在 Pi 裡執行。輸入 `/windows-notifier ` 後按 **Tab**,可以補全 `status`、`reload` 或 `test`;輸入 `test ` 後,Tab 會補全事件名稱,也支援 `test co` 這類前綴。
192
+ 在 Pi 裡執行。輸入 `/windows-notifier ` 後按 **Tab**,會列出 `status`、`reload` 或 `test`;用 **↑/↓** 選擇,再按 **Tab** 接受。補完 `test` 會自動加上空白,接著連按 **Tab** 即可列出並接受事件名稱,不必手動輸入空白;也支援 `test co` 這類前綴。
159
193
 
160
194
  ```text
161
195
  /windows-notifier status
@@ -164,7 +198,7 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
164
198
  /windows-notifier test permission
165
199
  ```
166
200
 
167
- - `status`:查看通道開關、音效選擇、backend 狀態、佇列計數和診斷碼,不會顯示自訂文字或 session 內容。
201
+ - `status`:查看通道開關、音效選擇、backend 狀態、佇列計數和診斷碼,不會顯示自訂文字、檔案路徑或 session 內容。
168
202
  - `reload`:重新讀取設定,取消舊的通知工作。
169
203
  - `test`:預設測試完成通知,也能指定 `permission`、`question`、`completed`、`aborted` 或 `failed`。
170
204
 
@@ -196,13 +230,16 @@ Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方
196
230
 
197
231
  ## 隱私與程序安全
198
232
 
199
- 套件透過固定的 PowerShell 腳本呼叫 Windows 內建通知和音效 API,不用另外裝通知服務或播放器。沒有網路通知、遙測或自訂音效檔。
233
+ 套件透過固定的 PowerShell 腳本呼叫 Windows 內建通知和音效 API,不用另外裝通知服務或播放器。沒有網路通知、遙測、下載或內附音效檔。
234
+
235
+ 部分功能限制是刻意的安全設計,不只是平台限制。以下防護針對命令注入、執行檔搜尋劫持、憑證意外外洩、網路路徑存取、無界程序建立,以及誤傷其他程式等風險;不代表套件保證沒有漏洞。
200
236
 
201
- - PowerShell 使用啟動環境的 `SystemRoot`/`windir` 下的絕對路徑,不從專案目錄或 `PATH` 搜尋。
202
- - helper 不透過 shell 執行,只從 stdin 接收事件類型、已驗證的通道設定與固定設定文字。文字用 DOM text node 加進通知,不拼進 PowerShell 命令或 XML,也不傳入 session 內容。
203
- - 子程序只拿到必要的 Windows 環境變數,不繼承 Pi 的完整環境或 API tokens。`-ExecutionPolicy Bypass` 只作用於該子程序,不會取得管理員權限或改動永久設定,也不把 Execution Policy 當成安全邊界。
204
- - 同時最多一個 helper,佇列最多 16 筆,啟動至少間隔一秒。工作等待超過 30 秒會過期,helper 的 timeout 是 10 秒,stdout 和 stderr 各限制 8 KiB。權限和提問比回應結束通知優先。
205
- - 只嘗試終止自己建立的子程序,不會按名稱關閉其他程序。如果 Windows 不允許終止,就等它結束,不會繼續堆出新的 helper。
237
+ - 為降低執行檔搜尋劫持風險,PowerShell 使用啟動環境的 `SystemRoot`/`windir` 下的絕對路徑,不從專案目錄或 `PATH` 搜尋。
238
+ - 為避免設定值造成命令/XML 注入,helper 不透過 shell 執行,只從 stdin 接收事件類型、已驗證的通道設定(含自訂音效的本機 WAV 路徑)與固定設定文字。文字用 DOM text node 加進通知,不拼進 PowerShell 命令或 XML,也不傳入 session 內容。
239
+ - 為降低憑證意外外洩風險,子程序只拿到必要的 Windows 環境變數,不繼承 Pi 的完整環境或 API tokens。`-ExecutionPolicy Bypass` 只作用於該子程序,不會取得管理員權限或改動永久設定,也不把 Execution Policy 當成安全邊界。
240
+ - WAV 播放前會檢查本機磁碟路徑與 reparse points,讀入有大小限制的記憶體並驗證格式,再交給 `System.Media.SoundPlayer`。播放留在同一個 helper,沿用逾時與取消機制;檔案檢查不是防止攻擊者同時修改路徑的 sandbox。
241
+ - 為限制大量程序啟動與資源消耗,同時最多一個 helper,佇列最多 16 筆,啟動至少間隔一秒。工作等待超過 30 秒會過期,helper 的 timeout 是 10 秒,stdout 和 stderr 各限制 8 KiB。權限和提問比回應結束通知優先。
242
+ - 為避免誤關其他程式,只嘗試終止自己建立的子程序,不會按名稱關閉其他程序。如果 Windows 不允許終止,就等它結束,不會繼續堆出新的 helper。
206
243
  - 權限決策、問題結束、新回應、reload、session 切換和 shutdown 都會取消相關舊工作。已經顯示的 Toast 無法收回,取消和提交給 Windows 之間仍可能發生競態。
207
244
 
208
245
  這些防護假設 Windows 系統目錄、Pi 啟動環境和已安裝套件可信。它們無法防禦同程序裡的惡意 extension,或已遭入侵的使用者帳號。Pi permission system 並不是 extension 的 OS 沙盒。
@@ -215,7 +252,7 @@ npm run verify
215
252
  npm pack --dry-run
216
253
  ```
217
254
 
218
- 測試使用假時鐘、event bus 和 launcher。Windows 上也會檢查 PowerShell 語法與無效輸入。一般 `npm test` 不會跳彈窗或播放音效;測試範圍與仍需確認的項目見[驗證紀錄](docs/verification.md)。
255
+ 測試使用假時鐘、event bus 和 launcher。Windows 上也會檢查 PowerShell 語法、無效輸入、PCM WAV 驗證、有界檔案讀取與 junction 拒絕。一般 `npm test` 不會跳彈窗或播放音效;測試範圍與仍需確認的項目見[驗證紀錄](docs/verification.md)。
219
256
 
220
257
  ## 授權
221
258
 
@@ -35,5 +35,5 @@ SOFTWARE.
35
35
  ## Other references
36
36
 
37
37
  - [UniPi notify](https://github.com/Neuron-Mr-White/UniPi/tree/main/packages/notify), reference commit `6ac5da3`: consulted for event/backend separation. No code was copied, and neither UniPi core nor node-notifier is included.
38
- - [pi-jingle](https://github.com/Git-Monke/pi-jingle), reference commit `7e265ce`: consulted for the idea of event-specific sounds. Its license was not confirmed during development, so no code or audio assets were copied.
38
+ - [pi-jingle](https://github.com/Git-Monke/pi-jingle), reference commit `7e265ce`: consulted for the idea of event-specific sounds. Its license was not confirmed during development, so no code or audio assets were copied. Custom WAV support, path validation, bounded PCM parsing, and tests were implemented independently using Windows/.NET APIs.
39
39
  - Pi, pi-permission-system, RPIV/Lean, and Plan mode APIs and source were consulted to verify integration contracts. Their source code is not bundled in this package.
@@ -3,8 +3,9 @@
3
3
  ## 自動驗證
4
4
 
5
5
  - TypeScript `tsc --noEmit` 與 Node test runner 通過;測試覆蓋設定驗證、權限與提問事件、回應狀態、去重取消、限流佇列、PowerShell launcher、資源清理與發布檔案清單。
6
- - 設定測試涵蓋 schema v2 合併、舊格式相容、獨立通道、系統音效白名單、自訂文字上限與 Unicode/XML 驗證;status 與錯誤提示不展示設定文字。
7
- - Windows 測試檢查固定 PowerShell helper 語法、無效輸入,以及通道全關閉時的有效輸入;不呼叫 Toast 或音效 API。單通道成功/失敗結果由 mock launcher 驗證。
6
+ - 設定測試涵蓋 schema v2 合併、舊格式相容、獨立通道、系統音效白名單、本機 WAV 路徑、自訂文字上限與 Unicode/XML 驗證;status 與錯誤提示不展示設定文字或檔案路徑。
7
+ - Windows 測試檢查兩個固定 PowerShell 腳本的語法、無效輸入,以及通道全關閉時的有效輸入;不呼叫 Toast 或音效 API。單通道成功/失敗結果由 mock launcher 驗證。
8
+ - WAV 測試使用自行合成的 PCM 資料,實際呼叫 helper 的讀取與驗證函式:mono/stereo、8/16 bit、五秒邊界、奇數 chunk padding、Unicode/引號/指令樣式檔名、超大/截斷/偽造格式、重複 chunks、缺失檔案、目錄與祖先 junction。這些測試不執行播放函式。
8
9
  - 發布包以 npm pack dry-run 核對;僅包含套件 metadata、runtime 原始碼、PowerShell helper、文件及授權。
9
10
 
10
11
  執行完整檢查:
@@ -21,6 +22,8 @@ npm pack --dry-run
21
22
 
22
23
  0.2.0 的自訂標題/訊息、Toast-only、sound-only 與新增系統音效選擇尚未完成實際可見/可聽驗收;不以舊版結果推論新 helper 已通過。
23
24
 
25
+ 新增的自訂 WAV 播放尚未完成實際可聽驗收。手動驗收時,請在全域 config 的 `events.completed.sound.source` 設定 `{ "type": "file", "path": "C:/Sounds/done.wav" }`,使用符合限制的可信任檔案,reload 後執行 `/windows-notifier test completed`。另需確認 sound-only、檔案缺失時 Toast 仍送出,以及播放中 reload/shutdown 的取消行為。這些指令會產生實際通知與聲音。
26
+
24
27
  這項 backend 驗證不等於所有 Pi 整合情境均已端到端驗收。以下項目仍需在實際使用環境確認:
25
28
 
26
29
  - `pi-permission-system` 的 ask/轉送權限請求。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-windows-notifier",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Windows notifications and sounds for Pi permission requests, questions, and completed, interrupted, or failed responses.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/C-W-Z/pi-windows-notifier.git"
10
10
  },
11
- "homepage": "https://github.com/C-W-Z/pi-windows-notifier#readme",
11
+ "homepage": "https://pi.dev/packages/pi-windows-notifier",
12
12
  "bugs": {
13
13
  "url": "https://github.com/C-W-Z/pi-windows-notifier/issues"
14
14
  },
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "files": [
25
25
  "src/**/*.ts",
26
- "src/windows-notify.ps1",
26
+ "src/*.ps1",
27
27
  "README.md",
28
28
  "README.zh-TW.md",
29
29
  "docs/verification.md",
@@ -0,0 +1,42 @@
1
+ import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui";
2
+ import { KINDS } from "./types.ts";
3
+
4
+ export function notifierArgumentCompletions(prefix: string): AutocompleteItem[] | null {
5
+ const leading = prefix.match(/^\s*/u)![0];
6
+ const argument = prefix.slice(leading.length);
7
+ if (!/\s/u.test(argument)) {
8
+ const matches = ["status", "reload", "test"].filter(value => value.startsWith(argument));
9
+ // test 還有下一層參數;補上空白,讓下一次 Tab 能直接補事件名稱。
10
+ return matches.length ? matches.map(value => ({
11
+ value: leading + value + (value === "test" ? " " : ""), label: value,
12
+ })) : null;
13
+ }
14
+ const match = argument.match(/^test(\s+)(\S*)$/u);
15
+ if (!match) return null;
16
+ const matches = KINDS.filter(kind => kind.startsWith(match[2]));
17
+ // Pi 會替換整段 argument prefix;必須保留 test 與空白,不只回傳事件名稱。
18
+ return matches.length ? matches.map(kind => ({ value: leading + "test" + match[1] + kind, label: kind })) : null;
19
+ }
20
+
21
+ /** 只接管本指令的參數,避免選單關閉後的 Tab 改走 Pi 的強制檔案補全。 */
22
+ export function withNotifierCompletions(current: AutocompleteProvider): AutocompleteProvider {
23
+ const argumentPrefix = (lines: string[], cursorLine: number, cursorCol: number) =>
24
+ cursorLine === 0 ? (lines[0] ?? "").slice(0, cursorCol).match(/^\s*\/windows-notifier (.*)$/u)?.[1] : undefined;
25
+ return {
26
+ triggerCharacters: current.triggerCharacters,
27
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
28
+ const prefix = argumentPrefix(lines, cursorLine, cursorCol);
29
+ if (prefix === undefined) return current.getSuggestions(lines, cursorLine, cursorCol, options);
30
+ if (options.signal.aborted) return null;
31
+ const items = notifierArgumentCompletions(prefix);
32
+ return items ? { items, prefix } : null;
33
+ },
34
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
35
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
36
+ },
37
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
38
+ if (argumentPrefix(lines, cursorLine, cursorCol) !== undefined) return true;
39
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
40
+ },
41
+ };
42
+ }
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { KINDS, SYSTEM_SOUNDS, TITLE_LIMIT, MESSAGE_LIMIT, isRecord, onlyKeys, validText,
4
+ import { KINDS, TITLE_LIMIT, MESSAGE_LIMIT, isRecord, onlyKeys, validText, validSoundSource,
5
5
  type NotificationKind, type ToastConfig, type SoundConfig } from "./types.ts";
6
6
 
7
7
  export interface EventConfig { enabled: boolean; toast: ToastConfig; sound: SoundConfig }
@@ -22,7 +22,7 @@ const MESSAGES: Record<NotificationKind, string> = {
22
22
  export function defaults(): Config {
23
23
  return { schemaVersion: 2, enabled: true, events: Object.fromEntries(KINDS.map(kind =>
24
24
  [kind, { enabled: true, toast: { enabled: true, title: "Pi", message: MESSAGES[kind] },
25
- sound: { enabled: true, source: { type: "system", name: kind === "completed" ? "Asterisk" : "Exclamation" } } }])) as Config["events"] };
25
+ sound: { enabled: true, source: { type: "system", name: kind === "completed" ? "Hand" : "Exclamation" } } }])) as Config["events"] };
26
26
  }
27
27
  function applyChannels(target: EventConfig, value: Record<string, unknown>): boolean {
28
28
  if (Object.hasOwn(value, "toast")) {
@@ -48,10 +48,9 @@ function applyChannels(target: EventConfig, value: Record<string, unknown>): boo
48
48
  }
49
49
  if (Object.hasOwn(sound, "source")) {
50
50
  const source = sound.source;
51
- if (!isRecord(source) || !onlyKeys(source, ["type", "name"]) ||
52
- source.type !== "system" || !SYSTEM_SOUNDS.includes(source.name as SoundConfig["source"]["name"])) return false;
53
- // source 作為完整單位覆寫,避免未來不同來源類型留下混合欄位。
54
- target.sound.source = { type: "system", name: source.name as SoundConfig["source"]["name"] };
51
+ if (!validSoundSource(source)) return false;
52
+ // source 作為完整單位覆寫,不合併不同來源類型的欄位。
53
+ target.sound.source = { ...source };
55
54
  }
56
55
  }
57
56
  return true;
@@ -94,9 +93,16 @@ export function parseConfig(value: unknown): ConfigResult {
94
93
  }
95
94
  export const CONFIG_LIMIT = 16 * 1024;
96
95
  export function configPath(): string {
97
- return join(homedir(), ".pi", "agent", "pi-windows-notifier", "config.json");
96
+ return join(homedir(), ".pi", "agent", "extensions", "pi-windows-notifier", "config.json");
98
97
  }
99
- export function loadConfig(path = configPath()): ConfigResult {
98
+ /** 預設先讀新位置,只有檔案不存在才讀舊位置;明確傳入路徑時不讀取其他使用者設定。 */
99
+ export function loadConfig(path?: string, fallbackPath?: string): ConfigResult {
100
+ const primary = path ?? configPath();
101
+ const legacy = fallbackPath ?? (path === undefined
102
+ ? join(homedir(), ".pi", "agent", "pi-windows-notifier", "config.json") : undefined);
103
+ return readConfig(primary) ?? (legacy ? readConfig(legacy) : undefined) ?? { ok: true, config: defaults() };
104
+ }
105
+ function readConfig(path: string): ConfigResult | undefined {
100
106
  let fd: number | undefined;
101
107
  try {
102
108
  fd = openSync(path, "r");
@@ -112,7 +118,7 @@ export function loadConfig(path = configPath()): ConfigResult {
112
118
  try { return parseConfig(JSON.parse(buffer.subarray(0, size).toString("utf8"))); }
113
119
  catch { return { ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } }; }
114
120
  } catch (error) {
115
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, config: defaults() };
121
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
116
122
  return { ok: false, code: "CONFIG_READ_FAILED", config: { ...defaults(), enabled: false } };
117
123
  } finally {
118
124
  if (fd !== undefined) closeSync(fd);
package/src/launcher.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
2
  import { statSync } from "node:fs";
3
- import { dirname, win32 } from "node:path";
3
+ import { dirname, join, win32 } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isRecord, validPayload, type NotificationPayload } from "./types.ts";
6
6
 
@@ -79,7 +79,7 @@ export function createWindowsBackend(options: BackendOptions = {}): Backend {
79
79
  const script = options.scriptPath ?? SCRIPT;
80
80
  const isFile = options.isFile ?? (path => { try { return statSync(path).isFile(); } catch { return false; } });
81
81
  const available = platform === "win32" && ["x64", "arm64"].includes(arch) &&
82
- !!paths && isFile(paths.executable) && isFile(script);
82
+ !!paths && isFile(paths.executable) && isFile(script) && isFile(join(dirname(script), "windows-sound.ps1"));
83
83
  const spawnProcess = options.spawnProcess ?? spawn;
84
84
  const code = available ? "OK" : "BACKEND_UNAVAILABLE";
85
85
  return {
package/src/runtime.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { defaults, loadConfig, type ConfigResult } from "./config.ts";
3
+ import { notifierArgumentCompletions, withNotifierCompletions } from "./completion.ts";
3
4
  import { createWindowsBackend, windowsPaths, type Backend } from "./launcher.ts";
4
5
  import { NotificationScheduler, systemClock, type Clock, type Submission } from "./scheduler.ts";
5
6
  import { NotificationState } from "./state.ts";
@@ -107,7 +108,10 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
107
108
  if (!target || target.disposed || target.backendCode === "ENV_UNSUPPORTED") return;
108
109
  try { handler(target.state); } catch { diagnose(target, "EVENT_INVALID"); }
109
110
  };
110
- pi.on("session_start", async (_event, context) => { await transition(() => start(context)); });
111
+ pi.on("session_start", async (_event, context) => {
112
+ if (context.mode === "tui" && context.hasUI) context.ui.addAutocompleteProvider(withNotifierCompletions);
113
+ await transition(() => start(context));
114
+ });
111
115
  pi.on("session_shutdown", async () => { await transition(stop); });
112
116
  pi.on("tool_execution_start", event => { observe(state => state.toolStart(event)); });
113
117
  pi.on("tool_execution_end", event => { observe(state => state.toolEnd(event)); });
@@ -121,19 +125,7 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
121
125
 
122
126
  pi.registerCommand("windows-notifier", {
123
127
  description: "Windows 通知:status、reload、test [permission|question|completed|aborted|failed]",
124
- getArgumentCompletions: prefix => {
125
- const leading = prefix.match(/^\s*/u)![0];
126
- const argument = prefix.slice(leading.length);
127
- if (!/\s/u.test(argument)) {
128
- const matches = ["status", "reload", "test"].filter(value => value.startsWith(argument));
129
- return matches.length ? matches.map(value => ({ value: leading + value, label: value })) : null;
130
- }
131
- const match = argument.match(/^test(\s+)(\S*)$/u);
132
- if (!match) return null;
133
- const matches = KINDS.filter(kind => kind.startsWith(match[2]));
134
- // Pi 會替換整段 argument prefix;必須保留 test 與空白,不只回傳事件名稱。
135
- return matches.length ? matches.map(kind => ({ value: leading + "test" + match[1] + kind, label: kind })) : null;
136
- },
128
+ getArgumentCompletions: notifierArgumentCompletions,
137
129
  handler: async (args, context) => {
138
130
  const parts = args.trim().split(/\s+/u);
139
131
  if (parts.length === 1 && parts[0] === "reload") {
@@ -147,11 +139,12 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
147
139
  backend: target?.backendCode ?? "NOT_STARTED",
148
140
  enabled: target?.result.config.enabled ?? false,
149
141
  schemaVersion: target?.result.config.schemaVersion,
150
- // 自訂文字只送入 helper;status 不展示可能含敏感資訊的設定文字。
142
+ // 自訂文字與檔案路徑只送入 helper;status 不展示可能含敏感資訊的設定。
151
143
  events: target ? Object.fromEntries(KINDS.map(kind => {
152
144
  const event = target.result.config.events[kind];
153
145
  return [kind, { enabled: event.enabled, toast: { enabled: event.toast.enabled },
154
- sound: { enabled: event.sound.enabled, source: { ...event.sound.source } } }];
146
+ sound: { enabled: event.sound.enabled, source: event.sound.source.type === "system"
147
+ ? { ...event.sound.source } : { type: "file" } } }];
155
148
  })) : undefined,
156
149
  ...target?.scheduler?.status(),
157
150
  diagnostics: [...(target?.diagnostics ?? [])],
package/src/types.ts CHANGED
@@ -6,7 +6,9 @@ export type SystemSound = (typeof SYSTEM_SOUNDS)[number];
6
6
  export const TITLE_LIMIT = 128;
7
7
  export const MESSAGE_LIMIT = 512;
8
8
  export interface ToastConfig { enabled: boolean; title: string; message: string }
9
- export interface SoundConfig { enabled: boolean; source: { type: "system"; name: SystemSound } }
9
+ export const SOUND_PATH_LIMIT = 1024;
10
+ export type SoundSource = { type: "system"; name: SystemSound } | { type: "file"; path: string };
11
+ export interface SoundConfig { enabled: boolean; source: SoundSource }
10
12
  export interface NotificationPayload { kind: NotificationKind; toast: ToastConfig; sound: SoundConfig }
11
13
  /** 限制單行 XML 文字;保留引號與 XML 字元,由 DOM 安全編碼。 */
12
14
  export function validText(value: unknown, limit: number): value is string {
@@ -16,6 +18,19 @@ export function validText(value: unknown, limit: number): value is string {
16
18
  export function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
17
19
  return Object.keys(value).every(key => keys.includes(key));
18
20
  }
21
+ /** 僅接受一般磁碟上的絕對 WAV 路徑;不展開環境變數、家目錄或專案相對路徑。 */
22
+ export function validSoundPath(value: unknown): value is string {
23
+ if (!validText(value, SOUND_PATH_LIMIT) || !/^[a-z]:[\\/]/iu.test(value) ||
24
+ /[<>:"|?*]/u.test(value.slice(2)) || !/\.wav$/iu.test(value)) return false;
25
+ return value.slice(3).split(/[\\/]/u).every(part => part.length > 0 && !/[. ]$/u.test(part) &&
26
+ !/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(part));
27
+ }
28
+ export function validSoundSource(value: unknown): value is SoundSource {
29
+ if (!isRecord(value)) return false;
30
+ return value.type === "system"
31
+ ? onlyKeys(value, ["type", "name"]) && SYSTEM_SOUNDS.includes(value.name as SystemSound)
32
+ : value.type === "file" && onlyKeys(value, ["type", "path"]) && validSoundPath(value.path);
33
+ }
19
34
  export function validPayload(value: unknown): value is NotificationPayload {
20
35
  if (!isRecord(value) || !onlyKeys(value, ["kind", "toast", "sound"]) ||
21
36
  !KINDS.includes(value.kind as NotificationKind) || !isRecord(value.toast) || !isRecord(value.sound)) return false;
@@ -23,8 +38,7 @@ export function validPayload(value: unknown): value is NotificationPayload {
23
38
  return onlyKeys(toast, ["enabled", "title", "message"]) && typeof toast.enabled === "boolean" &&
24
39
  validText(toast.title, TITLE_LIMIT) && validText(toast.message, MESSAGE_LIMIT) &&
25
40
  onlyKeys(sound, ["enabled", "source"]) && typeof sound.enabled === "boolean" &&
26
- isRecord(sound.source) && onlyKeys(sound.source, ["type", "name"]) &&
27
- sound.source.type === "system" && SYSTEM_SOUNDS.includes(sound.source.name as SystemSound) &&
41
+ validSoundSource(sound.source) &&
28
42
  (toast.enabled || sound.enabled);
29
43
  }
30
44
  export interface NotificationJob {
@@ -1,8 +1,9 @@
1
- # 固定 helper:只接受已驗證的通道設定與靜態文字,不接受任意命令或音效路徑。
1
+ # 固定 helper:只接受已驗證的通道設定、靜態文字與本機 WAV 路徑,不接受任意命令。
2
2
  Set-StrictMode -Version Latest
3
3
  $ErrorActionPreference = "Stop"
4
4
  [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false, $true)
5
5
  [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
6
+ . (Join-Path $PSScriptRoot "windows-sound.ps1")
6
7
 
7
8
  function Write-Result {
8
9
  param([string]$Code, [bool]$Toast, [string]$Sound)
@@ -67,10 +68,15 @@ try {
67
68
  Assert-Text $inputData.toast.message 512
68
69
  Assert-Keys $inputData.sound @("enabled", "source")
69
70
  if ($inputData.sound.enabled -isnot [bool]) { throw "INPUT_INVALID" }
70
- Assert-Keys $inputData.sound.source @("type", "name")
71
- if ($inputData.sound.source.type -cne "system" -or $inputData.sound.source.type -isnot [string] -or
72
- $inputData.sound.source.name -isnot [string] -or
73
- @("Asterisk", "Beep", "Exclamation", "Hand", "Question") -cnotcontains $inputData.sound.source.name) { throw "INPUT_INVALID" }
71
+ if ($inputData.sound.source.type -ceq "system") {
72
+ Assert-Keys $inputData.sound.source @("type", "name")
73
+ if ($inputData.sound.source.type -isnot [string] -or $inputData.sound.source.name -isnot [string] -or
74
+ @("Asterisk", "Beep", "Exclamation", "Hand", "Question") -cnotcontains $inputData.sound.source.name) { throw "INPUT_INVALID" }
75
+ } elseif ($inputData.sound.source.type -ceq "file") {
76
+ Assert-Keys $inputData.sound.source @("type", "path")
77
+ if ($inputData.sound.source.type -isnot [string]) { throw "INPUT_INVALID" }
78
+ Assert-SoundPath $inputData.sound.source.path
79
+ } else { throw "INPUT_INVALID" }
74
80
  }
75
81
  catch {
76
82
  Write-Result "INPUT_INVALID" $false "failed"
@@ -101,17 +107,21 @@ if ($inputData.toast.enabled) {
101
107
 
102
108
  if ($inputData.sound.enabled) {
103
109
  try {
104
- # 固定 switch 不使用反射、動態 member access 或外部播放器。
105
- switch -CaseSensitive ($inputData.sound.source.name) {
106
- "Asterisk" { [System.Media.SystemSounds]::Asterisk.Play() }
107
- "Beep" { [System.Media.SystemSounds]::Beep.Play() }
108
- "Exclamation" { [System.Media.SystemSounds]::Exclamation.Play() }
109
- "Hand" { [System.Media.SystemSounds]::Hand.Play() }
110
- "Question" { [System.Media.SystemSounds]::Question.Play() }
110
+ if ($inputData.sound.source.type -ceq "file") {
111
+ Play-LocalWave $inputData.sound.source.path
112
+ } else {
113
+ # 固定 switch 不使用反射、動態 member access 或外部播放器。
114
+ switch -CaseSensitive ($inputData.sound.source.name) {
115
+ "Asterisk" { [System.Media.SystemSounds]::Asterisk.Play() }
116
+ "Beep" { [System.Media.SystemSounds]::Beep.Play() }
117
+ "Exclamation" { [System.Media.SystemSounds]::Exclamation.Play() }
118
+ "Hand" { [System.Media.SystemSounds]::Hand.Play() }
119
+ "Question" { [System.Media.SystemSounds]::Question.Play() }
120
+ }
121
+ # Play 非同步;有界等待不保證聲音實際送達,也不建立其他播放器。
122
+ Start-Sleep -Milliseconds 750
111
123
  }
112
124
  $soundStatus = "played"
113
- # Play 非同步;有界等待不保證聲音實際送達,也不建立其他播放器。
114
- Start-Sleep -Milliseconds 750
115
125
  }
116
126
  catch { $soundStatus = "failed" }
117
127
  }
@@ -0,0 +1,110 @@
1
+ # 本機 WAV 的獨立驗證與有界讀取;不執行路徑,也不使用 URL、COM 或外部播放器。
2
+ function Assert-SoundPath {
3
+ param($Value)
4
+ if ($Value -isnot [string] -or $Value.Length -gt 1024 -or
5
+ $Value -notmatch '^[a-zA-Z]:[\\/]' -or $Value -notmatch '(?i)\.wav$' -or
6
+ $Value -match '[\x00-\x1f\x7f-\x9f\u2028\u2029\ufffe\uffff]' -or
7
+ $Value.Substring(2) -match '[<>:"|?*]') { throw "INPUT_INVALID" }
8
+ [System.Xml.XmlConvert]::VerifyXmlChars($Value) | Out-Null
9
+ foreach ($part in ($Value.Substring(3) -split '[\\/]')) {
10
+ if ($part.Length -eq 0 -or $part -match '[. ]$' -or
11
+ $part -match '(?i)^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\.|$)') { throw "INPUT_INVALID" }
12
+ }
13
+ }
14
+
15
+ function Assert-Wave {
16
+ param([byte[]]$Bytes)
17
+ # 僅接受 RIFF PCM:mono/stereo、8/16 bit、8–48 kHz,最多五秒。
18
+ if ($Bytes.Length -lt 44 -or $Bytes.Length -gt 5242880 -or
19
+ [Text.Encoding]::ASCII.GetString($Bytes, 0, 4) -cne "RIFF" -or
20
+ [Text.Encoding]::ASCII.GetString($Bytes, 8, 4) -cne "WAVE" -or
21
+ [BitConverter]::ToUInt32($Bytes, 4) -ne $Bytes.Length - 8) { throw "SOUND_FAILED" }
22
+ $offset = 12
23
+ $rate = 0
24
+ $alignment = 0
25
+ $dataSize = 0
26
+ $hasFormat = $false
27
+ $hasData = $false
28
+ while ($offset -lt $Bytes.Length) {
29
+ if ($Bytes.Length - $offset -lt 8) { throw "SOUND_FAILED" }
30
+ $tag = [Text.Encoding]::ASCII.GetString($Bytes, $offset, 4)
31
+ $size = [long][BitConverter]::ToUInt32($Bytes, $offset + 4)
32
+ $start = $offset + 8
33
+ $end = $start + $size + ($size % 2)
34
+ if ($end -gt $Bytes.Length) { throw "SOUND_FAILED" }
35
+ if ($tag -ceq "fmt ") {
36
+ if ($hasFormat -or ($size -ne 16 -and $size -ne 18)) { throw "SOUND_FAILED" }
37
+ if ([BitConverter]::ToUInt16($Bytes, $start) -ne 1 -or
38
+ ($size -eq 18 -and [BitConverter]::ToUInt16($Bytes, $start + 16) -ne 0)) { throw "SOUND_FAILED" }
39
+ $channels = [BitConverter]::ToUInt16($Bytes, $start + 2)
40
+ $sampleRate = [BitConverter]::ToUInt32($Bytes, $start + 4)
41
+ $rate = [BitConverter]::ToUInt32($Bytes, $start + 8)
42
+ $alignment = [BitConverter]::ToUInt16($Bytes, $start + 12)
43
+ $bits = [BitConverter]::ToUInt16($Bytes, $start + 14)
44
+ if (@(1, 2) -notcontains $channels -or @(8, 16) -notcontains $bits -or
45
+ $sampleRate -lt 8000 -or $sampleRate -gt 48000 -or
46
+ $alignment -ne $channels * ($bits / 8) -or $rate -ne $sampleRate * $alignment) { throw "SOUND_FAILED" }
47
+ $hasFormat = $true
48
+ } elseif ($tag -ceq "data") {
49
+ if (-not $hasFormat -or $hasData -or $size -eq 0) { throw "SOUND_FAILED" }
50
+ $dataSize = $size
51
+ $hasData = $true
52
+ }
53
+ $offset = [int]$end
54
+ }
55
+ if (-not $hasFormat -or -not $hasData -or $dataSize % $alignment -ne 0 -or
56
+ $dataSize -gt [long]$rate * 5) { throw "SOUND_FAILED" }
57
+ }
58
+
59
+ function Read-LocalWave {
60
+ param([string]$Path)
61
+ Assert-SoundPath $Path
62
+ # 拒絕網路磁碟及每一層 reparse point,避免一般 junction/symlink 指向遠端。
63
+ $fullPath = [IO.Path]::GetFullPath($Path)
64
+ $drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($fullPath))
65
+ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw "SOUND_FAILED" }
66
+ # 由磁碟根目錄往下檢查,不能先存取可能經由 junction 指向遠端的完整路徑。
67
+ $root = [IO.Path]::GetPathRoot($fullPath)
68
+ $current = $root
69
+ $parts = @("") + ($fullPath.Substring($root.Length) -split '\\')
70
+ foreach ($part in $parts) {
71
+ if ($part.Length -gt 0) { $current = [IO.Path]::Combine($current, $part) }
72
+ $attributes = [IO.File]::GetAttributes($current)
73
+ if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
74
+ ($current -eq $fullPath -and ($attributes -band [IO.FileAttributes]::Directory) -ne 0)) { throw "SOUND_FAILED" }
75
+ }
76
+ $stream = $null
77
+ try {
78
+ # 開啟後不允許其他程序寫入或刪除;只從同一個 handle 讀入有界記憶體。
79
+ $stream = [IO.FileStream]::new($fullPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
80
+ if ($stream.Length -lt 44 -or $stream.Length -gt 5242880) { throw "SOUND_FAILED" }
81
+ $bytes = New-Object byte[] ([int]$stream.Length)
82
+ $offset = 0
83
+ while ($offset -lt $bytes.Length) {
84
+ $count = $stream.Read($bytes, $offset, $bytes.Length - $offset)
85
+ if ($count -eq 0) { throw "SOUND_FAILED" }
86
+ $offset += $count
87
+ }
88
+ Assert-Wave $bytes
89
+ # PowerShell 不可逐 byte 展開管線,保持 byte[] 型別。
90
+ return ,$bytes
91
+ } finally {
92
+ if ($null -ne $stream) { $stream.Dispose() }
93
+ }
94
+ }
95
+
96
+ function Play-LocalWave {
97
+ param([string]$Path)
98
+ $bytes = Read-LocalWave $Path
99
+ $memory = $null
100
+ $player = $null
101
+ try {
102
+ $memory = [IO.MemoryStream]::new($bytes, $false)
103
+ $player = [System.Media.SoundPlayer]::new($memory)
104
+ $player.Load()
105
+ $player.PlaySync()
106
+ } finally {
107
+ if ($null -ne $player) { $player.Dispose() }
108
+ if ($null -ne $memory) { $memory.Dispose() }
109
+ }
110
+ }