@khorsheed/dsh-mobile 0.1.0
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 +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +132 -0
- package/README.zh.md +132 -0
- package/cordis.patch.yml +3 -0
- package/examples/https-ingress.mjs +84 -0
- package/examples/safari-stream-compat.mjs +98 -0
- package/lib/client.js +6138 -0
- package/lib/index.js +172 -0
- package/lib/invariant.js +20 -0
- package/lib/types/client/ConnectPhone.d.ts +9 -0
- package/lib/types/client/ConnectPhone.js +83 -0
- package/lib/types/client/DirectoryFlow.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.js +75 -0
- package/lib/types/client/MobileChrome.d.ts +15 -0
- package/lib/types/client/MobileChrome.js +124 -0
- package/lib/types/client/MobileIcon.d.ts +6 -0
- package/lib/types/client/MobileIcon.js +16 -0
- package/lib/types/client/MobileLibrary.d.ts +13 -0
- package/lib/types/client/MobileLibrary.js +73 -0
- package/lib/types/client/MobileQueue.d.ts +9 -0
- package/lib/types/client/MobileQueue.js +90 -0
- package/lib/types/client/MobileRooms.d.ts +13 -0
- package/lib/types/client/MobileRooms.js +80 -0
- package/lib/types/client/MobileSeats.d.ts +4 -0
- package/lib/types/client/MobileSeats.js +5 -0
- package/lib/types/client/MobileTools.d.ts +7 -0
- package/lib/types/client/MobileTools.js +34 -0
- package/lib/types/client/SessionRename.d.ts +10 -0
- package/lib/types/client/SessionRename.js +43 -0
- package/lib/types/client/SubmissionFocus.d.ts +26 -0
- package/lib/types/client/SubmissionFocus.js +74 -0
- package/lib/types/client/composerActions.d.ts +24 -0
- package/lib/types/client/composerActions.js +66 -0
- package/lib/types/client/composerFocus.d.ts +15 -0
- package/lib/types/client/composerFocus.js +41 -0
- package/lib/types/client/directory.d.ts +17 -0
- package/lib/types/client/directory.js +54 -0
- package/lib/types/client/foreground.d.ts +4 -0
- package/lib/types/client/foreground.js +47 -0
- package/lib/types/client/gestures.d.ts +4 -0
- package/lib/types/client/gestures.js +118 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/index.js +144 -0
- package/lib/types/client/locales.d.ts +145 -0
- package/lib/types/client/locales.js +80 -0
- package/lib/types/client/messageMenu.d.ts +19 -0
- package/lib/types/client/messageMenu.js +99 -0
- package/lib/types/client/native.d.ts +6 -0
- package/lib/types/client/native.js +15 -0
- package/lib/types/client/navigation.d.ts +41 -0
- package/lib/types/client/navigation.js +61 -0
- package/lib/types/client/pickers.d.ts +17 -0
- package/lib/types/client/pickers.js +109 -0
- package/lib/types/client/presentation.d.ts +38 -0
- package/lib/types/client/presentation.js +146 -0
- package/lib/types/client/queue.d.ts +47 -0
- package/lib/types/client/queue.js +26 -0
- package/lib/types/client/roomActions.d.ts +6 -0
- package/lib/types/client/roomActions.js +41 -0
- package/lib/types/client/rooms.d.ts +44 -0
- package/lib/types/client/rooms.js +75 -0
- package/lib/types/client/styles.d.ts +4 -0
- package/lib/types/client/styles.js +434 -0
- package/lib/types/client/surface.d.ts +17 -0
- package/lib/types/client/surface.js +112 -0
- package/lib/types/client/viewport.d.ts +13 -0
- package/lib/types/client/viewport.js +42 -0
- package/lib/types/connect.d.ts +7 -0
- package/lib/types/connect.js +61 -0
- package/lib/types/directories.d.ts +5 -0
- package/lib/types/directories.js +39 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/index.js +21 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +17 -0
- package/lib/types/protocol.d.ts +30 -0
- package/lib/types/protocol.js +17 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +102 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-plugins 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.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record: the git blob hash of each side as of the
|
|
2
|
+
# last confirmed-consistent state. Both languages carry equal authority; after
|
|
3
|
+
# editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/mobile/README.md
|
|
5
|
+
packages/mobile/README.md: 3ee04dfa141d5163163baa7d574385f05b53e20a
|
|
6
|
+
packages/mobile/README.zh.md: 6032673a68af225e9f88b7ef5c7f453345484509
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# DSH Mobile
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
An independently removable mobile presentation for DeepSeek Harness. The Mac continues to own sessions, models, skills, tools and plugin execution. The optional [iOS shell](../../apps/ios/README.md) embeds the same official Web client.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- A conversation library with time/workspace grouping, collapsible workspace sections and bottom search. Grouping stays local to this browser; the official chat and draft remain mounted behind navigation.
|
|
10
|
+
- Client-local mobile layout, full-width right-panel details, mobile settings and a wrapping composer toolbar with larger touch targets.
|
|
11
|
+
- Official conversation renderer, composer, model/permission selectors, attachments and streaming transport stay in place.
|
|
12
|
+
- Choose a **computer** directory in a mobile sheet: saved workspaces, one-level folder browsing, parent navigation, hidden-folder toggle and a validated absolute-path fallback. Workspace adoption and Room member picks share this sheet; desktop clients keep the native chooser.
|
|
13
|
+
- Authenticated `GET /api/mobile/handshake`; versioned native bridge for presentation status and optional native settings/QR scanning. Foreground recovery uses the official connection service, never replays a send command. Healthy brief interruptions and initial connecting signals avoid redundant reconnects; suspension of at least 5 s still refreshes the stream generation.
|
|
14
|
+
- No required community plugins. Existing message-tools/member/preview surfaces retain their owners; their mobile combinations still require acceptance.
|
|
15
|
+
|
|
16
|
+
This is a development baseline, not a released or real-device-qualified app. Camera/file picking, sharing/downloads, background recovery and community combinations are not yet qualified. The iOS scanner accepts existing official HTTPS login links and requires host confirmation; camera acceptance is still pending. No one-time device pairing, per-device credential revocation or APNs is provided.
|
|
17
|
+
|
|
18
|
+
## Install and remove
|
|
19
|
+
|
|
20
|
+
Build and pack from a repository worktree first:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm --filter @khorsheed/dsh-mobile build
|
|
24
|
+
pnpm --filter @khorsheed/dsh-mobile test
|
|
25
|
+
pnpm exec tsx scripts/pack-dist.ts --package packages/mobile --scope @khorsheed --version 0.1.0 --out packages/mobile/.dev/dist
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install the resulting tarball with `dsh plugin --profile web add /path/to/package.tgz`; remove with `dsh plugin --profile web remove @khorsheed/dsh-mobile`. The package self-mounts; do not edit profile YAML. On the tested rc1 instance, dependency changes require a controlled Host restart and client reload; hot uninstall is not qualified. Use an isolated `DSH_HOME` and test port during development. Production 3080 still goes through the repository deployment gate.
|
|
29
|
+
|
|
30
|
+
A narrow touch screen or the native shell enables the layout automatically. `?mobile=1` explicitly enables it; `?mobile=0` disables it. The mobile settings choice persists only in this browser under `dsh.mobile.display`; library grouping uses `dsh.mobile.grouping`. After selecting desktop layout, `?mobile=1` restores the settings entry. The login exchange redirects to `/`, so apply a query override after authentication if needed.
|
|
31
|
+
|
|
32
|
+
Unload removes owned styles, frame markers, observers, listeners, routes and slot contributions. It does not delete sessions, cancel Host tasks, close the shared connection, revoke official cookies or uninstall the App/VPN. Preferences may remain for reinstall. Browser hot-unload and tarball acceptance are recorded separately below.
|
|
33
|
+
|
|
34
|
+
## Connection and authentication
|
|
35
|
+
|
|
36
|
+
The App accepts a configured HTTPS origin or official launch-token login URL. It persists the clean origin only; WebKit stores the official browser session cookie. The bridge accepts only matching-origin main-frame messages with bridge version 1, and supports `ready`, `unloaded`, `chrome`, `settings` and `scan`. New native actions require advertised capabilities; older shells keep their fallback controls. It does not grant file or command privileges.
|
|
37
|
+
|
|
38
|
+
Different physical networks need an independently configured HTTPS/WSS ingress to the loopback Host port (3080 in production), valid official authentication, an awake/online Mac and reachable phone. Starting 3080 alone does not establish remote access. The App does not configure networking or credentials on your behalf. Debug simulator builds allow HTTP on loopback only; Release requires HTTPS. Clear local App data and server-side device revocation are different operations.
|
|
39
|
+
|
|
40
|
+
## Connect a phone from Web settings
|
|
41
|
+
|
|
42
|
+
The authenticated Web client exposes **Settings → Connect phone** through the public `settings.section` slot. This is available on desktop without enabling mobile layout. Configure the phone-reachable **clean HTTPS origin** in the mobile plugin's `publicOrigin` option, or set `DSH_MOBILE_PUBLIC_ORIGIN` on the **Host process**. The plugin option takes precedence. Add the same hostname to the Host's `--trusted-host` list; the ingress `PUBLIC_ORIGIN` must also match. These are deployment settings, not phone preferences.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
DSH_MOBILE_PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com dsh web --no-open --port 3181 --trusted-host YOUR-HOST.trycloudflare.com
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use an isolated profile for the example; change production launch configuration through its supervised cutover flow. Missing/invalid origin, unsupported Host capability and an untrusted destination show setup guidance without generating a login link.
|
|
49
|
+
|
|
50
|
+
1. Open Web Settings → Connect phone and check the displayed host.
|
|
51
|
+
2. Click **Show login QR code**.
|
|
52
|
+
3. In the iOS App, open **Connection Settings → Scan to connect**, scan and confirm the host.
|
|
53
|
+
|
|
54
|
+
`GET /api/mobile/connect` returns configuration status only. An authenticated, same-origin JSON `POST` obtains the official `connection.authenticatedUrl()` for the deployment-owned origin; browser input cannot override the destination. Both routes use the official Connection registry, preserving its cookie and Host/Origin checks. Responses are no-store. QR encoding happens locally in the browser; no third-party QR service, secret logging or browser persistence is used. Leaving the section, hiding the page or waiting two minutes conceals the code and aborts pending requests.
|
|
55
|
+
|
|
56
|
+
**Concealment is not credential expiry.** This is the official process login token, valid until that Host process restarts, not a short-lived/one-use pairing token. Existing cookies have their own Host-owned lifetime. Hiding or regenerating the QR does not revoke a copied link or a logged-in phone. Per-device credentials, revocation, Keychain pairing and push remain unimplemented.
|
|
57
|
+
|
|
58
|
+
If the entry is unreachable, restore the HTTPS tunnel first. A running tunnel process with zero active connections cannot carry requests. When a temporary domain changes, update ingress origin, Host trusted hostname and mobile public origin together, then generate a new login QR; old cookies are bound to their old authority. A successful local 3080 response alone does not prove the phone can connect. Prefer a stable HTTPS entry for regular use. Scanning does not renew an expired iOS development signature.
|
|
59
|
+
|
|
60
|
+
## Optional Quick Tunnel preview
|
|
61
|
+
|
|
62
|
+
Networking is deployment configuration, not a plugin dependency. Community users can choose Quick Tunnel for temporary previews, their own HTTPS reverse tunnel/server, or private networking. No personal domain, server or Cloudflare account is embedded in the plugin. A stable managed relay would be a separate service with its own operating costs.
|
|
63
|
+
|
|
64
|
+
[Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) create a temporary HTTPS address without a Cloudflare account or your own domain. They are for testing, have no uptime guarantee, allow 200 concurrent requests and do not support SSE. The tested rc1 conversation transport uses WebSocket; this does not qualify unrelated plugins that require SSE.
|
|
65
|
+
|
|
66
|
+
Use an isolated Host profile with the mobile tarball installed. Install `cloudflared` from its official distribution and use Node 22+. From the repository root, start the tunnel first (the origin can be offline while the address is allocated):
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
cloudflared tunnel --url http://127.0.0.1:3182 --protocol quic --no-autoupdate
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Protocol choice depends on the local network. If the registered QUIC tunnel stalls on real requests, try `--protocol http2`; HTTP/2 can also fail on another network, so neither protocol is a universal default. A registered tunnel or a healthy loopback origin alone is not enough evidence. Verify public login, the session list and a full WebSocket reply before sharing the preview. A new Quick Tunnel allocates another hostname: update both the ingress origin and the Host trusted-host through the guarded launch cutover, then reconnect the app. Keep these processes running independently of short-lived automation sessions.
|
|
73
|
+
|
|
74
|
+
Copy its generated hostname in place of `YOUR-HOST.trycloudflare.com` below. Start the standalone example in a second terminal:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com HOST_PORT=3181 INGRESS_PORT=3182 node packages/mobile/examples/https-ingress.mjs
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then start the isolated Host with its own `DSH_HOME`, workspace and configured model, adding the exact public authority through the official flag:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
dsh web --no-open --port 3181 --trusted-host YOUR-HOST.trycloudflare.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The example is also included under `examples/` in the package tarball. It binds only to loopback, preserves the public Host and Origin for official authentication, requires an HTTPS forwarded scheme, adds Secure to upstream cookies, and forwards HTTP streams and WebSocket upgrades. It neither reads Host private keys nor adds a pairing or device-revocation system. Run it only behind the HTTPS tunnel; a local header is not proof of an authenticated user. The Host remains responsible for authorization.
|
|
87
|
+
|
|
88
|
+
For phone login, replace only the origin of the Host's launch URL with the tunnel HTTPS origin, preserving its `?token=...`. Open it in Safari or paste it into the iOS shell. Keep that credential-bearing URL private. Login redirects to a clean `/`; use `?mobile=1` afterwards if the device does not enable mobile layout automatically.
|
|
89
|
+
|
|
90
|
+
Keep the Mac awake and all three services running. Stop the tunnel process to close this public access path; removing the mobile plugin alone does not stop the tunnel or revoke official sessions. A newly allocated hostname requires updating both `PUBLIC_ORIGIN` and `--trusted-host` and logging in again. This preview does not establish long-term cellular availability. See the [Quick Tunnel acceptance record](../../docs/acceptance/mobile-quick-tunnel-2026-09-11.md).
|
|
91
|
+
|
|
92
|
+
Desktop mode keeps a “Return to mobile layout” button even after reload. Mobile entry suppresses automatic composer focus until an explicit input gesture; rc1 DOM-anchor changes fall back to official focus behavior. Ordinary session titles are deduplicated while ancestor/lineage controls remain visible. These Web-only fixes do not require reinstalling the native App.
|
|
93
|
+
|
|
94
|
+
The polished mobile view retains composer statistics and official message actions, hides the unselected trajectory entry, and contributes its welcome through the public brand seat. Existing session workspace/preset labels are read-only on rc1; only new sessions offer the official pickers. The native settings page now controls layout through the validated `dsh-mobile-display` event. Unknown header geometry preserves the original header and ancestor navigation.
|
|
95
|
+
|
|
96
|
+
The composer’s plus menu groups the existing attachment, command and permission triggers. The current permission name and available options remain official. Checked rc1 button anchors retain the original callbacks and confirmation flow, with original controls restored if the structure is unknown. Workspace/preset metadata sits below the mobile title; known desktop header controls are hidden, while unknown plugin contributions remain available.
|
|
97
|
+
|
|
98
|
+
## Optional collaboration surfaces
|
|
99
|
+
|
|
100
|
+
Install message-tools, TaskPilot, Room and the Local Agent family separately. Mobile reads their public capabilities and does not require them to boot. Long-press recognized user messages for the original copy/edit/withdraw actions; assistant messages use their own turn actions. Confirmations, disabled actions and mutation semantics remain with their owners. Unrecognized renderers retain their original controls.
|
|
101
|
+
|
|
102
|
+
The plus sheet opens Room's **original invite form**, styled as a mobile bottom sheet. The member shortcut reads the roster and opens existing child sessions; its settings action opens the original Room members view and edit form. Provider discovery, authentication, random names, first-task dispatch, directory adoption, changed-field patches, removal confirmation and business errors belong to Room. There is no second mobile invite/edit API implementation. Mobile temporarily adapts the public `uiWorkspace.pickDirectory` method in this browser to resolve a selected path from its sheet; the original Room form consumes that result unchanged. Unloading restores the method, and unknown read-only facades keep their original behavior. Blank first task means idle invitation; a supplied first task retains Room's dispatch behavior. Missing checked UI anchors retain the original Members tab as fallback.
|
|
103
|
+
|
|
104
|
+
Room and Local Agent keep their own composers. The Room queue panel uses the official scoped Conversation API and authoritative `row.text` editability, deduplicates admitted/pending request IDs, and closes an edit when its row disappears or becomes immutable. Goal/todo and TaskPilot entries keep their original owners. Room's own composer replaces the ordinary dock on both platforms, so mobile does not promise that every ordinary TaskPilot pill appears inside a Room.
|
|
105
|
+
|
|
106
|
+
Mobile reading text uses a 17px floor, primary controls 16–17px and captions approximately 14px, derived from host typography without persisting a host preference. Invite/edit controls remain at least 44px high and their sheet uses the visible viewport when the keyboard opens.
|
|
107
|
+
|
|
108
|
+
The library preserves workspace identity and shows member counts for both room and ordinary sessions. Visible rows fetch optional Room metadata; failures remain unknown rather than displaying a false count. In this Room revision, an asynchronous initial cache fill can leave the composer election stale; a declining mobile chain entry refreshes the public slot election without replacing its winner. Pending interactions still take precedence.
|
|
109
|
+
|
|
110
|
+
Successful local message admission dismisses the same empty focused editor; rejected submissions and newer drafts retain focus. The context meter stays beside Send. Glass-like surfaces use host light/dark colors with opaque text and a reduced-transparency fallback. See [collaboration acceptance](../../docs/acceptance/mobile-collaboration-2026-09-12.md).
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Safari live-stream compatibility adapter
|
|
114
|
+
|
|
115
|
+
Host `0.1.5-rc.1` includes a JSON validator that rejects ordinary objects in Safari when restoring an in-progress reply. For this known defect, the bundled HTTPS ingress accepts `MOBILE_SAFARI_COMPAT=1`. Restart the ingress with that environment variable and have the client reload after the adapter is active. Existing dead reply subscriptions do not recover just by reconnecting the socket.
|
|
116
|
+
|
|
117
|
+
This is opt-in delivery adaptation, not a Host source patch: only WebKit GET requests for `/plugins/` JavaScript are examined. A SHA-256 fingerprint of the complete known validator gates replacement; unknown code and already-fixed code pass unchanged. The adapter compares against the current engine's native constructor formatting without changing `Function.prototype.toString`. Authentication, RPC bodies, WebSocket frames and the Host's execution are preserved. A direct LAN connection or another ingress does not receive this adapter.
|
|
118
|
+
|
|
119
|
+
Bounded JavaScript is gzip-compressed for clients that accept gzip after inspection, including unchanged bundles, to avoid sending multi-megabyte identity assets through the mobile tunnel. Responses remain private/no-store; adapted assets have corrected byte lengths and no stale ETag/digest. Assets above 16 MiB, non-JS responses, authentication failures and upstream responses that ignore the requested identity encoding pass through unchanged. Inspect `x-dsh-mobile-compat` and the ingress's value-only compatibility log when qualifying a Host upgrade. Do not broaden the fingerprint automatically. Remove the flag after an upstream-fixed build passes real WebKit foreground/reconnect acceptance. Run the ingress from an installed tarball's `examples/`, not a development checkout, for a reproducible deployment.
|
|
120
|
+
|
|
121
|
+
The read-only `GET /api/mobile/directories` companion uses the official Connection authentication and Host/Origin checks. It lists directory names and paths, including enterable symlinks, within the authenticated operator’s existing filesystem access; it never reads file contents or creates directories. Listings are bounded to 1,000 directory rows / 10,000 scanned entries and identify truncation. This supports native-picker hosts without changing their shared backend. A missing route or disconnected host shows an inline error with retry; the served HTTP carrier is required.
|
|
122
|
+
|
|
123
|
+
## Compatibility
|
|
124
|
+
|
|
125
|
+
| Host line | Verdict |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Official `0.1.5-rc.1`, `183f08e9c6` | Local browser/Host validation; iOS simulator build passes. Full device/network qualification pending. |
|
|
128
|
+
| Other npm RCs / Harness master | Not verified. Audit before adopting. |
|
|
129
|
+
|
|
130
|
+
`dsh.compat.minHost` is `0.1.5-rc.1`; no `verifiedHost` is claimed while the release matrix is incomplete. The library uses the optional official sessions/workspaces/uiWorkspace services; it filters archived sessions and subagent rows while retaining ordinary forks. Missing services fall back to the basic official sidebar. Search here filters titles and workspace paths, not message contents. The public frame/slot DOM anchors are checked before enabling layout; an unknown frame retains the official page. A changed structure can reduce mobile usability without breaking Host execution. Native bridge version changes require an App compatibility decision; compatible Web updates do not automatically require a new IPA.
|
|
131
|
+
|
|
132
|
+
See [navigation/QR acceptance](../../docs/acceptance/mobile-navigation-2026-09-11.md), [acceptance evidence](../../docs/acceptance/mobile-rc1-2026-09-11.md) and the [proposal](../../proposals/active/2026-08-19-mobile-access.md). No upstream or sibling source changes are required.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# DSH Mobile
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
可独立卸载的 DeepSeek Harness 移动适配插件。Mac 继续持有会话、模型、skill、tool 和插件执行;可选的 [iOS 薄壳](../../apps/ios/README.zh.md) 嵌入同一套官方 Web 客户端。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 会话首页支持时间/工作区分组、工作区折叠和底部搜索。分组偏好仅保存在当前浏览器;导航覆盖期间仍挂载官方聊天与草稿。
|
|
10
|
+
- 当前客户端独立的移动布局、全宽右栏详情、移动设置,以及可换行、扩大触控区域的输入工具栏。
|
|
11
|
+
- 保留官方正文、输入框、模型/权限选择、附件及流式传输。
|
|
12
|
+
- 通过移动端面板选择**电脑目录**:已有工作区、逐级浏览、返回上级、显示隐藏目录及校验完整路径。工作区接入和 Room 成员目录选择共用此面板;桌面客户端保留原生选择器。
|
|
13
|
+
- 受认证的 `GET /api/mobile/handshake`;带版本的原生桥接提供展示状态与可选原生设置/扫码入口。回前台通过官方连接服务恢复,不重放发送命令。短暂切换且连接正常、初次正在连接时不重复重连;后台停留至少 5 秒仍刷新流式连接代际。
|
|
14
|
+
- 无社区插件强依赖。message-tools、成员、预览保留各自所有者;移动组合仍需验收。
|
|
15
|
+
|
|
16
|
+
当前是开发基线,尚未发布或通过真机资格验收。相机/文件选择、分享/下载、后台恢复和社区组合尚未完成验收。iOS 扫码接收已有官方 HTTPS 登录链接,并要求确认主机;相机实测验收仍待完成。不提供一次性设备配对、逐设备凭据撤销或 APNs。
|
|
17
|
+
|
|
18
|
+
## 安装与卸载
|
|
19
|
+
|
|
20
|
+
先在仓库 worktree 构建、打包:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm --filter @khorsheed/dsh-mobile build
|
|
24
|
+
pnpm --filter @khorsheed/dsh-mobile test
|
|
25
|
+
pnpm exec tsx scripts/pack-dist.ts --package packages/mobile --scope @khorsheed --version 0.1.0 --out packages/mobile/.dev/dist
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
用 `dsh plugin --profile web add /path/to/package.tgz` 安装产物;用 `dsh plugin --profile web remove @khorsheed/dsh-mobile` 移除。插件自挂载,不手改 profile YAML。实测 rc1 实例的依赖变化需要受控重启 Host 并刷新客户端,未通过热卸载验收。开发使用独立 `DSH_HOME` 与端口;生产 3080 仍走仓库部署门禁。
|
|
29
|
+
|
|
30
|
+
窄屏触控设备或原生壳自动启用移动布局。`?mobile=1` 显式启用,`?mobile=0` 停用。移动设置中的选择仅保存在当前浏览器的 `dsh.mobile.display`,首页分组使用 `dsh.mobile.grouping`。切到桌面布局后,可用 `?mobile=1` 恢复设置入口。官方登录交换会跳转 `/`,需要时在认证后再加参数。
|
|
31
|
+
|
|
32
|
+
卸载移除自有样式、布局标记、observer、监听、路由和槽贡献,不删除会话、取消 Host 任务、关闭共享连接、撤销官方 Cookie 或卸载 App/VPN。偏好可保留用于重装。浏览器热卸载与 tarball 验收分开记录于下方证据。
|
|
33
|
+
|
|
34
|
+
## 连接与认证
|
|
35
|
+
|
|
36
|
+
App 接受配置的 HTTPS 主机地址或官方启动 token 登录链接,只保存无凭据的 origin;WebKit 保存官方浏览器会话 Cookie。桥接仅接受同 origin 主框架、bridge version 1 的消息,支持 `ready`、`unloaded`、`chrome`、`settings` 和 `scan`;新增原生动作需壳声明能力,旧壳保留回退控件。不授予文件或命令权限。
|
|
37
|
+
|
|
38
|
+
跨物理网络需要独立配置的 HTTPS/WSS 入口转发到 loopback Host 端口(生产为 3080)、有效官方认证、保持唤醒和联网的 Mac,以及网络可达的手机。仅启动 3080 不等于远程可用。App 不自动配置网络或凭据。Debug 模拟器构建仅允许 loopback HTTP,Release 要求 HTTPS。清除 App 本地数据与服务端设备撤销是不同操作。
|
|
39
|
+
|
|
40
|
+
## 从 Web 设置连接手机
|
|
41
|
+
|
|
42
|
+
已登录的 Web 客户端通过公开 `settings.section` 槽位提供 **设置 → 连接手机**;桌面无需开启移动布局。将手机可达的**无凭据 HTTPS origin** 配置为 mobile 插件的 `publicOrigin` 选项,或在 **Host 进程**上设置 `DSH_MOBILE_PUBLIC_ORIGIN`;插件选项优先。同一域名需加入 Host 的 `--trusted-host`,ingress 的 `PUBLIC_ORIGIN` 也必须一致。这些属于部署设置,不是手机偏好。
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
DSH_MOBILE_PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com dsh web --no-open --port 3181 --trusted-host YOUR-HOST.trycloudflare.com
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
示例使用隔离 profile;生产启动配置通过守护进程的受保护切换流程更新。地址缺失或无效、宿主能力不支持、目标域名未受信任时,只显示配置提示,不生成登录链接。
|
|
49
|
+
|
|
50
|
+
1. 打开 Web「设置 → 连接手机」,核对展示的主机。
|
|
51
|
+
2. 点击**显示登录二维码**。
|
|
52
|
+
3. 在 iOS App 进入**连接设置 → 扫码连接电脑**,扫描后确认主机。
|
|
53
|
+
|
|
54
|
+
`GET /api/mobile/connect` 仅返回配置状态。已认证、同源的 JSON `POST` 调用官方 `connection.authenticatedUrl()`,为部署者配置的 origin 获取登录链接;浏览器输入不能覆盖目标地址。两个接口都通过官方 Connection 注册,保留 Cookie 和 Host/Origin 校验,响应为 no-store。二维码在浏览器本地编码,不调用第三方二维码服务、不记录凭据日志、不写浏览器存储。离开设置分区、页面隐藏或展示两分钟后会隐藏二维码,并取消未完成的请求。
|
|
55
|
+
|
|
56
|
+
**隐藏不等于凭据过期。** 这里使用宿主进程的官方登录 token,在该进程重启前有效,并非短时或一次性配对码。已有 Cookie 的有效期仍由宿主控制。隐藏或重新生成二维码不会撤销已复制的链接,也不会登出手机。逐设备凭据、撤销、Keychain 配对和推送仍未实现。
|
|
57
|
+
|
|
58
|
+
入口不可达时,应先恢复 HTTPS 隧道。隧道进程仍在但活跃连接数为零时,仍无法承载请求。临时域名改变后,同步更新 ingress origin、Host 信任域名和 mobile 公网 origin,再生成新二维码;旧 Cookie 绑定旧 authority。本机 3080 响应正常不等于手机能连通。日常使用建议使用固定 HTTPS 入口;扫码也不会续签过期的 iOS 开发签名。
|
|
59
|
+
|
|
60
|
+
## 可选的 Quick Tunnel 预览
|
|
61
|
+
|
|
62
|
+
网络接入属于部署配置,不是插件依赖。社区用户可以选择临时预览用的 Quick Tunnel、自建 HTTPS 反向隧道/服务器或私网连接。插件不嵌入个人域名、服务器或 Cloudflare 账号。稳定的托管中继属于独立服务,会有自己的运营成本。
|
|
63
|
+
|
|
64
|
+
[Cloudflare Quick Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) 无需 Cloudflare 账号或自有域名即可生成临时 HTTPS 地址。它用于测试,不保证在线时间,允许 200 个并发请求,不支持 SSE。实测 rc1 的会话传输使用 WebSocket;这不代表需要 SSE 的其他插件已通过验收。
|
|
65
|
+
|
|
66
|
+
使用已安装 mobile tarball 的隔离 Host profile。从官方渠道安装 `cloudflared`,使用 Node 22+。在仓库根目录先启动隧道(分配地址时本地入口可以尚未启动):
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
cloudflared tunnel --url http://127.0.0.1:3182 --protocol quic --no-autoupdate
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
协议选择取决于本地网络。已注册的 QUIC 隧道若在实际请求上卡住,可以尝试 `--protocol http2`;其他网络上的 HTTP/2 也可能失败,两者都不是普遍适用的默认选择。仅看到隧道注册成功或本地入口健康还不够,分享预览前需验证公网登录、会话列表和完整 WebSocket 回复。新建 Quick Tunnel 会分配另一个域名:通过受保护的启动切换同步更新 ingress origin 与 Host trusted-host,再让 App 重新连接。测试进程应独立运行,避免依赖短生命周期的自动化会话。
|
|
73
|
+
|
|
74
|
+
将生成的主机名替换下方的 `YOUR-HOST.trycloudflare.com`,在第二个终端启动独立示例:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
PUBLIC_ORIGIN=https://YOUR-HOST.trycloudflare.com HOST_PORT=3181 INGRESS_PORT=3182 node packages/mobile/examples/https-ingress.mjs
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
然后使用独立的 `DSH_HOME`、工作区和已配置模型启动隔离 Host,通过官方参数加入精确的公网 authority:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
dsh web --no-open --port 3181 --trusted-host YOUR-HOST.trycloudflare.com
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
示例也随 tarball 的 `examples/` 分发。它仅绑定 loopback,保留公网 Host 与 Origin 交给官方认证,要求转发协议为 HTTPS,为上游 Cookie 补充 Secure,并透传 HTTP 流和 WebSocket 升级。它不读取 Host 私有密钥,不增加配对或逐设备撤销系统。仅在 HTTPS 隧道之后运行;本地请求头不构成用户已认证的证明,授权仍由 Host 负责。
|
|
87
|
+
|
|
88
|
+
手机登录时,仅将 Host 启动链接的 origin 替换为隧道 HTTPS origin,保留 `?token=...`,在 Safari 打开或粘贴到 iOS 壳。带凭据链接应私下保存。登录会跳转到干净的 `/`;如果设备没有自动启用移动布局,认证后再加 `?mobile=1`。
|
|
89
|
+
|
|
90
|
+
Mac 保持唤醒,三个服务持续运行。停止隧道进程可关闭这条公网访问路径;仅卸载 mobile 不会停止隧道或撤销官方会话。重新分配域名后,需要同步更新 `PUBLIC_ORIGIN`、`--trusted-host` 并重新登录。这次预览不代表长期蜂窝网络可达性已验证。参见 [Quick Tunnel 验收记录](../../docs/acceptance/mobile-quick-tunnel-2026-09-11.md)。
|
|
91
|
+
|
|
92
|
+
桌面模式在重载后仍保留“返回移动布局”按钮。移动端进入会话时阻止输入框自动聚焦,等待明确的输入手势;rc1 DOM 锚点变化时回退官方聚焦行为。普通会话标题去重,保留祖先/lineage 导航。这些 Web 层修复无需重新安装原生 App。
|
|
93
|
+
|
|
94
|
+
新版移动界面保留输入区统计和官方消息动作,隐藏未选中的轨迹入口,通过公开品牌槽位提供欢迎内容。rc1 已有会话工作区/preset 为只读标签,新建会话才提供官方选择器。原生设置通过校验后的 `dsh-mobile-display` 事件调整布局。未知标题区结构保留原始标题及祖先导航。
|
|
95
|
+
|
|
96
|
+
输入框加号菜单归并已有附件、指令和权限入口,当前权限名称与可选项仍由官方提供。校验后的 rc1 按钮锚点保留原回调和确认流程,结构未知时恢复官方控件。工作区/模式显示在移动标题下方;隐藏已识别的桌面标题控件,保留未知插件贡献。
|
|
97
|
+
|
|
98
|
+
## 可选协作界面
|
|
99
|
+
|
|
100
|
+
message-tools、TaskPilot、Room 和 Local Agent 家族独立安装。移动端读取公开能力,不把它们作为启动依赖。长按已识别的用户消息,调用原有复制/编辑/撤回;助手消息使用自己的回合动作。确认、禁用状态及修改语义仍由原组件负责。无法识别的渲染器保留原控件。
|
|
101
|
+
|
|
102
|
+
加号面板打开 **Room 原有邀请表单**,仅调整为移动端底部面板样式。成员快捷面板读取名册、打开已有子会话;设置按钮进入 Room 原成员页与编辑表单。代理发现、认证、随机命名、首个任务派发、目录接入、仅修改字段的补丁、移除确认及业务错误都由 Room 负责,不再维护第二套移动邀请/编辑 API。移动端在当前浏览器内临时适配公开的 `uiWorkspace.pickDirectory` 方法,由面板返回所选路径,Room 原表单继续消费该结果。卸载时恢复方法;未知的只读服务外观保留原行为。首个任务留空表示待命;填写后沿用 Room 的派发行为。无法识别快捷入口时保留原成员页作为回退。
|
|
103
|
+
|
|
104
|
+
Room 与 Local Agent 保留各自输入框。Room 排队面板使用官方会话 API 和 `row.text` 可编辑判据,按请求 ID 去重已接收/待确认消息,并在消息消失或不可修改时退出编辑。目标、任务与 TaskPilot 保留原组件所有权。Room 在两端都会用自己的输入框替换普通 dock,因此移动端不承诺 Room 内展示全部普通会话 TaskPilot 胶囊。
|
|
105
|
+
|
|
106
|
+
移动阅读正文最低 17px,主要控件 16–17px,辅助信息约 14px;从宿主字体变量派生,不写入宿主字号偏好。邀请/编辑控件点击高度至少 44px,面板跟随键盘打开后的可见视口。
|
|
107
|
+
|
|
108
|
+
会话列表统一保留工作区,并显示群聊和普通会话的成员数。仅为可见行查询可选 Room 元数据;失败保留未知状态,不显示错误的成员数。当前 Room 版本首次异步填充缓存时可能未刷新输入框选择;移动端通过始终让出的 chain 条目刷新公开槽位选择,不替换胜出的组件。待处理交互仍优先。
|
|
109
|
+
|
|
110
|
+
本机消息确认提交后,收起同一个空白编辑器的键盘;提交失败和后续草稿保留焦点。上下文圆环留在发送按钮旁。玻璃质感表面跟随宿主浅暗色,文字保持不透明,并提供降低透明度时的回退。参见[协作验收](../../docs/acceptance/mobile-collaboration-2026-09-12.md)。
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
## Safari 流式同步兼容层
|
|
114
|
+
|
|
115
|
+
宿主 `0.1.5-rc.1` 的 JSON 校验会在 Safari 还原进行中回复时误拒绝普通对象。针对该已知缺陷,随包分发的 HTTPS ingress 支持 `MOBILE_SAFARI_COMPAT=1`。使用此环境变量重启 ingress,兼容层生效后重新加载客户端。已经停止的正文订阅不能仅靠重新连接 socket 恢复。
|
|
116
|
+
|
|
117
|
+
这是默认关闭的交付层适配,不修改宿主源码:只检查 WebKit 对 `/plugins/` JavaScript 的 GET 请求。完整已知校验函数的 SHA-256 指纹决定是否替换;未知代码和已经修好的代码保持原样。补丁使用当前引擎的原生构造函数格式比较,不修改 `Function.prototype.toString`。认证、RPC 请求体、WebSocket 帧和宿主执行保持不变。直接连接局域网或使用其他 ingress 不会获得此兼容层。
|
|
118
|
+
|
|
119
|
+
有界 JavaScript 完成检查后,向支持 gzip 的客户端压缩输出,包括未修改的 bundle,避免多兆字节的未压缩脚本拖慢手机隧道加载。响应保持 private/no-store;修改后的脚本重算字节长度并移除旧 ETag/digest。超过 16 MiB、非 JS、认证失败、上游忽略 identity 编码要求而返回压缩内容的响应原样透传。升级宿主时检查 `x-dsh-mobile-compat` 与 ingress 不含请求内容的兼容日志,不自动扩大指纹范围。上游修复通过真实 WebKit 前后台和重连验收后移除开关。正式部署从已安装 tarball 的 `examples/` 启动 ingress,避免使用开发检出。
|
|
120
|
+
|
|
121
|
+
只读配套接口 `GET /api/mobile/directories` 复用官方 Connection 认证和 Host/Origin 检查。在已认证操作者现有文件系统权限内返回目录名称与路径,包括可进入的符号链接;不读取文件内容,不创建目录。每次最多返回 1,000 个目录、扫描 10,000 个条目,并提示截断。原生选择器宿主无需切换共享后端即可支持手机浏览。接口缺失或连接中断时显示错误和重试;此功能要求使用提供 HTTP 服务的连接方式。
|
|
122
|
+
|
|
123
|
+
## Compatibility
|
|
124
|
+
|
|
125
|
+
| 宿主线 | 结论 |
|
|
126
|
+
|---|---|
|
|
127
|
+
| 官方 `0.1.5-rc.1`、`183f08e9c6` | 本地浏览器/Host 验证,iOS 模拟器构建通过;完整真机/网络验收待完成。 |
|
|
128
|
+
| 其他 npm RC / Harness master | 未验证,采用前审计。 |
|
|
129
|
+
|
|
130
|
+
`dsh.compat.minHost` 为 `0.1.5-rc.1`,发布矩阵未完成前不声明 `verifiedHost`。会话首页使用可选的官方 sessions/workspaces/uiWorkspace 服务,过滤已归档会话和子代理,保留普通分叉会话;缺少服务时回退基础官方侧栏。这里搜索的是标题和工作区路径,不是消息正文。启用布局前检查公开 frame/slot DOM 锚点;未知结构保留官方页面。结构变化可能降低移动可用性,但不改变 Host 执行。原生桥接版本变化需要判断 App 兼容性;兼容的 Web 更新不自动要求重发 IPA。
|
|
131
|
+
|
|
132
|
+
参见[导航/扫码验收](../../docs/acceptance/mobile-navigation-2026-09-11.md)、[验收证据](../../docs/acceptance/mobile-rc1-2026-09-11.md)与[提案](../../proposals/active/2026-08-19-mobile-access.md)。无需修改官方或兄弟插件源码。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Optional loopback ingress behind an HTTPS tunnel. Host authentication stays upstream. */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { isSafariBundleRequest, serveSafariBundle } from './safari-stream-compat.mjs'
|
|
5
|
+
|
|
6
|
+
export function createIngress({ origin, targetPort, safariStreamCompat = false, onCompatibility }) {
|
|
7
|
+
const url = new URL(origin)
|
|
8
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Expected a clean HTTPS origin')
|
|
9
|
+
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) throw new Error('Invalid upstream port')
|
|
10
|
+
const allowed = req => req.headers.host?.toLowerCase() === url.host.toLowerCase()
|
|
11
|
+
&& (!req.headers.origin || req.headers.origin === url.origin)
|
|
12
|
+
&& req.headers['x-forwarded-proto'] === 'https'
|
|
13
|
+
const forward = (req, compat = false) => http.request({
|
|
14
|
+
hostname: '127.0.0.1', port: targetPort, method: req.method,
|
|
15
|
+
path: req.url, headers: compat ? Object.fromEntries([
|
|
16
|
+
...Object.entries(req.headers).filter(([name]) => !['accept-encoding', 'range', 'if-range', 'if-none-match', 'if-modified-since'].includes(name)),
|
|
17
|
+
['accept-encoding', 'identity'],
|
|
18
|
+
]) : req.headers,
|
|
19
|
+
})
|
|
20
|
+
const server = http.createServer((req, res) => {
|
|
21
|
+
if (!allowed(req)) { res.writeHead(403); res.end('Forbidden'); return }
|
|
22
|
+
const compat = safariStreamCompat && isSafariBundleRequest(req)
|
|
23
|
+
const upstream = forward(req, compat)
|
|
24
|
+
upstream.on('response', response => {
|
|
25
|
+
const headers = { ...response.headers, 'referrer-policy': 'no-referrer', 'cache-control': 'private, no-store' }
|
|
26
|
+
if (headers['set-cookie']) headers['set-cookie'] = headers['set-cookie'].map(cookie => /;\s*Secure(?:;|$)/i.test(cookie) ? cookie : `${cookie}; Secure`)
|
|
27
|
+
response.on('error', () => { res.destroy() })
|
|
28
|
+
if (compat) { serveSafariBundle(response, res, headers, onCompatibility, req.headers['accept-encoding']); return }
|
|
29
|
+
res.writeHead(response.statusCode ?? 502, headers)
|
|
30
|
+
response.pipe(res)
|
|
31
|
+
})
|
|
32
|
+
upstream.on('error', () => {
|
|
33
|
+
if (!res.headersSent) { res.writeHead(502); res.end('Host unavailable') } else res.destroy()
|
|
34
|
+
})
|
|
35
|
+
req.on('aborted', () => { upstream.destroy() })
|
|
36
|
+
res.on('close', () => { upstream.destroy() })
|
|
37
|
+
req.pipe(upstream)
|
|
38
|
+
})
|
|
39
|
+
server.on('upgrade', (req, socket, head) => {
|
|
40
|
+
if (!allowed(req)) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return }
|
|
41
|
+
const upstream = forward(req)
|
|
42
|
+
let remote
|
|
43
|
+
socket.on('error', () => { upstream.destroy(); remote?.destroy() })
|
|
44
|
+
socket.on('close', () => { upstream.destroy(); remote?.destroy() })
|
|
45
|
+
upstream.on('error', () => { socket.destroy() })
|
|
46
|
+
// Preserve an upstream auth rejection; never turn a 401 into an upgrade.
|
|
47
|
+
upstream.on('response', response => {
|
|
48
|
+
socket.end(`HTTP/1.1 ${response.statusCode ?? 502} Rejected\r\nConnection: close\r\n\r\n`)
|
|
49
|
+
response.resume()
|
|
50
|
+
})
|
|
51
|
+
upstream.on('upgrade', (response, peer, first) => {
|
|
52
|
+
remote = peer
|
|
53
|
+
const lines = response.rawHeaders.reduce((out, value, index, all) => index % 2 ? out : [...out, `${value}: ${all[index + 1]}`], [])
|
|
54
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${lines.join('\r\n')}\r\n\r\n`)
|
|
55
|
+
peer.on('error', () => { socket.destroy() })
|
|
56
|
+
peer.on('close', () => { socket.destroy() })
|
|
57
|
+
if (first.length) socket.write(first)
|
|
58
|
+
if (head.length) peer.write(head)
|
|
59
|
+
socket.pipe(peer).pipe(socket)
|
|
60
|
+
})
|
|
61
|
+
upstream.end()
|
|
62
|
+
})
|
|
63
|
+
const sockets = new Set()
|
|
64
|
+
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)) })
|
|
65
|
+
return { server, close: async () => {
|
|
66
|
+
for (const socket of sockets) socket.destroy()
|
|
67
|
+
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
68
|
+
} }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
72
|
+
const port = Number(process.env.INGRESS_PORT ?? 3182)
|
|
73
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid ingress port')
|
|
74
|
+
const reported = new Set()
|
|
75
|
+
const ingress = createIngress({ origin: process.env.PUBLIC_ORIGIN, targetPort: Number(process.env.HOST_PORT ?? 3181),
|
|
76
|
+
safariStreamCompat: process.env.MOBILE_SAFARI_COMPAT === '1',
|
|
77
|
+
onCompatibility: result => {
|
|
78
|
+
const label = `${result.status}; count=${result.patched}`
|
|
79
|
+
if (!reported.has(label)) { reported.add(label); console.log(`Mobile Safari compatibility: ${label}`) }
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
ingress.server.listen(port, '127.0.0.1', () => { console.log(`HTTPS tunnel ingress ready on loopback port ${port}`) })
|
|
83
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { void ingress.close().then(() => process.exit(0)) })
|
|
84
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Temporary delivery-only adapter for the 0.1.5-rc.1 Safari JSON validator defect. */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { gzip } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
export const MAX_COMPAT_BYTES = 16 * 1024 * 1024
|
|
6
|
+
const KNOWN_FUNCTION = '32fbc1fc74b60872560e2eac834fa77d3cd3df3ce4a842827df0d2e1fafe7fcd'
|
|
7
|
+
const OLD_INTRINSIC = '`function ${name}() { [native code] }`'
|
|
8
|
+
const NEW_INTRINSIC = 'Function.prototype.toString.call(globalThis[name])'
|
|
9
|
+
|
|
10
|
+
/** Restrict adaptation to WebKit clients requesting official client bundles. */
|
|
11
|
+
export function isSafariBundleRequest(req) {
|
|
12
|
+
const ua = req.headers['user-agent'] ?? ''
|
|
13
|
+
const webkit = /AppleWebKit\//.test(ua)
|
|
14
|
+
&& (/iPhone|iPad|iPod/.test(ua) || !/Chrome\/|Chromium\/|Edg\/|OPR\//.test(ua))
|
|
15
|
+
try { return req.method === 'GET' && webkit && new URL(req.url, 'http://localhost').pathname === '/plugins/' }
|
|
16
|
+
catch { return false }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Match the entire known function, tolerating only indentation and bundler suffixes. */
|
|
20
|
+
export function adaptSafariBundle(source) {
|
|
21
|
+
if (!source.includes(OLD_INTRINSIC)) return { source, status: 'not-needed', patched: 0 }
|
|
22
|
+
const pattern = /^([\t ]*)function (hasIntrinsicConstructor(?:\$\d+)?)\(prototype, name\) \{[\s\S]*?^\1\}/gm
|
|
23
|
+
const matches = [...source.matchAll(pattern)].filter(match => match[0].includes(OLD_INTRINSIC))
|
|
24
|
+
if (!matches.length || matches.some(match => {
|
|
25
|
+
const normalized = match[0].split('\n').map(line => line.trim()).join('\n').replace(match[2], 'hasIntrinsicConstructor')
|
|
26
|
+
return createHash('sha256').update(normalized).digest('hex') !== KNOWN_FUNCTION
|
|
27
|
+
})) return { source, status: 'unrecognized', patched: 0 }
|
|
28
|
+
// All-or-nothing for the matched validators. Unrelated script bytes are retained.
|
|
29
|
+
let result = source
|
|
30
|
+
for (const match of matches.reverse()) {
|
|
31
|
+
result = result.slice(0, match.index) + match[0].replace(OLD_INTRINSIC, NEW_INTRINSIC) + result.slice(match.index + match[0].length)
|
|
32
|
+
}
|
|
33
|
+
return { source: result, status: 'patched', patched: matches.length }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Buffer only bounded, successful JS assets; all application streams bypass this path. */
|
|
37
|
+
function acceptsGzip(value = '') {
|
|
38
|
+
const encodings = new Map(String(value).split(',').map(part => {
|
|
39
|
+
const [name, ...parameters] = part.trim().toLowerCase().split(';')
|
|
40
|
+
const q = parameters.map(p => p.trim()).find(p => p.startsWith('q='))
|
|
41
|
+
const quality = q === undefined ? 1 : Number(q.slice(2))
|
|
42
|
+
return [name, Number.isFinite(quality) && quality > 0 && quality <= 1]
|
|
43
|
+
}))
|
|
44
|
+
return encodings.get('gzip') ?? encodings.get('*') ?? false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function serveSafariBundle(response, res, headers, report = () => {}, acceptEncoding = '') {
|
|
48
|
+
const encoding = response.headers['content-encoding']
|
|
49
|
+
if (response.statusCode !== 200
|
|
50
|
+
|| !/^(?:text|application)\/javascript\b/i.test(String(response.headers['content-type'] ?? ''))
|
|
51
|
+
|| encoding && encoding !== 'identity') {
|
|
52
|
+
res.writeHead(response.statusCode ?? 502, headers)
|
|
53
|
+
response.pipe(res)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
let chunks = [], size = 0, bypass = false
|
|
57
|
+
response.on('data', chunk => {
|
|
58
|
+
if (bypass) return
|
|
59
|
+
size += chunk.length
|
|
60
|
+
chunks.push(chunk)
|
|
61
|
+
if (size <= MAX_COMPAT_BYTES) return
|
|
62
|
+
bypass = true
|
|
63
|
+
report({ status: 'too-large', patched: 0 })
|
|
64
|
+
res.writeHead(200, headers)
|
|
65
|
+
for (const buffered of chunks) res.write(buffered)
|
|
66
|
+
chunks = []
|
|
67
|
+
response.pipe(res)
|
|
68
|
+
})
|
|
69
|
+
response.on('end', () => {
|
|
70
|
+
if (bypass || res.destroyed) return
|
|
71
|
+
const original = Buffer.concat(chunks)
|
|
72
|
+
const result = adaptSafariBundle(original.toString('utf8'))
|
|
73
|
+
const body = result.patched ? Buffer.from(result.source) : original
|
|
74
|
+
const send = (wire, compressed) => {
|
|
75
|
+
if (res.destroyed) return
|
|
76
|
+
const outgoing = { ...headers, 'x-dsh-mobile-compat': `safari-json-v1; ${result.status}; count=${result.patched}` }
|
|
77
|
+
const vary = String(outgoing.vary ?? '').split(',').map(v => v.trim()).filter(Boolean)
|
|
78
|
+
if (!vary.some(v => v === '*' || v.toLowerCase() === 'accept-encoding')) vary.push('Accept-Encoding')
|
|
79
|
+
outgoing.vary = vary.join(', ')
|
|
80
|
+
if (result.patched || compressed) {
|
|
81
|
+
for (const name of ['etag', 'content-md5', 'digest', 'content-digest', 'repr-digest', 'transfer-encoding', 'accept-ranges']) delete outgoing[name]
|
|
82
|
+
outgoing['content-length'] = String(wire.length)
|
|
83
|
+
}
|
|
84
|
+
if (compressed) outgoing['content-encoding'] = 'gzip'
|
|
85
|
+
report({ status: result.status, patched: result.patched })
|
|
86
|
+
res.writeHead(200, outgoing)
|
|
87
|
+
res.end(wire)
|
|
88
|
+
}
|
|
89
|
+
// Identity is needed for inspection, not for the slow phone/tunnel hop.
|
|
90
|
+
// Compress untouched bundles too; the main UI bundle can exceed 12 MB.
|
|
91
|
+
if (body.length >= 1024 && acceptsGzip(acceptEncoding)) {
|
|
92
|
+
gzip(body, { level: 6 }, (error, zipped) => {
|
|
93
|
+
const smaller = !error && zipped.length < body.length
|
|
94
|
+
send(smaller ? zipped : body, smaller)
|
|
95
|
+
})
|
|
96
|
+
} else send(body, false)
|
|
97
|
+
})
|
|
98
|
+
}
|