agent-loft 0.2.34 → 0.2.36
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 +10 -3
- package/package.json +7 -1
- package/src/channels/web/api.ts +27 -1
- package/src/channels/web/desktop.ts +183 -0
- package/src/channels/web/server.ts +64 -2
- package/src/channels/web/terminal.ts +154 -0
- package/src/core/config.ts +27 -0
- package/src/core/desktop.ts +281 -0
- package/src/core/terminals.ts +308 -0
- package/src/core/users.ts +16 -1
- package/web/css/remote.css +415 -0
- package/web/css/terminal.css +243 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +15 -0
- package/web/lib/ws.js +6 -1
- package/web/settings/general.js +96 -0
- package/web/settings/users.js +23 -1
- package/web/state.js +12 -1
- package/web/views/desktop-touch.js +344 -0
- package/web/views/desktop.js +539 -0
- package/web/views/layout.js +57 -1
- package/web/views/messages.js +3 -1
- package/web/views/remote.js +136 -0
- package/web/views/sidebar.js +6 -1
- package/web/views/tasks.js +2 -0
- package/web/views/terminal-touch.js +207 -0
- package/web/views/terminal.js +540 -0
- package/web/views/topbar.js +4 -3
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import RFB from "/vendor/novnc/core/rfb.js";
|
|
2
|
+
import { fill, h, icon, isTouch, toast } from "../lib/dom.js";
|
|
3
|
+
import { api } from "../lib/api.js";
|
|
4
|
+
import { copyText } from "../lib/markdown.js";
|
|
5
|
+
import { modal, showMenu } from "../lib/overlay.js";
|
|
6
|
+
import { S } from "../state.js";
|
|
7
|
+
import { renderSidebar } from "./sidebar.js";
|
|
8
|
+
import { createTouch } from "./desktop-touch.js";
|
|
9
|
+
|
|
10
|
+
// ---------- 远程桌面(noVNC) ----------
|
|
11
|
+
// 画面、鼠标、键盘、剪贴板都走 VNC 协议:页面 ↔ /ws/desktop ↔ 服务端的 VNC(Linux 上是 agent-loft 按需起的 x11vnc)。
|
|
12
|
+
// 进来时不自动连,点了「连接」才连(Linux 上会起 x11vnc)。
|
|
13
|
+
// 离开远程控制页时连接不断,画面在后台照常更新;断线了自己重连,点了「断开」才不连。
|
|
14
|
+
// 手机上:触摸事件由 desktop-touch.js 接管(触控板 / 触摸两种模式、双指本地放大),下面多一排按键。
|
|
15
|
+
|
|
16
|
+
const D = {
|
|
17
|
+
rfb: null,
|
|
18
|
+
/** idle / connecting / connected / error */
|
|
19
|
+
state: "idle",
|
|
20
|
+
message: "",
|
|
21
|
+
/** 用户想不想连着:点了「断开」就是 false,不再自动重连 */
|
|
22
|
+
want: false,
|
|
23
|
+
backoff: 1000,
|
|
24
|
+
retry: 0,
|
|
25
|
+
/** 缩放到窗口里(否则按原始大小、可以滚动)。手机上一直缩放到窗口,靠双指放大 */
|
|
26
|
+
fit: isTouch || localStorage.getItem("aim.deskFit") !== "0",
|
|
27
|
+
/** 按一下就粘住的修饰键(keysym),下一个按键 / 点击带上它们 */
|
|
28
|
+
mods: new Set(),
|
|
29
|
+
viewOnly: false,
|
|
30
|
+
name: "",
|
|
31
|
+
/** 远程那边最近复制的文字 */
|
|
32
|
+
remoteClip: "",
|
|
33
|
+
pane: null,
|
|
34
|
+
tools: null,
|
|
35
|
+
screen: null,
|
|
36
|
+
status: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function setLive(on) {
|
|
40
|
+
if (S.remoteLive.desktop === on) return;
|
|
41
|
+
S.remoteLive.desktop = on;
|
|
42
|
+
renderSidebar();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 远程控制页切到「桌面」:没连着时中间一个「连接」按钮,点了才连 */
|
|
46
|
+
export function showDesktop(pane, tools) {
|
|
47
|
+
if (!D.pane) {
|
|
48
|
+
D.pane = pane;
|
|
49
|
+
D.status = h("div", { class: "desk-status" });
|
|
50
|
+
D.screen = h("div", { class: "desk-screen" });
|
|
51
|
+
D.zoomBadge = h("button", { class: "desk-zoom", hidden: true, onclick: () => D.touch?.resetZoom() });
|
|
52
|
+
D.hint = h("div", { class: "desk-hint", hidden: true });
|
|
53
|
+
D.idle = h(
|
|
54
|
+
"div",
|
|
55
|
+
{ class: "desk-idle", hidden: true },
|
|
56
|
+
icon("monitor"),
|
|
57
|
+
h("div", { class: "muted" }, "远程桌面未连接"),
|
|
58
|
+
h("button", { class: "btn primary", onclick: reconnect }, icon("plug"), "连接桌面"),
|
|
59
|
+
);
|
|
60
|
+
D.exitFull = h("button", { class: "desk-exitfull", hidden: true, title: "退出全屏", "aria-label": "退出全屏", onclick: () => setFull(false) }, icon("shrink"));
|
|
61
|
+
D.viewport = h("div", { class: "desk-viewport" }, D.screen, D.zoomBadge, D.hint, D.idle, D.exitFull);
|
|
62
|
+
D.keyInput = isTouch ? mobileKeyInput() : null;
|
|
63
|
+
D.keyRow = isTouch ? keyRow() : null;
|
|
64
|
+
fill(pane, D.status, D.viewport, D.keyRow, D.keyInput);
|
|
65
|
+
if (isTouch) {
|
|
66
|
+
D.touch = createTouch({
|
|
67
|
+
viewport: D.viewport,
|
|
68
|
+
screen: D.screen,
|
|
69
|
+
send: sendPointer,
|
|
70
|
+
active: () => D.state === "connected",
|
|
71
|
+
mods: { down: holdMods, up: releaseMods },
|
|
72
|
+
onZoom: (z) => {
|
|
73
|
+
D.zoomBadge.hidden = z <= 1.01;
|
|
74
|
+
D.zoomBadge.textContent = `${z.toFixed(1)}× · 还原`;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
D.tools = tools;
|
|
80
|
+
renderTools();
|
|
81
|
+
renderStatus();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function connect() {
|
|
85
|
+
clearTimeout(D.retry);
|
|
86
|
+
if (!D.want || D.rfb) return;
|
|
87
|
+
D.state = "connecting";
|
|
88
|
+
D.message = "";
|
|
89
|
+
render();
|
|
90
|
+
let password;
|
|
91
|
+
try {
|
|
92
|
+
({ password } = await api("/api/desktop/session", { method: "POST" }));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return fail(err.message, err.status !== 403);
|
|
95
|
+
}
|
|
96
|
+
// 等密码的这会儿里点了断开,或者又发起了一次连接(切回前台、点了重连):这次作罢
|
|
97
|
+
if (!D.want || D.rfb) return;
|
|
98
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
99
|
+
const rfb = new RFB(D.screen, `${proto}://${location.host}/ws/desktop`, { credentials: { password }, shared: true, wsProtocols: [] });
|
|
100
|
+
D.rfb = rfb;
|
|
101
|
+
rfb.scaleViewport = D.fit;
|
|
102
|
+
// 不裁剪:原始大小时 noVNC 的画面容器自己出滚动条
|
|
103
|
+
rfb.clipViewport = false;
|
|
104
|
+
rfb.resizeSession = false;
|
|
105
|
+
rfb.viewOnly = D.viewOnly;
|
|
106
|
+
rfb.showDotCursor = true;
|
|
107
|
+
rfb.qualityLevel = 6;
|
|
108
|
+
rfb.compressionLevel = 2;
|
|
109
|
+
rfb.addEventListener("connect", () => {
|
|
110
|
+
D.state = "connected";
|
|
111
|
+
D.backoff = 1000;
|
|
112
|
+
setLive(true);
|
|
113
|
+
render();
|
|
114
|
+
// 第一帧到了画布才有大小
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
D.touch?.connected();
|
|
117
|
+
rotateHint();
|
|
118
|
+
}, 300);
|
|
119
|
+
});
|
|
120
|
+
rfb.addEventListener("disconnect", (e) => {
|
|
121
|
+
if (D.rfb !== rfb) return;
|
|
122
|
+
D.rfb = null;
|
|
123
|
+
setLive(false);
|
|
124
|
+
if (!D.want) {
|
|
125
|
+
// 认证失败之后 noVNC 还会报一次断开:别把「认证失败」盖成「已断开」
|
|
126
|
+
if (D.state !== "error") D.state = "idle";
|
|
127
|
+
return render();
|
|
128
|
+
}
|
|
129
|
+
// 意外断开(网络、服务重启、x11vnc 被停):问一下还能不能用,能用就过一会儿重连
|
|
130
|
+
void api("/api/remote").then(
|
|
131
|
+
(r) => {
|
|
132
|
+
if (!r.desktop?.allowed || !r.desktop.available) {
|
|
133
|
+
// 权限或设置变了:侧栏、远程控制页跟着更新
|
|
134
|
+
document.dispatchEvent(new Event("aim:remote"));
|
|
135
|
+
return fail("远程桌面现在用不了了", false);
|
|
136
|
+
}
|
|
137
|
+
fail(e.detail?.clean ? "桌面连接已断开,正在重连…" : "连接断了,正在重连…", true);
|
|
138
|
+
},
|
|
139
|
+
() => fail("连不上服务器,正在重连…", true),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
rfb.addEventListener("credentialsrequired", () => rfb.sendCredentials({ password }));
|
|
143
|
+
rfb.addEventListener("securityfailure", (e) => {
|
|
144
|
+
D.want = false;
|
|
145
|
+
const reason = String(e.detail?.reason ?? "").replace(/[\s!!.。]+$/, "");
|
|
146
|
+
fail(`VNC 认证失败${reason ? `(${reason})` : ""}。用的是已有的 VNC 服务的话,检查设置里的密码`, false);
|
|
147
|
+
});
|
|
148
|
+
rfb.addEventListener("clipboard", (e) => (D.remoteClip = e.detail.text ?? ""));
|
|
149
|
+
rfb.addEventListener("desktopname", (e) => {
|
|
150
|
+
D.name = e.detail.name ?? "";
|
|
151
|
+
renderStatus();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 出错了:retry 为真时按退避重连 */
|
|
156
|
+
function fail(message, retry) {
|
|
157
|
+
D.state = "error";
|
|
158
|
+
D.message = message;
|
|
159
|
+
setLive(false);
|
|
160
|
+
if (retry && D.want) {
|
|
161
|
+
D.retry = setTimeout(() => void connect(), D.backoff);
|
|
162
|
+
D.backoff = Math.min(D.backoff * 2, 30_000);
|
|
163
|
+
}
|
|
164
|
+
render();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function disconnect() {
|
|
168
|
+
D.want = false;
|
|
169
|
+
clearTimeout(D.retry);
|
|
170
|
+
const rfb = D.rfb;
|
|
171
|
+
D.rfb = null;
|
|
172
|
+
D.state = "idle";
|
|
173
|
+
setLive(false);
|
|
174
|
+
rfb?.disconnect();
|
|
175
|
+
render();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function reconnect() {
|
|
179
|
+
D.want = true;
|
|
180
|
+
D.backoff = 1000;
|
|
181
|
+
if (D.rfb) {
|
|
182
|
+
const rfb = D.rfb;
|
|
183
|
+
D.rfb = null;
|
|
184
|
+
rfb.disconnect();
|
|
185
|
+
}
|
|
186
|
+
void connect();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 手机切回前台、网络恢复:断着的马上重连
|
|
190
|
+
const wake = () => {
|
|
191
|
+
if (D.want && !D.rfb && document.visibilityState === "visible") reconnect();
|
|
192
|
+
};
|
|
193
|
+
document.addEventListener("visibilitychange", wake);
|
|
194
|
+
window.addEventListener("online", wake);
|
|
195
|
+
|
|
196
|
+
// ---------- 工具栏和状态 ----------
|
|
197
|
+
|
|
198
|
+
function render() {
|
|
199
|
+
renderTools();
|
|
200
|
+
renderStatus();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderStatus() {
|
|
204
|
+
if (!D.status) return;
|
|
205
|
+
const text =
|
|
206
|
+
D.state === "connected"
|
|
207
|
+
? null
|
|
208
|
+
: D.state === "connecting"
|
|
209
|
+
? "正在连接桌面…"
|
|
210
|
+
: D.state === "error"
|
|
211
|
+
? D.message
|
|
212
|
+
: null;
|
|
213
|
+
D.status.hidden = !text;
|
|
214
|
+
fill(
|
|
215
|
+
D.status,
|
|
216
|
+
text ? h("span", { class: D.state === "error" ? "error-text" : "muted" }, text) : null,
|
|
217
|
+
D.state === "error" && !D.want ? h("button", { class: "btn small", onclick: reconnect }, "连接") : null,
|
|
218
|
+
);
|
|
219
|
+
// 没连着(还没点过连接、点了断开):中间的「连接桌面」
|
|
220
|
+
D.idle.hidden = D.state !== "idle";
|
|
221
|
+
D.screen.classList.toggle("dim", D.state !== "connected");
|
|
222
|
+
D.touch?.refresh();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 手机竖着看横的桌面:提示一次横过来 */
|
|
226
|
+
function rotateHint() {
|
|
227
|
+
const c = D.screen.querySelector("canvas");
|
|
228
|
+
if (!isTouch || !c || innerHeight <= innerWidth || c.width <= c.height || sessionStorage.getItem("aim.deskRotate")) return;
|
|
229
|
+
sessionStorage.setItem("aim.deskRotate", "1");
|
|
230
|
+
D.hint.textContent = "横过来看更大 · 双指张开可以放大";
|
|
231
|
+
D.hint.hidden = false;
|
|
232
|
+
setTimeout(() => (D.hint.hidden = true), 5000);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 手势发来的鼠标:按帧缓冲坐标直接发(noVNC 的公开接口没有这个) */
|
|
236
|
+
function sendPointer(x, y, mask) {
|
|
237
|
+
if (D.state !== "connected" || D.viewOnly || !D.rfb?._sock) return;
|
|
238
|
+
RFB.messages.pointerEvent(D.rfb._sock, x, y, mask);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function tool(label, iconName, onclick, opts = {}) {
|
|
242
|
+
return h(
|
|
243
|
+
"button",
|
|
244
|
+
{ class: `icon-btn desk-tool${opts.on ? " on" : ""}`, title: label, "aria-label": label, disabled: opts.disabled, onclick },
|
|
245
|
+
icon(iconName),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderTools() {
|
|
250
|
+
if (!D.tools || D.tools.closest(".remote-pane")?.querySelector(".remote-body:not([hidden])") !== D.pane) return;
|
|
251
|
+
const on = D.state === "connected";
|
|
252
|
+
const trackpad = D.touch?.mode === "trackpad";
|
|
253
|
+
fill(
|
|
254
|
+
D.tools,
|
|
255
|
+
D.touch ? tool(trackpad ? "触控板模式(点一下换成触摸模式)" : "触摸模式(点一下换成触控板模式)", trackpad ? "pointer" : "hand", toggleTouchMode) : null,
|
|
256
|
+
D.touch ? tool("手势说明", "help", openGestureHelp) : null,
|
|
257
|
+
tool("剪贴板", "clipboard", openClipboard, { disabled: !on }),
|
|
258
|
+
tool("发送按键", "command", (e) => openKeysMenu(e.currentTarget), { disabled: !on || D.viewOnly }),
|
|
259
|
+
tool(D.viewOnly ? "只看(点一下恢复控制)" : "只看不控制", "eye", toggleViewOnly, { on: D.viewOnly }),
|
|
260
|
+
// 手机上靠双指放大,不切原始大小
|
|
261
|
+
isTouch ? null : tool(D.fit ? "原始大小" : "缩放到窗口", D.fit ? "maximize" : "restore", toggleFit),
|
|
262
|
+
tool("全屏", "expand", () => setFull(true)),
|
|
263
|
+
on || D.state === "connecting" ? tool("断开连接", "unplug", disconnect) : tool("连接", "plug", reconnect),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 全屏:浏览器支持就用真全屏;iPhone 的 Safari 不支持元素全屏,改成铺满整个页面(盖住顶栏、侧栏)
|
|
268
|
+
const fsEnabled = () => document.fullscreenEnabled || document.webkitFullscreenEnabled;
|
|
269
|
+
const fsElement = () => document.fullscreenElement ?? document.webkitFullscreenElement;
|
|
270
|
+
|
|
271
|
+
function setFull(on) {
|
|
272
|
+
if (on) {
|
|
273
|
+
if (fsEnabled()) {
|
|
274
|
+
const req = D.pane.requestFullscreen ?? D.pane.webkitRequestFullscreen;
|
|
275
|
+
// 被浏览器拒了(比如不是用户点的):退回铺满页面
|
|
276
|
+
Promise.resolve(req?.call(D.pane)).catch(() => D.pane.classList.add("desk-full"));
|
|
277
|
+
} else D.pane.classList.add("desk-full");
|
|
278
|
+
} else {
|
|
279
|
+
D.pane.classList.remove("desk-full");
|
|
280
|
+
if (fsElement() === D.pane) (document.exitFullscreen ?? document.webkitExitFullscreen)?.call(document);
|
|
281
|
+
}
|
|
282
|
+
syncFull();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 全屏时画面上一个「退出全屏」(手机上没有 Esc;真全屏在电脑上按 Esc 就行,不显示) */
|
|
286
|
+
function syncFull() {
|
|
287
|
+
if (!D.pane) return;
|
|
288
|
+
const pseudo = D.pane.classList.contains("desk-full");
|
|
289
|
+
D.exitFull.hidden = !(pseudo || (isTouch && fsElement() === D.pane));
|
|
290
|
+
}
|
|
291
|
+
document.addEventListener("fullscreenchange", syncFull);
|
|
292
|
+
document.addEventListener("webkitfullscreenchange", syncFull);
|
|
293
|
+
|
|
294
|
+
function toggleViewOnly() {
|
|
295
|
+
D.viewOnly = !D.viewOnly;
|
|
296
|
+
if (D.rfb) D.rfb.viewOnly = D.viewOnly;
|
|
297
|
+
toast(D.viewOnly ? "只看:鼠标键盘不会发给桌面" : "已恢复控制");
|
|
298
|
+
renderTools();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function toggleFit() {
|
|
302
|
+
D.fit = !D.fit;
|
|
303
|
+
localStorage.setItem("aim.deskFit", D.fit ? "1" : "0");
|
|
304
|
+
if (D.rfb) D.rfb.scaleViewport = D.fit;
|
|
305
|
+
renderTools();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function toggleTouchMode() {
|
|
309
|
+
const next = D.touch.mode === "trackpad" ? "touch" : "trackpad";
|
|
310
|
+
D.touch.setMode(next);
|
|
311
|
+
toast(next === "trackpad" ? "触控板模式:手指滑动移动指针,轻点单击" : "触摸模式:点哪里就点哪里", 3000);
|
|
312
|
+
renderTools();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 两种模式各自的手势,当前的排在前面 */
|
|
316
|
+
function openGestureHelp() {
|
|
317
|
+
const rows = (list) => h("div", { class: "gesture-list" }, list.map(([g, what]) => h("div", { class: "gesture-row" }, h("b", {}, g), h("span", {}, what))));
|
|
318
|
+
const trackpad = h(
|
|
319
|
+
"div",
|
|
320
|
+
{ class: "gesture-sec" },
|
|
321
|
+
h("h4", {}, "触控板模式", D.touch.mode === "trackpad" ? "(现在)" : ""),
|
|
322
|
+
rows([
|
|
323
|
+
["单指滑动", "移动指针(划得快走得远)"],
|
|
324
|
+
["轻点", "在指针处单击;连点两下是双击"],
|
|
325
|
+
["双指轻点 / 长按", "右键"],
|
|
326
|
+
["长按后拖动", "按住左键拖:选文字、拖窗口"],
|
|
327
|
+
["双指上下、左右滑", "滚动"],
|
|
328
|
+
["双指捏合", "放大缩小画面;放大后指针推到边上,画面跟着走"],
|
|
329
|
+
]),
|
|
330
|
+
);
|
|
331
|
+
const touch = h(
|
|
332
|
+
"div",
|
|
333
|
+
{ class: "gesture-sec" },
|
|
334
|
+
h("h4", {}, "触摸模式", D.touch.mode === "touch" ? "(现在)" : ""),
|
|
335
|
+
rows([
|
|
336
|
+
["轻点", "在手指的位置单击"],
|
|
337
|
+
["长按", "右键"],
|
|
338
|
+
["长按后拖动", "按住左键拖"],
|
|
339
|
+
["单指拖动", "没放大时是按住左键拖;放大后是挪画面"],
|
|
340
|
+
["双指上下、左右滑", "滚动"],
|
|
341
|
+
["双指捏合", "放大缩小画面"],
|
|
342
|
+
]),
|
|
343
|
+
);
|
|
344
|
+
modal({
|
|
345
|
+
title: "手势说明",
|
|
346
|
+
body: h(
|
|
347
|
+
"div",
|
|
348
|
+
{ class: "modal-body" },
|
|
349
|
+
D.touch.mode === "trackpad" ? [trackpad, touch] : [touch, trackpad],
|
|
350
|
+
h("div", { class: "muted" }, "下面那排键:Ctrl、Alt、Win 点一下会亮着,下一个按键或点击会带上它,再点一下取消。最左边的键盘按钮弹出键盘打字。"),
|
|
351
|
+
),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// X11 的 keysym
|
|
356
|
+
const K = {
|
|
357
|
+
ctrl: 0xffe3,
|
|
358
|
+
alt: 0xffe9,
|
|
359
|
+
super: 0xffeb,
|
|
360
|
+
tab: 0xff09,
|
|
361
|
+
esc: 0xff1b,
|
|
362
|
+
print: 0xff61,
|
|
363
|
+
del: 0xffff,
|
|
364
|
+
f4: 0xffc1,
|
|
365
|
+
enter: 0xff0d,
|
|
366
|
+
back: 0xff08,
|
|
367
|
+
left: 0xff51,
|
|
368
|
+
up: 0xff52,
|
|
369
|
+
right: 0xff53,
|
|
370
|
+
down: 0xff54,
|
|
371
|
+
home: 0xff50,
|
|
372
|
+
end: 0xff57,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** 按下 mods、按一下 key、再松开 mods */
|
|
376
|
+
function combo(mods, key) {
|
|
377
|
+
const rfb = D.rfb;
|
|
378
|
+
if (!rfb || D.viewOnly) return;
|
|
379
|
+
for (const m of mods) rfb.sendKey(m, null, true);
|
|
380
|
+
rfb.sendKey(key, null);
|
|
381
|
+
for (const m of [...mods].reverse()) rfb.sendKey(m, null, false);
|
|
382
|
+
// 手机上别去抢焦点:软键盘会收起来
|
|
383
|
+
if (!isTouch) rfb.focus();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 按一个键,带上粘住的修饰键,用完就松开 */
|
|
387
|
+
function sendKey(keysym) {
|
|
388
|
+
combo([...D.mods], keysym);
|
|
389
|
+
if (D.mods.size) setMods(new Set());
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function setMods(next) {
|
|
393
|
+
D.mods = next;
|
|
394
|
+
for (const btn of D.keyRow?.querySelectorAll("[data-mod]") ?? []) btn.classList.toggle("on", D.mods.has(Number(btn.dataset.mod)));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 手势点击、拖动前后:按下 / 松开粘住的修饰键(Ctrl+点击之类) */
|
|
398
|
+
let heldMods = [];
|
|
399
|
+
function holdMods() {
|
|
400
|
+
heldMods = D.viewOnly ? [] : [...D.mods];
|
|
401
|
+
for (const m of heldMods) D.rfb?.sendKey(m, null, true);
|
|
402
|
+
}
|
|
403
|
+
function releaseMods() {
|
|
404
|
+
for (const m of heldMods.reverse()) D.rfb?.sendKey(m, null, false);
|
|
405
|
+
if (heldMods.length) setMods(new Set());
|
|
406
|
+
heldMods = [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 手机:画面下面一排键(最左边的键盘按钮弹出软键盘) */
|
|
410
|
+
function keyRow() {
|
|
411
|
+
const keys = [
|
|
412
|
+
// 再点一下收起键盘
|
|
413
|
+
[icon("keyboard"), () => (document.activeElement === D.keyInput ? D.keyInput.blur() : D.keyInput.focus()), "打字(再点一下收起)"],
|
|
414
|
+
["Esc", K.esc],
|
|
415
|
+
["Tab", K.tab],
|
|
416
|
+
["Ctrl", K.ctrl, null, true],
|
|
417
|
+
["Alt", K.alt, null, true],
|
|
418
|
+
["Win", K.super, null, true],
|
|
419
|
+
["←", K.left],
|
|
420
|
+
["↑", K.up],
|
|
421
|
+
["↓", K.down],
|
|
422
|
+
["→", K.right],
|
|
423
|
+
["Home", K.home],
|
|
424
|
+
["End", K.end],
|
|
425
|
+
["Del", K.del],
|
|
426
|
+
];
|
|
427
|
+
return h(
|
|
428
|
+
"div",
|
|
429
|
+
{ class: "term-keys desk-keys" },
|
|
430
|
+
keys.map(([label, key, title, mod]) =>
|
|
431
|
+
h(
|
|
432
|
+
"button",
|
|
433
|
+
{
|
|
434
|
+
class: `term-key${typeof key === "function" ? " icon" : ""}`,
|
|
435
|
+
title: title ?? undefined,
|
|
436
|
+
"data-mod": mod ? String(key) : undefined,
|
|
437
|
+
// 按下时别抢走焦点,不然软键盘会收起来
|
|
438
|
+
onpointerdown: (e) => e.preventDefault(),
|
|
439
|
+
onclick: () => {
|
|
440
|
+
if (D.state !== "connected") return;
|
|
441
|
+
if (typeof key === "function") return key();
|
|
442
|
+
if (!mod) return sendKey(key);
|
|
443
|
+
const next = new Set(D.mods);
|
|
444
|
+
if (next.has(key)) next.delete(key);
|
|
445
|
+
else next.add(key);
|
|
446
|
+
setMods(next);
|
|
447
|
+
// 只按了修饰键就松手(比如单按 Win 打开开始菜单):再点一下发出去
|
|
448
|
+
},
|
|
449
|
+
ondblclick: mod ? () => (setMods(new Set()), combo([], key)) : undefined,
|
|
450
|
+
},
|
|
451
|
+
label,
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function openKeysMenu(anchor) {
|
|
458
|
+
const r = anchor.getBoundingClientRect();
|
|
459
|
+
showMenu({ x: r.right - 200, y: r.bottom + 4 }, [
|
|
460
|
+
{ label: "Ctrl+Alt+Del", onclick: () => D.rfb?.sendCtrlAltDel() },
|
|
461
|
+
{ label: "Win / Super", onclick: () => combo([], K.super) },
|
|
462
|
+
{ label: "Alt+Tab", onclick: () => combo([K.alt], K.tab) },
|
|
463
|
+
{ label: "Alt+F4", onclick: () => combo([K.alt], K.f4) },
|
|
464
|
+
{ label: "Esc", onclick: () => combo([], K.esc) },
|
|
465
|
+
{ label: "Print Screen", onclick: () => combo([], K.print) },
|
|
466
|
+
]);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 剪贴板:看远程那边复制了什么;把文字发到远程的剪贴板 */
|
|
470
|
+
function openClipboard() {
|
|
471
|
+
const text = h("textarea", { class: "desk-clip", rows: 6, placeholder: "在这里粘贴,发到远程桌面的剪贴板" });
|
|
472
|
+
text.value = D.remoteClip;
|
|
473
|
+
const body = h(
|
|
474
|
+
"div",
|
|
475
|
+
{ class: "modal-body" },
|
|
476
|
+
h("div", { class: "muted" }, D.remoteClip ? "下面是远程桌面最近复制的文字。改好后点「发到远程」,再在远程那边粘贴。" : "粘贴文字后点「发到远程」,再在远程那边粘贴。"),
|
|
477
|
+
text,
|
|
478
|
+
);
|
|
479
|
+
const copy = h("button", { class: "btn", onclick: (e) => void copyText(text.value, e.currentTarget) }, "复制到本机");
|
|
480
|
+
const send = h(
|
|
481
|
+
"button",
|
|
482
|
+
{
|
|
483
|
+
class: "btn primary",
|
|
484
|
+
onclick: () => {
|
|
485
|
+
D.rfb?.clipboardPasteFrom(text.value);
|
|
486
|
+
close();
|
|
487
|
+
toast("已发到远程剪贴板");
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
"发到远程",
|
|
491
|
+
);
|
|
492
|
+
const close = modal({ title: "剪贴板", body, foot: h("div", { class: "modal-foot" }, copy, send) });
|
|
493
|
+
text.focus();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 手机:软键盘打的字逐个发过去(noVNC 自己只认实体键盘)。
|
|
498
|
+
* 用 beforeinput 而不是 keydown:安卓的软键盘大多不报具体按了哪个键(keyCode 229),空输入框里按退格连事件都没有
|
|
499
|
+
*/
|
|
500
|
+
function mobileKeyInput() {
|
|
501
|
+
const input = h("textarea", { class: "desk-keyinput", autocapitalize: "off", autocomplete: "off", autocorrect: "off", spellcheck: "false", "aria-label": "发给桌面的键盘输入" });
|
|
502
|
+
// 安卓的输入法在空输入框里按退格常常什么事件都没有:里面一直留一个占位的空格,退格删它时就有 beforeinput 了
|
|
503
|
+
// (删除被拦下,空格一直在)
|
|
504
|
+
const SENTINEL = " ";
|
|
505
|
+
const reset = () => {
|
|
506
|
+
input.value = SENTINEL;
|
|
507
|
+
input.setSelectionRange(SENTINEL.length, SENTINEL.length);
|
|
508
|
+
};
|
|
509
|
+
input.addEventListener("focus", reset);
|
|
510
|
+
const typeText = (text) => {
|
|
511
|
+
for (const ch of text) {
|
|
512
|
+
const cp = ch.codePointAt(0);
|
|
513
|
+
if (cp === 10) sendKey(K.enter);
|
|
514
|
+
else sendKey(cp < 0x100 ? cp : 0x01000000 | cp);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
input.addEventListener("beforeinput", (e) => {
|
|
518
|
+
// 中文输入法组字中:等选完字(compositionend)再发
|
|
519
|
+
if (e.isComposing || e.inputType === "insertCompositionText") return;
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
if (e.inputType === "insertText" || e.inputType === "insertReplacementText") typeText(e.data ?? "");
|
|
522
|
+
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") sendKey(K.enter);
|
|
523
|
+
else if (e.inputType === "deleteContentBackward") sendKey(K.back);
|
|
524
|
+
else if (e.inputType === "deleteContentForward") sendKey(K.del);
|
|
525
|
+
else if (e.inputType === "insertFromPaste") typeText(e.dataTransfer?.getData("text/plain") ?? e.data ?? "");
|
|
526
|
+
});
|
|
527
|
+
input.addEventListener("compositionend", (e) => {
|
|
528
|
+
typeText(e.data ?? "");
|
|
529
|
+
reset();
|
|
530
|
+
});
|
|
531
|
+
// 实体键盘(iPad 外接键盘)的这几个键不走 beforeinput
|
|
532
|
+
input.addEventListener("keydown", (e) => {
|
|
533
|
+
const map = { Escape: K.esc, Tab: K.tab, ArrowLeft: K.left, ArrowUp: K.up, ArrowRight: K.right, ArrowDown: K.down };
|
|
534
|
+
if (!(e.key in map)) return;
|
|
535
|
+
e.preventDefault();
|
|
536
|
+
sendKey(map[e.key]);
|
|
537
|
+
});
|
|
538
|
+
return input;
|
|
539
|
+
}
|
package/web/views/layout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { $app, fill, h, icon, toast } from "../lib/dom.js";
|
|
2
2
|
import { api } from "../lib/api.js";
|
|
3
|
-
import { desktop, desktopMenu, desktopQuota, desktopSettings, S, ui } from "../state.js";
|
|
3
|
+
import { desktop, desktopMenu, desktopQuota, desktopSettings, remoteTabs, S, ui } from "../state.js";
|
|
4
4
|
import { clearAppBadge, WS } from "../lib/ws.js";
|
|
5
5
|
import { showAuth } from "./auth.js";
|
|
6
6
|
import {
|
|
@@ -51,6 +51,47 @@ export function openSettings(tab) {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 远程控制(桌面 / 终端):第一次用到时才加载(连 noVNC、xterm.js 一起)。
|
|
56
|
+
* 加载过的模块记着:权限变了要告诉它们
|
|
57
|
+
*/
|
|
58
|
+
const loaded = {};
|
|
59
|
+
function lazy(name, load) {
|
|
60
|
+
loaded[name] ??= load().catch((err) => {
|
|
61
|
+
delete loaded[name];
|
|
62
|
+
toast(`远程控制加载失败,刷新页面再试:${err.message}`);
|
|
63
|
+
throw err;
|
|
64
|
+
});
|
|
65
|
+
return loaded[name];
|
|
66
|
+
}
|
|
67
|
+
/** 侧栏的「远程控制」;tab:desktop / terminal,不给就是上次看的 */
|
|
68
|
+
export function openRemoteView(tab) {
|
|
69
|
+
if (remoteTabs().length) void lazy("remote", () => import("./remote.js")).then((m) => m.openRemote(tab));
|
|
70
|
+
}
|
|
71
|
+
/** Ctrl+`:对话下面的终端面板开 / 关 */
|
|
72
|
+
function toggleTerminalDock() {
|
|
73
|
+
if (S.remote?.terminal?.allowed) void lazy("terminal", () => import("./terminal.js")).then((m) => m.toggleDock());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 终端开了 / 关了、权限或设置变了:更新入口,已经加载了的让它们对一下
|
|
77
|
+
document.addEventListener("aim:remote", async () => {
|
|
78
|
+
try {
|
|
79
|
+
S.remote = await api("/api/remote");
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
renderSidebar();
|
|
84
|
+
void loaded.terminal?.then((m) => m.syncTerminals());
|
|
85
|
+
void loaded.remote?.then((m) => m.syncRemote());
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
window.addEventListener("keydown", (e) => {
|
|
89
|
+
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.code === "Backquote" && S.remote?.terminal?.allowed) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
toggleTerminalDock();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
54
95
|
/** 定时任务页:第一次打开时才加载 */
|
|
55
96
|
export function openTasksView() {
|
|
56
97
|
import("./tasks.js").then(
|
|
@@ -72,6 +113,7 @@ export async function boot() {
|
|
|
72
113
|
S.workspaceRoot = data.workspaceRoot;
|
|
73
114
|
S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
|
|
74
115
|
S.quotaEnabled = data.quotaEnabled !== false;
|
|
116
|
+
S.remote = data.remote ?? null;
|
|
75
117
|
applyBootstrap(data);
|
|
76
118
|
S.pending = new Map(data.pending.map((i) => [i.id, i]));
|
|
77
119
|
const enabled = usableAgents().map((a) => a.id);
|
|
@@ -85,6 +127,12 @@ export async function boot() {
|
|
|
85
127
|
// 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
|
|
86
128
|
const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
|
|
87
129
|
if (fromHash === "tasks") openTasksView();
|
|
130
|
+
else if (fromHash.startsWith("remote") && remoteTabs().length) {
|
|
131
|
+
// 刷新前在远程控制里:底下先放上最近的对话,切回对话时就是它
|
|
132
|
+
if (latest) await openConversation(latest.key);
|
|
133
|
+
else newDraft();
|
|
134
|
+
openRemoteView(fromHash.split("/")[1]);
|
|
135
|
+
}
|
|
88
136
|
else if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
|
|
89
137
|
else if (latest) await openConversation(latest.key);
|
|
90
138
|
else newDraft();
|
|
@@ -179,6 +227,14 @@ function renderLayout() {
|
|
|
179
227
|
),
|
|
180
228
|
// 「最近」上面的入口,像 ChatGPT 的 Tasks。当前在任务页时高亮(renderSidebar 里切换)
|
|
181
229
|
(ui.tasksNav = h("button", { class: "btn new-chat side-nav", onclick: () => openTasksView() }, icon("clock"), "定时任务")),
|
|
230
|
+
// 远程控制:这台机器的桌面和终端。后台连着时右边亮个小绿点(renderSidebar 里切换)
|
|
231
|
+
(ui.remoteNav = h(
|
|
232
|
+
"button",
|
|
233
|
+
{ class: "btn new-chat side-nav", hidden: !remoteTabs().length, onclick: () => openRemoteView() },
|
|
234
|
+
icon("monitor"),
|
|
235
|
+
"远程控制",
|
|
236
|
+
h("span", { class: "live-dot", title: "在后台连着" }),
|
|
237
|
+
)),
|
|
182
238
|
ui.convList,
|
|
183
239
|
// 底部只有额度:没有额度可显示(关了查询、还没查到)时整块藏起来,连分隔线一起
|
|
184
240
|
(ui.sideBottom = h(
|
package/web/views/messages.js
CHANGED
|
@@ -14,11 +14,13 @@ import { scheduleNav } from "./msg-nav.js";
|
|
|
14
14
|
|
|
15
15
|
// ---------- 打开会话 ----------
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/** 从定时任务页、远程控制回到对话:把输入框放回来 */
|
|
18
18
|
function leaveTasksView() {
|
|
19
19
|
if (!S.view) return;
|
|
20
20
|
S.view = null;
|
|
21
21
|
ui.main.classList.remove("tasks-view");
|
|
22
|
+
// 远程控制页藏起来,连接留着(views/remote.js)
|
|
23
|
+
document.dispatchEvent(new Event("aim:leave-view"));
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function newDraft() {
|