dsh-mobile-web 0.1.1 → 0.1.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/LICENSE-APACHE +202 -202
- package/NOTICE +26 -26
- package/README.md +17 -0
- package/assets/mobile.bundle.js +16898 -15958
- package/assets/theme.css +3492 -3439
- package/client.js +468 -141
- package/mobile-src/UPSTREAM.md +58 -50
- package/package.json +1 -1
- package/src/host/assets.js +9 -9
- package/src/host/cleanup.js +380 -0
- package/src/host/config.js +6 -0
- package/src/host/core.js +445 -18
- package/src/host/devices.js +226 -226
- package/src/host/http.js +8 -8
- package/src/host/pages.js +514 -514
- package/src/host/rpc/index.js +32 -32
- package/src/host/rpc/push.js +58 -58
- package/src/host/rpc/settings.js +88 -88
- package/src/host/rpc/subagent.js +27 -27
- package/src/host/state.js +30 -30
package/src/host/pages.js
CHANGED
|
@@ -1,514 +1,514 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 页面与注入脚本:/m/ 的 HTML、推送自检页、PWA manifest,以及必须在 bundle
|
|
3
|
-
* 之前执行的三个内联脚本(在线守卫、视口高度、变更总线)。
|
|
4
|
-
* 纯搬自 core.js 顶层,无闭包依赖。
|
|
5
|
-
*/
|
|
6
|
-
import { bundleVersion } from './assets.js'
|
|
7
|
-
import { MOBILE_ROOT } from './config.js'
|
|
8
|
-
|
|
9
|
-
export const ONLINE_GUARD_HTML = `<script>
|
|
10
|
-
(function () {
|
|
11
|
-
/* dm-online-guard: Android/HarmonyOS WebViews may misreport navigator.onLine === false */
|
|
12
|
-
try {
|
|
13
|
-
Object.defineProperty(navigator, 'onLine', {
|
|
14
|
-
get: function () { return true; },
|
|
15
|
-
configurable: true
|
|
16
|
-
});
|
|
17
|
-
} catch (e) {}
|
|
18
|
-
})();
|
|
19
|
-
</script>`
|
|
20
|
-
|
|
21
|
-
export const VIEWPORT_VH_HTML = `<script>
|
|
22
|
-
(function () {
|
|
23
|
-
var KEY = '--dm-vh';
|
|
24
|
-
var THRESHOLD = 2; // px:小于这个变化量就不写,专治键盘/地址栏动画的抖动
|
|
25
|
-
var last = -1;
|
|
26
|
-
var frame = 0;
|
|
27
|
-
|
|
28
|
-
function measure() {
|
|
29
|
-
var vv = window.visualViewport;
|
|
30
|
-
var h;
|
|
31
|
-
if (vv && typeof vv.height === 'number' && vv.height > 0) {
|
|
32
|
-
// visualViewport.height = **当前可见**高度(已扣掉地址栏/键盘),单位是
|
|
33
|
-
// CSS px。双指放大时它会变小(scale=2 → 只看得到一半的 CSS px),
|
|
34
|
-
// 乘回 scale 还原成「布局视口坐标系」下的可见高度,避免缩放把应用缩小。
|
|
35
|
-
var s = typeof vv.scale === 'number' && vv.scale > 0 ? vv.scale : 1;
|
|
36
|
-
h = vv.height * s;
|
|
37
|
-
} else {
|
|
38
|
-
// 老 WebView 没有 visualViewport:innerHeight 就是可见高度
|
|
39
|
-
// (100vh 之所以偏大,是因为它取的是大视口)。
|
|
40
|
-
h = window.innerHeight;
|
|
41
|
-
}
|
|
42
|
-
// 上限兜底:永远不超过布局视口高度。万一上面算出偏大的值,也不会把文档
|
|
43
|
-
// 撑出滚动条 —— 这是「兜底的兜底」,正常情况下不生效。
|
|
44
|
-
var cap = document.documentElement.clientHeight;
|
|
45
|
-
if (typeof cap === 'number' && cap > 0 && h > cap) h = cap;
|
|
46
|
-
return Math.round(h);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function apply() {
|
|
50
|
-
frame = 0;
|
|
51
|
-
var h = measure();
|
|
52
|
-
if (h <= 0) return;
|
|
53
|
-
if (last >= 0 && Math.abs(h - last) < THRESHOLD) return; // 不显著 → 不写、不重排
|
|
54
|
-
last = h;
|
|
55
|
-
document.documentElement.style.setProperty(KEY, h + 'px');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 一帧内的多次事件合并成一次写入。
|
|
59
|
-
function schedule() {
|
|
60
|
-
if (frame) return;
|
|
61
|
-
frame = (window.requestAnimationFrame || function (fn) { return window.setTimeout(fn, 16); })(apply);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
apply(); // 首帧:必须在 bundle 的 #root 渲染前就把值写好
|
|
65
|
-
|
|
66
|
-
window.addEventListener('resize', schedule, { passive: true });
|
|
67
|
-
window.addEventListener('orientationchange', schedule, { passive: true });
|
|
68
|
-
if (window.visualViewport) {
|
|
69
|
-
// 地址栏收展、软键盘弹出都走这里(部分 WebView 上 window.resize 不触发)。
|
|
70
|
-
window.visualViewport.addEventListener('resize', schedule, { passive: true });
|
|
71
|
-
window.visualViewport.addEventListener('scroll', schedule, { passive: true });
|
|
72
|
-
}
|
|
73
|
-
// 兜底:极老 WebView 上 visualViewport 缺失、resize 也不触发时,
|
|
74
|
-
// 低频轮询(1s)仍能跟上地址栏收展;阈值保证它平时不产生任何写入。
|
|
75
|
-
window.setInterval(schedule, 1000);
|
|
76
|
-
})();
|
|
77
|
-
</script>`
|
|
78
|
-
|
|
79
|
-
export const MUTATION_BUS_HTML = `<script>
|
|
80
|
-
(function () {
|
|
81
|
-
if (window.__dmMutationBus) return;
|
|
82
|
-
var frameListeners = [];
|
|
83
|
-
var normalListeners = [];
|
|
84
|
-
var attrListeners = [];
|
|
85
|
-
var frameQueued = false;
|
|
86
|
-
var normalTimer = 0;
|
|
87
|
-
var attrTimer = 0;
|
|
88
|
-
function notify(list) {
|
|
89
|
-
var snapshot = list.slice();
|
|
90
|
-
for (var i = 0; i < snapshot.length; i++) {
|
|
91
|
-
try { snapshot[i](); } catch (e) {}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
function scheduleFrame() {
|
|
95
|
-
if (frameQueued) return;
|
|
96
|
-
frameQueued = true;
|
|
97
|
-
if (window.requestAnimationFrame) window.requestAnimationFrame(function () { frameQueued = false; notify(frameListeners); });
|
|
98
|
-
else window.setTimeout(function () { frameQueued = false; notify(frameListeners); }, 0);
|
|
99
|
-
}
|
|
100
|
-
function scheduleNormal() {
|
|
101
|
-
if (normalTimer !== 0) return;
|
|
102
|
-
normalTimer = window.setTimeout(function () { normalTimer = 0; notify(normalListeners); }, 600);
|
|
103
|
-
}
|
|
104
|
-
function scheduleAttr() {
|
|
105
|
-
if (attrTimer !== 0) return;
|
|
106
|
-
attrTimer = window.setTimeout(function () { attrTimer = 0; notify(attrListeners); }, 400);
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
var structural = new MutationObserver(function () {
|
|
110
|
-
if (frameListeners.length > 0) scheduleFrame();
|
|
111
|
-
if (normalListeners.length > 0) scheduleNormal();
|
|
112
|
-
});
|
|
113
|
-
structural.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
114
|
-
var attributes = new MutationObserver(function () {
|
|
115
|
-
if (attrListeners.length > 0) scheduleAttr();
|
|
116
|
-
});
|
|
117
|
-
attributes.observe(document.documentElement, {
|
|
118
|
-
attributes: true,
|
|
119
|
-
subtree: true,
|
|
120
|
-
attributeFilter: ['class', 'aria-label', 'title', 'aria-hidden', 'style', 'hidden']
|
|
121
|
-
});
|
|
122
|
-
} catch (e) { return; }
|
|
123
|
-
|
|
124
|
-
// ── 统一 ensure tick:120ms 一条表,任务按各自 period 到期才跑;全都不 ready 就停表。
|
|
125
|
-
var tasks = [];
|
|
126
|
-
var tickTimer = 0;
|
|
127
|
-
var idleTicks = 0;
|
|
128
|
-
function tick() {
|
|
129
|
-
var now = Date.now();
|
|
130
|
-
var ran = false;
|
|
131
|
-
for (var i = 0; i < tasks.length; i++) {
|
|
132
|
-
var task = tasks[i];
|
|
133
|
-
if (now < task.next) continue;
|
|
134
|
-
if (task.ready && !task.ready()) continue;
|
|
135
|
-
task.next = now + task.period;
|
|
136
|
-
ran = true;
|
|
137
|
-
try { task.fn(); } catch (e) {}
|
|
138
|
-
}
|
|
139
|
-
if (ran) { idleTicks = 0; return; }
|
|
140
|
-
idleTicks++;
|
|
141
|
-
if (idleTicks >= 3) {
|
|
142
|
-
idleTicks = 0;
|
|
143
|
-
window.clearInterval(tickTimer);
|
|
144
|
-
tickTimer = 0;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
function startTick() {
|
|
148
|
-
if (tickTimer !== 0) return;
|
|
149
|
-
idleTicks = 0;
|
|
150
|
-
tickTimer = window.setInterval(tick, 120);
|
|
151
|
-
}
|
|
152
|
-
var ensureTick = {
|
|
153
|
-
add: function (fn, period, ready) {
|
|
154
|
-
tasks.push({ fn: fn, period: typeof period === 'number' && period > 0 ? period : 120, ready: typeof ready === 'function' ? ready : null, next: 0 });
|
|
155
|
-
startTick();
|
|
156
|
-
},
|
|
157
|
-
// 唤醒时立刻跑一次 tick:否则要等下一个 120ms 周期,进页面时控件会滞后。
|
|
158
|
-
wake: function () {
|
|
159
|
-
startTick();
|
|
160
|
-
try { tick(); } catch (e) {}
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
normalListeners.push(ensureTick.wake);
|
|
164
|
-
|
|
165
|
-
// 进聊天页时 composer 会重新挂载。那一刻立刻唤醒一次 ensure tick,否则要等 600ms 切片
|
|
166
|
-
// + 各任务 800ms 周期,用户会看到「工具栏图标后来才蹦出来」。
|
|
167
|
-
// 只在 composer 元素身份变化(挂载/卸载)时唤醒一次,不挂全帧通道 —— 我们的控件写入
|
|
168
|
-
// 会再次触发总线,全帧唤醒会形成「写入→通知→再写入」的循环。
|
|
169
|
-
var composerSeen = null;
|
|
170
|
-
frameListeners.push(function () {
|
|
171
|
-
var c = document.querySelector('.chat-composer');
|
|
172
|
-
if (c === composerSeen) return;
|
|
173
|
-
composerSeen = c;
|
|
174
|
-
if (c) ensureTick.wake();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
window.__dmMutationBus = {
|
|
178
|
-
subscribe: function (listener, options) {
|
|
179
|
-
if (typeof listener !== 'function') return;
|
|
180
|
-
if (options && options.frame === true) frameListeners.push(listener);
|
|
181
|
-
else if (options && options.attributes === true) attrListeners.push(listener);
|
|
182
|
-
else normalListeners.push(listener);
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
window.__dmEnsureTick = ensureTick;
|
|
186
|
-
startTick();
|
|
187
|
-
})();
|
|
188
|
-
</script>`
|
|
189
|
-
|
|
190
|
-
export function mobilePageHtml() {
|
|
191
|
-
return [
|
|
192
|
-
'<!doctype html>',
|
|
193
|
-
'<html lang="zh-CN">',
|
|
194
|
-
'<head>',
|
|
195
|
-
'<meta charset="utf-8">',
|
|
196
|
-
// 必须最早:先把 navigator.onLine 的误报按住,bundle 才不会在发送时丢掉图片。
|
|
197
|
-
ONLINE_GUARD_HTML,
|
|
198
|
-
MUTATION_BUS_HTML,
|
|
199
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">',
|
|
200
|
-
'<meta name="theme-color" content="#f5f5f7">',
|
|
201
|
-
'<meta name="referrer" content="no-referrer">',
|
|
202
|
-
'<meta name="mobile-web-app-capable" content="yes">',
|
|
203
|
-
'<meta name="apple-mobile-web-app-capable" content="yes">',
|
|
204
|
-
'<meta name="apple-mobile-web-app-title" content="dsh">',
|
|
205
|
-
'<link rel="manifest" href="/m/manifest.webmanifest">',
|
|
206
|
-
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
207
|
-
'<link rel="icon" href="/m/icon.svg">',
|
|
208
|
-
'<title>dsh</title>',
|
|
209
|
-
'<link rel="stylesheet" href="/m/theme.css">',
|
|
210
|
-
'</head>',
|
|
211
|
-
'<body>',
|
|
212
|
-
'<div id="root"></div>',
|
|
213
|
-
// 在 bundle 之前注册:拦住「图片还没解码完就点发送」的一次点击,解码完成后自动重发。
|
|
214
|
-
`<script type="module" src="/m/mobile.js?v=${bundleVersion()}"></script>`,
|
|
215
|
-
VIEWPORT_VH_HTML,
|
|
216
|
-
// 聊天/待发送图片的全屏预览(捕获阶段委托,抢在 bundle 自带 lightbox 之前)。
|
|
217
|
-
'</body>',
|
|
218
|
-
'</html>',
|
|
219
|
-
].join('')
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function pushCheckPageHtml() {
|
|
223
|
-
const pageJs = `
|
|
224
|
-
(function () {
|
|
225
|
-
var $ = function (id) { return document.getElementById(id) }
|
|
226
|
-
|
|
227
|
-
function mark(el, state) {
|
|
228
|
-
// state: 'ok' | 'no' | 'warn'
|
|
229
|
-
el.classList.remove('pc-ok', 'pc-no', 'pc-warn')
|
|
230
|
-
el.classList.add('pc-' + state)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function icon(state) {
|
|
234
|
-
return state === 'ok' ? '✅' : state === 'no' ? '❌' : '⚠️'
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function setRow(id, state, valueText) {
|
|
238
|
-
var row = $(id)
|
|
239
|
-
if (!row) return
|
|
240
|
-
var cell = row.querySelector('.pc-icon')
|
|
241
|
-
var val = row.querySelector('.pc-value')
|
|
242
|
-
if (cell) { cell.textContent = icon(state); mark(cell, state) }
|
|
243
|
-
if (val) {
|
|
244
|
-
val.textContent = valueText
|
|
245
|
-
val.classList.remove('pc-ok', 'pc-no', 'pc-warn')
|
|
246
|
-
val.classList.add('pc-' + state)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function addNote(id, text) {
|
|
251
|
-
var row = $(id)
|
|
252
|
-
if (!row) return
|
|
253
|
-
var p = document.createElement('p')
|
|
254
|
-
p.className = 'pc-note'
|
|
255
|
-
p.textContent = text
|
|
256
|
-
row.appendChild(p)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
var results = {}
|
|
260
|
-
|
|
261
|
-
// ── 1. 安全上下文 ────────────────────────────────────────────────────
|
|
262
|
-
var secure = window.isSecureContext === true
|
|
263
|
-
results.secure = secure
|
|
264
|
-
setRow('row-secure', secure ? 'ok' : 'no', secure ? 'true(安全上下文)' : 'false(非安全上下文)')
|
|
265
|
-
if (!secure) {
|
|
266
|
-
addNote('row-secure', '这是协议硬性要求:非 HTTPS(且非 localhost)时浏览器根本不暴露 Push API,Web Push 必定不可用。请改用 https 地址打开本页。')
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ── 2. 访问协议 / 地址 ───────────────────────────────────────────────
|
|
270
|
-
setRow('row-proto',
|
|
271
|
-
location.protocol === 'https:' ? 'ok' : 'no',
|
|
272
|
-
location.protocol.replace(':', '').toUpperCase())
|
|
273
|
-
$('pc-origin').textContent = location.origin || location.href
|
|
274
|
-
|
|
275
|
-
// ── 3. Service Worker ────────────────────────────────────────────────
|
|
276
|
-
var hasSw = 'serviceWorker' in navigator
|
|
277
|
-
results.sw = hasSw
|
|
278
|
-
setRow('row-sw', hasSw ? 'ok' : 'no', hasSw ? '存在(navigator.serviceWorker)' : '不存在')
|
|
279
|
-
|
|
280
|
-
// ── 4. PushManager ───────────────────────────────────────────────────
|
|
281
|
-
var hasPush = 'PushManager' in window
|
|
282
|
-
results.push = hasPush
|
|
283
|
-
setRow('row-push', hasPush ? 'ok' : 'no', hasPush ? '存在(window.PushManager)' : '不存在')
|
|
284
|
-
|
|
285
|
-
// ── 5. Notification API ──────────────────────────────────────────────
|
|
286
|
-
var hasNotif = 'Notification' in window
|
|
287
|
-
results.notif = hasNotif
|
|
288
|
-
setRow('row-notif', hasNotif ? 'ok' : 'no', hasNotif ? '存在(window.Notification)' : '不存在')
|
|
289
|
-
|
|
290
|
-
// ── 6. 通知权限 ──────────────────────────────────────────────────────
|
|
291
|
-
var perm = hasNotif ? Notification.permission : 'unsupported'
|
|
292
|
-
results.perm = perm
|
|
293
|
-
var permState = perm === 'granted' ? 'ok' : perm === 'denied' ? 'no' : 'warn'
|
|
294
|
-
var permText = { granted: 'granted(已授权)', denied: 'denied(已被拒绝)', default: 'default(尚未询问)', unsupported: '不支持(无 Notification API)' }[perm] || String(perm)
|
|
295
|
-
setRow('row-perm', permState, permText)
|
|
296
|
-
if (perm === 'denied') {
|
|
297
|
-
addNote('row-perm', '权限已被拒绝:浏览器不会再弹窗,需要到浏览器「网站设置 → 通知」里手动改回允许。')
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ── 9. User-Agent ────────────────────────────────────────────────────
|
|
301
|
-
var ua = navigator.userAgent || '(无法读取)'
|
|
302
|
-
$('pc-ua').textContent = ua
|
|
303
|
-
var isQuark = /Quark/i.test(ua)
|
|
304
|
-
var chromeVer = /Chrome\\/([0-9]+)/.exec(ua)
|
|
305
|
-
setRow('row-ua', 'warn', ua)
|
|
306
|
-
addNote('row-ua',
|
|
307
|
-
(isQuark ? '识别为夸克浏览器(Quark)。' : '未识别到 Quark 字样——请确认是不是在用夸克打开。') +
|
|
308
|
-
(chromeVer ? '内核 Chrome ' + chromeVer[1] + '。' : '未识别到 Chrome 版本号。'))
|
|
309
|
-
|
|
310
|
-
// ── 7. 请求权限(按钮,用户主动触发) ────────────────────────────────
|
|
311
|
-
var permBtn = $('pc-perm-btn')
|
|
312
|
-
permBtn.addEventListener('click', function () {
|
|
313
|
-
if (!hasNotif) {
|
|
314
|
-
$('pc-perm-result').textContent = '❌ 浏览器没有 Notification API,无法请求权限。'
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
$('pc-perm-result').textContent = '⏳ 正在请求…'
|
|
318
|
-
var done = function (p) {
|
|
319
|
-
var st = p === 'granted' ? 'ok' : p === 'denied' ? 'no' : 'warn'
|
|
320
|
-
setRow('row-perm', st, '请求后:' + p)
|
|
321
|
-
$('pc-perm-result').textContent = p === 'granted'
|
|
322
|
-
? '✅ 授权成功(' + p + ')'
|
|
323
|
-
: p === 'denied'
|
|
324
|
-
? '❌ 被拒绝(' + p + '):需到浏览器网站设置里手动允许。'
|
|
325
|
-
: '⚠️ 未作出选择(' + p + ')'
|
|
326
|
-
render()
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
// 兼容两种签名:新浏览器返回 Promise,老的用回调。
|
|
330
|
-
var r = Notification.requestPermission(function (p) { done(p) })
|
|
331
|
-
if (r && typeof r.then === 'function') r.then(done, function () { done(Notification.permission) })
|
|
332
|
-
} catch (e) {
|
|
333
|
-
$('pc-perm-result').textContent = '❌ 请求权限抛出异常:' + ((e && e.message) || e)
|
|
334
|
-
}
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
// ── 8. SW 注册 + 订阅可用性 ──────────────────────────────────────────
|
|
338
|
-
var swDone = false
|
|
339
|
-
var subDone = false
|
|
340
|
-
|
|
341
|
-
function runSw() {
|
|
342
|
-
if (!hasSw) {
|
|
343
|
-
setRow('row-swreg', 'no', '跳过:无 Service Worker API')
|
|
344
|
-
setRow('row-sub', 'no', '跳过:Service Worker 不可注册')
|
|
345
|
-
return Promise.resolve()
|
|
346
|
-
}
|
|
347
|
-
setRow('row-swreg', 'warn', '正在注册 /m/service-worker.js …')
|
|
348
|
-
// scope '/m/' 与 bundle 注册时用的完全一致。
|
|
349
|
-
return navigator.serviceWorker.register('/m/service-worker.js', { scope: '/m/' })
|
|
350
|
-
.then(function (reg) {
|
|
351
|
-
swDone = true
|
|
352
|
-
setRow('row-swreg', 'ok', '注册成功(scope ' + reg.scope + ')')
|
|
353
|
-
addNote('row-swreg', '注册成功说明浏览器支持 Service Worker。')
|
|
354
|
-
return checkSub(reg)
|
|
355
|
-
})
|
|
356
|
-
.catch(function (err) {
|
|
357
|
-
var msg = (err && err.message) || String(err)
|
|
358
|
-
setRow('row-swreg', 'no', '注册失败:' + msg)
|
|
359
|
-
addNote('row-swreg', '注册失败最常见的两个原因:① 非安全上下文(http 打开);② /m/service-worker.js 这个地址返回了非 200/非 JS(当前插件还没给它配路由,所以会 404)。')
|
|
360
|
-
setRow('row-sub', 'no', '跳过:Service Worker 注册失败')
|
|
361
|
-
subDone = true
|
|
362
|
-
})
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function checkSub(reg) {
|
|
366
|
-
if (!hasPush) {
|
|
367
|
-
setRow('row-sub', 'no', '不可用:无 PushManager')
|
|
368
|
-
subDone = true
|
|
369
|
-
return Promise.resolve()
|
|
370
|
-
}
|
|
371
|
-
setRow('row-sub', 'warn', '正在读取已有订阅…')
|
|
372
|
-
// 只读:getSubscription() 不会创建新订阅、不会触发任何推送。
|
|
373
|
-
return reg.pushManager.getSubscription().then(function (sub) {
|
|
374
|
-
if (sub) {
|
|
375
|
-
setRow('row-sub', 'ok', '存在已有订阅(endpoint 可解析)')
|
|
376
|
-
addNote('row-sub', '浏览器允许读取订阅对象,说明 PushManager 基本可用。真正的可用性还要看能否成功 subscribe(),本页不会去订阅。')
|
|
377
|
-
} else {
|
|
378
|
-
setRow('row-sub', 'warn', '暂无订阅,但 pushManager 可访问(未实际订阅)')
|
|
379
|
-
addNote('row-sub', '能读到 pushManager 说明 API 存在。是否真能订阅成功、以及鸿蒙无 GMS 时能否收到 FCM 推送,仍需实测。')
|
|
380
|
-
}
|
|
381
|
-
subDone = true
|
|
382
|
-
}).catch(function (err) {
|
|
383
|
-
setRow('row-sub', 'no', '读取订阅失败:' + ((err && err.message) || err))
|
|
384
|
-
subDone = true
|
|
385
|
-
})
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── 总结论 ───────────────────────────────────────────────────────────
|
|
389
|
-
function render() {
|
|
390
|
-
var box = $('pc-verdict')
|
|
391
|
-
var title = $('pc-verdict-title')
|
|
392
|
-
var desc = $('pc-verdict-desc')
|
|
393
|
-
var ok = results.secure && results.sw && results.push && results.notif
|
|
394
|
-
var missing = []
|
|
395
|
-
if (!results.secure) missing.push('安全上下文(HTTPS)')
|
|
396
|
-
if (!results.sw) missing.push('Service Worker')
|
|
397
|
-
if (!results.push) missing.push('PushManager')
|
|
398
|
-
if (!results.notif) missing.push('Notification API')
|
|
399
|
-
|
|
400
|
-
box.classList.remove('pc-v-ok', 'pc-v-no', 'pc-v-warn')
|
|
401
|
-
if (missing.length > 0) {
|
|
402
|
-
box.classList.add('pc-v-no')
|
|
403
|
-
title.textContent = '❌ 结论:Web Push 不可用'
|
|
404
|
-
desc.textContent = '缺失:' + missing.join('、') + '。浏览器通知这条路走不通,需要改用第三方渠道(如 Bark / WxPusher)。'
|
|
405
|
-
return
|
|
406
|
-
}
|
|
407
|
-
if (swDone && subDone) {
|
|
408
|
-
box.classList.add('pc-v-ok')
|
|
409
|
-
title.textContent = '✅ 结论:前置条件都满足'
|
|
410
|
-
desc.textContent = 'HTTPS + Service Worker + PushManager 三者齐备。请注意:这只能说明浏览器「可能」支持,鸿蒙无 GMS 时真正的 FCM 推送仍可能收不到,需要一次真实订阅+发送测试才能确认。'
|
|
411
|
-
return
|
|
412
|
-
}
|
|
413
|
-
box.classList.add('pc-v-warn')
|
|
414
|
-
title.textContent = '⚠️ 结论:API 存在,但 Service Worker 尚未验证成功'
|
|
415
|
-
desc.textContent = '浏览器有 PushManager,但 SW 注册/订阅读取还没成功完成(见上面两项)。在 SW 真正注册成功之前,Web Push 无法工作。'
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
render()
|
|
419
|
-
runSw().then(function () { render() })
|
|
420
|
-
|
|
421
|
-
// 重新检测:整页刷新最干净(SW 注册状态不会被残留状态干扰)。
|
|
422
|
-
$('pc-reload').addEventListener('click', function () { location.reload() })
|
|
423
|
-
})();
|
|
424
|
-
`
|
|
425
|
-
|
|
426
|
-
return [
|
|
427
|
-
'<!doctype html>',
|
|
428
|
-
'<html lang="zh-CN">',
|
|
429
|
-
'<head>',
|
|
430
|
-
'<meta charset="utf-8">',
|
|
431
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">',
|
|
432
|
-
'<meta name="referrer" content="no-referrer">',
|
|
433
|
-
'<meta name="theme-color" content="#f5f5f7">',
|
|
434
|
-
'<title>浏览器推送能力自检</title>',
|
|
435
|
-
'<link rel="stylesheet" href="/m/theme.css">',
|
|
436
|
-
'</head>',
|
|
437
|
-
'<body class="pc-body">',
|
|
438
|
-
'<main class="pc-page">',
|
|
439
|
-
'<h1 class="pc-title">浏览器推送能力自检</h1>',
|
|
440
|
-
|
|
441
|
-
'<div class="pc-banner">',
|
|
442
|
-
'<p class="pc-banner-title">⚠️ Web Push 需要三样东西,缺一不可</p>',
|
|
443
|
-
'<p class="pc-banner-body"><b>HTTPS</b>(安全上下文) + <b>Service Worker</b> + <b>PushManager</b>。<br>',
|
|
444
|
-
'通过 <b>http</b> 访问时必定不可用——这是浏览器协议的硬性要求,不是配置问题。</p>',
|
|
445
|
-
'</div>',
|
|
446
|
-
|
|
447
|
-
'<section class="pc-card">',
|
|
448
|
-
'<h2 class="pc-h2">访问地址</h2>',
|
|
449
|
-
'<p class="pc-kv">当前地址:<code id="pc-origin"></code></p>',
|
|
450
|
-
'<p class="pc-hint">若上面是 <code>http://</code> 开头,请改用 https 的公网地址重新打开本页:</p>',
|
|
451
|
-
'<ul class="pc-urls">',
|
|
452
|
-
'<li><span class="pc-tag pc-tag-no">http</span> <code>http://192.168.1.10:19387/m/push-check</code> — 局域网,必定不可用(仅用于对照)</li>',
|
|
453
|
-
'<li><span class="pc-tag pc-tag-ok">https</span> <code>https://your-host.example.com:45632/m/push-check</code> — 公网反代,<b>用这个测</b></li>',
|
|
454
|
-
'</ul>',
|
|
455
|
-
'</section>',
|
|
456
|
-
|
|
457
|
-
'<section class="pc-card">',
|
|
458
|
-
'<h2 class="pc-h2">逐项检测</h2>',
|
|
459
|
-
'<ul class="pc-list">',
|
|
460
|
-
'<li class="pc-row" id="row-secure"><span class="pc-icon"></span><span class="pc-label">安全上下文 <i>window.isSecureContext</i></span><span class="pc-value"></span></li>',
|
|
461
|
-
'<li class="pc-row" id="row-proto"><span class="pc-icon"></span><span class="pc-label">访问协议 <i>location.protocol</i></span><span class="pc-value"></span></li>',
|
|
462
|
-
'<li class="pc-row" id="row-sw"><span class="pc-icon"></span><span class="pc-label">Service Worker <i>navigator.serviceWorker</i></span><span class="pc-value"></span></li>',
|
|
463
|
-
'<li class="pc-row" id="row-push"><span class="pc-icon"></span><span class="pc-label">PushManager <i>window.PushManager</i></span><span class="pc-value"></span></li>',
|
|
464
|
-
'<li class="pc-row" id="row-notif"><span class="pc-icon"></span><span class="pc-label">Notification API <i>window.Notification</i></span><span class="pc-value"></span></li>',
|
|
465
|
-
'<li class="pc-row" id="row-perm"><span class="pc-icon"></span><span class="pc-label">通知权限 <i>Notification.permission</i></span><span class="pc-value"></span></li>',
|
|
466
|
-
'<li class="pc-row" id="row-swreg"><span class="pc-icon"></span><span class="pc-label">SW 注册 <i>register(\'/m/service-worker.js\')</i></span><span class="pc-value"></span></li>',
|
|
467
|
-
'<li class="pc-row" id="row-sub"><span class="pc-icon"></span><span class="pc-label">订阅可用性 <i>getSubscription()</i>(只读)</span><span class="pc-value"></span></li>',
|
|
468
|
-
'<li class="pc-row" id="row-ua"><span class="pc-icon"></span><span class="pc-label">User-Agent</span><span class="pc-value"></span></li>',
|
|
469
|
-
'</ul>',
|
|
470
|
-
'</section>',
|
|
471
|
-
|
|
472
|
-
'<section class="pc-card">',
|
|
473
|
-
'<h2 class="pc-h2">请求通知权限</h2>',
|
|
474
|
-
'<p class="pc-hint">点下面的按钮会调用 <code>Notification.requestPermission()</code>,由浏览器弹窗询问。本页不会真的订阅、不会配对设备。</p>',
|
|
475
|
-
'<button type="button" class="pc-btn" id="pc-perm-btn">请求通知权限</button>',
|
|
476
|
-
'<p class="pc-result" id="pc-perm-result"></p>',
|
|
477
|
-
'</section>',
|
|
478
|
-
|
|
479
|
-
'<section class="pc-card pc-verdict" id="pc-verdict">',
|
|
480
|
-
'<p class="pc-verdict-title" id="pc-verdict-title">检测中…</p>',
|
|
481
|
-
'<p class="pc-verdict-desc" id="pc-verdict-desc"></p>',
|
|
482
|
-
'</section>',
|
|
483
|
-
|
|
484
|
-
'<section class="pc-card">',
|
|
485
|
-
'<h2 class="pc-h2">User-Agent 全文</h2>',
|
|
486
|
-
'<p class="pc-ua" id="pc-ua"></p>',
|
|
487
|
-
'<p class="pc-hint">用来确认是不是夸克浏览器,以及它的 Chromium 内核版本。</p>',
|
|
488
|
-
'</section>',
|
|
489
|
-
|
|
490
|
-
'<button type="button" class="pc-btn pc-btn-ghost" id="pc-reload">重新检测</button>',
|
|
491
|
-
'<p class="pc-foot">本页只读:不配对、不写设备、不创建订阅。</p>',
|
|
492
|
-
'</main>',
|
|
493
|
-
'<script>' + pageJs + '</script>',
|
|
494
|
-
'</body>',
|
|
495
|
-
'</html>',
|
|
496
|
-
].join('')
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
export function manifestJson() {
|
|
500
|
-
return JSON.stringify({
|
|
501
|
-
id: MOBILE_ROOT,
|
|
502
|
-
name: 'dsh 掌上助手',
|
|
503
|
-
short_name: 'dsh',
|
|
504
|
-
start_url: MOBILE_ROOT,
|
|
505
|
-
scope: MOBILE_ROOT,
|
|
506
|
-
display: 'standalone',
|
|
507
|
-
background_color: '#151424',
|
|
508
|
-
theme_color: '#f3f5f9',
|
|
509
|
-
icons: [
|
|
510
|
-
{ src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
511
|
-
{ src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
|
512
|
-
],
|
|
513
|
-
}, null, 2)
|
|
514
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 页面与注入脚本:/m/ 的 HTML、推送自检页、PWA manifest,以及必须在 bundle
|
|
3
|
+
* 之前执行的三个内联脚本(在线守卫、视口高度、变更总线)。
|
|
4
|
+
* 纯搬自 core.js 顶层,无闭包依赖。
|
|
5
|
+
*/
|
|
6
|
+
import { bundleVersion } from './assets.js'
|
|
7
|
+
import { MOBILE_ROOT } from './config.js'
|
|
8
|
+
|
|
9
|
+
export const ONLINE_GUARD_HTML = `<script>
|
|
10
|
+
(function () {
|
|
11
|
+
/* dm-online-guard: Android/HarmonyOS WebViews may misreport navigator.onLine === false */
|
|
12
|
+
try {
|
|
13
|
+
Object.defineProperty(navigator, 'onLine', {
|
|
14
|
+
get: function () { return true; },
|
|
15
|
+
configurable: true
|
|
16
|
+
});
|
|
17
|
+
} catch (e) {}
|
|
18
|
+
})();
|
|
19
|
+
</script>`
|
|
20
|
+
|
|
21
|
+
export const VIEWPORT_VH_HTML = `<script>
|
|
22
|
+
(function () {
|
|
23
|
+
var KEY = '--dm-vh';
|
|
24
|
+
var THRESHOLD = 2; // px:小于这个变化量就不写,专治键盘/地址栏动画的抖动
|
|
25
|
+
var last = -1;
|
|
26
|
+
var frame = 0;
|
|
27
|
+
|
|
28
|
+
function measure() {
|
|
29
|
+
var vv = window.visualViewport;
|
|
30
|
+
var h;
|
|
31
|
+
if (vv && typeof vv.height === 'number' && vv.height > 0) {
|
|
32
|
+
// visualViewport.height = **当前可见**高度(已扣掉地址栏/键盘),单位是
|
|
33
|
+
// CSS px。双指放大时它会变小(scale=2 → 只看得到一半的 CSS px),
|
|
34
|
+
// 乘回 scale 还原成「布局视口坐标系」下的可见高度,避免缩放把应用缩小。
|
|
35
|
+
var s = typeof vv.scale === 'number' && vv.scale > 0 ? vv.scale : 1;
|
|
36
|
+
h = vv.height * s;
|
|
37
|
+
} else {
|
|
38
|
+
// 老 WebView 没有 visualViewport:innerHeight 就是可见高度
|
|
39
|
+
// (100vh 之所以偏大,是因为它取的是大视口)。
|
|
40
|
+
h = window.innerHeight;
|
|
41
|
+
}
|
|
42
|
+
// 上限兜底:永远不超过布局视口高度。万一上面算出偏大的值,也不会把文档
|
|
43
|
+
// 撑出滚动条 —— 这是「兜底的兜底」,正常情况下不生效。
|
|
44
|
+
var cap = document.documentElement.clientHeight;
|
|
45
|
+
if (typeof cap === 'number' && cap > 0 && h > cap) h = cap;
|
|
46
|
+
return Math.round(h);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function apply() {
|
|
50
|
+
frame = 0;
|
|
51
|
+
var h = measure();
|
|
52
|
+
if (h <= 0) return;
|
|
53
|
+
if (last >= 0 && Math.abs(h - last) < THRESHOLD) return; // 不显著 → 不写、不重排
|
|
54
|
+
last = h;
|
|
55
|
+
document.documentElement.style.setProperty(KEY, h + 'px');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 一帧内的多次事件合并成一次写入。
|
|
59
|
+
function schedule() {
|
|
60
|
+
if (frame) return;
|
|
61
|
+
frame = (window.requestAnimationFrame || function (fn) { return window.setTimeout(fn, 16); })(apply);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
apply(); // 首帧:必须在 bundle 的 #root 渲染前就把值写好
|
|
65
|
+
|
|
66
|
+
window.addEventListener('resize', schedule, { passive: true });
|
|
67
|
+
window.addEventListener('orientationchange', schedule, { passive: true });
|
|
68
|
+
if (window.visualViewport) {
|
|
69
|
+
// 地址栏收展、软键盘弹出都走这里(部分 WebView 上 window.resize 不触发)。
|
|
70
|
+
window.visualViewport.addEventListener('resize', schedule, { passive: true });
|
|
71
|
+
window.visualViewport.addEventListener('scroll', schedule, { passive: true });
|
|
72
|
+
}
|
|
73
|
+
// 兜底:极老 WebView 上 visualViewport 缺失、resize 也不触发时,
|
|
74
|
+
// 低频轮询(1s)仍能跟上地址栏收展;阈值保证它平时不产生任何写入。
|
|
75
|
+
window.setInterval(schedule, 1000);
|
|
76
|
+
})();
|
|
77
|
+
</script>`
|
|
78
|
+
|
|
79
|
+
export const MUTATION_BUS_HTML = `<script>
|
|
80
|
+
(function () {
|
|
81
|
+
if (window.__dmMutationBus) return;
|
|
82
|
+
var frameListeners = [];
|
|
83
|
+
var normalListeners = [];
|
|
84
|
+
var attrListeners = [];
|
|
85
|
+
var frameQueued = false;
|
|
86
|
+
var normalTimer = 0;
|
|
87
|
+
var attrTimer = 0;
|
|
88
|
+
function notify(list) {
|
|
89
|
+
var snapshot = list.slice();
|
|
90
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
91
|
+
try { snapshot[i](); } catch (e) {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function scheduleFrame() {
|
|
95
|
+
if (frameQueued) return;
|
|
96
|
+
frameQueued = true;
|
|
97
|
+
if (window.requestAnimationFrame) window.requestAnimationFrame(function () { frameQueued = false; notify(frameListeners); });
|
|
98
|
+
else window.setTimeout(function () { frameQueued = false; notify(frameListeners); }, 0);
|
|
99
|
+
}
|
|
100
|
+
function scheduleNormal() {
|
|
101
|
+
if (normalTimer !== 0) return;
|
|
102
|
+
normalTimer = window.setTimeout(function () { normalTimer = 0; notify(normalListeners); }, 600);
|
|
103
|
+
}
|
|
104
|
+
function scheduleAttr() {
|
|
105
|
+
if (attrTimer !== 0) return;
|
|
106
|
+
attrTimer = window.setTimeout(function () { attrTimer = 0; notify(attrListeners); }, 400);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
var structural = new MutationObserver(function () {
|
|
110
|
+
if (frameListeners.length > 0) scheduleFrame();
|
|
111
|
+
if (normalListeners.length > 0) scheduleNormal();
|
|
112
|
+
});
|
|
113
|
+
structural.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
114
|
+
var attributes = new MutationObserver(function () {
|
|
115
|
+
if (attrListeners.length > 0) scheduleAttr();
|
|
116
|
+
});
|
|
117
|
+
attributes.observe(document.documentElement, {
|
|
118
|
+
attributes: true,
|
|
119
|
+
subtree: true,
|
|
120
|
+
attributeFilter: ['class', 'aria-label', 'title', 'aria-hidden', 'style', 'hidden']
|
|
121
|
+
});
|
|
122
|
+
} catch (e) { return; }
|
|
123
|
+
|
|
124
|
+
// ── 统一 ensure tick:120ms 一条表,任务按各自 period 到期才跑;全都不 ready 就停表。
|
|
125
|
+
var tasks = [];
|
|
126
|
+
var tickTimer = 0;
|
|
127
|
+
var idleTicks = 0;
|
|
128
|
+
function tick() {
|
|
129
|
+
var now = Date.now();
|
|
130
|
+
var ran = false;
|
|
131
|
+
for (var i = 0; i < tasks.length; i++) {
|
|
132
|
+
var task = tasks[i];
|
|
133
|
+
if (now < task.next) continue;
|
|
134
|
+
if (task.ready && !task.ready()) continue;
|
|
135
|
+
task.next = now + task.period;
|
|
136
|
+
ran = true;
|
|
137
|
+
try { task.fn(); } catch (e) {}
|
|
138
|
+
}
|
|
139
|
+
if (ran) { idleTicks = 0; return; }
|
|
140
|
+
idleTicks++;
|
|
141
|
+
if (idleTicks >= 3) {
|
|
142
|
+
idleTicks = 0;
|
|
143
|
+
window.clearInterval(tickTimer);
|
|
144
|
+
tickTimer = 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function startTick() {
|
|
148
|
+
if (tickTimer !== 0) return;
|
|
149
|
+
idleTicks = 0;
|
|
150
|
+
tickTimer = window.setInterval(tick, 120);
|
|
151
|
+
}
|
|
152
|
+
var ensureTick = {
|
|
153
|
+
add: function (fn, period, ready) {
|
|
154
|
+
tasks.push({ fn: fn, period: typeof period === 'number' && period > 0 ? period : 120, ready: typeof ready === 'function' ? ready : null, next: 0 });
|
|
155
|
+
startTick();
|
|
156
|
+
},
|
|
157
|
+
// 唤醒时立刻跑一次 tick:否则要等下一个 120ms 周期,进页面时控件会滞后。
|
|
158
|
+
wake: function () {
|
|
159
|
+
startTick();
|
|
160
|
+
try { tick(); } catch (e) {}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
normalListeners.push(ensureTick.wake);
|
|
164
|
+
|
|
165
|
+
// 进聊天页时 composer 会重新挂载。那一刻立刻唤醒一次 ensure tick,否则要等 600ms 切片
|
|
166
|
+
// + 各任务 800ms 周期,用户会看到「工具栏图标后来才蹦出来」。
|
|
167
|
+
// 只在 composer 元素身份变化(挂载/卸载)时唤醒一次,不挂全帧通道 —— 我们的控件写入
|
|
168
|
+
// 会再次触发总线,全帧唤醒会形成「写入→通知→再写入」的循环。
|
|
169
|
+
var composerSeen = null;
|
|
170
|
+
frameListeners.push(function () {
|
|
171
|
+
var c = document.querySelector('.chat-composer');
|
|
172
|
+
if (c === composerSeen) return;
|
|
173
|
+
composerSeen = c;
|
|
174
|
+
if (c) ensureTick.wake();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
window.__dmMutationBus = {
|
|
178
|
+
subscribe: function (listener, options) {
|
|
179
|
+
if (typeof listener !== 'function') return;
|
|
180
|
+
if (options && options.frame === true) frameListeners.push(listener);
|
|
181
|
+
else if (options && options.attributes === true) attrListeners.push(listener);
|
|
182
|
+
else normalListeners.push(listener);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
window.__dmEnsureTick = ensureTick;
|
|
186
|
+
startTick();
|
|
187
|
+
})();
|
|
188
|
+
</script>`
|
|
189
|
+
|
|
190
|
+
export function mobilePageHtml() {
|
|
191
|
+
return [
|
|
192
|
+
'<!doctype html>',
|
|
193
|
+
'<html lang="zh-CN">',
|
|
194
|
+
'<head>',
|
|
195
|
+
'<meta charset="utf-8">',
|
|
196
|
+
// 必须最早:先把 navigator.onLine 的误报按住,bundle 才不会在发送时丢掉图片。
|
|
197
|
+
ONLINE_GUARD_HTML,
|
|
198
|
+
MUTATION_BUS_HTML,
|
|
199
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">',
|
|
200
|
+
'<meta name="theme-color" content="#f5f5f7">',
|
|
201
|
+
'<meta name="referrer" content="no-referrer">',
|
|
202
|
+
'<meta name="mobile-web-app-capable" content="yes">',
|
|
203
|
+
'<meta name="apple-mobile-web-app-capable" content="yes">',
|
|
204
|
+
'<meta name="apple-mobile-web-app-title" content="dsh">',
|
|
205
|
+
'<link rel="manifest" href="/m/manifest.webmanifest">',
|
|
206
|
+
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
207
|
+
'<link rel="icon" href="/m/icon.svg">',
|
|
208
|
+
'<title>dsh</title>',
|
|
209
|
+
'<link rel="stylesheet" href="/m/theme.css">',
|
|
210
|
+
'</head>',
|
|
211
|
+
'<body>',
|
|
212
|
+
'<div id="root"></div>',
|
|
213
|
+
// 在 bundle 之前注册:拦住「图片还没解码完就点发送」的一次点击,解码完成后自动重发。
|
|
214
|
+
`<script type="module" src="/m/mobile.js?v=${bundleVersion()}"></script>`,
|
|
215
|
+
VIEWPORT_VH_HTML,
|
|
216
|
+
// 聊天/待发送图片的全屏预览(捕获阶段委托,抢在 bundle 自带 lightbox 之前)。
|
|
217
|
+
'</body>',
|
|
218
|
+
'</html>',
|
|
219
|
+
].join('')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function pushCheckPageHtml() {
|
|
223
|
+
const pageJs = `
|
|
224
|
+
(function () {
|
|
225
|
+
var $ = function (id) { return document.getElementById(id) }
|
|
226
|
+
|
|
227
|
+
function mark(el, state) {
|
|
228
|
+
// state: 'ok' | 'no' | 'warn'
|
|
229
|
+
el.classList.remove('pc-ok', 'pc-no', 'pc-warn')
|
|
230
|
+
el.classList.add('pc-' + state)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function icon(state) {
|
|
234
|
+
return state === 'ok' ? '✅' : state === 'no' ? '❌' : '⚠️'
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function setRow(id, state, valueText) {
|
|
238
|
+
var row = $(id)
|
|
239
|
+
if (!row) return
|
|
240
|
+
var cell = row.querySelector('.pc-icon')
|
|
241
|
+
var val = row.querySelector('.pc-value')
|
|
242
|
+
if (cell) { cell.textContent = icon(state); mark(cell, state) }
|
|
243
|
+
if (val) {
|
|
244
|
+
val.textContent = valueText
|
|
245
|
+
val.classList.remove('pc-ok', 'pc-no', 'pc-warn')
|
|
246
|
+
val.classList.add('pc-' + state)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function addNote(id, text) {
|
|
251
|
+
var row = $(id)
|
|
252
|
+
if (!row) return
|
|
253
|
+
var p = document.createElement('p')
|
|
254
|
+
p.className = 'pc-note'
|
|
255
|
+
p.textContent = text
|
|
256
|
+
row.appendChild(p)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
var results = {}
|
|
260
|
+
|
|
261
|
+
// ── 1. 安全上下文 ────────────────────────────────────────────────────
|
|
262
|
+
var secure = window.isSecureContext === true
|
|
263
|
+
results.secure = secure
|
|
264
|
+
setRow('row-secure', secure ? 'ok' : 'no', secure ? 'true(安全上下文)' : 'false(非安全上下文)')
|
|
265
|
+
if (!secure) {
|
|
266
|
+
addNote('row-secure', '这是协议硬性要求:非 HTTPS(且非 localhost)时浏览器根本不暴露 Push API,Web Push 必定不可用。请改用 https 地址打开本页。')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── 2. 访问协议 / 地址 ───────────────────────────────────────────────
|
|
270
|
+
setRow('row-proto',
|
|
271
|
+
location.protocol === 'https:' ? 'ok' : 'no',
|
|
272
|
+
location.protocol.replace(':', '').toUpperCase())
|
|
273
|
+
$('pc-origin').textContent = location.origin || location.href
|
|
274
|
+
|
|
275
|
+
// ── 3. Service Worker ────────────────────────────────────────────────
|
|
276
|
+
var hasSw = 'serviceWorker' in navigator
|
|
277
|
+
results.sw = hasSw
|
|
278
|
+
setRow('row-sw', hasSw ? 'ok' : 'no', hasSw ? '存在(navigator.serviceWorker)' : '不存在')
|
|
279
|
+
|
|
280
|
+
// ── 4. PushManager ───────────────────────────────────────────────────
|
|
281
|
+
var hasPush = 'PushManager' in window
|
|
282
|
+
results.push = hasPush
|
|
283
|
+
setRow('row-push', hasPush ? 'ok' : 'no', hasPush ? '存在(window.PushManager)' : '不存在')
|
|
284
|
+
|
|
285
|
+
// ── 5. Notification API ──────────────────────────────────────────────
|
|
286
|
+
var hasNotif = 'Notification' in window
|
|
287
|
+
results.notif = hasNotif
|
|
288
|
+
setRow('row-notif', hasNotif ? 'ok' : 'no', hasNotif ? '存在(window.Notification)' : '不存在')
|
|
289
|
+
|
|
290
|
+
// ── 6. 通知权限 ──────────────────────────────────────────────────────
|
|
291
|
+
var perm = hasNotif ? Notification.permission : 'unsupported'
|
|
292
|
+
results.perm = perm
|
|
293
|
+
var permState = perm === 'granted' ? 'ok' : perm === 'denied' ? 'no' : 'warn'
|
|
294
|
+
var permText = { granted: 'granted(已授权)', denied: 'denied(已被拒绝)', default: 'default(尚未询问)', unsupported: '不支持(无 Notification API)' }[perm] || String(perm)
|
|
295
|
+
setRow('row-perm', permState, permText)
|
|
296
|
+
if (perm === 'denied') {
|
|
297
|
+
addNote('row-perm', '权限已被拒绝:浏览器不会再弹窗,需要到浏览器「网站设置 → 通知」里手动改回允许。')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── 9. User-Agent ────────────────────────────────────────────────────
|
|
301
|
+
var ua = navigator.userAgent || '(无法读取)'
|
|
302
|
+
$('pc-ua').textContent = ua
|
|
303
|
+
var isQuark = /Quark/i.test(ua)
|
|
304
|
+
var chromeVer = /Chrome\\/([0-9]+)/.exec(ua)
|
|
305
|
+
setRow('row-ua', 'warn', ua)
|
|
306
|
+
addNote('row-ua',
|
|
307
|
+
(isQuark ? '识别为夸克浏览器(Quark)。' : '未识别到 Quark 字样——请确认是不是在用夸克打开。') +
|
|
308
|
+
(chromeVer ? '内核 Chrome ' + chromeVer[1] + '。' : '未识别到 Chrome 版本号。'))
|
|
309
|
+
|
|
310
|
+
// ── 7. 请求权限(按钮,用户主动触发) ────────────────────────────────
|
|
311
|
+
var permBtn = $('pc-perm-btn')
|
|
312
|
+
permBtn.addEventListener('click', function () {
|
|
313
|
+
if (!hasNotif) {
|
|
314
|
+
$('pc-perm-result').textContent = '❌ 浏览器没有 Notification API,无法请求权限。'
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
$('pc-perm-result').textContent = '⏳ 正在请求…'
|
|
318
|
+
var done = function (p) {
|
|
319
|
+
var st = p === 'granted' ? 'ok' : p === 'denied' ? 'no' : 'warn'
|
|
320
|
+
setRow('row-perm', st, '请求后:' + p)
|
|
321
|
+
$('pc-perm-result').textContent = p === 'granted'
|
|
322
|
+
? '✅ 授权成功(' + p + ')'
|
|
323
|
+
: p === 'denied'
|
|
324
|
+
? '❌ 被拒绝(' + p + '):需到浏览器网站设置里手动允许。'
|
|
325
|
+
: '⚠️ 未作出选择(' + p + ')'
|
|
326
|
+
render()
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
// 兼容两种签名:新浏览器返回 Promise,老的用回调。
|
|
330
|
+
var r = Notification.requestPermission(function (p) { done(p) })
|
|
331
|
+
if (r && typeof r.then === 'function') r.then(done, function () { done(Notification.permission) })
|
|
332
|
+
} catch (e) {
|
|
333
|
+
$('pc-perm-result').textContent = '❌ 请求权限抛出异常:' + ((e && e.message) || e)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// ── 8. SW 注册 + 订阅可用性 ──────────────────────────────────────────
|
|
338
|
+
var swDone = false
|
|
339
|
+
var subDone = false
|
|
340
|
+
|
|
341
|
+
function runSw() {
|
|
342
|
+
if (!hasSw) {
|
|
343
|
+
setRow('row-swreg', 'no', '跳过:无 Service Worker API')
|
|
344
|
+
setRow('row-sub', 'no', '跳过:Service Worker 不可注册')
|
|
345
|
+
return Promise.resolve()
|
|
346
|
+
}
|
|
347
|
+
setRow('row-swreg', 'warn', '正在注册 /m/service-worker.js …')
|
|
348
|
+
// scope '/m/' 与 bundle 注册时用的完全一致。
|
|
349
|
+
return navigator.serviceWorker.register('/m/service-worker.js', { scope: '/m/' })
|
|
350
|
+
.then(function (reg) {
|
|
351
|
+
swDone = true
|
|
352
|
+
setRow('row-swreg', 'ok', '注册成功(scope ' + reg.scope + ')')
|
|
353
|
+
addNote('row-swreg', '注册成功说明浏览器支持 Service Worker。')
|
|
354
|
+
return checkSub(reg)
|
|
355
|
+
})
|
|
356
|
+
.catch(function (err) {
|
|
357
|
+
var msg = (err && err.message) || String(err)
|
|
358
|
+
setRow('row-swreg', 'no', '注册失败:' + msg)
|
|
359
|
+
addNote('row-swreg', '注册失败最常见的两个原因:① 非安全上下文(http 打开);② /m/service-worker.js 这个地址返回了非 200/非 JS(当前插件还没给它配路由,所以会 404)。')
|
|
360
|
+
setRow('row-sub', 'no', '跳过:Service Worker 注册失败')
|
|
361
|
+
subDone = true
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function checkSub(reg) {
|
|
366
|
+
if (!hasPush) {
|
|
367
|
+
setRow('row-sub', 'no', '不可用:无 PushManager')
|
|
368
|
+
subDone = true
|
|
369
|
+
return Promise.resolve()
|
|
370
|
+
}
|
|
371
|
+
setRow('row-sub', 'warn', '正在读取已有订阅…')
|
|
372
|
+
// 只读:getSubscription() 不会创建新订阅、不会触发任何推送。
|
|
373
|
+
return reg.pushManager.getSubscription().then(function (sub) {
|
|
374
|
+
if (sub) {
|
|
375
|
+
setRow('row-sub', 'ok', '存在已有订阅(endpoint 可解析)')
|
|
376
|
+
addNote('row-sub', '浏览器允许读取订阅对象,说明 PushManager 基本可用。真正的可用性还要看能否成功 subscribe(),本页不会去订阅。')
|
|
377
|
+
} else {
|
|
378
|
+
setRow('row-sub', 'warn', '暂无订阅,但 pushManager 可访问(未实际订阅)')
|
|
379
|
+
addNote('row-sub', '能读到 pushManager 说明 API 存在。是否真能订阅成功、以及鸿蒙无 GMS 时能否收到 FCM 推送,仍需实测。')
|
|
380
|
+
}
|
|
381
|
+
subDone = true
|
|
382
|
+
}).catch(function (err) {
|
|
383
|
+
setRow('row-sub', 'no', '读取订阅失败:' + ((err && err.message) || err))
|
|
384
|
+
subDone = true
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── 总结论 ───────────────────────────────────────────────────────────
|
|
389
|
+
function render() {
|
|
390
|
+
var box = $('pc-verdict')
|
|
391
|
+
var title = $('pc-verdict-title')
|
|
392
|
+
var desc = $('pc-verdict-desc')
|
|
393
|
+
var ok = results.secure && results.sw && results.push && results.notif
|
|
394
|
+
var missing = []
|
|
395
|
+
if (!results.secure) missing.push('安全上下文(HTTPS)')
|
|
396
|
+
if (!results.sw) missing.push('Service Worker')
|
|
397
|
+
if (!results.push) missing.push('PushManager')
|
|
398
|
+
if (!results.notif) missing.push('Notification API')
|
|
399
|
+
|
|
400
|
+
box.classList.remove('pc-v-ok', 'pc-v-no', 'pc-v-warn')
|
|
401
|
+
if (missing.length > 0) {
|
|
402
|
+
box.classList.add('pc-v-no')
|
|
403
|
+
title.textContent = '❌ 结论:Web Push 不可用'
|
|
404
|
+
desc.textContent = '缺失:' + missing.join('、') + '。浏览器通知这条路走不通,需要改用第三方渠道(如 Bark / WxPusher)。'
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (swDone && subDone) {
|
|
408
|
+
box.classList.add('pc-v-ok')
|
|
409
|
+
title.textContent = '✅ 结论:前置条件都满足'
|
|
410
|
+
desc.textContent = 'HTTPS + Service Worker + PushManager 三者齐备。请注意:这只能说明浏览器「可能」支持,鸿蒙无 GMS 时真正的 FCM 推送仍可能收不到,需要一次真实订阅+发送测试才能确认。'
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
box.classList.add('pc-v-warn')
|
|
414
|
+
title.textContent = '⚠️ 结论:API 存在,但 Service Worker 尚未验证成功'
|
|
415
|
+
desc.textContent = '浏览器有 PushManager,但 SW 注册/订阅读取还没成功完成(见上面两项)。在 SW 真正注册成功之前,Web Push 无法工作。'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
render()
|
|
419
|
+
runSw().then(function () { render() })
|
|
420
|
+
|
|
421
|
+
// 重新检测:整页刷新最干净(SW 注册状态不会被残留状态干扰)。
|
|
422
|
+
$('pc-reload').addEventListener('click', function () { location.reload() })
|
|
423
|
+
})();
|
|
424
|
+
`
|
|
425
|
+
|
|
426
|
+
return [
|
|
427
|
+
'<!doctype html>',
|
|
428
|
+
'<html lang="zh-CN">',
|
|
429
|
+
'<head>',
|
|
430
|
+
'<meta charset="utf-8">',
|
|
431
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">',
|
|
432
|
+
'<meta name="referrer" content="no-referrer">',
|
|
433
|
+
'<meta name="theme-color" content="#f5f5f7">',
|
|
434
|
+
'<title>浏览器推送能力自检</title>',
|
|
435
|
+
'<link rel="stylesheet" href="/m/theme.css">',
|
|
436
|
+
'</head>',
|
|
437
|
+
'<body class="pc-body">',
|
|
438
|
+
'<main class="pc-page">',
|
|
439
|
+
'<h1 class="pc-title">浏览器推送能力自检</h1>',
|
|
440
|
+
|
|
441
|
+
'<div class="pc-banner">',
|
|
442
|
+
'<p class="pc-banner-title">⚠️ Web Push 需要三样东西,缺一不可</p>',
|
|
443
|
+
'<p class="pc-banner-body"><b>HTTPS</b>(安全上下文) + <b>Service Worker</b> + <b>PushManager</b>。<br>',
|
|
444
|
+
'通过 <b>http</b> 访问时必定不可用——这是浏览器协议的硬性要求,不是配置问题。</p>',
|
|
445
|
+
'</div>',
|
|
446
|
+
|
|
447
|
+
'<section class="pc-card">',
|
|
448
|
+
'<h2 class="pc-h2">访问地址</h2>',
|
|
449
|
+
'<p class="pc-kv">当前地址:<code id="pc-origin"></code></p>',
|
|
450
|
+
'<p class="pc-hint">若上面是 <code>http://</code> 开头,请改用 https 的公网地址重新打开本页:</p>',
|
|
451
|
+
'<ul class="pc-urls">',
|
|
452
|
+
'<li><span class="pc-tag pc-tag-no">http</span> <code>http://192.168.1.10:19387/m/push-check</code> — 局域网,必定不可用(仅用于对照)</li>',
|
|
453
|
+
'<li><span class="pc-tag pc-tag-ok">https</span> <code>https://your-host.example.com:45632/m/push-check</code> — 公网反代,<b>用这个测</b></li>',
|
|
454
|
+
'</ul>',
|
|
455
|
+
'</section>',
|
|
456
|
+
|
|
457
|
+
'<section class="pc-card">',
|
|
458
|
+
'<h2 class="pc-h2">逐项检测</h2>',
|
|
459
|
+
'<ul class="pc-list">',
|
|
460
|
+
'<li class="pc-row" id="row-secure"><span class="pc-icon"></span><span class="pc-label">安全上下文 <i>window.isSecureContext</i></span><span class="pc-value"></span></li>',
|
|
461
|
+
'<li class="pc-row" id="row-proto"><span class="pc-icon"></span><span class="pc-label">访问协议 <i>location.protocol</i></span><span class="pc-value"></span></li>',
|
|
462
|
+
'<li class="pc-row" id="row-sw"><span class="pc-icon"></span><span class="pc-label">Service Worker <i>navigator.serviceWorker</i></span><span class="pc-value"></span></li>',
|
|
463
|
+
'<li class="pc-row" id="row-push"><span class="pc-icon"></span><span class="pc-label">PushManager <i>window.PushManager</i></span><span class="pc-value"></span></li>',
|
|
464
|
+
'<li class="pc-row" id="row-notif"><span class="pc-icon"></span><span class="pc-label">Notification API <i>window.Notification</i></span><span class="pc-value"></span></li>',
|
|
465
|
+
'<li class="pc-row" id="row-perm"><span class="pc-icon"></span><span class="pc-label">通知权限 <i>Notification.permission</i></span><span class="pc-value"></span></li>',
|
|
466
|
+
'<li class="pc-row" id="row-swreg"><span class="pc-icon"></span><span class="pc-label">SW 注册 <i>register(\'/m/service-worker.js\')</i></span><span class="pc-value"></span></li>',
|
|
467
|
+
'<li class="pc-row" id="row-sub"><span class="pc-icon"></span><span class="pc-label">订阅可用性 <i>getSubscription()</i>(只读)</span><span class="pc-value"></span></li>',
|
|
468
|
+
'<li class="pc-row" id="row-ua"><span class="pc-icon"></span><span class="pc-label">User-Agent</span><span class="pc-value"></span></li>',
|
|
469
|
+
'</ul>',
|
|
470
|
+
'</section>',
|
|
471
|
+
|
|
472
|
+
'<section class="pc-card">',
|
|
473
|
+
'<h2 class="pc-h2">请求通知权限</h2>',
|
|
474
|
+
'<p class="pc-hint">点下面的按钮会调用 <code>Notification.requestPermission()</code>,由浏览器弹窗询问。本页不会真的订阅、不会配对设备。</p>',
|
|
475
|
+
'<button type="button" class="pc-btn" id="pc-perm-btn">请求通知权限</button>',
|
|
476
|
+
'<p class="pc-result" id="pc-perm-result"></p>',
|
|
477
|
+
'</section>',
|
|
478
|
+
|
|
479
|
+
'<section class="pc-card pc-verdict" id="pc-verdict">',
|
|
480
|
+
'<p class="pc-verdict-title" id="pc-verdict-title">检测中…</p>',
|
|
481
|
+
'<p class="pc-verdict-desc" id="pc-verdict-desc"></p>',
|
|
482
|
+
'</section>',
|
|
483
|
+
|
|
484
|
+
'<section class="pc-card">',
|
|
485
|
+
'<h2 class="pc-h2">User-Agent 全文</h2>',
|
|
486
|
+
'<p class="pc-ua" id="pc-ua"></p>',
|
|
487
|
+
'<p class="pc-hint">用来确认是不是夸克浏览器,以及它的 Chromium 内核版本。</p>',
|
|
488
|
+
'</section>',
|
|
489
|
+
|
|
490
|
+
'<button type="button" class="pc-btn pc-btn-ghost" id="pc-reload">重新检测</button>',
|
|
491
|
+
'<p class="pc-foot">本页只读:不配对、不写设备、不创建订阅。</p>',
|
|
492
|
+
'</main>',
|
|
493
|
+
'<script>' + pageJs + '</script>',
|
|
494
|
+
'</body>',
|
|
495
|
+
'</html>',
|
|
496
|
+
].join('')
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function manifestJson() {
|
|
500
|
+
return JSON.stringify({
|
|
501
|
+
id: MOBILE_ROOT,
|
|
502
|
+
name: 'dsh 掌上助手',
|
|
503
|
+
short_name: 'dsh',
|
|
504
|
+
start_url: MOBILE_ROOT,
|
|
505
|
+
scope: MOBILE_ROOT,
|
|
506
|
+
display: 'standalone',
|
|
507
|
+
background_color: '#151424',
|
|
508
|
+
theme_color: '#f3f5f9',
|
|
509
|
+
icons: [
|
|
510
|
+
{ src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
|
511
|
+
{ src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
|
512
|
+
],
|
|
513
|
+
}, null, 2)
|
|
514
|
+
}
|