@feiyang666/dsh-mobile-remote 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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/README.zh.md +117 -0
- package/cordis.patch.yml +19 -0
- package/lib/client.js +503 -0
- package/lib/index.js +221 -0
- package/package.json +65 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2026-08-19
|
|
4
|
+
|
|
5
|
+
### 新增(Added)
|
|
6
|
+
|
|
7
|
+
- 初始版本:手机端远程控制 DeepSeek Harness。
|
|
8
|
+
- Host 半:`/api/dsh-mobile-remote` JSON API(status / ping)、局域网 IPv4 枚举、`mobile_remote_status` 模型工具。
|
|
9
|
+
- Client 半:
|
|
10
|
+
- 设置页「远程控制」:开关状态、监听地址/端口、会话数、手机访问地址(一键复制)、二维码、连接测试、使用说明。
|
|
11
|
+
- 移动端界面适配(≤768px):单列布局、侧栏抽屉化(滑入滑出 + 遮罩)、触控优化、iOS 安全区。
|
|
12
|
+
- 浮动控制条(shell.overlay):会话列表抽屉开关 + 远程连接状态指示,每 15 秒轮询状态。
|
|
13
|
+
|
|
14
|
+
[1.0.0]: https://github.com/feiyang-dev/dsh-mobile-remote/releases/tag/v1.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 feiyang-dev
|
|
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
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# dsh-mobile-remote 📱
|
|
4
|
+
|
|
5
|
+
[简体中文](./README.zh.md) | **English**
|
|
6
|
+
|
|
7
|
+
[MIT License](./LICENSE)
|
|
8
|
+
|
|
9
|
+
**A plugin for DeepSeek Harness that lets your phone sync with and remotely control your desktop**, with full two-way synchronization of conversations, session lists and settings, plus mobile-optimized UI.
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Overview
|
|
16
|
+
|
|
17
|
+
`dsh-mobile-remote` builds on the built-in **「移动端远程控制」 (mobile remote control)** switch of the DeepSeek Harness desktop app (`dsh-desktop`). When enabled, the dsh web service listens on all interfaces (`0.0.0.0`), so any phone on the same Wi-Fi can open the desktop's LAN address in a browser and use **the very same Web UI**.
|
|
18
|
+
|
|
19
|
+
Because the phone and the desktop access the same session data:
|
|
20
|
+
|
|
21
|
+
- **Real-time two-way sync**: conversations, session list, settings — everything stays consistent. An action on either side shows up on the other instantly;
|
|
22
|
+
- **Full control from your phone**: send messages, switch sessions, inspect details, manage settings — everything the desktop can do, the phone can do;
|
|
23
|
+
- **Mobile UI adaptation**: this plugin optimizes touch targets and layout for phone screens.
|
|
24
|
+
|
|
25
|
+
npm package: `@feiyang666/dsh-mobile-remote` (same series as `@feiyang666/dsh-usage-plugin` and `@feiyang666/dsh-vault`).
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
| Capability | Description |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **Remote-control settings page** | A new「远程控制」page in the Web settings: live switch state, bind host/port, session count, LAN IPv4 addresses, one-tap copy of the phone URL, QR code, connection test and usage guide |
|
|
32
|
+
| **Mobile UI adaptation** | On screens ≤768px: three-column layout collapses to a single column, the sidebar becomes a slide-in drawer with backdrop, touch targets are enlarged, double-tap zoom is disabled, iOS safe-area is respected |
|
|
33
|
+
| **Floating control dock** | Top-right floating buttons on phones: `☰` opens the session-list drawer anytime; the status dot shows connection state (green = connected over LAN, yellow = enabled but accessing locally, gray = off). Tap it to view and copy the phone address |
|
|
34
|
+
| **Status polling** | The dock queries the host every 15 seconds to keep sessions, addresses and connection state fresh |
|
|
35
|
+
| **Model tool** | Registers `mobile_remote_status` so the model can query remote-control status and phone URLs directly |
|
|
36
|
+
| **Host JSON API** | `POST /api/dsh-mobile-remote`: `status` (full snapshot) / `ping` (heartbeat) |
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
┌─────────────┐ same Wi-Fi ┌──────────────────────────┐
|
|
42
|
+
│ Phone browser │ ──────────▶ │ dsh web (desktop, :3080) │
|
|
43
|
+
│ (mobile UI) │ http://LAN │ - the same Web UI │
|
|
44
|
+
│ ☰ drawer/FAB │ ◀────────── │ - the same session data │
|
|
45
|
+
└─────────────┘ two-way sync └──────────────────────────┘
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- The phone and the desktop access the **same web app and the same `~/.dsh` session data**, so synchronization is inherent and real-time (no intermediate layer, no copying);
|
|
49
|
+
- The dsh-desktop switch makes the service listen on `0.0.0.0` via an overlay config (only the listen address changes; the desktop itself still uses `127.0.0.1`);
|
|
50
|
+
- This plugin adds what is missing: mobile UI adaptation, a setup/guide settings page, and connection-status indicators.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
### Option 1: dsh-desktop (recommended)
|
|
55
|
+
|
|
56
|
+
Install [DeepSeek Harness 桌面版](https://github.com/feiyang-dev/DeepSeek-Harness-Desktop), open **「安装插件」→ recommended plugins → 手机端远程控制 → 一键安装**, then click **「立即重启服务」**.
|
|
57
|
+
|
|
58
|
+
### Option 2: command line
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Requires dsh (npm install -g @deepseek-ai/dsh)
|
|
62
|
+
dsh plugin --profile web add @feiyang666/dsh-mobile-remote
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
1. **Enable remote control**: open dsh-desktop settings, turn on **「移动端远程控制」**, restart the service;
|
|
68
|
+
2. **Get the phone URL**: open the Web UI → Settings → **「远程控制」**, copy any「手机访问地址」or scan the QR code;
|
|
69
|
+
3. **Open on your phone**: make sure the phone and desktop are on the **same Wi-Fi**, then open the address in the phone browser;
|
|
70
|
+
4. **Start controlling**: the phone shows the exact same interface as the desktop. Use `☰` anytime to open the session drawer; tap the status dot to check the connection.
|
|
71
|
+
|
|
72
|
+
> Phone URLs look like `http://192.168.x.x:3080`. If the page says「not enabled」, make sure the dsh-desktop switch is on and the service has been restarted.
|
|
73
|
+
|
|
74
|
+
## API reference
|
|
75
|
+
|
|
76
|
+
`POST /api/dsh-mobile-remote` with body `{ "action": "status" | "ping" }`.
|
|
77
|
+
|
|
78
|
+
### status
|
|
79
|
+
|
|
80
|
+
| Field | Type | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `enabled` | boolean | Whether remote control is active (listening on a non-loopback address) |
|
|
83
|
+
| `host` | string | Actual bind host (e.g. `0.0.0.0`) |
|
|
84
|
+
| `port` | number | Actual listen port (e.g. `3080`) |
|
|
85
|
+
| `lanAddresses` | string[] | LAN IPv4 addresses of the desktop |
|
|
86
|
+
| `urls` | string[] | Phone-accessible URLs (`http://<ip>:<port>`) |
|
|
87
|
+
| `sessionCount` | number | Current session count |
|
|
88
|
+
| `serverTime` / `serverTimeIso` | number / string | Server time (clock sync / heartbeat) |
|
|
89
|
+
| `pid` / `workspace` | number / string | Process PID and working directory |
|
|
90
|
+
|
|
91
|
+
### ping
|
|
92
|
+
|
|
93
|
+
Returns `{ ok: true, serverTime, serverTimeIso }` for link probing.
|
|
94
|
+
|
|
95
|
+
## FAQ
|
|
96
|
+
|
|
97
|
+
**Q: The phone cannot open the desktop address?**
|
|
98
|
+
|
|
99
|
+
A: Make sure ① the phone and desktop are on the same Wi-Fi/LAN segment; ② the dsh-desktop「移动端远程控制」switch is on and the service has **been restarted**; ③ the firewall allows the dsh port.
|
|
100
|
+
|
|
101
|
+
**Q: Does the phone need a dedicated app?**
|
|
102
|
+
|
|
103
|
+
A: No. Any mobile browser works, nothing to install.
|
|
104
|
+
|
|
105
|
+
**Q: Does enabling remote control affect desktop usage?**
|
|
106
|
+
|
|
107
|
+
A: No. dsh-desktop's health checks, main window and logs still use `127.0.0.1`; only the LAN listening is added.
|
|
108
|
+
|
|
109
|
+
**Q: Is the phone data the same as the desktop?**
|
|
110
|
+
|
|
111
|
+
A: Yes, and it stays in sync in real time. The phone and desktop share one service and one set of session data, with no intermediate sync layer.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
[MIT](./LICENSE)
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 手机端远程控制(dsh-mobile-remote)📱
|
|
4
|
+
|
|
5
|
+
[English](./README.md) | **简体中文**
|
|
6
|
+
|
|
7
|
+
[MIT License](./LICENSE)
|
|
8
|
+
|
|
9
|
+
**让手机实时同步并远程操控电脑端 DeepSeek Harness 的官方扩展插件** —— 对话记录、会话列表、设置全部双向同步,手机上直接发消息,并对手机屏幕做移动端界面适配。
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 简介
|
|
16
|
+
|
|
17
|
+
`dsh-mobile-remote` 利用 DeepSeek Harness 桌面端(dsh-desktop)内置的「移动端远程控制」能力:开启该开关后,dsh web 服务会监听局域网(`0.0.0.0`),手机与电脑处于同一 Wi-Fi 时,用手机浏览器打开电脑的局域网地址即可访问 **同一个 Web UI**。
|
|
18
|
+
|
|
19
|
+
因为手机和电脑访问的是同一份会话数据,所以:
|
|
20
|
+
|
|
21
|
+
- **实时双向同步**:对话记录、会话列表、设置项等所有信息天然一致,任何一端的操作都会即时反映到另一端;
|
|
22
|
+
- **手机端完整操控**:手机上直接发送消息、切换会话、查看详情、管理设置……电脑端能做的一切都能做;
|
|
23
|
+
- **移动端界面适配**:本插件针对手机屏幕优化触控与布局,让 Web UI 在手机上流畅可用。
|
|
24
|
+
|
|
25
|
+
npm 包名:`@feiyang666/dsh-mobile-remote`(同项目系列:`@feiyang666/dsh-usage-plugin` 用量插件、`@feiyang666/dsh-vault` 数据保险箱)。
|
|
26
|
+
|
|
27
|
+
## 功能
|
|
28
|
+
|
|
29
|
+
| 能力 | 说明 |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **远程控制设置页** | Web 端设置页新增「远程控制」页面:实时显示开关状态、监听地址/端口、会话数、本机局域网 IPv4 地址、手机访问 URL(一键复制)、扫码二维码、连接测试、使用说明 |
|
|
32
|
+
| **移动端界面适配** | 手机屏幕(≤768px)自动生效:三列布局收敛为单列、侧栏变成滑出抽屉(带遮罩)、触控目标放大、禁止双击缩放、iOS 安全区适配 |
|
|
33
|
+
| **浮动控制条** | 手机端右上角悬浮按钮:`☰` 随时打开会话列表抽屉;状态圆点指示远程连接状态(绿=已通过局域网连接、黄=已开启但本机访问、灰=未开启),点击可查看手机访问地址并复制 |
|
|
34
|
+
| **实时状态轮询** | 浮动控制条每 15 秒向 Host 查询一次状态,会话数、监听地址、连接状态保持最新 |
|
|
35
|
+
| **模型工具** | 注册 `mobile_remote_status` 工具,模型可直接查询远程控制状态与手机访问链接 |
|
|
36
|
+
| **Host JSON API** | `POST /api/dsh-mobile-remote`:`status`(完整状态快照)/ `ping`(心跳),供 UI 与扩展调用 |
|
|
37
|
+
|
|
38
|
+
## 工作原理
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
┌─────────────┐ 同 Wi-Fi ┌──────────────────────────┐
|
|
42
|
+
│ 手机浏览器 │ ──────────▶ │ dsh web (电脑, :3080) │
|
|
43
|
+
│ (移动端适配) │ http://LAN │ - 同一个 Web UI │
|
|
44
|
+
│ ☰ 抽屉/FAB │ ◀────────── │ - 同一份会话数据(实时同步) │
|
|
45
|
+
└─────────────┘ 双向同步 └──────────────────────────┘
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- 手机与电脑访问的是**同一个 Web 应用、同一份 `~/.dsh` 会话数据**,因此「同步」是天然且实时的(无中间层、无二次复制);
|
|
49
|
+
- dsh-desktop 的「移动端远程控制」开关通过 overlay 配置让服务监听 `0.0.0.0`(仅影响监听地址,不影响桌面端自身);
|
|
50
|
+
- 本插件在此基础上补齐:移动端 UI 适配、远程控制引导设置页、连接状态指示与巡检。
|
|
51
|
+
|
|
52
|
+
## 推荐安装方式
|
|
53
|
+
|
|
54
|
+
> 两个方法任选其一,效果等价。**推荐使用桌面端**,全程图形化、无需命令行。
|
|
55
|
+
|
|
56
|
+
### 方式一:桌面端一键安装
|
|
57
|
+
|
|
58
|
+
安装 [DeepSeek Harness 桌面版](https://github.com/feiyang-dev/DeepSeek-Harness-Desktop),打开后点击 **「安装插件」→ 推荐插件 → 手机端远程控制 → 一键安装**,完成后点 **「立即重启服务」** 即可生效。
|
|
59
|
+
|
|
60
|
+
### 方式二:命令行安装
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 前提:已安装 dsh(npm install -g @deepseek-ai/dsh)
|
|
64
|
+
dsh plugin --profile web add @feiyang666/dsh-mobile-remote
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 使用方法
|
|
68
|
+
|
|
69
|
+
1. **开启远程控制**:打开 dsh-desktop 设置页,开启 **「移动端远程控制」** 开关,重启服务;
|
|
70
|
+
2. **获取手机访问地址**:打开 Web UI → 设置 → **「远程控制」** 页面,复制任一「手机访问地址」(或扫二维码);
|
|
71
|
+
3. **手机访问**:确保手机与电脑连接**同一个 Wi-Fi**,用手机浏览器打开该地址;
|
|
72
|
+
4. **开始远程操控**:手机上即可看到与电脑端完全一致的界面并直接操控。右上角 `☰` 可随时打开会话列表抽屉,状态圆点可查看连接状态。
|
|
73
|
+
|
|
74
|
+
> 提示:手机访问地址形如 `http://192.168.x.x:3080`。若页面提示「未开启」,请确认 dsh-desktop 的「移动端远程控制」开关已打开并重启了服务。
|
|
75
|
+
|
|
76
|
+
## API 参考
|
|
77
|
+
|
|
78
|
+
`POST /api/dsh-mobile-remote`,请求体为 JSON:`{ "action": "status" | "ping" }`。
|
|
79
|
+
|
|
80
|
+
### status
|
|
81
|
+
|
|
82
|
+
| 字段 | 类型 | 说明 |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `enabled` | boolean | 远程控制是否生效(服务监听非回环地址) |
|
|
85
|
+
| `host` | string | 实际监听地址(如 `0.0.0.0`) |
|
|
86
|
+
| `port` | number | 实际监听端口(如 `3080`) |
|
|
87
|
+
| `lanAddresses` | string[] | 本机局域网 IPv4 地址列表 |
|
|
88
|
+
| `urls` | string[] | 手机可访问 URL 列表(`http://<ip>:<port>`) |
|
|
89
|
+
| `sessionCount` | number | 当前会话数 |
|
|
90
|
+
| `serverTime` / `serverTimeIso` | number / string | 服务器时间(心跳/对时用) |
|
|
91
|
+
| `pid` / `workspace` | number / string | 进程 PID 与工作目录 |
|
|
92
|
+
|
|
93
|
+
### ping
|
|
94
|
+
|
|
95
|
+
返回 `{ ok: true, serverTime, serverTimeIso }`,用于连接链路探测。
|
|
96
|
+
|
|
97
|
+
## 常见问题
|
|
98
|
+
|
|
99
|
+
**Q:手机打不开电脑地址?**
|
|
100
|
+
|
|
101
|
+
A:确认 ① 手机与电脑在同一个 Wi-Fi/网段;② dsh-desktop「移动端远程控制」已开启并**重启过服务**;③ 电脑防火墙放行了 dsh 监听端口。
|
|
102
|
+
|
|
103
|
+
**Q:本插件是否需要手机端单独安装 App?**
|
|
104
|
+
|
|
105
|
+
A:不需要。手机浏览器即可,无需任何安装。
|
|
106
|
+
|
|
107
|
+
**Q:在电脑上开启远程控制是否影响桌面端使用?**
|
|
108
|
+
|
|
109
|
+
A:不影响。dsh-desktop 自身的健康检查、主窗口、日志仍走 `127.0.0.1`,仅新增局域网监听能力。
|
|
110
|
+
|
|
111
|
+
**Q:手机上的数据与电脑端一致吗?**
|
|
112
|
+
|
|
113
|
+
A:完全一致且实时。手机与电脑访问同一个服务、同一份会话数据,不经过任何中间同步层。
|
|
114
|
+
|
|
115
|
+
## 许可证
|
|
116
|
+
|
|
117
|
+
[MIT](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# dsh-mobile-remote bundle patch: 手机端远程控制插件。
|
|
2
|
+
# 在 dsh-base / dsh-web-app 基础层之后挂载。
|
|
3
|
+
#
|
|
4
|
+
# `inject` 列表为必需项:让 Cordis loader 等待 webServer / tools /
|
|
5
|
+
# sessionPersistence 服务就绪后再激活插件。否则 apply 可能早于服务
|
|
6
|
+
# 创建就运行,/api/dsh-mobile-remote 路由注册不上,设置页会报 not found。
|
|
7
|
+
#
|
|
8
|
+
# Host 半提供 /api/dsh-mobile-remote JSON API(状态 / 心跳)与
|
|
9
|
+
# mobile_remote_status 模型工具;浏览器半通过 exports["./client"] 与
|
|
10
|
+
# package.json 的 dsh.client 声明被 dsh-client-modules 自动发现,注入
|
|
11
|
+
# 「远程控制」设置页与移动端浮动控制条。
|
|
12
|
+
|
|
13
|
+
- insert:
|
|
14
|
+
- id: dsh-mobile-remote
|
|
15
|
+
name: '@feiyang666/dsh-mobile-remote'
|
|
16
|
+
inject:
|
|
17
|
+
- webServer
|
|
18
|
+
- tools
|
|
19
|
+
- sessionPersistence
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// dsh-mobile-remote — 手机端远程控制 DeepSeek Harness(Client 半)
|
|
2
|
+
// 职责:
|
|
3
|
+
// 1. 移动端 UI 适配:窄屏强制单列布局、侧栏抽屉化(滑入滑出 + 遮罩)、
|
|
4
|
+
// 触控目标优化(仅作用于 ≤768px 的移动端,桌面端完全不受影响)。
|
|
5
|
+
// 2. shell.overlay「浮动控制条」:会话列表开关 + 远程连接状态指示(每 15s
|
|
6
|
+
// 向 /api/dsh-mobile-remote 轮询状态)。
|
|
7
|
+
// 3. settings.section「远程控制」设置页:展示开关状态、局域网访问地址、
|
|
8
|
+
// 二维码、连接测试与使用说明。
|
|
9
|
+
// 格式遵循 dsh client 插件规范(window.__ModuleLoader__.load + exports.apply)。
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: '@feiyang666/dsh-mobile-remote',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} }
|
|
15
|
+
var exports = module.exports
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
17
|
+
var React = require('react')
|
|
18
|
+
var el = React.createElement
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------
|
|
21
|
+
// 移动端适配样式:只作用于 ≤768px 的窄屏(手机),桌面端不受影响。
|
|
22
|
+
// 用属性选择器定位 AppFrame(frame 是 [data-shell-overlay] 的父元素),
|
|
23
|
+
// 不依赖任何 CSS-module 哈希类名。
|
|
24
|
+
// ---------------------------------------------------------------
|
|
25
|
+
var MOBILE_CSS = [
|
|
26
|
+
'@media (max-width: 768px) {',
|
|
27
|
+
'/* 1) 三列 → 单列:隐藏侧栏 rail 与 details 列 */',
|
|
28
|
+
'div:has(> [data-shell-overlay]) {',
|
|
29
|
+
' grid-template-columns: 0px minmax(0, 1fr) 0px !important;',
|
|
30
|
+
'}',
|
|
31
|
+
'/* 2) 侧栏改为抽屉:默认滑出屏幕,打开时滑入(JS 维护 data-mr-open) */',
|
|
32
|
+
'div:has(> [data-shell-overlay]) > div:first-child {',
|
|
33
|
+
' position: fixed !important;',
|
|
34
|
+
' top: 0 !important;',
|
|
35
|
+
' left: 0 !important;',
|
|
36
|
+
' bottom: 0 !important;',
|
|
37
|
+
' height: 100% !important;',
|
|
38
|
+
' height: 100dvh !important;',
|
|
39
|
+
' width: min(84vw, 280px) !important;',
|
|
40
|
+
' margin: 0 !important;',
|
|
41
|
+
' z-index: 120 !important;',
|
|
42
|
+
' background: var(--dsw-alias-bg-base, #ffffff) !important;',
|
|
43
|
+
' border-right: 1px solid rgba(128,128,128,.2) !important;',
|
|
44
|
+
' box-shadow: 0 0 36px rgba(0,0,0,.35) !important;',
|
|
45
|
+
' transform: translateX(-106%);',
|
|
46
|
+
' transition: transform .22s ease !important;',
|
|
47
|
+
' -webkit-overflow-scrolling: touch;',
|
|
48
|
+
'}',
|
|
49
|
+
'div:has(> [data-shell-overlay]) > div:first-child[data-mr-open="true"] {',
|
|
50
|
+
' transform: translateX(0) !important;',
|
|
51
|
+
'}',
|
|
52
|
+
'/* 3) 抽屉遮罩(JS 创建) */',
|
|
53
|
+
'body > [data-mr-mask] {',
|
|
54
|
+
' position: fixed !important;',
|
|
55
|
+
' inset: 0 !important;',
|
|
56
|
+
' z-index: 110 !important;',
|
|
57
|
+
' background: rgba(0,0,0,.35) !important;',
|
|
58
|
+
'}',
|
|
59
|
+
'/* 4) 触控优化 */',
|
|
60
|
+
'button, [role="button"], input, select, textarea {',
|
|
61
|
+
' touch-action: manipulation;',
|
|
62
|
+
' -webkit-tap-highlight-color: transparent;',
|
|
63
|
+
'}',
|
|
64
|
+
'body { -webkit-text-size-adjust: 100%; }',
|
|
65
|
+
'}'
|
|
66
|
+
].join('\n')
|
|
67
|
+
|
|
68
|
+
;(function injectCss() {
|
|
69
|
+
if (typeof document === 'undefined') return
|
|
70
|
+
var existing = document.querySelector('style[data-plugin-css="dsh-mobile-remote/mobile"]')
|
|
71
|
+
if (existing) return
|
|
72
|
+
var tag = document.createElement('style')
|
|
73
|
+
tag.setAttribute('data-plugin', 'dsh-mobile-remote')
|
|
74
|
+
tag.setAttribute('data-plugin-css', 'dsh-mobile-remote/mobile')
|
|
75
|
+
tag.textContent = MOBILE_CSS
|
|
76
|
+
document.head.appendChild(tag)
|
|
77
|
+
})()
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------
|
|
80
|
+
// 工具函数
|
|
81
|
+
// ---------------------------------------------------------------
|
|
82
|
+
var API_PATH = '/api/dsh-mobile-remote'
|
|
83
|
+
var NS = 'dsh-mobile-remote'
|
|
84
|
+
|
|
85
|
+
function callRemote(args) {
|
|
86
|
+
return fetch(API_PATH, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify(args || {}),
|
|
90
|
+
}).then(function (r) { return r.json() })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isMobileViewport() {
|
|
94
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
|
95
|
+
var w = window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0
|
|
96
|
+
var coarse = window.matchMedia('(pointer: coarse)').matches
|
|
97
|
+
return w <= 768 || (coarse && w <= 1024)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 当前页面是否通过局域网(手机/其他设备)访问,而非本机。 */
|
|
101
|
+
function isRemoteAccess() {
|
|
102
|
+
if (typeof location === 'undefined') return false
|
|
103
|
+
var h = String(location.hostname || '').toLowerCase()
|
|
104
|
+
return !(h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- 复制到剪贴板(含降级) ----
|
|
108
|
+
function copyText(text, cb) {
|
|
109
|
+
function fallback() {
|
|
110
|
+
try {
|
|
111
|
+
var ta = document.createElement('textarea')
|
|
112
|
+
ta.value = text
|
|
113
|
+
ta.style.position = 'fixed'
|
|
114
|
+
ta.style.opacity = '0'
|
|
115
|
+
document.body.appendChild(ta)
|
|
116
|
+
ta.select()
|
|
117
|
+
var ok = document.execCommand('copy')
|
|
118
|
+
document.body.removeChild(ta)
|
|
119
|
+
cb(ok)
|
|
120
|
+
} catch (e) { cb(false) }
|
|
121
|
+
}
|
|
122
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
|
|
123
|
+
navigator.clipboard.writeText(text).then(function () { cb(true) }).catch(fallback)
|
|
124
|
+
} else {
|
|
125
|
+
fallback()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------
|
|
130
|
+
// 抽屉化:监听 frame 的 grid-template-columns,同步抽屉开合与遮罩。
|
|
131
|
+
// 侧栏开关由 AppFrame 的 narrow 模式控制(ctx.layout.toggleSidebar()),
|
|
132
|
+
// 切换后 frame 内联 grid 首列会在 0 ↔ 280px 之间变化,这里据此反映。
|
|
133
|
+
// ---------------------------------------------------------------
|
|
134
|
+
function startDrawer(layout) {
|
|
135
|
+
if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return
|
|
136
|
+
var frame = null
|
|
137
|
+
var sidebar = null
|
|
138
|
+
var mask = null
|
|
139
|
+
var styleObserver = null
|
|
140
|
+
var enabled = false // 仅在移动视口下启用抽屉逻辑(桌面端绝不干预)
|
|
141
|
+
|
|
142
|
+
function ensureMask() {
|
|
143
|
+
if (mask) return
|
|
144
|
+
mask = document.createElement('div')
|
|
145
|
+
mask.setAttribute('data-mr-mask', 'true')
|
|
146
|
+
mask.addEventListener('click', function () {
|
|
147
|
+
if (layout) layout.toggleSidebar()
|
|
148
|
+
})
|
|
149
|
+
document.body.appendChild(mask)
|
|
150
|
+
}
|
|
151
|
+
function removeMask() {
|
|
152
|
+
if (mask) { mask.remove(); mask = null }
|
|
153
|
+
}
|
|
154
|
+
function applyOpen(open) {
|
|
155
|
+
if (!sidebar) return
|
|
156
|
+
if (open) {
|
|
157
|
+
sidebar.setAttribute('data-mr-open', 'true')
|
|
158
|
+
ensureMask()
|
|
159
|
+
} else {
|
|
160
|
+
sidebar.removeAttribute('data-mr-open')
|
|
161
|
+
removeMask()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function syncFromFrame() {
|
|
165
|
+
if (!frame || !enabled) { applyOpen(false); return }
|
|
166
|
+
var cols = String(frame.style.gridTemplateColumns || '').trim()
|
|
167
|
+
var m = /^(-?\d+(?:\.\d+)?)/.exec(cols)
|
|
168
|
+
var first = m ? parseFloat(m[1]) : 0
|
|
169
|
+
applyOpen(first > 0)
|
|
170
|
+
}
|
|
171
|
+
function setup() {
|
|
172
|
+
var overlay = document.querySelector('[data-shell-overlay]')
|
|
173
|
+
if (!overlay) return
|
|
174
|
+
var f = overlay.parentElement
|
|
175
|
+
if (!f || f === frame) return
|
|
176
|
+
frame = f
|
|
177
|
+
sidebar = f.firstElementChild
|
|
178
|
+
if (styleObserver) styleObserver.disconnect()
|
|
179
|
+
styleObserver = new MutationObserver(syncFromFrame)
|
|
180
|
+
styleObserver.observe(frame, { attributes: true, attributeFilter: ['style'] })
|
|
181
|
+
syncFromFrame()
|
|
182
|
+
}
|
|
183
|
+
// 视口变化时切换启用/停用抽屉逻辑
|
|
184
|
+
function syncEnabled() {
|
|
185
|
+
var on = isMobileViewport()
|
|
186
|
+
if (on === enabled) return
|
|
187
|
+
enabled = on
|
|
188
|
+
if (!on) applyOpen(false)
|
|
189
|
+
else syncFromFrame()
|
|
190
|
+
}
|
|
191
|
+
function watchViewport() {
|
|
192
|
+
if (typeof window === 'undefined') return
|
|
193
|
+
var mq = window.matchMedia('(max-width: 768px)')
|
|
194
|
+
var mq2 = window.matchMedia('(pointer: coarse)')
|
|
195
|
+
function update() { syncEnabled() }
|
|
196
|
+
if (mq.addEventListener) mq.addEventListener('change', update)
|
|
197
|
+
if (mq2.addEventListener) mq2.addEventListener('change', update)
|
|
198
|
+
window.addEventListener('resize', update)
|
|
199
|
+
}
|
|
200
|
+
var wait = new MutationObserver(function () { setup() })
|
|
201
|
+
if (document.body) wait.observe(document.body, { childList: true, subtree: true })
|
|
202
|
+
syncEnabled()
|
|
203
|
+
watchViewport()
|
|
204
|
+
setup()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------
|
|
208
|
+
// 浮动控制条(shell.overlay):只在移动端渲染。
|
|
209
|
+
// - ☰ 打开会话列表(侧栏抽屉)
|
|
210
|
+
// - ● 远程控制状态指示(绿=局域网已连,黄=已开启本机访问,灰=未开启)
|
|
211
|
+
// - 点击状态点弹出信息卡:当前访问方式 / 手机地址 / 复制
|
|
212
|
+
// ---------------------------------------------------------------
|
|
213
|
+
var DOCK_STYLE = {
|
|
214
|
+
position: 'fixed',
|
|
215
|
+
top: 'calc(10px + env(safe-area-inset-top, 0px))',
|
|
216
|
+
right: 'calc(10px + env(safe-area-inset-right, 0px))',
|
|
217
|
+
display: 'flex',
|
|
218
|
+
flexDirection: 'column',
|
|
219
|
+
gap: 8,
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
zIndex: 200,
|
|
222
|
+
pointerEvents: 'none',
|
|
223
|
+
}
|
|
224
|
+
var DOCK_BTN = {
|
|
225
|
+
width: 44,
|
|
226
|
+
height: 44,
|
|
227
|
+
borderRadius: 22,
|
|
228
|
+
border: '1px solid rgba(128,128,128,.3)',
|
|
229
|
+
background: 'var(--dsw-alias-bg-base, #ffffff)',
|
|
230
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
231
|
+
fontSize: 20,
|
|
232
|
+
lineHeight: 1,
|
|
233
|
+
display: 'flex',
|
|
234
|
+
alignItems: 'center',
|
|
235
|
+
justifyContent: 'center',
|
|
236
|
+
cursor: 'pointer',
|
|
237
|
+
boxShadow: '0 2px 10px rgba(0,0,0,.18)',
|
|
238
|
+
pointerEvents: 'auto',
|
|
239
|
+
padding: 0,
|
|
240
|
+
boxSizing: 'border-box',
|
|
241
|
+
}
|
|
242
|
+
var INFO_CARD = {
|
|
243
|
+
position: 'absolute',
|
|
244
|
+
top: 104,
|
|
245
|
+
right: 0,
|
|
246
|
+
width: 240,
|
|
247
|
+
background: 'var(--dsw-alias-bg-base, #ffffff)',
|
|
248
|
+
border: '1px solid rgba(128,128,128,.25)',
|
|
249
|
+
borderRadius: 12,
|
|
250
|
+
boxShadow: '0 6px 24px rgba(0,0,0,.2)',
|
|
251
|
+
padding: '10px 12px',
|
|
252
|
+
fontSize: 12,
|
|
253
|
+
color: 'var(--dsw-alias-label-primary, #111111)',
|
|
254
|
+
pointerEvents: 'auto',
|
|
255
|
+
boxSizing: 'border-box',
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
var Dock = function (props) {
|
|
259
|
+
var layout = props && props.layout ? props.layout : null
|
|
260
|
+
var [isMobile, setIsMobile] = React.useState(isMobileViewport())
|
|
261
|
+
var [status, setStatus] = React.useState(null)
|
|
262
|
+
var [infoOpen, setInfoOpen] = React.useState(false)
|
|
263
|
+
var [copied, setCopied] = React.useState(false)
|
|
264
|
+
|
|
265
|
+
React.useEffect(function () {
|
|
266
|
+
function update() { setIsMobile(isMobileViewport()) }
|
|
267
|
+
var mq = window.matchMedia('(max-width: 768px)')
|
|
268
|
+
var mq2 = window.matchMedia('(pointer: coarse)')
|
|
269
|
+
if (mq.addEventListener) mq.addEventListener('change', update)
|
|
270
|
+
if (mq2.addEventListener) mq2.addEventListener('change', update)
|
|
271
|
+
return function () {
|
|
272
|
+
if (mq.removeEventListener) mq.removeEventListener('change', update)
|
|
273
|
+
if (mq2.removeEventListener) mq2.removeEventListener('change', update)
|
|
274
|
+
}
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
277
|
+
React.useEffect(function () {
|
|
278
|
+
if (!isMobile) return
|
|
279
|
+
var timer = setInterval(function () {
|
|
280
|
+
callRemote({ action: 'status' }).then(function (r) { setStatus(r) }).catch(function () { /* ignore */ })
|
|
281
|
+
}, 15000)
|
|
282
|
+
callRemote({ action: 'status' }).then(function (r) { setStatus(r) }).catch(function () { /* ignore */ })
|
|
283
|
+
return function () { clearInterval(timer) }
|
|
284
|
+
}, [isMobile])
|
|
285
|
+
|
|
286
|
+
if (!isMobile) return null
|
|
287
|
+
|
|
288
|
+
var remote = isRemoteAccess()
|
|
289
|
+
var enabled = !!(status && status.enabled)
|
|
290
|
+
var urls = (status && status.urls) || []
|
|
291
|
+
var dotColor = (enabled && remote) ? '#22c55e' : (enabled ? '#eab308' : '#9ca3af')
|
|
292
|
+
var statusTitle = (enabled && remote)
|
|
293
|
+
? '已通过局域网连接电脑'
|
|
294
|
+
: (enabled ? '远程控制已开启(当前为本机访问)' : '远程控制未开启(见设置页「远程控制」)')
|
|
295
|
+
|
|
296
|
+
return el('div', { style: DOCK_STYLE },
|
|
297
|
+
el('button', {
|
|
298
|
+
style: DOCK_BTN,
|
|
299
|
+
onClick: function () { if (layout) layout.toggleSidebar() },
|
|
300
|
+
'aria-label': '打开会话列表',
|
|
301
|
+
title: '会话列表',
|
|
302
|
+
}, '\u2630'),
|
|
303
|
+
el('button', {
|
|
304
|
+
style: DOCK_BTN,
|
|
305
|
+
onClick: function () { setInfoOpen(!infoOpen); setCopied(false) },
|
|
306
|
+
'aria-label': '远程控制状态',
|
|
307
|
+
title: statusTitle,
|
|
308
|
+
}, el('span', { style: { width: 14, height: 14, borderRadius: 7, background: dotColor, display: 'block' } })),
|
|
309
|
+
infoOpen
|
|
310
|
+
? el('div', { style: INFO_CARD },
|
|
311
|
+
el('div', { style: { fontWeight: 600, marginBottom: 6, fontSize: 13 } }, '远程控制'),
|
|
312
|
+
el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, padding: '2px 0' } },
|
|
313
|
+
el('span', { style: { opacity: .65 } }, '连接状态'),
|
|
314
|
+
el('span', {}, (enabled && remote) ? '局域网已连接' : (enabled ? '已开启(本机访问)' : '未开启'))),
|
|
315
|
+
el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 8, padding: '2px 0' } },
|
|
316
|
+
el('span', { style: { opacity: .65 } }, '当前访问'),
|
|
317
|
+
el('span', {}, remote ? '局域网' : '本机')),
|
|
318
|
+
urls.length
|
|
319
|
+
? el('div', { style: { padding: '6px 0 2px', wordBreak: 'break-all', opacity: .85 } }, urls[0])
|
|
320
|
+
: el('div', { style: { padding: '6px 0 2px', opacity: .65 } }, '未检测到局域网地址'),
|
|
321
|
+
el('div', { style: { display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
|
|
322
|
+
urls.length
|
|
323
|
+
? el('button', {
|
|
324
|
+
style: { border: '1px solid rgba(128,128,128,.4)', background: 'transparent', borderRadius: 6, padding: '4px 10px', fontSize: 12, cursor: 'pointer', color: 'inherit' },
|
|
325
|
+
onClick: function () { copyText(urls[0], function (ok) { setCopied(ok) }) },
|
|
326
|
+
}, copied ? '已复制' : '复制地址')
|
|
327
|
+
: null,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
: null,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------
|
|
335
|
+
// 设置页(settings.section):远程控制状态 / 访问地址 / 二维码 / 使用说明
|
|
336
|
+
// ---------------------------------------------------------------
|
|
337
|
+
var BASE_FS = 13
|
|
338
|
+
function fs(n) { return (Math.round(n / BASE_FS * 100) / 100).toFixed(2) + 'em' }
|
|
339
|
+
|
|
340
|
+
var RemoteSection = function () {
|
|
341
|
+
var [status, setStatus] = React.useState(null)
|
|
342
|
+
var [copied, setCopied] = React.useState('')
|
|
343
|
+
var [pinging, setPinging] = React.useState(false)
|
|
344
|
+
var [pingResult, setPingResult] = React.useState('')
|
|
345
|
+
|
|
346
|
+
function refresh() {
|
|
347
|
+
callRemote({ action: 'status' }).then(setStatus).catch(function (e) { setStatus({ error: String(e) }) })
|
|
348
|
+
}
|
|
349
|
+
React.useEffect(function () { refresh() }, [])
|
|
350
|
+
|
|
351
|
+
function doPing() {
|
|
352
|
+
setPinging(true)
|
|
353
|
+
setPingResult('')
|
|
354
|
+
callRemote({ action: 'ping' }).then(function (r) {
|
|
355
|
+
if (r && r.ok) {
|
|
356
|
+
var t = r.serverTimeIso ? new Date(r.serverTimeIso).toLocaleTimeString() : String(r.serverTime)
|
|
357
|
+
setPingResult('✓ 连接正常(服务器时间 ' + t + ')')
|
|
358
|
+
} else {
|
|
359
|
+
setPingResult('✗ ' + ((r && r.error) || '无响应'))
|
|
360
|
+
}
|
|
361
|
+
}).catch(function (e) { setPingResult('✗ ' + String(e)) }).finally(function () { setPinging(false) })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
var err = status && status.error
|
|
365
|
+
var enabled = !!(status && status.enabled)
|
|
366
|
+
var host = status && status.host
|
|
367
|
+
var port = status && status.port
|
|
368
|
+
var lanAddresses = (status && status.lanAddresses) || []
|
|
369
|
+
var urls = (status && status.urls) || []
|
|
370
|
+
var sessionCount = status && typeof status.sessionCount === 'number' ? status.sessionCount : null
|
|
371
|
+
var qrUrl = urls.length
|
|
372
|
+
? ('https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' + encodeURIComponent(urls[0]))
|
|
373
|
+
: ''
|
|
374
|
+
|
|
375
|
+
var st = {
|
|
376
|
+
root: { display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 0', width: '100%', minWidth: 0, boxSizing: 'border-box' },
|
|
377
|
+
title: { fontWeight: 600, fontSize: fs(15) },
|
|
378
|
+
sub: { fontSize: fs(11), opacity: .55 },
|
|
379
|
+
card: { border: '1px solid rgba(128,128,128,.25)', borderRadius: 10, padding: '12px 14px', minWidth: 0 },
|
|
380
|
+
row: { display: 'flex', justifyContent: 'space-between', gap: 10, padding: '2px 0', fontSize: fs(12), flexWrap: 'wrap' },
|
|
381
|
+
rowKey: { opacity: .65 },
|
|
382
|
+
statusLine: { padding: '9px 11px', borderRadius: 8, fontSize: fs(12), fontWeight: 600 },
|
|
383
|
+
urlRow: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', flexWrap: 'wrap' },
|
|
384
|
+
urlText: { fontSize: fs(12), wordBreak: 'break-all', flex: '1 1 180px', minWidth: 0 },
|
|
385
|
+
btn: { border: '1px solid rgba(128,128,128,.4)', background: 'transparent', borderRadius: 6, padding: '5px 12px', fontSize: fs(12), cursor: 'pointer', color: 'inherit', whiteSpace: 'nowrap' },
|
|
386
|
+
btnPrimary: { border: '1px solid rgba(90,140,255,.6)', background: 'rgba(90,140,255,.22)', borderRadius: 6, padding: '5px 12px', fontSize: fs(12), cursor: 'pointer', color: 'inherit', fontWeight: 600 },
|
|
387
|
+
section: { fontWeight: 600, fontSize: fs(13), marginTop: 4 },
|
|
388
|
+
qr: { display: 'flex', justifyContent: 'center', margin: '8px 0' },
|
|
389
|
+
qrImg: { width: 220, height: 220, borderRadius: 8, border: '1px solid rgba(128,128,128,.2)' },
|
|
390
|
+
qrFallback: { fontSize: fs(11), opacity: .6, textAlign: 'center', padding: '12px 0' },
|
|
391
|
+
msg: { fontSize: fs(11), opacity: .8, wordBreak: 'break-all' },
|
|
392
|
+
note: { fontSize: fs(11), opacity: .55, lineHeight: 1.6 },
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return el('div', { style: st.root },
|
|
396
|
+
el('div', { style: st.title }, '手机端远程控制'),
|
|
397
|
+
el('div', { style: st.sub }, '手机与电脑连接同一 Wi-Fi 时,用手机浏览器打开下方地址,即可实时同步对话记录、会话列表与设置,并直接发送消息远程操控电脑端 DeepSeek Harness。'),
|
|
398
|
+
|
|
399
|
+
err
|
|
400
|
+
? el('div', { style: st.msg }, '状态获取失败:' + err)
|
|
401
|
+
: el(React.Fragment, null,
|
|
402
|
+
// 状态卡片
|
|
403
|
+
el('div', { style: st.card },
|
|
404
|
+
el('div', { style: st.statusLine, background: enabled ? 'rgba(34,197,94,.16)' : 'rgba(156,163,175,.14)', color: enabled ? '#16a34a' : 'inherit', borderRadius: 8 },
|
|
405
|
+
enabled ? '✓ 远程控制已开启:服务正在监听局域网' : '○ 远程控制未开启:当前仅本机可访问'),
|
|
406
|
+
el('div', { style: { height: 8 } }),
|
|
407
|
+
el('div', { style: st.row }, el('span', { style: st.rowKey }, '监听地址'), el('span', {}, host + ':' + port)),
|
|
408
|
+
el('div', { style: st.row }, el('span', { style: st.rowKey }, '会话数'), el('span', {}, sessionCount === null ? '…' : String(sessionCount))),
|
|
409
|
+
el('div', { style: st.row }, el('span', { style: st.rowKey }, '本机访问'), el('span', {}, 'http://127.0.0.1:' + port)),
|
|
410
|
+
),
|
|
411
|
+
// 手机访问地址
|
|
412
|
+
el('div', { style: st.section }, '手机访问地址'),
|
|
413
|
+
el('div', { style: st.card },
|
|
414
|
+
lanAddresses.length === 0
|
|
415
|
+
? el('div', { style: st.msg }, '未检测到局域网 IPv4 地址。请检查电脑是否连接 Wi-Fi / 有线网络,或在 dsh-desktop 中重新开启「移动端远程控制」。')
|
|
416
|
+
: urls.map(function (u) {
|
|
417
|
+
return el('div', { key: u, style: st.urlRow },
|
|
418
|
+
el('span', { style: st.urlText }, u),
|
|
419
|
+
el('button', { style: st.btn, onClick: function () { copyText(u, function (ok) { setCopied(ok ? u : '') }) } }, copied === u ? '✓ 已复制' : '复制'),
|
|
420
|
+
)
|
|
421
|
+
}),
|
|
422
|
+
el('div', { style: { height: 4 } }),
|
|
423
|
+
el('div', { style: st.note }, '打开 dsh-desktop 设置页的「移动端远程控制」开关后,重启服务即生效(服务监听 0.0.0.0,手机可访问)。'),
|
|
424
|
+
),
|
|
425
|
+
// 二维码
|
|
426
|
+
urls.length
|
|
427
|
+
? el(React.Fragment, null,
|
|
428
|
+
el('div', { style: st.section }, '扫码访问'),
|
|
429
|
+
el('div', { style: st.card },
|
|
430
|
+
el('div', { style: st.qr },
|
|
431
|
+
el('img', {
|
|
432
|
+
src: qrUrl,
|
|
433
|
+
alt: '手机访问二维码',
|
|
434
|
+
style: st.qrImg,
|
|
435
|
+
onError: function (e) {
|
|
436
|
+
// 外部二维码服务不可用时降级为文本
|
|
437
|
+
e.currentTarget.style.display = 'none'
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
),
|
|
441
|
+
el('div', { style: st.qrFallback }, '用手机相机 / 浏览器扫码打开。'),
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
: null,
|
|
445
|
+
// 连接测试
|
|
446
|
+
el('div', { style: st.section }, '连接测试'),
|
|
447
|
+
el('div', { style: st.card },
|
|
448
|
+
el('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
449
|
+
el('button', { style: st.btnPrimary, disabled: pinging, onClick: doPing }, pinging ? '测试中…' : '发送测试请求'),
|
|
450
|
+
el('span', { style: st.msg }, '用于确认本页面与电脑端 Harness 服务的通信链路'),
|
|
451
|
+
),
|
|
452
|
+
pingResult ? el('div', { style: { fontSize: fs(11), opacity: 1, wordBreak: 'break-all', marginTop: 8 } }, pingResult) : null,
|
|
453
|
+
),
|
|
454
|
+
// 使用说明
|
|
455
|
+
el('div', { style: st.section }, '使用说明'),
|
|
456
|
+
el('div', { style: st.card },
|
|
457
|
+
el('div', { style: st.note }, [
|
|
458
|
+
'1. 电脑与手机连接到同一个 Wi-Fi;',
|
|
459
|
+
'2. 在 dsh-desktop 设置中开启「移动端远程控制」,重启服务;',
|
|
460
|
+
'3. 用手机浏览器打开上方任一地址(或扫码);',
|
|
461
|
+
'4. 手机端与电脑端共用同一份会话数据,对话、列表、设置实时双向同步,任何一端的操作都会即时反映到另一端;',
|
|
462
|
+
'5. 手机端已针对触控优化:侧栏自动变为抽屉,右上角浮动按钮可随时打开会话列表并查看连接状态。',
|
|
463
|
+
].join('\n')),
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------
|
|
470
|
+
// 插件入口
|
|
471
|
+
// ---------------------------------------------------------------
|
|
472
|
+
var inject = ['slots', 'layout']
|
|
473
|
+
|
|
474
|
+
function apply(ctx) {
|
|
475
|
+
var slots = ctx.get('slots')
|
|
476
|
+
if (slots === undefined) return
|
|
477
|
+
var layout = ctx.get('layout')
|
|
478
|
+
|
|
479
|
+
// 移动端适配:抽屉化(内部仅在移动视口下生效,桌面端绝不干预)
|
|
480
|
+
startDrawer(layout)
|
|
481
|
+
|
|
482
|
+
// 浮动控制条(shell.overlay)
|
|
483
|
+
slots.inject('shell.overlay', function () {
|
|
484
|
+
return slots.register(
|
|
485
|
+
{ name: 'shell.overlay', id: 'mobile-remote-dock', order: 80 },
|
|
486
|
+
function () { return el(Dock, { layout: layout }) },
|
|
487
|
+
)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// 远程控制设置页(settings.section)
|
|
491
|
+
slots.inject('settings.section', function () {
|
|
492
|
+
return slots.register(
|
|
493
|
+
{ name: 'settings.section', id: 'mobile-remote', order: 90, label: function () { return '远程控制' } },
|
|
494
|
+
function () { return el(RemoteSection, null) },
|
|
495
|
+
)
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
exports.apply = apply
|
|
500
|
+
exports.inject = inject
|
|
501
|
+
return module.exports
|
|
502
|
+
},
|
|
503
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// dsh-mobile-remote — 手机端远程控制 DeepSeek Harness(Host 半)
|
|
2
|
+
// Host + Client 双面插件:
|
|
3
|
+
// - Host:检测 webServer 监听地址与端口、枚举局域网 IPv4 地址并生成手机访问
|
|
4
|
+
// URL,通过 webServer 提供 /api/dsh-mobile-remote JSON API(状态 / 心跳),
|
|
5
|
+
// 注册 mobile_remote_status 模型工具。
|
|
6
|
+
// - Client(lib/client.js):设置页「远程控制」页面(展示状态 / 访问地址 /
|
|
7
|
+
// 二维码 / 使用说明)+ 移动端 UI 适配(单列布局、侧栏抽屉化、浮动控制条)。
|
|
8
|
+
//
|
|
9
|
+
// 原理:dsh-desktop 的「移动端远程控制」开关会让 dsh web 以 --host 0.0.0.0
|
|
10
|
+
// 监听所有网卡;手机与电脑处于同一 Wi-Fi 时,用手机浏览器打开
|
|
11
|
+
// http://<局域网IP>:<端口> 即可访问同一个 Web UI(同一份会话数据),因此
|
|
12
|
+
// 对话记录、会话列表、设置天然实时双向同步;发送消息、切换会话等全部操控
|
|
13
|
+
// 在手机上即可完成。
|
|
14
|
+
|
|
15
|
+
import { networkInterfaces } from 'node:os'
|
|
16
|
+
|
|
17
|
+
const name = 'dsh-mobile-remote'
|
|
18
|
+
|
|
19
|
+
// inject 为必需项:声明硬依赖,让 loader 等服务就绪后才调用 apply。
|
|
20
|
+
// 否则 apply 可能早于 webServer 等服务运行,ctx.get 返回 undefined,
|
|
21
|
+
// /api/dsh-mobile-remote 路由不会注册,设置页报 "not found"。
|
|
22
|
+
const inject = ['webServer', 'tools', 'sessionPersistence']
|
|
23
|
+
|
|
24
|
+
/** 是否为回环(本机)监听地址。 */
|
|
25
|
+
function isLoopbackHost(host) {
|
|
26
|
+
const h = String(host || '').toLowerCase()
|
|
27
|
+
return h === '' || h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '::'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 枚举本机非回环 IPv4 地址(供手机同 Wi-Fi 访问)。 */
|
|
31
|
+
function getLanIPv4Addresses() {
|
|
32
|
+
const out = []
|
|
33
|
+
try {
|
|
34
|
+
const ifaces = networkInterfaces()
|
|
35
|
+
for (const list of Object.values(ifaces)) {
|
|
36
|
+
for (const it of list || []) {
|
|
37
|
+
if (it && it.family === 'IPv4' && !it.internal && it.address) {
|
|
38
|
+
out.push(it.address)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (e) {
|
|
43
|
+
/* ignore */
|
|
44
|
+
}
|
|
45
|
+
// 去重并保持稳定顺序
|
|
46
|
+
return Array.from(new Set(out))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function apply(ctx) {
|
|
50
|
+
const webServer = ctx.get('webServer')
|
|
51
|
+
const tools = ctx.get('tools')
|
|
52
|
+
const sessionPersistence = ctx.get('sessionPersistence')
|
|
53
|
+
|
|
54
|
+
// ---- 读取实际监听地址 / 端口(webServer 服务提供) ----
|
|
55
|
+
function readListen() {
|
|
56
|
+
let host = '127.0.0.1'
|
|
57
|
+
let port = 3080
|
|
58
|
+
try {
|
|
59
|
+
if (webServer) {
|
|
60
|
+
if (typeof webServer.host === 'string' && webServer.host) host = webServer.host
|
|
61
|
+
if (typeof webServer.port === 'number' && webServer.port > 0) port = webServer.port
|
|
62
|
+
}
|
|
63
|
+
} catch (e) { /* ignore */ }
|
|
64
|
+
return { host, port }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 手机访问 URL 列表(仅当非回环监听时才真正可访问)。 */
|
|
68
|
+
function mobileUrls(port) {
|
|
69
|
+
return getLanIPv4Addresses().map((ip) => `http://${ip}:${port}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function dispatchAction(args) {
|
|
73
|
+
const action = args && args.action
|
|
74
|
+
if (action === 'status' || action === undefined) {
|
|
75
|
+
const { host, port } = readListen()
|
|
76
|
+
const lanAddresses = getLanIPv4Addresses()
|
|
77
|
+
const enabled = !isLoopbackHost(host)
|
|
78
|
+
let sessionCount = 0
|
|
79
|
+
if (sessionPersistence) {
|
|
80
|
+
try {
|
|
81
|
+
const rows = await sessionPersistence.list()
|
|
82
|
+
sessionCount = Array.isArray(rows) ? rows.length : 0
|
|
83
|
+
} catch (e) { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
enabled, // 远程控制是否生效(监听 0.0.0.0 等非回环地址)
|
|
89
|
+
host, // 实际监听地址
|
|
90
|
+
port, // 实际监听端口
|
|
91
|
+
lanAddresses, // 局域网 IPv4 地址列表
|
|
92
|
+
urls: mobileUrls(port), // 手机访问 URL 列表
|
|
93
|
+
sessionCount, // 当前会话数
|
|
94
|
+
serverTime: now,
|
|
95
|
+
serverTimeIso: new Date(now).toISOString(),
|
|
96
|
+
pid: typeof process !== 'undefined' ? process.pid : null,
|
|
97
|
+
workspace: typeof process !== 'undefined' ? process.cwd() : '',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (action === 'ping') {
|
|
101
|
+
const now = Date.now()
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
serverTime: now,
|
|
105
|
+
serverTimeIso: new Date(now).toISOString(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, error: '未知操作: ' + action }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- webServer JSON API:设置页 / 移动端 FAB 调用 ----
|
|
112
|
+
if (webServer) {
|
|
113
|
+
const route = {
|
|
114
|
+
kind: 'exact',
|
|
115
|
+
path: '/api/dsh-mobile-remote',
|
|
116
|
+
handler(req, res) {
|
|
117
|
+
const chunks = []
|
|
118
|
+
req.on('data', (c) => chunks.push(c))
|
|
119
|
+
req.on('end', () => {
|
|
120
|
+
let args = {}
|
|
121
|
+
try {
|
|
122
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
123
|
+
if (body) args = JSON.parse(body)
|
|
124
|
+
} catch (e) {
|
|
125
|
+
args = {}
|
|
126
|
+
}
|
|
127
|
+
dispatchAction(args).then((result) => {
|
|
128
|
+
const payload = JSON.stringify(result || { ok: false })
|
|
129
|
+
res.writeHead(200, {
|
|
130
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
131
|
+
'Access-Control-Allow-Origin': '*',
|
|
132
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
133
|
+
})
|
|
134
|
+
res.end(payload)
|
|
135
|
+
}).catch((err) => {
|
|
136
|
+
const payload = JSON.stringify({ ok: false, error: String((err && err.message) || err) })
|
|
137
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
138
|
+
res.end(payload)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const disposeRoute = webServer.register(route)
|
|
145
|
+
ctx.effect(() => disposeRoute, 'dsh-mobile-remote: /api/dsh-mobile-remote route')
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('[dsh-mobile-remote] webServer 路由注册失败(设置页将不可用):', (e && e.message) || e)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- mobile_remote_status 工具:模型可调用 ----
|
|
152
|
+
if (tools) {
|
|
153
|
+
try {
|
|
154
|
+
const disposeTool = tools.register({
|
|
155
|
+
name: 'mobile_remote_status',
|
|
156
|
+
description: '查询 DeepSeek Harness 的手机端远程控制状态:返回是否已监听局域网(远程控制开关)、实际监听地址与端口、本机局域网 IPv4 地址列表、手机可访问的 URL 列表、当前会话数。手机与电脑连接同一 Wi-Fi 时,用手机浏览器打开返回的 URL 即可实时同步并远程操控电脑端。',
|
|
157
|
+
parameters: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
output: {
|
|
162
|
+
schema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
required: ['ok'],
|
|
166
|
+
properties: {
|
|
167
|
+
ok: { type: 'boolean' },
|
|
168
|
+
enabled: { type: 'boolean' },
|
|
169
|
+
host: { type: 'string' },
|
|
170
|
+
port: { type: 'number' },
|
|
171
|
+
lanAddresses: { type: 'array' },
|
|
172
|
+
urls: { type: 'array' },
|
|
173
|
+
sessionCount: { type: 'number' },
|
|
174
|
+
serverTimeIso: { type: 'string' },
|
|
175
|
+
error: { type: 'string' },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
render: (args, value) => {
|
|
179
|
+
if (!value || !value.ok) {
|
|
180
|
+
return [{ type: 'text', text: '[dsh-mobile-remote] 状态查询失败: ' + ((value && value.error) || '未知错误') }]
|
|
181
|
+
}
|
|
182
|
+
const lines = []
|
|
183
|
+
lines.push('[dsh-mobile-remote] 远程控制' + (value.enabled ? '已开启' : '未开启(需在 dsh-desktop 设置中打开「移动端远程控制」)'))
|
|
184
|
+
lines.push('监听地址: ' + value.host + ':' + value.port)
|
|
185
|
+
if (value.lanAddresses && value.lanAddresses.length) {
|
|
186
|
+
lines.push('局域网地址: ' + value.lanAddresses.join(' / '))
|
|
187
|
+
lines.push('手机访问: ' + ((value.urls || []).join(' 或 ')))
|
|
188
|
+
} else {
|
|
189
|
+
lines.push('局域网地址: 未检测到(请检查电脑网络连接)')
|
|
190
|
+
}
|
|
191
|
+
lines.push('当前会话数: ' + (value.sessionCount ?? '—'))
|
|
192
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async execute(args) {
|
|
196
|
+
return dispatchAction({ action: 'status' })
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
ctx.effect(() => disposeTool, 'dsh-mobile-remote: mobile_remote_status tool')
|
|
200
|
+
} catch (e) {
|
|
201
|
+
console.error('[dsh-mobile-remote] mobile_remote_status 工具注册失败:', (e && e.message) || e)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- 启动日志 ----
|
|
206
|
+
try {
|
|
207
|
+
const { host, port } = readListen()
|
|
208
|
+
if (isLoopbackHost(host)) {
|
|
209
|
+
console.log(`[dsh-mobile-remote] 已就绪(仅本机监听 ${host}:${port})。在 dsh-desktop 设置中开启「移动端远程控制」后重启服务,手机即可扫码/访问局域网地址远程控制。`)
|
|
210
|
+
} else {
|
|
211
|
+
const urls = mobileUrls(port)
|
|
212
|
+
if (urls.length) {
|
|
213
|
+
console.log(`[dsh-mobile-remote] 手机端远程控制可用:手机与电脑连同一 Wi-Fi,访问 ${urls.join(' 或 ')} 即可实时同步并远程操控。`)
|
|
214
|
+
} else {
|
|
215
|
+
console.log(`[dsh-mobile-remote] 已监听 ${host}:${port},但未检测到局域网 IPv4 地址(请检查电脑网络连接)。`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (e) { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { apply, inject, name }
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@feiyang666/dsh-mobile-remote",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "手机端远程控制:让手机与电脑端 DeepSeek Harness 双向同步并远程操控。开启 dsh-desktop「移动端远程控制」后,手机浏览器扫码访问电脑局域网地址即可实时同步对话记录、会话列表与设置,并直接发送消息操控电脑端;插件同时为手机屏幕注入移动端适配(单列布局、侧栏抽屉化、触控优化、浮动控制条)。Host+Client 双面插件。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek",
|
|
7
|
+
"dsh",
|
|
8
|
+
"plugin",
|
|
9
|
+
"mobile",
|
|
10
|
+
"remote",
|
|
11
|
+
"remote-control",
|
|
12
|
+
"lan",
|
|
13
|
+
"cordis",
|
|
14
|
+
"移动端",
|
|
15
|
+
"远程控制"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/feiyang-dev/dsh-mobile-remote.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/feiyang-dev/dsh-mobile-remote#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/feiyang-dev/dsh-mobile-remote/issues"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./lib/index.js",
|
|
30
|
+
"./client": "./lib/client.js",
|
|
31
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"lib/index.js",
|
|
36
|
+
"lib/client.js",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"README.md",
|
|
40
|
+
"README.zh.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"pack": "npm pack",
|
|
60
|
+
"pack:list": "npm pack --dry-run"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18"
|
|
64
|
+
}
|
|
65
|
+
}
|