@linxin666/dsh-remote-web-ui 0.1.16 → 0.1.18
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/README.i18n.yaml +2 -2
- package/README.md +5 -2
- package/README.zh.md +2 -2
- package/assets/apple-touch-icon.png +0 -0
- package/lib/client.js +48 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +76 -14
- package/lib/mobile.js +649 -45
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +11 -5
- package/lib/types/mobile/markdown.d.ts +26 -0
- package/lib/types/mobile/markdown.d.ts.map +1 -0
- package/lib/types/mobile/markdown.js +272 -0
- package/lib/types/mobile/markdown.test.d.ts +2 -0
- package/lib/types/mobile/markdown.test.d.ts.map +1 -0
- package/lib/types/mobile/markdown.test.js +67 -0
- package/lib/types/mobile/messages.d.ts +14 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +54 -2
- package/lib/types/mobile/messages.test.js +99 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +167 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +81 -5
- package/lib/types/mobile/views/ChatView.test.js +105 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +19 -2
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +32 -0
- package/lib/types/update.d.ts +5 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +22 -6
- package/package.json +2 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/settings-card.module.css +9 -0
- package/src/mobile/markdown.test.ts +77 -0
- package/src/mobile/markdown.ts +278 -0
- package/src/mobile/messages.test.ts +106 -0
- package/src/mobile/messages.ts +66 -2
- package/src/mobile/mobile-styles.ts +167 -0
- package/src/mobile/views/ChatView.test.tsx +121 -0
- package/src/mobile/views/ChatView.tsx +164 -6
- package/src/mobile-api.ts +19 -2
- package/src/mobile-routes.ts +33 -0
- package/src/update.ts +23 -6
|
@@ -461,3 +461,108 @@ describe('ChatView scrolling', () => {
|
|
|
461
461
|
await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
|
|
462
462
|
});
|
|
463
463
|
});
|
|
464
|
+
describe('ChatView display toggles and context usage', () => {
|
|
465
|
+
/** jsdom in this setup ships a bare localStorage object; install a real fake. */
|
|
466
|
+
const makeStorage = () => {
|
|
467
|
+
const map = new Map();
|
|
468
|
+
return {
|
|
469
|
+
getItem: (key) => map.get(key) ?? null,
|
|
470
|
+
setItem: (key, value) => { map.set(key, value); },
|
|
471
|
+
removeItem: (key) => { map.delete(key); },
|
|
472
|
+
clear: () => { map.clear(); },
|
|
473
|
+
key: (index) => [...map.keys()][index] ?? null,
|
|
474
|
+
get length() { return map.size; },
|
|
475
|
+
};
|
|
476
|
+
};
|
|
477
|
+
/** A user/message whose source.kind is 'plugin' (injected system message). */
|
|
478
|
+
const systemEvents = () => [
|
|
479
|
+
makeEntry('user/message', {
|
|
480
|
+
id: 'u-plugin',
|
|
481
|
+
role: 'user',
|
|
482
|
+
content: [{ type: 'text', text: '系统注入消息' }],
|
|
483
|
+
source: { kind: 'plugin', name: 'react-extension' },
|
|
484
|
+
}, 0),
|
|
485
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
|
|
486
|
+
];
|
|
487
|
+
const toolEvents = () => [
|
|
488
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
|
|
489
|
+
makeEntry('assistant/message', {
|
|
490
|
+
turn: 0,
|
|
491
|
+
step: 0,
|
|
492
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
|
|
493
|
+
}, 1),
|
|
494
|
+
makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
|
|
495
|
+
];
|
|
496
|
+
beforeEach(() => {
|
|
497
|
+
vi.stubGlobal('localStorage', makeStorage());
|
|
498
|
+
});
|
|
499
|
+
afterEach(() => {
|
|
500
|
+
vi.unstubAllGlobals();
|
|
501
|
+
});
|
|
502
|
+
it('hides injected user messages by default and reveals them via the display sheet', async () => {
|
|
503
|
+
loadHistoryMock.mockResolvedValue(historyPage(systemEvents()));
|
|
504
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
505
|
+
// The plugin-injected message is hidden by default; the real user message shows.
|
|
506
|
+
expect(await screen.findByText('普通消息')).toBeTruthy();
|
|
507
|
+
expect(screen.queryByText('系统注入消息')).toBeNull();
|
|
508
|
+
// Open the display sheet and flip the system-message switch on.
|
|
509
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }));
|
|
510
|
+
const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' });
|
|
511
|
+
expect(systemSwitch.getAttribute('aria-checked')).toBe('false');
|
|
512
|
+
fireEvent.click(systemSwitch);
|
|
513
|
+
expect(await screen.findByText('系统注入消息')).toBeTruthy();
|
|
514
|
+
// Flip it back off: the injected row disappears again.
|
|
515
|
+
const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' });
|
|
516
|
+
expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true');
|
|
517
|
+
fireEvent.click(systemSwitchAfter);
|
|
518
|
+
expect(screen.queryByText('系统注入消息')).toBeNull();
|
|
519
|
+
});
|
|
520
|
+
it('hides the tool disclosure when the tool-call toggle is off', async () => {
|
|
521
|
+
loadHistoryMock.mockResolvedValue(historyPage(toolEvents()));
|
|
522
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
523
|
+
// Tool disclosure visible by default.
|
|
524
|
+
expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy();
|
|
525
|
+
fireEvent.click(screen.getByRole('button', { name: /显示/ }));
|
|
526
|
+
const toolSwitch = await screen.findByRole('switch', { name: '工具调用' });
|
|
527
|
+
expect(toolSwitch.getAttribute('aria-checked')).toBe('true');
|
|
528
|
+
fireEvent.click(toolSwitch);
|
|
529
|
+
// The disclosure is gone while reasoning/text remain.
|
|
530
|
+
expect(screen.queryByRole('button', { name: /工具/ })).toBeNull();
|
|
531
|
+
expect(screen.getByText('完成')).toBeTruthy();
|
|
532
|
+
});
|
|
533
|
+
it('renders the context usage chip from request/context plus assistant usage', async () => {
|
|
534
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
535
|
+
makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
536
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
|
|
537
|
+
makeEntry('assistant/message', {
|
|
538
|
+
turn: 0,
|
|
539
|
+
step: 0,
|
|
540
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
541
|
+
usage: { inputTokens: 30_000, outputTokens: 1_000 },
|
|
542
|
+
}, 2),
|
|
543
|
+
]));
|
|
544
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
545
|
+
expect(await screen.findByText('上下文 30%')).toBeTruthy();
|
|
546
|
+
});
|
|
547
|
+
it('adds the warn class when context usage is at or above 80%', async () => {
|
|
548
|
+
loadHistoryMock.mockResolvedValue(historyPage([
|
|
549
|
+
makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
550
|
+
makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
|
|
551
|
+
makeEntry('assistant/message', {
|
|
552
|
+
turn: 0,
|
|
553
|
+
step: 0,
|
|
554
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
555
|
+
usage: { inputTokens: 80_000, outputTokens: 0 },
|
|
556
|
+
}, 2),
|
|
557
|
+
]));
|
|
558
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
559
|
+
const chip = await screen.findByText('上下文 80%');
|
|
560
|
+
expect(chip.className).toContain('chat-context-warn');
|
|
561
|
+
});
|
|
562
|
+
it('renders no context chip when there is no usage/context data', async () => {
|
|
563
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
|
|
564
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
565
|
+
await screen.findByText('已完成修改');
|
|
566
|
+
expect(screen.queryByText(/上下文/)).toBeNull();
|
|
567
|
+
});
|
|
568
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,
|
|
1
|
+
{"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgDlD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA6InE"}
|
package/lib/types/mobile-api.js
CHANGED
|
@@ -77,10 +77,23 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
|
|
|
77
77
|
export function makeMobileApiRoutes(deps) {
|
|
78
78
|
const { service, apiProxy, mobileEnterToSend } = deps;
|
|
79
79
|
const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
|
|
80
|
+
/**
|
|
81
|
+
* Refresh the paired device's presence and report whether it is live.
|
|
82
|
+
* The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
|
|
83
|
+
* sender, so any activity on the mobile channel — a gated RPC, or the live
|
|
84
|
+
* SSE stream staying open — must count as presence. Without this, an
|
|
85
|
+
* idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
|
|
86
|
+
* wrongly reports it as disconnected.
|
|
87
|
+
*/
|
|
88
|
+
const touchDeviceFor = (req) => {
|
|
89
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
90
|
+
if (deviceId === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
return service.touchDevice(deviceId);
|
|
93
|
+
};
|
|
80
94
|
/** The phone gate: a live paired-device cookie, or nothing else proceeds. */
|
|
81
95
|
const gateOk = (req) => {
|
|
82
|
-
|
|
83
|
-
return deviceId !== undefined && service.hasDevice(deviceId);
|
|
96
|
+
return touchDeviceFor(req);
|
|
84
97
|
};
|
|
85
98
|
const writeJson = (res, status, body) => {
|
|
86
99
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -164,6 +177,10 @@ export function makeMobileApiRoutes(deps) {
|
|
|
164
177
|
const heartbeat = setInterval(() => {
|
|
165
178
|
if (closed)
|
|
166
179
|
return;
|
|
180
|
+
// An open SSE stream proves the phone is still live even while the agent
|
|
181
|
+
// idles (no RPC traffic), so refresh presence alongside the transport
|
|
182
|
+
// keepalive — otherwise an idle phone drifts to "disconnected".
|
|
183
|
+
touchDeviceFor(req);
|
|
167
184
|
try {
|
|
168
185
|
res.write(': ping\n\n');
|
|
169
186
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;
|
|
1
|
+
{"version":3,"file":"mobile-routes.d.ts","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAwD/D;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,QAAQ,EAAE,CAmC7C"}
|
|
@@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
function mobileBundlePath() {
|
|
15
15
|
return fileURLToPath(new URL('../lib/mobile.js', import.meta.url));
|
|
16
16
|
}
|
|
17
|
+
/** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
|
|
18
|
+
function appleTouchIconPath() {
|
|
19
|
+
return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url));
|
|
20
|
+
}
|
|
17
21
|
/** The mobile page shell: minimal, offline-safe, no external assets. */
|
|
18
22
|
function pageHtml(bundleUrl) {
|
|
19
23
|
return [
|
|
@@ -24,6 +28,10 @@ function pageHtml(bundleUrl) {
|
|
|
24
28
|
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
|
|
25
29
|
'<meta name="theme-color" content="#f3f5f9">',
|
|
26
30
|
'<meta name="referrer" content="no-referrer">',
|
|
31
|
+
// iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
|
|
32
|
+
// the PNG ships with the package (opaque background — iOS composites
|
|
33
|
+
// transparency onto black, so the icon avoids transparent regions).
|
|
34
|
+
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
27
35
|
'<title>移动端远程控制</title>',
|
|
28
36
|
'</head>',
|
|
29
37
|
'<body>',
|
|
@@ -42,6 +50,15 @@ function writeStatic(res, status, type, body) {
|
|
|
42
50
|
});
|
|
43
51
|
res.end(body);
|
|
44
52
|
}
|
|
53
|
+
/** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
|
|
54
|
+
function writePng(res, status, body) {
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
'content-type': 'image/png',
|
|
57
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
58
|
+
'referrer-policy': 'no-referrer',
|
|
59
|
+
});
|
|
60
|
+
res.end(body);
|
|
61
|
+
}
|
|
45
62
|
/**
|
|
46
63
|
* Build the mobile page routes.
|
|
47
64
|
* @returns the two exact routes to register on webServer.
|
|
@@ -64,8 +81,23 @@ export function makeMobileRoutes() {
|
|
|
64
81
|
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle');
|
|
65
82
|
}
|
|
66
83
|
};
|
|
84
|
+
const handleIcon = async (_req, res) => {
|
|
85
|
+
const path = appleTouchIconPath();
|
|
86
|
+
if (!existsSync(path)) {
|
|
87
|
+
writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const body = await readFile(path);
|
|
92
|
+
writePng(res, 200, body);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon');
|
|
96
|
+
}
|
|
97
|
+
};
|
|
67
98
|
return [
|
|
68
99
|
{ kind: 'exact', path: '/m', handler: handlePage },
|
|
69
100
|
{ kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
|
|
101
|
+
{ kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
|
|
70
102
|
];
|
|
71
103
|
}
|
package/lib/types/update.d.ts
CHANGED
|
@@ -206,9 +206,11 @@ export interface UpdateRunVerifiedDeps {
|
|
|
206
206
|
}
|
|
207
207
|
/**
|
|
208
208
|
* Run the update, then verify the installed versions actually moved. pnpm
|
|
209
|
-
* exits 0 even when it silently kept the installed versions —
|
|
210
|
-
* `minimumReleaseAge` gate
|
|
211
|
-
*
|
|
209
|
+
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
210
|
+
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
211
|
+
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
212
|
+
* another silent no-op can still keep versions in place — so a green exit
|
|
213
|
+
* alone must not report a misleading "update complete". Re-read the
|
|
212
214
|
* installed versions afterwards and surface a `stale` failure (with the
|
|
213
215
|
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
214
216
|
* how to unblock the gate instead of claiming success.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
|
package/lib/types/update.js
CHANGED
|
@@ -302,6 +302,16 @@ const OUTPUT_CAP = 16 * 1024;
|
|
|
302
302
|
* detects this message instead of the spawn error event.
|
|
303
303
|
*/
|
|
304
304
|
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i;
|
|
305
|
+
/**
|
|
306
|
+
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
307
|
+
* silently skips same-day releases — `pnpm update --latest` then exits 0
|
|
308
|
+
* without moving anything and the post-run verification would misreport a
|
|
309
|
+
* stale no-op. The update here is explicitly user-initiated and the panel
|
|
310
|
+
* shows exactly which versions are being installed, so the gate only adds a
|
|
311
|
+
* confusing silent no-op; override it per-invocation instead of asking every
|
|
312
|
+
* user to edit the profile's pnpm-workspace.yaml.
|
|
313
|
+
*/
|
|
314
|
+
const MIN_RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
|
|
305
315
|
/**
|
|
306
316
|
* Run the update inside the profile directory. Tries pnpm first, falls back
|
|
307
317
|
* to corepack and then npx when the previous command is missing (ENOENT);
|
|
@@ -328,10 +338,14 @@ export function runUpdate(deps) {
|
|
|
328
338
|
// "0.1.12" without a range), and plain `pnpm update` treats an exact spec
|
|
329
339
|
// as pinned — it prints "Already up to date" and exits 0 without moving
|
|
330
340
|
// the installed version, so the panel would report a false success.
|
|
341
|
+
// `MIN_RELEASE_AGE_OVERRIDE` rides along every candidate: without it the
|
|
342
|
+
// pnpm 11 minimumReleaseAge gate (default 24 h) silently keeps same-day
|
|
343
|
+
// releases in place, which the post-run verification would then have to
|
|
344
|
+
// surface as `stale` even though the update is legitimate.
|
|
331
345
|
const candidates = [
|
|
332
|
-
{ command: 'pnpm', args: ['update', '--latest', ...packages] },
|
|
333
|
-
{ command: 'corepack', args: ['pnpm', 'update', '--latest', ...packages] },
|
|
334
|
-
{ command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', ...packages] },
|
|
346
|
+
{ command: 'pnpm', args: ['update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
347
|
+
{ command: 'corepack', args: ['pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
348
|
+
{ command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', MIN_RELEASE_AGE_OVERRIDE, ...packages] },
|
|
335
349
|
];
|
|
336
350
|
// `output` accumulates across candidates for UI display; `currentOutput`
|
|
337
351
|
// is reset per candidate and carries only that candidate's own diagnostics
|
|
@@ -444,9 +458,11 @@ export function runUpdate(deps) {
|
|
|
444
458
|
}
|
|
445
459
|
/**
|
|
446
460
|
* Run the update, then verify the installed versions actually moved. pnpm
|
|
447
|
-
* exits 0 even when it silently kept the installed versions —
|
|
448
|
-
* `minimumReleaseAge` gate
|
|
449
|
-
*
|
|
461
|
+
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
462
|
+
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
463
|
+
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
464
|
+
* another silent no-op can still keep versions in place — so a green exit
|
|
465
|
+
* alone must not report a misleading "update complete". Re-read the
|
|
450
466
|
* installed versions afterwards and surface a `stale` failure (with the
|
|
451
467
|
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
452
468
|
* how to unblock the gate instead of claiming success.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.18",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"lib/**/*.d.ts",
|
|
31
31
|
"lib/**/*.d.ts.map",
|
|
32
32
|
"src",
|
|
33
|
+
"assets",
|
|
33
34
|
"cordis.patch.yml"
|
|
34
35
|
],
|
|
35
36
|
"license": "Apache-2.0",
|
|
@@ -45,6 +45,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
45
45
|
onSave: () => void
|
|
46
46
|
/** Drop every staged edit. */
|
|
47
47
|
onDiscard: () => void
|
|
48
|
+
/**
|
|
49
|
+
* Render the controls expanded on arrival (still collapsible). Defaults to
|
|
50
|
+
* true: promoted first-level sections show their settings immediately.
|
|
51
|
+
*/
|
|
52
|
+
defaultOpen?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Render without the collapse affordance: a static header and an always
|
|
55
|
+
* visible body. Used by first-level settings sections whose nav entry
|
|
56
|
+
* already provides the selection.
|
|
57
|
+
*/
|
|
58
|
+
alwaysOpen?: boolean
|
|
48
59
|
/** The plugin's controls. */
|
|
49
60
|
children: ReactNode
|
|
50
61
|
}
|
|
@@ -55,57 +66,27 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
55
66
|
* @returns the card, or nothing while the namespace is still loading.
|
|
56
67
|
*/
|
|
57
68
|
export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
|
|
58
|
-
const [open, setOpen] = useState(
|
|
59
|
-
const { state } = props
|
|
69
|
+
const [open, setOpen] = useState(props.defaultOpen ?? true)
|
|
70
|
+
const { state, alwaysOpen } = props
|
|
60
71
|
if (!state.available) return null
|
|
61
72
|
const title = props.t(props.titleKey)
|
|
62
73
|
const description = props.t(props.descriptionKey)
|
|
63
74
|
const blocked = !state.dirty || state.invalid || state.saving
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
onClick={() => { setOpen(!open) }}
|
|
78
|
-
>
|
|
79
|
-
<span className={css.headText}>
|
|
80
|
-
<span className={css.name} title={title}>{title}</span>
|
|
81
|
-
<span className={css.description} title={description}>{description}</span>
|
|
82
|
-
</span>
|
|
83
|
-
<svg
|
|
84
|
-
width="14"
|
|
85
|
-
height="14"
|
|
86
|
-
viewBox="0 0 14 14"
|
|
87
|
-
fill="none"
|
|
88
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
89
|
-
className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron}
|
|
90
|
-
>
|
|
91
|
-
<path
|
|
92
|
-
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
93
|
-
fill="currentColor"
|
|
94
|
-
/>
|
|
95
|
-
</svg>
|
|
96
|
-
</button>
|
|
97
|
-
{open
|
|
98
|
-
? (
|
|
99
|
-
<div className={css.body}>
|
|
100
|
-
<p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
|
|
101
|
-
</div>
|
|
102
|
-
)
|
|
103
|
-
: null}
|
|
104
|
-
</li>
|
|
75
|
+
const expanded = alwaysOpen === true || open
|
|
76
|
+
const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card
|
|
77
|
+
// With alwaysOpen the nav entry already provides the selection, so the
|
|
78
|
+
// header is a static title row instead of a disclosure button.
|
|
79
|
+
const header = alwaysOpen === true
|
|
80
|
+
? (
|
|
81
|
+
<div className={css.headerStatic}>
|
|
82
|
+
<span className={css.headText}>
|
|
83
|
+
<span className={css.name} title={title}>{title}</span>
|
|
84
|
+
<span className={css.description} title={description}>{description}</span>
|
|
85
|
+
</span>
|
|
86
|
+
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
87
|
+
</div>
|
|
105
88
|
)
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<li className={cardClass}>
|
|
89
|
+
: (
|
|
109
90
|
<button
|
|
110
91
|
type="button"
|
|
111
92
|
className={css.header}
|
|
@@ -132,7 +113,29 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
132
113
|
/>
|
|
133
114
|
</svg>
|
|
134
115
|
</button>
|
|
135
|
-
|
|
116
|
+
)
|
|
117
|
+
// The namespace exists but the Host does not serve it to this client (the
|
|
118
|
+
// official settings allowlist omits third-party namespaces): show a card
|
|
119
|
+
// that explains the gap instead of vanishing, so a missing card never
|
|
120
|
+
// reads as a missing plugin.
|
|
121
|
+
if (!state.exposed) {
|
|
122
|
+
return (
|
|
123
|
+
<li className={cardClass}>
|
|
124
|
+
{header}
|
|
125
|
+
{expanded
|
|
126
|
+
? (
|
|
127
|
+
<div className={css.body}>
|
|
128
|
+
<p className={css.notExposed} role="status">{props.t('settings.notExposed')}</p>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
: null}
|
|
132
|
+
</li>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
<li className={cardClass}>
|
|
137
|
+
{header}
|
|
138
|
+
{expanded
|
|
136
139
|
? (
|
|
137
140
|
<div className={css.body}>
|
|
138
141
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile markdown renderer tests: GFM subset coverage plus the security
|
|
3
|
+
* contract — raw HTML escapes, dangerous link/image protocols are
|
|
4
|
+
* stripped, and code fences never parse their content.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { escapeHtml, renderInline, renderMarkdown, safeUrl } from './markdown.ts'
|
|
8
|
+
|
|
9
|
+
describe('safeUrl', () => {
|
|
10
|
+
it('allows http/https/mailto/fragments and scheme-less targets', () => {
|
|
11
|
+
expect(safeUrl('https://example.com/a')).toBe('https://example.com/a')
|
|
12
|
+
expect(safeUrl('http://example.com')).toBe('http://example.com')
|
|
13
|
+
expect(safeUrl('mailto:a@b.c')).toBe('mailto:a@b.c')
|
|
14
|
+
expect(safeUrl('#top')).toBe('#top')
|
|
15
|
+
expect(safeUrl('./rel/path')).toBe('./rel/path')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rejects javascript:/data:/vbscript: and blanks', () => {
|
|
19
|
+
expect(safeUrl('javascript:alert(1)')).toBeNull()
|
|
20
|
+
expect(safeUrl('data:text/html,x')).toBeNull()
|
|
21
|
+
expect(safeUrl('vbscript:x')).toBeNull()
|
|
22
|
+
expect(safeUrl('')).toBeNull()
|
|
23
|
+
expect(safeUrl(' ')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('renderInline', () => {
|
|
28
|
+
it('renders code, bold, italic, strikethrough and links', () => {
|
|
29
|
+
expect(renderInline('`a<b`')).toBe('<code>a<b</code>')
|
|
30
|
+
expect(renderInline('**b**')).toBe('<strong>b</strong>')
|
|
31
|
+
expect(renderInline('*i*')).toBe('<em>i</em>')
|
|
32
|
+
expect(renderInline('~~d~~')).toBe('<del>d</del>')
|
|
33
|
+
expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('strips unsafe link targets and renders the label as plain text', () => {
|
|
37
|
+
expect(renderInline('[x](javascript:alert(1))')).toBe('x')
|
|
38
|
+
expect(renderInline('')).toBe('a')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('escapes raw HTML instead of passing it through', () => {
|
|
42
|
+
expect(renderInline('<script>alert(1)</script>')).toBe('<script>alert(1)</script>')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('renderMarkdown', () => {
|
|
47
|
+
it('renders headings, hr, paragraphs', () => {
|
|
48
|
+
const html = renderMarkdown('# T\n\npara\n\n---')
|
|
49
|
+
expect(html).toContain('<h1>T</h1>')
|
|
50
|
+
expect(html).toContain('<p>para</p>')
|
|
51
|
+
expect(html).toContain('<hr />')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders fenced code without parsing its content', () => {
|
|
55
|
+
const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```')
|
|
56
|
+
expect(html).toBe('<pre class="language-ts"><code>const a = **bold** <x></code></pre>')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders lists, blockquotes and tables', () => {
|
|
60
|
+
const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |')
|
|
61
|
+
expect(html).toContain('<ul><li>a</li><li>b</li></ul>')
|
|
62
|
+
expect(html).toContain('<ol><li>x</li><li>y</li></ol>')
|
|
63
|
+
expect(html).toContain('<blockquote><p>quote</p></blockquote>')
|
|
64
|
+
expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>')
|
|
65
|
+
expect(html).toContain('<td>c1</td><td>c2</td>')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('normalizes CRLF and keeps user-visible text intact', () => {
|
|
69
|
+
expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('escapeHtml', () => {
|
|
74
|
+
it('escapes all five HTML-significant characters', () => {
|
|
75
|
+
expect(escapeHtml('<&>"\'')).toBe('<&>"'')
|
|
76
|
+
})
|
|
77
|
+
})
|