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);
};
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);
}
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
}
The workflow:
- Decode Base64 → byte array
- Extract ZIP to temporary directory
- Invoke appropriate compiler
- Return output
- 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);
}
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
);
}
}
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}")
};
}
}
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
}
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);
}
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);
Run/Upload phase (visible):
csharp
// Show terminal window so user sees progress
await ShellExecutor.ExecuteVisibleCommandAsync("arduino-cli", "upload ...", projectPath);
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:
- Create new
XxxProjectBuilderclass - Add one line to factory
- 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:
- Web version: https://llm-codeforge.netlify.app/
- Desktop (this article): GitHub - fra00/CodeForge-Ai-Portable
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)