Vamos a ver una serie de tutoriales destinados a ver cómo crear una aplicación híbrida desktop que combine WPF, Webview2, ASP.NET y una aplicación Web como UI (¡ahí es nada!).
Hace muchos años que oímos que el futuro del desarrollo es Web, y el Desktop está condenado a desaparecer. En muchos sentidos, es así, no voy a ser yo el que lo niegue.
Pero, aunque muchas veces se ignora, aún siguen haciendo falta aplicaciones de escritorio. Porque sí, aunque ignoremos ese elefante de en medio de la habitación, aún no todo puede ser web.
Mientras esperamos que el mundo Desktop termine de colapsar, o de reinventarse, han surgido un buen puñado de tecnologías para cubrir el hueco. Electron, Tauri, Fluter, Blazor hybrid, Reactive Native, … todo el mundo tiene su solución (otra cosa es que alguna funcione realmente bien o tenga futuro).
Por si no eran suficientes opciones, hoy vamos a ver una de estas soluciones para generar aplicaciones Desktop usando tecnologías de C# para el backend, y Web para el UI.
En este primer tutorial veremos cómo configurar la parte de backend (ASP.NET, y WPF) y en los próximos tutoriales veremos como conectarlo con un Frontend web (con React, Vue o Astro).
Stack tecnológico de la solución
La solución que plantemos es una aplicación híbrida que combina lo mejor de tres mundos (desktop, backend y web). Básicamente tenemos,
- .NET WPF o MAUI para la interfaz de escritorio. El ejemplo voy a hacerlo con WPF (Windows). Pero sería básicamente igual en MAUI (Android, Mac).
- Webview2 para renderización del UI (baso en Chromium)
- ASP.NET Core como backend (con opción embebido local o stand-alone)
- Frontend web para el UI. Donde podemos usar cualquier framework o tecnología Web (como React, Vue, Astro, etc).
¿Por qué elegir esta combinación frente a otras tantas? Pues principalmente porque usa tecnologías nativas de .NET (WPF/MAUI, Asp.NET) junto a Webview2 como control nativo.
Es decir, tecnologías probadas, mantenidas, bien conocidas, y con un alto rendimiento. Sin dependencias, sin librerías. Y la experiencia de desarrollo es muy cómoda.
Vamos, que tan mal stack tecnológico no será, ya que es muy similar a la que está usando el propio Microsoft en sus aplicaciones.
Y como no hay un tutorial en el que te expliquen como montar este tinglado, pues vamos a verlo paso a paso 👇
Requisitos Previos
Antes de comenzar, supongo que tienes lo necesario.
- Visual Studio 2022 (o superior) con las cargas de trabajo de .NET y desarrollo de escritorio.
- Node.js y NPM para manejar las dependencias de Web.
- WebView2 Runtime instalado en tu sistema.
- Conocimientos básicos de C#, WPF, ASP.NET, y desarrollo web.
Si te falta alguno de ellos, date un paseo por la web, que tenemos todos explicados 😉.
Estructura del proyecto
Nuestra aplicación híbrida tendrá la siguiente estructura:
- AppWPF: Un proyecto WPF que integrará la aplicación web usando WebView2.
- ServerAspnet: Un proyecto ASP.NET Core que actuará como servidor API embebido.
- ClienteWeb: Una aplicación Web que se ejecutará en el navegador o dentro de WebView2.
Y está una estructura de los ficheros, para que no nos perdamos.
HybridApp/
│
├── AppWPF/ # Proyecto WPF (Frontend Desktop)
│ ├── MainWindow.xaml # Interfaz de usuario de WPF
│ └── MainWindow.xaml.cs # Lógica de la ventana principal
│
├── ServerAspnet/ # Proyecto ASP.NET Core (Backend)
│ ├── Controllers/ # Controladores de ASP.NET Core
│ ├── appsettings.json # Configuración de la aplicación
│ └── ServerAspnet.csproj # Archivo de proyecto de ASP.NET Core
│
└── ClientWeb/ # Frontend Web
├── public/ # Archivos estáticos (HTML, imágenes, etc.)
├── src/ # Código fuente de frontend
└── package.json # Dependencias de Node.js
Solo he puesto los ficheros principales, para entender las relaciones. Haber, habrá muchos más ficheros, lógicamente
Crear proyecto en ASP.NET
Vamos a empezar creando el proyecto de ASP.NET, que es la parte más fácil. Para ello, creamos una carpeta para nuestro proyecto, por ejemplo, MiAppHibrida
Dentro de esta carpeta ejecutamos,
dotnet new webapi -n ServerAspnet
Cuando haya creado el proyecto, abrimos el fichero Program.cs
y lo cambiamos por este código de ejemplo que os paso.
using System.Net;
using System.Net.NetworkInformation;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
namespace ServerAspnet
{
public class Program
{
public static int port = 0;
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy(
"AllowAll",
policy =>
{
policy
.WithOrigins("http://localhost:5173") // La URL de tu frontend en modo Dev
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials(); // Permitir el envío de credenciales (cookies, headers de autenticación)
}
);
});
builder.Services.AddSignalR();
builder
.Services.AddControllers()
.PartManager.ApplicationParts.Add(new AssemblyPart(typeof(Program).Assembly));
#if DEBUG
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
#endif
var app = builder.Build();
#if DEBUG
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
#endif
#if !DEBUG
// puerto dinámico (lo vemos en el siguiente punto)
port = GetAvailablePort(5000);
builder.WebHost.UseUrls($"http://localhost:{port}");
#endif
app.UseCors("AllowAll");
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.UseDefaultFiles(); // <-- Sirve index.html por defecto
app.UseStaticFiles(); // <-- Sirve archivos de wwwroot
app.MapGet("/api/demo", () => new { message = "¡Funciona! 🎉" });
app.MapHub<ClockHub>("/clockHub");
if (args.Any(x => x == "run_async"))
app.RunAsync();
else
app.Run();
}
}
}
Básicamente lo que hemos hecho es una API de ASP.NET, que ejecutaremos de forma embebida en nuestra App de WPF, o bien de forma independiente.
Por supuesto podéis ajustar vuestra configuración, esto es un ejemplo para ver cómo ejecutarla en modo híbrido.
Variar puerto
Cuando ejecutamos una aplicación web en un servidor (o en un contenedor) tenemos un control sobre los puertos que usa. Pero al estar haciendo una app desktop, no tenemos control sobre que el puerto que queramos usar esté disponible.
Por ejemplo, incluso lanzar dos instancias de tu programa daría error, porque ambos usarían el mismo puerto.
Una forma de solucionar esto es hacer que el programa busque un puerto libre. Por eso hemos añadido estas dos líneas.
#if !DEBUG
port = GetAvailablePort(5000);
builder.WebHost.UseUrls($"http://localhost:{port}");
#endif
Que usan esta función GetAvailablePort
que lo que hace es, pues eso… buscar un Available Port 😉.
public static int GetAvailablePort(int startingPort)
{
IPEndPoint[] endPoints;
List<int> portArray = new List<int>();
IPGlobalProperties properties = IPGlobalProperties.GetIPGlobalProperties();
//getting active connections
TcpConnectionInformation[] connections = properties.GetActiveTcpConnections();
portArray.AddRange(
from n in connections
where n.LocalEndPoint.Port >= startingPort
select n.LocalEndPoint.Port
);
//getting active tcp listners - WCF service listening in tcp
endPoints = properties.GetActiveTcpListeners();
portArray.AddRange(from n in endPoints where n.Port >= startingPort select n.Port);
//getting active udp listeners
endPoints = properties.GetActiveUdpListeners();
portArray.AddRange(from n in endPoints where n.Port >= startingPort select n.Port);
portArray.Sort();
for (int i = startingPort; i < UInt16.MaxValue; i++)
if (!portArray.Contains(i))
return i;
return 0;
}
Integrar controllers
En general, la forma preferida para añadir lógica a una app de ASP.NET es a través de Controllers, que gestionan los endpoints.
Para el ejemplo, además del tradicional WeatherForecastController (que se usa siempre de ejemplo) he añadido este
using Microsoft.AspNetCore.Mvc;
namespace ServerAspnet.Controllers;
[ApiController]
[Route("[controller]")]
public class FilesController : ControllerBase
{
private readonly ILogger<FilesController> _logger;
public FilesController(ILogger<FilesController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetFiles")]
public IEnumerable<string> Get()
{
var path = "C:\\temp";
return System.IO.Directory.GetFiles(path);
}
}
Que simplemente muestra los ficheros que tengáis en la carpeta C:\temp
para demostrar cómo la App en Desktop tendrá las mismas capacidades de acceso que una App “normal”.
Si no tenéis la carpeta C:\temp
os dará un error. Si queréis ejecutar el ejemplo tal cuál os lo pongo, crear esta carpeta, o modificar la ruta a otra que exista.
Por otro lado, en una aplicación ASP.NET “normal”, automáticamente reconocería los controllers durante la compilación.
En nuestro caso también va a funcionar. Pero, como podemos lanzar el backend desde otro ensamblado (al embeberlo en la app Desktop), tenemos que especificar el nombre del ensamblado
builder
.Services.AddControllers()
.PartManager.ApplicationParts.Add(new AssemblyPart(typeof(Program).Assembly)); // esta es la parte añadida
Esto no interfiere con la App cuando funcione en modo Web, sólo es una forma de que también funcione en modo Desktop
Integrar SignalR
SignalR es una solución de tiempo real de Microsoft, que es muy práctica en comunicación bidireccional entre servidor y cliente. También podemos añadirla a nuestra solución.
Para eso instalamos el paquete SignalR.
dotnet add package Microsoft.AspNetCore.SignalR
Para el ejemplo he creado un Hub muy sencillo, que simplemente envía la hora actual al cliente UI.
using Microsoft.AspNetCore.SignalR;
namespace ServerAspnet
{
public class ClockHub : Hub
{
// Este método se llama cuando un cliente se conecta
public override async Task OnConnectedAsync()
{
// Comienza a enviar la hora cada segundo
await SendTime();
}
// Método que envía la hora cada segundo
private async Task SendTime()
{
while (true)
{
// Enviar la hora a todos los clientes conectados
await Clients.All.SendAsync("ReceiveTime", DateTime.Now.ToString("HH:mm:ss"));
await Task.Delay(1000); // Esperar 1 segundo antes de enviar la hora nuevamente
}
}
}
}
Estas líneas de program.cs
son las que activan SignalR en la solución, y registran el Hub de demo que he creado.
builder.Services.AddSignalR();
///....
app.MapHub<ClockHub>("/clockHub");
Crear proyecto WPF
Ahora toca crear el esqueleto de nuestra aplicación de escritorio, creando la aplicación de WPF. Para ello abrimos una terminar y ejecutamos
dotnet new wpf -n HybridAppWPF
cd HybridAppWPF
A continuación, tenemos que instalar los paquetes y dependencias necesarios. Para ello hacemos lo siguiente,
dotnet add package Microsoft.AspNetCore.App
dotnet add package Microsoft.Web.WebView2
Si lo hacéis así os funcionará, pero os dará un warning NETSDK1080 de que
PackageReference a Microsoft.AspNetCore.App no es necesario cuando el destino es .NET Core 3.0 o una versión posterior. Si se usa Microsoft.NET.Sdk.Web, se hará referencia automáticamente al marco compartido. De lo contrario, PackageReference debe reemplazarse por frameworkReference.
AspNETCore.App no le gusta que lo añadamos así. Esto es debido a que es un Paquete pensado para usar en proyectos de Microsoft.NET.Sdk.Web
y nosotros estamos usando un proyecto de WPF, y por lo tanto es Microsoft.NET.Sdk
Podemos arreglarlo mejor si lo hacemos así en el fichero del proyecto
Añadir el Webview2
Para que nuestra aplicación de WPF muestre nuestra App web debemos modificar los siguientes ficheros
<Window x:Class="HybridAppWPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
mc:Ignorable="d"
Title="Hybrid App" Height="600" Width="800">
<Grid>
<wv2:WebView2 Name="webView" />
</Grid>
</Window>
using System.Windows;
namespace AppWPF;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private readonly WebServer _server = new WebServer();
public MainWindow()
{
InitializeComponent();
Loaded += MainWindow_Loaded;
}
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
Console.WriteLine("Iniciando servidor web...");
var port = _server.Start();
await webView.EnsureCoreWebView2Async();
#if DEBUG
webView.CoreWebView2.Navigate("http://localhost:5173");
#else
webView.CoreWebView2.Navigate($"http://localhost:{port}");
#endif
}
}
Es decir,
- Hemos añadido el control Webview2 en el XAML
- Hemos configurado la lógica para que, en modo DEBUG, muestre la página de Web (que lanzaremos en paralelo con NPM para desarrollo.
- En modo RELEASE alojaremos y serviremos la web final desde el WPF.
Embeber el proyecto ASP.NET
Ahora, para crear el servidor de AP.NET Core dentro de WPF, en primer lugar añadimos como dependencia el proyecto que hemos creado anteriormente.
Por otro lado, vamos a añadir un fichero llamado WebServer.cs
que actúe de intermediario entre WPF y ASP.NET.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
namespace AppWPF
{
public class WebServer
{
private WebApplication? _app;
public int Start()
{
try
{
Console.WriteLine("Iniciando servidor web...");
ServerAspnet.Program.Main(new string[] { "run_async" });
var port = ServerAspnet.Program.port;
Console.WriteLine($"Iniciado en {port}");
return port;
}
catch (Exception ex)
{
MessageBox.Show($"Error en el servidor: {ex.Message}");
return 0;
}
}
public void Stop() => _app?.StopAsync();
}
}
Lo que estamos haciendo es crear un adaptador en el proyecto de WPF, que lance y apague (por si queréis) el proyecto de ASP.NET. Al lanzarlo, este quedará “embebido” dentro del WPF.
Nuevamente, podéis añadir más lógica aquí. Este es el ejemplo mínimo para que os funcione
Ejecutar la aplicación en desarrollo
Cuando estemos desarrollando con nuestra aplicación, simplemente tenemos que ejecutar la app de WPF en modo Debug.
Veremos un error diciendo que no hay web (no es preocupéis, es lo que queríamos en Debug).
Ahora, por otro lado, nos vamos a otro terminal, abrimos el proyecto del proyecto Web, y ejecutamos
cd ClientWeb
npm run dev
Pulsamos F5 en nuestra App de WPF y ¡tadá!… Vemos nuestra aplicación funcionando “¡Funciona! 🎉” proveniente del servidor ASP.NET
Ahora podemos desarrollar nuestra App de ASP.NET y de tecnologías Web como haríamos con cualquier App “normal”, con Hot Reload, depuración, consola, devtools, y toa la pesca.
Si queremos podemos mejorar el proces creando un Script sencillo en PowerShell en la raiz de la solución (fuera de ambos proyectos), por ejemplo llamado dev.ps1
# Iniciar servidor ASP.NET Core
Start-Process dotnet -ArgumentList "run --configuration Debug --project AppWPF" -WindowStyle Hidden
# Iniciar Vite
npm --prefix ClientWeb run dev
Así únicamente tenemos que hacer
.dev
Para iniciar todo en modo desarrollo de forma simultánea.
Lanzar como una web
¿Te va gustando? Pues hay más. La ventaja de tener nuestro proyecto ASP.NET separado es que podemos lanzar fácilmente todo como una web.
Simplemente cambiamos la configuración del proyecto WPF al proyecto ASP.NET, y arrancará todo como una web “normal”.
Hasta podemos usar Swagger durante el desarrollo.
Incluso, llegado el caso, podemos hacer un doble build, por un lado de nuestra app desktop, y por otro lado de nuestra app web en un contenedor Docker (como haríamos con cualquier proyecto ASP.NET, porque… bueno, porque es lo que es 😉).
Configuración de Build automatizada
Finalmente, como hacemos un deploy de esto, para que nuestra App de WPF hibrida sea autocontenida. Para ello, en el fichero de proyecto de WPF ServerAspnet.csproj
vamos a añadir lo siguiente
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<Target Name="BuildWebApp" BeforeTargets="BeforeBuild" Condition="'$(Configuration)' == 'Release'">
<RemoveDir Directories="wwwroot" Condition="Exists('wwwroot')" />
<Message Importance="high" Text="Construyendo Web App..." />
<Exec Command="npm install" WorkingDirectory="../ClientWeb" />
<Exec Command="npm run build" WorkingDirectory="../ClientWeb" />
<ItemGroup>
<WebBuildFiles Include="../ClientWeb/dist/**" />
</ItemGroup>
<MakeDir Directories="wwwroot" />
<Copy SourceFiles="@(WebBuildFiles)" DestinationFiles="@(WebBuildFiles->'wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" />
</Target>
</Project>
Con esto
- Hemos hecho que al hacer un build en Realease, se lance el
npm run build
de la app Web. - Los ficheros de la app ya “buildeada” se copiarán a
wwwroot
de nuestra app.
Ahora, simplemente hacemos
dotnet build
dotnet run
O si la prefieres selfcontained
dotnet publish -c Release -o publish -r win-x64 --self-contained true
E igualmente podemos hacer un un PowerShell en la raiz de la solución, que haga las acciones por nosotros
# Publicar
dotnet publish HybridAppWPF -c Release -o HybridAppWPF/publish
Así que publicar es tan sencillo como hacer
./publish.ps
Bonus
Bonus pack final, requete final. También podemos hacer una comunicación bidireccional entre la app Web embebida en el Webview2, y la app Host en WPF / MAUI. Para ello haríamos
// WPF a JavaScript
webView.CoreWebView2.PostWebMessageAsString("Hola desde WPF");
// JavaScript a WPF
webView.CoreWebView2.WebMessageReceived += (s, e) =>
MessageBox.Show(e.WebMessageAsJson);
Pero lo veremos en otro tutorial, que este ya ha quedado larguísimo 😅.
En la próxima entrada veremos cómo crear una App en React que se conecte con el proyecto y estructura que hemos creado aquí.
Descarga el código
Todo el código de esta entrada está disponible para su descarga en Github. El código que he subido es un pelín (tranquilos, un pelin pequeño) más complicado, porque he añadido un controller para que lo tengáis de ejemplo.
Por lo demás, es lo que hemos visto aquí. Os dejo el enlace.