Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local
.wrangler
.turbo

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
8 changes: 8 additions & 0 deletions examples/desktop/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM sandbox-desktop:local
EXPOSE 6080

# Cloudflare-branded desktop wallpaper
COPY assets/wallpaper.png /usr/share/backgrounds/cloudflare.png
COPY assets/set-wallpaper.sh /usr/local/bin/set-wallpaper.sh
RUN chmod +x /usr/local/bin/set-wallpaper.sh
COPY assets/set-wallpaper.desktop /etc/xdg/autostart/set-wallpaper.desktop
7 changes: 7 additions & 0 deletions examples/desktop/assets/set-wallpaper.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Set Wallpaper
Exec=/usr/local/bin/set-wallpaper.sh
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
7 changes: 7 additions & 0 deletions examples/desktop/assets/set-wallpaper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
# Set the Cloudflare wallpaper for whatever monitor XFCE detects.
# Runs as an XFCE autostart entry after xfdesktop initializes.
sleep 2
for prop in $(xfconf-query -c xfce4-desktop -l 2>/dev/null | grep last-image); do
xfconf-query -c xfce4-desktop -p "$prop" -s "/usr/share/backgrounds/cloudflare.png"
done
Binary file added examples/desktop/assets/wallpaper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions examples/desktop/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloudflare Sandbox Desktop</title>
</head>
<body class="bg-gray-950 text-gray-100 font-sans h-screen overflow-hidden">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
31 changes: 31 additions & 0 deletions examples/desktop/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@cloudflare/sandbox-desktop-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy": "vite build && wrangler deploy",
"typecheck": "tsc -b",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@cloudflare/kumo": "^1.8.0",
"@phosphor-icons/react": "^2.1.10",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@cloudflare/sandbox": "*",
"@cloudflare/vite-plugin": "^1.15.2",
"@tailwindcss/vite": "^4.2.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"wrangler": "^4.63.0"
}
}
44 changes: 44 additions & 0 deletions examples/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useRef } from 'react';
import { DesktopViewer } from './components/DesktopViewer';
import { Header } from './components/Header';
import { useDesktop } from './hooks/useDesktop';

export default function App() {
const { isRunning, isStarting, streamUrl, error, start, stop } = useDesktop();
const viewerRef = useRef<HTMLElement>(null);

const handleStart = () => {
const el = viewerRef.current;
if (el) {
// Measure the available space and start the desktop at that resolution
start([el.clientWidth, el.clientHeight]);
} else {
start();
}
};

return (
<div className="flex flex-col h-screen">
<Header
isRunning={isRunning}
isStarting={isStarting}
onStart={handleStart}
onStop={stop}
/>

{error && (
<div className="px-4 py-2 bg-red-950 text-red-300 text-sm border-b border-red-800">
{error}
</div>
)}

<main ref={viewerRef} className="flex-1 flex overflow-hidden bg-black">
<DesktopViewer
isRunning={isRunning}
isStarting={isStarting}
streamUrl={streamUrl}
/>
</main>
</div>
);
}
31 changes: 31 additions & 0 deletions examples/desktop/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
interface DesktopStatus {
status: 'active' | 'partial' | 'inactive';
processes: Record<
string,
{ running: boolean; pid?: number; uptime?: number }
>;
resolution: [number, number] | null;
dpi: number | null;
}
async function json<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const res = await fetch(input, init);
if (!res.ok) {
const body = await res.text();
throw new Error(body || res.statusText);
}
return res.json() as Promise<T>;
}

export const api = {
startDesktop: (resolution?: [number, number]) =>
json<{ success: boolean }>('/api/start', {
method: 'POST',
headers: resolution ? { 'Content-Type': 'application/json' } : undefined,
body: resolution ? JSON.stringify({ resolution }) : undefined
}),
stopDesktop: () =>
json<{ success: boolean }>('/api/stop', { method: 'POST' }),
getStatus: () => json<DesktopStatus>('/api/status'),
getStreamUrl: () =>
json<{ url: string }>('/api/stream-url', { method: 'POST' })
};
54 changes: 54 additions & 0 deletions examples/desktop/src/components/DesktopViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
interface DesktopViewerProps {
isRunning: boolean;
isStarting: boolean;
streamUrl: string | null;
}

export function DesktopViewer({
isRunning,
isStarting,
streamUrl
}: DesktopViewerProps) {
if (isStarting) {
return (
<div className="w-full h-full flex justify-center items-center">
<div className="text-center text-gray-500">
<p>Starting desktop environment…</p>
<p className="text-sm mt-2">This may take a few seconds</p>
</div>
</div>
);
}

if (!isRunning) {
return (
<div className="w-full h-full flex justify-center items-center">
<div className="text-center text-gray-500">
<p>Desktop is stopped</p>
<p className="text-sm mt-2">
Click &quot;Start Desktop&quot; to begin
</p>
</div>
</div>
);
}

if (!streamUrl) {
return (
<div className="w-full h-full flex justify-center items-center">
<div className="text-center text-gray-500">
<p>Connecting…</p>
</div>
</div>
);
}

return (
<iframe
src={streamUrl}
title="Desktop Stream"
className="w-full h-full border-none"
allow="clipboard-read; clipboard-write"
/>
);
}
54 changes: 54 additions & 0 deletions examples/desktop/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Badge } from '@cloudflare/kumo/components/badge';
import { Button } from '@cloudflare/kumo/components/button';
import { Camera } from '@phosphor-icons/react';

interface HeaderProps {
isRunning: boolean;
isStarting: boolean;
onStart: () => void;
onStop: () => void;
}

export function Header({
isRunning,
isStarting,
onStart,
onStop
}: HeaderProps) {
return (
<header className="flex justify-between items-center px-4 py-3 bg-gray-950 border-b border-gray-800">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-gray-100">
Desktop Environment
</h1>
<Badge
variant={
isStarting ? 'secondary' : isRunning ? 'primary' : 'secondary'
}
>
{isStarting ? 'Starting…' : isRunning ? 'Running' : 'Stopped'}
</Badge>
</div>
<div className="flex gap-2">
{isRunning && (
<Button
variant="outline"
icon={Camera}
onClick={() => window.open('/api/screenshot', '_blank')}
>
Screenshot
</Button>
)}
{isRunning ? (
<Button variant="destructive" onClick={onStop}>
Stop Desktop
</Button>
) : (
<Button variant="primary" onClick={onStart} disabled={isStarting}>
{isStarting ? 'Starting…' : 'Start Desktop'}
</Button>
)}
</div>
</header>
);
}
78 changes: 78 additions & 0 deletions examples/desktop/src/hooks/useDesktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api/client';

export function useDesktop() {
const [isRunning, setIsRunning] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const statusTimer = useRef<number>(0);
const fetchingUrl = useRef(false);

const refreshStatus = useCallback(async () => {
try {
const status = await api.getStatus();
const running = status.status === 'active' || status.status === 'partial';
setIsRunning(running);

if (!running) {
setStreamUrl(null);
fetchingUrl.current = false;
}
} catch {
// Transient errors during polling are expected
}
}, []);

// Fetch stream URL when desktop becomes running and we don't have one
useEffect(() => {
if (!isRunning || streamUrl || fetchingUrl.current) return;

fetchingUrl.current = true;
api
.getStreamUrl()
.then(({ url }) => setStreamUrl(url))
.catch(() => {
fetchingUrl.current = false;
});
}, [isRunning, streamUrl]);

const start = async (resolution?: [number, number]) => {
setError(null);
setIsStarting(true);
try {
await api.startDesktop(resolution);
await refreshStatus();
// getDesktopStreamUrl waits for the platform to detect port 6080
// (via the Containers runtime's getTcpPort polling) before returning.
const { url } = await api.getStreamUrl();
setStreamUrl(url);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to start desktop');
} finally {
setIsStarting(false);
}
};

const stop = async () => {
setError(null);
try {
await api.stopDesktop();
setStreamUrl(null);
fetchingUrl.current = false;
await refreshStatus();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to stop desktop');
}
};

// Poll status every 3s
useEffect(() => {
refreshStatus();
statusTimer.current = window.setInterval(refreshStatus, 3000);
return () => clearInterval(statusTimer.current);
}, [refreshStatus]);

return { isRunning, isStarting, streamUrl, error, start, stop };
}
3 changes: 3 additions & 0 deletions examples/desktop/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@source "../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}";
@import "@cloudflare/kumo/styles/tailwind";
@import "tailwindcss";
10 changes: 10 additions & 0 deletions examples/desktop/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Loading
Loading