dsh-mobile-access 0.0.0-stage → 1.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-mobile-access contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,196 @@
1
- # Temporary Holding Version
1
+ # 手机访问(dsh-mobile-access)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 一个独立的 TypeScript DSH bundle:让同一局域网内的 iPhone / iPad 用自带 Safari 安全打开 Mac 上的官方 DeepSeek Harness Web,并在左侧栏提供一个「手机访问」入口。
4
+
5
+ 它只增加三个精确路由和一个客户端面板,不修改 DSH 核心、不改认证、不替换页面根节点、不注册 Service Worker,也不拦截现有请求,因此 Mac 上原有的 DSH Web 使用方式保持不变。
6
+
7
+ ## 解决什么问题
8
+
9
+ DSH Web 在局域网上打开裸地址时会返回 401:
10
+
11
+ ```text
12
+ dsh web authentication required; reopen the URL printed by dsh web.
13
+ ```
14
+
15
+ 这是正常的认证保护,不是网络故障。手机无法输入终端里那条带 `?token=` 的长链接,本插件用「8 位配对码」完成一次安全配对:
16
+
17
+ 1. Mac 的 DSH 左侧栏打开「手机访问」。
18
+ 2. iPhone Safari 打开面板显示的短地址,例如 `http://192.168.71.37:3080/m`。
19
+ 3. 输入 Mac 面板上的 8 位数字。
20
+ 4. 服务器在响应中直接签发 HttpOnly 登录 Cookie,然后跳转到 `/` 进入完整 DSH Web。
21
+
22
+ **启动令牌不会出现在手机地址栏、浏览器历史或 Referer 中。** 配对成功后,手机获得与 Mac 浏览器相同的登录 Cookie 生命周期(默认 30 天)。
23
+
24
+ ## 前置条件
25
+
26
+ - Mac 与 iPhone 在同一局域网,且路由器未开启客户端隔离(AP isolation)。
27
+ - DSH Web 监听的是具体局域网地址,而不是 `127.0.0.1`。
28
+ - 本机当前配置在 `/Users/open/.dsh/profiles/web/cordis.patch.yml` 的 `webserver` 行:`host: 192.168.71.37`、`port: 3080`。
29
+ - 手机访问 `http://<Mac局域网IP>:3080/m`;若使用 HTTPS,必须是手机信任的证书,否则 Safari 会先拦截。
30
+
31
+ ## 安装
32
+
33
+ 推荐使用官方 DSH CLI 一次安装。已发布到 npm:
34
+
35
+ ```bash
36
+ dsh plugin --profile web add dsh-mobile-access@latest
37
+ ```
38
+
39
+ `dsh plugin` 会在 profile 目录执行 pnpm 安装,并自动把声明了 `dsh.bundle.patch` 的包追加到 `dsh.profile.bundles`;不需要手工编辑 `cordis.patch.yml` 或 bundle 列表。
40
+
41
+ 从本目录安装(开发机、未发布版本或离线环境):
42
+
43
+ ```bash
44
+ cd /Users/open/Documents/Stocks/plugins/dsh-mobile-access
45
+ npm run verify
46
+ dsh plugin --profile web add /Users/open/Documents/Stocks/plugins/dsh-mobile-access
47
+ ```
48
+
49
+ 也可以使用 `npm pack` 生成 tgz 后安装:
50
+
51
+ ```bash
52
+ npm pack
53
+ dsh plugin --profile web add /absolute/path/to/dsh-mobile-access-1.0.1.tgz
54
+ ```
55
+
56
+ 如果 DSH 正在运行,安装后的 host bundle 可能需要重启才能加载;插件管理器提示 `restart-required` 时,**不要自行重启**,按项目约定通知用户手工重启 DSH,用户确认「已重启」后再继续。已有环境也可以继续使用 DSH 插件管理入口的 `install_bundle`,目标为本目录的绝对路径;两种方式等价。
57
+
58
+ ## 使用
59
+
60
+ ### Mac 端
61
+
62
+ 1. 打开 DSH Web,左侧栏应出现手机图标「手机访问」。
63
+ 2. 面板会显示:
64
+ - 配对地址,例如 `http://192.168.71.37:3080/m`
65
+ - 8 位配对码,例如 `1234 5678`
66
+ - 剩余时间、最近配对时间、成功配对次数
67
+ - 「复制链接」「复制配对码」「刷新配对码」
68
+ - 手机兼容性自检
69
+ 3. 如果面板提示「当前绑定在回环地址」,说明 DSH 只监听了 `127.0.0.1`,手机无法连接;需要把 `webserver.host` 改为 Mac 的局域网 IP,再由用户手工重启 DSH。
70
+
71
+ ### iPhone 端
72
+
73
+ 1. 确认 iPhone 与 Mac 在同一 Wi-Fi。
74
+ 2. 打开 Safari,访问 Mac 面板上显示的 `/m` 地址。
75
+ 3. 输入 8 位配对码,点「连接 DSH」。
76
+ 4. 成功后进入完整 DSH Web。建议:
77
+ - 点 Safari 分享按钮 → 「添加到主屏幕」。
78
+ - 以后从主屏幕图标进入;如果登录过期,重新打开一次 `/m` 配对即可。
79
+ 5. 若面板显示 `window.isSecureContext === false`,这是 HTTP 局域网的正常现象;本插件只对 iOS/iPadOS 安装一个窄范围的剪贴板回退,其他平台不会改动全局对象。
80
+
81
+ ## 路由与权限
82
+
83
+ | 方法 | 路径 | 权限 | 用途 |
84
+ |---|---|---|---|
85
+ | GET | `/m` | 公开(Host 固定 + 一次性 nonce) | 显示配对页;已有有效 v1 页面 Cookie 时跳转 `/` |
86
+ | POST | `/m` | 公开(Host 固定 + 一次性 nonce) | 校验 nonce 与配对码,签发 v1 HttpOnly Cookie |
87
+ | GET | `/api/mobile-access/state` | 仅 owner | 面板读取状态、配对码、绑定地址诊断 |
88
+ | POST | `/api/mobile-access/rotate` | 仅 owner | 刷新配对码并清空失败计数 |
89
+
90
+ 所有路由都是 `kind: 'exact'`。没有前缀路由,没有 fallback 路由,不接管 SPA 的 index。
91
+
92
+ ## 安全设计
93
+
94
+ - 8 位数字配对码,使用 `crypto.getRandomValues` 生成,服务端使用常量时间比较。
95
+ - 默认 10 分钟过期;每次刷新配对码都会清空失败计数。
96
+ - 单 IP 5 次失败锁定;全局 20 次失败锁定;单 IP 每分钟最多 10 次尝试。
97
+ - 配对成功由服务端调用 `authorizeIndex` 签发 Cookie,**不把启动令牌放进 303 的 Location**。
98
+ - 配对 POST 校验 Host(固定为监听地址)、一次性 nonce、`Content-Type`,请求体上限 2048 字节。
99
+ - 配对页带 CSP、`X-Frame-Options: DENY`、`Referrer-Policy: no-referrer`、`Cache-Control: no-store`。
100
+ - 状态与刷新接口走 Connection 的 owner-only Fetch 路由,不是公开接口。
101
+ - 配对码和失败计数只在内存中,不写磁盘、不写日志。
102
+
103
+ 更完整的威胁模型见 `docs/security.md`。
104
+
105
+ ## 对 Mac 零影响的边界
106
+
107
+ - 不修改 DSH 核心源码、profile 配置、认证密钥或 Cookie。
108
+ - 不注册全局 CSS、字体、主题或布局补丁。
109
+ - 不替换 `#root`,不挂载第二个应用。
110
+ - 不注册 Service Worker,不缓存或改写现有请求。
111
+ - 不启动后台轮询;面板只在挂载、窗口聚焦、页面可见时读取一次状态。
112
+ - 全局剪贴板回退只在 **iOS/iPadOS 且非安全上下文** 时安装,并在插件销毁时恢复原 descriptor。
113
+ - 插件注册失败只影响本插件自身;所有路由、slot、监听器和定时器都有清理路径。
114
+
115
+ ## 开发与验证
116
+
117
+ ```bash
118
+ cd /Users/open/Documents/Stocks/plugins/dsh-mobile-access
119
+
120
+ # 只做类型检查,不写文件
121
+ node scripts/build.mjs --check
122
+
123
+ # 构建根目录运行产物
124
+ node scripts/build.mjs
125
+
126
+ # 单元测试:配对服务 + HTTP 路由
127
+ node --test tests/*.test.mjs
128
+
129
+ # 完整门禁:类型检查、构建、测试、静态检查、npm pack 检查
130
+ npm run verify
131
+ ```
132
+
133
+ TypeScript 编译器默认使用当前环境的
134
+ `/Users/open/deepseek-harness/node_modules/.bin/tsc`;也可通过 `DSH_TSC` 指定其他 `tsc`。
135
+
136
+ ### 产物结构
137
+
138
+ ```text
139
+ index.js host 入口(ESM)
140
+ service.js 配对状态机
141
+ routes.js /m、/api/mobile-access/state、/api/mobile-access/rotate
142
+ html.js 无依赖配对页
143
+ types.js 结构性类型(编译后为空导出)
144
+ client.js 浏览器端 bundle(单文件、无 import/export)
145
+ cordis.patch.yml
146
+ locale/*.json meta 与 ui 文案
147
+ icon.svg
148
+ ```
149
+
150
+ `client.js` 由 `scripts/build.mjs` 把 `client-runtime.js` 与 `client.js` 两段拼接,并注入 `locale/*.json` 的 `ui` 字典和 `src/panel.css`,没有运行时依赖。
151
+
152
+ ## 常见问题
153
+
154
+ ### 手机打开 `/m` 一直是配对页,配对成功后仍回配对页
155
+
156
+ - 检查 iPhone 是否允许 Cookie(Safari 设置 → 隐私与安全性 → 阻止所有 Cookie 必须关闭)。
157
+ - 检查访问地址的 `Host` 是否与 Mac 面板上的地址完全一致(`192.168.71.37:3080` 与 `openmacbook-pro...` 是不同 authority,Cookie 不通用)。
158
+ - 检查 DSH 是否在配对后被重启;启动令牌会随进程轮换,但已签发的 Cookie 仍然有效,除非签名密钥变化。
159
+
160
+ ### 配对码输入后提示「已锁定」
161
+
162
+ 在 Mac 面板点「刷新配对码」。锁定是安全设计,不会自动解锁。
163
+
164
+ ### 面板提示回环地址
165
+
166
+ DSH 只绑定了 `127.0.0.1`,iPhone 无法访问。把 `webserver.host` 改为局域网 IP 后,**由用户手工重启 DSH**,不要让 agent 自行重启。
167
+
168
+ ### 复制按钮在 iPhone 上无反应
169
+
170
+ 本插件在 iOS 非安全上下文会安装剪贴板回退,并优先尝试 `navigator.clipboard`;若仍失败,请长按文本手动复制。Mac 不受此回退影响。
171
+
172
+ ### 是否支持 Android / 桌面浏览器
173
+
174
+ 配对页对现代浏览器通用;左侧栏面板在所有 DSH Web 客户端显示。剪贴板全局回退只在 iOS/iPadOS 生效。
175
+
176
+ ## 已知平台限制(不是插件缺陷)
177
+
178
+ - iOS Safari 不支持桌面式拖放(Drag and Drop);依赖该交互的桌面功能在 iPhone 上不可用。
179
+ - iOS 会在后台挂起页面;长任务请保持 DSH 页面在前台。
180
+ - Safari 的下载行为由 iOS 的「文件」App 接管,不能像 macOS 一样直接保存到任意目录。
181
+ - 局域网 HTTP 不是安全上下文;依赖 `crypto.subtle`、Service Worker、Web Push 等安全上下文 API 的功能不可用。DSH 已对 UUID 和剪贴板做了回退,本插件再补一层 iOS 剪贴板回退。
182
+
183
+ ## 卸载
184
+
185
+ 通过 DSH 插件管理器移除 `dsh-mobile-access`。移除后:
186
+
187
+ - 左侧栏入口消失;
188
+ - `/m` 恢复为 SPA fallback(不再是配对页);
189
+ - `/api/mobile-access/*` 不再注册;
190
+ - iOS 剪贴板回退被恢复。
191
+
192
+ 已签发的登录 Cookie 仍可能留在浏览器中,直到过期;这是 DSH 认证层的行为,不是插件写入的额外凭证。
193
+
194
+ ## 版本
195
+
196
+ - `1.0.0` — 首个工程化版本:配对页、owner-only 状态/刷新、左侧栏面板、零 Mac 影响边界、单元/路由/静态门禁。
package/client.js ADDED
@@ -0,0 +1,396 @@
1
+ "use strict";
2
+ const STATE_PATH = '/api/mobile-access/state';
3
+ const ROTATE_PATH = '/api/mobile-access/rotate';
4
+ let React;
5
+ const h = (type, props, ...children) => React.createElement(type, props, ...children);
6
+ function useLocaleRevision(locale) {
7
+ const subscribe = React.useCallback((onChange) => {
8
+ if (locale === undefined)
9
+ return () => undefined;
10
+ return locale.subscribe(onChange);
11
+ }, [locale]);
12
+ const getSnapshot = React.useCallback(() => locale?.getSnapshot().revision ?? 0, [locale]);
13
+ React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
14
+ }
15
+ function isAppleMobile() {
16
+ const navigatorLike = navigator;
17
+ return /iPad|iPhone|iPod/.test(navigatorLike.userAgent)
18
+ || (navigatorLike.platform === 'MacIntel' && (navigatorLike.maxTouchPoints ?? 0) > 1);
19
+ }
20
+ function legacyCopy(value) {
21
+ const area = document.createElement('textarea');
22
+ area.value = value;
23
+ area.setAttribute('readonly', '');
24
+ area.style.position = 'fixed';
25
+ area.style.top = '-1000px';
26
+ area.style.left = '-1000px';
27
+ area.style.opacity = '0';
28
+ area.style.fontSize = '16px';
29
+ document.body.appendChild(area);
30
+ let copied = false;
31
+ try {
32
+ area.select();
33
+ area.setSelectionRange(0, value.length);
34
+ copied = document.execCommand('copy');
35
+ }
36
+ catch {
37
+ copied = false;
38
+ }
39
+ finally {
40
+ area.remove();
41
+ }
42
+ return copied;
43
+ }
44
+ async function copyText(value) {
45
+ const clipboard = navigator.clipboard;
46
+ if (clipboard?.writeText !== undefined) {
47
+ try {
48
+ await clipboard.writeText(value);
49
+ return true;
50
+ }
51
+ catch {
52
+ return legacyCopy(value);
53
+ }
54
+ }
55
+ return legacyCopy(value);
56
+ }
57
+ /**
58
+ * On iOS an HTTP LAN page has no `navigator.clipboard`. A few existing DSH
59
+ * controls call it directly, so install a narrowly scoped fallback there only.
60
+ * The original descriptor is restored when the plugin is disposed.
61
+ */
62
+ function installClipboardFallback() {
63
+ if (!isAppleMobile() || window.isSecureContext)
64
+ return () => undefined;
65
+ const navigatorLike = navigator;
66
+ if (navigatorLike.clipboard?.writeText !== undefined)
67
+ return () => undefined;
68
+ const fallback = {
69
+ writeText: async (value) => {
70
+ if (!legacyCopy(value))
71
+ throw new Error('clipboard fallback failed');
72
+ },
73
+ };
74
+ const prototype = Object.getPrototypeOf(navigator);
75
+ const targets = [prototype, navigator].filter((target) => target !== null);
76
+ for (const target of targets) {
77
+ let descriptor;
78
+ try {
79
+ descriptor = Object.getOwnPropertyDescriptor(target, 'clipboard');
80
+ if (descriptor?.configurable === false)
81
+ continue;
82
+ Object.defineProperty(target, 'clipboard', {
83
+ configurable: true,
84
+ enumerable: descriptor?.enumerable ?? true,
85
+ value: fallback,
86
+ });
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ return () => {
92
+ try {
93
+ if (descriptor === undefined) {
94
+ delete target.clipboard;
95
+ }
96
+ else {
97
+ Object.defineProperty(target, 'clipboard', descriptor);
98
+ }
99
+ }
100
+ catch {
101
+ // A host page that froze the descriptor wins; never throw during cleanup.
102
+ }
103
+ };
104
+ }
105
+ return () => undefined;
106
+ }
107
+ function useMobileAccessState() {
108
+ const [state, setState] = React.useState(null);
109
+ const [loading, setLoading] = React.useState(true);
110
+ const [error, setError] = React.useState(null);
111
+ const alive = React.useRef(true);
112
+ const reload = React.useCallback(async () => {
113
+ setLoading(true);
114
+ setError(null);
115
+ try {
116
+ const response = await fetch(STATE_PATH, {
117
+ cache: 'no-store',
118
+ credentials: 'same-origin',
119
+ headers: { accept: 'application/json' },
120
+ });
121
+ if (!response.ok)
122
+ throw new Error(`HTTP ${response.status}`);
123
+ const payload = await response.json();
124
+ if (payload?.ok !== true || typeof payload.expiresAt !== 'string')
125
+ throw new Error('invalid state payload');
126
+ if (alive.current)
127
+ setState(payload);
128
+ }
129
+ catch (caught) {
130
+ if (alive.current)
131
+ setError(caught instanceof Error ? caught.message : 'request failed');
132
+ }
133
+ finally {
134
+ if (alive.current)
135
+ setLoading(false);
136
+ }
137
+ }, []);
138
+ React.useEffect(() => {
139
+ alive.current = true;
140
+ void reload();
141
+ const onFocus = () => { void reload(); };
142
+ const onVisibility = () => {
143
+ if (document.visibilityState === 'visible')
144
+ void reload();
145
+ };
146
+ window.addEventListener('focus', onFocus);
147
+ document.addEventListener('visibilitychange', onVisibility);
148
+ return () => {
149
+ alive.current = false;
150
+ window.removeEventListener('focus', onFocus);
151
+ document.removeEventListener('visibilitychange', onVisibility);
152
+ };
153
+ }, [reload]);
154
+ return { state, loading, error, reload };
155
+ }
156
+ function useRemaining(expiresAt) {
157
+ const [now, setNow] = React.useState(() => Date.now());
158
+ React.useEffect(() => {
159
+ if (expiresAt === undefined)
160
+ return;
161
+ const expiry = new Date(expiresAt).getTime();
162
+ if (Number.isNaN(expiry) || Date.now() >= expiry)
163
+ return;
164
+ const timer = window.setInterval(() => {
165
+ const value = Date.now();
166
+ setNow(value);
167
+ if (value >= expiry)
168
+ window.clearInterval(timer);
169
+ }, 1000);
170
+ return () => window.clearInterval(timer);
171
+ }, [expiresAt]);
172
+ if (expiresAt === undefined)
173
+ return 0;
174
+ const expiry = new Date(expiresAt).getTime();
175
+ return Number.isNaN(expiry) ? 0 : Math.max(0, expiry - now);
176
+ }
177
+ function formatRemaining(milliseconds) {
178
+ const total = Math.max(0, Math.ceil(milliseconds / 1000));
179
+ const minutes = Math.floor(total / 60);
180
+ const seconds = total % 60;
181
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
182
+ }
183
+ async function runChecks() {
184
+ const checks = [];
185
+ checks.push(await copyText('D-SH-MOBILE-ACCESS-SELFTEST').then((ok) => ({ label: 'clipboard', status: ok ? 'pass' : 'fail', detail: ok ? 'writeText' : 'blocked' })));
186
+ const cryptoLike = globalThis.crypto;
187
+ checks.push({
188
+ label: 'crypto',
189
+ status: typeof cryptoLike?.getRandomValues === 'function' ? 'pass' : 'fail',
190
+ detail: typeof cryptoLike?.getRandomValues === 'function' ? 'getRandomValues' : 'missing',
191
+ });
192
+ try {
193
+ const key = '__dsh_mobile_access_probe__';
194
+ localStorage.setItem(key, '1');
195
+ localStorage.removeItem(key);
196
+ checks.push({ label: 'storage', status: 'pass', detail: 'localStorage' });
197
+ }
198
+ catch {
199
+ checks.push({ label: 'storage', status: 'warn', detail: 'unavailable or private mode' });
200
+ }
201
+ checks.push({
202
+ label: 'websocket',
203
+ status: typeof WebSocket === 'function' ? 'pass' : 'fail',
204
+ detail: typeof WebSocket === 'function' ? 'constructor' : 'missing',
205
+ });
206
+ checks.push({
207
+ label: 'viewport',
208
+ status: window.visualViewport != null ? 'pass' : 'warn',
209
+ detail: `${window.innerWidth}×${window.innerHeight}${window.visualViewport != null ? ` / vv ${Math.round(window.visualViewport.width)}` : ''}`,
210
+ });
211
+ checks.push({
212
+ label: 'network',
213
+ status: navigator.onLine ? 'pass' : 'warn',
214
+ detail: navigator.onLine ? 'online' : 'offline',
215
+ });
216
+ return checks;
217
+ }
218
+ function statusOf(state, loading, error, t) {
219
+ if (error !== null)
220
+ return { label: t('statusError'), tone: 'bad' };
221
+ if (state === null)
222
+ return { label: loading ? t('statusLoading') : t('statusError'), tone: 'muted' };
223
+ if (state.loopback)
224
+ return { label: t('statusLoopback'), tone: 'bad' };
225
+ if (state.locked)
226
+ return { label: t('statusLocked'), tone: 'bad' };
227
+ if (state.expired)
228
+ return { label: t('statusExpired'), tone: 'warn' };
229
+ return { label: t('statusReady'), tone: 'ok' };
230
+ }
231
+
232
+ "use strict";
233
+ const NS = 'dsh-mobile-access';
234
+ const PANEL_ID = 'mobile-access';
235
+ const ZH = {"panel":"手机访问","title":"手机访问","subtitle":"在同一局域网内,用 iPhone 或 iPad 打开这台 Mac 上的官方 DSH。","statusLoading":"正在读取配对状态","statusReady":"已就绪","statusLoopback":"仅回环地址","statusLocked":"配对码已锁定","statusExpired":"配对码已过期","statusError":"暂时无法读取状态","errorTitle":"无法读取配对状态","retry":"重新加载","urlLabel":"iPhone 打开的地址","urlHint":"在 iPhone 的 Safari 地址栏输入这个地址。","codeLabel":"配对码","codeHint":"在 iPhone 的配对页输入这 8 位数字。","copyLink":"复制地址","copyCode":"复制配对码","copied":"已复制","copyFailed":"复制失败,请手动选择文本","rotate":"刷新配对码","rotating":"正在刷新…","toastRotated":"配对码已刷新","rotateFailed":"刷新失败,请重试","expiry":"有效至","expiresIn":"剩余","expired":"已过期","locked":"已锁定","never":"尚未配对","lastPaired":"最近配对","pairCount":"成功配对次数","stepsTitle":"在 iPhone 上连接","step1":"确认 iPhone 与 Mac 连接同一个 Wi-Fi,且没有开启会隔离设备的访客网络。","step2":"在 iPhone Safari 打开上面的地址,输入配对码并点「连接 DSH」。","step3":"配对成功后会自动进入 DSH;再点 Safari 分享按钮,选择「添加到主屏幕」。","diagnosticsTitle":"连接诊断","bindLabel":"Mac 监听地址","deviceLabel":"当前设备","deviceIOS":"iPhone / iPad","deviceOther":"电脑或其他设备","launchMode":"启动方式","standaloneYes":"已从主屏幕启动","standaloneNo":"浏览器标签页","secureYes":"安全上下文","secureNo":"HTTP 非安全上下文(剪贴板回退已启用)","loopbackTitle":"iPhone 现在无法连接","loopbackBody":"DSH 只监听了回环地址(localhost / 127.0.0.1)。请把 Host 的 webserver.host 改为这台 Mac 的局域网 IPv4 地址,然后由你手动重启 DSH。","httpTitle":"关于 HTTP 访问","httpBody":"当前是局域网 HTTP。DSH 已为 UUID 和多数复制按钮提供兼容实现;本插件会在 iPhone 上补齐直接调用 navigator.clipboard 的旧代码。","selftestTitle":"浏览器能力自检","selftestRun":"运行自检","selftestRunning":"正在自检…","selftestNote":"自检只读取浏览器能力,不修改 DSH 配置,也不影响 Mac。","checkClipboard":"剪贴板写入","checkCrypto":"安全随机数","checkStorage":"本地存储","checkWebSocket":"WebSocket","checkViewport":"移动端视口","checkOnline":"网络状态","checkPass":"通过","checkWarn":"注意","checkFail":"失败","limitsTitle":"平台边界(不是插件缺陷)","limit1":"iOS Safari 不支持桌面式拖放;文件请用系统选择器上传。","limit2":"iOS 会在后台挂起网页;长任务请保持 DSH 页面在前台。","limit3":"下载文件由 Safari 交给「文件」App,落点由 iOS 决定。","zeroImpactTitle":"对 Mac 的零影响设计","zeroImpactBody":"插件只增加三个精确路由、一个左侧栏入口和一个主面板;不修改核心代码、认证、配置、主题、布局或其他插件,也不在后台轮询。","mainAria":"手机访问面板","codeAria":"配对码","reload":"刷新","unknown":"未知"};
236
+ const EN = {"panel":"Mobile Access","title":"Mobile Access","subtitle":"Open this Mac’s official DSH from an iPhone or iPad on the same LAN.","statusLoading":"Reading pairing state","statusReady":"Ready","statusLoopback":"Loopback only","statusLocked":"Code locked","statusExpired":"Code expired","statusError":"State unavailable","errorTitle":"Could not read pairing state","retry":"Reload","urlLabel":"Address for iPhone","urlHint":"Type this address into Safari on the iPhone.","codeLabel":"Pairing code","codeHint":"Enter these 8 digits on the iPhone pairing page.","copyLink":"Copy address","copyCode":"Copy code","copied":"Copied","copyFailed":"Copy failed; select the text manually","rotate":"New code","rotating":"Rotating…","toastRotated":"Pairing code rotated","rotateFailed":"Rotation failed; try again","expiry":"Valid until","expiresIn":"Remaining","expired":"Expired","locked":"Locked","never":"Never","lastPaired":"Last paired","pairCount":"Successful pairings","stepsTitle":"Connect from iPhone","step1":"Put the iPhone and the Mac on the same Wi-Fi, without client isolation.","step2":"Open the address in Safari, enter the code, and tap “Connect DSH”.","step3":"After pairing, use Safari Share → “Add to Home Screen”.","diagnosticsTitle":"Connection diagnostics","bindLabel":"Mac listener","deviceLabel":"This device","deviceIOS":"iPhone / iPad","deviceOther":"Computer or other device","launchMode":"Launch mode","standaloneYes":"Launched from Home Screen","standaloneNo":"Browser tab","secureYes":"Secure context","secureNo":"HTTP insecure context (clipboard fallback enabled)","loopbackTitle":"The iPhone cannot connect yet","loopbackBody":"DSH is listening on loopback only. Set Host webserver.host to this Mac’s LAN IPv4 address, then restart DSH manually.","httpTitle":"About HTTP","httpBody":"This is plain-HTTP LAN access. DSH already handles UUIDs and most copy buttons; this plugin adds a fallback for older direct navigator.clipboard calls on iPhone.","selftestTitle":"Browser capability self-test","selftestRun":"Run self-test","selftestRunning":"Testing…","selftestNote":"The self-test only reads browser capabilities; it does not change DSH or affect the Mac.","checkClipboard":"Clipboard write","checkCrypto":"Secure random","checkStorage":"Local storage","checkWebSocket":"WebSocket","checkViewport":"Mobile viewport","checkOnline":"Network status","checkPass":"Pass","checkWarn":"Note","checkFail":"Fail","limitsTitle":"Platform limits (not plugin defects)","limit1":"iOS Safari has no desktop drag-and-drop; use the system file picker.","limit2":"iOS suspends background pages; keep DSH in the foreground for long tasks.","limit3":"Safari hands downloads to the Files app; iOS chooses the location.","zeroImpactTitle":"Zero-impact design for the Mac","zeroImpactBody":"The plugin adds exactly three routes, one sidebar entry, and one main panel. It does not modify core code, auth, config, theme, layout, or other plugins, and it never polls in the background.","mainAria":"Mobile access panel","codeAria":"Pairing code","reload":"Reload","unknown":"Unknown"};
237
+ function createPanel(t, locale) {
238
+ return function MobileAccessPanel() {
239
+ useLocaleRevision(locale);
240
+ const { state, loading, error, reload } = useMobileAccessState();
241
+ const [rotating, setRotating] = React.useState(false);
242
+ const [testing, setTesting] = React.useState(false);
243
+ const [checks, setChecks] = React.useState(null);
244
+ const [toast, setToast] = React.useState(null);
245
+ const toastTimer = React.useRef(undefined);
246
+ const remaining = useRemaining(state?.expiresAt);
247
+ const status = statusOf(state, loading, error, t);
248
+ const isIOS = isAppleMobile();
249
+ const standalone = navigator.standalone === true
250
+ || window.matchMedia('(display-mode: standalone)').matches;
251
+ const flash = (message) => {
252
+ setToast(message);
253
+ if (toastTimer.current !== undefined)
254
+ window.clearTimeout(toastTimer.current);
255
+ toastTimer.current = window.setTimeout(() => setToast(null), 2400);
256
+ };
257
+ React.useEffect(() => () => {
258
+ if (toastTimer.current !== undefined)
259
+ window.clearTimeout(toastTimer.current);
260
+ }, []);
261
+ const copy = async (value) => {
262
+ flash(await copyText(value) ? t('copied') : t('copyFailed'));
263
+ };
264
+ const rotate = async () => {
265
+ setRotating(true);
266
+ try {
267
+ const response = await fetch(ROTATE_PATH, {
268
+ method: 'POST',
269
+ cache: 'no-store',
270
+ credentials: 'same-origin',
271
+ headers: { accept: 'application/json' },
272
+ });
273
+ if (!response.ok)
274
+ throw new Error(`HTTP ${response.status}`);
275
+ await reload();
276
+ flash(t('toastRotated'));
277
+ }
278
+ catch {
279
+ flash(t('rotateFailed'));
280
+ }
281
+ finally {
282
+ setRotating(false);
283
+ }
284
+ };
285
+ const selfTest = async () => {
286
+ setTesting(true);
287
+ try {
288
+ setChecks(await runChecks());
289
+ }
290
+ finally {
291
+ setTesting(false);
292
+ }
293
+ };
294
+ const labels = {
295
+ clipboard: t('checkClipboard'),
296
+ crypto: t('checkCrypto'),
297
+ storage: t('checkStorage'),
298
+ websocket: t('checkWebSocket'),
299
+ viewport: t('checkViewport'),
300
+ network: t('checkOnline'),
301
+ };
302
+ const statusText = {
303
+ pass: t('checkPass'),
304
+ warn: t('checkWarn'),
305
+ fail: t('checkFail'),
306
+ };
307
+ const codeBlock = state === null
308
+ ? null
309
+ : h('div', { className: 'dsh-ma-card dsh-ma-pair' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('p', { className: 'dsh-ma-label' }, t('codeLabel')), h('div', { className: 'dsh-ma-code', 'aria-label': t('codeAria') }, state.displayCode), h('p', { className: 'dsh-ma-hint' }, t('codeHint'))), h('div', { className: `dsh-ma-pill ${state.loopback || state.locked ? 'is-bad' : state.expired ? 'is-warn' : 'is-ok'}` }, h('span', { className: 'dsh-ma-dot' }), status.label)), h('div', { className: 'dsh-ma-actions' }, h('button', { type: 'button', className: 'dsh-ma-button primary', onClick: () => { void copy(state.pairUrl); }, disabled: state.loopback }, t('copyLink')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void copy(state.displayCode); } }, t('copyCode')), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void rotate(); }, disabled: rotating }, rotating ? t('rotating') : t('rotate'))), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('expiry')), h('dd', null, new Date(state.expiresAt).toLocaleTimeString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('expiresIn')), h('dd', { className: remaining === 0 ? 'is-bad' : '' }, remaining === 0 ? t('expired') : formatRemaining(remaining))), h('div', null, h('dt', null, t('lastPaired')), h('dd', null, state.lastPairedAt === null ? t('never') : new Date(state.lastPairedAt).toLocaleString(undefined, { hour12: false }))), h('div', null, h('dt', null, t('pairCount')), h('dd', null, String(state.pairingCount)))));
310
+ return h('div', { className: 'dsh-ma', 'data-testid': 'mobile-access-panel', 'data-state': status.tone }, h('style', null, STYLES), h('div', { className: 'dsh-ma-inner', role: 'region', 'aria-label': t('mainAria') }, h('header', { className: 'dsh-ma-head' }, h('div', { className: 'dsh-ma-mark' }, 'D'), h('div', null, h('h1', null, t('title')), h('p', null, t('subtitle')))), h('div', { className: 'dsh-ma-card dsh-ma-url' }, h('p', { className: 'dsh-ma-label' }, t('urlLabel')), state === null
311
+ ? h('p', { className: 'dsh-ma-hint' }, loading ? t('statusLoading') : t('statusError'))
312
+ : h('code', null, state.pairUrl), h('p', { className: 'dsh-ma-hint' }, t('urlHint')), state?.loopback ? h('div', { className: 'dsh-ma-alert' }, h('strong', null, t('loopbackTitle')), h('p', null, t('loopbackBody'))) : null), error !== null ? h('div', { className: 'dsh-ma-alert' }, h('strong', null, t('errorTitle')), h('p', null, error), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void reload(); } }, t('retry'))) : null, state === null && loading ? h('div', { className: 'dsh-ma-card dsh-ma-hint' }, t('statusLoading')) : null, codeBlock, h('div', { className: 'dsh-ma-card' }, h('h2', null, t('stepsTitle')), h('ol', { className: 'dsh-ma-steps' }, h('li', null, t('step1')), h('li', null, t('step2')), h('li', null, t('step3')))), h('div', { className: 'dsh-ma-card' }, h('h2', null, t('diagnosticsTitle')), h('dl', { className: 'dsh-ma-meta' }, h('div', null, h('dt', null, t('bindLabel')), h('dd', null, state === null ? t('unknown') : `${state.bindHost}:${state.port}`)), h('div', null, h('dt', null, t('deviceLabel')), h('dd', null, isIOS ? t('deviceIOS') : t('deviceOther'))), h('div', null, h('dt', null, t('launchMode')), h('dd', null, standalone ? t('standaloneYes') : t('standaloneNo'))), h('div', null, h('dt', null, t('secureYes')), h('dd', null, window.isSecureContext ? t('secureYes') : t('secureNo'))))), h('div', { className: 'dsh-ma-card' }, h('div', { className: 'dsh-ma-row' }, h('div', null, h('h2', null, t('selftestTitle')), h('p', { className: 'dsh-ma-hint' }, t('selftestNote'))), h('button', { type: 'button', className: 'dsh-ma-button', onClick: () => { void selfTest(); }, disabled: testing }, testing ? t('selftestRunning') : t('selftestRun'))), checks === null ? null : h('ul', { className: 'dsh-ma-checks' }, ...checks.map((check, index) => h('li', { key: index, className: `is-${check.status}` }, h('span', { className: 'dsh-ma-dot' }), h('span', { className: 'dsh-ma-check-label' }, labels[check.label] ?? check.label), h('span', { className: 'dsh-ma-check-status' }, statusText[check.status]), h('span', { className: 'dsh-ma-check-detail' }, check.detail))))), h('div', { className: 'dsh-ma-card dsh-ma-note' }, h('h2', null, t('httpTitle')), h('p', null, t('httpBody')), h('h2', null, t('zeroImpactTitle')), h('p', null, t('zeroImpactBody'))), h('details', { className: 'dsh-ma-card dsh-ma-details' }, h('summary', null, t('limitsTitle')), h('ul', null, h('li', null, t('limit1')), h('li', null, t('limit2')), h('li', null, t('limit3')))), h('p', { className: 'dsh-ma-toast', 'aria-live': 'polite' }, toast ?? '')));
313
+ };
314
+ }
315
+ const STYLES = ".dsh-ma{height:100%;min-height:100%;overflow:auto;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827)}\n.dsh-ma *{box-sizing:border-box}\n.dsh-ma-inner{width:100%;max-width:860px;margin:0 auto;padding:clamp(18px,4vw,36px);display:flex;flex-direction:column;gap:14px}\n.dsh-ma-head{display:flex;align-items:center;gap:14px;margin-bottom:4px}\n.dsh-ma-mark{display:grid;place-items:center;flex:0 0 46px;width:46px;height:46px;border-radius:14px;background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff;font:800 21px/1 -apple-system,BlinkMacSystemFont,sans-serif}\n.dsh-ma-head h1{margin:0 0 3px;font-size:24px;line-height:1.2;letter-spacing:-.02em}\n.dsh-ma-head p{margin:0;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.5}\n.dsh-ma-card{padding:16px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:16px;background:var(--dsw-alias-bg-layer-1,#f8fafc)}\n.dsh-ma-card h2{margin:0 0 8px;font-size:15px;line-height:1.35}\n.dsh-ma-card p{margin:0 0 8px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.6}\n.dsh-ma-card p:last-child{margin-bottom:0}\n.dsh-ma-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}\n.dsh-ma-label{margin:0 0 5px!important;color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important;font-weight:700;letter-spacing:.06em;text-transform:uppercase}\n.dsh-ma-url code{display:block;padding:12px 13px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:11px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:600 15px/1.45 ui-monospace,\"SF Mono\",Menlo,monospace;word-break:break-all}\n.dsh-ma-hint{color:var(--dsw-alias-label-tertiary,#6b7280)!important;font-size:12px!important}\n.dsh-ma-code{margin:3px 0 6px;font:800 clamp(28px,6vw,40px)/1.1 ui-monospace,\"SF Mono\",Menlo,monospace;font-variant-numeric:tabular-nums;letter-spacing:.1em;color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-pill{display:inline-flex;align-items:center;gap:7px;min-height:28px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe4ec);border-radius:999px;font-size:12px;font-weight:700;white-space:nowrap}\n.dsh-ma-pill.is-ok{color:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn{color:#b45309}\n.dsh-ma-pill.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-dot{display:inline-block;flex:0 0 8px;width:8px;height:8px;border-radius:50%;background:currentColor}\n.dsh-ma-pill.is-ok .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-pill.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-pill.is-bad .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n.dsh-ma-button{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 13px;border:1px solid var(--dsw-alias-border-l2,#d5dbe5);border-radius:10px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#111827);font:650 13px/1.2 -apple-system,BlinkMacSystemFont,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent}\n.dsh-ma-button.primary{border-color:var(--dsw-alias-brand-primary,#2f5bea);background:var(--dsw-alias-brand-primary,#2f5bea);color:#fff}\n.dsh-ma-button:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#2f5bea)}\n.dsh-ma-button:disabled{opacity:.55;cursor:not-allowed}\n.dsh-ma-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#2f5bea);outline-offset:2px}\n.dsh-ma-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px 16px;margin:8px 0 0}\n.dsh-ma-meta div{min-width:0}\n.dsh-ma-meta dt{margin:0 0 2px;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}\n.dsh-ma-meta dd{margin:0;font-size:13px;line-height:1.45;word-break:break-word}\n.dsh-ma-meta dd.is-bad{color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert{margin:12px 0 0;padding:12px 13px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 35%,transparent);border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#b42318) 8%,transparent);color:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-alert strong{display:block;margin-bottom:4px;font-size:13px}\n.dsh-ma-alert p{color:inherit!important;font-size:13px!important;line-height:1.55!important}\n.dsh-ma-steps{margin:0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:14px;line-height:1.65}\n.dsh-ma-steps li{margin:0 0 7px;padding-left:2px}\n.dsh-ma-steps li:last-child{margin-bottom:0}\n.dsh-ma-checks{display:flex;flex-direction:column;gap:7px;margin:10px 0 0;padding:0;list-style:none}\n.dsh-ma-checks li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:13px;color:var(--dsw-alias-label-secondary,#5f6b7a)}\n.dsh-ma-checks li.is-pass .dsh-ma-dot{background:var(--dsw-alias-state-success-primary,#087443)}\n.dsh-ma-checks li.is-warn .dsh-ma-dot{background:#d97706}\n.dsh-ma-checks li.is-fail .dsh-ma-dot{background:var(--dsw-alias-state-error-primary,#b42318)}\n.dsh-ma-check-label{color:var(--dsw-alias-label-primary,#111827);font-weight:650}\n.dsh-ma-check-status{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}\n.dsh-ma-check-detail{margin-left:auto;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px;word-break:break-word}\n.dsh-ma-note h2{font-size:13px;margin-top:4px}\n.dsh-ma-details summary{cursor:pointer;font-size:14px;font-weight:700}\n.dsh-ma-details ul{margin:10px 0 0;padding-left:20px;color:var(--dsw-alias-label-secondary,#5f6b7a);font-size:13px;line-height:1.65}\n.dsh-ma-toast{min-height:20px;margin:0!important;text-align:center;color:var(--dsw-alias-brand-primary,#2f5bea)!important;font-size:13px!important;font-weight:650}\n@media (max-width:560px){\n.dsh-ma-inner{padding:max(14px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left));gap:11px}\n.dsh-ma-card{padding:14px;border-radius:14px}\n.dsh-ma-head h1{font-size:21px}\n.dsh-ma-mark{flex-basis:40px;width:40px;height:40px;border-radius:12px;font-size:18px}\n.dsh-ma-actions .dsh-ma-button{flex:1 1 140px;min-height:44px}\n.dsh-ma-check-detail{width:100%;margin-left:16px}\n}\n@media (prefers-reduced-motion:no-preference){\n.dsh-ma-button{transition:border-color .15s ease,background-color .15s ease,opacity .15s ease}\n}";
316
+ function factory(require) {
317
+ React = require('react');
318
+ return {
319
+ inject: ['slots', 'locale'],
320
+ apply(ctx) {
321
+ ctx.effect(() => {
322
+ const cleanups = [];
323
+ let translate = (key) => ZH[key] ?? key;
324
+ try {
325
+ if (ctx.locale !== undefined) {
326
+ cleanups.push(ctx.locale.register(NS, { zh: ZH, en: EN }));
327
+ translate = ctx.locale.bind(NS);
328
+ }
329
+ }
330
+ catch (error) {
331
+ ctx.logger?.error?.(`[mobile-access] locale registration failed: ${String(error)}`);
332
+ }
333
+ const t = (key) => translate(key);
334
+ const Panel = createPanel(t, ctx.locale);
335
+ const safe = (label, register) => {
336
+ try {
337
+ cleanups.push(register());
338
+ }
339
+ catch (error) {
340
+ ctx.logger?.error?.(`[mobile-access] ${label} registration failed: ${String(error)}`);
341
+ }
342
+ };
343
+ safe('main panel', () => ctx.slots.inject('main', () => ctx.slots.register({
344
+ name: 'main',
345
+ key: PANEL_ID,
346
+ locale: NS,
347
+ }, Panel)));
348
+ safe('sidebar entry', () => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
349
+ name: 'sidebar.panellist',
350
+ id: PANEL_ID,
351
+ order: 15,
352
+ locale: NS,
353
+ label: () => t('panel'),
354
+ }, PhoneIcon)));
355
+ try {
356
+ cleanups.push(installClipboardFallback());
357
+ }
358
+ catch (error) {
359
+ ctx.logger?.warn?.(`[mobile-access] clipboard fallback unavailable: ${String(error)}`);
360
+ }
361
+ return () => {
362
+ for (const cleanup of cleanups.reverse()) {
363
+ try {
364
+ cleanup();
365
+ }
366
+ catch {
367
+ // Plugin cleanup must never break the host UI.
368
+ }
369
+ }
370
+ };
371
+ }, 'mobile-access client');
372
+ },
373
+ };
374
+ }
375
+ function PhoneIcon(props) {
376
+ const size = props.size ?? 24;
377
+ return h('svg', {
378
+ width: size,
379
+ height: size,
380
+ viewBox: '0 0 24 24',
381
+ fill: 'none',
382
+ stroke: 'currentColor',
383
+ strokeWidth: 1.8,
384
+ strokeLinecap: 'round',
385
+ strokeLinejoin: 'round',
386
+ 'aria-hidden': 'true',
387
+ focusable: 'false',
388
+ }, h('rect', { x: 6.2, y: 2.4, width: 11.6, height: 19.2, rx: 2.8 }), h('path', { d: 'M10 5.2h4' }), h('path', { d: 'M9.6 18.8h4.8' }), props.active === true ? h('path', { d: 'M9.5 10.2c1.4-1.4 3.6-1.4 5 0M10.8 12.4c.8-.8 1.8-.8 2.4 0' }) : null);
389
+ }
390
+ const loader = window.__ModuleLoader__;
391
+ if (loader === undefined) {
392
+ console.error('[mobile-access] window.__ModuleLoader__ is unavailable; the client half was not loaded.');
393
+ }
394
+ else {
395
+ loader.load({ id: 'dsh-mobile-access', factory });
396
+ }
@@ -0,0 +1,13 @@
1
+ # dsh-mobile-access bundle patch.
2
+ #
3
+ # One Host row. The Host half registers exactly three routes:
4
+ # GET /m public pairing page
5
+ # GET /api/mobile-access/state owner-only state snapshot
6
+ # POST /api/mobile-access/rotate owner-only code rotation
7
+ #
8
+ # The Web client half adds one sidebar entry and one main panel. No core row is
9
+ # replaced, wrapped, or reconfigured. Removing the bundle removes the row.
10
+ - insert:
11
+ - id: dsh-mobile-access
12
+ name: dsh-mobile-access
13
+ config: {}