Arena Agent commited on
Commit
e23ad3a
·
1 Parent(s): 4a95464

Add interactive live Chromium browser

Browse files
Files changed (6) hide show
  1. Dockerfile +21 -11
  2. README.md +10 -12
  3. app.py +0 -512
  4. browser-ui/index.html +55 -0
  5. requirements.txt +0 -3
  6. start-browser.sh +51 -0
Dockerfile CHANGED
@@ -1,21 +1,31 @@
1
- FROM python:3.11-slim
2
 
3
- ENV PYTHONDONTWRITEBYTECODE=1 \
4
- PYTHONUNBUFFERED=1 \
5
- PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
 
6
 
7
  WORKDIR /app
8
 
9
  RUN apt-get update \
10
- && apt-get install -y --no-install-recommends ca-certificates \
 
 
 
 
 
 
 
 
 
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
- COPY requirements.txt .
14
- RUN pip install --no-cache-dir --upgrade pip \
15
- && pip install --no-cache-dir -r requirements.txt \
16
- && python -m playwright install --with-deps chromium
17
 
18
- COPY app.py .
 
 
19
 
20
  EXPOSE 7860
21
- CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "90", "app:app"]
 
1
+ FROM debian:bookworm-slim
2
 
3
+ ENV DEBIAN_FRONTEND=noninteractive \
4
+ DISPLAY=:99 \
5
+ LANG=C.UTF-8 \
6
+ LC_ALL=C.UTF-8
7
 
8
  WORKDIR /app
9
 
10
  RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends \
12
+ ca-certificates \
13
+ chromium \
14
+ fluxbox \
15
+ fonts-liberation \
16
+ fonts-noto-color-emoji \
17
+ novnc \
18
+ websockify \
19
+ x11vnc \
20
+ xvfb \
21
  && rm -rf /var/lib/apt/lists/*
22
 
23
+ RUN mkdir -p /opt/browser-ui \
24
+ && cp -a /usr/share/novnc/. /opt/browser-ui/
 
 
25
 
26
+ COPY browser-ui/index.html /opt/browser-ui/index.html
27
+ COPY start-browser.sh /usr/local/bin/start-browser.sh
28
+ RUN chmod +x /usr/local/bin/start-browser.sh
29
 
30
  EXPOSE 7860
31
+ CMD ["/usr/local/bin/start-browser.sh"]
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: Web Test Lab
3
- emoji: 🧪
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
@@ -8,17 +8,15 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Web Test Lab
12
 
13
- A browser-based website testing dashboard powered by headless Chromium and Playwright.
14
 
15
- ## Features
16
 
17
- - Search the public web from a clean Chromium browser at `/search`
18
- - Test public HTTP and HTTPS websites from a clean browser session
19
- - Desktop, tablet, and mobile viewport profiles
20
- - HTTP status, page title, load time, final URL, and screenshot
21
- - Browser test UI available at `/`
22
- - Health check available at `/health`
23
 
24
- Only test websites that you own or are authorized to test. Localhost, private-network addresses, and URLs containing credentials are blocked.
 
1
  ---
2
+ title: Live Browser Lab
3
+ emoji: 🌐
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
 
8
  pinned: false
9
  ---
10
 
11
+ # Live Browser Lab
12
 
13
+ An interactive remote Chromium browser streamed through noVNC.
14
 
15
+ ## Use
16
 
17
+ 1. In **Settings Variables and secrets**, create `BROWSER_PASSWORD` with at least 8 characters.
18
+ 2. Open the Space and click **Open live browser**.
19
+ 3. Enter the first 8 characters of the password when noVNC asks.
20
+ 4. Use the visible Chromium address bar to search, type, click, scroll, and navigate.
 
 
21
 
22
+ The browser starts on DuckDuckGo. This Space is public, so do not enter sensitive credentials or private information. Use it only for websites you are authorized to access.
app.py DELETED
@@ -1,512 +0,0 @@
1
- import base64
2
- import ipaddress
3
- import socket
4
- import time
5
- from threading import Lock
6
- from time import perf_counter
7
- from urllib.parse import parse_qs, urlencode, urlparse
8
-
9
- from flask import Flask, jsonify, request
10
- from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
11
- from playwright.sync_api import sync_playwright
12
-
13
- app = Flask(__name__)
14
- app.config["MAX_CONTENT_LENGTH"] = 32 * 1024
15
-
16
- TEST_TIMEOUT_MS = 20_000
17
- RATE_WINDOW_SECONDS = 60
18
- RATE_LIMIT = 12
19
- RATE_LOCK = Lock()
20
- RATE_BUCKETS = {}
21
- BROWSER_LOCK = Lock()
22
- PLAYWRIGHT = None
23
- BROWSER = None
24
-
25
- DEVICES = {
26
- "desktop": {"viewport": {"width": 1440, "height": 900}},
27
- "tablet": {
28
- "viewport": {"width": 834, "height": 1112},
29
- "is_mobile": True,
30
- "has_touch": True,
31
- },
32
- "mobile": {
33
- "viewport": {"width": 390, "height": 844},
34
- "is_mobile": True,
35
- "has_touch": True,
36
- },
37
- }
38
-
39
- INDEX_HTML = r"""
40
- <!doctype html>
41
- <html lang="en">
42
- <head>
43
- <meta charset="utf-8">
44
- <meta name="viewport" content="width=device-width, initial-scale=1">
45
- <title>Web Test Lab</title>
46
- <style>
47
- :root {
48
- color-scheme: dark;
49
- --bg: #090d18;
50
- --panel: rgba(20, 27, 49, .82);
51
- --panel-strong: #151d35;
52
- --line: rgba(148, 163, 184, .18);
53
- --text: #f8fafc;
54
- --muted: #93a4c3;
55
- --blue: #6d8cff;
56
- --cyan: #39d9c4;
57
- --danger: #ff7b91;
58
- --shadow: 0 24px 70px rgba(0, 0, 0, .35);
59
- }
60
- * { box-sizing: border-box; }
61
- body {
62
- margin: 0;
63
- min-height: 100vh;
64
- color: var(--text);
65
- font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
66
- background:
67
- radial-gradient(circle at 15% 0%, rgba(109, 140, 255, .24), transparent 33rem),
68
- radial-gradient(circle at 95% 15%, rgba(57, 217, 196, .13), transparent 28rem),
69
- var(--bg);
70
- }
71
- a { color: #9bb5ff; }
72
- .shell { width: min(1160px, calc(100% - 36px)); margin: 0 auto; padding: 34px 0 54px; }
73
- .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 46px; }
74
- .brand { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .04em; }
75
- .brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; color: #07111e; background: linear-gradient(135deg, var(--cyan), var(--blue)); box-shadow: 0 8px 26px rgba(57, 217, 196, .22); }
76
- .brand small { display: block; color: var(--muted); font-weight: 500; letter-spacing: 0; }
77
- .engine { display: inline-flex; align-items: center; gap: 8px; color: #b6c4de; font-size: 13px; }
78
- .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 14px var(--cyan); }
79
- .hero { max-width: 760px; margin-bottom: 30px; }
80
- .eyebrow { margin: 0 0 12px; color: var(--cyan); font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
81
- h1 { max-width: 680px; margin: 0; font-size: clamp(2.4rem, 6vw, 4.8rem); line-height: .98; letter-spacing: -.065em; }
82
- .hero p { max-width: 620px; margin: 20px 0 0; color: var(--muted); font-size: 17px; }
83
- .workspace { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; align-items: start; }
84
- .card { border: 1px solid var(--line); border-radius: 22px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
85
- .controls { padding: 22px; position: sticky; top: 18px; }
86
- .card-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; font-weight: 750; }
87
- .badge { padding: 5px 9px; border: 1px solid rgba(57, 217, 196, .26); border-radius: 999px; color: var(--cyan); background: rgba(57, 217, 196, .08); font-size: 11px; font-weight: 700; }
88
- label { display: block; margin: 18px 0 8px; color: #c8d4ea; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
89
- input[type=url] { width: 100%; padding: 13px 14px; border: 1px solid var(--line); border-radius: 12px; outline: 0; color: var(--text); background: rgba(5, 10, 22, .64); font: inherit; }
90
- input[type=url]:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(109, 140, 255, .14); }
91
- .presets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
92
- .preset, .device { border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: transparent; cursor: pointer; font: inherit; }
93
- .preset { padding: 6px 9px; font-size: 12px; }
94
- .preset:hover, .device:hover { border-color: var(--blue); color: var(--text); }
95
- .devices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
96
- .device { padding: 10px 5px; }
97
- .device.active { border-color: var(--blue); color: white; background: rgba(109, 140, 255, .16); }
98
- .run { width: 100%; margin-top: 24px; padding: 14px 16px; border: 0; border-radius: 12px; color: #07111e; background: linear-gradient(100deg, var(--cyan), #85a0ff); box-shadow: 0 12px 26px rgba(66, 137, 255, .2); cursor: pointer; font: inherit; font-weight: 850; }
99
- .run:disabled { opacity: .55; cursor: wait; }
100
- .notice { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
101
- .results { padding: 22px; min-height: 480px; }
102
- .result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
103
- .result-head h2 { margin: 0; font-size: 18px; }
104
- #status { color: var(--muted); font-size: 13px; }
105
- #status.error { color: var(--danger); }
106
- #status.success { color: var(--cyan); }
107
- .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 18px; }
108
- .metric { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(7, 12, 27, .42); }
109
- .metric-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
110
- .metric-value { overflow: hidden; margin-top: 4px; font-size: 17px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
111
- .preview { display: grid; place-items: center; min-height: 330px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: #0a1020; }
112
- .preview.empty::before { content: "Your browser screenshot will appear here"; color: #60708f; }
113
- #screenshot { display: block; width: 100%; max-height: 610px; object-fit: contain; }
114
- .result-foot { display: flex; justify-content: space-between; gap: 12px; margin-top: 14px; color: var(--muted); font-size: 12px; }
115
- .download { display: none; color: #b9c8ff; font-weight: 700; text-decoration: none; }
116
- .download.show { display: inline; }
117
- @media (max-width: 820px) { .workspace { grid-template-columns: 1fr; } .controls { position: static; } .topbar { margin-bottom: 32px; } }
118
- @media (max-width: 520px) { .shell { width: min(100% - 24px, 1160px); padding-top: 20px; } .engine { display: none; } .metrics { grid-template-columns: 1fr; } .result-foot { flex-direction: column; } }
119
- </style>
120
- </head>
121
- <body>
122
- <main class="shell">
123
- <header class="topbar">
124
- <div class="brand"><div class="brand-mark">✦</div><div>WEB TEST LAB<small>Browser-powered checks</small></div></div>
125
- <div class="engine"><a href="/search">Search web ↗</a><span class="dot"></span> Chromium ready</div>
126
- </header>
127
- <section class="hero">
128
- <p class="eyebrow">Live website testing</p>
129
- <h1>See how your site behaves before users do.</h1>
130
- <p>Enter a public URL, choose a device profile, and run a real headless Chromium check with a visual screenshot. Need to find something? <a href="/search">Search the web</a>.</p>
131
- </section>
132
- <section class="workspace">
133
- <aside class="card controls">
134
- <div class="card-title"><span>Test setup</span><span class="badge">SECURE PREVIEW</span></div>
135
- <form id="test-form">
136
- <label for="url">Website URL</label>
137
- <input id="url" type="url" value="https://example.com" placeholder="https://your-site.com" required>
138
- <div class="presets">
139
- <button class="preset" type="button" data-url="https://example.com">Example</button>
140
- <button class="preset" type="button" data-url="https://huggingface.co">Hugging Face</button>
141
- </div>
142
- <label>Viewport</label>
143
- <div class="devices">
144
- <button class="device active" type="button" data-device="desktop">Desktop</button>
145
- <button class="device" type="button" data-device="tablet">Tablet</button>
146
- <button class="device" type="button" data-device="mobile">Mobile</button>
147
- </div>
148
- <button class="run" id="run" type="submit">Run browser test →</button>
149
- </form>
150
- <p class="notice">Only public HTTP and HTTPS websites are allowed. Use this tool only on sites you own or are authorized to test.</p>
151
- </aside>
152
- <section class="card results">
153
- <div class="result-head"><h2>Test result</h2><span id="status">Ready when you are</span></div>
154
- <div class="metrics">
155
- <div class="metric"><div class="metric-label">HTTP status</div><div class="metric-value" id="http-status">—</div></div>
156
- <div class="metric"><div class="metric-label">Load time</div><div class="metric-value" id="load-time">—</div></div>
157
- <div class="metric"><div class="metric-label">Page title</div><div class="metric-value" id="page-title">—</div></div>
158
- </div>
159
- <div class="preview empty" id="preview"><img id="screenshot" alt="Website screenshot" hidden></div>
160
- <div class="result-foot"><span id="final-url">No test run yet</span><a class="download" id="download" download="website-screenshot.png">Download screenshot</a></div>
161
- </section>
162
- </section>
163
- </main>
164
- <script>
165
- const form = document.getElementById('test-form');
166
- const urlInput = document.getElementById('url');
167
- const runButton = document.getElementById('run');
168
- const status = document.getElementById('status');
169
- const preview = document.getElementById('preview');
170
- const screenshot = document.getElementById('screenshot');
171
- const download = document.getElementById('download');
172
- const httpStatus = document.getElementById('http-status');
173
- const loadTime = document.getElementById('load-time');
174
- const pageTitle = document.getElementById('page-title');
175
- const finalUrl = document.getElementById('final-url');
176
- let device = 'desktop';
177
- document.querySelectorAll('.device').forEach((button) => button.addEventListener('click', () => {
178
- device = button.dataset.device;
179
- document.querySelectorAll('.device').forEach((item) => item.classList.toggle('active', item === button));
180
- }));
181
- document.querySelectorAll('.preset').forEach((button) => button.addEventListener('click', () => { urlInput.value = button.dataset.url; }));
182
- form.addEventListener('submit', async (event) => {
183
- event.preventDefault();
184
- runButton.disabled = true;
185
- status.className = '';
186
- status.textContent = 'Opening Chromium…';
187
- preview.classList.add('empty');
188
- screenshot.hidden = true;
189
- download.classList.remove('show');
190
- try {
191
- const response = await fetch('/api/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: urlInput.value, device }) });
192
- const data = await response.json();
193
- if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`);
194
- httpStatus.textContent = data.status ?? '—';
195
- loadTime.textContent = `${data.load_time_ms} ms`;
196
- pageTitle.textContent = data.title || '(untitled)';
197
- finalUrl.textContent = data.url;
198
- screenshot.src = `data:image/png;base64,${data.screenshot}`;
199
- screenshot.hidden = false;
200
- preview.classList.remove('empty');
201
- download.href = screenshot.src;
202
- download.classList.add('show');
203
- status.className = data.ok ? 'success' : 'error';
204
- status.textContent = data.ok ? 'Test completed' : 'Page returned an error status';
205
- } catch (error) {
206
- status.className = 'error';
207
- status.textContent = error.message;
208
- httpStatus.textContent = '—';
209
- loadTime.textContent = '—';
210
- pageTitle.textContent = '—';
211
- finalUrl.textContent = 'Test did not complete';
212
- } finally {
213
- runButton.disabled = false;
214
- }
215
- });
216
- </script>
217
- </body>
218
- </html>
219
- """
220
-
221
- SEARCH_HTML = r"""
222
- <!doctype html>
223
- <html lang="en">
224
- <head>
225
- <meta charset="utf-8">
226
- <meta name="viewport" content="width=device-width, initial-scale=1">
227
- <title>Web Search · Web Test Lab</title>
228
- <style>
229
- :root { color-scheme: dark; --bg:#080c17; --panel:#121a30; --line:rgba(148,163,184,.18); --text:#f8fafc; --muted:#92a2bf; --blue:#7b96ff; --cyan:#43ddc6; --pink:#ff8eaa; }
230
- * { box-sizing:border-box; }
231
- body { margin:0; min-height:100vh; color:var(--text); font:15px/1.55 Inter,ui-sans-serif,system-ui,sans-serif; background:radial-gradient(circle at 20% 0%,rgba(123,150,255,.24),transparent 34rem),radial-gradient(circle at 90% 15%,rgba(67,221,198,.13),transparent 30rem),var(--bg); }
232
- a { color:#abc0ff; }
233
- .wrap { width:min(940px,calc(100% - 32px)); margin:0 auto; padding:28px 0 56px; }
234
- nav { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:68px; }
235
- .brand { display:flex; align-items:center; gap:10px; color:var(--text); font-weight:850; letter-spacing:.05em; text-decoration:none; }
236
- .mark { display:grid; place-items:center; width:35px; height:35px; border-radius:11px; color:#07111e; background:linear-gradient(135deg,var(--cyan),var(--blue)); }
237
- .engine { color:var(--muted); font-size:13px; }
238
- .hero { text-align:center; }
239
- .eyebrow { margin:0 0 14px; color:var(--cyan); font-size:12px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
240
- h1 { margin:0; font-size:clamp(2.5rem,8vw,5.2rem); line-height:.95; letter-spacing:-.07em; }
241
- .hero p { max-width:600px; margin:20px auto 0; color:var(--muted); font-size:17px; }
242
- .search-box { display:flex; gap:10px; max-width:760px; margin:32px auto 0; padding:8px; border:1px solid rgba(123,150,255,.38); border-radius:16px; background:rgba(11,17,34,.8); box-shadow:0 20px 60px rgba(0,0,0,.25); }
243
- input { min-width:0; flex:1; padding:13px 14px; border:0; outline:0; color:var(--text); background:transparent; font:inherit; }
244
- button { padding:0 22px; border:0; border-radius:11px; color:#07111e; background:linear-gradient(100deg,var(--cyan),#8da6ff); cursor:pointer; font:inherit; font-weight:850; }
245
- button:disabled { opacity:.55; cursor:wait; }
246
- .chips { display:flex; justify-content:center; flex-wrap:wrap; gap:8px; margin-top:16px; }
247
- .chip { padding:7px 11px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:transparent; cursor:pointer; font:inherit; font-size:12px; }
248
- .chip:hover { border-color:var(--blue); color:var(--text); }
249
- .meta { min-height:23px; margin:42px 0 14px; color:var(--muted); font-size:13px; }
250
- .results { display:grid; gap:10px; }
251
- .result { padding:18px 20px; border:1px solid var(--line); border-radius:16px; background:rgba(18,26,48,.78); transition:transform .16s,border-color .16s; }
252
- .result:hover { transform:translateY(-2px); border-color:rgba(123,150,255,.55); }
253
- .result-top { display:flex; gap:13px; align-items:flex-start; }
254
- .num { flex:0 0 auto; display:grid; place-items:center; width:27px; height:27px; border-radius:9px; color:var(--cyan); background:rgba(67,221,198,.1); font-size:12px; font-weight:800; }
255
- .title { display:block; color:#c4d1ff; font-size:17px; font-weight:750; text-decoration:none; }
256
- .title:hover { text-decoration:underline; }
257
- .url { overflow:hidden; margin-top:3px; color:#6fcdbd; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
258
- .snippet { margin:10px 0 0 40px; color:var(--muted); }
259
- .empty { padding:42px 20px; border:1px dashed var(--line); border-radius:16px; color:var(--muted); text-align:center; }
260
- footer { margin-top:42px; color:#6d7b96; font-size:12px; text-align:center; }
261
- @media (max-width:560px) { .wrap{width:min(100% - 22px,940px);padding-top:18px;} nav{margin-bottom:48px;} .engine{display:none;} .search-box{margin-top:25px;} button{padding:0 15px;} .snippet{margin-left:0;} }
262
- </style>
263
- </head>
264
- <body>
265
- <main class="wrap">
266
- <nav><a class="brand" href="/"><span class="mark">✦</span> WEB TEST LAB</a><span class="engine">Chromium-powered web search</span></nav>
267
- <section class="hero">
268
- <p class="eyebrow">Search the open web</p>
269
- <h1>Find what you need.</h1>
270
- <p>Search from a clean headless Chromium browser and open results in a new tab.</p>
271
- <form class="search-box" id="search-form">
272
- <input id="query" type="search" placeholder="Search the web…" autocomplete="off" required>
273
- <button id="search-button" type="submit">Search</button>
274
- </form>
275
- <div class="chips"><button class="chip" type="button" data-query="web development news">Web development</button><button class="chip" type="button" data-query="AI tools">AI tools</button><button class="chip" type="button" data-query="Bengaluru weather">Bengaluru weather</button></div>
276
- </section>
277
- <div class="meta" id="meta">Search results will appear here</div>
278
- <section class="results" id="results"><div class="empty">Enter a search query to begin.</div></section>
279
- <footer>Search uses public web results. Use this service responsibly and follow each website's terms.</footer>
280
- </main>
281
- <script>
282
- const form = document.getElementById('search-form');
283
- const queryInput = document.getElementById('query');
284
- const button = document.getElementById('search-button');
285
- const meta = document.getElementById('meta');
286
- const results = document.getElementById('results');
287
- document.querySelectorAll('.chip').forEach((chip) => chip.addEventListener('click', () => { queryInput.value = chip.dataset.query; form.requestSubmit(); }));
288
- form.addEventListener('submit', async (event) => {
289
- event.preventDefault();
290
- const query = queryInput.value.trim();
291
- if (!query) return;
292
- button.disabled = true;
293
- button.textContent = 'Searching…';
294
- meta.textContent = 'Opening Chromium and searching…';
295
- results.textContent = '';
296
- try {
297
- const response = await fetch('/api/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({query}) });
298
- const data = await response.json();
299
- if (!response.ok) throw new Error(data.error || `Search failed: ${response.status}`);
300
- meta.textContent = `${data.results.length} results for “${data.query}” · ${data.elapsed_ms} ms`;
301
- if (!data.results.length) { results.innerHTML = '<div class="empty">No results found. Try a different search.</div>'; return; }
302
- data.results.forEach((item, index) => {
303
- const card = document.createElement('article'); card.className = 'result';
304
- const top = document.createElement('div'); top.className = 'result-top';
305
- const num = document.createElement('span'); num.className = 'num'; num.textContent = String(index + 1);
306
- const body = document.createElement('div');
307
- const title = document.createElement('a'); title.className = 'title'; title.href = item.url; title.target = '_blank'; title.rel = 'noopener noreferrer'; title.textContent = item.title || item.url;
308
- const url = document.createElement('div'); url.className = 'url'; url.textContent = item.url;
309
- const snippet = document.createElement('p'); snippet.className = 'snippet'; snippet.textContent = item.snippet || 'Open this result in a new tab.';
310
- body.append(title, url); top.append(num, body); card.append(top, snippet); results.append(card);
311
- });
312
- } catch (error) {
313
- meta.textContent = error.message;
314
- results.innerHTML = '<div class="empty">Search could not be completed. Please try again.</div>';
315
- } finally { button.disabled = false; button.textContent = 'Search'; }
316
- });
317
- </script>
318
- </body>
319
- </html>
320
- """
321
-
322
-
323
- def _client_key():
324
- forwarded = request.headers.get("X-Forwarded-For", "")
325
- return (forwarded.split(",")[0].strip() or request.remote_addr or "unknown")
326
-
327
-
328
- def _within_rate_limit():
329
- now = time.time()
330
- key = _client_key()
331
- with RATE_LOCK:
332
- recent = [stamp for stamp in RATE_BUCKETS.get(key, []) if now - stamp < RATE_WINDOW_SECONDS]
333
- if len(recent) >= RATE_LIMIT:
334
- RATE_BUCKETS[key] = recent
335
- return False
336
- recent.append(now)
337
- RATE_BUCKETS[key] = recent
338
- return True
339
-
340
-
341
- def _public_url(value):
342
- if not isinstance(value, str) or len(value.strip()) > 2048:
343
- raise ValueError("Enter a valid public URL")
344
- value = value.strip()
345
- parsed = urlparse(value)
346
- if parsed.scheme not in {"http", "https"} or not parsed.hostname:
347
- raise ValueError("URL must start with http:// or https://")
348
- if parsed.username or parsed.password:
349
- raise ValueError("URLs with embedded credentials are not allowed")
350
- host = parsed.hostname.lower().rstrip(".")
351
- if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
352
- raise ValueError("Local network URLs are not allowed")
353
- try:
354
- addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
355
- except socket.gaierror as exc:
356
- raise ValueError("The hostname could not be resolved") from exc
357
- if not addresses or any(not ipaddress.ip_address(address).is_global for address in addresses):
358
- raise ValueError("Only public internet URLs are allowed")
359
- return value
360
-
361
-
362
- def _route_guard(route):
363
- target = route.request.url
364
- if target.startswith(("data:", "blob:", "about:", "chrome:")):
365
- route.continue_()
366
- return
367
- try:
368
- _public_url(target)
369
- except ValueError:
370
- route.abort()
371
- else:
372
- route.continue_()
373
-
374
-
375
- def _browser_test(target_url, device):
376
- global PLAYWRIGHT, BROWSER
377
- with BROWSER_LOCK:
378
- if BROWSER is None or not BROWSER.is_connected():
379
- if PLAYWRIGHT is not None:
380
- PLAYWRIGHT.stop()
381
- PLAYWRIGHT = sync_playwright().start()
382
- BROWSER = PLAYWRIGHT.chromium.launch(
383
- headless=True,
384
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
385
- )
386
- context = BROWSER.new_context(**DEVICES[device])
387
- page = context.new_page()
388
- page.route("**/*", _route_guard)
389
- started = perf_counter()
390
- try:
391
- response = page.goto(target_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
392
- page.wait_for_timeout(500)
393
- screenshot = base64.b64encode(page.screenshot(type="png", full_page=False)).decode("ascii")
394
- status_code = response.status if response is not None else None
395
- return {
396
- "ok": status_code is not None and 200 <= status_code < 400,
397
- "status": status_code,
398
- "title": page.title(),
399
- "url": page.url,
400
- "load_time_ms": round((perf_counter() - started) * 1000),
401
- "screenshot": screenshot,
402
- }
403
- finally:
404
- context.close()
405
-
406
-
407
- def _web_search(query):
408
- global PLAYWRIGHT, BROWSER
409
- search_url = "https://html.duckduckgo.com/html/?" + urlencode({"q": query})
410
- with BROWSER_LOCK:
411
- if BROWSER is None or not BROWSER.is_connected():
412
- if PLAYWRIGHT is not None:
413
- PLAYWRIGHT.stop()
414
- PLAYWRIGHT = sync_playwright().start()
415
- BROWSER = PLAYWRIGHT.chromium.launch(
416
- headless=True,
417
- args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
418
- )
419
- context = BROWSER.new_context(viewport={"width": 1280, "height": 900})
420
- page = context.new_page()
421
- page.route("**/*", _route_guard)
422
- started = perf_counter()
423
- try:
424
- page.goto(search_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
425
- page.wait_for_timeout(700)
426
- rows = page.locator("a.result__a, a[data-testid='result-title-a']").evaluate_all(
427
- """
428
- (links) => links.slice(0, 8).map((link) => {
429
- const result = link.closest('.result') || link.closest('[data-testid="result"]') || link.parentElement;
430
- const snippet = result?.querySelector('.result__snippet')?.innerText || '';
431
- return { title: link.innerText.trim(), url: link.href, snippet: snippet.trim() };
432
- })
433
- """
434
- )
435
- results = []
436
- for row in rows:
437
- href = row.get("url", "")
438
- parsed = urlparse(href)
439
- if parsed.hostname in {"duckduckgo.com", "www.duckduckgo.com"} and parsed.path.startswith("/l/"):
440
- href = parse_qs(parsed.query).get("uddg", [href])[0]
441
- try:
442
- href = _public_url(href)
443
- except ValueError:
444
- continue
445
- results.append({
446
- "title": row.get("title") or href,
447
- "url": href,
448
- "snippet": row.get("snippet", ""),
449
- })
450
- return {
451
- "query": query,
452
- "results": results,
453
- "elapsed_ms": round((perf_counter() - started) * 1000),
454
- }
455
- finally:
456
- context.close()
457
-
458
-
459
- @app.get("/")
460
- def home():
461
- return INDEX_HTML
462
-
463
-
464
- @app.get("/search")
465
- def search_page():
466
- return SEARCH_HTML
467
-
468
-
469
- @app.post("/api/search")
470
- def search_api():
471
- if not _within_rate_limit():
472
- return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
473
- payload = request.get_json(silent=True) or {}
474
- query = payload.get("query", "")
475
- if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
476
- return jsonify({"error": "Enter a search query between 2 and 200 characters."}), 400
477
- try:
478
- return jsonify(_web_search(query.strip()))
479
- except PlaywrightTimeoutError:
480
- return jsonify({"error": "The search took too long to complete."}), 504
481
- except Exception:
482
- app.logger.exception("Web search failed")
483
- return jsonify({"error": "The web search could not be completed."}), 502
484
-
485
-
486
- @app.post("/api/test")
487
- def run_test():
488
- if not _within_rate_limit():
489
- return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
490
- payload = request.get_json(silent=True) or {}
491
- device = payload.get("device", "desktop")
492
- if device not in DEVICES:
493
- return jsonify({"error": "Unknown viewport"}), 400
494
- try:
495
- target_url = _public_url(payload.get("url"))
496
- return jsonify(_browser_test(target_url, device))
497
- except ValueError as exc:
498
- return jsonify({"error": str(exc)}), 400
499
- except PlaywrightTimeoutError:
500
- return jsonify({"error": "The page took too long to load."}), 504
501
- except Exception:
502
- app.logger.exception("Browser test failed")
503
- return jsonify({"error": "The browser test could not be completed."}), 502
504
-
505
-
506
- @app.get("/health")
507
- def health_check():
508
- return jsonify({"status": "healthy", "browser": "chromium"}), 200
509
-
510
-
511
- if __name__ == "__main__":
512
- app.run(host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
browser-ui/index.html ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Live Browser Lab</title>
7
+ <style>
8
+ :root { color-scheme: dark; --bg:#090d18; --panel:#151d35; --line:rgba(148,163,184,.18); --muted:#96a7c4; --text:#f8fafc; --cyan:#43ddc6; --blue:#8198ff; }
9
+ * { box-sizing:border-box; }
10
+ body { min-height:100vh; margin:0; color:var(--text); font:15px/1.55 system-ui,sans-serif; background:radial-gradient(circle at 15% 0%,rgba(129,152,255,.26),transparent 34rem),radial-gradient(circle at 95% 20%,rgba(67,221,198,.14),transparent 30rem),var(--bg); }
11
+ .wrap { width:min(900px,calc(100% - 32px)); margin:0 auto; padding:42px 0; }
12
+ .brand { display:flex; align-items:center; gap:11px; color:var(--text); font-weight:850; letter-spacing:.06em; }
13
+ .mark { display:grid; place-items:center; width:40px; height:40px; border-radius:13px; color:#07111e; background:linear-gradient(135deg,var(--cyan),var(--blue)); box-shadow:0 10px 30px rgba(67,221,198,.2); }
14
+ .hero { margin-top:76px; max-width:700px; }
15
+ .eyebrow { color:var(--cyan); font-size:12px; font-weight:800; letter-spacing:.15em; text-transform:uppercase; }
16
+ h1 { margin:12px 0 0; font-size:clamp(2.8rem,8vw,5.8rem); line-height:.92; letter-spacing:-.075em; }
17
+ .hero p { max-width:620px; margin:22px 0 0; color:var(--muted); font-size:18px; }
18
+ .panel { margin-top:34px; padding:24px; border:1px solid var(--line); border-radius:24px; background:rgba(21,29,53,.78); box-shadow:0 25px 80px rgba(0,0,0,.3); backdrop-filter:blur(15px); }
19
+ .panel-head { display:flex; align-items:center; justify-content:space-between; gap:14px; }
20
+ .panel-head h2 { margin:0; font-size:19px; }
21
+ .live { display:inline-flex; align-items:center; gap:8px; color:var(--cyan); font-size:12px; font-weight:750; }
22
+ .dot { width:8px; height:8px; border-radius:50%; background:var(--cyan); box-shadow:0 0 15px var(--cyan); }
23
+ .steps { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:22px 0; }
24
+ .step { padding:14px; border:1px solid var(--line); border-radius:14px; color:var(--muted); }
25
+ .step strong { display:block; margin-bottom:4px; color:var(--text); }
26
+ .step span { font-size:13px; }
27
+ .launch { display:inline-flex; align-items:center; justify-content:center; width:100%; padding:15px 18px; border-radius:12px; color:#07111e; background:linear-gradient(100deg,var(--cyan),#91a6ff); font-weight:850; text-decoration:none; box-shadow:0 12px 28px rgba(67,221,198,.15); }
28
+ .launch:hover { filter:brightness(1.08); }
29
+ .note { margin:16px 0 0; color:var(--muted); font-size:12px; }
30
+ footer { margin-top:24px; color:#71809a; font-size:12px; }
31
+ @media (max-width:600px) { .wrap{width:min(100% - 22px,900px);padding-top:25px;} .hero{margin-top:52px;} .steps{grid-template-columns:1fr;} }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <main class="wrap">
36
+ <div class="brand"><span class="mark">✦</span> LIVE BROWSER LAB</div>
37
+ <section class="hero">
38
+ <div class="eyebrow">Interactive remote Chromium</div>
39
+ <h1>A real browser, live in your tab.</h1>
40
+ <p>Launch a private streamed browser session. Use the address bar, search the web, click links, type, scroll, and navigate just like a normal browser.</p>
41
+ </section>
42
+ <section class="panel">
43
+ <div class="panel-head"><h2>Ready to connect</h2><span class="live"><span class="dot"></span> Browser online</span></div>
44
+ <div class="steps">
45
+ <div class="step"><strong>1 · Launch</strong><span>Open the live session below.</span></div>
46
+ <div class="step"><strong>2 · Connect</strong><span>Enter your Space secret when asked.</span></div>
47
+ <div class="step"><strong>3 · Browse</strong><span>Search and use the browser normally.</span></div>
48
+ </div>
49
+ <a class="launch" href="/vnc.html?autoconnect=true&resize=scale">Open live browser →</a>
50
+ <p class="note">The browser starts on DuckDuckGo. VNC authentication protects this public Space; use the first 8 characters of your <code>BROWSER_PASSWORD</code> Space secret.</p>
51
+ </section>
52
+ <footer>Only browse websites you are authorized to access. Do not enter banking, email, or other sensitive credentials into a shared remote browser.</footer>
53
+ </main>
54
+ </body>
55
+ </html>
requirements.txt DELETED
@@ -1,3 +0,0 @@
1
- Flask>=3.0,<4
2
- playwright>=1.45,<2
3
- gunicorn>=22,<24
 
 
 
 
start-browser.sh ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ : "${BROWSER_PASSWORD:?Set BROWSER_PASSWORD in Space Settings → Variables and secrets}"
5
+ if [ "${#BROWSER_PASSWORD}" -lt 8 ]; then
6
+ echo "BROWSER_PASSWORD must contain at least 8 characters. x11vnc uses the first 8 characters." >&2
7
+ exit 1
8
+ fi
9
+
10
+ export DISPLAY=:99
11
+ export HOME=/tmp/browser-home
12
+ mkdir -p "$HOME" /tmp/browser-profile
13
+
14
+ # VNC authentication is required because this Space is publicly reachable.
15
+ x11vnc -storepasswd "${BROWSER_PASSWORD:0:8}" /tmp/vnc.pass >/dev/null
16
+
17
+ Xvfb :99 -screen 0 1440x900x24 -ac +extension GLX +render -noreset >/tmp/xvfb.log 2>&1 &
18
+ fluxbox -display :99 >/tmp/fluxbox.log 2>&1 &
19
+ sleep 1
20
+
21
+ x11vnc \
22
+ -display :99 \
23
+ -forever \
24
+ -shared \
25
+ -localhost \
26
+ -rfbport 5900 \
27
+ -rfbauth /tmp/vnc.pass \
28
+ -noxdamage \
29
+ -repeat >/tmp/x11vnc.log 2>&1 &
30
+
31
+ chromium \
32
+ --display=:99 \
33
+ --no-sandbox \
34
+ --disable-dev-shm-usage \
35
+ --disable-gpu \
36
+ --start-maximized \
37
+ --no-first-run \
38
+ --no-default-browser-check \
39
+ --disable-session-crashed-bubble \
40
+ --user-data-dir=/tmp/browser-profile \
41
+ https://duckduckgo.com/ >/tmp/chromium.log 2>&1 &
42
+
43
+ NOVNC_PROXY=/usr/share/novnc/utils/novnc_proxy
44
+ if [ ! -x "$NOVNC_PROXY" ]; then
45
+ NOVNC_PROXY="$(command -v novnc_proxy)"
46
+ fi
47
+
48
+ exec "$NOVNC_PROXY" \
49
+ --vnc localhost:5900 \
50
+ --listen 7860 \
51
+ --web /opt/browser-ui