dsh-push-host 0.0.0-stage → 0.3.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.
@@ -0,0 +1,7 @@
1
+ # dsh-push-host bundle patch: mounts the phone-push plugin under the stable plugin id
2
+ # `push-host`. The plugin owns no DSH seam of its own — it injects the question service
3
+ # (and falls back to the DSH web API when that seam is unavailable), pushes notifications
4
+ # to ntfy and answers questions with what the phone submitted through the relay.
5
+ - insert:
6
+ - id: push-host
7
+ name: 'dsh-push-host'
package/lib/client.js ADDED
@@ -0,0 +1,470 @@
1
+ // dsh-push-host 的浏览器半(v0.3.0):设置页「手机作答模式」开关 + 「通行证」自助发号面板 + 按推送链接的 hash 切到来源会话。
2
+ // v0.2.3:补 hashchange 监听——网页已开着时点另一条推送只做片段跳转、不重载页面。
3
+ // v0.2.4:开关与「展开其余 N 个会话」都改用语义锚点 + 中英双标签——这些文案是本地化的,
4
+ // 只按英文标签找在中文手机上必然找不到(用户真机实遇)。
5
+ // v0.2.5:为点到行而展开的侧栏,到位后自动收回——窄屏下侧栏是抽屉,会盖住对话;
6
+ // 只有我们自己开过的才关,用户本来就开着的一律不动。
7
+ // 手写模块,格式照抄现成编译产物(window.__ModuleLoader__.load,见
8
+ // dsh-client-ui-settings-subagent/lib/client.js);宿主 client-modules 经
9
+ // package.json 的 dsh.client{platform:"web"} + exports "./client" 挂载它。
10
+ // 写入通道:settings RPC 只能写 volatile 字段,所以这里只写 push-host 自己的
11
+ // askUserMode;宿主延后到 HMR 事务外备份并 configEditor.edit 改 tool-ask-user 的
12
+ // mode/timeout(那两个字段不是 volatile,客户端写不了)。
13
+ // v0.2.1:展示读宿主的实际态镜像 askUserActual(不拿期望态当展示值);开关只升不降
14
+ // (0.2.0 的 P0 把生产 timed 反向降级成 legacy),已是 timed 时按钮禁用。
15
+ window.__ModuleLoader__.load({
16
+ id: "dsh-push-host",
17
+ factory: (require) => {
18
+ var module = { exports: {} };
19
+ var exports = module.exports;
20
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
21
+ let react = require("react");
22
+
23
+ // 诊断(v0.2.2):开机瞬间 URL 里到底有没有 hash、切会话走到哪一步,一页之内就能看出来,
24
+ // 不用去猜「是没跑还是没点到」。控制台里读 window.__DSHPUSH_CLIENT__ 即可。
25
+ const DIAG = { v: "0.3.0", hash: "", service: false, tries: 0, opened: false, rowSeen: false, clicks: 0, closed: false, done: false };
26
+ try {
27
+ DIAG.hash = String(location.hash || "");
28
+ } catch {
29
+ // 读不到就当没有:诊断本身绝不能把插件搞崩。
30
+ }
31
+ try {
32
+ window.__DSHPUSH_CLIENT__ = DIAG;
33
+ } catch {
34
+ // window 不可写时丢掉诊断即可。
35
+ }
36
+
37
+ /** 本行读写的 settings 命名空间 = push-host 的 profile entry id。 */
38
+ const NS = "push-host";
39
+
40
+ /** 浏览器侧所需服务(client fiber inject)。 */
41
+ const inject = ["slots", "configForms"];
42
+
43
+ /**
44
+ * 挂载设置行:宿主在提供 `push-host` 命名空间(= 插件已装载)时才出现。
45
+ * @param ctx - 浏览器插件上下文。
46
+ */
47
+ function apply(ctx) {
48
+ const scope = ctx.configForms.get(NS);
49
+ let disposed = false;
50
+ const listeners = new Set();
51
+ const notify = () => {
52
+ for (const listener of listeners) listener();
53
+ };
54
+ const unsubscribe = scope.subscribe(() => {
55
+ if (!disposed) notify();
56
+ });
57
+ ctx.effect(() => () => {
58
+ disposed = true;
59
+ unsubscribe();
60
+ }, "dsh-push-host: ask-user form subscription");
61
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
62
+ name: "settings.general.item",
63
+ id: "push-host-ask-user-mode",
64
+ order: 120,
65
+ label: "手机作答模式",
66
+ }, () => AskUserModeRow(scope, listeners, notify)))), "dsh-push-host: ask-user mode row");
67
+ // v0.3.0:通行证面板——一人一 topic 一对凭据的自助入口(宿主 lib/index.js:PassProvisioner 跑发号)。
68
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
69
+ name: "settings.general.item",
70
+ id: "push-host-pass-panel",
71
+ order: 130,
72
+ label: "通行证",
73
+ }, () => PassPanelRow(scope, listeners, notify)))), "dsh-push-host: pass panel row");
74
+ // v0.2.2:把推送链接 hash 里的来源会话切到主视图(与设置行互不相干,各自独立生效)。
75
+ revealFromHash(ctx);
76
+ }
77
+
78
+ /**
79
+ * 设置行本体:settings.general.item 的列只叠行,标签、状态、写入全是行自己的。
80
+ * @param scope - 绑定了 `push-host` 的配置表单。
81
+ * @param listeners - 订阅回调集合(宿主配置文档变化时唤醒)。
82
+ * @param notify - 唤醒全部订阅者。
83
+ */
84
+ function AskUserModeRow(scope, listeners, notify) {
85
+ const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot());
86
+ const [busy, setBusy] = react.useState(false);
87
+ react.useEffect(() => {
88
+ const update = () => setSnapshot(scope.getSnapshot());
89
+ listeners.add(update);
90
+ update();
91
+ return () => {
92
+ listeners.delete(update);
93
+ };
94
+ }, []);
95
+ const ready = snapshot.status === "ready";
96
+ const value = snapshot.value ?? {};
97
+ // 展示读宿主的实际态镜像;镜像还没回写('')时暂按期望态兜底。
98
+ const timed = value.askUserActual === "timed" || (value.askUserActual !== "legacy" && value.askUserMode === "timed");
99
+ const status = typeof value.askUserStatus === "string" ? value.askUserStatus : "";
100
+ const writable = ready && snapshot.writable === true && !busy && !timed;
101
+ const setMode = (next) => {
102
+ if (!writable) return;
103
+ setBusy(true);
104
+ Promise.resolve()
105
+ .then(() => scope.mutate([{
106
+ op: "set",
107
+ path: ["askUserMode"],
108
+ value: next
109
+ }], snapshot.revision))
110
+ .catch(() => {})
111
+ .then(() => {
112
+ setBusy(false);
113
+ notify();
114
+ });
115
+ };
116
+ return react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6, padding: "8px 0" } },
117
+ react.createElement("div", { style: { fontWeight: 600 } }, "手机作答模式"),
118
+ react.createElement("div", { style: { fontSize: 13, opacity: 0.75 } }, timed
119
+ ? "已开启:ask_user_question 走 timed——提问会推送到手机,超时后手机答案仍可续答。"
120
+ : "未开启:ask_user_question 走 legacy——只有 DSH 网页能作答。"),
121
+ react.createElement("div", { style: { fontSize: 12, opacity: 0.55 } },
122
+ "启用即时热生效、无需重启宿主;新会话立即生效,已开老会话保持旧行为(工具变体钉在会话创建时)。启用前自动备份 cordis.patch.yml。本开关只升不降:要回到 legacy 请手动改 profile 的 cordis.patch.yml。"),
123
+ status !== ""
124
+ ? react.createElement("div", { style: { fontSize: 12, color: "#c0392b" } },
125
+ "上次切换失败:" + status + "(若提示 overridden by a home patch,请先移除 home 层或命令行对 tool-ask-user 的同 id 覆盖)")
126
+ : null,
127
+ react.createElement("div", {}, react.createElement("button", {
128
+ type: "button",
129
+ disabled: !writable,
130
+ onClick: () => setMode("timed"),
131
+ style: { padding: "6px 14px", borderRadius: 8, border: "1px solid currentColor", opacity: writable ? 1 : 0.5 },
132
+ }, busy ? "启用中…" : timed ? "已开启(本开关不降级)" : "启用(切到 timed)")));
133
+ }
134
+
135
+ /**
136
+ * 通行证面板(v0.3.0)。请求经 volatile 字段表达(浏览器只能写 volatile),宿主跑完把
137
+ * provisionResult / provisionList / provisionDone 镜像回来;busy 用「done 是否等于本次 seq」判,
138
+ * 不靠超时猜(另加 30s 兜底,免得宿主不在时按钮永久卡住)。
139
+ * @param scope - 绑定了 push-host 的配置表单。
140
+ * @param listeners - 订阅回调集合。
141
+ * @param notify - 唤醒全部订阅者。
142
+ */
143
+ function PassPanelRow(scope, listeners, notify) {
144
+ const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot());
145
+ const [busy, setBusy] = react.useState(false);
146
+ const [name, setName] = react.useState("");
147
+ const [copied, setCopied] = react.useState(false);
148
+ react.useEffect(() => {
149
+ const update = () => setSnapshot(scope.getSnapshot());
150
+ listeners.add(update);
151
+ update();
152
+ return () => {
153
+ listeners.delete(update);
154
+ };
155
+ }, []);
156
+ const ready = snapshot.status === "ready";
157
+ const value = snapshot.value ?? {};
158
+ const text = (key) => (typeof value[key] === "string" ? value[key] : "");
159
+ const suggested = text("provisionDefault");
160
+ const result = text("provisionResult");
161
+ const done = text("provisionDone");
162
+ const seq = text("provisionSeq");
163
+ react.useEffect(() => {
164
+ if (busy && done !== "" && done === seq) setBusy(false);
165
+ }, [done, seq, busy]);
166
+ const rawList = text("provisionList");
167
+ let passes = [];
168
+ let listError = "";
169
+ if (rawList.indexOf("__err__") === 0) listError = rawList.slice(7);
170
+ else if (rawList) {
171
+ try { passes = JSON.parse(rawList) || []; } catch { passes = []; }
172
+ }
173
+ const writable = ready && snapshot.writable === true && !busy;
174
+ const ask = (action, forcedName) => {
175
+ if (!writable) return;
176
+ const nextSeq = String(Date.now()) + "-" + Math.floor(Math.random() * 1e6).toString(36);
177
+ const ops = [
178
+ { op: "set", path: ["provisionAction"], value: action },
179
+ { op: "set", path: ["provisionSeq"], value: nextSeq },
180
+ ];
181
+ const typed = typeof forcedName === "string" && forcedName !== "" ? forcedName.trim() : name.trim();
182
+ if (action !== "refresh" && typed !== "") ops.push({ op: "set", path: ["provisionName"], value: typed });
183
+ setBusy(true);
184
+ setCopied(false);
185
+ setTimeout(() => setBusy(false), 30000);
186
+ Promise.resolve()
187
+ .then(() => scope.mutate(ops, snapshot.revision))
188
+ .catch(() => setBusy(false))
189
+ .then(() => notify());
190
+ };
191
+ const copy = () => {
192
+ if (!result) return;
193
+ try {
194
+ navigator.clipboard.writeText(result);
195
+ setCopied(true);
196
+ } catch {
197
+ setCopied(false);
198
+ }
199
+ };
200
+ const btn = (label, onClick, disabled) => react.createElement("button", {
201
+ type: "button", disabled: disabled, onClick: onClick,
202
+ style: { padding: "6px 12px", borderRadius: 8, border: "1px solid currentColor", opacity: disabled ? 0.5 : 1 },
203
+ }, label);
204
+ return react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6, padding: "8px 0" } },
205
+ react.createElement("div", { style: { fontWeight: 600 } }, "通行证"),
206
+ react.createElement("div", { style: { fontSize: 13, opacity: 0.75 } },
207
+ "给手机签发一条专属通道:一个 topic + 一只只读 token。多人各用各的,通知互不串。"),
208
+ react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
209
+ react.createElement("input", {
210
+ value: name, placeholder: suggested || "dsh-local",
211
+ onChange: (event) => setName(event.target.value),
212
+ disabled: !writable,
213
+ style: { padding: "6px 10px", borderRadius: 8, border: "1px solid currentColor", minWidth: 170, background: "transparent", color: "inherit" },
214
+ }),
215
+ btn(busy ? "处理中…" : "发通行证", () => ask("issue"), !writable),
216
+ btn("刷新列表", () => ask("refresh"), !writable)),
217
+ react.createElement("div", { style: { fontSize: 12, opacity: 0.6 } },
218
+ "名字:小写字母/数字/短横线,2-31 位、开头不能是短横线。发号成功后 topic 与只写 token 会写回本机凭据文件,重启本插件后按新 topic 发布。"),
219
+ result !== "" ? react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
220
+ react.createElement("textarea", {
221
+ readOnly: true, value: result, rows: 2,
222
+ onFocus: (event) => { try { event.target.select(); } catch {} },
223
+ style: { flex: "1 1 240px", padding: "6px 8px", borderRadius: 8, border: "1px solid currentColor", background: "transparent", color: "inherit", fontFamily: "monospace", fontSize: 12 },
224
+ }),
225
+ btn(copied ? "已复制" : "复制", copy, false)) : null,
226
+ listError !== "" ? react.createElement("div", { style: { fontSize: 12, color: "#c0392b" } }, "列表读取失败:" + listError) : null,
227
+ passes.length > 0 ? react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 4 } },
228
+ react.createElement("div", { style: { fontSize: 12, opacity: 0.7 } }, "已签发 " + passes.length + " 条(吊销即让对方手机上的通行证立刻失效)"),
229
+ ...passes.map((row) => react.createElement("div", {
230
+ key: row.name,
231
+ style: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, justifyContent: "space-between", flexWrap: "wrap" },
232
+ },
233
+ react.createElement("span", { style: { fontFamily: "monospace", wordBreak: "break-all" } },
234
+ row.name + " → " + (row.topics || []).map((t) => t.topic + "(" + t.permission + ")").join(", ")),
235
+ btn("吊销", () => ask("revoke", row.name), !writable)))) : null);
236
+ }
237
+
238
+
239
+ /**
240
+ * v0.2.2:推送里的 `#web` 链接把来源会话放在 **URL hash 片段**(`…#session-<uuid>`,服务端
241
+ * `lib/index.js:webUrl()` 拼),这里把它兑现成「主视图切到那个会话」——用户「进入网页后能直接
242
+ * 进入推送通知的那个对话吗」。用片段而不是查询参数是实测决定的:`?token=` 入口会 `303 →
243
+ * Location: ./` 把查询串丢掉,而浏览器跳转会保留 fragment;网页端也没有路由,别处不读 URL。
244
+ *
245
+ * 选择器全部语义化,不依赖哈希类名:当前会话 `[data-conversation-session="<id>"]`、侧栏行
246
+ * `[data-row-key="session:<id>"]`、展开侧栏 `button[aria-label="Open sidebar"]`。目标不在列表
247
+ * 里(会话未加载/分组收起)就静默放弃,等于本功能之前的行为,MUST NOT 影响页面其它部分。
248
+ * @param ctx - 浏览器插件上下文(用它的 effect 挂清理,HMR 不留定时器)。
249
+ */
250
+ /**
251
+ * v0.2.2:推送里的 `#web` 链接把来源会话放在 **URL hash 片段**(`…#session-<uuid>`,服务端
252
+ * `lib/index.js:webUrl()` 拼),这里把它兑现成「主视图切到那个会话」。
253
+ *
254
+ * 用片段而不是查询参数,两条实测理由(VM1 的 DSH Web,2026-10-17):`?token=` 入口回的是
255
+ * `303 → Location: ./`,查询串在这一步被丢掉,而 fragment 跨跳转保留(实测早期 location.hash 仍在);
256
+ * 网页客户端没有路由,路径与查询串都没人读。
257
+ *
258
+ * 抓手全是语义化选择器(不依赖哈希类名):当前会话 `[data-conversation-session=…]`、
259
+ * 侧栏行 `[data-row-key=session:<id>]`(实测点它就能切主视图)、侧栏容器 `[data-slot=sidebar]`。
260
+ *
261
+ * 侧栏指纹(实测):收起时 0 个会话行、按钮标签是 Open sidebar;展开时按钮变 Collapse sidebar,
262
+ * 且列表**截断**(还有 Show N more sessions 按钮、工作区分组可能 aria-expanded=false)。
263
+ * 所以每一拍按当前状态决定动作,而不是只点一次就完事:插件 apply 跑得很早,那一下点击可能被
264
+ * 尚未挂好 handler 的界面吞掉(实测踩过:只点一次 ⇒ rowSeen 始终 false)。按标签判断方向也保证
265
+ * 重复点击安全——侧栏已开时 Open sidebar 这个按钮就不存在了,不会反过来把它关掉。
266
+ *
267
+ * 目标找不到(会话未加载、分组没展开)就静默放弃,最多 20s,等于本功能之前的行为。
268
+ * @param ctx - 浏览器插件上下文(用它的 effect 挂清理,HMR 不留定时器)。
269
+ */
270
+ /**
271
+ * v0.2.2:推送里的 `#web` 链接把来源会话放在 **URL hash 片段**(`…#session-<uuid>`,服务端
272
+ * `lib/index.js:webUrl()` 拼),这里把它兑现成「主视图切到那个会话」。
273
+ *
274
+ * 用片段而不是查询参数,两条实测理由(VM1 的 DSH Web,2026-10-17):`?token=` 入口回的是
275
+ * `303 → Location: ./`,查询串在这一步被丢掉,而 fragment 跨跳转保留;网页客户端没有路由,
276
+ * 路径与查询串都没人读。
277
+ *
278
+ * v0.2.3 关键修正(用户真机实遇):**网页已经开着时再点另一条推送**,App 的 WebActivity 是
279
+ * singleTask,`onNewIntent` 会 `loadUrl(新链接)`,但新链接与当前页只差一个片段 ⇒ WebView 做的是
280
+ * **同文档跳转、不重载页面** ⇒ 只在模块加载时跑一次的旧实现根本不会再执行,于是停在用户上一个
281
+ * 会话。所以这里同时挂 **`hashchange` 监听**:片段一变就重新切。之所以每次都能触发,是因为成功
282
+ * 之后会把片段抹掉(`replaceState` 不产生 hashchange,不会自激),地址常态是无片段的。
283
+ *
284
+ * 抓手全是语义化选择器(不依赖哈希类名):当前会话 `[data-conversation-session=…]`、侧栏行
285
+ * `[data-row-key=session:<id>]`(实测点它就能切主视图)。侧栏收起时 0 个会话行、按钮标签是
286
+ * Open sidebar;展开后按钮变 Collapse sidebar,且列表**截断**(Show N more sessions、
287
+ * 工作区分组 aria-expanded=false)⇒ 每一拍按当前状态决定一个动作,而不是只点一次:插件 apply
288
+ * 跑得很早,那一下点击会被尚未挂好 handler 的界面吞掉(实测 rowSeen 恒 false)。按标签判断方向
289
+ * 也保证重复点安全——侧栏已开时 Open sidebar 这个按钮就不存在了。
290
+ *
291
+ * 目标找不到就静默放弃(≤20s),等于本功能之前的行为,MUST NOT 影响页面其余部分。
292
+ * @param ctx - 浏览器插件上下文(用它的 effect 挂清理:定时器与监听都不留残留)。
293
+ */
294
+ function revealFromHash(ctx) {
295
+ const SESSION_ID = /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
296
+ // 每次现读 hash:hashchange 之后要读新值,不能只在开头读一次。
297
+ const readTarget = () => {
298
+ const raw = String(location.hash || "").replace(/^#/, "").trim();
299
+ DIAG.hash = String(location.hash || "");
300
+ return SESSION_ID.test(raw) ? raw : "";
301
+ };
302
+ let timer = 0;
303
+ const stop = () => {
304
+ if (timer !== 0) {
305
+ clearInterval(timer);
306
+ timer = 0;
307
+ }
308
+ };
309
+ // 切到某个会话:轮询到「当前会话就是它」为止,到位后抹掉片段。
310
+ const reveal = (target) => {
311
+ stop();
312
+ let tries = 0;
313
+ let done = false;
314
+ // 侧栏是我们为了点到行才展开的:到位后要恢复原状(窄屏下它是抽屉,会盖住对话)。
315
+ // 只有我们自己开过的才关,用户本来就开着的一律不动。
316
+ let openedByUs = false;
317
+ let closeTries = 0;
318
+ let moreClicked = false;
319
+ const groupsTried = new Set();
320
+ const clearHash = () => {
321
+ try {
322
+ history.replaceState(null, "", location.pathname + location.search);
323
+ } catch {
324
+ // 某些 webview 限制 replaceState:抹不掉只是刷新会再切一次,不影响本次跳转。
325
+ }
326
+ };
327
+ // 首选 DSH 客户端自己的服务:`ctx.uiWorkspace.openSession(id)`——bundle 实测 `uiWorkspace`
328
+ // 的 client 服务,DSH 自家插件就用它切主视图。走这条路**侧栏根本不会弹出来**,这正是用户要的
329
+ // 「不弹侧边栏直接切换」。服务缺失或点了没生效时,才退回「展开侧栏 → 点那一行」的老路子。
330
+ // `uiWorkspace` 是**声明依赖才拿得到**的 client 服务:bundle 里 DSH 自家插件一律写
331
+ // `ctx.inject([... , "uiWorkspace"], scope => scope.uiWorkspace.openSession(id))`。直接读
332
+ // `ctx.uiWorkspace` 实测拿不到(v0.2.5 第一版就是这样,service 恒 false 只能走降级)。
333
+ let workspaceApi = null;
334
+ // inject 的回调**不保证同步**跑到(实测第一拍时还没到货)。没有这个标志就会在服务到货前
335
+ // 误判成「服务不可用」而去点侧栏开关——抽屉闪一下,正是用户不要的效果。
336
+ let workspaceProbed = false;
337
+ try {
338
+ ctx.inject(["uiWorkspace"], (scope) => {
339
+ workspaceProbed = true;
340
+ workspaceApi = scope.uiWorkspace ?? null;
341
+ });
342
+ } catch {
343
+ // 注入不了就永远走点侧栏行的降级路子。
344
+ }
345
+ const openViaService = () => {
346
+ try {
347
+ if (workspaceApi === null || typeof workspaceApi.openSession !== "function") return false;
348
+ workspaceApi.openSession(target);
349
+ DIAG.service = true;
350
+ return true;
351
+ } catch {
352
+ return false;
353
+ }
354
+ };
355
+ const step = () => {
356
+ if (done) {
357
+ stop();
358
+ return;
359
+ }
360
+ tries += 1;
361
+ DIAG.tries = tries;
362
+ try {
363
+ if (document.querySelector('[data-conversation-session="' + target + '"]')) {
364
+ // 到位。若侧栏是我们为了点行才展开的,先把它收回去再收尾(窄屏下侧栏是抽屉,
365
+ // 会盖住对话);用户本来就开着的一律不动。
366
+ const rowsStill = document.querySelectorAll('[data-row-key^="session:"]').length > 0;
367
+ if (openedByUs && rowsStill && closeTries < 4) {
368
+ closeTries += 1;
369
+ // 收起必须按标签找:收起态下侧栏根的第一个按钮就是开关,但**展开态下第一个是
370
+ // New session**(实测 openBtns = New session、Collapse sidebar、New session、Plugins、
371
+ // Search sessions),拿位置当锚点会点错。标签只有 zh/en 两套,配对即完备。
372
+ const back = document.querySelector('button[aria-label="Collapse sidebar"], button[aria-label="收起侧边栏"]')
373
+ ?? [].slice.call(document.querySelectorAll('[data-slot="sidebar"] button'))
374
+ .filter((b) => /collapse|收起/i.test(String(b.getAttribute("aria-label"))))[0]
375
+ ?? null;
376
+ if (back !== null) {
377
+ DIAG.closed = true;
378
+ back.click();
379
+ return;
380
+ }
381
+ }
382
+ done = true;
383
+ DIAG.done = true;
384
+ stop();
385
+ clearHash();
386
+ return;
387
+ }
388
+ if (tries > 40) {
389
+ stop();
390
+ return;
391
+ }
392
+ // 服务优先:前 8 拍(4 秒)只试 openSession,**绝不碰侧栏**——inject 可能晚一拍到货;
393
+ // openSession 幂等,每拍重点一次没有副作用。8 拍还不成才降级去点侧栏的行。
394
+ if (tries <= 8) {
395
+ if (openViaService()) return;
396
+ // 服务还没确认不可用(inject 未到货,或到货了但这一拍没成)⇒ 这一拍什么都不点,
397
+ // 绝不先开侧栏。只有确认服务不存在才提前降级。
398
+ if (workspaceProbed === false || workspaceApi !== null) return;
399
+ }
400
+ const row = document.querySelector('[data-row-key="session:' + target + '"]');
401
+ if (row !== null) {
402
+ DIAG.rowSeen = true;
403
+ DIAG.clicks += 1;
404
+ row.click();
405
+ return;
406
+ }
407
+ // 展开侧栏那颗按钮的标签是本地化的(en `Open sidebar` / zh 「打开侧边栏」),只按英文
408
+ // 标签找在中文手机上必然为空。首选与语言无关的锚点:收起态下侧栏根里的第一个按钮
409
+ // 就是这颗开关(实测 sameElement:true),且收起态 0 个会话行 ⇒ 「一个行都没有才点它」
410
+ // 既有效又安全(不会反向关掉已展开的侧栏)。
411
+ const rowsPresent = document.querySelectorAll('[data-row-key^="session:"]').length > 0;
412
+ const toggle = rowsPresent ? null : (document.querySelector('[data-slot="sidebar"] button')
413
+ ?? document.querySelector('button[aria-label="Open sidebar"], button[aria-label="打开侧边栏"]'));
414
+ if (toggle !== null) {
415
+ DIAG.opened = true;
416
+ openedByUs = true;
417
+ toggle.click();
418
+ return;
419
+ }
420
+ if (!moreClicked) {
421
+ // 同样本地化:en `Show {n} more sessions` / zh 「展开其余 {n} 个会话」;`收起` 是已展开
422
+ // 后的反向按钮,MUST NOT 点,所以只匹配展开这一个方向。
423
+ const more = [].slice.call(document.querySelectorAll('button,[role="button"]')).filter((b) => /show\s*\d+\s*more|展开其余/i.test(String(b.textContent)))[0];
424
+ if (more !== undefined) {
425
+ moreClicked = true;
426
+ more.click();
427
+ return;
428
+ }
429
+ }
430
+ const collapsed = [].slice.call(document.querySelectorAll('[data-row-key^="workspace:"]'))
431
+ .filter((g) => g.getAttribute("aria-expanded") === "false");
432
+ for (const group of collapsed) {
433
+ const key = group.getAttribute("data-row-key") || "";
434
+ if (groupsTried.has(key)) continue;
435
+ groupsTried.add(key);
436
+ group.click();
437
+ return;
438
+ }
439
+ } catch {
440
+ // 任何一次尝试出错都不算失败:继续下一拍,超时自然停。
441
+ }
442
+ };
443
+ timer = setInterval(step, 500);
444
+ step();
445
+ };
446
+
447
+
448
+ const first = readTarget();
449
+ if (first.length > 0) reveal(first);
450
+ // 网页已开着时点另一条推送:只发生片段跳转,不重载页面 ⇒ 靠这个监听。
451
+ const onHash = () => {
452
+ const target = readTarget();
453
+ if (target.length > 0) reveal(target);
454
+ };
455
+ window.addEventListener("hashchange", onHash);
456
+ // 注意 `ctx.effect(fn)` 里的 fn 是**立即执行**的效果体,**它返回的那个函数**才是清理器
457
+ // (本文件上面那处就是 `ctx.effect(() => () => …)`)。写成 `ctx.effect(() => { 清理… })`
458
+ // 会在挂上监听的同一刻就把监听摘掉——v0.2.3 第一版正是这么坏的(实测 hashchange 不触发)。
459
+ ctx.effect(() => () => {
460
+ stop();
461
+ window.removeEventListener("hashchange", onHash);
462
+ }, "dsh-push-host: reveal session from hash");
463
+ }
464
+
465
+ exports.NS = NS;
466
+ exports.apply = apply;
467
+ exports.inject = inject;
468
+ return module.exports;
469
+ }
470
+ });