@khorsheed/dsh-mobile 0.1.1 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.1.2(2026-09-28)
4
+
5
+ - **public tunnel 恢复**,QR 码按隧道可达性门禁——隧道不通时不再展示一张扫不开的二维码
6
+
3
7
  ## 0.1.1(2026-09-27)
4
8
 
5
9
  按活体试用反馈修复六处移动端问题;README 中英统一为中文主版并落地实拍截图(两张从未存在的引用删除)。对宿主 `0.1.7-rc.2` 类型面构建+测试通过(2026-09-27)。
package/README.en.md CHANGED
@@ -6,9 +6,9 @@ Your dsh sessions, in your pocket — the Mac still runs the sessions, models, s
6
6
 
7
7
  The official Web client is desktop-shaped: a sidebar that eats the screen, a dense composer, touch targets made for a mouse. This plugin re-presents the very same client for narrow touch screens — a conversation library with grouping and search, a wrapping composer with reachable controls, a sheet for picking directories on the computer — and adds an optional [iOS shell](../../apps/ios/README.md) that embeds that same official Web client and pairs to it over a QR login link. Everything stays removable: uninstalling restores the official desktop UI exactly, and no Host or sibling-plugin state is ever written.
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/mobile-library.png" width="640" alt="the mobile conversation library: time and workspace grouping, the bottom search field and the QR pairing entry">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/mobile-library.png" width="640" alt="the mobile conversation library: time and workspace grouping, the bottom search field and the QR pairing entry">
10
10
 
11
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/mobile-conversation.png" width="640" alt="a conversation in mobile layout: the session header with its Members tab, the message flow, and the composer toolbar at the bottom">
11
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/mobile-conversation.png" width="640" alt="a conversation in mobile layout: the session header with its Members tab, the message flow, and the composer toolbar at the bottom">
12
12
 
13
13
  ## Features
14
14
 
@@ -55,6 +55,15 @@ DSH_MOBILE_PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com dsh web --no-open -
55
55
 
56
56
  Different physical networks need an independently configured HTTPS/WSS ingress to the loopback Host port, valid official authentication, an awake/online Mac and a reachable phone — starting the Host alone does not establish remote access, and the App does not configure networking or credentials for you. Debug simulator builds allow HTTP on loopback only; Release requires HTTPS.
57
57
 
58
+
59
+ ### Desktop Quick Tunnel recovery
60
+
61
+ The connection page anonymously probes the configured public root before offering or generating a QR. An unavailable origin returns an actionable state without issuing a login URL. A displayed QR is checked every 15 seconds and hidden if the endpoint fails or changes. This is a computer-side reachability check, not a guarantee about the phone's network.
62
+
63
+ For this repository's supervised 3080 deployment, `pnpm mobile:tunnel` runs an opt-in desktop recovery manager; `--repair-now` skips the initial three-check delay. It checks every 30 seconds, waits through a local host outage, and rebuilds only after three consecutive public failures. Recovery attempts have a ten-minute cooldown. It starts an exact-origin loopback ingress and a new Quick Tunnel, then calls `pnpm deploy:3080 --package packages/mobile --mobile-origin https://new-link.trycloudflare.com`. This runs the normal build/test and guarded `reconfigure` path, updating the existing public-origin/trusted-host bindings and preserving other launch settings. It restarts the host; failure retains the guard's restore-previous behavior. Changed mobile/deployment source files block automated deployment.
64
+
65
+ Keep the manager running under the computer's service manager (for example, a per-user macOS LaunchAgent). It is separate from the host process and is not installed by the plugin. `DSH_HOME` selects the existing supervised deployment; `CLOUDFLARED_BIN` optionally supplies the installed cloudflared binary. State and diagnostic logs live under `$DSH_HOME/state/mobile-tunnel/`; they contain no login tokens. Only an existing explicit Quick Tunnel binding is supported: no wildcard trusted hosts, account provisioning, or automatic changes to the phone. After rotation, reopen **Connect phone** and scan its new QR.
66
+
58
67
  ## Optional: a Quick Tunnel preview
59
68
 
60
69
  Networking is deployment configuration, not a plugin dependency — bring your own HTTPS reverse tunnel/server or private networking; the plugin embeds no domain, server or Cloudflare account. For temporary previews, [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) mint a temporary HTTPS address without an account or domain: no uptime guarantee, 200 concurrent requests, no SSE (the tested conversation transport uses WebSocket).
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/mobile/README.en.md
5
- packages/mobile/README.en.md: 68ecfef324379a455d01be189d2febf9d99e4c1c
6
- packages/mobile/README.md: 23668e6d25f50d04c0b8e3184294dd86d7e626b3
5
+ packages/mobile/README.en.md: aedcdd10fd6a33f134e2b47764852c251e8571ae
6
+ packages/mobile/README.md: 872d2ff32cf881a39fa40404ed7f5edd17d06c41
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  官方 Web 客户端是桌面形态:侧栏吃掉半个屏幕、输入区密密麻麻、触控目标按鼠标设计。这个插件把同一套客户端重新呈现给窄屏触控设备——带分组和搜索的会话首页、可换行且够得着的输入工具栏、在手机上选电脑目录的面板——再配上一个可选的 [iOS 薄壳](../../apps/ios/README.zh.md):嵌入同一套官方 Web 客户端,扫码登录即用。一切都可以干净移除:卸载即精确还原官方桌面界面,绝不写入宿主或兄弟插件的状态。
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/mobile-library.png" width="640" alt="移动端会话首页:时间与工作区分组、底部搜索框与扫码入口">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/mobile-library.png" width="640" alt="移动端会话首页:时间与工作区分组、底部搜索框与扫码入口">
10
10
 
11
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/mobile-conversation.png" width="640" alt="移动布局下的会话页:会话头部与成员 tab、消息流、底部输入工具栏">
11
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-basic/main/docs/screenshots/mobile-conversation.png" width="640" alt="移动布局下的会话页:会话头部与成员 tab、消息流、底部输入工具栏">
12
12
 
13
13
  ## 特性
14
14
 
@@ -55,6 +55,15 @@ DSH_MOBILE_PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com dsh web --no-open -
55
55
 
56
56
  跨物理网络需要独立配置的 HTTPS/WSS 入口转发到 loopback Host 端口、有效官方认证、保持唤醒联网的 Mac 和网络可达的手机——仅启动 Host 不等于远程可用,App 也不会替你配置网络或凭据。Debug 模拟器构建仅允许 loopback HTTP,Release 要求 HTTPS。
57
57
 
58
+
59
+ ### 电脑端 Quick Tunnel 自动恢复
60
+
61
+ 连接手机页面在提供或生成二维码前,会匿名检查已配置的公网入口。不可达时返回明确状态,不签发登录链接。正在展示的二维码每 15 秒复查一次,入口失效或变化时收起。这是电脑侧连通性检查,不能保证手机所处网络同样可达。
62
+
63
+ 本仓库受监督的 3080 部署可选择运行 `pnpm mobile:tunnel`,`--repair-now` 可跳过初始三次检测的等待。管理进程每 30 秒检测一次,本地宿主不可用时等待;公网连续三次失败才重建,恢复尝试之间至少间隔十分钟。它启动严格绑定新域名的 loopback 转发和新 Quick Tunnel,再调用 `pnpm deploy:3080 --package packages/mobile --mobile-origin https://new-link.trycloudflare.com`,通过正常构建、测试及受控 `reconfigure` 更新原有公网地址和信任域名,其余启动设置不变。此过程会重启宿主,失败保留守护进程的回退机制。移动包或部署代码存在未提交修改时,自动部署会被阻止。
64
+
65
+ 通过电脑的服务管理器保持运行,例如 macOS 的用户级 LaunchAgent。它独立于宿主进程,不由插件自动安装。`DSH_HOME` 指向现有受监督部署,`CLOUDFLARED_BIN` 可指定已安装的 cloudflared。状态和诊断日志位于 `$DSH_HOME/state/mobile-tunnel/`,不包含登录 token。仅支持已明确配置的 Quick Tunnel:不增加通配信任、不注册账号、不自动修改手机地址。域名切换后重新打开「连接手机」,扫描新二维码即可。
66
+
58
67
  ## 可选:Quick Tunnel 预览
59
68
 
60
69
  网络接入属于部署配置,不是插件依赖——自建 HTTPS 反向隧道/服务器或私网连接均可;插件不嵌入任何域名、服务器或 Cloudflare 账号。临时预览可用 [Cloudflare Quick Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/):无需账号或自有域名即可生成临时 HTTPS 地址,但不保证在线时间、限 200 并发、不支持 SSE(实测会话传输走 WebSocket)。
package/lib/client.js CHANGED
@@ -2603,9 +2603,16 @@ window.__ModuleLoader__.load({
2603
2603
  body: "{}"
2604
2604
  } : {}
2605
2605
  });
2606
- if (!response.ok) throw new Error("unavailable");
2607
2606
  const result = await response.json();
2608
2607
  if (controller.signal.aborted) return;
2608
+ if (!response.ok) {
2609
+ if (result.state === "unreachable") {
2610
+ setInfo(result);
2611
+ return;
2612
+ }
2613
+ throw new Error("unavailable");
2614
+ }
2615
+ if (controller.signal.aborted) return;
2609
2616
  if (generate) {
2610
2617
  const url = new URL(result.loginUrl);
2611
2618
  if (!info?.origin || url.origin !== info.origin || url.protocol !== "https:" || !url.searchParams.get("token")) throw new Error("invalid");
@@ -2632,6 +2639,38 @@ window.__ModuleLoader__.load({
2632
2639
  window.removeEventListener("pagehide", hide);
2633
2640
  };
2634
2641
  }, []);
2642
+ (0, react.useEffect)(() => {
2643
+ if (!login) return;
2644
+ const controller = new AbortController();
2645
+ let checking = false;
2646
+ const poll = setInterval(async () => {
2647
+ if (checking) return;
2648
+ checking = true;
2649
+ try {
2650
+ const response = await fetch(CONNECT_PATH, {
2651
+ credentials: "same-origin",
2652
+ cache: "no-store",
2653
+ signal: controller.signal
2654
+ });
2655
+ if (!response.ok) throw new Error("unavailable");
2656
+ const next = await response.json();
2657
+ if (controller.signal.aborted) return;
2658
+ setInfo(next);
2659
+ if (next.state !== "ready" || next.origin !== new URL(login).origin) hide();
2660
+ } catch {
2661
+ if (!controller.signal.aborted) {
2662
+ hide();
2663
+ setFailed(true);
2664
+ }
2665
+ } finally {
2666
+ checking = false;
2667
+ }
2668
+ }, 15e3);
2669
+ return () => {
2670
+ controller.abort();
2671
+ clearInterval(poll);
2672
+ };
2673
+ }, [login]);
2635
2674
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2636
2675
  "data-mobile-connect": true,
2637
2676
  children: [
@@ -2644,7 +2683,7 @@ window.__ModuleLoader__.load({
2644
2683
  }),
2645
2684
  info && info.state !== "ready" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2646
2685
  role: "status",
2647
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t(info.state === "untrusted-origin" ? "connectUntrusted" : info.state === "unsupported" ? "connectUnsupported" : "connectSetup") })
2686
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t(info.state === "unreachable" ? "connectUnreachable" : info.state === "untrusted-origin" ? "connectUntrusted" : info.state === "unsupported" ? "connectUnsupported" : "connectSetup") })
2648
2687
  }),
2649
2688
  failed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2650
2689
  role: "alert",
@@ -5844,6 +5883,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5844
5883
  connectStepConfirm: "Scan this code, check the host address, then confirm the connection.",
5845
5884
  connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.",
5846
5885
  connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.",
5886
+ connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.",
5847
5887
  connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.",
5848
5888
  connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.",
5849
5889
  connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.",
@@ -5980,6 +6020,7 @@ html[data-dsh-mobile] [data-mobile-picker=preset] [data-mobile-picker-divider] {
5980
6020
  connectStepConfirm: "扫描二维码,核对主机地址后确认连接。",
5981
6021
  connectPrivacy: "二维码可授予这台主机的访问权限,请勿分享。隐藏二维码不会撤销官方登录链接;链接在宿主进程重启前仍然有效。",
5982
6022
  connectNetwork: "请保持电脑和 HTTPS 入口在线。扫码用于重新连接 App,无法修复已经断开的隧道。",
6023
+ connectUnreachable: "公网入口暂不可用。请等待电脑端恢复后重试,获取新的二维码。",
5983
6024
  connectSetup: "请由部署者通过 mobile.publicOrigin 或 DSH_MOBILE_PUBLIC_ORIGIN 配置手机可达的 HTTPS 地址,并将域名加入宿主信任列表。",
5984
6025
  connectUntrusted: "宿主尚未信任配置的地址。请更新 --trusted-host 并重启宿主后再生成二维码。",
5985
6026
  connectUnsupported: "此宿主版本不能提供官方登录链接,请更新到支持的宿主和插件组合。",
package/lib/index.js CHANGED
@@ -23,6 +23,25 @@ function mobileHandshake() {
23
23
  }
24
24
  const DIRECTORY_PATH = "/api/mobile/directories";
25
25
  //#endregion
26
+ //#region lib/types/reachability.js
27
+ /** Probe only the operator-configured origin, anonymously and without redirects.
28
+ * Official protected roots answer 401; unprotected roots can answer 2xx.
29
+ * A login token must never be used as a connectivity probe. */
30
+ async function publicOriginReachable(origin, request = fetch) {
31
+ try {
32
+ const response = await request(`${origin}/`, {
33
+ method: "GET",
34
+ redirect: "manual",
35
+ signal: AbortSignal.timeout(6e3),
36
+ headers: { "cache-control": "no-cache" }
37
+ });
38
+ await response.body?.cancel();
39
+ return response.status === 401 || response.ok;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+ //#endregion
26
45
  //#region lib/types/connect.js
27
46
  const headers = {
28
47
  "cache-control": "no-store",
@@ -65,10 +84,14 @@ function connectInfo(connection, configured) {
65
84
  };
66
85
  }
67
86
  /** Called only behind the official authenticated Fetch registry. */
68
- function connectResponse(connection, configured) {
87
+ function connectResponse(connection, configured, reachable = publicOriginReachable) {
69
88
  return async (request) => {
70
89
  const info = connectInfo(connection, configured);
71
- if (request.method === "GET") return Response.json(info, { headers });
90
+ const checked = async () => info.state === "ready" && info.origin && !await reachable(info.origin) ? {
91
+ ...info,
92
+ state: "unreachable"
93
+ } : info;
94
+ if (request.method === "GET") return Response.json(await checked(), { headers });
72
95
  let sameOrigin = false;
73
96
  try {
74
97
  const origin = new URL(request.headers.get("origin") ?? "");
@@ -83,6 +106,11 @@ function connectResponse(connection, configured) {
83
106
  status: 409,
84
107
  headers
85
108
  });
109
+ const live = await checked();
110
+ if (live.state !== "ready") return Response.json(live, {
111
+ status: 503,
112
+ headers
113
+ });
86
114
  try {
87
115
  const login = new URL(connection.authenticatedUrl(info.origin));
88
116
  if (login.origin !== info.origin || login.pathname !== "/" || login.username || login.password || login.hash || !login.searchParams.get("token") || [...login.searchParams].length !== 1) throw new Error("unsupported");
@@ -32,9 +32,16 @@ export function ConnectPhone({ t }) {
32
32
  try {
33
33
  const response = await fetch(CONNECT_PATH, { method: generate ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', signal: controller.signal,
34
34
  ...(generate ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}) });
35
- if (!response.ok)
36
- throw new Error('unavailable');
37
35
  const result = await response.json();
36
+ if (controller.signal.aborted)
37
+ return;
38
+ if (!response.ok) {
39
+ if (result.state === 'unreachable') {
40
+ setInfo(result);
41
+ return;
42
+ }
43
+ throw new Error('unavailable');
44
+ }
38
45
  if (controller.signal.aborted)
39
46
  return;
40
47
  if (generate) {
@@ -64,7 +71,39 @@ export function ConnectPhone({ t }) {
64
71
  window.addEventListener('pagehide', hide);
65
72
  return () => { request.current?.abort(); clearTimeout(timer.current); document.removeEventListener('visibilitychange', conceal); window.removeEventListener('pagehide', hide); };
66
73
  }, []);
67
- return _jsxs("section", { "data-mobile-connect": true, children: [_jsx("style", { children: styles }), _jsx("h2", { children: t('connectPhone') }), _jsx("p", { children: t('connectIntro') }), info?.origin && _jsxs("div", { "data-connect-host": true, children: [_jsx("span", { children: t('server') }), _jsx("strong", { children: new URL(info.origin).host })] }), info && info.state !== 'ready' && _jsx("div", { role: "status", children: _jsx("p", { children: t(info.state === 'untrusted-origin' ? 'connectUntrusted' : info.state === 'unsupported' ? 'connectUnsupported' : 'connectSetup') }) }), failed && _jsx("p", { role: "alert", children: t('connectError') }), busy && _jsx("p", { role: "status", children: t('connectLoading') }), login ? _jsxs("div", { "data-connect-code": true, children: [_jsx(LoginQR, { value: login, label: t('connectQR') }), _jsx("p", { children: t('connectConceal') }), _jsx("button", { type: "button", onClick: hide, children: t('connectHide') })] })
74
+ useEffect(() => {
75
+ if (!login)
76
+ return;
77
+ const controller = new AbortController();
78
+ let checking = false;
79
+ const poll = setInterval(async () => {
80
+ if (checking)
81
+ return;
82
+ checking = true;
83
+ try {
84
+ const response = await fetch(CONNECT_PATH, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal });
85
+ if (!response.ok)
86
+ throw new Error('unavailable');
87
+ const next = await response.json();
88
+ if (controller.signal.aborted)
89
+ return;
90
+ setInfo(next);
91
+ if (next.state !== 'ready' || next.origin !== new URL(login).origin)
92
+ hide();
93
+ }
94
+ catch {
95
+ if (!controller.signal.aborted) {
96
+ hide();
97
+ setFailed(true);
98
+ }
99
+ }
100
+ finally {
101
+ checking = false;
102
+ }
103
+ }, 15_000);
104
+ return () => { controller.abort(); clearInterval(poll); };
105
+ }, [login]);
106
+ return _jsxs("section", { "data-mobile-connect": true, children: [_jsx("style", { children: styles }), _jsx("h2", { children: t('connectPhone') }), _jsx("p", { children: t('connectIntro') }), info?.origin && _jsxs("div", { "data-connect-host": true, children: [_jsx("span", { children: t('server') }), _jsx("strong", { children: new URL(info.origin).host })] }), info && info.state !== 'ready' && _jsx("div", { role: "status", children: _jsx("p", { children: t(info.state === 'unreachable' ? 'connectUnreachable' : info.state === 'untrusted-origin' ? 'connectUntrusted' : info.state === 'unsupported' ? 'connectUnsupported' : 'connectSetup') }) }), failed && _jsx("p", { role: "alert", children: t('connectError') }), busy && _jsx("p", { role: "status", children: t('connectLoading') }), login ? _jsxs("div", { "data-connect-code": true, children: [_jsx(LoginQR, { value: login, label: t('connectQR') }), _jsx("p", { children: t('connectConceal') }), _jsx("button", { type: "button", onClick: hide, children: t('connectHide') })] })
68
107
  : _jsx("button", { type: "button", disabled: busy, onClick: () => { void load(info?.state === 'ready'); }, children: t(info?.state === 'ready' ? 'connectGenerate' : 'directoryRetry') }), _jsxs("ol", { children: [_jsx("li", { children: t('connectStepOpen') }), _jsx("li", { children: t('connectStepScan') }), _jsx("li", { children: t('connectStepConfirm') })] }), _jsx("p", { "data-connect-note": true, children: t('connectPrivacy') }), _jsx("p", { "data-connect-note": true, children: t('connectNetwork') })] });
69
108
  }
70
109
  const styles = `
@@ -12,6 +12,7 @@ export declare const en: {
12
12
  readonly connectStepConfirm: "Scan this code, check the host address, then confirm the connection.";
13
13
  readonly connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.";
14
14
  readonly connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.";
15
+ readonly connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.";
15
16
  readonly connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.";
16
17
  readonly connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.";
17
18
  readonly connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.";
@@ -12,6 +12,7 @@ export const en = {
12
12
  connectStepConfirm: "Scan this code, check the host address, then confirm the connection.",
13
13
  connectPrivacy: "This QR code grants access to this host. Keep it private. Hiding the code does not revoke the official login link; it remains valid until the host process restarts.",
14
14
  connectNetwork: "Keep the computer and its HTTPS entry online. Scanning reconnects the App; it cannot repair a disconnected tunnel.",
15
+ connectUnreachable: "The public connection is unavailable. Wait for the computer to restore it, then retry to get a new QR code.",
15
16
  connectSetup: "The operator must configure a reachable HTTPS origin using mobile.publicOrigin or DSH_MOBILE_PUBLIC_ORIGIN, and add its hostname to the host trusted hosts.",
16
17
  connectUntrusted: "The configured address is not trusted by this host. Update --trusted-host and restart the host before generating a code.",
17
18
  connectUnsupported: "This host version cannot provide official login links. Update the supported host/plugin combination.",
@@ -51,6 +52,7 @@ export const zh = {
51
52
  connectStepConfirm: "扫描二维码,核对主机地址后确认连接。",
52
53
  connectPrivacy: "二维码可授予这台主机的访问权限,请勿分享。隐藏二维码不会撤销官方登录链接;链接在宿主进程重启前仍然有效。",
53
54
  connectNetwork: "请保持电脑和 HTTPS 入口在线。扫码用于重新连接 App,无法修复已经断开的隧道。",
55
+ connectUnreachable: "公网入口暂不可用。请等待电脑端恢复后重试,获取新的二维码。",
54
56
  connectSetup: "请由部署者通过 mobile.publicOrigin 或 DSH_MOBILE_PUBLIC_ORIGIN 配置手机可达的 HTTPS 地址,并将域名加入宿主信任列表。",
55
57
  connectUntrusted: "宿主尚未信任配置的地址。请更新 --trusted-host 并重启宿主后再生成二维码。",
56
58
  connectUnsupported: "此宿主版本不能提供官方登录链接,请更新到支持的宿主和插件组合。",
@@ -3,5 +3,5 @@ import type { MobileConnectInfo } from './protocol.ts';
3
3
  /** Deployment-owned destination only: a browser cannot choose where a bearer is sent. */
4
4
  export declare function connectInfo(connection: HostConnectionHandle, configured?: string): MobileConnectInfo;
5
5
  /** Called only behind the official authenticated Fetch registry. */
6
- export declare function connectResponse(connection: HostConnectionHandle, configured?: string): (request: Request) => Promise<Response>;
6
+ export declare function connectResponse(connection: HostConnectionHandle, configured?: string, reachable?: (origin: string) => Promise<boolean>): (request: Request) => Promise<Response>;
7
7
  //# sourceMappingURL=connect.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { publicOriginReachable } from './reachability.js';
1
2
  const headers = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' };
2
3
  /** Deployment-owned destination only: a browser cannot choose where a bearer is sent. */
3
4
  export function connectInfo(connection, configured) {
@@ -24,11 +25,13 @@ export function connectInfo(connection, configured) {
24
25
  return { state: 'ready', origin: url.origin };
25
26
  }
26
27
  /** Called only behind the official authenticated Fetch registry. */
27
- export function connectResponse(connection, configured) {
28
+ export function connectResponse(connection, configured, reachable = publicOriginReachable) {
28
29
  return async (request) => {
29
30
  const info = connectInfo(connection, configured);
31
+ const checked = async () => info.state === 'ready' && info.origin && !await reachable(info.origin)
32
+ ? { ...info, state: 'unreachable' } : info;
30
33
  if (request.method === 'GET')
31
- return Response.json(info, { headers });
34
+ return Response.json(await checked(), { headers });
32
35
  // Explicit same-origin action; a navigation/form must never reveal a login URL.
33
36
  // The official HTTP bridge uses an internal Request URL. Compare the
34
37
  // preserved Host/Origin headers, as the official trust fence does, so an
@@ -46,6 +49,9 @@ export function connectResponse(connection, configured) {
46
49
  }
47
50
  if (info.state !== 'ready' || !info.origin)
48
51
  return Response.json(info, { status: 409, headers });
52
+ const live = await checked();
53
+ if (live.state !== 'ready')
54
+ return Response.json(live, { status: 503, headers });
49
55
  try {
50
56
  const login = new URL(connection.authenticatedUrl(info.origin));
51
57
  if (login.origin !== info.origin || login.pathname !== '/' || login.username || login.password || login.hash
@@ -4,7 +4,7 @@ export declare const BRIDGE_VERSION = 1;
4
4
  export declare const HANDSHAKE_PATH = "/api/mobile/handshake";
5
5
  export declare const CONNECT_PATH = "/api/mobile/connect";
6
6
  export interface MobileConnectInfo {
7
- state: 'ready' | 'not-configured' | 'invalid-origin' | 'untrusted-origin' | 'unsupported';
7
+ state: 'ready' | 'not-configured' | 'invalid-origin' | 'untrusted-origin' | 'unsupported' | 'unreachable';
8
8
  origin: string | null;
9
9
  }
10
10
  /** Read-only feature discovery; this does not create a login or grant access. */
@@ -0,0 +1,5 @@
1
+ /** Probe only the operator-configured origin, anonymously and without redirects.
2
+ * Official protected roots answer 401; unprotected roots can answer 2xx.
3
+ * A login token must never be used as a connectivity probe. */
4
+ export declare function publicOriginReachable(origin: string, request?: typeof fetch): Promise<boolean>;
5
+ //# sourceMappingURL=reachability.d.ts.map
@@ -0,0 +1,14 @@
1
+ /** Probe only the operator-configured origin, anonymously and without redirects.
2
+ * Official protected roots answer 401; unprotected roots can answer 2xx.
3
+ * A login token must never be used as a connectivity probe. */
4
+ export async function publicOriginReachable(origin, request = fetch) {
5
+ try {
6
+ const response = await request(`${origin}/`, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(6000),
7
+ headers: { 'cache-control': 'no-cache' } });
8
+ await response.body?.cancel();
9
+ return response.status === 401 || response.ok;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-mobile",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Removable mobile presentation and iOS bridge for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",