@linxin666/dsh-remote-web-ui 0.2.0 → 0.2.2

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.
Files changed (77) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -8
  3. package/README.zh.md +5 -5
  4. package/lib/client.js +259 -43
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +26 -7
  7. package/lib/mobile.js +209 -39
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +142 -3
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +11 -0
  14. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  15. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  16. package/lib/types/client/UpdateEntry.js +8 -5
  17. package/lib/types/client/UpdatePanel.d.ts +3 -1
  18. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  19. package/lib/types/client/UpdatePanel.js +3 -3
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -4
  23. package/lib/types/client/pair-api.d.ts +1 -6
  24. package/lib/types/client/pair-api.d.ts.map +1 -1
  25. package/lib/types/client/pair-api.js +1 -10
  26. package/lib/types/gate.d.ts.map +1 -1
  27. package/lib/types/gate.js +2 -0
  28. package/lib/types/mobile/api.d.ts +10 -0
  29. package/lib/types/mobile/api.d.ts.map +1 -1
  30. package/lib/types/mobile/api.js +4 -0
  31. package/lib/types/mobile/messages.d.ts +24 -0
  32. package/lib/types/mobile/messages.d.ts.map +1 -1
  33. package/lib/types/mobile/messages.js +60 -23
  34. package/lib/types/mobile/messages.test.js +49 -1
  35. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  36. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  37. package/lib/types/mobile/mobile-styles.js +38 -0
  38. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  39. package/lib/types/mobile/views/ChatView.js +34 -11
  40. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  41. package/lib/types/mobile/views/SessionListView.js +30 -4
  42. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  45. package/lib/types/mobile-api.d.ts.map +1 -1
  46. package/lib/types/mobile-api.js +10 -1
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +22 -2
  49. package/lib/types/tunnel.d.ts +1 -0
  50. package/lib/types/tunnel.d.ts.map +1 -1
  51. package/lib/types/tunnel.js +7 -2
  52. package/lib/types/update.d.ts +1 -1
  53. package/lib/types/update.d.ts.map +1 -1
  54. package/lib/types/update.js +1 -1
  55. package/package.json +1 -2
  56. package/src/client/PluginSettingsCard.tsx +219 -18
  57. package/src/client/RemoteEntry.tsx +10 -0
  58. package/src/client/RemoteSettingsCard.tsx +2 -2
  59. package/src/client/UpdateEntry.tsx +9 -5
  60. package/src/client/UpdatePanel.tsx +12 -1
  61. package/src/client/locales.ts +8 -4
  62. package/src/client/pair-api.ts +1 -10
  63. package/src/client/settings-card.module.css +90 -1
  64. package/src/gate.ts +2 -0
  65. package/src/mobile/api.ts +14 -1
  66. package/src/mobile/messages.test.ts +52 -1
  67. package/src/mobile/messages.ts +65 -22
  68. package/src/mobile/mobile-styles.ts +38 -0
  69. package/src/mobile/mobile-theme.test.ts +1 -1
  70. package/src/mobile/views/ChatView.tsx +33 -10
  71. package/src/mobile/views/SessionListView.test.tsx +69 -1
  72. package/src/mobile/views/SessionListView.tsx +58 -5
  73. package/src/mobile/views/WorkspaceView.tsx +4 -2
  74. package/src/mobile-api.ts +9 -1
  75. package/src/routes.ts +22 -2
  76. package/src/tunnel.ts +7 -2
  77. package/src/update.ts +2 -2
@@ -8,11 +8,13 @@ import { SessionListView } from './SessionListView.js';
8
8
  vi.mock('../api.ts', () => ({
9
9
  listSessions: vi.fn(),
10
10
  listWorkspaces: vi.fn(),
11
+ listAgentPresets: vi.fn(),
11
12
  createSession: vi.fn(),
12
13
  }));
13
- import { createSession, listSessions, listWorkspaces } from '../api.js';
14
+ import { createSession, listAgentPresets, listSessions, listWorkspaces } from '../api.js';
14
15
  const listSessionsMock = vi.mocked(listSessions);
15
16
  const listWorkspacesMock = vi.mocked(listWorkspaces);
17
+ const listAgentPresetsMock = vi.mocked(listAgentPresets);
16
18
  const createSessionMock = vi.mocked(createSession);
17
19
  const workspace = {
18
20
  workspaceId: 'w-1',
@@ -34,6 +36,7 @@ function renderList(props = {}) {
34
36
  beforeEach(() => {
35
37
  listSessionsMock.mockResolvedValue({ items: [], hasMore: false });
36
38
  listWorkspacesMock.mockResolvedValue([workspace]);
39
+ listAgentPresetsMock.mockResolvedValue({ presets: [], authorable: false, hasDocument: false });
37
40
  createSessionMock.mockResolvedValue({ sessionId: 's-new' });
38
41
  });
39
42
  afterEach(() => {
@@ -87,6 +90,60 @@ describe('SessionListView creation', () => {
87
90
  // The fresh row is prepended without waiting for a refetch.
88
91
  expect(await screen.findByText('新会话')).toBeTruthy();
89
92
  });
93
+ it('creates the session with the selected agent preset', async () => {
94
+ listAgentPresetsMock.mockResolvedValue({
95
+ presets: [
96
+ { id: 'router-standard', name: 'Router Standard', trust: 'system', isDefault: true },
97
+ { id: 'router-spec', name: 'Router Spec', description: '规格驱动模式', trust: 'system', isDefault: false },
98
+ ],
99
+ authorable: false,
100
+ hasDocument: false,
101
+ });
102
+ renderList();
103
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' });
104
+ expect(picker.value).toBe('router-standard');
105
+ fireEvent.change(picker, { target: { value: 'router-spec' } });
106
+ fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }));
107
+ await waitFor(() => {
108
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1', agentPreset: 'router-spec' });
109
+ });
110
+ expect(await screen.findByText('规格驱动模式')).toBeTruthy();
111
+ });
112
+ it('skips a broken default preset and disables it in the picker', async () => {
113
+ listAgentPresetsMock.mockResolvedValue({
114
+ presets: [
115
+ { id: 'broken-default', trust: 'system', isDefault: true, broken: 'missing plugin' },
116
+ { id: 'local-router', name: 'Local Router', trust: 'user', isDefault: false },
117
+ ],
118
+ authorable: false,
119
+ hasDocument: false,
120
+ });
121
+ renderList();
122
+ const picker = await screen.findByRole('combobox', { name: 'Agent 模式' });
123
+ expect(picker.value).toBe('local-router');
124
+ expect(screen.getByRole('option', { name: 'broken-default(默认)(不可用)' }).hasAttribute('disabled')).toBe(true);
125
+ expect(screen.getByRole('option', { name: 'Local Router(本地)' })).toBeTruthy();
126
+ });
127
+ it('keeps default session creation available when the preset roster fails', async () => {
128
+ listAgentPresetsMock.mockRejectedValue(new Error('preset roster unavailable'));
129
+ renderList();
130
+ await screen.findByText(/还没有会话/);
131
+ const button = await screen.findByRole('button', { name: '+ 新建会话' });
132
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false); });
133
+ fireEvent.click(button);
134
+ await waitFor(() => {
135
+ expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' });
136
+ });
137
+ });
138
+ it('waits for the preset roster before enabling session creation', async () => {
139
+ let resolvePresets = () => { };
140
+ listAgentPresetsMock.mockReturnValue(new Promise(resolve => { resolvePresets = resolve; }));
141
+ renderList();
142
+ const button = screen.getByRole('button', { name: '+ 新建会话' });
143
+ expect(button.hasAttribute('disabled')).toBe(true);
144
+ resolvePresets({ presets: [], authorable: false, hasDocument: false });
145
+ await waitFor(() => { expect(button.hasAttribute('disabled')).toBe(false); });
146
+ });
90
147
  it('keeps the button disabled while a creation is in flight', async () => {
91
148
  let resolveCreate = () => { };
92
149
  createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve; }));
@@ -1 +1 @@
1
- {"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;AAKjG,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,yCAAyC;IACzC,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,CAAA;CACtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,kBAAkB,EAAE,MAAM,EAAE,EAAE,kBAAkB,+BA0F/E"}
1
+ {"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;AAKjG,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,yCAAyC;IACzC,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,CAAA;CACtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,kBAAkB,EAAE,MAAM,EAAE,EAAE,kBAAkB,+BA4F/E"}
@@ -17,6 +17,8 @@ import { ThemeToggle } from '../theme-toggle.js';
17
17
  export function WorkspaceView({ initialWorkspaceId, onPick }) {
18
18
  const [items, setItems] = useState(undefined);
19
19
  const [error, setError] = useState(undefined);
20
+ // Bumped by the retry button to re-run the roster fetch effect.
21
+ const [reload, setReload] = useState(0);
20
22
  useEffect(() => {
21
23
  let cancelled = false;
22
24
  void listWorkspaces().then((rows) => {
@@ -36,9 +38,9 @@ export function WorkspaceView({ initialWorkspaceId, onPick }) {
36
38
  setError(errorText(reason));
37
39
  });
38
40
  return () => { cancelled = true; };
39
- }, [initialWorkspaceId, onPick]);
41
+ }, [initialWorkspaceId, onPick, reload]);
40
42
  if (error !== undefined) {
41
- return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsxs("div", { className: "mobile-empty", children: [_jsxs("p", { className: "mobile-error", children: ["\u52A0\u8F7D\u5931\u8D25\uFF1A", error] }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => { setError(undefined); setItems(undefined); }, children: "\u91CD\u8BD5" })] })] }));
43
+ return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsxs("div", { className: "mobile-empty", children: [_jsxs("p", { className: "mobile-error", children: ["\u52A0\u8F7D\u5931\u8D25\uFF1A", error] }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => { setError(undefined); setItems(undefined); setReload(n => n + 1); }, children: "\u91CD\u8BD5" })] })] }));
42
44
  }
43
45
  if (items === undefined) {
44
46
  return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u52A0\u8F7D\u4E2D\u2026" }) })] }));
@@ -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;AAiDlD,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;aAC3B,MAAM,EAAE,mBAAmB;CACnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA4InE"}
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;AAwDlD,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;aAC3B,MAAM,EAAE,mBAAmB;CACnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA4InE"}
@@ -21,9 +21,16 @@
21
21
  import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc';
22
22
  import { readBoundedJson, writeJson } from './http.js';
23
23
  import { readCookie } from './gate.js';
24
- /** Methods the phone surface may call. Everything else is refused. */
24
+ /**
25
+ * Methods the phone surface may call. Everything else is refused HERE — but
26
+ * note the paired-device cookie also passes the global api/gate for the full
27
+ * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
28
+ * the allowlist only constrains this /m/api proxy, not the cookie's reach.
29
+ * stop() revokes every device; there is no per-device revocation yet.
30
+ */
25
31
  const MOBILE_ALLOWLIST = new Set([
26
32
  'workspace.list',
33
+ 'agentPreset.list',
27
34
  'session.create',
28
35
  'session.list',
29
36
  'session.history',
@@ -273,6 +280,8 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
273
280
  });
274
281
  if (method === 'workspace.list')
275
282
  return wrap(await apiProxy.workspace.list(request));
283
+ if (method === 'agentPreset.list')
284
+ return wrap(await apiProxy.agentPresets.list(request));
276
285
  if (method === 'session.create')
277
286
  return wrap(await apiProxy.sessions.create(request));
278
287
  if (method === 'session.history')
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoL3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAwM3D"}
@@ -200,7 +200,16 @@ export function makeRoutes(deps) {
200
200
  const ACCEPT_MAX_ATTEMPTS = 10;
201
201
  const ACCEPT_WINDOW_MS = 30_000;
202
202
  const rateLimitAccept = (req) => {
203
- const ip = req.socket?.remoteAddress ?? 'unknown';
203
+ const socketIp = req.socket?.remoteAddress ?? 'unknown';
204
+ // Behind the auto-tunnel every internet client arrives from 127.0.0.1,
205
+ // so a single shared bucket would let one attacker keep the legitimate
206
+ // owner rate-limited. Partition the availability bucket by the first
207
+ // client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
208
+ // authentication and only separates buckets, it never grants access.
209
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
210
+ ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
211
+ : undefined;
212
+ const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded;
204
213
  const nowMs = Date.now();
205
214
  // The map lives as long as the plugin: prune expired windows once the
206
215
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -287,6 +296,9 @@ export function makeRoutes(deps) {
287
296
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
288
297
  return;
289
298
  }
299
+ // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
300
+ // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
301
+ // top-level navigations working while blocking cross-site subrequests.
290
302
  res.writeHead(200, {
291
303
  'content-type': 'application/json; charset=utf-8',
292
304
  'set-cookie': [
@@ -337,7 +349,15 @@ export function makeRoutes(deps) {
337
349
  return;
338
350
  }
339
351
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
340
- writeJson(res, 200, { ok: true, paired: deviceId !== undefined && service.hasDevice(deviceId), ...service.snapshot() });
352
+ const paired = deviceId !== undefined && service.hasDevice(deviceId);
353
+ const snapshot = service.snapshot();
354
+ // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
355
+ // token expiry, device roster, and public tunnel URL are an oracle for
356
+ // targeting and timing and stay behind a live device cookie.
357
+ const visible = paired
358
+ ? snapshot
359
+ : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses };
360
+ writeJson(res, 200, { ok: true, paired, ...visible });
341
361
  };
342
362
  const handleEvents = (req, res) => {
343
363
  if (!requireMethod(req, res, 'GET'))
@@ -64,6 +64,7 @@ export declare class TunnelManager {
64
64
  private urlTimer;
65
65
  private restartTimer;
66
66
  private attempts;
67
+ private generation;
67
68
  private stopping;
68
69
  private readonly urlListeners;
69
70
  private readonly phaseListeners;
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,0EAA0E;AAC1E,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAA;AAEvE,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAA;IAClB,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC9D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAA;IAC/D,IAAI,IAAI,OAAO,CAAA;CAChB;AAED,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,YAAY,CAAA;IAC7C,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,wCAAwC;IACxC,KAAK,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,YAAY,CAAC,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;KAAE,CAAA;CAC5F;AAkBD;;;GAGG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqF;IAE3G,OAAO,CAAC,KAAK,CAAyB;IACtC,OAAO,CAAC,GAAG,CAAoB;IAC/B,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,MAAM,CAA0B;IACxC,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,QAAQ,CAAI;IACpB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAmC;IAChE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAwC;IAEvE;;OAEG;IACH,YAAY,OAAO,GAAE,oBAAyB,EAO7C;IAED,gCAAgC;IAChC,IAAI,IAAI,IAAI,UAAU,CAMrB;IAED;;;;;OAKG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAO7B;IAED,uDAAuD;IACvD,IAAI,IAAI,IAAI,CAKX;IAED,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAEd;IAED,4EAA4E;IAC5E,KAAK,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAGjD;IAED,uCAAuC;IACvC,OAAO,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,CAGxD;IAED,OAAO,CAAC,OAAO;IAoCf,OAAO,CAAC,SAAS;IAkBjB,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,IAAI;IAqBZ,iFAAiF;IACjF,OAAO,CAAC,QAAQ;IAgBhB,OAAO,CAAC,QAAQ;CAWjB"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
@@ -46,6 +46,9 @@ export class TunnelManager {
46
46
  urlTimer;
47
47
  restartTimer;
48
48
  attempts = 0;
49
+ // Generation counter: a stale ensureBinary resolution from an earlier
50
+ // start() must not spawn a second handle after a stop/start cycle.
51
+ generation = 0;
49
52
  stopping = false;
50
53
  urlListeners = new Set();
51
54
  phaseListeners = new Set();
@@ -81,6 +84,7 @@ export class TunnelManager {
81
84
  this.stopping = false;
82
85
  this.targetUrl = targetUrl;
83
86
  this.attempts = 0;
87
+ this.generation += 1;
84
88
  this.attempt();
85
89
  }
86
90
  /** Stop the tunnel for good: no restarts, no state. */
@@ -107,12 +111,13 @@ export class TunnelManager {
107
111
  attempt() {
108
112
  if (this.stopping || this.targetUrl === undefined)
109
113
  return;
114
+ const gen = this.generation;
110
115
  this.setPhase('starting');
111
116
  this.handle = undefined;
112
117
  this.url = undefined;
113
118
  this.error = undefined;
114
119
  void this.ensureBinary().then(() => {
115
- if (this.stopping || this.targetUrl === undefined)
120
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
116
121
  return;
117
122
  const handle = this.factory(this.targetUrl);
118
123
  this.handle = handle;
@@ -139,7 +144,7 @@ export class TunnelManager {
139
144
  });
140
145
  }).catch((value) => {
141
146
  // Binary install failed (no network, no platform build): report it.
142
- if (this.stopping || this.targetUrl === undefined)
147
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
143
148
  return;
144
149
  const message = value instanceof Error ? value.message : String(value);
145
150
  this.fail(`could not obtain the cloudflared binary: ${message}`);
@@ -137,7 +137,7 @@ export declare function familyChildren(anchorManifest: Record<string, unknown>):
137
137
  * @param timeoutMs - probe timeout.
138
138
  * @returns the latest version string, or undefined on any failure.
139
139
  */
140
- export declare function fetchLatestVersion(name: string, fetchImpl: (url: string) => Promise<{
140
+ export declare function fetchLatestVersion(name: string, fetchImpl: (url: string, init?: RequestInit) => Promise<{
141
141
  ok: boolean;
142
142
  json(): Promise<unknown>;
143
143
  }>, timeoutMs?: number): Promise<string | undefined>;
@@ -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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"}
@@ -218,7 +218,7 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
218
218
  const controller = new AbortController();
219
219
  const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
220
220
  try {
221
- const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest');
221
+ const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', { signal: controller.signal });
222
222
  if (!response.ok)
223
223
  return undefined;
224
224
  const body = await response.json();
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.2.0",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -78,7 +78,6 @@
78
78
  "@types/react": "~18.3.1",
79
79
  "@types/react-dom": "~18.3.0",
80
80
  "jsdom": "29.1.1",
81
- "lightningcss": "^1.32.0",
82
81
  "react": "^18.2.0",
83
82
  "react-dom": "^18.2.0",
84
83
  "tsdown": "^0.22.2",
@@ -8,7 +8,7 @@
8
8
  * ui-plugin-config PluginCard in a self-contained slice.
9
9
  */
10
10
 
11
- import { useState, type ReactNode } from 'react'
11
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
12
12
  import type { CardShell } from './settings-form.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
@@ -255,6 +255,210 @@ export function ValueField(props: FieldProps & {
255
255
  )
256
256
  }
257
257
 
258
+ const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
259
+ // 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
260
+ function isSkinActive(): boolean {
261
+ const datasetList = Object.keys(document.body.dataset)
262
+ const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
263
+ return isActive
264
+ }
265
+
266
+ interface SelectOption {
267
+ value: string
268
+ label: string
269
+ }
270
+
271
+ const SELECT_CLOSE_MS = 100
272
+
273
+ /**
274
+ * The shared dual-mode select control. While an appearance skin is active it
275
+ * renders the legacy native `<select>` untouched, so element-level skin
276
+ * selectors keep working; under the default appearance it renders a
277
+ * self-drawn `role="listbox"` popup whose open/close is transition-animated.
278
+ * Staged cards reach it through BooleanField/ChoiceField; immediate-apply
279
+ * editors (the side-card prefs) bind it directly through onEdit.
280
+ * 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
281
+ */
282
+ export function SelectField(props: {
283
+ id: string
284
+ options: ReadonlyArray<SelectOption>
285
+ value: string
286
+ disabled: boolean
287
+ invalid: boolean
288
+ onEdit: (text: string) => void
289
+ }) {
290
+ const { id, options, value } = props
291
+ const [open, setOpen] = useState(false)
292
+ const [closing, setClosing] = useState(false)
293
+ const [phase, setPhase] = useState<'initial' | 'open'>('initial')
294
+ const [activeIndex, setActiveIndex] = useState(0)
295
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
296
+ const wrapRef = useRef<HTMLDivElement | null>(null)
297
+ const popupRef = useRef<HTMLDivElement | null>(null)
298
+
299
+ const currentIndex = () => {
300
+ const index = options.findIndex(option => option.value === value)
301
+ return index >= 0 ? index : 0
302
+ }
303
+
304
+ const close = useCallback(() => {
305
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
306
+ setClosing(true)
307
+ closeTimer.current = setTimeout(() => {
308
+ setClosing(false)
309
+ setOpen(false)
310
+ }, SELECT_CLOSE_MS)
311
+ }, [])
312
+
313
+ const openPopup = () => {
314
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
315
+ setActiveIndex(currentIndex())
316
+ setPhase('initial')
317
+ setClosing(false)
318
+ setOpen(true)
319
+ }
320
+
321
+ const commit = (index: number) => {
322
+ const option = options[index]
323
+ if (option) props.onEdit(option.value)
324
+ close()
325
+ }
326
+
327
+ const onTriggerClick = () => {
328
+ if (props.disabled) return
329
+ if (open && !closing) close()
330
+ else openPopup()
331
+ }
332
+
333
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
334
+ if (props.disabled) return
335
+ const count = options.length
336
+ switch (event.key) {
337
+ case 'ArrowDown':
338
+ case 'ArrowUp':
339
+ case 'Enter':
340
+ case ' ':
341
+ event.preventDefault()
342
+ if (!open) {
343
+ openPopup()
344
+ } else if (!closing) {
345
+ if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
346
+ else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
347
+ else commit(activeIndex)
348
+ }
349
+ break
350
+ case 'Escape':
351
+ if (open) {
352
+ event.preventDefault()
353
+ event.stopPropagation()
354
+ close()
355
+ }
356
+ break
357
+ case 'Tab':
358
+ if (open) close()
359
+ break
360
+ }
361
+ }
362
+
363
+ useEffect(() => () => {
364
+ if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
365
+ }, [])
366
+ useLayoutEffect(() => {
367
+ if (open && !closing && phase === 'initial') {
368
+ void popupRef.current?.offsetHeight
369
+ setPhase('open')
370
+ }
371
+ }, [open, closing, phase])
372
+
373
+ useEffect(() => {
374
+ if (!open) return
375
+ const onPointerDown = (event: PointerEvent) => {
376
+ const target = event.target
377
+ if (target instanceof Node && !wrapRef.current?.contains(target)) close()
378
+ }
379
+ document.addEventListener('pointerdown', onPointerDown)
380
+ return () => document.removeEventListener('pointerdown', onPointerDown)
381
+ }, [open, close])
382
+
383
+ useEffect(() => {
384
+ if (props.disabled && open) close()
385
+ }, [props.disabled, open, close])
386
+
387
+ // 使用了皮肤则退回原生select样式防止样式冲突。
388
+ if (isSkinActive()) {
389
+ return (
390
+ <select
391
+ id={id}
392
+ className={css.select}
393
+ value={value}
394
+ disabled={props.disabled}
395
+ onChange={(event) => { props.onEdit(event.target.value) }}
396
+ >
397
+ {options.map(option => (
398
+ <option key={option.value} value={option.value}>{option.label}</option>
399
+ ))}
400
+ </select>
401
+ )
402
+ }
403
+
404
+ const label = options.find(option => option.value === value)?.label ?? ''
405
+ const popupClass = closing
406
+ ? `${css.selectPopup} ${css.selectPopupClose}`
407
+ : phase === 'open'
408
+ ? `${css.selectPopup} ${css.selectPopupOpen}`
409
+ : css.selectPopup
410
+ return (
411
+ <div className={css.selectWrap} ref={wrapRef}>
412
+ <button
413
+ type="button"
414
+ id={id}
415
+ className={`${css.select} ${css.selectButton}`}
416
+ disabled={props.disabled}
417
+ aria-haspopup="listbox"
418
+ aria-expanded={open}
419
+ aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
420
+ aria-invalid={props.invalid || undefined}
421
+ onClick={onTriggerClick}
422
+ onKeyDown={onKeyDown}
423
+ >
424
+ <span className={css.selectLabel}>{label}</span>
425
+ <svg
426
+ width="14"
427
+ height="14"
428
+ viewBox="0 0 14 14"
429
+ fill="none"
430
+ xmlns="http://www.w3.org/2000/svg"
431
+ className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
432
+ aria-hidden="true"
433
+ >
434
+ <path
435
+ 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"
436
+ fill="currentColor"
437
+ />
438
+ </svg>
439
+ </button>
440
+ {open
441
+ ? (
442
+ <div className={popupClass} role="listbox" ref={popupRef}>
443
+ {options.map((option, index) => (
444
+ <div
445
+ key={option.value}
446
+ id={`${id}-o${index}`}
447
+ role="option"
448
+ aria-selected={option.value === value}
449
+ className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
450
+ onClick={() => { commit(index) }}
451
+ >
452
+ {option.label}
453
+ </div>
454
+ ))}
455
+ </div>
456
+ )
457
+ : null}
458
+ </div>
459
+ )
460
+ }
461
+
258
462
  /** A staged boolean field: 继承 / 开 / 关. */
259
463
  export function BooleanField(props: FieldProps & {
260
464
  /** Copy for the inherit option. */
@@ -284,17 +488,18 @@ export function BooleanField(props: FieldProps & {
284
488
  )
285
489
  : null}
286
490
  </div>
287
- <select
491
+ <SelectField
288
492
  id={props.id}
289
- className={css.select}
493
+ options={[
494
+ { value: '', label: props.inheritLabel },
495
+ { value: 'true', label: props.onLabel },
496
+ { value: 'false', label: props.offLabel },
497
+ ]}
290
498
  value={props.text}
291
499
  disabled={props.disabled}
292
- onChange={(event) => { props.onEdit(event.target.value) }}
293
- >
294
- <option value="">{props.inheritLabel}</option>
295
- <option value="true">{props.onLabel}</option>
296
- <option value="false">{props.offLabel}</option>
297
- </select>
500
+ invalid={props.invalid}
501
+ onEdit={props.onEdit}
502
+ />
298
503
  <p className={css.hint}>{props.hint}</p>
299
504
  </div>
300
505
  )
@@ -327,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
327
532
  )
328
533
  : null}
329
534
  </div>
330
- <select
535
+ <SelectField
331
536
  id={props.id}
332
- className={css.select}
537
+ options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
333
538
  value={props.text}
334
539
  disabled={props.disabled}
335
- onChange={(event) => { props.onEdit(event.target.value) }}
336
- >
337
- <option value="">{props.inheritLabel}</option>
338
- {props.choices.map(choice => (
339
- <option key={choice.value} value={choice.value}>{choice.label}</option>
340
- ))}
341
- </select>
540
+ invalid={props.invalid}
541
+ onEdit={props.onEdit}
542
+ />
342
543
  <p className={props.invalid ? css.invalid : css.hint}>
343
544
  {props.invalid ? props.invalidLabel : props.hint}
344
545
  </p>
@@ -57,6 +57,10 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
57
57
  useEffect(() => { stateRef.current = state }, [state])
58
58
  const [copied, setCopied] = useState(false)
59
59
  const eventSource = useRef<EventSource | undefined>(undefined)
60
+ // Generation counter for the open flow: closing (or re-opening) the panel
61
+ // bumps it, so an in-flight issue() that resolves after a close does not
62
+ // spawn a stray EventSource.
63
+ const openSeq = useRef(0)
60
64
 
61
65
  // The current workspace (the recent-workspace projection the shell's New
62
66
  // Session flow targets) — the deep-link target for the phone.
@@ -104,8 +108,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
104
108
  }, [workspaceId])
105
109
 
106
110
  const openPanel = useCallback(async (): Promise<void> => {
111
+ const seq = ++openSeq.current
107
112
  setOpen(true)
108
113
  const next = await mint()
114
+ // A close (or re-open) during the await invalidates this issue: skip the
115
+ // state write and the stream so a panel closed mid-mint neither leaks an
116
+ // EventSource nor resurrects a stale QR.
117
+ if (seq !== openSeq.current) return
109
118
  setState(next)
110
119
  // Live status: the desktop panel mirrors the pairing service state. The
111
120
  // stream makes sense in the ready state and on the lan-required banner —
@@ -143,6 +152,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
143
152
  }, [mint])
144
153
 
145
154
  const closePanel = useCallback(() => {
155
+ openSeq.current += 1
146
156
  closeEventSource()
147
157
  setOpen(false)
148
158
  }, [closeEventSource])
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The remote-control settings card: pairing security and device limits.
3
- * Registers into the `settings.plugin.item` slot the plugin-configuration
4
- * section renders, bound to the `remote-web-ui` settings namespace.
3
+ * Registers into the `web-ui.plugin.item` child slot the Web UI plugin group
4
+ * renders, bound to the `remote-web-ui` settings namespace.
5
5
  */
6
6
 
7
7
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'