dsh-remote-access-proxy 0.0.0-stage → 1.5.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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * dsh-remote-access-proxy, browser half — the Remote Access Proxy configuration
3
+ * page for the host row `remote-access-proxy`.
4
+ *
5
+ * The page answers the two questions a user opens it with — "is it running?" and
6
+ * "what do I type on my phone?" — before any field: a live status line and the
7
+ * entry addresses with copy buttons, read from the host plugin's authenticated
8
+ * `api/remote-access-proxy/status` route. The fields below are grouped by what
9
+ * they control, and booleans render as switches.
10
+ *
11
+ * A bundle row's own page is contributed through the Plugins page's
12
+ * `plugins.row.config` slot, keyed `<bundle package>#<row id>`. The staged form
13
+ * is the one the shipped settings pages use: `SettingsFormModel` over a
14
+ * `configForms` scope plus the `ui-primitives` settings form and fields. The
15
+ * registration follows `ctx.configForms.whileServed`, so a deployment that never
16
+ * composed the host plugin — or a remote page, which DSH does not let edit
17
+ * Host settings — shows no page. Every value is a `.volatile()` field of the
18
+ * host plugin's Config: a save lands in the active profile's `cordis.patch.yml`
19
+ * and restarts the embedded server without remounting the plugin.
20
+ *
21
+ * Updates are managed by the DSH Plugins page through npm.
22
+ */
23
+ window.__ModuleLoader__.load({
24
+ id: "dsh-remote-access-proxy",
25
+ factory: (require) => {
26
+ var module = { exports: {} };
27
+ var exports = module.exports;
28
+ const react = require("react");
29
+ const {
30
+ Button,
31
+ SettingsForm,
32
+ SettingsFormModel,
33
+ SettingsValueField,
34
+ StateDot,
35
+ Switch,
36
+ Tag,
37
+ settingsNumberField,
38
+ settingsTextField,
39
+ writeClipboard,
40
+ } = require("@deepseek-ai/dsh-client-ui-primitives");
41
+ const h = react.createElement;
42
+
43
+ /** Settings namespace — the host row's profile entry id, not a separate registration. */
44
+ const NS = "remote-access-proxy";
45
+ /** Bundle package that declares the row; a row page is keyed `<package>#<row id>`. */
46
+ const BUNDLE = "dsh-remote-access-proxy";
47
+ const ROW_KEY = BUNDLE + "#" + NS;
48
+ /** Host status route, relative to the page's `<base href>` so it also works through the proxy. */
49
+ const STATUS_URL = "api/remote-access-proxy/status";
50
+ const POLL_MS = 3000;
51
+
52
+ // ---- copy ----------------------------------------------------------------
53
+ const COPY = {
54
+ zh: {
55
+ summary: "在手机或其它电脑上用浏览器访问 DSH:随机路径入口,可选访问密码和来源地址白名单",
56
+ statusTitle: "状态",
57
+ listening: "运行中",
58
+ starting: "启动中…",
59
+ disabled: "已关闭",
60
+ error: "未能启动",
61
+ unknown: "读取状态中…",
62
+ unreachable: "读不到代理状态(插件可能未运行)",
63
+ entries: "入口地址",
64
+ entriesHint: "在同一网络(局域网 / VPN / 反代)的设备上用浏览器打开其中一个地址,存成书签或添加到主屏幕即可。",
65
+ passwordOn: "需要密码:登录后这台设备会被记住 30 天。",
66
+ allowSummary: "只允许来自:{list}(本机始终允许)",
67
+ denied: "刚刚拒绝了 {address} 的连接。如果这是你的设备,把它加进「允许的来源地址」。",
68
+ copy: "复制",
69
+ copied: "已复制",
70
+ remoteNote: "远程页面可以正常使用 DSH;插件更新和部分设置(如 API Key)只能在这台电脑上操作。",
71
+ tlsNote: "未启用 TLS:流量是明文。只在可信网络(VPN、家庭局域网)里这样用;公网请启用 TLS 或放在 HTTPS 反代后面。",
72
+ groupAccess: "访问",
73
+ groupGate: "门禁",
74
+ groupTls: "加密(TLS)",
75
+ groupAdvanced: "高级",
76
+ regenerate: "重新生成入口路径",
77
+ regenerateHint: "保存后旧地址失效,所有设备都需要用新地址重新进入",
78
+ presetPrivate: "只允许局域网和 VPN",
79
+ presetAny: "允许任何地址",
80
+ fields: {
81
+ enabled: ["启用", "关闭后代理停止监听"],
82
+ listenPort: ["监听端口", "1–65535,默认 13337;代理在本机所有网卡上监听"],
83
+ allowFrom: ["允许的来源地址", "客户端的 IP、网段或范围,用逗号分隔,例如 192.168.1.0/24, 100.64.0.0/10, 10.0.0.5-10.0.0.9;留空 = 不限制"],
84
+ secretPath: ["随机路径", "入口 = <地址>:<端口>/<随机路径>/;4–32 位字母、数字、- 或 _"],
85
+ password: ["访问密码", "留空 = 不需要密码;修改后所有设备需要重新登录"],
86
+ tlsEnabled: ["启用 TLS", "开:https(浏览器需接受一次自签证书);关:明文 http"],
87
+ tlsPfxPath: ["证书文件 (.pfx)", "启用 TLS 时必填;相对路径相对于 DSH 的工作目录"],
88
+ tlsPassphrase: ["证书口令", "导出 pfx 时设置的口令,没有就留空"],
89
+ upstreamHost: ["上游主机", "留空 = 自动跟随当前 DSH 实例"],
90
+ upstreamPort: ["上游端口", "0 = 自动(读当前实例实际绑定的端口)"],
91
+ logMaxBytes: ["日志上限(字节)", "access.log 超过此大小即轮转;0 = 不轮转"],
92
+ logKeep: ["日志保留份数", "保留 access.log.1…N;0 = 不留历史"],
93
+ },
94
+ auto: "自动",
95
+ form: {
96
+ unavailable: "配置暂不可用(宿主未提供该命名空间)",
97
+ readOnly: "配置只读(本部署以只读方式存储设置)",
98
+ saveFailed: "保存未生效,请检查输入",
99
+ saving: "保存中…",
100
+ save: "保存",
101
+ },
102
+ overridden: "已修改",
103
+ reset: "恢复默认",
104
+ invalid: "输入无效",
105
+ invalidNumber: "请填数字;留空恢复默认",
106
+ on: "开",
107
+ off: "关",
108
+ reasons: {
109
+ "port-in-use": "端口 {port} 已被占用:换一个端口,或关掉占用它的程序(两个 DSH 共用同一个 profile 也会这样)",
110
+ "bad-allow": "这些来源地址写法不对:{detail}。可以写 IP(10.0.0.5)、网段(10.0.0.0/24)或范围(10.0.0.5-10.0.0.9)",
111
+ "bad-password": "访问密码最长 256 个字符",
112
+ "port-denied": "没有权限监听端口 {port}:换一个 1024 以上的端口",
113
+ "bad-port": "监听端口要是 1–65535 的整数",
114
+ "bad-secret-path": "随机路径只能是 4–32 位字母、数字、- 或 _",
115
+ "tls-no-cert": "启用 TLS 需要先填证书文件 (.pfx)",
116
+ "tls-load": "证书读不出来:检查证书路径和口令",
117
+ },
118
+ },
119
+ en: {
120
+ summary: "Use DSH from your phone or another computer: a secret-path entry with an optional password and client allow list",
121
+ statusTitle: "Status",
122
+ listening: "Running",
123
+ starting: "Starting…",
124
+ disabled: "Off",
125
+ error: "Not running",
126
+ unknown: "Reading status…",
127
+ unreachable: "Can't read the proxy status (the plugin may not be running)",
128
+ entries: "Entry addresses",
129
+ entriesHint: "Open one of these in a browser on a device on the same network (LAN, VPN, or behind your reverse proxy), then bookmark it or add it to the home screen.",
130
+ passwordOn: "Password required: a signed-in device is remembered for 30 days.",
131
+ allowSummary: "Only from: {list} (this machine is always allowed)",
132
+ denied: "Just turned away a connection from {address}. If that's your device, add it to Allowed client addresses.",
133
+ copy: "Copy",
134
+ copied: "Copied",
135
+ remoteNote: "Remote pages can use DSH normally; plugin updates and some settings (such as the API key) work only on this computer.",
136
+ tlsNote: "TLS is off, so traffic is plain text. Use it like this only on a trusted network (VPN, home LAN); on the internet turn TLS on or put an HTTPS reverse proxy in front.",
137
+ groupAccess: "Access",
138
+ groupGate: "Gate",
139
+ groupTls: "Encryption (TLS)",
140
+ groupAdvanced: "Advanced",
141
+ regenerate: "Regenerate the entry path",
142
+ regenerateHint: "After saving, the old address stops working and every device re-enters with the new one",
143
+ presetPrivate: "LAN and VPN only",
144
+ presetAny: "Allow any address",
145
+ fields: {
146
+ enabled: ["Enabled", "Turn off to stop listening"],
147
+ listenPort: ["Listen port", "1–65535, default 13337; the proxy listens on every interface of this machine"],
148
+ allowFrom: ["Allowed client addresses", "Client IPs, CIDR blocks or ranges, comma-separated, e.g. 192.168.1.0/24, 100.64.0.0/10, 10.0.0.5-10.0.0.9; empty = anyone"],
149
+ secretPath: ["Secret path", "Entry = <address>:<port>/<secret path>/; 4–32 letters, digits, - or _"],
150
+ password: ["Access password", "Empty = no password; changing it signs every device out"],
151
+ tlsEnabled: ["Enable TLS", "On: https (accept the self-signed certificate once); off: plain http"],
152
+ tlsPfxPath: ["Certificate (.pfx)", "Required with TLS; relative paths resolve from DSH's working directory"],
153
+ tlsPassphrase: ["Certificate passphrase", "The passphrase set when exporting the pfx; leave empty if none"],
154
+ upstreamHost: ["Upstream host", "Empty = follow the running DSH instance"],
155
+ upstreamPort: ["Upstream port", "0 = automatic (reads the port the running instance actually bound)"],
156
+ logMaxBytes: ["Log size cap (bytes)", "Rotate access.log past this size; 0 = never rotate"],
157
+ logKeep: ["Rotated logs kept", "Keep access.log.1…N; 0 = keep no history"],
158
+ },
159
+ auto: "automatic",
160
+ form: {
161
+ unavailable: "Configuration unavailable (the Host does not serve this namespace)",
162
+ readOnly: "Configuration is read-only in this deployment",
163
+ saveFailed: "Not saved — check the values",
164
+ saving: "Saving…",
165
+ save: "Save",
166
+ },
167
+ overridden: "Changed",
168
+ reset: "Reset",
169
+ invalid: "Invalid value",
170
+ invalidNumber: "Enter a number; leave empty for the default",
171
+ on: "On",
172
+ off: "Off",
173
+ reasons: {
174
+ "port-in-use": "Port {port} is already in use: pick another port, or close the program holding it (two DSH apps sharing one profile do this too)",
175
+ "bad-allow": "These client addresses aren't valid: {detail}. Use an IP (10.0.0.5), a CIDR block (10.0.0.0/24) or a range (10.0.0.5-10.0.0.9)",
176
+ "bad-password": "The access password can be at most 256 characters",
177
+ "port-denied": "No permission to listen on port {port}: pick a port above 1024",
178
+ "bad-port": "The listen port must be an integer from 1 to 65535",
179
+ "bad-secret-path": "The secret path must be 4–32 letters, digits, - or _",
180
+ "tls-no-cert": "TLS needs a certificate file (.pfx) first",
181
+ "tls-load": "The certificate could not be loaded: check its path and passphrase",
182
+ },
183
+ },
184
+ };
185
+ /** Pick the dictionary for the page's language (DSH sets `<html lang>`). */
186
+ function copy() {
187
+ const lang = (document.documentElement.lang || navigator.language || "en").toLowerCase();
188
+ return lang.startsWith("zh") ? COPY.zh : COPY.en;
189
+ }
190
+
191
+ // ---- field specs -------------------------------------------------------
192
+ /** A two-state field; the page renders it as a Switch rather than a text box. */
193
+ const booleanField = (field) => ({
194
+ field,
195
+ format: (value) => (value === true ? "true" : "false"),
196
+ parse: (text) => ({ kind: "set", value: text === "true" }),
197
+ });
198
+
199
+ /** Fields by group, in page order. `kind` picks the control. */
200
+ const GROUPS = [
201
+ { title: "groupAccess", fields: [
202
+ { spec: booleanField("enabled"), kind: "boolean" },
203
+ { spec: settingsNumberField("listenPort"), kind: "number" },
204
+ { spec: settingsTextField("allowFrom"), kind: "text", presets: true },
205
+ ] },
206
+ { title: "groupGate", gate: true, fields: [
207
+ { spec: settingsTextField("secretPath"), kind: "text" },
208
+ { spec: settingsTextField("password"), kind: "text", secret: true },
209
+ // The server secret that signs remembered devices: edited only by the
210
+ // regenerate button, never shown.
211
+ { spec: settingsTextField("cookieValue"), kind: "text", hidden: true },
212
+ ] },
213
+ { title: "groupTls", fields: [
214
+ { spec: booleanField("tlsEnabled"), kind: "boolean" },
215
+ { spec: settingsTextField("tlsPfxPath"), kind: "text" },
216
+ { spec: settingsTextField("tlsPassphrase"), kind: "text" },
217
+ ] },
218
+ { title: "groupAdvanced", fields: [
219
+ { spec: settingsTextField("upstreamHost"), kind: "text", auto: true },
220
+ { spec: settingsNumberField("upstreamPort"), kind: "number", auto: true },
221
+ { spec: settingsNumberField("logMaxBytes"), kind: "number" },
222
+ { spec: settingsNumberField("logKeep"), kind: "number" },
223
+ ] },
224
+ ];
225
+ const FIELDS = GROUPS.flatMap((group) => group.fields);
226
+ /** Private LANs, CGNAT/Tailscale, IPv6 ULA and link-local (matches the host's PRIVATE_NETWORKS). */
227
+ const PRIVATE_NETWORKS = "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, fc00::/7, fe80::/10";
228
+ /** Mask a secret field until it is focused (the shared field is a plain text input). */
229
+ const SECRET_STYLE = "[data-rap-secret] input:not(:focus){-webkit-text-security:disc;text-security:disc}";
230
+
231
+ /** A random lowercase alphanumeric token (getRandomValues works on any origin). */
232
+ function randomToken(length) {
233
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
234
+ const limit = 256 - (256 % chars.length);
235
+ let out = "";
236
+ while (out.length < length) {
237
+ for (const byte of crypto.getRandomValues(new Uint8Array(length * 2))) {
238
+ if (byte >= limit) continue;
239
+ out += chars[byte % chars.length];
240
+ if (out.length === length) break;
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ // ---- staged form over the host row's configuration form ----------------
247
+ /**
248
+ * Bridges this page onto the shared form of one Host entry: field reads,
249
+ * staged drafts, and the single revision-fenced write a save performs.
250
+ */
251
+ class RemoteAccessProxyCardController {
252
+ constructor(scope) {
253
+ this.form = new SettingsFormModel(scope, FIELDS.map((entry) => entry.spec));
254
+ this.store = this.form.bind(() => this.projection());
255
+ /** The plugin manager Remote and whether this page runs on the Host's machine; set once served. */
256
+ }
257
+ projection() {
258
+ const out = { ...this.form.shell() };
259
+ for (const entry of FIELDS) out[entry.spec.field] = this.form.field(entry.spec.field);
260
+ return out;
261
+ }
262
+ /** The face the page's slot entry injects: one snapshot store plus the form actions. */
263
+ inject() {
264
+ return { hooks: { rapCard: this.store }, ...this.form.actions() };
265
+ }
266
+ dispose() {
267
+ this.form.dispose();
268
+ }
269
+ }
270
+
271
+ // ---- live status ---------------------------------------------------------
272
+ /** Poll the host plugin's status route while the page is open. */
273
+ function useProxyStatus(refreshKey) {
274
+ const [status, setStatus] = react.useState(undefined);
275
+ // "Check now" answers with a fresh status document; show it at once.
276
+ react.useEffect(() => {
277
+ const onFresh = (event) => setStatus(event.detail);
278
+ window.addEventListener("rap-status", onFresh);
279
+ return () => window.removeEventListener("rap-status", onFresh);
280
+ }, []);
281
+ react.useEffect(() => {
282
+ let alive = true;
283
+ let timer;
284
+ const read = async () => {
285
+ try {
286
+ const response = await fetch(STATUS_URL, { credentials: "same-origin", cache: "no-store" });
287
+ const next = response.ok ? await response.json() : { state: "unreachable" };
288
+ if (alive) setStatus(next);
289
+ } catch (_error) {
290
+ if (alive) setStatus({ state: "unreachable" });
291
+ }
292
+ if (alive) timer = setTimeout(read, POLL_MS);
293
+ };
294
+ void read();
295
+ return () => {
296
+ alive = false;
297
+ clearTimeout(timer);
298
+ };
299
+ }, [refreshKey]);
300
+ return status;
301
+ }
302
+
303
+ // ---- UI ----------------------------------------------------------------
304
+ const S = {
305
+ panel: { display: "flex", flexDirection: "column", gap: 10, padding: 16, marginBottom: 24, borderRadius: 12, border: "0.5px solid var(--dsw-alias-border-l3)", background: "var(--dsw-alias-bg-layer-1)" },
306
+ statusLine: { display: "flex", alignItems: "center", gap: 8, fontSize: 14, fontWeight: 500, color: "var(--dsw-alias-label-primary)" },
307
+ reason: { margin: 0, fontSize: 13, lineHeight: "20px", color: "var(--dsw-alias-state-error-primary)", overflowWrap: "anywhere" },
308
+ label: { margin: "6px 0 0", fontSize: 12, fontWeight: 500, color: "var(--dsw-alias-label-secondary)" },
309
+ urlRow: { display: "flex", alignItems: "center", gap: 8 },
310
+ url: { flex: 1, minWidth: 0, padding: "6px 10px", borderRadius: 8, background: "var(--dsw-alias-bg-layer-3)", fontFamily: "var(--dsw-font-mono, ui-monospace, monospace)", fontSize: 13, color: "var(--dsw-alias-label-primary)", overflowWrap: "anywhere", userSelect: "all" },
311
+ note: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-label-tertiary)" },
312
+ warn: { margin: 0, fontSize: 12, lineHeight: "18px", color: "var(--dsw-alias-state-warn-label, var(--dsw-alias-label-secondary))" },
313
+ groupTitle: { margin: "20px 0 0", fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)" },
314
+ field: { display: "flex", flexDirection: "column", gap: 6, padding: "12px 0", borderTop: "1px solid var(--dsw-alias-border-l2)" },
315
+ head: { display: "flex", alignItems: "center", gap: 8 },
316
+ fieldLabel: { flex: 1, fontSize: 13, fontWeight: 500, color: "var(--dsw-alias-label-primary)" },
317
+ badges: { display: "inline-flex", alignItems: "center", gap: 8 },
318
+ reset: { cursor: "pointer", background: "none", border: 0, fontSize: 12, color: "var(--dsw-alias-label-secondary)" },
319
+ hint: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12, margin: 0 },
320
+ regen: { display: "flex", alignItems: "center", gap: 10, padding: "10px 0 2px" },
321
+ ok: { margin: 0, fontSize: 13, lineHeight: "20px", color: "var(--dsw-alias-state-success-primary, var(--dsw-alias-label-primary))" },
322
+ pre: { margin: 0, maxHeight: 180, overflow: "auto", padding: "8px 10px", borderRadius: 8, background: "var(--dsw-alias-bg-layer-3)", fontFamily: "var(--dsw-font-mono, ui-monospace, monospace)", fontSize: 12, whiteSpace: "pre-wrap", overflowWrap: "anywhere", color: "var(--dsw-alias-label-secondary)" },
323
+ };
324
+
325
+ /** One entry address with its copy button. */
326
+ function EntryUrl(props) {
327
+ const t = props.t;
328
+ const [copied, setCopied] = react.useState(false);
329
+ return h(
330
+ "div",
331
+ { style: S.urlRow },
332
+ h("code", { style: S.url }, props.url),
333
+ h(
334
+ Button,
335
+ {
336
+ variant: "outline",
337
+ size: "sm",
338
+ onClick: async () => {
339
+ try {
340
+ await writeClipboard(props.url);
341
+ } catch (_error) {
342
+ /* the address stays selectable */
343
+ }
344
+ setCopied(true);
345
+ setTimeout(() => setCopied(false), 1500);
346
+ },
347
+ },
348
+ copied ? t.copied : t.copy,
349
+ ),
350
+ );
351
+ }
352
+
353
+ /** The status panel: running or why not, then the addresses to open remotely. */
354
+ function StatusPanel(props) {
355
+ const t = props.t;
356
+ const status = props.status;
357
+ const state = status === undefined ? "unknown" : status.state;
358
+ const dot = state === "listening" ? "done" : state === "error" || state === "unreachable" ? "error" : state === "disabled" ? "idle" : "ongoing";
359
+ const label = {
360
+ listening: t.listening + (status?.scheme ? " · " + status.scheme + " · :" + status.listenPort : ""),
361
+ starting: t.starting,
362
+ disabled: t.disabled,
363
+ error: t.error,
364
+ unreachable: t.unreachable,
365
+ unknown: t.unknown,
366
+ }[state] ?? state;
367
+ const children = [
368
+ h("div", { key: "line", style: S.statusLine }, h(StateDot, { state: dot }), h("span", null, label)),
369
+ ];
370
+ if (state === "error") {
371
+ const template = t.reasons[status.code];
372
+ const text = template === undefined
373
+ ? status.error
374
+ : template.replace("{port}", String(status.listenPort)).replace("{detail}", String(status.detail ?? ""));
375
+ children.push(h("p", { key: "reason", style: S.reason, title: status.error }, text));
376
+ }
377
+ if (state === "listening" && Array.isArray(status.urls) && status.urls.length > 0) {
378
+ children.push(h("p", { key: "label", style: S.label }, t.entries));
379
+ for (const url of status.urls) children.push(h(EntryUrl, { key: url, url, t }));
380
+ children.push(h("p", { key: "hint", style: S.note }, t.entriesHint));
381
+ if (status.password) children.push(h("p", { key: "pw", style: S.note }, t.passwordOn));
382
+ if (Array.isArray(status.allowFrom) && status.allowFrom.length > 0) {
383
+ children.push(h("p", { key: "allow", style: S.note }, t.allowSummary.replace("{list}", status.allowFrom.join(", "))));
384
+ }
385
+ if (status.lastDenied) children.push(h("p", { key: "denied", style: S.warn }, t.denied.replace("{address}", status.lastDenied.address)));
386
+ if (!status.tls) children.push(h("p", { key: "tls", style: S.warn }, t.tlsNote));
387
+ children.push(h("p", { key: "remote", style: S.note }, t.remoteNote));
388
+ }
389
+ // A save that breaks the listener (a busy port, a bad certificate) happens
390
+ // at the bottom of the form; bring the reason into view.
391
+ const ref = react.useRef(null);
392
+ react.useEffect(() => {
393
+ if (state === "error" && ref.current && typeof ref.current.scrollIntoView === "function") {
394
+ ref.current.scrollIntoView({ block: "nearest", behavior: "smooth" });
395
+ }
396
+ }, [state, status?.error]);
397
+ if (status?.version) children.push(h("p", { key: "version", style: S.note }, "v" + status.version));
398
+ return h("section", { ref, style: S.panel, "data-rap-status": state }, children);
399
+ }
400
+
401
+ /** Text/number input row over the shared primitives field. */
402
+ function ValueField(props) {
403
+ const { state, numeric, t, ...rest } = props;
404
+ return h(SettingsValueField, {
405
+ ...rest,
406
+ ...state,
407
+ numeric,
408
+ overriddenLabel: t.overridden,
409
+ resetLabel: t.reset,
410
+ invalidLabel: numeric ? t.invalidNumber : t.invalid,
411
+ });
412
+ }
413
+
414
+ /** Boolean toggle row; the shared primitives ship no boolean field control. */
415
+ function ToggleField(props) {
416
+ const { label, hint, state, disabled, onEdit, onReset, t } = props;
417
+ return h(
418
+ "div",
419
+ { style: S.field },
420
+ h(
421
+ "div",
422
+ { style: S.head },
423
+ h("label", { style: S.fieldLabel }, label),
424
+ state.overridden
425
+ ? h(
426
+ "span",
427
+ { style: S.badges },
428
+ h(Tag, { tone: "neutral" }, t.overridden),
429
+ h("button", { type: "button", style: S.reset, disabled, onClick: () => onReset() }, t.reset),
430
+ )
431
+ : null,
432
+ h(Switch, {
433
+ checked: state.text === "true",
434
+ disabled,
435
+ label,
436
+ onChange: (next) => onEdit(next ? "true" : "false"),
437
+ }),
438
+ ),
439
+ hint ? h("p", { style: S.hint }, hint) : null,
440
+ );
441
+ }
442
+
443
+ /** The page: a one-liner for the row list, status + grouped form for its own page. */
444
+ function RemoteAccessProxyCard(props) {
445
+ const t = copy();
446
+ if (props.view === "summary") return t.summary;
447
+ const state = props.useRapCard((snapshot) => snapshot);
448
+ // Re-read the status right after a save lands (the server restarts in place).
449
+ const status = useProxyStatus(state.saving ? 1 : 0);
450
+ const field = (entry) => {
451
+ const name = entry.spec.field;
452
+ const fieldState = state[name] ?? { text: "", overridden: false, invalid: false };
453
+ const [label, hint] = t.fields[name];
454
+ const shared = {
455
+ key: name,
456
+ id: "plugin-config-remote-access-" + name,
457
+ label,
458
+ hint,
459
+ t,
460
+ state: fieldState,
461
+ disabled: !state.writable,
462
+ onReset: () => props.resetField(name),
463
+ onEdit: (value) => props.edit(name, value),
464
+ };
465
+ if (entry.kind === "boolean") return h(ToggleField, shared);
466
+ const input = h(ValueField, { ...shared, numeric: entry.kind === "number", placeholder: entry.auto ? t.auto : undefined });
467
+ if (entry.secret) return h("div", { key: name, "data-rap-secret": "" }, input);
468
+ if (!entry.presets) return input;
469
+ return h(
470
+ "div",
471
+ { key: name },
472
+ input,
473
+ h(
474
+ "div",
475
+ { style: S.regen },
476
+ h(Button, { variant: "outline", size: "sm", disabled: !state.writable, onClick: () => props.edit(name, PRIVATE_NETWORKS) }, t.presetPrivate),
477
+ h(Button, { variant: "ghost", size: "sm", disabled: !state.writable, onClick: () => props.edit(name, "") }, t.presetAny),
478
+ ),
479
+ );
480
+ };
481
+ const body = [];
482
+ for (const group of GROUPS) {
483
+ body.push(h("h4", { key: "title-" + group.title, style: S.groupTitle }, t[group.title]));
484
+ for (const entry of group.fields) if (!entry.hidden) body.push(field(entry));
485
+ if (group.gate) {
486
+ body.push(
487
+ h(
488
+ "div",
489
+ { key: "regen", style: S.regen },
490
+ h(
491
+ Button,
492
+ {
493
+ variant: "outline",
494
+ size: "sm",
495
+ disabled: !state.writable,
496
+ onClick: () => {
497
+ props.edit("secretPath", randomToken(12));
498
+ props.edit("cookieValue", randomToken(24));
499
+ },
500
+ },
501
+ t.regenerate,
502
+ ),
503
+ h("span", { style: S.hint }, t.regenerateHint),
504
+ ),
505
+ );
506
+ }
507
+ }
508
+ return h(
509
+ "div",
510
+ null,
511
+ h("style", null, SECRET_STYLE),
512
+ h(StatusPanel, { status, t }),
513
+ h(SettingsForm, { labels: t.form, state, onSave: props.save, onDiscard: props.discard }, body),
514
+ );
515
+ }
516
+
517
+ // ---- registration ------------------------------------------------------
518
+ const inject = ["slots", "configForms"];
519
+
520
+ function apply(ctx) {
521
+ const card = new RemoteAccessProxyCardController(ctx.configForms.get(NS));
522
+ ctx.effect(() => () => card.dispose(), "dsh-remote-access-proxy: form subscription");
523
+ ctx.effect(
524
+ () =>
525
+ ctx.configForms.whileServed([NS], () =>
526
+ ctx.slots.inject("plugins.row.config", () =>
527
+ ctx.slots.register(
528
+ { name: "plugins.row.config", key: ROW_KEY, inject: () => card.inject() },
529
+ RemoteAccessProxyCard,
530
+ ),
531
+ ),
532
+ ),
533
+ "dsh-remote-access-proxy: configuration page",
534
+ );
535
+ }
536
+
537
+ exports.apply = apply;
538
+ exports.inject = inject;
539
+ return module.exports;
540
+ },
541
+ });
@@ -0,0 +1,43 @@
1
+ # dsh-remote-access-proxy — profile-layer configuration example (DSH 0.1.7+)
2
+ #
3
+ # DSH 0.1.7 removed `settings.yaml`. A plugin's configuration now lives in the
4
+ # active profile's own `cordis.patch.yml`, addressed by the profile entry id —
5
+ # here `remote-access-proxy`, the row this bundle declares.
6
+ #
7
+ # You normally do NOT need this file: open the Plugins page (sidebar →
8
+ # Plugins), open the `dsh-remote-access-proxy` bundle, open the
9
+ # `remote-access-proxy` row and use its 配置 page. That writes the same rows
10
+ # into `data/profiles/<profile>/cordis.patch.yml` for you.
11
+ #
12
+ # Merge only the keys you want to pin — a key left out keeps the bundle layer's
13
+ # value and then the schema default. Rows are addressed by id, so this user
14
+ # layer overrides the bundle's row wholesale for the keys it states.
15
+ #
16
+ # `secretPath` and the internal `cookieValue` signing secret are generated and written back on first start
17
+ # when left empty; there is no need to fill them in.
18
+
19
+ - id: remote-access-proxy
20
+ config:
21
+ enabled: true
22
+ # 端口;代理固定监听本机所有网卡(IPv4 + IPv6)
23
+ listenPort: 40443
24
+ # 允许连接的客户端地址:IP、网段(CIDR)或 a-b 范围,逗号/空格分隔;
25
+ # 留空 = 不限制。本机回环始终允许。
26
+ allowFrom: 192.168.0.0/16, 100.64.0.0/10
27
+ # 可选访问密码;留空 = 只靠随机路径
28
+ password: ""
29
+ # 随机门禁路径(4–32 位字母数字、连字符或下划线;留空自动生成)
30
+ secretPath: CHANGE_ME_RANDOM_PATH
31
+ # 上游 DSH 本体。留空 / 0 = 自动跟随当前实例(启动时读 webServer 实际绑定的
32
+ # 端口,再退到 webStartup;桌面端启动器传 --port 0,真实端口只在 webServer 上);
33
+ # 显式值优先。
34
+ upstreamHost: ""
35
+ upstreamPort: 0
36
+ # TLS:true 时用自签 pfx(浏览器需接受一次证书警告);false = 明文 http
37
+ tlsEnabled: true
38
+ tlsPfxPath: .\data\certs\local-proxy.pfx
39
+ tlsPassphrase: CHANGE_ME_PFX_PASSPHRASE
40
+ # access.log 轮转:写入将超过 logMaxBytes 就先轮转,保留 logKeep 份旧文件
41
+ # (access.log.1…N);0 = 不轮转 / 不留历史。默认 1048576 字节、3 份。
42
+ logMaxBytes: 1048576
43
+ logKeep: 3
@@ -0,0 +1,18 @@
1
+ # The dsh-remote-access-proxy profile bundle patch: one row over the profile
2
+ # root. A later bundle layer or the user's own profile cordis.patch.yml can
3
+ # override it by id (the last write wins per row).
4
+ #
5
+ # The row names the PACKAGE, not a `./` path: DSH resolves a relative name to an
6
+ # absolute file URL, and the configuration editor copies that absolute URL into
7
+ # the user patch on its first write — which breaks the row as soon as the DSH
8
+ # folder moves (a portable install). A package name resolves from the profile's
9
+ # node_modules wherever it lives. The same package is a dual-face module: its
10
+ # "." export is the host plugin, its "./client" export the configuration page,
11
+ # so the row gets the package's title, description and icon in the Plugins page.
12
+ #
13
+ # The row id is load-bearing: a plugin's settings namespace IS its profile entry
14
+ # id, so renaming it moves the settings form and orphans saved values.
15
+
16
+ - insert:
17
+ - id: remote-access-proxy
18
+ name: dsh-remote-access-proxy
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="none">
2
+ <path d="M14 40V22a10 10 0 0 1 20 0v18" stroke="#4d6bfe" stroke-width="4" stroke-linecap="round"/>
3
+ <circle cx="24" cy="31" r="4" fill="#4d6bfe"/>
4
+ </svg>
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Remote Access Proxy",
4
+ "description": "Use DSH from your phone or another computer: a secret-path entry with an optional password and client allow list, bridged to DSH's per-start launch token."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "远程访问代理",
4
+ "description": "在手机或其它电脑上用浏览器访问 DSH:随机路径入口,可选访问密码和来源地址白名单,自动桥接 DSH 的每进程启动 token。"
5
+ }
6
+ }