wip: Windows desktop app scaffold (WinUI 3/.NET 8) + NSIS installer + docs

Owner decision pending (STATE.md DC-100 tick): adopt/ship/park.
Preserved from fragile git stash to named branch 2026-08-23.
NOTE: requires a Windows build machine (WinUI XAML compiler + MSIX do not
cross-build on Linux) — see WINDOWS_APP_BUILD.md in this tree.
Secret-scanned clean 2026-08-23 (no keys/tokens/PEM in tree).
This commit is contained in:
Hermes
2026-08-22 23:50:34 -07:00
commit 86e4c9fc81
29 changed files with 4600 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI;
using DashCaddy.Desktop.ViewModels;
using Windows.UI;
namespace DashCaddy.Desktop.ViewModels;
public class StatusToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is ServiceHealth health)
{
return health switch
{
ServiceHealth.Healthy => new SolidColorBrush(Colors.LimeGreen),
ServiceHealth.Degraded => new SolidColorBrush(Colors.Gold),
ServiceHealth.Unhealthy => new SolidColorBrush(Colors.Red),
_ => new SolidColorBrush(Colors.Gray)
};
}
if (value is bool boolVal)
{
return boolVal ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Red);
}
return new SolidColorBrush(Colors.Gray);
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class StatusToTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is ServiceHealth health)
{
return health switch
{
ServiceHealth.Healthy => "Healthy",
ServiceHealth.Degraded => "Degraded",
ServiceHealth.Unhealthy => "Unhealthy",
_ => "Unknown"
};
}
return "Unknown";
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolVal)
{
return boolVal ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolVal)
return !boolVal;
return true;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
=> throw new NotImplementedException();
}
+166
View File
@@ -0,0 +1,166 @@
using CommunityToolkit.Mvvm.ComponentModel;
using DashCaddy.Desktop.Models;
using DashCaddy.Desktop.Services;
using System.Collections.ObjectModel;
using System.Collections.Generic;
namespace DashCaddy.Desktop.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly DockerService _dockerService;
private readonly ApiClient _apiClient;
private readonly CaddyConfigGenerator _caddyGenerator;
private readonly DnsClient _dnsClient;
[ObservableProperty]
private ObservableCollection<ServiceViewModel> _services = new();
[ObservableProperty]
private int _servicesHealthy;
[ObservableProperty]
private int _servicesTotal;
[ObservableProperty]
private bool _dnsHealthy = true;
[ObservableProperty]
private bool _certsHealthy = true;
[ObservableProperty]
private int _certsDaysRemaining = 45;
// For dialogs
public ObservableCollection<ServiceTemplate> Templates { get; } = new(TemplateRegistry.GetAll());
public List<VariableViewModel> TemplateVariables { get; set; } = new();
public Dictionary<string, string> TemplateVariablesDict { get; set; } = new();
public ServiceTemplate SelectedTemplate { get; set; }
public string SelectedComposeFile { get; set; }
public object CustomService { get; set; }
public string ServicesSummary => $"{ServicesHealthy}/{ServicesTotal} running";
public string CertsSummary => $"{CertsDaysRemaining} days";
public string DashboardUrl => "https://status.local";
public MainViewModel(DockerService dockerService, ApiClient apiClient, CaddyConfigGenerator caddyGenerator, DnsClient dnsClient)
{
_dockerService = dockerService;
_apiClient = apiClient;
_caddyGenerator = caddyGenerator;
_dnsClient = dnsClient;
}
public async Task InitializeAsync()
{
await RefreshServicesAsync();
await CheckHealthAsync();
}
public async Task RefreshServicesAsync()
{
try
{
var services = await _apiClient.GetServicesAsync();
Services.Clear();
foreach (var svc in services)
{
Services.Add(new ServiceViewModel
{
Id = svc.Id,
Name = svc.Name,
Type = svc.Type,
Url = svc.Url,
Health = Enum.TryParse<ServiceHealth>(svc.Health, true, out var h) ? h : ServiceHealth.Unknown,
IsRunning = svc.State == "running",
Port = svc.Port,
Host = svc.Host
});
}
ServicesHealthy = Services.Count(s => s.Health == ServiceHealth.Healthy);
ServicesTotal = Services.Count;
}
catch
{
await RefreshFromDockerAsync();
}
}
private async Task RefreshFromDockerAsync()
{
var containers = await _dockerService.GetContainersAsync("dashcaddy");
Services.Clear();
foreach (var c in containers)
{
Services.Add(new ServiceViewModel
{
Id = c.ID[..12],
Name = c.Names.FirstOrDefault()?.TrimStart('/') ?? "unknown",
Type = "docker",
Url = $"http://localhost:{c.Ports.FirstOrDefault()?.PublicPort ?? 0}",
Health = c.State == "running" ? ServiceHealth.Healthy : ServiceHealth.Unhealthy,
IsRunning = c.State == "running"
});
}
ServicesHealthy = Services.Count(s => s.Health == ServiceHealth.Healthy);
ServicesTotal = Services.Count;
}
public async Task CheckHealthAsync()
{
try
{
var health = await _apiClient.GetHealthAsync();
DnsHealthy = health.Dns == "healthy";
CertsHealthy = health.Certs == "healthy";
CertsDaysRemaining = health.CertsDaysRemaining;
}
catch
{
DnsHealthy = false;
CertsHealthy = false;
}
}
public async Task StartServiceAsync(string id) => await _dockerService.StartContainerAsync(id);
public async Task StopServiceAsync(string id) => await _dockerService.StopContainerAsync(id);
public async Task RestartServiceAsync(string id) => await _dockerService.RestartContainerAsync(id);
public async Task RemoveServiceAsync(string id)
{
await _dockerService.RemoveContainerAsync(id);
await _apiClient.RemoveServiceAsync(id);
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
}
public async Task ImportComposeAsync(string filePath)
{
var json = await File.ReadAllTextAsync(filePath);
var services = ComposeParser.ParseJson(json);
foreach (var svc in services)
{
await _apiClient.CreateServiceAsync(svc);
await _dockerService.CreateContainerAsync(svc);
}
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
}
public async Task<ServiceModel> CreateServiceFromTemplateAsync()
{
if (SelectedTemplate == null) return null;
var service = SelectedTemplate.Instantiate(TemplateVariablesDict);
await _apiClient.CreateServiceAsync(service);
await _dockerService.CreateContainerAsync(service);
_caddyGenerator.RegenerateConfig(Services.Select(s => s.ToServiceModel()).ToList());
await _caddyGenerator.ReloadCaddyAsync();
return service;
}
}
+39
View File
@@ -0,0 +1,39 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace DashCaddy.Desktop.ViewModels;
public enum ServiceHealth
{
Healthy,
Degraded,
Unhealthy,
Unknown
}
public partial class ServiceViewModel : ObservableObject
{
public string Id { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Url { get; set; }
public int Port { get; set; }
public string Host { get; set; }
[ObservableProperty]
private ServiceHealth _health;
[ObservableProperty]
private bool _isRunning;
public ServiceModel ToServiceModel() => new()
{
Id = Id,
Name = Name,
Type = Type,
Url = Url,
Port = Port,
Host = Host,
Health = Health,
State = IsRunning ? "running" : "stopped"
};
}