chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:17 +08:00
commit 4d7ba00a23
95 changed files with 6907 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
<script>
import { apiState, setApiKey, addMessage, clearMessageHistory, forceStop, messageList, currentMessage, enableThinking, getMessageDetails } from '$lib/anthropic.js';
import { tick } from 'svelte';
import { get } from 'svelte/store';
import PanelButton from './PanelButton.svelte';
import SmallButton from './SmallButton.svelte';
import { aiActivity } from './activities.js';
import html2canvas from 'html2canvas-pro';
export let handleTool;
let stopRequested = false;
function handleKeyEnter(e)
{
if(e.key != "Enter")
return;
var value = e.target.value;
if(value == "")
return;
setApiKey(value);
}
function handleMessage(e)
{
if(e.key != "Enter")
return;
e.preventDefault();
var textArea = e.target;
var value = textArea.value;
if(value == "")
return;
textArea.style.height = "unset";
// Reset the textarea
currentMessage.set("");
addMessage(value, handleTool);
}
function handleResize(e)
{
var textArea = e.target;
textArea.style.height = textArea.scrollHeight + "px";
}
async function scrollToBottom(node)
{
await tick();
node.scrollTop = node.scrollHeight;
if (!get(aiActivity)) {
document.getElementById("ai-input").focus();
}
}
function scrollMessage(node, messageList)
{
// Make sure the messages are always scrolled to the bottom
scrollToBottom(node);
return {
update(messageList) {
scrollToBottom(node);
}
}
}
async function handleStop() {
stopRequested = true;
await forceStop();
stopRequested = false;
}
async function handleDownload() {
const messageListElement = document.getElementById('message-list');
// Temporarily add padding and background for the list
messageListElement.classList.add("p-1");
const canvas = await html2canvas(messageListElement);
messageListElement.classList.remove("p-1");
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = 'WebVM_Claude.png';
link.click();
}
function toggleThinkingMode() {
enableThinking.set(!get(enableThinking));
}
</script>
<h1 class="text-lg font-bold">Claude AI Integration</h1>
<p>WebVM is integrated with Claude by Anthropic AI. You can prompt the AI to control the system.</p>
<p>You need to provide your API key. The key is only saved locally to your browser.</p>
<div class="flex grow flex-col overflow-y-hidden gap-2">
<p class="flex flex-row gap-2">
<span class="mr-auto flex items-center">Conversation history</span>
<SmallButton buttonIcon="fa-solid fa-download" clickHandler={handleDownload} buttonTooltip="Save conversation as image"></SmallButton>
<SmallButton buttonIcon="fa-solid fa-brain" clickHandler={toggleThinkingMode} buttonTooltip="{$enableThinking ? "Disable" : "Enable"} thinking mode" bgColor={$enableThinking ? "bg-neutral-500" : "bg-neutral-700"}></SmallButton>
<SmallButton buttonIcon="fa-solid fa-trash-can" clickHandler={clearMessageHistory} buttonTooltip="Clear conversation history"></SmallButton>
</p>
<div class="flex grow overflow-y-scroll scrollbar" use:scrollMessage={$messageList}>
<div class="h-full w-full">
<div class="w-full min-h-full flex flex-col gap-2 justify-end bg-neutral-600" id="message-list">
{#each $messageList as msg}
{@const details = getMessageDetails(msg)}
{#if details.isToolUse}
<p class="bg-neutral-700 p-2 rounded-md italic"><i class='fas {details.icon} w-6 mr-2 text-center'></i>{details.messageContent}</p>
{:else if !details.isToolResult}
<p class="{msg.role == 'error' ? 'bg-red-900' : 'bg-neutral-700'} p-2 rounded-md whitespace-pre-wrap"><i class='fas {details.icon} w-6 mr-2 text-center'></i>{details.messageContent}</p>
{/if}
{/each}
</div>
</div>
</div>
</div>
{#if $apiState == "KEY_REQUIRED"}
<textarea class="bg-neutral-700 p-2 rounded-md placeholder-gray-400 resize-none shrink-0" placeholder="Insert your Claude API Key" rows="1" on:keydown={handleKeyEnter} on:input={handleResize} id="ai-input"/>
{:else if $aiActivity}
{#if stopRequested }
<PanelButton buttonIcon="fa-solid fa-hand" buttonText="Stopping...">
</PanelButton>
{:else}
<PanelButton buttonIcon="fa-solid fa-hand" clickHandler={handleStop} buttonText="Stop">
</PanelButton>
{/if}
{:else}
<textarea class="bg-neutral-700 p-2 rounded-md placeholder-gray-400 resize-none shrink-0" placeholder={handleTool === null ? "Waiting for system initialization..." : "Prompt..."} rows="1" on:keydown={handleMessage} on:input={handleResize} bind:value={$currentMessage} id="ai-input" disabled={handleTool === null}/>
{/if}
+9
View File
@@ -0,0 +1,9 @@
<script>
export let title;
export let image;
export let url;
</script>
<a href={url} target="_blank"><div class="bg-neutral-700 hover:bg-neutral-500 p-2 rounded-md">
<img class="w-full aspect-[40/21]" src={image}>
<h2 class="text-sm font-bold">{title}</h2>
</div></a>
+12
View File
@@ -0,0 +1,12 @@
<script>
import PanelButton from './PanelButton.svelte';
import { cpuPercentage } from './activities.js'
</script>
<h1 class="text-lg font-bold">Engine</h1>
<PanelButton buttonImage="assets/cheerpx.svg" clickUrl="https://cheerpx.io/docs" buttonText="Explore CheerpX">
</PanelButton>
<p><span class="font-bold">Virtual CPU: </span>{$cpuPercentage}%</p>
<p>CheerpX is a x86 virtualization engine in WebAssembly</p>
<p>It can securely run unmodified x86 binaries and libraries in the browser</p>
<p>Excited about our technology? <a class="underline" href="https://cheerpx.io/docs/getting-started" target="_blank">Start building</a> your projects using <a class="underline" href="https://cheerpx.io/" target="_blank">CheerpX</a> today!</p>
+12
View File
@@ -0,0 +1,12 @@
<script>
import PanelButton from './PanelButton.svelte';
import DiscordPresenceCount from 'labs/packages/astro-theme/components/nav/DiscordPresenceCount.svelte'
</script>
<h1 class="text-lg font-bold">Discord</h1>
<PanelButton buttonImage="assets/discord-mark-blue.svg" clickUrl="https://discord.gg/yTNZgySKGa" buttonText="Join our Discord">
<i class='fas fa-circle fa-xs ml-auto text-green-500'></i>
<span class="ml-1"><DiscordPresenceCount /></span>
</PanelButton>
<p>Do you have any question about WebVM or CheerpX?</p>
<p>Join our community, we are happy to help!</p>
+62
View File
@@ -0,0 +1,62 @@
<script>
import PanelButton from './PanelButton.svelte';
import { createEventDispatcher } from 'svelte';
import { diskLatency } from './activities.js'
var dispatch = createEventDispatcher();
let state = "START";
function handleReset()
{
if(state == "START")
state = "CONFIRM";
else if (state == "CONFIRM") {
state = "RESETTING";
dispatch('reset');
}
}
function getButtonText(state)
{
if(state == "START")
return "Reset disk";
else if (state == "RESETTING")
return "Resetting...";
else
return "Reset disk. Confirm?";
}
function getBgColor(state)
{
if(state == "START")
{
// Use default
return undefined;
}
else
{
return "bg-red-900";
}
}
function getHoverColor(state)
{
if(state == "START")
{
// Use default
return undefined;
}
else
{
return "hover:bg-red-700";
}
}
</script>
<h1 class="text-lg font-bold">Disk</h1>
<PanelButton buttonIcon="fa-solid fa-trash-can" clickHandler={handleReset} buttonText={getButtonText(state)} bgColor={getBgColor(state)} hoverColor={getHoverColor(state)}>
</PanelButton>
{#if state == "CONFIRM"}
<p><span class="font-bold">Warning: </span>WebVM will reload</p>
{:else if state == "RESETTING"}
<p><span class="font-bold">Reset in progress: </span>Please wait...</p>
{:else}
<p><span class="font-bold">Backend latency: </span>{$diskLatency}ms</p>
{/if}
<p>WebVM runs on top of a complete Linux distribution</p>
<p>Filesystems up to 2GB are supported and data is downloaded completely on-demand</p>
<p>The WebVM cloud backend uses WebSockets and it's distributed via a global CDN to minimize download latency</p>
+13
View File
@@ -0,0 +1,13 @@
<script>
import PanelButton from './PanelButton.svelte';
import GitHubStarCount from 'labs/packages/astro-theme/components/nav/GitHubStarCount.svelte'
</script>
<h1 class="text-lg font-bold">GitHub</h1>
<PanelButton buttonImage="assets/github-mark-white.svg" clickUrl="https://github.com/leaningtech/webvm" buttonText="GitHub repo">
<i class='fas fa-star fa-xs ml-auto'></i>
<span class="ml-1"><GitHubStarCount repo="leaningtech/webvm"/></span>
</PanelButton>
<p>Like WebVM? <a class="underline" href="https://github.com/leaningtech/webvm" target="_blank">Give us a star!</a></p>
<p>WebVM is FOSS, you can fork it to build your own version and begin working on your CheerpX-based project</p>
<p>Found a bug? Please open a <a class="underline" href="https://github.com/leaningtech/webvm/issues" target="_blank">GitHub issue</a></p>
+23
View File
@@ -0,0 +1,23 @@
<script>
export let icon;
export let info;
export let activity;
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleMouseover() {
dispatch('mouseover', info);
}
function handleClick() {
dispatch('click', { icon, info });
}
</script>
<div
class="p-3 cursor-pointer text-center hover:bg-neutral-600 {$activity ? "text-amber-500 animate-pulse" : "hover:text-gray-100"}"
style="animation-duration: 0.5s"
on:mouseenter={handleMouseover}
on:click={handleClick}
>
<i class='{icon} fa-xl'></i>
</div>
+11
View File
@@ -0,0 +1,11 @@
<h1 class="text-lg font-bold">Information</h1>
<img src="assets/webvm_hero.png" alt="WebVM Logo" class="w-56 h-56 object-contain self-center">
<p>WebVM is a virtual Linux environment running in the browser via WebAssembly</p>
<p>It is based on:</p>
<ul class="list-disc list-inside">
<li><a class="underline" target="_blank" href="https://cheerpx.io/">CheerpX</a>: x86 JIT in Wasm</li>
<li><a class="underline" target="_blank" href="https://xtermjs.org/">Xterm.js</a>: interactive terminal</li>
<li>Local/private <a class="underline" target="_blank" href="https://cheerpx.io/docs/guides/File-System-support">file storage</a></li>
<li><a class="underline" target="_blank" href="https://cheerpx.io/docs/guides/Networking">Networking</a> via <a class="underline" target="_blank" href="https://tailscale.com/">Tailscale</a></li>
</ul>
<slot></slot>
+24
View File
@@ -0,0 +1,24 @@
<script>
import { networkData, startLogin, updateButtonData } from '$lib/network.js'
import { createEventDispatcher } from 'svelte';
import PanelButton from './PanelButton.svelte';
var dispatch = createEventDispatcher();
var connectionState = networkData.connectionState;
var exitNode = networkData.exitNode;
function handleConnect() {
connectionState.set("DOWNLOADING");
dispatch('connect');
}
let buttonData = null;
$: buttonData = updateButtonData($connectionState, handleConnect);
</script>
<h1 class="text-lg font-bold">Networking</h1>
<PanelButton buttonImage="assets/tailscale.svg" clickUrl={buttonData.clickUrl} clickHandler={buttonData.clickHandler} rightClickHandler={buttonData.rightClickHandler} buttonTooltip={buttonData.buttonTooltip} buttonText={buttonData.buttonText}>
{#if $connectionState == "CONNECTED"}
<i class='fas fa-circle fa-xs ml-auto {$exitNode ? 'text-green-500' : 'text-amber-500'}' title={$exitNode ? 'Ready' : 'No exit node'}></i>
{/if}
</PanelButton>
<p>WebVM can connect to the Internet via Tailscale</p>
<p>Using Tailscale is required since browser do not support TCP/UDP sockets (yet!)</p>
+19
View File
@@ -0,0 +1,19 @@
<script>
export let clickUrl = null;
export let clickHandler = null;
export let rightClickHandler = null;
export let buttonTooltip = null;
export let bgColor = "bg-neutral-700";
export let hoverColor = "hover:bg-neutral-500"
export let buttonImage = null;
export let buttonIcon = null;
export let buttonText;
</script>
<a href={clickUrl} target="_blank" on:click={clickHandler} on:contextmenu={rightClickHandler}><p class="flex flex-row items-center {bgColor} p-2 rounded-md shadow-md shadow-neutral-900 {(clickUrl != null || clickHandler != null) ? `${hoverColor} cursor-pointer` : ""}" title={buttonTooltip}>
{#if buttonImage}
<img src={buttonImage} class="inline w-8 h-8"/>
{:else if buttonIcon}
<i class="w-8 {buttonIcon} text-center" style="font-size: 2em;"></i>
{/if}
<span class="ml-1">{buttonText}</span><slot></slot></p></a>
+14
View File
@@ -0,0 +1,14 @@
<script>
import BlogPost from './BlogPost.svelte';
import { page } from '$app/stores';
</script>
<h1 class="text-lg font-bold">Blog posts</h1>
<div class="overflow-y-scroll scrollbar flex flex-col gap-2">
{#each $page.data.posts as post}
<BlogPost
title={post.title}
image={post.image}
url={post.url}
/>
{/each}
</div>
+135
View File
@@ -0,0 +1,135 @@
<script>
import { createEventDispatcher } from 'svelte';
import Icon from './Icon.svelte';
import InformationTab from './InformationTab.svelte';
import NetworkingTab from './NetworkingTab.svelte';
import CpuTab from './CpuTab.svelte';
import DiskTab from './DiskTab.svelte';
import AnthropicTab from './AnthropicTab.svelte';
import PostsTab from './PostsTab.svelte';
import DiscordTab from './DiscordTab.svelte';
import GitHubTab from './GitHubTab.svelte';
import SmallButton from './SmallButton.svelte';
import { cpuActivity, diskActivity, aiActivity } from './activities.js';
const icons = [
{ icon: 'fas fa-info-circle', info: 'Information', activity: null },
{ icon: 'fas fa-wifi', info: 'Networking', activity: null },
{ icon: 'fas fa-microchip', info: 'CPU', activity: cpuActivity },
{ icon: 'fas fa-compact-disc', info: 'Disk', activity: diskActivity },
{ icon: 'fas fa-robot', info: 'ClaudeAI', activity: aiActivity },
null,
{ icon: 'fas fa-book-open', info: 'Posts', activity: null },
{ icon: 'fab fa-discord', info: 'Discord', activity: null },
{ icon: 'fab fa-github', info: 'GitHub', activity: null },
];
let dispatch = createEventDispatcher();
let activeInfo = null; // Tracks currently visible info.
let hideTimeout = 0; // Timeout for hiding info panel.
export let sideBarPinned;
function showInfo(info) {
clearTimeout(hideTimeout);
hideTimeout = 0;
activeInfo = info;
}
function hideInfo() {
// Never remove the sidebar if pinning is enabled
if(sideBarPinned)
return;
// Prevents multiple timers and hides the info panel after 400ms unless interrupted.
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
activeInfo = null;
hideTimeout = 0;
}, 400);
}
function handleMouseEnterPanel() {
clearTimeout(hideTimeout);
hideTimeout = 0;
}
// Toggles the info panel for the clicked icon.
function handleClick(icon) {
if(sideBarPinned)
return;
// Hides the panel if the icon is active. Otherwise, shows the panel with info.
if (activeInfo === icon.info) {
activeInfo = null;
} else {
activeInfo = icon.info;
}
}
function toggleSidebarPin() {
sideBarPinned = !sideBarPinned;
dispatch('sidebarPinChange', sideBarPinned);
}
export let handleTool;
</script>
<div class="flex flex-row w-14 h-full bg-neutral-700" >
<div class="flex flex-col shrink-0 w-14 text-gray-300">
{#each icons as i}
{#if i}
<Icon
icon={i.icon}
info={i.info}
activity={i.activity}
on:mouseover={(e) => showInfo(e.detail)}
on:click={() => handleClick(i)}
/>
{:else}
<div class="grow" on:mouseenter={handleMouseEnterPanel}></div>
{/if}
{/each}
</div>
<div
class="relative flex flex-col gap-5 shrink-0 w-80 h-full z-10 p-2 bg-neutral-600 text-gray-100 opacity-95"
class:hidden={!activeInfo}
on:mouseenter={handleMouseEnterPanel}
on:mouseleave={hideInfo}
>
<div class="absolute right-2 top-2">
<SmallButton
buttonIcon="fa-solid fa-thumbtack"
clickHandler={toggleSidebarPin}
buttonTooltip={sideBarPinned ? "Unpin Sidebar" : "Pin Sidebar"}
bgColor={sideBarPinned ? "bg-neutral-500" : "bg-neutral-700"}
/>
</div>
{#if activeInfo === 'Information'}
<InformationTab>
<slot></slot>
</InformationTab>
{:else if activeInfo === 'Networking'}
<NetworkingTab on:connect/>
{:else if activeInfo === 'CPU'}
<CpuTab/>
{:else if activeInfo === 'Disk'}
<DiskTab on:reset/>
{:else if activeInfo === 'ClaudeAI'}
<AnthropicTab handleTool={handleTool} />
{:else if activeInfo === 'Posts'}
<PostsTab/>
{:else if activeInfo === 'Discord'}
<DiscordTab/>
{:else if activeInfo === 'GitHub'}
<GitHubTab/>
{:else}
<p>TODO: {activeInfo}</p>
{/if}
<div class="mt-auto text-sm text-gray-300">
<div class="pt-1 pb-1">
<a href="https://cheerpx.io/" target="_blank">
<span>Powered by CheerpX</span>
<img src="assets/cheerpx.svg" alt="CheerpX Logo" class="w-6 h-6 inline-block">
</a>
</div>
<hr class="border-t border-solid border-gray-300">
<div class="pt-1 pb-1">
<a href="https://leaningtech.com/" target="”_blank”">© 2022-2025 Leaning Technologies</a>
</div>
</div>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
<script>
export let clickHandler = null;
export let buttonTooltip = null;
export let bgColor = "bg-neutral-700";
export let hoverColor = "hover:bg-neutral-500"
export let buttonIcon = null;
</script>
<span class="inline-block h-7 {bgColor} text-sm p-1 rounded-md shadow-md shadow-neutral-900 {(clickHandler != null) ? `${hoverColor} cursor-pointer` : ""}" title={buttonTooltip} on:click={clickHandler}>
<i class="w-5 {buttonIcon} text-center"></i>
</span>
+385
View File
@@ -0,0 +1,385 @@
<script>
import { onMount, tick } from 'svelte';
import { get } from 'svelte/store';
import Nav from 'labs/packages/global-navbar/src/Nav.svelte';
import SideBar from '$lib/SideBar.svelte';
import '$lib/global.css';
import '@xterm/xterm/css/xterm.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import { networkInterface, startLogin } from '$lib/network.js'
import { cpuActivity, diskActivity, cpuPercentage, diskLatency } from '$lib/activities.js'
import { introMessage, errorMessage, unexpectedErrorMessage } from '$lib/messages.js'
import { displayConfig, handleToolImpl } from '$lib/anthropic.js'
import { tryPlausible } from '$lib/plausible.js'
export let configObj = null;
export let processCallback = null;
export let cacheId = null;
export let cpuActivityEvents = [];
export let diskLatencies = [];
export let activityEventsInterval = 0;
var term = null;
var cx = null;
var fitAddon = null;
var cxReadFunc = null;
var blockCache = null;
var processCount = 0;
var curVT = 0;
var sideBarPinned = false;
function writeData(buf, vt)
{
if(vt != 1)
return;
term.write(new Uint8Array(buf));
}
function readData(str)
{
if(cxReadFunc == null)
return;
for(var i=0;i<str.length;i++)
cxReadFunc(str.charCodeAt(i));
}
function printMessage(msg)
{
for(var i=0;i<msg.length;i++)
term.write(msg[i] + "\n");
}
function expireEvents(list, curTime, limitTime)
{
while(list.length > 1)
{
if(list[1].t < limitTime)
{
list.shift();
}
else
{
break;
}
}
}
function cleanupEvents()
{
var curTime = Date.now();
var limitTime = curTime - 10000;
expireEvents(cpuActivityEvents, curTime, limitTime);
computeCpuActivity(curTime, limitTime);
if(cpuActivityEvents.length == 0)
{
clearInterval(activityEventsInterval);
activityEventsInterval = 0;
}
}
function computeCpuActivity(curTime, limitTime)
{
var totalActiveTime = 0;
var lastActiveTime = limitTime;
var lastWasActive = false;
for(var i=0;i<cpuActivityEvents.length;i++)
{
var e = cpuActivityEvents[i];
// NOTE: The first event could be before the limit,
// we need at least one event to correctly mark
// active time when there is long time under load
var eTime = e.t;
if(eTime < limitTime)
eTime = limitTime;
if(e.state == "ready")
{
// Inactive state, add the time from lastActiveTime
totalActiveTime += (eTime - lastActiveTime);
lastWasActive = false;
}
else
{
// Active state
lastActiveTime = eTime;
lastWasActive = true;
}
}
// Add the last interval if needed
if(lastWasActive)
{
totalActiveTime += (curTime - lastActiveTime);
}
cpuPercentage.set(Math.ceil((totalActiveTime / 10000) * 100));
}
function hddCallback(state)
{
diskActivity.set(state != "ready");
}
function latencyCallback(latency)
{
diskLatencies.push(latency);
if(diskLatencies.length > 30)
diskLatencies.shift();
// Average the latency over at most 30 blocks
var total = 0;
for(var i=0;i<diskLatencies.length;i++)
total += diskLatencies[i];
var avg = total / diskLatencies.length;
diskLatency.set(Math.ceil(avg));
}
function cpuCallback(state)
{
cpuActivity.set(state != "ready");
var curTime = Date.now();
var limitTime = curTime - 10000;
expireEvents(cpuActivityEvents, curTime, limitTime);
cpuActivityEvents.push({t: curTime, state: state});
computeCpuActivity(curTime, limitTime);
// Start an interval timer to cleanup old samples when no further activity is received
if(activityEventsInterval != 0)
clearInterval(activityEventsInterval);
activityEventsInterval = setInterval(cleanupEvents, 2000);
}
function computeXTermFontSize()
{
return parseInt(getComputedStyle(document.body).fontSize);
}
function setScreenSize(display)
{
var internalMult = 1.0;
var displayWidth = display.offsetWidth;
var displayHeight = display.offsetHeight;
var minWidth = 1024;
var minHeight = 768;
if(displayWidth < minWidth)
internalMult = minWidth / displayWidth;
if(displayHeight < minHeight)
internalMult = Math.max(internalMult, minHeight / displayHeight);
var internalWidth = Math.floor(displayWidth * internalMult);
var internalHeight = Math.floor(displayHeight * internalMult);
cx.setKmsCanvas(display, internalWidth, internalHeight);
// Compute the size to be used for AI screenshots
var screenshotMult = 1.0;
var maxWidth = 1024;
var maxHeight = 768;
if(internalWidth > maxWidth)
screenshotMult = maxWidth / internalWidth;
if(internalHeight > maxHeight)
screenshotMult = Math.min(screenshotMult, maxHeight / internalHeight);
var screenshotWidth = Math.floor(internalWidth * screenshotMult);
var screenshotHeight = Math.floor(internalHeight * screenshotMult);
// Track the state of the mouse as requested by the AI, to avoid losing the position due to user movement
displayConfig.set({width: screenshotWidth, height: screenshotHeight, mouseMult: internalMult * screenshotMult});
}
var curInnerWidth = 0;
var curInnerHeight = 0;
function handleResize()
{
// Avoid spurious resize events caused by the soft keyboard
if(curInnerWidth == window.innerWidth && curInnerHeight == window.innerHeight)
return;
curInnerWidth = window.innerWidth;
curInnerHeight = window.innerHeight;
triggerResize();
}
function triggerResize()
{
term.options.fontSize = computeXTermFontSize();
fitAddon.fit();
const display = document.getElementById("display");
if(display)
setScreenSize(display);
}
async function initTerminal()
{
const { Terminal } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit');
const { WebLinksAddon } = await import('@xterm/addon-web-links');
term = new Terminal({cursorBlink:true, convertEol:true, fontFamily:"monospace", fontWeight: 400, fontWeightBold: 700, fontSize: computeXTermFontSize()});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
var linkAddon = new WebLinksAddon();
term.loadAddon(linkAddon);
const consoleDiv = document.getElementById("console");
term.open(consoleDiv);
term.scrollToTop();
fitAddon.fit();
window.addEventListener("resize", handleResize);
term.focus();
term.onData(readData);
// Avoid undesired default DnD handling
function preventDefaults (e) {
e.preventDefault()
e.stopPropagation()
}
consoleDiv.addEventListener("dragover", preventDefaults, false);
consoleDiv.addEventListener("dragenter", preventDefaults, false);
consoleDiv.addEventListener("dragleave", preventDefaults, false);
consoleDiv.addEventListener("drop", preventDefaults, false);
curInnerWidth = window.innerWidth;
curInnerHeight = window.innerHeight;
if(configObj.printIntro)
printMessage(introMessage);
try
{
await initCheerpX();
}
catch(e)
{
printMessage(unexpectedErrorMessage);
printMessage([e.toString()]);
return;
}
}
function handleActivateConsole(vt)
{
if(curVT == vt)
return;
curVT = vt;
if(vt != 7)
return;
// Raise the display to the foreground
const display = document.getElementById("display");
display.parentElement.style.zIndex = 5;
tryPlausible("Display activated");
}
function handleProcessCreated()
{
processCount++;
if(processCallback)
processCallback(processCount);
}
async function initCheerpX()
{
const CheerpX = await import('@leaningtech/cheerpx');
var blockDevice = null;
switch(configObj.diskImageType)
{
case "cloud":
try
{
blockDevice = await CheerpX.CloudDevice.create(configObj.diskImageUrl);
}
catch(e)
{
// Report the failure and try again with plain HTTP
var wssProtocol = "wss:";
if(configObj.diskImageUrl.startsWith(wssProtocol))
{
// WebSocket protocol failed, try agin using plain HTTP
tryPlausible("WS Disk failure");
blockDevice = await CheerpX.CloudDevice.create("https:" + configObj.diskImageUrl.substr(wssProtocol.length));
}
else
{
// No other recovery option
throw e;
}
}
break;
case "bytes":
blockDevice = await CheerpX.HttpBytesDevice.create(configObj.diskImageUrl);
break;
case "github":
blockDevice = await CheerpX.GitHubDevice.create(configObj.diskImageUrl);
break;
default:
throw new Error("Unrecognized device type");
}
blockCache = await CheerpX.IDBDevice.create(cacheId);
var overlayDevice = await CheerpX.OverlayDevice.create(blockDevice, blockCache);
var webDevice = await CheerpX.WebDevice.create("");
var documentsDevice = await CheerpX.WebDevice.create("documents");
var dataDevice = await CheerpX.DataDevice.create();
var mountPoints = [
// The root filesystem, as an Ext2 image
{type:"ext2", dev:overlayDevice, path:"/"},
// Access to files on the Web server, relative to the current page
{type:"dir", dev:webDevice, path:"/web"},
// Access to read-only data coming from JavaScript
{type:"dir", dev:dataDevice, path:"/data"},
// Automatically created device files
{type:"devs", path:"/dev"},
// Pseudo-terminals
{type:"devpts", path:"/dev/pts"},
// The Linux 'proc' filesystem which provides information about running processes
{type:"proc", path:"/proc"},
// The Linux 'sysfs' filesystem which is used to enumerate emulated devices
{type:"sys", path:"/sys"},
// Convenient access to sample documents in the user directory
{type:"dir", dev:documentsDevice, path:"/home/user/documents"}
];
try
{
cx = await CheerpX.Linux.create({mounts: mountPoints, networkInterface: networkInterface});
}
catch(e)
{
printMessage(errorMessage);
printMessage([e.toString()]);
return;
}
cx.registerCallback("cpuActivity", cpuCallback);
cx.registerCallback("diskActivity", hddCallback);
cx.registerCallback("diskLatency", latencyCallback);
cx.registerCallback("processCreated", handleProcessCreated);
term.scrollToBottom();
cxReadFunc = cx.setCustomConsole(writeData, term.cols, term.rows);
const display = document.getElementById("display");
if(display)
{
setScreenSize(display);
cx.setActivateConsole(handleActivateConsole);
}
// Run the command in a loop, in case the user exits
while (true)
{
await cx.run(configObj.cmd, configObj.args, configObj.opts);
}
}
onMount(initTerminal);
async function handleConnect()
{
const w = window.open("login.html", "_blank");
cx.networkLogin();
try
{
w.location.href = await startLogin();
}
catch(e)
{
w.close();
console.warn(e);
}
}
async function handleReset()
{
// Be robust before initialization
if(blockCache == null)
return;
await blockCache.reset();
location.reload();
}
async function handleTool(tool)
{
return await handleToolImpl(tool, term);
}
async function handleSidebarPinChange(event)
{
sideBarPinned = event.detail;
// Make sure the pinning state of reflected in the layout
await tick();
// Adjust the layout based on the new sidebar state
triggerResize();
}
</script>
<main class="relative w-full h-full">
<Nav />
<div class="absolute top-10 bottom-0 left-0 right-0">
<SideBar on:connect={handleConnect} on:reset={handleReset} handleTool={!configObj.needsDisplay || curVT == 7 ? handleTool : null} on:sidebarPinChange={handleSidebarPinChange}>
<slot></slot>
</SideBar>
{#if configObj.needsDisplay}
<div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0">
<canvas class="w-full h-full cursor-none" id="display"></canvas>
</div>
{/if}
<div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0 p-1 scrollbar" id="console">
</div>
</div>
</main>
+7
View File
@@ -0,0 +1,7 @@
import { writable } from 'svelte/store';
export const cpuActivity = writable(false);
export const diskActivity = writable(false);
export const aiActivity = writable(false);
export const cpuPercentage = writable(0);
export const diskLatency = writable(0);
+507
View File
@@ -0,0 +1,507 @@
import { get, writable } from 'svelte/store';
import { browser } from '$app/environment'
import { aiActivity } from '$lib/activities.js'
import { tryPlausible } from '$lib/plausible.js';
import Anthropic from '@anthropic-ai/sdk';
var client = null;
var messages = [];
var stopFlag = false;
var lastScreenshot = null;
var screenshotCanvas = null;
var screenshotCtx = null;
export function setApiKey(key)
{
client = new Anthropic({apiKey: key, dangerouslyAllowBrowser: true});
// Reset messages
messages = []
messageList.set(messages);
localStorage.setItem("anthropic-api-key", key);
apiState.set("READY");
tryPlausible("ClaudeAI Key");
}
function clearApiKey()
{
localStorage.removeItem("anthropic-api-key");
apiState.set("KEY_REQUIRED");
}
function addMessageInternal(role, content)
{
messages.push({role: role, content: content});
messageList.set(messages);
}
async function sendMessages(handleTool)
{
aiActivity.set(true);
try
{
var dc = get(displayConfig);
var tool = dc ? { type: "computer_20250124", name: "computer", display_width_px: dc.width, display_height_px: dc.height, display_number: 1 } : { type: "bash_20250124", name: "bash" }
const config = {max_tokens: 2048,
messages: messages,
system: "You are running on a virtualized machine. Wait some extra time after all operations to compensate for slowdown.",
model: 'claude-3-7-sonnet-20250219',
tools: [tool],
tool_choice: {type: "auto", disable_parallel_tool_use: true},
betas: ["computer-use-2025-01-24"]
};
if(get(enableThinking))
config.thinking = { type: "enabled", budget_tokens: 1024 };
const response = await client.beta.messages.create(config);
if(stopFlag)
{
aiActivity.set(false);
return;
}
// Remove all the image payloads, we don't want to send them over and over again
for(var i=0;i<messages.length;i++)
{
var c = messages[i].content;
if(Array.isArray(c))
{
if(c[0].type == "tool_result" && c[0].content && c[0].content[0].type == "image")
delete c[0].content;
}
}
var content = response.content;
// Be robust to multiple response
for(var i=0;i<content.length;i++)
{
var c = content[i];
if(c.type == "text")
{
addMessageInternal(response.role, c.text);
}
else if(c.type == "tool_use")
{
addMessageInternal(response.role, [c]);
var commandResponse = await handleTool(c.input);
var responseObj = {type: "tool_result", tool_use_id: c.id };
if(commandResponse != null)
{
if(commandResponse instanceof Error)
{
console.warn(`Tool error: ${commandResponse.message}`);
responseObj.content = commandResponse.message;
responseObj.is_error = true;
}
else
{
responseObj.content = commandResponse;
}
}
addMessageInternal("user", [responseObj]);
if(stopFlag)
{
// Maintain state consitency by stopping after adding a valid response
aiActivity.set(false);
return;
}
sendMessages(handleTool);
}
else if(c.type == "thinking")
{
addMessageInternal(response.role, [c]);
}
else
{
console.warn(`Invalid response type: ${c.type}`);
}
}
if(response.stop_reason == "end_turn")
{
tryPlausible("ClaudeAI Success");
aiActivity.set(false);
}
}
catch(e)
{
tryPlausible("ClaudeAI Error");
if(e.status == 401)
{
addMessageInternal('error', 'Invalid API key');
clearApiKey();
}
else
{
addMessageInternal('error', e.error.error.message);
}
}
}
export function addMessage(text, handleTool)
{
addMessageInternal('user', text);
sendMessages(handleTool);
tryPlausible("ClaudeAI Use");
}
export function clearMessageHistory() {
messages.length = 0;
messageList.set(messages);
}
export function forceStop() {
stopFlag = true;
return new Promise((resolve) => {
const unsubscribe = aiActivity.subscribe((value) => {
if (!value) {
unsubscribe();
stopFlag = false;
resolve();
}
});
});
}
export function getMessageDetails(msg) {
const isToolUse = Array.isArray(msg.content) && msg.content[0].type === "tool_use";
const isToolResult = Array.isArray(msg.content) && msg.content[0].type === "tool_result";
const isThinking = Array.isArray(msg.content) && msg.content[0].type === "thinking";
let icon = "";
let messageContent = "";
if (isToolUse) {
let tool = msg.content[0].input;
if (tool.action === "screenshot") {
icon = "fa-desktop";
messageContent = "Screenshot";
} else if (tool.action === "mouse_move") {
icon = "fa-mouse-pointer";
var coords = tool.coordinate;
messageContent = `Mouse at (${coords[0]}, ${coords[1]})`;
} else if (tool.action === "left_click") {
icon = "fa-mouse-pointer";
var coords = tool.coordinate;
messageContent = `Left click at (${coords[0]}, ${coords[1]})`;
} else if (tool.action === "right_click") {
icon = "fa-mouse-pointer";
var coords = tool.coordinate;
messageContent = `Right click at (${coords[0]}, ${coords[1]})`;
} else if (tool.action === "wait") {
icon = "fa-hourglass-half";
messageContent = "Waiting";
} else if (tool.action === "key") {
icon = "fa-keyboard";
messageContent = `Key press: ${tool.text}`;
} else if (tool.action === "type") {
icon = "fa-keyboard";
messageContent = "Type text";
} else {
icon = "fa-screwdriver-wrench";
messageContent = "Use the system";
}
} else if (isThinking) {
icon = "fa-brain";
messageContent = "Thinking...";
} else {
icon = msg.role === "user" ? "fa-user" : "fa-robot";
messageContent = msg.content;
}
return {
isToolUse,
isToolResult,
icon,
messageContent,
role: msg.role
};
}
async function yieldHelper(timeout)
{
return new Promise(function(f2, r2)
{
setTimeout(f2, timeout);
});
}
async function kmsSendChar(textArea, charStr)
{
textArea.value = "_" + charStr;
var ke = new KeyboardEvent("keydown");
textArea.dispatchEvent(ke);
var ke = new KeyboardEvent("keyup");
textArea.dispatchEvent(ke);
await yieldHelper(0);
}
function getKmsInputElement()
{
// Find the CheerpX textare, it's attached to the body element
for(const node of document.body.children)
{
if(node.tagName == "TEXTAREA")
return node;
}
return null;
}
export async function handleToolImpl(tool, term)
{
if(tool.command)
{
var sentinel = "# End of AI command";
var buffer = term.buffer.active;
// Get the current cursor position
var marker = term.registerMarker();
var startLine = marker.line;
marker.dispose();
var ret = new Promise(function(f, r)
{
var callbackDisposer = term.onWriteParsed(function()
{
var curLength = buffer.length;
// Accumulate the output and see if the sentinel has been printed
var output = "";
for(var i=startLine + 1;i<curLength;i++)
{
var curLine = buffer.getLine(i).translateToString(true, 0, term.cols);;
if(curLine.indexOf(sentinel) >= 0)
{
// We are done, cleanup and return
callbackDisposer.dispose();
return f(output);
}
output += curLine + "\n";
}
});
});
term.input(tool.command);
term.input("\n");
term.input(sentinel);
term.input("\n");
return ret;
}
else if(tool.action)
{
// Desktop control
// TODO: We should have an explicit API to interact with CheerpX display
switch(tool.action)
{
case "screenshot":
{
// Insert a 3 seconds delay unconditionally, the reference implementation uses 2
await yieldHelper(3000);
var delayCount = 0;
var display = document.getElementById("display");
var dc = get(displayConfig);
if(screenshotCanvas == null)
{
screenshotCanvas = document.createElement("canvas");
screenshotCtx = screenshotCanvas.getContext("2d");
}
if(screenshotCanvas.width != dc.width || screenshotCanvas.height != dc.height)
{
screenshotCanvas.width = dc.width;
screenshotCanvas.height = dc.height;
}
while(1)
{
// Resize the canvas to a Claude compatible size
screenshotCtx.drawImage(display, 0, 0, display.width, display.height, 0, 0, dc.width, dc.height);
var dataUrl = screenshotCanvas.toDataURL("image/png");
if(dataUrl == lastScreenshot)
{
// Delay at most 3 times
if(delayCount < 3)
{
// TODO: Defensive message, validate and remove
console.warn("Identical screenshot, rate limiting");
delayCount++;
// Wait some time and retry
await yieldHelper(5000);
continue;
}
}
lastScreenshot = dataUrl;
// Remove prefix from the encoded data
dataUrl = dataUrl.substring("data:image/png;base64,".length);
var imageSrc = { type: "base64", media_type: "image/png", data: dataUrl };
var contentObj = { type: "image", source: imageSrc };
return [ contentObj ];
}
}
case "mouse_move":
{
var coords = tool.coordinate;
var dc = get(displayConfig);
var mouseX = coords[0] / dc.mouseMult;
var mouseY = coords[1] / dc.mouseMult;
var display = document.getElementById("display");
var clientRect = display.getBoundingClientRect();
var me = new MouseEvent('mousemove', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top });
display.dispatchEvent(me);
return null;
}
case "left_click":
{
var coords = tool.coordinate;
var dc = get(displayConfig);
var mouseX = coords[0] / dc.mouseMult;
var mouseY = coords[1] / dc.mouseMult;
var display = document.getElementById("display");
var clientRect = display.getBoundingClientRect();
var me = new MouseEvent('mousedown', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 0 });
display.dispatchEvent(me);
// This delay prevent X11 logic from debouncing the mouseup
await yieldHelper(60)
me = new MouseEvent('mouseup', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 0 });
display.dispatchEvent(me);
return null;
}
case "right_click":
{
var coords = tool.coordinate;
var dc = get(displayConfig);
var mouseX = coords[0] / dc.mouseMult;
var mouseY = coords[1] / dc.mouseMult;
var display = document.getElementById("display");
var clientRect = display.getBoundingClientRect();
var me = new MouseEvent('mousedown', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 2 });
display.dispatchEvent(me);
// This delay prevent X11 logic from debouncing the mouseup
await yieldHelper(60)
me = new MouseEvent('mouseup', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 2 });
display.dispatchEvent(me);
return null;
}
case "type":
{
var str = tool.text;
return new Promise(async function(f, r)
{
var textArea = getKmsInputElement();
for(var i=0;i<str.length;i++)
{
await kmsSendChar(textArea, str[i]);
}
f(null);
});
}
case "key":
{
var textArea = getKmsInputElement();
var key = tool.text;
// Support arbitrary order of modifiers
var isCtrl = false;
var isAlt = false;
var isShift = false;
while(1)
{
if(key.startsWith("shift+"))
{
isShift = true;
key = key.substr("shift+".length);
var ke = new KeyboardEvent("keydown", {keyCode: 0x10});
textArea.dispatchEvent(ke);
await yieldHelper(0);
continue;
}
else if(key.startsWith("ctrl+"))
{
isCtrl = true;
key = key.substr("ctrl+".length);
var ke = new KeyboardEvent("keydown", {keyCode: 0x11});
textArea.dispatchEvent(ke);
await yieldHelper(0);
continue;
}
else if(key.startsWith("alt+"))
{
isAlt = true;
key = key.substr("alt+".length);
var ke = new KeyboardEvent("keydown", {keyCode: 0x12});
textArea.dispatchEvent(ke);
await yieldHelper(0);
continue;
}
break;
}
var ret = null;
// Dispatch single chars directly and parse the rest
if(key.length == 1)
{
await kmsSendChar(textArea, key);
}
else
{
switch(tool.text)
{
case "Return":
await kmsSendChar(textArea, "\n");
break;
case "Escape":
var ke = new KeyboardEvent("keydown", {keyCode: 0x1b});
textArea.dispatchEvent(ke);
await yieldHelper(0);
ke = new KeyboardEvent("keyup", {keyCode: 0x1b});
textArea.dispatchEvent(ke);
await yieldHelper(0);
break;
default:
// TODO: Support more key combinations
ret = new Error(`Error: Invalid key '${tool.text}'`);
}
}
if(isShift)
{
var ke = new KeyboardEvent("keyup", {keyCode: 0x10});
textArea.dispatchEvent(ke);
await yieldHelper(0);
}
if(isCtrl)
{
var ke = new KeyboardEvent("keyup", {keyCode: 0x11});
textArea.dispatchEvent(ke);
await yieldHelper(0);
}
if(isAlt)
{
var ke = new KeyboardEvent("keyup", {keyCode: 0x12});
textArea.dispatchEvent(ke);
await yieldHelper(0);
}
return ret;
}
case "wait":
{
// Wait 2x what the machine expects to compensate for virtualization slowdown
await yieldHelper(tool.duration * 2 * 1000);
return null;
}
default:
{
break;
}
}
return new Error("Error: Invalid action");
}
else
{
// We can get there due to model hallucinations
return new Error("Error: Invalid tool syntax");
}
}
function initialize()
{
var savedApiKey = localStorage.getItem("anthropic-api-key");
if(savedApiKey)
setApiKey(savedApiKey);
}
export const apiState = writable("KEY_REQUIRED");
export const messageList = writable(messages);
export const currentMessage = writable("");
export const displayConfig = writable(null);
export const enableThinking = writable(false);
if(browser)
initialize();
+26
View File
@@ -0,0 +1,26 @@
@import url('https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,100..900;1,100..900&display=swap');
@tailwind base;
@tailwind utilities;
body
{
font-family: Archivo, sans-serif;
margin: 0;
height: 100%;
overflow: hidden;
background: black;
}
html
{
height: 100%;
}
@media (width <= 850px)
{
html
{
font-size: calc(100vw / 55);
}
}
+53
View File
@@ -0,0 +1,53 @@
const color = "\x1b[1;35m";
const underline = "\x1b[94;4m";
const normal = "\x1b[0m";
export const introMessage = [
"+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+",
"| |",
"| WebVM is a virtual Linux environment running in the browser via WebAssembly.|",
"| |",
"| WebVM is powered by the CheerpX virtualization engine, which enables safe, |",
"| sandboxed execution of x86 binaries, fully client-side. |",
"| |",
"| CheerpX includes an x86-to-WebAssembly JIT compiler, a virtual block-based |",
"| file system, and a Linux syscall emulator. |",
"| |",
"| Try out the new Alpine / Xorg / i3 WebVM: " + underline + "https://webvm.io/alpine.html" + normal + " |",
"| |",
"| [News] BrowserCode: run Claude Code in the browser via WebAssembly |",
"| |",
"| " + underline + "https://browsercode.io/claude" + normal + " |",
"| |",
"+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+",
"",
" Welcome to WebVM. If unsure, try these examples:",
"",
" python3 examples/python3/fibonacci.py ",
" gcc -o helloworld examples/c/helloworld.c && ./helloworld",
" objdump -d ./helloworld | less -M",
" vim examples/c/helloworld.c",
" curl --max-time 15 parrot.live # requires networking",
"",
];
export const errorMessage = [
color + "CheerpX could not start" + normal,
"",
"Check the DevTools console for more information",
"",
"CheerpX is expected to work with recent desktop versions of Chrome, Edge, Firefox and Safari",
"",
"Give it a try from a desktop version / another browser!",
"",
"CheerpX internal error message is:",
"",
];
export const unexpectedErrorMessage = [
color + "WebVM encountered an unexpected error" + normal,
"",
"Check the DevTools console for further information",
"",
"Please consider reporting a bug!",
"",
"CheerpX internal error message is:",
"",
];
+190
View File
@@ -0,0 +1,190 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment'
let authKey = undefined;
let controlUrl = undefined;
if(browser)
{
let params = new URLSearchParams("?"+window.location.hash.substr(1));
authKey = params.get("authKey") || undefined;
controlUrl = params.get("controlUrl") || undefined;
}
let dashboardUrl = controlUrl ? null : "https://login.tailscale.com/admin/machines";
let resolveLogin = null;
let rejectLogin = null;
let loginPromise = null;
let connectionState = writable("DISCONNECTED");
let exitNode = writable(false);
function resetLoginPromise()
{
loginPromise = new Promise((f,r) => {
resolveLogin = f;
rejectLogin = r;
});
}
function validateLoginUrl(url)
{
const parsedUrl = new URL(url);
if(parsedUrl.protocol != "https:" && parsedUrl.protocol != "http:")
throw new Error("Invalid Tailscale login URL scheme");
return parsedUrl.href;
}
function loginUrlCb(url)
{
try
{
url = validateLoginUrl(url);
}
catch(e)
{
connectionState.set("LOGINFAILED");
rejectLogin(e);
resetLoginPromise();
return;
}
connectionState.set("LOGINREADY");
resolveLogin(url);
}
function stateUpdateCb(state)
{
switch(state)
{
case 6 /*Running*/:
{
connectionState.set("CONNECTED");
break;
}
}
}
function netmapUpdateCb(map)
{
networkData.currentIp = map.self.addresses[0];
var exitNodeFound = false;
for(var i=0; i < map.peers.length;i++)
{
if(map.peers[i].exitNode)
{
exitNodeFound = true;
break;
}
}
if(exitNodeFound)
{
exitNode.set(true);
}
}
export async function startLogin()
{
connectionState.set("LOGINSTARTING");
const url = await loginPromise;
networkData.loginUrl = url;
return url;
}
async function handleCopyIP(event)
{
// To prevent the default contexmenu from showing up when right-clicking..
event.preventDefault();
// Copy the IP to the clipboard.
try
{
await window.navigator.clipboard.writeText(networkData.currentIp)
connectionState.set("IPCOPIED");
setTimeout(() => {
connectionState.set("CONNECTED");
}, 2000);
}
catch(msg)
{
console.log("Copy ip to clipboard: Error: " + msg);
}
}
export function updateButtonData(state, handleConnect) {
switch(state) {
case "DISCONNECTED":
return {
buttonText: "Connect to Tailscale",
isClickable: true,
clickHandler: handleConnect,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
case "DOWNLOADING":
return {
buttonText: "Loading IP stack...",
isClickable: false,
clickHandler: null,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
case "LOGINSTARTING":
return {
buttonText: "Starting Login...",
isClickable: false,
clickHandler: null,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
case "LOGINREADY":
return {
buttonText: "Login to Tailscale",
isClickable: true,
clickHandler: null,
clickUrl: networkData.loginUrl,
buttonTooltip: null,
rightClickHandler: null
};
case "LOGINFAILED":
return {
buttonText: "Invalid login URL",
isClickable: false,
clickHandler: null,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
case "CONNECTED":
return {
buttonText: `IP: ${networkData.currentIp}`,
isClickable: true,
clickHandler: null,
clickUrl: networkData.dashboardUrl,
buttonTooltip: "Right-click to copy",
rightClickHandler: handleCopyIP
};
case "IPCOPIED":
return {
buttonText: "Copied!",
isClickable: false,
clickHandler: null,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
default:
return {
buttonText: `Text for state: ${state}`,
isClickable: false,
clickHandler: null,
clickUrl: null,
buttonTooltip: null,
rightClickHandler: null
};
}
}
export const networkInterface = { authKey: authKey, controlUrl: controlUrl, loginUrlCb: loginUrlCb, stateUpdateCb: stateUpdateCb, netmapUpdateCb: netmapUpdateCb };
export const networkData = { currentIp: null, connectionState: connectionState, exitNode: exitNode, loginUrl: null, dashboardUrl: dashboardUrl }
resetLoginPromise();
+6
View File
@@ -0,0 +1,6 @@
// Some ad-blockers block the plausible script from loading. Check if `plausible`
// is defined before calling it.
export function tryPlausible(msg) {
if (self.plausible)
plausible(msg)
}