dsh-remote-mobile 1.2.1 → 1.4.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,786 @@
1
+ /**
2
+ * 移动端样式片段注册表 (Mobile Style Snippets Store)
3
+ *
4
+ * 【设计职责】
5
+ * 本模块是插件内部的「样式小插件」体系:把 DSH Web 界面的样式适配拆分成
6
+ * 一个个可独立启停的 CSS 片段(Snippet)。
7
+ *
8
+ * - 内置预设片段(Builtin Presets):随插件分发,按界面区域划分为三份——
9
+ * 侧边栏(preset-sidebar)、设置面板(preset-settings)、正文(preset-main);
10
+ * - 用户自定义片段(Custom Snippets):通过设置面板或 API 动态增删改,持久化于
11
+ * ~/.dsh/remote-mobile/style-snippets.json,无需修改插件代码、无需重启;
12
+ * - 每个片段(含内置预设)支持【PC 端 / 移动端】分别启停,且按【视口宽度】判定而非设备 UA:
13
+ * mobileEnabled → 包装 @media (max-width: 900px),窄视口生效(PC 拉小窗口同样生效);
14
+ * pcEnabled → 包装 @media (min-width: 901px),宽视口生效;两端都开 → 全宽度原样生效。
15
+ *
16
+ * 注入发生在网关层响应拦截(见 bridge/compat.ts 的 patchHttpServerWithVirtualizer),
17
+ * 对主工作区 index.html 与 /auth 登录页统一生效。
18
+ */
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
21
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
22
+ /** 用户自定义样式片段持久化文件 */
23
+ export const DEFAULT_STYLE_FILE = join(homedir(), '.dsh', 'remote-mobile', 'style-snippets.json');
24
+ /** 移动端 User-Agent 识别正则 */
25
+ export const MOBILE_UA_REGEX = /Android|iPhone|iPod|iPad|Mobile Safari|Opera Mini|IEMobile|Windows Phone|BlackBerry|webOS|Mobi/i;
26
+ /** 单片段 CSS 长度上限(防配置膨胀) */
27
+ export const MAX_CSS_LENGTH = 200_000;
28
+ /** 片段名称长度上限 */
29
+ export const MAX_NAME_LENGTH = 80;
30
+ /** 内置预设 id 保留前缀(自定义片段不得占用) */
31
+ export const BUILTIN_ID_PREFIX = 'preset-';
32
+ /** 侧边栏预设:抽屉式导航 + 整体布局(overscroll / 三栏 0 占位 / 悬浮把手) */
33
+ const PRESET_SIDEBAR_CSS = `
34
+ /* ===== dsh-remote-mobile · 侧边栏抽屉导航与整体布局 ===== */
35
+ /* 注:窄屏媒体查询由注入层按端包裹(移动端包 @media (max-width:900px),PC 端原样生效) */
36
+ /* 0. 禁用浏览器原生橡皮筋下拉刷新与链式溢出滚动,彻底杜绝上下滑动误刷新页面 */
37
+ html,
38
+ body,
39
+ [data-dsh-frame],
40
+ [role="dialog"],
41
+ [class*="_dialog"],
42
+ [class*="_modalContent"],
43
+ [class*="_overlay"],
44
+ [class*="_conversationArea"],
45
+ [data-pane="conversation"] {
46
+ overscroll-behavior-y: contain !important;
47
+ overscroll-behavior-x: none !important;
48
+ }
49
+
50
+ /* 1. 最外层三栏布局:在移动端主工作区占满 100% 全宽,消除 56px 强制占位 */
51
+ [data-dsh-frame],
52
+ [class*="_frame"] {
53
+ grid-template-columns: 0px minmax(0px, 1fr) 0px !important;
54
+ width: 100vw !important;
55
+ max-width: 100vw !important;
56
+ overflow-x: hidden !important;
57
+ position: relative !important;
58
+ }
59
+
60
+ [data-pane="conversation"],
61
+ [class*="_centerCol"] {
62
+ width: 100vw !important;
63
+ max-width: 100vw !important;
64
+ }
65
+
66
+ /* 2. 侧边栏最外层容器:作为整体抽屉浮层(阴影仅加在最外层,严禁污染内部列表) */
67
+ [data-pane="sidebar"],
68
+ [class*="_sidebarCol"] {
69
+ position: fixed !important;
70
+ top: 0 !important;
71
+ bottom: 0 !important;
72
+ left: 0 !important;
73
+ height: 100vh !important;
74
+ height: 100dvh !important;
75
+ /* 层级守卫:抽屉必须低于官方弹层体系(模态弹窗 z=1000、下拉/弹出菜单 z=1100),
76
+ 否则设置面板的操作菜单(portal 到 body)会被抽屉盖住无法交互。
77
+ 官方内容区元素均在 100 层级以下,900 足以保证抽屉覆盖正文。
78
+ 注意:展开态宽度由官方样式接管(width: min(88vw,320px) !important,特异性
79
+ 更高),「外层 ~320 / 内层内联 280」的差值由下方内层铺满规则消除。 */
80
+ min-width: 0 !important;
81
+ z-index: 900 !important;
82
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
83
+ box-shadow: 6px 0 30px rgba(0, 0, 0, 0.25) !important;
84
+ border-right: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.15)) !important;
85
+ /* 关键守卫:官方会在侧边栏列上留下恒等 transform(matrix(1,0,0,1,0,0)),
86
+ 任何 transform/filter 等都会把 position:fixed 后代的包含块「困」在本列内,
87
+ 叠加官方 overflow:hidden 后,设置弹窗(内联渲染于侧边栏 DOM)会被裁剪成
88
+ 抽屉宽度、表现为「弹窗出现在侧边栏里面」。显式清除全部包含块触发属性。 */
89
+ transform: none !important;
90
+ filter: none !important;
91
+ backdrop-filter: none !important;
92
+ perspective: none !important;
93
+ will-change: auto !important;
94
+ }
95
+
96
+ /* 官方窄屏抽屉的全屏点击遮罩([data-dsh-frame]::after:fixed、rgba 黑纱、
97
+ z-index:1050、pointer-events:auto)必须压在抽屉(900)之下,否则抽屉内容
98
+ 会被黑纱罩住并拦截全部点击。压到 899:低于抽屉、仍高于正文(≤100),
99
+ 「点抽屉外暗处自动收回」的能力保留。 */
100
+ [data-dsh-frame]::after {
101
+ z-index: 899 !important;
102
+ }
103
+
104
+ /* 内层根节点铺满抽屉:覆盖官方响应式脚本写入的内联 width,
105
+ 消除「外层 320 / 内层 280」的宽度差与右缘空白条 */
106
+ [data-pane="sidebar"] > [data-slot="sidebar"] > div {
107
+ width: 100% !important;
108
+ min-width: 0 !important;
109
+ max-width: none !important;
110
+ height: 100% !important;
111
+ }
112
+
113
+ /* 3. 折叠状态:侧边栏收起为 0px 并隐藏整体阴影和多余按钮 */
114
+ [data-pane="sidebar"]:has([class*="_collapsed"]),
115
+ [data-dsh-frame][data-sidebar-collapsed="true"] [data-pane="sidebar"] {
116
+ width: 0px !important;
117
+ min-width: 0px !important;
118
+ border: none !important;
119
+ box-shadow: none !important;
120
+ background: transparent !important;
121
+ pointer-events: none !important;
122
+ }
123
+
124
+ [data-pane="sidebar"] [class*="_collapsed"] > :not([class*="_logoRow"]),
125
+ [data-dsh-frame][data-sidebar-collapsed="true"] [data-pane="sidebar"] > div > div > :not([class*="_logoRow"]) {
126
+ display: none !important;
127
+ }
128
+
129
+ /* 折叠状态下,Logo 按钮悬浮在屏幕左上角作为展开把手(与 dsh-draggable-nav 拖拽脚本联动) */
130
+ [data-pane="sidebar"] [class*="_collapsed"] [class*="_logoRow"],
131
+ [data-dsh-frame][data-sidebar-collapsed="true"] [data-pane="sidebar"] [class*="_logoRow"] {
132
+ position: fixed !important;
133
+ top: 10px !important;
134
+ left: 10px !important;
135
+ width: 40px !important;
136
+ height: 40px !important;
137
+ border-radius: 10px !important;
138
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.95)) !important;
139
+ backdrop-filter: blur(12px) !important;
140
+ -webkit-backdrop-filter: blur(12px) !important;
141
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.2)) !important;
142
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
143
+ display: flex !important;
144
+ align-items: center !important;
145
+ justify-content: center !important;
146
+ pointer-events: auto !important;
147
+ /* 与抽屉(900)同层体系:高于正文内容,低于官方弹窗(1000)/菜单(1100) */
148
+ z-index: 901 !important;
149
+ }
150
+
151
+ /* 4. 触屏设备没有 hover:官方 Tooltip 气泡(黑底,如折叠把手的「收起侧边栏」)
152
+ 显示依赖 mouseenter/focus,而消失只认 mouseleave/blur —— 触摸点击后这两个
153
+ 事件永远不会到来,气泡会永久滞留在屏幕上。移动端 UA 下整体隐藏官方气泡
154
+ (按钮均保留 aria-label,可访问性不受影响);PC 窄窗口仍保留悬停提示。 */
155
+ html[data-dsh-mobile="1"] [role="tooltip"] {
156
+ display: none !important;
157
+ }
158
+ `;
159
+ /** 设置面板预设:官方设置弹窗整体微缩 + 遮罩锁滚动 + 内容横向滑动 */
160
+ const PRESET_SETTINGS_CSS = `
161
+ /* ===== dsh-remote-mobile · 设置面板适配 ===== */
162
+ /* 注:窄屏媒体查询由注入层按端包裹 */
163
+ /* 遮罩彻底锁死防漏底,弹窗整体居中 */
164
+ [data-slot*="overlay"]:has([role="dialog"]),
165
+ [class*="_overlay"]:has([role="dialog"]),
166
+ [class*="_modalWrapper"]:has([role="dialog"]) {
167
+ position: fixed !important;
168
+ inset: 0 !important;
169
+ display: flex !important;
170
+ align-items: center !important;
171
+ justify-content: center !important;
172
+ overflow: hidden !important;
173
+ z-index: 100005 !important;
174
+ background: rgba(0, 0, 0, 0.5) !important;
175
+ overscroll-behavior: contain !important;
176
+ }
177
+
178
+ /* 弹窗卡片:居中显示,占据 94vw 视口,内部开启弹性横向滚动 */
179
+ [role="dialog"],
180
+ [class*="_dialog"],
181
+ [class*="_modalContent"],
182
+ [class*="_settingsModal"] {
183
+ position: relative !important;
184
+ width: 94vw !important;
185
+ max-width: 94vw !important;
186
+ height: 86vh !important;
187
+ height: 86dvh !important;
188
+ max-height: 88vh !important;
189
+ max-height: 88dvh !important;
190
+ margin: auto !important;
191
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important;
192
+ border-radius: 16px !important;
193
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
194
+ overflow-x: auto !important;
195
+ overflow-y: hidden !important;
196
+ -webkit-overflow-scrolling: touch !important;
197
+ overscroll-behavior: contain !important;
198
+ font-size: 13.5px !important;
199
+ }
200
+
201
+ /* 弹窗内部主体容器:固定最小宽度并等比微缩,单屏显示更多内容且字号更精致 */
202
+ [role="dialog"] > div,
203
+ [class*="_dialog"] > div,
204
+ [class*="_modalContent"] > div {
205
+ min-width: 530px !important;
206
+ width: 530px !important;
207
+ height: 100% !important;
208
+ zoom: 0.88 !important;
209
+ }
210
+ `;
211
+ /** 正文预设:对话区紧凑高密度排版(小字号/紧凑行距/等宽代码/防溢出) */
212
+ const PRESET_MAIN_CSS = `
213
+ /* ===== dsh-remote-mobile · 对话正文高密度排版 ===== */
214
+ /* 注:窄屏媒体查询由注入层按端包裹 */
215
+ /* 目标:小字号 + 紧凑行距 + 收紧边距,让每行展示更多内容。
216
+ 不做布局缩放(zoom 会挤压 1fr 网格轨道、产生右侧空白并使页面破版);
217
+ 字号规则主要基于稳定 HTML 元素(p/h1/li/pre/code 不随 hash 变化),
218
+ 内容容器用 CSS Modules localName 后缀(只有前缀 hash 随机,后缀稳定)。 */
219
+
220
+ /* 1. 消息内容容器:紧凑字号与行距 */
221
+ [data-pane="conversation"],
222
+ [data-slot^="conversation.chat"],
223
+ [class*="_markdown"],
224
+ [class*="_message"],
225
+ [class*="_bubble"],
226
+ [class*="_text"] {
227
+ font-size: 12.5px !important;
228
+ line-height: 1.45 !important;
229
+ letter-spacing: -0.01em !important;
230
+ }
231
+
232
+ /* 2. HTML 元素级(稳定):段落与标题层级 */
233
+ [data-pane="conversation"] p,
234
+ [data-slot^="conversation.chat"] p,
235
+ [class*="_markdown"] p,
236
+ [class*="_message"] p,
237
+ [class*="_bubble"] p {
238
+ font-size: 12.5px !important;
239
+ line-height: 1.45 !important;
240
+ margin: 0 0 0.4em 0 !important;
241
+ }
242
+
243
+ [data-pane="conversation"] h1,
244
+ [data-pane="conversation"] h2,
245
+ [data-pane="conversation"] h3,
246
+ [data-pane="conversation"] h4,
247
+ [data-slot^="conversation.chat"] h1,
248
+ [data-slot^="conversation.chat"] h2,
249
+ [data-slot^="conversation.chat"] h3,
250
+ [class*="_markdown"] h1,
251
+ [class*="_markdown"] h2,
252
+ [class*="_markdown"] h3 {
253
+ font-size: 13.5px !important;
254
+ font-weight: 700 !important;
255
+ margin: 0.5em 0 0.25em 0 !important;
256
+ line-height: 1.3 !important;
257
+ }
258
+
259
+ [data-pane="conversation"] ul,
260
+ [data-pane="conversation"] ol,
261
+ [class*="_markdown"] ul,
262
+ [class*="_markdown"] ol {
263
+ margin: 0.2em 0 0.4em 0 !important;
264
+ padding-left: 1.2em !important;
265
+ }
266
+ [data-pane="conversation"] li,
267
+ [class*="_markdown"] li {
268
+ font-size: 12.5px !important;
269
+ line-height: 1.45 !important;
270
+ margin-bottom: 0.15em !important;
271
+ }
272
+
273
+ /* 3. 行内代码与代码块:小等宽 + 防横向溢出 */
274
+ [data-pane="conversation"] code,
275
+ [data-slot^="conversation.chat"] code,
276
+ [class*="_markdown"] code {
277
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
278
+ font-size: 11.5px !important;
279
+ }
280
+ [data-pane="conversation"] pre,
281
+ [data-slot^="conversation.chat"] pre,
282
+ [class*="_markdown"] pre {
283
+ font-size: 11.5px !important;
284
+ line-height: 1.4 !important;
285
+ max-width: 100% !important;
286
+ overflow-x: auto !important;
287
+ -webkit-overflow-scrolling: touch !important;
288
+ }
289
+
290
+ /* 表格:紧凑字号/内边距 + 窄屏横向滚动防撑破(display:block 让表格宽度受容器约束) */
291
+ [data-pane="conversation"] table,
292
+ [data-slot^="conversation.chat"] table,
293
+ [class*="_markdown"] table {
294
+ font-size: 11.5px !important;
295
+ line-height: 1.4 !important;
296
+ display: block !important;
297
+ max-width: 100% !important;
298
+ overflow-x: auto !important;
299
+ -webkit-overflow-scrolling: touch !important;
300
+ }
301
+ [data-pane="conversation"] table th,
302
+ [data-pane="conversation"] table td,
303
+ [data-slot^="conversation.chat"] table th,
304
+ [data-slot^="conversation.chat"] table td,
305
+ [class*="_markdown"] table th,
306
+ [class*="_markdown"] table td {
307
+ font-size: 11.5px !important;
308
+ line-height: 1.4 !important;
309
+ padding: 3px 7px !important;
310
+ }
311
+
312
+ /* 3.5 可折叠披露行(data-disclosure-row,如“Code/思考”等行):标题与摘要跟随紧凑字号 */
313
+ [data-pane="conversation"] [data-disclosure-row],
314
+ [data-slot^="conversation.chat"] [data-disclosure-row] {
315
+ font-size: 12.5px !important;
316
+ line-height: 1.45 !important;
317
+ }
318
+ [data-pane="conversation"] [data-disclosure-row] [class*="_title"],
319
+ [data-pane="conversation"] [data-disclosure-row] [class*="_summary"],
320
+ [data-slot^="conversation.chat"] [data-disclosure-row] [class*="_title"],
321
+ [data-slot^="conversation.chat"] [data-disclosure-row] [class*="_summary"] {
322
+ font-size: 12.5px !important;
323
+ line-height: 1.45 !important;
324
+ letter-spacing: -0.01em !important;
325
+ }
326
+
327
+ /* 3.7 消息列表双层滚动容器(wSkVaW_scrollBody + Md3f7G_scroll)padding 叠加:
328
+ 官方各带 12~16px(合计每侧最多 28px),统一每层压到 8px(每侧合计 16px)。
329
+ 均排除输入区滚动容器,避免误伤 composer。 */
330
+ [data-pane="conversation"] [class*="_scrollBody"],
331
+ [data-pane="conversation"] [class*="_scroll"]:not([data-composer-card] [class*="_scroll"]) {
332
+ padding: 8px 8px !important;
333
+ }
334
+
335
+ /* 4. 消息操作条(时间戳/复制等)与内行图标:显式收敛(SVG 不随字号缩放,需显式尺寸) */
336
+ [data-pane="conversation"] [class*="_actions"],
337
+ [data-slot^="conversation.chat"] [class*="_actions"] {
338
+ font-size: 11px !important;
339
+ line-height: 1.2 !important;
340
+ }
341
+ [data-pane="conversation"] [class*="_actions"] [class*="_timeStart"],
342
+ [data-pane="conversation"] [class*="_actions"] [class*="_timeEnd"],
343
+ [data-pane="conversation"] [class*="_actions"] [class*="_time"],
344
+ [data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeStart"],
345
+ [data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"],
346
+ [data-slot^="conversation.chat"] [class*="_actions"] [class*="_time"] {
347
+ font-size: 11px !important;
348
+ }
349
+ /* 用时统计内各指标/分隔点间距收紧(margin: 0) */
350
+ [data-pane="conversation"] [class*="_actions"] [class*="_timeEnd"] span,
351
+ [data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"] span {
352
+ margin: 0 !important;
353
+ }
354
+ /* 统计行:单行优先,空间不足才折行(不再强制独占整行);折行后与消息左缘对齐(margin-left 清零) */
355
+ [data-pane="conversation"] [class*="_actions"] [class*="_timeEnd"],
356
+ [data-slot^="conversation.chat"] [class*="_actions"] [class*="_timeEnd"] {
357
+ flex: 0 1 auto !important;
358
+ min-width: 0 !important;
359
+ height: auto !important;
360
+ white-space: normal !important;
361
+ overflow: visible !important;
362
+ text-overflow: clip !important;
363
+ padding-left: 6px !important; /* 折行时也保留 6px 缩进,与上方内容区分 */
364
+ }
365
+ [data-pane="conversation"] [class*="_actions"],
366
+ [data-slot^="conversation.chat"] [class*="_actions"] {
367
+ flex-wrap: wrap !important;
368
+ height: auto !important;
369
+ overflow: visible !important;
370
+ }
371
+ [data-pane="conversation"] [class*="_actions"] svg,
372
+ [data-slot^="conversation.chat"] [class*="_actions"] svg,
373
+ [data-pane="conversation"] [class*="_leading"] svg,
374
+ [data-slot^="conversation.chat"] [class*="_leading"] svg {
375
+ width: 13px !important;
376
+ height: 13px !important;
377
+ max-width: 13px !important;
378
+ max-height: 13px !important;
379
+ vertical-align: middle !important;
380
+ flex: none !important;
381
+ }
382
+
383
+ /* 5. 聊天输入区(composer)紧凑:ghost-textarea 三元件(backdrop 文字层 / input 输入层 / mirror 撑高层)
384
+ 官方共用同一排版:card 16px/24px,三层皆 inherit,且官方 JS 读 mirror 的 lineHeight 做光标滚动定位。
385
+ 因此必须整体改 card 一个基准让三层同步,绝不能只改其中一两层,否则光标随行数错位、高度不撑开。
386
+ 注:插件已设 user-scalable=no,iOS 聚焦不会放大,14px 安全。 */
387
+ [data-composer-card="true"] {
388
+ font-size: 14px !important;
389
+ line-height: 20px !important;
390
+ }
391
+ /* ghost-textarea 三件套(backdrop 文字层 / input 输入层 / mirror 撑高层)全参数锁定:
392
+ 与官方共享样式基准一致(仅字号行高改小),根除继承链差异 → 光标/高度/滚动定位同步 */
393
+ [data-composer-card="true"] textarea,
394
+ [data-composer-card="true"] [data-dsh-part="composer-input"],
395
+ [data-composer-card="true"] [data-input-mirror="true"],
396
+ [data-composer-card="true"] [data-input-backdrop="true"] {
397
+ font-family: var(--dsw-font-family, inherit) !important;
398
+ font-size: 14px !important;
399
+ line-height: 20px !important;
400
+ letter-spacing: 0 !important;
401
+ white-space: pre-wrap !important;
402
+ word-break: break-word !important;
403
+ overflow-wrap: anywhere !important;
404
+ padding: 4px 12px 0 16px !important;
405
+ }
406
+ [data-composer-card="true"] button,
407
+ [data-composer-card="true"] [role="button"],
408
+ [data-composer-card="true"] [class*="_triggerLabel"] {
409
+ font-size: 13px !important;
410
+ }
411
+ /* 输入框整体加宽:composer 外层容器左右留白 16px → 8px(:has 精准定位 card 的直接父,避免误伤其它 _root) */
412
+ [class*="_root"]:has(> [data-composer-card="true"]) {
413
+ padding: 0 8px 8px !important;
414
+ }
415
+ [data-composer-card="true"] svg {
416
+ width: 13px !important;
417
+ height: 13px !important;
418
+ max-width: 13px !important;
419
+ max-height: 13px !important;
420
+ flex: none !important;
421
+ }
422
+
423
+ `;
424
+ /**
425
+ * 内置官方预设:按界面区域划分的三段预设。
426
+ * 默认【移动端启用(≤900px 窄视口)、PC 关闭(>900px 宽视口)】——预设面向窄屏优化,宽屏按需开启。
427
+ */
428
+ export const BUILTIN_PRESETS = [
429
+ {
430
+ id: 'preset-sidebar',
431
+ name: { zh: '侧边栏抽屉导航', en: 'Sidebar Drawer' },
432
+ description: {
433
+ zh: '折叠时侧边栏 0 宽度占用,官方展开按钮浮动在屏幕左上角并可拖动记忆位置;点击直接触发官方原生展开。',
434
+ en: 'Zero-width sidebar when collapsed with a draggable floating toggle; expands to the native official layout.',
435
+ },
436
+ defaultPcEnabled: false,
437
+ defaultMobileEnabled: true,
438
+ css: PRESET_SIDEBAR_CSS,
439
+ },
440
+ {
441
+ id: 'preset-settings',
442
+ name: { zh: '设置面板适配', en: 'Settings Panel' },
443
+ description: {
444
+ zh: '官方设置弹窗居中微缩显示更多内容,遮罩锁死滚动,内部支持左右横滑。',
445
+ en: 'Centers and scales down the settings dialog, locks overlay scroll, and enables horizontal panning.',
446
+ },
447
+ defaultPcEnabled: false,
448
+ defaultMobileEnabled: true,
449
+ css: PRESET_SETTINGS_CSS,
450
+ },
451
+ {
452
+ id: 'preset-main',
453
+ name: { zh: '正文紧凑排版', en: 'Conversation Layout' },
454
+ description: {
455
+ zh: '对话正文高密度排版:小字号(12.5px)+ 紧凑行距 + 收紧边距,每行展示更多内容;不做布局缩放以免产生轨道空白。',
456
+ en: 'High-density conversation typography: compact 12.5px text, tight line-height and margins → more content per line; no layout scaling so no gaps appear.',
457
+ },
458
+ defaultPcEnabled: false,
459
+ defaultMobileEnabled: true,
460
+ css: PRESET_MAIN_CSS,
461
+ },
462
+ ];
463
+ /** 持久化文件的结构版本(v2:双端开关 presetStates + custom.pcEnabled/mobileEnabled) */
464
+ export const STORE_VERSION = 2;
465
+ /** 判断请求是否来自移动端浏览器 */
466
+ export function isMobileUserAgent(ua = '') {
467
+ return MOBILE_UA_REGEX.test(ua);
468
+ }
469
+ /** 给 <html> 打上 data-dsh-mobile 标记(幂等) */
470
+ export function applyDataMobileAttr(html) {
471
+ if (html.includes('data-dsh-mobile'))
472
+ return html;
473
+ return html.replace(/<html([^>]*)>/i, '<html$1 data-dsh-mobile="1">');
474
+ }
475
+ /** 把收集到的 CSS 包装成 <style> 注入标签(空内容返回空串) */
476
+ export function buildMobileStyleTag(css) {
477
+ if (!css)
478
+ return '';
479
+ // HTML raw-text 元素防护:转义 `</style`,防止片段意外/恶意提前闭合 <style> 标签结构
480
+ // (与 dsh-host-webserver 对 IndexInjection 'style' 行的约束一致:text 不得包含 </style)
481
+ const safeCss = css.replace(/<\/style/gi, '<\\/style');
482
+ return `<style id="dsh-remote-mobile-style-snippets">\n${safeCss.trim()}\n</style>`;
483
+ }
484
+ /** 简单的 UTF-8 校验,防止坏 JSON 拖垮存储 */
485
+ function isPlainObject(v) {
486
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
487
+ }
488
+ /**
489
+ * 样式片段存储:内置预设 + 用户自定义的启停状态与内容管理。
490
+ * 采用防抖 + 原子写盘,与 SessionStore 的持久化策略保持一致。
491
+ */
492
+ export class StyleSnippetStore {
493
+ filePath;
494
+ presetStates = new Map();
495
+ custom = new Map();
496
+ customOrder = [];
497
+ persistTimer = null;
498
+ dirty = false;
499
+ constructor(filePath = DEFAULT_STYLE_FILE) {
500
+ this.filePath = filePath;
501
+ this.load();
502
+ // 注册到进程退出兜底落盘集合(与 SessionStore 保持一致):
503
+ // 保证剩余改动在事件循环清空时也能 flush 到磁盘
504
+ activeStores.add(this);
505
+ }
506
+ /** 文件路径(用于界面展示) */
507
+ get persistPath() {
508
+ return this.filePath;
509
+ }
510
+ /** 读取持久化文件(自动迁移 v1 → v2 双端开关格式) */
511
+ load() {
512
+ try {
513
+ if (!existsSync(this.filePath))
514
+ return;
515
+ const raw = readFileSync(this.filePath, 'utf8');
516
+ const data = JSON.parse(raw);
517
+ if (!isPlainObject(data))
518
+ return;
519
+ const version = typeof data.version === 'number' ? data.version : 1;
520
+ if (version >= 2 && isPlainObject(data.presetStates)) {
521
+ for (const [id, st] of Object.entries(data.presetStates)) {
522
+ if (!isPlainObject(st))
523
+ continue;
524
+ const rec = {};
525
+ if (typeof st.pc === 'boolean')
526
+ rec.pc = st.pc;
527
+ if (typeof st.mobile === 'boolean')
528
+ rec.mobile = st.mobile;
529
+ this.presetStates.set(id, rec);
530
+ }
531
+ }
532
+ else if (isPlainObject(data.builtinEnabled)) {
533
+ // v1 迁移:旧语义为「enabled + mobileOnly」,内置预设全部面向移动端
534
+ for (const [id, enabled] of Object.entries(data.builtinEnabled)) {
535
+ this.presetStates.set(id, { pc: false, mobile: Boolean(enabled) });
536
+ }
537
+ }
538
+ const customs = Array.isArray(data.custom) ? data.custom : [];
539
+ for (const item of customs) {
540
+ if (!isPlainObject(item) || typeof item.id !== 'string' || typeof item.css !== 'string')
541
+ continue;
542
+ let pcEnabled;
543
+ let mobileEnabled;
544
+ if (typeof item.pcEnabled === 'boolean' || typeof item.mobileEnabled === 'boolean') {
545
+ pcEnabled = item.pcEnabled !== false;
546
+ mobileEnabled = item.mobileEnabled !== false;
547
+ }
548
+ else {
549
+ // v1 自定义片段迁移:mobileOnly=true → 仅移动端;false → 双端
550
+ const enabled = item.enabled !== false;
551
+ const mobileOnly = item.mobileOnly !== false;
552
+ pcEnabled = enabled && !mobileOnly;
553
+ mobileEnabled = enabled;
554
+ }
555
+ this.custom.set(item.id, {
556
+ id: item.id,
557
+ name: typeof item.name === 'string' ? item.name : item.id,
558
+ description: typeof item.description === 'string' ? item.description : undefined,
559
+ css: item.css,
560
+ pcEnabled,
561
+ mobileEnabled,
562
+ createdAt: typeof item.createdAt === 'number' ? item.createdAt : undefined,
563
+ updatedAt: typeof item.updatedAt === 'number' ? item.updatedAt : undefined,
564
+ });
565
+ }
566
+ const order = Array.isArray(data.customOrder) ? data.customOrder : [];
567
+ this.customOrder = order.filter((id) => typeof id === 'string' && this.custom.has(id));
568
+ // 未在 order 中的历史片段按创建时间补齐
569
+ for (const id of [...this.custom.keys()]) {
570
+ if (!this.customOrder.includes(id))
571
+ this.customOrder.push(id);
572
+ }
573
+ }
574
+ catch {
575
+ // 损坏文件不阻塞启动:保留原文件供排查,内存从空开始
576
+ }
577
+ }
578
+ /** 触发防抖持久化 */
579
+ schedulePersist() {
580
+ this.dirty = true;
581
+ if (this.persistTimer)
582
+ return;
583
+ this.persistTimer = setTimeout(() => {
584
+ this.persistTimer = null;
585
+ this.flushPersistedData();
586
+ }, 300);
587
+ if (typeof this.persistTimer.unref === 'function') {
588
+ ;
589
+ this.persistTimer.unref();
590
+ }
591
+ }
592
+ /** 立即落盘(进程退出前也会被调用) */
593
+ flushPersistedData() {
594
+ if (this.persistTimer) {
595
+ clearTimeout(this.persistTimer);
596
+ this.persistTimer = null;
597
+ }
598
+ if (!this.dirty)
599
+ return;
600
+ this.dirty = false;
601
+ // 分组落盘:恒写入全部内置预设(preset-sidebar / preset-settings / preset-main 三组齐全),
602
+ // 每组为「用户覆盖 ?? 代码默认」的当前有效状态,pc/mobile 两字段齐全
603
+ const presetStates = {};
604
+ for (const preset of BUILTIN_PRESETS) {
605
+ const st = this.presetStates.get(preset.id) ?? {};
606
+ presetStates[preset.id] = {
607
+ pc: st.pc ?? preset.defaultPcEnabled ?? false,
608
+ mobile: st.mobile ?? preset.defaultMobileEnabled ?? true,
609
+ };
610
+ }
611
+ const payload = {
612
+ version: STORE_VERSION,
613
+ presetStates,
614
+ custom: [...this.custom.values()],
615
+ customOrder: this.customOrder,
616
+ };
617
+ const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
618
+ try {
619
+ mkdirSync(dirname(this.filePath), { recursive: true });
620
+ writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
621
+ renameSync(tmpPath, this.filePath);
622
+ }
623
+ catch {
624
+ try {
625
+ if (existsSync(tmpPath))
626
+ unlinkSync(tmpPath);
627
+ }
628
+ catch { }
629
+ }
630
+ }
631
+ /** 全部片段:内置预设(按注册序)+ 自定义片段(按 customOrder)。lang 决定内置预设名称/描述的本地化。 */
632
+ list(lang = 'zh') {
633
+ const l = lang === 'en' ? 'en' : 'zh';
634
+ const out = [];
635
+ for (const preset of BUILTIN_PRESETS) {
636
+ const st = this.presetStates.get(preset.id) ?? {};
637
+ out.push({
638
+ id: preset.id,
639
+ name: preset.name[l],
640
+ description: preset.description[l],
641
+ css: preset.css,
642
+ pcEnabled: st.pc ?? preset.defaultPcEnabled ?? false,
643
+ mobileEnabled: st.mobile ?? preset.defaultMobileEnabled ?? true,
644
+ builtin: true,
645
+ });
646
+ }
647
+ for (const id of this.customOrder) {
648
+ const s = this.custom.get(id);
649
+ if (s)
650
+ out.push({ ...s });
651
+ }
652
+ return out;
653
+ }
654
+ /** 按 id 获取片段(内置或自定义) */
655
+ get(id, lang = 'zh') {
656
+ return this.list(lang).find((s) => s.id === id);
657
+ }
658
+ /**
659
+ * 按端启停任意片段(内置与自定义均可)。
660
+ * @param scope - pc:桌面端开关;mobile:移动端开关
661
+ */
662
+ setEnabled(id, scope, enabled) {
663
+ if (this.custom.has(id)) {
664
+ const s = this.custom.get(id);
665
+ if (scope === 'pc')
666
+ s.pcEnabled = enabled;
667
+ else
668
+ s.mobileEnabled = enabled;
669
+ s.updatedAt = Date.now();
670
+ this.custom.set(id, s);
671
+ this.schedulePersist();
672
+ return true;
673
+ }
674
+ const preset = BUILTIN_PRESETS.find((p) => p.id === id);
675
+ if (preset) {
676
+ // 按端精确记录:仅写入被操作的那一端,未操作端保持缺省(运行时回落预设默认值)
677
+ const cur = this.presetStates.get(id) ?? {};
678
+ if (scope === 'pc')
679
+ cur.pc = enabled;
680
+ else
681
+ cur.mobile = enabled;
682
+ this.presetStates.set(id, cur);
683
+ this.schedulePersist();
684
+ return true;
685
+ }
686
+ return false;
687
+ }
688
+ /** 重置所有片段的启停状态:内置恢复各自默认(移动端开/PC 关),自定义恢复为移动端开/PC 关 */
689
+ resetEnabled() {
690
+ this.presetStates.clear();
691
+ for (const s of this.custom.values()) {
692
+ s.pcEnabled = false;
693
+ s.mobileEnabled = true;
694
+ }
695
+ this.schedulePersist();
696
+ }
697
+ /** 新增或更新一个自定义片段 */
698
+ upsertCustom(input) {
699
+ const name = String(input.name || '').trim();
700
+ const css = String(input.css || '');
701
+ if (!name)
702
+ throw new Error('snippet name is required');
703
+ if (name.length > MAX_NAME_LENGTH)
704
+ throw new Error('snippet name too long');
705
+ if (!css.trim())
706
+ throw new Error('snippet css is required');
707
+ if (css.length > MAX_CSS_LENGTH)
708
+ throw new Error('snippet css too large');
709
+ // 防冲突:自定义片段不得占用内置预设 id 或 preset- 保留前缀(否则 list/setEnabled/删除均会歧义)
710
+ if (input.id) {
711
+ if (BUILTIN_PRESETS.some((p) => p.id === input.id) || input.id.startsWith(BUILTIN_ID_PREFIX)) {
712
+ throw new Error('snippet id is reserved for builtin presets');
713
+ }
714
+ }
715
+ const now = Date.now();
716
+ const existingId = input.id && this.custom.has(input.id) ? input.id : null;
717
+ const snippet = {
718
+ id: existingId || `custom-${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
719
+ name,
720
+ description: input.description ? String(input.description).trim() : undefined,
721
+ css,
722
+ pcEnabled: input.pcEnabled ?? false,
723
+ mobileEnabled: input.mobileEnabled ?? true,
724
+ createdAt: existingId ? this.custom.get(existingId).createdAt : now,
725
+ updatedAt: now,
726
+ };
727
+ if (existingId) {
728
+ this.custom.set(existingId, snippet);
729
+ }
730
+ else {
731
+ this.custom.set(snippet.id, snippet);
732
+ this.customOrder.push(snippet.id);
733
+ }
734
+ this.schedulePersist();
735
+ return { ...snippet };
736
+ }
737
+ /** 删除一个用户自定义片段(内置预设不可删除) */
738
+ removeCustom(id) {
739
+ if (!this.custom.has(id))
740
+ return false;
741
+ this.custom.delete(id);
742
+ this.customOrder = this.customOrder.filter((x) => x !== id);
743
+ this.schedulePersist();
744
+ return true;
745
+ }
746
+ /**
747
+ * 收集最终注入的 CSS(与设备 UA 无关,按视口宽度档生效):
748
+ * - mobileEnabled && !pcEnabled → 包 @media (max-width: 900px),窄视口(≤900px)生效;
749
+ * - pcEnabled && !mobileEnabled → 包 @media (min-width: 901px),宽视口(>900px)生效;
750
+ * - 两端都开 → 原样注入,全宽度生效。
751
+ */
752
+ collectCss() {
753
+ const parts = [];
754
+ for (const snippet of this.list()) {
755
+ const pc = snippet.pcEnabled;
756
+ const mobile = snippet.mobileEnabled;
757
+ if (!pc && !mobile)
758
+ continue;
759
+ const rawCss = snippet.css.trim();
760
+ let cssText;
761
+ if (pc && mobile) {
762
+ cssText = rawCss;
763
+ }
764
+ else if (mobile) {
765
+ cssText = `@media (max-width: 900px) {\n${rawCss}\n}`;
766
+ }
767
+ else {
768
+ cssText = `@media (min-width: 901px) {\n${rawCss}\n}`;
769
+ }
770
+ parts.push(`/* ${snippet.name} (${snippet.id}) */\n${cssText}`);
771
+ }
772
+ return parts.join('\n\n');
773
+ }
774
+ }
775
+ // 进程退出前兜底落盘(与 SessionStore 思路一致)
776
+ const activeStores = new Set();
777
+ if (typeof process !== 'undefined' && typeof process.on === 'function') {
778
+ process.on('beforeExit', () => {
779
+ for (const s of activeStores) {
780
+ try {
781
+ s.flushPersistedData();
782
+ }
783
+ catch { }
784
+ }
785
+ });
786
+ }