@mario.andreschak/mcp-browser 3.43.0 → 3.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audioTap.js +120 -120
- package/dist/resources.js +29 -3
- package/dist/resources.js.map +1 -1
- package/dist/runtime.js +5 -2
- package/dist/runtime.js.map +1 -1
- package/dist/viewHtml.js +454 -454
- package/package.json +1 -1
package/dist/viewHtml.js
CHANGED
|
@@ -11,459 +11,459 @@
|
|
|
11
11
|
export function renderBrowserViewHtml() {
|
|
12
12
|
return VIEW_HTML;
|
|
13
13
|
}
|
|
14
|
-
const VIEW_HTML = `<!doctype html>
|
|
15
|
-
<html lang="en">
|
|
16
|
-
<head>
|
|
17
|
-
<meta charset="utf-8" />
|
|
18
|
-
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
19
|
-
<meta name="referrer" content="no-referrer" />
|
|
20
|
-
<title>Browser</title>
|
|
21
|
-
<style>
|
|
22
|
-
:root{
|
|
23
|
-
--chrome:#dee1e6; --chrome-hi:#f1f3f4; --tab:#fff; --fg:#202124; --muted:#5f6368;
|
|
24
|
-
--omni:#fff; --omni-border:#dadce0; --accent:#1a73e8; --page:#fff; --danger:#c5221f; --hover:rgba(0,0,0,.06);
|
|
25
|
-
}
|
|
26
|
-
html[data-theme="dark"]{
|
|
27
|
-
--chrome:#202124; --chrome-hi:#292a2d; --tab:#35363a; --fg:#e8eaed; --muted:#9aa0a6;
|
|
28
|
-
--omni:#292a2d; --omni-border:#3c4043; --accent:#8ab4f8; --page:#202124; --danger:#f28b82; --hover:rgba(255,255,255,.08);
|
|
29
|
-
}
|
|
30
|
-
*{box-sizing:border-box}
|
|
31
|
-
html,body{height:100%;margin:0;overflow:hidden}
|
|
32
|
-
body{background:var(--chrome);color:var(--fg);font:13px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;user-select:none}
|
|
33
|
-
button{font:inherit;color:inherit;background:none;border:0;border-radius:50%;width:30px;height:30px;display:grid;place-items:center;cursor:pointer;flex:none}
|
|
34
|
-
button:hover:not(:disabled){background:var(--hover)}
|
|
35
|
-
button:disabled{opacity:.35;cursor:default}
|
|
36
|
-
button svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
37
|
-
|
|
38
|
-
/* ---- tab strip ---- */
|
|
39
|
-
#tabs{display:flex;align-items:flex-end;gap:2px;height:36px;padding:5px 6px 0;flex:none}
|
|
40
|
-
.tab{display:flex;align-items:center;gap:8px;height:31px;padding:0 10px;min-width:120px;max-width:260px;
|
|
41
|
-
background:var(--tab);border-radius:9px 9px 0 0;box-shadow:0 0 0 .5px rgba(0,0,0,.08)}
|
|
42
|
-
.tab .favicon{width:15px;height:15px;flex:none;fill:none;stroke:var(--muted);stroke-width:2;visibility:hidden}
|
|
43
|
-
.tab .label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
44
|
-
.tab button{width:19px;height:19px}
|
|
45
|
-
.tab button svg{width:11px;height:11px}
|
|
46
|
-
#spinner{width:14px;height:14px;flex:none;border:2px solid var(--muted);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;display:none}
|
|
47
|
-
.loading #spinner{display:block}
|
|
48
|
-
.loading .favicon{display:none}
|
|
49
|
-
@keyframes spin{to{transform:rotate(360deg)}}
|
|
50
|
-
|
|
51
|
-
/* ---- toolbar ---- */
|
|
52
|
-
#bar{display:flex;align-items:center;gap:2px;padding:4px 6px;background:var(--chrome-hi);flex:none}
|
|
53
|
-
#omnibox{flex:1;display:flex;align-items:center;gap:8px;height:30px;padding:0 12px;margin:0 6px;
|
|
54
|
-
background:var(--omni);border:1px solid var(--omni-border);border-radius:15px;min-width:0}
|
|
55
|
-
#omnibox:focus-within{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
|
56
|
-
#security{width:14px;height:14px;flex:none;stroke:var(--muted);fill:none;stroke-width:2}
|
|
57
|
-
#security.secure{stroke:#188038}
|
|
58
|
-
#security.insecure{stroke:var(--danger)}
|
|
59
|
-
#url{flex:1;min-width:0;border:0;outline:0;background:none;color:inherit;font:inherit;user-select:text}
|
|
60
|
-
#url::placeholder{color:var(--muted)}
|
|
61
|
-
#progress{height:2px;background:var(--accent);width:0;transition:width .2s ease,opacity .3s;opacity:0;flex:none}
|
|
62
|
-
.loading #progress{opacity:1}
|
|
63
|
-
|
|
64
|
-
/* ---- viewport ---- */
|
|
65
|
-
#stage{flex:1;position:relative;background:var(--page);overflow:hidden;min-height:0}
|
|
66
|
-
#screen{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block;background:var(--page);
|
|
67
|
-
-webkit-user-drag:none;outline:none}
|
|
68
|
-
#stage:focus-within #screen{outline:none}
|
|
69
|
-
#overlay{position:absolute;inset:0;display:none;place-items:center;background:var(--page);color:var(--muted);
|
|
70
|
-
text-align:center;padding:24px;font-size:13px}
|
|
71
|
-
#overlay.show{display:grid}
|
|
72
|
-
#overlay b{display:block;color:var(--fg);font-size:15px;margin-bottom:6px;font-weight:600}
|
|
73
|
-
#retry{width:auto;height:28px;padding:0 14px;border-radius:14px;border:1px solid var(--omni-border);margin-top:12px}
|
|
74
|
-
|
|
75
|
-
/* ---- status ---- */
|
|
76
|
-
#status{position:absolute;left:0;bottom:0;max-width:70%;padding:3px 9px;background:var(--chrome-hi);color:var(--muted);
|
|
77
|
-
font-size:11px;border-radius:0 6px 0 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none}
|
|
78
|
-
#status.show{display:block}
|
|
79
|
-
|
|
80
|
-
/* ---- sound ---- */
|
|
81
|
-
#soundWaves{display:none}
|
|
82
|
-
#soundCross{display:none}
|
|
83
|
-
body.audio-on #soundWaves{display:inline}
|
|
84
|
-
body:not(.audio-on) #soundCross{display:inline}
|
|
85
|
-
body.audio-blocked #sound{color:var(--accent)}
|
|
86
|
-
</style>
|
|
87
|
-
</head>
|
|
88
|
-
<body>
|
|
89
|
-
|
|
90
|
-
<div id="tabs">
|
|
91
|
-
<div class="tab" id="tab">
|
|
92
|
-
<div id="spinner"></div>
|
|
93
|
-
<svg class="favicon" id="favicon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/></svg>
|
|
94
|
-
<span class="label" id="tabLabel">New tab</span>
|
|
95
|
-
<button id="closeTab" title="Close session"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
|
|
99
|
-
<div id="bar">
|
|
100
|
-
<button id="back" title="Back (Alt+Left)"><svg viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg></button>
|
|
101
|
-
<button id="forward" title="Forward (Alt+Right)"><svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
|
|
102
|
-
<button id="reload" title="Reload (Ctrl+R)"><svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 11-2.64-6.36M21 3v6h-6"/></svg></button>
|
|
103
|
-
<div id="omnibox">
|
|
104
|
-
<svg id="security" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 018 0v3"/></svg>
|
|
105
|
-
<input id="url" type="text" placeholder="Search or enter address" spellcheck="false" autocomplete="off" />
|
|
106
|
-
</div>
|
|
107
|
-
<button id="sound" title="Play this session's audio" hidden><svg viewBox="0 0 24 24"><path id="soundIcon" d="M11 5L6 9H3v6h3l5 4V5z"/><g id="soundWaves"><path d="M16 9a4 4 0 010 6"/><path d="M19 6a8 8 0 010 12"/></g><path id="soundCross" d="M16 9l5 6M21 9l-5 6"/></svg></button>
|
|
108
|
-
<button id="fullscreen" title="Toggle full screen"><svg viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5"/></svg></button>
|
|
109
|
-
</div>
|
|
110
|
-
<div id="progress"></div>
|
|
111
|
-
|
|
112
|
-
<div id="stage">
|
|
113
|
-
<img id="screen" alt="Live browser viewport" draggable="false" tabindex="0" />
|
|
114
|
-
<div id="overlay" class="show"><div><b>Connecting to the browser…</b><span id="overlayMsg">Starting the isolated session.</span><br/><button id="retry">Retry</button></div></div>
|
|
115
|
-
<div id="status"></div>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<script>
|
|
119
|
-
(function(){
|
|
120
|
-
"use strict";
|
|
121
|
-
var q = new URLSearchParams(location.search);
|
|
122
|
-
var sid = q.get("s") || "";
|
|
123
|
-
var token = q.get("t") || "";
|
|
124
|
-
var el = function(id){ return document.getElementById(id); };
|
|
125
|
-
var screenEl = el("screen"), overlay = el("overlay"), overlayMsg = el("overlayMsg");
|
|
126
|
-
var urlEl = el("url"), statusEl = el("status"), progress = el("progress");
|
|
127
|
-
var tabLabel = el("tabLabel"), favicon = el("favicon"), security = el("security");
|
|
128
|
-
var viewport = { width: 1280, height: 720 };
|
|
129
|
-
var editing = false, streamAlive = false, retryTimer = 0, retryDelay = 500;
|
|
130
|
-
|
|
131
|
-
function api(path){
|
|
132
|
-
return path + "?s=" + encodeURIComponent(sid) + "&t=" + encodeURIComponent(token);
|
|
133
|
-
}
|
|
134
|
-
function toShell(message){
|
|
135
|
-
try { parent.postMessage(Object.assign({ source: "flujo-browser-view" }, message), "*"); } catch (e) {}
|
|
136
|
-
}
|
|
137
|
-
function setStatus(text){
|
|
138
|
-
statusEl.textContent = text || "";
|
|
139
|
-
statusEl.classList.toggle("show", !!text);
|
|
140
|
-
}
|
|
141
|
-
function setLoading(on){
|
|
142
|
-
document.body.classList.toggle("loading", !!on);
|
|
143
|
-
progress.style.width = on ? "70%" : "100%";
|
|
144
|
-
if (!on) setTimeout(function(){ progress.style.width = "0"; }, 250);
|
|
145
|
-
}
|
|
146
|
-
function showOverlay(title, detail){
|
|
147
|
-
overlay.querySelector("b").textContent = title;
|
|
148
|
-
overlayMsg.textContent = detail || "";
|
|
149
|
-
overlay.classList.add("show");
|
|
150
|
-
}
|
|
151
|
-
function hideOverlay(){ overlay.classList.remove("show"); }
|
|
152
|
-
|
|
153
|
-
/* ---------------- input ---------------- */
|
|
154
|
-
var queued = [], flushHandle = 0;
|
|
155
|
-
function flush(){
|
|
156
|
-
flushHandle = 0;
|
|
157
|
-
if (!queued.length) return;
|
|
158
|
-
var batch = queued;
|
|
159
|
-
queued = [];
|
|
160
|
-
fetch(api("/input"), {
|
|
161
|
-
method: "POST",
|
|
162
|
-
// A CORS-safelisted content type keeps every keystroke preflight-free.
|
|
163
|
-
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
164
|
-
body: JSON.stringify(batch)
|
|
165
|
-
}).catch(function(){});
|
|
166
|
-
}
|
|
167
|
-
function send(event, immediate){
|
|
168
|
-
queued.push(event);
|
|
169
|
-
if (immediate) { if (flushHandle) { cancelAnimationFrame(flushHandle); flushHandle = 0; } flush(); return; }
|
|
170
|
-
if (!flushHandle) flushHandle = requestAnimationFrame(flush);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Map a pointer event onto page CSS pixels through the letterboxed frame. */
|
|
174
|
-
function toPage(e){
|
|
175
|
-
var w = screenEl.naturalWidth, h = screenEl.naturalHeight;
|
|
176
|
-
if (!w || !h) return null;
|
|
177
|
-
var r = screenEl.getBoundingClientRect();
|
|
178
|
-
var scale = Math.min(r.width / w, r.height / h);
|
|
179
|
-
var ox = r.left + (r.width - w * scale) / 2;
|
|
180
|
-
var oy = r.top + (r.height - h * scale) / 2;
|
|
181
|
-
var x = (e.clientX - ox) / scale, y = (e.clientY - oy) / scale;
|
|
182
|
-
if (x < 0 || y < 0 || x >= w || y >= h) return null;
|
|
183
|
-
return { x: Math.round(x * (viewport.width / w)), y: Math.round(y * (viewport.height / h)) };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
screenEl.addEventListener("mousemove", function(e){
|
|
187
|
-
var p = toPage(e); if (!p) return;
|
|
188
|
-
// Coalesce: only the newest pointer position in a frame is meaningful.
|
|
189
|
-
for (var i = queued.length - 1; i >= 0; i--) if (queued[i].type === "mousemove") { queued.splice(i, 1); break; }
|
|
190
|
-
send({ type: "mousemove", x: p.x, y: p.y });
|
|
191
|
-
});
|
|
192
|
-
screenEl.addEventListener("mousedown", function(e){
|
|
193
|
-
var p = toPage(e); if (!p) return;
|
|
194
|
-
e.preventDefault(); screenEl.focus();
|
|
195
|
-
send({ type: "mousedown", x: p.x, y: p.y, button: buttonName(e.button), clickCount: Math.min(3, e.detail || 1) }, true);
|
|
196
|
-
});
|
|
197
|
-
window.addEventListener("mouseup", function(e){
|
|
198
|
-
var p = toPage(e); if (!p) return;
|
|
199
|
-
send({ type: "mouseup", x: p.x, y: p.y, button: buttonName(e.button), clickCount: Math.min(3, e.detail || 1) }, true);
|
|
200
|
-
});
|
|
201
|
-
screenEl.addEventListener("contextmenu", function(e){ e.preventDefault(); });
|
|
202
|
-
screenEl.addEventListener("wheel", function(e){
|
|
203
|
-
e.preventDefault();
|
|
204
|
-
send({ type: "wheel", deltaX: e.deltaX, deltaY: e.deltaY }, true);
|
|
205
|
-
}, { passive: false });
|
|
206
|
-
screenEl.addEventListener("dragstart", function(e){ e.preventDefault(); });
|
|
207
|
-
|
|
208
|
-
function buttonName(code){ return code === 2 ? "right" : code === 1 ? "middle" : "left"; }
|
|
209
|
-
|
|
210
|
-
var PASSTHROUGH = /^(Enter|Tab|Backspace|Delete|Escape|Arrow|Page|Home|End|F\\d)/;
|
|
211
|
-
screenEl.addEventListener("keydown", function(e){
|
|
212
|
-
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){ e.preventDefault(); urlEl.focus(); urlEl.select(); return; }
|
|
213
|
-
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "r"){ e.preventDefault(); navigateCommand("reload"); return; }
|
|
214
|
-
if (e.altKey && e.key === "ArrowLeft"){ e.preventDefault(); navigateCommand("back"); return; }
|
|
215
|
-
if (e.altKey && e.key === "ArrowRight"){ e.preventDefault(); navigateCommand("forward"); return; }
|
|
216
|
-
if (e.key.length === 1 || PASSTHROUGH.test(e.key)) e.preventDefault();
|
|
217
|
-
send({ type: "keydown", key: e.key }, true);
|
|
218
|
-
});
|
|
219
|
-
screenEl.addEventListener("keyup", function(e){ send({ type: "keyup", key: e.key }, true); });
|
|
220
|
-
// IME and clipboard paste never surface as plain keydowns.
|
|
221
|
-
screenEl.addEventListener("paste", function(e){
|
|
222
|
-
var text = e.clipboardData && e.clipboardData.getData("text");
|
|
223
|
-
if (!text) return;
|
|
224
|
-
e.preventDefault();
|
|
225
|
-
send({ type: "text", text: text }, true);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
/* ---------------- viewport sizing ---------------- */
|
|
229
|
-
var resizeTimer = 0;
|
|
230
|
-
function syncViewport(){
|
|
231
|
-
clearTimeout(resizeTimer);
|
|
232
|
-
resizeTimer = setTimeout(function(){
|
|
233
|
-
var r = el("stage").getBoundingClientRect();
|
|
234
|
-
var width = Math.max(320, Math.min(3840, Math.round(r.width)));
|
|
235
|
-
var height = Math.max(240, Math.min(2160, Math.round(r.height)));
|
|
236
|
-
if (width === viewport.width && height === viewport.height) return;
|
|
237
|
-
viewport = { width: width, height: height };
|
|
238
|
-
send({ type: "viewport", width: width, height: height }, true);
|
|
239
|
-
}, 200);
|
|
240
|
-
}
|
|
241
|
-
if (window.ResizeObserver) new ResizeObserver(syncViewport).observe(el("stage"));
|
|
242
|
-
window.addEventListener("resize", syncViewport);
|
|
243
|
-
|
|
244
|
-
/* ---------------- live frame stream ---------------- */
|
|
245
|
-
function attachStream(){
|
|
246
|
-
streamAlive = false;
|
|
247
|
-
screenEl.src = api("/stream") + "&_=" + Date.now();
|
|
248
|
-
}
|
|
249
|
-
screenEl.addEventListener("load", function(){
|
|
250
|
-
streamAlive = true; retryDelay = 500; hideOverlay();
|
|
251
|
-
});
|
|
252
|
-
// MJPEG keeps one response open forever, so "error" means the socket died.
|
|
253
|
-
screenEl.addEventListener("error", function(){
|
|
254
|
-
streamAlive = false;
|
|
255
|
-
showOverlay("Live view interrupted", "Reconnecting to the browser session…");
|
|
256
|
-
scheduleRetry();
|
|
257
|
-
});
|
|
258
|
-
function scheduleRetry(){
|
|
259
|
-
if (retryTimer) return;
|
|
260
|
-
retryTimer = setTimeout(function(){
|
|
261
|
-
retryTimer = 0;
|
|
262
|
-
retryDelay = Math.min(8000, retryDelay * 2);
|
|
263
|
-
attachStream();
|
|
264
|
-
}, retryDelay);
|
|
265
|
-
}
|
|
266
|
-
el("retry").onclick = function(){ retryDelay = 500; attachStream(); };
|
|
267
|
-
|
|
268
|
-
/* ---------------- audio ----------------
|
|
269
|
-
The page's Web Audio graph is tapped server-side and streamed here as raw
|
|
270
|
-
PCM; playback happens in this browser, exactly like a locally rendered
|
|
271
|
-
audio app. Autoplay policy still applies, so a suspended context surfaces
|
|
272
|
-
as a highlighted speaker button the user can click. */
|
|
273
|
-
var audioCtx, audioAbort, audioNext = 0, audioWanted = false, audioAvailable = false;
|
|
274
|
-
var soundBtn = el("sound");
|
|
275
|
-
|
|
276
|
-
function audioSupported(){
|
|
277
|
-
return !!(window.AudioContext || window.webkitAudioContext) && !!window.ReadableStream;
|
|
278
|
-
}
|
|
279
|
-
function setAudioState(){
|
|
280
|
-
document.body.classList.toggle("audio-on", audioWanted && !!audioCtx && audioCtx.state === "running");
|
|
281
|
-
document.body.classList.toggle("audio-blocked", audioWanted && !!audioCtx && audioCtx.state !== "running");
|
|
282
|
-
soundBtn.title = audioWanted && !!audioCtx && audioCtx.state === "running" ? "Mute this session" : "Play this session's audio";
|
|
283
|
-
}
|
|
284
|
-
function stopAudio(){
|
|
285
|
-
audioWanted = false;
|
|
286
|
-
if (audioAbort) { audioAbort.abort(); audioAbort = null; }
|
|
287
|
-
audioNext = 0;
|
|
288
|
-
setAudioState();
|
|
289
|
-
}
|
|
290
|
-
function startAudio(){
|
|
291
|
-
if (!audioAvailable || !audioSupported()) return;
|
|
292
|
-
audioWanted = true;
|
|
293
|
-
var Ctor = window.AudioContext || window.webkitAudioContext;
|
|
294
|
-
if (!audioCtx) audioCtx = new Ctor();
|
|
295
|
-
if (audioCtx.state === "suspended") audioCtx.resume().then(setAudioState, setAudioState);
|
|
296
|
-
if (!audioAbort) pumpAudio();
|
|
297
|
-
setAudioState();
|
|
298
|
-
}
|
|
299
|
-
soundBtn.onclick = function(){
|
|
300
|
-
if (audioWanted && audioCtx && audioCtx.state === "suspended") {
|
|
301
|
-
audioCtx.resume().then(setAudioState, setAudioState);
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (audioWanted) stopAudio(); else startAudio();
|
|
305
|
-
};
|
|
306
|
-
// A click anywhere in the view is the gesture an autoplay-blocked context needs.
|
|
307
|
-
document.addEventListener("pointerdown", function(){
|
|
308
|
-
if (audioWanted && audioCtx && audioCtx.state === "suspended") audioCtx.resume().then(setAudioState, setAudioState);
|
|
309
|
-
}, true);
|
|
310
|
-
|
|
311
|
-
function pumpAudio(){
|
|
312
|
-
audioAbort = new AbortController();
|
|
313
|
-
fetch(api("/audio"), { signal: audioAbort.signal }).then(function(response){
|
|
314
|
-
if (!response.ok || !response.body) throw new Error("audio unavailable");
|
|
315
|
-
var reader = response.body.getReader();
|
|
316
|
-
var pending = new Uint8Array(0);
|
|
317
|
-
function read(){
|
|
318
|
-
return reader.read().then(function(result){
|
|
319
|
-
if (result.done) return;
|
|
320
|
-
pending = concat(pending, result.value);
|
|
321
|
-
pending = drain(pending);
|
|
322
|
-
return read();
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
return read();
|
|
326
|
-
}).catch(function(){}).then(function(){
|
|
327
|
-
audioAbort = null;
|
|
328
|
-
// Reconnect while the user still wants sound; the stream ends whenever
|
|
329
|
-
// the session navigates away or the socket drops.
|
|
330
|
-
if (audioWanted) setTimeout(function(){ if (audioWanted && !audioAbort) pumpAudio(); }, 700);
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
function concat(a, b){
|
|
334
|
-
if (!a.length) return b;
|
|
335
|
-
var out = new Uint8Array(a.length + b.length);
|
|
336
|
-
out.set(a, 0); out.set(b, a.length);
|
|
337
|
-
return out;
|
|
338
|
-
}
|
|
339
|
-
/** Consume every whole [rate, channels, byteLength] frame in the buffer. */
|
|
340
|
-
function drain(bytes){
|
|
341
|
-
var offset = 0;
|
|
342
|
-
while (bytes.length - offset >= 12) {
|
|
343
|
-
var head = new DataView(bytes.buffer, bytes.byteOffset + offset, 12);
|
|
344
|
-
var rate = head.getUint32(0, true), channels = head.getUint32(4, true), length = head.getUint32(8, true);
|
|
345
|
-
if (bytes.length - offset - 12 < length) break;
|
|
346
|
-
var pcm = new Int16Array(bytes.buffer.slice(bytes.byteOffset + offset + 12, bytes.byteOffset + offset + 12 + length));
|
|
347
|
-
offset += 12 + length;
|
|
348
|
-
schedule(pcm, rate, channels);
|
|
349
|
-
}
|
|
350
|
-
return offset ? bytes.subarray(offset) : bytes;
|
|
351
|
-
}
|
|
352
|
-
function schedule(pcm, rate, channels){
|
|
353
|
-
if (!audioCtx || !audioWanted || audioCtx.state !== "running") return;
|
|
354
|
-
var frames = Math.floor(pcm.length / channels);
|
|
355
|
-
if (!frames) return;
|
|
356
|
-
var buffer = audioCtx.createBuffer(channels, frames, rate);
|
|
357
|
-
for (var c = 0; c < channels; c++) {
|
|
358
|
-
var data = buffer.getChannelData(c);
|
|
359
|
-
for (var i = 0; i < frames; i++) data[i] = pcm[i * channels + c] / 32768;
|
|
360
|
-
}
|
|
361
|
-
var source = audioCtx.createBufferSource();
|
|
362
|
-
source.buffer = buffer;
|
|
363
|
-
source.connect(audioCtx.destination);
|
|
364
|
-
var now = audioCtx.currentTime;
|
|
365
|
-
// Keep a small jitter buffer, and resynchronise whenever the network
|
|
366
|
-
// hiccup pushed us behind or a pause left us far ahead.
|
|
367
|
-
if (audioNext < now + 0.02 || audioNext > now + 0.6) audioNext = now + 0.12;
|
|
368
|
-
source.start(audioNext);
|
|
369
|
-
audioNext += buffer.duration;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/* ---------------- page state ---------------- */
|
|
373
|
-
var events;
|
|
374
|
-
function attachEvents(){
|
|
375
|
-
try { events = new EventSource(api("/events")); } catch (e) { return; }
|
|
376
|
-
events.addEventListener("state", function(message){
|
|
377
|
-
var data;
|
|
378
|
-
try { data = JSON.parse(message.data); } catch (e) { return; }
|
|
379
|
-
applyState(data);
|
|
380
|
-
});
|
|
381
|
-
events.onerror = function(){
|
|
382
|
-
// EventSource reconnects on its own; surface nothing unless frames stop.
|
|
383
|
-
if (!streamAlive) setStatus("Reconnecting…");
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
function applyState(data){
|
|
387
|
-
if (data.viewport && data.viewport.width) viewport = data.viewport;
|
|
388
|
-
if (data.audio && audioSupported() && !audioAvailable){
|
|
389
|
-
audioAvailable = true;
|
|
390
|
-
setAudioState();
|
|
391
|
-
// Sound is part of "live", so try immediately; a blocked context simply
|
|
392
|
-
// waits for the first click instead of failing silently.
|
|
393
|
-
startAudio();
|
|
394
|
-
}
|
|
395
|
-
// Configuration means the capture path may be used; a non-silent PCM
|
|
396
|
-
// chunk is the first truthful proof that this page actually has audio.
|
|
397
|
-
soundBtn.hidden = !(data.audio && data.audioSignal);
|
|
398
|
-
setLoading(data.phase === "loading");
|
|
399
|
-
var url = data.url && data.url !== "about:blank" ? data.url : "";
|
|
400
|
-
if (!editing) urlEl.value = url;
|
|
401
|
-
tabLabel.textContent = data.title || hostOf(url) || "New tab";
|
|
402
|
-
document.title = data.title || "Browser";
|
|
403
|
-
setStatus("");
|
|
404
|
-
if (url.indexOf("https://") === 0) security.className = "secure";
|
|
405
|
-
else if (url.indexOf("http://") === 0) security.className = "insecure";
|
|
406
|
-
else security.className = "";
|
|
407
|
-
// Deliberately no third-party favicon service: an isolated browser must not
|
|
408
|
-
// leak every hostname the session visits to an outside provider.
|
|
409
|
-
favicon.style.visibility = url ? "visible" : "hidden";
|
|
410
|
-
}
|
|
411
|
-
function hostOf(url){ try { return new URL(url).hostname; } catch (e) { return ""; } }
|
|
412
|
-
|
|
413
|
-
/* ---------------- navigation (relayed through the MCP shell) ---------------- */
|
|
414
|
-
function navigateCommand(kind, url){
|
|
415
|
-
setLoading(true);
|
|
416
|
-
toShell({ type: kind, url: url });
|
|
417
|
-
}
|
|
418
|
-
el("back").onclick = function(){ navigateCommand("back"); };
|
|
419
|
-
el("forward").onclick = function(){ navigateCommand("forward"); };
|
|
420
|
-
el("reload").onclick = function(){ navigateCommand("reload"); };
|
|
421
|
-
el("closeTab").onclick = function(){ toShell({ type: "close" }); };
|
|
422
|
-
el("fullscreen").onclick = function(){ toShell({ type: "fullscreen" }); };
|
|
423
|
-
urlEl.onfocus = function(){ editing = true; urlEl.select(); };
|
|
424
|
-
urlEl.onblur = function(){ editing = false; };
|
|
425
|
-
urlEl.onkeydown = function(e){
|
|
426
|
-
if (e.key === "Escape"){ urlEl.blur(); screenEl.focus(); return; }
|
|
427
|
-
if (e.key !== "Enter") return;
|
|
428
|
-
e.preventDefault();
|
|
429
|
-
var value = urlEl.value.trim();
|
|
430
|
-
if (!value) return;
|
|
431
|
-
urlEl.blur();
|
|
432
|
-
screenEl.focus();
|
|
433
|
-
navigateCommand("navigate", normalizeAddress(value));
|
|
434
|
-
};
|
|
435
|
-
/** Treat bare words as a search and bare hosts as https, like a real omnibox. */
|
|
436
|
-
function normalizeAddress(value){
|
|
437
|
-
if (/^https?:\\/\\//i.test(value)) return value;
|
|
438
|
-
if (/^[^\\s.\\/]+\\.[^\\s\\/]{2,}(\\/|$|\\?|#)/.test(value) || /^localhost(:\\d+)?(\\/|$)/i.test(value)) {
|
|
439
|
-
return "https://" + value;
|
|
440
|
-
}
|
|
441
|
-
return "https://duckduckgo.com/?q=" + encodeURIComponent(value);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/* ---------------- shell messages ---------------- */
|
|
445
|
-
window.addEventListener("message", function(event){
|
|
446
|
-
var data = event.data;
|
|
447
|
-
if (!data || data.source !== "flujo-browser-shell") return;
|
|
448
|
-
if (data.type === "theme"){
|
|
449
|
-
document.documentElement.dataset.theme = data.theme === "dark" ? "dark" : "light";
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
if (data.type === "error"){ setStatus(data.message || ""); return; }
|
|
453
|
-
if (data.type === "loading"){ setLoading(!!data.value); return; }
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
if (!sid || !token){
|
|
457
|
-
showOverlay("Live view unavailable", "The session handshake did not complete.");
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
attachEvents();
|
|
461
|
-
attachStream();
|
|
462
|
-
syncViewport();
|
|
463
|
-
screenEl.focus();
|
|
464
|
-
toShell({ type: "ready" });
|
|
465
|
-
})();
|
|
466
|
-
</script>
|
|
467
|
-
</body>
|
|
14
|
+
const VIEW_HTML = `<!doctype html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
19
|
+
<meta name="referrer" content="no-referrer" />
|
|
20
|
+
<title>Browser</title>
|
|
21
|
+
<style>
|
|
22
|
+
:root{
|
|
23
|
+
--chrome:#dee1e6; --chrome-hi:#f1f3f4; --tab:#fff; --fg:#202124; --muted:#5f6368;
|
|
24
|
+
--omni:#fff; --omni-border:#dadce0; --accent:#1a73e8; --page:#fff; --danger:#c5221f; --hover:rgba(0,0,0,.06);
|
|
25
|
+
}
|
|
26
|
+
html[data-theme="dark"]{
|
|
27
|
+
--chrome:#202124; --chrome-hi:#292a2d; --tab:#35363a; --fg:#e8eaed; --muted:#9aa0a6;
|
|
28
|
+
--omni:#292a2d; --omni-border:#3c4043; --accent:#8ab4f8; --page:#202124; --danger:#f28b82; --hover:rgba(255,255,255,.08);
|
|
29
|
+
}
|
|
30
|
+
*{box-sizing:border-box}
|
|
31
|
+
html,body{height:100%;margin:0;overflow:hidden}
|
|
32
|
+
body{background:var(--chrome);color:var(--fg);font:13px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;user-select:none}
|
|
33
|
+
button{font:inherit;color:inherit;background:none;border:0;border-radius:50%;width:30px;height:30px;display:grid;place-items:center;cursor:pointer;flex:none}
|
|
34
|
+
button:hover:not(:disabled){background:var(--hover)}
|
|
35
|
+
button:disabled{opacity:.35;cursor:default}
|
|
36
|
+
button svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
37
|
+
|
|
38
|
+
/* ---- tab strip ---- */
|
|
39
|
+
#tabs{display:flex;align-items:flex-end;gap:2px;height:36px;padding:5px 6px 0;flex:none}
|
|
40
|
+
.tab{display:flex;align-items:center;gap:8px;height:31px;padding:0 10px;min-width:120px;max-width:260px;
|
|
41
|
+
background:var(--tab);border-radius:9px 9px 0 0;box-shadow:0 0 0 .5px rgba(0,0,0,.08)}
|
|
42
|
+
.tab .favicon{width:15px;height:15px;flex:none;fill:none;stroke:var(--muted);stroke-width:2;visibility:hidden}
|
|
43
|
+
.tab .label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
44
|
+
.tab button{width:19px;height:19px}
|
|
45
|
+
.tab button svg{width:11px;height:11px}
|
|
46
|
+
#spinner{width:14px;height:14px;flex:none;border:2px solid var(--muted);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;display:none}
|
|
47
|
+
.loading #spinner{display:block}
|
|
48
|
+
.loading .favicon{display:none}
|
|
49
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
50
|
+
|
|
51
|
+
/* ---- toolbar ---- */
|
|
52
|
+
#bar{display:flex;align-items:center;gap:2px;padding:4px 6px;background:var(--chrome-hi);flex:none}
|
|
53
|
+
#omnibox{flex:1;display:flex;align-items:center;gap:8px;height:30px;padding:0 12px;margin:0 6px;
|
|
54
|
+
background:var(--omni);border:1px solid var(--omni-border);border-radius:15px;min-width:0}
|
|
55
|
+
#omnibox:focus-within{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
|
56
|
+
#security{width:14px;height:14px;flex:none;stroke:var(--muted);fill:none;stroke-width:2}
|
|
57
|
+
#security.secure{stroke:#188038}
|
|
58
|
+
#security.insecure{stroke:var(--danger)}
|
|
59
|
+
#url{flex:1;min-width:0;border:0;outline:0;background:none;color:inherit;font:inherit;user-select:text}
|
|
60
|
+
#url::placeholder{color:var(--muted)}
|
|
61
|
+
#progress{height:2px;background:var(--accent);width:0;transition:width .2s ease,opacity .3s;opacity:0;flex:none}
|
|
62
|
+
.loading #progress{opacity:1}
|
|
63
|
+
|
|
64
|
+
/* ---- viewport ---- */
|
|
65
|
+
#stage{flex:1;position:relative;background:var(--page);overflow:hidden;min-height:0}
|
|
66
|
+
#screen{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block;background:var(--page);
|
|
67
|
+
-webkit-user-drag:none;outline:none}
|
|
68
|
+
#stage:focus-within #screen{outline:none}
|
|
69
|
+
#overlay{position:absolute;inset:0;display:none;place-items:center;background:var(--page);color:var(--muted);
|
|
70
|
+
text-align:center;padding:24px;font-size:13px}
|
|
71
|
+
#overlay.show{display:grid}
|
|
72
|
+
#overlay b{display:block;color:var(--fg);font-size:15px;margin-bottom:6px;font-weight:600}
|
|
73
|
+
#retry{width:auto;height:28px;padding:0 14px;border-radius:14px;border:1px solid var(--omni-border);margin-top:12px}
|
|
74
|
+
|
|
75
|
+
/* ---- status ---- */
|
|
76
|
+
#status{position:absolute;left:0;bottom:0;max-width:70%;padding:3px 9px;background:var(--chrome-hi);color:var(--muted);
|
|
77
|
+
font-size:11px;border-radius:0 6px 0 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:none}
|
|
78
|
+
#status.show{display:block}
|
|
79
|
+
|
|
80
|
+
/* ---- sound ---- */
|
|
81
|
+
#soundWaves{display:none}
|
|
82
|
+
#soundCross{display:none}
|
|
83
|
+
body.audio-on #soundWaves{display:inline}
|
|
84
|
+
body:not(.audio-on) #soundCross{display:inline}
|
|
85
|
+
body.audio-blocked #sound{color:var(--accent)}
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
|
|
90
|
+
<div id="tabs">
|
|
91
|
+
<div class="tab" id="tab">
|
|
92
|
+
<div id="spinner"></div>
|
|
93
|
+
<svg class="favicon" id="favicon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/></svg>
|
|
94
|
+
<span class="label" id="tabLabel">New tab</span>
|
|
95
|
+
<button id="closeTab" title="Close session"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div id="bar">
|
|
100
|
+
<button id="back" title="Back (Alt+Left)"><svg viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg></button>
|
|
101
|
+
<button id="forward" title="Forward (Alt+Right)"><svg viewBox="0 0 24 24"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button>
|
|
102
|
+
<button id="reload" title="Reload (Ctrl+R)"><svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 11-2.64-6.36M21 3v6h-6"/></svg></button>
|
|
103
|
+
<div id="omnibox">
|
|
104
|
+
<svg id="security" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 018 0v3"/></svg>
|
|
105
|
+
<input id="url" type="text" placeholder="Search or enter address" spellcheck="false" autocomplete="off" />
|
|
106
|
+
</div>
|
|
107
|
+
<button id="sound" title="Play this session's audio" hidden><svg viewBox="0 0 24 24"><path id="soundIcon" d="M11 5L6 9H3v6h3l5 4V5z"/><g id="soundWaves"><path d="M16 9a4 4 0 010 6"/><path d="M19 6a8 8 0 010 12"/></g><path id="soundCross" d="M16 9l5 6M21 9l-5 6"/></svg></button>
|
|
108
|
+
<button id="fullscreen" title="Toggle full screen"><svg viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5"/></svg></button>
|
|
109
|
+
</div>
|
|
110
|
+
<div id="progress"></div>
|
|
111
|
+
|
|
112
|
+
<div id="stage">
|
|
113
|
+
<img id="screen" alt="Live browser viewport" draggable="false" tabindex="0" />
|
|
114
|
+
<div id="overlay" class="show"><div><b>Connecting to the browser…</b><span id="overlayMsg">Starting the isolated session.</span><br/><button id="retry">Retry</button></div></div>
|
|
115
|
+
<div id="status"></div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<script>
|
|
119
|
+
(function(){
|
|
120
|
+
"use strict";
|
|
121
|
+
var q = new URLSearchParams(location.search);
|
|
122
|
+
var sid = q.get("s") || "";
|
|
123
|
+
var token = q.get("t") || "";
|
|
124
|
+
var el = function(id){ return document.getElementById(id); };
|
|
125
|
+
var screenEl = el("screen"), overlay = el("overlay"), overlayMsg = el("overlayMsg");
|
|
126
|
+
var urlEl = el("url"), statusEl = el("status"), progress = el("progress");
|
|
127
|
+
var tabLabel = el("tabLabel"), favicon = el("favicon"), security = el("security");
|
|
128
|
+
var viewport = { width: 1280, height: 720 };
|
|
129
|
+
var editing = false, streamAlive = false, retryTimer = 0, retryDelay = 500;
|
|
130
|
+
|
|
131
|
+
function api(path){
|
|
132
|
+
return path + "?s=" + encodeURIComponent(sid) + "&t=" + encodeURIComponent(token);
|
|
133
|
+
}
|
|
134
|
+
function toShell(message){
|
|
135
|
+
try { parent.postMessage(Object.assign({ source: "flujo-browser-view" }, message), "*"); } catch (e) {}
|
|
136
|
+
}
|
|
137
|
+
function setStatus(text){
|
|
138
|
+
statusEl.textContent = text || "";
|
|
139
|
+
statusEl.classList.toggle("show", !!text);
|
|
140
|
+
}
|
|
141
|
+
function setLoading(on){
|
|
142
|
+
document.body.classList.toggle("loading", !!on);
|
|
143
|
+
progress.style.width = on ? "70%" : "100%";
|
|
144
|
+
if (!on) setTimeout(function(){ progress.style.width = "0"; }, 250);
|
|
145
|
+
}
|
|
146
|
+
function showOverlay(title, detail){
|
|
147
|
+
overlay.querySelector("b").textContent = title;
|
|
148
|
+
overlayMsg.textContent = detail || "";
|
|
149
|
+
overlay.classList.add("show");
|
|
150
|
+
}
|
|
151
|
+
function hideOverlay(){ overlay.classList.remove("show"); }
|
|
152
|
+
|
|
153
|
+
/* ---------------- input ---------------- */
|
|
154
|
+
var queued = [], flushHandle = 0;
|
|
155
|
+
function flush(){
|
|
156
|
+
flushHandle = 0;
|
|
157
|
+
if (!queued.length) return;
|
|
158
|
+
var batch = queued;
|
|
159
|
+
queued = [];
|
|
160
|
+
fetch(api("/input"), {
|
|
161
|
+
method: "POST",
|
|
162
|
+
// A CORS-safelisted content type keeps every keystroke preflight-free.
|
|
163
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
164
|
+
body: JSON.stringify(batch)
|
|
165
|
+
}).catch(function(){});
|
|
166
|
+
}
|
|
167
|
+
function send(event, immediate){
|
|
168
|
+
queued.push(event);
|
|
169
|
+
if (immediate) { if (flushHandle) { cancelAnimationFrame(flushHandle); flushHandle = 0; } flush(); return; }
|
|
170
|
+
if (!flushHandle) flushHandle = requestAnimationFrame(flush);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Map a pointer event onto page CSS pixels through the letterboxed frame. */
|
|
174
|
+
function toPage(e){
|
|
175
|
+
var w = screenEl.naturalWidth, h = screenEl.naturalHeight;
|
|
176
|
+
if (!w || !h) return null;
|
|
177
|
+
var r = screenEl.getBoundingClientRect();
|
|
178
|
+
var scale = Math.min(r.width / w, r.height / h);
|
|
179
|
+
var ox = r.left + (r.width - w * scale) / 2;
|
|
180
|
+
var oy = r.top + (r.height - h * scale) / 2;
|
|
181
|
+
var x = (e.clientX - ox) / scale, y = (e.clientY - oy) / scale;
|
|
182
|
+
if (x < 0 || y < 0 || x >= w || y >= h) return null;
|
|
183
|
+
return { x: Math.round(x * (viewport.width / w)), y: Math.round(y * (viewport.height / h)) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
screenEl.addEventListener("mousemove", function(e){
|
|
187
|
+
var p = toPage(e); if (!p) return;
|
|
188
|
+
// Coalesce: only the newest pointer position in a frame is meaningful.
|
|
189
|
+
for (var i = queued.length - 1; i >= 0; i--) if (queued[i].type === "mousemove") { queued.splice(i, 1); break; }
|
|
190
|
+
send({ type: "mousemove", x: p.x, y: p.y });
|
|
191
|
+
});
|
|
192
|
+
screenEl.addEventListener("mousedown", function(e){
|
|
193
|
+
var p = toPage(e); if (!p) return;
|
|
194
|
+
e.preventDefault(); screenEl.focus();
|
|
195
|
+
send({ type: "mousedown", x: p.x, y: p.y, button: buttonName(e.button), clickCount: Math.min(3, e.detail || 1) }, true);
|
|
196
|
+
});
|
|
197
|
+
window.addEventListener("mouseup", function(e){
|
|
198
|
+
var p = toPage(e); if (!p) return;
|
|
199
|
+
send({ type: "mouseup", x: p.x, y: p.y, button: buttonName(e.button), clickCount: Math.min(3, e.detail || 1) }, true);
|
|
200
|
+
});
|
|
201
|
+
screenEl.addEventListener("contextmenu", function(e){ e.preventDefault(); });
|
|
202
|
+
screenEl.addEventListener("wheel", function(e){
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
send({ type: "wheel", deltaX: e.deltaX, deltaY: e.deltaY }, true);
|
|
205
|
+
}, { passive: false });
|
|
206
|
+
screenEl.addEventListener("dragstart", function(e){ e.preventDefault(); });
|
|
207
|
+
|
|
208
|
+
function buttonName(code){ return code === 2 ? "right" : code === 1 ? "middle" : "left"; }
|
|
209
|
+
|
|
210
|
+
var PASSTHROUGH = /^(Enter|Tab|Backspace|Delete|Escape|Arrow|Page|Home|End|F\\d)/;
|
|
211
|
+
screenEl.addEventListener("keydown", function(e){
|
|
212
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){ e.preventDefault(); urlEl.focus(); urlEl.select(); return; }
|
|
213
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "r"){ e.preventDefault(); navigateCommand("reload"); return; }
|
|
214
|
+
if (e.altKey && e.key === "ArrowLeft"){ e.preventDefault(); navigateCommand("back"); return; }
|
|
215
|
+
if (e.altKey && e.key === "ArrowRight"){ e.preventDefault(); navigateCommand("forward"); return; }
|
|
216
|
+
if (e.key.length === 1 || PASSTHROUGH.test(e.key)) e.preventDefault();
|
|
217
|
+
send({ type: "keydown", key: e.key }, true);
|
|
218
|
+
});
|
|
219
|
+
screenEl.addEventListener("keyup", function(e){ send({ type: "keyup", key: e.key }, true); });
|
|
220
|
+
// IME and clipboard paste never surface as plain keydowns.
|
|
221
|
+
screenEl.addEventListener("paste", function(e){
|
|
222
|
+
var text = e.clipboardData && e.clipboardData.getData("text");
|
|
223
|
+
if (!text) return;
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
send({ type: "text", text: text }, true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/* ---------------- viewport sizing ---------------- */
|
|
229
|
+
var resizeTimer = 0;
|
|
230
|
+
function syncViewport(){
|
|
231
|
+
clearTimeout(resizeTimer);
|
|
232
|
+
resizeTimer = setTimeout(function(){
|
|
233
|
+
var r = el("stage").getBoundingClientRect();
|
|
234
|
+
var width = Math.max(320, Math.min(3840, Math.round(r.width)));
|
|
235
|
+
var height = Math.max(240, Math.min(2160, Math.round(r.height)));
|
|
236
|
+
if (width === viewport.width && height === viewport.height) return;
|
|
237
|
+
viewport = { width: width, height: height };
|
|
238
|
+
send({ type: "viewport", width: width, height: height }, true);
|
|
239
|
+
}, 200);
|
|
240
|
+
}
|
|
241
|
+
if (window.ResizeObserver) new ResizeObserver(syncViewport).observe(el("stage"));
|
|
242
|
+
window.addEventListener("resize", syncViewport);
|
|
243
|
+
|
|
244
|
+
/* ---------------- live frame stream ---------------- */
|
|
245
|
+
function attachStream(){
|
|
246
|
+
streamAlive = false;
|
|
247
|
+
screenEl.src = api("/stream") + "&_=" + Date.now();
|
|
248
|
+
}
|
|
249
|
+
screenEl.addEventListener("load", function(){
|
|
250
|
+
streamAlive = true; retryDelay = 500; hideOverlay();
|
|
251
|
+
});
|
|
252
|
+
// MJPEG keeps one response open forever, so "error" means the socket died.
|
|
253
|
+
screenEl.addEventListener("error", function(){
|
|
254
|
+
streamAlive = false;
|
|
255
|
+
showOverlay("Live view interrupted", "Reconnecting to the browser session…");
|
|
256
|
+
scheduleRetry();
|
|
257
|
+
});
|
|
258
|
+
function scheduleRetry(){
|
|
259
|
+
if (retryTimer) return;
|
|
260
|
+
retryTimer = setTimeout(function(){
|
|
261
|
+
retryTimer = 0;
|
|
262
|
+
retryDelay = Math.min(8000, retryDelay * 2);
|
|
263
|
+
attachStream();
|
|
264
|
+
}, retryDelay);
|
|
265
|
+
}
|
|
266
|
+
el("retry").onclick = function(){ retryDelay = 500; attachStream(); };
|
|
267
|
+
|
|
268
|
+
/* ---------------- audio ----------------
|
|
269
|
+
The page's Web Audio graph is tapped server-side and streamed here as raw
|
|
270
|
+
PCM; playback happens in this browser, exactly like a locally rendered
|
|
271
|
+
audio app. Autoplay policy still applies, so a suspended context surfaces
|
|
272
|
+
as a highlighted speaker button the user can click. */
|
|
273
|
+
var audioCtx, audioAbort, audioNext = 0, audioWanted = false, audioAvailable = false;
|
|
274
|
+
var soundBtn = el("sound");
|
|
275
|
+
|
|
276
|
+
function audioSupported(){
|
|
277
|
+
return !!(window.AudioContext || window.webkitAudioContext) && !!window.ReadableStream;
|
|
278
|
+
}
|
|
279
|
+
function setAudioState(){
|
|
280
|
+
document.body.classList.toggle("audio-on", audioWanted && !!audioCtx && audioCtx.state === "running");
|
|
281
|
+
document.body.classList.toggle("audio-blocked", audioWanted && !!audioCtx && audioCtx.state !== "running");
|
|
282
|
+
soundBtn.title = audioWanted && !!audioCtx && audioCtx.state === "running" ? "Mute this session" : "Play this session's audio";
|
|
283
|
+
}
|
|
284
|
+
function stopAudio(){
|
|
285
|
+
audioWanted = false;
|
|
286
|
+
if (audioAbort) { audioAbort.abort(); audioAbort = null; }
|
|
287
|
+
audioNext = 0;
|
|
288
|
+
setAudioState();
|
|
289
|
+
}
|
|
290
|
+
function startAudio(){
|
|
291
|
+
if (!audioAvailable || !audioSupported()) return;
|
|
292
|
+
audioWanted = true;
|
|
293
|
+
var Ctor = window.AudioContext || window.webkitAudioContext;
|
|
294
|
+
if (!audioCtx) audioCtx = new Ctor();
|
|
295
|
+
if (audioCtx.state === "suspended") audioCtx.resume().then(setAudioState, setAudioState);
|
|
296
|
+
if (!audioAbort) pumpAudio();
|
|
297
|
+
setAudioState();
|
|
298
|
+
}
|
|
299
|
+
soundBtn.onclick = function(){
|
|
300
|
+
if (audioWanted && audioCtx && audioCtx.state === "suspended") {
|
|
301
|
+
audioCtx.resume().then(setAudioState, setAudioState);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (audioWanted) stopAudio(); else startAudio();
|
|
305
|
+
};
|
|
306
|
+
// A click anywhere in the view is the gesture an autoplay-blocked context needs.
|
|
307
|
+
document.addEventListener("pointerdown", function(){
|
|
308
|
+
if (audioWanted && audioCtx && audioCtx.state === "suspended") audioCtx.resume().then(setAudioState, setAudioState);
|
|
309
|
+
}, true);
|
|
310
|
+
|
|
311
|
+
function pumpAudio(){
|
|
312
|
+
audioAbort = new AbortController();
|
|
313
|
+
fetch(api("/audio"), { signal: audioAbort.signal }).then(function(response){
|
|
314
|
+
if (!response.ok || !response.body) throw new Error("audio unavailable");
|
|
315
|
+
var reader = response.body.getReader();
|
|
316
|
+
var pending = new Uint8Array(0);
|
|
317
|
+
function read(){
|
|
318
|
+
return reader.read().then(function(result){
|
|
319
|
+
if (result.done) return;
|
|
320
|
+
pending = concat(pending, result.value);
|
|
321
|
+
pending = drain(pending);
|
|
322
|
+
return read();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return read();
|
|
326
|
+
}).catch(function(){}).then(function(){
|
|
327
|
+
audioAbort = null;
|
|
328
|
+
// Reconnect while the user still wants sound; the stream ends whenever
|
|
329
|
+
// the session navigates away or the socket drops.
|
|
330
|
+
if (audioWanted) setTimeout(function(){ if (audioWanted && !audioAbort) pumpAudio(); }, 700);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function concat(a, b){
|
|
334
|
+
if (!a.length) return b;
|
|
335
|
+
var out = new Uint8Array(a.length + b.length);
|
|
336
|
+
out.set(a, 0); out.set(b, a.length);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
/** Consume every whole [rate, channels, byteLength] frame in the buffer. */
|
|
340
|
+
function drain(bytes){
|
|
341
|
+
var offset = 0;
|
|
342
|
+
while (bytes.length - offset >= 12) {
|
|
343
|
+
var head = new DataView(bytes.buffer, bytes.byteOffset + offset, 12);
|
|
344
|
+
var rate = head.getUint32(0, true), channels = head.getUint32(4, true), length = head.getUint32(8, true);
|
|
345
|
+
if (bytes.length - offset - 12 < length) break;
|
|
346
|
+
var pcm = new Int16Array(bytes.buffer.slice(bytes.byteOffset + offset + 12, bytes.byteOffset + offset + 12 + length));
|
|
347
|
+
offset += 12 + length;
|
|
348
|
+
schedule(pcm, rate, channels);
|
|
349
|
+
}
|
|
350
|
+
return offset ? bytes.subarray(offset) : bytes;
|
|
351
|
+
}
|
|
352
|
+
function schedule(pcm, rate, channels){
|
|
353
|
+
if (!audioCtx || !audioWanted || audioCtx.state !== "running") return;
|
|
354
|
+
var frames = Math.floor(pcm.length / channels);
|
|
355
|
+
if (!frames) return;
|
|
356
|
+
var buffer = audioCtx.createBuffer(channels, frames, rate);
|
|
357
|
+
for (var c = 0; c < channels; c++) {
|
|
358
|
+
var data = buffer.getChannelData(c);
|
|
359
|
+
for (var i = 0; i < frames; i++) data[i] = pcm[i * channels + c] / 32768;
|
|
360
|
+
}
|
|
361
|
+
var source = audioCtx.createBufferSource();
|
|
362
|
+
source.buffer = buffer;
|
|
363
|
+
source.connect(audioCtx.destination);
|
|
364
|
+
var now = audioCtx.currentTime;
|
|
365
|
+
// Keep a small jitter buffer, and resynchronise whenever the network
|
|
366
|
+
// hiccup pushed us behind or a pause left us far ahead.
|
|
367
|
+
if (audioNext < now + 0.02 || audioNext > now + 0.6) audioNext = now + 0.12;
|
|
368
|
+
source.start(audioNext);
|
|
369
|
+
audioNext += buffer.duration;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ---------------- page state ---------------- */
|
|
373
|
+
var events;
|
|
374
|
+
function attachEvents(){
|
|
375
|
+
try { events = new EventSource(api("/events")); } catch (e) { return; }
|
|
376
|
+
events.addEventListener("state", function(message){
|
|
377
|
+
var data;
|
|
378
|
+
try { data = JSON.parse(message.data); } catch (e) { return; }
|
|
379
|
+
applyState(data);
|
|
380
|
+
});
|
|
381
|
+
events.onerror = function(){
|
|
382
|
+
// EventSource reconnects on its own; surface nothing unless frames stop.
|
|
383
|
+
if (!streamAlive) setStatus("Reconnecting…");
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
function applyState(data){
|
|
387
|
+
if (data.viewport && data.viewport.width) viewport = data.viewport;
|
|
388
|
+
if (data.audio && audioSupported() && !audioAvailable){
|
|
389
|
+
audioAvailable = true;
|
|
390
|
+
setAudioState();
|
|
391
|
+
// Sound is part of "live", so try immediately; a blocked context simply
|
|
392
|
+
// waits for the first click instead of failing silently.
|
|
393
|
+
startAudio();
|
|
394
|
+
}
|
|
395
|
+
// Configuration means the capture path may be used; a non-silent PCM
|
|
396
|
+
// chunk is the first truthful proof that this page actually has audio.
|
|
397
|
+
soundBtn.hidden = !(data.audio && data.audioSignal);
|
|
398
|
+
setLoading(data.phase === "loading");
|
|
399
|
+
var url = data.url && data.url !== "about:blank" ? data.url : "";
|
|
400
|
+
if (!editing) urlEl.value = url;
|
|
401
|
+
tabLabel.textContent = data.title || hostOf(url) || "New tab";
|
|
402
|
+
document.title = data.title || "Browser";
|
|
403
|
+
setStatus("");
|
|
404
|
+
if (url.indexOf("https://") === 0) security.className = "secure";
|
|
405
|
+
else if (url.indexOf("http://") === 0) security.className = "insecure";
|
|
406
|
+
else security.className = "";
|
|
407
|
+
// Deliberately no third-party favicon service: an isolated browser must not
|
|
408
|
+
// leak every hostname the session visits to an outside provider.
|
|
409
|
+
favicon.style.visibility = url ? "visible" : "hidden";
|
|
410
|
+
}
|
|
411
|
+
function hostOf(url){ try { return new URL(url).hostname; } catch (e) { return ""; } }
|
|
412
|
+
|
|
413
|
+
/* ---------------- navigation (relayed through the MCP shell) ---------------- */
|
|
414
|
+
function navigateCommand(kind, url){
|
|
415
|
+
setLoading(true);
|
|
416
|
+
toShell({ type: kind, url: url });
|
|
417
|
+
}
|
|
418
|
+
el("back").onclick = function(){ navigateCommand("back"); };
|
|
419
|
+
el("forward").onclick = function(){ navigateCommand("forward"); };
|
|
420
|
+
el("reload").onclick = function(){ navigateCommand("reload"); };
|
|
421
|
+
el("closeTab").onclick = function(){ toShell({ type: "close" }); };
|
|
422
|
+
el("fullscreen").onclick = function(){ toShell({ type: "fullscreen" }); };
|
|
423
|
+
urlEl.onfocus = function(){ editing = true; urlEl.select(); };
|
|
424
|
+
urlEl.onblur = function(){ editing = false; };
|
|
425
|
+
urlEl.onkeydown = function(e){
|
|
426
|
+
if (e.key === "Escape"){ urlEl.blur(); screenEl.focus(); return; }
|
|
427
|
+
if (e.key !== "Enter") return;
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
var value = urlEl.value.trim();
|
|
430
|
+
if (!value) return;
|
|
431
|
+
urlEl.blur();
|
|
432
|
+
screenEl.focus();
|
|
433
|
+
navigateCommand("navigate", normalizeAddress(value));
|
|
434
|
+
};
|
|
435
|
+
/** Treat bare words as a search and bare hosts as https, like a real omnibox. */
|
|
436
|
+
function normalizeAddress(value){
|
|
437
|
+
if (/^https?:\\/\\//i.test(value)) return value;
|
|
438
|
+
if (/^[^\\s.\\/]+\\.[^\\s\\/]{2,}(\\/|$|\\?|#)/.test(value) || /^localhost(:\\d+)?(\\/|$)/i.test(value)) {
|
|
439
|
+
return "https://" + value;
|
|
440
|
+
}
|
|
441
|
+
return "https://duckduckgo.com/?q=" + encodeURIComponent(value);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* ---------------- shell messages ---------------- */
|
|
445
|
+
window.addEventListener("message", function(event){
|
|
446
|
+
var data = event.data;
|
|
447
|
+
if (!data || data.source !== "flujo-browser-shell") return;
|
|
448
|
+
if (data.type === "theme"){
|
|
449
|
+
document.documentElement.dataset.theme = data.theme === "dark" ? "dark" : "light";
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (data.type === "error"){ setStatus(data.message || ""); return; }
|
|
453
|
+
if (data.type === "loading"){ setLoading(!!data.value); return; }
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
if (!sid || !token){
|
|
457
|
+
showOverlay("Live view unavailable", "The session handshake did not complete.");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
attachEvents();
|
|
461
|
+
attachStream();
|
|
462
|
+
syncViewport();
|
|
463
|
+
screenEl.focus();
|
|
464
|
+
toShell({ type: "ready" });
|
|
465
|
+
})();
|
|
466
|
+
</script>
|
|
467
|
+
</body>
|
|
468
468
|
</html>`;
|
|
469
469
|
//# sourceMappingURL=viewHtml.js.map
|