@myrialabs/clopen 0.2.8 ā 0.2.10
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/backend/index.ts +12 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +188 -31
- package/backend/preview/browser/browser-pool.ts +1 -1
- package/backend/preview/browser/browser-preview-service.ts +23 -0
- package/backend/preview/browser/browser-tab-manager.ts +16 -1
- package/backend/preview/browser/browser-video-capture.ts +2 -2
- package/backend/preview/browser/scripts/video-stream.ts +39 -4
- package/backend/terminal/stream-manager.ts +40 -26
- package/backend/ws/preview/browser/webcodecs.ts +11 -0
- package/backend/ws/preview/index.ts +8 -0
- package/backend/ws/system/operations.ts +23 -0
- package/frontend/components/chat/input/ChatInput.svelte +3 -3
- package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
- package/frontend/components/common/overlay/Dialog.svelte +1 -1
- package/frontend/components/common/overlay/Lightbox.svelte +2 -2
- package/frontend/components/common/overlay/Modal.svelte +2 -2
- package/frontend/components/common/xterm/XTerm.svelte +6 -1
- package/frontend/components/git/ConflictResolver.svelte +1 -1
- package/frontend/components/git/GitModal.svelte +2 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +10 -3
- package/frontend/components/preview/browser/components/Canvas.svelte +40 -23
- package/frontend/components/preview/browser/components/Container.svelte +8 -5
- package/frontend/components/preview/browser/components/Toolbar.svelte +16 -1
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -0
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +37 -4
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
- package/frontend/components/terminal/Terminal.svelte +1 -29
- package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
- package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
- package/frontend/components/workspace/PanelHeader.svelte +30 -22
- package/frontend/components/workspace/panels/PreviewPanel.svelte +1 -0
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +110 -18
- package/frontend/services/project/status.service.ts +11 -1
- package/frontend/stores/core/sessions.svelte.ts +11 -1
- package/frontend/stores/features/terminal.svelte.ts +56 -26
- package/frontend/stores/ui/theme.svelte.ts +1 -1
- package/frontend/utils/ws.ts +42 -0
- package/index.html +2 -2
- package/package.json +1 -1
- package/shared/utils/ws-client.ts +21 -4
- package/static/manifest.json +2 -2
package/backend/index.ts
CHANGED
|
@@ -156,7 +156,12 @@ startServer().catch((error) => {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
// Graceful shutdown - properly close server and database
|
|
159
|
+
let isShuttingDown = false;
|
|
160
|
+
|
|
159
161
|
async function gracefulShutdown() {
|
|
162
|
+
if (isShuttingDown) return;
|
|
163
|
+
isShuttingDown = true;
|
|
164
|
+
|
|
160
165
|
console.log('\nš Shutting down server...');
|
|
161
166
|
try {
|
|
162
167
|
// Close MCP remote server (before engines, as they may still reference it)
|
|
@@ -177,6 +182,13 @@ async function gracefulShutdown() {
|
|
|
177
182
|
process.on('SIGINT', gracefulShutdown);
|
|
178
183
|
process.on('SIGTERM', gracefulShutdown);
|
|
179
184
|
|
|
185
|
+
// Ignore SIGHUP ā sent when the controlling terminal closes or an SSH session
|
|
186
|
+
// disconnects. Without a handler Bun exits immediately; we want the server to
|
|
187
|
+
// keep running (e.g. started in a background tab or remote shell).
|
|
188
|
+
process.on('SIGHUP', () => {
|
|
189
|
+
debug.log('server', 'Received SIGHUP ā ignoring (server stays running)');
|
|
190
|
+
});
|
|
191
|
+
|
|
180
192
|
// Safety net: prevent server crash from unhandled errors.
|
|
181
193
|
// These can occur when AI engine SDKs emit asynchronous errors that bypass
|
|
182
194
|
// the normal try/catch flow (e.g., subprocess killed during initialization).
|
|
@@ -1,22 +1,74 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
-
import type { Page, HTTPRequest, Frame } from 'puppeteer';
|
|
2
|
+
import type { Page, HTTPRequest, Frame, CDPSession } from 'puppeteer';
|
|
3
3
|
import type { BrowserTab } from './types';
|
|
4
|
+
import { debug } from '$shared/utils/logger';
|
|
4
5
|
|
|
5
6
|
export class BrowserNavigationTracker extends EventEmitter {
|
|
7
|
+
private cdpSessions = new Map<string, CDPSession>();
|
|
8
|
+
|
|
6
9
|
constructor() {
|
|
7
10
|
super();
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Check if two URLs differ only by hash/fragment.
|
|
15
|
+
* Hash-only changes are same-document navigations and should NOT trigger
|
|
16
|
+
* full page reload or streaming restart.
|
|
17
|
+
*/
|
|
18
|
+
private isHashOnlyChange(oldUrl: string, newUrl: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const oldParsed = new URL(oldUrl);
|
|
21
|
+
const newParsed = new URL(newUrl);
|
|
22
|
+
// Compare URLs without hash ā if identical, it's a hash-only change
|
|
23
|
+
oldParsed.hash = '';
|
|
24
|
+
newParsed.hash = '';
|
|
25
|
+
return oldParsed.href === newParsed.href;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if two URLs share the same origin (protocol + host + port).
|
|
33
|
+
* Same-origin navigations are likely SPA internal navigations and should
|
|
34
|
+
* NOT show a progress bar ā the streaming restart happens silently while
|
|
35
|
+
* the last rendered frame stays visible.
|
|
36
|
+
*/
|
|
37
|
+
private isSameOrigin(oldUrl: string, newUrl: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(oldUrl).origin === new URL(newUrl).origin;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
10
45
|
async setupNavigationTracking(sessionId: string, page: Page, session: BrowserTab) {
|
|
11
46
|
|
|
12
|
-
// Track navigation start (loading begins)
|
|
47
|
+
// Track navigation start (loading begins) ā only for cross-origin document navigations
|
|
13
48
|
page.on('request', (request: HTTPRequest) => {
|
|
14
49
|
// Only track main frame document requests (not resources like images, CSS, etc.)
|
|
15
50
|
// Puppeteer uses resourceType() instead of isNavigationRequest()
|
|
16
51
|
if (request.resourceType() === 'document' && request.frame() === page.mainFrame()) {
|
|
17
52
|
const targetUrl = request.url();
|
|
18
53
|
|
|
19
|
-
//
|
|
54
|
+
// Skip hash-only changes ā they are same-document navigations
|
|
55
|
+
// that don't need loading states or streaming restart
|
|
56
|
+
if (this.isHashOnlyChange(session.url, targetUrl)) {
|
|
57
|
+
debug.log('preview', `āļø Skipping navigation-loading for hash-only change: ${session.url} ā ${targetUrl}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Skip same-origin navigations ā they are likely SPA internal navigations.
|
|
62
|
+
// No progress bar is shown; the last rendered frame stays visible while
|
|
63
|
+
// streaming restarts silently in the background. This makes SPA navigation
|
|
64
|
+
// feel instant, similar to how a real browser shows the old page until
|
|
65
|
+
// the new one is ready.
|
|
66
|
+
if (this.isSameOrigin(session.url, targetUrl)) {
|
|
67
|
+
debug.log('preview', `āļø Skipping navigation-loading for same-origin navigation: ${session.url} ā ${targetUrl}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Emit navigation loading event to frontend (cross-origin navigations only)
|
|
20
72
|
this.emit('navigation-loading', {
|
|
21
73
|
sessionId,
|
|
22
74
|
type: 'navigation-loading',
|
|
@@ -26,12 +78,59 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
26
78
|
}
|
|
27
79
|
});
|
|
28
80
|
|
|
29
|
-
// Track
|
|
30
|
-
page.on('framenavigated', (frame: Frame) => {
|
|
81
|
+
// Track full page navigations (actual page loads, not SPA)
|
|
82
|
+
page.on('framenavigated', async (frame: Frame) => {
|
|
31
83
|
// Only track main frame navigation (not iframes)
|
|
32
84
|
if (frame === page.mainFrame()) {
|
|
33
85
|
const newUrl = frame.url();
|
|
34
86
|
|
|
87
|
+
// Skip internal Chrome error/system pages ā they indicate a failed navigation
|
|
88
|
+
// and should not be surfaced to the frontend as a real URL change.
|
|
89
|
+
if (newUrl.startsWith('chrome-error://') || newUrl.startsWith('chrome://')) return;
|
|
90
|
+
|
|
91
|
+
// Skip if URL hasn't changed (already handled by navigatedWithinDocument)
|
|
92
|
+
if (newUrl === session.url) return;
|
|
93
|
+
|
|
94
|
+
// Hash-only changes should be treated as SPA navigations
|
|
95
|
+
// (no streaming restart needed, page context is unchanged)
|
|
96
|
+
if (this.isHashOnlyChange(session.url, newUrl)) {
|
|
97
|
+
debug.log('preview', `š Hash-only change detected, treating as SPA navigation: ${session.url} ā ${newUrl}`);
|
|
98
|
+
session.url = newUrl;
|
|
99
|
+
this.emit('navigation-spa', {
|
|
100
|
+
sessionId,
|
|
101
|
+
type: 'navigation-spa',
|
|
102
|
+
url: newUrl,
|
|
103
|
+
timestamp: Date.now()
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Same-origin navigation: check if the video encoder script survived.
|
|
109
|
+
// SPA frameworks (SvelteKit, Next.js, etc.) often trigger framenavigated
|
|
110
|
+
// for client-side routing even though the page context is NOT replaced.
|
|
111
|
+
// If __webCodecsPeer still exists, the scripts are alive ā SPA navigation.
|
|
112
|
+
// If it's gone, the page was truly replaced ā full navigation + stream restart.
|
|
113
|
+
if (this.isSameOrigin(session.url, newUrl)) {
|
|
114
|
+
try {
|
|
115
|
+
const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
|
|
116
|
+
if (scriptAlive) {
|
|
117
|
+
debug.log('preview', `š Same-origin navigation with script alive (SPA): ${session.url} ā ${newUrl}`);
|
|
118
|
+
session.url = newUrl;
|
|
119
|
+
this.emit('navigation-spa', {
|
|
120
|
+
sessionId,
|
|
121
|
+
type: 'navigation-spa',
|
|
122
|
+
url: newUrl,
|
|
123
|
+
timestamp: Date.now()
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
debug.log('preview', `š Same-origin navigation with script dead (full reload): ${session.url} ā ${newUrl}`);
|
|
128
|
+
} catch {
|
|
129
|
+
// page.evaluate failed ā page context was replaced, fall through to full navigation
|
|
130
|
+
debug.log('preview', `š Same-origin navigation evaluate failed (full reload): ${session.url} ā ${newUrl}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
35
134
|
// Update session URL
|
|
36
135
|
session.url = newUrl;
|
|
37
136
|
|
|
@@ -48,10 +147,43 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
48
147
|
// Also track URL changes via JavaScript (for single page applications)
|
|
49
148
|
page.on('load', async () => {
|
|
50
149
|
const currentUrl = page.url();
|
|
150
|
+
// Skip internal Chrome error/system pages
|
|
151
|
+
if (currentUrl.startsWith('chrome-error://') || currentUrl.startsWith('chrome://')) return;
|
|
51
152
|
if (currentUrl !== session.url) {
|
|
52
|
-
|
|
153
|
+
|
|
154
|
+
// Hash-only changes on load ā treat as SPA navigation
|
|
155
|
+
if (this.isHashOnlyChange(session.url, currentUrl)) {
|
|
156
|
+
session.url = currentUrl;
|
|
157
|
+
this.emit('navigation-spa', {
|
|
158
|
+
sessionId,
|
|
159
|
+
type: 'navigation-spa',
|
|
160
|
+
url: currentUrl,
|
|
161
|
+
timestamp: Date.now()
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Same-origin: check if video encoder script survived
|
|
167
|
+
if (this.isSameOrigin(session.url, currentUrl)) {
|
|
168
|
+
try {
|
|
169
|
+
const scriptAlive = await page.evaluate(() => !!(window as any).__webCodecsPeer);
|
|
170
|
+
if (scriptAlive) {
|
|
171
|
+
session.url = currentUrl;
|
|
172
|
+
this.emit('navigation-spa', {
|
|
173
|
+
sessionId,
|
|
174
|
+
type: 'navigation-spa',
|
|
175
|
+
url: currentUrl,
|
|
176
|
+
timestamp: Date.now()
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Fall through to full navigation
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
53
185
|
session.url = currentUrl;
|
|
54
|
-
|
|
186
|
+
|
|
55
187
|
this.emit('navigation', {
|
|
56
188
|
sessionId,
|
|
57
189
|
type: 'navigation',
|
|
@@ -61,34 +193,59 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
61
193
|
}
|
|
62
194
|
});
|
|
63
195
|
|
|
64
|
-
// Track
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
196
|
+
// Track SPA navigations (pushState/replaceState) via CDP
|
|
197
|
+
// Uses Page.navigatedWithinDocument which fires for same-document navigations
|
|
198
|
+
// This is purely CDP-level ā no script injection, safe from CloudFlare detection
|
|
199
|
+
try {
|
|
200
|
+
const cdp = await page.createCDPSession();
|
|
201
|
+
this.cdpSessions.set(sessionId, cdp);
|
|
69
202
|
|
|
70
|
-
|
|
71
|
-
const checkUrlChange = () => {
|
|
72
|
-
const currentUrl = window.location.href;
|
|
73
|
-
if (currentUrl !== lastUrl) {
|
|
74
|
-
lastUrl = currentUrl;
|
|
203
|
+
await cdp.send('Page.enable');
|
|
75
204
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
timestamp: Date.now()
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
};
|
|
205
|
+
// Get main frame ID via CDP (reliable across Puppeteer versions)
|
|
206
|
+
const frameTree = await cdp.send('Page.getFrameTree');
|
|
207
|
+
const mainFrameId = frameTree.frameTree.frame.id;
|
|
83
208
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
209
|
+
cdp.on('Page.navigatedWithinDocument', (params: { frameId: string; url: string }) => {
|
|
210
|
+
// Only track main frame SPA navigations (ignore iframe pushState)
|
|
211
|
+
if (params.frameId !== mainFrameId) return;
|
|
87
212
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
213
|
+
const newUrl = params.url;
|
|
214
|
+
if (newUrl === session.url) return;
|
|
215
|
+
|
|
216
|
+
debug.log('preview', `š SPA navigation detected: ${session.url} ā ${newUrl}`);
|
|
217
|
+
|
|
218
|
+
// Update session URL
|
|
219
|
+
session.url = newUrl;
|
|
220
|
+
|
|
221
|
+
// Emit SPA navigation event ā no loading state, no stream restart
|
|
222
|
+
this.emit('navigation-spa', {
|
|
223
|
+
sessionId,
|
|
224
|
+
type: 'navigation-spa',
|
|
225
|
+
url: newUrl,
|
|
226
|
+
timestamp: Date.now()
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
debug.log('preview', `ā
CDP SPA navigation tracking setup for session: ${sessionId}`);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
debug.warn('preview', `ā ļø Failed to setup CDP SPA tracking for ${sessionId}:`, error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Cleanup CDP session for a tab
|
|
238
|
+
*/
|
|
239
|
+
async cleanupSession(sessionId: string) {
|
|
240
|
+
const cdp = this.cdpSessions.get(sessionId);
|
|
241
|
+
if (cdp) {
|
|
242
|
+
try {
|
|
243
|
+
await cdp.detach();
|
|
244
|
+
} catch {
|
|
245
|
+
// Ignore detach errors
|
|
246
|
+
}
|
|
247
|
+
this.cdpSessions.delete(sessionId);
|
|
248
|
+
}
|
|
92
249
|
}
|
|
93
250
|
|
|
94
251
|
async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {
|
|
@@ -51,7 +51,7 @@ const CHROMIUM_ARGS = [
|
|
|
51
51
|
'--disable-blink-features=AutomationControlled',
|
|
52
52
|
'--window-size=1366,768',
|
|
53
53
|
'--autoplay-policy=no-user-gesture-required',
|
|
54
|
-
'--disable-features=AudioServiceOutOfProcess'
|
|
54
|
+
'--disable-features=AudioServiceOutOfProcess,WebRtcHideLocalIpsWithMdns'
|
|
55
55
|
];
|
|
56
56
|
|
|
57
57
|
class BrowserPool {
|
|
@@ -94,6 +94,7 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
// Forward navigation events and handle video streaming restart
|
|
97
|
+
// Only full navigations (framenavigated) need streaming restart
|
|
97
98
|
this.navigationTracker.on('navigation', async (data) => {
|
|
98
99
|
this.emit('preview:browser-navigation', data);
|
|
99
100
|
|
|
@@ -121,6 +122,12 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
121
122
|
this.emit('preview:browser-navigation-loading', data);
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
// Forward SPA navigation events (pushState/replaceState)
|
|
126
|
+
// No streaming restart needed ā page context is unchanged
|
|
127
|
+
this.navigationTracker.on('navigation-spa', (data) => {
|
|
128
|
+
this.emit('preview:browser-navigation-spa', data);
|
|
129
|
+
});
|
|
130
|
+
|
|
124
131
|
// Forward new window events
|
|
125
132
|
this.tabManager.on('new-window', (data) => {
|
|
126
133
|
this.emit('preview:browser-new-window', data);
|
|
@@ -140,6 +147,10 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
140
147
|
this.emit('preview:browser-tab-navigated', data);
|
|
141
148
|
});
|
|
142
149
|
|
|
150
|
+
this.tabManager.on('preview:browser-viewport-changed', (data) => {
|
|
151
|
+
this.emit('preview:browser-viewport-changed', data);
|
|
152
|
+
});
|
|
153
|
+
|
|
143
154
|
// Forward video capture events
|
|
144
155
|
this.videoCapture.on('ice-candidate', (data) => {
|
|
145
156
|
this.emit('preview:browser-webcodecs-ice-candidate', data);
|
|
@@ -249,6 +260,9 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
249
260
|
// Stop WebCodecs streaming first
|
|
250
261
|
await this.stopWebCodecsStreaming(tabId);
|
|
251
262
|
|
|
263
|
+
// Cleanup navigation tracker CDP session
|
|
264
|
+
await this.navigationTracker.cleanupSession(tabId);
|
|
265
|
+
|
|
252
266
|
// Clear cursor tracking for this tab
|
|
253
267
|
this.interactionHandler.clearSessionCursor(tabId);
|
|
254
268
|
|
|
@@ -650,6 +664,11 @@ class BrowserPreviewServiceManager {
|
|
|
650
664
|
ws.emit.project(projectId, 'preview:browser-navigation', data);
|
|
651
665
|
});
|
|
652
666
|
|
|
667
|
+
// Forward SPA navigation events (pushState/replaceState ā URL-only update)
|
|
668
|
+
service.on('preview:browser-navigation-spa', (data) => {
|
|
669
|
+
ws.emit.project(projectId, 'preview:browser-navigation-spa', data);
|
|
670
|
+
});
|
|
671
|
+
|
|
653
672
|
// Forward tab events
|
|
654
673
|
service.on('preview:browser-tab-opened', (data) => {
|
|
655
674
|
debug.log('preview', `š Forwarding preview:browser-tab-opened to project ${projectId}:`, data);
|
|
@@ -668,6 +687,10 @@ class BrowserPreviewServiceManager {
|
|
|
668
687
|
ws.emit.project(projectId, 'preview:browser-tab-navigated', data);
|
|
669
688
|
});
|
|
670
689
|
|
|
690
|
+
service.on('preview:browser-viewport-changed', (data) => {
|
|
691
|
+
ws.emit.project(projectId, 'preview:browser-viewport-changed', data);
|
|
692
|
+
});
|
|
693
|
+
|
|
671
694
|
// Forward console events
|
|
672
695
|
service.on('preview:browser-console-message', (data) => {
|
|
673
696
|
ws.emit.project(projectId, 'preview:browser-console-message', data);
|
|
@@ -730,6 +730,21 @@ export class BrowserTabManager extends EventEmitter {
|
|
|
730
730
|
// await page.evaluateOnNewDocument(cursorTrackingScript);
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Returns true for errors where retrying is pointless because the page/session is gone.
|
|
735
|
+
*/
|
|
736
|
+
private isNonRetryableError(error: unknown): boolean {
|
|
737
|
+
if (error instanceof Error) {
|
|
738
|
+
const msg = error.message;
|
|
739
|
+
return (
|
|
740
|
+
msg.includes('Session closed') ||
|
|
741
|
+
msg.includes('detached Frame') ||
|
|
742
|
+
error.constructor.name === 'TargetCloseError'
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
|
|
733
748
|
/**
|
|
734
749
|
* Navigate with retry, including Cloudflare auto-pass detection and CAPTCHA popup dismissal.
|
|
735
750
|
*/
|
|
@@ -750,7 +765,7 @@ export class BrowserTabManager extends EventEmitter {
|
|
|
750
765
|
} catch (error) {
|
|
751
766
|
retries--;
|
|
752
767
|
debug.warn('preview', `ā ļø Navigation failed, ${retries} retries left:`, error);
|
|
753
|
-
if (retries === 0) throw error;
|
|
768
|
+
if (retries === 0 || this.isNonRetryableError(error)) throw error;
|
|
754
769
|
|
|
755
770
|
// Wait before retry
|
|
756
771
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
@@ -383,8 +383,8 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
383
383
|
return null;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
const maxRetries =
|
|
387
|
-
const retryDelay =
|
|
386
|
+
const maxRetries = 6;
|
|
387
|
+
const retryDelay = 150;
|
|
388
388
|
|
|
389
389
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
390
390
|
try {
|
|
@@ -38,6 +38,23 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
38
38
|
// STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
|
|
39
39
|
const iceServers: { urls: string }[] = [];
|
|
40
40
|
|
|
41
|
+
// Create a loopback (127.0.0.1) copy of a host ICE candidate.
|
|
42
|
+
// Ensures WebRTC connects via loopback when VPN (e.g. Cloudflare WARP)
|
|
43
|
+
// interferes with host candidate connectivity between same-machine peers.
|
|
44
|
+
function createLoopbackCandidate(candidate: { candidate?: string; sdpMid?: string | null; sdpMLineIndex?: number | null }) {
|
|
45
|
+
if (!candidate.candidate) return null;
|
|
46
|
+
if (!candidate.candidate.includes('typ host')) return null;
|
|
47
|
+
|
|
48
|
+
const parts = candidate.candidate.split(' ');
|
|
49
|
+
if (parts.length < 8) return null;
|
|
50
|
+
|
|
51
|
+
const address = parts[4];
|
|
52
|
+
if (address === '127.0.0.1' || address === '::1') return null;
|
|
53
|
+
|
|
54
|
+
parts[4] = '127.0.0.1';
|
|
55
|
+
return { ...candidate, candidate: parts.join(' ') };
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
// Check cursor style from page
|
|
42
59
|
function checkCursor() {
|
|
43
60
|
try {
|
|
@@ -103,11 +120,18 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
103
120
|
// Handle ICE candidates
|
|
104
121
|
peerConnection.onicecandidate = (event) => {
|
|
105
122
|
if (event.candidate && (window as any).__sendIceCandidate) {
|
|
106
|
-
|
|
123
|
+
const candidateInit = {
|
|
107
124
|
candidate: event.candidate.candidate,
|
|
108
125
|
sdpMid: event.candidate.sdpMid,
|
|
109
126
|
sdpMLineIndex: event.candidate.sdpMLineIndex
|
|
110
|
-
}
|
|
127
|
+
};
|
|
128
|
+
(window as any).__sendIceCandidate(candidateInit);
|
|
129
|
+
|
|
130
|
+
// Also send loopback version for VPN compatibility (same-machine peers)
|
|
131
|
+
const loopback = createLoopbackCandidate(candidateInit);
|
|
132
|
+
if (loopback) {
|
|
133
|
+
(window as any).__sendIceCandidate(loopback);
|
|
134
|
+
}
|
|
111
135
|
}
|
|
112
136
|
};
|
|
113
137
|
|
|
@@ -337,16 +361,27 @@ export function videoEncoderScript(config: StreamingConfig['video']) {
|
|
|
337
361
|
}
|
|
338
362
|
}
|
|
339
363
|
|
|
340
|
-
// Add ICE candidate
|
|
364
|
+
// Add ICE candidate (+ loopback variant for VPN compatibility)
|
|
341
365
|
async function addIceCandidate(candidate: RTCIceCandidateInit) {
|
|
342
366
|
if (!peerConnection) return false;
|
|
343
367
|
|
|
344
368
|
try {
|
|
345
369
|
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
346
|
-
return true;
|
|
347
370
|
} catch (error) {
|
|
348
371
|
return false;
|
|
349
372
|
}
|
|
373
|
+
|
|
374
|
+
// Also try loopback version for VPN compatibility (same-machine peers)
|
|
375
|
+
const loopback = createLoopbackCandidate(candidate);
|
|
376
|
+
if (loopback) {
|
|
377
|
+
try {
|
|
378
|
+
await peerConnection.addIceCandidate(new RTCIceCandidate(loopback as RTCIceCandidateInit));
|
|
379
|
+
} catch {
|
|
380
|
+
// Expected to fail if loopback is not applicable
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return true;
|
|
350
385
|
}
|
|
351
386
|
|
|
352
387
|
// Reconfigure video encoder with new dimensions (hot-swap)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { IPty } from 'bun-pty';
|
|
7
|
-
import { existsSync, mkdirSync,
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
|
|
10
10
|
interface TerminalStream {
|
|
@@ -129,36 +129,50 @@ class TerminalStreamManager {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Pending write flag to coalesce rapid writes */
|
|
133
|
+
private pendingWrites = new Set<string>();
|
|
134
|
+
|
|
132
135
|
/**
|
|
133
|
-
* Persist output to disk for cross-project persistence
|
|
136
|
+
* Persist output to disk for cross-project persistence (async, coalesced)
|
|
134
137
|
*/
|
|
135
138
|
private persistOutputToDisk(stream: TerminalStream): void {
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
// Coalesce rapid writes - only schedule one write per session per microtask
|
|
140
|
+
if (this.pendingWrites.has(stream.sessionId)) return;
|
|
141
|
+
this.pendingWrites.add(stream.sessionId);
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const newOutput = stream.outputStartIndex !== undefined
|
|
142
|
-
? stream.output.slice(stream.outputStartIndex)
|
|
143
|
-
: stream.output;
|
|
143
|
+
queueMicrotask(() => {
|
|
144
|
+
this.pendingWrites.delete(stream.sessionId);
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
146
|
+
try {
|
|
147
|
+
const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
|
|
148
|
+
|
|
149
|
+
// Only save new output (from outputStartIndex onwards)
|
|
150
|
+
const newOutput = stream.outputStartIndex !== undefined
|
|
151
|
+
? stream.output.slice(stream.outputStartIndex)
|
|
152
|
+
: stream.output;
|
|
153
|
+
|
|
154
|
+
const cacheData = {
|
|
155
|
+
streamId: stream.streamId,
|
|
156
|
+
sessionId: stream.sessionId,
|
|
157
|
+
command: stream.command,
|
|
158
|
+
projectId: stream.projectId,
|
|
159
|
+
projectPath: stream.projectPath,
|
|
160
|
+
workingDirectory: stream.workingDirectory,
|
|
161
|
+
startedAt: stream.startedAt,
|
|
162
|
+
status: stream.status,
|
|
163
|
+
output: newOutput,
|
|
164
|
+
outputStartIndex: stream.outputStartIndex || 0,
|
|
165
|
+
lastUpdated: new Date().toISOString()
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Use Bun.write for non-blocking async disk write
|
|
169
|
+
Bun.write(cacheFile, JSON.stringify(cacheData)).catch(() => {
|
|
170
|
+
// Silently handle write errors
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// Silently handle errors
|
|
174
|
+
}
|
|
175
|
+
});
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
/**
|
|
@@ -252,6 +252,17 @@ export const streamPreviewHandler = createRouter()
|
|
|
252
252
|
url: t.String(),
|
|
253
253
|
timestamp: t.Number()
|
|
254
254
|
})
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// Server ā Client: SPA navigation (pushState/replaceState ā URL-only update, no page reload)
|
|
258
|
+
.emit(
|
|
259
|
+
'preview:browser-navigation-spa',
|
|
260
|
+
t.Object({
|
|
261
|
+
sessionId: t.String(),
|
|
262
|
+
type: t.String(),
|
|
263
|
+
url: t.String(),
|
|
264
|
+
timestamp: t.Number()
|
|
265
|
+
})
|
|
255
266
|
);
|
|
256
267
|
|
|
257
268
|
// Setup event forwarding from preview service to WebSocket
|
|
@@ -143,4 +143,12 @@ export const previewRouter = createRouter()
|
|
|
143
143
|
sessionId: t.String(),
|
|
144
144
|
timestamp: t.Number(),
|
|
145
145
|
source: t.Literal('mcp')
|
|
146
|
+
}))
|
|
147
|
+
.emit('preview:browser-viewport-changed', t.Object({
|
|
148
|
+
tabId: t.String(),
|
|
149
|
+
deviceSize: t.String(),
|
|
150
|
+
rotation: t.String(),
|
|
151
|
+
width: t.Number(),
|
|
152
|
+
height: t.Number(),
|
|
153
|
+
timestamp: t.Number()
|
|
146
154
|
}));
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
import { t } from 'elysia';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
13
14
|
import { createRouter } from '$shared/utils/ws-server';
|
|
14
15
|
import { initializeDatabase, getDatabase } from '../../database';
|
|
15
16
|
import { debug } from '$shared/utils/logger';
|
|
16
17
|
import { ws } from '$backend/utils/ws';
|
|
18
|
+
import { getClopenDir } from '$backend/utils/index';
|
|
19
|
+
import { resetEnvironment } from '$backend/engine/adapters/claude/environment';
|
|
17
20
|
|
|
18
21
|
/** In-memory flag: set after successful update, cleared on server restart */
|
|
19
22
|
let pendingUpdate: { fromVersion: string; toVersion: string } | null = null;
|
|
@@ -163,6 +166,26 @@ export const operationsHandler = createRouter()
|
|
|
163
166
|
|
|
164
167
|
debug.log('server', 'Database cleared successfully');
|
|
165
168
|
|
|
169
|
+
// Delete snapshots directory
|
|
170
|
+
const clopenDir = getClopenDir();
|
|
171
|
+
const snapshotsDir = join(clopenDir, 'snapshots');
|
|
172
|
+
try {
|
|
173
|
+
await fs.rm(snapshotsDir, { recursive: true, force: true });
|
|
174
|
+
debug.log('server', 'Snapshots directory cleared');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
debug.warn('server', 'Failed to clear snapshots directory:', err);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Delete Claude config directory and reset environment state
|
|
180
|
+
const claudeDir = join(clopenDir, 'claude');
|
|
181
|
+
try {
|
|
182
|
+
await fs.rm(claudeDir, { recursive: true, force: true });
|
|
183
|
+
resetEnvironment();
|
|
184
|
+
debug.log('server', 'Claude config directory cleared');
|
|
185
|
+
} catch (err) {
|
|
186
|
+
debug.warn('server', 'Failed to clear Claude config directory:', err);
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
return {
|
|
167
190
|
cleared: true,
|
|
168
191
|
tablesCount: tables.length
|
|
@@ -416,12 +416,12 @@
|
|
|
416
416
|
ondrop={fileHandling.handleDrop}
|
|
417
417
|
>
|
|
418
418
|
<div class="flex-1">
|
|
419
|
-
<!-- Engine/Model Picker -->
|
|
420
|
-
<EngineModelPicker />
|
|
421
|
-
|
|
422
419
|
<!-- Edit Mode Indicator -->
|
|
423
420
|
<EditModeIndicator onCancel={handleCancelEdit} />
|
|
424
421
|
|
|
422
|
+
<!-- Engine/Model Picker -->
|
|
423
|
+
<EngineModelPicker />
|
|
424
|
+
|
|
425
425
|
<div class="flex items-end">
|
|
426
426
|
<textarea
|
|
427
427
|
bind:this={textareaElement}
|