dsh-desktop-link 0.0.0-stage → 1.2.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 +196 -2
- package/assets/icon-180.png +0 -0
- package/assets/icon-96.png +0 -0
- package/client.js +570 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +9 -0
- package/index.js +397 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -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,197 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-desktop-link
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 **DeepSeek Harness(DSH)** 的侧栏底部加两个按钮:一个点一下就在系统浏览器 / 新标签页里打开**桌面版 DSH** 的 Web GUI,另一个(齿轮)用来**在界面里直接填地址**。
|
|
4
|
+
|
|
5
|
+
专门为 **DSHA(DeepSeek Harness 安卓启动器)** 设计 —— 在手机上的 DSHA 里点这个按钮,会交给系统浏览器打开你电脑上的 DSH,而 DSHA 自己那套 agent 照常独立运行。
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
🖥 → 桌面版 ⚙
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/dsh-desktop-link)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
dsh plugin --profile web add dsh-desktop-link
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 它解决什么
|
|
21
|
+
|
|
22
|
+
DSHA 的内置浏览器只导航它自己的 `http://127.0.0.1:<端口>`,没有「填个网址去连别的机器」的入口。
|
|
23
|
+
但 DSHA 本来就有这条规则:**顶层导航离开本机 DSH 服务时,交给系统浏览器**(`WebPreviewActivity.shouldOverrideUrlLoading` → `ACTION_VIEW`)。
|
|
24
|
+
|
|
25
|
+
这个插件就是给这条已有规则加一个按钮入口 —— 不改 DSHA、不需要 root、不需要 `am start`。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 行为
|
|
30
|
+
|
|
31
|
+
| 点击环境 | 结果 |
|
|
32
|
+
|---|---|
|
|
33
|
+
| **手机**(DSHA 的 WebView 或手机浏览器) | 导航离开 DSH 服务 → DSHA 交给系统浏览器 → 打开桌面版 GUI |
|
|
34
|
+
| **电脑浏览器** | `target="_blank"` 开新标签页,当前 GUI 不受影响 |
|
|
35
|
+
|
|
36
|
+
未配置地址时按钮变灰;此时点它会直接打开齿轮那个设置框,不会跳到一个死链。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 安装
|
|
41
|
+
|
|
42
|
+
### 从 npm(推荐)
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
dsh plugin --profile web add dsh-desktop-link
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 从本仓库(开发循环)
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
git clone https://github.com/lzlrt/dsha-desktop-link
|
|
52
|
+
dsh plugin --profile web add link:$(pwd)/dsha-desktop-link
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 从打包好的 tgz
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
dsh plugin --profile web add /path/to/dsh-desktop-link-1.2.0.tgz
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 装到手机上的 DSHA
|
|
62
|
+
|
|
63
|
+
DSHA → 插件 → 添加 → 粘贴一个能下到这个包的 URL,例如:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
https://registry.npmjs.org/dsh-desktop-link/-/dsh-desktop-link-1.2.0.tgz
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
或在 DSHA 插件页直接填包名 `dsh-desktop-link`。
|
|
70
|
+
|
|
71
|
+
或从本地导入(`adb push` 到 `/sdcard/Download/` 后在插件页选文件)。
|
|
72
|
+
|
|
73
|
+
装完在插件管理页点「重启 Web」。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 配置地址
|
|
78
|
+
|
|
79
|
+
### 在界面上填(手机推荐)
|
|
80
|
+
|
|
81
|
+
点侧栏底部那个**齿轮** → 填两个框 → 保存。写盘、立即生效,不用重启、不用碰命令行。
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
┌─────────────────────────────┐
|
|
85
|
+
│ 桌面版地址 │
|
|
86
|
+
│ 填一次就存在这台电脑上,手机和电脑都读同一份。 │
|
|
87
|
+
│ 当前生效(桌面地址文件):https://… │
|
|
88
|
+
│ │
|
|
89
|
+
│ 桌面版地址 │
|
|
90
|
+
│ [https://xxxx.dsh-market.com] │
|
|
91
|
+
│ 长期地址,留空则自动用固定中继。 │
|
|
92
|
+
│ │
|
|
93
|
+
│ 点按钮时打开的地址 │
|
|
94
|
+
│ [https://…/open.html?k=… ] │
|
|
95
|
+
│ 留空就等于上面的地址。 │
|
|
96
|
+
│ [取消] [保存] │
|
|
97
|
+
└─────────────────────────────┘
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
保存会写进 `$DSH_HOME/desktop-url.txt`(先写临时文件再 rename,半个文件不会被读走)。
|
|
101
|
+
两个框都清空 = 删掉该文件,退回自动探测。
|
|
102
|
+
|
|
103
|
+
### 用配置文件
|
|
104
|
+
|
|
105
|
+
插件自身不带默认地址 —— 它从下面这些地方按顺序找,命中即停:
|
|
106
|
+
|
|
107
|
+
| 优先级 | 来源 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| 1 | 本行 `config.desktopUrl` / `config.launchUrl`(profile 补丁里) |
|
|
110
|
+
| 2 | 环境变量 `DSH_DESKTOP_URL` / `DSH_DESKTOP_LAUNCH_URL` |
|
|
111
|
+
| 3 | `$DSH_HOME/desktop-url.txt` 第 1 行(桌面地址)/ 第 2 行(点击时打开的地址) |
|
|
112
|
+
| 4 | `$DSH_HOME/remote-web-ui-registry/web.json` 的 `id` → `https://<id>.dsh-market.com`(仅当装了 `dsh-remote-web-ui` 并开了固定域名中继) |
|
|
113
|
+
|
|
114
|
+
`desktop-url.txt` 是最好改的一个 —— **改完刷新页面即生效**,不用重启:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
# 第一行:桌面版长期地址(书签、复制链接用)
|
|
118
|
+
https://your-desktop.example.com
|
|
119
|
+
|
|
120
|
+
# 第二行(可选):点按钮时真正打开的地址。
|
|
121
|
+
# 指向一个「每次铸新配对码」的端点,未配对的手机也能一次进。
|
|
122
|
+
https://your-desktop.example.com/pair-entry
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
只写一行 = 按钮直接开那一行。删掉文件 = 退回优先级 4。
|
|
126
|
+
|
|
127
|
+
### 两种地址的区别
|
|
128
|
+
|
|
129
|
+
| 字段 | 用途 |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `desktopUrl` | 长期地址。锚点的 `href` 用它,中间键 / 复制链接 / 收藏都按这个走 |
|
|
132
|
+
| `launchUrl` | 点击时实际打开的地址。可以是一个会临时铸码的端点,让从未配对过的设备也能一次进入 |
|
|
133
|
+
|
|
134
|
+
没配 `launchUrl` 时它等于 `desktopUrl`。
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 未配对的手机会看到 401
|
|
139
|
+
|
|
140
|
+
DSH 的远程访问是要配对的。未配对设备直接打开桌面地址会被 harness 的认证栅栏拒绝 —— 这是配对机制正常工作,不是这个插件的 bug。
|
|
141
|
+
|
|
142
|
+
三种处理方式:
|
|
143
|
+
|
|
144
|
+
1. **先配对一次**:配对 cookie 默认有效期一年,之后点按钮直接进。
|
|
145
|
+
2. **配 `launchUrl` 指向铸码端点**:每次点击拿新码,永远有效。
|
|
146
|
+
3. **手机端自动铸码**:若本机装了 `dsh-remote-web-ui`,手机点击时插件会先调同源 `/api/pair/issue` 铸一枚一次性码再跳转(该接口只应答回环,所以页面能直接调)。没装则自动退回直接打开 `desktopUrl`。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 接口
|
|
151
|
+
|
|
152
|
+
| 方法 | 路径 | 说明 |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| GET | `/api/desktop-link/info` | `{"ok":true,"launchUrl":"…","desktopUrl":"…","source":"…"}` |
|
|
155
|
+
| POST | `/api/desktop-link/config` | body `{"desktopUrl":"…","launchUrl":"…"}` → 写入 `desktop-url.txt`,返回写入后的解析结果与 `path` |
|
|
156
|
+
|
|
157
|
+
POST 只接受 `http(s)` URL:传了非空值但格式不对会返回 `400` 并说明原因,不会静默清空。
|
|
158
|
+
请求体上限 8 KiB,非 `POST` 返回 `405`。
|
|
159
|
+
|
|
160
|
+
每次请求重新解析地址,所以改配置文件不需要重启。
|
|
161
|
+
另外 Host 半区会在 `webserver/index-inject` 里注入 `globalThis.__DSHA_DESKTOP_LINK__`,让按钮首帧就能拿到地址;全局缺失时 Client 半区回退到上面那个接口。
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 结构
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
package.json 清单:dsh.bundle.patch + dsh.client(platform: web)
|
|
169
|
+
cordis.patch.yml 插入一行 dsha-desktop-link
|
|
170
|
+
index.js Host 半区:地址解析 + /info + /config + index 全局
|
|
171
|
+
client.js Client 半区:注册 sidebar.footer.action ×2 + 设置框
|
|
172
|
+
icon.svg 显示器 + 外跳箭头
|
|
173
|
+
locale/{zh,en}.json meta.title / meta.description
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 实现要点
|
|
177
|
+
|
|
178
|
+
- 注册 `sidebar.footer.action` 两次(`replaceRisk: none`):启动按钮 `order: 10`,齿轮 `order: 11`,都排在官方「远程访问」之后。
|
|
179
|
+
- 设置框跟齿轮挂在同一棵组件树里(`position: fixed` 覆盖层),不额外 `createRoot` —— 这样不依赖 `react-dom/client` 是否在页面可 require 的白名单里。
|
|
180
|
+
- 两个按钮之间的状态用一个模块级 store 同步,保存完另一个立刻拿到新地址,不用刷新。
|
|
181
|
+
- `Config` 导出是**可选**的:`@deepseek-ai/schemastery` 只存在于 DSH 安装内部,运行时按包名 → `process.argv[1]` 上溯 → Node 全局路径依次找;找不到就整个不导出 `Config`,插件照常工作,只是没有 DSH 自带的设置表单。
|
|
182
|
+
- 手机与电脑的分支靠 `navigator.userAgent` 判定;桌面侧完全走原生锚点行为,不拦点击。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 已知限制
|
|
187
|
+
|
|
188
|
+
1. **Host 半区(`index.js`)改动需要重装插件或重启 `dsh web`**;Client 半区(`client.js`)刷新页面即生效(有 `pnpm run dev:web` 监听时可免刷新)。
|
|
189
|
+
2. 侧栏折叠成 56px 轨道时只显示图标,展开后才显示「桌面版」文字。
|
|
190
|
+
3. 手机端「点击时铸码」依赖本机装了 `dsh-remote-web-ui`(本插件不声明这个依赖,装不装都能用,只是没有自动铸码)。
|
|
191
|
+
4. 需要 DSH `>= 0.2.1-rc.2`(`webserver/index-inject` 事件与 `sidebar.footer.action` 槽位)。
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
|
Binary file
|
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
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
|
+
'settings': '填桌面地址',
|
|
14
|
+
'hint.unset': '还没配桌面地址:点右边的齿轮填一个,或把地址写进 ~/.dsh/desktop-url.txt',
|
|
15
|
+
'form.title': '桌面版地址',
|
|
16
|
+
'form.intro': '填一次就存在这台电脑上,手机和电脑都读同一份。',
|
|
17
|
+
'form.desktop': '桌面版地址',
|
|
18
|
+
'form.desktop.hint': '长期地址,例如 https://xxxx.dsh-market.com。留空则自动用固定中继。',
|
|
19
|
+
'form.launch': '点按钮时打开的地址',
|
|
20
|
+
'form.launch.hint': '留空就等于上面的地址。填中转的 /open.html 链接可以每次自动铸新配对码。',
|
|
21
|
+
'form.save': '保存',
|
|
22
|
+
'form.cancel': '取消',
|
|
23
|
+
'form.saving': '保存中…',
|
|
24
|
+
'form.saved': '已保存',
|
|
25
|
+
'form.current': '当前生效',
|
|
26
|
+
'form.close': '关闭',
|
|
27
|
+
'source.config': '页面配置',
|
|
28
|
+
'source.env': '环境变量',
|
|
29
|
+
'source.desktop-url.txt': '桌面地址文件',
|
|
30
|
+
'source.relay-registry': '固定中继',
|
|
31
|
+
'source.unset': '未配置',
|
|
32
|
+
}
|
|
33
|
+
const en = {
|
|
34
|
+
'action': 'Open desktop DSH',
|
|
35
|
+
'action.short': 'Desktop',
|
|
36
|
+
'settings': 'Set desktop address',
|
|
37
|
+
'hint.unset': 'No desktop address yet: click the gear to set one, or write it into ~/.dsh/desktop-url.txt',
|
|
38
|
+
'form.title': 'Desktop address',
|
|
39
|
+
'form.intro': 'Saved on this computer once; both the phone and the desktop read it.',
|
|
40
|
+
'form.desktop': 'Desktop address',
|
|
41
|
+
'form.desktop.hint': 'Long-lived origin, e.g. https://xxxx.dsh-market.com. Empty falls back to the fixed relay.',
|
|
42
|
+
'form.launch': 'Address opened on click',
|
|
43
|
+
'form.launch.hint': 'Empty means the same as above. Point it at a relay /open.html link to mint a fresh pair code each time.',
|
|
44
|
+
'form.save': 'Save',
|
|
45
|
+
'form.cancel': 'Cancel',
|
|
46
|
+
'form.saving': 'Saving…',
|
|
47
|
+
'form.saved': 'Saved',
|
|
48
|
+
'form.current': 'In effect',
|
|
49
|
+
'form.close': 'Close',
|
|
50
|
+
'source.config': 'page config',
|
|
51
|
+
'source.env': 'environment',
|
|
52
|
+
'source.desktop-url.txt': 'address file',
|
|
53
|
+
'source.relay-registry': 'fixed relay',
|
|
54
|
+
'source.unset': 'not configured',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* 18×18 monitor with an out-arrow — same artwork as icon.svg, inlined so
|
|
58
|
+
the button costs no extra request. */
|
|
59
|
+
function Mark() {
|
|
60
|
+
return h('svg', {
|
|
61
|
+
viewBox: '0 0 64 64', width: 18, height: 18, 'aria-hidden': true,
|
|
62
|
+
focusable: false,
|
|
63
|
+
style: { display: 'block', flex: 'none' },
|
|
64
|
+
},
|
|
65
|
+
h('rect', {
|
|
66
|
+
x: 8, y: 10, width: 48, height: 30, rx: 5,
|
|
67
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 4.5,
|
|
68
|
+
}),
|
|
69
|
+
h('path', {
|
|
70
|
+
d: 'M26 54h12M32 46v8', fill: 'none', stroke: 'currentColor',
|
|
71
|
+
strokeWidth: 4.5, strokeLinecap: 'round',
|
|
72
|
+
}),
|
|
73
|
+
h('path', {
|
|
74
|
+
d: 'M24 25h14M32 18l7 7-7 7', fill: 'none', stroke: 'currentColor',
|
|
75
|
+
strokeWidth: 4.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* 18×18 gear, drawn at the same weight as Mark so the two sit together. */
|
|
80
|
+
function Gear() {
|
|
81
|
+
return h('svg', {
|
|
82
|
+
viewBox: '0 0 64 64', width: 16, height: 16, 'aria-hidden': true,
|
|
83
|
+
focusable: false, style: { display: 'block', flex: 'none' },
|
|
84
|
+
},
|
|
85
|
+
h('circle', {
|
|
86
|
+
cx: 32, cy: 32, r: 9, fill: 'none', stroke: 'currentColor', strokeWidth: 5,
|
|
87
|
+
}),
|
|
88
|
+
h('path', {
|
|
89
|
+
d: 'M32 6v10M32 48v10M6 32h10M48 32h10M13.6 13.6l7.1 7.1M43.3 43.3l7.1 7.1M50.4 13.6l-7.1 7.1M20.7 43.3l-7.1 7.1',
|
|
90
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 5, strokeLinecap: 'round',
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The live address pair, shared by both sidebar items.
|
|
96
|
+
*
|
|
97
|
+
* The two items are separate components mounted side by side, so the
|
|
98
|
+
* settings dialog's save has to reach the launcher without a page reload.
|
|
99
|
+
* A module-scope store with listeners is the smallest thing that does it —
|
|
100
|
+
* a window event would work too, but this keeps the shape typed in one
|
|
101
|
+
* place and lets a late subscriber read the current value synchronously.
|
|
102
|
+
*/
|
|
103
|
+
const store = (() => {
|
|
104
|
+
let value = { desktopUrl: '', launchUrl: '', source: 'unset', loaded: false }
|
|
105
|
+
const listeners = new Set()
|
|
106
|
+
const emit = () => { for (const fn of [...listeners]) fn(value) }
|
|
107
|
+
return {
|
|
108
|
+
get: () => value,
|
|
109
|
+
set(patch) {
|
|
110
|
+
const next = { ...value, ...patch }
|
|
111
|
+
if (next.desktopUrl === value.desktopUrl
|
|
112
|
+
&& next.launchUrl === value.launchUrl
|
|
113
|
+
&& next.source === value.source
|
|
114
|
+
&& next.loaded === value.loaded) return
|
|
115
|
+
value = next
|
|
116
|
+
emit()
|
|
117
|
+
},
|
|
118
|
+
subscribe(fn) {
|
|
119
|
+
listeners.add(fn)
|
|
120
|
+
return () => { listeners.delete(fn) }
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Seed the store from the index global.
|
|
127
|
+
*
|
|
128
|
+
* The global is injected at index render time, so it can be absent on a
|
|
129
|
+
* page served before the Host half loaded; in that case the info route is
|
|
130
|
+
* asked once. Either way the result is pushed into the shared store so both
|
|
131
|
+
* sidebar items agree.
|
|
132
|
+
*/
|
|
133
|
+
function seed() {
|
|
134
|
+
const injected = window.__DSHA_DESKTOP_LINK__
|
|
135
|
+
if (injected && typeof injected === 'object') {
|
|
136
|
+
store.set({
|
|
137
|
+
desktopUrl: typeof injected.desktopUrl === 'string' ? injected.desktopUrl : '',
|
|
138
|
+
launchUrl: typeof injected.launchUrl === 'string' ? injected.launchUrl : '',
|
|
139
|
+
source: typeof injected.source === 'string' ? injected.source : 'unset',
|
|
140
|
+
loaded: true,
|
|
141
|
+
})
|
|
142
|
+
if (injected.desktopUrl) return Promise.resolve()
|
|
143
|
+
}
|
|
144
|
+
return fetch('/api/desktop-link/info', {
|
|
145
|
+
credentials: 'same-origin',
|
|
146
|
+
headers: { accept: 'application/json' },
|
|
147
|
+
}).then((res) => (res.ok ? res.json() : null))
|
|
148
|
+
.then((body) => {
|
|
149
|
+
if (!body || !body.ok) return
|
|
150
|
+
const next = {
|
|
151
|
+
desktopUrl: typeof body.desktopUrl === 'string' ? body.desktopUrl : '',
|
|
152
|
+
launchUrl: typeof body.launchUrl === 'string' ? body.launchUrl : '',
|
|
153
|
+
source: typeof body.source === 'string' ? body.source : 'unset',
|
|
154
|
+
loaded: true,
|
|
155
|
+
}
|
|
156
|
+
window.__DSHA_DESKTOP_LINK__ = { ...(injected || {}), ...next }
|
|
157
|
+
store.set(next)
|
|
158
|
+
})
|
|
159
|
+
.catch(() => { /* the button stays as-is; the gear still opens */ })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Whether this page runs on a handset (DSHA's WebView or a phone browser). */
|
|
163
|
+
function isHandset() {
|
|
164
|
+
const ua = navigator.userAgent || ''
|
|
165
|
+
return /Android|iPhone|iPad|iPod|HarmonyOS|Mobile/i.test(ua)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The URL a click should carry, or '' when nothing is configured. */
|
|
169
|
+
function linkHref(value) {
|
|
170
|
+
return value.launchUrl || value.desktopUrl
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Mint a one-shot pair URL from this instance's own pairing service.
|
|
175
|
+
*
|
|
176
|
+
* This page is same-origin on the loopback listener and `/api/pair/issue`
|
|
177
|
+
* answers loopback only, so the browser can ask on the phone's behalf. A
|
|
178
|
+
* phone that has never paired would otherwise open the published origin and
|
|
179
|
+
* meet the harness 401 fence; after one pairing the cookie lasts a year.
|
|
180
|
+
* An instance without the pairing plugin answers 404 — a handled miss.
|
|
181
|
+
* @returns the pair-accept URL, or an empty string.
|
|
182
|
+
*/
|
|
183
|
+
function mintPairUrl() {
|
|
184
|
+
return fetch('/api/pair/issue', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
credentials: 'same-origin',
|
|
187
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
188
|
+
body: '{}',
|
|
189
|
+
}).then((res) => (res.ok ? res.json() : null))
|
|
190
|
+
.then((body) => (body && typeof body.url === 'string' ? body.url : ''))
|
|
191
|
+
.catch(() => '')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The dialog's open state, shared so the launcher can open it too. */
|
|
195
|
+
const dialog = (() => {
|
|
196
|
+
let open = false
|
|
197
|
+
const listeners = new Set()
|
|
198
|
+
const emit = () => { for (const fn of [...listeners]) fn(open) }
|
|
199
|
+
return {
|
|
200
|
+
isOpen: () => open,
|
|
201
|
+
show() { if (!open) { open = true; emit() } },
|
|
202
|
+
hide() { if (open) { open = false; emit() } },
|
|
203
|
+
subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn) } },
|
|
204
|
+
}
|
|
205
|
+
})()
|
|
206
|
+
|
|
207
|
+
const baseButtonStyle = (wide) => ({
|
|
208
|
+
display: 'inline-flex',
|
|
209
|
+
alignItems: 'center',
|
|
210
|
+
justifyContent: wide ? 'flex-start' : 'center',
|
|
211
|
+
flex: 'none',
|
|
212
|
+
height: 28,
|
|
213
|
+
minWidth: 28,
|
|
214
|
+
padding: wide ? '0 8px' : 0,
|
|
215
|
+
boxSizing: 'border-box',
|
|
216
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
217
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
218
|
+
background: 'transparent',
|
|
219
|
+
border: 'none',
|
|
220
|
+
cursor: 'pointer',
|
|
221
|
+
fontSize: 13,
|
|
222
|
+
fontFamily: 'inherit',
|
|
223
|
+
textDecoration: 'none',
|
|
224
|
+
whiteSpace: 'nowrap',
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const hoverIn = (e) => {
|
|
228
|
+
e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))'
|
|
229
|
+
}
|
|
230
|
+
const hoverOut = (e) => { e.currentTarget.style.background = 'transparent' }
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Subscribe a component to one of the module-scope stores.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately not `useSyncExternalStore`: this plugin must run on whatever
|
|
236
|
+
* React the page shell ships, and the plain state+effect form works on every
|
|
237
|
+
* version that has hooks at all.
|
|
238
|
+
* @param source - object with `subscribe` and a getter.
|
|
239
|
+
* @param read - selector from the store value to the slice worth rendering.
|
|
240
|
+
* @returns the current slice, re-rendering on change.
|
|
241
|
+
*/
|
|
242
|
+
function useStore(source, read) {
|
|
243
|
+
const [, force] = React.useState(0)
|
|
244
|
+
React.useEffect(() => source.subscribe(() => force((n) => n + 1)), [source])
|
|
245
|
+
return read()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Row 1: "open the desktop DSH".
|
|
250
|
+
*
|
|
251
|
+
* Two paths, both deterministic:
|
|
252
|
+
* - Handset: no second window exists, so a top-level navigation is the
|
|
253
|
+
* answer. It is issued after a short mint call, which a navigation (unlike
|
|
254
|
+
* `window.open`) tolerates, and DSHA then hands it to the system browser.
|
|
255
|
+
* - Desktop: a plain `target="_blank"` anchor opens a second tab and leaves
|
|
256
|
+
* the current GUI alone. No script runs, so nothing can be blocked.
|
|
257
|
+
*
|
|
258
|
+
* With nothing configured the click opens the settings dialog instead of
|
|
259
|
+
* doing nothing — that is the only way a phone user can ever get an address
|
|
260
|
+
* in, since it has no shell to write the file with.
|
|
261
|
+
*/
|
|
262
|
+
function DesktopLinkButton(props) {
|
|
263
|
+
const wide = props.wide !== false
|
|
264
|
+
const t = props.t || ((key) => zh[key] || en[key] || key)
|
|
265
|
+
const value = useStore(store, () => store.get())
|
|
266
|
+
const href = linkHref(value)
|
|
267
|
+
|
|
268
|
+
React.useEffect(() => { seed() }, [])
|
|
269
|
+
|
|
270
|
+
const onClick = (event) => {
|
|
271
|
+
if (!href) { event.preventDefault(); dialog.show(); return }
|
|
272
|
+
// Let modifier-clicks and middle-clicks fall through to the anchor.
|
|
273
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
274
|
+
if (!isHandset()) return // desktop keeps the plain _blank anchor
|
|
275
|
+
event.preventDefault()
|
|
276
|
+
// A configured minting endpoint already hands out a fresh token, so
|
|
277
|
+
// navigating there is enough; otherwise mint here.
|
|
278
|
+
if (value.launchUrl && value.launchUrl !== value.desktopUrl) {
|
|
279
|
+
window.location.assign(value.launchUrl)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
mintPairUrl().then((url) => { window.location.assign(url || value.desktopUrl) })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return h('a', {
|
|
286
|
+
href: href || undefined,
|
|
287
|
+
target: '_blank',
|
|
288
|
+
rel: 'noopener noreferrer',
|
|
289
|
+
style: { ...baseButtonStyle(wide), opacity: href ? 1 : 0.45 },
|
|
290
|
+
title: href || t('hint.unset'),
|
|
291
|
+
'aria-label': t('action'),
|
|
292
|
+
'aria-disabled': href ? undefined : 'true',
|
|
293
|
+
onClick,
|
|
294
|
+
onMouseEnter: hoverIn,
|
|
295
|
+
onMouseLeave: hoverOut,
|
|
296
|
+
},
|
|
297
|
+
h(Mark),
|
|
298
|
+
wide
|
|
299
|
+
? h('span', {
|
|
300
|
+
style: { marginLeft: 8, overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 },
|
|
301
|
+
}, t('action.short'))
|
|
302
|
+
: null)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Row 2: the gear.
|
|
307
|
+
*
|
|
308
|
+
* A separate slot item rather than a modified click on the launcher: a
|
|
309
|
+
* long-press has no affordance on a phone, and a second small target next
|
|
310
|
+
* to the first is the one pattern that reads the same on both platforms.
|
|
311
|
+
*/
|
|
312
|
+
function DesktopLinkSettingsButton(props) {
|
|
313
|
+
const wide = props.wide !== false
|
|
314
|
+
const t = props.t || ((key) => zh[key] || en[key] || key)
|
|
315
|
+
return h(React.Fragment, null,
|
|
316
|
+
h('button', {
|
|
317
|
+
type: 'button',
|
|
318
|
+
style: baseButtonStyle(false),
|
|
319
|
+
title: t('settings'),
|
|
320
|
+
'aria-label': t('settings'),
|
|
321
|
+
onClick: () => dialog.show(),
|
|
322
|
+
onMouseEnter: hoverIn,
|
|
323
|
+
onMouseLeave: hoverOut,
|
|
324
|
+
}, h(Gear)),
|
|
325
|
+
h(DesktopLinkSettingsDialog, { t }))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const overlayStyle = {
|
|
329
|
+
position: 'fixed',
|
|
330
|
+
inset: 0,
|
|
331
|
+
zIndex: 2147483000,
|
|
332
|
+
display: 'flex',
|
|
333
|
+
alignItems: 'center',
|
|
334
|
+
justifyContent: 'center',
|
|
335
|
+
padding: 16,
|
|
336
|
+
background: 'rgba(0,0,0,.45)',
|
|
337
|
+
}
|
|
338
|
+
const cardStyle = {
|
|
339
|
+
width: 'min(520px, 100%)',
|
|
340
|
+
maxHeight: 'calc(100vh - 32px)',
|
|
341
|
+
overflow: 'auto',
|
|
342
|
+
boxSizing: 'border-box',
|
|
343
|
+
padding: 20,
|
|
344
|
+
borderRadius: 12,
|
|
345
|
+
background: 'var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-base, #fff))',
|
|
346
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
347
|
+
boxShadow: '0 12px 40px rgba(0,0,0,.28)',
|
|
348
|
+
fontFamily: 'inherit',
|
|
349
|
+
fontSize: 14,
|
|
350
|
+
lineHeight: 1.5,
|
|
351
|
+
}
|
|
352
|
+
const labelStyle = { display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6 }
|
|
353
|
+
const inputStyle = {
|
|
354
|
+
width: '100%',
|
|
355
|
+
boxSizing: 'border-box',
|
|
356
|
+
padding: '8px 10px',
|
|
357
|
+
borderRadius: 8,
|
|
358
|
+
border: '1px solid var(--dsw-alias-border-weak, rgba(127,127,127,.35))',
|
|
359
|
+
background: 'var(--dsw-alias-bg-base, transparent)',
|
|
360
|
+
color: 'inherit',
|
|
361
|
+
fontFamily: 'inherit',
|
|
362
|
+
fontSize: 14,
|
|
363
|
+
}
|
|
364
|
+
const hintStyle = { margin: '6px 0 0', fontSize: 12, opacity: .7, lineHeight: 1.45 }
|
|
365
|
+
const actionStyle = (primary) => ({
|
|
366
|
+
height: 34,
|
|
367
|
+
padding: '0 16px',
|
|
368
|
+
borderRadius: 8,
|
|
369
|
+
border: primary ? 'none' : '1px solid var(--dsw-alias-border-weak, rgba(127,127,127,.35))',
|
|
370
|
+
background: primary ? 'var(--dsw-alias-brand-primary, #4d6bfe)' : 'transparent',
|
|
371
|
+
color: primary ? '#fff' : 'inherit',
|
|
372
|
+
fontFamily: 'inherit',
|
|
373
|
+
fontSize: 14,
|
|
374
|
+
cursor: 'pointer',
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The settings dialog.
|
|
379
|
+
*
|
|
380
|
+
* Mounted once, unconditionally, and driven by the shared open flag: a
|
|
381
|
+
* dialog rendered inside the button would unmount with it, and the sidebar
|
|
382
|
+
* row can re-render for unrelated reasons.
|
|
383
|
+
*
|
|
384
|
+
* Saving posts to the Host, which writes `desktop-url.txt` — the same file
|
|
385
|
+
* the file-based resolution chain reads — and answers with the resolved
|
|
386
|
+
* pair, so the form shows what actually took effect rather than what was
|
|
387
|
+
* typed.
|
|
388
|
+
*/
|
|
389
|
+
function DesktopLinkSettingsDialog(props) {
|
|
390
|
+
const t = (props && props.t) || ((key) => zh[key] || en[key] || key)
|
|
391
|
+
const open = useStore(dialog, () => dialog.isOpen())
|
|
392
|
+
const value = useStore(store, () => store.get())
|
|
393
|
+
const [desktop, setDesktop] = React.useState('')
|
|
394
|
+
const [launch, setLaunch] = React.useState('')
|
|
395
|
+
const [status, setStatus] = React.useState('idle')
|
|
396
|
+
const [error, setError] = React.useState('')
|
|
397
|
+
const fallback = t
|
|
398
|
+
|
|
399
|
+
// Seed the fields from the live pair each time the dialog opens, so a
|
|
400
|
+
// second visit never shows a stale draft.
|
|
401
|
+
React.useEffect(() => {
|
|
402
|
+
if (!open) return
|
|
403
|
+
setDesktop(value.desktopUrl || '')
|
|
404
|
+
setLaunch(value.launchUrl && value.launchUrl !== value.desktopUrl ? value.launchUrl : '')
|
|
405
|
+
setStatus('idle')
|
|
406
|
+
setError('')
|
|
407
|
+
}, [open])
|
|
408
|
+
|
|
409
|
+
React.useEffect(() => {
|
|
410
|
+
if (!open) return
|
|
411
|
+
const onKey = (event) => { if (event.key === 'Escape') dialog.hide() }
|
|
412
|
+
window.addEventListener('keydown', onKey)
|
|
413
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
414
|
+
}, [open])
|
|
415
|
+
|
|
416
|
+
if (!open) return null
|
|
417
|
+
|
|
418
|
+
const submit = (event) => {
|
|
419
|
+
event.preventDefault()
|
|
420
|
+
if (status === 'saving') return
|
|
421
|
+
setStatus('saving')
|
|
422
|
+
setError('')
|
|
423
|
+
fetch('/api/desktop-link/config', {
|
|
424
|
+
method: 'POST',
|
|
425
|
+
credentials: 'same-origin',
|
|
426
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
427
|
+
body: JSON.stringify({ desktopUrl: desktop.trim(), launchUrl: launch.trim() }),
|
|
428
|
+
})
|
|
429
|
+
.then((res) => res.json().then((body) => ({ ok: res.ok, body })))
|
|
430
|
+
.then(({ ok, body }) => {
|
|
431
|
+
if (!ok || !body || body.ok !== true) {
|
|
432
|
+
setError((body && body.error) || 'save failed')
|
|
433
|
+
setStatus('idle')
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const next = {
|
|
437
|
+
desktopUrl: typeof body.desktopUrl === 'string' ? body.desktopUrl : '',
|
|
438
|
+
launchUrl: typeof body.launchUrl === 'string' ? body.launchUrl : '',
|
|
439
|
+
source: typeof body.source === 'string' ? body.source : 'unset',
|
|
440
|
+
loaded: true,
|
|
441
|
+
}
|
|
442
|
+
window.__DSHA_DESKTOP_LINK__ = { ...(window.__DSHA_DESKTOP_LINK__ || {}), ...next }
|
|
443
|
+
store.set(next)
|
|
444
|
+
setStatus('saved')
|
|
445
|
+
})
|
|
446
|
+
.catch((err) => { setError(String(err && err.message ? err.message : err)); setStatus('idle') })
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const sourceText = (fallback(`source.${value.source}`))
|
|
450
|
+
const body = []
|
|
451
|
+
|
|
452
|
+
body.push(h('h2', {
|
|
453
|
+
key: 'title',
|
|
454
|
+
style: { margin: '0 0 4px', fontSize: 17, fontWeight: 650 },
|
|
455
|
+
}, fallback('form.title')))
|
|
456
|
+
body.push(h('p', {
|
|
457
|
+
key: 'intro',
|
|
458
|
+
style: { margin: '0 0 16px', fontSize: 12, opacity: .72 },
|
|
459
|
+
}, fallback('form.intro')))
|
|
460
|
+
|
|
461
|
+
if (value.desktopUrl) {
|
|
462
|
+
body.push(h('p', {
|
|
463
|
+
key: 'current',
|
|
464
|
+
style: {
|
|
465
|
+
margin: '0 0 16px', padding: '8px 10px', borderRadius: 8, fontSize: 12,
|
|
466
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.1))',
|
|
467
|
+
wordBreak: 'break-all',
|
|
468
|
+
},
|
|
469
|
+
}, `${fallback('form.current')}(${sourceText}):${value.launchUrl || value.desktopUrl}`))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
body.push(h('div', { key: 'desktop', style: { marginBottom: 14 } },
|
|
473
|
+
h('label', { style: labelStyle, htmlFor: 'dsh-desktop-link-desktop' }, fallback('form.desktop')),
|
|
474
|
+
h('input', {
|
|
475
|
+
id: 'dsh-desktop-link-desktop',
|
|
476
|
+
type: 'url',
|
|
477
|
+
inputMode: 'url',
|
|
478
|
+
autoCapitalize: 'off',
|
|
479
|
+
autoCorrect: 'off',
|
|
480
|
+
spellCheck: false,
|
|
481
|
+
placeholder: 'https://xxxx.dsh-market.com',
|
|
482
|
+
value: desktop,
|
|
483
|
+
onChange: (e) => setDesktop(e.target.value),
|
|
484
|
+
style: inputStyle,
|
|
485
|
+
}),
|
|
486
|
+
h('p', { style: hintStyle }, fallback('form.desktop.hint'))))
|
|
487
|
+
|
|
488
|
+
body.push(h('div', { key: 'launch', style: { marginBottom: 14 } },
|
|
489
|
+
h('label', { style: labelStyle, htmlFor: 'dsh-desktop-link-launch' }, fallback('form.launch')),
|
|
490
|
+
h('input', {
|
|
491
|
+
id: 'dsh-desktop-link-launch',
|
|
492
|
+
type: 'url',
|
|
493
|
+
inputMode: 'url',
|
|
494
|
+
autoCapitalize: 'off',
|
|
495
|
+
autoCorrect: 'off',
|
|
496
|
+
spellCheck: false,
|
|
497
|
+
placeholder: 'https://…/open.html?k=…',
|
|
498
|
+
value: launch,
|
|
499
|
+
onChange: (e) => setLaunch(e.target.value),
|
|
500
|
+
style: inputStyle,
|
|
501
|
+
}),
|
|
502
|
+
h('p', { style: hintStyle }, fallback('form.launch.hint'))))
|
|
503
|
+
|
|
504
|
+
if (error) {
|
|
505
|
+
body.push(h('p', {
|
|
506
|
+
key: 'error',
|
|
507
|
+
style: { margin: '0 0 12px', fontSize: 12, color: 'var(--dsw-alias-danger, #d64545)' },
|
|
508
|
+
}, error))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
body.push(h('div', {
|
|
512
|
+
key: 'actions',
|
|
513
|
+
style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' },
|
|
514
|
+
},
|
|
515
|
+
status === 'saved'
|
|
516
|
+
? h('span', { style: { marginRight: 'auto', fontSize: 12, opacity: .75 } }, fallback('form.saved'))
|
|
517
|
+
: null,
|
|
518
|
+
h('button', {
|
|
519
|
+
type: 'button',
|
|
520
|
+
style: actionStyle(false),
|
|
521
|
+
onClick: () => dialog.hide(),
|
|
522
|
+
}, fallback('form.cancel')),
|
|
523
|
+
h('button', {
|
|
524
|
+
type: 'submit',
|
|
525
|
+
style: actionStyle(true),
|
|
526
|
+
disabled: status === 'saving',
|
|
527
|
+
onClick: submit,
|
|
528
|
+
}, status === 'saving' ? fallback('form.saving') : fallback('form.save'))))
|
|
529
|
+
|
|
530
|
+
return h('div', {
|
|
531
|
+
role: 'dialog',
|
|
532
|
+
'aria-modal': 'true',
|
|
533
|
+
'aria-label': fallback('form.title'),
|
|
534
|
+
style: overlayStyle,
|
|
535
|
+
onClick: (event) => { if (event.target === event.currentTarget) dialog.hide() },
|
|
536
|
+
}, h('form', {
|
|
537
|
+
onSubmit: submit,
|
|
538
|
+
style: cardStyle,
|
|
539
|
+
onClick: (event) => event.stopPropagation(),
|
|
540
|
+
}, body))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
inject: ['slots', 'locale'],
|
|
545
|
+
apply(ctx) {
|
|
546
|
+
ctx.effect(() => {
|
|
547
|
+
const offZh = ctx.locale.register(NS, 'zh', zh)
|
|
548
|
+
const offEn = ctx.locale.register(NS, 'en', en)
|
|
549
|
+
return () => { offZh(); offEn() }
|
|
550
|
+
}, 'dsha-desktop-link: dictionaries')
|
|
551
|
+
|
|
552
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
553
|
+
name: 'sidebar.footer.action',
|
|
554
|
+
id: 'dsha-desktop-link',
|
|
555
|
+
order: 10,
|
|
556
|
+
label: () => ctx.locale.bind(NS)('action'),
|
|
557
|
+
locale: NS,
|
|
558
|
+
}, DesktopLinkButton))
|
|
559
|
+
|
|
560
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
561
|
+
name: 'sidebar.footer.action',
|
|
562
|
+
id: 'dsha-desktop-link-settings',
|
|
563
|
+
order: 11,
|
|
564
|
+
label: () => ctx.locale.bind(NS)('settings'),
|
|
565
|
+
locale: NS,
|
|
566
|
+
}, DesktopLinkSettingsButton))
|
|
567
|
+
},
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
})
|
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,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-desktop-link —— 宿主半区。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:把两个地址交给浏览器半区,并允许在界面上改这两个地址。
|
|
5
|
+
* 路由(均为同源 JSON):
|
|
6
|
+
*
|
|
7
|
+
* GET /api/desktop-link/info
|
|
8
|
+
* → { ok, launchUrl, desktopUrl, source }
|
|
9
|
+
*
|
|
10
|
+
* POST /api/desktop-link/config { desktopUrl?, launchUrl? }
|
|
11
|
+
* → { ok, path, launchUrl, desktopUrl, source };写入 $DSH_HOME/desktop-url.txt
|
|
12
|
+
*
|
|
13
|
+
* launchUrl 点按钮时真正打开的地址(可以是中转的一次性链接)
|
|
14
|
+
* desktopUrl 桌面版 GUI 的长期地址(书签用,也是 launchUrl 的兜底)
|
|
15
|
+
*
|
|
16
|
+
* desktopUrl 的来源优先级(命中即停):
|
|
17
|
+
* 1. 本行 config.desktopUrl
|
|
18
|
+
* 2. 环境变量 DSH_DESKTOP_URL
|
|
19
|
+
* 3. $DSH_HOME/desktop-url.txt 第一行
|
|
20
|
+
* 4. $DSH_HOME/remote-web-ui-registry/web.json 的 id → https://<id>.dsh-market.com
|
|
21
|
+
*
|
|
22
|
+
* launchUrl 额外支持 config.launchUrl / 环境变量 DSH_DESKTOP_LAUNCH_URL,
|
|
23
|
+
* 用来指向 dsha-pair/bridge.py 的 /open.html(每次点都铸一枚新码)。
|
|
24
|
+
* 没配 launchUrl 时,launchUrl 恒等于 desktopUrl。
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { createRequire } from 'node:module'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { dirname, join, resolve } from 'node:path'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve `@deepseek-ai/schemastery`, which ships inside the harness
|
|
34
|
+
* installation rather than as a dependency of this package.
|
|
35
|
+
*
|
|
36
|
+
* A bare `import` here fails with ERR_MODULE_NOT_FOUND and takes the whole
|
|
37
|
+
* plugin down, so the module is located at runtime: first as a normal package
|
|
38
|
+
* import from this file, then by walking up from the running `dsh` binary (the
|
|
39
|
+
* harness's own node_modules), and finally by asking Node's global paths. The
|
|
40
|
+
* `Config` export is omitted entirely when none of those resolve — the plugin
|
|
41
|
+
* still works from config files, it just has no settings form.
|
|
42
|
+
* @returns the schemastery `z` function, or undefined.
|
|
43
|
+
*/
|
|
44
|
+
function loadSchemastery() {
|
|
45
|
+
const requireFrom = createRequire(import.meta.url)
|
|
46
|
+
const candidates = ['@deepseek-ai/schemastery']
|
|
47
|
+
|
|
48
|
+
// The harness entry point: .../node_modules/@deepseek-ai/dsh/lib/bin.js
|
|
49
|
+
const argv1 = process.argv[1]
|
|
50
|
+
if (typeof argv1 === 'string' && argv1) {
|
|
51
|
+
const dir = dirname(resolve(argv1))
|
|
52
|
+
candidates.push(join(dir, '..', 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
53
|
+
candidates.push(join(dir, 'node_modules', '@deepseek-ai', 'schemastery'))
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
for (const base of requireFrom.resolve.paths?.('@deepseek-ai/schemastery') ?? []) {
|
|
57
|
+
candidates.push(join(base, '@deepseek-ai', 'schemastery'))
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// resolve.paths is not on every Node build; the other candidates stand.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
try {
|
|
65
|
+
const mod = requireFrom(candidate)
|
|
66
|
+
const z = mod?.default ?? mod
|
|
67
|
+
if (typeof z === 'function' && typeof z.object === 'function') return z
|
|
68
|
+
} catch {
|
|
69
|
+
// try the next candidate
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const z = loadSchemastery()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Row config.
|
|
79
|
+
*
|
|
80
|
+
* Everything is optional. An install with no config falls through to the
|
|
81
|
+
* environment, then to `desktop-url.txt`, then to the relay origin that
|
|
82
|
+
* `dsh-remote-web-ui` publishes. The form exists so a phone — where there is no
|
|
83
|
+
* shell to write that file with — can set the address from the GUI.
|
|
84
|
+
*
|
|
85
|
+
* The loader validates Config through schemastery's own `meta`/`dict` shape, so
|
|
86
|
+
* a plain JSON Schema object is not interchangeable here (`undefined.validate`
|
|
87
|
+
* when it tries). When schemastery cannot be located the export is omitted: no
|
|
88
|
+
* settings form, but the row still activates.
|
|
89
|
+
*/
|
|
90
|
+
const configSchema = typeof z === 'function'
|
|
91
|
+
? z.object({
|
|
92
|
+
desktopUrl: z.string().description(
|
|
93
|
+
'桌面版 DSH 的长期地址(书签用),例如 https://xxx.dsh-market.com。'
|
|
94
|
+
+ '留空则依次读环境变量 DSH_DESKTOP_URL、$DSH_HOME/desktop-url.txt、'
|
|
95
|
+
+ 'dsh-remote-web-ui 的固定中继地址。'),
|
|
96
|
+
launchUrl: z.string().description(
|
|
97
|
+
'点按钮时真正打开的地址。留空则等于 desktopUrl。'
|
|
98
|
+
+ '填一个会临时铸配对码的端点(如 bridge.py 的 /open.html?k=…),'
|
|
99
|
+
+ '未配对过的手机也能一次进入。'),
|
|
100
|
+
})
|
|
101
|
+
: undefined
|
|
102
|
+
|
|
103
|
+
export { configSchema as Config }
|
|
104
|
+
|
|
105
|
+
function dshHome() {
|
|
106
|
+
const fromEnv = process.env.DSH_HOME
|
|
107
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
108
|
+
return join(homedir(), '.dsh')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function urlish(value) {
|
|
112
|
+
return typeof value === 'string' && /^https?:\/\/\S+$/i.test(value.trim()) ? value.trim() : ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every URL in a hand-edited comment-friendly file under the DSH home.
|
|
117
|
+
*
|
|
118
|
+
* `desktop-url.txt` takes up to two lines, in this order:
|
|
119
|
+
* line 1 = desktopUrl, the long-lived desktop origin (bookmark target)
|
|
120
|
+
* line 2 = launchUrl, what a click actually opens (a minting endpoint)
|
|
121
|
+
* A single line therefore means "open this one thing". `#` comments and blank
|
|
122
|
+
* lines are skipped, and anything that is not http(s) is ignored rather than
|
|
123
|
+
* returned half-parsed.
|
|
124
|
+
* @param name - file name inside the DSH home.
|
|
125
|
+
* @returns the URLs found, in file order.
|
|
126
|
+
*/
|
|
127
|
+
function readUrlLines(name) {
|
|
128
|
+
try {
|
|
129
|
+
const file = join(dshHome(), name)
|
|
130
|
+
if (!existsSync(file)) return []
|
|
131
|
+
const found = []
|
|
132
|
+
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
133
|
+
const line = raw.trim()
|
|
134
|
+
if (!line || line.startsWith('#')) continue
|
|
135
|
+
const value = urlish(line)
|
|
136
|
+
if (value) found.push(value)
|
|
137
|
+
}
|
|
138
|
+
return found
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readRelayOrigin() {
|
|
145
|
+
try {
|
|
146
|
+
const file = join(dshHome(), 'remote-web-ui-registry', 'web.json')
|
|
147
|
+
if (!existsSync(file)) return ''
|
|
148
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
149
|
+
const id = typeof parsed?.id === 'string' ? parsed.id.trim() : ''
|
|
150
|
+
return /^[A-Za-z0-9-]{8,}$/.test(id) ? `https://${id}.dsh-market.com` : ''
|
|
151
|
+
} catch {
|
|
152
|
+
return ''
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether this instance has ever paired a device.
|
|
158
|
+
*
|
|
159
|
+
* The roster is `dsh-remote-web-ui`'s own persisted store
|
|
160
|
+
* (`$DSH_HOME/remote-web-ui-devices.json`, 0600, its documented default). It is
|
|
161
|
+
* read only for this boolean — no ids, no credentials — and a missing or
|
|
162
|
+
* unreadable file simply means "nothing paired yet", which is the safe answer:
|
|
163
|
+
* the button then mints a token instead of opening a URL that would 401.
|
|
164
|
+
* @returns whether at least one device session exists on disk.
|
|
165
|
+
*/
|
|
166
|
+
function hasPairedDevice() {
|
|
167
|
+
try {
|
|
168
|
+
const file = join(dshHome(), 'remote-web-ui-devices.json')
|
|
169
|
+
if (!existsSync(file)) return false
|
|
170
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
171
|
+
const rows = Array.isArray(parsed) ? parsed : (parsed?.devices ?? [])
|
|
172
|
+
return Array.isArray(rows) ? rows.length > 0 : Boolean(rows && Object.keys(rows).length)
|
|
173
|
+
} catch {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveUrls(config) {
|
|
179
|
+
const fileUrls = readUrlLines('desktop-url.txt')
|
|
180
|
+
const chain = [
|
|
181
|
+
['config', () => urlish(config?.desktopUrl)],
|
|
182
|
+
['env', () => urlish(process.env.DSH_DESKTOP_URL)],
|
|
183
|
+
['desktop-url.txt', () => fileUrls[0] ?? ''],
|
|
184
|
+
['relay-registry', readRelayOrigin],
|
|
185
|
+
]
|
|
186
|
+
let desktopUrl = ''
|
|
187
|
+
let source = 'unset'
|
|
188
|
+
for (const [label, read] of chain) {
|
|
189
|
+
const value = read()
|
|
190
|
+
if (value) { desktopUrl = value; source = label; break }
|
|
191
|
+
}
|
|
192
|
+
// launchUrl is what a click opens. In order: the row's config, the
|
|
193
|
+
// environment, line 2 of desktop-url.txt (a local minting endpoint such as
|
|
194
|
+
// dsha-pair/bridge.py's /open.html?k=…), then the desktop origin itself.
|
|
195
|
+
const launchUrl =
|
|
196
|
+
urlish(config?.launchUrl) ||
|
|
197
|
+
urlish(process.env.DSH_DESKTOP_LAUNCH_URL) ||
|
|
198
|
+
fileUrls[1] ||
|
|
199
|
+
desktopUrl
|
|
200
|
+
return { launchUrl, desktopUrl, source }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Nothing to do here — kept as the single place that maps a URL string to a
|
|
205
|
+
* file line, so the write path and the read path agree on what counts as a URL.
|
|
206
|
+
* @param value - candidate URL.
|
|
207
|
+
* @returns the trimmed URL, or an empty string when it is not http(s).
|
|
208
|
+
*/
|
|
209
|
+
function cleanUrl(value) {
|
|
210
|
+
return urlish(value)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Persist both addresses to `$DSH_HOME/desktop-url.txt`.
|
|
215
|
+
*
|
|
216
|
+
* Written to a sibling temp file and renamed into place: a half-written file
|
|
217
|
+
* would be read back by the next `/api/desktop-link/info` and silently point
|
|
218
|
+
* the button at a truncated URL. An empty pair removes the file entirely, which
|
|
219
|
+
* restores the relay-registry fallback instead of pinning a dead address.
|
|
220
|
+
* @param desktopUrl - the long-lived origin, or ''.
|
|
221
|
+
* @param launchUrl - what a click opens, or '' for "same as desktopUrl".
|
|
222
|
+
* @returns the file path written, or '' when the file was removed.
|
|
223
|
+
*/
|
|
224
|
+
function writeUrlFile(desktopUrl, launchUrl) {
|
|
225
|
+
const file = join(dshHome(), 'desktop-url.txt')
|
|
226
|
+
const lines = []
|
|
227
|
+
if (desktopUrl) lines.push(desktopUrl)
|
|
228
|
+
// Line 2 only carries meaning next to a line 1; a lone launchUrl is written
|
|
229
|
+
// as line 1 so a user who only knows the minting endpoint still gets one.
|
|
230
|
+
if (launchUrl && launchUrl !== desktopUrl) {
|
|
231
|
+
if (lines.length === 0) lines.push(launchUrl)
|
|
232
|
+
else lines.push(launchUrl)
|
|
233
|
+
}
|
|
234
|
+
if (lines.length === 0) {
|
|
235
|
+
try { rmSync(file, { force: true }) } catch { /* already gone */ }
|
|
236
|
+
return ''
|
|
237
|
+
}
|
|
238
|
+
const tmp = `${file}.tmp`
|
|
239
|
+
writeFileSync(tmp, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
240
|
+
renameSync(tmp, file)
|
|
241
|
+
return file
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read a small JSON request body.
|
|
246
|
+
*
|
|
247
|
+
* Bounded on purpose: this route is reachable from any paired device, and an
|
|
248
|
+
* unbounded read would let one request hold the process's memory. The cap is
|
|
249
|
+
* far above the two URLs the form sends.
|
|
250
|
+
* @param req - the incoming request.
|
|
251
|
+
* @returns the parsed object, or null when the body is absent, oversized, or
|
|
252
|
+
* not JSON.
|
|
253
|
+
*/
|
|
254
|
+
function readJsonBody(req) {
|
|
255
|
+
return new Promise((resolveBody) => {
|
|
256
|
+
const chunks = []
|
|
257
|
+
let size = 0
|
|
258
|
+
let settled = false
|
|
259
|
+
const finish = (value) => {
|
|
260
|
+
if (settled) return
|
|
261
|
+
settled = true
|
|
262
|
+
resolveBody(value)
|
|
263
|
+
}
|
|
264
|
+
req.on('data', (chunk) => {
|
|
265
|
+
size += chunk.length
|
|
266
|
+
if (size > 8192) { finish(null); req.destroy(); return }
|
|
267
|
+
chunks.push(chunk)
|
|
268
|
+
})
|
|
269
|
+
req.on('end', () => {
|
|
270
|
+
if (!chunks.length) return finish({})
|
|
271
|
+
try {
|
|
272
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
273
|
+
finish(parsed && typeof parsed === 'object' ? parsed : null)
|
|
274
|
+
} catch {
|
|
275
|
+
finish(null)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
req.on('error', () => finish(null))
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Host half: publish the desktop address. */
|
|
283
|
+
export const inject = ['webServer']
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Register the info route and the index global.
|
|
287
|
+
*
|
|
288
|
+
* The browser half reads `globalThis.__DSHA_DESKTOP_LINK__` synchronously, so
|
|
289
|
+
* the button is usable on first paint. `paired` tells it whether this instance
|
|
290
|
+
* ever paired a device: when it never has, the client mints a token itself
|
|
291
|
+
* through `/api/pair/issue` (same-origin, loopback, so the pairing service
|
|
292
|
+
* answers) instead of opening a URL that would hit the harness 401 fence.
|
|
293
|
+
*
|
|
294
|
+
* `config` is read per request rather than captured once: the settings form
|
|
295
|
+
* writes through the Host's config layer, and a request that arrived after such
|
|
296
|
+
* a write must see the new value without a restart.
|
|
297
|
+
* @param ctx - host plugin context carrying webServer.
|
|
298
|
+
* @param config - the row's live config (`{ desktopUrl?, launchUrl? }`).
|
|
299
|
+
*/
|
|
300
|
+
export function apply(ctx, config) {
|
|
301
|
+
/**
|
|
302
|
+
* A Cordis config value may be a getter-backed proxy that throws when a field
|
|
303
|
+
* is read outside the loader's own evaluation, so every read is guarded: a
|
|
304
|
+
* failing field is treated as absent, and the chain simply continues.
|
|
305
|
+
*/
|
|
306
|
+
const field = (name) => {
|
|
307
|
+
try {
|
|
308
|
+
const source = typeof config === 'function' ? config() : config
|
|
309
|
+
const value = source?.[name]
|
|
310
|
+
return typeof value === 'string' ? value : ''
|
|
311
|
+
} catch {
|
|
312
|
+
return ''
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const live = () => resolveUrls({ desktopUrl: field('desktopUrl'), launchUrl: field('launchUrl') })
|
|
316
|
+
|
|
317
|
+
const disposeGlobal = ctx.on('webserver/index-inject', (table) => {
|
|
318
|
+
table.push({ kind: 'global', name: '__DSHA_DESKTOP_LINK__', value: live() })
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const info = ctx.webServer.register({
|
|
322
|
+
kind: 'exact',
|
|
323
|
+
path: '/api/desktop-link/info',
|
|
324
|
+
handler(_req, res) {
|
|
325
|
+
// Re-read per request: editing desktop-url.txt (or saving the settings
|
|
326
|
+
// form) takes effect on the next click without restarting the profile.
|
|
327
|
+
// Never let a resolve failure answer with a bare 400 — the button reads
|
|
328
|
+
// this and a crash here looks exactly like "the plugin is gone".
|
|
329
|
+
let payload
|
|
330
|
+
try {
|
|
331
|
+
payload = { ok: true, ...live() }
|
|
332
|
+
} catch (error) {
|
|
333
|
+
payload = { ok: false, error: String(error?.message ?? error) }
|
|
334
|
+
}
|
|
335
|
+
const body = JSON.stringify(payload)
|
|
336
|
+
res.writeHead(200, {
|
|
337
|
+
'content-type': 'application/json; charset=utf-8',
|
|
338
|
+
'content-length': Buffer.byteLength(body),
|
|
339
|
+
'cache-control': 'no-store',
|
|
340
|
+
})
|
|
341
|
+
res.end(body)
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Save the two addresses.
|
|
347
|
+
*
|
|
348
|
+
* This is the form a phone can actually reach: DSH renders a settings form
|
|
349
|
+
* only for entries it considers editable, and an installed bundle's own row
|
|
350
|
+
* is not always one of them. The route writes the same file the file-based
|
|
351
|
+
* chain reads, so both entry points converge on one source of truth.
|
|
352
|
+
*
|
|
353
|
+
* POST /api/desktop-link/config { desktopUrl?, launchUrl? }
|
|
354
|
+
*/
|
|
355
|
+
const save = ctx.webServer.register({
|
|
356
|
+
kind: 'exact',
|
|
357
|
+
path: '/api/desktop-link/config',
|
|
358
|
+
handler(req, res) {
|
|
359
|
+
const reply = (status, payload) => {
|
|
360
|
+
const body = JSON.stringify(payload)
|
|
361
|
+
res.writeHead(status, {
|
|
362
|
+
'content-type': 'application/json; charset=utf-8',
|
|
363
|
+
'content-length': Buffer.byteLength(body),
|
|
364
|
+
'cache-control': 'no-store',
|
|
365
|
+
})
|
|
366
|
+
res.end(body)
|
|
367
|
+
}
|
|
368
|
+
if (req.method !== 'POST') return reply(405, { ok: false, error: 'POST only' })
|
|
369
|
+
readJsonBody(req).then((body) => {
|
|
370
|
+
if (!body) return reply(400, { ok: false, error: 'invalid JSON body' })
|
|
371
|
+
const desktopUrl = cleanUrl(body.desktopUrl)
|
|
372
|
+
const launchUrl = cleanUrl(body.launchUrl)
|
|
373
|
+
// A supplied non-empty value that fails validation is an error, not a
|
|
374
|
+
// silent "unset": the user typed something and deserves to know it was
|
|
375
|
+
// rejected instead of watching the button stay grey.
|
|
376
|
+
if (body.desktopUrl && !desktopUrl) {
|
|
377
|
+
return reply(400, { ok: false, error: 'desktopUrl must be an http(s) URL' })
|
|
378
|
+
}
|
|
379
|
+
if (body.launchUrl && !launchUrl) {
|
|
380
|
+
return reply(400, { ok: false, error: 'launchUrl must be an http(s) URL' })
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const path = writeUrlFile(desktopUrl, launchUrl)
|
|
384
|
+
return reply(200, { ok: true, path, ...live() })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return reply(500, { ok: false, error: String(error?.message ?? error) })
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
ctx.effect(() => () => {
|
|
393
|
+
if (typeof disposeGlobal === 'function') disposeGlobal()
|
|
394
|
+
if (typeof info === 'function') info()
|
|
395
|
+
if (typeof save === 'function') save()
|
|
396
|
+
}, 'dsha-desktop-link: info + config routes + index global')
|
|
397
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-desktop-link",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-desktop-link",
|
|
3
|
+
"version": "1.2.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
|
+
"assets"
|
|
20
|
+
],
|
|
21
|
+
"dsh": {
|
|
22
|
+
"engines": {
|
|
23
|
+
"dsh": ">=0.2.1-rc.2"
|
|
24
|
+
},
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"immediately": true,
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "lzlrt",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"dsh",
|
|
40
|
+
"dsh-plugin",
|
|
41
|
+
"deepseek",
|
|
42
|
+
"deepseek-harness",
|
|
43
|
+
"dsha",
|
|
44
|
+
"remote",
|
|
45
|
+
"sidebar",
|
|
46
|
+
"mobile"
|
|
47
|
+
],
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/lzlrt/dsha-desktop-link.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/lzlrt/dsha-desktop-link#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/lzlrt/dsha-desktop-link/issues"
|
|
55
|
+
},
|
|
56
|
+
"description": "DSH sidebar button: open the desktop DSH Web GUI from DSHA on your phone, in one tap.",
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
59
|
+
}
|
|
6
60
|
}
|