@nszzj/dsh-webview-remote-server 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/README.md +121 -2
- package/client/client.js +395 -0
- package/cordis.patch.yml +22 -0
- package/lib/gateway.js +1972 -0
- package/lib/index.js +360 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nszzj/dsh-webview-remote-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**A password-protected LAN entry point for DSH** — let a phone or tablet use the DSH running on your computer, without exposing it to the internet.
|
|
4
|
+
|
|
5
|
+
The DSH itself only talks to the local machine: its web server binds to loopback and it checks where requests come from. This plugin stands in the middle. The phone connects to the plugin's port, clears one password, and the plugin knocks on DSH's door **carrying DSH's own launch token**.
|
|
6
|
+
|
|
7
|
+
> The companion client — a Flutter app for Android and HarmonyOS — is at
|
|
8
|
+
> **[github.com/ns-zzj/dsh-webview-remote](https://github.com/ns-zzj/dsh-webview-remote)**.
|
|
9
|
+
> You do not need it: a phone browser can open the same address and work just as well.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it is
|
|
14
|
+
|
|
15
|
+
| | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Role | A **host-side DSH plugin**: an authenticated reverse proxy plus one panel in DSH's Settings |
|
|
18
|
+
| What it does | The phone opens `http://<your computer's LAN IP>:<port>/`, enters the password once, then uses DSH as usual |
|
|
19
|
+
| How it works | The plugin listens on `0.0.0.0:<port>`, forwards to `127.0.0.1:<DSH's real port>`, and attaches DSH's launch token |
|
|
20
|
+
| Authentication | **One password**, sent in a request header (never in the URL) |
|
|
21
|
+
| Configuration | DSH Settings → left sidebar → "WebView Remote" |
|
|
22
|
+
| Data | `$DSH_HOME/webview-remote-server/` (see below) |
|
|
23
|
+
| **Not** | Not a tunnel, not a public deployment, not a VPN |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ⚠️ Security first — please read before installing
|
|
28
|
+
|
|
29
|
+
| | |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **The password is the only boundary** | Whoever holds it can **run commands on this machine** — that is what DSH is. Treat it as handing over a shell on this computer. |
|
|
32
|
+
| **Zero exposure by default** | With no password set, the plugin **does not listen at all**. Not "listening and rejecting" — it never calls `listen`. |
|
|
33
|
+
| **Once it starts** | It listens on `0.0.0.0`, so **every device on the same LAN** can reach that port. |
|
|
34
|
+
| **Plain HTTP** | The password and all traffic can be sniffed on the LAN. **Do not enable this on an untrusted network** (cafés, hotels, guest Wi-Fi). |
|
|
35
|
+
| **Never map it to the internet** | No port forwarding, no frp, no ngrok, nothing of that kind. |
|
|
36
|
+
|
|
37
|
+
**The management surface.** A remote client **cannot see** the Models settings page and its like. That is DSH's own behaviour: when the page hostname is not a loopback address, DSH's settings mirror enters an "unavailable" state. This plugin's own configuration panel behaves the same way — **it only renders the form when DSH is running on the local machine.**
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
| Step | Do this |
|
|
44
|
+
|---|---|
|
|
45
|
+
| 1 | Install `@nszzj/dsh-webview-remote-server` from DSH's **plugin panel** |
|
|
46
|
+
| 2 | **Fully quit DSH and open it again** |
|
|
47
|
+
|
|
48
|
+
> The first install needs no restart; **updates** (reinstalling a newer version) do. Host-side changes only take effect after a restart.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
1. DSH **Settings** → left sidebar → "**WebView Remote**"
|
|
55
|
+
2. **Access password** — set one (do not reuse a password from anywhere else)
|
|
56
|
+
3. **Listen port** — pick a port for the gateway (default `8080`; change it if it is taken)
|
|
57
|
+
4. Click **Save**. The status line becomes "**Listening on 0.0.0.0:<port>**"
|
|
58
|
+
5. On the phone:
|
|
59
|
+
- **With the companion client** — enter `your computer's LAN IP:port` and the password
|
|
60
|
+
- **Browser only** — open `http://<your computer's LAN IP>:<port>/` and enter the password
|
|
61
|
+
6. Back in that panel, "**Connection history**" should have gained an entry
|
|
62
|
+
|
|
63
|
+
> On Windows, find your LAN IP with `ipconfig` and look for `IPv4 Address` (usually `192.168.x.x`).
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## What the panel contains
|
|
68
|
+
|
|
69
|
+
| Item | Notes |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Access password | Stored in plain text in a local config file (see below). Empty = stop listening |
|
|
72
|
+
| Listen port | Saving restarts the listener **immediately** — no DSH restart needed |
|
|
73
|
+
| Status | "Listening on 0.0.0.0:port", or "Not started (no password yet)" |
|
|
74
|
+
| Connection history | The most recent **200** entries: **time / platform / address** |
|
|
75
|
+
|
|
76
|
+
**Why the "platform" column may show `—`.** It comes from a request header the client sends voluntarily. The companion client sends it (Android / HarmonyOS); **a phone browser does not**, so that column stays empty.
|
|
77
|
+
|
|
78
|
+
**When an entry is written.** Only at the moment the **password check succeeds**. Attempts with a wrong password are **not** recorded.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Where the data lives
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
$DSH_HOME/webview-remote-server/
|
|
86
|
+
├── config.json password / port / historyLimit
|
|
87
|
+
└── history.json connection history, at most 200 entries (oldest dropped)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `$DSH_HOME` is DSH's home directory — on Windows, usually `C:\Users\<you>\.dsh`
|
|
91
|
+
- **`password` is plain text** — an ordinary string in a local file. Not encrypted, not obfuscated.
|
|
92
|
+
- This is deliberate: it is a local file and remotes cannot read it; encrypting it would be false comfort.
|
|
93
|
+
- If that bothers you, simply do not reuse a password you use elsewhere.
|
|
94
|
+
- **Deleting the whole `webview-remote-server` directory** returns you to a "never installed" state — configuration and history both gone.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Known limitations
|
|
99
|
+
|
|
100
|
+
- **One instance per machine.** The listen port must be free; it fails to start if an old standalone gateway script is holding the same port
|
|
101
|
+
- **Plain HTTP.** Moving to HTTPS means a self-signed certificate plus installing a CA on the phone — this plugin does not do that for you
|
|
102
|
+
- **The platform column depends on the client reporting it.** A phone browser will always show `—`
|
|
103
|
+
- **Host-side changes require fully quitting and reopening DSH.** Browser-side changes only need a page refresh
|
|
104
|
+
- **A remote client cannot change the configuration** — the panel only renders the form when DSH is on the local machine
|
|
105
|
+
- **Failed authentications are not recorded** in the history
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Package layout
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
lib/index.js host half: config, history, RPC endpoints, listener lifecycle
|
|
113
|
+
lib/gateway.js the gateway itself: authentication, token injection, in-page shim
|
|
114
|
+
client/client.js client half: the Settings panel (plain JS, no build step)
|
|
115
|
+
cordis.patch.yml DSH plugin manifest — `id` and `name` are two different things,
|
|
116
|
+
see the comments inside the file
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Zero dependencies.** Nothing outside Node's built-in modules is bundled. The client half
|
|
120
|
+
pulls React from DSH's own module loader at runtime; it is not shipped in this package.
|
|
121
|
+
|
|
122
|
+
**The token is never written to disk.** The gateway and DSH live in the same process, so it asks `connection.authenticatedUrl()` for a token whenever it needs one. That removes the whole "stale token file" class of problem.
|
package/client/client.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// dsh-webview-remote-server — client half(source-as-product,无需构建)
|
|
2
|
+
//
|
|
3
|
+
// 在 DSH 设置页左栏加一栏「WebView 远程」:
|
|
4
|
+
// 密码配置 / 端口配置 / 连接历史(地址 + 平台 + 年月日时分秒)
|
|
5
|
+
//
|
|
6
|
+
// ── 非本机时怎么办(【用户要求】:跟模型页一样的逻辑、一样的报错)──────
|
|
7
|
+
// 判断依据和 DSH 自己的设置页**同一个来源**:ctx.remote.$host.isLoopback
|
|
8
|
+
// (DSH 那边是 persistence = isLoopback ? 'host' : 'memory',远程时设置镜像
|
|
9
|
+
// 直接进入终态 'unavailable')
|
|
10
|
+
// 非本机时只渲染那句话,与模型页**逐字一致**:
|
|
11
|
+
// Settings are unavailable in this browser.
|
|
12
|
+
// 注意:那句是**硬编码英文**(不在任何字典里)—— 界面是中文却看到英文就是这个原因。
|
|
13
|
+
// 所以这里也照原样硬编码,不做本地化。
|
|
14
|
+
// 判断不出来时(拿不到 remote 服务)**按非本机处理**:宁可本机看不到,
|
|
15
|
+
// 也不要把密码给一个来路不明的调用方。
|
|
16
|
+
//
|
|
17
|
+
// ── 模块形状(照 dsh-workspace-mover,这是 DSH 客户端半区的壳)──────────
|
|
18
|
+
// 往 window.__ModuleLoader__ 注册一个工厂,工厂里做 CommonJS:
|
|
19
|
+
// factory(require) → { var module = { exports: {} }; … module.exports = { inject, apply } }
|
|
20
|
+
// React 由 require("react") 提供,**不是** ES import。
|
|
21
|
+
//
|
|
22
|
+
// ── 语言 ─────────────────────────────────────────────────────
|
|
23
|
+
// 不用 locale.register/bind(热重载会报 "namespace already has locale",实测坑),
|
|
24
|
+
// 每次现读活动语言、自己挑字典 —— 同 dsh-scrcpy-core 的做法。
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
// 这里必须写**带 scope 的完整包名**(不是 cordis.patch.yml 里那个短 id)——
|
|
27
|
+
// 照 dsh-scrcpy-core 的写法:它注册的 id 是 '@nszzj/dsh-scrcpy-core'。
|
|
28
|
+
id: "@nszzj/dsh-webview-remote-server",
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
var module = { exports: {} };
|
|
31
|
+
var exports = module.exports;
|
|
32
|
+
const React = require("react");
|
|
33
|
+
|
|
34
|
+
/** RPC 通道,与宿主半区(lib/index.js)里的 CHANNEL 保持一致。 */
|
|
35
|
+
const CHANNEL = "/webview-remote-server";
|
|
36
|
+
|
|
37
|
+
/** 非本机时显示的那句话 —— 与 DSH 模型页逐字一致(硬编码英文,故意不翻译)。 */
|
|
38
|
+
const UNAVAILABLE_TEXT = "Settings are unavailable in this browser.";
|
|
39
|
+
|
|
40
|
+
// ────────────────────────────────────────────────────────
|
|
41
|
+
// 字典(中英双语;只翻界面文案,不翻数据)
|
|
42
|
+
// ────────────────────────────────────────────────────────
|
|
43
|
+
const ZH = {
|
|
44
|
+
nav: "WebView 远程",
|
|
45
|
+
title: "WebView 远程接入",
|
|
46
|
+
intro: "让手机通过局域网连到这台机器上的 DSH。修改后立即生效,不需要重启。",
|
|
47
|
+
secPassword: "访问密码",
|
|
48
|
+
passwordHint: "手机 App 用它进门。这是唯一的一道门 —— 拿到它就能在这台机器上执行命令。",
|
|
49
|
+
passwordPlaceholder: "设一个密码",
|
|
50
|
+
secPort: "监听端口",
|
|
51
|
+
portHint: "网关在这个端口上等手机。改端口后要给手机填新的地址。",
|
|
52
|
+
save: "保存",
|
|
53
|
+
saving: "保存中…",
|
|
54
|
+
saved: "已保存",
|
|
55
|
+
saveFailed: "保存失败",
|
|
56
|
+
statusRunning: "正在监听 0.0.0.0:{port}",
|
|
57
|
+
statusStopped: "未启动(还没设密码)",
|
|
58
|
+
secHistory: "连接历史",
|
|
59
|
+
historyEmpty: "还没有连接记录。",
|
|
60
|
+
colTime: "时间",
|
|
61
|
+
colPlatform: "平台",
|
|
62
|
+
colAddress: "地址",
|
|
63
|
+
platformAndroid: "安卓",
|
|
64
|
+
platformOhos: "鸿蒙",
|
|
65
|
+
loadFailed: "读取配置失败",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const EN = {
|
|
69
|
+
nav: "WebView Remote",
|
|
70
|
+
title: "WebView remote access",
|
|
71
|
+
intro: "Let a phone reach the DSH on this machine over the LAN. Changes apply immediately, no restart needed.",
|
|
72
|
+
secPassword: "Access password",
|
|
73
|
+
passwordHint: "The phone App uses it to get in. It is the only lock — whoever has it can run commands on this machine.",
|
|
74
|
+
passwordPlaceholder: "Set a password",
|
|
75
|
+
secPort: "Listen port",
|
|
76
|
+
portHint: "The gateway waits for the phone on this port. Changing it means entering a new address on the phone.",
|
|
77
|
+
save: "Save",
|
|
78
|
+
saving: "Saving…",
|
|
79
|
+
saved: "Saved",
|
|
80
|
+
saveFailed: "Could not save",
|
|
81
|
+
statusRunning: "Listening on 0.0.0.0:{port}",
|
|
82
|
+
statusStopped: "Not started (no password yet)",
|
|
83
|
+
secHistory: "Connection history",
|
|
84
|
+
historyEmpty: "No connections recorded yet.",
|
|
85
|
+
colTime: "Time",
|
|
86
|
+
colPlatform: "Platform",
|
|
87
|
+
colAddress: "Address",
|
|
88
|
+
platformAndroid: "Android",
|
|
89
|
+
platformOhos: "HarmonyOS",
|
|
90
|
+
loadFailed: "Could not read the configuration",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ────────────────────────────────────────────────────────
|
|
94
|
+
// 小工具
|
|
95
|
+
// ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** 代替 JSX:h("div", { style: … }, 子元素…) */
|
|
98
|
+
function h(tag, props, ...children) {
|
|
99
|
+
return React.createElement(tag, props ?? null, ...children);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 占位替换:{name} 用 params 里的值;缺参数就原样留着(便于发现漏传)。 */
|
|
103
|
+
function tmpl(s, params) {
|
|
104
|
+
if (!params) return s;
|
|
105
|
+
return String(s).replace(/\{(\w+)\}/g, function (m, k) {
|
|
106
|
+
return Object.prototype.hasOwnProperty.call(params, k) ? String(params[k]) : m;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 时间戳 → 年月日时分秒(本地时区)。 */
|
|
111
|
+
function formatTime(at) {
|
|
112
|
+
try {
|
|
113
|
+
const d = new Date(at);
|
|
114
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
115
|
+
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " "
|
|
116
|
+
+ p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return String(at);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ────────────────────────────────────────────────────────
|
|
123
|
+
// 语言
|
|
124
|
+
// ────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** 出厂作用域里接上的 locale 服务(由 apply 里 bindLocale 赋值)。 */
|
|
127
|
+
let localeSvc = null;
|
|
128
|
+
|
|
129
|
+
function warnPlugin(msg) {
|
|
130
|
+
try { console.warn("[webview-remote-server] " + String(msg)); } catch (e) { /* 忽略 */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 读 DSH 当前语言。
|
|
135
|
+
* ⚠️ 必须用 getSnapshot().active:getLocale() 返回的是语言**定义对象**
|
|
136
|
+
* (有 id/label/fallback),没有 active 字段。
|
|
137
|
+
*/
|
|
138
|
+
function readActiveLocale() {
|
|
139
|
+
try {
|
|
140
|
+
const s = localeSvc && localeSvc.getSnapshot ? localeSvc.getSnapshot() : null;
|
|
141
|
+
if (s && s.active) return String(s.active);
|
|
142
|
+
} catch (e) { /* 下一个兜底 */ }
|
|
143
|
+
try {
|
|
144
|
+
const d = localeSvc && localeSvc.getLocale ? localeSvc.getLocale() : null;
|
|
145
|
+
if (d && d.id) return String(d.id);
|
|
146
|
+
} catch (e) { /* 放弃 */ }
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 取文案:自己挑字典(不用 register/bind —— 热重载会报 namespace 重复)。 */
|
|
151
|
+
function T(key, params) {
|
|
152
|
+
const dict = (readActiveLocale() === "en") ? EN : ZH;
|
|
153
|
+
const s = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key]
|
|
154
|
+
: (Object.prototype.hasOwnProperty.call(ZH, key) ? ZH[key] : key);
|
|
155
|
+
return tmpl(s, params);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 语言切换后重渲染(拿不到 subscribe 就退化成不响应)。 */
|
|
159
|
+
function useLocaleRev() {
|
|
160
|
+
const [rev, setRev] = React.useState(0);
|
|
161
|
+
React.useEffect(function () {
|
|
162
|
+
if (!localeSvc || typeof localeSvc.subscribe !== "function") return undefined;
|
|
163
|
+
let dispose = null;
|
|
164
|
+
try {
|
|
165
|
+
dispose = localeSvc.subscribe(function () { setRev(function (n) { return n + 1; }); });
|
|
166
|
+
} catch (e) { /* 忽略 */ }
|
|
167
|
+
return function () {
|
|
168
|
+
try { if (typeof dispose === "function") dispose(); } catch (e) { /* 忽略 */ }
|
|
169
|
+
};
|
|
170
|
+
}, []);
|
|
171
|
+
return rev;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 从注入的 ctx 里判断是不是本机;判断不出来时按"不是"处理(fail closed)。 */
|
|
175
|
+
function detectLoopback(ctx) {
|
|
176
|
+
try {
|
|
177
|
+
const remote = typeof ctx.get === "function" ? ctx.get("remote") : ctx.remote;
|
|
178
|
+
const facts = remote && remote.$host;
|
|
179
|
+
if (facts && typeof facts.isLoopback === "boolean") return facts.isLoopback;
|
|
180
|
+
} catch (e) { /* 拿不到就 fail closed */ }
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ────────────────────────────────────────────────────────
|
|
185
|
+
// 面板
|
|
186
|
+
// ────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const S = {
|
|
189
|
+
wrap: { padding: "4px 2px", fontSize: 13, lineHeight: 1.6 },
|
|
190
|
+
section: { marginTop: 18 },
|
|
191
|
+
label: { display: "block", fontWeight: 600, marginBottom: 4 },
|
|
192
|
+
hint: { opacity: 0.65, fontSize: 12, margin: "2px 0 6px" },
|
|
193
|
+
input: { width: "100%", boxSizing: "border-box", padding: "6px 8px" },
|
|
194
|
+
row: { display: "flex", gap: 8, alignItems: "center", marginTop: 10 },
|
|
195
|
+
button: { padding: "6px 14px", cursor: "pointer" },
|
|
196
|
+
status: { marginTop: 8, fontSize: 12, opacity: 0.75 },
|
|
197
|
+
table: { width: "100%", borderCollapse: "collapse", fontSize: 12, marginTop: 6 },
|
|
198
|
+
th: { textAlign: "left", padding: "4px 6px", borderBottom: "1px solid rgba(128,128,128,.35)" },
|
|
199
|
+
td: { padding: "4px 6px", borderBottom: "1px solid rgba(128,128,128,.15)" },
|
|
200
|
+
pre: { whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: 12 },
|
|
201
|
+
title: { fontWeight: 700, fontSize: 15 },
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function Line(props) {
|
|
205
|
+
return h("p", { style: props.style ?? S.hint }, props.text);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function Section(props) {
|
|
209
|
+
return h("div", { style: S.section }, props.children);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function Panel(props) {
|
|
213
|
+
useLocaleRev();
|
|
214
|
+
const rpcCall = props.rpcCall;
|
|
215
|
+
|
|
216
|
+
const [state, setState] = React.useState({ status: "loading" });
|
|
217
|
+
const [password, setPassword] = React.useState("");
|
|
218
|
+
const [port, setPort] = React.useState("8080");
|
|
219
|
+
const [busy, setBusy] = React.useState(false);
|
|
220
|
+
const [note, setNote] = React.useState("");
|
|
221
|
+
|
|
222
|
+
const load = React.useCallback(function () {
|
|
223
|
+
Promise.all([
|
|
224
|
+
rpcCall("remote.config.get"),
|
|
225
|
+
rpcCall("remote.status"),
|
|
226
|
+
rpcCall("remote.history.list"),
|
|
227
|
+
]).then(function (r) {
|
|
228
|
+
const cfg = r[0], st = r[1], hist = r[2];
|
|
229
|
+
if (!cfg || !cfg.ok) {
|
|
230
|
+
setState({ status: "error", error: (cfg && cfg.error) || null });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
setPassword(cfg.value.password ?? "");
|
|
234
|
+
setPort(String(cfg.value.port ?? 8080));
|
|
235
|
+
setState({
|
|
236
|
+
status: "ready",
|
|
237
|
+
info: (st && st.ok) ? st.value : null,
|
|
238
|
+
history: (hist && hist.ok && Array.isArray(hist.value)) ? hist.value : [],
|
|
239
|
+
});
|
|
240
|
+
}).catch(function (error) {
|
|
241
|
+
setState({ status: "error", error: { message: String((error && error.message) || error) } });
|
|
242
|
+
});
|
|
243
|
+
}, [rpcCall]);
|
|
244
|
+
|
|
245
|
+
React.useEffect(function () { load(); }, [load]);
|
|
246
|
+
|
|
247
|
+
function save() {
|
|
248
|
+
setBusy(true);
|
|
249
|
+
setNote("");
|
|
250
|
+
rpcCall("remote.config.set", { password: password, port: Number(port) })
|
|
251
|
+
.then(function (r) {
|
|
252
|
+
if (r && r.ok) {
|
|
253
|
+
setNote(T("saved"));
|
|
254
|
+
load();
|
|
255
|
+
} else {
|
|
256
|
+
setNote(T("saveFailed") + (r && r.error && r.error.message ? ":" + r.error.message : ""));
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
.catch(function (error) {
|
|
260
|
+
setNote(T("saveFailed") + ":" + String((error && error.message) || error));
|
|
261
|
+
})
|
|
262
|
+
.finally(function () { setBusy(false); });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 出错就如实说,不装作没事
|
|
266
|
+
if (state.status === "error") {
|
|
267
|
+
return h("div", { style: S.wrap },
|
|
268
|
+
h("div", null, T("loadFailed")),
|
|
269
|
+
h("pre", { style: S.pre }, String((state.error && state.error.message) || "")));
|
|
270
|
+
}
|
|
271
|
+
if (state.status === "loading") {
|
|
272
|
+
return h("div", { style: S.wrap }, "…");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const info = state.info;
|
|
276
|
+
const history = state.history ?? [];
|
|
277
|
+
|
|
278
|
+
return h("div", { style: S.wrap },
|
|
279
|
+
h("div", { style: S.title }, T("title")),
|
|
280
|
+
h(Line, { text: T("intro") }),
|
|
281
|
+
|
|
282
|
+
h(Section, null,
|
|
283
|
+
h("label", { style: S.label }, T("secPassword")),
|
|
284
|
+
h(Line, { text: T("passwordHint") }),
|
|
285
|
+
h("input", {
|
|
286
|
+
style: S.input,
|
|
287
|
+
type: "text",
|
|
288
|
+
value: password,
|
|
289
|
+
placeholder: T("passwordPlaceholder"),
|
|
290
|
+
onChange: function (e) { setPassword(e.target.value); },
|
|
291
|
+
})),
|
|
292
|
+
|
|
293
|
+
h(Section, null,
|
|
294
|
+
h("label", { style: S.label }, T("secPort")),
|
|
295
|
+
h(Line, { text: T("portHint") }),
|
|
296
|
+
h("input", {
|
|
297
|
+
style: S.input,
|
|
298
|
+
type: "number",
|
|
299
|
+
min: 1,
|
|
300
|
+
max: 65535,
|
|
301
|
+
value: port,
|
|
302
|
+
onChange: function (e) { setPort(e.target.value); },
|
|
303
|
+
})),
|
|
304
|
+
|
|
305
|
+
h("div", { style: S.row },
|
|
306
|
+
h("button", {
|
|
307
|
+
style: S.button,
|
|
308
|
+
disabled: busy,
|
|
309
|
+
onClick: save,
|
|
310
|
+
}, busy ? T("saving") : T("save")),
|
|
311
|
+
note ? h("span", { style: { fontSize: 12, opacity: 0.8 } }, note) : null),
|
|
312
|
+
|
|
313
|
+
h("div", { style: S.status },
|
|
314
|
+
info && info.running ? T("statusRunning", { port: info.port }) : T("statusStopped")),
|
|
315
|
+
|
|
316
|
+
h(Section, null,
|
|
317
|
+
h("div", { style: S.label }, T("secHistory")),
|
|
318
|
+
history.length === 0
|
|
319
|
+
? h(Line, { text: T("historyEmpty") })
|
|
320
|
+
: h("table", { style: S.table },
|
|
321
|
+
h("thead", null,
|
|
322
|
+
h("tr", null,
|
|
323
|
+
h("th", { style: S.th }, T("colTime")),
|
|
324
|
+
h("th", { style: S.th }, T("colPlatform")),
|
|
325
|
+
h("th", { style: S.th }, T("colAddress")))),
|
|
326
|
+
h("tbody", null,
|
|
327
|
+
history.slice().reverse().map(function (item, index) {
|
|
328
|
+
const p = item && item.platform;
|
|
329
|
+
const platform = p === "android" ? T("platformAndroid")
|
|
330
|
+
: p === "ohos" ? T("platformOhos")
|
|
331
|
+
: (p ? String(p) : "—");
|
|
332
|
+
return h("tr", { key: String((item && item.at) || index) },
|
|
333
|
+
h("td", { style: S.td }, formatTime(item && item.at)),
|
|
334
|
+
h("td", { style: S.td }, platform),
|
|
335
|
+
h("td", { style: S.td }, String((item && item.address) || "—")));
|
|
336
|
+
})))),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 非本机时只显示这一句 —— 与模型页逐字一致。
|
|
342
|
+
*
|
|
343
|
+
* ⚠️ 判断必须放在**渲染时**,不能在 apply 那一刻快照:
|
|
344
|
+
* apply 早期 `ctx.get("remote")` 可能还没起来,那时判出来是 false,
|
|
345
|
+
* 就会**本机也永久显示"不可用"**。现判则服务一出现就恢复正常。
|
|
346
|
+
*/
|
|
347
|
+
function Gate(props) {
|
|
348
|
+
if (!detectLoopback(props.ctx)) {
|
|
349
|
+
return h("div", { style: S.wrap }, UNAVAILABLE_TEXT);
|
|
350
|
+
}
|
|
351
|
+
return h(Panel, props);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ────────────────────────────────────────────────────────
|
|
355
|
+
// apply
|
|
356
|
+
// ────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/** slots:设置页那一栏需要;未注入时访问 ctx.slots 会被宿主拒绝。 */
|
|
359
|
+
var inject = ["connection", "slots"];
|
|
360
|
+
|
|
361
|
+
function apply(ctx) {
|
|
362
|
+
// 语言服务是可选依赖:故意不写进 inject(inject 是硬依赖,缺了插件起不来)
|
|
363
|
+
try {
|
|
364
|
+
const svc = typeof ctx.get === "function" ? ctx.get("locale") : ctx.locale;
|
|
365
|
+
if (svc && (typeof svc.getSnapshot === "function" || typeof svc.getLocale === "function")) {
|
|
366
|
+
localeSvc = svc;
|
|
367
|
+
} else {
|
|
368
|
+
warnPlugin("locale 服务不可用,界面用中文");
|
|
369
|
+
}
|
|
370
|
+
} catch (e) {
|
|
371
|
+
warnPlugin("locale 服务接不上,退回中文:" + String((e && e.message) || e));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (typeof ctx.slots?.inject !== "function" || typeof ctx.slots?.register !== "function") {
|
|
375
|
+
warnPlugin("没有槽位系统(宿主版本太老?),设置页那一栏不会出现");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function rpcCall(endpoint, payload) {
|
|
380
|
+
return ctx.connection.rpc.call(CHANNEL, endpoint, payload ?? {});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
384
|
+
name: "settings.section",
|
|
385
|
+
id: "webview-remote",
|
|
386
|
+
order: 70,
|
|
387
|
+
label: () => T("nav"),
|
|
388
|
+
inject: () => ({ rpcCall: rpcCall, ctx: ctx }),
|
|
389
|
+
}, Gate));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = { inject, apply };
|
|
393
|
+
return module.exports;
|
|
394
|
+
}
|
|
395
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dsh-webview-remote-server bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 通过插件面板 / 官方 CLI 安装时(dsh plugin --profile <name> add @nszzj/dsh-webview-remote-server),
|
|
4
|
+
# 安装器会把本包追加进 dsh.profile.bundles 并在启动时合并本补丁,
|
|
5
|
+
# 等价于手工在 profile 的 cordis.patch.yml 里挂载一行:
|
|
6
|
+
#
|
|
7
|
+
# - insert:
|
|
8
|
+
# - id: webview-remote-server
|
|
9
|
+
# name: '@nszzj/dsh-webview-remote-server'
|
|
10
|
+
#
|
|
11
|
+
# ⚠️ `id` 与 `name` 是**两个不同的东西**,别写成一个(照 dsh-scrcpy 三个包的写法):
|
|
12
|
+
# · id —— 短名、**不带 scope**,只在本组合树里要求唯一;
|
|
13
|
+
# 两条同 id 的条目会被 EntryGroup.update 以 `duplicate loader entry id`
|
|
14
|
+
# 拒绝、启动直接失败。
|
|
15
|
+
# · name —— 是 npm 包名,**必须带 scope**,装载器用它去 import。
|
|
16
|
+
#
|
|
17
|
+
# 单包单插件,无配置项(配置走 $DSH_HOME 下的配置文件,见 lib/index.js,
|
|
18
|
+
# 目录名仍是 `webview-remote-server`,**没有跟着包名加 scope** ——
|
|
19
|
+
# 磁盘目录名不必背一个 @ 前缀)。
|
|
20
|
+
- insert:
|
|
21
|
+
- id: webview-remote-server
|
|
22
|
+
name: "@nszzj/dsh-webview-remote-server"
|