@linkshell/host 0.2.0 → 0.3.1

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.
@@ -0,0 +1,784 @@
1
+ // The page a device opens to watch — and control — the computer's screen. It is
2
+ // served by the host, so the gestures and the toolbar come with the host's
3
+ // version; the app around it only supplies what a page cannot do itself (the
4
+ // full screen, turning the phone's screen, room for the keyboard).
5
+ //
6
+ // Gestures follow what remote desktop apps have settled on:
7
+ // 触控板 one finger moves the pointer, a tap clicks where the pointer is
8
+ // 点按 a tap clicks where the finger is
9
+ // both two-finger tap or a long press: right click; two fingers moving:
10
+ // scroll; a second tap that stays down and moves: drag; pinch: zoom;
11
+ // three-finger tap: the keyboard
12
+ // With a mouse (a desktop browser) the pointer, buttons, wheel and keys go straight through.
13
+ const icon = (body) => `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
14
+ const ICONS = {
15
+ view: icon('<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="2.8"/>'),
16
+ trackpad: icon('<path d="M6.5 3.5v15l4-3.4 2.6 5.4 2.5-1.2-2.6-5.3 5-.6Z"/>'),
17
+ touch: icon('<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="7.6"/>'),
18
+ keyboard: icon('<rect x="2.5" y="6" width="19" height="12" rx="2.6"/><path d="M6.6 10h.01M10.2 10h.01M13.8 10h.01M17.4 10h.01M6.6 14h.01M17.4 14h.01M10 14h4"/>'),
19
+ fit: icon('<circle cx="10.5" cy="10.5" r="6.2"/><path d="m15.2 15.2 5 5M8 10.5h5"/>'),
20
+ rotate: icon('<rect x="3.5" y="10" width="17" height="9.5" rx="2.2"/><path d="M7 6.2a8.4 8.4 0 0 1 10 0M17 6.2V3.4M17 6.2h-2.8"/>'),
21
+ expand: icon('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>'),
22
+ shrink: icon('<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>'),
23
+ down: icon('<path d="m6 9.5 6 6 6-6"/>'),
24
+ };
25
+ const STYLE = String.raw `
26
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
27
+ html, body { margin: 0; height: 100%; background: #000; overflow: hidden; overscroll-behavior: none; }
28
+ body { position: fixed; inset: 0; color: #fff; font: 15px/1.45 -apple-system, system-ui, "PingFang SC", "Noto Sans CJK SC", sans-serif; }
29
+ #stage { position: absolute; inset: 0; overflow: hidden; touch-action: none; }
30
+ canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
31
+ #pointer { position: absolute; left: -9px; top: -9px; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;
32
+ border: 2px solid rgba(255,255,255,0.95); box-shadow: 0 0 0 1px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(0,0,0,0.35);
33
+ opacity: 0; transition: opacity 0.3s; will-change: transform; }
34
+ #pointer.on { opacity: 1; transition: none; }
35
+ #pointer.pressed { background: rgba(255,255,255,0.4); }
36
+ #note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 32px;
37
+ color: rgba(255,255,255,0.72); text-align: center; pointer-events: none; }
38
+ .glass { background: rgba(38,38,42,0.74); -webkit-backdrop-filter: blur(22px) saturate(1.6); backdrop-filter: blur(22px) saturate(1.6);
39
+ border: 0.5px solid rgba(255,255,255,0.16); box-shadow: 0 8px 28px rgba(0,0,0,0.38); }
40
+ #bar { position: absolute; display: flex; gap: 2px; padding: 4px; border-radius: 26px; transition: opacity 0.4s; }
41
+ #bar.side { flex-direction: column; }
42
+ #bar.idle { opacity: 0.38; }
43
+ #bar.hidden, .gone { display: none !important; }
44
+ .tool { width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.92); }
45
+ .tool.on { background: #fff; color: #111; }
46
+ .tool:active { background: rgba(255,255,255,0.18); }
47
+ .tool.on:active { background: rgba(255,255,255,0.82); }
48
+ #menu { position: absolute; width: 248px; padding: 6px; border-radius: 20px; }
49
+ .choice { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; }
50
+ .choice:active { background: rgba(255,255,255,0.12); }
51
+ .choice.on { background: rgba(255,255,255,0.16); }
52
+ .choice b { display: block; font-weight: 600; font-size: 15px; }
53
+ .choice span { display: block; font-size: 12.5px; color: rgba(255,255,255,0.62); }
54
+ #toast { position: absolute; left: 50%; max-width: min(86vw, 420px); width: max-content; transform: translateX(-50%); padding: 10px 16px; border-radius: 18px;
55
+ font-size: 13.5px; text-align: center; color: rgba(255,255,255,0.94); pointer-events: none; opacity: 0; transition: opacity 0.3s; }
56
+ #toast.on { opacity: 1; }
57
+ #keys { position: absolute; left: 0; right: 0; height: 46px; display: flex; align-items: center; gap: 5px; padding: 0 6px;
58
+ background: rgba(28,28,30,0.96); border-top: 0.5px solid rgba(255,255,255,0.14); }
59
+ .key { flex: 1 1 0; min-width: 0; height: 34px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
60
+ background: rgba(255,255,255,0.13); color: #fff; font-size: 15px; font-variant-numeric: tabular-nums; }
61
+ .key.word { font-size: 12.5px; }
62
+ .key:active { background: rgba(255,255,255,0.3); }
63
+ .key.on { background: #fff; color: #111; }
64
+ #typing, #shortcut { position: absolute; left: 0; top: 0; width: 2px; height: 2px; padding: 0; border: 0; outline: 0; resize: none; opacity: 0;
65
+ background: transparent; color: transparent; caret-color: transparent; font-size: 16px; -webkit-user-select: text; user-select: text; }
66
+ `;
67
+ const SCRIPT = String.raw `
68
+ (() => {
69
+ const $ = (id) => document.getElementById(id);
70
+ const stage = $("stage"), canvas = $("screen"), ctx = canvas.getContext("2d"), pointer = $("pointer");
71
+ const note = $("note"), bar = $("bar"), menu = $("menu"), toast = $("toast"), keys = $("keys"), typing = $("typing"), shortcut = $("shortcut");
72
+ const query = new URLSearchParams(location.search);
73
+ const app = window.ReactNativeWebView;
74
+ const tellApp = (message) => { if (app) app.postMessage(JSON.stringify(message)); };
75
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
76
+ const coarse = matchMedia("(pointer: coarse)").matches;
77
+ const android = /Android/i.test(navigator.userAgent);
78
+
79
+ const MODES = ["view", "trackpad", "touch"];
80
+ const HINTS = {
81
+ view: "只看:双指缩放,单指移动画面",
82
+ trackpad: "触控板:滑动移动指针,轻点点击 · 双指轻点右键 · 双指滑动滚动 · 轻点两下并按住拖拽",
83
+ touch: "点按:点哪里就点哪里 · 长按右键 · 双指滑动滚动 · 轻点两下并按住拖拽",
84
+ };
85
+ let stored = null;
86
+ try { stored = localStorage.getItem("linkshell.screen.mode"); } catch {}
87
+ let mode = MODES.includes(query.get("mode")) ? query.get("mode") : MODES.includes(stored) ? stored : "view";
88
+
89
+ // What the app around the page has done to the phone's screen. A page in a plain browser has none of it.
90
+ // "clear" is the side of a phone lying down that has no camera in it.
91
+ const chrome = Object.assign({ fullscreen: false, landscape: false, canRotate: false, clear: null, insets: { top: 0, right: 0, bottom: 0, left: 0 } }, window.__linkshellChrome || {});
92
+ // Whether this viewer may move the computer's pointer: asked for with the first controlling mode.
93
+ const control = { asked: false, known: false, available: false, trusted: false, reason: "", app: "", prompted: false };
94
+ const content = { w: 0, h: 0 };
95
+ let zoom = 1, pan = { x: 0, y: 0 }, fit = 1;
96
+ let area = { x: 0, y: 0, w: 1, h: 1 }, shown = { x: 0, y: 0, w: 1, h: 1 };
97
+ const cursor = { x: 0.5, y: 0.5 };
98
+ let keysOpen = false;
99
+ const mods = new Set();
100
+
101
+ // ---- The picture ----------------------------------------------------------
102
+
103
+ const say = (text) => { note.textContent = text; note.classList.toggle("gone", !text); };
104
+ const hex = (n) => n.toString(16).padStart(2, "0");
105
+ function sps(unit) {
106
+ for (let i = 0; i + 3 < unit.length; i++)
107
+ if (unit[i] === 0 && unit[i + 1] === 0 && unit[i + 2] === 1 && (unit[i + 3] & 0x1f) === 7) return unit.subarray(i + 3);
108
+ }
109
+ if (!("VideoDecoder" in window)) say("这个系统版本的浏览器内核不支持视频解码,请升级系统后再试。");
110
+ let decoder;
111
+ const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/stream" + location.search);
112
+ ws.binaryType = "arraybuffer";
113
+ const send = (message) => { if (ws.readyState === 1) ws.send(JSON.stringify(message)); };
114
+ ws.onopen = () => { if (mode !== "view") askControl(); };
115
+ ws.onmessage = (event) => {
116
+ if (typeof event.data === "string") return heard(JSON.parse(event.data));
117
+ if (!("VideoDecoder" in window)) return;
118
+ const bytes = new Uint8Array(event.data);
119
+ const key = bytes[0] === 1;
120
+ const unit = bytes.subarray(1);
121
+ if (!decoder) {
122
+ const params = key && sps(unit);
123
+ if (!params) return;
124
+ decoder = new VideoDecoder({
125
+ output: (frame) => {
126
+ if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
127
+ canvas.width = content.w = frame.displayWidth;
128
+ canvas.height = content.h = frame.displayHeight;
129
+ layout();
130
+ }
131
+ ctx.drawImage(frame, 0, 0);
132
+ frame.close();
133
+ say("");
134
+ },
135
+ error: (error) => say("解码失败:" + error.message),
136
+ });
137
+ decoder.configure({ codec: "avc1." + hex(params[1]) + hex(params[2]) + hex(params[3]), optimizeForLatency: true });
138
+ }
139
+ decoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: performance.now() * 1000, data: unit }));
140
+ };
141
+ ws.onclose = () => { if (note.classList.contains("gone") || note.textContent.startsWith("正在")) say("屏幕连接已断开"); };
142
+
143
+ function heard(message) {
144
+ if (message.error) return say(message.error);
145
+ if (message.control) {
146
+ const was = controlling();
147
+ Object.assign(control, { known: true, reason: "" }, message.control);
148
+ if (mode !== "view") explain(was);
149
+ refresh();
150
+ }
151
+ // The computer's own pointer moved: the next swipe starts from where it is now.
152
+ if (message.cursor && !gesture) { cursor.x = message.cursor.x; cursor.y = message.cursor.y; place(); }
153
+ }
154
+
155
+ // ---- Layout ---------------------------------------------------------------
156
+
157
+ function viewport() {
158
+ const v = window.visualViewport;
159
+ return v ? { w: v.width, h: v.height, x: v.offsetLeft, y: v.offsetTop } : { w: innerWidth, h: innerHeight, x: 0, y: 0 };
160
+ }
161
+
162
+ function layout() {
163
+ const v = viewport(), inset = chrome.insets;
164
+ // Above the keyboard, or above the phone's bottom edge when the keys are a real keyboard's.
165
+ const keysHeight = keysOpen ? 46 + inset.bottom : 0;
166
+ area = { x: v.x + inset.left, y: v.y + inset.top, w: Math.max(1, v.w - inset.left - inset.right), h: Math.max(1, v.h - inset.top - keysHeight) };
167
+ if (content.w) {
168
+ fit = Math.min(area.w / content.w, area.h / content.h);
169
+ const w = content.w * fit * zoom, h = content.h * fit * zoom;
170
+ // Centred while it fits; once larger than the view it is moved about, never past its own edges.
171
+ pan.x = w <= area.w ? area.x + (area.w - w) / 2 : clamp(pan.x, area.x + area.w - w, area.x);
172
+ pan.y = h <= area.h ? area.y + (area.h - h) / 2 : clamp(pan.y, area.y + area.h - h, area.y);
173
+ shown = { x: pan.x, y: pan.y, w, h };
174
+ canvas.style.transform = "translate(" + pan.x + "px," + pan.y + "px) scale(" + fit * zoom + ")";
175
+ }
176
+ keys.classList.toggle("gone", !keysOpen);
177
+ keys.style.top = v.y + v.h - keysHeight + "px";
178
+ keys.style.height = keysHeight + "px";
179
+ keys.style.paddingBottom = inset.bottom + "px";
180
+ // The toolbar stands in the black beside the picture when there is some, under it otherwise; the keyboard takes its place.
181
+ const beside = content.w ? area.w - content.w * fit > area.h - content.h * fit + inset.bottom : v.w > v.h;
182
+ bar.classList.toggle("side", beside);
183
+ bar.classList.toggle("hidden", keysOpen);
184
+ const size = { w: bar.offsetWidth, h: bar.offsetHeight };
185
+ // Beside the picture it takes the side without the camera, and stands in the middle of the black there.
186
+ const onLeft = beside && (chrome.clear ? chrome.clear === "left" : inset.left < inset.right);
187
+ if (beside) {
188
+ const edge = onLeft ? inset.left : inset.right;
189
+ const black = edge + (content.w ? (area.w - content.w * fit) / 2 : 0);
190
+ const gap = chrome.clear || !edge ? Math.max(8, (black - size.w) / 2) : edge;
191
+ bar.style.left = (onLeft ? v.x + gap : v.x + v.w - gap - size.w) + "px";
192
+ bar.style.top = area.y + (area.h - size.h) / 2 + "px";
193
+ } else {
194
+ bar.style.left = v.x + (v.w - size.w) / 2 + "px";
195
+ bar.style.top = v.y + v.h - Math.max(inset.bottom, 8) - 8 - size.h + "px";
196
+ }
197
+ if (!menu.classList.contains("gone")) {
198
+ const box = bar.getBoundingClientRect();
199
+ menu.style.left = clamp(!beside ? box.left + box.width / 2 - 124 : onLeft ? box.right + 10 : box.left - 248 - 10, v.x + 8, v.x + v.w - 256) + "px";
200
+ menu.style.top = clamp(beside ? box.top : box.top - menu.offsetHeight - 10, v.y + inset.top + 8, v.y + v.h - menu.offsetHeight - 8) + "px";
201
+ }
202
+ toast.style.top = area.y + 14 + "px";
203
+ place();
204
+ }
205
+
206
+ function place() {
207
+ pointer.style.transform = "translate(" + (shown.x + cursor.x * shown.w) + "px," + (shown.y + cursor.y * shown.h) + "px)";
208
+ }
209
+
210
+ addEventListener("resize", layout);
211
+ if (window.visualViewport) {
212
+ visualViewport.addEventListener("resize", layout);
213
+ visualViewport.addEventListener("scroll", layout);
214
+ }
215
+
216
+ function zoomTo(next, at) {
217
+ // The point of the picture under the fingers stays under them.
218
+ const u = (at.x - shown.x) / shown.w, v = (at.y - shown.y) / shown.h;
219
+ zoom = clamp(next, 1, 8);
220
+ pan.x = at.x - u * content.w * fit * zoom;
221
+ pan.y = at.y - v * content.h * fit * zoom;
222
+ layout();
223
+ refresh();
224
+ }
225
+
226
+ function panBy(dx, dy) {
227
+ pan.x += dx;
228
+ pan.y += dy;
229
+ layout();
230
+ }
231
+
232
+ // ---- Sending --------------------------------------------------------------
233
+
234
+ const controlling = () => mode !== "view" && control.available && control.trusted;
235
+
236
+ function askControl() {
237
+ if (control.asked) return;
238
+ control.asked = true;
239
+ send({ t: "control" });
240
+ }
241
+
242
+ let moveWaiting = false;
243
+ function flush() {
244
+ if (!moveWaiting) return;
245
+ moveWaiting = false;
246
+ send({ t: "move", x: +cursor.x.toFixed(5), y: +cursor.y.toFixed(5) });
247
+ }
248
+ // A pointer position a frame: as often as the picture could show it.
249
+ function moved() {
250
+ if (!moveWaiting) requestAnimationFrame(flush);
251
+ moveWaiting = true;
252
+ glow();
253
+ place();
254
+ }
255
+ // Everything else goes at once, after the position it happens at.
256
+ function act(message) {
257
+ flush();
258
+ send(message);
259
+ }
260
+
261
+ let fade = 0;
262
+ function glow() {
263
+ if (mode !== "trackpad") return;
264
+ pointer.classList.add("on");
265
+ clearTimeout(fade);
266
+ fade = setTimeout(() => pointer.classList.remove("on"), 900);
267
+ }
268
+
269
+ /** The modifiers armed on the key bar, used up by the click or key that takes them. */
270
+ function takeMods() {
271
+ if (!mods.size) return undefined;
272
+ const taken = [...mods];
273
+ mods.clear();
274
+ drawKeys();
275
+ return taken;
276
+ }
277
+
278
+ function click(buttonName, count) {
279
+ const m = takeMods();
280
+ act({ t: "down", b: buttonName, n: count, m });
281
+ send({ t: "up", b: buttonName, n: count, m });
282
+ }
283
+
284
+ /** Puts the pointer under a point of the page; false when that is outside the picture. */
285
+ function pointAt(x, y) {
286
+ const u = (x - shown.x) / shown.w, v = (y - shown.y) / shown.h;
287
+ if (u < 0 || u > 1 || v < 0 || v > 1) return false;
288
+ cursor.x = u;
289
+ cursor.y = v;
290
+ moved();
291
+ return true;
292
+ }
293
+
294
+ /** Moves the pointer as a trackpad does: slow fingers place it, quick ones cross the screen. */
295
+ function nudge(dx, dy, dt) {
296
+ const gain = clamp(0.85 + (Math.hypot(dx, dy) / dt) * 1.15, 0.85, 3.4);
297
+ cursor.x = clamp(cursor.x + (dx * gain) / shown.w, 0, 1);
298
+ cursor.y = clamp(cursor.y + (dy * gain) / shown.h, 0, 1);
299
+ // Zoomed in, the view follows the pointer to the edge it is pushing.
300
+ if (zoom > 1) {
301
+ const edge = Math.min(56, area.w / 4, area.h / 4);
302
+ const x = shown.x + cursor.x * shown.w, y = shown.y + cursor.y * shown.h;
303
+ const shiftX = x < area.x + edge ? area.x + edge - x : x > area.x + area.w - edge ? area.x + area.w - edge - x : 0;
304
+ const shiftY = y < area.y + edge ? area.y + edge - y : y > area.y + area.h - edge ? area.y + area.h - edge - y : 0;
305
+ if (shiftX || shiftY) panBy(shiftX, shiftY);
306
+ }
307
+ moved();
308
+ }
309
+
310
+ // ---- Touch ----------------------------------------------------------------
311
+
312
+ const SLOP = 8, HOLD_MS = 480, AGAIN_MS = 320, TAP_MS = 350;
313
+ const touches = new Map();
314
+ let gesture = null;
315
+ let lastTap = null;
316
+
317
+ function centre() {
318
+ const [a, b] = [...touches.values()];
319
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, d: Math.hypot(a.x - b.x, a.y - b.y) };
320
+ }
321
+
322
+ function endOne() {
323
+ if (!gesture || gesture.kind !== "one") return;
324
+ clearTimeout(gesture.hold);
325
+ if (gesture.dragging) {
326
+ act({ t: "up", b: "left" });
327
+ pointer.classList.remove("pressed");
328
+ }
329
+ }
330
+
331
+ stage.addEventListener("pointerdown", (event) => {
332
+ wake();
333
+ // A touch that closes the menu does nothing else.
334
+ const closing = closeMenu();
335
+ if (event.pointerType === "mouse") return closing ? undefined : mouseDown(event);
336
+ event.preventDefault();
337
+ try { stage.setPointerCapture(event.pointerId); } catch {}
338
+ touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
339
+ const now = performance.now();
340
+ if (closing) {
341
+ gesture = { kind: "more", t0: now };
342
+ } else if (touches.size === 1) {
343
+ const again = !!lastTap && now - lastTap.t < AGAIN_MS && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) < 48;
344
+ const mine = (gesture = { kind: "one", id: event.pointerId, x0: event.clientX, y0: event.clientY, x: event.clientX, y: event.clientY, t0: now, t: now, again, moving: false, dragging: false, held: false, hold: 0 });
345
+ // Staying down without moving is the right button.
346
+ if (controlling() && !again) mine.hold = setTimeout(() => {
347
+ if (gesture !== mine || mine.moving) return;
348
+ if (mode === "touch" && !pointAt(mine.x0, mine.y0)) return;
349
+ mine.held = true;
350
+ click("right", 1);
351
+ tellApp({ type: "haptic", kind: "medium" });
352
+ }, HOLD_MS);
353
+ } else if (touches.size === 2) {
354
+ endOne();
355
+ const c = centre();
356
+ gesture = { kind: "two", t0: now, c0: c, c, d0: c.d, zoom0: zoom, what: null, restX: 0, restY: 0 };
357
+ } else {
358
+ endOne();
359
+ gesture = { kind: touches.size === 3 ? "three" : "more", t0: now };
360
+ }
361
+ });
362
+
363
+ stage.addEventListener("pointermove", (event) => {
364
+ if (event.pointerType === "mouse") return mouseMove(event);
365
+ const touch = touches.get(event.pointerId);
366
+ if (!touch || !gesture) return;
367
+ touch.x = event.clientX;
368
+ touch.y = event.clientY;
369
+ if (gesture.kind === "one" && gesture.id === event.pointerId) {
370
+ const g = gesture, now = performance.now();
371
+ const dx = touch.x - g.x, dy = touch.y - g.y, dt = Math.max(1, now - g.t);
372
+ if (!g.moving) {
373
+ if (Math.hypot(touch.x - g.x0, touch.y - g.y0) < SLOP) return;
374
+ g.moving = true;
375
+ clearTimeout(g.hold);
376
+ // A second tap that stays down and moves holds the button: a drag.
377
+ if (controlling() && g.again && !g.held && (mode === "trackpad" || pointAt(g.x0, g.y0))) {
378
+ act({ t: "down", b: "left", n: 1, m: takeMods() });
379
+ g.dragging = true;
380
+ pointer.classList.add("pressed");
381
+ tellApp({ type: "haptic", kind: "light" });
382
+ }
383
+ }
384
+ g.x = touch.x;
385
+ g.y = touch.y;
386
+ g.t = now;
387
+ if (g.held) return;
388
+ if (!controlling()) panBy(dx, dy);
389
+ else if (mode === "trackpad") nudge(dx, dy, dt);
390
+ else if (g.dragging) pointAt(clamp(g.x, shown.x, shown.x + shown.w), clamp(g.y, shown.y, shown.y + shown.h));
391
+ else panBy(dx, dy);
392
+ } else if (gesture.kind === "two" && touches.size === 2) {
393
+ const g = gesture, c = centre();
394
+ if (!g.what) {
395
+ if (Math.abs(c.d - g.d0) > 26) {
396
+ g.what = "zoom";
397
+ g.d0 = c.d;
398
+ } else if (Math.hypot(c.x - g.c0.x, c.y - g.c0.y) > 10) {
399
+ g.what = controlling() ? "scroll" : "pan";
400
+ // Scrolling lands on what is under the fingers.
401
+ if (g.what === "scroll" && mode === "touch") pointAt(g.c0.x, g.c0.y);
402
+ } else return;
403
+ }
404
+ if (g.what === "zoom") {
405
+ const u = (g.c.x - shown.x) / shown.w, v = (g.c.y - shown.y) / shown.h;
406
+ zoom = clamp((g.zoom0 * c.d) / g.d0, 1, 8);
407
+ pan.x = c.x - u * content.w * fit * zoom;
408
+ pan.y = c.y - v * content.h * fit * zoom;
409
+ layout();
410
+ } else if (g.what === "scroll") {
411
+ // The page follows the fingers, as it does on the phone itself.
412
+ g.restX += (c.x - g.c.x) * 2.4;
413
+ g.restY += (c.y - g.c.y) * 2.4;
414
+ const sx = Math.trunc(g.restX), sy = Math.trunc(g.restY);
415
+ if (sx || sy) {
416
+ g.restX -= sx;
417
+ g.restY -= sy;
418
+ act({ t: "scroll", dx: sx, dy: sy, m: mods.size ? [...mods] : undefined });
419
+ }
420
+ } else panBy(c.x - g.c.x, c.y - g.c.y);
421
+ g.c = c;
422
+ }
423
+ });
424
+
425
+ function lift(event, cancelled) {
426
+ if (event.pointerType === "mouse") return mouseUp(event);
427
+ if (!touches.delete(event.pointerId) || !gesture) return;
428
+ const g = gesture, now = performance.now();
429
+ if (g.kind === "one") {
430
+ endOne();
431
+ gesture = null;
432
+ if (!cancelled && !g.moving && !g.held && now - g.t0 < HOLD_MS + 200) tap(g, now);
433
+ return;
434
+ }
435
+ // The rest of a gesture with several fingers ends when the last one leaves.
436
+ if (touches.size) return;
437
+ gesture = null;
438
+ refresh();
439
+ if (cancelled || now - g.t0 > TAP_MS) return;
440
+ if (g.kind === "two" && !g.what && controlling()) {
441
+ if (mode === "touch" && !pointAt(g.c0.x, g.c0.y)) return;
442
+ click("right", 1);
443
+ tellApp({ type: "haptic", kind: "light" });
444
+ } else if (g.kind === "three" && mode !== "view") toggleKeys();
445
+ }
446
+ stage.addEventListener("pointerup", (event) => lift(event, false));
447
+ stage.addEventListener("pointercancel", (event) => lift(event, true));
448
+ // Keeps the keyboard up while the fingers work on the picture, and the page from scrolling or selecting.
449
+ stage.addEventListener("touchstart", (event) => event.preventDefault(), { passive: false });
450
+ addEventListener("contextmenu", (event) => event.preventDefault());
451
+
452
+ function tap(g, now) {
453
+ if (!controlling()) {
454
+ if (mode !== "view") return explain(false);
455
+ // Only watching: a double tap goes in on that spot, and back out.
456
+ if (g.again) {
457
+ lastTap = null;
458
+ return zoomTo(zoom > 1.05 ? 1 : 2.5, { x: g.x0, y: g.y0 });
459
+ }
460
+ lastTap = { t: now, x: g.x0, y: g.y0 };
461
+ return;
462
+ }
463
+ if (mode === "touch" && !pointAt(g.x0, g.y0)) return;
464
+ glow();
465
+ click("left", g.again ? 2 : 1);
466
+ lastTap = g.again ? null : { t: now, x: g.x0, y: g.y0 };
467
+ }
468
+
469
+ // ---- Mouse (a desktop browser) -------------------------------------------
470
+
471
+ let mouseHeld = null, mouseClicks = { t: 0, n: 0, x: 0, y: 0, b: 0 };
472
+ function mouseDown(event) {
473
+ if (!controlling()) { mouseHeld = "pan"; return; }
474
+ typing.focus({ preventScroll: true });
475
+ event.preventDefault();
476
+ if (!pointAt(event.clientX, event.clientY)) return;
477
+ const now = performance.now();
478
+ // Clicks count up only in the same place, with the same button, soon after one another.
479
+ const repeat = now - mouseClicks.t < 400 && event.button === mouseClicks.b && Math.hypot(event.clientX - mouseClicks.x, event.clientY - mouseClicks.y) < 6;
480
+ mouseClicks = { t: now, n: repeat ? Math.min(mouseClicks.n + 1, 3) : 1, x: event.clientX, y: event.clientY, b: event.button };
481
+ mouseHeld = event.button === 2 ? "right" : "left";
482
+ act({ t: "down", b: mouseHeld, n: mouseClicks.n, m: hardMods(event) });
483
+ }
484
+ function mouseMove(event) {
485
+ if (mouseHeld === "pan") return panBy(event.movementX, event.movementY);
486
+ if (controlling()) pointAt(clamp(event.clientX, shown.x, shown.x + shown.w), clamp(event.clientY, shown.y, shown.y + shown.h));
487
+ }
488
+ function mouseUp(event) {
489
+ if (mouseHeld && mouseHeld !== "pan") act({ t: "up", b: mouseHeld, n: mouseClicks.n, m: hardMods(event) });
490
+ mouseHeld = null;
491
+ }
492
+ stage.addEventListener("wheel", (event) => {
493
+ event.preventDefault();
494
+ if (controlling() && !event.ctrlKey) return act({ t: "scroll", dx: -event.deltaX, dy: -event.deltaY });
495
+ zoomTo(zoom * Math.exp(-event.deltaY / 300), { x: event.clientX, y: event.clientY });
496
+ }, { passive: false });
497
+
498
+ // ---- Keyboard -------------------------------------------------------------
499
+
500
+ // The field is never empty, so a backspace always has something to take and reports itself.
501
+ const HELD = "::";
502
+ const NAMED = { Enter: "return", Backspace: "backspace", Tab: "tab", Escape: "escape", ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
503
+ Delete: "delete", Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown" };
504
+ const KEY_OF = { " ": "space" };
505
+ let composing = false, composed = "", switching = false, leftover = null;
506
+
507
+ const hardMods = (event) => {
508
+ const list = [event.metaKey && "cmd", event.ctrlKey && "ctrl", event.altKey && "alt", event.shiftKey && "shift"].filter(Boolean);
509
+ return list.length ? list : undefined;
510
+ };
511
+
512
+ function resetTyping() {
513
+ typing.value = HELD;
514
+ typing.setSelectionRange(HELD.length, HELD.length);
515
+ }
516
+
517
+ function press(name, extra) {
518
+ const armed = takeMods() || [];
519
+ const all = [...new Set([...armed, ...(extra || [])])];
520
+ act({ t: "key", k: name, m: all.length ? all : undefined });
521
+ }
522
+
523
+ function typed(text) {
524
+ if (!text) return;
525
+ // One character with a modifier armed is a shortcut, not text.
526
+ if (mods.size && [...text].length === 1) return press(KEY_OF[text] || text.toLowerCase());
527
+ const lines = text.split("\n");
528
+ lines.forEach((line, index) => {
529
+ if (line) act({ t: "text", s: line });
530
+ if (index < lines.length - 1) press("return");
531
+ });
532
+ }
533
+
534
+ function keyDown(event) {
535
+ if (event.isComposing || event.keyCode === 229) return;
536
+ const name = NAMED[event.key] || (/^F([1-9]|1[0-2])$/.test(event.key) ? event.key.toLowerCase() : null);
537
+ if (name) {
538
+ event.preventDefault();
539
+ return press(name, hardMods(event));
540
+ }
541
+ // A real keyboard's shortcuts.
542
+ if ((event.metaKey || event.ctrlKey) && event.key.length === 1) {
543
+ event.preventDefault();
544
+ press(KEY_OF[event.key] || event.key.toLowerCase(), hardMods(event));
545
+ }
546
+ }
547
+ typing.addEventListener("keydown", keyDown);
548
+ shortcut.addEventListener("keydown", keyDown);
549
+ typing.addEventListener("compositionstart", () => { composing = true; composed = ""; });
550
+ typing.addEventListener("compositionupdate", (event) => {
551
+ // Android keyboards compose plain words too: what they have so far is typed as it grows, and
552
+ // taken back when they change their mind, rather than arriving a word late.
553
+ if (!android || mods.size) return;
554
+ const next = event.data || "";
555
+ let same = 0;
556
+ while (same < composed.length && same < next.length && composed[same] === next[same]) same++;
557
+ for (let i = same; i < composed.length; i++) act({ t: "key", k: "backspace" });
558
+ if (next.length > same) act({ t: "text", s: next.slice(same) });
559
+ composed = next;
560
+ });
561
+ typing.addEventListener("compositionend", (event) => {
562
+ composing = false;
563
+ const text = isLeftover(event.data || "") ? "" : event.data || "";
564
+ if (android && !mods.size) {
565
+ let same = 0;
566
+ while (same < composed.length && same < text.length && composed[same] === text[same]) same++;
567
+ for (let i = same; i < composed.length; i++) act({ t: "key", k: "backspace" });
568
+ if (text.length > same) act({ t: "text", s: text.slice(same) });
569
+ } else typed(text);
570
+ composed = "";
571
+ resetTyping();
572
+ });
573
+ typing.addEventListener("input", () => {
574
+ if (composing) return;
575
+ const value = typing.value;
576
+ if (value.startsWith(HELD) && isLeftover(value.slice(HELD.length))) return resetTyping();
577
+ if (value.startsWith(HELD)) typed(value.slice(HELD.length));
578
+ else for (let i = value.length; i < HELD.length; i++) press("backspace");
579
+ resetTyping();
580
+ });
581
+ // A letter typed with a modifier armed is a shortcut's key, and an input method (pinyin, say) would
582
+ // start composing with it; nothing a page does takes that back. So while a modifier is armed the
583
+ // keyboard belongs to a second field, of a kind the phone gives a plain keyboard to.
584
+ shortcut.addEventListener("input", () => {
585
+ const value = shortcut.value;
586
+ shortcut.value = "";
587
+ if (!value) return;
588
+ const letter = [...value][0];
589
+ // An input method that composed with the letter anyway hands it to the other field as the keyboard goes back: not typed twice.
590
+ leftover = { text: letter, until: performance.now() + 600 };
591
+ typed(letter);
592
+ });
593
+ /** Whether this text is what an input method had left of a shortcut's letter. */
594
+ function isLeftover(text) {
595
+ const mine = leftover && text === leftover.text && performance.now() < leftover.until;
596
+ leftover = null;
597
+ return mine;
598
+ }
599
+ const keyboardUp = () => document.activeElement === typing || document.activeElement === shortcut;
600
+ function syncField() {
601
+ if (!keyboardUp()) return;
602
+ const field = mods.size ? shortcut : typing;
603
+ if (document.activeElement === field) return;
604
+ switching = true;
605
+ field.focus({ preventScroll: true });
606
+ switching = false;
607
+ }
608
+ for (const field of [typing, shortcut]) {
609
+ field.addEventListener("focus", () => { if (switching) return; resetTyping(); keysOpen = coarse; layout(); refresh(); });
610
+ field.addEventListener("blur", () => { if (switching) return; keysOpen = false; mods.clear(); drawKeys(); layout(); refresh(); });
611
+ }
612
+
613
+ function toggleKeys() {
614
+ if (keyboardUp()) document.activeElement.blur();
615
+ else typing.focus({ preventScroll: true });
616
+ }
617
+
618
+ // ---- Toolbar, menu, key bar ----------------------------------------------
619
+
620
+ /** A control that works on touch-up and never takes the focus (the keyboard stays as it is). */
621
+ function tappable(element, action) {
622
+ element.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); wake(); });
623
+ element.addEventListener("touchstart", (event) => event.preventDefault(), { passive: false });
624
+ element.addEventListener("mousedown", (event) => event.preventDefault());
625
+ element.addEventListener("pointerup", (event) => {
626
+ event.preventDefault();
627
+ event.stopPropagation();
628
+ const box = element.getBoundingClientRect();
629
+ if (event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom) action();
630
+ });
631
+ }
632
+
633
+ let idle = 0;
634
+ function wake() {
635
+ bar.classList.remove("idle");
636
+ clearTimeout(idle);
637
+ idle = setTimeout(() => { if (menu.classList.contains("gone")) bar.classList.add("idle"); }, 4500);
638
+ }
639
+
640
+ let toastTimer = 0;
641
+ function hint(text, ms) {
642
+ toast.textContent = text;
643
+ toast.classList.add("on");
644
+ clearTimeout(toastTimer);
645
+ if (ms !== 0) toastTimer = setTimeout(() => toast.classList.remove("on"), ms || 4200);
646
+ }
647
+
648
+ /** Says why a controlling mode is not controlling, or how it works once it is. */
649
+ function explain(was) {
650
+ if (!control.known) return hint("正在准备控制电脑…", 0);
651
+ if (!control.available) return hint(control.reason || "这台电脑暂时不能被控制");
652
+ if (!control.trusted) {
653
+ // The system's own dialog, on the computer, once.
654
+ if (!control.prompted) { control.prompted = true; send({ t: "prompt" }); }
655
+ const who = control.app ? "「" + control.app + "」" : "LinkShell";
656
+ return hint("电脑还没有允许被控制。电脑上已经打开「系统设置 › 隐私与安全性 › 辅助功能」,把" + who + "的开关打开,这里会自动继续;人不在电脑旁,回去后运行 linkshell screen 即可。", 0);
657
+ }
658
+ if (!was) hint(HINTS[mode]);
659
+ }
660
+
661
+ function setMode(next) {
662
+ if (next === mode) return;
663
+ mode = next;
664
+ try { localStorage.setItem("linkshell.screen.mode", mode); } catch {}
665
+ tellApp({ type: "mode", mode });
666
+ pointer.classList.remove("on");
667
+ if (mode === "view") {
668
+ if (keyboardUp()) document.activeElement.blur();
669
+ hint(HINTS.view);
670
+ } else {
671
+ askControl();
672
+ explain(false);
673
+ }
674
+ refresh();
675
+ }
676
+
677
+ function closeMenu() {
678
+ if (menu.classList.contains("gone")) return false;
679
+ menu.classList.add("gone");
680
+ wake();
681
+ return true;
682
+ }
683
+
684
+ function refresh() {
685
+ $("mode").innerHTML = ICON[mode];
686
+ $("mode").classList.toggle("on", mode !== "view");
687
+ $("keyboard").classList.toggle("gone", mode === "view");
688
+ $("fit").classList.toggle("gone", zoom < 1.05);
689
+ $("rotate").classList.toggle("gone", !app || !chrome.canRotate);
690
+ $("rotate").classList.toggle("on", chrome.landscape);
691
+ const full = app ? chrome.fullscreen : !!document.fullscreenElement;
692
+ $("full").classList.toggle("gone", !app && !document.fullscreenEnabled);
693
+ $("full").innerHTML = full ? ICON.shrink : ICON.expand;
694
+ for (const choice of menu.children) choice.classList.toggle("on", choice.dataset.mode === mode);
695
+ layout();
696
+ }
697
+
698
+ function drawKeys() {
699
+ for (const key of keys.children) if (key.dataset.mod) key.classList.toggle("on", mods.has(key.dataset.mod));
700
+ syncField();
701
+ }
702
+
703
+ const ICON = JSON.parse($("icons").textContent);
704
+ tappable($("mode"), () => { menu.classList.toggle("gone"); layout(); });
705
+ tappable($("keyboard"), toggleKeys);
706
+ tappable($("fit"), () => zoomTo(1, { x: area.x + area.w / 2, y: area.y + area.h / 2 }));
707
+ tappable($("rotate"), () => tellApp({ type: "landscape", on: !chrome.landscape }));
708
+ tappable($("full"), () => {
709
+ if (app) return tellApp({ type: "fullscreen", on: !chrome.fullscreen });
710
+ if (document.fullscreenElement) document.exitFullscreen();
711
+ else document.documentElement.requestFullscreen().catch(() => {});
712
+ });
713
+ document.addEventListener("fullscreenchange", refresh);
714
+ for (const choice of menu.children) {
715
+ choice.querySelector("i").innerHTML = ICON[choice.dataset.mode];
716
+ tappable(choice, () => { closeMenu(); setMode(choice.dataset.mode); });
717
+ }
718
+ for (const key of keys.children) {
719
+ tappable(key, () => {
720
+ const mod = key.dataset.mod;
721
+ if (mod) {
722
+ if (!mods.delete(mod)) mods.add(mod);
723
+ return drawKeys();
724
+ }
725
+ if (key.dataset.key) return press(key.dataset.key);
726
+ if (keyboardUp()) document.activeElement.blur();
727
+ });
728
+ }
729
+ $("hide").innerHTML = ICON.down;
730
+
731
+ /** The app tells the page what it did with the phone's screen. */
732
+ window.linkshellChrome = (next) => {
733
+ Object.assign(chrome, next);
734
+ refresh();
735
+ };
736
+
737
+ resetTyping();
738
+ refresh();
739
+ wake();
740
+ tellApp({ type: "ready", mode });
741
+ })();
742
+ `;
743
+ export function viewerPage() {
744
+ return `<!doctype html>
745
+ <html><head><meta charset="utf-8">
746
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
747
+ <title>屏幕</title>
748
+ <style>${STYLE}</style></head>
749
+ <body>
750
+ <div id="stage"><canvas id="screen"></canvas><div id="pointer"></div></div>
751
+ <div id="note">正在连接电脑屏幕…</div>
752
+ <div id="toast" class="glass"></div>
753
+ <div id="menu" class="glass gone">
754
+ <div class="choice" data-mode="view"><i></i><div><b>只看</b><span>不会碰到电脑上的任何东西</span></div></div>
755
+ <div class="choice" data-mode="trackpad"><i></i><div><b>触控板</b><span>滑动移动指针,轻点点击</span></div></div>
756
+ <div class="choice" data-mode="touch"><i></i><div><b>点按</b><span>点哪里,就点击哪里</span></div></div>
757
+ </div>
758
+ <div id="bar" class="glass">
759
+ <div class="tool" id="mode" role="button" aria-label="控制方式"></div>
760
+ <div class="tool" id="keyboard" role="button" aria-label="键盘">${ICONS.keyboard}</div>
761
+ <div class="tool" id="fit" role="button" aria-label="还原缩放">${ICONS.fit}</div>
762
+ <div class="tool" id="rotate" role="button" aria-label="横屏">${ICONS.rotate}</div>
763
+ <div class="tool" id="full" role="button" aria-label="全屏"></div>
764
+ </div>
765
+ <div id="keys" class="gone">
766
+ <div class="key word" data-key="escape">esc</div>
767
+ <div class="key word" data-key="tab">tab</div>
768
+ <div class="key" data-mod="ctrl">⌃</div>
769
+ <div class="key" data-mod="alt">⌥</div>
770
+ <div class="key" data-mod="cmd">⌘</div>
771
+ <div class="key" data-mod="shift">⇧</div>
772
+ <div class="key" data-key="left">←</div>
773
+ <div class="key" data-key="down">↓</div>
774
+ <div class="key" data-key="up">↑</div>
775
+ <div class="key" data-key="right">→</div>
776
+ <div class="key" id="hide" role="button" aria-label="收起键盘"></div>
777
+ </div>
778
+ <textarea id="typing" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" enterkeyhint="enter" aria-label="键盘输入"></textarea>
779
+ <input id="shortcut" type="email" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="快捷键">
780
+ <script type="application/json" id="icons">${JSON.stringify(ICONS)}</script>
781
+ <script>${SCRIPT}</script>
782
+ </body></html>`;
783
+ }
784
+ //# sourceMappingURL=screen-viewer.js.map