@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.
Files changed (69) hide show
  1. package/dist/host/src/drivers/acp/connection.js +2 -7
  2. package/dist/host/src/drivers/acp/connection.js.map +1 -1
  3. package/dist/host/src/drivers/acp/driver.js +2 -4
  4. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  5. package/dist/host/src/drivers/auth.js +1 -3
  6. package/dist/host/src/drivers/auth.js.map +1 -1
  7. package/dist/host/src/drivers/claude/driver.js +23 -11
  8. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  9. package/dist/host/src/drivers/codex/app-server.d.ts +6 -0
  10. package/dist/host/src/drivers/codex/app-server.js +44 -21
  11. package/dist/host/src/drivers/codex/app-server.js.map +1 -1
  12. package/dist/host/src/drivers/codex/driver.d.ts +1 -0
  13. package/dist/host/src/drivers/codex/driver.js +11 -7
  14. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  15. package/dist/host/src/drivers/codex/installation.d.ts +2 -0
  16. package/dist/host/src/drivers/codex/installation.js +29 -0
  17. package/dist/host/src/drivers/codex/installation.js.map +1 -0
  18. package/dist/host/src/fs.d.ts +13 -0
  19. package/dist/host/src/fs.js +29 -2
  20. package/dist/host/src/fs.js.map +1 -1
  21. package/dist/host/src/host.d.ts +1 -0
  22. package/dist/host/src/host.js +6 -4
  23. package/dist/host/src/host.js.map +1 -1
  24. package/dist/host/src/input.d.ts +29 -2
  25. package/dist/host/src/input.js +113 -5
  26. package/dist/host/src/input.js.map +1 -1
  27. package/dist/host/src/local-transport.d.ts +5 -0
  28. package/dist/host/src/local-transport.js +22 -0
  29. package/dist/host/src/local-transport.js.map +1 -0
  30. package/dist/host/src/ports.js +10 -1
  31. package/dist/host/src/ports.js.map +1 -1
  32. package/dist/host/src/process.d.ts +12 -0
  33. package/dist/host/src/process.js +59 -0
  34. package/dist/host/src/process.js.map +1 -0
  35. package/dist/host/src/rpc/client.js +2 -3
  36. package/dist/host/src/rpc/client.js.map +1 -1
  37. package/dist/host/src/rpc/server.d.ts +1 -0
  38. package/dist/host/src/rpc/server.js +12 -3
  39. package/dist/host/src/rpc/server.js.map +1 -1
  40. package/dist/host/src/screen-h264.d.ts +15 -0
  41. package/dist/host/src/screen-h264.js +88 -0
  42. package/dist/host/src/screen-h264.js.map +1 -0
  43. package/dist/host/src/screen-pacer.d.ts +7 -1
  44. package/dist/host/src/screen-pacer.js +11 -5
  45. package/dist/host/src/screen-pacer.js.map +1 -1
  46. package/dist/host/src/screen-session.d.ts +35 -0
  47. package/dist/host/src/screen-session.js +26 -0
  48. package/dist/host/src/screen-session.js.map +1 -0
  49. package/dist/host/src/screen-viewer.d.ts +1 -1
  50. package/dist/host/src/screen-viewer.js +373 -42
  51. package/dist/host/src/screen-viewer.js.map +1 -1
  52. package/dist/host/src/screen.d.ts +3 -1
  53. package/dist/host/src/screen.js +560 -165
  54. package/dist/host/src/screen.js.map +1 -1
  55. package/dist/host/src/terminals.js +6 -3
  56. package/dist/host/src/terminals.js.map +1 -1
  57. package/dist/host/src/windows-screen.d.ts +38 -0
  58. package/dist/host/src/windows-screen.js +80 -0
  59. package/dist/host/src/windows-screen.js.map +1 -0
  60. package/dist/host/src/windows-video.d.ts +78 -0
  61. package/dist/host/src/windows-video.js +368 -0
  62. package/dist/host/src/windows-video.js.map +1 -0
  63. package/dist/host/src/worktrees.js +2 -2
  64. package/dist/host/src/worktrees.js.map +1 -1
  65. package/dist/host/tsconfig.tsbuildinfo +1 -1
  66. package/dist/wire/src/rpc.d.ts +49 -0
  67. package/dist/wire/src/rpc.js +18 -0
  68. package/dist/wire/src/rpc.js.map +1 -1
  69. 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
- const say = (text) => { note.textContent = text; note.classList.toggle("gone", !text); };
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 new choice loads the page again with it: the track is offered anew.
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
- const width = WIDTHS.includes(query.get("width")) ? query.get("width") : WIDTHS.includes(keptWidth) ? keptWidth : "1920";
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
- ws.onopen = () => { if (mode !== "view") askControl(); if (document.hidden) send({ t: "hidden" }); };
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", () => send({ t: document.hidden ? "hidden" : "shown" }));
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 (!("VideoDecoder" in window)) return;
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(() => giveUp("not connected within " + patienceSeconds + " s of the offer"), patienceSeconds * 1000);
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 (document.hidden || now - since > 6000) mine.away = setTimeout(() => away(mine, now), 4000);
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 (document.hidden || blocked) patience = setTimeout(frameless, 4000);
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].send(JSON.stringify(message));
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 += 1;
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") held = Math.max(0, held - 1);
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
- element.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); wake(); pressFeedback(element, true); });
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
- if (nativePicture) return;
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>⌥</b><span>option</span></div>
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>⌘</b><span>command</span></div>
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">⌥</div>
2874
- <div class="key" data-mod="cmd">⌘</div>
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>