@yezack/dsh-remote-mobile 0.0.0-stage → 1.9.1

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,418 @@
1
+ import { getPublicKeyPem } from '../auth/crypto.js';
2
+ /**
3
+ * 手机端授权与登录页面 HTML
4
+ */
5
+ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
6
+ const inlinePublicKey = publicKeyPem || getPublicKeyPem();
7
+ const isEn = lang === 'en';
8
+ const i18n = {
9
+ pageTitle: isEn ? 'DSH Remote Access Authorization' : 'DSH 移动端访问授权',
10
+ headerTitle: isEn ? 'Remote Access Auth' : '移动端访问授权',
11
+ headerDesc: isEn ? 'Enter 6-digit dynamic pairing code or persistent password' : '请输入 6 位动态配对码或长期访问密码',
12
+ placeholder: isEn ? '6-digit code or password' : '6 位临时配对码 / 长期密码',
13
+ submitBtn: isEn ? 'Authorize & Connect' : '立即授权连接',
14
+ footerSecurity: isEn ? '🔒 Protected by 2048-bit RSA encryption' : '🔒 全程受 2048 位 RSA 加密保护',
15
+ verifying: isEn ? '🔐 Encrypting & verifying...' : '🔐 正在 RSA 加密与验证...',
16
+ success: isEn ? 'Authorization successful! Entering workspace...' : '授权成功!正在进入工作区...',
17
+ networkErr: isEn ? 'Network connection error, please try again' : '网络连接异常,请检查网络',
18
+ };
19
+ return `<!DOCTYPE html>
20
+ <html lang="${isEn ? 'en' : 'zh-CN'}">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
24
+ <title>${i18n.pageTitle}</title>
25
+ <style>
26
+ :root {
27
+ --bg: #0f1117;
28
+ --card-bg: rgba(26, 29, 39, 0.85);
29
+ --card-border: rgba(255, 255, 255, 0.08);
30
+ --primary: #3b82f6;
31
+ --primary-hover: #2563eb;
32
+ --text: #f3f4f6;
33
+ --text-muted: #9ca3af;
34
+ --error: #ef4444;
35
+ --success: #10b981;
36
+ }
37
+ @media (prefers-color-scheme: light) {
38
+ :root {
39
+ --bg: #f3f4f6;
40
+ --card-bg: rgba(255, 255, 255, 0.9);
41
+ --card-border: rgba(0, 0, 0, 0.08);
42
+ --text: #111827;
43
+ --text-muted: #6b7280;
44
+ }
45
+ }
46
+ * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
47
+ body {
48
+ background: var(--bg);
49
+ color: var(--text);
50
+ min-height: 100vh;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ padding: 20px;
55
+ }
56
+ .auth-card {
57
+ width: 100%;
58
+ max-width: 380px;
59
+ background: var(--card-bg);
60
+ backdrop-filter: blur(16px);
61
+ -webkit-backdrop-filter: blur(16px);
62
+ border: 1px solid var(--card-border);
63
+ border-radius: 20px;
64
+ padding: 32px 24px;
65
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
66
+ text-align: center;
67
+ }
68
+ .logo-badge {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ width: 56px;
73
+ height: 56px;
74
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
75
+ border-radius: 16px;
76
+ margin-bottom: 20px;
77
+ color: white;
78
+ box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
79
+ }
80
+ h1 {
81
+ font-size: 20px;
82
+ font-weight: 700;
83
+ margin-bottom: 8px;
84
+ }
85
+ p.desc {
86
+ font-size: 13px;
87
+ color: var(--text-muted);
88
+ line-height: 1.5;
89
+ margin-bottom: 24px;
90
+ }
91
+ .input-group {
92
+ margin-bottom: 16px;
93
+ }
94
+ input {
95
+ width: 100%;
96
+ height: 48px;
97
+ background: rgba(128, 128, 128, 0.08);
98
+ border: 1px solid var(--card-border);
99
+ border-radius: 12px;
100
+ padding: 0 16px;
101
+ font-size: 16px;
102
+ color: var(--text);
103
+ outline: none;
104
+ text-align: center;
105
+ letter-spacing: 2px;
106
+ transition: all 0.2s;
107
+ }
108
+ input:focus {
109
+ border-color: var(--primary);
110
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
111
+ }
112
+ button.submit-btn {
113
+ width: 100%;
114
+ height: 48px;
115
+ background: var(--primary);
116
+ color: white;
117
+ border: none;
118
+ border-radius: 12px;
119
+ font-size: 15px;
120
+ font-weight: 600;
121
+ cursor: pointer;
122
+ transition: background 0.2s;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ gap: 8px;
127
+ }
128
+ button.submit-btn:active {
129
+ background: var(--primary-hover);
130
+ transform: scale(0.99);
131
+ }
132
+ .status-msg {
133
+ margin-top: 16px;
134
+ font-size: 13px;
135
+ display: none;
136
+ }
137
+ .status-msg.error {
138
+ color: var(--error);
139
+ display: block;
140
+ }
141
+ .status-msg.success {
142
+ color: var(--success);
143
+ display: block;
144
+ }
145
+ .info-footer {
146
+ margin-top: 24px;
147
+ padding-top: 16px;
148
+ border-top: 1px solid var(--card-border);
149
+ font-size: 12px;
150
+ color: var(--text-muted);
151
+ line-height: 1.6;
152
+ text-align: left;
153
+ }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <div class="auth-card">
158
+ <div class="logo-badge">
159
+ <svg style="width: 28px; height: 28px; fill: currentColor;" viewBox="0 0 24 24">
160
+ <path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
161
+ </svg>
162
+ </div>
163
+ <h1>${i18n.headerTitle}</h1>
164
+ <p class="desc">${i18n.headerDesc}</p>
165
+
166
+ <form id="authForm">
167
+ <div class="input-group">
168
+ <input
169
+ type="password"
170
+ id="credentialInput"
171
+ placeholder="${i18n.placeholder}"
172
+ autocomplete="off"
173
+ required
174
+ />
175
+ </div>
176
+ <button type="submit" class="submit-btn" id="submitBtn">
177
+ ${i18n.submitBtn}
178
+ </button>
179
+ <div id="statusMsg" class="status-msg"></div>
180
+ </form>
181
+
182
+ <div class="info-footer">
183
+ <div>${i18n.footerSecurity}</div>
184
+ </div>
185
+ </div>
186
+
187
+ <script>
188
+ const SERVER_RSA_KEY = ${JSON.stringify(inlinePublicKey)};
189
+ const form = document.getElementById('authForm');
190
+ const input = document.getElementById('credentialInput');
191
+ const statusMsg = document.getElementById('statusMsg');
192
+ const submitBtn = document.getElementById('submitBtn');
193
+
194
+ // 解析 SPKI 格式公钥并提取 n 和 e(用于纯 JS 垫片计算)
195
+ function parseSpkiKey(pemStr) {
196
+ const b64 = pemStr.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
197
+ const binary = window.atob(b64);
198
+ const bytes = new Uint8Array(binary.length);
199
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
200
+
201
+ let idx = 0;
202
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
203
+ const lenByte = bytes[idx + 1];
204
+ let nLen = 0;
205
+ let nStart = 0;
206
+ if (lenByte === 0x82) {
207
+ nLen = (bytes[idx + 2] << 8) | bytes[idx + 3];
208
+ nStart = idx + 4;
209
+ } else if (lenByte === 0x81) {
210
+ nLen = bytes[idx + 2];
211
+ nStart = idx + 3;
212
+ } else {
213
+ nLen = lenByte;
214
+ nStart = idx + 2;
215
+ }
216
+ let nBytes = bytes.slice(nStart, nStart + nLen);
217
+ if (nBytes[0] === 0x00) nBytes = nBytes.slice(1);
218
+
219
+ idx = nStart + nLen;
220
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
221
+ const eLen = bytes[idx + 1];
222
+ const eBytes = bytes.slice(idx + 2, idx + 2 + eLen);
223
+
224
+ let nHex = '';
225
+ for (let b of nBytes) nHex += b.toString(16).padStart(2, '0');
226
+ let eHex = '';
227
+ for (let b of eBytes) eHex += b.toString(16).padStart(2, '0');
228
+
229
+ return { n: BigInt('0x' + nHex), e: BigInt('0x' + eHex), keyLen: nBytes.length };
230
+ }
231
+
232
+ function modPow(b, exp, mod) {
233
+ let res = 1n;
234
+ let base = b % mod;
235
+ let e = exp;
236
+ while (e > 0n) {
237
+ if (e & 1n) res = (res * base) % mod;
238
+ base = (base * base) % mod;
239
+ e >>= 1n;
240
+ }
241
+ return res;
242
+ }
243
+
244
+ // 纯 JS RSA 垫片加密:使用 CSPRNG (crypto.getRandomValues) 填充
245
+ function rsaEncryptPureJs(plainText, pubPem) {
246
+ const { n, e, keyLen } = parseSpkiKey(pubPem);
247
+ const msgBytes = new TextEncoder().encode(plainText);
248
+ const padLen = keyLen - 3 - msgBytes.length;
249
+ if (padLen < 8) throw new Error('Message too long');
250
+
251
+ const pad = new Uint8Array(padLen);
252
+ if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
253
+ window.crypto.getRandomValues(pad);
254
+ for (let i = 0; i < padLen; i++) {
255
+ if (pad[i] === 0) pad[i] = (Math.floor(Math.random() * 254) + 1);
256
+ }
257
+ } else {
258
+ for (let i = 0; i < padLen; i++) {
259
+ pad[i] = Math.floor(Math.random() * 255) + 1;
260
+ }
261
+ }
262
+
263
+ const em = new Uint8Array(keyLen);
264
+ em[0] = 0x00;
265
+ em[1] = 0x02;
266
+ em.set(pad, 2);
267
+ em[2 + padLen] = 0x00;
268
+ em.set(msgBytes, 3 + padLen);
269
+
270
+ let emHex = '';
271
+ for (let b of em) emHex += b.toString(16).padStart(2, '0');
272
+ const m = BigInt('0x' + emHex);
273
+ const c = modPow(m, e, n);
274
+ let cHex = c.toString(16);
275
+ if (cHex.length % 2 !== 0) cHex = '0' + cHex;
276
+ while (cHex.length < keyLen * 2) cHex = '00' + cHex;
277
+
278
+ let binary = '';
279
+ for (let i = 0; i < cHex.length; i += 2) {
280
+ binary += String.fromCharCode(parseInt(cHex.slice(i, i + 2), 16));
281
+ }
282
+ return window.btoa(binary);
283
+ }
284
+
285
+ // 尝试使用 Web Crypto API 原生 RSA-OAEP SHA-256 加密(安全上下文可用)
286
+ async function trySubtleEncrypt(plainText, pubPem) {
287
+ if (!window.crypto || !window.crypto.subtle) return null;
288
+ const b64 = pubPem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
289
+ const binary = window.atob(b64);
290
+ const bytes = new Uint8Array(binary.length);
291
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
292
+
293
+ const cryptoKey = await window.crypto.subtle.importKey(
294
+ 'spki',
295
+ bytes.buffer,
296
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
297
+ false,
298
+ ['encrypt']
299
+ );
300
+
301
+ const encBuffer = await window.crypto.subtle.encrypt(
302
+ { name: 'RSA-OAEP' },
303
+ cryptoKey,
304
+ new TextEncoder().encode(plainText)
305
+ );
306
+
307
+ const encBytes = new Uint8Array(encBuffer);
308
+ let binaryEnc = '';
309
+ for (let i = 0; i < encBytes.length; i++) binaryEnc += String.fromCharCode(encBytes[i]);
310
+ return window.btoa(binaryEnc);
311
+ }
312
+
313
+ async function encryptCredential(plainText) {
314
+ try {
315
+ const subtleB64 = await trySubtleEncrypt(plainText, SERVER_RSA_KEY);
316
+ if (subtleB64) return { encryptedCredential: subtleB64 };
317
+ } catch (e) {}
318
+
319
+ try {
320
+ const pureB64 = rsaEncryptPureJs(plainText, SERVER_RSA_KEY);
321
+ return { encryptedCredential: pureB64 };
322
+ } catch (err) {
323
+ console.error('RSA encrypt error:', err);
324
+ return { credential: plainText };
325
+ }
326
+ }
327
+
328
+ // 检测 URL 参数里的 token,仅回填输入框,不自动触发验证
329
+ const urlParams = new URLSearchParams(window.location.search);
330
+ const queryToken = urlParams.get('token');
331
+ if (queryToken) {
332
+ input.value = queryToken;
333
+ }
334
+
335
+ // 自适应网关子路径 / baseURI 与重定向
336
+ const verifyApiUrl = (function () {
337
+ try {
338
+ if (document.baseURI && document.querySelector('base')) {
339
+ return new URL('api/remote-mobile/verify', document.baseURI).toString();
340
+ }
341
+ // ⚠️ 本段内联脚本位于模板字符串内:反斜杠在字符串求值时会被吃掉,
342
+ // 所以 /auth 后缀正则必须写成双反斜杠形式,否则会拼出以两条斜杠开头
343
+ // 的行注释,使整个 script 抛 SyntaxError、登录按钮彻底失效(下两处同理)。
344
+ const pathname = window.location.pathname.replace(/\\/auth\\/?$/, '/');
345
+ return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
346
+ } catch (e) {
347
+ return '/api/remote-mobile/verify';
348
+ }
349
+ })();
350
+
351
+ const successRedirectUrl = (function () {
352
+ try {
353
+ // 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
354
+ const redirectParam = urlParams.get('redirect');
355
+ if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
356
+ const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '');
357
+ if (appPrefix && redirectParam.startsWith(appPrefix)) {
358
+ return window.location.origin + redirectParam;
359
+ }
360
+ return window.location.origin + appPrefix + redirectParam;
361
+ }
362
+ if (document.baseURI && document.querySelector('base')) {
363
+ return new URL('./', document.baseURI).toString();
364
+ }
365
+ const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '/');
366
+ return window.location.origin + appPrefix;
367
+ } catch (e) {
368
+ return '/';
369
+ }
370
+ })();
371
+
372
+ form.addEventListener('submit', (e) => {
373
+ e.preventDefault();
374
+ const val = input.value.trim();
375
+ if (!val) return;
376
+ doVerify(val);
377
+ });
378
+
379
+ async function doVerify(credential) {
380
+ submitBtn.disabled = true;
381
+ submitBtn.innerText = ${JSON.stringify(i18n.verifying)};
382
+ statusMsg.className = 'status-msg';
383
+ statusMsg.style.display = 'none';
384
+
385
+ try {
386
+ const payload = await encryptCredential(credential);
387
+ const res = await fetch(verifyApiUrl, {
388
+ method: 'POST',
389
+ headers: { 'Content-Type': 'application/json' },
390
+ body: JSON.stringify(payload)
391
+ });
392
+ const data = await res.json();
393
+ if (data.success) {
394
+ statusMsg.className = 'status-msg success';
395
+ statusMsg.innerText = ${JSON.stringify(i18n.success)};
396
+ statusMsg.style.display = 'block';
397
+ setTimeout(() => {
398
+ window.location.href = successRedirectUrl;
399
+ }, 600);
400
+ } else {
401
+ statusMsg.className = 'status-msg error';
402
+ statusMsg.innerText = data.reason || ${JSON.stringify(isEn ? 'Authorization failed, please try again' : '授权失败,请重试')};
403
+ statusMsg.style.display = 'block';
404
+ submitBtn.disabled = false;
405
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
406
+ }
407
+ } catch (err) {
408
+ statusMsg.className = 'status-msg error';
409
+ statusMsg.innerText = ${JSON.stringify(i18n.networkErr)};
410
+ statusMsg.style.display = 'block';
411
+ submitBtn.disabled = false;
412
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
413
+ }
414
+ }
415
+ </script>
416
+ </body>
417
+ </html>`;
418
+ }
@@ -0,0 +1,133 @@
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
+ /** 用户自定义样式片段持久化文件 */
20
+ export declare const DEFAULT_STYLE_FILE: string;
21
+ /** 移动端 User-Agent 识别正则 */
22
+ export declare const MOBILE_UA_REGEX: RegExp;
23
+ /** 单片段 CSS 长度上限(防配置膨胀) */
24
+ export declare const MAX_CSS_LENGTH = 200000;
25
+ /** 片段名称长度上限 */
26
+ export declare const MAX_NAME_LENGTH = 80;
27
+ /** 内置预设 id 保留前缀(自定义片段不得占用) */
28
+ export declare const BUILTIN_ID_PREFIX = "preset-";
29
+ export interface StyleSnippet {
30
+ /** 稳定唯一 id(内置片段为 preset-xxx,自定义片段为 custom-xxx) */
31
+ id: string;
32
+ /** 片段名称(list(lang) 按语言本地化;自定义片段为用户填写原文) */
33
+ name: string;
34
+ /** 可选说明文案 */
35
+ description?: string;
36
+ /** CSS 代码片段本体 */
37
+ css: string;
38
+ /** 桌面端(PC 浏览器)是否注入 */
39
+ pcEnabled: boolean;
40
+ /** 移动端(手机/平板浏览器)是否注入 */
41
+ mobileEnabled: boolean;
42
+ /** 是否为内置预设(内置不可删除,仅可启停) */
43
+ builtin?: boolean;
44
+ createdAt?: number;
45
+ updatedAt?: number;
46
+ }
47
+ export interface BuiltinStylePreset {
48
+ id: string;
49
+ name: {
50
+ zh: string;
51
+ en: string;
52
+ };
53
+ description: {
54
+ zh: string;
55
+ en: string;
56
+ };
57
+ css: string;
58
+ /** 桌面端默认启停(默认 false:预设主要面向移动端) */
59
+ defaultPcEnabled?: boolean;
60
+ /** 移动端默认启停(默认 true) */
61
+ defaultMobileEnabled?: boolean;
62
+ }
63
+ export interface UpsertStyleInput {
64
+ id?: string;
65
+ name: string;
66
+ description?: string;
67
+ css: string;
68
+ pcEnabled?: boolean;
69
+ mobileEnabled?: boolean;
70
+ }
71
+ /**
72
+ * 内置官方预设:按界面区域划分的三段预设。
73
+ * 默认【移动端启用(≤900px 窄视口)、PC 关闭(>900px 宽视口)】——预设面向窄屏优化,宽屏按需开启。
74
+ */
75
+ export declare const BUILTIN_PRESETS: BuiltinStylePreset[];
76
+ /** 持久化文件的结构版本(v2:双端开关 presetStates + custom.pcEnabled/mobileEnabled) */
77
+ export declare const STORE_VERSION = 2;
78
+ /** 判断请求是否来自移动端浏览器 */
79
+ export declare function isMobileUserAgent(ua?: string): boolean;
80
+ /** 给 <html> 打上 data-dsh-mobile 标记(幂等) */
81
+ export declare function applyDataMobileAttr(html: string): string;
82
+ /** 把收集到的 CSS 包装成 <style> 注入标签(空内容返回空串) */
83
+ export declare function buildMobileStyleTag(css: string): string;
84
+ /** 内置预设某 id 的启停状态。
85
+ * 落盘时按「三个预设分组齐全」写出:每组两条字段齐全(用户覆盖 ?? 代码默认值),
86
+ * 不再只写被操作过的端;内存与读取端允许字段缺省(回落预设默认值)。 */
87
+ export interface PresetState {
88
+ pc?: boolean;
89
+ mobile?: boolean;
90
+ }
91
+ /**
92
+ * 样式片段存储:内置预设 + 用户自定义的启停状态与内容管理。
93
+ * 采用防抖 + 原子写盘,与 SessionStore 的持久化策略保持一致。
94
+ */
95
+ export declare class StyleSnippetStore {
96
+ private filePath;
97
+ private presetStates;
98
+ private custom;
99
+ private customOrder;
100
+ private persistTimer;
101
+ private dirty;
102
+ constructor(filePath?: string);
103
+ /** 文件路径(用于界面展示) */
104
+ get persistPath(): string;
105
+ /** 读取持久化文件(自动迁移 v1 → v2 双端开关格式) */
106
+ load(): void;
107
+ /** 触发防抖持久化 */
108
+ private schedulePersist;
109
+ /** 立即落盘(进程退出前也会被调用) */
110
+ flushPersistedData(): void;
111
+ /** 全部片段:内置预设(按注册序)+ 自定义片段(按 customOrder)。lang 决定内置预设名称/描述的本地化。 */
112
+ list(lang?: 'zh' | 'en'): StyleSnippet[];
113
+ /** 按 id 获取片段(内置或自定义) */
114
+ get(id: string, lang?: 'zh' | 'en'): StyleSnippet | undefined;
115
+ /**
116
+ * 按端启停任意片段(内置与自定义均可)。
117
+ * @param scope - pc:桌面端开关;mobile:移动端开关
118
+ */
119
+ setEnabled(id: string, scope: 'pc' | 'mobile', enabled: boolean): boolean;
120
+ /** 重置所有片段的启停状态:内置恢复各自默认(移动端开/PC 关),自定义恢复为移动端开/PC 关 */
121
+ resetEnabled(): void;
122
+ /** 新增或更新一个自定义片段 */
123
+ upsertCustom(input: UpsertStyleInput): StyleSnippet;
124
+ /** 删除一个用户自定义片段(内置预设不可删除) */
125
+ removeCustom(id: string): boolean;
126
+ /**
127
+ * 收集最终注入的 CSS(与设备 UA 无关,按视口宽度档生效):
128
+ * - mobileEnabled && !pcEnabled → 包 @media (max-width: 900px),窄视口(≤900px)生效;
129
+ * - pcEnabled && !mobileEnabled → 包 @media (min-width: 901px),宽视口(>900px)生效;
130
+ * - 两端都开 → 原样注入,全宽度生效。
131
+ */
132
+ collectCss(): string;
133
+ }