WebVM.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. <script>
  2. import { onMount, tick } from 'svelte';
  3. import { get } from 'svelte/store';
  4. import Nav from 'labs/packages/global-navbar/src/Nav.svelte';
  5. import SideBar from '$lib/SideBar.svelte';
  6. import '$lib/global.css';
  7. import '@xterm/xterm/css/xterm.css'
  8. import '@fortawesome/fontawesome-free/css/all.min.css'
  9. import { networkInterface, startLogin } from '$lib/network.js'
  10. import { cpuActivity, diskActivity, cpuPercentage, diskLatency } from '$lib/activities.js'
  11. import { introMessage, errorMessage, unexpectedErrorMessage } from '$lib/messages.js'
  12. import { displayConfig, handleToolImpl } from '$lib/anthropic.js'
  13. import { tryPlausible } from '$lib/plausible.js'
  14. export let configObj = null;
  15. export let processCallback = null;
  16. export let cacheId = null;
  17. export let cpuActivityEvents = [];
  18. export let diskLatencies = [];
  19. export let activityEventsInterval = 0;
  20. var term = null;
  21. var cx = null;
  22. var fitAddon = null;
  23. var cxReadFunc = null;
  24. var blockCache = null;
  25. var processCount = 0;
  26. var curVT = 0;
  27. var sideBarPinned = false;
  28. function writeData(buf, vt)
  29. {
  30. if(vt != 1)
  31. return;
  32. term.write(new Uint8Array(buf));
  33. }
  34. function readData(str)
  35. {
  36. if(cxReadFunc == null)
  37. return;
  38. for(var i=0;i<str.length;i++)
  39. cxReadFunc(str.charCodeAt(i));
  40. }
  41. function printMessage(msg)
  42. {
  43. for(var i=0;i<msg.length;i++)
  44. term.write(msg[i] + "\n");
  45. }
  46. function expireEvents(list, curTime, limitTime)
  47. {
  48. while(list.length > 1)
  49. {
  50. if(list[1].t < limitTime)
  51. {
  52. list.shift();
  53. }
  54. else
  55. {
  56. break;
  57. }
  58. }
  59. }
  60. function cleanupEvents()
  61. {
  62. var curTime = Date.now();
  63. var limitTime = curTime - 10000;
  64. expireEvents(cpuActivityEvents, curTime, limitTime);
  65. computeCpuActivity(curTime, limitTime);
  66. if(cpuActivityEvents.length == 0)
  67. {
  68. clearInterval(activityEventsInterval);
  69. activityEventsInterval = 0;
  70. }
  71. }
  72. function computeCpuActivity(curTime, limitTime)
  73. {
  74. var totalActiveTime = 0;
  75. var lastActiveTime = limitTime;
  76. var lastWasActive = false;
  77. for(var i=0;i<cpuActivityEvents.length;i++)
  78. {
  79. var e = cpuActivityEvents[i];
  80. // NOTE: The first event could be before the limit,
  81. // we need at least one event to correctly mark
  82. // active time when there is long time under load
  83. var eTime = e.t;
  84. if(eTime < limitTime)
  85. eTime = limitTime;
  86. if(e.state == "ready")
  87. {
  88. // Inactive state, add the time from lastActiveTime
  89. totalActiveTime += (eTime - lastActiveTime);
  90. lastWasActive = false;
  91. }
  92. else
  93. {
  94. // Active state
  95. lastActiveTime = eTime;
  96. lastWasActive = true;
  97. }
  98. }
  99. // Add the last interval if needed
  100. if(lastWasActive)
  101. {
  102. totalActiveTime += (curTime - lastActiveTime);
  103. }
  104. cpuPercentage.set(Math.ceil((totalActiveTime / 10000) * 100));
  105. }
  106. function hddCallback(state)
  107. {
  108. diskActivity.set(state != "ready");
  109. }
  110. function latencyCallback(latency)
  111. {
  112. diskLatencies.push(latency);
  113. if(diskLatencies.length > 30)
  114. diskLatencies.shift();
  115. // Average the latency over at most 30 blocks
  116. var total = 0;
  117. for(var i=0;i<diskLatencies.length;i++)
  118. total += diskLatencies[i];
  119. var avg = total / diskLatencies.length;
  120. diskLatency.set(Math.ceil(avg));
  121. }
  122. function cpuCallback(state)
  123. {
  124. cpuActivity.set(state != "ready");
  125. var curTime = Date.now();
  126. var limitTime = curTime - 10000;
  127. expireEvents(cpuActivityEvents, curTime, limitTime);
  128. cpuActivityEvents.push({t: curTime, state: state});
  129. computeCpuActivity(curTime, limitTime);
  130. // Start an interval timer to cleanup old samples when no further activity is received
  131. if(activityEventsInterval != 0)
  132. clearInterval(activityEventsInterval);
  133. activityEventsInterval = setInterval(cleanupEvents, 2000);
  134. }
  135. function computeXTermFontSize()
  136. {
  137. return parseInt(getComputedStyle(document.body).fontSize);
  138. }
  139. function setScreenSize(display)
  140. {
  141. var internalMult = 1.0;
  142. var displayWidth = display.offsetWidth;
  143. var displayHeight = display.offsetHeight;
  144. var minWidth = 1024;
  145. var minHeight = 768;
  146. if(displayWidth < minWidth)
  147. internalMult = minWidth / displayWidth;
  148. if(displayHeight < minHeight)
  149. internalMult = Math.max(internalMult, minHeight / displayHeight);
  150. var internalWidth = Math.floor(displayWidth * internalMult);
  151. var internalHeight = Math.floor(displayHeight * internalMult);
  152. cx.setKmsCanvas(display, internalWidth, internalHeight);
  153. // Compute the size to be used for AI screenshots
  154. var screenshotMult = 1.0;
  155. var maxWidth = 1024;
  156. var maxHeight = 768;
  157. if(internalWidth > maxWidth)
  158. screenshotMult = maxWidth / internalWidth;
  159. if(internalHeight > maxHeight)
  160. screenshotMult = Math.min(screenshotMult, maxHeight / internalHeight);
  161. var screenshotWidth = Math.floor(internalWidth * screenshotMult);
  162. var screenshotHeight = Math.floor(internalHeight * screenshotMult);
  163. // Track the state of the mouse as requested by the AI, to avoid losing the position due to user movement
  164. displayConfig.set({width: screenshotWidth, height: screenshotHeight, mouseMult: internalMult * screenshotMult});
  165. }
  166. var curInnerWidth = 0;
  167. var curInnerHeight = 0;
  168. function handleResize()
  169. {
  170. // Avoid spurious resize events caused by the soft keyboard
  171. if(curInnerWidth == window.innerWidth && curInnerHeight == window.innerHeight)
  172. return;
  173. curInnerWidth = window.innerWidth;
  174. curInnerHeight = window.innerHeight;
  175. triggerResize();
  176. }
  177. function triggerResize()
  178. {
  179. term.options.fontSize = computeXTermFontSize();
  180. fitAddon.fit();
  181. const display = document.getElementById("display");
  182. if(display)
  183. setScreenSize(display);
  184. }
  185. async function initTerminal()
  186. {
  187. const { Terminal } = await import('@xterm/xterm');
  188. const { FitAddon } = await import('@xterm/addon-fit');
  189. const { WebLinksAddon } = await import('@xterm/addon-web-links');
  190. term = new Terminal({cursorBlink:true, convertEol:true, fontFamily:"monospace", fontWeight: 400, fontWeightBold: 700, fontSize: computeXTermFontSize()});
  191. fitAddon = new FitAddon();
  192. term.loadAddon(fitAddon);
  193. var linkAddon = new WebLinksAddon();
  194. term.loadAddon(linkAddon);
  195. const consoleDiv = document.getElementById("console");
  196. term.open(consoleDiv);
  197. term.scrollToTop();
  198. fitAddon.fit();
  199. window.addEventListener("resize", handleResize);
  200. term.focus();
  201. term.onData(readData);
  202. // Avoid undesired default DnD handling
  203. function preventDefaults (e) {
  204. e.preventDefault()
  205. e.stopPropagation()
  206. }
  207. consoleDiv.addEventListener("dragover", preventDefaults, false);
  208. consoleDiv.addEventListener("dragenter", preventDefaults, false);
  209. consoleDiv.addEventListener("dragleave", preventDefaults, false);
  210. consoleDiv.addEventListener("drop", preventDefaults, false);
  211. curInnerWidth = window.innerWidth;
  212. curInnerHeight = window.innerHeight;
  213. if(configObj.printIntro)
  214. printMessage(introMessage);
  215. try
  216. {
  217. await initCheerpX();
  218. }
  219. catch(e)
  220. {
  221. printMessage(unexpectedErrorMessage);
  222. printMessage([e.toString()]);
  223. return;
  224. }
  225. }
  226. function handleActivateConsole(vt)
  227. {
  228. if(curVT == vt)
  229. return;
  230. curVT = vt;
  231. if(vt != 7)
  232. return;
  233. // Raise the display to the foreground
  234. const display = document.getElementById("display");
  235. display.parentElement.style.zIndex = 5;
  236. tryPlausible("Display activated");
  237. }
  238. function handleProcessCreated()
  239. {
  240. processCount++;
  241. if(processCallback)
  242. processCallback(processCount);
  243. }
  244. async function initCheerpX()
  245. {
  246. const CheerpX = await import('@leaningtech/cheerpx');
  247. var blockDevice = null;
  248. switch(configObj.diskImageType)
  249. {
  250. case "cloud":
  251. try
  252. {
  253. blockDevice = await CheerpX.CloudDevice.create(configObj.diskImageUrl);
  254. }
  255. catch(e)
  256. {
  257. // Report the failure and try again with plain HTTP
  258. var wssProtocol = "wss:";
  259. if(configObj.diskImageUrl.startsWith(wssProtocol))
  260. {
  261. // WebSocket protocol failed, try agin using plain HTTP
  262. tryPlausible("WS Disk failure");
  263. blockDevice = await CheerpX.CloudDevice.create("https:" + configObj.diskImageUrl.substr(wssProtocol.length));
  264. }
  265. else
  266. {
  267. // No other recovery option
  268. throw e;
  269. }
  270. }
  271. break;
  272. case "bytes":
  273. blockDevice = await CheerpX.HttpBytesDevice.create(configObj.diskImageUrl);
  274. break;
  275. case "github":
  276. blockDevice = await CheerpX.GitHubDevice.create(configObj.diskImageUrl);
  277. break;
  278. default:
  279. throw new Error("Unrecognized device type");
  280. }
  281. blockCache = await CheerpX.IDBDevice.create(cacheId);
  282. var overlayDevice = await CheerpX.OverlayDevice.create(blockDevice, blockCache);
  283. var webDevice = await CheerpX.WebDevice.create("");
  284. var documentsDevice = await CheerpX.WebDevice.create("documents");
  285. var dataDevice = await CheerpX.DataDevice.create();
  286. var mountPoints = [
  287. // The root filesystem, as an Ext2 image
  288. {type:"ext2", dev:overlayDevice, path:"/"},
  289. // Access to files on the Web server, relative to the current page
  290. {type:"dir", dev:webDevice, path:"/web"},
  291. // Access to read-only data coming from JavaScript
  292. {type:"dir", dev:dataDevice, path:"/data"},
  293. // Automatically created device files
  294. {type:"devs", path:"/dev"},
  295. // Pseudo-terminals
  296. {type:"devpts", path:"/dev/pts"},
  297. // The Linux 'proc' filesystem which provides information about running processes
  298. {type:"proc", path:"/proc"},
  299. // The Linux 'sysfs' filesystem which is used to enumerate emulated devices
  300. {type:"sys", path:"/sys"},
  301. // Convenient access to sample documents in the user directory
  302. {type:"dir", dev:documentsDevice, path:"/home/user/documents"}
  303. ];
  304. try
  305. {
  306. cx = await CheerpX.Linux.create({mounts: mountPoints, networkInterface: networkInterface});
  307. }
  308. catch(e)
  309. {
  310. printMessage(errorMessage);
  311. printMessage([e.toString()]);
  312. return;
  313. }
  314. cx.registerCallback("cpuActivity", cpuCallback);
  315. cx.registerCallback("diskActivity", hddCallback);
  316. cx.registerCallback("diskLatency", latencyCallback);
  317. cx.registerCallback("processCreated", handleProcessCreated);
  318. term.scrollToBottom();
  319. cxReadFunc = cx.setCustomConsole(writeData, term.cols, term.rows);
  320. const display = document.getElementById("display");
  321. if(display)
  322. {
  323. setScreenSize(display);
  324. cx.setActivateConsole(handleActivateConsole);
  325. }
  326. // Run the command in a loop, in case the user exits
  327. while (true)
  328. {
  329. await cx.run(configObj.cmd, configObj.args, configObj.opts);
  330. }
  331. }
  332. onMount(initTerminal);
  333. async function handleConnect()
  334. {
  335. const w = window.open("login.html", "_blank");
  336. await cx.networkLogin();
  337. w.location.href = await startLogin();
  338. }
  339. async function handleReset()
  340. {
  341. // Be robust before initialization
  342. if(blockCache == null)
  343. return;
  344. await blockCache.reset();
  345. location.reload();
  346. }
  347. async function handleTool(tool)
  348. {
  349. return await handleToolImpl(tool, term);
  350. }
  351. async function handleSidebarPinChange(event)
  352. {
  353. sideBarPinned = event.detail;
  354. // Make sure the pinning state of reflected in the layout
  355. await tick();
  356. // Adjust the layout based on the new sidebar state
  357. triggerResize();
  358. }
  359. </script>
  360. <main class="relative w-full h-full">
  361. <Nav />
  362. <div class="absolute top-10 bottom-0 left-0 right-0">
  363. <SideBar on:connect={handleConnect} on:reset={handleReset} handleTool={!configObj.needsDisplay || curVT == 7 ? handleTool : null} on:sidebarPinChange={handleSidebarPinChange}>
  364. <slot></slot>
  365. </SideBar>
  366. {#if configObj.needsDisplay}
  367. <div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0">
  368. <canvas class="w-full h-full cursor-none" id="display"></canvas>
  369. </div>
  370. {/if}
  371. <div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0 p-1 scrollbar" id="console">
  372. </div>
  373. </div>
  374. </main>