dsh-pocket 1.0.1 → 1.0.2
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.en.md +1 -0
- package/README.md +1 -0
- package/client/api.js +4 -0
- package/client/client.js +46 -1
- package/client/index.jsx +49 -1
- package/lib/index.js +61 -3
- package/lib/pocket-sw.js +32 -0
- package/lib/push.mjs +122 -0
- package/lib/web-rpc.js +17 -3
- package/package.json +3 -2
package/README.en.md
CHANGED
|
@@ -35,6 +35,7 @@ Phone ──scan──> dsh-pocket proxy ──> dsh web :3080
|
|
|
35
35
|
| 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
|
|
36
36
|
| 🧩 Zero-dependency install | One npm package, one settings tab — no core/adapter split, no account, no server |
|
|
37
37
|
| 🔒 URL is the key | No public URL exposure in LAN mode; public URL rotates on every restart |
|
|
38
|
+
| 🔔 Web Push | Phone notifications when a task finishes or fails (even with the page closed); requires the HTTPS public tunnel or localhost |
|
|
38
39
|
|
|
39
40
|
## 🚀 Usage
|
|
40
41
|
|
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
| 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
|
|
36
36
|
| 🧩 零依赖安装 | 一个 npm 包、一个设置页,没有核心/适配器要分开装;无需账号、无需服务器 |
|
|
37
37
|
| 🔒 URL 即钥匙 | 无公网 URL 暴露给第三方(局域网模式);公网 URL 每次重启自动换新 |
|
|
38
|
+
| 🔔 Web Push 通知 | agent 跑完/出错 → 手机推送通知(即使没开页面);需 HTTPS 公网路径或 localhost |
|
|
38
39
|
|
|
39
40
|
## 🚀 怎么用
|
|
40
41
|
|
package/client/api.js
CHANGED
|
@@ -5,6 +5,10 @@ export const POCKET_ENDPOINTS = Object.freeze({
|
|
|
5
5
|
status: 'pocket.status',
|
|
6
6
|
tunnelStart: 'tunnel.start',
|
|
7
7
|
tunnelStop: 'tunnel.stop',
|
|
8
|
+
pushVapidKey: 'push.vapidKey',
|
|
9
|
+
pushSubscribe: 'push.subscribe',
|
|
10
|
+
pushUnsubscribe: 'push.unsubscribe',
|
|
11
|
+
pushStatus: 'push.status',
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
/** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
|
package/client/client.js
CHANGED
|
@@ -37,7 +37,11 @@ var POCKET_RPC_CHANNEL = "/dsh-pocket";
|
|
|
37
37
|
var POCKET_ENDPOINTS = Object.freeze({
|
|
38
38
|
status: "pocket.status",
|
|
39
39
|
tunnelStart: "tunnel.start",
|
|
40
|
-
tunnelStop: "tunnel.stop"
|
|
40
|
+
tunnelStop: "tunnel.stop",
|
|
41
|
+
pushVapidKey: "push.vapidKey",
|
|
42
|
+
pushSubscribe: "push.subscribe",
|
|
43
|
+
pushUnsubscribe: "push.unsubscribe",
|
|
44
|
+
pushStatus: "push.status"
|
|
41
45
|
});
|
|
42
46
|
function redactStatus(s) {
|
|
43
47
|
return {
|
|
@@ -1241,6 +1245,32 @@ function mobileApply(ctx) {
|
|
|
1241
1245
|
// client/index.jsx
|
|
1242
1246
|
var name = "dsh-pocket";
|
|
1243
1247
|
var inject = ["slots", "connection", "layout", "locale", "sessionLogDownload"];
|
|
1248
|
+
async function setupPush(rpcCall) {
|
|
1249
|
+
try {
|
|
1250
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
|
1251
|
+
if (!window.isSecureContext) return "insecure";
|
|
1252
|
+
const reg = await navigator.serviceWorker.register("/pocket-sw.js");
|
|
1253
|
+
const vapid = await rpcCall(POCKET_ENDPOINTS.pushVapidKey, {});
|
|
1254
|
+
if (!vapid?.ok) return "host-error";
|
|
1255
|
+
let sub = await reg.pushManager.getSubscription();
|
|
1256
|
+
if (!sub) {
|
|
1257
|
+
sub = await reg.pushManager.subscribe({
|
|
1258
|
+
userVisibleOnly: true,
|
|
1259
|
+
applicationServerKey: urlBase64ToUint8Array(vapid.value.publicKey)
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
const res = await rpcCall(POCKET_ENDPOINTS.pushSubscribe, { subscription: sub.toJSON() });
|
|
1263
|
+
return res?.ok ? "on" : "host-error";
|
|
1264
|
+
} catch {
|
|
1265
|
+
return "error";
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
function urlBase64ToUint8Array(base64url) {
|
|
1269
|
+
const pad = "=".repeat((4 - base64url.length % 4) % 4);
|
|
1270
|
+
const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
1271
|
+
const raw = atob(b64);
|
|
1272
|
+
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
1273
|
+
}
|
|
1244
1274
|
var styles = {
|
|
1245
1275
|
card: { background: "var(--dsw-alias-bg-layer-1,#fff)", border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", borderRadius: 12, padding: "14px 16px", maxWidth: 480 },
|
|
1246
1276
|
block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 12, paddingTop: 12 },
|
|
@@ -1255,6 +1285,7 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1255
1285
|
const [status, setStatus] = (0, import_react2.useState)(null);
|
|
1256
1286
|
const [busy, setBusy] = (0, import_react2.useState)(false);
|
|
1257
1287
|
const [error, setError] = (0, import_react2.useState)(null);
|
|
1288
|
+
const [pushState, setPushState] = (0, import_react2.useState)("checking");
|
|
1258
1289
|
const call = async (endpoint, payload) => {
|
|
1259
1290
|
const res = await rpcCall(endpoint, payload);
|
|
1260
1291
|
if (!res?.ok) throw new Error(res?.error?.message ?? "RPC failed");
|
|
@@ -1271,6 +1302,9 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1271
1302
|
const t = setInterval(load, 3e3);
|
|
1272
1303
|
return () => clearInterval(t);
|
|
1273
1304
|
}, []);
|
|
1305
|
+
(0, import_react2.useEffect)(() => {
|
|
1306
|
+
setupPush(rpcCall).then(setPushState);
|
|
1307
|
+
}, []);
|
|
1274
1308
|
const startTunnel = async () => {
|
|
1275
1309
|
setBusy(true);
|
|
1276
1310
|
setError(null);
|
|
@@ -1331,6 +1365,17 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1331
1365
|
(0, import_react2.createElement)("div", { style: styles.warn, marginTop: 8 }, "\u26A0\uFE0F DSH \u80FD\u6267\u884C\u7535\u8111\u4EE3\u7801\uFF1A\u4E8C\u7EF4\u7801/URL \u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u52FF\u53D1\u7ED9\u522B\u4EBA")
|
|
1332
1366
|
)
|
|
1333
1367
|
),
|
|
1368
|
+
// Web Push 状态
|
|
1369
|
+
(0, import_react2.createElement)(
|
|
1370
|
+
"div",
|
|
1371
|
+
{ style: styles.block },
|
|
1372
|
+
(0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F514} \u63A8\u9001\u901A\u77E5 | Push notifications"),
|
|
1373
|
+
(0, import_react2.createElement)(
|
|
1374
|
+
"div",
|
|
1375
|
+
{ style: styles.muted },
|
|
1376
|
+
pushState === "on" ? "\u5DF2\u5F00\u542F\uFF1Aagent \u8DD1\u5B8C/\u51FA\u9519\u65F6\u624B\u673A\u6536\u5230\u901A\u77E5 | on: notified when tasks finish or fail" : pushState === "unsupported" ? "\u5F53\u524D\u6D4F\u89C8\u5668\u4E0D\u652F\u6301\u63A8\u9001 | this browser does not support push" : pushState === "insecure" ? "\u9700\u8981 HTTPS\uFF08\u516C\u7F51\u96A7\u9053\uFF09\u6216 localhost \u624D\u80FD\u5F00\u542F\u63A8\u9001 | push needs HTTPS (public tunnel) or localhost" : pushState === "checking" ? "\u68C0\u67E5\u4E2D\u2026 | checking\u2026" : "\u63A8\u9001\u672A\u5F00\u542F | push not enabled"
|
|
1377
|
+
)
|
|
1378
|
+
),
|
|
1334
1379
|
error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${error}`) : null
|
|
1335
1380
|
);
|
|
1336
1381
|
}
|
package/client/index.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// dsh-pocket 网页客户端:
|
|
2
|
-
// 1.
|
|
2
|
+
// 1. 设置页签「手机访问」(局域网/公网二维码 + Web Push 状态)
|
|
3
3
|
// 2. 移动端适配(移植自 MIT 项目 dsh-web-mobile,见 client/mobile/LICENSE.dsh-web-mobile)
|
|
4
|
+
// 3. Web Push:注册 Service Worker + 订阅推送(agent 跑完/出错 → 手机通知)
|
|
4
5
|
//
|
|
5
6
|
// 手机扫码打开的就是电脑上的 dsh web,实时同步;窄屏自动变成抽屉布局。
|
|
6
7
|
|
|
@@ -12,6 +13,36 @@ import { mobileApply } from './mobile/mobile-apply.tsx';
|
|
|
12
13
|
const name = 'dsh-pocket';
|
|
13
14
|
const inject = ['slots', 'connection', 'layout', 'locale', 'sessionLogDownload'];
|
|
14
15
|
|
|
16
|
+
/** Web Push:注册 SW + 订阅(仅安全上下文可用:HTTPS 公网 / localhost)。 */
|
|
17
|
+
async function setupPush(rpcCall) {
|
|
18
|
+
try {
|
|
19
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported';
|
|
20
|
+
if (!window.isSecureContext) return 'insecure'; // http://LAN-IP 无 Push API
|
|
21
|
+
const reg = await navigator.serviceWorker.register('/pocket-sw.js');
|
|
22
|
+
const vapid = await rpcCall(POCKET_ENDPOINTS.pushVapidKey, {});
|
|
23
|
+
if (!vapid?.ok) return 'host-error';
|
|
24
|
+
let sub = await reg.pushManager.getSubscription();
|
|
25
|
+
if (!sub) {
|
|
26
|
+
sub = await reg.pushManager.subscribe({
|
|
27
|
+
userVisibleOnly: true,
|
|
28
|
+
applicationServerKey: urlBase64ToUint8Array(vapid.value.publicKey),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const res = await rpcCall(POCKET_ENDPOINTS.pushSubscribe, { subscription: sub.toJSON() });
|
|
32
|
+
return res?.ok ? 'on' : 'host-error';
|
|
33
|
+
} catch {
|
|
34
|
+
return 'error';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** VAPID 公钥(base64url)→ Uint8Array(pushManager.subscribe 需要)。 */
|
|
39
|
+
function urlBase64ToUint8Array(base64url) {
|
|
40
|
+
const pad = '='.repeat((4 - (base64url.length % 4)) % 4);
|
|
41
|
+
const b64 = (base64url + pad).replace(/-/g, '+').replace(/_/g, '/');
|
|
42
|
+
const raw = atob(b64);
|
|
43
|
+
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
const styles = {
|
|
16
47
|
card: { background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', borderRadius: 12, padding: '14px 16px', maxWidth: 480 },
|
|
17
48
|
block: { borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', marginTop: 12, paddingTop: 12 },
|
|
@@ -27,6 +58,7 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
27
58
|
const [status, setStatus] = useState(null);
|
|
28
59
|
const [busy, setBusy] = useState(false);
|
|
29
60
|
const [error, setError] = useState(null);
|
|
61
|
+
const [pushState, setPushState] = useState('checking'); // checking|on|unsupported|insecure|off
|
|
30
62
|
|
|
31
63
|
const call = async (endpoint, payload) => {
|
|
32
64
|
const res = await rpcCall(endpoint, payload);
|
|
@@ -44,6 +76,11 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
44
76
|
return () => clearInterval(t);
|
|
45
77
|
}, []);
|
|
46
78
|
|
|
79
|
+
// Web Push 订阅(只跑一次)
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
setupPush(rpcCall).then(setPushState);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
47
84
|
const startTunnel = async () => {
|
|
48
85
|
setBusy(true);
|
|
49
86
|
setError(null);
|
|
@@ -97,6 +134,17 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
97
134
|
),
|
|
98
135
|
),
|
|
99
136
|
|
|
137
|
+
// Web Push 状态
|
|
138
|
+
h('div', { style: styles.block },
|
|
139
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '🔔 推送通知 | Push notifications'),
|
|
140
|
+
h('div', { style: styles.muted },
|
|
141
|
+
pushState === 'on' ? '已开启:agent 跑完/出错时手机收到通知 | on: notified when tasks finish or fail'
|
|
142
|
+
: pushState === 'unsupported' ? '当前浏览器不支持推送 | this browser does not support push'
|
|
143
|
+
: pushState === 'insecure' ? '需要 HTTPS(公网隧道)或 localhost 才能开启推送 | push needs HTTPS (public tunnel) or localhost'
|
|
144
|
+
: pushState === 'checking' ? '检查中… | checking…'
|
|
145
|
+
: '推送未开启 | push not enabled'),
|
|
146
|
+
),
|
|
147
|
+
|
|
100
148
|
error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${error}`) : null,
|
|
101
149
|
);
|
|
102
150
|
}
|
package/lib/index.js
CHANGED
|
@@ -3,14 +3,27 @@
|
|
|
3
3
|
// 设置页「设置 → 插件 → 手机访问」:
|
|
4
4
|
// - 局域网二维码:自动显示(代理随插件启动)
|
|
5
5
|
// - 公网二维码:点「开启公网」→ cloudflared 隧道 → 扫码即用,人在外面也能访问
|
|
6
|
+
// - Web Push:agent 跑完/出错 → 手机推送通知(需 HTTPS 公网路径或 localhost)
|
|
6
7
|
// 手机看到的界面 = 电脑上的 dsh web,实时同步(WebSocket 透传)。
|
|
7
8
|
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
8
12
|
import { createPocketService } from './service.mjs';
|
|
9
13
|
import { installPocketRpc } from './web-rpc.js';
|
|
14
|
+
import { createPushService } from './push.mjs';
|
|
10
15
|
|
|
11
16
|
const name = 'dsh-pocket';
|
|
12
17
|
const inject = ['connection', 'webServer'];
|
|
13
18
|
|
|
19
|
+
// 服务 worker 源码(同源提供:/pocket-sw.js)
|
|
20
|
+
const SW_SOURCE = new URL('./pocket-sw.js', import.meta.url);
|
|
21
|
+
let swCache = null;
|
|
22
|
+
async function swScript() {
|
|
23
|
+
if (!swCache) swCache = await readFile(fileURLToPath(SW_SOURCE), 'utf8');
|
|
24
|
+
return swCache;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export function apply(ctx, config = {}, internals = {}) {
|
|
15
28
|
const logger = ctx.logger?.(name) ?? console;
|
|
16
29
|
const dshPort = internals.dshPort ?? ctx.webServer?.port;
|
|
@@ -25,7 +38,52 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
25
38
|
internals,
|
|
26
39
|
});
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
// Web Push 服务(web-push 库;测试可注入 stub)
|
|
42
|
+
const pushPromise = internals.pushPromise ?? createPushService({ internals });
|
|
43
|
+
pushPromise.catch((err) => logger.error('dsh-pocket: push service init failed | 推送服务初始化失败: %s', err?.message ?? err));
|
|
44
|
+
|
|
45
|
+
const disposers = [];
|
|
46
|
+
const disposeRpc = installPocketRpc(ctx, {
|
|
47
|
+
service,
|
|
48
|
+
push: internals.push ?? { vapidPublicKey: () => '', count: () => 0, subscribe: async () => false, unsubscribe: async () => false },
|
|
49
|
+
log: logger,
|
|
50
|
+
});
|
|
51
|
+
disposers.push(disposeRpc);
|
|
52
|
+
|
|
53
|
+
// 同源提供 Service Worker(Web Push 必需)
|
|
54
|
+
try {
|
|
55
|
+
const removeSw = ctx.webServer.register({
|
|
56
|
+
kind: 'exact',
|
|
57
|
+
path: '/pocket-sw.js',
|
|
58
|
+
handler: async (req, res) => {
|
|
59
|
+
res.writeHead(200, { 'content-type': 'application/javascript', 'cache-control': 'no-cache' });
|
|
60
|
+
res.end(await swScript());
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
disposers.push(removeSw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
logger.error('dsh-pocket: sw route register failed | SW 路由注册失败: %s', err?.message ?? err);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Agent 回合结束/出错 → 推送通知(有订阅才发)
|
|
69
|
+
const onSessionEvent = (session, event) => {
|
|
70
|
+
if (event?.type !== 'turn/end') return;
|
|
71
|
+
const reason = event?.data?.reason ?? event?.reason;
|
|
72
|
+
if (!reason) return;
|
|
73
|
+
void pushPromise.then(async (push) => {
|
|
74
|
+
try {
|
|
75
|
+
if (reason.kind === 'error') {
|
|
76
|
+
await push.notify({ title: '❌ 任务失败 | Task failed', body: String(reason.error?.message ?? '').slice(0, 120) || 'Agent 执行出错', url: '/' });
|
|
77
|
+
} else if (reason.kind === 'completed') {
|
|
78
|
+
await push.notify({ title: '✅ 任务完成 | Task done', body: 'Agent 已完成任务,点开查看结果', url: '/' });
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.error('dsh-pocket: push send failed | 推送发送失败: %s', err?.message ?? err);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const offSession = ctx.on('session/event', onSessionEvent);
|
|
86
|
+
disposers.push(offSession);
|
|
29
87
|
|
|
30
88
|
// 代理随插件自动启动(局域网二维码开箱即用,零配置)
|
|
31
89
|
void service.startProxy().then((proxy) => {
|
|
@@ -35,9 +93,9 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
35
93
|
});
|
|
36
94
|
|
|
37
95
|
ctx.effect(() => async () => {
|
|
38
|
-
|
|
96
|
+
for (const d of disposers.reverse()) { try { d(); } catch { /* 忽略 */ } }
|
|
39
97
|
await service.dispose();
|
|
40
|
-
}, 'dsh-pocket: stop proxy and
|
|
98
|
+
}, 'dsh-pocket: stop proxy, tunnel and push');
|
|
41
99
|
}
|
|
42
100
|
|
|
43
101
|
export { name, inject };
|
package/lib/pocket-sw.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// dsh-pocket Service Worker(经 webServer 同源提供:/pocket-sw.js)
|
|
2
|
+
// 职责:接收推送 → 显示通知;点击通知 → 聚焦/打开 DSH 页面。
|
|
3
|
+
const ICON = '/favicon.svg';
|
|
4
|
+
|
|
5
|
+
self.addEventListener('push', (event) => {
|
|
6
|
+
let data = {};
|
|
7
|
+
try {
|
|
8
|
+
data = event.data ? event.data.json() : {};
|
|
9
|
+
} catch { /* 非 JSON 载荷忽略 */ }
|
|
10
|
+
event.waitUntil(
|
|
11
|
+
self.registration.showNotification(data.title || 'DSH Pocket', {
|
|
12
|
+
body: data.body || '',
|
|
13
|
+
icon: ICON,
|
|
14
|
+
badge: ICON,
|
|
15
|
+
tag: data.tag || 'dsh-pocket',
|
|
16
|
+
data: { url: data.url || '/' },
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
self.addEventListener('notificationclick', (event) => {
|
|
22
|
+
event.notification.close();
|
|
23
|
+
const url = event.notification.data?.url || '/';
|
|
24
|
+
event.waitUntil(
|
|
25
|
+
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
|
26
|
+
for (const client of windowClients) {
|
|
27
|
+
if ('focus' in client) return client.focus();
|
|
28
|
+
}
|
|
29
|
+
return self.clients.openWindow(url);
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
});
|
package/lib/push.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// dsh-pocket Web Push 服务(Agent 跑完/出错 → 手机推送通知,即使没开页面)
|
|
2
|
+
//
|
|
3
|
+
// 原理:浏览器 Push API(Service Worker + pushManager.subscribe)把订阅
|
|
4
|
+
// 交给我们,我们在 dsh web 进程内用 web-push(VAPID 认证 + RFC 8030 协议)
|
|
5
|
+
// 直接推送到浏览器厂商的推送服务(Chrome→FCM / Firefox→Mozilla / Safari→APNs)。
|
|
6
|
+
// 免费、无需第三方账号;VAPID 密钥与订阅存本机。
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ 硬性前提:Web Push 只在**安全上下文**可用(HTTPS 或 localhost)。
|
|
9
|
+
// - 公网隧道 https://xxx.trycloudflare.com ✅(人在外面的主场景)
|
|
10
|
+
// - 桌面 localhost:3080 ✅
|
|
11
|
+
// - 局域网 http://192.168.x.x ❌(明文 HTTP 没有 Push API)
|
|
12
|
+
|
|
13
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const DATA_REL = join('dsh-pocket', 'push');
|
|
18
|
+
const VAPID_FILE = 'vapid.json';
|
|
19
|
+
const SUBS_FILE = 'subscriptions.json';
|
|
20
|
+
|
|
21
|
+
function defaultWebPush() {
|
|
22
|
+
// 动态 require(web-push 是 CJS)
|
|
23
|
+
return require('web-push');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建推送服务。
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} [opts.home] $DSH_HOME(默认 ~/.dsh)
|
|
30
|
+
* @param {object} [opts.webpush] web-push 库(测试注入 stub)
|
|
31
|
+
* @param {string} [opts.subject] VAPID subject(mailto:)
|
|
32
|
+
* @param {object} [opts.internals] 测试注入:mkdir/write/read
|
|
33
|
+
* @returns {Promise<PushService>}
|
|
34
|
+
*/
|
|
35
|
+
export async function createPushService({
|
|
36
|
+
home,
|
|
37
|
+
webpush = defaultWebPush(),
|
|
38
|
+
subject = 'mailto:shaobeichen@outlook.com',
|
|
39
|
+
internals = {},
|
|
40
|
+
} = {}) {
|
|
41
|
+
const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
42
|
+
const dir = join(dshHome, DATA_REL);
|
|
43
|
+
const vapidPath = join(dir, VAPID_FILE);
|
|
44
|
+
const subsPath = join(dir, SUBS_FILE);
|
|
45
|
+
const mkdirFn = internals.mkdir ?? mkdir;
|
|
46
|
+
const read = internals.read ?? readFile;
|
|
47
|
+
const write = internals.write ?? writeFile;
|
|
48
|
+
|
|
49
|
+
await mkdirFn(dir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
// VAPID 密钥:生成一次,持久化
|
|
52
|
+
let vapid;
|
|
53
|
+
try {
|
|
54
|
+
vapid = JSON.parse(await read(vapidPath, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
vapid = webpush.generateVAPIDKeys();
|
|
57
|
+
await write(vapidPath, JSON.stringify(vapid, null, 2) + '\n', { mode: 0o600 });
|
|
58
|
+
}
|
|
59
|
+
webpush.setVapidDetails(subject, vapid.publicKey, vapid.privateKey);
|
|
60
|
+
|
|
61
|
+
// 订阅集合:endpoint → subscription
|
|
62
|
+
let subs = new Map();
|
|
63
|
+
try {
|
|
64
|
+
for (const s of JSON.parse(await read(subsPath, 'utf8')) ?? []) {
|
|
65
|
+
if (s?.endpoint) subs.set(s.endpoint, s);
|
|
66
|
+
}
|
|
67
|
+
} catch { /* 空开始 */ }
|
|
68
|
+
|
|
69
|
+
const persist = async () => {
|
|
70
|
+
await write(subsPath, JSON.stringify([...subs.values()], null, 2) + '\n', { mode: 0o600 });
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
vapidPublicKey: () => vapid.publicKey,
|
|
75
|
+
count: () => subs.size,
|
|
76
|
+
|
|
77
|
+
/** 记录浏览器订阅。 */
|
|
78
|
+
async subscribe(subscription) {
|
|
79
|
+
if (!subscription?.endpoint || !subscription?.keys) return false;
|
|
80
|
+
subs.set(subscription.endpoint, subscription);
|
|
81
|
+
await persist();
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** 按 endpoint 取消订阅。 */
|
|
86
|
+
async unsubscribe(endpoint) {
|
|
87
|
+
const removed = subs.delete(endpoint);
|
|
88
|
+
if (removed) await persist();
|
|
89
|
+
return removed;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 推送通知到所有订阅;失效订阅自动清理。
|
|
94
|
+
* @param {object} opts
|
|
95
|
+
* @param {string} opts.title
|
|
96
|
+
* @param {string} [opts.body]
|
|
97
|
+
* @param {string} [opts.url] 点击通知打开的地址(默认 /)
|
|
98
|
+
* @param {object} [opts.data] 额外载荷
|
|
99
|
+
*/
|
|
100
|
+
async notify({ title, body = '', url = '/', data = {} }) {
|
|
101
|
+
if (subs.size === 0) return 0;
|
|
102
|
+
const payload = JSON.stringify({ title, body, url, ...data });
|
|
103
|
+
let sent = 0;
|
|
104
|
+
for (const [endpoint, sub] of [...subs.entries()]) {
|
|
105
|
+
try {
|
|
106
|
+
await webpush.sendNotification(sub, payload, { TTL: 600 });
|
|
107
|
+
sent += 1;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// 410 Gone / 404 = 订阅失效,清理
|
|
110
|
+
const code = err?.statusCode;
|
|
111
|
+
if (code === 410 || code === 404) {
|
|
112
|
+
subs.delete(endpoint);
|
|
113
|
+
await persist();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return sent;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async dispose() { /* 无长连接需要清理 */ },
|
|
121
|
+
};
|
|
122
|
+
}
|
package/lib/web-rpc.js
CHANGED
|
@@ -17,12 +17,12 @@ function fail(code, message) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
|
|
20
|
-
export function installPocketRpc(ctx, { service, log = console }) {
|
|
20
|
+
export function installPocketRpc(ctx, { service, push, log = console }) {
|
|
21
21
|
if (!ctx?.connection?.rpc?.handle) {
|
|
22
22
|
log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
|
|
23
23
|
return () => {};
|
|
24
24
|
}
|
|
25
|
-
return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint,
|
|
25
|
+
return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint, payload = {}, signal) => {
|
|
26
26
|
if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
|
|
27
27
|
|
|
28
28
|
try {
|
|
@@ -37,10 +37,24 @@ export function installPocketRpc(ctx, { service, log = console }) {
|
|
|
37
37
|
service.stopTunnel();
|
|
38
38
|
return ok(redactStatus(await service.status()));
|
|
39
39
|
}
|
|
40
|
+
if (endpoint === POCKET_ENDPOINTS.pushVapidKey) {
|
|
41
|
+
return ok({ publicKey: push.vapidPublicKey() });
|
|
42
|
+
}
|
|
43
|
+
if (endpoint === POCKET_ENDPOINTS.pushSubscribe) {
|
|
44
|
+
const added = await push.subscribe(payload?.subscription);
|
|
45
|
+
return ok({ subscribed: added, count: push.count() });
|
|
46
|
+
}
|
|
47
|
+
if (endpoint === POCKET_ENDPOINTS.pushUnsubscribe) {
|
|
48
|
+
const removed = await push.unsubscribe(payload?.endpoint);
|
|
49
|
+
return ok({ removed, count: push.count() });
|
|
50
|
+
}
|
|
51
|
+
if (endpoint === POCKET_ENDPOINTS.pushStatus) {
|
|
52
|
+
return ok({ enabled: push.count() > 0, count: push.count() });
|
|
53
|
+
}
|
|
40
54
|
return fail('bad-request', `Unknown endpoint: ${endpoint}`);
|
|
41
55
|
} catch (err) {
|
|
42
56
|
log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
|
|
43
|
-
return fail('
|
|
57
|
+
return fail('bad-request', err?.message ?? String(err));
|
|
44
58
|
}
|
|
45
59
|
}, { authority: 'loopback' });
|
|
46
60
|
}
|
package/package.json
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"qrcode": "^1.5.4",
|
|
30
|
-
"qrcode-terminal": "^0.12.0"
|
|
30
|
+
"qrcode-terminal": "^0.12.0",
|
|
31
|
+
"web-push": "^3.6.7"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"esbuild": "^0.25.9",
|
|
@@ -76,5 +77,5 @@
|
|
|
76
77
|
"access": "public",
|
|
77
78
|
"registry": "https://registry.npmjs.org/"
|
|
78
79
|
},
|
|
79
|
-
"version": "1.0.
|
|
80
|
+
"version": "1.0.2"
|
|
80
81
|
}
|