@linxin666/dsh-remote-web-ui 0.1.1 → 0.1.3
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.md +4 -1
- package/lib/client.js +35 -0
- package/lib/client.js.map +1 -0
- package/lib/mobile.js +15467 -0
- package/lib/mobile.js.map +1 -0
- package/lib/types/client/FooterRemoteEntry.d.ts +22 -0
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -0
- package/lib/types/client/FooterRemoteEntry.js +10 -0
- package/lib/types/client/PairFailedNotice.js +24 -0
- package/lib/types/client/PairFailedNotice.js.map +1 -0
- package/lib/types/client/PhoneIcon.js +9 -0
- package/lib/types/client/PhoneIcon.js.map +1 -0
- package/lib/types/client/PluginSettingsCard.js +39 -0
- package/lib/types/client/PluginSettingsCard.js.map +1 -0
- package/lib/types/client/RemoteEntry.js +162 -0
- package/lib/types/client/RemoteEntry.js.map +1 -0
- package/lib/types/client/RemotePanel.js +34 -0
- package/lib/types/client/RemotePanel.js.map +1 -0
- package/lib/types/client/RemoteSettingsCard.js +59 -0
- package/lib/types/client/RemoteSettingsCard.js.map +1 -0
- package/lib/types/client/deep-link.js +118 -0
- package/lib/types/client/deep-link.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +134 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.js +141 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/pair-api.js +123 -0
- package/lib/types/client/pair-api.js.map +1 -0
- package/lib/types/client/settings-form.js +211 -0
- package/lib/types/client/settings-form.js.map +1 -0
- package/lib/types/gate.js +121 -0
- package/lib/types/gate.js.map +1 -0
- package/lib/types/index.js +227 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/invariant.js.map +1 -0
- package/lib/types/lan.js +19 -0
- package/lib/types/lan.js.map +1 -0
- package/lib/types/mobile/api.js +60 -0
- package/lib/types/mobile/api.js.map +1 -0
- package/lib/types/mobile/index.js +25 -0
- package/lib/types/mobile/index.js.map +1 -0
- package/lib/types/mobile/messages.js +449 -0
- package/lib/types/mobile/messages.js.map +1 -0
- package/lib/types/mobile/messages.test.js +189 -0
- package/lib/types/mobile/messages.test.js.map +1 -0
- package/lib/types/mobile/mobile-styles.js +853 -0
- package/lib/types/mobile/mobile-styles.js.map +1 -0
- package/lib/types/mobile/mobile-theme.js +70 -0
- package/lib/types/mobile/mobile-theme.test.js +71 -0
- package/lib/types/mobile/mux.js +110 -0
- package/lib/types/mobile/mux.js.map +1 -0
- package/lib/types/mobile/rpc.js +83 -0
- package/lib/types/mobile/rpc.js.map +1 -0
- package/lib/types/mobile/theme-toggle.js +26 -0
- package/lib/types/mobile/views/App.js +102 -0
- package/lib/types/mobile/views/App.js.map +1 -0
- package/lib/types/mobile/views/ChatView.js +348 -0
- package/lib/types/mobile/views/ChatView.js.map +1 -0
- package/lib/types/mobile/views/ChatView.test.js +215 -0
- package/lib/types/mobile/views/SessionListView.js +108 -0
- package/lib/types/mobile/views/SessionListView.js.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.js +110 -0
- package/lib/types/mobile/views/WorkspaceView.js +43 -0
- package/lib/types/mobile/views/WorkspaceView.js.map +1 -0
- package/lib/types/mobile-api.js +263 -0
- package/lib/types/mobile-api.js.map +1 -0
- package/lib/types/mobile-routes.js +71 -0
- package/lib/types/mobile-routes.js.map +1 -0
- package/lib/types/pairing.js +285 -0
- package/lib/types/pairing.js.map +1 -0
- package/lib/types/routes.js +327 -0
- package/lib/types/routes.js.map +1 -0
- package/lib/types/tunnel.js +222 -0
- package/lib/types/tunnel.js.map +1 -0
- package/package.json +9 -6
- package/src/client/FooterRemoteEntry.tsx +31 -0
- package/src/client/index.ts +23 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Sessions level: one workspace's sessions, loaded incrementally — the
|
|
4
|
+
* first page fetches session.list with a cursor, scrolling appends further
|
|
5
|
+
* pages (never the whole list at once). Rows are filtered to the opened
|
|
6
|
+
* workspace by its owned session ids; extra pages are pulled on demand so
|
|
7
|
+
* a workspace with many sessions converges without a full transfer.
|
|
8
|
+
*
|
|
9
|
+
* Creating a session is this level's other action: the workspace's id is
|
|
10
|
+
* sent to session.create (the host attaches the new session to it), the
|
|
11
|
+
* fresh row is prepended optimistically, and the user lands straight in
|
|
12
|
+
* the new chat — the same "new session opens" flow as the desktop UI.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { createSession, listSessions, listWorkspaces } from "../api.js";
|
|
16
|
+
import { errorText, formatTime, staleHostHint, toSessionView } from "./App.js";
|
|
17
|
+
import { ThemeToggle } from "../theme-toggle.js";
|
|
18
|
+
/** Rows that belong to the opened workspace (its owned session id set). */
|
|
19
|
+
function ownedItems(page, workspace) {
|
|
20
|
+
const owned = new Set(workspace.sessionIds);
|
|
21
|
+
return page
|
|
22
|
+
.filter(item => owned.has(item.sessionId))
|
|
23
|
+
.map(item => toSessionView(item));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Render one workspace's paged session list.
|
|
27
|
+
* @param props - the workspace, back action, and pick action.
|
|
28
|
+
* @returns the session list.
|
|
29
|
+
*/
|
|
30
|
+
export function SessionListView({ workspace, onBack, onPick }) {
|
|
31
|
+
const [rows, setRows] = useState([]);
|
|
32
|
+
const [hasMore, setHasMore] = useState(false);
|
|
33
|
+
const [loading, setLoading] = useState(true);
|
|
34
|
+
const [error, setError] = useState(undefined);
|
|
35
|
+
const [creating, setCreating] = useState(false);
|
|
36
|
+
const [createError, setCreateError] = useState(undefined);
|
|
37
|
+
const cursorRef = useRef(undefined);
|
|
38
|
+
const busyRef = useRef(false);
|
|
39
|
+
// First page on mount. The workspace roster is re-read alongside so the
|
|
40
|
+
// owned-id set is fresh: a session created (or attached) since this
|
|
41
|
+
// workspace row was captured must not vanish from the filter.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
setLoading(true);
|
|
45
|
+
setError(undefined);
|
|
46
|
+
void Promise.all([listSessions(), listWorkspaces()]).then(([page, workspaces]) => {
|
|
47
|
+
if (cancelled)
|
|
48
|
+
return;
|
|
49
|
+
const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId);
|
|
50
|
+
const current = fresh ?? workspace;
|
|
51
|
+
setRows(ownedItems(page.items, current));
|
|
52
|
+
cursorRef.current = page.nextCursor;
|
|
53
|
+
setHasMore(page.hasMore);
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}, (reason) => {
|
|
56
|
+
if (cancelled)
|
|
57
|
+
return;
|
|
58
|
+
setError(errorText(reason));
|
|
59
|
+
setLoading(false);
|
|
60
|
+
});
|
|
61
|
+
return () => { cancelled = true; };
|
|
62
|
+
}, [workspace]);
|
|
63
|
+
/** Pull the next page and append the rows that belong to this workspace. */
|
|
64
|
+
const loadMore = useCallback(() => {
|
|
65
|
+
if (busyRef.current)
|
|
66
|
+
return;
|
|
67
|
+
const cursor = cursorRef.current;
|
|
68
|
+
if (cursor === undefined)
|
|
69
|
+
return;
|
|
70
|
+
busyRef.current = true;
|
|
71
|
+
setLoading(true);
|
|
72
|
+
void listSessions(cursor).then((page) => {
|
|
73
|
+
busyRef.current = false;
|
|
74
|
+
setLoading(false);
|
|
75
|
+
cursorRef.current = page.nextCursor;
|
|
76
|
+
setHasMore(page.hasMore);
|
|
77
|
+
setRows(previous => [...previous, ...ownedItems(page.items, workspace)]);
|
|
78
|
+
}, (reason) => {
|
|
79
|
+
busyRef.current = false;
|
|
80
|
+
setLoading(false);
|
|
81
|
+
setError(errorText(reason));
|
|
82
|
+
});
|
|
83
|
+
}, [workspace]);
|
|
84
|
+
/** Create a blank session in this workspace and open it immediately. */
|
|
85
|
+
const handleCreate = useCallback(() => {
|
|
86
|
+
if (creating)
|
|
87
|
+
return;
|
|
88
|
+
setCreating(true);
|
|
89
|
+
setCreateError(undefined);
|
|
90
|
+
void createSession({ workspaceId: workspace.workspaceId }).then((created) => {
|
|
91
|
+
setCreating(false);
|
|
92
|
+
const view = {
|
|
93
|
+
sessionId: created.sessionId,
|
|
94
|
+
title: '新会话',
|
|
95
|
+
updatedAt: Date.now(),
|
|
96
|
+
running: false,
|
|
97
|
+
blank: true,
|
|
98
|
+
};
|
|
99
|
+
setRows(previous => [view, ...previous]);
|
|
100
|
+
onPick(view);
|
|
101
|
+
}, (reason) => {
|
|
102
|
+
setCreating(false);
|
|
103
|
+
setCreateError(errorText(reason));
|
|
104
|
+
});
|
|
105
|
+
}, [creating, workspace, onPick]);
|
|
106
|
+
const createHint = createError !== undefined ? staleHostHint(createError) : undefined;
|
|
107
|
+
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-back", "aria-label": "\u8FD4\u56DE", onClick: onBack, children: "\u2039" }), _jsx("h1", { className: "mobile-title mobile-titleInline", children: workspace.title }), _jsx(ThemeToggle, {})] }), error !== undefined && _jsx("p", { className: "mobile-error mobile-pad", children: error }), _jsx("div", { className: "mobile-pad", children: _jsx("button", { type: "button", className: "mobile-new", disabled: creating, onClick: () => { void handleCreate(); }, children: creating ? '创建中…' : '+ 新建会话' }) }), createError !== undefined && (_jsxs("p", { className: "mobile-error mobile-pad", children: [createError, createHint !== undefined && _jsx("span", { className: "mobile-hint", children: createHint })] })), _jsx("ul", { className: "mobile-list", children: rows.map(row => (_jsx("li", { children: _jsxs("button", { type: "button", className: "mobile-row", onClick: () => { onPick(row); }, children: [_jsxs("span", { className: "mobile-rowMain", children: [_jsxs("span", { className: "mobile-rowTitle", children: [row.blank ? '新会话' : row.title, row.running ? _jsx("span", { className: "mobile-live", children: "\u25CF" }) : null] }), _jsx("span", { className: "mobile-rowMeta", children: formatTime(row.updatedAt) })] }), _jsx("span", { className: "mobile-chevron", children: "\u203A" })] }) }, row.sessionId))) }), hasMore && (_jsx("div", { className: "mobile-pad", children: _jsx("button", { type: "button", className: "mobile-button mobile-block", disabled: loading, onClick: () => { void loadMore(); }, children: loading ? '加载中…' : '加载更多会话' }) })), !hasMore && rows.length === 0 && !loading && (_jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u8BE5\u5DE5\u4F5C\u533A\u8FD8\u6CA1\u6709\u4F1A\u8BDD\uFF0C\u70B9\u4E0A\u65B9\u6309\u94AE\u65B0\u5EFA\u4E00\u4E2A" }) }))] }));
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SessionListView.js","sourceRoot":"","sources":["../../../../src/mobile/views/SessionListView.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
/** SessionListView: owned-row filtering, incremental pages, session creation. */
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { SessionListView } from "./SessionListView.js";
|
|
7
|
+
// The api module is fully mocked; the view's App.tsx helpers stay real.
|
|
8
|
+
vi.mock('../api.ts', () => ({
|
|
9
|
+
listSessions: vi.fn(),
|
|
10
|
+
listWorkspaces: vi.fn(),
|
|
11
|
+
createSession: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
import { createSession, listSessions, listWorkspaces } from "../api.js";
|
|
14
|
+
const listSessionsMock = vi.mocked(listSessions);
|
|
15
|
+
const listWorkspacesMock = vi.mocked(listWorkspaces);
|
|
16
|
+
const createSessionMock = vi.mocked(createSession);
|
|
17
|
+
const workspace = {
|
|
18
|
+
workspaceId: 'w-1',
|
|
19
|
+
path: '/tmp/demo',
|
|
20
|
+
title: '演示项目',
|
|
21
|
+
sessionIds: ['s-1'],
|
|
22
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
23
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
24
|
+
};
|
|
25
|
+
/** A session.list summary row. */
|
|
26
|
+
function summary(sessionId, updatedAt, extra = {}) {
|
|
27
|
+
return { sessionId, updatedAt, running: false, blank: false, ...extra };
|
|
28
|
+
}
|
|
29
|
+
let picked;
|
|
30
|
+
function renderList(props = {}) {
|
|
31
|
+
picked = undefined;
|
|
32
|
+
render(_jsx(SessionListView, { workspace: workspace, onBack: () => { }, onPick: (session) => { picked = session; }, ...props }));
|
|
33
|
+
}
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
listSessionsMock.mockResolvedValue({ items: [], hasMore: false });
|
|
36
|
+
listWorkspacesMock.mockResolvedValue([workspace]);
|
|
37
|
+
createSessionMock.mockResolvedValue({ sessionId: 's-new' });
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
cleanup();
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
describe('SessionListView roster', () => {
|
|
44
|
+
it('renders the sessions owned by the workspace', async () => {
|
|
45
|
+
listSessionsMock.mockResolvedValue({
|
|
46
|
+
items: [
|
|
47
|
+
summary('s-1', 1_700_000_000_000, { projections: { values: { title: '改造移动端' } } }),
|
|
48
|
+
summary('s-other', 1_600_000_000_000, { cwd: '/tmp/foreign' }),
|
|
49
|
+
],
|
|
50
|
+
hasMore: false,
|
|
51
|
+
});
|
|
52
|
+
renderList();
|
|
53
|
+
expect(await screen.findByText('改造移动端')).toBeTruthy();
|
|
54
|
+
// The foreign session (not in workspace.sessionIds) stays hidden.
|
|
55
|
+
expect(screen.queryByText(/foreign/)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
it('shows the empty state when the workspace owns no sessions', async () => {
|
|
58
|
+
renderList();
|
|
59
|
+
expect(await screen.findByText(/还没有会话/)).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
it('refreshes the owned-id set from workspace.list on mount', async () => {
|
|
62
|
+
listSessionsMock.mockResolvedValue({
|
|
63
|
+
items: [summary('s-2', 1_700_000_000_000, { cwd: '/tmp/other' })],
|
|
64
|
+
hasMore: false,
|
|
65
|
+
});
|
|
66
|
+
listWorkspacesMock.mockResolvedValue([
|
|
67
|
+
{ ...workspace, sessionIds: ['s-2'] },
|
|
68
|
+
]);
|
|
69
|
+
renderList();
|
|
70
|
+
// s-2 is absent from the stale row but present in the fresh roster.
|
|
71
|
+
expect(await screen.findByText(/other/)).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('SessionListView creation', () => {
|
|
75
|
+
it('creates a blank session in the workspace and opens it immediately', async () => {
|
|
76
|
+
renderList();
|
|
77
|
+
await screen.findByText(/还没有会话/);
|
|
78
|
+
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }));
|
|
79
|
+
await waitFor(() => {
|
|
80
|
+
expect(createSessionMock).toHaveBeenCalledWith({ workspaceId: 'w-1' });
|
|
81
|
+
});
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(picked).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
expect(picked?.sessionId).toBe('s-new');
|
|
86
|
+
expect(picked?.blank).toBe(true);
|
|
87
|
+
// The fresh row is prepended without waiting for a refetch.
|
|
88
|
+
expect(await screen.findByText('新会话')).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
it('keeps the button disabled while a creation is in flight', async () => {
|
|
91
|
+
let resolveCreate = () => { };
|
|
92
|
+
createSessionMock.mockReturnValue(new Promise(resolve => { resolveCreate = resolve; }));
|
|
93
|
+
renderList();
|
|
94
|
+
await screen.findByText(/还没有会话/);
|
|
95
|
+
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }));
|
|
96
|
+
const button = await screen.findByRole('button', { name: '创建中…' });
|
|
97
|
+
expect(button.hasAttribute('disabled')).toBe(true);
|
|
98
|
+
resolveCreate({ sessionId: 's-new' });
|
|
99
|
+
await waitFor(() => { expect(picked).toBeDefined(); });
|
|
100
|
+
});
|
|
101
|
+
it('shows the stale-host hint when creation is refused with HTTP 403', async () => {
|
|
102
|
+
createSessionMock.mockRejectedValue(new Error('HTTP 403'));
|
|
103
|
+
renderList();
|
|
104
|
+
await screen.findByText(/还没有会话/);
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: '+ 新建会话' }));
|
|
106
|
+
expect(await screen.findByText(/HTTP 403/)).toBeTruthy();
|
|
107
|
+
expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
|
|
108
|
+
expect(picked).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Landing level: the workspace roster. The mobile surface opens straight
|
|
4
|
+
* here — no new-session homepage — and every workspace row is a thin
|
|
5
|
+
* fetch from workspace.list (the roster is small; sessions are not loaded
|
|
6
|
+
* until a workspace is opened).
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
import { listWorkspaces } from "../api.js";
|
|
10
|
+
import { errorText } from "./App.js";
|
|
11
|
+
import { ThemeToggle } from "../theme-toggle.js";
|
|
12
|
+
/**
|
|
13
|
+
* Render the workspace roster.
|
|
14
|
+
* @param props - the pick action.
|
|
15
|
+
* @returns the roster.
|
|
16
|
+
*/
|
|
17
|
+
export function WorkspaceView({ onPick }) {
|
|
18
|
+
const [items, setItems] = useState(undefined);
|
|
19
|
+
const [error, setError] = useState(undefined);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
void listWorkspaces().then((rows) => {
|
|
23
|
+
if (cancelled)
|
|
24
|
+
return;
|
|
25
|
+
setItems(rows);
|
|
26
|
+
}, (reason) => {
|
|
27
|
+
if (cancelled)
|
|
28
|
+
return;
|
|
29
|
+
setError(errorText(reason));
|
|
30
|
+
});
|
|
31
|
+
return () => { cancelled = true; };
|
|
32
|
+
}, []);
|
|
33
|
+
if (error !== undefined) {
|
|
34
|
+
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" })] })] }));
|
|
35
|
+
}
|
|
36
|
+
if (items === undefined) {
|
|
37
|
+
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" }) })] }));
|
|
38
|
+
}
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
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: "\u6682\u65E0\u5DE5\u4F5C\u533A" }) })] }));
|
|
41
|
+
}
|
|
42
|
+
return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsx("ul", { className: "mobile-list", children: items.map(workspace => (_jsx("li", { children: _jsxs("button", { type: "button", className: "mobile-row", onClick: () => { onPick(workspace); }, children: [_jsx("span", { className: "mobile-rowTitle", children: workspace.title }), _jsx("span", { className: "mobile-rowMeta", children: workspace.path }), _jsx("span", { className: "mobile-chevron", children: "\u203A" })] }) }, workspace.workspaceId))) })] }));
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WorkspaceView.js","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,263 @@
|
|
|
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; privileged domains
|
|
13
|
+
* (settings, credentials, host actions, goals, subagents, …) are never
|
|
14
|
+
* reachable from the phone.
|
|
15
|
+
* - `session.list` is paged here (the host API returns everything; this
|
|
16
|
+
* layer slices stable pages) so the phone never transfers the whole list.
|
|
17
|
+
* - The live mux stream is bridged over Server-Sent Events on the same
|
|
18
|
+
* prefix (one-directional push; answers to questions/approvals ride the
|
|
19
|
+
* unary channel), gated identically.
|
|
20
|
+
*/
|
|
21
|
+
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc';
|
|
22
|
+
import { readCookie } from "./gate.js";
|
|
23
|
+
/** Methods the phone surface may call. Everything else is refused. */
|
|
24
|
+
const MOBILE_ALLOWLIST = new Set([
|
|
25
|
+
'workspace.list',
|
|
26
|
+
'session.create',
|
|
27
|
+
'session.list',
|
|
28
|
+
'session.history',
|
|
29
|
+
'session.search',
|
|
30
|
+
'session.prompt',
|
|
31
|
+
'session.models',
|
|
32
|
+
'session.selectModel',
|
|
33
|
+
'session.rename',
|
|
34
|
+
]);
|
|
35
|
+
/** One session.list page (thin phones load incrementally). */
|
|
36
|
+
const SESSION_PAGE_SIZE = 20;
|
|
37
|
+
/** Encode one list position as an opaque continuation cursor. */
|
|
38
|
+
function sessionListCursor(updatedAt, sessionId) {
|
|
39
|
+
return `${updatedAt}:${sessionId}`;
|
|
40
|
+
}
|
|
41
|
+
/** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
|
|
42
|
+
function parseSessionListCursor(cursor) {
|
|
43
|
+
const separator = cursor.indexOf(':');
|
|
44
|
+
if (separator < 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const updatedAt = Number(cursor.slice(0, separator));
|
|
47
|
+
if (!Number.isFinite(updatedAt))
|
|
48
|
+
return undefined;
|
|
49
|
+
return { updatedAt, sessionId: cursor.slice(separator + 1) };
|
|
50
|
+
}
|
|
51
|
+
/** Whether a row comes strictly after the cursor position. */
|
|
52
|
+
function afterCursor(row, position) {
|
|
53
|
+
return row.updatedAt < position.updatedAt
|
|
54
|
+
|| (row.updatedAt === position.updatedAt && row.sessionId > position.sessionId);
|
|
55
|
+
}
|
|
56
|
+
/** Mobile API route paths. */
|
|
57
|
+
export const MOBILE_API_PATHS = {
|
|
58
|
+
events: '/m/api/events.mux',
|
|
59
|
+
};
|
|
60
|
+
/** The mobile-api prefix (every other path under it is a method name). */
|
|
61
|
+
const MOBILE_API_PREFIX = '/m/api';
|
|
62
|
+
/** Method extraction: the prefix plus one slash. */
|
|
63
|
+
const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
|
|
64
|
+
/**
|
|
65
|
+
* Build the mobile data-channel routes.
|
|
66
|
+
* @param deps - pairing service + apiProxy.
|
|
67
|
+
* @returns the routes to register on webServer.
|
|
68
|
+
*/
|
|
69
|
+
export function makeMobileApiRoutes(deps) {
|
|
70
|
+
const { service, apiProxy } = deps;
|
|
71
|
+
/** The phone gate: a live paired-device cookie, or nothing else proceeds. */
|
|
72
|
+
const gateOk = (req) => {
|
|
73
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
74
|
+
return deviceId !== undefined && service.hasDevice(deviceId);
|
|
75
|
+
};
|
|
76
|
+
const writeJson = (res, status, body) => {
|
|
77
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
78
|
+
res.end(JSON.stringify(body));
|
|
79
|
+
};
|
|
80
|
+
const handleMethod = async (req, res) => {
|
|
81
|
+
if (req.method !== 'POST') {
|
|
82
|
+
res.writeHead(405);
|
|
83
|
+
res.end();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!gateOk(req)) {
|
|
87
|
+
writeJson(res, 403, { ok: false, error: { code: 'unpaired', message: 'mobile session is not paired' } });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
91
|
+
if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
|
|
92
|
+
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown mobile api path' } });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
|
|
96
|
+
if (!MOBILE_ALLOWLIST.has(method)) {
|
|
97
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let envelope;
|
|
101
|
+
try {
|
|
102
|
+
envelope = await readJsonBody(req);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const parsed = envelope;
|
|
109
|
+
const rpcId = typeof parsed?.rpcId === 'string' ? parsed.rpcId : '';
|
|
110
|
+
if (rpcId === '') {
|
|
111
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing rpcId' } });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const response = await dispatch(apiProxy, method, parsed?.payload, rpcId);
|
|
116
|
+
writeJson(res, 200, response);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
writeJson(res, 200, {
|
|
121
|
+
type: 'server-response',
|
|
122
|
+
rpcId,
|
|
123
|
+
result: { ok: false, error: { code: 'internal', message } },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
/** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
|
|
128
|
+
const handleEvents = async (req, res) => {
|
|
129
|
+
if (req.method !== 'GET') {
|
|
130
|
+
res.writeHead(405);
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!gateOk(req)) {
|
|
135
|
+
res.writeHead(403);
|
|
136
|
+
res.end('forbidden');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
res.writeHead(200, {
|
|
140
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
141
|
+
'cache-control': 'no-cache',
|
|
142
|
+
connection: 'keep-alive',
|
|
143
|
+
});
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
let closed = false;
|
|
146
|
+
const heartbeat = setInterval(() => {
|
|
147
|
+
if (closed)
|
|
148
|
+
return;
|
|
149
|
+
try {
|
|
150
|
+
res.write(': ping\n\n');
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// The write failed; the close handler tears the subscription down.
|
|
154
|
+
}
|
|
155
|
+
}, 15_000);
|
|
156
|
+
const onClose = () => {
|
|
157
|
+
if (closed)
|
|
158
|
+
return;
|
|
159
|
+
closed = true;
|
|
160
|
+
controller.abort();
|
|
161
|
+
clearInterval(heartbeat);
|
|
162
|
+
};
|
|
163
|
+
res.on('close', onClose);
|
|
164
|
+
req.on('close', onClose);
|
|
165
|
+
try {
|
|
166
|
+
const frames = apiProxy.events.mux({ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`), payload: {} }, controller.signal);
|
|
167
|
+
for await (const frame of frames) {
|
|
168
|
+
if (closed)
|
|
169
|
+
break;
|
|
170
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// The stream ended or errored; the EventSource reconnects.
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
controller.abort();
|
|
178
|
+
clearInterval(heartbeat);
|
|
179
|
+
}
|
|
180
|
+
if (!closed)
|
|
181
|
+
res.end();
|
|
182
|
+
};
|
|
183
|
+
return [
|
|
184
|
+
{ kind: 'prefix', path: MOBILE_API_PREFIX, handler: handleMethod },
|
|
185
|
+
{ kind: 'exact', path: MOBILE_API_PATHS.events, handler: handleEvents },
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
/** Read a request body as JSON (bounded). */
|
|
189
|
+
async function readJsonBody(req) {
|
|
190
|
+
const chunks = [];
|
|
191
|
+
let size = 0;
|
|
192
|
+
for await (const chunk of req) {
|
|
193
|
+
const buffer = chunk;
|
|
194
|
+
size += buffer.length;
|
|
195
|
+
if (size > 64 * 1024)
|
|
196
|
+
throw new Error('body too large');
|
|
197
|
+
chunks.push(buffer);
|
|
198
|
+
}
|
|
199
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
200
|
+
}
|
|
201
|
+
/** Dispatch one allowlisted method through the host ApiProxy. */
|
|
202
|
+
async function dispatch(apiProxy, method, payload, rpcId) {
|
|
203
|
+
const request = { rpcId: RpcId(rpcId), payload };
|
|
204
|
+
if (method === 'session.list') {
|
|
205
|
+
const full = await apiProxy.sessions.list(request);
|
|
206
|
+
if (!full.result.ok)
|
|
207
|
+
return full;
|
|
208
|
+
const items = full.result.value.items;
|
|
209
|
+
const cursor = payload?.cursor;
|
|
210
|
+
// Every call pages (the first call with no cursor IS the first page):
|
|
211
|
+
// the phone must never transfer the whole session list at once.
|
|
212
|
+
// One stable page over (updatedAt desc, sessionId asc); pages never skip
|
|
213
|
+
// or repeat a row while the list changes between calls.
|
|
214
|
+
items.sort((a, b) => b.updatedAt - a.updatedAt
|
|
215
|
+
|| (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
216
|
+
const position = cursor === undefined ? undefined : parseSessionListCursor(cursor);
|
|
217
|
+
const from = position === undefined ? 0 : items.findIndex(row => afterCursor(row, position));
|
|
218
|
+
const start = from < 0 ? items.length : from;
|
|
219
|
+
const page = items.slice(start, start + SESSION_PAGE_SIZE);
|
|
220
|
+
const last = page[page.length - 1];
|
|
221
|
+
const nextCursor = last !== undefined && start + page.length < items.length
|
|
222
|
+
? sessionListCursor(last.updatedAt, last.sessionId)
|
|
223
|
+
: undefined;
|
|
224
|
+
return {
|
|
225
|
+
type: 'server-response',
|
|
226
|
+
rpcId,
|
|
227
|
+
result: {
|
|
228
|
+
ok: true,
|
|
229
|
+
value: {
|
|
230
|
+
items: page,
|
|
231
|
+
hasMore: nextCursor !== undefined,
|
|
232
|
+
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// The ApiProxy unary methods resolve to the internal response shape
|
|
238
|
+
// ({ rpcId, result }) without the transport envelope the phone's callUnary
|
|
239
|
+
// requires — wrap every pass-through in the same 'server-response'
|
|
240
|
+
// envelope session.list builds above.
|
|
241
|
+
const wrap = (response) => ({
|
|
242
|
+
type: 'server-response',
|
|
243
|
+
rpcId,
|
|
244
|
+
result: response.result,
|
|
245
|
+
});
|
|
246
|
+
if (method === 'workspace.list')
|
|
247
|
+
return wrap(await apiProxy.workspace.list(request));
|
|
248
|
+
if (method === 'session.create')
|
|
249
|
+
return wrap(await apiProxy.sessions.create(request));
|
|
250
|
+
if (method === 'session.history')
|
|
251
|
+
return wrap(await apiProxy.sessions.history(request));
|
|
252
|
+
if (method === 'session.search')
|
|
253
|
+
return wrap(await apiProxy.sessions.search(request, new AbortController().signal));
|
|
254
|
+
if (method === 'session.prompt')
|
|
255
|
+
return wrap(await apiProxy.sessions.prompt(request));
|
|
256
|
+
if (method === 'session.models')
|
|
257
|
+
return wrap(await apiProxy.sessions.models(request));
|
|
258
|
+
if (method === 'session.selectModel')
|
|
259
|
+
return wrap(await apiProxy.sessions.selectModel(request));
|
|
260
|
+
if (method === 'session.rename')
|
|
261
|
+
return wrap(await apiProxy.sessions.rename(request));
|
|
262
|
+
throw new Error(`unhandled allowlisted method ${method}`);
|
|
263
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mobile-api.js","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mobile surface's page routes: `/m` serves the standalone phone UI
|
|
3
|
+
* (an independent bundle, built to lib/mobile.js by the mobile tsdown
|
|
4
|
+
* entry), `/m/mobile.js` serves the bundle itself. The page talks to the
|
|
5
|
+
* host exclusively through the shared /api transport (paired-device cookie
|
|
6
|
+
* already crosses the api/gate fence), so no host-side data plumbing is
|
|
7
|
+
* needed here — only static serving, loopback+paired-fence via the normal
|
|
8
|
+
* webserver route registration.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
/** The standalone mobile bundle (built artifact, next to this file's own lib output). */
|
|
14
|
+
function mobileBundlePath() {
|
|
15
|
+
return fileURLToPath(new URL('../lib/mobile.js', import.meta.url));
|
|
16
|
+
}
|
|
17
|
+
/** The mobile page shell: minimal, offline-safe, no external assets. */
|
|
18
|
+
function pageHtml(bundleUrl) {
|
|
19
|
+
return [
|
|
20
|
+
'<!doctype html>',
|
|
21
|
+
'<html lang="zh-CN">',
|
|
22
|
+
'<head>',
|
|
23
|
+
'<meta charset="utf-8">',
|
|
24
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
|
|
25
|
+
'<meta name="theme-color" content="#f3f5f9">',
|
|
26
|
+
'<meta name="referrer" content="no-referrer">',
|
|
27
|
+
'<title>移动端远程控制</title>',
|
|
28
|
+
'</head>',
|
|
29
|
+
'<body>',
|
|
30
|
+
'<div id="root"></div>',
|
|
31
|
+
`<script type="module" src="${bundleUrl}"></script>`,
|
|
32
|
+
'</body>',
|
|
33
|
+
'</html>',
|
|
34
|
+
].join('');
|
|
35
|
+
}
|
|
36
|
+
/** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
|
|
37
|
+
function writeStatic(res, status, type, body) {
|
|
38
|
+
res.writeHead(status, {
|
|
39
|
+
'content-type': `${type}; charset=utf-8`,
|
|
40
|
+
'cache-control': 'no-cache',
|
|
41
|
+
'referrer-policy': 'no-referrer',
|
|
42
|
+
});
|
|
43
|
+
res.end(body);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build the mobile page routes.
|
|
47
|
+
* @returns the two exact routes to register on webServer.
|
|
48
|
+
*/
|
|
49
|
+
export function makeMobileRoutes() {
|
|
50
|
+
const handlePage = (_req, res) => {
|
|
51
|
+
writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'));
|
|
52
|
+
};
|
|
53
|
+
const handleBundle = async (_req, res) => {
|
|
54
|
+
const path = mobileBundlePath();
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const body = await readFile(path, 'utf8');
|
|
61
|
+
writeStatic(res, 200, 'text/javascript', body);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return [
|
|
68
|
+
{ kind: 'exact', path: '/m', handler: handlePage },
|
|
69
|
+
{ kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
|
|
70
|
+
];
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mobile-routes.js","sourceRoot":"","sources":["../../src/mobile-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC3C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAIxC,yFAAyF;AACzF,SAAS,gBAAgB;IACvB,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,kBAAkB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AACpE,CAAC;AAED,wEAAwE;AACxE,SAAS,QAAQ,CAAC,SAAiB;IACjC,OAAO;QACL,iBAAiB;QACjB,qBAAqB;QACrB,QAAQ;QACR,wBAAwB;QACxB,6HAA6H;QAC7H,6CAA6C;QAC7C,wBAAwB;QACxB,SAAS;QACT,QAAQ;QACR,uBAAuB;QACvB,8BAA8B,SAAS,aAAa;QACpD,SAAS;QACT,SAAS;KACV,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACZ,CAAC;AAED,6FAA6F;AAC7F,SAAS,WAAW,CAAC,GAAmB,EAAE,MAAc,EAAE,IAAY,EAAE,IAAY;IAClF,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE;QACpB,cAAc,EAAE,GAAG,IAAI,iBAAiB;QACxC,eAAe,EAAE,UAAU;KAC5B,CAAC,CAAA;IACF,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;AACf,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,UAAU,GAAG,CAAC,IAAqB,EAAE,GAAmB,EAAQ,EAAE;QACtE,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,WAAW,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAA;IAC9D,CAAC,CAAA;IACD,MAAM,YAAY,GAAG,KAAK,EAAE,IAAqB,EAAE,GAAmB,EAAiB,EAAE;QACvF,MAAM,IAAI,GAAG,gBAAgB,EAAE,CAAA;QAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YACtB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,YAAY,EAAE,iFAAiF,CAAC,CAAA;YACtH,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;YACzC,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,iBAAiB,EAAE,IAAI,CAAC,CAAA;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,YAAY,EAAE,kCAAkC,CAAC,CAAA;QACzE,CAAC;IACH,CAAC,CAAA;IACD,OAAO;QACL,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE;QAClD,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,YAAY,EAAE;KAC/D,CAAA;AACH,CAAC"}
|