DEV Community

Cover image for From Web to Desktop: Building CodeForge Portable with WebView2
Francesco Marconi
Francesco Marconi

Posted on

From Web to Desktop: Building CodeForge Portable with WebView2

TL;DR

Built a 2MB desktop version of my web-based AI coding assistant using WPF + WebView2. Result: Native filesystem access, multi-language compilation (C#, C++, Arduino), and <1s startup time. No Electron bloat required.


The Problem: Browser Limitations

I built CodeForge, a web-based AI coding assistant that helps developers write and edit code. The web version works well for editing and AI-powered code generation, but it has a fundamental limitation.

The core issue isn't storage or UI - it's execution.

From a browser, I cannot:

  • Call native executables (dotnet CLI, cmake, arduino-cli)
  • Access the real filesystem (only virtualized storage)
  • Execute system processes (compile and run programs)

I explored alternatives:

  • Local API server: Requires users to run a separate backend process
  • Chrome extensions: Limited API access, still can't spawn arbitrary processes
  • WebAssembly toolchains: Incomplete, massive bundle sizes (50+ MB just for compilers)

The reality: To compile C# projects with dotnet build, C++ with cmake, or upload Arduino sketches with arduino-cli, I needed native system access.

A desktop application wasn't optional - it was necessary.


The Solution: WebView2 + WPF

Instead of rewriting the entire application, I took a hybrid approach:

Keep the web UI, add a native wrapper.

Why WebView2, Not Electron?

I considered Electron (the standard choice), but the numbers didn't make sense:

Metric Electron WebView2
Bundle size 150+ MB 2-5 MB
Startup time 3-5 seconds <1 second
Memory usage 200+ MB 60-80 MB
Platform Cross-platform Windows only

For a Windows-focused tool, WebView2 is objectively superior:

  • Chromium engine (same as Electron) but uses Windows' built-in WebView2 runtime
  • 100% code reuse - my React UI runs unchanged
  • Tiny footprint - just WPF shell + business logic

The trade-off? Windows-only. But for my target audience (developers with Windows dev machines), that's acceptable.


Implementation: The PostMessage Bridge

The magic happens through WebView2's PostMessage API - a simple, bidirectional communication channel between JavaScript and C#.

JavaScript → C# (Sending Commands)

From the web UI, sending a compilation request:

javascript

// React component
const compileProject = async (projectFiles, environment) => {
  const zip = await createZipFromFiles(projectFiles);
  const base64Payload = btoa(zip);

  const message = {
    action: 'build',
    environment: 'csharp', // or 'cpp', 'arduino', 'esp32'
    payload: base64Payload,
    arduinoConfig: environment === 'arduino' ? {
      board: 'arduino:avr:uno',
      port: 'COM3'
    } : null
  };

  window.chrome.webview.postMessage(message);
};
Enter fullscreen mode Exit fullscreen mode

C# → JavaScript (Receiving Results)

In the WPF application, handling the message:

csharp

// MainWindow.xaml.cs
private async void CoreWebView2_WebMessageReceived(
    object sender, 
    CoreWebView2WebMessageReceivedEventArgs e)
{
    string jsonData = e.WebMessageAsJson;
    var payload = JsonConvert.DeserializeObject<WebMessagePayload>(jsonData);

    if (payload != null)
    {
        ProjectProcessor processor = new();
        var result = await processor.ProcessPayload(payload);

        // Send result back to JavaScript
        SendToJavaScript(new { 
            success = true,
            output = result 
        });
    }
}

private void SendToJavaScript(object data)
{
    var json = JsonConvert.SerializeObject(data);
    webView.CoreWebView2.PostWebMessageAsJson(json);
}
Enter fullscreen mode Exit fullscreen mode

That's it. No complex IPC, no gRPC, no WebSockets. Just JSON over PostMessage.

The Payload Structure

csharp

public class WebMessagePayload
{
    [JsonPropertyName("action")]
    public string Action { get; set; } // "build", "run", "build_and_run"

    [JsonPropertyName("environment")]
    public string Environment { get; set; } // "csharp", "cpp", "arduino"

    [JsonPropertyName("payload")]
    public string Payload { get; set; } // Base64-encoded ZIP

    [JsonPropertyName("arduinoConfig")]
    public ArduinoConfig ArduinoConfig { get; set; } // Optional
}
Enter fullscreen mode Exit fullscreen mode

The workflow:

  1. Decode Base64 → byte array
  2. Extract ZIP to temporary directory
  3. Invoke appropriate compiler
  4. Return output
  5. Clean up temp directory

Architecture: Strategy Pattern for Multi-Environment Support

Supporting multiple compilation environments (C#, C++, Arduino, ESP32) could have been a mess of if/else blocks. Instead, I used the Strategy Pattern.

The Interface

csharp

public interface IProjectBuilder
{
    Task<string> Build(string projectPath);
    Task Run(string projectPath);
}
Enter fullscreen mode Exit fullscreen mode

Every environment implements this contract, encapsulating its specific compilation logic.

Example: Arduino Builder

csharp

public class ArduinoProjectBuilder : IProjectBuilder
{
    private readonly ArduinoConfig _config;

    public ArduinoProjectBuilder(ArduinoConfig config)
    {
        _config = config;
    }

    public async Task<string> Build(string projectPath)
    {
        string sketchDir = FindSketchDirectory(projectPath);
        string args = $"compile --fqbn {_config.Board} \"{sketchDir}\"";

        return await ShellExecutor.ExecuteCommandAsync(
            "arduino-cli", 
            args, 
            projectPath
        );
    }

    public async Task Run(string projectPath)
    {
        string sketchDir = FindSketchDirectory(projectPath);
        string args = $"upload -p \"{_config.Port}\" --fqbn {_config.Board} \"{sketchDir}\"";

        // Execute in visible shell so user sees upload progress
        await ShellExecutor.ExecuteVisibleCommandAsync(
            "arduino-cli", 
            args, 
            projectPath
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The Factory

csharp

public static class ProjectBuilderFactory
{
    public static IProjectBuilder GetBuilder(string environment, ArduinoConfig config = null)
    {
        return environment.ToLowerInvariant() switch
        {
            "csharp" => new CSharpProjectBuilder(),
            "cpp" => new CppProjectBuilder(),
            "arduino" => new ArduinoProjectBuilder(config),
            "esp32" => new ArduinoProjectBuilder(config), // Same builder, different config
            _ => throw new ArgumentException($"Unsupported environment: {environment}")
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Strategy Over If/Else?

Bad approach (what I avoided):

csharp

public async Task ProcessPayload(WebMessagePayload payload)
{
    if (payload.Environment == "csharp") {
        // 50 lines of C# compilation logic
    }
    else if (payload.Environment == "cpp") {
        // 60 lines of C++ compilation logic
    }
    else if (payload.Environment == "arduino") {
        // 70 lines of Arduino logic
    }
    // This gets unmaintainable fast
}
Enter fullscreen mode Exit fullscreen mode

Good approach (Strategy pattern):

csharp

public async Task ProcessPayload(WebMessagePayload payload)
{
    var builder = ProjectBuilderFactory.GetBuilder(
        payload.Environment, 
        payload.ArduinoConfig
    );

    var buildOutput = await builder.Build(projectPath);

    if (payload.Action.Contains("run"))
        await builder.Run(projectPath);
}
Enter fullscreen mode Exit fullscreen mode

Adding Python support? Create PythonProjectBuilder, add one line to the factory. Zero changes to existing code.


Real-World Impact

Before (Web Version)

  • ❌ Compile/run: Impossible (browser sandbox)
  • ❌ Arduino upload: Impossible
  • ❌ Filesystem: IndexedDB only
  • ✅ Install: 0 MB (hosted)
  • ✅ Portability: Any browser

After (Desktop Version)

  • ✅ Compile/run: Native (dotnet, cmake, arduino-cli)
  • ✅ Arduino upload: Working
  • ✅ Filesystem: Native OS access
  • ✅ Install: 2-5 MB
  • ❌ Portability: Windows only

Performance Benchmarks

Tested on: Windows 11, Ryzen 5 5600X, 16GB RAM

Metric Electron (estimate) CodeForge Portable
Cold startup 3-5s 0.8s
Memory (idle) 200+ MB 65 MB
Memory (compiling) 250+ MB 120 MB
Installer size 150+ MB 4.2 MB

Dual-Mode Execution

One subtle but important feature: dual-mode shell execution.

Build phase (headless):

csharp

// Capture output, don't show window
await ShellExecutor.ExecuteCommandAsync("dotnet", "build", projectPath);
Enter fullscreen mode Exit fullscreen mode

Run/Upload phase (visible):

csharp

// Show terminal window so user sees progress
await ShellExecutor.ExecuteVisibleCommandAsync("arduino-cli", "upload ...", projectPath);
Enter fullscreen mode Exit fullscreen mode

This is critical for Arduino uploads - users need to see the upload progress and any connection errors in real-time.

A Meta Moment

Here’s something interesting: the entire C# backend was generated using CodeForge itself.

I used the web version powered by Gemini 2.5 Flash, provided explicit architectural constraints (Strategy pattern, multi-environment support, async execution, resource cleanup), and CodeForge produced all 7 C# files in 28 structured operations.

This wasn’t scaffolding.

This wasn’t boilerplate.

It resulted in a production-ready WPF backend with clear separation of concerns, design patterns, error handling, and deterministic runtime behavior.

The LLM was used strictly at design-time to generate the code. Once compiled, the application runs as a fully deterministic system with no AI involved at runtime.

This wasn’t accidental — CodeForge uses a custom DSL embedded in natural language to guide the LLM through complex, multi-step engineering tasks.

(The DSL and orchestration model deserve a dedicated article of their own.)


Lessons Learned

1. WebView2 Is Production-Ready

I was initially hesitant about WebView2 (relatively new, Windows-only). After building this, I'm convinced: for Windows-focused tools, WebView2 > Electron.

The performance difference is real, not theoretical. Sub-second startup and 60MB memory footprint vs Electron's bloat is night and day.

2. PostMessage Is Simple and Robust

I expected to need complex IPC mechanisms. PostMessage proved sufficient:

  • JSON serialization handles 99% of use cases
  • Async by default (no blocking)
  • Error handling is straightforward
  • Debugging is easy (just log the JSON)

3. Strategy Pattern Scales

As I added C++, then Arduino, then ESP32 support, the architecture never creaked. Each new environment was:

  1. Create new XxxProjectBuilder class
  2. Add one line to factory
  3. Done

No refactoring, no touching existing code. This is what good architecture feels like.

4. You Don't Always Need Electron

The web development community defaults to Electron for "desktop apps from web code". But if you're targeting a single platform, evaluate native options:

  • Windows: WebView2 (this project)
  • macOS: WKWebView (Swift)
  • Linux: WebKitGTK

The performance gains are worth it.


Conclusion

CodeForge Portable demonstrates that you can have both portability (web version) and power (desktop version) without maintaining two codebases.

Key takeaways:

  • WebView2 enables 100% code reuse with native capabilities
  • PostMessage is sufficient for most UI ↔ Backend communication
  • Strategy pattern makes multi-environment support maintainable
  • A lightweight native host unlocks real hardware integration:
    • Native filesystem access
    • Tool execution (dotnet, cmake, arduino-cli)
    • Serial port access (Arduino / ESP32 monitoring)

Try it yourself:

The desktop version adds native compilation for C#, C++, and Arduino/ESP32 boards. If you're building developer tools, consider the hybrid approach - it might be the best of both worlds.


Questions? Feedback? Drop a comment below or open an issue on GitHub. I'm particularly interested in hearing from developers who've tried similar architectures.

Related articles:
Building an AI-Powered Code Editor
Building an AI-Powered Code Editor: (part 2)
Building an AI-Powered Code Editor: Browser Test Runner

Top comments (0)