dsh-remote-mobile 1.2.1 → 1.4.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.
- package/CHANGELOG.md +82 -0
- package/README.md +94 -13
- package/README_EN.md +93 -13
- package/lib/auth/crypto.js +2 -2
- package/lib/auth/gate.d.ts +15 -0
- package/lib/auth/gate.js +49 -0
- package/lib/auth/token.js +9 -4
- package/lib/bridge/compat.d.ts +79 -9
- package/lib/bridge/compat.js +562 -51
- package/lib/client.js +1114 -303
- package/lib/index.d.ts +1 -0
- package/lib/index.js +9 -6
- package/lib/routes/api.d.ts +6 -1
- package/lib/routes/api.js +85 -2
- package/lib/styles/style-snippets.d.ts +133 -0
- package/lib/styles/style-snippets.js +932 -0
- package/package.json +2 -2
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { SessionStore } from './auth/token.js';
|
|
15
15
|
import { createGlobalAuthGate } from './auth/gate.js';
|
|
16
16
|
import { createRoutes } from './routes/api.js';
|
|
17
|
+
import { StyleSnippetStore } from './styles/style-snippets.js';
|
|
17
18
|
import { getLocalTailscaleIp } from './auth/tailscale.js';
|
|
18
19
|
import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, } from './bridge/compat.js';
|
|
19
20
|
/** Cordis 插件名称标识 */
|
|
@@ -75,35 +76,36 @@ function bindDshSettings(ctx, store) {
|
|
|
75
76
|
export function apply(ctx, config = {}) {
|
|
76
77
|
// 1. 初始化会话与令牌持久化管理器
|
|
77
78
|
const store = new SessionStore(config);
|
|
79
|
+
const styleStore = new StyleSnippetStore();
|
|
78
80
|
const gateMiddleware = createGlobalAuthGate(store);
|
|
79
81
|
// 2. 接入 DSH Settings 服务 (命名空间: dsh-remote-mobile)
|
|
80
82
|
bindDshSettings(ctx, store);
|
|
81
83
|
// 3. 挂载底座 WebServer 相关的门禁拦截与路由注册
|
|
82
84
|
if (ctx.webServer) {
|
|
83
85
|
if (ctx.webServer.server) {
|
|
84
|
-
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store);
|
|
86
|
+
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
|
|
85
87
|
}
|
|
86
88
|
// 挂载 HTML 模板层的 Crypto Polyfill 注入钩子
|
|
87
89
|
mountIndexInjections(ctx);
|
|
88
90
|
// 注册插件专属的 HTTP & SSE API 路由
|
|
89
91
|
if (typeof ctx.webServer.register === 'function') {
|
|
90
|
-
const routes = createRoutes(store);
|
|
92
|
+
const routes = createRoutes(store, styleStore);
|
|
91
93
|
for (const route of routes) {
|
|
92
94
|
ctx.webServer.register(route);
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
|
-
// 4. 向 Cordis 容器桥接注册 remoteWebUiPairing
|
|
97
|
-
//
|
|
98
|
+
// 4. 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决:与其他远程接入
|
|
99
|
+
// 类插件共存时自动让出,保证启动不冲突,详见 compat.ts)
|
|
98
100
|
registerRemoteWebUiPairingBridge(ctx, store);
|
|
99
101
|
// 5. 延迟补丁与服务启动日志输出
|
|
100
102
|
setTimeout(() => {
|
|
101
103
|
if (ctx.webServer && ctx.webServer.server) {
|
|
102
|
-
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store);
|
|
104
|
+
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
|
|
103
105
|
}
|
|
104
106
|
const tsIp = getLocalTailscaleIp();
|
|
105
107
|
const opts = store.getOptions();
|
|
106
|
-
ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (
|
|
108
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (上下文虚拟化桥接就绪,样式片段: ${styleStore.list().length} 个)`);
|
|
107
109
|
if (tsIp) {
|
|
108
110
|
ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:3080 (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
|
|
109
111
|
}
|
|
@@ -115,3 +117,4 @@ export * from './auth/token.js';
|
|
|
115
117
|
export * from './auth/gate.js';
|
|
116
118
|
export * from './routes/api.js';
|
|
117
119
|
export * from './bridge/compat.js';
|
|
120
|
+
export * from './styles/style-snippets.js';
|
package/lib/routes/api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type SessionStore } from '../auth/token.js';
|
|
3
|
-
|
|
3
|
+
import { type StyleSnippetStore } from '../styles/style-snippets.js';
|
|
4
|
+
export declare function createRoutes(store: SessionStore, styleStore?: StyleSnippetStore): ({
|
|
4
5
|
method: string;
|
|
5
6
|
path: string;
|
|
6
7
|
handler: (_req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -8,4 +9,8 @@ export declare function createRoutes(store: SessionStore): ({
|
|
|
8
9
|
method: string;
|
|
9
10
|
path: string;
|
|
10
11
|
handler: (req: IncomingMessage, res: ServerResponse) => Promise<ServerResponse<IncomingMessage> | undefined>;
|
|
12
|
+
} | {
|
|
13
|
+
method?: undefined;
|
|
14
|
+
path: string;
|
|
15
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
11
16
|
})[];
|
package/lib/routes/api.js
CHANGED
|
@@ -2,6 +2,8 @@ import { AUTH_COOKIE_NAME, readGlobalLocale } from '../auth/token.js';
|
|
|
2
2
|
import { getLocalTailscaleIp, getLocalLanIp, getAllNetworkIps, getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
|
|
3
3
|
import { getPublicKeyPem, decryptWithPrivateKey, validateSecretStrength, RSA_KEY_FILE } from '../auth/crypto.js';
|
|
4
4
|
import { getLoginPageHtml } from './login-page.js';
|
|
5
|
+
import { DEFAULT_STYLE_FILE } from '../styles/style-snippets.js';
|
|
6
|
+
import { getPairingBridgeState } from '../bridge/compat.js';
|
|
5
7
|
function jsonResponse(res, status, data) {
|
|
6
8
|
res.writeHead(status, {
|
|
7
9
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -34,7 +36,7 @@ function parseJsonBody(req) {
|
|
|
34
36
|
req.on('error', () => resolve({}));
|
|
35
37
|
});
|
|
36
38
|
}
|
|
37
|
-
export function createRoutes(store) {
|
|
39
|
+
export function createRoutes(store, styleStore) {
|
|
38
40
|
return [
|
|
39
41
|
// 0. RSA 公钥获取接口 (RSA-OAEP-SHA256)
|
|
40
42
|
{
|
|
@@ -59,6 +61,7 @@ export function createRoutes(store) {
|
|
|
59
61
|
const ips = getAllNetworkIps();
|
|
60
62
|
const sessionStats = store.getSessionsList();
|
|
61
63
|
const ipSecurityStats = store.getIpSecurityStats();
|
|
64
|
+
const bridgeState = getPairingBridgeState();
|
|
62
65
|
jsonResponse(res, 200, {
|
|
63
66
|
success: true,
|
|
64
67
|
locale: readGlobalLocale(),
|
|
@@ -76,7 +79,12 @@ export function createRoutes(store) {
|
|
|
76
79
|
ipSecurityStats,
|
|
77
80
|
persistPath: store.persistPath,
|
|
78
81
|
rsaKeyPath: RSA_KEY_FILE,
|
|
82
|
+
styleSnippetsPath: styleStore ? styleStore.persistPath : DEFAULT_STYLE_FILE,
|
|
79
83
|
publicKey: getPublicKeyPem(),
|
|
84
|
+
// 与其他远程接入类插件共存的裁决结果(设置页据此展示警示横幅与修复信息)
|
|
85
|
+
pairingBridgeMode: bridgeState.mode,
|
|
86
|
+
pairingBridgeConflict: bridgeState.conflictWith || '',
|
|
87
|
+
pairingBridgeConflictId: bridgeState.conflictEntryId || '',
|
|
80
88
|
});
|
|
81
89
|
},
|
|
82
90
|
},
|
|
@@ -319,7 +327,8 @@ export function createRoutes(store) {
|
|
|
319
327
|
'Content-Type': 'text/event-stream',
|
|
320
328
|
'Cache-Control': 'no-cache, no-transform',
|
|
321
329
|
'Connection': 'keep-alive',
|
|
322
|
-
|
|
330
|
+
// 注意:不设置 Access-Control-Allow-Origin。前端 Bundle 与本接口同源加载,
|
|
331
|
+
// EventSource 同源连接无需 CORS 头;开放跨域会允许任意网页读取设备与安全事件流。
|
|
323
332
|
});
|
|
324
333
|
res.write(': connected\n\n');
|
|
325
334
|
const onConnected = (device) => {
|
|
@@ -366,5 +375,79 @@ export function createRoutes(store) {
|
|
|
366
375
|
res.on('close', cleanup);
|
|
367
376
|
},
|
|
368
377
|
},
|
|
378
|
+
// 10. 移动端样式片段(样式小插件)管理 API(合并 GET 与 POST 处理,避免 webserver.register 重复 path 冲突)
|
|
379
|
+
{
|
|
380
|
+
path: '/api/remote-mobile/styles',
|
|
381
|
+
handler: async (req, res) => {
|
|
382
|
+
if (!styleStore) {
|
|
383
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
384
|
+
}
|
|
385
|
+
// POST: 新增或修改自定义片段(pcEnabled/mobileEnabled 双端开关,缺省移动端开、PC 关)
|
|
386
|
+
if (req.method === 'POST') {
|
|
387
|
+
const body = await parseJsonBody(req);
|
|
388
|
+
try {
|
|
389
|
+
const snippet = styleStore.upsertCustom({
|
|
390
|
+
id: typeof body.id === 'string' ? body.id : undefined,
|
|
391
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
392
|
+
description: typeof body.description === 'string' ? body.description : undefined,
|
|
393
|
+
css: typeof body.css === 'string' ? body.css : '',
|
|
394
|
+
pcEnabled: typeof body.pcEnabled === 'boolean' ? body.pcEnabled : false,
|
|
395
|
+
mobileEnabled: typeof body.mobileEnabled === 'boolean' ? body.mobileEnabled : true,
|
|
396
|
+
});
|
|
397
|
+
return jsonResponse(res, 200, { success: true, snippet });
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
return jsonResponse(res, 400, {
|
|
401
|
+
success: false,
|
|
402
|
+
reason: e?.message || 'invalid style snippet payload',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// 默认 GET: 列出全部样式片段(内置预设名称按请求语言本地化)
|
|
407
|
+
const accept = req.headers['accept-language'] || '';
|
|
408
|
+
const lang = accept.toLowerCase().startsWith('en') ? 'en' : readGlobalLocale();
|
|
409
|
+
jsonResponse(res, 200, {
|
|
410
|
+
success: true,
|
|
411
|
+
snippets: styleStore.list(lang),
|
|
412
|
+
persistPath: styleStore.persistPath,
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
method: 'POST',
|
|
418
|
+
path: '/api/remote-mobile/styles/toggle',
|
|
419
|
+
handler: async (req, res) => {
|
|
420
|
+
if (!styleStore) {
|
|
421
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
422
|
+
}
|
|
423
|
+
const body = await parseJsonBody(req);
|
|
424
|
+
const scope = body.scope === 'pc' ? 'pc' : 'mobile';
|
|
425
|
+
const ok = styleStore.setEnabled(String(body.id || ''), scope, Boolean(body.enabled));
|
|
426
|
+
jsonResponse(res, ok ? 200 : 400, { success: ok, reason: ok ? undefined : 'style snippet not found' });
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
method: 'POST',
|
|
431
|
+
path: '/api/remote-mobile/styles/delete',
|
|
432
|
+
handler: async (req, res) => {
|
|
433
|
+
if (!styleStore) {
|
|
434
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
435
|
+
}
|
|
436
|
+
const body = await parseJsonBody(req);
|
|
437
|
+
const ok = styleStore.removeCustom(String(body.id || ''));
|
|
438
|
+
jsonResponse(res, ok ? 200 : 400, { success: ok, reason: ok ? undefined : 'custom snippet not found' });
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
method: 'POST',
|
|
443
|
+
path: '/api/remote-mobile/styles/reset',
|
|
444
|
+
handler: async (_req, res) => {
|
|
445
|
+
if (!styleStore) {
|
|
446
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
447
|
+
}
|
|
448
|
+
styleStore.resetEnabled();
|
|
449
|
+
jsonResponse(res, 200, { success: true, snippets: styleStore.list() });
|
|
450
|
+
},
|
|
451
|
+
},
|
|
369
452
|
];
|
|
370
453
|
}
|
|
@@ -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
|
+
}
|