dsh-desktop-embed 0.0.0-stage → 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lzlrt
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,93 @@
1
- # Temporary Holding Version
1
+ # dsh-desktop-embed
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
+ > **为 [`dsh-desktop-link`](https://github.com/lzlrt/dsha-desktop-link) 的下半场而生。**
4
+ > 那个插件把手机送到电脑版门口;这个插件负责把门整个搬进 DSHA——**桌面界面直接嵌在 DSHA 里用,不再跳外部浏览器**。
5
+
6
+ ## 它解决什么
7
+
8
+ `dsh-desktop-link` 的行为是「跳转」:手机上的顶层导航离开本机服务时,DSHA 按设计把它交给系统浏览器(`WebPreviewActivity.shouldOverrideUrlLoading` → `openExternal`)。于是界面进了 Chrome / 夸克,DSHA 只剩一个壳,原生外壳的价值就没了。
9
+
10
+ `dsh-desktop-embed` 换成另一种走法:**手机上开一个回环反向代理,用一个 iframe 把电脑版界面装进来**。
11
+
12
+ ```
13
+ DSHA 的 WebView
14
+ └── 顶层文档 http://127.0.0.1:<手机 DSH 端口>/embed ← 始终是本机服务,DSHA 不管
15
+ └── iframe http://127.0.0.1:8799/ ← 本插件的反向代理
16
+ └── https://<你的中继>/pair-app ← 电脑上的 dsh web
17
+ ```
18
+
19
+ 三层里只有最里面是真的远端。中间那层由插件自己持有 `dsh_pair` cookie 并在服务端注入,所以**浏览器侧完全不需要第三方 cookie**——正好绕开 DSHA 关掉的 `AcceptThirdPartyCookies`。
20
+
21
+ 两条 DSHA 的既有行为被这个结构利用上了:
22
+
23
+ - `shouldOverrideUrlLoading` 只接管 **main frame**,iframe 显式放行(源码注释原文:「插件的 iframe / 内嵌预览保持 WebView 原有行为,只接管顶层导航。」)。
24
+ - `WebPreviewPolicy.loopbackBaseUrl` 接受 **任意回环端口**(`http://127.0.0.1:<1..65535>/`),不只是 DSH 自己那个端口——所以 8799 也是一个合法的「本机服务」。
25
+
26
+ 代理挂在根路径上,所以 `/assets/…`、`/plugins/…`、`/api/…` 全部照常穿过。三处路径改写是实测出来的,缺一个页面就起不来:
27
+
28
+ | 进来的路径 | 发给中继的路径 | 为什么 |
29
+ |---|---|---|
30
+ | `/` | `/pair-app` | 中继的 `/` 是「认证门」,带着有效 cookie 也只回 401;应用页在 `/pair-app` |
31
+ | `/api/…` | `/remote/api/…` | 中继把已认证 API 挂在 `/remote` 下。GET 两个前缀都通,但 **POST/PUT 只认 `/remote/api/…`**,裸 `/api/*` 会被桌面 api-gateway 回 `403 forbidden`,页面就永远停在「Loading plugins…」 |
32
+ | 其它 | 原样 | `<base href="./">` 让资源和插件目录仍落在 `/assets/…`、`/plugins/…` |
33
+
34
+ ## 装在哪一侧
35
+
36
+ **手机侧(DSHA 内置的那个 DSH)**。页面由手机自己的 DSH 提供,代理也跑在手机上。
37
+
38
+ ```bash
39
+ dsh plugin --profile web add dsh-desktop-embed
40
+ ```
41
+
42
+ 装完刷新页面,侧栏底部多一个「桌面版」按钮。
43
+
44
+ ## 第一次使用:填两个值
45
+
46
+ 点侧栏的「桌面版」按钮会先打开 `/embed/setup`(纯表单,没有 JavaScript):
47
+
48
+ | 字段 | 填什么 |
49
+ |---|---|
50
+ | 中转地址 | `dsh-desktop-link` 那套 `bridge.py` 的地址,例如 `http://192.168.100.2:8791` |
51
+ | 中转密钥 | `bridge-secret.txt` 里的那串 hex |
52
+ | 代理端口 | 留空用默认 `8799`;被占用就换一个 1024–65535 |
53
+
54
+ 提交后插件会立刻向桥要一次配对链接(`GET /link`)、跟一次 `/pair-accept`,把 `dsh_pair` 拿在手里,然后 303 跳到 `/embed`。
55
+
56
+ 配置落在 `$DSH_HOME/dsh-embed.json`(权限 `0600`)。也可以用环境变量覆盖:
57
+
58
+ ```bash
59
+ export DSH_EMBED_BRIDGE_URL=http://192.168.100.2:8791
60
+ export DSH_EMBED_BRIDGE_KEY=<bridge-secret>
61
+ export DSH_EMBED_PORT=8799
62
+ ```
63
+
64
+ ## 自检
65
+
66
+ ```bash
67
+ curl -s http://127.0.0.1:8799/embed/setup -H 'sec-fetch-site: none' | grep -o 'name="[a-zA-Z]*"'
68
+ ```
69
+
70
+ 手机端更直接:点按钮 → 填两个值 → 页面里出现电脑版界面,且**地址栏仍是 127.0.0.1**。
71
+
72
+ ## 已知限制
73
+
74
+ 1. **`Secure` cookie 不能用明文 http 送过去。** 中继的 `dsh_pair` 带 `Secure`;手机 → 中继这一段如果是 `https://`(中继本身就是 https,正常情况都满足),代理拿着 cookie 发请求没问题。但如果你的 `bridgeUrl` 指向一个 http 桥,插件只是拿它当「取链接的信使」,不拿它传 cookie,所以照样可用。
75
+ 2. **中继掉线要重新配对。** 令牌 600 秒过期、会话 cookie 一年;代理在收到上游 401 时会**自动重铸一次再重试**,所以一般无感。若中继换域名(`*.trycloudflare.com` 就是这样),要在 `/embed/setup` 里改 `bridgeUrl`。
76
+ 3. **`/embed` 不接管键盘快捷键**:DSHA 的返回手势会直接退出这个页面(顶层导航回对话列表),这是 WebView 的正常行为。
77
+ 4. **只支持 `http://127.0.0.1`**:DSHA 的预览策略只认回环明文。别把代理绑到 `0.0.0.0`,也没必要。
78
+ 5. **中继不转发的部分插件静态资源会 403**:中继只放行 `/api`、`/assets`、`/plugins/??…` 目录批量加载和 `/pair-app`;个别插件用绝对路径挂的自己的静态文件(实测 `dsh-whale/widget.js`、`dsh-pet/pet.js`、`/plugins/<插件>/state`)会 403。它们都是可选挂件,界面照常可用,控制台里会留几条 403。
79
+ 6. **iframe 里的页面禁止混合内容**(`MIXED_CONTENT_NEVER_ALLOW`):中继页面上的跨站资源只有一条 https 的 `.webmcp/bridge.js`,被同源策略挡掉,无害。
80
+
81
+ ## 与另外两个插件的关系
82
+
83
+ | 插件 | 装在哪 | 干什么 |
84
+ |---|---|---|
85
+ | `dsh-desktop-link` | 手机 | 侧栏按钮 + `/desktop-link/settings`;把手机送到电脑版门口(跳浏览器) |
86
+ | `dsh-desktop-embed` | 手机 | 侧栏按钮 + `/embed`;把电脑版界面嵌在 DSHA 里(不跳浏览器) |
87
+ | `dsh-mobile-boost` | 电脑 | 让电脑版界面在窄屏触摸下真的能用 |
88
+
89
+ 装 `dsh-desktop-embed` 之后,仍然建议在**电脑** profile 里装 `dsh-mobile-boost`:嵌入的界面是电脑版页面,窄屏适配由它负责。
90
+
91
+ ## License
92
+
93
+ MIT © 2026 lzlrt
package/client.js ADDED
@@ -0,0 +1,152 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-desktop-embed',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+
7
+ /** Locale namespace owned by this plugin. */
8
+ const NS = 'dsh-embed'
9
+ /** 同源页面,宿主半区提供。顶层导航仍在 127.0.0.1 上,DSHA 不会把它交给浏览器。 */
10
+ const EMBED_PATH = '/embed'
11
+
12
+ const zh = {
13
+ 'action': '桌面版(嵌在这里)',
14
+ 'action.short': '桌面版',
15
+ 'hint': '在 DSHA 里打开电脑版界面,不跳浏览器',
16
+ 'crash.fallback': '内嵌按钮出错了,界面其余部分不受影响。也可以直接在地址栏打开 /embed。',
17
+ }
18
+ const en = {
19
+ 'action': 'Desktop DSH (embedded)',
20
+ 'action.short': 'Desktop',
21
+ 'hint': 'Open the desktop UI inside DSHA, not in the browser',
22
+ 'crash.fallback': 'The embed button failed; the rest of the UI is fine. You can also open /embed directly.',
23
+ }
24
+
25
+ /** 一台显示器 + 里面一个内嵌框:和 dsh-desktop-link 的图标区分开。 */
26
+ function Mark() {
27
+ return h('svg', {
28
+ viewBox: '0 0 64 64', width: 18, height: 18, 'aria-hidden': true, focusable: false,
29
+ style: { display: 'block', flex: 'none' },
30
+ },
31
+ h('rect', {
32
+ x: 7, y: 9, width: 50, height: 32, rx: 5,
33
+ fill: 'none', stroke: 'currentColor', strokeWidth: 4,
34
+ }),
35
+ h('rect', {
36
+ x: 17, y: 18, width: 30, height: 14, rx: 2.5,
37
+ fill: 'none', stroke: 'currentColor', strokeWidth: 3, strokeDasharray: '5 4',
38
+ }),
39
+ h('path', {
40
+ d: 'M26 55h12M32 42v13', fill: 'none', stroke: 'currentColor',
41
+ strokeWidth: 4, strokeLinecap: 'round',
42
+ }))
43
+ }
44
+
45
+ /**
46
+ * 把子树包进 error boundary。
47
+ *
48
+ * 侧栏是每次渲染都要跑的公共路径,这里任何一次抛错都会把整页带下去
49
+ * (DSHA 上表现为只剩基础界面)。兜住它,最坏情况只是少一个按钮。
50
+ */
51
+ class Boundary extends React.Component {
52
+ constructor(props) {
53
+ super(props)
54
+ this.state = { failed: false }
55
+ }
56
+
57
+ static getDerivedStateFromError() {
58
+ return { failed: true }
59
+ }
60
+
61
+ componentDidCatch(error) {
62
+ try {
63
+ console.error('dsh-desktop-embed: render failed', error)
64
+ } catch {}
65
+ }
66
+
67
+ render() {
68
+ if (this.state.failed) {
69
+ const t = this.props.t || ((key) => zh[key] || en[key] || key)
70
+ return h('span', {
71
+ style: { fontSize: 12, opacity: .7, padding: '0 6px' },
72
+ title: t('crash.fallback'),
73
+ }, t('crash.fallback'))
74
+ }
75
+ return this.props.children
76
+ }
77
+ }
78
+
79
+ const baseButtonStyle = (wide) => ({
80
+ display: 'inline-flex',
81
+ alignItems: 'center',
82
+ justifyContent: wide ? 'flex-start' : 'center',
83
+ flex: 'none',
84
+ height: 28,
85
+ minWidth: 28,
86
+ padding: wide ? '0 8px' : 0,
87
+ boxSizing: 'border-box',
88
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
89
+ color: 'var(--dsw-alias-label-secondary)',
90
+ background: 'transparent',
91
+ border: 'none',
92
+ cursor: 'pointer',
93
+ fontSize: 13,
94
+ fontFamily: 'inherit',
95
+ textDecoration: 'none',
96
+ whiteSpace: 'nowrap',
97
+ })
98
+
99
+ const hoverIn = (e) => {
100
+ e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))'
101
+ }
102
+ const hoverOut = (e) => { e.currentTarget.style.background = 'transparent' }
103
+
104
+ function EmbedButton(props) {
105
+ const wide = props.wide !== false
106
+ const t = props.t || ((key) => zh[key] || en[key] || key)
107
+
108
+ const onClick = (event) => {
109
+ // 同源、同服务:DSHA 的 shouldOverrideUrlLoading 只在顶层离开本机服务时
110
+ // 才调 openExternal,所以这一次跳转留在 WebView 里。
111
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
112
+ event.preventDefault()
113
+ window.location.assign(EMBED_PATH)
114
+ }
115
+
116
+ return h(Boundary, { t }, h('a', {
117
+ href: EMBED_PATH,
118
+ style: baseButtonStyle(wide),
119
+ title: t('hint'),
120
+ 'aria-label': t('action'),
121
+ onClick,
122
+ onMouseEnter: hoverIn,
123
+ onMouseLeave: hoverOut,
124
+ },
125
+ h(Mark),
126
+ wide
127
+ ? h('span', {
128
+ style: { marginLeft: 8, overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 },
129
+ }, t('action.short'))
130
+ : null))
131
+ }
132
+
133
+ return {
134
+ inject: ['slots', 'locale'],
135
+ apply(ctx) {
136
+ ctx.effect(() => {
137
+ const offZh = ctx.locale.register(NS, 'zh', zh)
138
+ const offEn = ctx.locale.register(NS, 'en', en)
139
+ return () => { offZh(); offEn() }
140
+ }, 'dsh-desktop-embed: dictionaries')
141
+
142
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
143
+ name: 'sidebar.footer.action',
144
+ id: 'dsh-desktop-embed',
145
+ order: 12,
146
+ label: () => ctx.locale.bind(NS)('action'),
147
+ locale: NS,
148
+ }, EmbedButton))
149
+ },
150
+ }
151
+ },
152
+ })
@@ -0,0 +1,5 @@
1
+ # dsh-desktop-embed:把桌面版 DSH 嵌进 DSHA 的 WebView(手机侧插件)。
2
+ # 配置走 $DSH_HOME/dsh-embed.json、环境变量,或安装后打开的 /embed/setup。
3
+ - insert:
4
+ - id: dsh-desktop-embed
5
+ name: 'dsh-desktop-embed'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="#111"/>
3
+ <rect x="9" y="12" width="46" height="29" rx="4" fill="none" stroke="#fff" stroke-width="3.4"/>
4
+ <rect x="17" y="19" width="30" height="15" rx="2.5" fill="none" stroke="#4f8cff" stroke-width="2.8" stroke-dasharray="5 4"/>
5
+ <path d="M26 50h12M32 41v9" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,675 @@
1
+ /**
2
+ * dsh-desktop-embed —— 宿主半区(装在**手机**侧那一份 DSH 上)。
3
+ *
4
+ * 要解决的问题
5
+ * ------------
6
+ * DSHA 的 WebView 只认 `http://127.0.0.1:<端口>` 这一个服务,任何**顶层**跳转到别的
7
+ * 地址都会被 `openExternal()` 交给系统浏览器。源码里那行判断写得很清楚:
8
+ *
9
+ * // 插件的 iframe / 内嵌预览保持 WebView 原有行为,只接管顶层导航。
10
+ * if (!request.isForMainFrame()) return false;
11
+ *
12
+ * 也就是说:**iframe 是被允许的,被拦掉的只有顶层跳转。** 于是只要让桌面版界面以
13
+ * `127.0.0.1` 的地址出现在一个 iframe 里,它就留在 DSHA 里,不会再被甩给浏览器。
14
+ *
15
+ * 但直接把中继地址塞进 iframe 不行:`/pair-accept` 下发的 `dsh_pair` 是
16
+ * `SameSite=Lax; Secure`,而 DSHA 又显式关掉了第三方 cookie
17
+ * (`cookies.setAcceptThirdPartyCookies(view, false)`),跨站的 iframe 拿不到它。
18
+ *
19
+ * 所以这里起一个**回环反向代理**(默认 127.0.0.1:8799):代理自己持有配对 cookie,
20
+ * 服务端注入到每一个上游请求里;浏览器侧完全不需要 cookie。iframe 指向代理,
21
+ * 代理把 `https://<中继>` 以 `http://127.0.0.1:8799` 同源端出来 —— 页面里的
22
+ * 绝对路径(`/assets/…`、`/plugins/…`、`/api/…`)和 WebSocket 全部照常工作。
23
+ *
24
+ * 路由(都挂在这台手机的 DSH 上)
25
+ * ------------------------------
26
+ * GET /embed iframe 外壳页(全屏,零外链)
27
+ * GET /embed/setup 无脚本配置表单
28
+ * POST /embed/setup 保存并立即配对一次,然后 303 到 /embed
29
+ * GET /api/dsh-embed/status 同源 JSON 诊断
30
+ *
31
+ * 配置来源(命中即停):本行 config → 环境变量 → `$DSH_HOME/dsh-embed.json`
32
+ *
33
+ * {
34
+ * "bridgeUrl": "http://192.168.100.2:8791", // dsha-pair/bridge.py
35
+ * "bridgeKey": "<bridge-secret.txt 的内容>",
36
+ * "proxyPort": 8799
37
+ * }
38
+ *
39
+ * 配对流程:`GET {bridgeUrl}/link` 铸一枚码 → 取回 `pair-accept?pair=…` 的
40
+ * `Set-Cookie: dsh_pair=…`(不跟随重定向,只留 cookie)→ 中继 origin 由该链接推出。
41
+ * cookie 只留在内存里,进程重启就重铸一枚;设备会话本身仍然沿用
42
+ * `~/.dsh/remote-web-ui-devices.json`,重启 `dsh web` 不会掉。
43
+ */
44
+ import { randomBytes } from 'node:crypto'
45
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
46
+ import { createServer, request as httpRequest } from 'node:http'
47
+ import { request as httpsRequest } from 'node:https'
48
+ import { createRequire } from 'node:module'
49
+ import { homedir } from 'node:os'
50
+ import { dirname, join, resolve } from 'node:path'
51
+ import { fileURLToPath } from 'node:url'
52
+
53
+ const NS = 'dsh-embed'
54
+ const EMBED_PATH = '/embed'
55
+ const SETUP_PATH = '/embed/setup'
56
+ const STATUS_PATH = '/api/dsh-embed/status'
57
+ const DEFAULT_PROXY_PORT = 8799
58
+ const CONFIG_FILE_NAME = 'dsh-embed.json'
59
+ /** cookie 由 `/embed` 下发、由代理校验;都绑在 127.0.0.1 上(cookie 不区分端口)。 */
60
+ const GATE_COOKIE = 'dsh_embed'
61
+ const TARGET_COOKIE = 'dsh_pair'
62
+
63
+ /**
64
+ * 定位随 harness 一起安装的 schemastery;找不到就不导出 Config,
65
+ * 插件仍然可以用文件 / 环境变量配置。裸 `import` 在插件目录里解析不到,
66
+ * 会把整个插件一起拖死。
67
+ * @returns {any} schemastery 的 z 函数,或 undefined
68
+ */
69
+ function loadSchemastery() {
70
+ const requireFrom = createRequire(import.meta.url)
71
+ const candidates = ['@deepseek-ai/schemastery']
72
+ const argv1 = process.argv[1]
73
+ if (typeof argv1 === 'string' && argv1) {
74
+ const dir = dirname(resolve(argv1))
75
+ candidates.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'schemastery'))
76
+ candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'schemastery'))
77
+ }
78
+ try {
79
+ for (const base of requireFrom.resolve.paths?.('@deepseek-ai/schemastery') ?? []) {
80
+ candidates.push(join(base, '@deepseek-ai', 'schemastery'))
81
+ }
82
+ } catch {}
83
+ for (const spec of candidates) {
84
+ try {
85
+ const mod = requireFrom(spec)
86
+ const z = mod?.default ?? mod
87
+ if (typeof z === 'function') return z
88
+ } catch {}
89
+ }
90
+ return undefined
91
+ }
92
+
93
+ const z = loadSchemastery()
94
+ const configSchema = z?.object({
95
+ bridgeUrl: z.string().description('中转地址,例如 http://192.168.100.2:8791(dsha-pair/bridge.py)'),
96
+ bridgeKey: z.string().description('中转密钥,就是 bridge-secret.txt 的内容'),
97
+ proxyPort: z.natural().description('回环代理端口,默认 8799'),
98
+ })
99
+
100
+ function dshHome() {
101
+ const fromEnv = process.env.DSH_HOME
102
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return resolve(fromEnv.trim())
103
+ return join(homedir(), '.dsh')
104
+ }
105
+
106
+ function configPath() {
107
+ return join(dshHome(), CONFIG_FILE_NAME)
108
+ }
109
+
110
+ function readConfigFile() {
111
+ const file = configPath()
112
+ if (!existsSync(file)) return {}
113
+ try {
114
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
115
+ return parsed && typeof parsed === 'object' ? parsed : {}
116
+ } catch {
117
+ return {}
118
+ }
119
+ }
120
+
121
+ function writeConfigFile(next) {
122
+ const file = configPath()
123
+ const tmp = `${file}.tmp`
124
+ const payload = `${JSON.stringify(next, null, 2)}\n`
125
+ writeFileSync(tmp, payload, { mode: 0o600 })
126
+ try {
127
+ renameSync(tmp, file)
128
+ } catch {
129
+ writeFileSync(file, payload, { mode: 0o600 })
130
+ }
131
+ }
132
+
133
+ /** 逐字段读配置:Cordis 的 config 是代理对象,整块展开可能抛错。 */
134
+ function resolveConfig(live) {
135
+ const fromFile = readConfigFile()
136
+ const pick = (name, envName, fallback) => {
137
+ let value
138
+ try {
139
+ value = live?.[name]
140
+ } catch {
141
+ value = undefined
142
+ }
143
+ if (typeof value === 'string' && value.trim()) return value.trim()
144
+ if (typeof value === 'number' && Number.isFinite(value)) return value
145
+ const env = process.env[envName]
146
+ if (typeof env === 'string' && env.trim()) return env.trim()
147
+ const file = fromFile[name]
148
+ if (typeof file === 'string' && file.trim()) return file.trim()
149
+ if (typeof file === 'number' && Number.isFinite(file)) return file
150
+ return fallback
151
+ }
152
+ const port = Number(pick('proxyPort', 'DSH_EMBED_PORT', DEFAULT_PROXY_PORT))
153
+ return {
154
+ bridgeUrl: String(pick('bridgeUrl', 'DSH_EMBED_BRIDGE_URL', '')).replace(/\/+$/, ''),
155
+ bridgeKey: String(pick('bridgeKey', 'DSH_EMBED_BRIDGE_KEY', '')),
156
+ proxyPort: Number.isInteger(port) && port >= 1024 && port <= 65535 ? port : DEFAULT_PROXY_PORT,
157
+ }
158
+ }
159
+
160
+ /** 会话状态:中继 origin + 配对 cookie,全在内存里。 */
161
+ function createState() {
162
+ return {
163
+ relay: '',
164
+ cookie: '',
165
+ mintedAt: 0,
166
+ lastError: '',
167
+ server: null,
168
+ actualPort: 0,
169
+ gate: randomBytes(8).toString('hex'),
170
+ minting: null,
171
+ }
172
+ }
173
+
174
+ function escapeHtml(value) {
175
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
176
+ '&': '&amp;',
177
+ '<': '&lt;',
178
+ '>': '&gt;',
179
+ '"': '&quot;',
180
+ "'": '&#39;',
181
+ })[ch])
182
+ }
183
+
184
+ function sendHtml(res, status, body) {
185
+ const raw = Buffer.from(body, 'utf8')
186
+ res.writeHead(status, {
187
+ 'content-type': 'text/html; charset=utf-8',
188
+ 'content-length': String(raw.length),
189
+ 'cache-control': 'no-store',
190
+ })
191
+ res.end(raw)
192
+ }
193
+
194
+ function sendJson(res, status, obj) {
195
+ const raw = Buffer.from(`${JSON.stringify(obj)}\n`, 'utf8')
196
+ res.writeHead(status, {
197
+ 'content-type': 'application/json; charset=utf-8',
198
+ 'content-length': String(raw.length),
199
+ 'cache-control': 'no-store',
200
+ })
201
+ res.end(raw)
202
+ }
203
+
204
+ function parseCookies(header) {
205
+ const out = {}
206
+ if (typeof header !== 'string' || !header) return out
207
+ for (const part of header.split(';')) {
208
+ const idx = part.indexOf('=')
209
+ if (idx < 0) continue
210
+ const name = part.slice(0, idx).trim()
211
+ if (!name) continue
212
+ out[name] = part.slice(idx + 1).trim()
213
+ }
214
+ return out
215
+ }
216
+
217
+ /**
218
+ * 铸一次配对:bridge 铸码 → 取回 pair-accept 的 Set-Cookie。
219
+ * 只留 `dsh_pair`,不跟随 303(跟过去只会把设备会话又打一次)。
220
+ */
221
+ async function mint(cfg) {
222
+ if (!cfg.bridgeUrl) throw new Error('还没配置中转地址(bridgeUrl)')
223
+ const linkRes = await fetch(`${cfg.bridgeUrl}/link`, {
224
+ headers: cfg.bridgeKey ? { 'x-pair-key': cfg.bridgeKey } : {},
225
+ redirect: 'manual',
226
+ })
227
+ if (!linkRes.ok) throw new Error(`中转返回 HTTP ${linkRes.status}`)
228
+ const link = await linkRes.json()
229
+ const pairUrl = typeof link?.url === 'string' ? link.url : ''
230
+ if (!pairUrl) throw new Error('中转没有返回配对链接')
231
+ const accept = await fetch(pairUrl, { redirect: 'manual' })
232
+ const cookies = typeof accept.headers.getSetCookie === 'function' ? accept.headers.getSetCookie() : []
233
+ const pair = cookies
234
+ .map((line) => line.split(';')[0].trim())
235
+ .find((line) => line.startsWith(`${TARGET_COOKIE}=`))
236
+ if (!pair) throw new Error(`配对链接没有下发 cookie(HTTP ${accept.status})`)
237
+ return { relay: new URL(pairUrl).origin, cookie: pair }
238
+ }
239
+
240
+ /** 同一时刻只铸一次。 */
241
+ function ensureSession(state, cfg) {
242
+ if (state.minting) return state.minting
243
+ state.minting = (async () => {
244
+ try {
245
+ const { relay, cookie } = await mint(cfg)
246
+ state.relay = relay
247
+ state.cookie = cookie
248
+ state.mintedAt = Date.now()
249
+ state.lastError = ''
250
+ } catch (error) {
251
+ state.lastError = error?.message ?? String(error)
252
+ throw error
253
+ } finally {
254
+ state.minting = null
255
+ }
256
+ })()
257
+ return state.minting
258
+ }
259
+
260
+ const HOP_BY_HOP = new Set([
261
+ 'connection',
262
+ 'keep-alive',
263
+ 'proxy-authenticate',
264
+ 'proxy-authorization',
265
+ 'te',
266
+ 'trailer',
267
+ 'transfer-encoding',
268
+ 'upgrade',
269
+ 'host',
270
+ 'cookie',
271
+ 'origin',
272
+ 'referer',
273
+ 'accept-encoding',
274
+ ])
275
+
276
+ function upstreamHeaders(incoming, cookie, host) {
277
+ const headers = {}
278
+ for (const [name, value] of Object.entries(incoming)) {
279
+ const key = name.toLowerCase()
280
+ if (HOP_BY_HOP.has(key)) continue
281
+ if (key.startsWith('sec-fetch-')) continue
282
+ if (value === undefined) continue
283
+ headers[key] = value
284
+ }
285
+ headers.host = host
286
+ headers.cookie = cookie
287
+ headers['accept-encoding'] = 'identity'
288
+ return headers
289
+ }
290
+
291
+ /** 下游不能看到上游的 cookie,也不能让它声明自己不许被嵌。 */
292
+ const STRIPPED_RESPONSE = new Set([
293
+ 'set-cookie',
294
+ 'content-encoding',
295
+ 'content-length',
296
+ 'x-frame-options',
297
+ 'content-security-policy',
298
+ 'content-security-policy-report-only',
299
+ 'strict-transport-security',
300
+ 'alt-svc',
301
+ 'location',
302
+ ])
303
+
304
+ function downstreamHeaders(incoming, upstreamOrigin) {
305
+ const headers = {}
306
+ for (const [name, value] of Object.entries(incoming)) {
307
+ const key = name.toLowerCase()
308
+ if (STRIPPED_RESPONSE.has(key)) continue
309
+ if (value === undefined) continue
310
+ headers[key] = value
311
+ }
312
+ return headers
313
+ }
314
+
315
+ function rewriteLocation(value, upstreamOrigin) {
316
+ if (typeof value !== 'string' || !value) return value
317
+ try {
318
+ const url = new URL(value, upstreamOrigin)
319
+ if (url.origin === upstreamOrigin) return `${url.pathname}${url.search}${url.hash}`
320
+ return value
321
+ } catch {
322
+ return value
323
+ }
324
+ }
325
+
326
+ function gateOk(state, req) {
327
+ const cookies = parseCookies(req.headers.cookie)
328
+ return cookies[GATE_COOKIE] === state.gate
329
+ }
330
+
331
+ /**
332
+ * 代理入口映射:中继的 `/` 是「认证门」,带 cookie 也只回 401;
333
+ * 真正的应用页在 `/pair-app`。所以把根路径改写成它,其余路径原样透传
334
+ * (`<base href="./">` 让资源仍落在 `/assets/…`、`/plugins/…`)。
335
+ */
336
+ const ENTRY_PATH = '/pair-app'
337
+
338
+ function upstreamPath(rawUrl) {
339
+ const url = new URL(rawUrl || '/', 'http://placeholder.invalid')
340
+ const pathname = url.pathname || '/'
341
+ if (pathname === '/') return `${ENTRY_PATH}${url.search}`
342
+ // 中继把「已认证 API」挂在 /remote 下:GET 两个前缀都通,但 POST/PUT 只认
343
+ // /remote/api/*(裸 /api/* 会被桌面 api-gateway 以 403 forbidden 拒掉)。
344
+ // 浏览器侧只有在 relay 域名上运行时才会自己加这个前缀,127.0.0.1 不会,
345
+ // 所以这里统一补上。
346
+ if (pathname.startsWith('/api/')) return `/remote${pathname}${url.search}`
347
+ return `${pathname}${url.search}`
348
+ }
349
+
350
+ /**
351
+ * 通用反向代理:路径、方法、请求体、响应体一律原样转发,cookie 服务端注入。
352
+ * 上游 401 时重铸一次再重试(设备会话过期 / cookie 被顶掉的常见情形)。
353
+ */
354
+ function proxyOnce(state, clientReq, clientRes, retried = false) {
355
+ if (!state.relay || !state.cookie) {
356
+ return sendHtml(
357
+ clientRes,
358
+ 503,
359
+ page('还没配对', '<p>这个代理还没有拿到配对 cookie。</p><p><a href="/embed/setup">先配置中转</a></p>'),
360
+ )
361
+ }
362
+ const target = new URL(clientReq.url || '/', state.relay)
363
+ if (target.origin !== state.relay) {
364
+ return sendHtml(clientRes, 400, page('地址不对', '<p>只代理同一个中继。</p>'))
365
+ }
366
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
367
+ const upstream = mod({
368
+ protocol: target.protocol,
369
+ hostname: target.hostname,
370
+ port: target.port || undefined,
371
+ method: clientReq.method,
372
+ path: upstreamPath(clientReq.url),
373
+ headers: upstreamHeaders(clientReq.headers, state.cookie, target.host),
374
+ agent: false,
375
+ })
376
+ upstream.on('response', (up) => {
377
+ if (up.statusCode === 401 && !retried && state.canRemint) {
378
+ up.resume()
379
+ return state
380
+ .remint()
381
+ .then(() => proxyOnce(state, clientReq, clientRes, true))
382
+ .catch((error) => proxyOnce(state, clientReq, clientRes, true) ?? sendHtml(clientRes, 502, page('上游失败', `<p>${escapeHtml(error?.message)}</p>`)))
383
+ }
384
+ const headers = downstreamHeaders(up.headers, state.relay)
385
+ const location = rewriteLocation(up.headers.location, state.relay)
386
+ if (location) headers.location = location
387
+ clientRes.writeHead(up.statusCode ?? 502, headers)
388
+ up.pipe(clientRes)
389
+ })
390
+ upstream.on('error', (error) => {
391
+ if (!clientRes.headersSent) {
392
+ sendHtml(clientRes, 502, page('上游失败', `<p>${escapeHtml(error?.message ?? error)}</p>`))
393
+ } else {
394
+ clientRes.destroy()
395
+ }
396
+ })
397
+ clientReq.pipe(upstream)
398
+ }
399
+
400
+ /** WebSocket / 其它 upgrade:握手与后续字节全部双向打通。 */
401
+ function proxyUpgrade(state, req, socket, head) {
402
+ if (!gateOk(state, req) || !state.relay || !state.cookie) {
403
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n')
404
+ return
405
+ }
406
+ const target = new URL(req.url || '/', state.relay)
407
+ const mod = target.protocol === 'https:' ? httpsRequest : httpRequest
408
+ const headers = upstreamHeaders(req.headers, state.cookie, target.host)
409
+ headers.connection = 'Upgrade'
410
+ headers.upgrade = req.headers.upgrade || 'websocket'
411
+ if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
412
+ if (req.headers['sec-websocket-version']) headers['sec-websocket-version'] = req.headers['sec-websocket-version']
413
+ const upstream = mod({
414
+ protocol: target.protocol,
415
+ hostname: target.hostname,
416
+ port: target.port || undefined,
417
+ method: 'GET',
418
+ path: upstreamPath(req.url),
419
+ headers,
420
+ agent: false,
421
+ })
422
+ upstream.on('upgrade', (up, upSocket, upHead) => {
423
+ const lines = [`HTTP/1.1 101 ${up.statusMessage || 'Switching Protocols'}`]
424
+ const raw = up.rawHeaders || []
425
+ for (let i = 0; i + 1 < raw.length; i += 2) {
426
+ const name = String(raw[i]).toLowerCase()
427
+ if (name === 'upgrade' || name === 'connection' || name === 'sec-websocket-accept' || name === 'sec-websocket-protocol' || name === 'sec-websocket-extensions') {
428
+ lines.push(`${raw[i]}: ${raw[i + 1]}`)
429
+ }
430
+ }
431
+ if (!raw.some((name) => String(name).toLowerCase() === 'sec-websocket-accept')) {
432
+ socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n')
433
+ return
434
+ }
435
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`)
436
+ if (upHead?.length) upSocket.unshift(upHead)
437
+ if (head?.length) upSocket.write(head)
438
+ upSocket.pipe(socket)
439
+ socket.pipe(upSocket)
440
+ const kill = () => {
441
+ upSocket.destroy()
442
+ socket.destroy()
443
+ }
444
+ upSocket.on('error', kill)
445
+ socket.on('error', kill)
446
+ upSocket.on('close', () => socket.destroy())
447
+ socket.on('close', () => upSocket.destroy())
448
+ })
449
+ upstream.on('response', (up) => {
450
+ const body = `上游拒绝了 upgrade(HTTP ${up.statusCode ?? 0})\n`
451
+ up.resume()
452
+ socket.end(`HTTP/1.1 ${up.statusCode ?? 502} Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
453
+ })
454
+ upstream.on('error', () => socket.destroy())
455
+ upstream.end()
456
+ }
457
+
458
+ function page(title, body, extraHead = '') {
459
+ return `<!doctype html>
460
+ <html lang="zh"><head><meta charset="utf-8">
461
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
462
+ <title>${escapeHtml(title)}</title>
463
+ <style>
464
+ :root { color-scheme: light dark; }
465
+ body { font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; padding: 24px; }
466
+ main { max-width: 680px; margin: 0 auto; }
467
+ h1 { font-size: 20px; margin: 0 0 12px; }
468
+ p, li { color: #444; }
469
+ label { display: block; margin: 14px 0 6px; font-size: 14px; color: #555; }
470
+ input { width: 100%; box-sizing: border-box; font-size: 16px; padding: 10px 12px;
471
+ border: 1px solid #bbb; border-radius: 10px; }
472
+ button { margin-top: 18px; width: 100%; min-height: 44px; font-size: 16px;
473
+ border: 0; border-radius: 12px; background: #2b6cff; color: #fff; }
474
+ .note { font-size: 13px; color: #666; margin-top: 6px; }
475
+ a { color: #2b6cff; }
476
+ @media (prefers-color-scheme: dark) { p, li { color: #bbb; } input { background: #1b1b1b; color: #eee; border-color: #444; } .note { color: #999; } }
477
+ </style>${extraHead}</head><body><main>${body}</main></body></html>`
478
+ }
479
+
480
+ /** iframe 外壳:整页只有一个 iframe,唯一的外壳元素是一个小齿轮。 */
481
+ function embedPage(state, port) {
482
+ const src = `http://127.0.0.1:${port}/`
483
+ return page(
484
+ '桌面版 DSH',
485
+ `<iframe src="${escapeHtml(src)}" title="桌面版 DSH"
486
+ allow="clipboard-read; clipboard-write; microphone; camera"
487
+ style="position:fixed;inset:0;width:100%;height:100%;border:0;background:#fff"></iframe>
488
+ <a href="/embed/setup" title="中转与端口设置"
489
+ style="position:fixed;right:10px;bottom:calc(10px + env(safe-area-inset-bottom));z-index:2147483000;
490
+ width:40px;height:40px;border-radius:20px;background:rgba(0,0,0,.45);color:#fff;
491
+ display:flex;align-items:center;justify-content:center;text-decoration:none;
492
+ font:18px/1 system-ui">⚙</a>`,
493
+ '<style>body{margin:0;padding:0;overflow:hidden}</style>',
494
+ )
495
+ }
496
+
497
+ function setupPage(state, cfg, notice = '') {
498
+ const rows = [
499
+ ['bridgeUrl', '中转地址', cfg.bridgeUrl || 'http://192.168.100.2:8791', '运行 dsha-pair/bridge.py 的那台电脑,手机要能访问到。'],
500
+ ['bridgeKey', '中转密钥', cfg.bridgeKey || '', '就是 bridge-secret.txt 的内容。'],
501
+ ['proxyPort', '回环代理端口', String(cfg.proxyPort || DEFAULT_PROXY_PORT), '默认 8799,只有手机本机能访问。'],
502
+ ]
503
+ const body = `<h1>把桌面版嵌进 DSHA</h1>
504
+ <p>这台手机的 DSHA 会把任何顶层跳转交给系统浏览器,但**不会**拦截 iframe。
505
+ 所以这个插件在手机里起一个回环代理,把电脑上的界面以 <code>127.0.0.1</code> 端出来,
506
+ 再用 iframe 嵌回 DSHA 自己的页面。</p>
507
+ ${notice ? `<p style="color:#b00">${escapeHtml(notice)}</p>` : ''}
508
+ <p class="note">当前状态:${state.relay ? `已配对 ${escapeHtml(state.relay)}` : '还未配对'}${state.actualPort ? ` · 代理端口 ${state.actualPort}` : ''}</p>
509
+ <form method="post" action="/embed/setup">
510
+ ${rows
511
+ .map(
512
+ ([name, label, value, hint]) =>
513
+ `<label for="${name}">${label}</label>
514
+ <input id="${name}" name="${name}" value="${escapeHtml(value)}" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false">
515
+ <div class="note">${escapeHtml(hint)}</div>`,
516
+ )
517
+ .join('\n ')}
518
+ <button type="submit">保存并配对</button>
519
+ </form>
520
+ <p class="note">电脑侧要先跑起来:<code>python bridge.py</code>(默认 8791)。
521
+ 配对只是把一枚 <code>dsh_pair</code> cookie 拿在代理手里,cookie 不会进浏览器。</p>`
522
+ return page('中转设置', body)
523
+ }
524
+
525
+ function readForm(req, limit = 8192) {
526
+ return new Promise((resolvePromise) => {
527
+ let size = 0
528
+ const chunks = []
529
+ req.on('data', (chunk) => {
530
+ size += chunk.length
531
+ if (size > limit) {
532
+ req.destroy()
533
+ resolvePromise(null)
534
+ return
535
+ }
536
+ chunks.push(chunk)
537
+ })
538
+ req.on('end', () => {
539
+ try {
540
+ resolvePromise(new URLSearchParams(Buffer.concat(chunks).toString('utf8')))
541
+ } catch {
542
+ resolvePromise(null)
543
+ }
544
+ })
545
+ req.on('error', () => resolvePromise(null))
546
+ })
547
+ }
548
+
549
+ async function apply(ctx, rawConfig) {
550
+ const state = createState()
551
+ const cfgOf = () => resolveConfig(typeof rawConfig === 'function' ? rawConfig() : rawConfig)
552
+
553
+ state.remint = () => ensureSession(state, cfgOf())
554
+ state.canRemint = true
555
+
556
+ // ---- 回环代理 -----------------------------------------------------------
557
+ const proxy = createServer((req, res) => {
558
+ if (!gateOk(state, req)) {
559
+ return sendHtml(
560
+ res,
561
+ 403,
562
+ page(
563
+ '需要从 /embed 打开',
564
+ '<p>这个代理只服务于 <a href="/embed">/embed</a> 那个页面。直接从地址栏打开是拿不到通行 cookie 的。</p>',
565
+ ),
566
+ )
567
+ }
568
+ proxyOnce(state, req, res)
569
+ })
570
+ proxy.on('upgrade', (req, socket, head) => proxyUpgrade(state, req, socket, head))
571
+
572
+ const cfg = cfgOf()
573
+ const port = cfg.proxyPort
574
+ await new Promise((resolvePromise) => {
575
+ proxy.once('error', (error) => {
576
+ state.lastError = `端口 ${port} 起不来:${error?.message ?? error}`
577
+ resolvePromise(null)
578
+ })
579
+ proxy.listen(port, '127.0.0.1', () => {
580
+ state.server = proxy
581
+ state.actualPort = proxy.address()?.port ?? port
582
+ resolvePromise(null)
583
+ })
584
+ })
585
+
586
+ // ---- 页面与接口 ---------------------------------------------------------
587
+ const embedRoute = ctx.webServer.register({
588
+ kind: 'exact',
589
+ path: EMBED_PATH,
590
+ handler: (req, res) => {
591
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
592
+ res.writeHead(405, { allow: 'GET' })
593
+ return res.end()
594
+ }
595
+ const portNow = state.actualPort || port
596
+ res.setHeader(
597
+ 'set-cookie',
598
+ `${GATE_COOKIE}=${state.gate}; Path=/; HttpOnly; SameSite=Lax`,
599
+ )
600
+ sendHtml(res, 200, embedPage(state, portNow))
601
+ },
602
+ })
603
+
604
+ const setupRoute = ctx.webServer.register({
605
+ kind: 'exact',
606
+ path: SETUP_PATH,
607
+ handler: async (req, res) => {
608
+ if (req.method === 'GET' || req.method === 'HEAD') {
609
+ return sendHtml(res, 200, setupPage(state, cfgOf()))
610
+ }
611
+ if (req.method !== 'POST') {
612
+ res.writeHead(405, { allow: 'GET, POST' })
613
+ return res.end()
614
+ }
615
+ const form = await readForm(req)
616
+ if (!form) return sendHtml(res, 400, setupPage(state, cfgOf(), '提交内容太大或格式不对。'))
617
+ const next = {
618
+ bridgeUrl: String(form.get('bridgeUrl') ?? '').trim().replace(/\/+$/, ''),
619
+ bridgeKey: String(form.get('bridgeKey') ?? '').trim(),
620
+ proxyPort: Number(form.get('proxyPort') ?? DEFAULT_PROXY_PORT),
621
+ }
622
+ if (!/^https?:\/\/[^\s]+$/i.test(next.bridgeUrl)) {
623
+ return sendHtml(res, 400, setupPage(state, next, '中转地址要写成 http:// 或 https:// 开头的完整地址。'))
624
+ }
625
+ if (!Number.isInteger(next.proxyPort) || next.proxyPort < 1024 || next.proxyPort > 65535) {
626
+ return sendHtml(res, 400, setupPage(state, next, '端口要在 1024–65535 之间。'))
627
+ }
628
+ writeConfigFile(next)
629
+ try {
630
+ await ensureSession(state, next)
631
+ } catch (error) {
632
+ return sendHtml(res, 502, setupPage(state, next, `配对失败:${error?.message ?? error}`))
633
+ }
634
+ res.writeHead(303, { location: EMBED_PATH, 'cache-control': 'no-store' })
635
+ res.end()
636
+ },
637
+ })
638
+
639
+ const statusRoute = ctx.webServer.register({
640
+ kind: 'exact',
641
+ path: STATUS_PATH,
642
+ handler: (req, res) => {
643
+ if (req.method !== 'GET') {
644
+ res.writeHead(405, { allow: 'GET' })
645
+ return res.end()
646
+ }
647
+ const live = cfgOf()
648
+ sendJson(res, 200, {
649
+ ok: true,
650
+ relay: state.relay,
651
+ paired: Boolean(state.cookie),
652
+ mintedAt: state.mintedAt,
653
+ lastError: state.lastError,
654
+ proxyPort: state.actualPort || live.proxyPort,
655
+ bridgeUrl: live.bridgeUrl,
656
+ configPath: configPath(),
657
+ embedPath: EMBED_PATH,
658
+ })
659
+ },
660
+ })
661
+
662
+ ctx.effect?.(() => () => {
663
+ embedRoute?.()
664
+ setupRoute?.()
665
+ statusRoute?.()
666
+ try {
667
+ state.server?.close()
668
+ } catch {}
669
+ })
670
+ }
671
+
672
+ export const name = NS
673
+ export const inject = ['webServer']
674
+ export { apply }
675
+ export { configSchema as Config }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "dsh-desktop-embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Embed the desktop DSH UI inside DSHA's WebView instead of handing it to the system browser: a loopback reverse proxy on the phone plus an iframe shell page.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "lzlrt",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lzlrt/dsh-desktop-embed.git"
11
+ },
12
+ "homepage": "https://github.com/lzlrt/dsh-desktop-embed#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/lzlrt/dsh-desktop-embed/issues"
15
+ },
16
+ "keywords": [
17
+ "deepseek-harness",
18
+ "dsh",
19
+ "dsh-plugin",
20
+ "dsha",
21
+ "embed",
22
+ "iframe",
23
+ "proxy",
24
+ "dsh-desktop-link"
25
+ ],
26
+ "files": [
27
+ "index.js",
28
+ "client.js",
29
+ "cordis.patch.yml",
30
+ "icon.svg",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": "^22.19.0 || >=24.0.0"
36
+ },
37
+ "dsh": {
38
+ "engines": {
39
+ "dsh": ">=0.2.1-rc.2"
40
+ },
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "platform": "web",
46
+ "immediately": true,
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-ui-sidebar"
49
+ ],
50
+ "load": "./client.js"
51
+ }
52
+ },
53
+ "peerDependencies": {},
54
+ "exports": {
55
+ ".": "./index.js",
56
+ "./client": "./client.js",
57
+ "./icon": "./icon.svg",
58
+ "./package.json": "./package.json"
59
+ }
60
+ }