diff --git a/README.en.md b/README.en.md
index c5a23f8..6c97d63 100644
--- a/README.en.md
+++ b/README.en.md
@@ -53,6 +53,7 @@ What it looks like — the phone shows the exact same UI as your computer, live:
| ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways; built-in WS heartbeat keep-alive (defeats silent NAT/battery link drops with auto-reconnect) |
| 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
| 🧭 Optional right sidebar | Shows the native right-sidebar entry on mobile; disable it for a compact phone header or keep it available alongside the terminal dock on an unfolded display |
+| 🛠️ Remote settings | Phone/remote pages can **edit plugin config and models** (issue #58). **Off by default** (remote pages are read-only; change settings on the computer itself). |
| 📁 File browser | The mobile "Files" entries need a host-side explorer panel (a dsh-web-ui component); on stock DSH without it the entries are auto-hidden instead of doing nothing |
| 🗜️ Transfer compression | Large JSON responses are gzip/brotli'd on the fly (17MB session history → ~1MB; brotli quality 6: fast and bandwidth-friendly) — faster loads, less mobile data |
| 🔁 Tunnel auto-restore | After a DSH restart the previously-running public tunnel comes back automatically |
@@ -89,6 +90,8 @@ Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LA
>
> The LAN PIN is **on by default** (security-first). If you're the only user and find typing it every time annoying, flip "LAN access PIN" to **Off** in the LAN block — LAN scans then connect directly with no PIN (LAN-only devices; the **public tunnel always requires a PIN**, unaffected).
>
+> "**Remote settings**" is **off by default**: plugin config and models are read-only on phone/remote pages — change them on the computer itself (127.0.0.1). Flip it **On** in the LAN block and the phone can edit plugin config and models directly.
+>
> After logging in once, the phone **won't ask again**: as long as the computer's dsh web keeps running, reopening the phone needs no PIN (**a dsh web restart/update asks for it once more**).
>
> Advanced option: auto-detection may not pick a reachable address for Tailscale/VPN setups. You can select a detected IP from the "LAN address" dropdown; normally no change is needed.
diff --git a/README.md b/README.md
index 470dc21..20355ac 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
| ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) |
| 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
| 🧭 可选右边栏 | 手机端显示原生右边栏入口;普通手机可在设置中关闭以保持紧凑,折叠屏展开后可更方便地同时使用终端底栏和右边栏 |
+| 🛠️ 远程设置 | 手机/远程页面**可编辑设置**(插件配置、模型管理等,issue #58)。默认**关闭**(远程只读,设置仅本机可改);开启后安全边界为访问密码——公网强制、局域网建议开启 |
| 📁 文件浏览 | 移动端「文件浏览」入口需要宿主提供 explorer 面板(dsh-web-ui 组件);官方 DSH 未内置时入口自动隐藏,不会出现"点了没反应" |
| 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
| 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 |
@@ -89,6 +90,8 @@ npx @deepseek-ai/dsh web
>
> 局域网密码**默认开启**(安全优先)。如果只有自己用、嫌每次输密码麻烦,可在设置页局域网区块把「局域网访问密码」切到**关**——之后局域网扫码直连、无需密码(仅同一局域网设备可访问;**公网始终要密码**,不受影响)。
>
+> 「**远程设置**」默认**关**:远程页面(手机)里的插件配置、模型管理为只读,改动请在电脑本机(127.0.0.1)操作。在设置页局域网区块把它切到**开**,手机即可直接编辑插件配置与模型管理。
+>
> 手机登录一次后**长期免输**:只要电脑上的 dsh web 不重启,再次打开手机不用再输入(**dsh web 重启/更新后需重新输入一次**)。
>
> 高级选项:自动识别在 Tailscale/VPN 等场景下可能选不到可达地址。可在「局域网地址」下拉框手动选择已检测到的 IP;一般不需要修改。
diff --git a/client/api.js b/client/api.js
index 648a56d..a1de863 100644
--- a/client/api.js
+++ b/client/api.js
@@ -13,6 +13,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
restart: 'pocket.restart',
lanTokenRefresh: 'token.lanRefresh',
lanAuthSetEnabled: 'lanAuth.setEnabled',
+ trustSetEnabled: 'trust.setEnabled',
lanSetOverride: 'lan.setOverride',
lanSetEnabled: 'lan.setEnabled',
mobileRightbarSetEnabled: 'mobile.rightbar.setEnabled',
diff --git a/client/client.js b/client/client.js
index d614db0..f2d0288 100644
--- a/client/client.js
+++ b/client/client.js
@@ -28,7 +28,7 @@ var __copyProps = (to, from, except, desc) => {
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
-// client/index.jsx
+// index.jsx
var index_exports = {};
__export(index_exports, {
apply: () => apply,
@@ -39,7 +39,7 @@ __export(index_exports, {
module.exports = __toCommonJS(index_exports);
var import_react2 = require("react");
-// client/api.js
+// api.js
var POCKET_RPC_CHANNEL = "/dsh-pocket";
var MOBILE_RIGHTBAR_ATTRIBUTE = "data-dsh-pocket-mobile-rightbar";
var MOBILE_RIGHTBAR_EVENT = "dsh-pocket:mobile-rightbar";
@@ -53,6 +53,7 @@ var POCKET_ENDPOINTS = Object.freeze({
restart: "pocket.restart",
lanTokenRefresh: "token.lanRefresh",
lanAuthSetEnabled: "lanAuth.setEnabled",
+ trustSetEnabled: "trust.setEnabled",
lanSetOverride: "lan.setOverride",
lanSetEnabled: "lan.setEnabled",
mobileRightbarSetEnabled: "mobile.rightbar.setEnabled",
@@ -108,7 +109,7 @@ function redactStatus(s) {
};
}
-// client/mobile/MobileNavToggle.tsx
+// mobile/MobileNavToggle.tsx
var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
function MobileNavToggle({ toggleSidebar, t }) {
const toggleExplorer = () => {
@@ -143,11 +144,11 @@ function MobileNavToggle({ toggleSidebar, t }) {
));
}
-// client/mobile/MobileNavOverlay.tsx
+// mobile/MobileNavOverlay.tsx
var import_react = require("react");
var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
-// client/mobile/nav-targets.mjs
+// mobile/nav-targets.mjs
var DRAWER_SELECTOR = '[data-mobile-nav="frame"] > :first-child';
var TOGGLE_SELECTOR = '[data-mobile-nav="toggle"]';
var NAV_TARGETS = [
@@ -180,7 +181,7 @@ function isOverlayTap(target) {
return target.closest(OVERLAY_SELECTOR) !== null;
}
-// client/mobile/MobileNavOverlay.tsx
+// mobile/MobileNavOverlay.tsx
var MOBILE_QUERY = "(max-width: 1023px)";
function useMobile() {
const [mobile, setMobile] = (0, import_react.useState)(() => window.matchMedia(MOBILE_QUERY).matches);
@@ -371,7 +372,7 @@ function MobileNavOverlay({ toggleSidebar, t }) {
));
}
-// client/mobile/MobileDrawerFooter.tsx
+// mobile/MobileDrawerFooter.tsx
var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t }) {
const sessionId = useSessions((state) => state.current);
@@ -407,7 +408,7 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t
));
}
-// client/mobile/fileGuard.ts
+// mobile/fileGuard.ts
var GUARD_MSG = "\u624B\u673A\u4E0A\u65E0\u6CD5\u76F4\u63A5\u6253\u5F00\u7535\u8111\u4E0A\u7684\u6587\u4EF6";
var WS_LABELS = ["\u6DFB\u52A0\u5DE5\u4F5C\u533A", "\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026", "Add workspace", "Add workspace\u2026"];
var COPY_LABEL = "\u590D\u5236";
@@ -570,7 +571,7 @@ function startFileGuard(readFile) {
};
}
-// client/mobile/mobile.css.ts
+// mobile/mobile.css.ts
var MOBILE_CSS = `
/* ---------- base control styles (rendered at any width, hidden where unused) ---------- */
@@ -1561,7 +1562,7 @@ var MOBILE_CSS = `
`;
-// client/mobile/locales.ts
+// mobile/locales.ts
var NS = "mobileNav";
var zh = {
"open": "\u6253\u5F00\u76EE\u5F55",
@@ -1578,7 +1579,7 @@ var en = {
"files": "Files"
};
-// client/mobile/layout-mode.mjs
+// mobile/layout-mode.mjs
function resolveLayout({ urlValue, stored, narrowMatch }) {
const url = String(urlValue ?? "").trim();
if (url === "desktop") return "desktop";
@@ -1602,7 +1603,7 @@ function persistLayoutFromUrl(urlValue) {
}
}
-// client/mobile/mobile-apply.tsx
+// mobile/mobile-apply.tsx
function mobileApply(ctx) {
const urlValue = new URL(window.location.href).searchParams.get("dsh-layout") ?? "";
const narrowMQ = window.matchMedia("(max-width: 1023px)");
@@ -1879,7 +1880,7 @@ function mobileApply(ctx) {
}, MobileDrawerFooter));
}
-// client/pocket-locales.js
+// pocket-locales.js
var NS2 = "pocket";
var zh2 = {
"section": "\u624B\u673A\u8BBF\u95EE",
@@ -1943,6 +1944,9 @@ var zh2 = {
"pinInvalid": "\u5BC6\u7801\u5FC5\u987B\u662F 8\u201364 \u4F4D\u82F1\u6587\u5B57\u6BCD\u6216\u6570\u5B57",
"pinCustomHint": "\u81EA\u5B9A\u4E49\u540E\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0",
"lanPinOff": "\u{1F513} \u5BC6\u7801\u5DF2\u5173\u95ED\uFF1A\u626B\u7801\u76F4\u8FDE\uFF0C\u65E0\u9700\u5BC6\u7801\uFF08\u4EC5\u540C\u4E00\u5C40\u57DF\u7F51\u8BBE\u5907\u53EF\u8BBF\u95EE\uFF1B\u516C\u7F51\u4ECD\u8981\u5BC6\u7801\uFF09",
+ "trustClients": "\u8FDC\u7A0B\u8BBE\u7F6E\uFF08\u63D2\u4EF6\u914D\u7F6E / \u6A21\u578B\u7BA1\u7406\uFF09",
+ "trustClientsOn": "\u2705 \u624B\u673A/\u8FDC\u7A0B\u9875\u9762\u53EF\u7F16\u8F91\u63D2\u4EF6\u914D\u7F6E\u4E0E\u6A21\u578B\u7BA1\u7406\u3002",
+ "trustClientsOff": "\u{1F512} \u8FDC\u7A0B\u9875\u9762\u53EA\u8BFB\uFF1A\u8BBE\u7F6E\u8BF7\u5728\u7535\u8111\u672C\u673A\uFF08127.0.0.1\uFF09\u4FEE\u6539\u3002",
"lanStarting": "\u4EE3\u7406\u672A\u5C31\u7EEA\u2026",
"mobileRightbar": "\u624B\u673A\u7AEF\u53F3\u8FB9\u680F",
"mobileRightbarHint": "\u663E\u793A\u539F\u751F\u53F3\u8FB9\u680F\u5165\u53E3\uFF1B\u666E\u901A\u624B\u673A\u53EF\u6309\u9700\u5173\u95ED\uFF0C\u6298\u53E0\u5C4F\u5C55\u5F00\u540E\u4F7F\u7528\u66F4\u65B9\u4FBF",
@@ -2041,6 +2045,9 @@ var en2 = {
"pinInvalid": "PIN must be 8\u201364 characters (letters and digits only)",
"pinCustomHint": "custom PINs are not rotated on tunnel start",
"lanPinOff": "\u{1F513} PIN off \u2014 scan & go, no PIN (LAN devices only; public still requires PIN)",
+ "trustClients": "Remote settings (plugin config / models)",
+ "trustClientsOn": "\u2705 Phone/remote pages can edit plugin config and models.",
+ "trustClientsOff": "\u{1F512} Remote pages are read-only: change settings on the computer itself (127.0.0.1).",
"lanStarting": "Proxy starting\u2026",
"mobileRightbar": "Mobile right sidebar",
"mobileRightbarHint": "Show the native right-sidebar entry; disable it for a compact phone header or keep it on for an unfolded display",
@@ -2078,7 +2085,7 @@ var en2 = {
"feedback": "\u{1F64F} Questions? Open an issue on GitHub"
};
-// client/index.jsx
+// index.jsx
var name = "dsh-pocket";
var inject = ["slots", "connection", "layout", "locale", "sessionLogDownload"];
function fmt(t, key, vars) {
@@ -2305,6 +2312,13 @@ function PocketSettingsTab({ rpcCall, t }) {
} catch {
}
};
+ const setTrust = async (on) => {
+ try {
+ const r = await call(POCKET_ENDPOINTS.trustSetEnabled, { on });
+ setStatus((s) => ({ ...s, trustProxiedClients: r.trustProxiedClients }));
+ } catch {
+ }
+ };
const setMobileRightbar = async (on) => {
try {
const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on });
@@ -2515,6 +2529,16 @@ function PocketSettingsTab({ rpcCall, t }) {
status?.lanPinCustom ? (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null
)
),
+ // 远程设置开关(issue #58):信任经代理客户端 → 设置页/模型管理远程可编辑
+ row(
+ t("trustClients"),
+ Switch(status?.trustProxiedClients === true, () => setTrust(status?.trustProxiedClients !== true)),
+ (0, import_react2.createElement)(
+ "div",
+ { style: { ...styles.muted, marginTop: 6 } },
+ status?.trustProxiedClients === true ? t("trustClientsOn") : t("trustClientsOff")
+ )
+ ),
// 高级:手动选地址(默认收起)
row(
t("advAddress"),
diff --git a/client/index.jsx b/client/index.jsx
index 1227511..5bdb18b 100644
--- a/client/index.jsx
+++ b/client/index.jsx
@@ -266,6 +266,15 @@ function PocketSettingsTab({ rpcCall, t }) {
} catch { /* 忽略 */ }
};
+ // 远程设置开关(issue #58):开启后手机/远程页面的插件配置、模型管理恢复可编辑;
+ // 关闭回到只读(设置仅本机可改)。安全边界为访问密码。
+ const setTrust = async (on) => {
+ try {
+ const r = await call(POCKET_ENDPOINTS.trustSetEnabled, { on });
+ setStatus((s) => ({ ...s, trustProxiedClients: r.trustProxiedClients }));
+ } catch { /* 忽略 */ }
+ };
+
const setMobileRightbar = async (on) => {
try {
const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on });
@@ -467,6 +476,11 @@ function PocketSettingsTab({ rpcCall, t }) {
customBtn('lan'),
status?.lanPinCustom ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
))),
+ // 远程设置开关(issue #58):信任经代理客户端 → 设置页/模型管理远程可编辑
+ row(t('trustClients'),
+ Switch(status?.trustProxiedClients === true, () => setTrust(status?.trustProxiedClients !== true)),
+ h('div', { style: { ...styles.muted, marginTop: 6 } },
+ status?.trustProxiedClients === true ? t('trustClientsOn') : t('trustClientsOff'))),
// 高级:手动选地址(默认收起)
row(t('advAddress'),
h('button', { style: { border: 'none', background: 'none', font: 'inherit', cursor: 'pointer', fontSize: 12, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', padding: 0 }, onClick: () => setAdvOpen((v) => !v) },
diff --git a/client/pocket-locales.js b/client/pocket-locales.js
index effee90..c71032f 100644
--- a/client/pocket-locales.js
+++ b/client/pocket-locales.js
@@ -65,6 +65,9 @@ export const zh = {
'pinInvalid': '密码必须是 8–64 位英文字母或数字',
'pinCustomHint': '自定义后开启公网不再自动换新',
'lanPinOff': '🔓 密码已关闭:扫码直连,无需密码(仅同一局域网设备可访问;公网仍要密码)',
+ 'trustClients': '远程设置(插件配置 / 模型管理)',
+ 'trustClientsOn': '✅ 手机/远程页面可编辑插件配置与模型管理。',
+ 'trustClientsOff': '🔒 远程页面只读:设置请在电脑本机(127.0.0.1)修改。',
'lanStarting': '代理未就绪…',
'mobileRightbar': '手机端右边栏',
'mobileRightbarHint': '显示原生右边栏入口;普通手机可按需关闭,折叠屏展开后使用更方便',
@@ -165,6 +168,9 @@ export const en = {
'pinInvalid': 'PIN must be 8–64 characters (letters and digits only)',
'pinCustomHint': 'custom PINs are not rotated on tunnel start',
'lanPinOff': '🔓 PIN off — scan & go, no PIN (LAN devices only; public still requires PIN)',
+ 'trustClients': 'Remote settings (plugin config / models)',
+ 'trustClientsOn': '✅ Phone/remote pages can edit plugin config and models.',
+ 'trustClientsOff': '🔒 Remote pages are read-only: change settings on the computer itself (127.0.0.1).',
'lanStarting': 'Proxy starting…',
'mobileRightbar': 'Mobile right sidebar',
'mobileRightbarHint': 'Show the native right-sidebar entry; disable it for a compact phone header or keep it on for an unfolded display',
diff --git a/lib/index.js b/lib/index.js
index bcfe433..c90924c 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
import { installPocketRpc } from './web-rpc.js';
import { restartHost } from './restart.js';
import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
-import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, mobileRightbarEnabled, setMobileRightbarEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath } from './settings.mjs';
+import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, mobileRightbarEnabled, setMobileRightbarEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, cloudflaredPath, trustProxiedClients, setTrustProxiedClients } from './settings.mjs';
const name = 'dsh-pocket';
const inject = ['connection', 'webServer'];
@@ -291,6 +291,8 @@ export function apply(ctx, config = {}, internals = {}) {
internals,
getLanIpOverride: () => lanIpOverride(),
getLanEnabled: () => lanEnabled(),
+ // 经代理客户端信任开关(issue #58):代理每次请求实时读,切换立即生效
+ trustProxiedClients: () => trustProxiedClients(),
// 桌面端**不再**注入 dsh-desktop-* 标记(issue #76):
// - 旧版 dsh-plugin-desktop 缺 mode/platform 会抛错,当初正是为此加了补丁(issue #3/#4);
// - 但 2.0.3 起,mode 与 platform **同时缺失**时 parseDesktopClientEnvironment 直接返回
@@ -355,6 +357,8 @@ export function apply(ctx, config = {}, internals = {}) {
refreshLanToken: () => refreshLanToken(),
getLanAuthEnabled: () => lanAuthEnabled(),
setLanAuthEnabled: (on) => setLanAuthEnabled(on),
+ getTrustEnabled: () => trustProxiedClients(),
+ setTrustEnabled: (on) => setTrustProxiedClients(on),
getLanEnabled: () => lanEnabled(),
setLanEnabled: (on) => setLanEnabled(on),
getMobileRightbarEnabled: () => mobileRightbarEnabled(),
diff --git a/lib/proxy.mjs b/lib/proxy.mjs
index 8e29df1..3155d82 100644
--- a/lib/proxy.mjs
+++ b/lib/proxy.mjs
@@ -51,6 +51,19 @@ export const RANDOM_UUID_POLYFILL = ``;
+/**
+ * 信任版 transport shim(issue #58):web shell 从不创建 __DSH_TRANSPORT__,这里在
+ * 缺失时创建 { ownsHost: true } —— dsh-client-connection 以 transport?.ownsHost===true
+ * 视作 loopback(client.js 的 isLoopback 计算),使经代理访问时 settings/plugin-config
+ * 恢复 host 持久化(issue #58 作者等待的「可注入 loopback 标记」其实已存在)。
+ * 仅在 __DSH_TRANSPORT__ 缺失时创建(桌面端宿主自己会提供 transport,不受影响——
+ * 这也是 #87 方案在 #105 场景出问题、而本 shim 不会的原因)。
+ * 默认关闭,由设置页「远程设置」开关(trustProxiedClients)控制。开启后经本代理的
+ * 所有客户端都被客户端栅栏视为受信;服务器侧栅栏又因 Host/Origin 改写而放行
+ * → 安全边界回落到 pocket 访问密码(公网强制;局域网按开关)。
+ */
+export const TRUSTED_TRANSPORT_SHIM = ``;
+
const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
/**
@@ -708,7 +721,7 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise}>}
*/
-export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '', handshakeLimit } = {}) {
+export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '', handshakeLimit, trustProxiedClients = () => false } = {}) {
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
// 会话握手重试计数(issue #91):Safari 在 http://IP 源上丢 3xx 的 cookie → 死循环
const handshake = createHandshakeTracker(
@@ -841,6 +854,13 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
}
}
const headers = loopbackAuthority({ ...req.headers }, upstream);
+ // HTML 文档请求强制上游返回未压缩体:注入分支只能改写未压缩 HTML,而浏览器导航
+ // 默认带 accept-encoding: gzip,上游(dsh web)会把文档压缩 → isCompressed 命中
+ // → 注入被整体跳过,polyfill 与 transport shim 对真实浏览器等于没生效。
+ // 文档本身很小,identity 无带宽损失;JS/CSS 等子资源不注入,仍透传压缩。
+ if (injectHtml && isHtmlRequest(req)) {
+ headers['accept-encoding'] = 'identity';
+ }
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
// 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
@@ -940,14 +960,17 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
return;
}
// 只给**未压缩**的 HTML 文档注入(SSE/WS/JS/CSS 原样透传;压缩流注入会损坏页面);
- // 注入后修正 Content-Length
+ // 注入后修正 Content-Length。信任开关开启时追加 TRUSTED_TRANSPORT_SHIM
+ // (每次请求实时读设置,开关切换立即生效,无需重启代理)。
if (injectHtml && contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
+ const trusted = trustProxiedClients?.() === true;
+ const injectNow = trusted ? injectHtml + TRUSTED_TRANSPORT_SHIM : injectHtml;
const chunks = [];
proxyRes.on('data', (c) => chunks.push(c));
proxyRes.on('end', () => {
let html = Buffer.concat(chunks).toString('utf8');
if (!html.includes(INJECT_MARK)) {
- html = html.replace(/]*>/i, (m) => `${m}${injectHtml}`);
+ html = html.replace(/]*>/i, (m) => `${m}${injectNow}`);
}
const out = Buffer.from(html, 'utf8');
const outHeaders = { ...proxyRes.headers };
diff --git a/lib/service.mjs b/lib/service.mjs
index 4312153..02d8dd5 100644
--- a/lib/service.mjs
+++ b/lib/service.mjs
@@ -179,6 +179,8 @@ export function createPocketService({
getLanEnabled = () => true,
/** 代理注入 HTML 的内容(桌面端补丁等由 lib/index.js 传入;默认 randomUUID polyfill) */
injectHtml,
+ /** 经代理客户端信任开关(issue #58):() => boolean;默认关闭。开启时注入 ownsHost shim */
+ trustProxiedClients = () => false,
/** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
auth,
/** @type {() => string} dsh web 浏览器会话启动 token(issue #77;老版本返回空字符串) */
@@ -276,6 +278,8 @@ export function createPocketService({
...(auth ? { auth } : {}),
// 每次请求实时读开关:设置页切换后立即生效,无需重启代理
lanAccessEnabled: () => getLanEnabled(),
+ // 经代理客户端信任开关(issue #58):每次请求实时读,切换立即生效
+ trustProxiedClients,
// dsh web 浏览器会话启动 token(issue #77):实时取,新版 dsh 才有
...(launchToken ? { launchToken } : {}),
});
diff --git a/lib/settings.mjs b/lib/settings.mjs
index 5b71531..690acd5 100644
--- a/lib/settings.mjs
+++ b/lib/settings.mjs
@@ -4,6 +4,7 @@
// - lanEnabled 局域网访问总开关(默认开启):关闭后局域网扫码/链接直接失效(代理拒绝局域网 Host)
// - lanAuthEnabled 局域网访问密码开关(issue #24),默认开启
// - mobileRightbarEnabled 手机端右边栏入口(默认开启)
+// - trustProxiedClients 经代理客户端信任开关(issue #58),默认关闭
// - publicPinCustom 公网密码是否用户自定义(issue #33),自定义后不自动轮换
// - lanPinCustom 局域网密码是否用户自定义(issue #33)
// - tunnelMode 公网隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
@@ -65,6 +66,26 @@ export function setLanAuthEnabled(on) {
return s.lanAuthEnabled;
}
+/**
+ * 经代理客户端信任开关(issue #58):默认**关闭**(文件缺失/损坏都视为关闭)。
+ * 开启后代理在注入 HTML 时附 TRUSTED_TRANSPORT_SHIM:web shell 从不创建
+ * __DSH_TRANSPORT__,缺一个 { ownsHost: true }——dsh-client-connection 以
+ * transport?.ownsHost===true 视作 loopback,于是手机/远程页面的设置(插件配置、
+ * 模型管理)恢复 host 持久化,而不是降级 memory 只读。开启后经代理的所有客户端
+ * 都被客户端栅栏视为受信,安全边界回落到访问密码(公网强制;局域网按开关)。
+ */
+export function trustProxiedClients() {
+ return readSettings().trustProxiedClients === true;
+}
+
+/** 设置经代理客户端信任开关,返回新状态(持久化)。 */
+export function setTrustProxiedClients(on) {
+ const s = readSettings();
+ s.trustProxiedClients = !!on;
+ writeSettings(s);
+ return s.trustProxiedClients;
+}
+
/** 手机端右边栏入口:默认开启,可按需关闭以保持更紧凑的标题栏。 */
export function mobileRightbarEnabled() {
return readSettings().mobileRightbarEnabled !== false;
diff --git a/lib/web-rpc.js b/lib/web-rpc.js
index 42132a8..a4badd8 100644
--- a/lib/web-rpc.js
+++ b/lib/web-rpc.js
@@ -267,7 +267,7 @@ export function killHint(port) {
* 优先直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+ 兼容路径),
* 不可用时回退 ctx.connection.rpc.handle(旧版 dsh 兼容路径)。
* wire 协议两条路径完全一致:client-request → handler → server-response。 */
-export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getMobileRightbarEnabled = null, setMobileRightbarEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
+export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getTrustEnabled = null, setTrustEnabled = null, getLanEnabled = null, setLanEnabled = null, getMobileRightbarEnabled = null, setMobileRightbarEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
const pocketRpcHandler = async (endpoint, payload = {}, signal) => {
if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
@@ -284,6 +284,7 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
accessToken: getToken?.() ?? null,
lanToken: getLanToken?.() ?? null,
lanAuthEnabled: getLanAuthEnabled?.() ?? true,
+ trustProxiedClients: getTrustEnabled?.() ?? false,
lanEnabled: getLanEnabled?.() ?? true,
mobileRightbarEnabled: getMobileRightbarEnabled?.() ?? true,
publicPinCustom: getPinCustom?.('public') ?? false,
@@ -306,6 +307,13 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
if (enabled === undefined) return fail('bad-request', '局域网密码开关不可用 | LAN PIN switch unavailable');
return ok({ lanAuthEnabled: enabled });
}
+ if (endpoint === POCKET_ENDPOINTS.trustSetEnabled) {
+ // 经代理客户端信任开关(issue #58):开启后设置页/模型管理等远程编辑恢复可用,
+ // 安全边界回落到访问密码;关闭回到原行为(远程只读,仅本机可改)。
+ const enabled = setTrustEnabled?.(payload?.on === true);
+ if (enabled === undefined) return fail('bad-request', '信任开关不可用 | trust switch unavailable');
+ return ok({ trustProxiedClients: enabled });
+ }
if (endpoint === POCKET_ENDPOINTS.lanSetEnabled) {
const enabled = setLanEnabled?.(payload?.on === true);
if (enabled === undefined) return fail('bad-request', '局域网访问开关不可用 | LAN access switch unavailable');
diff --git a/test/proxy.test.js b/test/proxy.test.js
index 77fe8c3..52889e7 100644
--- a/test/proxy.test.js
+++ b/test/proxy.test.js
@@ -422,6 +422,78 @@ test('压缩 HTML(gzip)不注入 polyfill——防止损坏压缩流', async
}
});
+test('HTML 导航请求向上游要求 identity,压缩文档也能注入(上游按 accept-encoding 压缩的场景)', async () => {
+ const zlib = await import('node:zlib');
+ const up = createServer((req, res) => {
+ if (req.url === '/') {
+ // 模拟 dsh web:客户端带 gzip 时返回压缩文档
+ if (String(req.headers['accept-encoding'] ?? '').includes('gzip')) {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
+ res.end(zlib.gzipSync('gz'));
+ } else {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end('identity');
+ }
+ } else {
+ res.writeHead(200, { 'content-type': 'application/javascript' });
+ res.end('console.log("asset");');
+ }
+ });
+ await new Promise((r) => up.listen(0, '127.0.0.1', r));
+ const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
+ try {
+ // 用原始 http.request(不带 undici 自动解压)拿真实字节
+ const raw = await new Promise((resolve, reject) => {
+ const req = httpRequest({ host: '127.0.0.1', port: proxy.port, path: '/', headers: { accept: 'text/html', 'accept-encoding': 'gzip' } }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+ assert.equal(raw.headers['content-encoding'], undefined, '导航响应未压缩(可注入)');
+ assert.ok(raw.body.toString('utf8').includes('randomUUID'), '浏览器导航也能拿到注入');
+ assert.ok(raw.body.toString('utf8').includes('identity'), '注入的是上游未压缩文档');
+ } finally {
+ await proxy.close();
+ await new Promise((r) => up.close(r));
+ }
+});
+
+test('TRUSTED_TRANSPORT_SHIM(issue #58):默认关闭,开启后注入 ownsHost 标记,运行时切换立即生效', async () => {
+ const up = createServer((req, res) => {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end('xapp');
+ });
+ await new Promise((r) => up.listen(0, '127.0.0.1', r));
+ const flag = { v: false };
+ let off = null;
+ let on = null;
+ try {
+ off = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
+ const htmlOff = await (await fetch(`http://127.0.0.1:${off.port}/`)).text();
+ assert.ok(!htmlOff.includes('ownsHost'), '默认(关闭)不注入 ownsHost');
+ assert.ok(htmlOff.includes('data-dsh-pocket-transport-shim'), '基础 transport shim 仍在');
+
+ on = await createPocketProxy({
+ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port },
+ trustProxiedClients: () => flag.v,
+ });
+ flag.v = true;
+ const htmlOn = await (await fetch(`http://127.0.0.1:${on.port}/`)).text();
+ assert.ok(htmlOn.includes('ownsHost:true'), '开启后注入 ownsHost:true');
+ assert.ok(htmlOn.indexOf('ownsHost:true') < htmlOn.indexOf(''), '注入在 head 内、bundle 之前');
+ flag.v = false;
+ const htmlOff2 = await (await fetch(`http://127.0.0.1:${on.port}/`)).text();
+ assert.ok(!htmlOff2.includes('ownsHost'), '关闭开关后立即停止注入(无需重启代理)');
+ } finally {
+ if (off) await off.close();
+ if (on) await on.close();
+ await new Promise((r) => up.close(r));
+ }
+});
+
test('活动 WS 连接存在时 close 不挂起(closeAllConnections)', async () => {
const up = await fakeUpstream();
const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.port } });