@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,183 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- // @vitest-environment jsdom
3
- /** Mobile workspace landing: roster rendering and QR deep-link selection. */
4
- import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
6
- import { mobileWorkspaceTarget } from "./App.js";
7
- import { WorkspaceView } from "./WorkspaceView.js";
8
- vi.mock('../api.ts', () => ({
9
- listWorkspaces: vi.fn(),
10
- listDirectory: vi.fn(),
11
- createWorkspace: vi.fn(),
12
- }));
13
- import { listWorkspaces, listDirectory, createWorkspace } from "../api.js";
14
- const listWorkspacesMock = vi.mocked(listWorkspaces);
15
- const listDirectoryMock = vi.mocked(listDirectory);
16
- const createWorkspaceMock = vi.mocked(createWorkspace);
17
- const workspaces = [
18
- {
19
- workspaceId: 'ws-1',
20
- path: '/tmp/first',
21
- title: 'First',
22
- sessionIds: [],
23
- createdAt: '2026-01-01T00:00:00.000Z',
24
- updatedAt: '2026-01-01T00:00:00.000Z',
25
- },
26
- {
27
- workspaceId: 'ws-2',
28
- path: '/tmp/second',
29
- title: 'Second',
30
- sessionIds: [],
31
- createdAt: '2026-01-01T00:00:00.000Z',
32
- updatedAt: '2026-01-01T00:00:00.000Z',
33
- },
34
- ];
35
- afterEach(() => {
36
- cleanup();
37
- vi.clearAllMocks();
38
- });
39
- describe('mobile workspace deep link', () => {
40
- it('reads a non-empty workspace target from the query', () => {
41
- expect(mobileWorkspaceTarget('?workspace=ws-2')).toBe('ws-2');
42
- expect(mobileWorkspaceTarget('?workspace=')).toBeUndefined();
43
- expect(mobileWorkspaceTarget('')).toBeUndefined();
44
- });
45
- it('opens the targeted workspace as soon as the roster loads', async () => {
46
- listWorkspacesMock.mockResolvedValue(workspaces);
47
- const onPick = vi.fn();
48
- render(_jsx(WorkspaceView, { initialWorkspaceId: "ws-2", onPick: onPick }));
49
- await waitFor(() => expect(onPick).toHaveBeenCalledWith(workspaces[1]));
50
- expect(onPick).toHaveBeenCalledTimes(1);
51
- });
52
- it('falls back to the roster when the target no longer exists', async () => {
53
- listWorkspacesMock.mockResolvedValue(workspaces);
54
- const onPick = vi.fn();
55
- render(_jsx(WorkspaceView, { initialWorkspaceId: "missing", onPick: onPick }));
56
- expect(await screen.findByText('First')).toBeTruthy();
57
- expect(await screen.findByText('Second')).toBeTruthy();
58
- expect(onPick).not.toHaveBeenCalled();
59
- });
60
- });
61
- describe('mobile workspace creation', () => {
62
- it('shows new workspace button and navigates to directory browser', async () => {
63
- listWorkspacesMock.mockResolvedValue(workspaces);
64
- listDirectoryMock.mockResolvedValue({
65
- path: '/home/user',
66
- home: '/home/user',
67
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
68
- entries: [
69
- { name: 'projects', path: '/home/user/projects', hidden: false },
70
- { name: '.config', path: '/home/user/.config', hidden: true }
71
- ],
72
- truncated: false
73
- });
74
- const onPick = vi.fn();
75
- render(_jsx(WorkspaceView, { onPick: onPick }));
76
- // Wait for workspaces to load
77
- const createBtn = await screen.findByText('+ 新建工作区');
78
- // Click create button
79
- fireEvent.click(createBtn);
80
- // Verify directory browser renders
81
- expect(await screen.findByText('选择目录')).toBeTruthy();
82
- // Verify crumbs
83
- expect(await screen.findByText('user')).toBeTruthy();
84
- // Verify entries
85
- expect(await screen.findByText('projects')).toBeTruthy();
86
- expect(await screen.findByText('.config')).toBeTruthy();
87
- });
88
- it('navigates into directory and allows breadcrumb navigation', async () => {
89
- listWorkspacesMock.mockResolvedValue(workspaces);
90
- // Initial dir
91
- listDirectoryMock.mockResolvedValueOnce({
92
- path: '/home/user',
93
- home: '/home/user',
94
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'user', path: '/home/user', hidden: false }],
95
- entries: [{ name: 'projects', path: '/home/user/projects', hidden: false }],
96
- truncated: false
97
- });
98
- const onPick = vi.fn();
99
- render(_jsx(WorkspaceView, { onPick: onPick }));
100
- // Open dir browser
101
- fireEvent.click(await screen.findByText('+ 新建工作区'));
102
- // Wait for first dir listing
103
- const projBtn = await screen.findByText('projects');
104
- // Prepare next listing
105
- listDirectoryMock.mockResolvedValueOnce({
106
- path: '/home/user/projects',
107
- home: '/home/user',
108
- crumbs: [
109
- { name: '', path: '/', hidden: false },
110
- { name: 'home', path: '/home', hidden: false },
111
- { name: 'user', path: '/home/user', hidden: false },
112
- { name: 'projects', path: '/home/user/projects', hidden: false }
113
- ],
114
- entries: [{ name: 'foo', path: '/home/user/projects/foo', hidden: false }],
115
- truncated: false
116
- });
117
- // Click into folder
118
- fireEvent.click(projBtn);
119
- // Verify new contents load
120
- expect(await screen.findByText('foo')).toBeTruthy();
121
- // Check breadcrumb
122
- const homeCrumb = screen.getByText('home');
123
- expect(homeCrumb).toBeTruthy();
124
- // Click breadcrumb
125
- listDirectoryMock.mockResolvedValueOnce({
126
- path: '/home',
127
- home: '/home/user',
128
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }],
129
- entries: [{ name: 'user', path: '/home/user', hidden: false }],
130
- truncated: false
131
- });
132
- fireEvent.click(homeCrumb);
133
- expect(await screen.findByText('user')).toBeTruthy();
134
- });
135
- it('creates workspace successfully', async () => {
136
- listWorkspacesMock.mockResolvedValue(workspaces);
137
- listDirectoryMock.mockResolvedValue({
138
- path: '/home/user/projects/foo',
139
- home: '/home/user',
140
- crumbs: [{ name: '', path: '/', hidden: false }, { name: 'foo', path: '/home/user/projects/foo', hidden: false }],
141
- entries: [],
142
- truncated: false
143
- });
144
- const newWorkspace = { ...workspaces[0], workspaceId: 'ws-new', title: 'foo' };
145
- createWorkspaceMock.mockResolvedValue({
146
- workspace: newWorkspace,
147
- created: true
148
- });
149
- const onPick = vi.fn();
150
- render(_jsx(WorkspaceView, { onPick: onPick }));
151
- fireEvent.click(await screen.findByText('+ 新建工作区'));
152
- const selectBtn = await screen.findByText('选择此目录');
153
- fireEvent.click(selectBtn);
154
- expect(await screen.findByText('创建中…')).toBeTruthy();
155
- await waitFor(() => {
156
- expect(createWorkspaceMock).toHaveBeenCalledWith('/home/user/projects/foo');
157
- expect(onPick).toHaveBeenCalledWith(newWorkspace);
158
- });
159
- });
160
- it('handles errors from directory listing and workspace creation', async () => {
161
- listWorkspacesMock.mockResolvedValue(workspaces);
162
- listDirectoryMock.mockRejectedValue(new Error('Permission denied'));
163
- const onPick = vi.fn();
164
- render(_jsx(WorkspaceView, { onPick: onPick }));
165
- fireEvent.click(await screen.findByText('+ 新建工作区'));
166
- // Verify error from listing
167
- expect(await screen.findByText('Permission denied')).toBeTruthy();
168
- // Retry with success
169
- listDirectoryMock.mockResolvedValue({
170
- path: '/home',
171
- home: '/home/user',
172
- crumbs: [],
173
- entries: [],
174
- truncated: false
175
- });
176
- fireEvent.click(screen.getByText('重试'));
177
- const selectBtn = await screen.findByText('选择此目录');
178
- // Fail creation
179
- createWorkspaceMock.mockRejectedValue(new Error('Already exists'));
180
- fireEvent.click(selectBtn);
181
- expect(await screen.findByText('Already exists')).toBeTruthy();
182
- });
183
- });
@@ -1,65 +0,0 @@
1
- /**
2
- * The mobile surface's data channel: `/m/api` proxies the host ApiProxy
3
- * service for the standalone phone page. The phone's RPC calls ride THIS
4
- * prefix instead of the connection plugin's `/api` — so the tunneled Host
5
- * never needs to enter the connection trust fence (a distributable plugin
6
- * cannot change that fence), and this plugin's own pairing gate is the
7
- * access control instead.
8
- *
9
- * Security model:
10
- * - Every request must carry a live paired-device cookie (the same gate
11
- * semantic as the LAN fence), enforced before any host call.
12
- * - Only an explicit allowlist of methods is proxied ON THIS PREFIX. The
13
- * allowlist constrains the /m/api proxy alone: the same paired-device
14
- * cookie also passes the global api/gate, so a paired device is a
15
- * full-control credential for the host /api surface outside the SDK's
16
- * loopback-pinned privileged set (settings/credentials/agentPreset/host
17
- * actions/llm.discoverModels). Pairing is full device trust.
18
- * - `session.list` is paged here (the host API returns everything; this
19
- * layer slices stable pages) so the phone never transfers the whole list.
20
- * - The live mux stream is bridged over Server-Sent Events on the same
21
- * prefix (one-directional push; answers to questions/approvals ride the
22
- * unary channel), gated identically.
23
- */
24
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
25
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy';
26
- import type { PendingTracker } from './mobile-pending.ts';
27
- import type { PairingService } from './pairing.ts';
28
- /** Route-family dependencies. */
29
- export interface MobileApiDeps {
30
- /** The pairing service (device gate + cookie name). */
31
- service: PairingService;
32
- /** The host ApiProxy service (injected by the plugin). */
33
- apiProxy: ApiProxy;
34
- /** The pending tracker. */
35
- pendingTracker: PendingTracker;
36
- /** The resolved mobile composer preference (live per request). */
37
- mobileEnterToSend: () => boolean;
38
- /** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
39
- eventsHeartbeatMs?: number;
40
- /**
41
- * Optional command dispatcher for executing slash commands on the mobile
42
- * surface. When absent, `mobile.command` returns an error; the mobile UI
43
- * can still fall back to `session.prompt` (which sends the line to the
44
- * model, not the command registry).
45
- */
46
- commandDispatcher?: {
47
- execute(sessionId: string, line: string, signal: AbortSignal): Promise<{
48
- ok: true;
49
- result: unknown;
50
- } | {
51
- error: string;
52
- }>;
53
- };
54
- }
55
- /** Mobile API route paths. */
56
- export declare const MOBILE_API_PATHS: {
57
- readonly events: "/m/api/events.mux";
58
- };
59
- /**
60
- * Build the mobile data-channel routes.
61
- * @param deps - pairing service + apiProxy.
62
- * @returns the routes to register on webServer.
63
- */
64
- export declare function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[];
65
- //# sourceMappingURL=mobile-api.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;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,qBAAqB,CAAA;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAgElD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,2BAA2B;IAC3B,cAAc,EAAE,cAAc,CAAA;IAC9B,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;IAChC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE;QAClB,OAAO,CACL,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC;YAAE,EAAE,EAAE,IAAI,CAAC;YAAC,MAAM,EAAE,OAAO,CAAA;SAAE,GAAG;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAC9D,CAAA;CACF;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA2NnE"}
@@ -1,404 +0,0 @@
1
- /**
2
- * The mobile surface's data channel: `/m/api` proxies the host ApiProxy
3
- * service for the standalone phone page. The phone's RPC calls ride THIS
4
- * prefix instead of the connection plugin's `/api` — so the tunneled Host
5
- * never needs to enter the connection trust fence (a distributable plugin
6
- * cannot change that fence), and this plugin's own pairing gate is the
7
- * access control instead.
8
- *
9
- * Security model:
10
- * - Every request must carry a live paired-device cookie (the same gate
11
- * semantic as the LAN fence), enforced before any host call.
12
- * - Only an explicit allowlist of methods is proxied ON THIS PREFIX. The
13
- * allowlist constrains the /m/api proxy alone: the same paired-device
14
- * cookie also passes the global api/gate, so a paired device is a
15
- * full-control credential for the host /api surface outside the SDK's
16
- * loopback-pinned privileged set (settings/credentials/agentPreset/host
17
- * actions/llm.discoverModels). Pairing is full device trust.
18
- * - `session.list` is paged here (the host API returns everything; this
19
- * layer slices stable pages) so the phone never transfers the whole list.
20
- * - The live mux stream is bridged over Server-Sent Events on the same
21
- * prefix (one-directional push; answers to questions/approvals ride the
22
- * unary channel), gated identically.
23
- */
24
- import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc';
25
- import { readBoundedJson, writeJson } from "./http.js";
26
- import { readCookie } from "./gate.js";
27
- /**
28
- * Methods the phone surface may call. Everything else is refused HERE — but
29
- * note the paired-device cookie also passes the global api/gate for the full
30
- * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
31
- * the allowlist only constrains this /m/api proxy, not the cookie's reach.
32
- * stop() revokes every device; the loopback panel can also revoke one
33
- * device at a time.
34
- */
35
- const MOBILE_ALLOWLIST = new Set([
36
- 'host.listDirectory',
37
- 'workspace.create',
38
- 'workspace.list',
39
- 'agentPreset.list',
40
- 'session.create',
41
- 'session.list',
42
- 'session.history',
43
- 'session.search',
44
- 'session.prompt',
45
- 'session.models',
46
- 'session.selectModel',
47
- 'session.rename',
48
- 'session.cancel',
49
- ]);
50
- /**
51
- * Locally answered display-preference method (the phone's read-only
52
- * surface preferences; never proxied to the host ApiProxy and never a
53
- * settings-domain write).
54
- */
55
- const MOBILE_PREFERENCES_METHOD = 'mobile.preferences';
56
- const MOBILE_PENDING_METHOD = 'mobile.pending';
57
- const MOBILE_RESPOND_METHOD = 'mobile.respond';
58
- /** Execute a slash command through the host command registry (#1125). */
59
- const MOBILE_COMMAND_METHOD = 'mobile.command';
60
- /** One session.list page (thin phones load incrementally). */
61
- const SESSION_PAGE_SIZE = 20;
62
- /** SSE keep-alive ping cadence for the live mux stream (single connection). */
63
- const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000;
64
- /** Encode one list position as an opaque continuation cursor. */
65
- function sessionListCursor(updatedAt, sessionId) {
66
- return `${updatedAt}:${sessionId}`;
67
- }
68
- /** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
69
- function parseSessionListCursor(cursor) {
70
- const separator = cursor.indexOf(':');
71
- if (separator < 0)
72
- return undefined;
73
- const updatedAt = Number(cursor.slice(0, separator));
74
- if (!Number.isFinite(updatedAt))
75
- return undefined;
76
- return { updatedAt, sessionId: cursor.slice(separator + 1) };
77
- }
78
- /** Whether a row comes strictly after the cursor position. */
79
- function afterCursor(row, position) {
80
- return row.updatedAt < position.updatedAt
81
- || (row.updatedAt === position.updatedAt && row.sessionId > position.sessionId);
82
- }
83
- /** Mobile API route paths. */
84
- export const MOBILE_API_PATHS = {
85
- events: '/m/api/events.mux',
86
- };
87
- /** The mobile-api prefix (every other path under it is a method name). */
88
- const MOBILE_API_PREFIX = '/m/api';
89
- /** Method extraction: the prefix plus one slash. */
90
- const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
91
- /**
92
- * Build the mobile data-channel routes.
93
- * @param deps - pairing service + apiProxy.
94
- * @returns the routes to register on webServer.
95
- */
96
- export function makeMobileApiRoutes(deps) {
97
- const { service, apiProxy, mobileEnterToSend } = deps;
98
- const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
99
- /**
100
- * Refresh the paired device's presence and report whether it is live.
101
- * The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
102
- * sender, so any activity on the mobile channel — a gated RPC, or the live
103
- * SSE stream staying open — must count as presence. Without this, an
104
- * idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
105
- * wrongly reports it as disconnected.
106
- */
107
- const touchDeviceFor = (req) => {
108
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
109
- if (deviceId === undefined)
110
- return false;
111
- return service.touchDevice(deviceId);
112
- };
113
- /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
114
- const gateOk = (req) => {
115
- return touchDeviceFor(req);
116
- };
117
- const handleMethod = async (req, res) => {
118
- if (req.method !== 'POST') {
119
- res.writeHead(405);
120
- res.end();
121
- return;
122
- }
123
- if (!gateOk(req)) {
124
- writeJson(res, 403, { ok: false, error: { code: 'unpaired', message: 'mobile session is not paired' } });
125
- return;
126
- }
127
- const pathname = new URL(req.url ?? '/', 'http://x').pathname;
128
- if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
129
- writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown mobile api path' } });
130
- return;
131
- }
132
- const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
133
- const local = method === MOBILE_PREFERENCES_METHOD
134
- || method === MOBILE_PENDING_METHOD
135
- || method === MOBILE_RESPOND_METHOD
136
- || method === MOBILE_COMMAND_METHOD;
137
- if (!MOBILE_ALLOWLIST.has(method) && !local) {
138
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } });
139
- return;
140
- }
141
- let envelope;
142
- try {
143
- envelope = await readBoundedJson(req, 64 * 1024);
144
- }
145
- catch {
146
- writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } });
147
- return;
148
- }
149
- const parsed = envelope;
150
- const rpcId = typeof parsed?.rpcId === 'string' ? parsed.rpcId : '';
151
- if (rpcId === '') {
152
- writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing rpcId' } });
153
- return;
154
- }
155
- if (local) {
156
- if (method === MOBILE_PREFERENCES_METHOD) {
157
- writeJson(res, 200, {
158
- type: 'server-response',
159
- rpcId,
160
- result: { ok: true, value: { mobileEnterToSend: mobileEnterToSend() } },
161
- });
162
- }
163
- else if (method === MOBILE_PENDING_METHOD) {
164
- const payload = parsed.payload;
165
- writeJson(res, 200, {
166
- type: 'server-response',
167
- rpcId,
168
- result: { ok: true, value: deps.pendingTracker.pending(payload?.sessionId) },
169
- });
170
- }
171
- else if (method === MOBILE_RESPOND_METHOD) {
172
- const payload = parsed.payload;
173
- try {
174
- const receipt = await apiProxy.respond({
175
- type: 'client-response',
176
- rpcId: RpcId(payload.rpcId),
177
- result: { ok: true, value: payload.response },
178
- });
179
- writeJson(res, 200, {
180
- type: 'server-response',
181
- rpcId,
182
- result: { ok: true, value: receipt },
183
- });
184
- }
185
- catch (error) {
186
- const message = error instanceof Error ? error.message : String(error);
187
- writeJson(res, 200, {
188
- type: 'server-response',
189
- rpcId,
190
- result: { ok: false, error: { code: 'internal', message } },
191
- });
192
- }
193
- }
194
- else if (method === MOBILE_COMMAND_METHOD) {
195
- const payload = parsed.payload;
196
- const sessionId = payload?.sessionId;
197
- const line = payload?.line;
198
- if (typeof sessionId !== 'string' || typeof line !== 'string') {
199
- writeJson(res, 200, {
200
- type: 'server-response',
201
- rpcId,
202
- result: { ok: false, error: { code: 'bad-request', message: 'missing sessionId or line' } },
203
- });
204
- }
205
- else if (deps.commandDispatcher === undefined) {
206
- writeJson(res, 200, {
207
- type: 'server-response',
208
- rpcId,
209
- result: { ok: false, error: { code: 'unavailable', message: 'command dispatcher is not available' } },
210
- });
211
- }
212
- else {
213
- try {
214
- const abort = new AbortController();
215
- res.on('close', () => { if (!res.writableEnded)
216
- abort.abort(); });
217
- const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal);
218
- if ('error' in outcome) {
219
- writeJson(res, 200, {
220
- type: 'server-response',
221
- rpcId,
222
- result: { ok: false, error: { code: outcome.error, message: outcome.error } },
223
- });
224
- }
225
- else {
226
- writeJson(res, 200, {
227
- type: 'server-response',
228
- rpcId,
229
- result: { ok: true, value: { matched: true, result: outcome.result } },
230
- });
231
- }
232
- }
233
- catch (error) {
234
- const message = error instanceof Error ? error.message : String(error);
235
- writeJson(res, 200, {
236
- type: 'server-response',
237
- rpcId,
238
- result: { ok: false, error: { code: 'internal', message } },
239
- });
240
- }
241
- }
242
- }
243
- return;
244
- }
245
- try {
246
- // Cancel the host-side work when the phone goes away mid-call (the
247
- // response stream closing before we answer means nobody is listening).
248
- const abort = new AbortController();
249
- res.on('close', () => { if (!res.writableEnded)
250
- abort.abort(); });
251
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal);
252
- writeJson(res, 200, response);
253
- }
254
- catch (error) {
255
- const message = error instanceof Error ? error.message : String(error);
256
- writeJson(res, 200, {
257
- type: 'server-response',
258
- rpcId,
259
- result: { ok: false, error: { code: 'internal', message } },
260
- });
261
- }
262
- };
263
- /** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
264
- const handleEvents = async (req, res) => {
265
- if (req.method !== 'GET') {
266
- res.writeHead(405);
267
- res.end();
268
- return;
269
- }
270
- if (!gateOk(req)) {
271
- writeJson(res, 403, {
272
- ok: false,
273
- error: { code: 'unpaired', message: 'mobile session is not paired' },
274
- });
275
- return;
276
- }
277
- res.writeHead(200, {
278
- 'content-type': 'text/event-stream; charset=utf-8',
279
- 'cache-control': 'no-cache',
280
- connection: 'keep-alive',
281
- });
282
- const controller = new AbortController();
283
- let closed = false;
284
- const heartbeat = setInterval(() => {
285
- if (closed)
286
- return;
287
- // An open SSE stream proves the phone is still live even while the agent
288
- // idles (no RPC traffic), so refresh presence alongside the transport
289
- // keepalive — otherwise an idle phone drifts to "disconnected".
290
- touchDeviceFor(req);
291
- try {
292
- res.write(': ping\n\n');
293
- }
294
- catch {
295
- // The write failed; the close handler tears the subscription down.
296
- }
297
- }, eventsHeartbeatMs);
298
- const onClose = () => {
299
- if (closed)
300
- return;
301
- closed = true;
302
- controller.abort();
303
- clearInterval(heartbeat);
304
- };
305
- res.on('close', onClose);
306
- req.on('close', onClose);
307
- try {
308
- const frames = apiProxy.events.mux({ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`), payload: {} }, controller.signal);
309
- for await (const frame of frames) {
310
- if (closed)
311
- break;
312
- deps.pendingTracker.onFrame(frame);
313
- res.write(`data: ${JSON.stringify(frame)}\n\n`);
314
- }
315
- }
316
- catch {
317
- // The stream ended or errored; the EventSource reconnects.
318
- }
319
- finally {
320
- controller.abort();
321
- clearInterval(heartbeat);
322
- }
323
- if (!closed)
324
- res.end();
325
- };
326
- return [
327
- { kind: 'prefix', path: MOBILE_API_PREFIX, handler: handleMethod },
328
- { kind: 'exact', path: MOBILE_API_PATHS.events, handler: handleEvents },
329
- ];
330
- }
331
- /** Dispatch one allowlisted method through the host ApiProxy. */
332
- async function dispatch(apiProxy, method, payload, rpcId, signal) {
333
- const request = { rpcId: RpcId(rpcId), payload };
334
- if (method === 'session.list') {
335
- const full = await apiProxy.sessions.list(request);
336
- // The error path must carry the same 'server-response' envelope the
337
- // success path builds, or the phone's callUnary throws a transport error
338
- // and masks the real business error.
339
- if (!full.result.ok)
340
- return { type: 'server-response', rpcId, result: full.result };
341
- const items = full.result.value.items;
342
- const cursor = payload?.cursor;
343
- // Every call pages (the first call with no cursor IS the first page):
344
- // the phone must never transfer the whole session list at once.
345
- // One stable page over (updatedAt desc, sessionId asc); pages never skip
346
- // or repeat a row while the list changes between calls.
347
- items.sort((a, b) => b.updatedAt - a.updatedAt
348
- || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
349
- const position = cursor === undefined ? undefined : parseSessionListCursor(cursor);
350
- const from = position === undefined ? 0 : items.findIndex(row => afterCursor(row, position));
351
- const start = from < 0 ? items.length : from;
352
- const page = items.slice(start, start + SESSION_PAGE_SIZE);
353
- const last = page[page.length - 1];
354
- const nextCursor = last !== undefined && start + page.length < items.length
355
- ? sessionListCursor(last.updatedAt, last.sessionId)
356
- : undefined;
357
- return {
358
- type: 'server-response',
359
- rpcId,
360
- result: {
361
- ok: true,
362
- value: {
363
- items: page,
364
- hasMore: nextCursor !== undefined,
365
- ...(nextCursor !== undefined ? { nextCursor } : {}),
366
- },
367
- },
368
- };
369
- }
370
- // The ApiProxy unary methods resolve to the internal response shape
371
- // ({ rpcId, result }) without the transport envelope the phone's callUnary
372
- // requires — wrap every pass-through in the same 'server-response'
373
- // envelope session.list builds above.
374
- const wrap = (response) => ({
375
- type: 'server-response',
376
- rpcId,
377
- result: response.result,
378
- });
379
- if (method === 'workspace.list')
380
- return wrap(await apiProxy.workspace.list(request));
381
- if (method === 'workspace.create')
382
- return wrap(await apiProxy.workspace.create(request));
383
- if (method === 'host.listDirectory')
384
- return wrap(await apiProxy.host.listDirectory(request, signal ?? new AbortController().signal));
385
- if (method === 'agentPreset.list')
386
- return wrap(await apiProxy.agentPresets.list(request));
387
- if (method === 'session.create')
388
- return wrap(await apiProxy.sessions.create(request));
389
- if (method === 'session.history')
390
- return wrap(await apiProxy.sessions.history(request));
391
- if (method === 'session.search')
392
- return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
393
- if (method === 'session.prompt')
394
- return wrap(await apiProxy.sessions.prompt(request));
395
- if (method === 'session.models')
396
- return wrap(await apiProxy.sessions.models(request));
397
- if (method === 'session.selectModel')
398
- return wrap(await apiProxy.sessions.selectModel(request));
399
- if (method === 'session.rename')
400
- return wrap(await apiProxy.sessions.rename(request));
401
- if (method === 'session.cancel')
402
- return wrap(await apiProxy.sessions.cancel(request));
403
- throw new Error(`unhandled allowlisted method ${method}`);
404
- }