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.
- package/README.md +14 -6
- package/client.js +80 -14
- package/package.json +1 -1
- 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.
|
|
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.
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
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.
|
|
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
|
|
200
|
-
*
|
|
201
|
-
* untouched
|
|
202
|
-
*
|
|
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
package/routes.js
CHANGED
|
@@ -112,7 +112,28 @@ function pairingHostAllowed(ctx, request) {
|
|
|
112
112
|
catch {
|
|
113
113
|
return false;
|
|
114
114
|
}
|
|
115
|
-
|
|
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);
|