@linkshell/host 0.4.44 → 0.4.46
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/dist/host/src/drivers/acp/connection.js +2 -7
- package/dist/host/src/drivers/acp/connection.js.map +1 -1
- package/dist/host/src/drivers/acp/driver.js +2 -4
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/auth.js +1 -3
- package/dist/host/src/drivers/auth.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.js +23 -11
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/app-server.d.ts +6 -0
- package/dist/host/src/drivers/codex/app-server.js +44 -21
- package/dist/host/src/drivers/codex/app-server.js.map +1 -1
- package/dist/host/src/drivers/codex/driver.d.ts +1 -0
- package/dist/host/src/drivers/codex/driver.js +11 -7
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/installation.d.ts +2 -0
- package/dist/host/src/drivers/codex/installation.js +29 -0
- package/dist/host/src/drivers/codex/installation.js.map +1 -0
- package/dist/host/src/fs.d.ts +13 -0
- package/dist/host/src/fs.js +29 -2
- package/dist/host/src/fs.js.map +1 -1
- package/dist/host/src/host.d.ts +1 -0
- package/dist/host/src/host.js +6 -4
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/input.d.ts +29 -2
- package/dist/host/src/input.js +113 -5
- package/dist/host/src/input.js.map +1 -1
- package/dist/host/src/local-transport.d.ts +5 -0
- package/dist/host/src/local-transport.js +22 -0
- package/dist/host/src/local-transport.js.map +1 -0
- package/dist/host/src/ports.js +10 -1
- package/dist/host/src/ports.js.map +1 -1
- package/dist/host/src/process.d.ts +12 -0
- package/dist/host/src/process.js +59 -0
- package/dist/host/src/process.js.map +1 -0
- package/dist/host/src/rpc/client.js +2 -3
- package/dist/host/src/rpc/client.js.map +1 -1
- package/dist/host/src/rpc/server.d.ts +1 -0
- package/dist/host/src/rpc/server.js +12 -3
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen-h264.d.ts +15 -0
- package/dist/host/src/screen-h264.js +88 -0
- package/dist/host/src/screen-h264.js.map +1 -0
- package/dist/host/src/screen-pacer.d.ts +7 -1
- package/dist/host/src/screen-pacer.js +11 -5
- package/dist/host/src/screen-pacer.js.map +1 -1
- package/dist/host/src/screen-session.d.ts +35 -0
- package/dist/host/src/screen-session.js +26 -0
- package/dist/host/src/screen-session.js.map +1 -0
- package/dist/host/src/screen-viewer.d.ts +1 -1
- package/dist/host/src/screen-viewer.js +373 -42
- package/dist/host/src/screen-viewer.js.map +1 -1
- package/dist/host/src/screen.d.ts +3 -1
- package/dist/host/src/screen.js +560 -165
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/terminals.js +6 -3
- package/dist/host/src/terminals.js.map +1 -1
- package/dist/host/src/windows-screen.d.ts +38 -0
- package/dist/host/src/windows-screen.js +80 -0
- package/dist/host/src/windows-screen.js.map +1 -0
- package/dist/host/src/windows-video.d.ts +78 -0
- package/dist/host/src/windows-video.js +368 -0
- package/dist/host/src/windows-video.js.map +1 -0
- package/dist/host/src/worktrees.js +2 -2
- package/dist/host/src/worktrees.js.map +1 -1
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/rpc.d.ts +49 -0
- package/dist/wire/src/rpc.js +18 -0
- package/dist/wire/src/rpc.js.map +1 -1
- package/package.json +8 -6
|
@@ -421,6 +421,132 @@ function cleanShortcuts(list) {
|
|
|
421
421
|
}
|
|
422
422
|
return out;
|
|
423
423
|
}
|
|
424
|
+
|
|
425
|
+
/** A configuration belongs to this socket; only its latest acknowledgement may restore input. */
|
|
426
|
+
function createScreenSession(initial, effects) {
|
|
427
|
+
let desired = { active: true, display: 0, width: "1920" }, hidden = false;
|
|
428
|
+
let capabilities = null, opened = false, closed = false, scheduled = false;
|
|
429
|
+
let requestId = 0, pending = null, sentKey = "", error = "", inputReady = true, generation;
|
|
430
|
+
const clean = (next) => {
|
|
431
|
+
const out = {};
|
|
432
|
+
if (!next || typeof next !== "object") return out;
|
|
433
|
+
if (typeof next.active === "boolean") out.active = next.active;
|
|
434
|
+
if (Number.isSafeInteger(next.display) && next.display >= 0) out.display = next.display;
|
|
435
|
+
if (next.width === "native") out.width = "native";
|
|
436
|
+
else if ((typeof next.width === "string" || typeof next.width === "number") && Number.isInteger(Number(next.width)) && Number(next.width) >= 640 && Number(next.width) <= 3840) out.width = String(Number(next.width));
|
|
437
|
+
if ([30, 60, 120].includes(next.maxFps)) out.maxFps = next.maxFps;
|
|
438
|
+
return out;
|
|
439
|
+
};
|
|
440
|
+
desired = Object.assign(desired, clean(initial));
|
|
441
|
+
const original = { ...desired };
|
|
442
|
+
const state = () => ({ display: desired.display, width: desired.width, ...(desired.maxFps ? { maxFps: desired.maxFps } : {}), paused: !desired.active || hidden });
|
|
443
|
+
const key = () => JSON.stringify(state());
|
|
444
|
+
const targetChanged = () => ["display", "width", "maxFps"].some((name) => desired[name] !== original[name]);
|
|
445
|
+
const changed = () => effects.changed?.({ ...desired, paused: state().paused, inputReady, error });
|
|
446
|
+
const suspend = () => { effects.release(); inputReady = false; };
|
|
447
|
+
const pump = () => {
|
|
448
|
+
scheduled = false;
|
|
449
|
+
if (closed || !opened || !capabilities) return;
|
|
450
|
+
const next = state(), nextKey = key();
|
|
451
|
+
if (capabilities.configure) {
|
|
452
|
+
if (nextKey === sentKey) return;
|
|
453
|
+
suspend();
|
|
454
|
+
sentKey = nextKey;
|
|
455
|
+
pending = { requestId: ++requestId, key: nextKey, ...next };
|
|
456
|
+
effects.send({ t: "screen.configure", requestId, ...next });
|
|
457
|
+
} else {
|
|
458
|
+
if (targetChanged() && !next.paused) {
|
|
459
|
+
if (nextKey !== sentKey) effects.reload({ display: desired.display, width: desired.width, maxFps: desired.maxFps });
|
|
460
|
+
inputReady = false;
|
|
461
|
+
} else inputReady = !next.paused;
|
|
462
|
+
sentKey = nextKey;
|
|
463
|
+
}
|
|
464
|
+
changed();
|
|
465
|
+
};
|
|
466
|
+
const schedule = () => {
|
|
467
|
+
if (!scheduled && !closed) { scheduled = true; effects.defer(pump); }
|
|
468
|
+
};
|
|
469
|
+
return {
|
|
470
|
+
desired: () => ({ ...desired }),
|
|
471
|
+
active: () => !closed && !state().paused,
|
|
472
|
+
canInput: () => !closed && !state().paused && inputReady,
|
|
473
|
+
generation: () => generation,
|
|
474
|
+
supported: () => capabilities?.configure === true,
|
|
475
|
+
known: () => capabilities !== null,
|
|
476
|
+
open() {
|
|
477
|
+
if (closed) return;
|
|
478
|
+
opened = true;
|
|
479
|
+
if (state().paused) { suspend(); effects.send({ t: "hidden" }); }
|
|
480
|
+
schedule();
|
|
481
|
+
},
|
|
482
|
+
update(next) {
|
|
483
|
+
if (closed) return;
|
|
484
|
+
const before = key();
|
|
485
|
+
desired = Object.assign({}, desired, clean(next));
|
|
486
|
+
if (key() === before && !error) return;
|
|
487
|
+
suspend();
|
|
488
|
+
// A pause can reach the native receiver before this microtask runs. Even a
|
|
489
|
+
// quick return to the same target needs a fresh apply and input generation.
|
|
490
|
+
pending = null;
|
|
491
|
+
sentKey = "";
|
|
492
|
+
error = "";
|
|
493
|
+
if (!capabilities?.configure && opened && state().paused !== JSON.parse(before).paused) effects.send({ t: state().paused ? "hidden" : "shown" });
|
|
494
|
+
changed();
|
|
495
|
+
schedule();
|
|
496
|
+
},
|
|
497
|
+
visibility(nextHidden) {
|
|
498
|
+
if (closed || hidden === nextHidden) return;
|
|
499
|
+
hidden = nextHidden;
|
|
500
|
+
suspend();
|
|
501
|
+
pending = null;
|
|
502
|
+
sentKey = "";
|
|
503
|
+
error = "";
|
|
504
|
+
if (!capabilities?.configure && opened) effects.send({ t: state().paused ? "hidden" : "shown" });
|
|
505
|
+
changed();
|
|
506
|
+
schedule();
|
|
507
|
+
},
|
|
508
|
+
receive(message) {
|
|
509
|
+
if (closed || !message || typeof message !== "object") return;
|
|
510
|
+
if (message.t === "capabilities") {
|
|
511
|
+
capabilities = { ...message, configure: message.version === 1 && message.configure === true, pause: message.version === 1 && message.pause === true };
|
|
512
|
+
sentKey = "";
|
|
513
|
+
pending = null;
|
|
514
|
+
if (capabilities.configure || state().paused || targetChanged()) suspend();
|
|
515
|
+
else inputReady = true;
|
|
516
|
+
effects.report(capabilities);
|
|
517
|
+
changed();
|
|
518
|
+
schedule();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!pending || message.requestId !== pending.requestId || pending.key !== key()) return;
|
|
522
|
+
if (message.t !== "configured" && message.t !== "error") return;
|
|
523
|
+
if (message.t === "configured" && (message.display !== pending.display || message.paused !== pending.paused || !Number.isSafeInteger(message.generation) || message.generation < 0)) return;
|
|
524
|
+
inputReady = message.t === "configured" && !message.paused;
|
|
525
|
+
if (message.t === "configured") generation = message.generation;
|
|
526
|
+
error = message.t === "error" ? message.message || "屏幕配置失败,请重试" : "";
|
|
527
|
+
pending = null;
|
|
528
|
+
if (error) sentKey = "";
|
|
529
|
+
effects.report(message);
|
|
530
|
+
changed();
|
|
531
|
+
},
|
|
532
|
+
close() {
|
|
533
|
+
if (closed) return;
|
|
534
|
+
suspend();
|
|
535
|
+
closed = true;
|
|
536
|
+
pending = null;
|
|
537
|
+
changed();
|
|
538
|
+
},
|
|
539
|
+
reconnect() {
|
|
540
|
+
if (closed) return;
|
|
541
|
+
suspend();
|
|
542
|
+
capabilities = pending = null;
|
|
543
|
+
generation = undefined;
|
|
544
|
+
sentKey = "";
|
|
545
|
+
error = "";
|
|
546
|
+
changed();
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
424
550
|
`;
|
|
425
551
|
const BODY = String.raw `
|
|
426
552
|
const $ = (id) => document.getElementById(id);
|
|
@@ -428,8 +554,15 @@ const stage = $("stage"), picture = $("picture"), canvas = $("screen"), ctx = ca
|
|
|
428
554
|
const note = $("note"), bar = $("bar"), controlShell = $("control-shell"), orb = $("orb"), menu = $("menu"), connection = $("connection"), toast = $("toast"), keys = $("keys"), typing = $("typing"), shortcut = $("shortcut");
|
|
429
555
|
const sheet = $("sheet"), actions = $("actions"), maker = $("maker"), naming = $("makername"), composer = $("composer"), words = $("words");
|
|
430
556
|
const query = new URLSearchParams(location.search);
|
|
557
|
+
const windowsHost = document.body.dataset.platform === "win32";
|
|
558
|
+
if (windowsHost) Object.assign(MOD_SIGNS, { ctrl: "Ctrl+", alt: "Alt+", shift: "Shift+", cmd: "Win+" });
|
|
431
559
|
const app = window.ReactNativeWebView;
|
|
432
560
|
const tellApp = (message) => { if (app) app.postMessage(JSON.stringify(message)); };
|
|
561
|
+
const tellSession = (session) => {
|
|
562
|
+
const message = { type: "screenSession", session };
|
|
563
|
+
tellApp(message);
|
|
564
|
+
if (typeof window.__linkshellSession === "function") window.__linkshellSession(message);
|
|
565
|
+
};
|
|
433
566
|
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
434
567
|
const coarse = matchMedia("(pointer: coarse)").matches;
|
|
435
568
|
const android = /Android/i.test(navigator.userAgent);
|
|
@@ -442,7 +575,7 @@ const HINTS = {
|
|
|
442
575
|
};
|
|
443
576
|
let stored = null;
|
|
444
577
|
try { stored = localStorage.getItem("linkshell.screen.mode"); } catch {}
|
|
445
|
-
let mode = MODES.includes(query.get("mode")) ? query.get("mode") : MODES.includes(stored) ? stored : "view";
|
|
578
|
+
let mode = MODES.includes(query.get("mode")) ? query.get("mode") : MODES.includes(window.__linkshellChrome?.mode) ? window.__linkshellChrome.mode : MODES.includes(stored) ? stored : "view";
|
|
446
579
|
|
|
447
580
|
// What the app around the page has done to the phone's screen. A page in a plain browser has none of it.
|
|
448
581
|
// "clear" is the side of a phone lying down that has no camera in it.
|
|
@@ -464,7 +597,8 @@ const mods = new Set();
|
|
|
464
597
|
|
|
465
598
|
// ---- The picture ----------------------------------------------------------
|
|
466
599
|
|
|
467
|
-
|
|
600
|
+
let sessionError = "";
|
|
601
|
+
const say = (text) => { text = sessionError || text; note.textContent = text; note.classList.toggle("gone", !text); };
|
|
468
602
|
const round = (value, digits) => (value == null || !isFinite(value) ? null : +value.toFixed(digits === undefined ? 1 : digits));
|
|
469
603
|
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
470
604
|
function sps(unit) {
|
|
@@ -480,8 +614,48 @@ const wantVideo = !nativePicture && "RTCPeerConnection" in window && (!app || ch
|
|
|
480
614
|
// The iOS app owns the final transport fallback, so it can bypass a failed direct data channel.
|
|
481
615
|
const relayFallback = !nativePicture && !!app && (chrome.relayFallback === true || query.get("fallback") === "relay") && query.get("video") !== "0";
|
|
482
616
|
const unseeable = () => say("这个系统版本的浏览器内核不支持视频解码,请升级系统后再试。");
|
|
483
|
-
if (!nativePicture && !wantVideo && !("VideoDecoder" in window)) unseeable();
|
|
617
|
+
if (!windowsHost && !nativePicture && !wantVideo && !("VideoDecoder" in window)) unseeable();
|
|
484
618
|
let decoder, skipping = false, skipped = 0, arrived = 0, keyAsked = -Infinity;
|
|
619
|
+
const jpegStats = { frames: 0, bytes: 0, decode: 0, at: performance.now(), beforeFrames: 0, beforeBytes: 0, beforeDecode: 0, fps: 0, bitrateMbps: 0, decodeMs: 0 };
|
|
620
|
+
function jpegVitals() {
|
|
621
|
+
const now = performance.now(), elapsed = now - jpegStats.at;
|
|
622
|
+
if (elapsed >= 1000) {
|
|
623
|
+
const frames = jpegStats.frames - jpegStats.beforeFrames;
|
|
624
|
+
jpegStats.fps = round(frames * 1000 / elapsed);
|
|
625
|
+
jpegStats.bitrateMbps = round((jpegStats.bytes - jpegStats.beforeBytes) * 8 / (elapsed * 1000), 2);
|
|
626
|
+
jpegStats.decodeMs = frames ? round((jpegStats.decode - jpegStats.beforeDecode) / frames, 2) : 0;
|
|
627
|
+
jpegStats.at = now; jpegStats.beforeFrames = jpegStats.frames; jpegStats.beforeBytes = jpegStats.bytes; jpegStats.beforeDecode = jpegStats.decode;
|
|
628
|
+
}
|
|
629
|
+
return { width: content.w, height: content.h, codec: "JPEG", fps: jpegStats.fps, bitrateMbps: jpegStats.bitrateMbps, decodeMs: jpegStats.decodeMs, live: false };
|
|
630
|
+
}
|
|
631
|
+
let jpegPending = null, jpegDecoding = false, jpegGeneration = 0;
|
|
632
|
+
async function drawJpeg() {
|
|
633
|
+
if (jpegDecoding) return;
|
|
634
|
+
jpegDecoding = true;
|
|
635
|
+
try {
|
|
636
|
+
while (jpegPending && ws.readyState === 1 && session.active()) {
|
|
637
|
+
const next = jpegPending;
|
|
638
|
+
jpegPending = null;
|
|
639
|
+
const generation = jpegGeneration;
|
|
640
|
+
const started = performance.now();
|
|
641
|
+
const bitmap = await createImageBitmap(new Blob([next.unit], { type: "image/jpeg" }));
|
|
642
|
+
try {
|
|
643
|
+
if (generation !== jpegGeneration || ws.readyState !== 1 || !session.active()) continue;
|
|
644
|
+
if (live) show(false);
|
|
645
|
+
if (content.w !== bitmap.width || content.h !== bitmap.height) {
|
|
646
|
+
canvas.width = content.w = bitmap.width;
|
|
647
|
+
canvas.height = content.h = bitmap.height;
|
|
648
|
+
layout();
|
|
649
|
+
}
|
|
650
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
651
|
+
jpegStats.frames++; jpegStats.decode += performance.now() - started;
|
|
652
|
+
shownUpTo(next.seq);
|
|
653
|
+
say("");
|
|
654
|
+
} finally { bitmap.close(); }
|
|
655
|
+
}
|
|
656
|
+
} catch (error) { say("屏幕画面解码失败:" + error.message); }
|
|
657
|
+
finally { jpegDecoding = false; }
|
|
658
|
+
}
|
|
485
659
|
// Only a keyframe gets a decoder that lost its place going again, and the host makes them when asked rather than by the clock.
|
|
486
660
|
const askKey = () => { const now = performance.now(); if (now - keyAsked > 500) { keyAsked = now; send({ t: "keyframe" }); } };
|
|
487
661
|
// The host is told which frame is on screen: that is how it knows when this end has fallen behind,
|
|
@@ -492,6 +666,7 @@ const lag = Number(query.get("lag")) || 0, lagUntil = performance.now() + (Numbe
|
|
|
492
666
|
function shownUpTo(seq) {
|
|
493
667
|
if (seq > lastShown) lastShown = seq;
|
|
494
668
|
if (lag && performance.now() < lagUntil) { const late = lastShown; setTimeout(() => send({ t: "ack", n: late }), lag); return; }
|
|
669
|
+
if (windowsHost) return send({ t: "ack", n: lastShown });
|
|
495
670
|
if (!telling) telling = setTimeout(() => { telling = 0; send({ t: "ack", n: lastShown }); }, 40);
|
|
496
671
|
}
|
|
497
672
|
let lighterAt = -Infinity, failures = 0;
|
|
@@ -499,12 +674,40 @@ const asked = new URLSearchParams(location.search);
|
|
|
499
674
|
asked.delete("video");
|
|
500
675
|
if (wantVideo) asked.set("video", "1");
|
|
501
676
|
// How wide the video track may be (the socket's picture has its own ladder). The app says it in the address;
|
|
502
|
-
// a plain browser remembers its own. A
|
|
677
|
+
// a plain browser remembers its own. A capable host applies a new choice to this same track.
|
|
503
678
|
const WIDTHS = ["1280", "1920", "2560", "native"];
|
|
504
679
|
let keptWidth = null;
|
|
505
680
|
try { keptWidth = localStorage.getItem("linkshell.screen.width"); } catch {}
|
|
506
|
-
|
|
681
|
+
let width = WIDTHS.includes(query.get("width")) ? query.get("width") : WIDTHS.includes(keptWidth) ? keptWidth : "1920";
|
|
682
|
+
const initialSession = typeof window.__linkshellSession === "object" && window.__linkshellSession ? window.__linkshellSession : {};
|
|
683
|
+
const session = createScreenSession({ active: true, display: Number(query.get("display")) || 0, width, maxFps: Number(query.get("maxFps")) || undefined, ...initialSession }, {
|
|
684
|
+
send: (message) => send(message),
|
|
685
|
+
defer: (action) => Promise.resolve().then(action),
|
|
686
|
+
release: () => {
|
|
687
|
+
// A decoded bitmap from before a pause or display change belongs to the old surface.
|
|
688
|
+
jpegGeneration += 1;
|
|
689
|
+
jpegPending = null;
|
|
690
|
+
releaseInput();
|
|
691
|
+
},
|
|
692
|
+
report: tellSession,
|
|
693
|
+
reload: (state) => reloadSession(state),
|
|
694
|
+
changed: (state) => {
|
|
695
|
+
width = state.width;
|
|
696
|
+
const previousError = sessionError;
|
|
697
|
+
sessionError = state.error;
|
|
698
|
+
if (sessionError) say(sessionError);
|
|
699
|
+
else if (state.paused) say("屏幕已暂停");
|
|
700
|
+
else if (!state.inputReady) say("正在调整屏幕…");
|
|
701
|
+
else if (previousError || note.textContent === "屏幕已暂停" || note.textContent === "正在调整屏幕…") say("");
|
|
702
|
+
if (state.inputReady && mode !== "view") askControl();
|
|
703
|
+
refreshWidths();
|
|
704
|
+
refresh();
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
width = session.desired().width;
|
|
507
708
|
asked.set("width", width);
|
|
709
|
+
asked.set("display", String(session.desired().display));
|
|
710
|
+
if (session.desired().maxFps) asked.set("maxFps", String(session.desired().maxFps));
|
|
508
711
|
// A page that is a srcdoc (the gateway's web client puts it in one) has no address of its own to load again:
|
|
509
712
|
// it asks the page around it to, where that page says it can, and offers no choice where it can't.
|
|
510
713
|
const framed = document.URL === "about:srcdoc";
|
|
@@ -515,19 +718,52 @@ const ws = nativePicture
|
|
|
515
718
|
: new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/stream?" + asked);
|
|
516
719
|
ws.binaryType = "arraybuffer";
|
|
517
720
|
const send = (message) => { if (ws.readyState === 1) ws.send(JSON.stringify(message)); };
|
|
518
|
-
|
|
721
|
+
let capabilityTimer = 0, socketClosed = false;
|
|
722
|
+
function waitForCapabilities() {
|
|
723
|
+
clearTimeout(capabilityTimer);
|
|
724
|
+
capabilityTimer = setTimeout(() => {
|
|
725
|
+
if (!session.known()) session.receive({ t: "capabilities", version: 1, configure: false, pause: false, retainMs: 0 });
|
|
726
|
+
}, 10000);
|
|
727
|
+
}
|
|
728
|
+
ws.onopen = () => { session.open(); waitForCapabilities(); if (mode !== "view") askControl(); };
|
|
519
729
|
// Out of sight, a page answers late (its timers slow to one a second) and shows nothing: the host sends no
|
|
520
730
|
// picture down the socket meanwhile, and starts again at a keyframe.
|
|
521
|
-
document.addEventListener("visibilitychange", () =>
|
|
731
|
+
document.addEventListener("visibilitychange", () => session.visibility(document.hidden));
|
|
732
|
+
window.linkshellSession = (state) => session.update(state);
|
|
733
|
+
|
|
734
|
+
function reloadSession(state) {
|
|
735
|
+
if (nativePicture) return;
|
|
736
|
+
if (reloadVia) return reloadVia({ ...state, mode });
|
|
737
|
+
const next = new URLSearchParams(location.search);
|
|
738
|
+
for (const name of ["display", "width", "maxFps"]) if (state[name] !== undefined) next.set(name, String(state[name]));
|
|
739
|
+
next.set("mode", mode);
|
|
740
|
+
location.replace(location.pathname + "?" + next);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function refreshWidths() {
|
|
744
|
+
for (const tile of $("widths").querySelectorAll(".tile")) {
|
|
745
|
+
tile.classList.toggle("on", tile.dataset.width === width);
|
|
746
|
+
tile.setAttribute("aria-pressed", String(tile.dataset.width === width));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
522
749
|
ws.onmessage = (event) => {
|
|
523
750
|
if (typeof event.data === "string") return heard(JSON.parse(event.data));
|
|
524
|
-
if (!(
|
|
751
|
+
if (!(event.data instanceof ArrayBuffer) || event.data.byteLength < 6) return;
|
|
525
752
|
// A frame: whether it is a keyframe, its number, the picture.
|
|
526
753
|
const head = new DataView(event.data);
|
|
527
754
|
const key = head.getUint8(0) === 1;
|
|
528
755
|
const seq = head.getUint32(1);
|
|
529
756
|
const unit = new Uint8Array(event.data, 5);
|
|
530
757
|
arrived = seq;
|
|
758
|
+
if (head.getUint8(0) === 2) {
|
|
759
|
+
if (!session.active()) return shownUpTo(seq);
|
|
760
|
+
jpegStats.bytes += event.data.byteLength;
|
|
761
|
+
// A latest-frame mailbox keeps decoding work bounded when the viewer is slow.
|
|
762
|
+
jpegPending = { seq, unit };
|
|
763
|
+
void drawJpeg();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (!("VideoDecoder" in window)) return unseeable();
|
|
531
767
|
// A decoder that can't keep up skips to the next keyframe; what it skips has still arrived.
|
|
532
768
|
if (decoder && !key && (skipping || decoder.decodeQueueSize > 8)) { skipping = true; skipped += 1; askKey(); return shownUpTo(seq); }
|
|
533
769
|
skipping = false;
|
|
@@ -559,18 +795,29 @@ ws.onmessage = (event) => {
|
|
|
559
795
|
decoder.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: seq, data: unit }));
|
|
560
796
|
};
|
|
561
797
|
ws.onclose = () => {
|
|
798
|
+
if (socketClosed) return;
|
|
799
|
+
socketClosed = true;
|
|
800
|
+
clearTimeout(capabilityTimer);
|
|
801
|
+
session.close();
|
|
562
802
|
// A video track is this socket's: without the one there is no finding the other again, and no hands.
|
|
563
803
|
drop();
|
|
564
804
|
if (requestRelay()) return;
|
|
805
|
+
tellApp({ type: "screenClosed" });
|
|
565
806
|
if (blocked || note.classList.contains("gone") || note.textContent.startsWith("正在")) say("屏幕连接已断开");
|
|
566
807
|
blocked = false;
|
|
567
808
|
};
|
|
568
809
|
|
|
569
810
|
function heard(message) {
|
|
811
|
+
if (message.session) {
|
|
812
|
+
if (message.session.t === "capabilities") clearTimeout(capabilityTimer);
|
|
813
|
+
return session.receive(message.session);
|
|
814
|
+
}
|
|
570
815
|
if (message.error) return say(message.error);
|
|
571
816
|
if (message.rtc) return signal(message.rtc);
|
|
572
817
|
if (message.pong) return meter && meter.pong(message.pong);
|
|
573
818
|
if (message.restart) {
|
|
819
|
+
jpegGeneration += 1;
|
|
820
|
+
jpegPending = null;
|
|
574
821
|
// The host starts the stream again at another quality: a new decoder for it.
|
|
575
822
|
try { if (decoder) decoder.close(); } catch {}
|
|
576
823
|
decoder = undefined;
|
|
@@ -635,7 +882,7 @@ function signal(message) {
|
|
|
635
882
|
if (!mine) return;
|
|
636
883
|
if (message.t === "offer") {
|
|
637
884
|
tried = true;
|
|
638
|
-
patience = setTimeout(() =>
|
|
885
|
+
patience = setTimeout(() => connectionPatience(mine), patienceSeconds * 1000);
|
|
639
886
|
if (failAfter) setTimeout(() => { if (rtc === mine) giveUp("asked to (failAfter)"); }, failAfter * 1000);
|
|
640
887
|
// The answer is the browser's own, as it made it: the sender's playout-delay header has to survive in it.
|
|
641
888
|
mine.described = mine.pc.setRemoteDescription({ type: "offer", sdp: message.sdp })
|
|
@@ -702,16 +949,23 @@ function watch(mine) {
|
|
|
702
949
|
}
|
|
703
950
|
|
|
704
951
|
function away(mine, since) {
|
|
952
|
+
if (rtc !== mine) return;
|
|
705
953
|
// A page that was put aside (another app, another tab) could not hear the path come back: once it is
|
|
706
954
|
// looked at again it is given the time again.
|
|
707
955
|
const now = performance.now();
|
|
708
|
-
if (
|
|
956
|
+
if (!session.active() || now - since > 6000) mine.away = setTimeout(() => away(mine, now), 4000);
|
|
709
957
|
else giveUp("disconnected for more than 4 s");
|
|
710
958
|
}
|
|
711
959
|
|
|
960
|
+
function connectionPatience(mine) {
|
|
961
|
+
if (rtc !== mine) return;
|
|
962
|
+
if (!session.active()) patience = setTimeout(() => connectionPatience(mine), patienceSeconds * 1000);
|
|
963
|
+
else giveUp("not connected within " + patienceSeconds + " s of the offer");
|
|
964
|
+
}
|
|
965
|
+
|
|
712
966
|
function frameless() {
|
|
713
967
|
// A page nobody is looking at is shown nothing, and one waiting for a tap has not begun: neither is the path's doing.
|
|
714
|
-
if (
|
|
968
|
+
if (!session.active() || blocked) patience = setTimeout(frameless, 4000);
|
|
715
969
|
else giveUp("connected, but no frame within 4 s");
|
|
716
970
|
}
|
|
717
971
|
|
|
@@ -758,7 +1012,7 @@ function fallBack() {
|
|
|
758
1012
|
if (requestRelay()) return;
|
|
759
1013
|
if (blocked) say("正在连接电脑屏幕…");
|
|
760
1014
|
blocked = false;
|
|
761
|
-
if (!("VideoDecoder" in window)) return unseeable();
|
|
1015
|
+
if (!windowsHost && !("VideoDecoder" in window)) return unseeable();
|
|
762
1016
|
if (tried) hint(live ? "直连断开了:已改用兼容方式传画面,延迟会高一些" : "没能和电脑直连:已改用兼容方式传画面,延迟会高一些", 5000);
|
|
763
1017
|
}
|
|
764
1018
|
|
|
@@ -859,6 +1113,7 @@ async function presentTrack(track) {
|
|
|
859
1113
|
if (mine.stopped || !mine.latest) return;
|
|
860
1114
|
const frame = mine.latest;
|
|
861
1115
|
mine.latest = null;
|
|
1116
|
+
if (!session.active()) { frame.close(); return; }
|
|
862
1117
|
try {
|
|
863
1118
|
// The canvas carries the frame's own pixels; the layout gives it its place on the page.
|
|
864
1119
|
if (gpu.width !== frame.displayWidth || gpu.height !== frame.displayHeight) {
|
|
@@ -914,6 +1169,7 @@ async function presentTrack(track) {
|
|
|
914
1169
|
if (value) value.close();
|
|
915
1170
|
break;
|
|
916
1171
|
}
|
|
1172
|
+
if (!session.active()) { value.close(); continue; }
|
|
917
1173
|
if (mine.latest) {
|
|
918
1174
|
mine.latest.close();
|
|
919
1175
|
mine.dropped += 1;
|
|
@@ -1270,7 +1526,7 @@ function expandControls(open) {
|
|
|
1270
1526
|
}
|
|
1271
1527
|
function armControlsIdle() {
|
|
1272
1528
|
clearTimeout(controls.timer);
|
|
1273
|
-
if (!controlsMayCollapse() || controls.target === 0) return;
|
|
1529
|
+
if (!session.active() || !controlsMayCollapse() || controls.target === 0) return;
|
|
1274
1530
|
controls.timer = setTimeout(() => { if (controlsMayCollapse()) expandControls(false); }, 3000);
|
|
1275
1531
|
}
|
|
1276
1532
|
function layoutControls(safe, anchor) {
|
|
@@ -1310,7 +1566,7 @@ reducedMotion.addEventListener("change", () => {
|
|
|
1310
1566
|
});
|
|
1311
1567
|
orb.addEventListener("pointerdown", (event) => {
|
|
1312
1568
|
event.preventDefault(); event.stopPropagation();
|
|
1313
|
-
if (!controls.orb) return;
|
|
1569
|
+
if (!session.active() || !controls.orb) return;
|
|
1314
1570
|
try { orb.setPointerCapture(event.pointerId); } catch {}
|
|
1315
1571
|
clearTimeout(controls.timer);
|
|
1316
1572
|
controls.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, origin: { ...controls.orb }, moved: false };
|
|
@@ -1542,12 +1798,12 @@ function panBy(dx, dy) {
|
|
|
1542
1798
|
|
|
1543
1799
|
// ---- Sending --------------------------------------------------------------
|
|
1544
1800
|
|
|
1545
|
-
const controlling = () => mode !== "view" && control.available && control.trusted;
|
|
1801
|
+
const controlling = () => session.canInput() && mode !== "view" && control.available && control.trusted;
|
|
1546
1802
|
|
|
1547
1803
|
function askControl() {
|
|
1548
|
-
if (control.asked) return;
|
|
1804
|
+
if (control.asked || !session.canInput()) return;
|
|
1549
1805
|
control.asked = true;
|
|
1550
|
-
send({ t: "control" });
|
|
1806
|
+
send({ t: "control", ...(session.generation() !== undefined ? { screenGeneration: session.generation() } : {}) });
|
|
1551
1807
|
}
|
|
1552
1808
|
|
|
1553
1809
|
// Where an event travels: down the socket, in order, until a video's two channels to the app are open; then
|
|
@@ -1562,6 +1818,8 @@ function askControl() {
|
|
|
1562
1818
|
// after something sent later.
|
|
1563
1819
|
let wired = false, held = 0, sure = true, sent = 0, rest = 0, movedAt = -Infinity;
|
|
1564
1820
|
const trail = [];
|
|
1821
|
+
const heldButtons = new Map();
|
|
1822
|
+
let inputEpoch = 0;
|
|
1565
1823
|
|
|
1566
1824
|
function lanes() {
|
|
1567
1825
|
if (nativePicture) { wired = true; return; }
|
|
@@ -1572,15 +1830,23 @@ function lanes() {
|
|
|
1572
1830
|
}
|
|
1573
1831
|
|
|
1574
1832
|
function post(message, lane) {
|
|
1833
|
+
if (!session.canInput()) return;
|
|
1834
|
+
if (session.generation() !== undefined) message.screenGeneration = session.generation();
|
|
1835
|
+
transmitInput(message, lane);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function transmitInput(message, lane) {
|
|
1575
1839
|
if (nativePicture) return nativePost({ kind: "input", message, replaceable: lane === "pointer" });
|
|
1576
1840
|
if (!wired) return send(message);
|
|
1577
1841
|
message.i = ++sent;
|
|
1578
|
-
rtc.channels[lane]
|
|
1842
|
+
const channel = rtc && rtc.channels[lane];
|
|
1843
|
+
if (channel && channel.readyState === "open") channel.send(JSON.stringify(message));
|
|
1579
1844
|
}
|
|
1580
1845
|
|
|
1581
1846
|
let moveWaiting = false;
|
|
1582
1847
|
/** Says where the pointer is: in order, when something is about to happen there. */
|
|
1583
1848
|
function tell(placed) {
|
|
1849
|
+
if (!session.canInput()) { moveWaiting = false; return; }
|
|
1584
1850
|
lanes();
|
|
1585
1851
|
moveWaiting = false;
|
|
1586
1852
|
const now = performance.now(), lane = laneOf("move", placed, held > 0, false);
|
|
@@ -1597,6 +1863,7 @@ function tell(placed) {
|
|
|
1597
1863
|
const flush = () => { if (moveWaiting) tell(false); };
|
|
1598
1864
|
// A pointer position a frame: as often as the picture could show it.
|
|
1599
1865
|
function moved() {
|
|
1866
|
+
if (!session.canInput()) return;
|
|
1600
1867
|
if (!moveWaiting) requestAnimationFrame(flush);
|
|
1601
1868
|
moveWaiting = true;
|
|
1602
1869
|
movedAt = performance.now();
|
|
@@ -1605,13 +1872,64 @@ function moved() {
|
|
|
1605
1872
|
}
|
|
1606
1873
|
// Everything else goes at once, after the position it happens at.
|
|
1607
1874
|
function act(message) {
|
|
1875
|
+
if (!session.canInput()) return;
|
|
1608
1876
|
lanes();
|
|
1609
1877
|
const placed = moveWaiting || !sure;
|
|
1610
1878
|
if (placed) tell(true);
|
|
1611
|
-
if (message.t === "down") held
|
|
1879
|
+
if (message.t === "down") { heldButtons.set(message.b, { ...message }); held = heldButtons.size; }
|
|
1612
1880
|
if (message.t === "scroll" && placed && gesture) gesture.tied = true;
|
|
1613
1881
|
post(message, laneOf(message.t, placed, held > 0, !!gesture && !!gesture.tied));
|
|
1614
|
-
if (message.t === "up")
|
|
1882
|
+
if (message.t === "up") { heldButtons.delete(message.b); held = heldButtons.size; }
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/** Input queued for the old display must never be replayed when the retained page is shown again. */
|
|
1886
|
+
function releaseInput() {
|
|
1887
|
+
inputEpoch += 1;
|
|
1888
|
+
control.asked = false;
|
|
1889
|
+
lanes();
|
|
1890
|
+
for (const button of heldButtons.values()) {
|
|
1891
|
+
const released = { ...button, t: "up" };
|
|
1892
|
+
if (session.generation() !== undefined) released.screenGeneration = session.generation();
|
|
1893
|
+
transmitInput(released, "input");
|
|
1894
|
+
}
|
|
1895
|
+
heldButtons.clear();
|
|
1896
|
+
held = 0;
|
|
1897
|
+
sure = true;
|
|
1898
|
+
moveWaiting = false;
|
|
1899
|
+
clearTimeout(rest);
|
|
1900
|
+
clearTimeout(fade);
|
|
1901
|
+
clearTimeout(settling);
|
|
1902
|
+
clearTimeout(toastTimer);
|
|
1903
|
+
clearTimeout(disarming);
|
|
1904
|
+
clearInterval(statusTimer);
|
|
1905
|
+
clearTimeout(controls.timer);
|
|
1906
|
+
if (gesture) clearTimeout(gesture.hold);
|
|
1907
|
+
for (const id of touches.keys()) { try { stage.releasePointerCapture(id); } catch {} }
|
|
1908
|
+
touches.clear();
|
|
1909
|
+
gesture = lastTap = mouseHeld = null;
|
|
1910
|
+
mouseClicks = { t: 0, n: 0, x: 0, y: 0, b: 0 };
|
|
1911
|
+
movedAt = -Infinity;
|
|
1912
|
+
trail.length = 0;
|
|
1913
|
+
mods.clear();
|
|
1914
|
+
composing = writing = false;
|
|
1915
|
+
composed = "";
|
|
1916
|
+
leftover = wanted = null;
|
|
1917
|
+
askedAt = -Infinity;
|
|
1918
|
+
keysOpen = false;
|
|
1919
|
+
if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur();
|
|
1920
|
+
resetTyping();
|
|
1921
|
+
shortcut.value = "";
|
|
1922
|
+
disarm();
|
|
1923
|
+
pointer.classList.remove("pressed", "on");
|
|
1924
|
+
toast.classList.remove("on");
|
|
1925
|
+
if (controls.drag) { try { orb.releasePointerCapture(controls.drag.id); } catch {} }
|
|
1926
|
+
controls.drag = null;
|
|
1927
|
+
for (const element of pressedControls) element.style.scale = "";
|
|
1928
|
+
pressedControls.clear();
|
|
1929
|
+
finishPresentation();
|
|
1930
|
+
drawKeys();
|
|
1931
|
+
clearTimeout(controls.timer);
|
|
1932
|
+
clearTimeout(settling);
|
|
1615
1933
|
}
|
|
1616
1934
|
|
|
1617
1935
|
let fade = 0;
|
|
@@ -1687,7 +2005,7 @@ function endOne() {
|
|
|
1687
2005
|
}
|
|
1688
2006
|
|
|
1689
2007
|
stage.addEventListener("pointerdown", (event) => {
|
|
1690
|
-
if (presentation.active) { event.preventDefault(); return; }
|
|
2008
|
+
if (!session.active() || presentation.active) { event.preventDefault(); return; }
|
|
1691
2009
|
if (!immersiveControls() || controls.target > 0) wake();
|
|
1692
2010
|
// A touch that closes the menu, or the shortcuts, does nothing else.
|
|
1693
2011
|
const closing = [closeMenu(), closeSheet()].some(Boolean);
|
|
@@ -1720,7 +2038,7 @@ stage.addEventListener("pointerdown", (event) => {
|
|
|
1720
2038
|
});
|
|
1721
2039
|
|
|
1722
2040
|
stage.addEventListener("pointermove", (event) => {
|
|
1723
|
-
if (presentation.active) return;
|
|
2041
|
+
if (!session.active() || presentation.active) return;
|
|
1724
2042
|
if (event.pointerType === "mouse") return mouseMove(event);
|
|
1725
2043
|
const touch = touches.get(event.pointerId);
|
|
1726
2044
|
if (!touch || !gesture) return;
|
|
@@ -1852,7 +2170,7 @@ function mouseUp(event) {
|
|
|
1852
2170
|
}
|
|
1853
2171
|
stage.addEventListener("wheel", (event) => {
|
|
1854
2172
|
event.preventDefault();
|
|
1855
|
-
if (presentation.active) return;
|
|
2173
|
+
if (!session.active() || presentation.active) return;
|
|
1856
2174
|
if (controlling() && !event.ctrlKey) return act({ t: "scroll", dx: -event.deltaX, dy: -event.deltaY });
|
|
1857
2175
|
zoomTo(zoom * Math.exp(-event.deltaY / 300), { x: event.clientX, y: event.clientY });
|
|
1858
2176
|
}, { passive: false });
|
|
@@ -1990,6 +2308,7 @@ const keyboardShown = () => keyboardIsUp({
|
|
|
1990
2308
|
|
|
1991
2309
|
/** Brings the phone's keyboard up for a field. Within a touch: a browser gives a keyboard to nothing else. */
|
|
1992
2310
|
function ask(field, again) {
|
|
2311
|
+
if (!session.canInput()) return;
|
|
1993
2312
|
// A keyboard goes to the view the keys go to, and inside an app that is not always the page: the app
|
|
1994
2313
|
// sees to it, and has the page ask once more (linkshellKeyboard) if no keyboard has come.
|
|
1995
2314
|
if (!again) tellApp({ type: "keyboard" });
|
|
@@ -2055,7 +2374,8 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) sett
|
|
|
2055
2374
|
|
|
2056
2375
|
/** A control that works on touch-up and never takes the focus (the keyboard stays as it is). */
|
|
2057
2376
|
function tappable(element, action) {
|
|
2058
|
-
|
|
2377
|
+
let epoch = -1;
|
|
2378
|
+
element.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); if (!session.active()) return; epoch = inputEpoch; wake(); pressFeedback(element, true); });
|
|
2059
2379
|
element.addEventListener("pointercancel", () => pressFeedback(element, false));
|
|
2060
2380
|
element.addEventListener("pointerleave", () => pressFeedback(element, false));
|
|
2061
2381
|
element.addEventListener("touchstart", (event) => event.preventDefault(), { passive: false });
|
|
@@ -2064,6 +2384,8 @@ function tappable(element, action) {
|
|
|
2064
2384
|
event.preventDefault();
|
|
2065
2385
|
event.stopPropagation();
|
|
2066
2386
|
pressFeedback(element, false);
|
|
2387
|
+
if (!session.active() || epoch !== inputEpoch) return;
|
|
2388
|
+
epoch = -1;
|
|
2067
2389
|
const box = element.getBoundingClientRect(), cx = box.left + box.width / 2, cy = box.top + box.height / 2;
|
|
2068
2390
|
if (Math.abs(event.clientX - cx) <= element.offsetWidth / 2 && Math.abs(event.clientY - cy) <= element.offsetHeight / 2) action();
|
|
2069
2391
|
});
|
|
@@ -2072,7 +2394,7 @@ function tappable(element, action) {
|
|
|
2072
2394
|
/** The same inside something that scrolls: the touch is left to the browser, and counts if it stayed where it landed. */
|
|
2073
2395
|
function pressable(element, action) {
|
|
2074
2396
|
let from = null;
|
|
2075
|
-
element.addEventListener("pointerdown", (event) => { event.stopPropagation(); wake(); pressFeedback(element, true); from = { id: event.pointerId, x: event.clientX, y: event.clientY }; });
|
|
2397
|
+
element.addEventListener("pointerdown", (event) => { event.stopPropagation(); if (!session.active()) return; wake(); pressFeedback(element, true); from = { id: event.pointerId, x: event.clientX, y: event.clientY, epoch: inputEpoch }; });
|
|
2076
2398
|
element.addEventListener("pointercancel", () => { from = null; pressFeedback(element, false); });
|
|
2077
2399
|
element.addEventListener("pointerleave", () => pressFeedback(element, false));
|
|
2078
2400
|
// The mouse's events a browser makes up after a touch would land on whatever the action has left under the
|
|
@@ -2084,7 +2406,7 @@ function pressable(element, action) {
|
|
|
2084
2406
|
from = null;
|
|
2085
2407
|
pressFeedback(element, false);
|
|
2086
2408
|
event.stopPropagation();
|
|
2087
|
-
if (was && was.id === event.pointerId && Math.hypot(event.clientX - was.x, event.clientY - was.y) < SLOP) action();
|
|
2409
|
+
if (session.active() && was && was.epoch === inputEpoch && was.id === event.pointerId && Math.hypot(event.clientX - was.x, event.clientY - was.y) < SLOP) action();
|
|
2088
2410
|
});
|
|
2089
2411
|
}
|
|
2090
2412
|
|
|
@@ -2103,6 +2425,8 @@ function hint(text, ms) {
|
|
|
2103
2425
|
|
|
2104
2426
|
/** Says why a controlling mode is not controlling, or how it works once it is. */
|
|
2105
2427
|
function explain(was) {
|
|
2428
|
+
if (!session.active()) return;
|
|
2429
|
+
if (!session.canInput()) return hint("正在调整屏幕…", 0);
|
|
2106
2430
|
if (!control.known) return hint("正在准备控制电脑…", 0);
|
|
2107
2431
|
if (!control.available) return hint(control.reason || "这台电脑暂时不能被控制");
|
|
2108
2432
|
if (!control.trusted) {
|
|
@@ -2220,7 +2544,7 @@ if (typeof window.__linkshellReport === "function") {
|
|
|
2220
2544
|
previous = stats?.raw ?? null;
|
|
2221
2545
|
window.__linkshellReport(stats
|
|
2222
2546
|
? { width: stats.width, height: stats.height, fps: stats.fps, codec: stats.codec, bitrateMbps: stats.bitrateMbps, presented: stats.presented, live: true }
|
|
2223
|
-
: { width: content.w, height: content.h, live: false });
|
|
2547
|
+
: windowsHost ? jpegVitals() : { width: content.w, height: content.h, live: false });
|
|
2224
2548
|
} catch { /* A reconnect can close getStats while it is in flight. */ }
|
|
2225
2549
|
finally { pending = false; }
|
|
2226
2550
|
}, 2000);
|
|
@@ -2237,12 +2561,17 @@ async function status() {
|
|
|
2237
2561
|
layout();
|
|
2238
2562
|
};
|
|
2239
2563
|
// The choice is the track's: the socket's picture follows its own ladder.
|
|
2240
|
-
const choosing = live && canChooseWidth;
|
|
2564
|
+
const choosing = (live || windowsHost) && (canChooseWidth || session.supported());
|
|
2241
2565
|
if ($("widths").classList.contains("gone") === choosing) {
|
|
2242
2566
|
$("widths").classList.toggle("gone", !choosing);
|
|
2243
2567
|
layout();
|
|
2244
2568
|
}
|
|
2245
2569
|
const way = !content.w ? "正在连接…" : live ? "直连 · 视频" : (relayed ? "中继" : "直连") + " · 兼容\n" + content.w + "×" + content.h;
|
|
2570
|
+
if (windowsHost && !live && content.w) {
|
|
2571
|
+
const stats = jpegVitals();
|
|
2572
|
+
put(way + "\n" + stats.fps + " 帧/秒 · " + stats.bitrateMbps + " Mbps");
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2246
2575
|
if (nativePicture) {
|
|
2247
2576
|
nativePost({ kind: "stats" });
|
|
2248
2577
|
const detail = [content.w ? content.w + "×" + content.h : null,
|
|
@@ -2294,12 +2623,7 @@ for (const tile of $("widths").querySelectorAll(".tile")) {
|
|
|
2294
2623
|
if (chosen === width) return;
|
|
2295
2624
|
try { localStorage.setItem("linkshell.screen.width", chosen); } catch {}
|
|
2296
2625
|
tellApp({ type: "width", width: chosen });
|
|
2297
|
-
|
|
2298
|
-
if (reloadVia) return reloadVia({ width: chosen, mode });
|
|
2299
|
-
const next = new URLSearchParams(location.search);
|
|
2300
|
-
next.set("width", chosen);
|
|
2301
|
-
next.set("mode", mode);
|
|
2302
|
-
location.replace(location.pathname + "?" + next);
|
|
2626
|
+
window.linkshellSession({ width: chosen });
|
|
2303
2627
|
});
|
|
2304
2628
|
}
|
|
2305
2629
|
pressable($("full"), () => {
|
|
@@ -2335,7 +2659,11 @@ $("composekey").innerHTML = ICON.compose;
|
|
|
2335
2659
|
// Keys for the computer that a phone's keyboard has not got, one tap each, by what they are for: name, key,
|
|
2336
2660
|
// modifiers, and how the sheet takes the tap. "again": it stays, for another (the rest close it: what comes
|
|
2337
2661
|
// next is on the picture). "sure": asked twice, being a slip of the finger away from losing work.
|
|
2338
|
-
const QUICK = [
|
|
2662
|
+
const QUICK = windowsHost ? [
|
|
2663
|
+
["常用", [["复制", "c", "ctrl"], ["粘贴", "v", "ctrl"], ["剪切", "x", "ctrl"], ["全选", "a", "ctrl"], ["撤销", "z", "ctrl", "again"], ["重做", "y", "ctrl", "again"], ["保存", "s", "ctrl"], ["查找", "f", "ctrl"]]],
|
|
2664
|
+
["窗口与应用", [["切换应用", "tab", "alt", "again"], ["新标签", "t", "ctrl"], ["关闭标签", "w", "ctrl"], ["关闭窗口", "f4", "alt", "sure"], ["显示桌面", "d", "cmd"]]],
|
|
2665
|
+
["系统", [["切换输入法", "space", "cmd", "again"], ["截图工具", "s", "shift cmd"], ["任务管理器", "escape", "ctrl shift"], ["锁定屏幕", "l", "cmd", "sure"]]],
|
|
2666
|
+
] : [
|
|
2339
2667
|
["常用", [["复制", "c", "cmd"], ["粘贴", "v", "cmd"], ["剪切", "x", "cmd"], ["全选", "a", "cmd"], ["撤销", "z", "cmd", "again"], ["重做", "z", "shift cmd", "again"],
|
|
2340
2668
|
["保存", "s", "cmd"], ["查找", "f", "cmd"]]],
|
|
2341
2669
|
["窗口与应用", [["切换应用", "tab", "cmd", "again"], ["同应用窗口", "\x60", "cmd", "again"], ["新标签", "t", "cmd"], ["关闭窗口", "w", "cmd"], ["最小化", "m", "cmd"],
|
|
@@ -2451,7 +2779,7 @@ function drawMaker() {
|
|
|
2451
2779
|
function showMaker(on) {
|
|
2452
2780
|
if (on) {
|
|
2453
2781
|
making.clear();
|
|
2454
|
-
making.add("cmd");
|
|
2782
|
+
making.add(windowsHost ? "ctrl" : "cmd");
|
|
2455
2783
|
makerKey.value = "a";
|
|
2456
2784
|
naming.value = "";
|
|
2457
2785
|
drawMaker();
|
|
@@ -2761,6 +3089,7 @@ if (!nativePicture && query.get("measure")) meter = (() => {
|
|
|
2761
3089
|
resetTyping();
|
|
2762
3090
|
refresh();
|
|
2763
3091
|
wake();
|
|
3092
|
+
session.visibility(document.hidden);
|
|
2764
3093
|
if (nativePicture) {
|
|
2765
3094
|
window.linkshellNative = {
|
|
2766
3095
|
picture: (size) => {
|
|
@@ -2776,6 +3105,8 @@ if (nativePicture) {
|
|
|
2776
3105
|
stats: (stats) => { nativeStats = stats; },
|
|
2777
3106
|
connecting: () => {
|
|
2778
3107
|
nativeStats = null;
|
|
3108
|
+
session.reconnect();
|
|
3109
|
+
waitForCapabilities();
|
|
2779
3110
|
Object.assign(control, { asked: false, known: false, trusted: false });
|
|
2780
3111
|
say("正在连接电脑屏幕…");
|
|
2781
3112
|
if (mode !== "view") askControl();
|
|
@@ -2796,13 +3127,13 @@ if (nativePicture) {
|
|
|
2796
3127
|
tellApp({ type: "ready", mode });
|
|
2797
3128
|
`;
|
|
2798
3129
|
const SCRIPT = `(() => {${VIEWER_LOGIC}${BODY}})();`;
|
|
2799
|
-
export function viewerPage() {
|
|
3130
|
+
export function viewerPage(platform = "darwin") {
|
|
2800
3131
|
return `<!doctype html>
|
|
2801
3132
|
<html><head><meta charset="utf-8">
|
|
2802
3133
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
2803
3134
|
<title>屏幕</title>
|
|
2804
3135
|
<style>${STYLE}</style></head>
|
|
2805
|
-
<body>
|
|
3136
|
+
<body data-platform="${platform === "win32" ? "win32" : "darwin"}">
|
|
2806
3137
|
<div id="stage"><div id="picture"><canvas id="screen"></canvas><canvas id="gpu" class="gone"></canvas><video id="video" class="gone" autoplay playsinline muted></video><div id="pointer"></div></div></div>
|
|
2807
3138
|
<div id="note">正在连接电脑屏幕…</div>
|
|
2808
3139
|
<div id="toast" class="glass"></div>
|
|
@@ -2845,9 +3176,9 @@ export function viewerPage() {
|
|
|
2845
3176
|
<div class="group"><span>添加快捷键</span><span id="makersign"></span></div>
|
|
2846
3177
|
<div class="tiles" id="makermods">
|
|
2847
3178
|
<div class="tile" data-mod="ctrl"><b>⌃</b><span>control</span></div>
|
|
2848
|
-
<div class="tile" data-mod="alt"><b
|
|
3179
|
+
<div class="tile" data-mod="alt"><b>${platform === "win32" ? "Alt" : "⌥"}</b><span>${platform === "win32" ? "alt" : "option"}</span></div>
|
|
2849
3180
|
<div class="tile" data-mod="shift"><b>⇧</b><span>shift</span></div>
|
|
2850
|
-
<div class="tile" data-mod="cmd"><b
|
|
3181
|
+
<div class="tile" data-mod="cmd"><b>${platform === "win32" ? "Win" : "⌘"}</b><span>${platform === "win32" ? "windows" : "command"}</span></div>
|
|
2851
3182
|
</div>
|
|
2852
3183
|
<label class="field"><span>按键</span><select id="makerkey" aria-label="按键"></select></label>
|
|
2853
3184
|
<label class="field"><span>名称</span><input id="makername" type="text" maxlength="16" placeholder="可不填" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" enterkeyhint="done" aria-label="名称"></label>
|
|
@@ -2870,8 +3201,8 @@ export function viewerPage() {
|
|
|
2870
3201
|
<div class="key word" data-key="escape">esc</div>
|
|
2871
3202
|
<div class="key word" data-key="tab">tab</div>
|
|
2872
3203
|
<div class="key" data-mod="ctrl">⌃</div>
|
|
2873
|
-
<div class="key" data-mod="alt"
|
|
2874
|
-
<div class="key" data-mod="cmd"
|
|
3204
|
+
<div class="key" data-mod="alt">${platform === "win32" ? "Alt" : "⌥"}</div>
|
|
3205
|
+
<div class="key" data-mod="cmd">${platform === "win32" ? "Win" : "⌘"}</div>
|
|
2875
3206
|
<div class="key" data-mod="shift">⇧</div>
|
|
2876
3207
|
<div class="key" data-key="left">←</div>
|
|
2877
3208
|
<div class="key" data-key="down">↓</div>
|