replicas-computer 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # replicas-computer
2
2
 
3
- The versioned computer-use runtime for Replicas workspace images. It owns the desktop daemon, CLI commands, Chrome wrapper, and desktop assets.
3
+ The versioned computer-use runtime for Replicas workspace images.
4
+
5
+ - `src/daemon.ts` owns the serialized agent command boundary.
6
+ - `src/commands/` owns desktop, browser, and recording operations.
7
+ - `src/media-server.ts` owns the shared H.264 and Opus encoder, WebSocket transport, and input bridge.
8
+ - `src/stream-viewer.ts` owns browser decoding, audio playback, reconnects, and interactive input.
9
+ - `src/viewer.ts` owns the optional noVNC fallback.
10
+ - `assets/desktop/start-services.sh` owns the X11 and stream process lifecycle.
11
+
12
+ The viewer and agent command paths are independent. One on-demand encoder is shared by all viewers, stops when the last viewer disconnects, and restarts without reloading the page. The default 1920×1080 stream uses low-latency H.264 and Opus; noVNC remains available as an opt-in fallback.
4
13
 
5
14
  ```bash
6
15
  replicas-computer setup --check
@@ -1,282 +1,19 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <title>Replicas Desktop</title>
7
- <style>
8
- @font-face {
9
- font-family: "Geist Mono";
10
- font-style: normal;
11
- font-weight: 400 600;
12
- font-display: swap;
13
- src: url("./geist-mono.woff2") format("woff2");
14
- }
15
- html, body {
16
- margin: 0;
17
- padding: 0;
18
- height: 100%;
19
- width: 100%;
20
- background:
21
- radial-gradient(circle at 18% 20%, rgba(62, 235, 163, 0.16), transparent 34%),
22
- radial-gradient(circle at 84% 78%, rgba(8, 44, 37, 0.85), transparent 42%),
23
- linear-gradient(135deg, #1B9179 0%, #1D8570 13%, #082B24 100%);
24
- overflow: hidden;
25
- font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
26
- color: rgba(255, 255, 255, 0.72);
27
- }
28
- #screen {
29
- width: 100%;
30
- height: 100%;
31
- cursor: none;
32
- }
33
- #screen > div {
34
- background: transparent !important;
35
- overflow: hidden !important;
36
- }
37
- #status {
38
- position: fixed;
39
- inset: 0;
40
- display: flex;
41
- align-items: center;
42
- justify-content: center;
43
- gap: 16px;
44
- pointer-events: none;
45
- opacity: 0;
46
- transition: opacity 240ms ease-out;
47
- }
48
- #status.visible { opacity: 1; }
49
- #status .logo {
50
- width: 52px;
51
- height: 52px;
52
- flex: none;
53
- filter: drop-shadow(0 2px 16px rgba(62, 235, 163, 0.5));
54
- }
55
- #status-text {
56
- font-size: 24px;
57
- font-weight: 500;
58
- letter-spacing: -0.01em;
59
- color: rgba(255, 255, 255, 0.92);
60
- text-shadow: 0 1px 24px rgba(0, 0, 0, 0.5);
61
- }
62
- #hint {
63
- position: fixed;
64
- left: 50%;
65
- bottom: 20px;
66
- transform: translateX(-50%) translateY(6px);
67
- padding: 7px 12px;
68
- background: rgba(13, 17, 23, 0.92);
69
- border: 1px solid rgba(255, 255, 255, 0.16);
70
- color: rgba(255, 255, 255, 0.85);
71
- font-size: 12px;
72
- font-weight: 500;
73
- white-space: nowrap;
74
- pointer-events: none;
75
- opacity: 0;
76
- transition: opacity 200ms ease-out, transform 200ms ease-out;
77
- }
78
- #hint.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
79
- </style>
7
+ <link rel="stylesheet" href="./viewer.css">
8
+ <link rel="icon" href="./viewer-icon.svg" type="image/svg+xml">
80
9
  </head>
81
10
  <body>
82
11
  <div id="screen"></div>
83
- <div id="status">
84
- <svg class="logo" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
85
- <g transform="translate(67.5 30)" fill="#3eeba3">
86
- <path d="M0 0H75V75H0V0Z"/>
87
- <path d="M75 0H150V75H75V0Z"/>
88
- <path d="M150 75H225V150H150V75Z"/>
89
- <path d="M0 150H75V225H0V150Z"/>
90
- <path d="M75 150H150V225H75V150Z"/>
91
- <path d="M0 225H75V300H0V225Z"/>
92
- <path d="M150 225H225V300H150V225Z"/>
93
- </g>
94
- </svg>
12
+ <div id="status" class="visible" role="status" aria-live="polite">
13
+ <img src="./viewer-icon.svg" alt="">
95
14
  <span id="status-text">connecting…</span>
96
15
  </div>
97
16
  <div id="hint">This desktop runs Linux — ⌘ shortcuts won't work here. Use Ctrl instead (e.g. Ctrl+C / Ctrl+V).</div>
98
- <script>
99
- // Serialized from the status logo so the mark is defined once, and set outside the module below so a failed noVNC import still leaves the tab branded.
100
- const icon = document.createElement('link');
101
- icon.rel = 'icon';
102
- icon.type = 'image/svg+xml';
103
- icon.href = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(document.querySelector('#status .logo')))}`;
104
- document.head.append(icon);
105
- </script>
106
- <script type="module">
107
- import RFB from './core/rfb.js';
108
- import { encodings } from './core/encodings.js';
109
-
110
- const screen = document.getElementById('screen');
111
- const status = document.getElementById('status');
112
- const statusText = document.getElementById('status-text');
113
- const showStatus = (msg) => {
114
- statusText.textContent = msg;
115
- status.classList.add('visible');
116
- };
117
- const hideStatus = () => status.classList.remove('visible');
118
- const modernCursor = 'url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2724%27 height=%2724%27 viewBox=%270 0 24 24%27%3E%3Cpath d=%27M5 3.5l12.2 11.4-5.8.7 3.2 5.3-2.6 1.5-3.2-5.3-4.1 4.3z%27 fill=%27white%27 stroke=%27%2310161f%27 stroke-width=%272%27 stroke-linejoin=%27round%27/%3E%3Cpath d=%27M5 3.5l12.2 11.4-5.8.7 3.2 5.3-2.6 1.5-3.2-5.3-4.1 4.3z%27 fill=%27%23f8fafc%27/%3E%3C/svg%3E") 5 4, default';
119
- const applyModernCursor = () => {
120
- const canvas = screen.querySelector('canvas');
121
- if (screen.style.cursor !== modernCursor) screen.style.cursor = modernCursor;
122
- if (canvas && canvas.style.cursor !== modernCursor) canvas.style.cursor = modernCursor;
123
- };
124
-
125
- const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
126
- const wsUrl = `${proto}://${window.location.host}/websockify`;
127
- const params = new URLSearchParams(window.location.search);
128
- const intParam = (name, fallback, min = 0, max = 9) => {
129
- const value = Number.parseInt(params.get(name) || '', 10);
130
- return Number.isInteger(value) && value >= min && value <= max ? value : fallback;
131
- };
132
- const requestedProfile = params.get('profile');
133
- const streamProfile = requestedProfile === 'raw' || requestedProfile === 'hextile' ? requestedProfile : 'tight';
134
- const requestFps = intParam('requestFps', streamProfile === 'tight' ? 120 : 0, 0, 240);
135
- const requestBatch = intParam('requestBatch', 1, 1, 8);
136
- const inputRefresh = params.get('inputRefresh') === '1' || streamProfile === 'tight';
137
- const optimisticScroll = params.get('optimisticScroll') === '1';
138
- const optimisticScrollMax = intParam('optimisticScrollMax', 220, 0, 720);
139
- const sendRawEncodings = (sock) => {
140
- const nextEncodings = [
141
- encodings.encodingCopyRect,
142
- encodings.encodingRaw,
143
- encodings.pseudoEncodingDesktopSize,
144
- encodings.pseudoEncodingLastRect,
145
- encodings.pseudoEncodingQEMUExtendedKeyEvent,
146
- encodings.pseudoEncodingExtendedDesktopSize,
147
- encodings.pseudoEncodingXvp,
148
- encodings.pseudoEncodingFence,
149
- encodings.pseudoEncodingContinuousUpdates,
150
- encodings.pseudoEncodingDesktopName,
151
- encodings.pseudoEncodingExtendedClipboard,
152
- encodings.pseudoEncodingVMwareCursor,
153
- encodings.pseudoEncodingCursor,
154
- ];
155
- RFB.messages.clientEncodings(sock, nextEncodings);
156
- };
157
- const sendHextileEncodings = (sock) => {
158
- const nextEncodings = [
159
- encodings.encodingCopyRect,
160
- encodings.encodingHextile,
161
- encodings.encodingRaw,
162
- encodings.pseudoEncodingDesktopSize,
163
- encodings.pseudoEncodingLastRect,
164
- encodings.pseudoEncodingQEMUExtendedKeyEvent,
165
- encodings.pseudoEncodingExtendedDesktopSize,
166
- encodings.pseudoEncodingXvp,
167
- encodings.pseudoEncodingFence,
168
- encodings.pseudoEncodingContinuousUpdates,
169
- encodings.pseudoEncodingDesktopName,
170
- encodings.pseudoEncodingExtendedClipboard,
171
- encodings.pseudoEncodingVMwareCursor,
172
- encodings.pseudoEncodingCursor,
173
- ];
174
- RFB.messages.clientEncodings(sock, nextEncodings);
175
- };
176
- if (streamProfile !== 'tight') {
177
- const sendDefaultEncodings = RFB.prototype._sendEncodings;
178
- RFB.prototype._sendEncodings = function () {
179
- if (this._fbDepth !== 24) {
180
- sendDefaultEncodings.call(this);
181
- return;
182
- }
183
- if (streamProfile === 'hextile') sendHextileEncodings(this._sock);
184
- else sendRawEncodings(this._sock);
185
- };
186
- }
187
-
188
- showStatus('connecting…');
189
- const rfb = new RFB(screen, wsUrl);
190
- rfb.scaleViewport = true;
191
- rfb.resizeSession = false;
192
- rfb.background = '#000';
193
- rfb.showDotCursor = false;
194
- if (streamProfile === 'tight') {
195
- rfb.compressionLevel = intParam('compression', 2);
196
- rfb.qualityLevel = intParam('quality', 5);
197
- }
198
-
199
- let updatePumpId;
200
- const requestFramebufferUpdate = () => {
201
- if (rfb._rfbConnectionState !== 'connected' || rfb._enabledContinuousUpdates) return;
202
- if (!rfb._sock || !rfb._fbWidth || !rfb._fbHeight) return;
203
- for (let i = 0; i < requestBatch; i += 1) {
204
- RFB.messages.fbUpdateRequest(rfb._sock, true, 0, 0, rfb._fbWidth, rfb._fbHeight);
205
- }
206
- };
207
- const startUpdatePump = () => {
208
- clearInterval(updatePumpId);
209
- if (!requestFps) return;
210
- updatePumpId = setInterval(requestFramebufferUpdate, Math.max(8, Math.round(1000 / requestFps)));
211
- };
212
- const stopUpdatePump = () => clearInterval(updatePumpId);
213
-
214
- let lastInputRefreshAt = 0;
215
- const queueInputRefresh = () => {
216
- if (!inputRefresh) return;
217
- const now = performance.now();
218
- if (now - lastInputRefreshAt < 16) return;
219
- lastInputRefreshAt = now;
220
- requestFramebufferUpdate();
221
- for (const delay of [8, 16, 32, 48, 64, 96]) {
222
- setTimeout(requestFramebufferUpdate, delay);
223
- }
224
- };
225
- const shiftCanvasForWheel = (event) => {
226
- if (!optimisticScroll || event.ctrlKey || event.metaKey || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
227
- const canvas = screen.querySelector('canvas');
228
- const context = canvas?.getContext?.('2d');
229
- if (!canvas || !context || !canvas.width || !canvas.height) return;
230
-
231
- const maxOffset = Math.max(0, Math.min(optimisticScrollMax, canvas.height - 1));
232
- const deltaScale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 32 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? canvas.height : 1;
233
- const offset = Math.trunc(Math.max(-maxOffset, Math.min(maxOffset, event.deltaY * deltaScale)));
234
- if (!offset) return;
235
-
236
- if (offset > 0) {
237
- context.drawImage(canvas, 0, offset, canvas.width, canvas.height - offset, 0, 0, canvas.width, canvas.height - offset);
238
- context.drawImage(canvas, 0, canvas.height - offset - 1, canvas.width, 1, 0, canvas.height - offset, canvas.width, offset);
239
- } else {
240
- const size = -offset;
241
- context.drawImage(canvas, 0, 0, canvas.width, canvas.height - size, 0, size, canvas.width, canvas.height - size);
242
- context.drawImage(canvas, 0, size, canvas.width, 1, 0, 0, canvas.width, size);
243
- }
244
- };
245
- const handleWheelInput = (event) => {
246
- shiftCanvasForWheel(event);
247
- queueInputRefresh();
248
- };
249
-
250
- const hint = document.getElementById('hint');
251
- const isMac = /Mac|iPhone|iPad/.test(navigator.userAgentData?.platform || navigator.platform || '');
252
- let hintTimer;
253
- window.addEventListener('keydown', (e) => {
254
- if (!isMac || !e.metaKey || e.key === 'Meta') return;
255
- hint.classList.add('visible');
256
- clearTimeout(hintTimer);
257
- hintTimer = setTimeout(() => hint.classList.remove('visible'), 4000);
258
- }, { capture: true, passive: true });
259
-
260
- const cursorObserver = new MutationObserver(applyModernCursor);
261
- cursorObserver.observe(screen, { attributes: true, childList: true, subtree: true, attributeFilter: ['style'] });
262
- window.addEventListener('wheel', handleWheelInput, { capture: true, passive: true });
263
- for (const eventName of ['pointerdown', 'pointerup', 'pointermove', 'keydown', 'keyup']) {
264
- window.addEventListener(eventName, queueInputRefresh, { passive: true });
265
- }
266
- rfb.addEventListener('connect', () => {
267
- hideStatus();
268
- applyModernCursor();
269
- startUpdatePump();
270
- });
271
- rfb.addEventListener('disconnect', (e) => {
272
- stopUpdatePump();
273
- showStatus(e.detail.clean ? 'disconnected' : 'connecting…');
274
- if (!e.detail.clean) setTimeout(() => window.location.reload(), 1500);
275
- });
276
- rfb.addEventListener('securityfailure', (e) => {
277
- showStatus(`auth failed: ${e.detail.reason || 'unknown'}`);
278
- });
279
- rfb.addEventListener('bell', (e) => e.preventDefault?.());
280
- </script>
17
+ <script type="module" src="./viewer.js"></script>
281
18
  </body>
282
19
  </html>
@@ -26,14 +26,16 @@ if [ "$WIDTH" -lt 1920 ] || [ "$HEIGHT" -lt 1080 ]; then
26
26
  fi
27
27
  DEPTH="${REPLICAS_DESKTOP_DEPTH:-24}"
28
28
  VNC_PORT="${REPLICAS_DESKTOP_VNC_PORT:-5900}"
29
- NOVNC_PORT="${REPLICAS_DESKTOP_NOVNC_PORT:-6080}"
29
+ STREAM_PORT="${REPLICAS_DESKTOP_STREAM_PORT:-6080}"
30
+ NOVNC_PORT="${REPLICAS_DESKTOP_NOVNC_PORT:-6081}"
31
+ ENABLE_NOVNC_FALLBACK="${REPLICAS_DESKTOP_ENABLE_NOVNC_FALLBACK:-0}"
30
32
  STATE_DIR="${REPLICAS_DESKTOP_STATE_DIR:-/tmp/replicas-computer}"
31
33
  VNC_STARTUP_WAIT_MS="${REPLICAS_DESKTOP_VNC_STARTUP_WAIT_MS:-1}"
32
34
  VNC_STARTUP_DEFER_MS="${REPLICAS_DESKTOP_VNC_STARTUP_DEFER_MS:-0}"
33
- VNC_STARTUP_SETDEFER="${REPLICAS_DESKTOP_VNC_STARTUP_SETDEFER:--1}"
35
+ VNC_STARTUP_SETDEFER="${REPLICAS_DESKTOP_VNC_STARTUP_SETDEFER:--2}"
34
36
  VNC_WAIT_MS="${REPLICAS_DESKTOP_VNC_WAIT_MS:-0}"
35
37
  VNC_DEFER_MS="${REPLICAS_DESKTOP_VNC_DEFER_MS:-0}"
36
- VNC_SETDEFER="${REPLICAS_DESKTOP_VNC_SETDEFER:--1}"
38
+ VNC_SETDEFER="${REPLICAS_DESKTOP_VNC_SETDEFER:--2}"
37
39
  X11VNC_EXTRA_ARGS="${REPLICAS_DESKTOP_X11VNC_EXTRA_ARGS:-}"
38
40
  COMPOSITOR="${REPLICAS_DESKTOP_COMPOSITOR:-0}"
39
41
  CHROME_PROFILE_DIR="${REPLICAS_DESKTOP_CHROME_PROFILE_DIR:-$STATE_DIR/chrome-profile}"
@@ -96,6 +98,14 @@ websockify_listener_pids() {
96
98
  port_listener_pids "$NOVNC_PORT" 'websockify'
97
99
  }
98
100
 
101
+ legacy_stream_websockify_pids() {
102
+ port_listener_pids "$STREAM_PORT" 'websockify'
103
+ }
104
+
105
+ stream_listener_pids() {
106
+ port_listener_pids "$STREAM_PORT" 'MainThread\|node'
107
+ }
108
+
99
109
  x11vnc_pids() {
100
110
  { x11vnc_listener_pids; pgrep -f "[x]11vnc .* -rfbport $VNC_PORT" 2>/dev/null; } | sort -u
101
111
  }
@@ -104,6 +114,10 @@ websockify_pids() {
104
114
  { websockify_listener_pids; pgrep -f "[w]ebsockify .* $NOVNC_PORT localhost:$VNC_PORT" 2>/dev/null; } | sort -u
105
115
  }
106
116
 
117
+ stream_pids() {
118
+ { stream_listener_pids; cat "$STATE_DIR/stream.pid" 2>/dev/null; pgrep -f "[r]eplicas-computer stream" 2>/dev/null; } | sort -u
119
+ }
120
+
107
121
  root_pids() {
108
122
  local pids="$1"
109
123
  local candidate
@@ -140,6 +154,10 @@ has_websockify() {
140
154
  [ "$(websockify_listener_root_pids | count_lines)" -gt 0 ]
141
155
  }
142
156
 
157
+ has_stream() {
158
+ curl -fsS --max-time 1 "http://127.0.0.1:$STREAM_PORT/health" >/dev/null 2>&1
159
+ }
160
+
143
161
  listen_recvq() {
144
162
  local port="$1"
145
163
  ss -H -ltn "sport = :$port" 2>/dev/null | awk '{
@@ -152,6 +170,10 @@ listen_recvq() {
152
170
  } END { print sum + 0 }'
153
171
  }
154
172
 
173
+ established_connections() {
174
+ ss -H -tn state established "sport = :$1" 2>/dev/null | count_lines
175
+ }
176
+
155
177
  json_number_array() {
156
178
  local first=1
157
179
  local value
@@ -176,10 +198,14 @@ bridge_status_json() {
176
198
  websockify_roots="$(websockify_listener_root_pids)"
177
199
  printf '{"x11vnc":{"port":%s,"listeners":%s,"listenerPids":' "$VNC_PORT" "$(printf '%s\n' "$x11vnc_listeners" | count_lines)"
178
200
  json_number_array "$x11vnc_listeners"
179
- printf ',"backlog":%s},"websockify":{"port":%s,"rootListeners":%s,"listenerPids":' "$(listen_recvq "$VNC_PORT")" "$NOVNC_PORT" "$(printf '%s\n' "$websockify_roots" | count_lines)"
201
+ printf ',"backlog":%s,"connections":%s},"websockify":{"port":%s,"rootListeners":%s,"listenerPids":' "$(listen_recvq "$VNC_PORT")" "$(established_connections "$VNC_PORT")" "$NOVNC_PORT" "$(printf '%s\n' "$websockify_roots" | count_lines)"
180
202
  json_number_array "$websockify_listeners"
181
203
  printf ',"rootPids":'
182
204
  json_number_array "$websockify_roots"
205
+ printf ',"connections":%s},"stream":{"port":%s,"listeners":%s,"listenerPids":' "$(established_connections "$NOVNC_PORT")" "$STREAM_PORT" "$(stream_listener_pids | count_lines)"
206
+ json_number_array "$(stream_listener_pids)"
207
+ printf ',"connections":%s,"health":' "$(established_connections "$STREAM_PORT")"
208
+ curl -fsS --max-time 1 "http://127.0.0.1:$STREAM_PORT/health" 2>/dev/null || printf 'null'
183
209
  printf '}}\n'
184
210
  }
185
211
 
@@ -221,39 +247,18 @@ if [ "$1" = "--stop" ]; then
221
247
  exit 0
222
248
  fi
223
249
 
224
- bridge_needs_restart() {
225
- local vnc_count
226
- local novnc_count
227
- local vnc_proc_count
228
- local novnc_proc_count
229
- vnc_count="$(x11vnc_listener_pids | count_lines)"
230
- novnc_count="$(websockify_listener_root_pids | count_lines)"
231
- vnc_proc_count="$(x11vnc_pids | count_lines)"
232
- novnc_proc_count="$(websockify_root_pids | count_lines)"
233
- [ "$vnc_count" -gt 1 ] && return 0
234
- [ "$novnc_count" -gt 1 ] && return 0
235
- [ "$vnc_proc_count" -gt 1 ] && return 0
236
- [ "$novnc_proc_count" -gt 1 ] && return 0
237
- return 1
238
- }
239
-
240
- restart_bridge() {
241
- echo "repairing desktop bridge"
242
- kill_pids "$(websockify_pids)"
243
- kill_pids "$(x11vnc_pids)"
244
- rm -f "$STATE_DIR/x11vnc.pid" "$STATE_DIR/novnc.pid"
245
- }
246
-
247
250
  clear_bridge_pid_if_unhealthy() {
248
251
  local name="$1"
249
252
  local health_cmd="$2"
250
- local pids_cmd="$3"
253
+ local listener_pids_cmd="$3"
254
+ local all_pids_cmd="$4"
251
255
  local pid_file="$STATE_DIR/$name.pid"
252
256
  local pids
253
257
  if eval "$health_cmd" >/dev/null 2>&1; then
258
+ "$listener_pids_cmd" | head -n 1 > "$pid_file"
254
259
  return 0
255
260
  fi
256
- pids="$("$pids_cmd")"
261
+ pids="$("$all_pids_cmd")"
257
262
  kill_pids "$pids"
258
263
  rm -f "$pid_file"
259
264
  }
@@ -327,6 +332,10 @@ for _ in $(seq 1 30); do
327
332
  sleep 0.1
328
333
  done
329
334
 
335
+ start_bg pulseaudio \
336
+ "pactl info" \
337
+ "exec pulseaudio --daemonize=no --exit-idle-time=-1 --log-target=stderr"
338
+
330
339
  # Paint twice because some themes reset root pixmap during startup.
331
340
  paint_wallpaper
332
341
 
@@ -347,37 +356,36 @@ if [ "$COMPOSITOR" = "1" ]; then
347
356
  "DISPLAY=:$DISPLAY_NUM exec picom --config $HOME/.config/picom/picom.conf --no-fading-openclose"
348
357
  fi
349
358
 
350
- if bridge_needs_restart; then
351
- restart_bridge
352
- fi
353
- clear_bridge_pid_if_unhealthy x11vnc has_x11vnc x11vnc_pids
354
- clear_bridge_pid_if_unhealthy novnc has_websockify websockify_pids
359
+ if [ "$ENABLE_NOVNC_FALLBACK" = "1" ]; then
360
+ clear_bridge_pid_if_unhealthy x11vnc has_x11vnc x11vnc_listener_pids x11vnc_pids
361
+ clear_bridge_pid_if_unhealthy novnc has_websockify websockify_listener_root_pids websockify_pids
355
362
 
356
- start_bg x11vnc \
357
- "has_x11vnc" \
358
- "exec x11vnc -display :$DISPLAY_NUM -nopw -listen 127.0.0.1 -xkb -forever -shared -rfbport $VNC_PORT -desktop 'Replicas' -nobell -defer $VNC_STARTUP_DEFER_MS -wait $VNC_STARTUP_WAIT_MS -setdefer $VNC_STARTUP_SETDEFER -nonap -threads -input_eagerly -snapfb -onetile -nowireframe -speeds lan $X11VNC_EXTRA_ARGS -quiet"
359
-
360
- for _ in $(seq 1 20); do
361
- if x11vnc -display ":$DISPLAY_NUM" -query pid >/dev/null 2>&1; then break; fi
362
- sleep 0.1
363
- done
364
- x11vnc -display ":$DISPLAY_NUM" -remote "defer:$VNC_DEFER_MS" >/dev/null 2>&1 || true
365
- x11vnc -display ":$DISPLAY_NUM" -remote "wait:$VNC_WAIT_MS" >/dev/null 2>&1 || true
366
- x11vnc -display ":$DISPLAY_NUM" -remote "setdefer:$VNC_SETDEFER" >/dev/null 2>&1 || true
363
+ start_bg x11vnc \
364
+ "has_x11vnc" \
365
+ "exec x11vnc -display :$DISPLAY_NUM -nopw -listen 127.0.0.1 -xkb -forever -shared -alwaysshared -dontdisconnect -rfbport $VNC_PORT -desktop 'Replicas' -nobell -defer $VNC_STARTUP_DEFER_MS -wait $VNC_STARTUP_WAIT_MS -setdefer $VNC_STARTUP_SETDEFER -nonap -nowait_bog -sb 0 -input_eagerly -snapfb -onetile -nowireframe -speeds lan $X11VNC_EXTRA_ARGS -quiet"
367
366
 
368
- NOVNC_WEBROOT="${REPLICAS_DESKTOP_NOVNC_WEBROOT:-/usr/share/novnc}"
369
- if [ ! -d "$NOVNC_WEBROOT" ]; then
370
- NOVNC_WEBROOT="/usr/share/novnc-common"
371
- fi
372
-
373
- # Fallback install for dev workspaces booted on older images that predate
374
- # the package-managed /usr/share/novnc/index.html.
375
- if [ ! -f "$NOVNC_WEBROOT/index.html" ] && [ -f "$REPLICAS_DESKTOP_TEMPLATES/novnc-index.html" ]; then
376
- sudo install -m 0644 "$REPLICAS_DESKTOP_TEMPLATES/novnc-index.html" "$NOVNC_WEBROOT/index.html" 2>/dev/null || true
367
+ for _ in $(seq 1 20); do
368
+ if x11vnc -display ":$DISPLAY_NUM" -query pid >/dev/null 2>&1; then break; fi
369
+ sleep 0.1
370
+ done
371
+ x11vnc -display ":$DISPLAY_NUM" -remote "defer:$VNC_DEFER_MS" >/dev/null 2>&1 || true
372
+ x11vnc -display ":$DISPLAY_NUM" -remote "wait:$VNC_WAIT_MS" >/dev/null 2>&1 || true
373
+ x11vnc -display ":$DISPLAY_NUM" -remote "setdefer:$VNC_SETDEFER" >/dev/null 2>&1 || true
374
+
375
+ NOVNC_WEBROOT="${REPLICAS_DESKTOP_NOVNC_WEBROOT:-$REPLICAS_DESKTOP_TEMPLATES/novnc}"
376
+ start_bg novnc \
377
+ "has_websockify" \
378
+ "exec websockify --heartbeat=${REPLICAS_DESKTOP_WEBSOCKET_HEARTBEAT_SECONDS:-15} --web=$NOVNC_WEBROOT $NOVNC_PORT localhost:$VNC_PORT"
379
+ else
380
+ kill_pids "$(websockify_pids)"
381
+ kill_pids "$(x11vnc_pids)"
382
+ rm -f "$STATE_DIR/novnc.pid" "$STATE_DIR/x11vnc.pid"
377
383
  fi
378
384
 
379
- start_bg novnc \
380
- "has_websockify" \
381
- "exec websockify --web=$NOVNC_WEBROOT $NOVNC_PORT localhost:$VNC_PORT"
385
+ kill_pids "$(legacy_stream_websockify_pids)"
386
+ clear_bridge_pid_if_unhealthy stream has_stream stream_listener_pids stream_pids
387
+ start_bg stream \
388
+ "has_stream" \
389
+ "exec env REPLICAS_DESKTOP_DISPLAY=:$DISPLAY_NUM REPLICAS_DESKTOP_WIDTH=$WIDTH REPLICAS_DESKTOP_HEIGHT=$HEIGHT REPLICAS_DESKTOP_STREAM_FPS=${REPLICAS_DESKTOP_STREAM_FPS:-60} REPLICAS_DESKTOP_STREAM_PORT=$STREAM_PORT REPLICAS_DESKTOP_STREAM_WEBROOT=${REPLICAS_DESKTOP_STREAM_WEBROOT:-$REPLICAS_DESKTOP_TEMPLATES/stream} replicas-computer stream"
382
390
 
383
391
  exit 0
@@ -0,0 +1,20 @@
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, viewport-fit=cover">
6
+ <title>Replicas Desktop</title>
7
+ <link rel="stylesheet" href="./stream-viewer.css">
8
+ <link rel="icon" href="./viewer-icon.svg" type="image/svg+xml">
9
+ </head>
10
+ <body>
11
+ <video id="screen" tabindex="0" aria-label="Replicas desktop" autoplay muted playsinline></video>
12
+ <div id="status" class="visible" role="status" aria-live="polite">
13
+ <img src="./viewer-icon.svg" alt="">
14
+ <span id="status-text">connecting…</span>
15
+ </div>
16
+ <button id="audio-prompt" type="button">Enable audio</button>
17
+ <output id="diagnostics"></output>
18
+ <script type="module" src="./stream-viewer.js"></script>
19
+ </body>
20
+ </html>
@@ -0,0 +1,75 @@
1
+ @font-face {
2
+ font-family: "Geist Mono";
3
+ font-style: normal;
4
+ font-weight: 400 600;
5
+ font-display: swap;
6
+ src: url("./geist-mono.woff2") format("woff2");
7
+ }
8
+
9
+ html, body {
10
+ width: 100%;
11
+ height: 100%;
12
+ margin: 0;
13
+ overflow: hidden;
14
+ background: #050706;
15
+ font-family: "Geist Mono", ui-monospace, monospace;
16
+ }
17
+
18
+ body { display: grid; place-items: center; }
19
+
20
+ #screen {
21
+ display: block;
22
+ outline: none;
23
+ background: #050706;
24
+ cursor: default;
25
+ touch-action: none;
26
+ }
27
+
28
+ #status {
29
+ position: fixed;
30
+ inset: 0;
31
+ display: flex;
32
+ gap: 16px;
33
+ align-items: center;
34
+ justify-content: center;
35
+ color: rgb(255 255 255 / 92%);
36
+ background: radial-gradient(circle at 18% 20%, rgb(62 235 163 / 16%), transparent 34%),
37
+ radial-gradient(circle at 84% 78%, rgb(8 44 37 / 85%), transparent 42%),
38
+ linear-gradient(135deg, #1b9179 0%, #1d8570 13%, #082b24 100%);
39
+ font-size: 24px;
40
+ pointer-events: none;
41
+ opacity: 0;
42
+ transition: opacity 180ms ease-out;
43
+ }
44
+
45
+ #status.visible { opacity: 1; }
46
+ #status img { width: 52px; height: 52px; filter: drop-shadow(0 2px 16px rgb(62 235 163 / 50%)); }
47
+
48
+ #audio-prompt {
49
+ position: fixed;
50
+ right: 16px;
51
+ bottom: 16px;
52
+ padding: 8px 12px;
53
+ border: 1px solid rgb(255 255 255 / 20%);
54
+ border-radius: 6px;
55
+ color: white;
56
+ background: rgb(13 17 23 / 88%);
57
+ font: inherit;
58
+ cursor: pointer;
59
+ }
60
+
61
+ #audio-prompt[hidden] { display: none; }
62
+
63
+ #diagnostics {
64
+ position: fixed;
65
+ top: 12px;
66
+ right: 12px;
67
+ display: none;
68
+ padding: 5px 8px;
69
+ color: rgb(255 255 255 / 78%);
70
+ background: rgb(0 0 0 / 58%);
71
+ font-size: 11px;
72
+ pointer-events: none;
73
+ }
74
+
75
+ #diagnostics.visible { display: block; }
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360" fill="#3eeba3">
2
+ <path d="M68 30h75v75H68zm75 0h75v75h-75zm75 75h75v75h-75zM68 180h75v75H68zm75 0h75v75h-75zm-75 75h75v75H68zm150 0h75v75h-75z"/>
3
+ </svg>
@@ -0,0 +1,57 @@
1
+ @font-face {
2
+ font-family: "Geist Mono";
3
+ font-style: normal;
4
+ font-weight: 400 600;
5
+ font-display: swap;
6
+ src: url("./geist-mono.woff2") format("woff2");
7
+ }
8
+
9
+ html, body {
10
+ width: 100%;
11
+ height: 100%;
12
+ margin: 0;
13
+ overflow: hidden;
14
+ color: rgb(255 255 255 / 72%);
15
+ background: radial-gradient(circle at 18% 20%, rgb(62 235 163 / 16%), transparent 34%),
16
+ radial-gradient(circle at 84% 78%, rgb(8 44 37 / 85%), transparent 42%),
17
+ linear-gradient(135deg, #1b9179 0%, #1d8570 13%, #082b24 100%);
18
+ font-family: "Geist Mono", ui-monospace, monospace;
19
+ }
20
+
21
+ #screen { width: 100%; height: 100%; cursor: none; }
22
+ #screen > div { overflow: hidden !important; background: transparent !important; }
23
+
24
+ #status {
25
+ position: fixed;
26
+ inset: 0;
27
+ display: flex;
28
+ gap: 16px;
29
+ align-items: center;
30
+ justify-content: center;
31
+ pointer-events: none;
32
+ opacity: 0;
33
+ transition: opacity 240ms ease-out;
34
+ }
35
+
36
+ #status.visible { opacity: 1; }
37
+ #status img { width: 52px; height: 52px; filter: drop-shadow(0 2px 16px rgb(62 235 163 / 50%)); }
38
+ #status-text { color: rgb(255 255 255 / 92%); font-size: 24px; font-weight: 500; text-shadow: 0 1px 24px rgb(0 0 0 / 50%); }
39
+
40
+ #hint {
41
+ position: fixed;
42
+ bottom: 20px;
43
+ left: 50%;
44
+ padding: 7px 12px;
45
+ border: 1px solid rgb(255 255 255 / 16%);
46
+ color: rgb(255 255 255 / 85%);
47
+ background: rgb(13 17 23 / 92%);
48
+ font-size: 12px;
49
+ font-weight: 500;
50
+ white-space: nowrap;
51
+ pointer-events: none;
52
+ opacity: 0;
53
+ transform: translateX(-50%) translateY(6px);
54
+ transition: opacity 200ms ease-out, transform 200ms ease-out;
55
+ }
56
+
57
+ #hint.visible { opacity: 1; transform: translateX(-50%); }