dsh-mobile-access 1.1.3 → 1.1.5

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.
Files changed (4) hide show
  1. package/README.md +14 -6
  2. package/client.js +80 -14
  3. package/package.json +1 -1
  4. package/routes.js +22 -1
package/README.md CHANGED
@@ -79,7 +79,7 @@ iPhone 打开「设置 → 无线局域网」,确认和 Mac 连的是同一个
79
79
  ### 第 2 步:在 Mac 终端执行一条命令
80
80
 
81
81
  ```bash
82
- dsh plugin --profile web add dsh-mobile-access@1.1.3 --registry=https://registry.npmjs.org/
82
+ dsh plugin --profile web add dsh-mobile-access@1.1.5 --registry=https://registry.npmjs.org/
83
83
  ```
84
84
 
85
85
  这条命令做三件事:
@@ -181,13 +181,19 @@ Safari 分享按钮 → 添加到主屏幕
181
181
  这是官方会话行的触摸行为:每一行是 `role="treeitem"`、`tabindex="0"` 的可聚焦元素,
182
182
  iOS Safari 会把第一次触摸先用去聚焦,第二次才派发 `click`。
183
183
 
184
- 从 `1.1.3` 起,插件只在 iPhone / iPad 上、在你触摸会话行的那一刻临时移除该行的
185
- `tabindex`,400 毫秒后自动恢复;同时给会话行加 `touch-action: manipulation`,
186
- 避免旧式双击缩放延迟。Mac 和键盘操作不受影响。展开侧栏后,点一次会话标题即可切换。
184
+ 从 `1.1.4` 起,插件在 iPhone / iPad 上不再依赖“移除 `tabindex` 后等浏览器补发 click”
185
+ 这一条不确定路径:第一次触摸结束时就主动触发该会话行的点击,并抑制随后可能重复的
186
+ 原生点击,因此一次点击只会切换一次;切换完成后,窄屏左侧栏会自动收起,把空间还给
187
+ 对话区。会话行同时加 `touch-action: manipulation`,避免旧式双击缩放延迟。Mac 和
188
+ 键盘操作不受影响。
187
189
 
188
190
  如果左侧栏仍然是窄窄的图标条,第一下是展开侧栏——这是官方布局,不是会话行的问题;
189
191
  展开后每个会话只需单击一次。
190
192
 
193
+ `1.1.3` 的旧实现只移除 `tabindex`,在部分 iOS Safari 版本上仍可能被第一次触摸吃掉;
194
+ 如果更新到 `1.1.4` 后仍是两次,请彻底关闭这个 Safari 标签页再重新打开(不要只刷新),
195
+ 以排除旧的 `client.js` 缓存。
196
+
191
197
  ### iPhone 上切换大模型时页面放大、键盘自动弹出怎么办
192
198
 
193
199
  从 `1.1.2` 起不需要改 DSH,也不需要关掉 Safari 的缩放:
@@ -364,7 +370,7 @@ iPhone 软键盘没有可用的 Shift 键,因此插件在 **iOS / iPadOS** 上
364
370
  ### 推荐:npm 官方命令
365
371
 
366
372
  ```bash
367
- dsh plugin --profile web add dsh-mobile-access@1.1.3 --registry=https://registry.npmjs.org/
373
+ dsh plugin --profile web add dsh-mobile-access@1.1.5 --registry=https://registry.npmjs.org/
368
374
  ```
369
375
 
370
376
  ### 使用 `@latest`
@@ -494,7 +500,9 @@ dsh plugin --profile web remove dsh-mobile-access
494
500
  - `1.1.0` — 回环监听自动局域网网关(macOS / Windows,HTTP + `/api/remote.mux` WebSocket)。
495
501
  - `1.1.1` — iPhone / iPad 上模型选择页 16px 缩放守卫。
496
502
  - `1.1.2` — 打开模型选择时不自动弹键盘、不整体上移。
497
- - `1.1.3` — iPhone / iPad 上会话行第一次点击即可切换;当前 npm `latest`。
503
+ - `1.1.5` — Tailscale Serve 经 HTTPS 访问时,允许带 Tailscale 身份头的 `*.ts.net` Host(仅限本机回环转发);当前 npm `latest`。
504
+ - `1.1.4` — iPhone / iPad 上第一次点击直接切换会话,并在切换后自动收起左侧栏。
505
+ - `1.1.3` — 首次尝试临时移除会话行 `tabindex`。
498
506
 
499
507
  ## 许可证
500
508
 
package/client.js CHANGED
@@ -184,35 +184,45 @@ function installIOSInputZoomGuard() {
184
184
  document.head.appendChild(style);
185
185
  return () => style.remove();
186
186
  }
187
- /**
188
- * The official model menu can move focus into its search field while it opens.
189
- * On iPhone that opens the software keyboard and shifts the fixed-position menu
190
- * above the visible viewport. Suppress only programmatic search-box focus until
191
- * the user deliberately taps that field; blur a focused composer control before
192
- * any composer menu opens. Native taps still focus normally, and Mac is untouched.
193
- */
194
187
  /**
195
188
  * Safari treats a touch on a focusable, non-button row as a focus step: the
196
189
  * first tap focuses the row and only the second tap reaches its click handler.
197
190
  * Official session rows are `role="treeitem"` divs with `tabIndex={0}`, so
198
191
  * switching sessions on iPhone takes two taps. Remove that one attribute for
199
- * the duration of the tap on iOS only; React restores it on the next render and
200
- * this hook restores it after a short delay, leaving Mac and keyboard access
201
- * untouched. `touch-action: manipulation` is also applied in the iOS stylesheet
202
- * to avoid the legacy double-tap-zoom delay.
192
+ * the duration of the tap, activate the row on the first tap ourselves, and
193
+ * collapse the narrow sidebar after the switch. Mac and keyboard users are
194
+ * untouched; `touch-action: manipulation` in the iOS stylesheet removes the
195
+ * legacy double-tap-zoom delay.
203
196
  */
204
- function installIOSSidebarTapGuard() {
197
+ function installIOSSidebarTapGuard(layout) {
205
198
  if (!isAppleMobile())
206
199
  return () => undefined;
207
200
  const doc = document;
208
201
  const timers = new Set();
202
+ let pending = null;
203
+ let syntheticClick = false;
204
+ let suppressNativeClick = false;
205
+ const isAction = (target) => target.closest('button, a, input, textarea, select, [role="button"], [role="menuitem"], [role="menuitemradio"], [role="switch"], [data-row-actions]') !== null;
206
+ const point = (event) => {
207
+ const touches = event.changedTouches ?? event.touches;
208
+ const touch = touches?.[0];
209
+ return touch === undefined ? null : { x: touch.clientX, y: touch.clientY };
210
+ };
209
211
  const onTouchStart = (event) => {
210
212
  const target = event.target;
211
213
  if (target === null || typeof target.closest !== 'function')
212
214
  return;
215
+ if (isAction(target)) {
216
+ pending = null;
217
+ return;
218
+ }
213
219
  const row = target.closest('[data-row-key^="session:"]');
214
220
  if (row === null)
215
221
  return;
222
+ const at = point(event);
223
+ if (at === null)
224
+ return;
225
+ pending = { row, x: at.x, y: at.y, startedAt: Date.now() };
216
226
  const tabindex = row.getAttribute('tabindex');
217
227
  if (tabindex === null)
218
228
  return;
@@ -224,14 +234,70 @@ function installIOSSidebarTapGuard() {
224
234
  }, 400);
225
235
  timers.add(timer);
226
236
  };
237
+ const onClickCapture = (event) => {
238
+ const target = event.target;
239
+ if (target === null || typeof target.closest !== 'function' || target.closest('[data-row-key^="session:"]') === null)
240
+ return;
241
+ if (syntheticClick) {
242
+ syntheticClick = false;
243
+ suppressNativeClick = true;
244
+ return;
245
+ }
246
+ if (!suppressNativeClick)
247
+ return;
248
+ suppressNativeClick = false;
249
+ event.preventDefault();
250
+ event.stopImmediatePropagation();
251
+ };
252
+ const onTouchEnd = (event) => {
253
+ if (pending === null)
254
+ return;
255
+ const { row, x, y, startedAt } = pending;
256
+ pending = null;
257
+ if (!row.isConnected)
258
+ return;
259
+ const at = point(event);
260
+ if (at === null)
261
+ return;
262
+ if (Math.hypot(at.x - x, at.y - y) > 10 || Date.now() - startedAt > 600)
263
+ return;
264
+ syntheticClick = true;
265
+ row.click();
266
+ const timer = window.setTimeout(() => {
267
+ timers.delete(timer);
268
+ syntheticClick = false;
269
+ suppressNativeClick = false;
270
+ }, 500);
271
+ timers.add(timer);
272
+ if (window.innerWidth < 1024) {
273
+ window.setTimeout(() => {
274
+ try {
275
+ layout?.toggleSidebar();
276
+ }
277
+ catch {
278
+ }
279
+ }, 0);
280
+ }
281
+ };
227
282
  doc.addEventListener('touchstart', onTouchStart, true);
283
+ doc.addEventListener('touchend', onTouchEnd, true);
284
+ doc.addEventListener('click', onClickCapture, true);
228
285
  return () => {
229
286
  doc.removeEventListener('touchstart', onTouchStart, true);
287
+ doc.removeEventListener('touchend', onTouchEnd, true);
288
+ doc.removeEventListener('click', onClickCapture, true);
230
289
  for (const timer of timers)
231
290
  window.clearTimeout(timer);
232
291
  timers.clear();
233
292
  };
234
293
  }
294
+ /**
295
+ * The official model menu can move focus into its search field while it opens.
296
+ * On iPhone that opens the software keyboard and shifts the fixed-position menu
297
+ * above the visible viewport. Suppress only programmatic search-box focus until
298
+ * the user deliberately taps that field; blur a focused composer control before
299
+ * any composer menu opens. Native taps still focus normally, and Mac is untouched.
300
+ */
235
301
  function installIOSModelKeyboardGuard() {
236
302
  if (!isAppleMobile())
237
303
  return () => undefined;
@@ -505,7 +571,7 @@ const STYLES = ".dsh-ma{height:100%;min-height:100%;overflow:auto;background:var
505
571
  function factory(require) {
506
572
  React = require('react');
507
573
  return {
508
- inject: ['slots', 'locale'],
574
+ inject: ['slots', 'locale', 'layout'],
509
575
  apply(ctx) {
510
576
  ctx.effect(() => {
511
577
  const cleanups = [];
@@ -566,7 +632,7 @@ function factory(require) {
566
632
  ctx.logger?.warn?.(`[mobile-access] iOS model keyboard guard unavailable: ${String(error)}`);
567
633
  }
568
634
  try {
569
- cleanups.push(installIOSSidebarTapGuard());
635
+ cleanups.push(installIOSSidebarTapGuard(ctx.layout));
570
636
  }
571
637
  catch (error) {
572
638
  ctx.logger?.warn?.(`[mobile-access] iOS sidebar tap guard unavailable: ${String(error)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-access",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Independent DSH bundle that pairs iPhones and iPads with the official DeepSeek Harness Web GUI over the local network.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/routes.js CHANGED
@@ -112,7 +112,28 @@ function pairingHostAllowed(ctx, request) {
112
112
  catch {
113
113
  return false;
114
114
  }
115
- return isLoopbackHost(bare) || bare === expected;
115
+ if (isLoopbackHost(bare) || bare === expected)
116
+ return true;
117
+ return isTailscaleServeRequest(request, bare);
118
+ }
119
+ /**
120
+ * Tailscale Serve terminates TLS and forwards to the loopback listener while
121
+ * preserving the public `*.ts.net` Host. Serve adds identity headers that a
122
+ * cross-site browser form cannot forge; requiring one plus a loopback peer
123
+ * keeps DNS-rebinding protection intact for direct loopback requests.
124
+ */
125
+ function isTailscaleServeRequest(request, hostname) {
126
+ if (!hostname.endsWith('.ts.net'))
127
+ return false;
128
+ const identity = firstHeader(request.headers, 'tailscale-user-login')
129
+ ?? firstHeader(request.headers, 'tailscale-user-name');
130
+ if (identity === undefined || identity.length === 0)
131
+ return false;
132
+ const remote = request.socket?.remoteAddress;
133
+ if (remote === undefined)
134
+ return false;
135
+ const normalized = remote.startsWith('::ffff:') ? remote.slice(7) : remote;
136
+ return isLoopbackHost(normalized);
116
137
  }
117
138
  function pairingNonce() {
118
139
  const bytes = new Uint8Array(18);