web_examples.rs

  1#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
  2
  3use std::io::Write;
  4use std::path::Path;
  5use std::process::Command;
  6
  7use anyhow::{Context as _, Result, bail};
  8use clap::Parser;
  9
 10#[derive(Parser)]
 11pub struct WebExamplesArgs {
 12    #[arg(long)]
 13    pub release: bool,
 14    #[arg(long, default_value = "8080")]
 15    pub port: u16,
 16    #[arg(long)]
 17    pub no_serve: bool,
 18}
 19
 20fn check_program(binary: &str, install_hint: &str) -> Result<()> {
 21    match Command::new(binary).arg("--version").output() {
 22        Ok(output) if output.status.success() => Ok(()),
 23        _ => bail!("`{binary}` not found. Install with: {install_hint}"),
 24    }
 25}
 26
 27fn discover_examples() -> Result<Vec<String>> {
 28    let examples_dir = Path::new("crates/gpui/examples");
 29    let mut names = Vec::new();
 30
 31    for entry in std::fs::read_dir(examples_dir).context("failed to read crates/gpui/examples")? {
 32        let path = entry?.path();
 33        if path.extension().and_then(|e| e.to_str()) == Some("rs") {
 34            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
 35                names.push(stem.to_string());
 36            }
 37        }
 38    }
 39
 40    if names.is_empty() {
 41        bail!("no examples found in crates/gpui/examples");
 42    }
 43
 44    names.sort();
 45    Ok(names)
 46}
 47
 48pub fn run_web_examples(args: WebExamplesArgs) -> Result<()> {
 49    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
 50    let profile = if args.release { "release" } else { "debug" };
 51    let out_dir = "target/web-examples";
 52
 53    check_program("wasm-bindgen", "cargo install wasm-bindgen-cli")?;
 54
 55    let examples = discover_examples()?;
 56    eprintln!(
 57        "Building {} example(s) for wasm32-unknown-unknown ({profile})...\n",
 58        examples.len()
 59    );
 60
 61    std::fs::create_dir_all(out_dir).context("failed to create output directory")?;
 62
 63    eprintln!("Building all examples...");
 64
 65    let mut cmd = Command::new(&cargo);
 66    cmd.args([
 67        "build",
 68        "--target",
 69        "wasm32-unknown-unknown",
 70        "-p",
 71        "gpui",
 72        "--keep-going",
 73    ]);
 74    for name in &examples {
 75        cmd.args(["--example", name]);
 76    }
 77    if args.release {
 78        cmd.arg("--release");
 79    }
 80
 81    let _ = cmd.status().context("failed to run cargo build")?;
 82
 83    // Run wasm-bindgen on each .wasm that was produced.
 84    let mut succeeded: Vec<String> = Vec::new();
 85    let mut failed: Vec<String> = Vec::new();
 86
 87    for name in &examples {
 88        let wasm_path = format!("target/wasm32-unknown-unknown/{profile}/examples/{name}.wasm");
 89        if !Path::new(&wasm_path).exists() {
 90            eprintln!("[{name}] SKIPPED (build failed)");
 91            failed.push(name.clone());
 92            continue;
 93        }
 94
 95        eprintln!("[{name}] Running wasm-bindgen...");
 96
 97        let example_dir = format!("{out_dir}/{name}");
 98        std::fs::create_dir_all(&example_dir)
 99            .with_context(|| format!("failed to create {example_dir}"))?;
100
101        let status = Command::new("wasm-bindgen")
102            .args([
103                &wasm_path,
104                "--target",
105                "web",
106                "--no-typescript",
107                "--out-dir",
108                &example_dir,
109                "--out-name",
110                name,
111            ])
112            .status()
113            .context("failed to run wasm-bindgen")?;
114        if !status.success() {
115            eprintln!("[{name}] SKIPPED (wasm-bindgen failed)");
116            failed.push(name.clone());
117            continue;
118        }
119
120        // Write per-example index.html.
121        let html_path = format!("{example_dir}/index.html");
122        std::fs::File::create(&html_path)
123            .and_then(|mut file| file.write_all(make_example_html(name).as_bytes()))
124            .with_context(|| format!("failed to write {html_path}"))?;
125
126        eprintln!("[{name}] OK");
127        succeeded.push(name.clone());
128    }
129
130    if succeeded.is_empty() {
131        bail!("all {} examples failed to build", examples.len());
132    }
133
134    let example_names: Vec<&str> = succeeded.iter().map(|s| s.as_str()).collect();
135    let index_path = format!("{out_dir}/index.html");
136    std::fs::File::create(&index_path)
137        .and_then(|mut file| file.write_all(make_gallery_html(&example_names).as_bytes()))
138        .context("failed to write index.html")?;
139
140    if args.no_serve {
141        return Ok(());
142    }
143
144    // Serve with COEP/COOP headers required for WebGPU / SharedArrayBuffer.
145    eprintln!("Serving on http://127.0.0.1:{}...", args.port);
146
147    let server_script = format!(
148        r#"
149import http.server
150class Handler(http.server.SimpleHTTPRequestHandler):
151    def __init__(self, *args, **kwargs):
152        super().__init__(*args, directory="{out_dir}", **kwargs)
153    def end_headers(self):
154        self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
155        self.send_header("Cross-Origin-Opener-Policy", "same-origin")
156        super().end_headers()
157http.server.HTTPServer(("127.0.0.1", {port}), Handler).serve_forever()
158"#,
159        port = args.port,
160    );
161
162    let status = Command::new("python3")
163        .args(["-c", &server_script])
164        .status()
165        .context("failed to run python3 http server (is python3 installed?)")?;
166    if !status.success() {
167        bail!("python3 http server exited with: {status}");
168    }
169
170    Ok(())
171}
172
173fn make_example_html(name: &str) -> String {
174    format!(
175        r#"<!DOCTYPE html>
176<html lang="en">
177<head>
178    <meta charset="utf-8">
179    <meta name="viewport" content="width=device-width, initial-scale=1.0">
180    <title>GPUI Web: {name}</title>
181    <style>
182        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
183        html, body {{
184            width: 100%; height: 100%; overflow: hidden;
185            background: #1e1e2e; color: #cdd6f4;
186            font-family: system-ui, -apple-system, sans-serif;
187        }}
188        canvas {{ display: block; width: 100%; height: 100%; }}
189        #loading {{
190            position: fixed; inset: 0;
191            display: flex; align-items: center; justify-content: center;
192            font-size: 1.25rem; opacity: 0.6;
193        }}
194        #loading.hidden {{ display: none; }}
195    </style>
196</head>
197<body>
198    <div id="loading">Loading {name}…</div>
199    <script type="module">
200        import init from './{name}.js';
201        await init();
202        document.getElementById('loading').classList.add('hidden');
203    </script>
204</body>
205</html>
206"#
207    )
208}
209
210fn make_gallery_html(examples: &[&str]) -> String {
211    let mut buttons = String::new();
212    for name in examples {
213        buttons.push_str(&format!(
214            "                <button class=\"example-btn\" data-name=\"{name}\">{name}</button>\n"
215        ));
216    }
217
218    let first = examples.first().copied().unwrap_or("hello_web");
219
220    format!(
221        r##"<!DOCTYPE html>
222<html lang="en">
223<head>
224    <meta charset="utf-8">
225    <meta name="viewport" content="width=device-width, initial-scale=1.0">
226    <title>GPUI Web Examples</title>
227    <style>
228        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
229        html, body {{
230            width: 100%; height: 100%; overflow: hidden;
231            background: #1e1e2e; color: #cdd6f4;
232            font-family: system-ui, -apple-system, sans-serif;
233        }}
234        #app {{ display: flex; width: 100%; height: 100%; }}
235
236        #sidebar {{
237            width: 240px; min-width: 240px;
238            background: #181825;
239            border-right: 1px solid #313244;
240            display: flex; flex-direction: column;
241        }}
242        #sidebar-header {{
243            padding: 16px 14px 12px;
244            font-size: 0.8rem; font-weight: 700;
245            text-transform: uppercase; letter-spacing: 0.08em;
246            color: #a6adc8; border-bottom: 1px solid #313244;
247        }}
248        #sidebar-header span {{
249            font-size: 1rem; text-transform: none; letter-spacing: normal;
250            color: #cdd6f4; display: block; margin-top: 2px;
251        }}
252        #example-list {{
253            flex: 1; overflow-y: auto; padding: 8px 0;
254        }}
255        .example-btn {{
256            display: block; width: 100%;
257            padding: 8px 14px; border: none;
258            background: transparent; color: #bac2de;
259            font-size: 0.85rem; text-align: left;
260            cursor: pointer;
261            font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
262        }}
263        .example-btn:hover {{ background: #313244; color: #cdd6f4; }}
264        .example-btn.active {{ background: #45475a; color: #f5e0dc; font-weight: 600; }}
265
266        #main {{ flex: 1; display: flex; flex-direction: column; min-width: 0; }}
267        #toolbar {{
268            height: 40px; display: flex; align-items: center;
269            padding: 0 16px; gap: 12px;
270            background: #1e1e2e; border-bottom: 1px solid #313244;
271            font-size: 0.8rem; color: #a6adc8;
272        }}
273        #current-name {{
274            font-weight: 600; color: #cdd6f4;
275            font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
276        }}
277        #open-tab {{
278            margin-left: auto; padding: 4px 10px;
279            border: 1px solid #585b70; border-radius: 4px;
280            background: transparent; color: #a6adc8;
281            font-size: 0.75rem; cursor: pointer;
282            text-decoration: none;
283        }}
284        #open-tab:hover {{ background: #313244; color: #cdd6f4; }}
285        #viewer {{ flex: 1; border: none; width: 100%; background: #11111b; }}
286    </style>
287</head>
288<body>
289    <div id="app">
290        <div id="sidebar">
291            <div id="sidebar-header">
292                GPUI Examples
293                <span>{count} available</span>
294            </div>
295            <div id="example-list">
296{buttons}            </div>
297        </div>
298        <div id="main">
299            <div id="toolbar">
300                <span id="current-name">{first}</span>
301                <a id="open-tab" href="./{first}/" target="_blank">Open in new tab ↗</a>
302            </div>
303            <iframe id="viewer" src="./{first}/"></iframe>
304        </div>
305    </div>
306    <script>
307        const buttons = document.querySelectorAll('.example-btn');
308        const viewer  = document.getElementById('viewer');
309        const nameEl  = document.getElementById('current-name');
310        const openEl  = document.getElementById('open-tab');
311
312        function select(name) {{
313            buttons.forEach(b => b.classList.toggle('active', b.dataset.name === name));
314            viewer.src = './' + name + '/';
315            nameEl.textContent = name;
316            openEl.href = './' + name + '/';
317            history.replaceState(null, '', '#' + name);
318        }}
319
320        buttons.forEach(b => b.addEventListener('click', () => select(b.dataset.name)));
321
322        const hash = location.hash.slice(1);
323        if (hash && [...buttons].some(b => b.dataset.name === hash)) {{
324            select(hash);
325        }} else {{
326            select('{first}');
327        }}
328    </script>
329</body>
330</html>
331"##,
332        count = examples.len(),
333    )
334}