dsh-desktop-link 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 +21 -0
- package/README.md +159 -3
- package/client.js +232 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +9 -0
- package/index.js +164 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -5
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,159 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# dsha-desktop-link
|
|
2
|
+
|
|
3
|
+
在 **DeepSeek Harness(DSH)** 的侧栏底部加一个按钮:点一下就在系统浏览器 / 新标签页里打开**桌面版 DSH** 的 Web GUI。
|
|
4
|
+
|
|
5
|
+
专门为 **DSHA(DeepSeek Harness 安卓启动器)** 设计 —— 在手机上的 DSHA 里点这个按钮,会交给系统浏览器打开你电脑上的 DSH,而 DSHA 自己那套 agent 照常独立运行。
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
🖥 → 桌面版
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 它解决什么
|
|
14
|
+
|
|
15
|
+
DSHA 的内置浏览器只导航它自己的 `http://127.0.0.1:<端口>`,没有「填个网址去连别的机器」的入口。
|
|
16
|
+
但 DSHA 本来就有这条规则:**顶层导航离开本机 DSH 服务时,交给系统浏览器**(`WebPreviewActivity.shouldOverrideUrlLoading` → `ACTION_VIEW`)。
|
|
17
|
+
|
|
18
|
+
这个插件就是给这条已有规则加一个按钮入口 —— 不改 DSHA、不需要 root、不需要 `am start`。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 行为
|
|
23
|
+
|
|
24
|
+
| 点击环境 | 结果 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| **手机**(DSHA 的 WebView 或手机浏览器) | 导航离开 DSH 服务 → DSHA 交给系统浏览器 → 打开桌面版 GUI |
|
|
27
|
+
| **电脑浏览器** | `target="_blank"` 开新标签页,当前 GUI 不受影响 |
|
|
28
|
+
|
|
29
|
+
未配置地址时按钮变灰并提示怎么配,不会跳到一个死链。
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
### 从 npm(推荐)
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin --profile web add dsh-desktop-link
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 从本仓库(开发循环)
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/lzlrt/dsha-desktop-link
|
|
45
|
+
dsh plugin --profile web add link:$(pwd)/dsha-desktop-link
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 从打包好的 tgz
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
dsh plugin --profile web add /path/to/dsh-desktop-link-1.0.0.tgz
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 装到手机上的 DSHA
|
|
55
|
+
|
|
56
|
+
DSHA → 插件 → 添加 → 粘贴一个能下到这个包的 URL,例如:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
https://registry.npmjs.org/dsh-desktop-link/-/dsh-desktop-link-1.0.0.tgz
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
或在 DSHA 插件页直接填包名 `dsh-desktop-link`。
|
|
63
|
+
|
|
64
|
+
或从本地导入(`adb push` 到 `/sdcard/Download/` 后在插件页选文件)。
|
|
65
|
+
|
|
66
|
+
装完在插件管理页点「重启 Web」。
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 配置地址
|
|
71
|
+
|
|
72
|
+
插件自身不带默认地址 —— 它从下面这些地方按顺序找,命中即停:
|
|
73
|
+
|
|
74
|
+
| 优先级 | 来源 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| 1 | 本行 `config.desktopUrl` / `config.launchUrl`(profile 补丁里) |
|
|
77
|
+
| 2 | 环境变量 `DSH_DESKTOP_URL` / `DSH_DESKTOP_LAUNCH_URL` |
|
|
78
|
+
| 3 | `$DSH_HOME/desktop-url.txt` 第 1 行(桌面地址)/ 第 2 行(点击时打开的地址) |
|
|
79
|
+
| 4 | `$DSH_HOME/remote-web-ui-registry/web.json` 的 `id` → `https://<id>.dsh-market.com`(仅当装了 `dsh-remote-web-ui` 并开了固定域名中继) |
|
|
80
|
+
|
|
81
|
+
`desktop-url.txt` 是最好改的一个 —— **改完刷新页面即生效**,不用重启:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
# 第一行:桌面版长期地址(书签、复制链接用)
|
|
85
|
+
https://your-desktop.example.com
|
|
86
|
+
|
|
87
|
+
# 第二行(可选):点按钮时真正打开的地址。
|
|
88
|
+
# 指向一个「每次铸新配对码」的端点,未配对的手机也能一次进。
|
|
89
|
+
https://your-desktop.example.com/pair-entry
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
只写一行 = 按钮直接开那一行。删掉文件 = 退回优先级 4。
|
|
93
|
+
|
|
94
|
+
### 两种地址的区别
|
|
95
|
+
|
|
96
|
+
| 字段 | 用途 |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `desktopUrl` | 长期地址。锚点的 `href` 用它,中间键 / 复制链接 / 收藏都按这个走 |
|
|
99
|
+
| `launchUrl` | 点击时实际打开的地址。可以是一个会临时铸码的端点,让从未配对过的设备也能一次进入 |
|
|
100
|
+
|
|
101
|
+
没配 `launchUrl` 时它等于 `desktopUrl`。
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 未配对的手机会看到 401
|
|
106
|
+
|
|
107
|
+
DSH 的远程访问是要配对的。未配对设备直接打开桌面地址会被 harness 的认证栅栏拒绝 —— 这是配对机制正常工作,不是这个插件的 bug。
|
|
108
|
+
|
|
109
|
+
三种处理方式:
|
|
110
|
+
|
|
111
|
+
1. **先配对一次**:配对 cookie 默认有效期一年,之后点按钮直接进。
|
|
112
|
+
2. **配 `launchUrl` 指向铸码端点**:每次点击拿新码,永远有效。
|
|
113
|
+
3. **手机端自动铸码**:若本机装了 `dsh-remote-web-ui`,手机点击时插件会先调同源 `/api/pair/issue` 铸一枚一次性码再跳转(该接口只应答回环,所以页面能直接调)。没装则自动退回直接打开 `desktopUrl`。
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 接口
|
|
118
|
+
|
|
119
|
+
| 方法 | 路径 | 说明 |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| GET | `/api/desktop-link/info` | `{"ok":true,"launchUrl":"…","desktopUrl":"…","source":"…"}` |
|
|
122
|
+
|
|
123
|
+
每次请求重新解析地址,所以改配置文件不需要重启。
|
|
124
|
+
另外 Host 半区会在 `webserver/index-inject` 里注入 `globalThis.__DSHA_DESKTOP_LINK__`,让按钮首帧就能拿到地址;全局缺失时 Client 半区回退到上面那个接口。
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 结构
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
package.json 清单:dsh.bundle.patch + dsh.client(platform: web)
|
|
132
|
+
cordis.patch.yml 插入一行 dsha-desktop-link
|
|
133
|
+
index.js Host 半区:地址解析 + /api/desktop-link/info + index 全局
|
|
134
|
+
client.js Client 半区:注册 sidebar.footer.action
|
|
135
|
+
icon.svg 显示器 + 外跳箭头
|
|
136
|
+
locale/{zh,en}.json meta.title / meta.description
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 实现要点
|
|
140
|
+
|
|
141
|
+
- 只注册 `sidebar.footer.action` 一个槽位(`replaceRisk: none`),`order: 10`,排在官方「远程访问」之后。
|
|
142
|
+
- 图标 18×18 内联在 `client.js` 里,与 `icon.svg` 同款,少一次请求。
|
|
143
|
+
- 不做 `Config` 导出:装完即用,配置全走文件/环境变量。
|
|
144
|
+
- 手机与电脑的分支靠 `navigator.userAgent` 判定;桌面侧完全走原生锚点行为,不拦点击。
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 已知限制
|
|
149
|
+
|
|
150
|
+
1. **Host 半区(`index.js`)改动需要重启 `dsh web`**;Client 半区(`client.js`)HMR 即时生效。
|
|
151
|
+
2. 侧栏折叠成 56px 轨道时只显示图标,展开后才显示「桌面版」文字。
|
|
152
|
+
3. 手机端「点击时铸码」依赖本机装了 `dsh-remote-web-ui`(本插件不声明这个依赖,装不装都能用,只是没有自动铸码)。
|
|
153
|
+
4. 需要 DSH `>= 0.2.1-rc.2`(`webserver/index-inject` 事件与 `sidebar.footer.action` 槽位)。
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
package/client.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-desktop-link',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
/** Locale namespace owned by this plugin. */
|
|
8
|
+
const NS = 'dsha-desktop-link'
|
|
9
|
+
|
|
10
|
+
const zh = {
|
|
11
|
+
'action': '打开桌面版 DSH',
|
|
12
|
+
'action.short': '桌面版',
|
|
13
|
+
'hint.unset': '还没配桌面地址:把地址写进 ~/.dsh/desktop-url.txt(一行一个 URL)',
|
|
14
|
+
}
|
|
15
|
+
const en = {
|
|
16
|
+
'action': 'Open desktop DSH',
|
|
17
|
+
'action.short': 'Desktop',
|
|
18
|
+
'hint.unset': 'No desktop address yet: put one in ~/.dsh/desktop-url.txt (one URL per line)',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* 18×18 monitor with an out-arrow — same artwork as icon.svg, inlined so
|
|
22
|
+
the button costs no extra request. */
|
|
23
|
+
function Mark() {
|
|
24
|
+
return h('svg', {
|
|
25
|
+
viewBox: '0 0 64 64', width: 18, height: 18, 'aria-hidden': true,
|
|
26
|
+
focusable: false,
|
|
27
|
+
style: { display: 'block', flex: 'none' },
|
|
28
|
+
},
|
|
29
|
+
h('rect', {
|
|
30
|
+
x: 8, y: 10, width: 48, height: 30, rx: 5,
|
|
31
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 4.5,
|
|
32
|
+
}),
|
|
33
|
+
h('path', {
|
|
34
|
+
d: 'M26 54h12M32 46v8', fill: 'none', stroke: 'currentColor',
|
|
35
|
+
strokeWidth: 4.5, strokeLinecap: 'round',
|
|
36
|
+
}),
|
|
37
|
+
h('path', {
|
|
38
|
+
d: 'M24 25h14M32 18l7 7-7 7', fill: 'none', stroke: 'currentColor',
|
|
39
|
+
strokeWidth: 4.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The addresses the host published so far. Both empty means the host half
|
|
45
|
+
* resolved nothing — the button then renders disabled with a hint instead
|
|
46
|
+
* of sending the browser to an address nobody configured.
|
|
47
|
+
*/
|
|
48
|
+
function published() {
|
|
49
|
+
const injected = window.__DSHA_DESKTOP_LINK__
|
|
50
|
+
const desktopUrl = injected && typeof injected.desktopUrl === 'string' ? injected.desktopUrl : ''
|
|
51
|
+
const launchUrl = injected && typeof injected.launchUrl === 'string' ? injected.launchUrl : ''
|
|
52
|
+
return { desktopUrl, launchUrl }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fill in from the info route when the index global is absent.
|
|
57
|
+
*
|
|
58
|
+
* The global comes from `webserver/index-inject`, so it only appears on an
|
|
59
|
+
* index rendered after the Host half loaded; the route answers on the
|
|
60
|
+
* running Host either way. Cached on the window so later renders and clicks
|
|
61
|
+
* agree without another request.
|
|
62
|
+
* @returns the published addresses, once known.
|
|
63
|
+
*/
|
|
64
|
+
function hydrate() {
|
|
65
|
+
const injected = window.__DSHA_DESKTOP_LINK__
|
|
66
|
+
if (injected && injected.desktopUrl) return Promise.resolve(published())
|
|
67
|
+
return fetch('/api/desktop-link/info', {
|
|
68
|
+
credentials: 'same-origin',
|
|
69
|
+
headers: { accept: 'application/json' },
|
|
70
|
+
}).then((res) => (res.ok ? res.json() : null))
|
|
71
|
+
.then((body) => {
|
|
72
|
+
if (!body || typeof body.desktopUrl !== 'string' || !body.desktopUrl) return published()
|
|
73
|
+
const merged = {
|
|
74
|
+
desktopUrl: body.desktopUrl,
|
|
75
|
+
launchUrl: typeof body.launchUrl === 'string' ? body.launchUrl : '',
|
|
76
|
+
paired: Boolean(body.paired),
|
|
77
|
+
}
|
|
78
|
+
window.__DSHA_DESKTOP_LINK__ = merged
|
|
79
|
+
return merged
|
|
80
|
+
})
|
|
81
|
+
.catch(() => published())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether this page runs on a handset (DSHA's WebView or a phone browser). */
|
|
85
|
+
function isHandset() {
|
|
86
|
+
const ua = navigator.userAgent || ''
|
|
87
|
+
return /Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(ua)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mint a one-shot pair URL from this instance's own pairing service.
|
|
92
|
+
*
|
|
93
|
+
* This page is same-origin on the loopback listener and `/api/pair/issue`
|
|
94
|
+
* answers loopback only, so the browser can ask on the phone's behalf. A
|
|
95
|
+
* phone that has never paired would otherwise open the published origin and
|
|
96
|
+
* meet the harness 401 fence; after one pairing the cookie lasts a year.
|
|
97
|
+
* An instance without the pairing plugin answers 404 — a handled miss.
|
|
98
|
+
* @returns the pair-accept URL, or an empty string.
|
|
99
|
+
*/
|
|
100
|
+
function mintPairUrl() {
|
|
101
|
+
return fetch('/api/pair/issue', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
credentials: 'same-origin',
|
|
104
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
105
|
+
body: '{}',
|
|
106
|
+
}).then((res) => (res.ok ? res.json() : null))
|
|
107
|
+
.then((body) => (body && typeof body.url === 'string' ? body.url : ''))
|
|
108
|
+
.catch(() => '')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The URL the anchor carries, so middle-click and "copy link" stay
|
|
113
|
+
* meaningful. A configured `launchUrl` wins, else the desktop origin.
|
|
114
|
+
* @returns the absolute URL.
|
|
115
|
+
*/
|
|
116
|
+
function linkHref() {
|
|
117
|
+
const { desktopUrl, launchUrl } = published()
|
|
118
|
+
return launchUrl || desktopUrl
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handset click: navigate this page to a freshly minted pair URL.
|
|
123
|
+
*
|
|
124
|
+
* A top-level navigation needs no user gesture, so this survives the await
|
|
125
|
+
* that the popup-blocked `window.open` variant did not. The DSHA WebView
|
|
126
|
+
* sees the navigation leave its own service and hands it to the system
|
|
127
|
+
* browser, which is exactly the desired jump.
|
|
128
|
+
* @param fallback - the URL to use when minting is unavailable.
|
|
129
|
+
*/
|
|
130
|
+
function openOnHandset(fallback) {
|
|
131
|
+
mintPairUrl().then((url) => { window.location.assign(url || fallback) })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sidebar-foot button: "open the desktop DSH".
|
|
136
|
+
*
|
|
137
|
+
* Two paths, both deterministic:
|
|
138
|
+
* - Handset: no second window exists, so a top-level navigation is the
|
|
139
|
+
* answer. It is issued after a short mint call, which a navigation (unlike
|
|
140
|
+
* `window.open`) tolerates, and DSHA then hands it to the system browser.
|
|
141
|
+
* - Desktop: a plain `target="_blank"` anchor opens a second tab and leaves
|
|
142
|
+
* the current GUI alone. No script runs, so nothing can be blocked.
|
|
143
|
+
*/
|
|
144
|
+
function DesktopLinkButton(props) {
|
|
145
|
+
const wide = props.wide !== false
|
|
146
|
+
const t = props.t || ((key) => zh[key] || en[key] || key)
|
|
147
|
+
const [href, setHref] = React.useState(() => linkHref())
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
let alive = true
|
|
150
|
+
hydrate().then(() => { if (alive) setHref(linkHref()) })
|
|
151
|
+
return () => { alive = false }
|
|
152
|
+
}, [])
|
|
153
|
+
|
|
154
|
+
const onClick = (event) => {
|
|
155
|
+
// Nothing configured: stay inert and let the title/hint explain why.
|
|
156
|
+
if (!href) { event.preventDefault(); return }
|
|
157
|
+
// Let modifier-clicks and middle-clicks fall through to the anchor.
|
|
158
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
159
|
+
if (!isHandset()) return // desktop keeps the plain _blank anchor
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
// A configured minting endpoint already hands out a fresh token, so
|
|
162
|
+
// navigating there is enough; otherwise mint here.
|
|
163
|
+
const { desktopUrl, launchUrl } = published()
|
|
164
|
+
if (launchUrl && launchUrl !== desktopUrl) {
|
|
165
|
+
window.location.assign(launchUrl)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
openOnHandset(desktopUrl)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const style = {
|
|
172
|
+
display: 'inline-flex',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: wide ? 'flex-start' : 'center',
|
|
175
|
+
flex: 'none',
|
|
176
|
+
height: 28,
|
|
177
|
+
minWidth: 28,
|
|
178
|
+
padding: wide ? '0 8px' : 0,
|
|
179
|
+
boxSizing: 'border-box',
|
|
180
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
181
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
182
|
+
background: 'transparent',
|
|
183
|
+
border: 'none',
|
|
184
|
+
cursor: 'pointer',
|
|
185
|
+
fontSize: 13,
|
|
186
|
+
fontFamily: 'inherit',
|
|
187
|
+
textDecoration: 'none',
|
|
188
|
+
whiteSpace: 'nowrap',
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return h('a', {
|
|
192
|
+
href,
|
|
193
|
+
target: '_blank',
|
|
194
|
+
rel: 'noopener noreferrer',
|
|
195
|
+
style: { ...style, opacity: href ? 1 : 0.45 },
|
|
196
|
+
title: href || t('hint.unset'),
|
|
197
|
+
'aria-label': t('action'),
|
|
198
|
+
'aria-disabled': href ? undefined : 'true',
|
|
199
|
+
onClick,
|
|
200
|
+
onMouseEnter: (e) => {
|
|
201
|
+
e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))'
|
|
202
|
+
},
|
|
203
|
+
onMouseLeave: (e) => { e.currentTarget.style.background = 'transparent' },
|
|
204
|
+
},
|
|
205
|
+
h(Mark),
|
|
206
|
+
wide
|
|
207
|
+
? h('span', {
|
|
208
|
+
style: { marginLeft: 8, overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 },
|
|
209
|
+
}, t('action.short'))
|
|
210
|
+
: null)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
inject: ['slots', 'locale'],
|
|
215
|
+
apply(ctx) {
|
|
216
|
+
ctx.effect(() => {
|
|
217
|
+
const offZh = ctx.locale.register(NS, 'zh', zh)
|
|
218
|
+
const offEn = ctx.locale.register(NS, 'en', en)
|
|
219
|
+
return () => { offZh(); offEn() }
|
|
220
|
+
}, 'dsha-desktop-link: dictionaries')
|
|
221
|
+
|
|
222
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
223
|
+
name: 'sidebar.footer.action',
|
|
224
|
+
id: 'dsha-desktop-link',
|
|
225
|
+
order: 10,
|
|
226
|
+
label: () => ctx.locale.bind(NS)('action'),
|
|
227
|
+
locale: NS,
|
|
228
|
+
}, DesktopLinkButton))
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Desktop DSH">
|
|
2
|
+
<rect x="3" y="4" width="58" height="56" rx="14" fill="#0f172a"/>
|
|
3
|
+
<!-- monitor -->
|
|
4
|
+
<rect x="12" y="14" width="40" height="26" rx="4" fill="none" stroke="#5eead4" stroke-width="3.2"/>
|
|
5
|
+
<path d="M24 47h16M32 40v7" fill="none" stroke="#5eead4" stroke-width="3.2" stroke-linecap="round"/>
|
|
6
|
+
<!-- arrow out of the screen, i.e. "jump to desktop" -->
|
|
7
|
+
<path d="M25 27h13M33 21l7 6-7 6" fill="none" stroke="#f0abfc" stroke-width="3.2"
|
|
8
|
+
stroke-linecap="round" stroke-linejoin="round"/>
|
|
9
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-link —— 宿主半区。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:把两个地址交给浏览器半区。
|
|
5
|
+
* 唯一的路由是一条只读 JSON:
|
|
6
|
+
*
|
|
7
|
+
* GET /api/desktop-link/info
|
|
8
|
+
* → { ok, launchUrl, desktopUrl, source }
|
|
9
|
+
*
|
|
10
|
+
* launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
|
|
11
|
+
* desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
|
|
12
|
+
*
|
|
13
|
+
* desktopUrl 的来源优先级(命中即停):
|
|
14
|
+
* 1. 本行 config.desktopUrl
|
|
15
|
+
* 2. 环境变量 DSH_DESKTOP_URL
|
|
16
|
+
* 3. $DSH_HOME/desktop-url.txt 第一行
|
|
17
|
+
* 4. $DSH_HOME/remote-web-ui-registry/web.json 的 id → https://<id>.dsh-market.com
|
|
18
|
+
*
|
|
19
|
+
* launchUrl 额外支持 config.launchUrl / 环境变量 DSH_DESKTOP_LAUNCH_URL,
|
|
20
|
+
* 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
|
|
21
|
+
* 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
function dshHome() {
|
|
28
|
+
const fromEnv = process.env.DSH_HOME
|
|
29
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
30
|
+
return join(homedir(), '.dsh')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function urlish(value) {
|
|
34
|
+
return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim()) ? value.trim() : ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every URL in a hand-edited comment-friendly file under the DSH home.
|
|
39
|
+
*
|
|
40
|
+
* `desktop-url.txt` takes up to two lines, in this order:
|
|
41
|
+
* line 1 = desktopUrl, the long-lived desktop origin (bookmark target)
|
|
42
|
+
* line 2 = launchUrl, what a click actually opens (a minting endpoint)
|
|
43
|
+
* A single line therefore means "open this one thing". `#` comments and blank
|
|
44
|
+
* lines are skipped, and anything that is not http(s) is ignored rather than
|
|
45
|
+
* returned half-parsed.
|
|
46
|
+
* @param name - file name inside the DSH home.
|
|
47
|
+
* @returns the URLs found, in file order.
|
|
48
|
+
*/
|
|
49
|
+
function readUrlLines(name) {
|
|
50
|
+
try {
|
|
51
|
+
const file = join(dshHome(), name)
|
|
52
|
+
if (!existsSync(file)) return []
|
|
53
|
+
const found = []
|
|
54
|
+
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
55
|
+
const line = raw.trim()
|
|
56
|
+
if (!line || line.startsWith('#')) continue
|
|
57
|
+
const value = urlish(line)
|
|
58
|
+
if (value) found.push(value)
|
|
59
|
+
}
|
|
60
|
+
return found
|
|
61
|
+
} catch {
|
|
62
|
+
return []
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readRelayOrigin() {
|
|
67
|
+
try {
|
|
68
|
+
const file = join(dshHome(), 'remote-web-ui-registry', 'web.json')
|
|
69
|
+
if (!existsSync(file)) return ''
|
|
70
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
71
|
+
const id = typeof parsed?.id === 'string' ? parsed.id.trim() : ''
|
|
72
|
+
return /^[A-Za-z0-9-]{8,}$/.test(id) ? `https://${id}.dsh-market.com` : ''
|
|
73
|
+
} catch {
|
|
74
|
+
return ''
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether this instance has ever paired a device.
|
|
80
|
+
*
|
|
81
|
+
* The roster is `dsh-remote-web-ui`'s own persisted store
|
|
82
|
+
* (`$DSH_HOME/remote-web-ui-devices.json`, 0600, its documented default). It is
|
|
83
|
+
* read only for this boolean — no ids, no credentials — and a missing or
|
|
84
|
+
* unreadable file simply means "nothing paired yet", which is the safe answer:
|
|
85
|
+
* the button then mints a token instead of opening a URL that would 401.
|
|
86
|
+
* @returns whether at least one device session exists on disk.
|
|
87
|
+
*/
|
|
88
|
+
function hasPairedDevice() {
|
|
89
|
+
try {
|
|
90
|
+
const file = join(dshHome(), 'remote-web-ui-devices.json')
|
|
91
|
+
if (!existsSync(file)) return false
|
|
92
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
93
|
+
const rows = Array.isArray(parsed) ? parsed : (parsed?.devices ?? [])
|
|
94
|
+
return Array.isArray(rows) ? rows.length > 0 : Boolean(rows && Object.keys(rows).length)
|
|
95
|
+
} catch {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveUrls(config) {
|
|
101
|
+
const fileUrls = readUrlLines('desktop-url.txt')
|
|
102
|
+
const chain = [
|
|
103
|
+
['config', () => urlish(config?.desktopUrl)],
|
|
104
|
+
['env', () => urlish(process.env.DSH_DESKTOP_URL)],
|
|
105
|
+
['desktop-url.txt', () => fileUrls[0] ?? ''],
|
|
106
|
+
['relay-registry', readRelayOrigin],
|
|
107
|
+
]
|
|
108
|
+
let desktopUrl = ''
|
|
109
|
+
let source = 'unset'
|
|
110
|
+
for (const [label, read] of chain) {
|
|
111
|
+
const value = read()
|
|
112
|
+
if (value) { desktopUrl = value; source = label; break }
|
|
113
|
+
}
|
|
114
|
+
// launchUrl is what a click opens. In order: the row's config, the
|
|
115
|
+
// environment, line 2 of desktop-url.txt (a local minting endpoint such as
|
|
116
|
+
// dsha-pair/bridge.py's /open.html?k=…), then the desktop origin itself.
|
|
117
|
+
const launchUrl =
|
|
118
|
+
urlish(config?.launchUrl) ||
|
|
119
|
+
urlish(process.env.DSH_DESKTOP_LAUNCH_URL) ||
|
|
120
|
+
fileUrls[1] ||
|
|
121
|
+
desktopUrl
|
|
122
|
+
return { launchUrl, desktopUrl, source }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Host half: publish the desktop address. */
|
|
126
|
+
export const inject = ['webServer']
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Register the info route, the launch route, and the index global.
|
|
130
|
+
*
|
|
131
|
+
* The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
|
|
132
|
+
* the button is usable on first paint. `paired` tells it whether this instance
|
|
133
|
+
* ever paired a device: when it never has, the client mints a token itself
|
|
134
|
+
* through `/api/pair/issue` (same-origin, loopback, so the pairing service
|
|
135
|
+
* answers) instead of opening a URL that would hit the harness 401 fence.
|
|
136
|
+
* @param ctx - host plugin context carrying webServer.
|
|
137
|
+
* @param config - the row's config (`{ desktopUrl?, launchUrl? }`).
|
|
138
|
+
*/
|
|
139
|
+
export function apply(ctx, config) {
|
|
140
|
+
const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
|
|
141
|
+
table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: resolveUrls(config) })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const info = ctx.webServer.register({
|
|
145
|
+
kind: 'exact',
|
|
146
|
+
path: '/api/desktop-link/info',
|
|
147
|
+
handler(_req, res) {
|
|
148
|
+
// Re-read per request: editing desktop-url.txt takes effect on the next
|
|
149
|
+
// click without restarting the profile.
|
|
150
|
+
const body = JSON.stringify({ ok: true, ...resolveUrls(config) })
|
|
151
|
+
res.writeHead(200, {
|
|
152
|
+
'content-type': 'application/json; charset=utf-8',
|
|
153
|
+
'content-length': Buffer.byteLength(body),
|
|
154
|
+
'cache-control': 'no-store',
|
|
155
|
+
})
|
|
156
|
+
res.end(body)
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
ctx.effect(() => () => {
|
|
161
|
+
if (typeof disposeGlobal === 'function') disposeGlobal()
|
|
162
|
+
if (typeof info === 'function') info()
|
|
163
|
+
}, 'dsha-desktop-link: info route + index global')
|
|
164
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-desktop-link",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-desktop-link",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"icon": "./icon.svg",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./client": "./client.js",
|
|
9
|
+
"./locale/*.json": "./locale/*.json",
|
|
10
|
+
"./icon": "./icon.svg",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"icon.svg",
|
|
18
|
+
"locale"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"engines": {
|
|
22
|
+
"dsh": ">=0.2.1-rc.2"
|
|
23
|
+
},
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"immediately": true,
|
|
30
|
+
"inject": [
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"author": "lzlrt",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"dsh",
|
|
39
|
+
"dsh-plugin",
|
|
40
|
+
"deepseek",
|
|
41
|
+
"deepseek-harness",
|
|
42
|
+
"dsha",
|
|
43
|
+
"remote",
|
|
44
|
+
"sidebar",
|
|
45
|
+
"mobile"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/lzlrt/dsha-desktop-link.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/lzlrt/dsha-desktop-link#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/lzlrt/dsha-desktop-link/issues"
|
|
54
|
+
},
|
|
55
|
+
"description": "DSH sidebar button: open the desktop DSH Web GUI from DSHA on your phone, in one tap.",
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
58
|
+
}
|
|
6
59
|
}
|