pi-windows-notifier 0.1.1 → 0.2.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
@@ -4,7 +4,7 @@
4
4
 
5
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.
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
+ 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
8
 
9
9
  [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
10
10
 
@@ -41,7 +41,7 @@ pi remove npm:pi-windows-notifier
41
41
  | `aborted` | The response was interrupted | Exclamation |
42
42
  | `failed` | The response ended in an error | Exclamation |
43
43
 
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.
44
+ 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
45
 
46
46
  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
47
 
@@ -61,36 +61,101 @@ Some question packages also ring the terminal bell themselves. If you hear an ex
61
61
 
62
62
  ## Settings
63
63
 
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.
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.
65
65
 
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:
66
+ ### Complete config
67
+
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.
67
69
 
68
70
  ```json
69
71
  {
72
+ "schemaVersion": 2,
70
73
  "enabled": true,
74
+ "defaults": {
75
+ "toast": {
76
+ "enabled": true,
77
+ "title": "Pi",
78
+ "message": "Pi needs your attention."
79
+ },
80
+ "sound": {
81
+ "enabled": true,
82
+ "source": {
83
+ "type": "system",
84
+ "name": "Exclamation"
85
+ }
86
+ }
87
+ },
71
88
  "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 }
89
+ "permission": {
90
+ "enabled": true,
91
+ "toast": { "enabled": true, "title": "Pi", "message": "Permission approval needed" },
92
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
93
+ },
94
+ "question": {
95
+ "enabled": true,
96
+ "toast": { "enabled": true, "title": "Pi", "message": "Waiting for your answer" },
97
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
98
+ },
99
+ "completed": {
100
+ "enabled": true,
101
+ "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
102
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Asterisk" } }
103
+ },
104
+ "aborted": {
105
+ "enabled": true,
106
+ "toast": { "enabled": true, "title": "Pi", "message": "Response interrupted" },
107
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
108
+ },
109
+ "failed": {
110
+ "enabled": true,
111
+ "toast": { "enabled": true, "title": "Pi", "message": "Response failed" },
112
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
113
+ }
77
114
  }
78
115
  }
79
116
  ```
80
117
 
81
- You only need to include the values you want to change. For example, to keep completion toasts but turn off their sound:
118
+ ### Fields and override rules
82
119
 
83
- ```json
84
- { "events": { "completed": { "sound": false } } }
85
- ```
120
+ 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.
121
+
122
+ | Field | What it does |
123
+ |---|---|
124
+ | `schemaVersion` | Set to `2` for this format |
125
+ | `enabled` | Master switch; `false` turns all notifications off |
126
+ | `defaults` | Shared `toast` and `sound` settings; there is no `defaults.enabled` |
127
+ | `events.<event>` | Overrides for `permission`, `question`, `completed`, `aborted`, or `failed` |
128
+ | `events.<event>.enabled` | Turns that entire event on or off |
129
+ | `toast.enabled` | Turns the toast on or off |
130
+ | `toast.title` | Static notification title |
131
+ | `toast.message` | Static notification message |
132
+ | `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 |
135
+
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.
137
+
138
+ - **Toast only**: set `sound.enabled` to `false` and `toast.enabled` to `true`.
139
+ - **Sound only**: set `toast.enabled` to `false` and `sound.enabled` to `true`.
140
+ - 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
+
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.
143
+
144
+ ### Text and sound limits
86
145
 
87
- The top-level `enabled` switch controls everything. Each event's `enabled` switch controls both its toast and sound; `sound` only controls its sound.
146
+ 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.
88
147
 
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.
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.
149
+
150
+ ### Apply changes and older configs
151
+
152
+ 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.
153
+
154
+ 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
155
 
91
156
  ## Commands
92
157
 
93
- Run these inside Pi:
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.
94
159
 
95
160
  ```text
96
161
  /windows-notifier status
@@ -99,32 +164,19 @@ Run these inside Pi:
99
164
  /windows-notifier test permission
100
165
  ```
101
166
 
102
- - `status` shows the active settings, backend status, queue counters, and diagnostic codes—not your session content.
167
+ - `status` shows channel switches, sound choices, backend status, queue counters, and diagnostic codes—not your custom text or session content.
103
168
  - `reload` reads the config again and cancels old notification work.
104
169
  - `test` sends a completion notification by default. You can also choose `permission`, `question`, `completed`, `aborted`, or `failed`.
105
170
 
106
171
  **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
172
 
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
173
  ## FAQ
122
174
 
123
175
  ### Why aren't notifications showing up?
124
176
 
125
177
  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
178
 
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.
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.
128
180
 
129
181
  Use `/windows-notifier status` to check these codes:
130
182
 
@@ -141,6 +193,19 @@ Use `/windows-notifier status` to check these codes:
141
193
 
142
194
  Automatic errors show at most one terminal warning per minute. Status uses fixed diagnostic codes rather than raw PowerShell error output.
143
195
 
196
+ ## Privacy and process safety
197
+
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.
199
+
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.
205
+ - 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
+
207
+ 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.
208
+
144
209
  ## Development
145
210
 
146
211
  ```bash
package/README.zh-TW.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  不用一直盯著 Pi。需要你確認權限、回答問題,或模型回應結束時,這個 extension 會跳出 Windows 通知並播放提示音。就算你正在看別的視窗,也會提醒。
6
6
 
7
- 通知只告訴你發生了什麼事,不會帶出問題、命令、檔案路徑或模型回答,也不會替你批准權限或回答問題。所有提醒都在本機處理。
7
+ 通知不會從 session 帶出問題、命令、檔案路徑或模型回答,但你可以設定自己的固定提醒文字。它也不會替你批准權限或回答問題,所有提醒都在本機處理。
8
8
 
9
9
  [GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
10
10
 
@@ -35,13 +35,13 @@ pi remove npm:pi-windows-notifier
35
35
 
36
36
  | 事件 | 通知內容 | Windows 提示音 |
37
37
  |---|---|---|
38
- | `permission` | 需要權限確認 | Exclamation |
39
- | `question` | 有問題等待回答 | Exclamation |
40
- | `completed` | 回應已完成 | Asterisk |
41
- | `aborted` | 回應已中止 | Exclamation |
42
- | `failed` | 回應失敗 | Exclamation |
38
+ | `permission` | Permission approval needed | Exclamation |
39
+ | `question` | Waiting for your answer | Exclamation |
40
+ | `completed` | Response complete | Asterisk |
41
+ | `aborted` | Response interrupted | Exclamation |
42
+ | `failed` | Response failed | Exclamation |
43
43
 
44
- 彈窗標題是 **Pi**。目前通知文字和指令提示都是繁體中文,還沒有語言切換設定。
44
+ 彈窗標題預設是 **Pi**,預設訊息是英文。每個事件都能設定自己的標題與訊息;指令提示仍是繁體中文,沒有整體語言切換設定。
45
45
 
46
46
  **權限提醒**搭配 `@gotgenes/pi-permission-system` 使用,也支援從 subagent 轉送到父 session 的權限請求。自動允許、自動拒絕,或已經有 session approval 的請求不會提醒。
47
47
 
@@ -61,36 +61,101 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
61
61
 
62
62
  ## 設定
63
63
 
64
- 想改預設行為時,請自行建立 `~/.pi/agent/pi-windows-notifier/config.json`。在 Windows 上,就是使用者家目錄裡的 `.pi\agent\pi-windows-notifier\config.json`。
64
+ 想改預設行為時,請自行建立 `~/.pi/agent/pi-windows-notifier/config.json`。在 Windows 上,就是使用者家目錄裡的 `.pi\agent\pi-windows-notifier\config.json`。套件不會替你建立或修改這個檔案,也不讀專案內的設定。
65
65
 
66
- 套件不會替你建立或修改這個檔案,也不讀專案內的設定。沒有設定檔時,使用以下預設值:
66
+ ### 完整設定範例
67
+
68
+ 下面列出所有支援的欄位和五種事件,可以直接複製作為起點。不過,**實際只要保留想修改的欄位,加上 `schemaVersion: 2` 就好**。每個事件都覆寫了共用訊息,因此這份範例的效果等同內建預設。
67
69
 
68
70
  ```json
69
71
  {
72
+ "schemaVersion": 2,
70
73
  "enabled": true,
74
+ "defaults": {
75
+ "toast": {
76
+ "enabled": true,
77
+ "title": "Pi",
78
+ "message": "Pi needs your attention."
79
+ },
80
+ "sound": {
81
+ "enabled": true,
82
+ "source": {
83
+ "type": "system",
84
+ "name": "Exclamation"
85
+ }
86
+ }
87
+ },
71
88
  "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 }
89
+ "permission": {
90
+ "enabled": true,
91
+ "toast": { "enabled": true, "title": "Pi", "message": "Permission approval needed" },
92
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
93
+ },
94
+ "question": {
95
+ "enabled": true,
96
+ "toast": { "enabled": true, "title": "Pi", "message": "Waiting for your answer" },
97
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
98
+ },
99
+ "completed": {
100
+ "enabled": true,
101
+ "toast": { "enabled": true, "title": "Pi", "message": "Response complete" },
102
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Asterisk" } }
103
+ },
104
+ "aborted": {
105
+ "enabled": true,
106
+ "toast": { "enabled": true, "title": "Pi", "message": "Response interrupted" },
107
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
108
+ },
109
+ "failed": {
110
+ "enabled": true,
111
+ "toast": { "enabled": true, "title": "Pi", "message": "Response failed" },
112
+ "sound": { "enabled": true, "source": { "type": "system", "name": "Exclamation" } }
113
+ }
77
114
  }
78
115
  }
79
116
  ```
80
117
 
81
- 只要寫想改的欄位就好。例如保留完成彈窗,但不要播放提示音:
118
+ ### 欄位與覆寫規則
82
119
 
83
- ```json
84
- { "events": { "completed": { "sound": false } } }
85
- ```
120
+ 設定依序套用:**內建事件預設 → 共用的 `defaults` → 個別 `events.<事件>` 設定**。沒寫的欄位沿用前一層。例如,`defaults.toast.message` 會讓所有事件共用同一則訊息,除非事件另外設定自己的訊息;省略它就能保留內建的各事件文字。
121
+
122
+ | 欄位 | 用途 |
123
+ |---|---|
124
+ | `schemaVersion` | 使用這個格式時設為 `2` |
125
+ | `enabled` | 總開關,`false` 關閉所有通知 |
126
+ | `defaults` | 共用的 `toast` 和 `sound` 設定,沒有 `defaults.enabled` |
127
+ | `events.<事件>` | 可設定 `permission`、`question`、`completed`、`aborted`、`failed` |
128
+ | `events.<事件>.enabled` | 開啟或關閉整個事件 |
129
+ | `toast.enabled` | 彈窗開關 |
130
+ | `toast.title` | 固定的通知標題 |
131
+ | `toast.message` | 固定的通知訊息 |
132
+ | `sound.enabled` | 音效開關 |
133
+ | `sound.source.type` | 目前只支援 `"system"` |
134
+ | `sound.source.name` | `Asterisk`、`Beep`、`Exclamation`、`Hand`、`Question`,大小寫要一致 |
135
+
136
+ `toast` 和 `sound` 欄位都能放在 `defaults` 或個別事件底下。**完整範例已寫出每個事件的所有欄位,只改 `defaults` 不會改到已被事件覆寫的值。** 想讓事件沿用共用設定,請刪除該事件底下對應的欄位。設定 `sound.source` 時必須一起提供 `type` 和 `name`,它會整組取代原本的來源。
137
+
138
+ - **只要彈窗**:將 `sound.enabled` 設為 `false`、`toast.enabled` 設為 `true`。
139
+ - **只要音效**:將 `toast.enabled` 設為 `false`、`sound.enabled` 設為 `true`。
140
+ - 兩個通道都關閉時,不啟動 helper。事件可以覆寫共用通道開關,但不能繞過已關閉的總開關或事件開關。
141
+
142
+ 沒有設定檔時,所有事件與通道都開啟,標題是 Pi,訊息是英文。完成通知使用 Asterisk,其他事件使用 Exclamation。
143
+
144
+ ### 文字與音效限制
86
145
 
87
- 最外層的 `enabled` 是總開關。每個事件的 `enabled` 控制該類彈窗與音效,`sound` 則只控制音效。
146
+ 文字會照你寫的內容顯示,不會從 session 帶入變數,也沒有模板替換。標題最多 128 個 UTF-16 code units,訊息最多 512 個;emoji 可能算兩個。兩者都必須是非空白的單行字串,不能包含控制字元或不合法的 XML 字元。自訂文字會出現在 Windows 通知裡,請不要放敏感資訊;它不會顯示在 `status` 或錯誤提示中。
88
147
 
89
- 修改後執行 `/windows-notifier reload`。如果有未知欄位、值的型別不對、檔案無法讀取或超過 16 KiB,通知會先停用;修正後再 reload 即可。錯誤提示不會印出設定檔內容。
148
+ 音效名稱選的是 **Windows 系統音效事件**,不是套件內附的不同音效檔。實際聲音取決於 Windows 音效配置;不同事件可能共用同一個聲音,也可能沒有對應音效。你可以在 Windows「音效」設定中查看或修改對應,但修改也會影響使用相同事件的其他程式。目前不支援自訂音效檔。
149
+
150
+ ### 套用修改與舊格式相容
151
+
152
+ 修改後執行 `/reload` 或 `/windows-notifier reload`。如果有未知欄位、不支援的版本或音效來源、值不合法、檔案無法讀取或超過 16 KiB,通知會先停用;修正後再 reload 即可。錯誤提示不會印出設定檔內容。
153
+
154
+ 原本沒有版本欄位、音效使用 boolean 的格式(例如 `events.completed.sound: false`)仍能使用,只在記憶體裡轉換,不會修改你的檔案。要使用新的通道物件,請加上 `schemaVersion: 2`;不要把舊的音效 boolean 和 v2 物件混在一起。
90
155
 
91
156
  ## 指令
92
157
 
93
- 在 Pi 裡執行:
158
+ 在 Pi 裡執行。輸入 `/windows-notifier ` 後按 **Tab**,可以補全 `status`、`reload` 或 `test`;輸入 `test ` 後,Tab 會補全事件名稱,也支援 `test co` 這類前綴。
94
159
 
95
160
  ```text
96
161
  /windows-notifier status
@@ -99,24 +164,12 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
99
164
  /windows-notifier test permission
100
165
  ```
101
166
 
102
- - `status`:查看目前設定、backend 狀態、佇列計數和診斷碼,不會顯示 session 內容。
167
+ - `status`:查看通道開關、音效選擇、backend 狀態、佇列計數和診斷碼,不會顯示自訂文字或 session 內容。
103
168
  - `reload`:重新讀取設定,取消舊的通知工作。
104
169
  - `test`:預設測試完成通知,也能指定 `permission`、`question`、`completed`、`aborted` 或 `failed`。
105
170
 
106
171
  **測試會真的跳通知、播放音效。** 和自動通知一樣,它會遵守開關、佇列與限流,不會強行送出已停用的事件。
107
172
 
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
173
 
121
174
  ## 常見問題
122
175
 
@@ -124,7 +177,7 @@ Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好
124
177
 
125
178
  Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方案和靜音,都可能讓已提交的通知沒有彈出或沒有聲音。
126
179
 
127
- 因為使用 `Microsoft.Windows.PowerShell` AppID,通知來源可能顯示 **PowerShell**,但彈窗本身會寫 **Pi**。Toast 自帶的音效關閉,提示音另外播放,避免這個 backend 自己重複響兩次。彈窗和音效分開處理,一個失敗仍會嘗試另一個;沒有備用通知方式或播放器。
180
+ 因為使用 `Microsoft.Windows.PowerShell` AppID,通知來源可能顯示 **PowerShell**,但彈窗標題預設是 **Pi**,也可以改成你設定的標題。Toast 自帶的音效關閉,提示音另外播放,避免這個 backend 自己重複響兩次。彈窗和音效分開處理,一個失敗仍會嘗試另一個;沒有備用通知方式或播放器。
128
181
 
129
182
  可以用 `/windows-notifier status` 查看診斷碼:
130
183
 
@@ -141,6 +194,19 @@ Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方
141
194
 
142
195
  自動通知錯誤最多每分鐘顯示一次終端警告。診斷只使用固定代碼,不會帶出 PowerShell 原始錯誤內容。
143
196
 
197
+ ## 隱私與程序安全
198
+
199
+ 套件透過固定的 PowerShell 腳本呼叫 Windows 內建通知和音效 API,不用另外裝通知服務或播放器。沒有網路通知、遙測或自訂音效檔。
200
+
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。
206
+ - 權限決策、問題結束、新回應、reload、session 切換和 shutdown 都會取消相關舊工作。已經顯示的 Toast 無法收回,取消和提交給 Windows 之間仍可能發生競態。
207
+
208
+ 這些防護假設 Windows 系統目錄、Pi 啟動環境和已安裝套件可信。它們無法防禦同程序裡的惡意 extension,或已遭入侵的使用者帳號。Pi permission system 並不是 extension 的 OS 沙盒。
209
+
144
210
  ## 開發
145
211
 
146
212
  ```bash
@@ -3,7 +3,8 @@
3
3
  ## 自動驗證
4
4
 
5
5
  - TypeScript `tsc --noEmit` 與 Node test runner 通過;測試覆蓋設定驗證、權限與提問事件、回應狀態、去重取消、限流佇列、PowerShell launcher、資源清理與發布檔案清單。
6
- - Windows 測試檢查固定 PowerShell helper 語法及無效輸入;不呼叫 Toast 成功路徑,也不播放系統音效。
6
+ - 設定測試涵蓋 schema v2 合併、舊格式相容、獨立通道、系統音效白名單、自訂文字上限與 Unicode/XML 驗證;status 與錯誤提示不展示設定文字。
7
+ - Windows 測試檢查固定 PowerShell helper 語法、無效輸入,以及通道全關閉時的有效輸入;不呼叫 Toast 或音效 API。單通道成功/失敗結果由 mock launcher 驗證。
7
8
  - 發布包以 npm pack dry-run 核對;僅包含套件 metadata、runtime 原始碼、PowerShell helper、文件及授權。
8
9
 
9
10
  執行完整檢查:
@@ -16,7 +17,9 @@ npm pack --dry-run
16
17
 
17
18
  ## 實機驗證範圍
18
19
 
19
- Windows backend 的五類通知(permission、question、completed、aborted、failed)曾逐一送出,並經人工確認有 Toast 彈窗與提示音。
20
+ 0.1.x Windows backend 的五類通知(permission、question、completed、aborted、failed)曾逐一送出,並經人工確認有 Toast 彈窗與提示音。
21
+
22
+ 0.2.0 的自訂標題/訊息、Toast-only、sound-only 與新增系統音效選擇尚未完成實際可見/可聽驗收;不以舊版結果推論新 helper 已通過。
20
23
 
21
24
  這項 backend 驗證不等於所有 Pi 整合情境均已端到端驗收。以下項目仍需在實際使用環境確認:
22
25
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-windows-notifier",
3
- "version": "0.1.1",
4
- "description": "Pi 的 Windows Toast 與系統提示音:權限、結構化提問及回應結束通知。",
3
+ "version": "0.2.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": {
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, SYSTEM_SOUNDS, TITLE_LIMIT, MESSAGE_LIMIT, isRecord, onlyKeys, validText,
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,32 +15,79 @@ 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" ? "Asterisk" : "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 (!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"] };
55
+ }
56
+ }
57
+ return true;
21
58
  }
22
59
  export function parseConfig(value: unknown): ConfigResult {
23
60
  const config = defaults();
24
61
  const invalid = (): ConfigResult => ({ ok: false, code: "CONFIG_INVALID", config: { ...defaults(), enabled: false } });
25
- if (!isRecord(value) || !onlyKeys(value, ["enabled", "events"])) return invalid();
62
+ if (!isRecord(value)) return invalid();
63
+ const legacy = !Object.hasOwn(value, "schemaVersion");
64
+ if (!onlyKeys(value, legacy ? ["enabled", "events"] : ["schemaVersion", "enabled", "defaults", "events"]) ||
65
+ !legacy && value.schemaVersion !== 2) return invalid();
26
66
  if (Object.hasOwn(value, "enabled")) {
27
67
  if (typeof value.enabled !== "boolean") return invalid();
28
68
  config.enabled = value.enabled;
29
69
  }
70
+ if (!legacy && Object.hasOwn(value, "defaults")) {
71
+ if (!isRecord(value.defaults) || !onlyKeys(value.defaults, ["toast", "sound"])) return invalid();
72
+ for (const kind of KINDS) if (!applyChannels(config.events[kind], value.defaults)) return invalid();
73
+ }
30
74
  if (Object.hasOwn(value, "events")) {
31
75
  if (!isRecord(value.events) || !onlyKeys(value.events, KINDS)) return invalid();
32
76
  for (const kind of KINDS) {
33
77
  if (!Object.hasOwn(value.events, kind)) continue;
34
78
  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];
79
+ if (!isRecord(item) || !onlyKeys(item, legacy ? ["enabled", "sound"] : ["enabled", "toast", "sound"])) return invalid();
80
+ const target = config.events[kind];
81
+ if (Object.hasOwn(item, "enabled")) {
82
+ if (typeof item.enabled !== "boolean") return invalid();
83
+ target.enabled = item.enabled;
40
84
  }
85
+ if (legacy) {
86
+ if (Object.hasOwn(item, "sound")) {
87
+ if (typeof item.sound !== "boolean") return invalid();
88
+ target.sound.enabled = item.sound;
89
+ }
90
+ } else if (!applyChannels(target, item)) return invalid();
41
91
  }
42
92
  }
43
93
  return { ok: true, config };
package/src/launcher.ts CHANGED
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
2
2
  import { statSync } from "node:fs";
3
3
  import { dirname, 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"); }
@@ -83,7 +84,11 @@ export function createWindowsBackend(options: BackendOptions = {}): Backend {
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
@@ -121,6 +121,19 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
121
121
 
122
122
  pi.registerCommand("windows-notifier", {
123
123
  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
+ },
124
137
  handler: async (args, context) => {
125
138
  const parts = args.trim().split(/\s+/u);
126
139
  if (parts.length === 1 && parts[0] === "reload") {
@@ -133,7 +146,13 @@ export function registerNotifier(pi: ExtensionAPI, options: RuntimeOptions = {})
133
146
  const status = {
134
147
  backend: target?.backendCode ?? "NOT_STARTED",
135
148
  enabled: target?.result.config.enabled ?? false,
136
- events: target?.result.config.events,
149
+ schemaVersion: target?.result.config.schemaVersion,
150
+ // 自訂文字只送入 helper;status 不展示可能含敏感資訊的設定文字。
151
+ events: target ? Object.fromEntries(KINDS.map(kind => {
152
+ const event = target.result.config.events[kind];
153
+ return [kind, { enabled: event.enabled, toast: { enabled: event.toast.enabled },
154
+ sound: { enabled: event.sound.enabled, source: { ...event.sound.source } } }];
155
+ })) : undefined,
137
156
  ...target?.scheduler?.status(),
138
157
  diagnostics: [...(target?.diagnostics ?? [])],
139
158
  };
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,32 @@
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 interface SoundConfig { enabled: boolean; source: { type: "system"; name: SystemSound } }
10
+ export interface NotificationPayload { kind: NotificationKind; toast: ToastConfig; sound: SoundConfig }
11
+ /** 限制單行 XML 文字;保留引號與 XML 字元,由 DOM 安全編碼。 */
12
+ export function validText(value: unknown, limit: number): value is string {
13
+ return typeof value === "string" && value.trim().length > 0 && value.length <= limit &&
14
+ !/[\u0000-\u001f\u007f-\u009f\u2028\u2029\ufffe\uffff\ud800-\udfff]/u.test(value);
15
+ }
16
+ export function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
17
+ return Object.keys(value).every(key => keys.includes(key));
18
+ }
19
+ export function validPayload(value: unknown): value is NotificationPayload {
20
+ if (!isRecord(value) || !onlyKeys(value, ["kind", "toast", "sound"]) ||
21
+ !KINDS.includes(value.kind as NotificationKind) || !isRecord(value.toast) || !isRecord(value.sound)) return false;
22
+ const { toast, sound } = value;
23
+ return onlyKeys(toast, ["enabled", "title", "message"]) && typeof toast.enabled === "boolean" &&
24
+ validText(toast.title, TITLE_LIMIT) && validText(toast.message, MESSAGE_LIMIT) &&
25
+ 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) &&
28
+ (toast.enabled || sound.enabled);
29
+ }
4
30
  export interface NotificationJob {
5
31
  key: string;
6
32
  kind: NotificationKind;
@@ -1,37 +1,76 @@
1
- # 固定 helper:stdin 僅接受事件 enum 與音效開關,不接受任意訊息或命令。
1
+ # 固定 helper:只接受已驗證的通道設定與靜態文字,不接受任意命令或音效路徑。
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)
5
6
 
6
7
  function Write-Result {
7
8
  param([string]$Code, [bool]$Toast, [string]$Sound)
8
9
  [Console]::Out.WriteLine((@{ code = $Code; toast = $Toast; sound = $Sound } | ConvertTo-Json -Compress))
9
10
  }
11
+ function Assert-Keys {
12
+ param($Value, [string[]]$Keys)
13
+ if ($Value -isnot [pscustomobject]) { throw "INPUT_INVALID" }
14
+ $names = @($Value.PSObject.Properties.Name)
15
+ if ($names.Count -ne $Keys.Count) { throw "INPUT_INVALID" }
16
+ foreach ($name in $names) {
17
+ if ($Keys -cnotcontains $name) { throw "INPUT_INVALID" }
18
+ }
19
+ }
20
+ function Assert-Text {
21
+ param($Value, [int]$Limit)
22
+ if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value) -or $Value.Length -gt $Limit -or
23
+ $Value -match '[\x00-\x1f\x7f-\x9f\u2028\u2029\ufffe\uffff]') { throw "INPUT_INVALID" }
24
+ # 同時拒絕未配對的 UTF-16 surrogate;合法 emoji 可保留。
25
+ [System.Xml.XmlConvert]::VerifyXmlChars($Value) | Out-Null
26
+ }
27
+
28
+ function Assert-JsonSurrogates {
29
+ param([string]$Value)
30
+ # ConvertFrom-Json 會把未配對的 surrogate 換成 replacement character;先檢查原始 JSON escape。
31
+ foreach ($match in [regex]::Matches($Value, '"(?:[^"\\]|\\.)*"')) {
32
+ $text = $match.Value
33
+ for ($i = 1; $i -lt $text.Length - 1; $i++) {
34
+ if ($text[$i] -cne '\') { continue }
35
+ if ($text[$i + 1] -cne 'u') { $i++; continue }
36
+ $code = [Convert]::ToInt32($text.Substring($i + 2, 4), 16)
37
+ if ($code -ge 0xd800 -and $code -le 0xdbff) {
38
+ if ($i + 12 -gt $text.Length - 1 -or $text.Substring($i + 6, 2) -cne '\u') { throw "INPUT_INVALID" }
39
+ $low = [Convert]::ToInt32($text.Substring($i + 8, 4), 16)
40
+ if ($low -lt 0xdc00 -or $low -gt 0xdfff) { throw "INPUT_INVALID" }
41
+ $i += 11
42
+ } elseif ($code -ge 0xdc00 -and $code -le 0xdfff) {
43
+ throw "INPUT_INVALID"
44
+ } else { $i += 5 }
45
+ }
46
+ }
47
+ }
10
48
 
11
49
  try {
12
- $buffer = New-Object char[] 513
50
+ $buffer = New-Object char[] 4097
13
51
  $size = 0
14
52
  while ($size -lt $buffer.Length) {
15
53
  $count = [Console]::In.Read($buffer, $size, $buffer.Length - $size)
16
54
  if ($count -eq 0) { break }
17
55
  $size += $count
18
56
  }
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
- }
57
+ if ($size -eq 0 -or $size -gt 4096) { throw "INPUT_INVALID" }
58
+ $json = -join $buffer[0..($size - 1)]
59
+ Assert-JsonSurrogates $json
60
+ $inputData = $json | ConvertFrom-Json
61
+ Assert-Keys $inputData @("kind", "toast", "sound")
62
+ if ($inputData.kind -isnot [string] -or
63
+ @("permission", "question", "completed", "aborted", "failed") -cnotcontains $inputData.kind) { throw "INPUT_INVALID" }
64
+ Assert-Keys $inputData.toast @("enabled", "title", "message")
65
+ if ($inputData.toast.enabled -isnot [bool]) { throw "INPUT_INVALID" }
66
+ Assert-Text $inputData.toast.title 128
67
+ Assert-Text $inputData.toast.message 512
68
+ Assert-Keys $inputData.sound @("enabled", "source")
69
+ 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" }
35
74
  }
36
75
  catch {
37
76
  Write-Result "INPUT_INVALID" $false "failed"
@@ -40,28 +79,36 @@ catch {
40
79
 
41
80
  $toastOk = $false
42
81
  $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
82
+ if ($inputData.toast.enabled) {
83
+ try {
84
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
85
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
86
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(
87
+ [Windows.UI.Notifications.ToastTemplateType]::ToastText02)
88
+ $nodes = $xml.GetElementsByTagName("text")
89
+ $nodes.Item(0).AppendChild($xml.CreateTextNode($inputData.toast.title)) | Out-Null
90
+ $nodes.Item(1).AppendChild($xml.CreateTextNode($inputData.toast.message)) | Out-Null
91
+ $audio = $xml.CreateElement("audio")
92
+ $audio.SetAttribute("silent", "true")
93
+ $xml.DocumentElement.AppendChild($audio) | Out-Null
94
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
95
+ $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Microsoft.Windows.PowerShell")
96
+ $notifier.Show($toast)
97
+ $toastOk = $true
98
+ }
99
+ catch { $toastOk = $false }
58
100
  }
59
- catch { $toastOk = $false }
60
101
 
61
- if ($inputData.sound) {
102
+ if ($inputData.sound.enabled) {
62
103
  try {
63
- if ($inputData.kind -ceq "completed") { [System.Media.SystemSounds]::Asterisk.Play() }
64
- else { [System.Media.SystemSounds]::Exclamation.Play() }
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() }
111
+ }
65
112
  $soundStatus = "played"
66
113
  # Play 非同步;有界等待不保證聲音實際送達,也不建立其他播放器。
67
114
  Start-Sleep -Milliseconds 750
@@ -69,10 +116,12 @@ if ($inputData.sound) {
69
116
  catch { $soundStatus = "failed" }
70
117
  }
71
118
 
72
- $code = if ($toastOk) {
73
- if ($soundStatus -eq "failed") { "SOUND_FAILED" } else { "OK" }
119
+ $toastFailed = $inputData.toast.enabled -and -not $toastOk
120
+ $soundFailed = $inputData.sound.enabled -and $soundStatus -eq "failed"
121
+ $code = if ($toastFailed) {
122
+ if ($soundFailed) { "BOTH_FAILED" } else { "TOAST_FAILED" }
74
123
  } else {
75
- if ($soundStatus -eq "failed") { "BOTH_FAILED" } else { "TOAST_FAILED" }
124
+ if ($soundFailed) { "SOUND_FAILED" } else { "OK" }
76
125
  }
77
126
  Write-Result $code $toastOk $soundStatus
78
127
  if ($code -eq "OK") { exit 0 } else { exit 1 }