< Summary

Information
Class: AspxLint.Server.StartedServer
Assembly: AspxLint.Server
File(s): D:\a\claude-aspx-lint\claude-aspx-lint\src\AspxLint.Server\ServerHost.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 10
Coverable lines: 10
Total lines: 695
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%

File(s)

D:\a\claude-aspx-lint\claude-aspx-lint\src\AspxLint.Server\ServerHost.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.NetworkInformation;
 3using System.Net.Sockets;
 4using System.Reflection;
 5using System.Security.Cryptography;
 6using System.Text;
 7using AspxLint.Core;
 8using QRCoder;
 9
 10namespace AspxLint.Server;
 11
 12public sealed record ServerStartOptions(
 13    int Port = 5173,
 14    string? PreferredInterface = null,
 15    /// <summary>API key fixe a accepter. Si null, un token aleatoire est genere
 16    /// au demarrage (mode "personnel" / Desktop). Posez ASPXLINT_API_KEY pour
 17    /// un serveur heberge avec une cle stable.</summary>
 18    string? ApiKey = null,
 19    /// <summary>Si pose, scope tous les chemins manipules (scan/save/restore) a
 20    /// ce dossier racine. Hors-scope = 403. Indispensable en hosting public.</summary>
 21    string? AllowedRoot = null,
 22    /// <summary>Si true, /api/save et /api/restore renvoient 403. Mode lecture
 23    /// seule pour les deploiements ou seul le linting est autorise.</summary>
 24    bool ReadOnly = false
 25);
 26
 027public sealed record StartedServer(
 028    WebApplication App,
 029    string BuildId,
 030    string Token,
 031    string LocalUrl,
 032    string LanUrl,
 033    string LogFile,
 034    string DashboardSource,
 035    string? ProjectRoot
 036);
 37
 38public sealed record ScanRequest(string Path);
 39public sealed record SaveRequest(string Path, string Content);
 40public sealed record RestoreRequest(string Path);
 41public sealed record AnalyzeRequest(string Content, string Ext);
 42public sealed record FixRequest(string Content, string Ext, string RuleId);
 43public sealed record FixAllRequest(string Content, string Ext);
 44
 45public static class ServerHost
 46{
 47    /// <summary>
 48    /// Configure le builder (URLs, logging, services), cree la ServerSession
 49    /// et l'enregistre comme singleton DI. A appeler avant builder.Build().
 50    /// </summary>
 51    public static ServerSession Configure(WebApplicationBuilder builder, int port)
 52        => Configure(builder, new ServerStartOptions(port));
 53
 54    public static ServerSession Configure(WebApplicationBuilder builder, ServerStartOptions opt)
 55    {
 56        var session = CreateSession(opt);
 57
 58        builder.WebHost.UseUrls($"http://0.0.0.0:{opt.Port}");
 59        builder.Logging.ClearProviders();
 60        builder.Services.AddSingleton(session);
 61
 62        // CORS : ouvert pour permettre les frontends multi-origines (Web hostee
 63        // ailleurs que le Server, extensions Chrome, etc.). On autorise les
 64        // credentials (cookie aspx_lint_token) avec un origin reflexif au lieu
 65        // de "*", parce que la spec CORS interdit "*" + AllowCredentials.
 66        builder.Services.AddCors(options =>
 67        {
 68            options.AddDefaultPolicy(policy =>
 69            {
 70                policy.SetIsOriginAllowed(_ => true)
 71                      .AllowCredentials()
 72                      .AllowAnyMethod()
 73                      .AllowAnyHeader();
 74            });
 75        });
 76
 77        // OpenAPI / Swagger : auto-decouverte des minimal API endpoints.
 78        // Specifie "Bearer" pour que les futurs frontends puissent tester depuis
 79        // le UI Swagger.
 80        builder.Services.AddEndpointsApiExplorer();
 81        builder.Services.AddSwaggerGen(options =>
 82        {
 83            options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
 84            {
 85                Title = "aspx-lint API",
 86                Version = "v1",
 87                Description = "Linter et auto-fixer pour ASP.NET Web Forms (.aspx, .ascx, .master).",
 88                License = new Microsoft.OpenApi.Models.OpenApiLicense { Name = "MIT" }
 89            });
 90            options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
 91            {
 92                Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
 93                Scheme = "bearer",
 94                Description = "Token affiche en console au demarrage du serveur."
 95            });
 96            options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
 97            {
 98                {
 99                    new Microsoft.OpenApi.Models.OpenApiSecurityScheme
 100                    {
 101                        Reference = new Microsoft.OpenApi.Models.OpenApiReference
 102                        {
 103                            Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
 104                            Id = "Bearer"
 105                        }
 106                    },
 107                    Array.Empty<string>()
 108                }
 109            });
 110        });
 111
 112        session.Log("INFO", $"server starting, dashboard={session.DashboardSource}");
 113        return session;
 114    }
 115
 116    /// <summary>
 117    /// Branche les middlewares + routes sur l'application construite.
 118    /// A appeler apres builder.Build() et avant app.Run().
 119    /// </summary>
 120    public static void MapRoutes(WebApplication app)
 121    {
 122        var session = app.Services.GetRequiredService<ServerSession>();
 123
 124        app.UseCors();
 125
 126        // Swagger UI a /swagger, spec OpenAPI a /swagger/v1/swagger.json.
 127        // Sans auth pour faciliter l'integration externe (les frontends peuvent
 128        // pomper le contrat sans avoir le token).
 129        app.UseSwagger();
 130        app.UseSwaggerUI(options =>
 131        {
 132            options.SwaggerEndpoint("/swagger/v1/swagger.json", "aspx-lint v1");
 133            options.RoutePrefix = "swagger";
 134            options.DocumentTitle = "aspx-lint API";
 135        });
 136
 137        // Auth : token accepte via 3 canaux, dans cet ordre :
 138        //   1. ?token=...                       (URL, premiere visite navigateur)
 139        //   2. Cookie `aspx_lint_token`         (collant pour navigateur)
 140        //   3. Header `Authorization: Bearer X` (extensions, CI, frontends remote)
 141        app.Use(async (ctx, next) =>
 142        {
 143            if (ctx.Request.Path.StartsWithSegments("/healthz") ||
 144                ctx.Request.Path.StartsWithSegments("/swagger"))
 145            { await next(); return; }
 146
 147            var supplied = ctx.Request.Query["token"].ToString();
 148            if (string.IsNullOrEmpty(supplied))
 149                supplied = ctx.Request.Cookies["aspx_lint_token"] ?? "";
 150            if (string.IsNullOrEmpty(supplied))
 151            {
 152                var auth = ctx.Request.Headers.Authorization.ToString();
 153                if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
 154                    supplied = auth["Bearer ".Length..];
 155            }
 156
 157            var ok = supplied.Length == session.Token.Length &&
 158                     CryptographicOperations.FixedTimeEquals(
 159                         Encoding.ASCII.GetBytes(supplied),
 160                         Encoding.ASCII.GetBytes(session.Token));
 161
 162            if (!ok)
 163            {
 164                session.Log("WARN", $"auth refused from {ctx.Connection.RemoteIpAddress} path={ctx.Request.Path}");
 165                ctx.Response.StatusCode = 401;
 166                await ctx.Response.WriteAsync("Token requis ou invalide.");
 167                return;
 168            }
 169
 170            ctx.Response.Cookies.Append("aspx_lint_token", session.Token, new CookieOptions
 171            {
 172                HttpOnly = true,
 173                SameSite = SameSiteMode.Lax,
 174                MaxAge = TimeSpan.FromHours(12)
 175            });
 176            await next();
 177        });
 178
 179        app.MapGet("/healthz", () => Results.Ok(new { ok = true, buildId = session.BuildId }));
 180
 181        app.MapGet("/", async (HttpContext ctx) =>
 182        {
 183            session.Log("INFO", $"dashboard served to {ctx.Connection.RemoteIpAddress}");
 184            var html = await session.LoadDashboardHtml();
 185            ctx.Response.ContentType = "text/html; charset=utf-8";
 186            await ctx.Response.WriteAsync(html);
 187        });
 188
 189        app.MapGet("/api/rules", () => Results.Ok(
 190            RuleRegistry.All.Select(r => new
 191            {
 192                id = r.Id,
 193                name = r.Name,
 194                severity = r.Severity.ToString().ToLowerInvariant(),
 195                desc = r.Description,
 196                hasFix = r.HasFix
 197            })
 198        ));
 199
 200        // ------------------------------------------------------------------
 201        // Endpoints inline (path-less). Le client envoie le contenu brut, le
 202        // serveur applique le moteur Core et renvoie issues / corrections,
 203        // sans toucher au disque. Utilises par la dashboard pour analyser
 204        // les fichiers droppes / colles, et par les frontends futurs (Chrome,
 205        // VS extension, etc.) qui n'ont pas de chemin a fournir.
 206        // ------------------------------------------------------------------
 207
 208        app.MapPost("/api/analyze", (AnalyzeRequest req) =>
 209        {
 210            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 211            var lines = (req.Content ?? "").Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
 212            var issues = RuleRegistry.All
 213                .SelectMany(r => r.Detect(req.Content ?? "", lines, ctx))
 214                .Select(i => new
 215                {
 216                    ruleId = i.RuleId,
 217                    ruleName = i.RuleName,
 218                    severity = i.Severity.ToString().ToLowerInvariant(),
 219                    line = i.Line,
 220                    col = i.Col,
 221                    snippet = i.Snippet,
 222                    hint = i.Hint
 223                })
 224                .ToList();
 225            return Results.Ok(new { issues });
 226        });
 227
 228        app.MapPost("/api/fix", (FixRequest req) =>
 229        {
 230            var rule = RuleRegistry.All.FirstOrDefault(r =>
 231                r.Id.Equals(req.RuleId, StringComparison.OrdinalIgnoreCase));
 232            if (rule is null)
 233                return Results.NotFound(new { error = $"Regle inconnue : {req.RuleId}" });
 234            if (!rule.HasFix)
 235                return Results.BadRequest(new { error = $"Regle {req.RuleId} n'a pas d'auto-fix" });
 236
 237            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 238            var content = req.Content ?? "";
 239            var beforeCount = CountRuleIssues(rule, content, ctx);
 240            var fixedContent = rule.Fix(content, ctx) ?? content;
 241            var afterCount = CountRuleIssues(rule, fixedContent, ctx);
 242            return Results.Ok(new
 243            {
 244                content = fixedContent,
 245                applied = Math.Max(0, beforeCount - afterCount)
 246            });
 247        });
 248
 249        app.MapPost("/api/fix-all", (FixAllRequest req) =>
 250        {
 251            var ctx = new RuleContext((req.Ext ?? "").ToLowerInvariant(), "");
 252            var content = req.Content ?? "";
 253            var history = new List<object>();
 254            var fixableRules = RuleRegistry.All.Where(r => r.HasFix).ToList();
 255
 256            // Matche le comportement JS d'origine : jusqu'a 5 passes pour
 257            // convergence (un fix peut creer un nouveau probleme detectable).
 258            for (int pass = 0; pass < 5; pass++)
 259            {
 260                var before = content;
 261                foreach (var rule in fixableRules)
 262                {
 263                    var beforeCount = CountRuleIssues(rule, content, ctx);
 264                    if (beforeCount == 0) continue;
 265
 266                    var attempted = rule.Fix(content, ctx);
 267                    if (attempted is null || attempted == content) continue;
 268
 269                    var afterCount = CountRuleIssues(rule, attempted, ctx);
 270                    var resolved = beforeCount - afterCount;
 271                    content = attempted;
 272                    if (resolved > 0)
 273                        history.Add(new { ruleId = rule.Id, count = resolved });
 274                }
 275                if (content == before) break;
 276            }
 277
 278            return Results.Ok(new { content, history });
 279        });
 280
 281        static int CountRuleIssues(IRule rule, string content, RuleContext ctx)
 282        {
 283            var lines = content.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
 284            return rule.Detect(content, lines, ctx).Count();
 285        }
 286
 287        app.MapPost("/api/scan", (ScanRequest req) =>
 288        {
 289            session.Log("INFO", $"scan requested path={req.Path}");
 290
 291            // Scoping : si AllowedRoot pose, le chemin demande doit etre dessous.
 292            string requestedFull;
 293            try { requestedFull = Path.GetFullPath(req.Path); }
 294            catch (Exception ex)
 295            {
 296                session.Log("WARN", $"scan rejected (bad path): {ex.Message}");
 297                return Results.BadRequest(new { error = "Chemin invalide." });
 298            }
 299            if (!session.IsUnderAllowedRoot(requestedFull))
 300            {
 301                session.Log("WARN", $"scan refused (out of allowed root) path={requestedFull}");
 302                return Results.StatusCode(StatusCodes.Status403Forbidden);
 303            }
 304
 305            try
 306            {
 307                var scanned = ProjectScanner.Scan(req.Path, RuleRegistry.All).ToList();
 308                var files = scanned.Select(f => new
 309                {
 310                    path = f.AbsolutePath,
 311                    relativePath = f.RelativePath,
 312                    lineCount = f.LineCount,
 313                    content = f.Content,
 314                    issues = f.Issues.Select(i => new
 315                    {
 316                        ruleId = i.RuleId,
 317                        ruleName = i.RuleName,
 318                        severity = i.Severity.ToString().ToLowerInvariant(),
 319                        line = i.Line,
 320                        col = i.Col,
 321                        snippet = i.Snippet,
 322                        hint = i.Hint
 323                    }).ToList()
 324                }).ToList();
 325
 326                var totalIssues = files.Sum(f => f.issues.Count);
 327
 328                foreach (var f in scanned)
 329                    session.AddWritable(Path.GetFullPath(f.AbsolutePath));
 330
 331                session.Log("INFO", $"scan done path={req.Path} files={files.Count} issues={totalIssues}");
 332
 333                return Results.Ok(new
 334                {
 335                    scannedAt = DateTime.UtcNow,
 336                    buildId = session.BuildId,
 337                    path = req.Path,
 338                    fileCount = files.Count,
 339                    issueCount = totalIssues,
 340                    files
 341                });
 342            }
 343            catch (DirectoryNotFoundException ex)
 344            {
 345                session.Log("WARN", $"scan failed: {ex.Message}");
 346                return Results.NotFound(new { error = ex.Message });
 347            }
 348            catch (Exception ex)
 349            {
 350                session.Log("ERROR", $"scan crashed: {ex}");
 351                return Results.Problem(ex.Message);
 352            }
 353        });
 354
 355        app.MapPost("/api/save", (SaveRequest req) =>
 356        {
 357            if (session.ReadOnly)
 358            {
 359                session.Log("WARN", "save refused (read-only mode)");
 360                return Results.StatusCode(StatusCodes.Status403Forbidden);
 361            }
 362
 363            string full;
 364            try { full = Path.GetFullPath(req.Path); }
 365            catch (Exception ex)
 366            {
 367                session.Log("WARN", $"save rejected (bad path): {ex.Message}");
 368                return Results.BadRequest(new { error = "Chemin invalide." });
 369            }
 370
 371            if (!session.IsUnderAllowedRoot(full))
 372            {
 373                session.Log("WARN", $"save refused (out of allowed root) path={full}");
 374                return Results.StatusCode(StatusCodes.Status403Forbidden);
 375            }
 376            if (!session.IsWritable(full))
 377            {
 378                session.Log("WARN", $"save refused (not scanned) path={full}");
 379                return Results.StatusCode(StatusCodes.Status403Forbidden);
 380            }
 381
 382            try
 383            {
 384                var backupPath = full + ".bak";
 385                var backedUp = false;
 386                if (!File.Exists(backupPath) && File.Exists(full))
 387                {
 388                    File.Copy(full, backupPath);
 389                    backedUp = true;
 390                }
 391
 392                var bytes = Encoding.UTF8.GetBytes(req.Content);
 393                File.WriteAllBytes(full, bytes);
 394
 395                session.Log("INFO", $"saved path={full} bytes={bytes.Length} backup={backedUp}");
 396                return Results.Ok(new { ok = true, path = full, bytes = bytes.Length, backedUp });
 397            }
 398            catch (Exception ex)
 399            {
 400                session.Log("ERROR", $"save crashed: {ex}");
 401                return Results.Problem(ex.Message);
 402            }
 403        });
 404
 405        app.MapPost("/api/restore", (RestoreRequest req) =>
 406        {
 407            if (session.ReadOnly)
 408            {
 409                session.Log("WARN", "restore refused (read-only mode)");
 410                return Results.StatusCode(StatusCodes.Status403Forbidden);
 411            }
 412
 413            string full;
 414            try { full = Path.GetFullPath(req.Path); }
 415            catch (Exception ex)
 416            {
 417                session.Log("WARN", $"restore rejected (bad path): {ex.Message}");
 418                return Results.BadRequest(new { error = "Chemin invalide." });
 419            }
 420
 421            if (!session.IsUnderAllowedRoot(full))
 422            {
 423                session.Log("WARN", $"restore refused (out of allowed root) path={full}");
 424                return Results.StatusCode(StatusCodes.Status403Forbidden);
 425            }
 426            if (!session.IsWritable(full))
 427            {
 428                session.Log("WARN", $"restore refused (not scanned) path={full}");
 429                return Results.StatusCode(StatusCodes.Status403Forbidden);
 430            }
 431
 432            var backupPath = full + ".bak";
 433            if (!File.Exists(backupPath))
 434            {
 435                session.Log("INFO", $"restore failed (no .bak) path={full}");
 436                return Results.NotFound(new { error = "Aucun .bak pour ce fichier (jamais sauvegarde via /api/save)." })
 437            }
 438
 439            try
 440            {
 441                var bytes = File.ReadAllBytes(backupPath);
 442                File.WriteAllBytes(full, bytes);
 443
 444                var hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
 445                var decoded = hasBom
 446                    ? "" + Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3)
 447                    : Encoding.UTF8.GetString(bytes);
 448
 449                session.Log("INFO", $"restored from .bak path={full} bytes={bytes.Length}");
 450                return Results.Ok(new { ok = true, path = full, bytes = bytes.Length, content = decoded });
 451            }
 452            catch (Exception ex)
 453            {
 454                session.Log("ERROR", $"restore crashed: {ex}");
 455                return Results.Problem(ex.Message);
 456            }
 457        });
 458    }
 459
 460    /// <summary>
 461    /// Calcule l'URL locale et l'URL LAN pour le banner / QR code.
 462    /// </summary>
 463    public static (string LocalUrl, string LanUrl) ResolveUrls(int port, string token, string? preferredInterface = null
 464    {
 465        var ip = ResolveLocalIPv4(preferredInterface);
 466        return ($"http://localhost:{port}/?token={token}",
 467                $"http://{ip}:{port}/?token={token}");
 468    }
 469
 470    public static void PrintBannerAndQr(string buildId, string localUrl, string lanUrl, string logFile)
 471    {
 472        Console.WriteLine();
 473        Console.WriteLine($"  ASPX-LINT  build {buildId}");
 474        Console.WriteLine($"  ----------------------------------------------------");
 475        Console.WriteLine($"  Local : {localUrl}");
 476        Console.WriteLine($"  LAN   : {lanUrl}");
 477        Console.WriteLine($"  Logs  : {logFile}");
 478        Console.WriteLine();
 479        Console.WriteLine("  Scan ce QR depuis ton telephone (meme Wi-Fi) :");
 480        Console.WriteLine();
 481
 482        using var qrGen = new QRCodeGenerator();
 483        using var data = qrGen.CreateQrCode(lanUrl, QRCodeGenerator.ECCLevel.M);
 484        var ascii = new AsciiQRCode(data).GetGraphic(1, drawQuietZones: true);
 485        foreach (var line in ascii.Split('\n'))
 486            Console.WriteLine("  " + line.TrimEnd('\r'));
 487        Console.WriteLine();
 488    }
 489
 490    /// <summary>
 491    /// Back-compat pour AspxLint.Desktop : configure, build, MapRoutes,
 492    /// StartAsync synchrone, retourne StartedServer pret a l'emploi.
 493    /// </summary>
 494    public static StartedServer Start(ServerStartOptions opt)
 495    {
 496        var builder = WebApplication.CreateBuilder();
 497        var session = Configure(builder, opt);
 498        var app = builder.Build();
 499        MapRoutes(app);
 500        app.StartAsync().GetAwaiter().GetResult();
 501
 502        var (localUrl, lanUrl) = ResolveUrls(opt.Port, session.Token, opt.PreferredInterface);
 503        session.Log("INFO", $"server listening on :{opt.Port}, lanUrl={lanUrl}");
 504
 505        return new StartedServer(
 506            app, session.BuildId, session.Token,
 507            localUrl, lanUrl, session.LogFile, session.DashboardSource,
 508            ResolveProjectRoot(session.DashboardSource));
 509    }
 510
 511    /// <summary>
 512    /// Si DashboardSource est un chemin disque ("disk:..."), renvoie le
 513    /// dossier parent (le project root). Sinon (mode embedded), null.
 514    /// </summary>
 515    private static string? ResolveProjectRoot(string dashboardSource)
 516    {
 517        const string prefix = "disk:";
 518        if (!dashboardSource.StartsWith(prefix)) return null;
 519        var path = dashboardSource[prefix.Length..];
 520        // Le HTML est a src/AspxLint.Web/index.html â†’ on remonte 2 dossiers
 521        // pour avoir la racine du repo. Heuristique simple suffisante en dev.
 522        var dir = Path.GetDirectoryName(path);
 523        if (dir != null) dir = Path.GetDirectoryName(dir);   // src/AspxLint.Web
 524        if (dir != null) dir = Path.GetDirectoryName(dir);   // src
 525        return dir;
 526    }
 527
 528    public static void PrintBannerAndQr(StartedServer s) =>
 529        PrintBannerAndQr(s.BuildId, s.LocalUrl, s.LanUrl, s.LogFile);
 530
 531    private static ServerSession CreateSession(ServerStartOptions opt)
 532    {
 533        var buildId = $"b-{DateTime.UtcNow:yyyyMMdd-HHmmss}-" +
 534                      Convert.ToHexString(RandomNumberGenerator.GetBytes(2)).ToLowerInvariant();
 535
 536        // Token : prefere l'API key explicite, puis l'env var, puis aleatoire.
 537        var token = opt.ApiKey
 538                    ?? Environment.GetEnvironmentVariable("ASPXLINT_API_KEY")
 539                    ?? Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
 540
 541        var allowedRoot = opt.AllowedRoot
 542                          ?? Environment.GetEnvironmentVariable("ASPXLINT_ALLOWED_ROOT");
 543        var readOnly = opt.ReadOnly
 544                       || string.Equals(Environment.GetEnvironmentVariable("ASPXLINT_READ_ONLY"),
 545                                        "true", StringComparison.OrdinalIgnoreCase);
 546
 547        var (loadDashboard, dashboardSource, projectRoot) = ResolveDashboard();
 548        var logsDir = ResolveLogDir(projectRoot);
 549        Directory.CreateDirectory(logsDir);
 550        var logFile = Path.Combine(logsDir, $"{buildId}.log");
 551
 552        return new ServerSession
 553        {
 554            BuildId = buildId,
 555            Token = token,
 556            LogFile = logFile,
 557            DashboardSource = dashboardSource,
 558            LoadDashboardHtml = loadDashboard,
 559            AllowedRoot = allowedRoot,
 560            ReadOnly = readOnly
 561        };
 562    }
 563
 564    /// <summary>
 565    /// Resout la source de la dashboard HTML, par ordre de preference :
 566    ///   1. src/AspxLint.Web/index.html sur disque (mode dev, hot-reload)
 567    ///   2. AspxLint.Web/index.html (si on tourne depuis src/)
 568    ///   3. Ressource embarquee "AspxLint.Web.index.html" dans le .dll
 569    ///      (mode .exe self-contained, conteneur Docker, etc.)
 570    /// </summary>
 571    private static (Func<Task<string>> Load, string Source, string? ProjectRoot) ResolveDashboard()
 572    {
 573        // Disk d'abord pour le hot-reload en dev.
 574        var diskCandidate = FindUpwards(Path.Combine("src", "AspxLint.Web", "index.html"))
 575                         ?? FindUpwards(Path.Combine("AspxLint.Web", "index.html"));
 576        if (diskCandidate != null)
 577        {
 578            var path = diskCandidate;
 579            var root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(path)));
 580            return (() => File.ReadAllTextAsync(path), $"disk:{path}", root);
 581        }
 582
 583        // Fallback : ressource embarquee.
 584        var asm = typeof(ServerHost).Assembly;
 585        const string resourceName = "AspxLint.Web.index.html";
 586        using (var probe = asm.GetManifestResourceStream(resourceName))
 587        {
 588            if (probe == null)
 589            {
 590                var available = string.Join(", ", asm.GetManifestResourceNames());
 591                throw new FileNotFoundException(
 592                    $"Dashboard HTML introuvable. Ressources embarquees disponibles : [{available}]");
 593            }
 594        }
 595
 596        return (LoadEmbedded, $"embedded:{resourceName}", null);
 597
 598        static async Task<string> LoadEmbedded()
 599        {
 600            var asm = typeof(ServerHost).Assembly;
 601            using var stream = asm.GetManifestResourceStream("AspxLint.Web.index.html")!;
 602            using var reader = new StreamReader(stream);
 603            return await reader.ReadToEndAsync();
 604        }
 605    }
 606
 607    /// <summary>
 608    /// Logs : a cote du repo en dev (logs/), dans %LOCALAPPDATA%\AspxLint\logs
 609    /// quand on est en mode embedded (pas de repo a cote du .exe).
 610    /// </summary>
 611    private static string ResolveLogDir(string? projectRoot)
 612    {
 613        if (projectRoot != null)
 614            return Path.Combine(projectRoot, "logs");
 615
 616        var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
 617        return Path.Combine(appData, "AspxLint", "logs");
 618    }
 619
 620    static string? FindUpwards(string relativePath)
 621    {
 622        var dir = AppContext.BaseDirectory;
 623        for (int i = 0; i < 10; i++)
 624        {
 625            var candidate = Path.Combine(dir, relativePath);
 626            if (File.Exists(candidate)) return candidate;
 627            var parent = Directory.GetParent(dir);
 628            if (parent == null) return null;
 629            dir = parent.FullName;
 630        }
 631        return null;
 632    }
 633
 634    /// <summary>
 635    /// Choisit l'interface IPv4 la plus probable pour servir le LAN domestique.
 636    /// Penalite forte sur switches virtuels (Hyper-V, WSL, Docker, VirtualBox),
 637    /// bonus sur ranges RFC1918 192.168.* / 10.*, bonus sur Wi-Fi et Ethernet physiques.
 638    /// L'option --interface (substring case-insensitive) ecrase tout.
 639    /// </summary>
 640    public static string ResolveLocalIPv4(string? preferredInterface = null)
 641    {
 642        try
 643        {
 644            var candidates = new List<(int score, string ip, string name)>();
 645            string[] virtualMarkers =
 646            {
 647                "vethernet", "wsl", "virtualbox", "vmware",
 648                "docker", "hyper-v", "tap", "tun", "loopback"
 649            };
 650
 651            foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
 652            {
 653                if (ni.OperationalStatus != OperationalStatus.Up) continue;
 654                if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
 655
 656                foreach (var addr in ni.GetIPProperties().UnicastAddresses)
 657                {
 658                    if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
 659                    if (IPAddress.IsLoopback(addr.Address)) continue;
 660
 661                    var ip = addr.Address.ToString();
 662                    var name = (ni.Name + " " + ni.Description).ToLowerInvariant();
 663                    int score = 0;
 664
 665                    if (!string.IsNullOrEmpty(preferredInterface) &&
 666                        name.Contains(preferredInterface.ToLowerInvariant()))
 667                        score += 10000;
 668
 669                    if (virtualMarkers.Any(m => name.Contains(m))) score -= 500;
 670
 671                    if (ni.NetworkInterfaceType == NetworkInterfaceType.Wireless80211) score += 100;
 672                    else if (ni.NetworkInterfaceType == NetworkInterfaceType.Ethernet) score += 80;
 673
 674                    if (ip.StartsWith("192.168.")) score += 200;
 675                    else if (ip.StartsWith("10.")) score += 150;
 676                    else if (ip.StartsWith("172."))
 677                    {
 678                        var parts = ip.Split('.');
 679                        if (parts.Length > 1 && int.TryParse(parts[1], out var b) && b >= 16 && b <= 31)
 680                            score += 50;
 681                    }
 682
 683                    candidates.Add((score, ip, ni.Name));
 684                }
 685            }
 686
 687            if (candidates.Count == 0) return "127.0.0.1";
 688            return candidates.OrderByDescending(c => c.score).First().ip;
 689        }
 690        catch
 691        {
 692            return "127.0.0.1";
 693        }
 694    }
 695}