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 +10 -1
- package/assets/desktop/novnc-index.html +6 -269
- package/assets/desktop/start-services.sh +65 -57
- package/assets/desktop/stream-index.html +20 -0
- package/assets/desktop/stream-viewer.css +75 -0
- package/assets/desktop/viewer-icon.svg +3 -0
- package/assets/desktop/viewer.css +57 -0
- package/dist/index.js +1237 -548
- package/dist/stream-viewer.js +308 -0
- package/dist/viewer.js +199 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# replicas-computer
|
|
2
2
|
|
|
3
|
-
The versioned computer-use runtime for Replicas workspace images.
|
|
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
|
-
<!
|
|
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
|
-
<
|
|
8
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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:--
|
|
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:--
|
|
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
|
|
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="$("$
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
369
|
-
if
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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,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%); }
|