dsh-ntfy-notifier 0.0.0-stage → 1.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/LICENSE +18 -0
- package/README.md +170 -2
- package/client.js +482 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +32 -0
- package/index.js +576 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/notify.ps1 +115 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 liangfu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-ntfy-notifier-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 ntfy 消息转换为 Windows 本机通知的 DeepSeek Harness(DSH)插件。
|
|
4
|
+
|
|
5
|
+
插件直接在 Host 进程中用 Node 内置 `http`/`https` 订阅 ntfy 的 JSON 流,不依赖 `ntfy.exe`。支持配置多个 ntfy 服务器,每个服务器可配置多个频道,每个「服务器 + 频道」组合独立连接、独立重连。
|
|
6
|
+
|
|
7
|
+
插件包名是 `dsh-ntfy-notifier`;安装后由 DSH 插件管理器按本地目录或 npm 包加载。
|
|
8
|
+
|
|
9
|
+
> **AI 开发说明**:本插件由 AI 开发,代码和文档由 AI 生成并整理。使用前请自行审查代码、配置和运行行为。
|
|
10
|
+
|
|
11
|
+
## 功能
|
|
12
|
+
|
|
13
|
+
- 多个 ntfy 服务器,每个服务器多个频道
|
|
14
|
+
- 每个服务器地址、每个频道各用一个输入框;点 `+` 添加新服务器或新频道
|
|
15
|
+
- 订阅 ntfy 的 JSON 流,断线后指数退避重连
|
|
16
|
+
- 使用最后一条消息 ID 续订,并按消息 ID 去重
|
|
17
|
+
- 支持 `title`、`message`、`priority`
|
|
18
|
+
- 优先级 4/5 可选用系统闹钟铃声,其他优先级使用普通提示音
|
|
19
|
+
- 消息通过 base64 JSON 传给 PowerShell,避免命令行和 XML 注入
|
|
20
|
+
- 支持 Bearer Token
|
|
21
|
+
- 插件停用或 DSH 退出时关闭连接和通知进程
|
|
22
|
+
|
|
23
|
+
## 环境要求
|
|
24
|
+
|
|
25
|
+
- Windows 10/11
|
|
26
|
+
- DeepSeek Harness(支持 `dsh.client` 与 `plugins.row.config` 的版本)
|
|
27
|
+
- Windows PowerShell 5.1(系统自带)
|
|
28
|
+
- Node.js 24 或 DSH 自带的运行时
|
|
29
|
+
- pnpm(安装 `@deepseek-ai/schemastery` 依赖时需要)
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
1. 将插件目录放到本地任意位置,并在该目录中安装依赖:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
cd <插件目录>
|
|
37
|
+
pnpm install --ignore-scripts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
2. 在 DSH 的 **插件** 页面选择 **添加插件 → 安装第三方插件**,填入本目录的绝对路径。
|
|
41
|
+
3. 安装完成后打开 `ntfy 系统通知` 的详情,在插件行 `ntfy-notifier` 点 **配置**。
|
|
42
|
+
4. 填入自己的 ntfy 服务器地址和频道,保存。
|
|
43
|
+
|
|
44
|
+
> 如果配置页没有出现,先刷新一次 Web GUI。首次安装时如果客户端模块已被缓存为“无界面”,DSH 可能需要重启后才能重新扫描客户端模块。
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
### 通过界面配置
|
|
49
|
+
|
|
50
|
+
入口:**左侧栏「插件」→ `ntfy 系统通知` → 插件行 `ntfy-notifier` → 「配置」**。
|
|
51
|
+
|
|
52
|
+
配置页按服务器分组:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
服务器 1
|
|
56
|
+
服务器地址: [http://127.0.0.1:2586]
|
|
57
|
+
频道: [message] [alerts] [+ 添加频道]
|
|
58
|
+
|
|
59
|
+
服务器 2
|
|
60
|
+
服务器地址: [https://ntfy.example.com]
|
|
61
|
+
频道: [system] [+ 添加频道]
|
|
62
|
+
|
|
63
|
+
[+ 添加服务器] [保存]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- 每个服务器地址是一个输入框
|
|
67
|
+
- 每个频道也是一个独立输入框
|
|
68
|
+
- 点 **+ 添加频道** 给当前服务器增加频道输入框
|
|
69
|
+
- 点 **+ 添加服务器** 增加一组服务器和频道输入框
|
|
70
|
+
- 可移除服务器或单个频道
|
|
71
|
+
- 保存后 Host 会更新配置并重新建立订阅
|
|
72
|
+
|
|
73
|
+
### 通过 YAML 配置
|
|
74
|
+
|
|
75
|
+
在 `cordis.patch.yml` 中覆盖插件条目的 `config`:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- id: ntfy-notifier
|
|
79
|
+
name: 'dsh-ntfy-notifier'
|
|
80
|
+
config:
|
|
81
|
+
servers:
|
|
82
|
+
- url: 'http://127.0.0.1:2586'
|
|
83
|
+
topics:
|
|
84
|
+
- message
|
|
85
|
+
- alerts
|
|
86
|
+
- url: 'https://ntfy.example.com'
|
|
87
|
+
topics:
|
|
88
|
+
- system
|
|
89
|
+
accessToken: ''
|
|
90
|
+
appId: 'ntfy'
|
|
91
|
+
sound: true
|
|
92
|
+
highPriorityAlarm: true
|
|
93
|
+
reconnectDelaySeconds: 5
|
|
94
|
+
maxReconnectDelaySeconds: 60
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| 字段 | 默认值 | 说明 |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `servers` | 一个本地服务器和一个 `message` 频道 | 服务器列表,每项包含 `url` 和该服务器的 `topics` |
|
|
100
|
+
| `accessToken` | 空 | 可选,ntfy Bearer Token;所有服务器共用 |
|
|
101
|
+
| `appId` | `ntfy` | Windows Toast 的 AUMID;未注册时自动回退到 `Microsoft.Windows.PowerShell` |
|
|
102
|
+
| `sound` | `true` | 是否播放通知声音 |
|
|
103
|
+
| `highPriorityAlarm` | `true` | 优先级 4/5 是否使用系统闹钟铃声 |
|
|
104
|
+
| `reconnectDelaySeconds` | `5` | 首次重连等待秒数 |
|
|
105
|
+
| `maxReconnectDelaySeconds` | `60` | 指数退避上限 |
|
|
106
|
+
|
|
107
|
+
频道名不要包含空格或斜杠。如果使用反向代理子路径,`url` 填到子路径为止,例如 `https://ntfy.example.com/ntfy`,插件会请求 `https://ntfy.example.com/ntfy/<channel>/json`。
|
|
108
|
+
|
|
109
|
+
## 工作原理
|
|
110
|
+
|
|
111
|
+
1. 对每个「服务器 + 频道」组合请求 `<server>/<channel>/json`,读取换行分隔的 JSON 事件。
|
|
112
|
+
2. 只处理 `event: "message"`,忽略 `open`、`keepalive` 等控制事件。
|
|
113
|
+
3. 通过 `notify.ps1` 调用 Windows WinRT `ToastNotificationManager` 弹出通知。
|
|
114
|
+
4. 消息标题和正文先做控制字符清理、长度截断,再以 base64 UTF-8 JSON 传给 PowerShell;PowerShell 使用 `SecurityElement.Escape` 转义 XML。
|
|
115
|
+
5. 连接断开、超时或出错后按指数退避重连;续订参数使用最后一条消息的 `id`。
|
|
116
|
+
|
|
117
|
+
## 安全说明
|
|
118
|
+
|
|
119
|
+
- 不使用外部 `ntfy.exe`,减少一个可执行文件依赖。
|
|
120
|
+
- 消息内容不拼接进 PowerShell 命令行,也不直接拼进 XML,避免命令和 XML 注入。
|
|
121
|
+
- `accessToken` 会以明文保存在 DSH 的 profile 配置中;请确保本机账号和 profile 文件权限可信。
|
|
122
|
+
- 局域网 HTTP 不加密。跨网络部署建议使用 HTTPS,并配置访问令牌。
|
|
123
|
+
- 插件只读取 ntfy 消息并将其显示为本机通知,不执行消息内容。
|
|
124
|
+
|
|
125
|
+
## 开发与验证
|
|
126
|
+
|
|
127
|
+
```powershell
|
|
128
|
+
# 安装运行时依赖
|
|
129
|
+
pnpm install --ignore-scripts
|
|
130
|
+
|
|
131
|
+
# 语法检查
|
|
132
|
+
node --check index.js
|
|
133
|
+
node --check client.js
|
|
134
|
+
|
|
135
|
+
# PowerShell 助手语法检查
|
|
136
|
+
$tokens = $null
|
|
137
|
+
$errors = $null
|
|
138
|
+
[System.Management.Automation.Language.Parser]::ParseFile(
|
|
139
|
+
(Resolve-Path '.\notify.ps1'),
|
|
140
|
+
[ref]$tokens,
|
|
141
|
+
[ref]$errors
|
|
142
|
+
) | Out-Null
|
|
143
|
+
if ($errors.Count) { $errors | ForEach-Object Message; exit 1 }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
本地测试 Windows 通知助手(会实际弹出一条通知):
|
|
147
|
+
|
|
148
|
+
```powershell
|
|
149
|
+
$json = '{"title":"ntfy test","message":"notify.ps1 works","priority":3,"appId":"ntfy","sound":true,"alarm":true}'
|
|
150
|
+
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))
|
|
151
|
+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\notify.ps1 -PayloadBase64 $b64
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## 故障排查
|
|
155
|
+
|
|
156
|
+
- **配置页看不到**:刷新 Web GUI;确认插件行是 `ntfy-notifier` 且处于启用状态;确认配置页的客户端模块已经加载。
|
|
157
|
+
- **没有通知**:检查 Windows 通知权限、专注助手/勿扰模式,以及 `appId` 是否被系统接受;可先用上面的助手命令测试。
|
|
158
|
+
- **连接失败**:确认服务器地址、频道名、反向代理路径和 `accessToken`;查看 DSH Host 日志中的 `ntfy:` 前缀。
|
|
159
|
+
- **频道重复**:同一个 URL 只会建立一条连接,重复频道会被去重。
|
|
160
|
+
|
|
161
|
+
## 限制
|
|
162
|
+
|
|
163
|
+
- 插件仅在 DSH 运行期间监听,不是常驻 Windows 服务。
|
|
164
|
+
- 当前只实现 Windows Toast;其他平台会记录警告但不会弹窗。
|
|
165
|
+
- 首次连接只接收新消息,不补发历史消息。
|
|
166
|
+
- 所有服务器共用同一个 `accessToken`。
|
|
167
|
+
- 配置页依赖 DSH 的 `plugins.row.config` 和 `settings` 服务;也可以通过 YAML 配置。
|
|
168
|
+
|
|
169
|
+
## 许可证
|
|
170
|
+
|
|
171
|
+
MIT,见 [LICENSE](LICENSE)。
|
package/client.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-ntfy-notifier',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
const NS = 'ntfyNotifier'
|
|
7
|
+
let nextDraftId = 1
|
|
8
|
+
|
|
9
|
+
const en = {
|
|
10
|
+
intro: 'Add one or more ntfy servers. Each server has its own channel inputs and its own connection.',
|
|
11
|
+
server: 'Server',
|
|
12
|
+
serverLabel: 'Server address',
|
|
13
|
+
serverHint: 'Base address, for example http://127.0.0.1:2586. A reverse-proxy path such as https://ntfy.example.com/ntfy is allowed.',
|
|
14
|
+
channelsLabel: 'Channels',
|
|
15
|
+
channelsHint: 'Each input is one channel. Use + to add another channel to this server.',
|
|
16
|
+
addServer: 'Add server',
|
|
17
|
+
removeServer: 'Remove server',
|
|
18
|
+
addChannel: 'Add channel',
|
|
19
|
+
removeChannel: 'Remove',
|
|
20
|
+
save: 'Save',
|
|
21
|
+
saving: 'Saving…',
|
|
22
|
+
saved: 'Saved. The listener will reconnect with the new servers and channels.',
|
|
23
|
+
readOnly: 'Configuration is read-only in this deployment.',
|
|
24
|
+
unavailable: 'Configuration is unavailable for this plugin.',
|
|
25
|
+
loading: 'Loading configuration…',
|
|
26
|
+
errorServer: 'Enter a valid http(s) server address.',
|
|
27
|
+
errorChannels: 'Enter at least one channel for this server.',
|
|
28
|
+
errorChannel: 'Channel names cannot contain spaces or slashes.',
|
|
29
|
+
saveFailed: 'The Host did not accept the configuration. The previous values are still active.',
|
|
30
|
+
noChannels: 'No channels'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const zh = {
|
|
34
|
+
intro: '添加一个或多个 ntfy 服务器。每个服务器有自己的频道输入框和独立连接。',
|
|
35
|
+
server: '服务器',
|
|
36
|
+
serverLabel: '服务器地址',
|
|
37
|
+
serverHint: '填写基础地址,例如 http://127.0.0.1:2586;反向代理子路径也可以,例如 https://ntfy.example.com/ntfy。',
|
|
38
|
+
channelsLabel: '频道',
|
|
39
|
+
channelsHint: '每个输入框填一个频道;点 + 可以给当前服务器继续添加频道。',
|
|
40
|
+
addServer: '添加服务器',
|
|
41
|
+
removeServer: '移除服务器',
|
|
42
|
+
addChannel: '添加频道',
|
|
43
|
+
removeChannel: '移除',
|
|
44
|
+
save: '保存',
|
|
45
|
+
saving: '保存中…',
|
|
46
|
+
saved: '已保存,插件会使用新的服务器和频道重新连接。',
|
|
47
|
+
readOnly: '本部署的插件配置为只读。',
|
|
48
|
+
unavailable: '该插件当前没有可编辑的配置。',
|
|
49
|
+
loading: '正在读取配置…',
|
|
50
|
+
errorServer: '请填写有效的 http(s) 服务器地址。',
|
|
51
|
+
errorChannels: '请给这个服务器至少填写一个频道。',
|
|
52
|
+
errorChannel: '频道名不能包含空格或斜杠。',
|
|
53
|
+
saveFailed: 'Host 没有接受这次配置,当前仍使用原来的值。',
|
|
54
|
+
noChannels: '未配置频道'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const styles = {
|
|
58
|
+
root: {
|
|
59
|
+
display: 'flex',
|
|
60
|
+
flexDirection: 'column',
|
|
61
|
+
gap: '12px',
|
|
62
|
+
padding: '2px 0 6px',
|
|
63
|
+
maxWidth: '680px'
|
|
64
|
+
},
|
|
65
|
+
intro: {
|
|
66
|
+
margin: 0,
|
|
67
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
68
|
+
fontSize: '12px',
|
|
69
|
+
lineHeight: 1.55
|
|
70
|
+
},
|
|
71
|
+
serverCard: {
|
|
72
|
+
display: 'flex',
|
|
73
|
+
flexDirection: 'column',
|
|
74
|
+
gap: '8px',
|
|
75
|
+
padding: '10px',
|
|
76
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
77
|
+
borderRadius: '10px',
|
|
78
|
+
background: 'var(--dsw-alias-bg-layer-1)'
|
|
79
|
+
},
|
|
80
|
+
serverHeader: {
|
|
81
|
+
display: 'flex',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'space-between',
|
|
84
|
+
gap: '8px'
|
|
85
|
+
},
|
|
86
|
+
serverTitle: {
|
|
87
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
88
|
+
fontSize: '12px',
|
|
89
|
+
fontWeight: 700
|
|
90
|
+
},
|
|
91
|
+
label: {
|
|
92
|
+
display: 'flex',
|
|
93
|
+
flexDirection: 'column',
|
|
94
|
+
gap: '6px',
|
|
95
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
96
|
+
fontSize: '13px',
|
|
97
|
+
fontWeight: 600
|
|
98
|
+
},
|
|
99
|
+
channelLabel: {
|
|
100
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
101
|
+
fontSize: '13px',
|
|
102
|
+
fontWeight: 600
|
|
103
|
+
},
|
|
104
|
+
input: {
|
|
105
|
+
width: '100%',
|
|
106
|
+
boxSizing: 'border-box',
|
|
107
|
+
padding: '7px 9px',
|
|
108
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
109
|
+
borderRadius: '8px',
|
|
110
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
111
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
112
|
+
fontFamily: 'inherit',
|
|
113
|
+
fontSize: '13px',
|
|
114
|
+
lineHeight: 1.5
|
|
115
|
+
},
|
|
116
|
+
channelRow: {
|
|
117
|
+
display: 'flex',
|
|
118
|
+
alignItems: 'center',
|
|
119
|
+
gap: '6px'
|
|
120
|
+
},
|
|
121
|
+
channelInput: {
|
|
122
|
+
flex: '1 1 auto',
|
|
123
|
+
minWidth: 0
|
|
124
|
+
},
|
|
125
|
+
hint: {
|
|
126
|
+
margin: 0,
|
|
127
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
128
|
+
fontSize: '12px',
|
|
129
|
+
lineHeight: 1.5
|
|
130
|
+
},
|
|
131
|
+
error: {
|
|
132
|
+
margin: 0,
|
|
133
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
134
|
+
fontSize: '12px',
|
|
135
|
+
lineHeight: 1.5
|
|
136
|
+
},
|
|
137
|
+
success: {
|
|
138
|
+
margin: 0,
|
|
139
|
+
color: 'var(--dsw-alias-state-success-primary)',
|
|
140
|
+
fontSize: '12px',
|
|
141
|
+
lineHeight: 1.5
|
|
142
|
+
},
|
|
143
|
+
actions: {
|
|
144
|
+
display: 'flex',
|
|
145
|
+
alignItems: 'center',
|
|
146
|
+
gap: '8px',
|
|
147
|
+
flexWrap: 'wrap'
|
|
148
|
+
},
|
|
149
|
+
button: {
|
|
150
|
+
padding: '6px 14px',
|
|
151
|
+
border: '1px solid var(--dsw-alias-brand-primary)',
|
|
152
|
+
borderRadius: '8px',
|
|
153
|
+
background: 'transparent',
|
|
154
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
155
|
+
cursor: 'pointer',
|
|
156
|
+
fontFamily: 'inherit',
|
|
157
|
+
fontSize: '13px',
|
|
158
|
+
fontWeight: 600
|
|
159
|
+
},
|
|
160
|
+
smallButton: {
|
|
161
|
+
padding: '5px 9px',
|
|
162
|
+
border: '1px dashed var(--dsw-alias-border-l2)',
|
|
163
|
+
borderRadius: '7px',
|
|
164
|
+
background: 'transparent',
|
|
165
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
166
|
+
cursor: 'pointer',
|
|
167
|
+
fontFamily: 'inherit',
|
|
168
|
+
fontSize: '12px',
|
|
169
|
+
whiteSpace: 'nowrap'
|
|
170
|
+
},
|
|
171
|
+
removeButton: {
|
|
172
|
+
padding: '2px 4px',
|
|
173
|
+
border: 'none',
|
|
174
|
+
background: 'transparent',
|
|
175
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
176
|
+
cursor: 'pointer',
|
|
177
|
+
fontFamily: 'inherit',
|
|
178
|
+
fontSize: '12px',
|
|
179
|
+
whiteSpace: 'nowrap'
|
|
180
|
+
},
|
|
181
|
+
buttonDisabled: {
|
|
182
|
+
opacity: 0.55,
|
|
183
|
+
cursor: 'not-allowed'
|
|
184
|
+
},
|
|
185
|
+
summary: {
|
|
186
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
187
|
+
fontSize: '12px',
|
|
188
|
+
lineHeight: 1.5
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Read a plain value or a schemastery volatile reference. */
|
|
193
|
+
function plain(value) {
|
|
194
|
+
return value && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Return a usable base server URL, or throw a translated validation error. */
|
|
198
|
+
function normalizeServer(value, t) {
|
|
199
|
+
const text = String(value || '').trim()
|
|
200
|
+
if (!text) throw new Error(t('errorServer'))
|
|
201
|
+
let url
|
|
202
|
+
try {
|
|
203
|
+
url = new URL(text)
|
|
204
|
+
} catch {
|
|
205
|
+
throw new Error(t('errorServer'))
|
|
206
|
+
}
|
|
207
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(t('errorServer'))
|
|
208
|
+
if (!url.hostname) throw new Error(t('errorServer'))
|
|
209
|
+
url.hash = ''
|
|
210
|
+
url.search = ''
|
|
211
|
+
return url.href.replace(/\/+$/, '')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Validate one server's channel inputs, allowing a complete topic URL for compatibility. */
|
|
215
|
+
function validateChannels(values, t) {
|
|
216
|
+
const channels = [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
|
217
|
+
if (channels.length === 0) throw new Error(t('errorChannels'))
|
|
218
|
+
for (const channel of channels) {
|
|
219
|
+
if (/^https?:\/\//i.test(channel)) {
|
|
220
|
+
try {
|
|
221
|
+
new URL(channel)
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error(t('errorServer'))
|
|
224
|
+
}
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (!/^[^\s/]+$/.test(channel)) throw new Error(t('errorChannel'))
|
|
228
|
+
}
|
|
229
|
+
return channels
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Recover editable servers and channels from the current configuration. */
|
|
233
|
+
function deriveConfig(value) {
|
|
234
|
+
const config = value || {}
|
|
235
|
+
const configured = plain(config.servers)
|
|
236
|
+
const servers = Array.isArray(configured)
|
|
237
|
+
? configured
|
|
238
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
239
|
+
.map((entry) => {
|
|
240
|
+
const item = plain(entry) || {}
|
|
241
|
+
return {
|
|
242
|
+
url: typeof item.url === 'string' ? item.url : '',
|
|
243
|
+
topics: Array.isArray(item.topics)
|
|
244
|
+
? item.topics.filter((topic) => typeof topic === 'string' && topic.trim()).map((topic) => topic.trim())
|
|
245
|
+
: []
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
.filter((entry) => entry.url || entry.topics.length > 0)
|
|
249
|
+
: []
|
|
250
|
+
return { servers: servers.length > 0 ? servers : [{ url: '', topics: [] }] }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function nextId() {
|
|
254
|
+
return nextDraftId++
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function channelDraft(value) {
|
|
258
|
+
return { id: nextId(), value: typeof value === 'string' ? value : '' }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function serverDraft(server) {
|
|
262
|
+
const topics = Array.isArray(server?.topics) ? server.topics : []
|
|
263
|
+
return {
|
|
264
|
+
id: nextId(),
|
|
265
|
+
url: typeof server?.url === 'string' ? server.url : '',
|
|
266
|
+
channels: topics.length > 0 ? topics.map(channelDraft) : [channelDraft('')]
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ConfigPage(props) {
|
|
271
|
+
const { t, form, view } = props
|
|
272
|
+
const state = form?.state
|
|
273
|
+
const initial = React.useMemo(
|
|
274
|
+
() => deriveConfig(state?.value),
|
|
275
|
+
[state?.revision, state?.value]
|
|
276
|
+
)
|
|
277
|
+
const [servers, setServers] = React.useState(() => initial.servers.map(serverDraft))
|
|
278
|
+
const [error, setError] = React.useState('')
|
|
279
|
+
const [saved, setSaved] = React.useState(false)
|
|
280
|
+
const [saving, setSaving] = React.useState(false)
|
|
281
|
+
|
|
282
|
+
React.useEffect(() => {
|
|
283
|
+
setServers(initial.servers.map(serverDraft))
|
|
284
|
+
setError('')
|
|
285
|
+
}, [initial])
|
|
286
|
+
|
|
287
|
+
if (view === 'summary') {
|
|
288
|
+
const description = initial.servers
|
|
289
|
+
.map((server) => {
|
|
290
|
+
const channels = server.topics.length > 0 ? server.topics.join(', ') : t('noChannels')
|
|
291
|
+
return `${server.url || t('unavailable')} · ${channels}`
|
|
292
|
+
})
|
|
293
|
+
.join(' | ')
|
|
294
|
+
return h('span', { style: styles.summary }, description)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!form || !state) return h('p', { style: styles.hint }, t('unavailable'))
|
|
298
|
+
if (state.status === 'loading') return h('p', { style: styles.hint }, t('loading'))
|
|
299
|
+
if (state.status === 'unavailable') return h('p', { style: styles.hint }, t('unavailable'))
|
|
300
|
+
|
|
301
|
+
const disabled = !state.writable || saving
|
|
302
|
+
|
|
303
|
+
function updateServer(id, update) {
|
|
304
|
+
setServers((current) => current.map((server) => (server.id === id ? { ...server, ...update } : server)))
|
|
305
|
+
setSaved(false)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function addServer() {
|
|
309
|
+
setServers((current) => [...current, serverDraft({})])
|
|
310
|
+
setSaved(false)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function removeServer(id) {
|
|
314
|
+
setServers((current) => (current.length <= 1 ? current : current.filter((server) => server.id !== id)))
|
|
315
|
+
setSaved(false)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function addChannel(serverId) {
|
|
319
|
+
setServers((current) => current.map((server) => (
|
|
320
|
+
server.id === serverId
|
|
321
|
+
? { ...server, channels: [...server.channels, channelDraft('')] }
|
|
322
|
+
: server
|
|
323
|
+
)))
|
|
324
|
+
setSaved(false)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function updateChannel(serverId, channelId, value) {
|
|
328
|
+
setServers((current) => current.map((server) => (
|
|
329
|
+
server.id === serverId
|
|
330
|
+
? {
|
|
331
|
+
...server,
|
|
332
|
+
channels: server.channels.map((channel) => (
|
|
333
|
+
channel.id === channelId ? { ...channel, value } : channel
|
|
334
|
+
))
|
|
335
|
+
}
|
|
336
|
+
: server
|
|
337
|
+
)))
|
|
338
|
+
setSaved(false)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function removeChannel(serverId, channelId) {
|
|
342
|
+
setServers((current) => current.map((server) => (
|
|
343
|
+
server.id === serverId && server.channels.length > 1
|
|
344
|
+
? { ...server, channels: server.channels.filter((channel) => channel.id !== channelId) }
|
|
345
|
+
: server
|
|
346
|
+
)))
|
|
347
|
+
setSaved(false)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const save = async () => {
|
|
351
|
+
setError('')
|
|
352
|
+
setSaved(false)
|
|
353
|
+
setSaving(true)
|
|
354
|
+
try {
|
|
355
|
+
const normalized = servers.map((server, index) => {
|
|
356
|
+
try {
|
|
357
|
+
return {
|
|
358
|
+
url: normalizeServer(server.url, t),
|
|
359
|
+
topics: validateChannels(server.channels.map((channel) => channel.value), t)
|
|
360
|
+
}
|
|
361
|
+
} catch (cause) {
|
|
362
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
363
|
+
throw new Error(`${t('server')} ${index + 1}: ${message}`)
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
const accepted = await form.mutate([
|
|
367
|
+
{ op: 'set', path: ['servers'], value: normalized }
|
|
368
|
+
])
|
|
369
|
+
if (!accepted) {
|
|
370
|
+
setError(t('saveFailed'))
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
setServers(normalized.map(serverDraft))
|
|
374
|
+
setSaved(true)
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
377
|
+
} finally {
|
|
378
|
+
setSaving(false)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return h('div', { style: styles.root },
|
|
383
|
+
h('p', { style: styles.intro }, t('intro')),
|
|
384
|
+
servers.map((server, index) => h('section', {
|
|
385
|
+
key: server.id,
|
|
386
|
+
style: styles.serverCard
|
|
387
|
+
},
|
|
388
|
+
h('div', { style: styles.serverHeader },
|
|
389
|
+
h('span', { style: styles.serverTitle }, `${t('server')} ${index + 1}`),
|
|
390
|
+
servers.length > 1
|
|
391
|
+
? h('button', {
|
|
392
|
+
type: 'button',
|
|
393
|
+
style: styles.removeButton,
|
|
394
|
+
disabled,
|
|
395
|
+
onClick: () => removeServer(server.id)
|
|
396
|
+
}, t('removeServer'))
|
|
397
|
+
: null
|
|
398
|
+
),
|
|
399
|
+
h('label', { style: styles.label },
|
|
400
|
+
t('serverLabel'),
|
|
401
|
+
h('input', {
|
|
402
|
+
type: 'url',
|
|
403
|
+
style: styles.input,
|
|
404
|
+
value: server.url,
|
|
405
|
+
disabled,
|
|
406
|
+
spellCheck: false,
|
|
407
|
+
autoComplete: 'off',
|
|
408
|
+
placeholder: 'http://127.0.0.1:2586',
|
|
409
|
+
onChange: (event) => updateServer(server.id, { url: event.target.value })
|
|
410
|
+
})
|
|
411
|
+
),
|
|
412
|
+
h('p', { style: styles.hint }, t('serverHint')),
|
|
413
|
+
h('div', { style: styles.serverHeader },
|
|
414
|
+
h('span', { style: styles.channelLabel }, t('channelsLabel')),
|
|
415
|
+
h('button', {
|
|
416
|
+
type: 'button',
|
|
417
|
+
style: styles.smallButton,
|
|
418
|
+
disabled,
|
|
419
|
+
onClick: () => addChannel(server.id)
|
|
420
|
+
}, `+ ${t('addChannel')}`)
|
|
421
|
+
),
|
|
422
|
+
server.channels.map((channel, channelIndex) => h('div', {
|
|
423
|
+
key: channel.id,
|
|
424
|
+
style: styles.channelRow
|
|
425
|
+
},
|
|
426
|
+
h('input', {
|
|
427
|
+
type: 'text',
|
|
428
|
+
style: { ...styles.input, ...styles.channelInput },
|
|
429
|
+
value: channel.value,
|
|
430
|
+
disabled,
|
|
431
|
+
spellCheck: false,
|
|
432
|
+
autoComplete: 'off',
|
|
433
|
+
placeholder: channelIndex === 0 ? 'message' : 'alerts',
|
|
434
|
+
'aria-label': `${t('channelsLabel')} ${channelIndex + 1}`,
|
|
435
|
+
onChange: (event) => updateChannel(server.id, channel.id, event.target.value)
|
|
436
|
+
}),
|
|
437
|
+
server.channels.length > 1
|
|
438
|
+
? h('button', {
|
|
439
|
+
type: 'button',
|
|
440
|
+
style: styles.removeButton,
|
|
441
|
+
disabled,
|
|
442
|
+
onClick: () => removeChannel(server.id, channel.id)
|
|
443
|
+
}, t('removeChannel'))
|
|
444
|
+
: null
|
|
445
|
+
)),
|
|
446
|
+
h('p', { style: styles.hint }, t('channelsHint'))
|
|
447
|
+
)),
|
|
448
|
+
h('div', { style: styles.actions },
|
|
449
|
+
h('button', {
|
|
450
|
+
type: 'button',
|
|
451
|
+
style: disabled ? { ...styles.smallButton, ...styles.buttonDisabled } : styles.smallButton,
|
|
452
|
+
disabled,
|
|
453
|
+
onClick: addServer
|
|
454
|
+
}, `+ ${t('addServer')}`)
|
|
455
|
+
),
|
|
456
|
+
!state.writable ? h('p', { style: styles.hint }, t('readOnly')) : null,
|
|
457
|
+
error ? h('p', { role: 'alert', style: styles.error }, error) : null,
|
|
458
|
+
saved ? h('p', { role: 'status', style: styles.success }, t('saved')) : null,
|
|
459
|
+
h('div', { style: styles.actions },
|
|
460
|
+
h('button', {
|
|
461
|
+
type: 'button',
|
|
462
|
+
style: disabled ? { ...styles.button, ...styles.buttonDisabled } : styles.button,
|
|
463
|
+
disabled,
|
|
464
|
+
onClick: save
|
|
465
|
+
}, saving ? t('saving') : t('save'))
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
inject: ['slots', 'locale'],
|
|
472
|
+
apply(ctx) {
|
|
473
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }))
|
|
474
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
475
|
+
name: 'plugins.row.config',
|
|
476
|
+
key: 'dsh-ntfy-notifier#ntfy-notifier',
|
|
477
|
+
locale: NS
|
|
478
|
+
}, ConfigPage))
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
})
|