dsh-desktop-embed 0.0.0-stage → 1.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.md +130 -2
- package/client.js +152 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +6 -0
- package/index.js +1049 -0
- package/package.json +58 -4
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,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-desktop-embed
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
## 为什么第一次打开慢,以及 1.1.0 做了什么
|
|
73
|
+
|
|
74
|
+
手机上第一次打开要等一两分钟,主要不是「电脑慢」,是这条链路上重复了太多工作:
|
|
75
|
+
|
|
76
|
+
- 每取一个资源都新建一条 TCP + TLS 连接;界面的框架资源有几十个,手机上每次握手几百毫秒,串起来就是一分钟。
|
|
77
|
+
- 入口页引用的主 JS 单文件约 940 KB,从中继直取实测 **6.05 秒**。
|
|
78
|
+
- 用户在点按钮之后才开始取,前面这段时间完全是干等。
|
|
79
|
+
|
|
80
|
+
1.1.0 加了四件事:
|
|
81
|
+
|
|
82
|
+
| 改动 | 效果(同一台中继上实测) |
|
|
83
|
+
|---|---|
|
|
84
|
+
| 连接池(keep-alive,`maxSockets 16`) | 同一台中继的请求复用连接,不再每次重新握手 |
|
|
85
|
+
| 启动时预热:先把入口页和它引用的 `/assets`、`/plugins` 资源拉进内存缓存 | 点按钮时框架资源已经在本地 |
|
|
86
|
+
| 静态资源缓存(按内容哈希为键,上限 96 MB)+ 并发请求合并 | 主 JS `941436 B`:中继直取 **6.05 s** → 命中缓存 **1.7 ms** |
|
|
87
|
+
| `/embed` 加载进度层(步骤文案 + 秒表 + 「已预热 n/m」) | 不再是白屏干等;实测「页面载入 → 界面就绪」约 **4.5 s**(缓存热) |
|
|
88
|
+
|
|
89
|
+
预热是**自动**的:插件启动时若已有配置就立即开始,保存设置后也会再跑一次。缓存只在内存里,DSH 重启就没了——重启后的第一次打开会慢一些,之后又是快的。
|
|
90
|
+
|
|
91
|
+
排查「为什么还是慢」:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
curl http://127.0.0.1:<手机 DSH 端口>/api/dsh-embed/status
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
返回里的 `cache.entries` / `cache.bytes` / `warm`(资产数、成功数、耗时)就是预热结果;`warm.ok=false` 时看 `warm.error`。
|
|
98
|
+
|
|
99
|
+
## 手机上的排版
|
|
100
|
+
|
|
101
|
+
iframe 与外层不同源(端口不同),外层改不了里面的 DOM,所以排版修补放在代理这一层:给经过的入口页注入一张样式表,规则全部关在 `@media (max-width: 1023px) and (pointer: coarse)` 里——
|
|
102
|
+
|
|
103
|
+
- 按钮、`[role=button|tab|menuitem|option]` 最小 40×40,输入框最小高 40 且字号 16px(小于 16px 时 iOS / 部分安卓会放大页面)
|
|
104
|
+
- 侧栏 / 抽屉最大 `88vw`,不再盖住全部内容
|
|
105
|
+
- 表格横向滚动而不是撑破页面,图片视频限宽,正文禁止横向溢出
|
|
106
|
+
- 文本域底部留 `env(safe-area-inset-bottom)`,软键盘弹起时输入框不被顶掉
|
|
107
|
+
|
|
108
|
+
桌面上这条 media query 不匹配,所以不影响电脑端观感。
|
|
109
|
+
|
|
110
|
+
## 已知限制
|
|
111
|
+
|
|
112
|
+
1. **`Secure` cookie 不能用明文 http 送过去。** 中继的 `dsh_pair` 带 `Secure`;手机 → 中继这一段如果是 `https://`(中继本身就是 https,正常情况都满足),代理拿着 cookie 发请求没问题。但如果你的 `bridgeUrl` 指向一个 http 桥,插件只是拿它当「取链接的信使」,不拿它传 cookie,所以照样可用。
|
|
113
|
+
2. **中继掉线要重新配对。** 令牌 600 秒过期、会话 cookie 一年;代理在收到上游 401 时会**自动重铸一次再重试**,所以一般无感。若中继换域名(`*.trycloudflare.com` 就是这样),要在 `/embed/setup` 里改 `bridgeUrl`。
|
|
114
|
+
3. **`/embed` 不接管键盘快捷键**:DSHA 的返回手势会直接退出这个页面(顶层导航回对话列表),这是 WebView 的正常行为。
|
|
115
|
+
4. **只支持 `http://127.0.0.1`**:DSHA 的预览策略只认回环明文。别把代理绑到 `0.0.0.0`,也没必要。
|
|
116
|
+
5. **中继不转发的部分插件静态资源会 403**:中继只放行 `/api`、`/assets`、`/plugins/??…` 目录批量加载和 `/pair-app`;个别插件用绝对路径挂的自己的静态文件(实测 `dsh-whale/widget.js`、`dsh-pet/pet.js`、`/plugins/<插件>/state`)会 403。它们都是可选挂件,界面照常可用,控制台里会留几条 403。
|
|
117
|
+
6. **iframe 里的页面禁止混合内容**(`MIXED_CONTENT_NEVER_ALLOW`):中继页面上的跨站资源只有一条 https 的 `.webmcp/bridge.js`,被同源策略挡掉,无害。
|
|
118
|
+
|
|
119
|
+
## 与另外两个插件的关系
|
|
120
|
+
|
|
121
|
+
| 插件 | 装在哪 | 干什么 |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `dsh-desktop-link` | 手机 | 侧栏按钮 + `/desktop-link/settings`;把手机送到电脑版门口(跳浏览器) |
|
|
124
|
+
| `dsh-desktop-embed` | 手机 | 侧栏按钮 + `/embed`;把电脑版界面嵌在 DSHA 里(不跳浏览器) |
|
|
125
|
+
| `dsh-mobile-boost` | 电脑 | 让电脑版界面在窄屏触摸下真的能用 |
|
|
126
|
+
|
|
127
|
+
装 `dsh-desktop-embed` 之后,仍然建议在**电脑** profile 里装 `dsh-mobile-boost`:嵌入的界面是电脑版页面,窄屏适配由它负责。
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
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
|
+
})
|
package/cordis.patch.yml
ADDED
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>
|