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 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
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsha-desktop-link
3
+ name: 'dsh-desktop-link'
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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Desktop DSH shortcut",
4
+ "description": "Adds one sidebar-foot button that opens the desktop DSH Web GUI in the system browser or a new tab."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "桌面版 DSH 快捷入口",
4
+ "description": "在侧栏底部加一个按钮:点一下就在系统浏览器 / 新标签页里打开桌面版 DSH 的 Web GUI。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
- {
2
- "name": "dsh-desktop-link",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }