pi-windows-notifier 0.1.1 → 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
6
 
7
- Notifications stay on your machine and only tell you what happened. They don't include your questions, commands, file paths, or Pi's replies. The extension doesn't approve permissions or answer questions for you.
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.
8
8
 
9
- [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
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.
10
+
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,11 +41,11 @@ 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
 
44
- The toast title is **Pi**. Notification text and the extension's command messages are currently in Traditional Chinese; there isn't a language setting yet.
48
+ The default toast title is **Pi**, and default messages are in English. You can change the title and message for each event in your config. Command messages are still in Traditional Chinese; there isn't a language switch.
45
49
 
46
50
  Permission notifications work with `@gotgenes/pi-permission-system`, including requests forwarded from a subagent to its parent session. Requests that are automatically allowed or denied, or covered by an existing session approval, don't trigger a notification.
47
51
 
@@ -61,36 +65,131 @@ 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.
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
- The extension doesn't create or edit this file for you, and it doesn't read project-level settings. If the file doesn't exist, it uses these defaults:
72
+ ### Complete config
73
+
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.
67
75
 
68
76
  ```json
69
77
  {
78
+ "schemaVersion": 2,
70
79
  "enabled": true,
80
+ "defaults": {
81
+ "toast": {
82
+ "enabled": true,
83
+ "title": "Pi",
84
+ "message": "Pi needs your attention."
85
+ },
86
+ "sound": {
87
+ "enabled": true,
88
+ "source": {
89
+ "type": "system",
90
+ "name": "Exclamation"
91
+ }
92
+ }
93
+ },
71
94
  "events": {
72
- "permission": { "enabled": true, "sound": true },
73
- "question": { "enabled": true, "sound": true },
74
- "completed": { "enabled": true, "sound": true },
75
- "aborted": { "enabled": true, "sound": true },
76
- "failed": { "enabled": true, "sound": true }
95
+ "permission": {
96
+ "enabled": true,
97
+ "toast": { "enabled": true, "title": "Pi", "message": "Permission approval needed" },
98
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
99
+ },
100
+ "question": {
101
+ "enabled": true,
102
+ "toast": { "enabled": true, "title": "Pi", "message": "Waiting for your answer" },
103
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
104
+ },
105
+ "completed": {
106
+ "enabled": true,
107
+ "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
108
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Hand" } }
109
+ },
110
+ "aborted": {
111
+ "enabled": true,
112
+ "toast": { "enabled": true, "title": "Pi", "message": "Response interrupted" },
113
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
114
+ },
115
+ "failed": {
116
+ "enabled": true,
117
+ "toast": { "enabled": true, "title": "Pi", "message": "Response failed" },
118
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
119
+ }
77
120
  }
78
121
  }
79
122
  ```
80
123
 
81
- You only need to include the values you want to change. For example, to keep completion toasts but turn off their sound:
124
+ ### Fields and override rules
125
+
126
+ Settings are applied in this order: **built-in event defaults → shared `defaults` → individual `events.<event>` settings**. Omitted fields inherit the previous layer. For example, `defaults.toast.message` supplies one message for all events unless an event sets its own message; omit it to keep the built-in event-specific messages.
127
+
128
+ | Field | What it does |
129
+ |---|---|
130
+ | `schemaVersion` | Set to `2` for this format |
131
+ | `enabled` | Master switch; `false` turns all notifications off |
132
+ | `defaults` | Shared `toast` and `sound` settings; there is no `defaults.enabled` |
133
+ | `events.<event>` | Overrides for `permission`, `question`, `completed`, `aborted`, or `failed` |
134
+ | `events.<event>.enabled` | Turns that entire event on or off |
135
+ | `toast.enabled` | Turns the toast on or off |
136
+ | `toast.title` | Static notification title |
137
+ | `toast.message` | Static notification message |
138
+ | `sound.enabled` | Turns the sound on or off |
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 |
142
+
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.
144
+
145
+ - **Toast only**: set `sound.enabled` to `false` and `toast.enabled` to `true`.
146
+ - **Sound only**: set `toast.enabled` to `false` and `sound.enabled` to `true`.
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.
148
+
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.
150
+
151
+ ### Text and sound limits
152
+
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.
154
+
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:
82
160
 
83
161
  ```json
84
- { "events": { "completed": { "sound": false } } }
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
+ }
85
172
  ```
86
173
 
87
- The top-level `enabled` switch controls everything. Each event's `enabled` switch controls both its toast and sound; `sound` only controls its sound.
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`.
88
175
 
89
- After editing the file, run `/windows-notifier reload`. Unknown fields, invalid values, unreadable files, and files larger than 16 KiB disable notifications until you fix the settings and reload. Error messages won't print the file's contents.
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.
183
+
184
+ ### Apply changes and older configs
185
+
186
+ After editing the file, run `/reload` or `/windows-notifier reload`. Unknown fields, unsupported versions or sound sources, invalid values, unreadable files, and files larger than 16 KiB disable notifications until you fix the settings and reload. Error messages won't print the file's contents.
187
+
188
+ The old unversioned format, with a boolean such as `events.completed.sound: false`, still works and is converted in memory without rewriting your file. New channel objects require `schemaVersion: 2`; don't mix old sound booleans with v2 objects.
90
189
 
91
190
  ## Commands
92
191
 
93
- Run these inside Pi:
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.
94
193
 
95
194
  ```text
96
195
  /windows-notifier status
@@ -99,32 +198,19 @@ Run these inside Pi:
99
198
  /windows-notifier test permission
100
199
  ```
101
200
 
102
- - `status` shows the active settings, backend status, queue counters, and diagnostic codes—not your 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.
103
202
  - `reload` reads the config again and cancels old notification work.
104
203
  - `test` sends a completion notification by default. You can also choose `permission`, `question`, `completed`, `aborted`, or `failed`.
105
204
 
106
205
  **Tests produce real notifications and sounds.** They follow the same switches, queue limits, and rate limits as automatic notifications, so they won't override a disabled event.
107
206
 
108
- ## Privacy and process safety
109
-
110
- 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.
111
-
112
- - PowerShell is located using an absolute path under the startup environment's `SystemRoot` or `windir`, never the project directory or `PATH`.
113
- - The helper runs without a shell. It receives only an event type and a sound switch, validates both, and builds the toast from fixed strings. It doesn't interpolate your content into PowerShell commands or XML.
114
- - 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.
115
- - 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.
116
- - 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.
117
- - 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.
118
-
119
- 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.
120
-
121
207
  ## FAQ
122
208
 
123
209
  ### Why aren't notifications showing up?
124
210
 
125
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.
126
212
 
127
- The sender may appear as **PowerShell** because the extension uses the `Microsoft.Windows.PowerShell` AppID. The toast itself says **Pi**. 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.
128
214
 
129
215
  Use `/windows-notifier status` to check these codes:
130
216
 
@@ -141,6 +227,22 @@ Use `/windows-notifier status` to check these codes:
141
227
 
142
228
  Automatic errors show at most one terminal warning per minute. Status uses fixed diagnostic codes rather than raw PowerShell error output.
143
229
 
230
+ ## Privacy and process safety
231
+
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.
235
+
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.
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.
243
+
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.
245
+
144
246
  ## Development
145
247
 
146
248
  ```bash
@@ -149,7 +251,7 @@ npm run verify
149
251
  npm pack --dry-run
150
252
  ```
151
253
 
152
- 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.
153
255
 
154
256
  ## License
155
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
6
 
7
- 通知只告訴你發生了什麼事,不會帶出問題、命令、檔案路徑或模型回答,也不會替你批准權限或回答問題。所有提醒都在本機處理。
7
+ 需要你確認權限、回答問題,或模型回應結束時,會自動跳出 Windows 通知,並播放系統提示音或你自訂的 WAV 音效。就算你正在看別的視窗也會提醒,讓你不用一直盯著 Pi。
8
8
 
9
- [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
9
+ 通知不會從 session 帶出問題、命令、檔案路徑或模型回答,但你可以設定自己的固定提醒文字。它也不會替你批准權限或回答問題,所有提醒都在本機處理。
10
+
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
 
@@ -35,13 +39,13 @@ pi remove npm:pi-windows-notifier
35
39
 
36
40
  | 事件 | 通知內容 | Windows 提示音 |
37
41
  |---|---|---|
38
- | `permission` | 需要權限確認 | Exclamation |
39
- | `question` | 有問題等待回答 | Exclamation |
40
- | `completed` | 回應已完成 | Asterisk |
41
- | `aborted` | 回應已中止 | Exclamation |
42
- | `failed` | 回應失敗 | Exclamation |
42
+ | `permission` | Permission approval needed | Exclamation |
43
+ | `question` | Waiting for your answer | Exclamation |
44
+ | `completed` | Response complete | Hand |
45
+ | `aborted` | Response interrupted | Exclamation |
46
+ | `failed` | Response failed | Exclamation |
43
47
 
44
- 彈窗標題是 **Pi**。目前通知文字和指令提示都是繁體中文,還沒有語言切換設定。
48
+ 彈窗標題預設是 **Pi**,預設訊息是英文。每個事件都能設定自己的標題與訊息;指令提示仍是繁體中文,沒有整體語言切換設定。
45
49
 
46
50
  **權限提醒**搭配 `@gotgenes/pi-permission-system` 使用,也支援從 subagent 轉送到父 session 的權限請求。自動允許、自動拒絕,或已經有 session approval 的請求不會提醒。
47
51
 
@@ -61,36 +65,131 @@ 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
+ ### 完整設定範例
73
+
74
+ 下面列出系統音效的欄位和五種事件,可以直接複製作為起點;檔案音效範例另列於下方。不過,**實際只要保留想修改的欄位,加上 `schemaVersion: 2` 就好**。每個事件都覆寫了共用訊息,因此這份範例的效果等同內建預設。
67
75
 
68
76
  ```json
69
77
  {
78
+ "schemaVersion": 2,
70
79
  "enabled": true,
80
+ "defaults": {
81
+ "toast": {
82
+ "enabled": true,
83
+ "title": "Pi",
84
+ "message": "Pi needs your attention."
85
+ },
86
+ "sound": {
87
+ "enabled": true,
88
+ "source": {
89
+ "type": "system",
90
+ "name": "Exclamation"
91
+ }
92
+ }
93
+ },
71
94
  "events": {
72
- "permission": { "enabled": true, "sound": true },
73
- "question": { "enabled": true, "sound": true },
74
- "completed": { "enabled": true, "sound": true },
75
- "aborted": { "enabled": true, "sound": true },
76
- "failed": { "enabled": true, "sound": true }
95
+ "permission": {
96
+ "enabled": true,
97
+ "toast": { "enabled": true, "title": "Pi", "message": "Permission approval needed" },
98
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
99
+ },
100
+ "question": {
101
+ "enabled": true,
102
+ "toast": { "enabled": true, "title": "Pi", "message": "Waiting for your answer" },
103
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
104
+ },
105
+ "completed": {
106
+ "enabled": true,
107
+ "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
108
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Hand" } }
109
+ },
110
+ "aborted": {
111
+ "enabled": true,
112
+ "toast": { "enabled": true, "title": "Pi", "message": "Response interrupted" },
113
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
114
+ },
115
+ "failed": {
116
+ "enabled": true,
117
+ "toast": { "enabled": true, "title": "Pi", "message": "Response failed" },
118
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
119
+ }
77
120
  }
78
121
  }
79
122
  ```
80
123
 
81
- 只要寫想改的欄位就好。例如保留完成彈窗,但不要播放提示音:
124
+ ### 欄位與覆寫規則
125
+
126
+ 設定依序套用:**內建事件預設 → 共用的 `defaults` → 個別 `events.<事件>` 設定**。沒寫的欄位沿用前一層。例如,`defaults.toast.message` 會讓所有事件共用同一則訊息,除非事件另外設定自己的訊息;省略它就能保留內建的各事件文字。
127
+
128
+ | 欄位 | 用途 |
129
+ |---|---|
130
+ | `schemaVersion` | 使用這個格式時設為 `2` |
131
+ | `enabled` | 總開關,`false` 關閉所有通知 |
132
+ | `defaults` | 共用的 `toast` 和 `sound` 設定,沒有 `defaults.enabled` |
133
+ | `events.<事件>` | 可設定 `permission`、`question`、`completed`、`aborted`、`failed` |
134
+ | `events.<事件>.enabled` | 開啟或關閉整個事件 |
135
+ | `toast.enabled` | 彈窗開關 |
136
+ | `toast.title` | 固定的通知標題 |
137
+ | `toast.message` | 固定的通知訊息 |
138
+ | `sound.enabled` | 音效開關 |
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 |
142
+
143
+ `toast` 和 `sound` 欄位都能放在 `defaults` 或個別事件底下。**完整範例已寫出每個事件的所有欄位,只改 `defaults` 不會改到已被事件覆寫的值。** 想讓事件沿用共用設定,請刪除該事件底下對應的欄位。設定 `sound.source` 時必須提供 `type`,以及系統音效的 `name` 或檔案音效的 `path`,不能同時提供兩者;它會整組取代原本的來源。
144
+
145
+ - **只要彈窗**:將 `sound.enabled` 設為 `false`、`toast.enabled` 設為 `true`。
146
+ - **只要音效**:將 `toast.enabled` 設為 `false`、`sound.enabled` 設為 `true`。
147
+ - 兩個通道都關閉時,不啟動 helper。事件可以覆寫共用通道開關,但不能繞過已關閉的總開關或事件開關。
148
+
149
+ 沒有設定檔時,所有事件與通道都開啟,標題是 Pi,訊息是英文。完成通知使用 Hand,其他事件使用 Exclamation。
150
+
151
+ ### 文字與音效限制
152
+
153
+ 文字會照你寫的內容顯示,不會從 session 帶入變數,也沒有模板替換。標題最多 128 個 UTF-16 code units,訊息最多 512 個;emoji 可能算兩個。兩者都必須是非空白的單行字串,不能包含控制字元或不合法的 XML 字元。自訂文字會出現在 Windows 通知裡,請不要放敏感資訊;它不會顯示在 `status` 或錯誤提示中。
154
+
155
+ 音效名稱選的是 **Windows 系統音效事件**,不是套件內附的不同音效檔。實際聲音取決於 Windows 音效配置;不同事件可能共用同一個聲音,也可能沒有對應音效。你可以在 Windows「音效」設定中查看或修改對應,但修改也會影響使用相同事件的其他程式。改用檔案來源只影響這個 extension,不會修改 Windows 音效配置。
156
+
157
+ ### 自訂 WAV 音效
158
+
159
+ 例如,讓回應完成時播放你自己的音效:
82
160
 
83
161
  ```json
84
- { "events": { "completed": { "sound": false } } }
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
+ }
85
172
  ```
86
173
 
87
- 最外層的 `enabled` 是總開關。每個事件的 `enabled` 控制該類彈窗與音效,`sound` 則只控制音效。
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` 或錯誤提示中。
183
+
184
+ ### 套用修改與舊格式相容
88
185
 
89
- 修改後執行 `/windows-notifier reload`。如果有未知欄位、值的型別不對、檔案無法讀取或超過 16 KiB,通知會先停用;修正後再 reload 即可。錯誤提示不會印出設定檔內容。
186
+ 修改後執行 `/reload` 或 `/windows-notifier reload`。如果有未知欄位、不支援的版本或音效來源、值不合法、檔案無法讀取或超過 16 KiB,通知會先停用;修正後再 reload 即可。錯誤提示不會印出設定檔內容。
187
+
188
+ 原本沒有版本欄位、音效使用 boolean 的格式(例如 `events.completed.sound: false`)仍能使用,只在記憶體裡轉換,不會修改你的檔案。要使用新的通道物件,請加上 `schemaVersion: 2`;不要把舊的音效 boolean 和 v2 物件混在一起。
90
189
 
91
190
  ## 指令
92
191
 
93
- 在 Pi 裡執行:
192
+ 在 Pi 裡執行。輸入 `/windows-notifier ` 後按 **Tab**,會列出 `status`、`reload` 或 `test`;用 **↑/↓** 選擇,再按 **Tab** 接受。補完 `test` 會自動加上空白,接著連按 **Tab** 即可列出並接受事件名稱,不必手動輸入空白;也支援 `test co` 這類前綴。
94
193
 
95
194
  ```text
96
195
  /windows-notifier status
@@ -99,24 +198,12 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
99
198
  /windows-notifier test permission
100
199
  ```
101
200
 
102
- - `status`:查看目前設定、backend 狀態、佇列計數和診斷碼,不會顯示 session 內容。
201
+ - `status`:查看通道開關、音效選擇、backend 狀態、佇列計數和診斷碼,不會顯示自訂文字、檔案路徑或 session 內容。
103
202
  - `reload`:重新讀取設定,取消舊的通知工作。
104
203
  - `test`:預設測試完成通知,也能指定 `permission`、`question`、`completed`、`aborted` 或 `failed`。
105
204
 
106
205
  **測試會真的跳通知、播放音效。** 和自動通知一樣,它會遵守開關、佇列與限流,不會強行送出已停用的事件。
107
206
 
108
- ## 隱私與程序安全
109
-
110
- 套件透過固定的 PowerShell 腳本呼叫 Windows 內建通知和音效 API,不用另外裝通知服務或播放器。沒有網路通知、遙測或自訂音效檔。
111
-
112
- - PowerShell 使用啟動環境的 `SystemRoot`/`windir` 下的絕對路徑,不從專案目錄或 `PATH` 搜尋。
113
- - helper 不透過 shell 執行,只接收事件類型與音效開關。驗證後使用固定文字建立通知,不把工作內容拼進 PowerShell 命令或 XML。
114
- - 子程序只拿到必要的 Windows 環境變數,不繼承 Pi 的完整環境或 API tokens。`-ExecutionPolicy Bypass` 只作用於該子程序,不會取得管理員權限或改動永久設定,也不把 Execution Policy 當成安全邊界。
115
- - 同時最多一個 helper,佇列最多 16 筆,啟動至少間隔一秒。工作等待超過 30 秒會過期,helper 的 timeout 是 10 秒,stdout 和 stderr 各限制 8 KiB。權限和提問比回應結束通知優先。
116
- - 只嘗試終止自己建立的子程序,不會按名稱關閉其他程序。如果 Windows 不允許終止,就等它結束,不會繼續堆出新的 helper。
117
- - 權限決策、問題結束、新回應、reload、session 切換和 shutdown 都會取消相關舊工作。已經顯示的 Toast 無法收回,取消和提交給 Windows 之間仍可能發生競態。
118
-
119
- 這些防護假設 Windows 系統目錄、Pi 啟動環境和已安裝套件可信。它們無法防禦同程序裡的惡意 extension,或已遭入侵的使用者帳號。Pi permission system 並不是 extension 的 OS 沙盒。
120
207
 
121
208
  ## 常見問題
122
209
 
@@ -124,7 +211,7 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
124
211
 
125
212
  Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方案和靜音,都可能讓已提交的通知沒有彈出或沒有聲音。
126
213
 
127
- 因為使用 `Microsoft.Windows.PowerShell` AppID,通知來源可能顯示 **PowerShell**,但彈窗本身會寫 **Pi**。Toast 自帶的音效關閉,提示音另外播放,避免這個 backend 自己重複響兩次。彈窗和音效分開處理,一個失敗仍會嘗試另一個;沒有備用通知方式或播放器。
214
+ 因為使用 `Microsoft.Windows.PowerShell` AppID,通知來源可能顯示 **PowerShell**,但彈窗標題預設是 **Pi**,也可以改成你設定的標題。Toast 自帶的音效關閉,提示音另外播放,避免這個 backend 自己重複響兩次。彈窗和音效分開處理,一個失敗仍會嘗試另一個;沒有備用通知方式或播放器。
128
215
 
129
216
  可以用 `/windows-notifier status` 查看診斷碼:
130
217
 
@@ -141,6 +228,22 @@ Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方
141
228
 
142
229
  自動通知錯誤最多每分鐘顯示一次終端警告。診斷只使用固定代碼,不會帶出 PowerShell 原始錯誤內容。
143
230
 
231
+ ## 隱私與程序安全
232
+
233
+ 套件透過固定的 PowerShell 腳本呼叫 Windows 內建通知和音效 API,不用另外裝通知服務或播放器。沒有網路通知、遙測、下載或內附音效檔。
234
+
235
+ 部分功能限制是刻意的安全設計,不只是平台限制。以下防護針對命令注入、執行檔搜尋劫持、憑證意外外洩、網路路徑存取、無界程序建立,以及誤傷其他程式等風險;不代表套件保證沒有漏洞。
236
+
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。
243
+ - 權限決策、問題結束、新回應、reload、session 切換和 shutdown 都會取消相關舊工作。已經顯示的 Toast 無法收回,取消和提交給 Windows 之間仍可能發生競態。
244
+
245
+ 這些防護假設 Windows 系統目錄、Pi 啟動環境和已安裝套件可信。它們無法防禦同程序裡的惡意 extension,或已遭入侵的使用者帳號。Pi permission system 並不是 extension 的 OS 沙盒。
246
+
144
247
  ## 開發
145
248
 
146
249
  ```bash
@@ -149,7 +252,7 @@ npm run verify
149
252
  npm pack --dry-run
150
253
  ```
151
254
 
152
- 測試使用假時鐘、event bus 和 launcher。Windows 上也會檢查 PowerShell 語法與無效輸入。一般 `npm test` 不會跳彈窗或播放音效;測試範圍與仍需確認的項目見[驗證紀錄](docs/verification.md)。
255
+ 測試使用假時鐘、event bus 和 launcher。Windows 上也會檢查 PowerShell 語法、無效輸入、PCM WAV 驗證、有界檔案讀取與 junction 拒絕。一般 `npm test` 不會跳彈窗或播放音效;測試範圍與仍需確認的項目見[驗證紀錄](docs/verification.md)。
153
256
 
154
257
  ## 授權
155
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,7 +3,9 @@
3
3
  ## 自動驗證
4
4
 
5
5
  - TypeScript `tsc --noEmit` 與 Node test runner 通過;測試覆蓋設定驗證、權限與提問事件、回應狀態、去重取消、限流佇列、PowerShell launcher、資源清理與發布檔案清單。
6
- - Windows 測試檢查固定 PowerShell helper 語法及無效輸入;不呼叫 Toast 成功路徑,也不播放系統音效。
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。這些測試不執行播放函式。
7
9
  - 發布包以 npm pack dry-run 核對;僅包含套件 metadata、runtime 原始碼、PowerShell helper、文件及授權。
8
10
 
9
11
  執行完整檢查:
@@ -16,7 +18,11 @@ npm pack --dry-run
16
18
 
17
19
  ## 實機驗證範圍
18
20
 
19
- Windows backend 的五類通知(permission、question、completed、aborted、failed)曾逐一送出,並經人工確認有 Toast 彈窗與提示音。
21
+ 0.1.x Windows backend 的五類通知(permission、question、completed、aborted、failed)曾逐一送出,並經人工確認有 Toast 彈窗與提示音。
22
+
23
+ 0.2.0 的自訂標題/訊息、Toast-only、sound-only 與新增系統音效選擇尚未完成實際可見/可聽驗收;不以舊版結果推論新 helper 已通過。
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 的取消行為。這些指令會產生實際通知與聲音。
20
26
 
21
27
  這項 backend 驗證不等於所有 Pi 整合情境均已端到端驗收。以下項目仍需在實際使用環境確認:
22
28
 
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "pi-windows-notifier",
3
- "version": "0.1.1",
4
- "description": "Pi 的 Windows Toast 與系統提示音:權限、結構化提問及回應結束通知。",
3
+ "version": "0.3.0",
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",
7
7
  "repository": {
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,10 +1,13 @@
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, isRecord, type NotificationKind } from "./types.ts";
4
+ import { KINDS, TITLE_LIMIT, MESSAGE_LIMIT, isRecord, onlyKeys, validText, validSoundSource,
5
+ type NotificationKind, type ToastConfig, type SoundConfig } from "./types.ts";
5
6
 
6
- export interface EventConfig { enabled: boolean; sound: boolean }
7
+ export interface EventConfig { enabled: boolean; toast: ToastConfig; sound: SoundConfig }
8
+ /** 解析後的有效設定;defaults 已合併至各事件,不保留重複設定來源。 */
7
9
  export interface Config {
10
+ schemaVersion: 2;
8
11
  enabled: boolean;
9
12
  events: Record<NotificationKind, EventConfig>;
10
13
  }
@@ -12,41 +15,94 @@ export type ConfigResult =
12
15
  | { ok: true; config: Config }
13
16
  | { ok: false; code: "CONFIG_INVALID" | "CONFIG_TOO_LARGE" | "CONFIG_READ_FAILED"; config: Config };
14
17
 
18
+ const MESSAGES: Record<NotificationKind, string> = {
19
+ permission: "Permission approval needed", question: "Waiting for your answer", completed: "Response complete",
20
+ aborted: "Response interrupted", failed: "Response failed",
21
+ };
15
22
  export function defaults(): Config {
16
- return { enabled: true, events: Object.fromEntries(KINDS.map(kind =>
17
- [kind, { enabled: true, sound: true }])) as Config["events"] };
23
+ return { schemaVersion: 2, enabled: true, events: Object.fromEntries(KINDS.map(kind =>
24
+ [kind, { enabled: true, toast: { enabled: true, title: "Pi", message: MESSAGES[kind] },
25
+ sound: { enabled: true, source: { type: "system", name: kind === "completed" ? "Hand" : "Exclamation" } } }])) as Config["events"] };
18
26
  }
19
- function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
20
- return Object.keys(value).every(key => keys.includes(key));
27
+ function applyChannels(target: EventConfig, value: Record<string, unknown>): boolean {
28
+ if (Object.hasOwn(value, "toast")) {
29
+ const toast = value.toast;
30
+ if (!isRecord(toast) || !onlyKeys(toast, ["enabled", "title", "message"])) return false;
31
+ if (Object.hasOwn(toast, "enabled")) {
32
+ if (typeof toast.enabled !== "boolean") return false;
33
+ target.toast.enabled = toast.enabled;
34
+ }
35
+ for (const field of ["title", "message"] as const) {
36
+ if (!Object.hasOwn(toast, field)) continue;
37
+ const text = toast[field];
38
+ if (!validText(text, field === "title" ? TITLE_LIMIT : MESSAGE_LIMIT)) return false;
39
+ target.toast[field] = text;
40
+ }
41
+ }
42
+ if (Object.hasOwn(value, "sound")) {
43
+ const sound = value.sound;
44
+ if (!isRecord(sound) || !onlyKeys(sound, ["enabled", "source"])) return false;
45
+ if (Object.hasOwn(sound, "enabled")) {
46
+ if (typeof sound.enabled !== "boolean") return false;
47
+ target.sound.enabled = sound.enabled;
48
+ }
49
+ if (Object.hasOwn(sound, "source")) {
50
+ const source = sound.source;
51
+ if (!validSoundSource(source)) return false;
52
+ // source 作為完整單位覆寫,不合併不同來源類型的欄位。
53
+ target.sound.source = { ...source };
54
+ }
55
+ }
56
+ return true;
21
57
  }
22
58
  export function parseConfig(value: unknown): ConfigResult {
23
59
  const config = defaults();
24
60
  const invalid = (): ConfigResult => ({ ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } });
25
- if (!isRecord(value) || !onlyKeys(value, ["enabled", "events"])) return invalid();
61
+ if (!isRecord(value)) return invalid();
62
+ const legacy = !Object.hasOwn(value, "schemaVersion");
63
+ if (!onlyKeys(value, legacy ? ["enabled", "events"] : ["schemaVersion", "enabled", "defaults", "events"]) ||
64
+ !legacy && value.schemaVersion !== 2) return invalid();
26
65
  if (Object.hasOwn(value, "enabled")) {
27
66
  if (typeof value.enabled !== "boolean") return invalid();
28
67
  config.enabled = value.enabled;
29
68
  }
69
+ if (!legacy && Object.hasOwn(value, "defaults")) {
70
+ if (!isRecord(value.defaults) || !onlyKeys(value.defaults, ["toast", "sound"])) return invalid();
71
+ for (const kind of KINDS) if (!applyChannels(config.events[kind], value.defaults)) return invalid();
72
+ }
30
73
  if (Object.hasOwn(value, "events")) {
31
74
  if (!isRecord(value.events) || !onlyKeys(value.events, KINDS)) return invalid();
32
75
  for (const kind of KINDS) {
33
76
  if (!Object.hasOwn(value.events, kind)) continue;
34
77
  const item = value.events[kind];
35
- if (!isRecord(item) || !onlyKeys(item, ["enabled", "sound"])) return invalid();
36
- for (const field of ["enabled", "sound"] as const) {
37
- if (!Object.hasOwn(item, field)) continue;
38
- if (typeof item[field] !== "boolean") return invalid();
39
- config.events[kind][field] = item[field];
78
+ if (!isRecord(item) || !onlyKeys(item, legacy ? ["enabled", "sound"] : ["enabled", "toast", "sound"])) return invalid();
79
+ const target = config.events[kind];
80
+ if (Object.hasOwn(item, "enabled")) {
81
+ if (typeof item.enabled !== "boolean") return invalid();
82
+ target.enabled = item.enabled;
40
83
  }
84
+ if (legacy) {
85
+ if (Object.hasOwn(item, "sound")) {
86
+ if (typeof item.sound !== "boolean") return invalid();
87
+ target.sound.enabled = item.sound;
88
+ }
89
+ } else if (!applyChannels(target, item)) return invalid();
41
90
  }
42
91
  }
43
92
  return { ok: true, config };
44
93
  }
45
94
  export const CONFIG_LIMIT = 16 * 1024;
46
95
  export function configPath(): string {
47
- return join(homedir(), ".pi", "agent", "pi-windows-notifier", "config.json");
96
+ return join(homedir(), ".pi", "agent", "extensions", "pi-windows-notifier", "config.json");
97
+ }
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() };
48
104
  }
49
- export function loadConfig(path = configPath()): ConfigResult {
105
+ function readConfig(path: string): ConfigResult | undefined {
50
106
  let fd: number | undefined;
51
107
  try {
52
108
  fd = openSync(path, "r");
@@ -62,7 +118,7 @@ export function loadConfig(path = configPath()): ConfigResult {
62
118
  try { return parseConfig(JSON.parse(buffer.subarray(0, size).toString("utf8"))); }
63
119
  catch { return { ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } }; }
64
120
  } catch (error) {
65
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ok: true, config: defaults() };
121
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
66
122
  return { ok: false, code: "CONFIG_READ_FAILED", config: { ...defaults(), enabled: false } };
67
123
  } finally {
68
124
  if (fd !== undefined) closeSync(fd);
package/src/launcher.ts CHANGED
@@ -1,8 +1,8 @@
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
- import { isRecord, type NotificationKind } from "./types.ts";
5
+ import { isRecord, validPayload, type NotificationPayload } from "./types.ts";
6
6
 
7
7
  export const HELPER_TIMEOUT = 10_000;
8
8
  export const OUTPUT_LIMIT = 8 * 1024;
@@ -17,7 +17,7 @@ export interface LaunchResult {
17
17
  export interface Backend {
18
18
  available: boolean;
19
19
  code: string;
20
- launch(kind: NotificationKind, sound: boolean, signal: AbortSignal): Promise<LaunchResult>;
20
+ launch(payload: NotificationPayload, signal: AbortSignal): Promise<LaunchResult>;
21
21
  }
22
22
  const SCRIPT = fileURLToPath(new URL("./windows-notify.ps1", import.meta.url));
23
23
  const ENV_KEYS = ["SystemRoot", "windir", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "USERNAME", "USERDOMAIN"];
@@ -43,7 +43,7 @@ export function windowsPaths(env: NodeJS.ProcessEnv): { executable: string; env:
43
43
  return { executable: win32.join(clean, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), env: result };
44
44
  }
45
45
  function failed(code: LaunchCode): LaunchResult { return { code, toast: false, sound: "failed" }; }
46
- function parseResult(output: Buffer, exitCode: number | null, sound: boolean): LaunchResult {
46
+ function parseResult(output: Buffer, exitCode: number | null, payload: NotificationPayload): LaunchResult {
47
47
  try {
48
48
  const parsed: unknown = JSON.parse(output.toString("utf8").trim());
49
49
  if (!isRecord(parsed) || Object.keys(parsed).length !== 3 ||
@@ -51,13 +51,14 @@ function parseResult(output: Buffer, exitCode: number | null, sound: boolean): L
51
51
  typeof parsed.sound !== "string" || !["played", "disabled", "failed"].includes(parsed.sound)) return failed("HELPER_PROTOCOL");
52
52
  const code = parsed.code as LaunchCode;
53
53
  const audio = parsed.sound as LaunchResult["sound"];
54
- const expected = parsed.toast
55
- ? (audio === "failed" ? "SOUND_FAILED" : "OK")
56
- : (audio === "failed" ? "BOTH_FAILED" : "TOAST_FAILED");
57
- if (audio === "disabled" && sound || audio === "played" && !sound) return failed("HELPER_PROTOCOL");
58
54
  if (["INPUT_INVALID", "INTERNAL_ERROR"].includes(code)) {
59
- return !parsed.toast && audio === "failed" && exitCode !== 0 ? failed(code) : failed("HELPER_PROTOCOL");
55
+ return !parsed.toast && audio === "failed" && exitCode === 1 ? failed(code) : failed("HELPER_PROTOCOL");
60
56
  }
57
+ if ((!payload.toast.enabled && parsed.toast) ||
58
+ (payload.sound.enabled ? audio === "disabled" : audio !== "disabled")) return failed("HELPER_PROTOCOL");
59
+ const toastFailed = payload.toast.enabled && !parsed.toast;
60
+ const soundFailed = payload.sound.enabled && audio === "failed";
61
+ const expected = toastFailed ? (soundFailed ? "BOTH_FAILED" : "TOAST_FAILED") : (soundFailed ? "SOUND_FAILED" : "OK");
61
62
  if (code !== expected || exitCode !== (code === "OK" ? 0 : 1)) return failed("HELPER_PROTOCOL");
62
63
  return { code, toast: parsed.toast, sound: audio };
63
64
  } catch { return failed("HELPER_PROTOCOL"); }
@@ -78,12 +79,16 @@ export function createWindowsBackend(options: BackendOptions = {}): Backend {
78
79
  const script = options.scriptPath ?? SCRIPT;
79
80
  const isFile = options.isFile ?? (path => { try { return statSync(path).isFile(); } catch { return false; } });
80
81
  const available = platform === "win32" && ["x64", "arm64"].includes(arch) &&
81
- !!paths && isFile(paths.executable) && isFile(script);
82
+ !!paths && isFile(paths.executable) && isFile(script) && isFile(join(dirname(script), "windows-sound.ps1"));
82
83
  const spawnProcess = options.spawnProcess ?? spawn;
83
84
  const code = available ? "OK" : "BACKEND_UNAVAILABLE";
84
85
  return {
85
86
  available, code,
86
- launch(kind, sound, signal) {
87
+ launch(payload, signal) {
88
+ if (!validPayload(payload)) return Promise.resolve(failed("INPUT_INVALID"));
89
+ // 再建立白名單快照,避免呼叫方中途修改設定或額外傳入事件內容。
90
+ const request: NotificationPayload = { kind: payload.kind, toast: { ...payload.toast },
91
+ sound: { enabled: payload.sound.enabled, source: { ...payload.sound.source } } };
87
92
  if (!available || !paths) return Promise.resolve(failed("BACKEND_UNAVAILABLE"));
88
93
  if (signal.aborted) return Promise.resolve(failed("CANCELLED"));
89
94
  return new Promise(resolve => {
@@ -130,11 +135,11 @@ export function createWindowsBackend(options: BackendOptions = {}): Backend {
130
135
  if (stderrSize > OUTPUT_LIMIT) stop("OUTPUT_LIMIT");
131
136
  // 不保存或展示 PowerShell 的原始錯誤文字。
132
137
  });
133
- child.once("close", exitCode => finish(reason ? failed(reason) : parseResult(output, exitCode, sound)));
138
+ child.once("close", exitCode => finish(reason ? failed(reason) : parseResult(output, exitCode, request)));
134
139
  child.stdin?.on("error", () => stop("LAUNCH_FAILED"));
135
140
  child.stdout?.on("error", () => stop("LAUNCH_FAILED"));
136
141
  child.stderr?.on("error", () => stop("LAUNCH_FAILED"));
137
- try { child.stdin?.end(JSON.stringify({ kind, sound })); }
142
+ try { child.stdin?.end(JSON.stringify(request)); }
138
143
  catch { stop("LAUNCH_FAILED"); }
139
144
  if (signal.aborted) onAbort();
140
145
  });
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,6 +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]",
128
+ getArgumentCompletions: notifierArgumentCompletions,
124
129
  handler: async (args, context) => {
125
130
  const parts = args.trim().split(/\s+/u);
126
131
  if (parts.length === 1 && parts[0] === "reload") {
@@ -133,7 +138,14 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
133
138
  const status = {
134
139
  backend: target?.backendCode ?? "NOT_STARTED",
135
140
  enabled: target?.result.config.enabled ?? false,
136
- events: target?.result.config.events,
141
+ schemaVersion: target?.result.config.schemaVersion,
142
+ // 自訂文字與檔案路徑只送入 helper;status 不展示可能含敏感資訊的設定。
143
+ events: target ? Object.fromEntries(KINDS.map(kind => {
144
+ const event = target.result.config.events[kind];
145
+ return [kind, { enabled: event.enabled, toast: { enabled: event.toast.enabled },
146
+ sound: { enabled: event.sound.enabled, source: event.sound.source.type === "system"
147
+ ? { ...event.sound.source } : { type: "file" } } }];
148
+ })) : undefined,
137
149
  ...target?.scheduler?.status(),
138
150
  diagnostics: [...(target?.diagnostics ?? [])],
139
151
  };
package/src/scheduler.ts CHANGED
@@ -47,7 +47,8 @@ export class NotificationScheduler {
47
47
  }
48
48
  private enabled(job: NotificationJob): boolean {
49
49
  const config = this.config();
50
- return !this.closed && this.backend.available && config.enabled && config.events[job.kind].enabled;
50
+ const event = config.events[job.kind];
51
+ return !this.closed && this.backend.available && config.enabled && event.enabled && (event.toast.enabled || event.sound.enabled);
51
52
  }
52
53
  private valid(job: NotificationJob): boolean {
53
54
  try { return job.valid(); } catch { return false; }
@@ -114,7 +115,11 @@ export class NotificationScheduler {
114
115
  }
115
116
  private async deliver(item: Pending, signal: AbortSignal): Promise<void> {
116
117
  let result: LaunchResult;
117
- try { result = await this.backend.launch(item.job.kind, this.config().events[item.job.kind].sound, signal); }
118
+ try {
119
+ const event = this.config().events[item.job.kind];
120
+ result = await this.backend.launch({ kind: item.job.kind, toast: { ...event.toast },
121
+ sound: { enabled: event.sound.enabled, source: { ...event.sound.source } } }, signal);
122
+ }
118
123
  catch { result = { code: "LAUNCH_FAILED", toast: false, sound: "failed" }; }
119
124
  if (signal.aborted || result.code === "CANCELLED") { item.resolve({ status: "cancelled" }); return; }
120
125
  if (result.code !== "OK") this.diagnose(result.code);
package/src/types.ts CHANGED
@@ -1,6 +1,46 @@
1
1
  export const KINDS = ["permission", "question", "completed", "aborted", "failed"] as const;
2
2
  export type NotificationKind = (typeof KINDS)[number];
3
3
  export type Outcome = "completed" | "aborted" | "error";
4
+ export const SYSTEM_SOUNDS = ["Asterisk", "Beep", "Exclamation", "Hand", "Question"] as const;
5
+ export type SystemSound = (typeof SYSTEM_SOUNDS)[number];
6
+ export const TITLE_LIMIT = 128;
7
+ export const MESSAGE_LIMIT = 512;
8
+ export interface ToastConfig { enabled: boolean; title: string; message: string }
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 }
12
+ export interface NotificationPayload { kind: NotificationKind; toast: ToastConfig; sound: SoundConfig }
13
+ /** 限制單行 XML 文字;保留引號與 XML 字元,由 DOM 安全編碼。 */
14
+ export function validText(value: unknown, limit: number): value is string {
15
+ return typeof value === "string" && value.trim().length > 0 && value.length <= limit &&
16
+ !/[\u0000-\u001f\u007f-\u009f\u2028\u2029\ufffe\uffff\ud800-\udfff]/u.test(value);
17
+ }
18
+ export function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
19
+ return Object.keys(value).every(key => keys.includes(key));
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
+ }
34
+ export function validPayload(value: unknown): value is NotificationPayload {
35
+ if (!isRecord(value) || !onlyKeys(value, ["kind", "toast", "sound"]) ||
36
+ !KINDS.includes(value.kind as NotificationKind) || !isRecord(value.toast) || !isRecord(value.sound)) return false;
37
+ const { toast, sound } = value;
38
+ return onlyKeys(toast, ["enabled", "title", "message"]) && typeof toast.enabled === "boolean" &&
39
+ validText(toast.title, TITLE_LIMIT) && validText(toast.message, MESSAGE_LIMIT) &&
40
+ onlyKeys(sound, ["enabled", "source"]) && typeof sound.enabled === "boolean" &&
41
+ validSoundSource(sound.source) &&
42
+ (toast.enabled || sound.enabled);
43
+ }
4
44
  export interface NotificationJob {
5
45
  key: string;
6
46
  kind: NotificationKind;
@@ -1,37 +1,82 @@
1
- # 固定 helper:stdin 僅接受事件 enum 與音效開關,不接受任意訊息或命令。
1
+ # 固定 helper:只接受已驗證的通道設定、靜態文字與本機 WAV 路徑,不接受任意命令。
2
2
  Set-StrictMode -Version Latest
3
3
  $ErrorActionPreference = "Stop"
4
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false, $true)
4
5
  [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
6
+ . (Join-Path $PSScriptRoot "windows-sound.ps1")
5
7
 
6
8
  function Write-Result {
7
9
  param([string]$Code, [bool]$Toast, [string]$Sound)
8
10
  [Console]::Out.WriteLine((@{ code = $Code; toast = $Toast; sound = $Sound } | ConvertTo-Json -Compress))
9
11
  }
12
+ function Assert-Keys {
13
+ param($Value, [string[]]$Keys)
14
+ if ($Value -isnot [pscustomobject]) { throw "INPUT_INVALID" }
15
+ $names = @($Value.PSObject.Properties.Name)
16
+ if ($names.Count -ne $Keys.Count) { throw "INPUT_INVALID" }
17
+ foreach ($name in $names) {
18
+ if ($Keys -cnotcontains $name) { throw "INPUT_INVALID" }
19
+ }
20
+ }
21
+ function Assert-Text {
22
+ param($Value, [int]$Limit)
23
+ if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt $Limit -or
24
+ $Value -match '[\x00-\x1f\x7f-\x9f\u2028\u2029\ufffe\uffff]') { throw "INPUT_INVALID" }
25
+ # 同時拒絕未配對的 UTF-16 surrogate;合法 emoji 可保留。
26
+ [System.Xml.XmlConvert]::VerifyXmlChars($Value) | Out-Null
27
+ }
28
+
29
+ function Assert-JsonSurrogates {
30
+ param([string]$Value)
31
+ # ConvertFrom-Json 會把未配對的 surrogate 換成 replacement character;先檢查原始 JSON escape。
32
+ foreach ($match in [regex]::Matches($Value, '"(?:[^"\\]|\\.)*"')) {
33
+ $text = $match.Value
34
+ for ($i = 1; $i -lt $text.Length - 1; $i++) {
35
+ if ($text[$i] -cne '\') { continue }
36
+ if ($text[$i + 1] -cne 'u') { $i++; continue }
37
+ $code = [Convert]::ToInt32($text.Substring($i + 2, 4), 16)
38
+ if ($code -ge 0xd800 -and $code -le 0xdbff) {
39
+ if ($i + 12 -gt $text.Length - 1 -or $text.Substring($i + 6, 2) -cne '\u') { throw "INPUT_INVALID" }
40
+ $low = [Convert]::ToInt32($text.Substring($i + 8, 4), 16)
41
+ if ($low -lt 0xdc00 -or $low -gt 0xdfff) { throw "INPUT_INVALID" }
42
+ $i += 11
43
+ } elseif ($code -ge 0xdc00 -and $code -le 0xdfff) {
44
+ throw "INPUT_INVALID"
45
+ } else { $i += 5 }
46
+ }
47
+ }
48
+ }
10
49
 
11
50
  try {
12
- $buffer = New-Object char[] 513
51
+ $buffer = New-Object char[] 4097
13
52
  $size = 0
14
53
  while ($size -lt $buffer.Length) {
15
54
  $count = [Console]::In.Read($buffer, $size, $buffer.Length - $size)
16
55
  if ($count -eq 0) { break }
17
56
  $size += $count
18
57
  }
19
- if ($size -gt 512) { throw "INPUT_INVALID" }
20
- $inputData = (-join $buffer[0..($size - 1)]) | ConvertFrom-Json
21
- $names = @($inputData.PSObject.Properties.Name)
22
- if ($names.Count -ne 2 -or -not ($names -contains "kind") -or -not ($names -contains "sound") -or
23
- $inputData.kind -isnot [string] -or $inputData.sound -isnot [bool]) { throw "INPUT_INVALID" }
24
- $messages = @{
25
- permission = "需要權限確認"
26
- question = "有問題等待回答"
27
- completed = "回應已完成"
28
- aborted = "回應已中止"
29
- failed = "回應失敗"
30
- }
31
- # PowerShell hashtable 預設不區分大小寫,先以 case-sensitive 比較鎖定 enum。
32
- if (-not (@("permission", "question", "completed", "aborted", "failed") -ccontains $inputData.kind)) {
33
- throw "INPUT_INVALID"
34
- }
58
+ if ($size -eq 0 -or $size -gt 4096) { throw "INPUT_INVALID" }
59
+ $json = -join $buffer[0..($size - 1)]
60
+ Assert-JsonSurrogates $json
61
+ $inputData = $json | ConvertFrom-Json
62
+ Assert-Keys $inputData @("kind", "toast", "sound")
63
+ if ($inputData.kind -isnot [string] -or
64
+ @("permission", "question", "completed", "aborted", "failed") -cnotcontains $inputData.kind) { throw "INPUT_INVALID" }
65
+ Assert-Keys $inputData.toast @("enabled", "title", "message")
66
+ if ($inputData.toast.enabled -isnot [bool]) { throw "INPUT_INVALID" }
67
+ Assert-Text $inputData.toast.title 128
68
+ Assert-Text $inputData.toast.message 512
69
+ Assert-Keys $inputData.sound @("enabled", "source")
70
+ if ($inputData.sound.enabled -isnot [bool]) { 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" }
35
80
  }
36
81
  catch {
37
82
  Write-Result "INPUT_INVALID" $false "failed"
@@ -40,39 +85,53 @@ catch {
40
85
 
41
86
  $toastOk = $false
42
87
  $soundStatus = "disabled"
43
- try {
44
- [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
45
- [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
46
- $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(
47
- [Windows.UI.Notifications.ToastTemplateType]::ToastText02)
48
- $nodes = $xml.GetElementsByTagName("text")
49
- $nodes.Item(0).AppendChild($xml.CreateTextNode("Pi")) | Out-Null
50
- $nodes.Item(1).AppendChild($xml.CreateTextNode($messages[$inputData.kind])) | Out-Null
51
- $audio = $xml.CreateElement("audio")
52
- $audio.SetAttribute("silent", "true")
53
- $xml.DocumentElement.AppendChild($audio) | Out-Null
54
- $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
55
- $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Microsoft.Windows.PowerShell")
56
- $notifier.Show($toast)
57
- $toastOk = $true
88
+ if ($inputData.toast.enabled) {
89
+ try {
90
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
91
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
92
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(
93
+ [Windows.UI.Notifications.ToastTemplateType]::ToastText02)
94
+ $nodes = $xml.GetElementsByTagName("text")
95
+ $nodes.Item(0).AppendChild($xml.CreateTextNode($inputData.toast.title)) | Out-Null
96
+ $nodes.Item(1).AppendChild($xml.CreateTextNode($inputData.toast.message)) | Out-Null
97
+ $audio = $xml.CreateElement("audio")
98
+ $audio.SetAttribute("silent", "true")
99
+ $xml.DocumentElement.AppendChild($audio) | Out-Null
100
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
101
+ $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Microsoft.Windows.PowerShell")
102
+ $notifier.Show($toast)
103
+ $toastOk = $true
104
+ }
105
+ catch { $toastOk = $false }
58
106
  }
59
- catch { $toastOk = $false }
60
107
 
61
- if ($inputData.sound) {
108
+ if ($inputData.sound.enabled) {
62
109
  try {
63
- if ($inputData.kind -ceq "completed") { [System.Media.SystemSounds]::Asterisk.Play() }
64
- else { [System.Media.SystemSounds]::Exclamation.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
123
+ }
65
124
  $soundStatus = "played"
66
- # Play 非同步;有界等待不保證聲音實際送達,也不建立其他播放器。
67
- Start-Sleep -Milliseconds 750
68
125
  }
69
126
  catch { $soundStatus = "failed" }
70
127
  }
71
128
 
72
- $code = if ($toastOk) {
73
- if ($soundStatus -eq "failed") { "SOUND_FAILED" } else { "OK" }
129
+ $toastFailed = $inputData.toast.enabled -and -not $toastOk
130
+ $soundFailed = $inputData.sound.enabled -and $soundStatus -eq "failed"
131
+ $code = if ($toastFailed) {
132
+ if ($soundFailed) { "BOTH_FAILED" } else { "TOAST_FAILED" }
74
133
  } else {
75
- if ($soundStatus -eq "failed") { "BOTH_FAILED" } else { "TOAST_FAILED" }
134
+ if ($soundFailed) { "SOUND_FAILED" } else { "OK" }
76
135
  }
77
136
  Write-Result $code $toastOk $soundStatus
78
137
  if ($code -eq "OK") { exit 0 } else { exit 1 }
@@ -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
+ }