pi-windows-notifier 0.1.0 → 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 +159 -75
- package/README.zh-TW.md +222 -0
- package/THIRD_PARTY_NOTICES.md +10 -11
- package/docs/verification.md +25 -19
- package/package.json +11 -2
- package/src/config.ts +62 -12
- package/src/launcher.ts +16 -11
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +7 -2
- package/src/types.ts +26 -0
- package/src/windows-notify.ps1 +89 -40
package/README.md
CHANGED
|
@@ -1,130 +1,212 @@
|
|
|
1
1
|
# pi-windows-notifier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.md) | [繁體中文](README.zh-TW.md)
|
|
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
|
-
|
|
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
|
-
-
|
|
10
|
-
- Pi 1.1.0+;開發型別檢查使用 1.1.0。舊版事件 API 不支援,沒有 fallback。
|
|
11
|
-
- 僅互動式 TUI。RPC、print、JSON、非 Windows、32 位元 Node 停用通知,載入不拋錯。
|
|
12
|
-
- 使用 Windows 內建 Windows PowerShell 5.1;不需要 ffplay、node-notifier 或另外下載 executable。
|
|
9
|
+
[GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
## Install
|
|
15
12
|
|
|
16
13
|
```bash
|
|
17
|
-
pi
|
|
14
|
+
pi install npm:pi-windows-notifier
|
|
18
15
|
```
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
Start a new Pi session after installing. You don't need a config file to get started—all five notification types and their sounds are enabled by default.
|
|
18
|
+
|
|
19
|
+
You'll need:
|
|
20
|
+
|
|
21
|
+
- Windows 10 or 11.
|
|
22
|
+
- 64-bit Node.js 22.19 or newer (x64 or arm64).
|
|
23
|
+
- Pi 1.1.0 or newer.
|
|
24
|
+
- Windows PowerShell 5.1, included with Windows.
|
|
25
|
+
|
|
26
|
+
The extension only sends notifications in Pi's interactive terminal UI. It stays inactive in RPC, print, and JSON modes, on other operating systems, and with 32-bit Node.js. It doesn't support older Pi versions without the required UI events.
|
|
27
|
+
|
|
28
|
+
To uninstall:
|
|
21
29
|
|
|
22
30
|
```bash
|
|
23
|
-
pi
|
|
31
|
+
pi remove npm:pi-windows-notifier
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
## When it notifies you
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
| 事件 | Toast 標題/內容 | 提示音 |
|
|
36
|
+
| Event | When you get a notification | Windows sound |
|
|
31
37
|
|---|---|---|
|
|
32
|
-
| permission | Pi
|
|
33
|
-
| question |
|
|
34
|
-
| completed | Pi
|
|
35
|
-
| aborted |
|
|
36
|
-
| failed |
|
|
38
|
+
| `permission` | Pi needs you to review a permission request | Exclamation |
|
|
39
|
+
| `question` | A supported question tool is waiting for your answer | Exclamation |
|
|
40
|
+
| `completed` | Pi has finished responding | Asterisk |
|
|
41
|
+
| `aborted` | The response was interrupted | Exclamation |
|
|
42
|
+
| `failed` | The response ended in an error | Exclamation |
|
|
43
|
+
|
|
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
|
+
|
|
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.
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
- **提問**:支援 `ask_user_question`(包含 RPIV Lean)與 `plan_mode_question`。追蹤工具執行及真正等待 UI 的訊號,一次 questionnaire 只提醒一次,不按題數重複。
|
|
40
|
-
- **回應結束**:僅在 `agent_settled` 發送。重試/續跑尚未結束時不報完成或失敗;重試成功只報完成,單一工具失敗不等於模型失敗。
|
|
41
|
-
- 不辨識普通文字中的問句,不提醒單純手動設定 UI。不論終端是否在前景都提醒。
|
|
42
|
-
- 提問與權限套件是可選整合來源,不是本 package 的 dependencies;沒有安裝時,回應結束通知仍可使用。
|
|
48
|
+
Question notifications work with `ask_user_question` (including RPIV Lean) and `plan_mode_question`. One questionnaire gets one notification, not one per question. Ordinary questions written in a model's reply and manually opened settings dialogs don't count.
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
Response notifications wait until Pi has actually settled. They don't fire halfway through an automatic retry or continuation, and a failed tool call on its own doesn't count as a failed response.
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
Permission and question packages are optional. You don't have to install them to get response notifications.
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
### A note about question detection
|
|
49
55
|
|
|
50
|
-
|
|
56
|
+
Pi's UI events don't say which tool opened a dialog. This extension only classifies a UI wait as a question when exactly one supported question tool is running and no permission prompt or known unrelated UI is occupying the wait. If the signals are ambiguous, it skips the notification and records `UI_AMBIGUOUS` rather than guessing.
|
|
51
57
|
|
|
52
|
-
|
|
58
|
+
This can't reliably identify every possible combination of overlapping dialogs. RPIV's `rpiv:ask-user:blocked` event provides an additional signal and shares the same deduplication logic.
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
Some question packages also ring the terminal bell themselves. If you hear an extra sound, check that package's settings or your terminal's bell settings.
|
|
61
|
+
|
|
62
|
+
## Settings
|
|
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. The extension doesn't create or edit this file, and it doesn't read project-level settings.
|
|
65
|
+
|
|
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.
|
|
55
69
|
|
|
56
70
|
```json
|
|
57
71
|
{
|
|
72
|
+
"schemaVersion": 2,
|
|
58
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
|
+
},
|
|
59
88
|
"events": {
|
|
60
|
-
"permission": {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
116
|
```
|
|
68
117
|
|
|
69
|
-
|
|
118
|
+
### Fields and override rules
|
|
70
119
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
145
|
+
|
|
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.
|
|
147
|
+
|
|
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.
|
|
74
149
|
|
|
75
|
-
|
|
150
|
+
### Apply changes and older configs
|
|
76
151
|
|
|
77
|
-
|
|
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.
|
|
78
153
|
|
|
79
|
-
|
|
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.
|
|
155
|
+
|
|
156
|
+
## Commands
|
|
157
|
+
|
|
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.
|
|
80
159
|
|
|
81
160
|
```text
|
|
82
161
|
/windows-notifier status
|
|
83
162
|
/windows-notifier reload
|
|
84
163
|
/windows-notifier test
|
|
85
164
|
/windows-notifier test permission
|
|
86
|
-
/windows-notifier test question
|
|
87
|
-
/windows-notifier test completed
|
|
88
|
-
/windows-notifier test aborted
|
|
89
|
-
/windows-notifier test failed
|
|
90
165
|
```
|
|
91
166
|
|
|
92
|
-
`
|
|
167
|
+
- `status` shows channel switches, sound choices, backend status, queue counters, and diagnostic codes—not your custom text or session content.
|
|
168
|
+
- `reload` reads the config again and cancels old notification work.
|
|
169
|
+
- `test` sends a completion notification by default. You can also choose `permission`, `question`, `completed`, `aborted`, or `failed`.
|
|
93
170
|
|
|
94
|
-
|
|
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.
|
|
95
172
|
|
|
96
|
-
|
|
97
|
-
- SystemRoot/windir 僅來自啟動 OS 環境;拒絕相對、UNC、device 路徑,PowerShell 使用絕對路徑,不搜尋 cwd/PATH。
|
|
98
|
-
- `spawn` 不開 shell,使用固定 package cwd/腳本、`-NoProfile`、`-NonInteractive`、`-STA`。child environment 僅允許必要 Windows 路徑欄位,不繼承整份 agent environment 或 API tokens。
|
|
99
|
-
- stdin 僅含事件 enum 與音效 boolean;helper 再驗證,從固定字串表生成 DOM text node,不拼接 XML/PowerShell,不使用 Invoke-Expression。
|
|
100
|
-
- `-ExecutionPolicy Bypass` 只限該 child;不取得管理員權限、不更改永久設定,也不把 Execution Policy 當安全邊界。
|
|
101
|
-
- 最多一個 helper、佇列上限 16、啟動至少間隔一秒、等待上限 30 秒。權限/提問優先;滿載先丟棄最舊回應結束項目,否則丟棄最舊等待項目。
|
|
102
|
-
- child timeout 10 秒,stdout/stderr 各最多 8 KiB;只終止自有 child,不按名稱殺程序,不無限重試。若 OS 拒絕終止,等待 close,寧可暫停後續通知,不啟動第二個 helper。
|
|
103
|
-
- 決策、問題結束、新 run、reload、session 重建及 shutdown 取消相應舊工作。已展示的 Toast 不撤回;程序啟動與 Windows 提交仍有取消競態。
|
|
104
|
-
- 不支援背景音樂、自訂音效/音量、遠端通知、定期催答或點擊 Toast 後的自動操作。
|
|
173
|
+
## FAQ
|
|
105
174
|
|
|
106
|
-
|
|
175
|
+
### Why aren't notifications showing up?
|
|
107
176
|
|
|
108
|
-
|
|
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.
|
|
109
178
|
|
|
110
|
-
|
|
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.
|
|
111
180
|
|
|
112
|
-
|
|
181
|
+
Use `/windows-notifier status` to check these codes:
|
|
113
182
|
|
|
114
|
-
|
|
|
183
|
+
| Code | What to check |
|
|
115
184
|
|---|---|
|
|
116
|
-
| CONFIG_INVALID
|
|
117
|
-
| ENV_UNSUPPORTED |
|
|
118
|
-
| BACKEND_UNAVAILABLE |
|
|
119
|
-
| UI_AMBIGUOUS
|
|
120
|
-
| QUEUE_DROPPED |
|
|
121
|
-
| HELPER_TIMEOUT
|
|
122
|
-
| TOAST_FAILED
|
|
123
|
-
| INPUT_INVALID
|
|
185
|
+
| `CONFIG_INVALID` / `CONFIG_TOO_LARGE` / `CONFIG_READ_FAILED` | Fix the global config file, then reload |
|
|
186
|
+
| `ENV_UNSUPPORTED` | Use a supported Windows 64-bit interactive Pi session |
|
|
187
|
+
| `BACKEND_UNAVAILABLE` | Check that the Windows paths, PowerShell, and helper are available |
|
|
188
|
+
| `UI_AMBIGUOUS` / `TOOLS_LIMIT` | A UI wait couldn't be identified safely, or tracking hit its limit |
|
|
189
|
+
| `QUEUE_DROPPED` | The queue was full and a notification was dropped |
|
|
190
|
+
| `HELPER_TIMEOUT` / `OUTPUT_LIMIT` / `LAUNCH_FAILED` | The helper timed out, produced too much output, or couldn't run |
|
|
191
|
+
| `TOAST_FAILED` / `SOUND_FAILED` / `BOTH_FAILED` | Windows rejected the toast, sound, or both |
|
|
192
|
+
| `INPUT_INVALID` / `INTERNAL_ERROR` / `HELPER_PROTOCOL` | Check the package installation and helper protocol |
|
|
193
|
+
|
|
194
|
+
Automatic errors show at most one terminal warning per minute. Status uses fixed diagnostic codes rather than raw PowerShell error output.
|
|
195
|
+
|
|
196
|
+
## Privacy and process safety
|
|
124
197
|
|
|
125
|
-
|
|
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.
|
|
126
199
|
|
|
127
|
-
|
|
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
|
+
|
|
209
|
+
## Development
|
|
128
210
|
|
|
129
211
|
```bash
|
|
130
212
|
npm ci --ignore-scripts
|
|
@@ -132,6 +214,8 @@ npm run verify
|
|
|
132
214
|
npm pack --dry-run
|
|
133
215
|
```
|
|
134
216
|
|
|
135
|
-
|
|
217
|
+
Tests use a fake clock, event bus, and launcher. On Windows, they also check PowerShell syntax and invalid input handling. Normal `npm test` runs don't show toasts or play sounds. See the [verification notes (繁體中文)](docs/verification.md) for the test scope and remaining checks.
|
|
218
|
+
|
|
219
|
+
## License
|
|
136
220
|
|
|
137
|
-
MIT
|
|
221
|
+
MIT. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for upstream credits and license notices.
|
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# pi-windows-notifier
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [繁體中文](README.zh-TW.md)
|
|
4
|
+
|
|
5
|
+
不用一直盯著 Pi。需要你確認權限、回答問題,或模型回應結束時,這個 extension 會跳出 Windows 通知並播放提示音。就算你正在看別的視窗,也會提醒。
|
|
6
|
+
|
|
7
|
+
通知不會從 session 帶出問題、命令、檔案路徑或模型回答,但你可以設定自己的固定提醒文字。它也不會替你批准權限或回答問題,所有提醒都在本機處理。
|
|
8
|
+
|
|
9
|
+
[GitHub](https://github.com/C-W-Z/pi-windows-notifier) · [npm](https://www.npmjs.com/package/pi-windows-notifier)
|
|
10
|
+
|
|
11
|
+
## 安裝
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-windows-notifier
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
安裝後開一個新的 Pi session 就能使用,不用先寫設定檔。五種通知和提示音預設都會開啟。
|
|
18
|
+
|
|
19
|
+
需要的環境:
|
|
20
|
+
|
|
21
|
+
- Windows 10 或 11。
|
|
22
|
+
- 64 位元 Node.js 22.19 以上,支援 x64 和 arm64。
|
|
23
|
+
- Pi 1.1.0 以上。
|
|
24
|
+
- Windows 內建的 Windows PowerShell 5.1。
|
|
25
|
+
|
|
26
|
+
只在 Pi 的互動式終端介面啟用。RPC、print、JSON 模式、其他作業系統和 32 位元 Node.js 都不會發通知。缺少必要 UI 事件的舊版 Pi 不支援。
|
|
27
|
+
|
|
28
|
+
移除套件:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi remove npm:pi-windows-notifier
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 什麼時候會提醒?
|
|
35
|
+
|
|
36
|
+
| 事件 | 通知內容 | Windows 提示音 |
|
|
37
|
+
|---|---|---|
|
|
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
|
+
|
|
44
|
+
彈窗標題預設是 **Pi**,預設訊息是英文。每個事件都能設定自己的標題與訊息;指令提示仍是繁體中文,沒有整體語言切換設定。
|
|
45
|
+
|
|
46
|
+
**權限提醒**搭配 `@gotgenes/pi-permission-system` 使用,也支援從 subagent 轉送到父 session 的權限請求。自動允許、自動拒絕,或已經有 session approval 的請求不會提醒。
|
|
47
|
+
|
|
48
|
+
**問題提醒**支援 `ask_user_question`(包含 RPIV Lean)和 `plan_mode_question`。一份問卷只提醒一次,不會每一題都響。模型在一般文字回答裡寫的問句,以及你手動打開的設定介面,不算這裡的提問。
|
|
49
|
+
|
|
50
|
+
**回應結束提醒**會等 Pi 真正結束這次回應才發送,不會在自動重試或續跑途中提早報完成。單一工具出錯,也不等於整次模型回應失敗。
|
|
51
|
+
|
|
52
|
+
權限和提問套件都是可選的;沒有安裝它們,仍然可以收到回應結束提醒。
|
|
53
|
+
|
|
54
|
+
### 提問辨識的限制
|
|
55
|
+
|
|
56
|
+
Pi 的 UI 事件沒有說明是哪個工具開了視窗。因此,只有恰好一個支援的提問工具正在執行,而且沒有權限提示或已知的其他 UI 佔用時,才會把等待畫面的訊號當成提問。資訊不明確就略過,並記錄 `UI_AMBIGUOUS`,不硬猜來源。
|
|
57
|
+
|
|
58
|
+
這個做法無法精確辨識所有重疊視窗的情況。RPIV 的 `rpiv:ask-user:blocked` 會提供額外訊號,並和共通 UI 事件一起去重。
|
|
59
|
+
|
|
60
|
+
有些提問套件本身也會發出 terminal bell。如果聽到額外的聲音,請檢查該套件或終端的提示音設定。
|
|
61
|
+
|
|
62
|
+
## 設定
|
|
63
|
+
|
|
64
|
+
想改預設行為時,請自行建立 `~/.pi/agent/pi-windows-notifier/config.json`。在 Windows 上,就是使用者家目錄裡的 `.pi\agent\pi-windows-notifier\config.json`。套件不會替你建立或修改這個檔案,也不讀專案內的設定。
|
|
65
|
+
|
|
66
|
+
### 完整設定範例
|
|
67
|
+
|
|
68
|
+
下面列出所有支援的欄位和五種事件,可以直接複製作為起點。不過,**實際只要保留想修改的欄位,加上 `schemaVersion: 2` 就好**。每個事件都覆寫了共用訊息,因此這份範例的效果等同內建預設。
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"schemaVersion": 2,
|
|
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
|
+
},
|
|
88
|
+
"events": {
|
|
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
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 欄位與覆寫規則
|
|
119
|
+
|
|
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
|
+
### 文字與音效限制
|
|
145
|
+
|
|
146
|
+
文字會照你寫的內容顯示,不會從 session 帶入變數,也沒有模板替換。標題最多 128 個 UTF-16 code units,訊息最多 512 個;emoji 可能算兩個。兩者都必須是非空白的單行字串,不能包含控制字元或不合法的 XML 字元。自訂文字會出現在 Windows 通知裡,請不要放敏感資訊;它不會顯示在 `status` 或錯誤提示中。
|
|
147
|
+
|
|
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 物件混在一起。
|
|
155
|
+
|
|
156
|
+
## 指令
|
|
157
|
+
|
|
158
|
+
在 Pi 裡執行。輸入 `/windows-notifier ` 後按 **Tab**,可以補全 `status`、`reload` 或 `test`;輸入 `test ` 後,Tab 會補全事件名稱,也支援 `test co` 這類前綴。
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
/windows-notifier status
|
|
162
|
+
/windows-notifier reload
|
|
163
|
+
/windows-notifier test
|
|
164
|
+
/windows-notifier test permission
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `status`:查看通道開關、音效選擇、backend 狀態、佇列計數和診斷碼,不會顯示自訂文字或 session 內容。
|
|
168
|
+
- `reload`:重新讀取設定,取消舊的通知工作。
|
|
169
|
+
- `test`:預設測試完成通知,也能指定 `permission`、`question`、`completed`、`aborted` 或 `failed`。
|
|
170
|
+
|
|
171
|
+
**測試會真的跳通知、播放音效。** 和自動通知一樣,它會遵守開關、佇列與限流,不會強行送出已停用的事件。
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
## 常見問題
|
|
175
|
+
|
|
176
|
+
### 為什麼沒看到通知?
|
|
177
|
+
|
|
178
|
+
Windows 仍然有最終決定權。勿擾模式、通知設定、系統音效方案和靜音,都可能讓已提交的通知沒有彈出或沒有聲音。
|
|
179
|
+
|
|
180
|
+
因為使用 `Microsoft.Windows.PowerShell` AppID,通知來源可能顯示 **PowerShell**,但彈窗標題預設是 **Pi**,也可以改成你設定的標題。Toast 自帶的音效關閉,提示音另外播放,避免這個 backend 自己重複響兩次。彈窗和音效分開處理,一個失敗仍會嘗試另一個;沒有備用通知方式或播放器。
|
|
181
|
+
|
|
182
|
+
可以用 `/windows-notifier status` 查看診斷碼:
|
|
183
|
+
|
|
184
|
+
| 診斷碼 | 意義或處理方式 |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `CONFIG_INVALID`/`CONFIG_TOO_LARGE`/`CONFIG_READ_FAILED` | 修正全域設定檔後 reload |
|
|
187
|
+
| `ENV_UNSUPPORTED` | 請使用支援的 Windows 64 位元 Pi 互動式介面 |
|
|
188
|
+
| `BACKEND_UNAVAILABLE` | 檢查 Windows 路徑、PowerShell 和 helper 是否可用 |
|
|
189
|
+
| `UI_AMBIGUOUS`/`TOOLS_LIMIT` | 無法安全辨識 UI 來源,或追蹤已達上限 |
|
|
190
|
+
| `QUEUE_DROPPED` | 佇列已滿,有通知被丟棄 |
|
|
191
|
+
| `HELPER_TIMEOUT`/`OUTPUT_LIMIT`/`LAUNCH_FAILED` | helper 超時、輸出過多或啟動失敗 |
|
|
192
|
+
| `TOAST_FAILED`/`SOUND_FAILED`/`BOTH_FAILED` | Windows 彈窗、音效或兩者提交失敗 |
|
|
193
|
+
| `INPUT_INVALID`/`INTERNAL_ERROR`/`HELPER_PROTOCOL` | 檢查套件安裝與 helper 協定 |
|
|
194
|
+
|
|
195
|
+
自動通知錯誤最多每分鐘顯示一次終端警告。診斷只使用固定代碼,不會帶出 PowerShell 原始錯誤內容。
|
|
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
|
+
|
|
210
|
+
## 開發
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npm ci --ignore-scripts
|
|
214
|
+
npm run verify
|
|
215
|
+
npm pack --dry-run
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
測試使用假時鐘、event bus 和 launcher。Windows 上也會檢查 PowerShell 語法與無效輸入。一般 `npm test` 不會跳彈窗或播放音效;測試範圍與仍需確認的項目見[驗證紀錄](docs/verification.md)。
|
|
219
|
+
|
|
220
|
+
## 授權
|
|
221
|
+
|
|
222
|
+
MIT。上游來源和授權聲明見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Third-party notices
|
|
2
2
|
|
|
3
3
|
## pi-permission-windows-notifier
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
5
|
+
- Source: https://github.com/zhangyu-ch/pi-permission-windows-notifier
|
|
6
|
+
- Reference commit: `988decb1a28a38a6d5b8aa940ec2ceca22088c54`
|
|
7
|
+
- Referenced features: permission events, WinRT DOM text nodes, the fixed PowerShell AppID, system sounds, and silent toast audio.
|
|
8
|
+
|
|
9
|
+
This package rewrites executable resolution, the stdin protocol, resource limits, cancellation, configuration, and the state machine. The following original MIT notice is retained for reused or adapted portions:
|
|
9
10
|
|
|
10
11
|
```text
|
|
11
12
|
MIT License
|
|
@@ -31,10 +32,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
31
32
|
SOFTWARE.
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
##
|
|
35
|
-
|
|
36
|
-
- UniPi notify:https://github.com/Neuron-Mr-White/UniPi/tree/main/packages/notify,參考 6ac5da3。只參考事件/backend 分層概念,未複製程式碼或引入 UniPi core/node-notifier。
|
|
37
|
-
- pi-jingle:https://github.com/Git-Monke/pi-jingle,參考 7e265ce。只參考事件音效概念,未複製程式碼或音效,因為尚未確認其授權。
|
|
38
|
-
- Pi、permission system、RPIV/Lean、Plan mode 的本機型別及原始碼用於核對契約,不把第三方程式碼放入發布包。
|
|
35
|
+
## Other references
|
|
39
36
|
|
|
40
|
-
|
|
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.
|
|
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.
|
package/docs/verification.md
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 驗證與已知限制
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 自動驗證
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
- npm pack
|
|
9
|
-
- Windows PowerShell 語法解析與無效輸入路徑已執行,不呼叫 Toast 或音效。
|
|
10
|
-
- mock 接線涵蓋 permission、RPIV/Lean、Plan mode 及完成/中止/失敗。
|
|
11
|
-
- 涵蓋去重、取消、不明 UI、重試/續跑、空 run、開關、最小 payload、cwd/PATH 污染防護、佇列/輸出上限、timeout、reload/shutdown 清理。
|
|
12
|
-
- 開發依賴使用 npm install --ignore-scripts;當次 npm audit 回報 0 個已知漏洞,不代表完整供應鏈或 OS 安全稽核。
|
|
13
|
-
- 發布包只允許 metadata、runtime 原始碼、固定 helper、README、驗證文件及授權,不含 node_modules、測試、設定或上游 checkout。
|
|
5
|
+
- TypeScript `tsc --noEmit` 與 Node test runner 通過;測試覆蓋設定驗證、權限與提問事件、回應狀態、去重取消、限流佇列、PowerShell launcher、資源清理與發布檔案清單。
|
|
6
|
+
- 設定測試涵蓋 schema v2 合併、舊格式相容、獨立通道、系統音效白名單、自訂文字上限與 Unicode/XML 驗證;status 與錯誤提示不展示設定文字。
|
|
7
|
+
- Windows 測試檢查固定 PowerShell helper 語法、無效輸入,以及通道全關閉時的有效輸入;不呼叫 Toast 或音效 API。單通道成功/失敗結果由 mock launcher 驗證。
|
|
8
|
+
- 發布包以 npm pack dry-run 核對;僅包含套件 metadata、runtime 原始碼、PowerShell helper、文件及授權。
|
|
14
9
|
|
|
15
|
-
|
|
10
|
+
執行完整檢查:
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
```bash
|
|
13
|
+
npm ci --ignore-scripts
|
|
14
|
+
npm run verify
|
|
15
|
+
npm pack --dry-run
|
|
16
|
+
```
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
## 實機驗證範圍
|
|
20
19
|
|
|
21
|
-
1.
|
|
22
|
-
2. 第三方 terminal bell 是否造成額外聲響。
|
|
23
|
-
3. 勿擾/通知關閉/靜音行為。
|
|
24
|
-
4. 不同 extension 載入順序、reload、OS 程序監看及網路活動觀察(資源限制已由自動測試覆蓋)。
|
|
20
|
+
0.1.x Windows backend 的五類通知(permission、question、completed、aborted、failed)曾逐一送出,並經人工確認有 Toast 彈窗與提示音。
|
|
25
21
|
|
|
26
|
-
|
|
22
|
+
0.2.0 的自訂標題/訊息、Toast-only、sound-only 與新增系統音效選擇尚未完成實際可見/可聽驗收;不以舊版結果推論新 helper 已通過。
|
|
23
|
+
|
|
24
|
+
這項 backend 驗證不等於所有 Pi 整合情境均已端到端驗收。以下項目仍需在實際使用環境確認:
|
|
25
|
+
|
|
26
|
+
- `pi-permission-system` 的 ask/轉送權限請求。
|
|
27
|
+
- RPIV Lean 與 Plan mode 的結構化問題,以及第三方 terminal bell 是否造成額外聲響。
|
|
28
|
+
- Pi 正常結束、中止、錯誤及自動重試/續跑時的通知分類。
|
|
29
|
+
- Windows 勿擾、通知停用、靜音設定下的行為。
|
|
30
|
+
- 不同 extension 載入順序及 reload 後的實際程序生命週期。
|
|
31
|
+
|
|
32
|
+
自動測試使用 mock event bus 與 mock launcher,不能替代上述真實 Pi 工作流程。Windows 可能抑制通知或音效;helper 回報提交成功不保證使用者一定看見或聽見。
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-windows-notifier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi
|
|
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
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/C-W-Z/pi-windows-notifier.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/C-W-Z/pi-windows-notifier#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/C-W-Z/pi-windows-notifier/issues"
|
|
14
|
+
},
|
|
7
15
|
"engines": {
|
|
8
16
|
"node": ">=22.19.0"
|
|
9
17
|
},
|
|
@@ -17,6 +25,7 @@
|
|
|
17
25
|
"src/**/*.ts",
|
|
18
26
|
"src/windows-notify.ps1",
|
|
19
27
|
"README.md",
|
|
28
|
+
"README.zh-TW.md",
|
|
20
29
|
"docs/verification.md",
|
|
21
30
|
"LICENSE",
|
|
22
31
|
"THIRD_PARTY_NOTICES.md"
|
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,
|
|
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:
|
|
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,
|
|
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
|
|
20
|
-
|
|
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)
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
if (typeof item
|
|
39
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|
package/src/windows-notify.ps1
CHANGED
|
@@ -1,37 +1,76 @@
|
|
|
1
|
-
# 固定 helper
|
|
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[]
|
|
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
|
|
20
|
-
$
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
$
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
[Windows.UI.Notifications.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
$
|
|
73
|
-
|
|
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 ($
|
|
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 }
|