mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 04:52:39 +08:00
[feat] add CrossDesk MCP with remote desktop panels
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,230 @@
|
||||
type FrameInfo = { width: number; height: number; fps: number; frames: number };
|
||||
function parameters(data: Uint8Array) {
|
||||
let codec = "", signature = "";
|
||||
for (let i = 0; i + 4 < data.length; i++) {
|
||||
if (data[i] || data[i + 1]) continue;
|
||||
const prefix = data[i + 2] === 1 ? 3 : data[i + 2] === 0 && data[i + 3] === 1 ? 4 : 0;
|
||||
if (!prefix) continue;
|
||||
const start = i + prefix, type = data[start]! & 31;
|
||||
if (type !== 7 && type !== 8) continue;
|
||||
let end = start + 1;
|
||||
while (end + 3 < data.length && !(data[end] === 0 && data[end + 1] === 0 &&
|
||||
(data[end + 2] === 1 || (data[end + 2] === 0 && data[end + 3] === 1)))) end++;
|
||||
if (end + 3 >= data.length) end = data.length;
|
||||
if (end - start > 65536) throw new Error("VIDEO_INVALID");
|
||||
const hex = [...data.subarray(start, end)].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
signature += hex;
|
||||
if (type === 7 && end >= start + 4) codec = "avc1." + hex.slice(2, 8);
|
||||
i = end - 1;
|
||||
}
|
||||
return { codec, signature };
|
||||
}
|
||||
|
||||
export class VideoPlayer {
|
||||
private socket?: WebSocket;
|
||||
private decoder?: VideoDecoder;
|
||||
private stopped = false;
|
||||
private failed = false;
|
||||
private waitingKey = true;
|
||||
private generation = -1;
|
||||
private sequence = -1;
|
||||
private signature = "";
|
||||
private queue: ArrayBuffer[] = [];
|
||||
private queuedBytes = 0;
|
||||
private pumping = false;
|
||||
private keyRequested = 0;
|
||||
private firstFrame?: ReturnType<typeof setTimeout>;
|
||||
private latest?: VideoFrame;
|
||||
private drawing = 0;
|
||||
private count = 0;
|
||||
private samples = 0;
|
||||
private sampledAt = performance.now();
|
||||
private fps = 0;
|
||||
private raw = false;
|
||||
private software = false;
|
||||
private automatic = true;
|
||||
constructor(private canvas: HTMLCanvasElement,
|
||||
private onFrame: (info: FrameInfo) => void,
|
||||
private onError: (error: Error) => void) {}
|
||||
|
||||
start(url: string, ticket: string, protocol = "h264-annexb/1", decoder = "auto") {
|
||||
if (!["h264-annexb/1", "nv12/1"].includes(protocol) ||
|
||||
!["auto", "software"].includes(decoder)) throw new Error("VIDEO_INVALID");
|
||||
this.raw = protocol === "nv12/1";
|
||||
this.automatic = decoder === "auto";
|
||||
this.software = !this.automatic;
|
||||
if (typeof VideoFrame === "undefined" || (!this.raw && typeof VideoDecoder === "undefined"))
|
||||
throw new Error("VIDEO_UNSUPPORTED");
|
||||
if (!/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(url)) throw new Error("VIDEO_INVALID");
|
||||
const socket = this.socket = new WebSocket(url);
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.onopen = () => socket.send(JSON.stringify({ ticket }));
|
||||
socket.onerror = () => this.fail(new Error("VIDEO_UNAVAILABLE"));
|
||||
socket.onclose = () => { if (!this.stopped) this.fail(new Error("VIDEO_UNAVAILABLE")); };
|
||||
this.firstFrame = setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 10000);
|
||||
socket.onmessage = (event) => {
|
||||
if (this.stopped || !(event.data instanceof ArrayBuffer)) return;
|
||||
const data = event.data;
|
||||
if (data.byteLength < (this.raw ? 38 : 25) ||
|
||||
data.byteLength > (this.raw ? 16777216 * 3 / 2 + 32 : 4 * 1024 * 1024 + 24))
|
||||
return this.fail(new Error("VIDEO_INVALID"));
|
||||
const header = new DataView(data);
|
||||
if (header.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631))
|
||||
return this.fail(new Error("VIDEO_INVALID"));
|
||||
// Receipt, not presentation: bounds native writes even when the decoder
|
||||
// or the canvas is slow. This message goes directly to the native CLI.
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
|
||||
type: "ack", generation: header.getUint32(4), sequence: header.getUint32(8),
|
||||
}));
|
||||
if (this.raw || this.queue.length >= 8 || this.queuedBytes + data.byteLength > 8 * 1024 * 1024) {
|
||||
this.queue = [];
|
||||
this.queuedBytes = 0;
|
||||
this.waitingKey = true;
|
||||
this.requestKey();
|
||||
}
|
||||
this.queue.push(data);
|
||||
this.queuedBytes += data.byteLength;
|
||||
void this.pump();
|
||||
};
|
||||
}
|
||||
private requestKey() {
|
||||
if (this.raw) return;
|
||||
if (Date.now() - this.keyRequested < 1000 || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.keyRequested = Date.now();
|
||||
this.socket.send(JSON.stringify({ type: "keyframe" }));
|
||||
}
|
||||
private async pump() {
|
||||
if (this.pumping) return;
|
||||
this.pumping = true;
|
||||
try {
|
||||
while (!this.stopped && this.queue.length) {
|
||||
const bytes = this.queue.shift()!;
|
||||
this.queuedBytes -= bytes.byteLength;
|
||||
const packet = new DataView(bytes);
|
||||
if (packet.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631) || packet.getUint32(20) > 1)
|
||||
throw new Error("VIDEO_INVALID");
|
||||
const generation = packet.getUint32(4), sequence = packet.getUint32(8);
|
||||
const key = packet.getUint32(20) === 1;
|
||||
if (generation < this.generation) continue;
|
||||
const timestamp = Number(packet.getBigUint64(12));
|
||||
if (!Number.isSafeInteger(timestamp)) throw new Error("VIDEO_INVALID");
|
||||
if (this.raw) {
|
||||
const width = packet.getUint32(24), height = packet.getUint32(28);
|
||||
if (!key || !width || !height || (width & 1) || (height & 1) ||
|
||||
width > 8192 || height > 8192 || width * height > 16777216 ||
|
||||
bytes.byteLength !== 32 + width * height * 3 / 2)
|
||||
throw new Error("VIDEO_INVALID");
|
||||
if (generation === this.generation && sequence <= this.sequence) continue;
|
||||
this.generation = generation;
|
||||
this.sequence = sequence;
|
||||
// Match the native NV12 screenshot conversion's limited-range BT.601.
|
||||
this.present(new VideoFrame(new Uint8Array(bytes, 32), {
|
||||
format: "NV12", codedWidth: width, codedHeight: height, timestamp,
|
||||
colorSpace: { matrix: "smpte170m", primaries: "smpte170m", transfer: "smpte170m", fullRange: false },
|
||||
transfer: [bytes],
|
||||
} as VideoFrameBufferInit & { transfer: ArrayBuffer[] }));
|
||||
continue;
|
||||
}
|
||||
if (generation !== this.generation || (this.sequence >= 0 && sequence !== this.sequence + 1))
|
||||
this.waitingKey = true;
|
||||
this.generation = generation;
|
||||
this.sequence = sequence;
|
||||
if ((this.decoder?.decodeQueueSize ?? 0) >= 4) this.waitingKey = true;
|
||||
if (this.waitingKey && !key) { this.requestKey(); continue; }
|
||||
const data = new Uint8Array(bytes, 24);
|
||||
if (key) {
|
||||
const { codec, signature } = parameters(data);
|
||||
if (!codec) { this.waitingKey = true; this.requestKey(); continue; }
|
||||
if (!this.decoder || this.waitingKey || signature !== this.signature) {
|
||||
const config: VideoDecoderConfig = { codec, optimizeForLatency: true,
|
||||
hardwareAcceleration: this.software ? "prefer-software" : "prefer-hardware" };
|
||||
if (!(await VideoDecoder.isConfigSupported(config)).supported) {
|
||||
if (this.software) throw new Error("VIDEO_UNSUPPORTED");
|
||||
this.software = true;
|
||||
config.hardwareAcceleration = "prefer-software";
|
||||
if (!(await VideoDecoder.isConfigSupported(config)).supported) throw new Error("VIDEO_UNSUPPORTED");
|
||||
}
|
||||
if (this.stopped) break;
|
||||
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
|
||||
const decoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
if (this.stopped || this.decoder !== decoder) { frame.close(); return; }
|
||||
this.present(frame);
|
||||
},
|
||||
error: () => {
|
||||
if (this.stopped || this.decoder !== decoder) return;
|
||||
this.decoder = undefined;
|
||||
if (this.software || !this.automatic) {
|
||||
this.fail(new Error("VIDEO_UNAVAILABLE"));
|
||||
return;
|
||||
}
|
||||
this.software = true;
|
||||
this.waitingKey = true;
|
||||
this.requestKey();
|
||||
},
|
||||
});
|
||||
this.decoder = decoder;
|
||||
decoder.configure(config);
|
||||
this.signature = signature;
|
||||
}
|
||||
this.waitingKey = false;
|
||||
}
|
||||
this.decoder?.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data }));
|
||||
}
|
||||
} catch (error) {
|
||||
this.fail(error instanceof Error ? error : new Error("VIDEO_INVALID"));
|
||||
} finally { this.pumping = false; }
|
||||
}
|
||||
private present(frame: VideoFrame) {
|
||||
this.latest?.close();
|
||||
this.latest = frame;
|
||||
if (!this.drawing) this.drawing = requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
private draw() {
|
||||
this.drawing = 0;
|
||||
const frame = this.latest;
|
||||
this.latest = undefined;
|
||||
if (!frame) return;
|
||||
try {
|
||||
if (this.stopped) return;
|
||||
const width = frame.displayWidth, height = frame.displayHeight;
|
||||
if (width > 8192 || height > 8192 || width * height > 16777216) throw new Error("VIDEO_INVALID");
|
||||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
}
|
||||
this.canvas.getContext("2d", { alpha: false })!.drawImage(frame, 0, 0);
|
||||
clearTimeout(this.firstFrame);
|
||||
this.count++;
|
||||
this.samples++;
|
||||
const elapsed = performance.now() - this.sampledAt;
|
||||
if (elapsed >= 1000) {
|
||||
this.fps = this.samples * 1000 / elapsed;
|
||||
this.samples = 0;
|
||||
this.sampledAt = performance.now();
|
||||
}
|
||||
this.canvas.dataset.frames = String(this.count);
|
||||
this.onFrame({ width, height, frames: this.count, fps: this.fps });
|
||||
} catch { this.fail(new Error("VIDEO_INVALID")); }
|
||||
finally { frame.close(); }
|
||||
}
|
||||
private fail(error: Error) {
|
||||
if (this.failed || this.stopped) return;
|
||||
this.failed = true;
|
||||
this.stop();
|
||||
this.onError(error);
|
||||
}
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
clearTimeout(this.firstFrame);
|
||||
this.socket?.close();
|
||||
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
|
||||
this.decoder = undefined;
|
||||
cancelAnimationFrame(this.drawing);
|
||||
this.drawing = 0;
|
||||
this.latest?.close();
|
||||
this.latest = undefined;
|
||||
this.queue = [];
|
||||
this.queuedBytes = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #292d33;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color-scheme: light;
|
||||
--accent: #3269b7;
|
||||
--muted: #777d85;
|
||||
--line: #e4e6e9;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; }
|
||||
[hidden] { display: none !important; }
|
||||
button, input, select { font: inherit; }
|
||||
button, summary, input, select { -webkit-tap-highlight-color: transparent; }
|
||||
:focus-visible { outline: 2px solid #7194c6; outline-offset: 2px; }
|
||||
#app { width: 100%; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
|
||||
.app-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex: none; }
|
||||
.brand, .header-right { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.brand-logo { display: block; width: 28px; height: 28px; }
|
||||
h1 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
||||
h2 { margin: 0; font-size: 13px; font-weight: 600; }
|
||||
.connection { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.connection[data-state="online"] { color: #397557; }
|
||||
.connection[data-state="connecting"] { color: #8a682d; }
|
||||
.connection[data-state="offline"] { color: #a15149; }
|
||||
#details-toggle { display: none; }
|
||||
main { display: grid; grid-template-columns: 270px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
aside { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 22px 20px; border-right: 1px solid var(--line); }
|
||||
#sidebar-title { margin-bottom: 20px; }
|
||||
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
label { display: block; margin: 16px 0 7px; font-size: 12px; }
|
||||
fieldset > label:first-child { margin-top: 0; }
|
||||
.optional { color: var(--muted); margin-left: 4px; font-size: 11px; }
|
||||
input:not([type="checkbox"]), select { display: block; width: 100%; height: 36px; min-width: 0; border: 1px solid #d7dade; border-radius: 4px; padding: 7px 9px; background: #fff; color: inherit; }
|
||||
input:not([type="checkbox"]):focus, select:focus { border-color: var(--accent); outline: 1px solid var(--accent); }
|
||||
input:disabled, select:disabled { background: #f5f5f5; color: #999; }
|
||||
input::placeholder { color: #92969c; }
|
||||
#device-id, #session-device-id { font-variant-numeric: tabular-nums; }
|
||||
.password-field { position: relative; }
|
||||
.password-field input { padding-right: 48px; }
|
||||
.password-field button { position: absolute; right: 2px; top: 2px; height: 32px; min-height: 32px; padding: 0 8px; border: 0; background: transparent; color: var(--muted); }
|
||||
.field-hint, .privacy, .session-note { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 7px 0 0; }
|
||||
.preferences { border-top: 1px solid var(--line); margin-top: 20px; padding-top: 7px; }
|
||||
details { font-size: 12px; }
|
||||
summary { padding: 9px 0; cursor: pointer; color: #666d76; }
|
||||
summary::marker { font-size: 10px; }
|
||||
.checks { padding-bottom: 10px; }
|
||||
.checks label { display: flex; align-items: flex-start; gap: 8px; margin-top: 13px; font-size: 12px; line-height: 1.5; cursor: pointer; }
|
||||
.checks label:has(input:disabled) { color: #999; cursor: default; }
|
||||
.checks small { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
input[type="checkbox"] { width: 14px; height: 14px; flex: none; margin: 2px 0 0; accent-color: var(--accent); }
|
||||
.row { display: flex; gap: 12px; }
|
||||
.row > div { flex: 1; min-width: 0; }
|
||||
button { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 6px 10px; border: 1px solid #d9dce0; border-radius: 4px; background: #fff; color: #4d535c; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||
button:not(:disabled):hover { background: #f3f4f5; border-color: #bfc4cb; }
|
||||
button:not(:disabled):active { background: #e9ebee; }
|
||||
button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-color: #e7e9eb; }
|
||||
.primary { width: 100%; min-height: 36px; background: var(--accent); color: #fff; border-color: var(--accent); margin-top: 20px; }
|
||||
.primary:not(:disabled):hover { background: #285b9f; border-color: #285b9f; }
|
||||
.privacy { margin-top: 12px; }
|
||||
#error { flex: none; max-height: 25%; overflow: auto; padding: 10px 20px; background: #fff5f2; border-bottom: 1px solid #edddd6; color: #964a37; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.device-card strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.device-card > span { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||
.session-details { margin: 20px 0 0; font-size: 12px; }
|
||||
.session-details > div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eee; }
|
||||
.session-details dt { color: var(--muted); flex: none; }
|
||||
.session-details dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
#session-agent[data-paused="true"] { color: #956e2d; }
|
||||
.session-note { margin-top: 18px; }
|
||||
.workspace { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; overflow: auto; padding: 16px; background: #f6f7f8; }
|
||||
.viewer-card { display: flex; flex-direction: column; flex: 1; min-height: 160px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
|
||||
.viewer-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 9px 12px; flex: none; }
|
||||
.viewer-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||||
.viewer-title h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#video-state { color: var(--muted); font-size: 11px; white-space: nowrap; flex: none; }
|
||||
#video-state[data-state="stale"] { color: #956e2d; }
|
||||
.viewer-controls { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.danger:not(:disabled) { color: #9a4e47; }
|
||||
.danger:not(:disabled):hover { border-color: #d6b4ae; background: #fff6f4; }
|
||||
/* The screen takes the remaining panel height; object-fit preserves the remote aspect ratio. */
|
||||
.screen { position: relative; flex: 1; min-height: 60px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; isolation: isolate; border-radius: 8px; background: #17191c; }
|
||||
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
/* Overlay the control indicator so toggling control never resizes the video. */
|
||||
.control-halo { display: none; }
|
||||
#app[data-controlling="true"] .screen::before,
|
||||
#app[data-controlling="true"] .screen::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
|
||||
#app[data-controlling="true"] .screen::before { z-index: 1; box-shadow: inset 0 0 10px #3976ff40, inset 0 0 3px #8ccaff70; }
|
||||
#app[data-controlling="true"] .control-halo {
|
||||
--halo-spread: 14px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
/* Fade the light inward at the edges, leaving the desktop itself clear. */
|
||||
mask-image:
|
||||
linear-gradient(to bottom, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000),
|
||||
linear-gradient(to right, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000);
|
||||
}
|
||||
.control-halo::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -22%;
|
||||
background:
|
||||
radial-gradient(ellipse 32% 38% at 24% 26%, #d5f3ff 0%, #76c4ff 20%, #427bff80 50%, transparent 100%),
|
||||
radial-gradient(ellipse 38% 40% at 78% 75%, #c4d7ff 0%, #637cff 24%, #436bff70 60%, transparent 100%),
|
||||
linear-gradient(125deg, #3c8aff70, #687dff50 55%, #4ab5ff80);
|
||||
filter: blur(8px);
|
||||
opacity: .9;
|
||||
/* Move a cached light layer rather than repainting an animated gradient. */
|
||||
animation: control-halo-drift 9s ease-in-out infinite alternate;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
#app[data-controlling="true"] .screen::after {
|
||||
z-index: 3;
|
||||
padding: 1px;
|
||||
background: linear-gradient(125deg, #dbf6ffb0, #5e98ff70 30%, #c2e9ffc0 53%, #7e9dff80 75%, #b0e6ffb0);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
@keyframes control-halo-drift {
|
||||
0% { transform: translate3d(-7%, -5%, 0) scale(1); opacity: .7; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate3d(7%, 5%, 0) scale(1.05); opacity: .85; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.control-halo::before { animation: none; will-change: auto; }
|
||||
}
|
||||
#empty { text-align: center; padding: 16px; max-width: 100%; color: #b4b7bd; }
|
||||
#empty h3 { font-size: 14px; font-weight: 400; margin: 0 0 8px; }
|
||||
#empty p { color: #7e848e; font-size: 12px; line-height: 1.7; margin: 0; }
|
||||
.stale { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; background: #25272cdd; color: #e2c495; border-radius: 3px; font-size: 11px; }
|
||||
.telemetry { display: flex; align-items: center; flex-wrap: wrap; gap: 7px 18px; padding: 9px 12px; flex: none; color: var(--muted); font-size: 11px; }
|
||||
.stream-metrics { display: flex; gap: 14px; font-variant-numeric: tabular-nums; }
|
||||
#permission { margin-left: auto; }
|
||||
#permission[data-state="paused"] { color: #956e2d; }
|
||||
.panel-status { flex: none; font-size: 12px; line-height: 1.6; color: var(--muted); margin: 0; }
|
||||
.video-options { display: flex; flex-wrap: wrap; gap: 8px; flex: none; }
|
||||
.video-options:not(:has(button:not([hidden]))) { display: none; }
|
||||
.focus-mode main { grid-template-columns: minmax(0, 1fr); }
|
||||
.focus-mode .app-header { display: none; }
|
||||
.focus-mode aside, .focus-mode #details-toggle { display: none !important; }
|
||||
@media (max-width: 760px) {
|
||||
.app-header { padding: 10px 14px; }
|
||||
main { display: flex; flex-direction: column; }
|
||||
aside { flex: 1; border-right: 0; padding: 20px; }
|
||||
#app:not([data-connected="true"]) .workspace { display: none; }
|
||||
#app[data-connected="true"] aside { display: none; flex: none; max-height: 40%; border-bottom: 1px solid var(--line); }
|
||||
#app[data-connected="true"].details-open aside { display: block; }
|
||||
#app[data-connected="true"] #details-toggle { display: inline-flex; }
|
||||
#app[data-connected="true"] .workspace { flex: 1; }
|
||||
.workspace { padding: 10px; }
|
||||
#sidebar-title { margin-bottom: 16px; }
|
||||
.session-details { margin-top: 12px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
#app[data-controlling="true"] .control-halo { --halo-spread: 10px; }
|
||||
.app-header { gap: 8px; padding: 10px 12px; }
|
||||
.brand, .header-right { gap: 7px; }
|
||||
.brand-logo { width: 24px; height: 24px; }
|
||||
h1 { font-size: 15px; }
|
||||
.connection { font-size: 11px; }
|
||||
#details-toggle { padding: 5px 7px; min-height: 28px; font-size: 11px; }
|
||||
.workspace { padding: 8px; gap: 8px; }
|
||||
.viewer-toolbar { padding: 9px; }
|
||||
.viewer-title, .viewer-controls { width: 100%; flex: auto; }
|
||||
.viewer-controls button { flex: 1; min-height: 36px; }
|
||||
.telemetry { padding: 8px 9px; gap: 6px 10px; }
|
||||
#session-info { display: none; }
|
||||
.stream-metrics { gap: 10px; }
|
||||
#permission { font-size: 10px; }
|
||||
}
|
||||
@media (max-height: 460px) and (min-width: 481px) {
|
||||
.app-header { padding-top: 6px; padding-bottom: 6px; }
|
||||
.workspace { padding: 8px; gap: 6px; }
|
||||
.viewer-toolbar { padding-top: 5px; padding-bottom: 5px; }
|
||||
.telemetry { padding-top: 6px; padding-bottom: 6px; }
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import type { App } from "@modelcontextprotocol/ext-apps";
|
||||
import "./viewer.css";
|
||||
import { VideoPlayer } from "./video-player.js";
|
||||
|
||||
declare const CROSSDESK_APPS: boolean;
|
||||
declare const CROSSDESK_LOGO: string;
|
||||
type Data = Record<string, any>;
|
||||
type Invoke = (name: string, args?: Data) => Promise<Data>;
|
||||
const root = document.querySelector<HTMLDivElement>("#app")!;
|
||||
root.innerHTML = `
|
||||
<header class="app-header">
|
||||
<div class="brand"><img class="brand-logo" src="${CROSSDESK_LOGO}" alt="" width="28" height="28"><h1>CrossDesk MCP</h1></div>
|
||||
<div class="header-right"><button id="details-toggle" aria-expanded="false" aria-controls="sidebar">连接信息</button><span class="connection" id="connection" role="status"><span id="connection-label">等待连接</span></span></div>
|
||||
</header>
|
||||
<div id="error" role="alert" hidden></div>
|
||||
<main>
|
||||
<aside id="sidebar" aria-labelledby="sidebar-title">
|
||||
<h2 id="sidebar-title">连接设备</h2>
|
||||
<div id="connect-form" role="form" aria-label="连接远程电脑">
|
||||
<fieldset id="connection-fields">
|
||||
<label for="device">设备</label><select id="device"><option value="">连接新设备</option></select>
|
||||
<div id="new-device">
|
||||
<label for="device-id">设备 ID</label><input id="device-id" inputmode="numeric" autocomplete="off" placeholder="输入 9 位设备 ID" maxlength="11">
|
||||
</div>
|
||||
<label for="password">访问密码</label><div class="password-field"><input id="password" type="password" autocomplete="off" maxlength="256" placeholder="输入远端访问密码"><button type="button" id="password-toggle" aria-label="显示密码" aria-pressed="false">显示</button></div>
|
||||
<label for="mode">授权范围</label><select id="mode" aria-describedby="mode-description"><option value="observe">仅查看画面</option><option value="control">允许 Agent 操作</option></select>
|
||||
<p class="field-hint" id="mode-description">Agent 可以查看屏幕,无法操作键鼠。</p>
|
||||
<details class="preferences"><summary>保存与授权</summary>
|
||||
<div id="new-device-label"><label for="label">设备备注 <span class="optional">可选</span></label><input id="label" placeholder="例如:工作电脑" maxlength="80"></div>
|
||||
<div class="checks">
|
||||
<label><input id="save" type="checkbox"><span>保存设备</span></label>
|
||||
<label><input id="remember" type="checkbox" disabled><span>记住密码<small>保存在本机系统凭据库</small></span></label>
|
||||
<label><input id="authorize" type="checkbox" disabled><span>允许下次自动连接<small>沿用本次授权范围</small></span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details id="advanced"><summary>高级设置</summary>
|
||||
<label for="server">服务器</label><input id="server" value="api.crossdesk.cn" autocomplete="off">
|
||||
<div class="row"><div><label for="port">端口</label><input id="port" type="number" min="1" max="65535" value="9099"></div><div><label for="display">显示器索引</label><input id="display" type="number" min="0" max="63" value="0"></div></div>
|
||||
</details>
|
||||
</fieldset>
|
||||
<button id="connect" type="button" class="primary" disabled><span id="connect-label">连接</span></button>
|
||||
<p class="privacy">密码不会发送到 Agent 对话中。</p>
|
||||
</div>
|
||||
<div id="session-card" hidden>
|
||||
<div class="device-card"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></div>
|
||||
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div></dl>
|
||||
<p class="session-note">关闭页面不会断开连接。</p>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="workspace" aria-label="远程画面与会话控制">
|
||||
<div class="viewer-card">
|
||||
<div class="viewer-toolbar">
|
||||
<div class="viewer-title"><h2 id="device-name">远程画面</h2><span id="video-state">待连接</span></div>
|
||||
<div class="viewer-controls"><button id="pause" disabled><span id="pause-label">暂停控制</span></button><button id="disconnect" class="danger" disabled>断开连接</button><button id="focus-view" aria-label="放大画面" aria-pressed="false" disabled>放大画面</button></div>
|
||||
</div>
|
||||
<div class="screen" id="screen">
|
||||
<canvas id="frame" role="img" aria-label="远程桌面实时画面" hidden></canvas>
|
||||
<div class="control-halo" aria-hidden="true"></div>
|
||||
<div id="empty"><h3 id="empty-title">未连接设备</h3><p id="empty-description">选择设备并输入访问密码。</p></div>
|
||||
<div class="stale" id="stale" role="status" hidden>画面暂未更新</div>
|
||||
</div>
|
||||
<div class="telemetry"><div class="stream-metrics"><span id="resolution">—</span><span id="fps">—</span></div><span id="session-info">等待设备连接</span><span id="permission">等待授权</span></div>
|
||||
</div>
|
||||
<div class="video-options"><button id="video-retry" hidden>重新加载画面</button><button id="video-browser" hidden>在浏览器中观看</button></div>
|
||||
<p id="panel-status" class="panel-status" role="status" hidden></p>
|
||||
</section>
|
||||
</main>`;
|
||||
const element = <T extends HTMLElement = HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
const input = (id: string) => element<HTMLInputElement>(id);
|
||||
const select = (id: string) => element<HTMLSelectElement>(id);
|
||||
let invoke: Invoke;
|
||||
let app: App | undefined;
|
||||
let context: Data | undefined;
|
||||
let status: Data = {};
|
||||
let savedDevices: Data[] = [];
|
||||
let controlBusy = false;
|
||||
let disconnectedByUser = false;
|
||||
let frameSize = "", frameRate = "";
|
||||
let player: VideoPlayer | undefined;
|
||||
let videoStarting = false, videoFailed = false, videoEpoch = 0;
|
||||
let videoSession: string | undefined;
|
||||
let videoGeneration: number | undefined;
|
||||
let imageAt = 0;
|
||||
let busy = false,
|
||||
polling = false,
|
||||
started = false;
|
||||
const messages: Record<string, string> = {
|
||||
VIDEO_UNSUPPORTED: "当前浏览器不支持此视频格式,请使用支持 WebCodecs 的浏览器。",
|
||||
VIDEO_UNAVAILABLE: "视频通道暂不可用。可重新加载画面;内嵌面板受限时请在浏览器中观看。",
|
||||
VIDEO_INVALID: "视频格式发生变化,请重新加载画面。",
|
||||
VIDEO_LIMIT: "观看面板数量已达上限,请关闭其他面板。",
|
||||
AUTH_FAILED: "访问密码错误,请重新输入。",
|
||||
AUTH_REQUIRED: "请输入访问密码,或在本机 setup 中配置凭据。",
|
||||
CONNECTION_FAILED: "连接失败,请检查设备是否在线及服务器设置。",
|
||||
CONNECTION_TIMEOUT: "等待远端画面超时,请检查远端状态后重试。",
|
||||
CONNECTION_LOST: "远程连接已中断。请检查远端状态并重新连接。",
|
||||
SESSION_LIMIT: "已有活动连接,请先断开当前电脑。",
|
||||
DEVICE_BUSY: "另一实例正在使用此设备。",
|
||||
CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。",
|
||||
PAIRING_REQUIRED:
|
||||
"请在终端运行 Agent 工具结果中的 setup --open-panel 配对命令,再打开面板。",
|
||||
PANEL_UNAUTHORIZED: "面板授权已失效,请重新打开。",
|
||||
VIEW_UNAUTHORIZED: "此面板已失效,请使用最新打开的面板。",
|
||||
CONTROL_DENIED: "当前会话没有控制权限,或已被人工暂停。",
|
||||
NATIVE_PACKAGE_MISSING:
|
||||
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
|
||||
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
|
||||
DEVICE_ALIAS_CONFLICT: "这个备注已属于另一台设备,请换一个备注。",
|
||||
};
|
||||
function text(id: string, value: string) {
|
||||
if (element(id).textContent !== value) element(id).textContent = value;
|
||||
}
|
||||
function platformName(platform?: string) {
|
||||
return ({ macos: "macOS", windows: "Windows", linux: "Linux", android: "Android", ios: "iOS" } as Record<string, string>)[platform ?? ""] ?? "远程电脑";
|
||||
}
|
||||
function resetMetrics() {
|
||||
imageAt = 0;
|
||||
frameSize = frameRate = "";
|
||||
text("resolution", "—");
|
||||
text("fps", "—");
|
||||
element<HTMLButtonElement>("focus-view").disabled = true;
|
||||
}
|
||||
function setFocus(focused: boolean) {
|
||||
root.classList.toggle("focus-mode", focused);
|
||||
const button = element<HTMLButtonElement>("focus-view");
|
||||
const label = focused ? "还原布局" : "放大画面";
|
||||
button.textContent = label;
|
||||
if (focused) setDetails(false);
|
||||
button.setAttribute("aria-pressed", String(focused));
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
}
|
||||
function setDetails(open: boolean) {
|
||||
root.classList.toggle("details-open", open);
|
||||
element("details-toggle").setAttribute("aria-expanded", String(open));
|
||||
text("details-toggle", open ? "收起信息" : "连接信息");
|
||||
}
|
||||
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
|
||||
element("focus-view").onclick = () => setFocus(!root.classList.contains("focus-mode"));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && root.classList.contains("focus-mode")) setFocus(false);
|
||||
});
|
||||
function hidePassword() {
|
||||
input("password").type = "password";
|
||||
text("password-toggle", "显示");
|
||||
element("password-toggle").setAttribute("aria-label", "显示密码");
|
||||
element("password-toggle").setAttribute("aria-pressed", "false");
|
||||
}
|
||||
element("password-toggle").onclick = () => {
|
||||
if (input("password").type === "text") return hidePassword();
|
||||
input("password").type = "text";
|
||||
text("password-toggle", "隐藏");
|
||||
element("password-toggle").setAttribute("aria-label", "隐藏密码");
|
||||
element("password-toggle").setAttribute("aria-pressed", "true");
|
||||
};
|
||||
select("mode").onchange = () => {
|
||||
text("mode-description", select("mode").value === "control"
|
||||
? "Agent 可以操作键鼠,你可以随时暂停。"
|
||||
: "Agent 可以查看屏幕,无法操作键鼠。");
|
||||
};
|
||||
function showError(error: unknown) {
|
||||
const code = error instanceof Error ? error.message : "INTERNAL_ERROR";
|
||||
element("error").textContent =
|
||||
messages[code] ??
|
||||
`操作未完成(${/^[A-Z_]+$/.test(code) ? code : "CONNECTION_ERROR"})`;
|
||||
element("error").hidden = false;
|
||||
}
|
||||
function check(result: Data): Data {
|
||||
if (result.error) throw new Error(result.error.code ?? "INTERNAL_ERROR");
|
||||
return result;
|
||||
}
|
||||
async function devices() {
|
||||
const result = await invoke("devices");
|
||||
if (!started && result.defaults) {
|
||||
input("server").value = result.defaults.server;
|
||||
input("port").value = String(result.defaults.port);
|
||||
}
|
||||
const chosen = select("device").value || context?.device_alias || "";
|
||||
select("device").replaceChildren(new Option("连接新设备", ""));
|
||||
savedDevices = result.devices ?? [];
|
||||
for (const d of savedDevices)
|
||||
select("device").add(
|
||||
new Option(`${d.device_alias} · ${d.device_id}`, d.device_alias),
|
||||
);
|
||||
select("device").value = [...select("device").options].some(
|
||||
(o) => o.value === chosen,
|
||||
)
|
||||
? chosen
|
||||
: "";
|
||||
deviceChanged();
|
||||
}
|
||||
function deviceChanged() {
|
||||
const saved = !!select("device").value;
|
||||
element("new-device").hidden = saved;
|
||||
element("new-device-label").hidden = saved;
|
||||
input("server").disabled = input("port").disabled = saved;
|
||||
input("password").placeholder = saved
|
||||
? "留空使用已保存的密码"
|
||||
: "输入远端访问密码";
|
||||
}
|
||||
select("device").onchange = deviceChanged;
|
||||
input("save").onchange = () => {
|
||||
for (const name of ["remember", "authorize"]) {
|
||||
input(name).disabled = !input("save").checked;
|
||||
if (!input("save").checked) input(name).checked = false;
|
||||
}
|
||||
};
|
||||
function renderStatus(s: Data) {
|
||||
const connected = s.state === "ready" || s.state === "waiting_frame";
|
||||
const lost = ["closed", "failed"].includes(s.state);
|
||||
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
|
||||
const changedSession = s.session_id !== status.session_id;
|
||||
status = s;
|
||||
const connecting = busy || s.state === "waiting_frame";
|
||||
root.dataset.connected = String(connected);
|
||||
root.dataset.connecting = String(connecting);
|
||||
root.dataset.controlling = String(connected && s.control === true && s.control_allowed === true && !s.human_paused);
|
||||
element("connection").dataset.state = connected ? "online" : connecting ? "connecting" : lost && !disconnectedByUser ? "offline" : "idle";
|
||||
text("connection-label", connected ? "已连接" : connecting ? "正在连接" : lost ? "未连接" : "等待连接");
|
||||
element("connect-form").hidden = connected;
|
||||
element("session-card").hidden = !connected;
|
||||
text("sidebar-title", connected ? "连接信息" : "连接设备");
|
||||
text("device-name", connected ? s.device_alias || "远程电脑" : "远程画面");
|
||||
if (!connected || changedSession) setDetails(false);
|
||||
text("permission", connected ? s.human_paused ? "控制已暂停" : s.control_allowed ? "允许 Agent 操作" : "仅查看画面" : "等待授权");
|
||||
element("permission").dataset.state = !connected ? "idle" : s.human_paused ? "paused" : "active";
|
||||
const platform = platformName(s.platform);
|
||||
text("session-info", connected ? `${platform} · 显示器 ${(s.display ?? 0) + 1}` : "等待设备连接");
|
||||
text("session-device", s.device_alias || "远程电脑");
|
||||
text("session-platform", platform);
|
||||
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
|
||||
element("session-device-id-row").hidden = !device;
|
||||
text("session-device-id", device ? String(device.device_id).replace(/(\d{3})(?=\d)/g, "$1 ") : "");
|
||||
text("session-access", s.control_allowed ? "查看与操作" : "仅查看");
|
||||
text("session-display", `显示器 ${(s.display ?? 0) + 1}`);
|
||||
text("session-agent", s.human_paused ? "已暂停" : s.control_allowed ? "允许操作" : "仅可观察");
|
||||
element("session-agent").dataset.paused = String(!!s.human_paused);
|
||||
element<HTMLButtonElement>("pause").disabled = !connected || !s.control_allowed || controlBusy;
|
||||
const pauseLabel = s.human_paused ? "恢复控制" : "暂停控制";
|
||||
text("pause-label", pauseLabel);
|
||||
element<HTMLButtonElement>("disconnect").disabled = !s.session_id || controlBusy || busy;
|
||||
element<HTMLButtonElement>("connect").disabled = busy || connected || !invoke;
|
||||
element<HTMLFieldSetElement>("connection-fields").disabled = busy;
|
||||
element("connect").setAttribute("aria-busy", String(busy));
|
||||
text("connect-label", busy ? "正在连接…" : "连接");
|
||||
if (!connected) {
|
||||
element("frame").hidden = true;
|
||||
element("empty").hidden = false;
|
||||
element("stale").hidden = true;
|
||||
resetMetrics();
|
||||
if (root.classList.contains("focus-mode")) setFocus(false);
|
||||
} else if (changedSession) {
|
||||
resetMetrics();
|
||||
element("frame").hidden = true;
|
||||
element("empty").hidden = false;
|
||||
}
|
||||
if (!imageAt && !videoFailed) {
|
||||
text("empty-title", connecting || connected ? "正在建立远程连接" : lost ? "会话已结束" : "未连接设备");
|
||||
text("empty-description", connecting || connected ? "正在等待远端画面,请稍候。" : lost ? "请重新连接设备。" : "选择设备并输入访问密码。");
|
||||
text("video-state", connecting || connected ? "等待画面" : "待连接");
|
||||
element("video-state").dataset.state = "idle";
|
||||
}
|
||||
}
|
||||
async function connect() {
|
||||
if (!invoke || busy || ["ready", "waiting_frame"].includes(status.state)) return;
|
||||
element("error").hidden = true;
|
||||
disconnectedByUser = false;
|
||||
busy = true;
|
||||
renderStatus(status);
|
||||
const saved = select("device").value;
|
||||
let args: Data = {
|
||||
mode: select("mode").value,
|
||||
display_index: Number(input("display").value),
|
||||
save_device: input("save").checked,
|
||||
remember_password: input("remember").checked,
|
||||
persistent_authorization: input("authorize").checked,
|
||||
...(saved
|
||||
? { device_alias: saved }
|
||||
: {
|
||||
device_id: input("device-id").value.replace(/\s/g, ""),
|
||||
server: input("server").value,
|
||||
port: Number(input("port").value),
|
||||
...(input("label").value ? { label: input("label").value } : {}),
|
||||
}),
|
||||
};
|
||||
if (!saved || input("password").value)
|
||||
args.password = input("password").value;
|
||||
input("password").value = "";
|
||||
hidePassword();
|
||||
try {
|
||||
const result = await invoke("connect", args);
|
||||
args = {};
|
||||
renderStatus(result);
|
||||
if (result.persistence_error)
|
||||
showError(new Error(result.persistence_error));
|
||||
await devices();
|
||||
} catch (error) {
|
||||
args = {};
|
||||
showError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
renderStatus(status);
|
||||
}
|
||||
}
|
||||
element("connect").onclick = () => {
|
||||
void connect();
|
||||
};
|
||||
element("connect-form").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
|
||||
event.preventDefault();
|
||||
void connect();
|
||||
}
|
||||
});
|
||||
element("pause").onclick = async () => {
|
||||
if (controlBusy) return;
|
||||
controlBusy = true;
|
||||
renderStatus(status);
|
||||
try {
|
||||
await invoke("control", {
|
||||
operation: status.human_paused ? "resume" : "pause",
|
||||
});
|
||||
renderStatus(await invoke("status"));
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
controlBusy = false;
|
||||
renderStatus(status);
|
||||
}
|
||||
};
|
||||
element("disconnect").onclick = async () => {
|
||||
if (controlBusy) return;
|
||||
controlBusy = true;
|
||||
disconnectedByUser = true;
|
||||
renderStatus(status);
|
||||
try {
|
||||
await closePreview();
|
||||
await invoke("control", { operation: "disconnect" });
|
||||
element("error").hidden = true;
|
||||
resetMetrics();
|
||||
renderStatus(await invoke("status"));
|
||||
} catch (e) {
|
||||
disconnectedByUser = false;
|
||||
showError(e);
|
||||
} finally {
|
||||
controlBusy = false;
|
||||
renderStatus(status);
|
||||
}
|
||||
};
|
||||
async function closePreview() {
|
||||
++videoEpoch;
|
||||
player?.stop();
|
||||
player = undefined;
|
||||
videoSession = undefined;
|
||||
videoGeneration = undefined;
|
||||
videoStarting = videoFailed = false;
|
||||
element("video-retry").hidden = true;
|
||||
element("video-browser").hidden = true;
|
||||
}
|
||||
async function startPreview() {
|
||||
if (player || videoStarting || videoFailed || document.hidden) return;
|
||||
const epoch = ++videoEpoch;
|
||||
videoSession = status.session_id;
|
||||
videoGeneration = status.generation;
|
||||
videoStarting = true;
|
||||
const failed = (error: Error) => {
|
||||
if (epoch !== videoEpoch) return;
|
||||
videoFailed = true;
|
||||
showError(error);
|
||||
element("video-retry").hidden = false;
|
||||
element("video-browser").hidden = !CROSSDESK_APPS;
|
||||
element("stale").hidden = !imageAt;
|
||||
text("video-state", "画面不可用");
|
||||
element("video-state").dataset.state = "stale";
|
||||
if (!imageAt) {
|
||||
text("empty-title", "暂时无法显示画面");
|
||||
text("empty-description", "会话仍然保留。请尝试重新加载画面。");
|
||||
}
|
||||
};
|
||||
try {
|
||||
const grant = await invoke("video_open");
|
||||
if (epoch !== videoEpoch || document.hidden) return;
|
||||
player = new VideoPlayer(element<HTMLCanvasElement>("frame"), (info) => {
|
||||
if (epoch !== videoEpoch) return;
|
||||
imageAt = Date.now();
|
||||
element("frame").hidden = false;
|
||||
element("empty").hidden = true;
|
||||
element("stale").hidden = true;
|
||||
const size = `${info.width} × ${info.height}`;
|
||||
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
|
||||
if (size !== frameSize) { frameSize = size; text("resolution", size); }
|
||||
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
|
||||
element<HTMLButtonElement>("focus-view").disabled = false;
|
||||
text("video-state", "实时");
|
||||
element("video-state").dataset.state = "live";
|
||||
}, failed);
|
||||
player.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
|
||||
} catch (error) {
|
||||
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
|
||||
} finally {
|
||||
if (epoch === videoEpoch) videoStarting = false;
|
||||
}
|
||||
}
|
||||
element("video-retry").onclick = async () => {
|
||||
await closePreview();
|
||||
element("error").hidden = true;
|
||||
await startPreview();
|
||||
};
|
||||
element("video-browser").onclick = async () => {
|
||||
try {
|
||||
const result = await app!.callServerTool({name: "crossdesk_open_browser", arguments: {view_id: context!.view_id}});
|
||||
if (result.isError) throw new Error("VIDEO_UNAVAILABLE");
|
||||
started = false;
|
||||
await closePreview();
|
||||
element("error").hidden = true;
|
||||
for (const name of ["connect", "pause", "disconnect"])
|
||||
element<HTMLButtonElement>(name).disabled = true;
|
||||
element("panel-status").hidden = false;
|
||||
element("panel-status").textContent = "浏览器观看面板已打开。";
|
||||
} catch (error) { showError(error); }
|
||||
};
|
||||
async function poll() {
|
||||
if (polling || !invoke || !started || document.hidden || busy || controlBusy) return;
|
||||
polling = true;
|
||||
try {
|
||||
renderStatus(await invoke("status"));
|
||||
if (videoSession && (videoSession !== status.session_id ||
|
||||
videoGeneration !== status.generation)) {
|
||||
if (videoFailed) element("error").hidden = true;
|
||||
await closePreview();
|
||||
}
|
||||
if (status.state !== "ready" || document.hidden) {
|
||||
await closePreview();
|
||||
return;
|
||||
}
|
||||
await startPreview();
|
||||
const stale = !!imageAt && Date.now() - imageAt > 2000;
|
||||
element("stale").hidden = !stale;
|
||||
if (stale) {
|
||||
text("video-state", "等待画面");
|
||||
element("video-state").dataset.state = "stale";
|
||||
text("fps", "—");
|
||||
frameRate = "";
|
||||
}
|
||||
} catch (error) { showError(error); }
|
||||
finally { polling = false; }
|
||||
}
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) void closePreview();
|
||||
else void poll();
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
void closePreview();
|
||||
});
|
||||
setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
async function ready(next: Data) {
|
||||
if (
|
||||
started &&
|
||||
context?.view_id === next.view_id &&
|
||||
context?.view_generation === next.view_generation
|
||||
)
|
||||
return;
|
||||
if (started) await closePreview();
|
||||
context = next;
|
||||
element("panel-status").hidden = true;
|
||||
started = false;
|
||||
await invoke("ready");
|
||||
await devices();
|
||||
started = true;
|
||||
element<HTMLButtonElement>("connect").disabled = false;
|
||||
await poll();
|
||||
}
|
||||
async function start() {
|
||||
if (CROSSDESK_APPS) {
|
||||
const { App } = await import("@modelcontextprotocol/ext-apps");
|
||||
app = new App({ name: "CrossDesk MCP", version: "0.1.0" }, {});
|
||||
const names: Record<string, string> = {
|
||||
ready: "crossdesk_ui_ready",
|
||||
devices: "crossdesk_ui_list_devices",
|
||||
status: "crossdesk_ui_get_session",
|
||||
connect: "crossdesk_ui_connect",
|
||||
video_open: "crossdesk_video_open",
|
||||
control: "crossdesk_viewer_control",
|
||||
};
|
||||
let connected = false,
|
||||
initial: Data | undefined;
|
||||
invoke = async (name, args = {}) => {
|
||||
const result = await app!.callServerTool({
|
||||
name: names[name]!,
|
||||
arguments: {
|
||||
...args,
|
||||
view_id: context!.view_id,
|
||||
view_generation: context!.view_generation,
|
||||
ui_token: context!.ui_token,
|
||||
},
|
||||
});
|
||||
const data =
|
||||
result.structuredContent ??
|
||||
JSON.parse((result.content?.[0] as Data)?.text ?? "{}");
|
||||
return check(data);
|
||||
};
|
||||
app.ontoolresult = (result) => {
|
||||
const meta = result._meta?.["crossdesk/ui"] as Data | undefined;
|
||||
if (!meta) return;
|
||||
initial = { ...(result.structuredContent ?? {}), ...meta };
|
||||
if (connected) void ready(initial).catch(showError);
|
||||
};
|
||||
app.onteardown = async () => {
|
||||
await closePreview();
|
||||
return {};
|
||||
};
|
||||
await app.connect();
|
||||
connected = true;
|
||||
if (initial) await ready(initial);
|
||||
} else {
|
||||
let csrf = "";
|
||||
const request = async (path: string, body?: Data) =>
|
||||
check(
|
||||
await (
|
||||
await fetch("/api/" + path, {
|
||||
method: body ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
headers: body
|
||||
? { "Content-Type": "application/json", "X-CrossDesk-CSRF": csrf }
|
||||
: {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
cache: "no-store",
|
||||
})
|
||||
).json(),
|
||||
);
|
||||
const bootstrap = new URLSearchParams(location.hash.slice(1)).get(
|
||||
"bootstrap",
|
||||
);
|
||||
history.replaceState(null, "", location.pathname);
|
||||
const result = bootstrap
|
||||
? await request("bootstrap", { token: bootstrap })
|
||||
: await request("context");
|
||||
csrf = result.csrf;
|
||||
invoke = (name, args = {}) =>
|
||||
request(name, { ...args, view_id: context!.view_id });
|
||||
await ready(bootstrap ? result : result.views.at(-1));
|
||||
}
|
||||
}
|
||||
void start().catch(showError);
|
||||
Reference in New Issue
Block a user