@myrialabs/clopen 0.0.4 → 0.0.6

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.
Files changed (453) hide show
  1. package/.env.example +5 -5
  2. package/.github/workflows/{release.yml → ci.yml} +86 -60
  3. package/CONTRIBUTING.md +499 -499
  4. package/LICENSE +21 -21
  5. package/README.md +209 -209
  6. package/backend/index.ts +168 -156
  7. package/backend/lib/chat/helpers.ts +42 -42
  8. package/backend/lib/chat/index.ts +1 -1
  9. package/backend/lib/chat/stream-manager.ts +1126 -1126
  10. package/backend/lib/database/README.md +76 -76
  11. package/backend/lib/database/index.ts +118 -118
  12. package/backend/lib/database/migrations/001_create_projects_table.ts +30 -30
  13. package/backend/lib/database/migrations/002_create_chat_sessions_table.ts +32 -32
  14. package/backend/lib/database/migrations/003_create_messages_table.ts +31 -31
  15. package/backend/lib/database/migrations/004_create_prompt_templates_table.ts +34 -34
  16. package/backend/lib/database/migrations/005_create_settings_table.ts +23 -23
  17. package/backend/lib/database/migrations/006_add_user_to_messages.ts +57 -57
  18. package/backend/lib/database/migrations/007_create_stream_states_table.ts +40 -40
  19. package/backend/lib/database/migrations/008_create_message_snapshots_table.ts +61 -61
  20. package/backend/lib/database/migrations/009_add_delta_snapshot_fields.ts +41 -41
  21. package/backend/lib/database/migrations/010_add_soft_delete_and_branch_support.ts +70 -70
  22. package/backend/lib/database/migrations/011_git_like_commit_graph.ts +156 -156
  23. package/backend/lib/database/migrations/012_add_file_change_statistics.ts +41 -41
  24. package/backend/lib/database/migrations/013_checkpoint_tree_state.ts +118 -118
  25. package/backend/lib/database/migrations/014_add_engine_to_sessions.ts +18 -18
  26. package/backend/lib/database/migrations/015_add_model_to_sessions.ts +18 -18
  27. package/backend/lib/database/migrations/016_create_user_projects_table.ts +34 -34
  28. package/backend/lib/database/migrations/017_add_current_session_to_user_projects.ts +32 -32
  29. package/backend/lib/database/migrations/018_create_claude_accounts_table.ts +24 -24
  30. package/backend/lib/database/migrations/019_add_claude_account_to_sessions.ts +18 -18
  31. package/backend/lib/database/migrations/020_add_snapshot_tree_hash.ts +32 -32
  32. package/backend/lib/database/migrations/021_drop_prompt_templates_table.ts +33 -33
  33. package/backend/lib/database/migrations/index.ts +153 -153
  34. package/backend/lib/database/queries/checkpoint-queries.ts +87 -87
  35. package/backend/lib/database/queries/engine-queries.ts +75 -75
  36. package/backend/lib/database/queries/index.ts +8 -8
  37. package/backend/lib/database/queries/message-queries.ts +471 -471
  38. package/backend/lib/database/queries/project-queries.ts +117 -117
  39. package/backend/lib/database/queries/session-queries.ts +270 -270
  40. package/backend/lib/database/queries/settings-queries.ts +33 -33
  41. package/backend/lib/database/queries/snapshot-queries.ts +325 -325
  42. package/backend/lib/database/queries/utils-queries.ts +58 -58
  43. package/backend/lib/database/seeders/index.ts +12 -12
  44. package/backend/lib/database/seeders/settings_seeder.ts +83 -83
  45. package/backend/lib/database/utils/connection.ts +173 -173
  46. package/backend/lib/database/utils/index.ts +3 -3
  47. package/backend/lib/database/utils/migration-runner.ts +117 -117
  48. package/backend/lib/database/utils/seeder-runner.ts +120 -120
  49. package/backend/lib/engine/adapters/claude/environment.ts +160 -164
  50. package/backend/lib/engine/adapters/claude/error-handler.ts +60 -60
  51. package/backend/lib/engine/adapters/claude/index.ts +1 -1
  52. package/backend/lib/engine/adapters/claude/path-utils.ts +38 -38
  53. package/backend/lib/engine/adapters/claude/stream.ts +177 -177
  54. package/backend/lib/engine/adapters/opencode/index.ts +2 -2
  55. package/backend/lib/engine/adapters/opencode/message-converter.ts +862 -862
  56. package/backend/lib/engine/adapters/opencode/server.ts +104 -104
  57. package/backend/lib/engine/adapters/opencode/stream.ts +755 -755
  58. package/backend/lib/engine/index.ts +196 -196
  59. package/backend/lib/engine/types.ts +58 -58
  60. package/backend/lib/files/file-operations.ts +478 -478
  61. package/backend/lib/files/file-reading.ts +308 -308
  62. package/backend/lib/files/file-watcher.ts +383 -383
  63. package/backend/lib/files/path-browsing.ts +382 -382
  64. package/backend/lib/git/git-executor.ts +89 -88
  65. package/backend/lib/git/git-parser.ts +411 -411
  66. package/backend/lib/git/git-service.ts +505 -505
  67. package/backend/lib/mcp/README.md +1144 -1144
  68. package/backend/lib/mcp/config.ts +317 -316
  69. package/backend/lib/mcp/index.ts +35 -35
  70. package/backend/lib/mcp/project-context.ts +236 -236
  71. package/backend/lib/mcp/servers/browser-automation/actions.ts +156 -156
  72. package/backend/lib/mcp/servers/browser-automation/browser.ts +419 -419
  73. package/backend/lib/mcp/servers/browser-automation/index.ts +791 -791
  74. package/backend/lib/mcp/servers/browser-automation/inspection.ts +501 -501
  75. package/backend/lib/mcp/servers/helper.ts +143 -143
  76. package/backend/lib/mcp/servers/index.ts +44 -44
  77. package/backend/lib/mcp/servers/weather/get-temperature.ts +56 -56
  78. package/backend/lib/mcp/servers/weather/index.ts +31 -31
  79. package/backend/lib/mcp/stdio-server.ts +103 -103
  80. package/backend/lib/mcp/types.ts +65 -65
  81. package/backend/lib/preview/browser/browser-audio-capture.ts +86 -86
  82. package/backend/lib/preview/browser/browser-console-manager.ts +262 -262
  83. package/backend/lib/preview/browser/browser-dialog-handler.ts +222 -222
  84. package/backend/lib/preview/browser/browser-interaction-handler.ts +421 -421
  85. package/backend/lib/preview/browser/browser-mcp-control.ts +415 -415
  86. package/backend/lib/preview/browser/browser-native-ui-handler.ts +512 -512
  87. package/backend/lib/preview/browser/browser-navigation-tracker.ts +103 -103
  88. package/backend/lib/preview/browser/browser-pool.ts +357 -357
  89. package/backend/lib/preview/browser/browser-preview-service.ts +882 -882
  90. package/backend/lib/preview/browser/browser-tab-manager.ts +935 -935
  91. package/backend/lib/preview/browser/browser-video-capture.ts +695 -695
  92. package/backend/lib/preview/browser/scripts/audio-stream.ts +292 -292
  93. package/backend/lib/preview/browser/scripts/cursor-tracking.ts +85 -85
  94. package/backend/lib/preview/browser/scripts/video-stream.ts +438 -438
  95. package/backend/lib/preview/browser/types.ts +359 -359
  96. package/backend/lib/preview/index.ts +23 -23
  97. package/backend/lib/project/index.ts +1 -1
  98. package/backend/lib/project/status-manager.ts +181 -181
  99. package/backend/lib/shared/env.ts +117 -0
  100. package/backend/lib/shared/index.ts +5 -2
  101. package/backend/lib/shared/port-utils.ts +25 -25
  102. package/backend/lib/shared/process-manager.ts +280 -280
  103. package/backend/lib/snapshot/blob-store.ts +227 -227
  104. package/backend/lib/snapshot/gitignore.ts +307 -307
  105. package/backend/lib/snapshot/helpers.ts +397 -397
  106. package/backend/lib/snapshot/snapshot-service.ts +483 -483
  107. package/backend/lib/terminal/helpers.ts +14 -14
  108. package/backend/lib/terminal/index.ts +7 -7
  109. package/backend/lib/terminal/pty-manager.ts +3 -3
  110. package/backend/lib/terminal/pty-session-manager.ts +370 -387
  111. package/backend/lib/terminal/shell-utils.ts +315 -312
  112. package/backend/lib/terminal/stream-manager.ts +292 -292
  113. package/backend/lib/tunnel/global-tunnel-manager.ts +266 -243
  114. package/backend/lib/tunnel/project-tunnel-manager.ts +311 -311
  115. package/backend/lib/user/helpers.ts +87 -87
  116. package/backend/lib/utils/ws.ts +944 -944
  117. package/backend/lib/vite-dev.ts +295 -295
  118. package/backend/middleware/cors.ts +16 -15
  119. package/backend/middleware/error-handler.ts +50 -49
  120. package/backend/middleware/logger.ts +9 -9
  121. package/backend/types/api.ts +24 -24
  122. package/backend/ws/README.md +1505 -1505
  123. package/backend/ws/chat/background.ts +198 -198
  124. package/backend/ws/chat/index.ts +21 -21
  125. package/backend/ws/chat/stream.ts +707 -707
  126. package/backend/ws/engine/claude/accounts.ts +399 -401
  127. package/backend/ws/engine/claude/index.ts +13 -13
  128. package/backend/ws/engine/claude/status.ts +43 -43
  129. package/backend/ws/engine/index.ts +14 -14
  130. package/backend/ws/engine/opencode/index.ts +11 -11
  131. package/backend/ws/engine/opencode/status.ts +30 -30
  132. package/backend/ws/engine/utils.ts +36 -36
  133. package/backend/ws/files/index.ts +30 -30
  134. package/backend/ws/files/read.ts +189 -189
  135. package/backend/ws/files/search.ts +453 -453
  136. package/backend/ws/files/watch.ts +124 -124
  137. package/backend/ws/files/write.ts +143 -143
  138. package/backend/ws/git/branch.ts +106 -106
  139. package/backend/ws/git/commit.ts +39 -39
  140. package/backend/ws/git/conflict.ts +68 -68
  141. package/backend/ws/git/diff.ts +69 -69
  142. package/backend/ws/git/index.ts +24 -24
  143. package/backend/ws/git/log.ts +41 -41
  144. package/backend/ws/git/remote.ts +214 -214
  145. package/backend/ws/git/staging.ts +84 -84
  146. package/backend/ws/git/status.ts +90 -90
  147. package/backend/ws/index.ts +69 -69
  148. package/backend/ws/mcp/index.ts +61 -61
  149. package/backend/ws/messages/crud.ts +74 -74
  150. package/backend/ws/messages/index.ts +14 -14
  151. package/backend/ws/preview/browser/cleanup.ts +129 -129
  152. package/backend/ws/preview/browser/console.ts +114 -114
  153. package/backend/ws/preview/browser/interact.ts +513 -513
  154. package/backend/ws/preview/browser/mcp.ts +129 -129
  155. package/backend/ws/preview/browser/native-ui.ts +235 -235
  156. package/backend/ws/preview/browser/stats.ts +55 -55
  157. package/backend/ws/preview/browser/tab-info.ts +126 -126
  158. package/backend/ws/preview/browser/tab.ts +166 -166
  159. package/backend/ws/preview/browser/webcodecs.ts +293 -293
  160. package/backend/ws/preview/index.ts +146 -146
  161. package/backend/ws/projects/crud.ts +113 -113
  162. package/backend/ws/projects/index.ts +25 -25
  163. package/backend/ws/projects/presence.ts +46 -46
  164. package/backend/ws/projects/status.ts +116 -116
  165. package/backend/ws/sessions/crud.ts +327 -327
  166. package/backend/ws/sessions/index.ts +33 -33
  167. package/backend/ws/settings/crud.ts +112 -112
  168. package/backend/ws/settings/index.ts +14 -14
  169. package/backend/ws/snapshot/index.ts +17 -17
  170. package/backend/ws/snapshot/restore.ts +173 -173
  171. package/backend/ws/snapshot/timeline.ts +141 -141
  172. package/backend/ws/system/index.ts +14 -14
  173. package/backend/ws/system/operations.ts +49 -49
  174. package/backend/ws/terminal/index.ts +40 -40
  175. package/backend/ws/terminal/persistence.ts +153 -153
  176. package/backend/ws/terminal/session.ts +382 -382
  177. package/backend/ws/terminal/stream.ts +79 -79
  178. package/backend/ws/tunnel/index.ts +14 -14
  179. package/backend/ws/tunnel/operations.ts +91 -91
  180. package/backend/ws/types.ts +20 -20
  181. package/backend/ws/user/crud.ts +156 -156
  182. package/backend/ws/user/index.ts +14 -14
  183. package/bin/clopen.ts +307 -307
  184. package/bun.lock +1353 -1352
  185. package/frontend/App.svelte +38 -34
  186. package/frontend/app.css +313 -313
  187. package/frontend/lib/app-environment.ts +10 -10
  188. package/frontend/lib/components/chat/ChatInterface.svelte +406 -406
  189. package/frontend/lib/components/chat/formatters/ErrorMessage.svelte +56 -56
  190. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +223 -223
  191. package/frontend/lib/components/chat/formatters/TextMessage.svelte +394 -394
  192. package/frontend/lib/components/chat/formatters/Tools.svelte +69 -69
  193. package/frontend/lib/components/chat/formatters/index.ts +2 -2
  194. package/frontend/lib/components/chat/input/ChatInput.svelte +421 -421
  195. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +78 -78
  196. package/frontend/lib/components/chat/input/components/DragDropOverlay.svelte +30 -30
  197. package/frontend/lib/components/chat/input/components/EditModeIndicator.svelte +33 -33
  198. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +619 -619
  199. package/frontend/lib/components/chat/input/components/FileAttachmentPreview.svelte +48 -48
  200. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +31 -31
  201. package/frontend/lib/components/chat/input/composables/use-animations.svelte.ts +201 -201
  202. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +148 -148
  203. package/frontend/lib/components/chat/input/composables/use-file-handling.svelte.ts +216 -216
  204. package/frontend/lib/components/chat/input/composables/use-input-state.svelte.ts +357 -357
  205. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +57 -57
  206. package/frontend/lib/components/chat/message/ChatMessage.svelte +478 -478
  207. package/frontend/lib/components/chat/message/ChatMessages.svelte +541 -541
  208. package/frontend/lib/components/chat/message/DateSeparator.svelte +86 -86
  209. package/frontend/lib/components/chat/message/MessageBubble.svelte +86 -86
  210. package/frontend/lib/components/chat/message/MessageHeader.svelte +157 -157
  211. package/frontend/lib/components/chat/modal/DebugModal.svelte +59 -59
  212. package/frontend/lib/components/chat/modal/TokenUsageModal.svelte +124 -124
  213. package/frontend/lib/components/chat/shared/index.ts +1 -1
  214. package/frontend/lib/components/chat/shared/utils.ts +115 -115
  215. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +35 -35
  216. package/frontend/lib/components/chat/tools/BashTool.svelte +45 -45
  217. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +139 -139
  218. package/frontend/lib/components/chat/tools/EditTool.svelte +47 -47
  219. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +31 -31
  220. package/frontend/lib/components/chat/tools/GlobTool.svelte +50 -50
  221. package/frontend/lib/components/chat/tools/GrepTool.svelte +89 -89
  222. package/frontend/lib/components/chat/tools/KillShellTool.svelte +25 -25
  223. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +30 -30
  224. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +37 -37
  225. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +33 -33
  226. package/frontend/lib/components/chat/tools/ReadTool.svelte +40 -40
  227. package/frontend/lib/components/chat/tools/TaskTool.svelte +63 -63
  228. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +74 -74
  229. package/frontend/lib/components/chat/tools/WebFetchTool.svelte +34 -34
  230. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +83 -83
  231. package/frontend/lib/components/chat/tools/WriteTool.svelte +32 -32
  232. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +78 -78
  233. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +407 -407
  234. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +45 -45
  235. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +18 -18
  236. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +26 -26
  237. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +53 -53
  238. package/frontend/lib/components/chat/tools/components/index.ts +7 -7
  239. package/frontend/lib/components/chat/tools/index.ts +25 -25
  240. package/frontend/lib/components/chat/widgets/FloatingTodoList.svelte +248 -248
  241. package/frontend/lib/components/chat/widgets/TokenUsage.svelte +78 -78
  242. package/frontend/lib/components/checkpoint/TimelineModal.svelte +391 -391
  243. package/frontend/lib/components/checkpoint/timeline/TimelineEdge.svelte +26 -26
  244. package/frontend/lib/components/checkpoint/timeline/TimelineGraph.svelte +86 -86
  245. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +108 -108
  246. package/frontend/lib/components/checkpoint/timeline/TimelineVersionGroup.svelte +59 -59
  247. package/frontend/lib/components/checkpoint/timeline/animation.ts +168 -168
  248. package/frontend/lib/components/checkpoint/timeline/config.ts +44 -44
  249. package/frontend/lib/components/checkpoint/timeline/graph-builder.ts +304 -304
  250. package/frontend/lib/components/checkpoint/timeline/types.ts +65 -65
  251. package/frontend/lib/components/checkpoint/timeline/utils.ts +53 -53
  252. package/frontend/lib/components/common/Alert.svelte +138 -138
  253. package/frontend/lib/components/common/AvatarBubble.svelte +55 -55
  254. package/frontend/lib/components/common/Button.svelte +71 -71
  255. package/frontend/lib/components/common/Card.svelte +102 -102
  256. package/frontend/lib/components/common/Checkbox.svelte +48 -48
  257. package/frontend/lib/components/common/Dialog.svelte +248 -248
  258. package/frontend/lib/components/common/FolderBrowser.svelte +842 -842
  259. package/frontend/lib/components/common/Icon.svelte +57 -57
  260. package/frontend/lib/components/common/Input.svelte +72 -72
  261. package/frontend/lib/components/common/Lightbox.svelte +232 -232
  262. package/frontend/lib/components/common/LoadingScreen.svelte +52 -52
  263. package/frontend/lib/components/common/LoadingSpinner.svelte +48 -48
  264. package/frontend/lib/components/common/Modal.svelte +177 -177
  265. package/frontend/lib/components/common/ModalProvider.svelte +27 -27
  266. package/frontend/lib/components/common/ModelSelector.svelte +110 -110
  267. package/frontend/lib/components/common/MonacoEditor.svelte +568 -568
  268. package/frontend/lib/components/common/NotificationToast.svelte +113 -113
  269. package/frontend/lib/components/common/PageTemplate.svelte +75 -75
  270. package/frontend/lib/components/common/ProjectUserAvatars.svelte +79 -79
  271. package/frontend/lib/components/common/Select.svelte +97 -97
  272. package/frontend/lib/components/common/Textarea.svelte +79 -79
  273. package/frontend/lib/components/common/ThemeToggle.svelte +44 -44
  274. package/frontend/lib/components/common/lucide-icons.ts +1642 -1642
  275. package/frontend/lib/components/common/material-icons.ts +1082 -1082
  276. package/frontend/lib/components/common/xterm/XTerm.svelte +809 -795
  277. package/frontend/lib/components/common/xterm/index.ts +15 -15
  278. package/frontend/lib/components/common/xterm/terminal-config.ts +67 -67
  279. package/frontend/lib/components/common/xterm/types.ts +30 -30
  280. package/frontend/lib/components/common/xterm/xterm-service.ts +379 -353
  281. package/frontend/lib/components/files/FileNode.svelte +383 -383
  282. package/frontend/lib/components/files/FileTree.svelte +681 -681
  283. package/frontend/lib/components/files/FileViewer.svelte +728 -728
  284. package/frontend/lib/components/files/SearchResults.svelte +303 -303
  285. package/frontend/lib/components/git/BranchManager.svelte +458 -458
  286. package/frontend/lib/components/git/ChangesSection.svelte +107 -107
  287. package/frontend/lib/components/git/CommitForm.svelte +76 -76
  288. package/frontend/lib/components/git/ConflictResolver.svelte +158 -158
  289. package/frontend/lib/components/git/DiffViewer.svelte +364 -364
  290. package/frontend/lib/components/git/FileChangeItem.svelte +97 -97
  291. package/frontend/lib/components/git/GitButton.svelte +33 -33
  292. package/frontend/lib/components/git/GitLog.svelte +361 -361
  293. package/frontend/lib/components/git/GitModal.svelte +80 -80
  294. package/frontend/lib/components/history/HistoryModal.svelte +563 -563
  295. package/frontend/lib/components/history/HistoryView.svelte +614 -614
  296. package/frontend/lib/components/index.ts +34 -34
  297. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +549 -549
  298. package/frontend/lib/components/preview/browser/components/Canvas.svelte +1058 -1058
  299. package/frontend/lib/components/preview/browser/components/ConsolePanel.svelte +756 -756
  300. package/frontend/lib/components/preview/browser/components/Container.svelte +450 -450
  301. package/frontend/lib/components/preview/browser/components/ContextMenu.svelte +236 -236
  302. package/frontend/lib/components/preview/browser/components/SelectDropdown.svelte +224 -224
  303. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +338 -338
  304. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +35 -35
  305. package/frontend/lib/components/preview/browser/core/cleanup.svelte.ts +155 -155
  306. package/frontend/lib/components/preview/browser/core/coordinator.svelte.ts +837 -837
  307. package/frontend/lib/components/preview/browser/core/interactions.svelte.ts +113 -113
  308. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +296 -296
  309. package/frontend/lib/components/preview/browser/core/native-ui-handlers.svelte.ts +391 -391
  310. package/frontend/lib/components/preview/browser/core/stream-handler.svelte.ts +231 -231
  311. package/frontend/lib/components/preview/browser/core/tab-manager.svelte.ts +210 -210
  312. package/frontend/lib/components/preview/browser/core/tab-operations.svelte.ts +239 -239
  313. package/frontend/lib/components/preview/index.ts +1 -1
  314. package/frontend/lib/components/settings/SettingsModal.svelte +235 -235
  315. package/frontend/lib/components/settings/SettingsView.svelte +36 -36
  316. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +51 -51
  317. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +160 -160
  318. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +76 -76
  319. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +917 -917
  320. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +187 -187
  321. package/frontend/lib/components/settings/general/DataManagementSettings.svelte +203 -203
  322. package/frontend/lib/components/settings/general/GeneralSettings.svelte +10 -10
  323. package/frontend/lib/components/settings/model/ModelSettings.svelte +357 -357
  324. package/frontend/lib/components/settings/notifications/NotificationSettings.svelte +205 -205
  325. package/frontend/lib/components/settings/user/UserSettings.svelte +197 -197
  326. package/frontend/lib/components/terminal/Terminal.svelte +367 -367
  327. package/frontend/lib/components/terminal/TerminalTabs.svelte +87 -87
  328. package/frontend/lib/components/terminal/TerminalView.svelte +54 -54
  329. package/frontend/lib/components/tunnel/TunnelActive.svelte +157 -142
  330. package/frontend/lib/components/tunnel/TunnelButton.svelte +60 -54
  331. package/frontend/lib/components/tunnel/TunnelInactive.svelte +285 -284
  332. package/frontend/lib/components/tunnel/TunnelModal.svelte +48 -47
  333. package/frontend/lib/components/tunnel/TunnelQRCode.svelte +49 -49
  334. package/frontend/lib/components/workspace/DesktopNavigator.svelte +382 -382
  335. package/frontend/lib/components/workspace/MobileNavigator.svelte +394 -403
  336. package/frontend/lib/components/workspace/PanelContainer.svelte +100 -100
  337. package/frontend/lib/components/workspace/PanelHeader.svelte +505 -505
  338. package/frontend/lib/components/workspace/ViewMenu.svelte +162 -162
  339. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +169 -169
  340. package/frontend/lib/components/workspace/layout/DesktopLayout.svelte +15 -15
  341. package/frontend/lib/components/workspace/layout/MobileLayout.svelte +17 -17
  342. package/frontend/lib/components/workspace/layout/split-pane/Container.svelte +42 -42
  343. package/frontend/lib/components/workspace/layout/split-pane/Handle.svelte +84 -84
  344. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +37 -37
  345. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +274 -274
  346. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +1261 -1261
  347. package/frontend/lib/components/workspace/panels/GitPanel.svelte +1560 -1560
  348. package/frontend/lib/components/workspace/panels/PreviewPanel.svelte +150 -150
  349. package/frontend/lib/components/workspace/panels/TerminalPanel.svelte +73 -73
  350. package/frontend/lib/constants/preview.ts +44 -44
  351. package/frontend/lib/services/chat/chat.service.ts +704 -704
  352. package/frontend/lib/services/chat/index.ts +6 -6
  353. package/frontend/lib/services/notification/global-stream-monitor.ts +86 -86
  354. package/frontend/lib/services/notification/index.ts +7 -7
  355. package/frontend/lib/services/notification/push.service.ts +143 -143
  356. package/frontend/lib/services/notification/sound.service.ts +126 -126
  357. package/frontend/lib/services/preview/browser/browser-console.service.ts +61 -61
  358. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +1499 -1499
  359. package/frontend/lib/services/preview/browser/mcp-integration.svelte.ts +67 -67
  360. package/frontend/lib/services/preview/index.ts +22 -22
  361. package/frontend/lib/services/project/index.ts +7 -7
  362. package/frontend/lib/services/project/status.service.ts +159 -159
  363. package/frontend/lib/services/snapshot/snapshot.service.ts +47 -47
  364. package/frontend/lib/services/terminal/background/index.ts +129 -129
  365. package/frontend/lib/services/terminal/background/session-restore.ts +273 -273
  366. package/frontend/lib/services/terminal/background/stream-manager.ts +285 -285
  367. package/frontend/lib/services/terminal/index.ts +13 -13
  368. package/frontend/lib/services/terminal/persistence.service.ts +260 -260
  369. package/frontend/lib/services/terminal/project.service.ts +952 -952
  370. package/frontend/lib/services/terminal/session.service.ts +363 -363
  371. package/frontend/lib/services/terminal/terminal.service.ts +369 -369
  372. package/frontend/lib/stores/core/app.svelte.ts +117 -117
  373. package/frontend/lib/stores/core/files.svelte.ts +72 -72
  374. package/frontend/lib/stores/core/presence.svelte.ts +48 -48
  375. package/frontend/lib/stores/core/projects.svelte.ts +317 -317
  376. package/frontend/lib/stores/core/sessions.svelte.ts +383 -383
  377. package/frontend/lib/stores/features/claude-accounts.svelte.ts +58 -58
  378. package/frontend/lib/stores/features/models.svelte.ts +89 -89
  379. package/frontend/lib/stores/features/settings.svelte.ts +87 -87
  380. package/frontend/lib/stores/features/terminal.svelte.ts +700 -700
  381. package/frontend/lib/stores/features/tunnel.svelte.ts +163 -161
  382. package/frontend/lib/stores/features/user.svelte.ts +95 -95
  383. package/frontend/lib/stores/ui/chat-input.svelte.ts +56 -56
  384. package/frontend/lib/stores/ui/chat-model.svelte.ts +61 -61
  385. package/frontend/lib/stores/ui/dialog.svelte.ts +58 -58
  386. package/frontend/lib/stores/ui/edit-mode.svelte.ts +214 -214
  387. package/frontend/lib/stores/ui/notification.svelte.ts +166 -166
  388. package/frontend/lib/stores/ui/settings-modal.svelte.ts +88 -88
  389. package/frontend/lib/stores/ui/theme.svelte.ts +179 -179
  390. package/frontend/lib/stores/ui/workspace.svelte.ts +754 -754
  391. package/frontend/lib/types/native-ui.ts +73 -73
  392. package/frontend/lib/utils/chat/date-separator.ts +38 -38
  393. package/frontend/lib/utils/chat/message-grouper.ts +218 -218
  394. package/frontend/lib/utils/chat/message-processor.ts +134 -134
  395. package/frontend/lib/utils/chat/tool-handler.ts +160 -160
  396. package/frontend/lib/utils/chat/virtual-scroll.svelte.ts +142 -142
  397. package/frontend/lib/utils/click-outside.ts +20 -20
  398. package/frontend/lib/utils/context-manager.ts +256 -256
  399. package/frontend/lib/utils/file-icon-mappings.ts +768 -768
  400. package/frontend/lib/utils/folder-icon-mappings.ts +1029 -1029
  401. package/frontend/lib/utils/git-status.ts +68 -68
  402. package/frontend/lib/utils/platform.ts +112 -112
  403. package/frontend/lib/utils/port-check.ts +64 -64
  404. package/frontend/lib/utils/terminalFormatter.ts +206 -206
  405. package/frontend/lib/utils/theme.ts +6 -6
  406. package/frontend/lib/utils/tree-visualizer.ts +320 -320
  407. package/frontend/lib/utils/ws.ts +44 -44
  408. package/frontend/main.ts +13 -13
  409. package/index.html +70 -70
  410. package/package.json +111 -111
  411. package/scripts/generate-icons.ts +86 -86
  412. package/scripts/pre-publish-check.sh +142 -142
  413. package/scripts/setup-hooks.sh +134 -134
  414. package/scripts/validate-branch-name.sh +47 -47
  415. package/scripts/validate-commit-msg.sh +42 -42
  416. package/shared/constants/engines.ts +134 -134
  417. package/shared/types/database/connection.ts +15 -15
  418. package/shared/types/database/index.ts +5 -5
  419. package/shared/types/database/schema.ts +140 -140
  420. package/shared/types/engine/index.ts +45 -45
  421. package/shared/types/filesystem/index.ts +21 -21
  422. package/shared/types/git.ts +171 -171
  423. package/shared/types/messaging/index.ts +238 -238
  424. package/shared/types/messaging/tool.ts +525 -525
  425. package/shared/types/network/api.ts +17 -17
  426. package/shared/types/network/index.ts +4 -4
  427. package/shared/types/stores/app.ts +22 -22
  428. package/shared/types/stores/dialog.ts +20 -20
  429. package/shared/types/stores/index.ts +2 -2
  430. package/shared/types/stores/settings.ts +15 -15
  431. package/shared/types/terminal/index.ts +43 -43
  432. package/shared/types/ui/components.ts +60 -60
  433. package/shared/types/ui/icons.ts +22 -22
  434. package/shared/types/ui/index.ts +21 -21
  435. package/shared/types/ui/notifications.ts +13 -13
  436. package/shared/types/ui/theme.ts +11 -11
  437. package/shared/types/websocket/index.ts +43 -43
  438. package/shared/types/window.d.ts +12 -12
  439. package/shared/utils/anonymous-user.ts +167 -167
  440. package/shared/utils/async.ts +10 -10
  441. package/shared/utils/diff-calculator.ts +184 -184
  442. package/shared/utils/file-type-detection.ts +165 -165
  443. package/shared/utils/logger.ts +144 -158
  444. package/shared/utils/message-formatter.ts +79 -79
  445. package/shared/utils/path.ts +47 -47
  446. package/shared/utils/ws-client.ts +768 -768
  447. package/shared/utils/ws-server.ts +660 -660
  448. package/static/favicon.svg +7 -7
  449. package/static/fonts/dm-sans.css +96 -96
  450. package/svelte.config.js +20 -20
  451. package/tsconfig.json +41 -41
  452. package/vite.config.ts +33 -33
  453. package/.github/workflows/test.yml +0 -40
@@ -1,695 +1,695 @@
1
- /**
2
- * Browser Video Capture Handler
3
- *
4
- * Handles WebCodecs-based video streaming with WebRTC DataChannel transport.
5
- *
6
- * Video Architecture:
7
- * 1. Puppeteer CDP captures JPEG frames via Page.screencastFrame
8
- * 2. Decode JPEG to ImageBitmap in browser
9
- * 3. Encode with VideoEncoder (VP8) in browser
10
- * 4. Send encoded chunks via RTCDataChannel
11
- *
12
- * Audio Architecture:
13
- * 1. AudioContext interception (handled by BrowserAudioCapture)
14
- * 2. Audio encoded with AudioEncoder (Opus) in headless browser
15
- * 3. Encoded chunks sent via sendAudioChunk() to same DataChannel
16
- *
17
- * Client:
18
- * - Receives video + audio chunks via DataChannel
19
- * - Decodes with VideoDecoder + AudioDecoder
20
- * - Renders video to canvas, plays audio with proper scheduling
21
- *
22
- * Benefits vs Canvas + WebRTC:
23
- * - Lower bandwidth (500-800 Kbps vs 1 Mbps)
24
- * - More control over codec selection
25
- * - Skip canvas rendering overhead
26
- * - DataChannel = lower latency than video track
27
- */
28
-
29
- import { EventEmitter } from 'events';
30
- import type { Page } from 'puppeteer';
31
- import type { BrowserTab, StreamingConfig } from './types';
32
- import { DEFAULT_STREAMING_CONFIG } from './types';
33
- import { videoEncoderScript } from './scripts/video-stream';
34
- import { debug } from '$shared/utils/logger';
35
-
36
- interface VideoStreamSession {
37
- sessionId: string;
38
- isActive: boolean;
39
- clientConnected: boolean;
40
- headlessReady: boolean;
41
- pendingCandidates: RTCIceCandidateInit[];
42
- scriptInjected: boolean; // Track if persistent script was injected
43
- stats: {
44
- videoBytesSent: number;
45
- audioBytesSent: number;
46
- videoFramesEncoded: number;
47
- audioFramesEncoded: number;
48
- connectionState: string;
49
- };
50
- }
51
-
52
- export class BrowserVideoCapture extends EventEmitter {
53
- private sessions = new Map<string, VideoStreamSession>();
54
-
55
- constructor() {
56
- super();
57
- }
58
-
59
- /**
60
- * Start video streaming for a session
61
- */
62
- async startStreaming(
63
- sessionId: string,
64
- session: BrowserTab,
65
- isValidSession: () => boolean
66
- ): Promise<boolean> {
67
- debug.log('webcodecs', `Starting streaming for session ${sessionId}`);
68
-
69
- // If session exists, stop it first
70
- if (this.sessions.has(sessionId)) {
71
- debug.log('webcodecs', `Session ${sessionId} exists, stopping for restart`);
72
- await this.stopStreaming(sessionId, session);
73
- await new Promise(resolve => setTimeout(resolve, 100));
74
- }
75
-
76
- if (!session.page || session.page.isClosed()) {
77
- debug.error('webcodecs', `Cannot start: page is closed`);
78
- return false;
79
- }
80
-
81
- try {
82
- const page = session.page;
83
- const viewport = page.viewport()!;
84
- const config = DEFAULT_STREAMING_CONFIG;
85
-
86
- // Get scale from session (default to 1 if not set)
87
- const scale = session.scale || 1;
88
- debug.log('webcodecs', `Using scale: ${scale} for session ${sessionId}`);
89
-
90
- // Get or create session tracking
91
- let videoSession = this.sessions.get(sessionId);
92
- const isRestart = !!videoSession;
93
-
94
- if (!videoSession) {
95
- videoSession = {
96
- sessionId,
97
- isActive: false,
98
- clientConnected: false,
99
- headlessReady: false,
100
- pendingCandidates: [],
101
- scriptInjected: false,
102
- stats: {
103
- videoBytesSent: 0,
104
- audioBytesSent: 0,
105
- videoFramesEncoded: 0,
106
- audioFramesEncoded: 0,
107
- connectionState: 'new'
108
- }
109
- };
110
- this.sessions.set(sessionId, videoSession);
111
- }
112
-
113
- // Check if bindings exist
114
- const bindingsExist = await page.evaluate(() => {
115
- return typeof (window as any).__sendIceCandidate === 'function';
116
- });
117
-
118
- // Expose signaling functions (persists across navigations)
119
- if (!bindingsExist) {
120
- await page.exposeFunction('__sendIceCandidate', (candidate: RTCIceCandidateInit) => {
121
- const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
122
- if (!activeSession) return;
123
- this.emit('ice-candidate', { sessionId: activeSession.sessionId, candidate, from: 'headless' });
124
- });
125
-
126
- await page.exposeFunction('__sendConnectionState', (state: string) => {
127
- const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
128
- if (activeSession) {
129
- activeSession.stats.connectionState = state;
130
- this.emit('connection-state', { sessionId: activeSession.sessionId, state });
131
- }
132
- });
133
-
134
- await page.exposeFunction('__sendCursorChange', (cursor: string) => {
135
- const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
136
- if (activeSession) {
137
- this.emit('cursor-change', { sessionId: activeSession.sessionId, cursor });
138
- }
139
- });
140
- }
141
-
142
- // Calculate scaled dimensions
143
- const scaledWidth = Math.round(viewport.width * scale);
144
- const scaledHeight = Math.round(viewport.height * scale);
145
-
146
- const videoConfig: StreamingConfig['video'] = {
147
- ...config.video,
148
- width: scaledWidth,
149
- height: scaledHeight
150
- };
151
-
152
- // Store config globally for persistent script access
153
- await page.evaluate((cfg) => {
154
- (window as any).__videoEncoderConfig = cfg;
155
- }, videoConfig);
156
-
157
- // Inject persistent video encoder script (survives navigation)
158
- // Only inject once per page instance
159
- if (!videoSession.scriptInjected) {
160
- await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
- videoSession.scriptInjected = true;
162
- debug.log('webcodecs', `Persistent video encoder script injected for ${sessionId}`);
163
- }
164
-
165
- // Also inject immediately for current page context
166
- // (evaluateOnNewDocument only runs on NEXT navigation)
167
- await page.evaluate(videoEncoderScript, videoConfig);
168
-
169
- // Verify peer was created
170
- const peerExists = await page.evaluate(() => {
171
- return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
172
- });
173
-
174
- if (!peerExists) {
175
- debug.error('webcodecs', `Peer script injected but __webCodecsPeer not available`);
176
- this.sessions.delete(sessionId);
177
- return false;
178
- }
179
-
180
- videoSession.isActive = true;
181
-
182
- // Wait for page to be fully loaded
183
- try {
184
- const loadState = await page.evaluate(() => document.readyState);
185
- if (loadState !== 'complete') {
186
- debug.log('webcodecs', `Waiting for page load...`);
187
- await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60000 });
188
- }
189
- } catch (loadError) {
190
- debug.warn('webcodecs', 'Page load wait timed out, proceeding anyway');
191
- }
192
-
193
- // Start video streaming
194
- const started = await page.evaluate(() => {
195
- return (window as any).__webCodecsPeer?.startStreaming();
196
- });
197
-
198
- if (!started) {
199
- debug.error('webcodecs', `startStreaming returned false`);
200
- this.sessions.delete(sessionId);
201
- return false;
202
- }
203
-
204
- // Initialize and start audio encoder (from AudioContext interception)
205
- const audioEncoderAvailable = await page.evaluate(() => {
206
- return typeof (window as any).__audioEncoder?.init === 'function';
207
- });
208
-
209
- if (audioEncoderAvailable) {
210
- debug.log('webcodecs', 'Initializing audio encoder from AudioContext interception...');
211
-
212
- const audioInitialized = await page.evaluate(async () => {
213
- const encoder = (window as any).__audioEncoder;
214
- if (!encoder) return false;
215
-
216
- const initiated = await encoder.init();
217
- if (initiated) {
218
- return encoder.start();
219
- }
220
- return false;
221
- });
222
-
223
- if (audioInitialized) {
224
- debug.log('webcodecs', 'Audio encoder initialized and started');
225
- } else {
226
- debug.warn('webcodecs', 'Audio encoder initialization failed, continuing with video only');
227
- }
228
- } else {
229
- debug.warn('webcodecs', 'Audio encoder not available (AudioEncoder API may not be supported)');
230
- }
231
-
232
- videoSession.headlessReady = true;
233
-
234
- // Setup CDP screencast to feed frames to encoder
235
- await this.setupFrameFeeder(sessionId, session, config, isValidSession);
236
-
237
- debug.log('webcodecs', `Streaming started for ${sessionId}`);
238
- return true;
239
- } catch (error) {
240
- debug.error('webcodecs', `Failed to start streaming:`, error);
241
- this.sessions.delete(sessionId);
242
- throw error;
243
- }
244
- }
245
-
246
- /**
247
- * Setup CDP screencast to feed JPEG frames to VideoEncoder
248
- */
249
- private async setupFrameFeeder(
250
- sessionId: string,
251
- session: BrowserTab,
252
- config: StreamingConfig,
253
- isValidSession: () => boolean
254
- ): Promise<void> {
255
- const page = session.page;
256
- const viewport = page.viewport()!;
257
-
258
- // Get scale from session (default to 1 if not set)
259
- const scale = session.scale || 1;
260
-
261
- const cdp = await page.createCDPSession();
262
-
263
- let cdpFrameCount = 0;
264
-
265
- cdp.on('Page.screencastFrame', async (event: any) => {
266
- cdpFrameCount++;
267
-
268
- const videoSession = this.sessions.get(sessionId);
269
- if (!videoSession?.isActive || session.isDestroyed) {
270
- cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {});
271
- return;
272
- }
273
-
274
- if (!isValidSession()) {
275
- this.stopStreaming(sessionId);
276
- return;
277
- }
278
-
279
- // ACK immediately
280
- cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {});
281
-
282
- // Send frame to encoder
283
- page.evaluate((frameData) => {
284
- const peer = (window as any).__webCodecsPeer;
285
- if (!peer) return false;
286
- peer.encodeFrame(frameData);
287
- return true;
288
- }, event.data).catch((err) => {
289
- if (cdpFrameCount <= 5) {
290
- debug.warn('webcodecs', `Frame delivery error (frame ${cdpFrameCount}):`, err.message);
291
- }
292
- });
293
- });
294
-
295
- // Start screencast with scaled dimensions
296
- const scaledWidth = Math.round(viewport.width * scale);
297
- const scaledHeight = Math.round(viewport.height * scale);
298
-
299
- await cdp.send('Page.startScreencast', {
300
- format: 'jpeg',
301
- quality: config.video.screenshotQuality,
302
- maxWidth: scaledWidth,
303
- maxHeight: scaledHeight,
304
- everyNthFrame: 1
305
- });
306
-
307
- debug.log('webcodecs', `CDP screencast started with scaled dimensions: ${scaledWidth}x${scaledHeight} (scale: ${scale})`);
308
- (session as any).__webCodecsCdp = cdp;
309
- }
310
-
311
- /**
312
- * Create offer from headless browser
313
- */
314
- async createOffer(sessionId: string, session: BrowserTab): Promise<RTCSessionDescriptionInit | null> {
315
- const videoSession = this.sessions.get(sessionId);
316
- if (!videoSession?.isActive || !session.page) {
317
- return null;
318
- }
319
-
320
- const maxRetries = 5;
321
- const retryDelay = 100;
322
-
323
- for (let attempt = 0; attempt < maxRetries; attempt++) {
324
- try {
325
- const peerReady = await session.page.evaluate(() => {
326
- return typeof (window as any).__webCodecsPeer?.createOffer === 'function';
327
- });
328
-
329
- if (!peerReady) {
330
- if (attempt < maxRetries - 1) {
331
- await new Promise(resolve => setTimeout(resolve, retryDelay));
332
- continue;
333
- }
334
- return null;
335
- }
336
-
337
- const offer = await session.page.evaluate(() => {
338
- return (window as any).__webCodecsPeer?.createOffer();
339
- });
340
-
341
- if (offer) return offer;
342
-
343
- if (attempt < maxRetries - 1) {
344
- await new Promise(resolve => setTimeout(resolve, retryDelay));
345
- }
346
- } catch (error) {
347
- debug.error('webcodecs', `Create offer error (attempt ${attempt + 1}):`, error);
348
- if (attempt < maxRetries - 1) {
349
- await new Promise(resolve => setTimeout(resolve, retryDelay));
350
- }
351
- }
352
- }
353
-
354
- return null;
355
- }
356
-
357
- /**
358
- * Handle answer from client
359
- */
360
- async handleAnswer(
361
- sessionId: string,
362
- session: BrowserTab,
363
- answer: RTCSessionDescriptionInit
364
- ): Promise<boolean> {
365
- const videoSession = this.sessions.get(sessionId);
366
- if (!videoSession?.isActive || !session.page) {
367
- return false;
368
- }
369
-
370
- try {
371
- const success = await session.page.evaluate((ans) => {
372
- return (window as any).__webCodecsPeer?.handleAnswer(ans);
373
- }, answer);
374
-
375
- if (success) {
376
- videoSession.clientConnected = true;
377
-
378
- // Process pending ICE candidates
379
- for (const candidate of videoSession.pendingCandidates) {
380
- await this.addIceCandidate(sessionId, session, candidate);
381
- }
382
- videoSession.pendingCandidates = [];
383
- }
384
-
385
- return success;
386
- } catch (error) {
387
- debug.error('webcodecs', `Handle answer error:`, error);
388
- return false;
389
- }
390
- }
391
-
392
- /**
393
- * Add ICE candidate from client
394
- */
395
- async addIceCandidate(
396
- sessionId: string,
397
- session: BrowserTab,
398
- candidate: RTCIceCandidateInit
399
- ): Promise<boolean> {
400
- const videoSession = this.sessions.get(sessionId);
401
- if (!videoSession?.isActive || !session.page) {
402
- return false;
403
- }
404
-
405
- // Queue if not connected yet
406
- if (!videoSession.clientConnected) {
407
- videoSession.pendingCandidates.push(candidate);
408
- return true;
409
- }
410
-
411
- try {
412
- return await session.page.evaluate((cand) => {
413
- return (window as any).__webCodecsPeer?.addIceCandidate(cand);
414
- }, candidate);
415
- } catch (error) {
416
- debug.error('webcodecs', `Add ICE candidate error:`, error);
417
- return false;
418
- }
419
- }
420
-
421
- /**
422
- * Update viewport and scale without reconnection (hot-swap)
423
- */
424
- async updateViewport(sessionId: string, session: BrowserTab, width: number, height: number, newScale: number): Promise<boolean> {
425
- const videoSession = this.sessions.get(sessionId);
426
- if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
427
- debug.warn('webcodecs', `Cannot update viewport: session not active`);
428
- return false;
429
- }
430
-
431
- try {
432
- const page = session.page;
433
- const config = DEFAULT_STREAMING_CONFIG;
434
-
435
- // Calculate scaled dimensions
436
- const scaledWidth = Math.round(width * newScale);
437
- const scaledHeight = Math.round(height * newScale);
438
-
439
- debug.log('webcodecs', `🔄 Hot-swapping viewport to ${width}x${height} (scaled: ${scaledWidth}x${scaledHeight}, scale: ${newScale})`);
440
-
441
- // Step 1: Update viewport via CDP (without page reload)
442
- await page.setViewport({ width, height });
443
-
444
- // Step 2: Reconfigure VideoEncoder with scaled dimensions
445
- const reconfigured = await page.evaluate((dimensions) => {
446
- const peer = (window as any).__webCodecsPeer;
447
- if (!peer || !peer.reconfigureEncoder) return false;
448
- return peer.reconfigureEncoder(dimensions.width, dimensions.height);
449
- }, { width: scaledWidth, height: scaledHeight });
450
-
451
- if (!reconfigured) {
452
- debug.error('webcodecs', `Failed to reconfigure encoder`);
453
- return false;
454
- }
455
-
456
- // Step 3: Restart CDP screencast with new dimensions
457
- const cdp = (session as any).__webCodecsCdp;
458
- if (cdp) {
459
- await cdp.send('Page.stopScreencast').catch(() => {});
460
- await cdp.send('Page.startScreencast', {
461
- format: 'jpeg',
462
- quality: config.video.screenshotQuality,
463
- maxWidth: scaledWidth,
464
- maxHeight: scaledHeight,
465
- everyNthFrame: 1
466
- });
467
-
468
- debug.log('webcodecs', `✅ Viewport hot-swapped successfully to ${width}x${height} (scale: ${newScale})`);
469
- }
470
-
471
- return true;
472
- } catch (error) {
473
- debug.error('webcodecs', `Failed to update viewport:`, error);
474
- return false;
475
- }
476
- }
477
-
478
- /**
479
- * Update scale without reconnection (hot-swap)
480
- */
481
- async updateScale(sessionId: string, session: BrowserTab, newScale: number): Promise<boolean> {
482
- const videoSession = this.sessions.get(sessionId);
483
- if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
484
- debug.warn('webcodecs', `Cannot update scale: session not active`);
485
- return false;
486
- }
487
-
488
- try {
489
- const page = session.page;
490
- const viewport = page.viewport()!;
491
- const config = DEFAULT_STREAMING_CONFIG;
492
-
493
- // Calculate new scaled dimensions
494
- const scaledWidth = Math.round(viewport.width * newScale);
495
- const scaledHeight = Math.round(viewport.height * newScale);
496
-
497
- debug.log('webcodecs', `🔄 Hot-swapping resolution to ${scaledWidth}x${scaledHeight} (scale: ${newScale})`);
498
-
499
- // Step 1: Reconfigure VideoEncoder in headless browser
500
- const reconfigured = await page.evaluate((dimensions) => {
501
- const peer = (window as any).__webCodecsPeer;
502
- if (!peer || !peer.reconfigureEncoder) return false;
503
- return peer.reconfigureEncoder(dimensions.width, dimensions.height);
504
- }, { width: scaledWidth, height: scaledHeight });
505
-
506
- if (!reconfigured) {
507
- debug.error('webcodecs', `Failed to reconfigure encoder`);
508
- return false;
509
- }
510
-
511
- // Step 2: Restart CDP screencast with new dimensions
512
- const cdp = (session as any).__webCodecsCdp;
513
- if (cdp) {
514
- // Stop current screencast
515
- await cdp.send('Page.stopScreencast').catch(() => {});
516
-
517
- // Start with new dimensions
518
- await cdp.send('Page.startScreencast', {
519
- format: 'jpeg',
520
- quality: config.video.screenshotQuality,
521
- maxWidth: scaledWidth,
522
- maxHeight: scaledHeight,
523
- everyNthFrame: 1
524
- });
525
-
526
- debug.log('webcodecs', `✅ Scale hot-swapped successfully to ${scaledWidth}x${scaledHeight}`);
527
- }
528
-
529
- return true;
530
- } catch (error) {
531
- debug.error('webcodecs', `Failed to update scale:`, error);
532
- return false;
533
- }
534
- }
535
-
536
- /**
537
- * Handle navigation - re-inject peer script and restart CDP screencast
538
- * Called after page navigation to restore video streaming without full reconnection
539
- */
540
- async handleNavigation(sessionId: string, session: BrowserTab): Promise<boolean> {
541
- const videoSession = this.sessions.get(sessionId);
542
- if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
543
- debug.warn('webcodecs', `Cannot handle navigation: session not active`);
544
- return false;
545
- }
546
-
547
- try {
548
- const page = session.page;
549
- const viewport = page.viewport()!;
550
- const config = DEFAULT_STREAMING_CONFIG;
551
- const scale = session.scale || 1;
552
-
553
- debug.log('webcodecs', `🔄 Handling navigation for ${sessionId} - re-injecting peer script and restarting screencast`);
554
-
555
- // Calculate scaled dimensions
556
- const scaledWidth = Math.round(viewport.width * scale);
557
- const scaledHeight = Math.round(viewport.height * scale);
558
-
559
- const videoConfig: StreamingConfig['video'] = {
560
- ...config.video,
561
- width: scaledWidth,
562
- height: scaledHeight
563
- };
564
-
565
- // Re-inject video encoder script to new page context
566
- // (evaluateOnNewDocument doesn't run for the current navigation, only future ones)
567
- await page.evaluate((cfg) => {
568
- (window as any).__videoEncoderConfig = cfg;
569
- }, videoConfig);
570
-
571
- await page.evaluate(videoEncoderScript, videoConfig);
572
-
573
- // Verify peer was re-created
574
- const peerExists = await page.evaluate(() => {
575
- return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
576
- });
577
-
578
- if (!peerExists) {
579
- debug.error('webcodecs', `Peer script re-injection failed - peer not available`);
580
- return false;
581
- }
582
-
583
- // Start video streaming on new page
584
- const started = await page.evaluate(() => {
585
- return (window as any).__webCodecsPeer?.startStreaming();
586
- });
587
-
588
- if (!started) {
589
- debug.error('webcodecs', `Failed to start streaming on new page`);
590
- return false;
591
- }
592
-
593
- // Restart CDP screencast
594
- const cdp = (session as any).__webCodecsCdp;
595
- if (cdp) {
596
- // Stop current screencast
597
- await cdp.send('Page.stopScreencast').catch(() => {});
598
-
599
- // Start with current dimensions
600
- await cdp.send('Page.startScreencast', {
601
- format: 'jpeg',
602
- quality: config.video.screenshotQuality,
603
- maxWidth: scaledWidth,
604
- maxHeight: scaledHeight,
605
- everyNthFrame: 1
606
- });
607
-
608
- debug.log('webcodecs', `✅ Navigation handled - screencast restarted at ${scaledWidth}x${scaledHeight}`);
609
- }
610
-
611
- // Emit event to notify frontend that streaming is ready
612
- this.emit('navigation-streaming-ready', { sessionId });
613
-
614
- return true;
615
- } catch (error) {
616
- debug.error('webcodecs', `Failed to handle navigation:`, error);
617
- return false;
618
- }
619
- }
620
-
621
- /**
622
- * Stop video streaming
623
- */
624
- async stopStreaming(sessionId: string, session?: BrowserTab): Promise<void> {
625
- const videoSession = this.sessions.get(sessionId);
626
- if (!videoSession) return;
627
-
628
- debug.log('webcodecs', `Stopping streaming for ${sessionId}`);
629
-
630
- videoSession.isActive = false;
631
-
632
- if (session?.page && !session.page.isClosed()) {
633
- try {
634
- // Stop peer
635
- await session.page.evaluate(() => {
636
- (window as any).__webCodecsPeer?.stopStreaming();
637
- });
638
-
639
- // Stop CDP screencast
640
- const cdp = (session as any).__webCodecsCdp;
641
- if (cdp) {
642
- await cdp.send('Page.stopScreencast').catch(() => {});
643
- await cdp.detach().catch(() => {});
644
- (session as any).__webCodecsCdp = null;
645
- }
646
- } catch (error) {
647
- debug.warn('webcodecs', `Error during cleanup: ${error}`);
648
- }
649
- }
650
-
651
- this.sessions.delete(sessionId);
652
- }
653
-
654
- /**
655
- * Check if streaming is active
656
- */
657
- isStreaming(sessionId: string): boolean {
658
- return this.sessions.get(sessionId)?.isActive ?? false;
659
- }
660
-
661
- /**
662
- * Get session stats
663
- */
664
- async getStats(sessionId: string, session: BrowserTab): Promise<VideoStreamSession['stats'] | null> {
665
- const videoSession = this.sessions.get(sessionId);
666
- if (!videoSession?.isActive || !session.page) {
667
- return videoSession?.stats || null;
668
- }
669
-
670
- try {
671
- const stats = await session.page.evaluate(() => {
672
- return (window as any).__webCodecsPeer?.getStats();
673
- });
674
-
675
- if (stats) {
676
- videoSession.stats = stats;
677
- }
678
- return videoSession.stats;
679
- } catch (error) {
680
- return videoSession.stats;
681
- }
682
- }
683
-
684
- /**
685
- * Cleanup all sessions
686
- */
687
- async cleanup(): Promise<void> {
688
- debug.log('webcodecs', 'Cleaning up all sessions');
689
-
690
- const sessionIds = Array.from(this.sessions.keys());
691
- await Promise.all(sessionIds.map((id) => this.stopStreaming(id)));
692
-
693
- this.sessions.clear();
694
- }
695
- }
1
+ /**
2
+ * Browser Video Capture Handler
3
+ *
4
+ * Handles WebCodecs-based video streaming with WebRTC DataChannel transport.
5
+ *
6
+ * Video Architecture:
7
+ * 1. Puppeteer CDP captures JPEG frames via Page.screencastFrame
8
+ * 2. Decode JPEG to ImageBitmap in browser
9
+ * 3. Encode with VideoEncoder (VP8) in browser
10
+ * 4. Send encoded chunks via RTCDataChannel
11
+ *
12
+ * Audio Architecture:
13
+ * 1. AudioContext interception (handled by BrowserAudioCapture)
14
+ * 2. Audio encoded with AudioEncoder (Opus) in headless browser
15
+ * 3. Encoded chunks sent via sendAudioChunk() to same DataChannel
16
+ *
17
+ * Client:
18
+ * - Receives video + audio chunks via DataChannel
19
+ * - Decodes with VideoDecoder + AudioDecoder
20
+ * - Renders video to canvas, plays audio with proper scheduling
21
+ *
22
+ * Benefits vs Canvas + WebRTC:
23
+ * - Lower bandwidth (500-800 Kbps vs 1 Mbps)
24
+ * - More control over codec selection
25
+ * - Skip canvas rendering overhead
26
+ * - DataChannel = lower latency than video track
27
+ */
28
+
29
+ import { EventEmitter } from 'events';
30
+ import type { Page } from 'puppeteer';
31
+ import type { BrowserTab, StreamingConfig } from './types';
32
+ import { DEFAULT_STREAMING_CONFIG } from './types';
33
+ import { videoEncoderScript } from './scripts/video-stream';
34
+ import { debug } from '$shared/utils/logger';
35
+
36
+ interface VideoStreamSession {
37
+ sessionId: string;
38
+ isActive: boolean;
39
+ clientConnected: boolean;
40
+ headlessReady: boolean;
41
+ pendingCandidates: RTCIceCandidateInit[];
42
+ scriptInjected: boolean; // Track if persistent script was injected
43
+ stats: {
44
+ videoBytesSent: number;
45
+ audioBytesSent: number;
46
+ videoFramesEncoded: number;
47
+ audioFramesEncoded: number;
48
+ connectionState: string;
49
+ };
50
+ }
51
+
52
+ export class BrowserVideoCapture extends EventEmitter {
53
+ private sessions = new Map<string, VideoStreamSession>();
54
+
55
+ constructor() {
56
+ super();
57
+ }
58
+
59
+ /**
60
+ * Start video streaming for a session
61
+ */
62
+ async startStreaming(
63
+ sessionId: string,
64
+ session: BrowserTab,
65
+ isValidSession: () => boolean
66
+ ): Promise<boolean> {
67
+ debug.log('webcodecs', `Starting streaming for session ${sessionId}`);
68
+
69
+ // If session exists, stop it first
70
+ if (this.sessions.has(sessionId)) {
71
+ debug.log('webcodecs', `Session ${sessionId} exists, stopping for restart`);
72
+ await this.stopStreaming(sessionId, session);
73
+ await new Promise(resolve => setTimeout(resolve, 100));
74
+ }
75
+
76
+ if (!session.page || session.page.isClosed()) {
77
+ debug.error('webcodecs', `Cannot start: page is closed`);
78
+ return false;
79
+ }
80
+
81
+ try {
82
+ const page = session.page;
83
+ const viewport = page.viewport()!;
84
+ const config = DEFAULT_STREAMING_CONFIG;
85
+
86
+ // Get scale from session (default to 1 if not set)
87
+ const scale = session.scale || 1;
88
+ debug.log('webcodecs', `Using scale: ${scale} for session ${sessionId}`);
89
+
90
+ // Get or create session tracking
91
+ let videoSession = this.sessions.get(sessionId);
92
+ const isRestart = !!videoSession;
93
+
94
+ if (!videoSession) {
95
+ videoSession = {
96
+ sessionId,
97
+ isActive: false,
98
+ clientConnected: false,
99
+ headlessReady: false,
100
+ pendingCandidates: [],
101
+ scriptInjected: false,
102
+ stats: {
103
+ videoBytesSent: 0,
104
+ audioBytesSent: 0,
105
+ videoFramesEncoded: 0,
106
+ audioFramesEncoded: 0,
107
+ connectionState: 'new'
108
+ }
109
+ };
110
+ this.sessions.set(sessionId, videoSession);
111
+ }
112
+
113
+ // Check if bindings exist
114
+ const bindingsExist = await page.evaluate(() => {
115
+ return typeof (window as any).__sendIceCandidate === 'function';
116
+ });
117
+
118
+ // Expose signaling functions (persists across navigations)
119
+ if (!bindingsExist) {
120
+ await page.exposeFunction('__sendIceCandidate', (candidate: RTCIceCandidateInit) => {
121
+ const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
122
+ if (!activeSession) return;
123
+ this.emit('ice-candidate', { sessionId: activeSession.sessionId, candidate, from: 'headless' });
124
+ });
125
+
126
+ await page.exposeFunction('__sendConnectionState', (state: string) => {
127
+ const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
128
+ if (activeSession) {
129
+ activeSession.stats.connectionState = state;
130
+ this.emit('connection-state', { sessionId: activeSession.sessionId, state });
131
+ }
132
+ });
133
+
134
+ await page.exposeFunction('__sendCursorChange', (cursor: string) => {
135
+ const activeSession = Array.from(this.sessions.values()).find(s => s.isActive);
136
+ if (activeSession) {
137
+ this.emit('cursor-change', { sessionId: activeSession.sessionId, cursor });
138
+ }
139
+ });
140
+ }
141
+
142
+ // Calculate scaled dimensions
143
+ const scaledWidth = Math.round(viewport.width * scale);
144
+ const scaledHeight = Math.round(viewport.height * scale);
145
+
146
+ const videoConfig: StreamingConfig['video'] = {
147
+ ...config.video,
148
+ width: scaledWidth,
149
+ height: scaledHeight
150
+ };
151
+
152
+ // Store config globally for persistent script access
153
+ await page.evaluate((cfg) => {
154
+ (window as any).__videoEncoderConfig = cfg;
155
+ }, videoConfig);
156
+
157
+ // Inject persistent video encoder script (survives navigation)
158
+ // Only inject once per page instance
159
+ if (!videoSession.scriptInjected) {
160
+ await page.evaluateOnNewDocument(videoEncoderScript, videoConfig);
161
+ videoSession.scriptInjected = true;
162
+ debug.log('webcodecs', `Persistent video encoder script injected for ${sessionId}`);
163
+ }
164
+
165
+ // Also inject immediately for current page context
166
+ // (evaluateOnNewDocument only runs on NEXT navigation)
167
+ await page.evaluate(videoEncoderScript, videoConfig);
168
+
169
+ // Verify peer was created
170
+ const peerExists = await page.evaluate(() => {
171
+ return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
172
+ });
173
+
174
+ if (!peerExists) {
175
+ debug.error('webcodecs', `Peer script injected but __webCodecsPeer not available`);
176
+ this.sessions.delete(sessionId);
177
+ return false;
178
+ }
179
+
180
+ videoSession.isActive = true;
181
+
182
+ // Wait for page to be fully loaded
183
+ try {
184
+ const loadState = await page.evaluate(() => document.readyState);
185
+ if (loadState !== 'complete') {
186
+ debug.log('webcodecs', `Waiting for page load...`);
187
+ await page.waitForFunction(() => document.readyState === 'complete', { timeout: 60000 });
188
+ }
189
+ } catch (loadError) {
190
+ debug.warn('webcodecs', 'Page load wait timed out, proceeding anyway');
191
+ }
192
+
193
+ // Start video streaming
194
+ const started = await page.evaluate(() => {
195
+ return (window as any).__webCodecsPeer?.startStreaming();
196
+ });
197
+
198
+ if (!started) {
199
+ debug.error('webcodecs', `startStreaming returned false`);
200
+ this.sessions.delete(sessionId);
201
+ return false;
202
+ }
203
+
204
+ // Initialize and start audio encoder (from AudioContext interception)
205
+ const audioEncoderAvailable = await page.evaluate(() => {
206
+ return typeof (window as any).__audioEncoder?.init === 'function';
207
+ });
208
+
209
+ if (audioEncoderAvailable) {
210
+ debug.log('webcodecs', 'Initializing audio encoder from AudioContext interception...');
211
+
212
+ const audioInitialized = await page.evaluate(async () => {
213
+ const encoder = (window as any).__audioEncoder;
214
+ if (!encoder) return false;
215
+
216
+ const initiated = await encoder.init();
217
+ if (initiated) {
218
+ return encoder.start();
219
+ }
220
+ return false;
221
+ });
222
+
223
+ if (audioInitialized) {
224
+ debug.log('webcodecs', 'Audio encoder initialized and started');
225
+ } else {
226
+ debug.warn('webcodecs', 'Audio encoder initialization failed, continuing with video only');
227
+ }
228
+ } else {
229
+ debug.warn('webcodecs', 'Audio encoder not available (AudioEncoder API may not be supported)');
230
+ }
231
+
232
+ videoSession.headlessReady = true;
233
+
234
+ // Setup CDP screencast to feed frames to encoder
235
+ await this.setupFrameFeeder(sessionId, session, config, isValidSession);
236
+
237
+ debug.log('webcodecs', `Streaming started for ${sessionId}`);
238
+ return true;
239
+ } catch (error) {
240
+ debug.error('webcodecs', `Failed to start streaming:`, error);
241
+ this.sessions.delete(sessionId);
242
+ throw error;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Setup CDP screencast to feed JPEG frames to VideoEncoder
248
+ */
249
+ private async setupFrameFeeder(
250
+ sessionId: string,
251
+ session: BrowserTab,
252
+ config: StreamingConfig,
253
+ isValidSession: () => boolean
254
+ ): Promise<void> {
255
+ const page = session.page;
256
+ const viewport = page.viewport()!;
257
+
258
+ // Get scale from session (default to 1 if not set)
259
+ const scale = session.scale || 1;
260
+
261
+ const cdp = await page.createCDPSession();
262
+
263
+ let cdpFrameCount = 0;
264
+
265
+ cdp.on('Page.screencastFrame', async (event: any) => {
266
+ cdpFrameCount++;
267
+
268
+ const videoSession = this.sessions.get(sessionId);
269
+ if (!videoSession?.isActive || session.isDestroyed) {
270
+ cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {});
271
+ return;
272
+ }
273
+
274
+ if (!isValidSession()) {
275
+ this.stopStreaming(sessionId);
276
+ return;
277
+ }
278
+
279
+ // ACK immediately
280
+ cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {});
281
+
282
+ // Send frame to encoder
283
+ page.evaluate((frameData) => {
284
+ const peer = (window as any).__webCodecsPeer;
285
+ if (!peer) return false;
286
+ peer.encodeFrame(frameData);
287
+ return true;
288
+ }, event.data).catch((err) => {
289
+ if (cdpFrameCount <= 5) {
290
+ debug.warn('webcodecs', `Frame delivery error (frame ${cdpFrameCount}):`, err.message);
291
+ }
292
+ });
293
+ });
294
+
295
+ // Start screencast with scaled dimensions
296
+ const scaledWidth = Math.round(viewport.width * scale);
297
+ const scaledHeight = Math.round(viewport.height * scale);
298
+
299
+ await cdp.send('Page.startScreencast', {
300
+ format: 'jpeg',
301
+ quality: config.video.screenshotQuality,
302
+ maxWidth: scaledWidth,
303
+ maxHeight: scaledHeight,
304
+ everyNthFrame: 1
305
+ });
306
+
307
+ debug.log('webcodecs', `CDP screencast started with scaled dimensions: ${scaledWidth}x${scaledHeight} (scale: ${scale})`);
308
+ (session as any).__webCodecsCdp = cdp;
309
+ }
310
+
311
+ /**
312
+ * Create offer from headless browser
313
+ */
314
+ async createOffer(sessionId: string, session: BrowserTab): Promise<RTCSessionDescriptionInit | null> {
315
+ const videoSession = this.sessions.get(sessionId);
316
+ if (!videoSession?.isActive || !session.page) {
317
+ return null;
318
+ }
319
+
320
+ const maxRetries = 5;
321
+ const retryDelay = 100;
322
+
323
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
324
+ try {
325
+ const peerReady = await session.page.evaluate(() => {
326
+ return typeof (window as any).__webCodecsPeer?.createOffer === 'function';
327
+ });
328
+
329
+ if (!peerReady) {
330
+ if (attempt < maxRetries - 1) {
331
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
332
+ continue;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ const offer = await session.page.evaluate(() => {
338
+ return (window as any).__webCodecsPeer?.createOffer();
339
+ });
340
+
341
+ if (offer) return offer;
342
+
343
+ if (attempt < maxRetries - 1) {
344
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
345
+ }
346
+ } catch (error) {
347
+ debug.error('webcodecs', `Create offer error (attempt ${attempt + 1}):`, error);
348
+ if (attempt < maxRetries - 1) {
349
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
350
+ }
351
+ }
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * Handle answer from client
359
+ */
360
+ async handleAnswer(
361
+ sessionId: string,
362
+ session: BrowserTab,
363
+ answer: RTCSessionDescriptionInit
364
+ ): Promise<boolean> {
365
+ const videoSession = this.sessions.get(sessionId);
366
+ if (!videoSession?.isActive || !session.page) {
367
+ return false;
368
+ }
369
+
370
+ try {
371
+ const success = await session.page.evaluate((ans) => {
372
+ return (window as any).__webCodecsPeer?.handleAnswer(ans);
373
+ }, answer);
374
+
375
+ if (success) {
376
+ videoSession.clientConnected = true;
377
+
378
+ // Process pending ICE candidates
379
+ for (const candidate of videoSession.pendingCandidates) {
380
+ await this.addIceCandidate(sessionId, session, candidate);
381
+ }
382
+ videoSession.pendingCandidates = [];
383
+ }
384
+
385
+ return success;
386
+ } catch (error) {
387
+ debug.error('webcodecs', `Handle answer error:`, error);
388
+ return false;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Add ICE candidate from client
394
+ */
395
+ async addIceCandidate(
396
+ sessionId: string,
397
+ session: BrowserTab,
398
+ candidate: RTCIceCandidateInit
399
+ ): Promise<boolean> {
400
+ const videoSession = this.sessions.get(sessionId);
401
+ if (!videoSession?.isActive || !session.page) {
402
+ return false;
403
+ }
404
+
405
+ // Queue if not connected yet
406
+ if (!videoSession.clientConnected) {
407
+ videoSession.pendingCandidates.push(candidate);
408
+ return true;
409
+ }
410
+
411
+ try {
412
+ return await session.page.evaluate((cand) => {
413
+ return (window as any).__webCodecsPeer?.addIceCandidate(cand);
414
+ }, candidate);
415
+ } catch (error) {
416
+ debug.error('webcodecs', `Add ICE candidate error:`, error);
417
+ return false;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Update viewport and scale without reconnection (hot-swap)
423
+ */
424
+ async updateViewport(sessionId: string, session: BrowserTab, width: number, height: number, newScale: number): Promise<boolean> {
425
+ const videoSession = this.sessions.get(sessionId);
426
+ if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
427
+ debug.warn('webcodecs', `Cannot update viewport: session not active`);
428
+ return false;
429
+ }
430
+
431
+ try {
432
+ const page = session.page;
433
+ const config = DEFAULT_STREAMING_CONFIG;
434
+
435
+ // Calculate scaled dimensions
436
+ const scaledWidth = Math.round(width * newScale);
437
+ const scaledHeight = Math.round(height * newScale);
438
+
439
+ debug.log('webcodecs', `🔄 Hot-swapping viewport to ${width}x${height} (scaled: ${scaledWidth}x${scaledHeight}, scale: ${newScale})`);
440
+
441
+ // Step 1: Update viewport via CDP (without page reload)
442
+ await page.setViewport({ width, height });
443
+
444
+ // Step 2: Reconfigure VideoEncoder with scaled dimensions
445
+ const reconfigured = await page.evaluate((dimensions) => {
446
+ const peer = (window as any).__webCodecsPeer;
447
+ if (!peer || !peer.reconfigureEncoder) return false;
448
+ return peer.reconfigureEncoder(dimensions.width, dimensions.height);
449
+ }, { width: scaledWidth, height: scaledHeight });
450
+
451
+ if (!reconfigured) {
452
+ debug.error('webcodecs', `Failed to reconfigure encoder`);
453
+ return false;
454
+ }
455
+
456
+ // Step 3: Restart CDP screencast with new dimensions
457
+ const cdp = (session as any).__webCodecsCdp;
458
+ if (cdp) {
459
+ await cdp.send('Page.stopScreencast').catch(() => {});
460
+ await cdp.send('Page.startScreencast', {
461
+ format: 'jpeg',
462
+ quality: config.video.screenshotQuality,
463
+ maxWidth: scaledWidth,
464
+ maxHeight: scaledHeight,
465
+ everyNthFrame: 1
466
+ });
467
+
468
+ debug.log('webcodecs', `✅ Viewport hot-swapped successfully to ${width}x${height} (scale: ${newScale})`);
469
+ }
470
+
471
+ return true;
472
+ } catch (error) {
473
+ debug.error('webcodecs', `Failed to update viewport:`, error);
474
+ return false;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Update scale without reconnection (hot-swap)
480
+ */
481
+ async updateScale(sessionId: string, session: BrowserTab, newScale: number): Promise<boolean> {
482
+ const videoSession = this.sessions.get(sessionId);
483
+ if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
484
+ debug.warn('webcodecs', `Cannot update scale: session not active`);
485
+ return false;
486
+ }
487
+
488
+ try {
489
+ const page = session.page;
490
+ const viewport = page.viewport()!;
491
+ const config = DEFAULT_STREAMING_CONFIG;
492
+
493
+ // Calculate new scaled dimensions
494
+ const scaledWidth = Math.round(viewport.width * newScale);
495
+ const scaledHeight = Math.round(viewport.height * newScale);
496
+
497
+ debug.log('webcodecs', `🔄 Hot-swapping resolution to ${scaledWidth}x${scaledHeight} (scale: ${newScale})`);
498
+
499
+ // Step 1: Reconfigure VideoEncoder in headless browser
500
+ const reconfigured = await page.evaluate((dimensions) => {
501
+ const peer = (window as any).__webCodecsPeer;
502
+ if (!peer || !peer.reconfigureEncoder) return false;
503
+ return peer.reconfigureEncoder(dimensions.width, dimensions.height);
504
+ }, { width: scaledWidth, height: scaledHeight });
505
+
506
+ if (!reconfigured) {
507
+ debug.error('webcodecs', `Failed to reconfigure encoder`);
508
+ return false;
509
+ }
510
+
511
+ // Step 2: Restart CDP screencast with new dimensions
512
+ const cdp = (session as any).__webCodecsCdp;
513
+ if (cdp) {
514
+ // Stop current screencast
515
+ await cdp.send('Page.stopScreencast').catch(() => {});
516
+
517
+ // Start with new dimensions
518
+ await cdp.send('Page.startScreencast', {
519
+ format: 'jpeg',
520
+ quality: config.video.screenshotQuality,
521
+ maxWidth: scaledWidth,
522
+ maxHeight: scaledHeight,
523
+ everyNthFrame: 1
524
+ });
525
+
526
+ debug.log('webcodecs', `✅ Scale hot-swapped successfully to ${scaledWidth}x${scaledHeight}`);
527
+ }
528
+
529
+ return true;
530
+ } catch (error) {
531
+ debug.error('webcodecs', `Failed to update scale:`, error);
532
+ return false;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Handle navigation - re-inject peer script and restart CDP screencast
538
+ * Called after page navigation to restore video streaming without full reconnection
539
+ */
540
+ async handleNavigation(sessionId: string, session: BrowserTab): Promise<boolean> {
541
+ const videoSession = this.sessions.get(sessionId);
542
+ if (!videoSession?.isActive || !session.page || session.page.isClosed()) {
543
+ debug.warn('webcodecs', `Cannot handle navigation: session not active`);
544
+ return false;
545
+ }
546
+
547
+ try {
548
+ const page = session.page;
549
+ const viewport = page.viewport()!;
550
+ const config = DEFAULT_STREAMING_CONFIG;
551
+ const scale = session.scale || 1;
552
+
553
+ debug.log('webcodecs', `🔄 Handling navigation for ${sessionId} - re-injecting peer script and restarting screencast`);
554
+
555
+ // Calculate scaled dimensions
556
+ const scaledWidth = Math.round(viewport.width * scale);
557
+ const scaledHeight = Math.round(viewport.height * scale);
558
+
559
+ const videoConfig: StreamingConfig['video'] = {
560
+ ...config.video,
561
+ width: scaledWidth,
562
+ height: scaledHeight
563
+ };
564
+
565
+ // Re-inject video encoder script to new page context
566
+ // (evaluateOnNewDocument doesn't run for the current navigation, only future ones)
567
+ await page.evaluate((cfg) => {
568
+ (window as any).__videoEncoderConfig = cfg;
569
+ }, videoConfig);
570
+
571
+ await page.evaluate(videoEncoderScript, videoConfig);
572
+
573
+ // Verify peer was re-created
574
+ const peerExists = await page.evaluate(() => {
575
+ return typeof (window as any).__webCodecsPeer?.startStreaming === 'function';
576
+ });
577
+
578
+ if (!peerExists) {
579
+ debug.error('webcodecs', `Peer script re-injection failed - peer not available`);
580
+ return false;
581
+ }
582
+
583
+ // Start video streaming on new page
584
+ const started = await page.evaluate(() => {
585
+ return (window as any).__webCodecsPeer?.startStreaming();
586
+ });
587
+
588
+ if (!started) {
589
+ debug.error('webcodecs', `Failed to start streaming on new page`);
590
+ return false;
591
+ }
592
+
593
+ // Restart CDP screencast
594
+ const cdp = (session as any).__webCodecsCdp;
595
+ if (cdp) {
596
+ // Stop current screencast
597
+ await cdp.send('Page.stopScreencast').catch(() => {});
598
+
599
+ // Start with current dimensions
600
+ await cdp.send('Page.startScreencast', {
601
+ format: 'jpeg',
602
+ quality: config.video.screenshotQuality,
603
+ maxWidth: scaledWidth,
604
+ maxHeight: scaledHeight,
605
+ everyNthFrame: 1
606
+ });
607
+
608
+ debug.log('webcodecs', `✅ Navigation handled - screencast restarted at ${scaledWidth}x${scaledHeight}`);
609
+ }
610
+
611
+ // Emit event to notify frontend that streaming is ready
612
+ this.emit('navigation-streaming-ready', { sessionId });
613
+
614
+ return true;
615
+ } catch (error) {
616
+ debug.error('webcodecs', `Failed to handle navigation:`, error);
617
+ return false;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Stop video streaming
623
+ */
624
+ async stopStreaming(sessionId: string, session?: BrowserTab): Promise<void> {
625
+ const videoSession = this.sessions.get(sessionId);
626
+ if (!videoSession) return;
627
+
628
+ debug.log('webcodecs', `Stopping streaming for ${sessionId}`);
629
+
630
+ videoSession.isActive = false;
631
+
632
+ if (session?.page && !session.page.isClosed()) {
633
+ try {
634
+ // Stop peer
635
+ await session.page.evaluate(() => {
636
+ (window as any).__webCodecsPeer?.stopStreaming();
637
+ });
638
+
639
+ // Stop CDP screencast
640
+ const cdp = (session as any).__webCodecsCdp;
641
+ if (cdp) {
642
+ await cdp.send('Page.stopScreencast').catch(() => {});
643
+ await cdp.detach().catch(() => {});
644
+ (session as any).__webCodecsCdp = null;
645
+ }
646
+ } catch (error) {
647
+ debug.warn('webcodecs', `Error during cleanup: ${error}`);
648
+ }
649
+ }
650
+
651
+ this.sessions.delete(sessionId);
652
+ }
653
+
654
+ /**
655
+ * Check if streaming is active
656
+ */
657
+ isStreaming(sessionId: string): boolean {
658
+ return this.sessions.get(sessionId)?.isActive ?? false;
659
+ }
660
+
661
+ /**
662
+ * Get session stats
663
+ */
664
+ async getStats(sessionId: string, session: BrowserTab): Promise<VideoStreamSession['stats'] | null> {
665
+ const videoSession = this.sessions.get(sessionId);
666
+ if (!videoSession?.isActive || !session.page) {
667
+ return videoSession?.stats || null;
668
+ }
669
+
670
+ try {
671
+ const stats = await session.page.evaluate(() => {
672
+ return (window as any).__webCodecsPeer?.getStats();
673
+ });
674
+
675
+ if (stats) {
676
+ videoSession.stats = stats;
677
+ }
678
+ return videoSession.stats;
679
+ } catch (error) {
680
+ return videoSession.stats;
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Cleanup all sessions
686
+ */
687
+ async cleanup(): Promise<void> {
688
+ debug.log('webcodecs', 'Cleaning up all sessions');
689
+
690
+ const sessionIds = Array.from(this.sessions.keys());
691
+ await Promise.all(sessionIds.map((id) => this.stopStreaming(id)));
692
+
693
+ this.sessions.clear();
694
+ }
695
+ }