dsh-pocket 1.0.1 → 1.0.3
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 +5 -0
- package/client/client.js +76 -1
- package/client/index.jsx +81 -1
- package/lib/index.js +61 -3
- package/lib/pocket-sw.js +32 -0
- package/lib/push.mjs +140 -0
- package/lib/web-rpc.js +21 -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,11 @@ 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',
|
|
12
|
+
pushSetEnabled: 'push.setEnabled',
|
|
8
13
|
});
|
|
9
14
|
|
|
10
15
|
/** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
|
package/client/client.js
CHANGED
|
@@ -37,7 +37,12 @@ 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",
|
|
45
|
+
pushSetEnabled: "push.setEnabled"
|
|
41
46
|
});
|
|
42
47
|
function redactStatus(s) {
|
|
43
48
|
return {
|
|
@@ -1241,6 +1246,32 @@ function mobileApply(ctx) {
|
|
|
1241
1246
|
// client/index.jsx
|
|
1242
1247
|
var name = "dsh-pocket";
|
|
1243
1248
|
var inject = ["slots", "connection", "layout", "locale", "sessionLogDownload"];
|
|
1249
|
+
async function setupPush(rpcCall) {
|
|
1250
|
+
try {
|
|
1251
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return "unsupported";
|
|
1252
|
+
if (!window.isSecureContext) return "insecure";
|
|
1253
|
+
const reg = await navigator.serviceWorker.register("/pocket-sw.js");
|
|
1254
|
+
const vapid = await rpcCall(POCKET_ENDPOINTS.pushVapidKey, {});
|
|
1255
|
+
if (!vapid?.ok) return "host-error";
|
|
1256
|
+
let sub = await reg.pushManager.getSubscription();
|
|
1257
|
+
if (!sub) {
|
|
1258
|
+
sub = await reg.pushManager.subscribe({
|
|
1259
|
+
userVisibleOnly: true,
|
|
1260
|
+
applicationServerKey: urlBase64ToUint8Array(vapid.value.publicKey)
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const res = await rpcCall(POCKET_ENDPOINTS.pushSubscribe, { subscription: sub.toJSON() });
|
|
1264
|
+
return res?.ok ? "on" : "host-error";
|
|
1265
|
+
} catch {
|
|
1266
|
+
return "error";
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function urlBase64ToUint8Array(base64url) {
|
|
1270
|
+
const pad = "=".repeat((4 - base64url.length % 4) % 4);
|
|
1271
|
+
const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
1272
|
+
const raw = atob(b64);
|
|
1273
|
+
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
|
1274
|
+
}
|
|
1244
1275
|
var styles = {
|
|
1245
1276
|
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
1277
|
block: { borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", marginTop: 12, paddingTop: 12 },
|
|
@@ -1255,6 +1286,8 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1255
1286
|
const [status, setStatus] = (0, import_react2.useState)(null);
|
|
1256
1287
|
const [busy, setBusy] = (0, import_react2.useState)(false);
|
|
1257
1288
|
const [error, setError] = (0, import_react2.useState)(null);
|
|
1289
|
+
const [pushEnabled, setPushEnabled] = (0, import_react2.useState)(true);
|
|
1290
|
+
const [pushState, setPushState] = (0, import_react2.useState)("checking");
|
|
1258
1291
|
const call = async (endpoint, payload) => {
|
|
1259
1292
|
const res = await rpcCall(endpoint, payload);
|
|
1260
1293
|
if (!res?.ok) throw new Error(res?.error?.message ?? "RPC failed");
|
|
@@ -1271,6 +1304,32 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1271
1304
|
const t = setInterval(load, 3e3);
|
|
1272
1305
|
return () => clearInterval(t);
|
|
1273
1306
|
}, []);
|
|
1307
|
+
(0, import_react2.useEffect)(() => {
|
|
1308
|
+
call(POCKET_ENDPOINTS.pushStatus, {}).then((s) => setPushEnabled(s.enabled)).catch(() => {
|
|
1309
|
+
});
|
|
1310
|
+
}, []);
|
|
1311
|
+
const enablePush = async () => {
|
|
1312
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: true });
|
|
1313
|
+
setPushEnabled(true);
|
|
1314
|
+
setPushState(await setupPush(rpcCall));
|
|
1315
|
+
};
|
|
1316
|
+
const disablePush = async () => {
|
|
1317
|
+
try {
|
|
1318
|
+
if ("serviceWorker" in navigator) {
|
|
1319
|
+
const reg = await navigator.serviceWorker.getRegistration("/pocket-sw.js");
|
|
1320
|
+
const sub = await reg?.pushManager?.getSubscription();
|
|
1321
|
+
if (sub) {
|
|
1322
|
+
const endpoint = sub.endpoint;
|
|
1323
|
+
await sub.unsubscribe();
|
|
1324
|
+
await call(POCKET_ENDPOINTS.pushUnsubscribe, { endpoint });
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
} catch {
|
|
1328
|
+
}
|
|
1329
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: false });
|
|
1330
|
+
setPushEnabled(false);
|
|
1331
|
+
setPushState("off");
|
|
1332
|
+
};
|
|
1274
1333
|
const startTunnel = async () => {
|
|
1275
1334
|
setBusy(true);
|
|
1276
1335
|
setError(null);
|
|
@@ -1331,6 +1390,22 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
1331
1390
|
(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
1391
|
)
|
|
1333
1392
|
),
|
|
1393
|
+
// Web Push 状态 + 开关
|
|
1394
|
+
(0, import_react2.createElement)(
|
|
1395
|
+
"div",
|
|
1396
|
+
{ style: styles.block },
|
|
1397
|
+
(0, import_react2.createElement)(
|
|
1398
|
+
"div",
|
|
1399
|
+
{ style: { display: "flex", alignItems: "center", justifyContent: "space-between" } },
|
|
1400
|
+
(0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13 } }, "\u{1F514} \u63A8\u9001\u901A\u77E5 | Push notifications"),
|
|
1401
|
+
pushEnabled ? (0, import_react2.createElement)("button", { style: styles.btn, onClick: disablePush }, "\u5173\u95ED | Off") : (0, import_react2.createElement)("button", { style: styles.primary, onClick: enablePush }, "\u5F00\u542F | On")
|
|
1402
|
+
),
|
|
1403
|
+
(0, import_react2.createElement)(
|
|
1404
|
+
"div",
|
|
1405
|
+
{ style: styles.muted },
|
|
1406
|
+
!pushEnabled ? "\u5DF2\u5173\u95ED\uFF1Aagent \u8DD1\u5B8C\u4E0D\u4F1A\u63A8\u9001 | off: no notifications" : 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" ? "\u5DF2\u5F00\u542F\uFF08\u4F46\u5F53\u524D\u6D4F\u89C8\u5668\u4E0D\u652F\u6301\u63A8\u9001\uFF09| on, but this browser does not support push" : pushState === "insecure" ? "\u5DF2\u5F00\u542F\uFF0C\u4F46\u5F53\u524D\u8DEF\u5F84\u4E0D\u662F HTTPS\u2014\u2014\u63A8\u9001\u9700\u8981\u516C\u7F51\u96A7\u9053\u6216 localhost | on, but push needs HTTPS (public tunnel) or localhost" : pushState === "checking" ? "\u68C0\u67E5\u4E2D\u2026 | checking\u2026" : "\u63A8\u9001\u672A\u751F\u6548 | push not active"
|
|
1407
|
+
)
|
|
1408
|
+
),
|
|
1334
1409
|
error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${error}`) : null
|
|
1335
1410
|
);
|
|
1336
1411
|
}
|
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,8 @@ 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 [pushEnabled, setPushEnabled] = useState(true); // 宿主开关
|
|
62
|
+
const [pushState, setPushState] = useState('checking'); // checking|on|unsupported|insecure|off
|
|
30
63
|
|
|
31
64
|
const call = async (endpoint, payload) => {
|
|
32
65
|
const res = await rpcCall(endpoint, payload);
|
|
@@ -44,6 +77,36 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
44
77
|
return () => clearInterval(t);
|
|
45
78
|
}, []);
|
|
46
79
|
|
|
80
|
+
// 读取宿主开关状态
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
call(POCKET_ENDPOINTS.pushStatus, {}).then((s) => setPushEnabled(s.enabled)).catch(() => {});
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
// 开启推送:宿主开关开 + 浏览器订阅(安全上下文才有效)
|
|
86
|
+
const enablePush = async () => {
|
|
87
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: true });
|
|
88
|
+
setPushEnabled(true);
|
|
89
|
+
setPushState(await setupPush(rpcCall));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// 关闭推送:取消浏览器订阅 + 宿主开关关
|
|
93
|
+
const disablePush = async () => {
|
|
94
|
+
try {
|
|
95
|
+
if ('serviceWorker' in navigator) {
|
|
96
|
+
const reg = await navigator.serviceWorker.getRegistration('/pocket-sw.js');
|
|
97
|
+
const sub = await reg?.pushManager?.getSubscription();
|
|
98
|
+
if (sub) {
|
|
99
|
+
const endpoint = sub.endpoint;
|
|
100
|
+
await sub.unsubscribe();
|
|
101
|
+
await call(POCKET_ENDPOINTS.pushUnsubscribe, { endpoint });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch { /* 忽略 */ }
|
|
105
|
+
await call(POCKET_ENDPOINTS.pushSetEnabled, { enabled: false });
|
|
106
|
+
setPushEnabled(false);
|
|
107
|
+
setPushState('off');
|
|
108
|
+
};
|
|
109
|
+
|
|
47
110
|
const startTunnel = async () => {
|
|
48
111
|
setBusy(true);
|
|
49
112
|
setError(null);
|
|
@@ -97,6 +160,23 @@ function PocketSettingsTab({ rpcCall }) {
|
|
|
97
160
|
),
|
|
98
161
|
),
|
|
99
162
|
|
|
163
|
+
// Web Push 状态 + 开关
|
|
164
|
+
h('div', { style: styles.block },
|
|
165
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
|
|
166
|
+
h('div', { style: { fontWeight: 600, fontSize: 13 } }, '🔔 推送通知 | Push notifications'),
|
|
167
|
+
pushEnabled
|
|
168
|
+
? h('button', { style: styles.btn, onClick: disablePush }, '关闭 | Off')
|
|
169
|
+
: h('button', { style: styles.primary, onClick: enablePush }, '开启 | On'),
|
|
170
|
+
),
|
|
171
|
+
h('div', { style: styles.muted },
|
|
172
|
+
!pushEnabled ? '已关闭:agent 跑完不会推送 | off: no notifications'
|
|
173
|
+
: pushState === 'on' ? '已开启:agent 跑完/出错时手机收到通知 | on: notified when tasks finish or fail'
|
|
174
|
+
: pushState === 'unsupported' ? '已开启(但当前浏览器不支持推送)| on, but this browser does not support push'
|
|
175
|
+
: pushState === 'insecure' ? '已开启,但当前路径不是 HTTPS——推送需要公网隧道或 localhost | on, but push needs HTTPS (public tunnel) or localhost'
|
|
176
|
+
: pushState === 'checking' ? '检查中… | checking…'
|
|
177
|
+
: '推送未生效 | push not active'),
|
|
178
|
+
),
|
|
179
|
+
|
|
100
180
|
error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${error}`) : null,
|
|
101
181
|
);
|
|
102
182
|
}
|
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,140 @@
|
|
|
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
|
+
const SETTINGS_FILE = 'settings.json';
|
|
21
|
+
|
|
22
|
+
function defaultWebPush() {
|
|
23
|
+
// 动态 require(web-push 是 CJS)
|
|
24
|
+
return require('web-push');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 创建推送服务。
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} [opts.home] $DSH_HOME(默认 ~/.dsh)
|
|
31
|
+
* @param {object} [opts.webpush] web-push 库(测试注入 stub)
|
|
32
|
+
* @param {string} [opts.subject] VAPID subject(mailto:)
|
|
33
|
+
* @param {object} [opts.internals] 测试注入:mkdir/write/read
|
|
34
|
+
* @returns {Promise<PushService>}
|
|
35
|
+
*/
|
|
36
|
+
export async function createPushService({
|
|
37
|
+
home,
|
|
38
|
+
webpush = defaultWebPush(),
|
|
39
|
+
subject = 'mailto:shaobeichen@outlook.com',
|
|
40
|
+
internals = {},
|
|
41
|
+
} = {}) {
|
|
42
|
+
const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
43
|
+
const dir = join(dshHome, DATA_REL);
|
|
44
|
+
const vapidPath = join(dir, VAPID_FILE);
|
|
45
|
+
const subsPath = join(dir, SUBS_FILE);
|
|
46
|
+
const settingsPath = join(dir, SETTINGS_FILE);
|
|
47
|
+
const mkdirFn = internals.mkdir ?? mkdir;
|
|
48
|
+
const read = internals.read ?? readFile;
|
|
49
|
+
const write = internals.write ?? writeFile;
|
|
50
|
+
|
|
51
|
+
await mkdirFn(dir, { recursive: true });
|
|
52
|
+
|
|
53
|
+
// 开关状态:默认开,持久化到 settings.json
|
|
54
|
+
let enabled = true;
|
|
55
|
+
try {
|
|
56
|
+
const s = JSON.parse(await read(settingsPath, 'utf8'));
|
|
57
|
+
if (typeof s.enabled === 'boolean') enabled = s.enabled;
|
|
58
|
+
} catch { /* 默认开 */ }
|
|
59
|
+
if (internals.enabled !== undefined) enabled = internals.enabled === true;
|
|
60
|
+
|
|
61
|
+
// VAPID 密钥:生成一次,持久化
|
|
62
|
+
let vapid;
|
|
63
|
+
try {
|
|
64
|
+
vapid = JSON.parse(await read(vapidPath, 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
vapid = webpush.generateVAPIDKeys();
|
|
67
|
+
await write(vapidPath, JSON.stringify(vapid, null, 2) + '\n', { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
webpush.setVapidDetails(subject, vapid.publicKey, vapid.privateKey);
|
|
70
|
+
|
|
71
|
+
// 订阅集合:endpoint → subscription
|
|
72
|
+
let subs = new Map();
|
|
73
|
+
try {
|
|
74
|
+
for (const s of JSON.parse(await read(subsPath, 'utf8')) ?? []) {
|
|
75
|
+
if (s?.endpoint) subs.set(s.endpoint, s);
|
|
76
|
+
}
|
|
77
|
+
} catch { /* 空开始 */ }
|
|
78
|
+
|
|
79
|
+
const persist = async () => {
|
|
80
|
+
await write(subsPath, JSON.stringify([...subs.values()], null, 2) + '\n', { mode: 0o600 });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
vapidPublicKey: () => vapid.publicKey,
|
|
85
|
+
count: () => subs.size,
|
|
86
|
+
isEnabled: () => enabled,
|
|
87
|
+
|
|
88
|
+
/** 设置推送总开关(持久化)。 */
|
|
89
|
+
async setEnabled(value) {
|
|
90
|
+
enabled = value === true;
|
|
91
|
+
await write(settingsPath, JSON.stringify({ enabled }, null, 2) + '\n', { mode: 0o600 });
|
|
92
|
+
return enabled;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/** 记录浏览器订阅。 */
|
|
96
|
+
async subscribe(subscription) {
|
|
97
|
+
if (!subscription?.endpoint || !subscription?.keys) return false;
|
|
98
|
+
subs.set(subscription.endpoint, subscription);
|
|
99
|
+
await persist();
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** 按 endpoint 取消订阅。 */
|
|
104
|
+
async unsubscribe(endpoint) {
|
|
105
|
+
const removed = subs.delete(endpoint);
|
|
106
|
+
if (removed) await persist();
|
|
107
|
+
return removed;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 推送通知到所有订阅;失效订阅自动清理。
|
|
112
|
+
* @param {object} opts
|
|
113
|
+
* @param {string} opts.title
|
|
114
|
+
* @param {string} [opts.body]
|
|
115
|
+
* @param {string} [opts.url] 点击通知打开的地址(默认 /)
|
|
116
|
+
* @param {object} [opts.data] 额外载荷
|
|
117
|
+
*/
|
|
118
|
+
async notify({ title, body = '', url = '/', data = {} }) {
|
|
119
|
+
if (!enabled || subs.size === 0) return 0;
|
|
120
|
+
const payload = JSON.stringify({ title, body, url, ...data });
|
|
121
|
+
let sent = 0;
|
|
122
|
+
for (const [endpoint, sub] of [...subs.entries()]) {
|
|
123
|
+
try {
|
|
124
|
+
await webpush.sendNotification(sub, payload, { TTL: 600 });
|
|
125
|
+
sent += 1;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// 410 Gone / 404 = 订阅失效,清理
|
|
128
|
+
const code = err?.statusCode;
|
|
129
|
+
if (code === 410 || code === 404) {
|
|
130
|
+
subs.delete(endpoint);
|
|
131
|
+
await persist();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return sent;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async dispose() { /* 无长连接需要清理 */ },
|
|
139
|
+
};
|
|
140
|
+
}
|
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,28 @@ 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.isEnabled(), count: push.count() });
|
|
53
|
+
}
|
|
54
|
+
if (endpoint === POCKET_ENDPOINTS.pushSetEnabled) {
|
|
55
|
+
const enabled = await push.setEnabled(payload?.enabled === true);
|
|
56
|
+
return ok({ enabled, count: push.count() });
|
|
57
|
+
}
|
|
40
58
|
return fail('bad-request', `Unknown endpoint: ${endpoint}`);
|
|
41
59
|
} catch (err) {
|
|
42
60
|
log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
|
|
43
|
-
return fail('
|
|
61
|
+
return fail('bad-request', err?.message ?? String(err));
|
|
44
62
|
}
|
|
45
63
|
}, { authority: 'loopback' });
|
|
46
64
|
}
|
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.3"
|
|
80
81
|
}
|