From f0e2fe24df66ba657a1bad03746d438d2c3798cf Mon Sep 17 00:00:00 2001 From: ShawnKung Date: Sat, 12 Sep 2026 14:29:18 +0800 Subject: [PATCH] fix(mobile): restore right sidebar entry Restore the native mobile right-sidebar control hidden by a brittle header selector, while adding a persisted switch for compact layouts. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- README.en.md | 1 + README.md | 1 + client/api.js | 3 ++ client/client.js | 89 +++++++++++++++++++++++++++------- client/index.jsx | 32 +++++++++++- client/mobile/mobile-apply.tsx | 30 +++++++++++- client/mobile/mobile.css.ts | 17 +++---- client/pocket-locales.js | 8 ++- lib/index.js | 4 +- lib/settings.mjs | 18 ++++++- lib/web-rpc.js | 8 ++- test/mobile-nav.test.js | 15 ++++++ test/service.test.js | 28 +++++++++++ test/settings.test.js | 12 +++++ 14 files changed, 231 insertions(+), 35 deletions(-) diff --git a/README.en.md b/README.en.md index 66cb4ee..42ce902 100644 --- a/README.en.md +++ b/README.en.md @@ -52,6 +52,7 @@ What it looks like — the phone shows the exact same UI as your computer, live: | 🧘 Session persistence | Enter the PIN once and you're set for a long time (login is tied to the computer's dsh web process: as long as it stays up, the phone won't ask again; **after a dsh web restart/update, enter it once more**) | | ⚡ 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 | | 📁 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 | diff --git a/README.md b/README.md index 01715b6..6130d66 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看 | 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) | | ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) | | 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 | +| 🧭 可选右边栏 | 手机端显示原生右边栏入口;普通手机可在设置中关闭以保持紧凑,折叠屏展开后可更方便地同时使用终端底栏和右边栏 | | 📁 文件浏览 | 移动端「文件浏览」入口需要宿主提供 explorer 面板(dsh-web-ui 组件);官方 DSH 未内置时入口自动隐藏,不会出现"点了没反应" | | 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 | | 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 | diff --git a/client/api.js b/client/api.js index 636a848..648a56d 100644 --- a/client/api.js +++ b/client/api.js @@ -1,5 +1,7 @@ // dsh-pocket 设置页签 RPC 契约(client 与 host 共享) export const POCKET_RPC_CHANNEL = '/dsh-pocket'; +export const MOBILE_RIGHTBAR_ATTRIBUTE = 'data-dsh-pocket-mobile-rightbar'; +export const MOBILE_RIGHTBAR_EVENT = 'dsh-pocket:mobile-rightbar'; export const POCKET_ENDPOINTS = Object.freeze({ status: 'pocket.status', @@ -13,6 +15,7 @@ export const POCKET_ENDPOINTS = Object.freeze({ lanAuthSetEnabled: 'lanAuth.setEnabled', lanSetOverride: 'lan.setOverride', lanSetEnabled: 'lan.setEnabled', + mobileRightbarSetEnabled: 'mobile.rightbar.setEnabled', pinSetCustom: 'pin.setCustom', pocketReset: 'pocket.reset', // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文, diff --git a/client/client.js b/client/client.js index d2eb711..9b5fd37 100644 --- a/client/client.js +++ b/client/client.js @@ -41,6 +41,8 @@ var import_react2 = require("react"); // client/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"; var POCKET_ENDPOINTS = Object.freeze({ status: "pocket.status", tunnelStart: "tunnel.start", @@ -53,6 +55,7 @@ var POCKET_ENDPOINTS = Object.freeze({ lanAuthSetEnabled: "lanAuth.setEnabled", lanSetOverride: "lan.setOverride", lanSetEnabled: "lan.setEnabled", + mobileRightbarSetEnabled: "mobile.rightbar.setEnabled", pinSetCustom: "pin.setCustom", pocketReset: "pocket.reset", // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文, @@ -962,12 +965,8 @@ var MOBILE_CSS = ` } /* --- Session header on mobile --- - Layout goal: [toggle] [session title] [mode badge] in a row, with the - Session log capsule removed from the header (relocated to the drawer - footer). Stable structural hooks only: - [data-phase] header the session header element - header > :first-child titleRow (titleCluster + utilities) - header > :first-child > :last-child headerUtilities (Session log seat) */ + Layout goal: [toggle] [session title] [mode badge] in a row. The optional + rightbar entry uses the shell's stable header-corner hook. */ [data-phase] header { padding: 8px 12px 0 !important; } @@ -1004,11 +1003,14 @@ var MOBILE_CSS = ` top: 12px !important; z-index: 2 !important; } - /* Session log download: gone from the header row on mobile (the utilities - seat holds only the session-log-export capsule). */ - [data-phase] header > :first-child > :last-child { + /* The native rightbar entry is visible by default. Users who prefer the + compact header can turn it off in Pocket settings. */ + body[data-dsh-pocket-mobile-rightbar="off"] [data-conversation-header-corner] { display: none !important; } + body:not([data-dsh-pocket-mobile-rightbar="off"]) [data-mobile-nav="files"] { + right: 44px !important; + } /* --- Settings dialog on mobile --- Desktop: 800px two-column flex (188px nav + content). Mobile: a @@ -1615,6 +1617,30 @@ function mobileApply(ctx) { } }; } ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-mobile-nav: dictionaries"); + ctx.effect(() => { + let active = true; + const applyEnabled = (enabled) => { + document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, enabled ? "on" : "off"); + }; + const onChange = (event) => { + applyEnabled(event.detail?.enabled === true); + }; + const load = async () => { + try { + const result = await ctx.connection.rpc.call(POCKET_RPC_CHANNEL, POCKET_ENDPOINTS.status, {}); + if (active) applyEnabled(result?.ok === true ? result.value?.mobileRightbarEnabled !== false : true); + } catch { + if (active) applyEnabled(true); + } + }; + window.addEventListener(MOBILE_RIGHTBAR_EVENT, onChange); + void load(); + return () => { + active = false; + window.removeEventListener(MOBILE_RIGHTBAR_EVENT, onChange); + document.body?.removeAttribute(MOBILE_RIGHTBAR_ATTRIBUTE); + }; + }, "dsh-mobile-nav: optional right sidebar"); ctx.effect(() => { const tag = document.createElement("style"); tag.dataset.plugin = "@dsh-external/dsh-mobile-nav"; @@ -1799,8 +1825,8 @@ function mobileApply(ctx) { ctx.effect(() => { if (!narrow.matches) return () => { }; - const PHRASES = ["加载提供方目录失败", "Settings are unavailable in this browser"]; - const NOTICE = "手机上不支持模型设置,请去电脑端修改设置"; + const PHRASES = ["\u52A0\u8F7D\u63D0\u4F9B\u65B9\u76EE\u5F55\u5931\u8D25", "Settings are unavailable in this browser"]; + const NOTICE = "\u624B\u673A\u4E0A\u4E0D\u652F\u6301\u6A21\u578B\u8BBE\u7F6E\uFF0C\u8BF7\u53BB\u7535\u8111\u7AEF\u4FEE\u6539\u8BBE\u7F6E"; const findDeepest = (el) => { let deepest = el; for (const child of el.querySelectorAll("*")) { @@ -1821,9 +1847,7 @@ function mobileApply(ctx) { const observer = new MutationObserver(patch); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); patch(); - return () => { - observer.disconnect(); - }; + return () => observer.disconnect(); }, "dsh-mobile-nav: replace model-settings load error with mobile hint"); ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({ name: "conversation.session.header.actions", @@ -1891,7 +1915,7 @@ var zh2 = { "resetGo": "\u6062\u590D", "resetIntro": "\u8BBE\u7F6E\u641E\u51FA\u95EE\u9898\u65F6\u7684\u4E34\u65F6\u515C\u5E95\uFF1A\u6E05\u7A7A\u672C\u673A\u914D\u7F6E\u5E76\u91CD\u8BBE\u968F\u673A\u5BC6\u7801\uFF08DSH \u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF09", "resetTitle": "\u26A0\uFE0F \u786E\u8BA4\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1F", - "resetBody": "\u5C06\u6E05\u7A7A\u5E76\u6062\u590D\u9ED8\u8BA4\uFF1A\n\u2460 \u5F00\u5173\uFF1A\u5C40\u57DF\u7F51\u8BBF\u95EE=\u5F00\u3001\u8BBF\u95EE\u5BC6\u7801=\u5F00\u3001\u5C40\u57DF\u7F51\u5730\u5740=\u81EA\u52A8\n\u2461 \u516C\u7F51\uFF1A\u6A21\u5F0F\u56DE\u5230\u968F\u673A\u57DF\u540D\uFF0C\u6E05\u7A7A Tunnel Token \u4E0E\u56FA\u5B9A\u57DF\u540D\uFF0C\u5E76\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u516C\u7F51\n\u2462 \u5BC6\u7801\uFF1A\u516C\u7F51\u548C\u5C40\u57DF\u7F51\u90FD\u6362\u6210\u65B0\u7684\u968F\u673A 8 \u4F4D\u5BC6\u7801\uFF08\u65E7\u5BC6\u7801\u7ACB\u5373\u4F5C\u5E9F\uFF0C\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09\n\nDSH \u81EA\u8EAB\u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF1B\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002", + "resetBody": "\u5C06\u6E05\u7A7A\u5E76\u6062\u590D\u9ED8\u8BA4\uFF1A\n\u2460 \u5F00\u5173\uFF1A\u5C40\u57DF\u7F51\u8BBF\u95EE=\u5F00\u3001\u8BBF\u95EE\u5BC6\u7801=\u5F00\u3001\u624B\u673A\u7AEF\u53F3\u8FB9\u680F=\u5F00\u3001\u5C40\u57DF\u7F51\u5730\u5740=\u81EA\u52A8\n\u2461 \u516C\u7F51\uFF1A\u6A21\u5F0F\u56DE\u5230\u968F\u673A\u57DF\u540D\uFF0C\u6E05\u7A7A Tunnel Token \u4E0E\u56FA\u5B9A\u57DF\u540D\uFF0C\u5E76\u5173\u95ED\u6B63\u5728\u8FD0\u884C\u7684\u516C\u7F51\n\u2462 \u5BC6\u7801\uFF1A\u516C\u7F51\u548C\u5C40\u57DF\u7F51\u90FD\u6362\u6210\u65B0\u7684\u968F\u673A 8 \u4F4D\u5BC6\u7801\uFF08\u65E7\u5BC6\u7801\u7ACB\u5373\u4F5C\u5E9F\uFF0C\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09\n\nDSH \u81EA\u8EAB\u7684\u4F1A\u8BDD\u3001\u6A21\u578B\u3001\u63D2\u4EF6\u914D\u7F6E\u4E0D\u53D7\u5F71\u54CD\uFF1B\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002", "resetConfirm": "\u786E\u8BA4\u6062\u590D", "resetDone": "\u2705 \u5DF2\u6062\u590D\u51FA\u5382\u8BBE\u7F6E\uFF1A\u8BBE\u7F6E\u5DF2\u6E05\u7A7A\uFF0C\u5BC6\u7801\u5DF2\u6362\u65B0\uFF08\u624B\u673A\u9700\u91CD\u65B0\u8F93\u5165\uFF09", "resetFailed": "\u274C \u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5", @@ -1920,6 +1944,8 @@ var zh2 = { "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", "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", "wanTitle": "\u{1F310} \u516C\u7F51\uFF08\u4EBA\u5728\u5916\u9762\uFF09", "wanHint": "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09", "wanPin": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09", @@ -1987,7 +2013,7 @@ var en2 = { "resetGo": "Reset", "resetIntro": "Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)", "resetTitle": "\u26A0\uFE0F Confirm factory reset?", - "resetBody": "This clears and restores defaults:\n\u2460 Switches: LAN access on, access PIN on, LAN address auto\n\u2461 Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n\u2462 PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.", + "resetBody": "This clears and restores defaults:\n\u2460 Switches: LAN access on, access PIN on, mobile right sidebar on, LAN address auto\n\u2461 Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n\u2462 PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.", "resetConfirm": "Reset", "resetDone": "\u2705 Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)", "resetFailed": "\u274C Reset failed \u2014 please retry", @@ -2016,6 +2042,8 @@ var en2 = { "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)", "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", "wanTitle": "\u{1F310} Anywhere (public)", "wanHint": "Scan from any network (the URL changes on every restart)", "wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)", @@ -2074,6 +2102,11 @@ var styles = { qr: { width: 220, height: 220, borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", margin: "8px 0" }, warn: { color: "var(--dsw-alias-state-warn-primary,#b45309)", fontSize: 12, lineHeight: 1.5 } }; +function applyMobileRightbarSetting(enabled) { + const on = enabled !== false; + document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, on ? "on" : "off"); + window.dispatchEvent(new CustomEvent(MOBILE_RIGHTBAR_EVENT, { detail: { enabled: on } })); +} function PocketSettingsTab({ rpcCall, t }) { const [status, setStatus] = (0, import_react2.useState)(null); const [busy, setBusy] = (0, import_react2.useState)(false); @@ -2097,6 +2130,7 @@ function PocketSettingsTab({ rpcCall, t }) { try { const s = await call(POCKET_ENDPOINTS.status, {}); setStatus(s); + applyMobileRightbarSetting(s.mobileRightbarEnabled); setTunnelState(s.tunnelState ?? null); if (s.desktop) setIsDesktop(true); if (s.restartNotice) { @@ -2243,7 +2277,9 @@ function PocketSettingsTab({ rpcCall, t }) { setBusy(true); setError(null); try { - setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true })); + const next = await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }); + setStatus(next); + applyMobileRightbarSetting(next.mobileRightbarEnabled); setTunnelCfg(null); setCustomPin(null); setAdvOpen(false); @@ -2269,6 +2305,16 @@ function PocketSettingsTab({ rpcCall, t }) { } catch { } }; + const setMobileRightbar = async (on) => { + try { + const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on }); + const enabled = r.mobileRightbarEnabled === true; + setStatus((s) => ({ ...s, mobileRightbarEnabled: enabled })); + applyMobileRightbarSetting(enabled); + } catch (err) { + setError(err.message); + } + }; const [lanToggleOpen, setLanToggleOpen] = (0, import_react2.useState)(null); const requestLanToggle = (on) => setLanToggleOpen(on); const confirmLanToggle = async () => { @@ -2612,6 +2658,15 @@ function PocketSettingsTab({ rpcCall, t }) { ) : null ) : null ), + (0, import_react2.createElement)( + "div", + { style: styles.block }, + row( + t("mobileRightbar"), + Switch(status?.mobileRightbarEnabled !== false, () => setMobileRightbar(status?.mobileRightbarEnabled === false)), + (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("mobileRightbarHint")) + ) + ), error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null, // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触) (0, import_react2.createElement)( diff --git a/client/index.jsx b/client/index.jsx index 99f09ba..7da3362 100644 --- a/client/index.jsx +++ b/client/index.jsx @@ -9,7 +9,7 @@ import { createElement as h, useEffect, useRef, useState } from 'react'; -import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus, compareVersions } from './api.js'; +import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT, redactStatus, compareVersions } from './api.js'; import { mobileApply } from './mobile/mobile-apply.tsx'; import { NS as POCKET_NS, zh as POCKET_ZH, en as POCKET_EN } from './pocket-locales.js'; @@ -44,6 +44,12 @@ const styles = { warn: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.5 }, }; +function applyMobileRightbarSetting(enabled) { + const on = enabled !== false; + document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, on ? 'on' : 'off'); + window.dispatchEvent(new CustomEvent(MOBILE_RIGHTBAR_EVENT, { detail: { enabled: on } })); +} + function PocketSettingsTab({ rpcCall, t }) { const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); @@ -71,6 +77,7 @@ function PocketSettingsTab({ rpcCall, t }) { try { const s = await call(POCKET_ENDPOINTS.status, {}); setStatus(s); + applyMobileRightbarSetting(s.mobileRightbarEnabled); setTunnelState(s.tunnelState ?? null); if (s.desktop) setIsDesktop(true); if (s.restartNotice) { @@ -228,7 +235,9 @@ function PocketSettingsTab({ rpcCall, t }) { setBusy(true); setError(null); try { - setStatus(await call(POCKET_ENDPOINTS.pocketReset, { confirm: true })); + const next = await call(POCKET_ENDPOINTS.pocketReset, { confirm: true }); + setStatus(next); + applyMobileRightbarSetting(next.mobileRightbarEnabled); setTunnelCfg(null); setCustomPin(null); setAdvOpen(false); @@ -257,6 +266,17 @@ function PocketSettingsTab({ rpcCall, t }) { } catch { /* 忽略 */ } }; + const setMobileRightbar = async (on) => { + try { + const r = await call(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on }); + const enabled = r.mobileRightbarEnabled === true; + setStatus((s) => ({ ...s, mobileRightbarEnabled: enabled })); + applyMobileRightbarSetting(enabled); + } catch (err) { + setError(err.message); + } + }; + // 局域网访问总开关:关闭后局域网扫码/链接直接失效(公网不受影响)。 // 切换前弹窗确认(弹窗提醒);服务端用 setLanEnabled 持久化,代理按 Host 实时拦截。 const [lanToggleOpen, setLanToggleOpen] = useState(null); // null | true | false(目标 on 状态) @@ -555,6 +575,14 @@ function PocketSettingsTab({ rpcCall, t }) { : null, ), + h('div', { style: styles.block }, + row( + t('mobileRightbar'), + Switch(status?.mobileRightbarEnabled !== false, () => setMobileRightbar(status?.mobileRightbarEnabled === false)), + h('div', { style: { ...styles.muted, marginTop: 6 } }, t('mobileRightbarHint')), + ), + ), + error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null, // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触) diff --git a/client/mobile/mobile-apply.tsx b/client/mobile/mobile-apply.tsx index 04c0014..ba4ba0e 100644 --- a/client/mobile/mobile-apply.tsx +++ b/client/mobile/mobile-apply.tsx @@ -5,7 +5,7 @@ import { MobileNavOverlay } from './MobileNavOverlay.tsx' import { MobileDrawerFooter } from './MobileDrawerFooter.tsx' import { startFileGuard } from './fileGuard.ts' import { MOBILE_CSS } from './mobile.css.ts' -import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js' +import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, MOBILE_RIGHTBAR_ATTRIBUTE, MOBILE_RIGHTBAR_EVENT } from '../api.js' import { NS, en, zh } from './locales.ts' import type { MobileNavKey } from './locales.ts' import { resolveLayout, persistLayoutFromUrl } from './layout-mode.mjs' @@ -44,6 +44,34 @@ export function mobileApply(ctx): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-mobile-nav: dictionaries') + ctx.effect(() => { + let active = true + const applyEnabled = (enabled: boolean): void => { + document.body?.setAttribute(MOBILE_RIGHTBAR_ATTRIBUTE, enabled ? 'on' : 'off') + } + const onChange = (event: Event): void => { + applyEnabled((event as CustomEvent<{ enabled?: boolean }>).detail?.enabled === true) + } + const load = async (): Promise => { + try { + const result = await ctx.connection.rpc.call(POCKET_RPC_CHANNEL, POCKET_ENDPOINTS.status, {}) as { + ok?: boolean + value?: { mobileRightbarEnabled?: boolean } + } + if (active) applyEnabled(result?.ok === true ? result.value?.mobileRightbarEnabled !== false : true) + } catch { + if (active) applyEnabled(true) + } + } + window.addEventListener(MOBILE_RIGHTBAR_EVENT, onChange) + void load() + return () => { + active = false + window.removeEventListener(MOBILE_RIGHTBAR_EVENT, onChange) + document.body?.removeAttribute(MOBILE_RIGHTBAR_ATTRIBUTE) + } + }, 'dsh-mobile-nav: optional right sidebar') + ctx.effect(() => { const tag = document.createElement('style') tag.dataset.plugin = '@dsh-external/dsh-mobile-nav' diff --git a/client/mobile/mobile.css.ts b/client/mobile/mobile.css.ts index 6e05008..9c5ed4b 100644 --- a/client/mobile/mobile.css.ts +++ b/client/mobile/mobile.css.ts @@ -407,12 +407,8 @@ export const MOBILE_CSS = ` } /* --- Session header on mobile --- - Layout goal: [toggle] [session title] [mode badge] in a row, with the - Session log capsule removed from the header (relocated to the drawer - footer). Stable structural hooks only: - [data-phase] header the session header element - header > :first-child titleRow (titleCluster + utilities) - header > :first-child > :last-child headerUtilities (Session log seat) */ + Layout goal: [toggle] [session title] [mode badge] in a row. The optional + rightbar entry uses the shell's stable header-corner hook. */ [data-phase] header { padding: 8px 12px 0 !important; } @@ -449,11 +445,14 @@ export const MOBILE_CSS = ` top: 12px !important; z-index: 2 !important; } - /* Session log download: gone from the header row on mobile (the utilities - seat holds only the session-log-export capsule). */ - [data-phase] header > :first-child > :last-child { + /* The native rightbar entry is visible by default. Users who prefer the + compact header can turn it off in Pocket settings. */ + body[data-dsh-pocket-mobile-rightbar="off"] [data-conversation-header-corner] { display: none !important; } + body:not([data-dsh-pocket-mobile-rightbar="off"]) [data-mobile-nav="files"] { + right: 44px !important; + } /* --- Settings dialog on mobile --- Desktop: 800px two-column flex (188px nav + content). Mobile: a diff --git a/client/pocket-locales.js b/client/pocket-locales.js index 9c34bf8..89bfb8f 100644 --- a/client/pocket-locales.js +++ b/client/pocket-locales.js @@ -37,7 +37,7 @@ export const zh = { 'resetGo': '恢复', 'resetIntro': '设置搞出问题时的临时兜底:清空本机配置并重设随机密码(DSH 的会话、模型、插件配置不受影响)', 'resetTitle': '⚠️ 确认恢复出厂设置?', - 'resetBody': '将清空并恢复默认:\n① 开关:局域网访问=开、访问密码=开、局域网地址=自动\n② 公网:模式回到随机域名,清空 Tunnel Token 与固定域名,并关闭正在运行的公网\n③ 密码:公网和局域网都换成新的随机 8 位密码(旧密码立即作废,手机需重新输入)\n\nDSH 自身的会话、模型、插件配置不受影响;此操作不可撤销。', + 'resetBody': '将清空并恢复默认:\n① 开关:局域网访问=开、访问密码=开、手机端右边栏=开、局域网地址=自动\n② 公网:模式回到随机域名,清空 Tunnel Token 与固定域名,并关闭正在运行的公网\n③ 密码:公网和局域网都换成新的随机 8 位密码(旧密码立即作废,手机需重新输入)\n\nDSH 自身的会话、模型、插件配置不受影响;此操作不可撤销。', 'resetConfirm': '确认恢复', 'resetDone': '✅ 已恢复出厂设置:设置已清空,密码已换新(手机需重新输入)', 'resetFailed': '❌ 恢复失败,请重试', @@ -66,6 +66,8 @@ export const zh = { 'pinCustomHint': '自定义后开启公网不再自动换新', 'lanPinOff': '🔓 密码已关闭:扫码直连,无需密码(仅同一局域网设备可访问;公网仍要密码)', 'lanStarting': '代理未就绪…', + 'mobileRightbar': '手机端右边栏', + 'mobileRightbarHint': '显示原生右边栏入口;普通手机可按需关闭,折叠屏展开后使用更方便', 'wanTitle': '🌐 公网(人在外面)', 'wanHint': '任何网络扫码即用(URL 每次重启自动换新)', 'wanPin': '🔐 访问密码:{pin}(每次开启公网变新;手机打开链接需输入此密码)', @@ -135,7 +137,7 @@ export const en = { 'resetGo': 'Reset', 'resetIntro': 'Temporary fallback when settings break: clear local config and re-roll random PINs (DSH sessions, models and plugin config are untouched)', 'resetTitle': '⚠️ Confirm factory reset?', - 'resetBody': 'This clears and restores defaults:\n① Switches: LAN access on, access PIN on, LAN address auto\n② Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n③ PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.', + 'resetBody': 'This clears and restores defaults:\n① Switches: LAN access on, access PIN on, mobile right sidebar on, LAN address auto\n② Public: mode back to random URL, Tunnel Token and fixed domain cleared, and any running tunnel is stopped\n③ PINs: both public and LAN become new random 8-character PINs (old ones stop working; the phone must re-enter)\n\nYour DSH sessions, models and plugin config are untouched. This cannot be undone.', 'resetConfirm': 'Reset', 'resetDone': '✅ Factory reset done: settings cleared and PINs re-rolled (re-enter the PIN on your phone)', 'resetFailed': '❌ Reset failed — please retry', @@ -164,6 +166,8 @@ export const en = { 'pinCustomHint': 'custom PINs are not rotated on tunnel start', 'lanPinOff': '🔓 PIN off — scan & go, no PIN (LAN devices only; public still requires PIN)', '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', 'wanTitle': '🌐 Anywhere (public)', 'wanHint': 'Scan from any network (the URL changes on every restart)', 'wanPin': '🔐 PIN: {pin} (changes each time the tunnel is enabled; required on the phone)', diff --git a/lib/index.js b/lib/index.js index 79f3c05..aa7a037 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, 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 } from './settings.mjs'; const name = 'dsh-pocket'; const inject = ['connection', 'webServer']; @@ -357,6 +357,8 @@ export function apply(ctx, config = {}, internals = {}) { setLanAuthEnabled: (on) => setLanAuthEnabled(on), getLanEnabled: () => lanEnabled(), setLanEnabled: (on) => setLanEnabled(on), + getMobileRightbarEnabled: () => mobileRightbarEnabled(), + setMobileRightbarEnabled: (on) => setMobileRightbarEnabled(on), getLanIpOverride: () => lanIpOverride(), setLanIpOverride: (ip) => setLanIpOverride(ip), getPinCustom: (which) => pinCustom(which), diff --git a/lib/settings.mjs b/lib/settings.mjs index 934596b..6a20904 100644 --- a/lib/settings.mjs +++ b/lib/settings.mjs @@ -3,6 +3,7 @@ // 当前项: // - lanEnabled 局域网访问总开关(默认开启):关闭后局域网扫码/链接直接失效(代理拒绝局域网 Host) // - lanAuthEnabled 局域网访问密码开关(issue #24),默认开启 +// - mobileRightbarEnabled 手机端右边栏入口(默认开启) // - publicPinCustom 公网密码是否用户自定义(issue #33),自定义后不自动轮换 // - lanPinCustom 局域网密码是否用户自定义(issue #33) // - tunnelMode 公网隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名) @@ -64,6 +65,19 @@ export function setLanAuthEnabled(on) { return s.lanAuthEnabled; } +/** 手机端右边栏入口:默认开启,可按需关闭以保持更紧凑的标题栏。 */ +export function mobileRightbarEnabled() { + return readSettings().mobileRightbarEnabled !== false; +} + +/** 设置手机端右边栏入口,返回新状态(持久化)。 */ +export function setMobileRightbarEnabled(on) { + const s = readSettings(); + s.mobileRightbarEnabled = !!on; + writeSettings(s); + return s.mobileRightbarEnabled; +} + /** 局域网地址手动覆盖:默认空字符串 = 自动选择。 */ export function lanIpOverride() { return readSettings().lanIpOverride ?? ''; @@ -106,8 +120,8 @@ export function setPinCustom(which, on) { // ---------- 恢复出厂设置 ---------- // 设置出问题时的临时兜底:删掉 settings.json 即回到出厂默认(文件缺失 = 默认开启, -// 于是局域网访问开、访问密码开、局域网地址自动、公网模式随机)。DSH 自身的会话、 -// 模型、插件配置都在 $DSH_HOME 的其他目录,不受影响。 +// 于是局域网访问开、访问密码开、手机端右边栏开、局域网地址自动、公网模式随机)。 +// DSH 自身的会话、模型、插件配置都在 $DSH_HOME 的其他目录,不受影响。 /** 删除本机设置文件(不存在也算成功);返回 true 表示已清空。 */ export function resetSettings() { diff --git a/lib/web-rpc.js b/lib/web-rpc.js index b543e97..42132a8 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, 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, 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.'); @@ -285,6 +285,7 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false, lanToken: getLanToken?.() ?? null, lanAuthEnabled: getLanAuthEnabled?.() ?? true, lanEnabled: getLanEnabled?.() ?? true, + mobileRightbarEnabled: getMobileRightbarEnabled?.() ?? true, publicPinCustom: getPinCustom?.('public') ?? false, lanPinCustom: getPinCustom?.('lan') ?? false, tunnelConfig: getTunnelConfig?.() ?? { mode: 'quick', hostname: '', tokenSet: false }, @@ -310,6 +311,11 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false, if (enabled === undefined) return fail('bad-request', '局域网访问开关不可用 | LAN access switch unavailable'); return ok({ lanEnabled: enabled }); } + if (endpoint === POCKET_ENDPOINTS.mobileRightbarSetEnabled) { + const enabled = setMobileRightbarEnabled?.(payload?.on === true); + if (enabled === undefined) return fail('bad-request', '手机端右边栏开关不可用 | mobile right sidebar switch unavailable'); + return ok({ mobileRightbarEnabled: enabled }); + } if (endpoint === POCKET_ENDPOINTS.lanSetOverride) { // 返回完整 status:前端 setStatus(await call(...)) 直接替换 status 对象, // 若只返回 { lanIpOverride } 会丢掉 accessToken/lanToken/tunnelUrl 等字段, diff --git a/test/mobile-nav.test.js b/test/mobile-nav.test.js index 3fe7e5c..264251e 100644 --- a/test/mobile-nav.test.js +++ b/test/mobile-nav.test.js @@ -161,6 +161,21 @@ test('打包产物里带上抽屉规则的关键字', () => { } }); +test('手机端右边栏默认显示,设置关闭后隐藏稳定 header corner 入口', () => { + const css = readFileSync(new URL('../client/mobile/mobile.css.ts', import.meta.url), 'utf8'); + const apply = readFileSync(new URL('../client/mobile/mobile-apply.tsx', import.meta.url), 'utf8'); + assert.ok( + css.includes('body[data-dsh-pocket-mobile-rightbar="off"] [data-conversation-header-corner]'), + '仅在用户关闭设置时隐藏官方右栏入口', + ); + assert.ok( + !css.includes('[data-phase] header > :first-child > :last-child'), + '不能依赖标题栏子元素顺序隐藏右栏入口', + ); + assert.ok(apply.includes('POCKET_ENDPOINTS.status'), '移动端启动时读取持久化设置'); + assert.ok(apply.includes('MOBILE_RIGHTBAR_EVENT'), '设置切换后立即同步右栏入口'); +}); + test('composer 底栏用稳定 card 标记并在 360px 视口保持单行', () => { const css = readFileSync(new URL('../client/mobile/mobile.css.ts', import.meta.url), 'utf8'); assert.ok(css.includes('[data-composer-card="true"] > [class$="_row"]')); diff --git a/test/service.test.js b/test/service.test.js index 842b433..dc82dfc 100644 --- a/test/service.test.js +++ b/test/service.test.js @@ -256,6 +256,34 @@ test('RPC:lan.setEnabled 设置局域网访问总开关并回写到 status', a await service.dispose(); }); +test('RPC:mobile.rightbar.setEnabled 默认开启并回写到 status', async () => { + const internals = stubInternals(); + let enabled = true; + const service = createPocketService({ dshPort: 3080, port: 3081, internals }); + const conn = fakeCtxConnection(); + installPocketRpc({ connection: conn }, { + service, + getMobileRightbarEnabled: () => enabled, + setMobileRightbarEnabled: (on) => { enabled = on === true; return enabled; }, + log: { error() {}, warn() {} }, + }); + + const initial = await conn.handler(POCKET_ENDPOINTS.status, {}); + assert.equal(initial.ok, true); + assert.equal(initial.value.mobileRightbarEnabled, true, '默认开启'); + + const off = await conn.handler(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on: false }); + assert.equal(off.ok, true); + assert.equal(off.value.mobileRightbarEnabled, false, '关闭成功'); + assert.equal((await conn.handler(POCKET_ENDPOINTS.status, {})).value.mobileRightbarEnabled, false, 'status 反映关闭'); + + const on = await conn.handler(POCKET_ENDPOINTS.mobileRightbarSetEnabled, { on: true }); + assert.equal(on.ok, true); + assert.equal(on.value.mobileRightbarEnabled, true, '可再次开启'); + + await service.dispose(); +}); + test('RPC:status 携带重启提示(restartNotice)', async () => { const internals = stubInternals(); const service = createPocketService({ dshPort: 3080, port: 3081, internals }); diff --git a/test/settings.test.js b/test/settings.test.js index 5862089..34c504f 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -58,6 +58,16 @@ test('再开 → true;settings.json 权限 0600', () => withHome(async () => { } })); +test('手机端右边栏默认开启,可关闭并持久化', () => withHome(async () => { + const { mobileRightbarEnabled, setMobileRightbarEnabled, settingsPath } = await import('../lib/settings.mjs'); + assert.equal(mobileRightbarEnabled(), true, '默认开启'); + assert.equal(setMobileRightbarEnabled(false), false, '返回关闭状态'); + assert.equal(mobileRightbarEnabled(), false, '关闭后立即生效'); + const raw = JSON.parse(readFileSync(settingsPath(), 'utf8')); + assert.equal(raw.mobileRightbarEnabled, false, 'settings.json 内容正确'); + assert.equal(setMobileRightbarEnabled(true), true, '可再次开启'); +})); + test('局域网地址覆盖:默认自动,设置/清除持久化,非法 IPv4 拒绝', () => withHome(async () => { const { lanIpOverride, setLanIpOverride, settingsPath } = await import('../lib/settings.mjs'); assert.equal(lanIpOverride(), '', '默认自动'); @@ -153,6 +163,7 @@ test('恢复出厂设置:清空全部设置 + 重设随机密码(开关回 // 先把设置搞成非默认:关掉开关、切命名隧道、填 Token/域名、自定义密码 settings.setLanEnabled(false); settings.setLanAuthEnabled(false); + settings.setMobileRightbarEnabled(true); settings.setLanIpOverride('10.0.0.7'); settings.setTunnelMode('named'); settings.setTunnelToken('eyJhIjoiY2xvdWRmbGFyZS10b2tlbi1leGFtcGxlLXZhbHVlIn0'); @@ -170,6 +181,7 @@ test('恢复出厂设置:清空全部设置 + 重设随机密码(开关回 // 全部开关回到出厂默认 assert.equal(settings.lanEnabled(), true, '局域网访问恢复默认开'); assert.equal(settings.lanAuthEnabled(), true, '访问密码恢复默认开'); + assert.equal(settings.mobileRightbarEnabled(), true, '手机端右边栏恢复默认开'); assert.equal(settings.lanIpOverride(), '', '局域网地址恢复自动'); assert.equal(settings.tunnelMode(), 'quick', '公网模式恢复随机域名'); assert.equal(settings.tunnelToken(), '', 'Tunnel Token 已清空');