diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 0000000..c8f9cb6 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,78 @@ +name: Samples Dev Staging Deployment Script + +on: + pull_request: + branches: [ "main" ] + workflow_dispatch: +concurrency: + group: samples + cancel-in-progress: true + +jobs: + build: + runs-on: [ ubuntu-latest ] + timeout-minutes: 90 + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x + + - name: Ensure wasm tools are installed + run: dotnet workload install wasm-tools + + # Add appsettings.json to apps + - name: Add appsettings.json + shell: pwsh + run: | + dotnet ./samples/build-tools/linux-x64/BuildAppSettings.dll ` + -k "${{ secrets.ARCGISAPIKEY }}" ` + -l "${{ secrets.SAMPLES_GEOBLAZOR_DEV_LICENSE_KEY }}" ` + -b "${{ secrets.SAMPLES_API_BYPASS_KEY }}" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.Client/wwwroot/appsettings.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.Client/wwwroot/appsettings.Production.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/appsettings.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/appsettings.Production.json" + + # Publishes the Samples project + # DON'T PRE-RESTORE, PRE-BUILD, IT BREAKS IN DOCKER -- SEE https://github.com/dotnet/aspnetcore/issues/63962 + - name: Publish Samples + shell: pwsh + run: | + dotnet publish ./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.csproj ` + -c Release ` + /p:RunAOT=true ` + /m ` + --output samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/published + + - name: Upload Samples Artifacts + uses: actions/upload-artifact@v4 + with: + name: dev-webapp + path: samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/published + + # This step will deploy the Pro Blazor Samples app to Azure develop slot + deploy: + runs-on: [ ubuntu-latest ] + needs: [ build ] + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: dev-webapp + path: published + - name: Azure Login + uses: azure/login@v2 + with: + creds: ${{ secrets.DY_GEOBLAZOR_AZURE_DEPLOY_ID }} + - name: Deploy to Azure Web App + uses: azure/webapps-deploy@v3 + with: + app-name: dy-blazor-samples-server + package: published + slot-name: develop + startup-command: 'dotnet dymaptic.GeoBlazor.Pro.Sample.WebApp.dll' \ No newline at end of file diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..1eed1ab --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,77 @@ +name: Samples Production Release Deployment Script + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build: + runs-on: [ ubuntu-latest ] + timeout-minutes: 90 + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.x + + - name: Ensure wasm tools are installed + run: dotnet workload install wasm-tools + + # Add appsettings.json to apps + - name: Add appsettings.json + shell: pwsh + run: | + dotnet ./samples/build-tools/linux-x64/BuildAppSettings.dll ` + -k "${{ secrets.ARCGISAPIKEY }}" ` + -l "${{ secrets.SAMPLES_GEOBLAZOR_LICENSE_KEY }}" ` + -b "${{ secrets.SAMPLES_API_BYPASS_KEY }}" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.Client/wwwroot/appsettings.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.Client/wwwroot/appsettings.Production.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/appsettings.json" ` + -o "./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/appsettings.Production.json" + + # Publishes the Samples project + # DON'T PRE-RESTORE, PRE-BUILD, IT BREAKS IN DOCKER -- SEE https://github.com/dotnet/aspnetcore/issues/63962 + - name: Publish Samples + shell: pwsh + run: | + dotnet publish ./samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp.csproj ` + -c Release ` + --no-restore ` + /p:RunAOT=true ` + /m ` + --output samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/published + + - name: Upload Samples Artifacts + uses: actions/upload-artifact@v4 + with: + name: webapp + path: samples/pro/dymaptic.GeoBlazor.Pro.Sample.WebApp/dymaptic.GeoBlazor.Pro.Sample.WebApp/published + + + # This step will deploy the Pro Blazor Samples app to Azure + deploy: + runs-on: [ ubuntu-latest ] + needs: [ build ] + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: webapp + path: published + - name: Azure Login + uses: azure/login@v2 + with: + creds: ${{ secrets.DY_GEOBLAZOR_AZURE_DEPLOY_ID }} + - name: Deploy to Azure Web App + uses: azure/webapps-deploy@v3 + with: + app-name: dy-blazor-samples-server + package: published + slot-name: staging + startup-command: 'dotnet dymaptic.GeoBlazor.Pro.Sample.WebApp.dll' \ No newline at end of file diff --git a/samples/build-tools/Directory.Build.props b/samples/build-tools/Directory.Build.props new file mode 100644 index 0000000..0051217 --- /dev/null +++ b/samples/build-tools/Directory.Build.props @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/build-tools/Directory.Build.targets b/samples/build-tools/Directory.Build.targets new file mode 100644 index 0000000..0051217 --- /dev/null +++ b/samples/build-tools/Directory.Build.targets @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/build-tools/build-scripts/BuildAppSettings.cs b/samples/build-tools/build-scripts/BuildAppSettings.cs new file mode 100644 index 0000000..b0ea8c7 --- /dev/null +++ b/samples/build-tools/build-scripts/BuildAppSettings.cs @@ -0,0 +1,181 @@ +#!/usr/bin/env dotnet + +// Build AppSettings Script +// C# file-based app version of buildAppSettings.ps1 +// Generates appsettings.json files for test applications. +// +// Usage: dotnet BuildAppSettings.cs [options] +// -k, --api-key ArcGIS API key (required) +// -l, --license-key GeoBlazor license key (required) +// -o, --output Output path(s) for appsettings.json (required, can specify multiple) +// -d, --docs-url Documentation URL (default: https://docs.geoblazor.com) +// -b, --bypass-key API bypass key for samples (optional) +// -w, --wfs-servers Additional WFS server configuration JSON fragment (optional) +// -h, --help Display help message +// +// Example: +// dotnet BuildAppSettings.cs -k "your-arcgis-key" -l "your-license" -o "./appsettings.json" +// dotnet BuildAppSettings.cs -k "key" -l "license" -o "./app1/appsettings.json" -o "./app2/appsettings.json" + +using System.Text; + +string? arcGISApiKey = null; +string? licenseKey = null; +List outputPaths = []; +string docsUrl = "https://docs.geoblazor.com"; +string byPassApiKey = ""; +string wfsServers = ""; +bool help = false; + +// Parse command line arguments +for (int i = 0; i < args.Length; i++) +{ + string arg = args[i]; + switch (arg.ToLowerInvariant()) + { + case "-k": + case "--api-key": + if (i + 1 < args.Length) + { + arcGISApiKey = args[++i]; + } + break; + case "-l": + case "--license-key": + if (i + 1 < args.Length) + { + licenseKey = args[++i]; + } + break; + case "-o": + case "--output": + if (i + 1 < args.Length) + { + outputPaths.Add(args[++i]); + } + break; + case "-d": + case "--docs-url": + if (i + 1 < args.Length) + { + docsUrl = args[++i]; + } + break; + case "-b": + case "--bypass-key": + if (i + 1 < args.Length) + { + byPassApiKey = args[++i]; + } + break; + case "-w": + case "--wfs-servers": + if (i + 1 < args.Length) + { + wfsServers = args[++i]; + } + break; + case "-h": + case "--help": + help = true; + break; + } +} + +if (help) +{ + Console.WriteLine("Build AppSettings Script"); + Console.WriteLine("Generates appsettings.json files for test applications."); + Console.WriteLine(); + Console.WriteLine("Usage: dotnet BuildAppSettings.cs [options]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -k, --api-key ArcGIS API key (required)"); + Console.WriteLine(" -l, --license-key GeoBlazor license key (required)"); + Console.WriteLine(" -o, --output Output path(s) for appsettings.json (required, can specify multiple)"); + Console.WriteLine(" -d, --docs-url Documentation URL (default: https://docs.geoblazor.com)"); + Console.WriteLine(" -b, --bypass-key API bypass key for samples (optional)"); + Console.WriteLine(" -w, --wfs-servers Additional WFS server configuration JSON fragment (optional)"); + Console.WriteLine(" -h, --help Display this help message"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" dotnet BuildAppSettings.cs -k \"your-arcgis-key\" -l \"your-license\" -o \"./appsettings.json\""); + Console.WriteLine(" dotnet BuildAppSettings.cs -k \"key\" -l \"license\" -o \"./app1/appsettings.json\" -o \"./app2/appsettings.json\""); + return 0; +} + +// Validate required parameters +if (string.IsNullOrWhiteSpace(arcGISApiKey)) +{ + Console.Error.WriteLine("Error: ArcGIS API key is required. Use -k or --api-key to specify."); + return 1; +} + +if (string.IsNullOrWhiteSpace(licenseKey)) +{ + Console.Error.WriteLine("Error: GeoBlazor license key is required. Use -l or --license-key to specify."); + return 1; +} + +if (outputPaths.Count == 0) +{ + Console.Error.WriteLine("Error: At least one output path is required. Use -o or --output to specify."); + return 1; +} + +// Build the appsettings JSON content +var sb = new StringBuilder(); +sb.AppendLine("{"); +sb.AppendLine($" \"ArcGISApiKey\": \"{EscapeJsonString(arcGISApiKey)}\","); +sb.AppendLine(" \"GeoBlazor\": {"); +sb.AppendLine($" \"LicenseKey\": \"{EscapeJsonString(licenseKey)}\""); +sb.AppendLine(" },"); +sb.AppendLine($" \"DocsUrl\": \"{EscapeJsonString(docsUrl)}\","); +sb.Append($" \"ByPassApiKey\": \"{EscapeJsonString(byPassApiKey)}\""); + +// Add WFS servers if provided +if (!string.IsNullOrWhiteSpace(wfsServers)) +{ + sb.AppendLine(","); + sb.Append($" {wfsServers}"); +} + +sb.AppendLine(); +sb.AppendLine("}"); + +string appSettingsContent = sb.ToString(); + +// Write to each target path +foreach (string path in outputPaths) +{ + try + { + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(path, appSettingsContent, Encoding.UTF8); + Console.WriteLine($"Created: {path}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error writing to {path}: {ex.Message}"); + return 1; + } +} + +Console.WriteLine("AppSettings files generated successfully."); +return 0; + +// Helper function to escape JSON string values +static string EscapeJsonString(string value) +{ + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); +} diff --git a/samples/build-tools/build-scripts/ConsoleDialog.cs b/samples/build-tools/build-scripts/ConsoleDialog.cs new file mode 100644 index 0000000..7de6d73 --- /dev/null +++ b/samples/build-tools/build-scripts/ConsoleDialog.cs @@ -0,0 +1,597 @@ +#!/usr/bin/env dotnet + +// Console Dialog - Build Progress Display Window +// =============================================== +// Manages a console window for displaying log messages during source generation +// and build processes. Opens a separate terminal window that tails a log file, +// allowing real-time visibility of build progress. +// +// Usage: +// dotnet ConsoleDialog.cs [title] [options] +// dotnet ConsoleDialog.cs "GeoBlazor Build" Start with custom title +// dotnet ConsoleDialog.cs "Build" -w 5 -t 120 Custom wait/timeout +// +// Options: +// -w, --wait Seconds to wait before closing on exit (default: 3) +// -t, --timeout Idle timeout before auto-close (default: 60) +// +// Communication: +// The dialog reads from stdin. Send lines of text to display in the console window. +// Special commands: +// "hold" - Prevent auto-timeout (keeps window open indefinitely) +// "exit" - Close the console window +// +// Cross-Platform Support: +// - Windows: Opens PowerShell 7 (pwsh) window with Get-Content -Wait +// - macOS: Opens Terminal.app via osascript +// - Linux: Tries gnome-terminal, konsole, xfce4-terminal, or xterm +// +// Note: Messages are written to a temp file and tailed by the console window. + +using System.Diagnostics; + +object _consoleLock = new(); +Process? _consoleProcess = null; +string? _consoleTempFile = null; + +string? title = null; +int wait = 3; +int idleTimeout = 300; + +for (int i = 0; i < args.Length; i++) +{ + string arg = args[i]; + + switch (arg) + { + case "-w": + case "--wait": + wait = int.TryParse(args[i + 1], out int parsedWait) ? parsedWait : wait; + i++; + break; + case "-t": + case "--timeout": + idleTimeout = int.TryParse(args[i + 1], out int parsedTimeout) ? parsedTimeout : idleTimeout; + i++; + break; + default: + if (title is null) + { + title = arg; + } + else + { + title = $"{title} {arg}"; + } + break; + } +} + +title ??= "GeoBlazor Build"; + +/// +/// Shows or updates the console window with a new message. +/// Creates the temp log file and starts the console window on first call. +/// +/// The title for the console window. +/// The message to display (empty string to just ensure window is open). +void ShowOrUpdateConsole(string title, string message) +{ + lock (_consoleLock) + { + // Ensure the temp file exists (create if needed) + if (_consoleTempFile is null || !File.Exists(_consoleTempFile)) + { + _consoleTempFile = Path.Combine(Path.GetTempPath(), $"geoblazor_sourcegen_{Guid.NewGuid():N}.log"); + // Create the file immediately so Get-Content -Wait has something to tail + File.WriteAllText(_consoleTempFile, $" {Environment.NewLine}"); + } + + if (!string.IsNullOrWhiteSpace(message)) + { + // Append message to the temp file + string timestamp = DateTime.Now.ToString("HH:mm:ss"); + string logLine = $"[{timestamp}] {message}{Environment.NewLine}"; + File.AppendAllText(_consoleTempFile, logLine); + } + + // Start the console window if not already running + if (_consoleProcess is null || _consoleProcess.HasExited) + { + StartConsoleWindow(title); + } + } +} + +/// +/// Detects if running under Windows Subsystem for Linux. +/// +bool IsWsl() +{ + try + { + if (File.Exists("/proc/version")) + { + string version = File.ReadAllText("/proc/version"); + return version.Contains("microsoft", StringComparison.OrdinalIgnoreCase); + } + } + catch { } + return false; +} + +/// +/// Starts a platform-specific console window that tails the log file. +/// Dispatches to Windows, macOS, WSL, or Linux-specific implementations. +/// +/// The title for the console window. +void StartConsoleWindow(string title) +{ + string windowTitle = string.IsNullOrWhiteSpace(title) ? "GeoBlazor Build" : title; + try + { + if (OperatingSystem.IsWindows()) + { + StartWindowsConsole(windowTitle); + } + else if (OperatingSystem.IsMacOS()) + { + StartMacConsole(windowTitle); + } + else if (OperatingSystem.IsLinux()) + { + if (IsWsl()) + { + StartWslConsole(windowTitle); + } + else + { + StartLinuxConsole(windowTitle); + } + } + } + catch + { + // Console window creation failed - continue silently + // Messages are still written to the temp file and MSBuild diagnostics + } +} + +/// +/// Starts a PowerShell 7 console window on Windows using Get-Content -Wait to tail the log file. +/// +/// The title for the console window. +void StartWindowsConsole(string title) +{ + string escapedPath = _consoleTempFile!.Replace("'", "''"); + string command = $"$Host.UI.RawUI.WindowTitle = '{title}'; " + + $"Write-Host '{title}' -ForegroundColor Cyan; " + + $"Write-Host ('=' * 120); " + + $"Get-Content -Path '{escapedPath}' -Wait -Tail 100"; + + _consoleProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "pwsh", + Arguments = $"-NoProfile -NoLogo -Command \"{command}\"", + UseShellExecute = true, + CreateNoWindow = false + } + }; + _consoleProcess.Start(); +} + +/// +/// Starts a Terminal.app window on macOS using osascript/AppleScript. +/// Uses tail -f for log following (no pwsh dependency required). +/// +void StartMacConsole(string title) +{ + // Escape for single-quoted shell strings + string shellTitle = title.Replace("'", "'\\''"); + string shellPath = _consoleTempFile!.Replace("'", "'\\''"); + + // Build the shell command for the visible Terminal window + // clear removes the login banner and echoed command that Terminal.app shows + string shellCommand = $"clear; echo '{shellTitle}'; echo '{new string('=', 80)}'; tail -f '{shellPath}'"; + + // Escape the shell command for embedding in an AppleScript double-quoted string + string asCommand = shellCommand.Replace("\\", "\\\\").Replace("\"", "\\\""); + + // Pass AppleScript via stdin to avoid nested shell escaping issues + string appleScript = "tell application \"Terminal\"\n" + + " activate\n" + + $" do script \"{asCommand}\"\n" + + "end tell"; + + var osascript = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "osascript", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true + } + }; + osascript.Start(); + osascript.StandardInput.Write(appleScript); + osascript.StandardInput.Close(); + osascript.WaitForExit(5000); + + // osascript exits immediately after telling Terminal to open, so start a + // long-lived sentinel process for lifecycle tracking by the main loop + _consoleProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "sleep", + Arguments = "86400", + UseShellExecute = false, + CreateNoWindow = true + } + }; + _consoleProcess.Start(); +} + +/// +/// Starts a console window from WSL by using Windows Terminal (wt.exe) to open +/// a new tab that runs tail -f back inside WSL. Falls back to standard Linux +/// terminals if Windows Terminal is not available. +/// +/// +/// Uses a temp script file instead of bash -c to avoid wt.exe interpreting +/// semicolons as its own command delimiters. +/// +void StartWslConsole(string title) +{ + // Write a helper bash script to avoid wt.exe's ';' delimiter parsing issues. + // wt.exe treats ';' as a separator for multiple tab/pane commands, so passing + // "echo '...'; tail -f '...'" via bash -c gets split into separate WT commands. + string scriptFile = _consoleTempFile + ".sh"; + string shellTitle = title.Replace("'", "'\\''"); + string shellPath = _consoleTempFile!.Replace("'", "'\\''"); + File.WriteAllText(scriptFile, + $"#!/bin/bash\necho '{shellTitle}'\necho '{new string('=', 80)}'\ntail -f '{shellPath}'\n"); + + // Try Windows Terminal (wt.exe) - available on most modern Windows 10/11 + WSL setups + try + { + var startInfo = new ProcessStartInfo + { + FileName = "wt.exe", + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("new-tab"); + startInfo.ArgumentList.Add("--title"); + startInfo.ArgumentList.Add(title); + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add("wsl.exe"); + startInfo.ArgumentList.Add("bash"); + startInfo.ArgumentList.Add(scriptFile); + + var wtProcess = new Process { StartInfo = startInfo }; + wtProcess.Start(); + wtProcess.WaitForExit(5000); // wt.exe exits immediately after dispatching + + // wt.exe exits immediately (delegates to Windows Terminal server), + // so use a sentinel process for lifecycle tracking + _consoleProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "sleep", + Arguments = "86400", + UseShellExecute = false, + CreateNoWindow = true + } + }; + _consoleProcess.Start(); + return; + } + catch + { + // wt.exe not available, clean up script and fall through + try { File.Delete(scriptFile); } catch { } + } + + // Fallback: try standard Linux terminal emulators (unlikely to work in WSL, but try anyway) + StartLinuxConsole(title); +} + +/// +/// Starts a terminal window on Linux by trying common terminal emulators +/// (gnome-terminal, konsole, xfce4-terminal, xterm) until one succeeds. +/// Uses tail -f for log following (no pwsh dependency required). +/// +void StartLinuxConsole(string title) +{ + string shellTitle = title.Replace("'", "'\\''"); + string shellPath = _consoleTempFile!.Replace("'", "'\\''"); + + // Shell command to display title banner and follow the log file + string shellCommand = $"echo '{shellTitle}'; echo '{new string('=', 80)}'; tail -f '{shellPath}'"; + + // Try common Linux terminal emulators in order of popularity + // Use ArgumentList to pass args directly as argv, avoiding shell escaping issues + string[] terminals = ["gnome-terminal", "konsole", "xfce4-terminal", "xterm"]; + + foreach (string terminal in terminals) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = terminal, + UseShellExecute = false, + CreateNoWindow = true + }; + + // Each terminal uses a different flag to specify the command to run + if (terminal == "gnome-terminal") + { + // gnome-terminal uses -- to separate its args from the child command + startInfo.ArgumentList.Add("--"); + } + else + { + // konsole, xfce4-terminal, xterm all use -e + startInfo.ArgumentList.Add("-e"); + } + + startInfo.ArgumentList.Add("bash"); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(shellCommand); + + var terminalProcess = new Process { StartInfo = startInfo }; + terminalProcess.Start(); + + // gnome-terminal exits immediately (delegates to the GNOME Terminal server), + // so use a sentinel process for lifecycle tracking, consistent with macOS + _consoleProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "sleep", + Arguments = "86400", + UseShellExecute = false, + CreateNoWindow = true + } + }; + _consoleProcess.Start(); + + return; // Success, exit the loop + } + catch + { + // This terminal emulator not available, try the next one + } + } + + // No terminal emulator found - messages still go to temp file and diagnostics +} + +/// +/// Closes the Terminal.app window on macOS by finding and closing the tab +/// that is running tail -f on our temp file. +/// +void CloseMacTerminalWindow(string tempFilePath) +{ + try + { + string escapedPath = tempFilePath.Replace("\\", "\\\\").Replace("\"", "\\\""); + + // AppleScript to find and close the Terminal tab running our tail command + string appleScript = + "tell application \"Terminal\"\n" + + " repeat with w in windows\n" + + " repeat with t in tabs of w\n" + + $" if processes of t contains \"tail\" and history of t contains \"{escapedPath}\" then\n" + + " close w\n" + + " return\n" + + " end if\n" + + " end repeat\n" + + " end repeat\n" + + "end tell"; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "osascript", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true + } + }; + process.Start(); + process.StandardInput.Write(appleScript); + process.StandardInput.Close(); + process.WaitForExit(5000); + } + catch + { + // Terminal may have already been closed + } +} + +/// +/// Closes a Linux terminal window by killing the tail process that is +/// following our specific temp file. When tail exits, bash -c completes, +/// and the terminal emulator closes the window. +/// +void CloseLinuxTerminalWindow(string tempFilePath) +{ + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "pkill", + UseShellExecute = false, + CreateNoWindow = true + } + }; + // Use ArgumentList so the pattern is passed as a single argv element + process.StartInfo.ArgumentList.Add("-f"); + process.StartInfo.ArgumentList.Add($"tail -f {tempFilePath}"); + process.Start(); + process.WaitForExit(5000); + } + catch + { + // Process may have already exited + } +} + +bool _cleanupComplete = false; + +/// +/// Closes the console window gracefully, waiting for final messages to display +/// before killing the process and cleaning up the temp file. +/// +/// The title (used in closing message). +/// Seconds to wait before killing the process. +void CloseConsole(string title, int wait) +{ + lock (_consoleLock) + { + try + { + if (_consoleProcess is { HasExited: false } && _consoleTempFile is not null) + { + File.WriteAllText(_consoleTempFile, $"[{DateTime.Now:HH:mm:ss}] {title}: Console closing..."); + // Give a brief moment for final messages to appear + Thread.Sleep(wait * 1000); + _consoleProcess.Kill(); + _consoleProcess.Dispose(); + } + + // Close the platform-specific terminal window (sentinel process doesn't own it) + if (OperatingSystem.IsMacOS() && _consoleTempFile is not null) + { + CloseMacTerminalWindow(_consoleTempFile); + } + else if (OperatingSystem.IsLinux() && _consoleTempFile is not null) + { + CloseLinuxTerminalWindow(_consoleTempFile); + } + + // delete the temp file and WSL helper script + if (_consoleTempFile is not null) + { + File.Delete(_consoleTempFile); + // Clean up the WSL helper script if it exists + string scriptFile = _consoleTempFile + ".sh"; + if (File.Exists(scriptFile)) File.Delete(scriptFile); + } + } + catch + { + // Process may have already exited + } + finally + { + _consoleProcess = null; + } + + // Clean up temp file + try + { + if (_consoleTempFile is not null && File.Exists(_consoleTempFile)) + { + File.Delete(_consoleTempFile); + } + } + catch + { + // File may be locked - ignore + } + finally + { + _consoleTempFile = null; + _cleanupComplete = true; + } + } +} + +ShowOrUpdateConsole(title, string.Empty); + +bool hold = false; +long lastMessageTicks = DateTime.UtcNow.Ticks; + +_ = Task.Run(async () => +{ + while (_consoleProcess is null || !_consoleProcess.HasExited) + { + await Task.Delay(1000); + + if (!hold && (DateTime.UtcNow.Ticks - Volatile.Read(ref lastMessageTicks)) > (long)idleTimeout * TimeSpan.TicksPerSecond) + { + Console.WriteLine("Console dialog timed out. Closing..."); + CloseConsole(title, wait); + Environment.Exit(0); + } + } + Console.WriteLine("Console window closed. Exiting..."); + CloseConsole(title, wait); + Environment.Exit(0); +}); + +// Handle Ctrl-C gracefully +Console.CancelKeyPress += (sender, e) => +{ + e.Cancel = true; // Prevent immediate termination to allow cleanup + + _ = Task.Run(() => CloseConsole(title, wait)); + + int timeoutSeconds = wait * 2; + + while (!_cleanupComplete && (timeoutSeconds > 0)) + { + Thread.Sleep(1000); + timeoutSeconds--; + } + + if (_cleanupComplete) + { + Environment.Exit(1); + return; + } +}; + +while (true) +{ + if (_consoleProcess?.HasExited == true) + { + break; + } + + if (Console.ReadLine() is not { } inputLine) + { + Thread.Sleep(100); + continue; + } + + Volatile.Write(ref lastMessageTicks, DateTime.UtcNow.Ticks); + + if (inputLine.Trim().Equals("hold", StringComparison.OrdinalIgnoreCase)) + { + hold = true; + continue; + } + + if (inputLine.Trim().Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + CloseConsole(title, wait); + + break; + } + + ShowOrUpdateConsole(title, inputLine); +} + +Environment.Exit(0); \ No newline at end of file diff --git a/samples/build-tools/build-scripts/Directory.Build.props b/samples/build-tools/build-scripts/Directory.Build.props new file mode 100644 index 0000000..0051217 --- /dev/null +++ b/samples/build-tools/build-scripts/Directory.Build.props @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/build-tools/build-scripts/Directory.Build.targets b/samples/build-tools/build-scripts/Directory.Build.targets new file mode 100644 index 0000000..0051217 --- /dev/null +++ b/samples/build-tools/build-scripts/Directory.Build.targets @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/build-tools/build-scripts/FetchNuGetVersion.cs b/samples/build-tools/build-scripts/FetchNuGetVersion.cs new file mode 100644 index 0000000..60f40f0 --- /dev/null +++ b/samples/build-tools/build-scripts/FetchNuGetVersion.cs @@ -0,0 +1,98 @@ +#!/usr/bin/env dotnet + +// Fetch NuGet Package Version Script +// C# file-based app version of fetchNuGetVersion.ps1 +// Usage: dotnet FetchNuGetVersion.cs +// Example: dotnet FetchNuGetVersion.cs dymaptic.GeoBlazor.Core +// Returns the latest non-prerelease version of the specified package from NuGet.org + +using System.Text.Json; + +if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0])) +{ + Console.Error.WriteLine("Package name must be provided."); + return 1; +} + +string package = args[0]; + +try +{ + // Query NuGet API (same endpoint as the PowerShell script) + string nugetUrl = $"https://azuresearch-usnc.nuget.org/query?q={Uri.EscapeDataString(package)}&prerelease=false"; + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "GeoBlazor-Build-Script"); + + string json = await client.GetStringAsync(nugetUrl); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("data", out var data) || data.GetArrayLength() == 0) + { + Console.Error.WriteLine("Could not determine latest version from NuGet API."); + return 1; + } + + // Find the package with exact match and get the highest version + string? latestVersion = null; + + foreach (var item in data.EnumerateArray()) + { + // Check for exact package name match (case-insensitive) + if (item.TryGetProperty("id", out var idProp)) + { + string? id = idProp.GetString(); + if (string.Equals(id, package, StringComparison.OrdinalIgnoreCase)) + { + if (item.TryGetProperty("version", out var versionProp)) + { + latestVersion = versionProp.GetString(); + break; // NuGet API returns the latest version for each package + } + } + } + } + + // Fallback: if no exact match, try to parse versions from all results + if (latestVersion is null) + { + var versions = new List<(Version Version, string Original)>(); + + foreach (var item in data.EnumerateArray()) + { + if (item.TryGetProperty("version", out var versionProp)) + { + string? versionStr = versionProp.GetString(); + if (versionStr is not null && Version.TryParse(versionStr.Split('-')[0], out var version)) + { + versions.Add((version, versionStr)); + } + } + } + + if (versions.Count > 0) + { + latestVersion = versions.OrderByDescending(v => v.Version).First().Original; + } + } + + if (latestVersion is null) + { + Console.Error.WriteLine("Could not determine latest version from NuGet API."); + return 1; + } + + Console.WriteLine(latestVersion); + return 0; +} +catch (HttpRequestException ex) +{ + Console.Error.WriteLine($"Failed to query NuGet API: {ex.Message}"); + return 1; +} +catch (JsonException ex) +{ + Console.Error.WriteLine($"Failed to parse NuGet API response: {ex.Message}"); + return 1; +} diff --git a/samples/build-tools/build-scripts/ScriptBuilder.cs b/samples/build-tools/build-scripts/ScriptBuilder.cs new file mode 100644 index 0000000..726d70e --- /dev/null +++ b/samples/build-tools/build-scripts/ScriptBuilder.cs @@ -0,0 +1,701 @@ +#!/usr/bin/env dotnet + +// Script Builder - Compiles C# build scripts to DLLs +// ==================================================== +// Builds all C# file-based apps in the build-scripts directory using 'dotnet build'. +// Outputs compiled DLLs to the ../build-tools/ directory for faster execution. +// +// This tool is used to pre-compile the build scripts so they can be run as DLLs +// rather than interpreted C# files, significantly improving startup time. +// +// Usage: +// dotnet ScriptBuilder.cs Build all scripts +// dotnet ScriptBuilder.cs Script1.cs Script2.cs Build only specified scripts +// dotnet ScriptBuilder.cs --exclude Script1.cs Build all except specified scripts +// +// Options: +// --exclude When specified, the listed scripts will be skipped instead of included +// --force, -f Force rebuild of all scripts regardless of changes +// --clean, -c Clean before building +// --linux, -l Build for Linux platform +// --mac, -m Build for macOS platform +// --win, -w Build for Windows platform +// --allPlatforms Build for all platforms +// --help, -h Show this help message +// +// Output: +// Compiled DLLs are placed in GeoBlazor/build-tools/ directory +// +// Note: ScriptBuilder.cs itself is always skipped to avoid self-compilation issues. + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.RegularExpressions; + +bool excludeMode = false; +HashSet scriptsToProcess = new(); +string scriptsDir = GetScriptsDirectory(); +string repoRoot = Path.Combine(scriptsDir, "..", "..", ".."); +string buildToolsDir = Path.GetFullPath(Path.Combine(scriptsDir, "..")); + +Trace.Listeners.Add(new TextWriterTraceListener(Console.Out)); +Trace.WriteLine("Starting ScriptBuilder..."); +Trace.WriteLine($"Scripts directory: {scriptsDir}"); + +string[] scripts = Directory.GetFiles(scriptsDir, "*.cs"); + +bool force = false; +bool cleanBeforeBuild = false; +bool allPlatforms = false; +string os = OperatingSystem.IsWindows() + ? "win" + : OperatingSystem.IsMacOS() + ? "osx" + : "linux"; +string arch = RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(); +string runtime = $"{os}-{arch}"; + +for (int i = 0; i < args.Length; i++) +{ + string arg = args[i]; + + switch (arg.ToLowerInvariant()) + { + case "--clean": + case "-c": + cleanBeforeBuild = true; + break; + case "--exclude": + excludeMode = true; + break; + case "--force": + case "-f": + force = true; + break; + case "--linux": + case "-l": + runtime = "linux-x64"; + break; + case "--mac": + case "-m": + runtime = "osx-arm64"; + break; + case "--win": + case "-w": + runtime = "win-x64"; + break; + case "--allplatforms": + allPlatforms = true; + break; + case "--help": + case "-h": + Console.WriteLine("Usage: dotnet ScriptBuilder.cs [options] [script1.cs script2.cs ...]"); + Console.WriteLine("Options:"); + Console.WriteLine(" --exclude Exclude listed scripts instead of including them"); + Console.WriteLine(" --force, -f Force rebuild of all scripts"); + Console.WriteLine(" --clean, -c Clean before building"); + Console.WriteLine(" --linux, -l Build for Linux platform"); + Console.WriteLine(" --mac, -m Build for macOS platform"); + Console.WriteLine(" --win, -w Build for Windows platform"); + Console.WriteLine(" --allPlatforms Build for all platforms"); + Console.WriteLine(" --help, -h Show this help message"); + return 0; + default: + scriptsToProcess.Add(arg); + break; + } +} + +string currentBranch = GetCurrentGitBranch(repoRoot); +List platforms = allPlatforms ? ["linux-x64", "osx-arm64", "win-x64"] : [runtime]; + +int result = -1; + +string utilitiesDir = Path.GetFullPath(Path.Combine(scriptsDir, "..", "utilities")); +string[] utilitiesProjectFiles = Directory.GetFiles(utilitiesDir, "*.csproj", SearchOption.AllDirectories); +HashSet updatedUtilities = []; + +using CancellationTokenSource cts = new(); + +foreach (string platform in platforms) +{ + try + { + string outDir = Path.GetFullPath(Path.Combine(buildToolsDir, platform)); + Trace.WriteLine($"Output directory: {outDir}"); + Directory.CreateDirectory(outDir); + + string recordFile = Path.Combine(outDir, ".csbuild-record.json"); + (long timeStamp, string oldBranch) = GetLastBuildRecord(recordFile); + bool branchChanged = oldBranch != currentBranch; + + // Build Utilities first since other scripts may depend on them + foreach (string utilityProj in utilitiesProjectFiles) + { + string projectName = Path.GetFileNameWithoutExtension(utilityProj); + if (force || branchChanged || CheckIfNeedsBuild(timeStamp, utilityProj, outDir, utilitiesDir)) + { + // restore first + Trace.WriteLine($"Cleaning {projectName}..."); + result = await CleanScript(utilityProj, utilitiesDir, outDir, platform, cts.Token); + if (result != 0) + { + Trace.WriteLine($"Failed to clean {projectName} with exit code {result}"); + cts.Cancel(); + break; + } + Trace.WriteLine($"Restoring {projectName}..."); + result = await RestoreScript(utilityProj, utilitiesDir, outDir, platform, cts.Token); + if (result != 0) + { + Trace.WriteLine($"Failed to restore {projectName} with exit code {result}"); + cts.Cancel(); + break; + } + Trace.WriteLine($"Building {projectName}..."); + result = await BuildScript(utilityProj, utilitiesDir, outDir, platform, cts.Token); + if (result != 0) + { + Trace.WriteLine($"Failed to build {projectName} with exit code {result}"); + cts.Cancel(); + break; + } + updatedUtilities.Add(utilityProj); + } + else + { + Trace.WriteLine($"Skipping {projectName} - no changes detected."); + } + } + + // Build Scripts + result = await BuildScripts(scripts, scriptsToProcess, scriptsDir, outDir, platform, force, + cleanBeforeBuild, branchChanged, timeStamp, excludeMode, updatedUtilities, cts); + + if (result == 0) + { + SaveBuildRecord(recordFile, currentBranch); + } + else + { + Trace.WriteLine($"Failed to build scripts for platform {platform} with exit code {result}"); + cts.Cancel(); + break; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Trace.WriteLine($"Exception occurred while building scripts for platform {platform}: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); + cts.Cancel(); + } +} + +if (result != 0) +{ + Trace.WriteLine($"ScriptBuilder failed with exit code {result}"); +} + +return result; + +static async Task BuildScripts(string[] scripts, HashSet scriptsToProcess, string scriptsDir, string outDir, + string runtime, bool force, bool cleanBeforeBuild, bool branchChanged, long timeStamp, bool excludeMode, + HashSet updatedUtilities, CancellationTokenSource cts) +{ + List filteredScripts; + if (scriptsToProcess.Count > 0) + { + Trace.WriteLine(excludeMode + ? $"Excluding specified scripts: {string.Join(", ", scriptsToProcess)}" + : $"Including only specified scripts: {string.Join(", ", scriptsToProcess)}"); + + filteredScripts = excludeMode + ? scripts.Where(s => !scriptsToProcess.Contains(Path.GetFileName(s))).ToList() + : scripts.Where(s => scriptsToProcess.Contains(Path.GetFileName(s))).ToList(); + } + else + { + filteredScripts = scripts.ToList(); + } + + filteredScripts.RemoveAll(s => Path.GetFileName(s) == "ScriptBuilder.cs"); + + Dictionary> scriptReferences = []; + + foreach (string script in filteredScripts) + { + scriptReferences[script] = GetScriptReferences(script); + } + + int returnCode = 0; + + using SemaphoreSlim semaphore = new(1, 1); + + if (cleanBeforeBuild) + { + await Parallel.ForEachAsync(filteredScripts, cts.Token, async (script, token) => + { + string fileName = Path.GetFileName(script); + + if (token.IsCancellationRequested) + { + Trace.WriteLine($"Build cancelled. Skipping script: {fileName}"); + return; + } + + if (!force && !branchChanged && !CheckIfNeedsBuild(timeStamp, script, outDir, scriptsDir) + && !updatedUtilities.Intersect(scriptReferences[script]).Any()) + { + Trace.WriteLine($"Skipping unchanged script: {fileName}"); + return; + } + + if (scriptReferences[script].Any()) + { + await semaphore.WaitAsync(token); + } + + try + { + int scriptReturnCode = await CleanScript(fileName, scriptsDir, outDir, runtime, cts.Token); + + if (scriptReturnCode != 0) + { + Trace.WriteLine($"Failed to clean script: {fileName} with exit code {scriptReturnCode}"); + returnCode = scriptReturnCode; + cts.Cancel(); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Trace.WriteLine($"Exception occurred while cleaning script {fileName}: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); + returnCode = 1; + cts.Cancel(); + } + finally + { + if (scriptReferences[script].Any()) + { + semaphore.Release(); + } + } + }); + } + + await Parallel.ForEachAsync(filteredScripts, cts.Token, async (script, token) => + { + string fileName = Path.GetFileName(script); + + if (token.IsCancellationRequested) + { + Trace.WriteLine($"Build cancelled. Skipping script: {fileName}"); + return; + } + + if (!force && !branchChanged && !CheckIfNeedsBuild(timeStamp, script, outDir, scriptsDir) + && !updatedUtilities.Intersect(scriptReferences[script]).Any()) + { + Trace.WriteLine($"Skipping unchanged script: {fileName}"); + return; + } + + if (scriptReferences[script].Any()) + { + await semaphore.WaitAsync(token); + } + + try + { + int scriptReturnCode = await BuildScript(fileName, scriptsDir, outDir, runtime, cts.Token); + + if (scriptReturnCode != 0) + { + Trace.WriteLine($"Failed to build script: {fileName} with exit code {scriptReturnCode}"); + returnCode = scriptReturnCode; + cts.Cancel(); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Trace.WriteLine($"Exception occurred while building script {fileName}: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); + returnCode = 1; + cts.Cancel(); + } + finally + { + if (scriptReferences[script].Any()) + { + semaphore.Release(); + } + } + }); + + return returnCode; +} + +/// +/// Compiles a single C# script to a DLL using 'dotnet build'. +/// +/// The name of the script file (e.g., "ESBuild.cs"). +/// The directory containing the script. +/// The output directory for the compiled DLL. +/// The runtime identifier for the build. +/// The cancellation token for the build process. +/// 0 on success, non-zero on failure. +static async Task BuildScript(string scriptName, string scriptsDir, string outDir, string runtime, CancellationToken cancellationToken) +{ + Console.WriteLine($"Building script: {scriptName} for runtime: {runtime}"); + List args = + [ + "build", + scriptName, + "-c", + "Release", + "-o", + outDir, + "--runtime", + runtime + ]; + + ProcessStartInfo psi = new() + { + FileName = "dotnet", + Arguments = string.Join(" ", args), + WorkingDirectory = scriptsDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using Process? process = Process.Start(psi); + if (process is null) + { + Trace.WriteLine($"Failed to build {scriptName}"); + return 1; + } + + // Read output asynchronously + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode; +} + +static async Task CleanScript(string scriptName, string scriptsDir, string outDir, string runtime, + CancellationToken cancellationToken) +{ + Console.WriteLine($"Cleaning script: {scriptName} for runtime: {runtime}"); + string[] args = + [ + "clean", + scriptName + ]; + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = string.Join(" ", args), + WorkingDirectory = scriptsDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using Process? process = Process.Start(psi); + if (process is null) + { + Trace.WriteLine($"Failed to build {scriptName}"); + return 1; + } + + // Read output asynchronously + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode; +} + +static async Task RestoreScript(string scriptName, string scriptsDir, string outDir, string runtime, + CancellationToken cancellationToken) +{ + Console.WriteLine($"Restoring packages for script: {scriptName} for runtime: {runtime}"); + string[] args = + [ + "restore", + scriptName, + "--runtime", + runtime + ]; + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = string.Join(" ", args), + WorkingDirectory = scriptsDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using Process? process = Process.Start(psi); + if (process is null) + { + Trace.WriteLine($"Failed to build {scriptName}"); + return 1; + } + + // Read output asynchronously + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + Trace.WriteLine(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode; +} + +/// +/// Gets the current Git branch name for a repository. +/// +/// The directory within the Git repository. +/// The branch name, or "unknown" if Git is unavailable or fails. +static string GetCurrentGitBranch(string workingDirectory) +{ + try + { + var psi = new ProcessStartInfo + { + FileName = "git", + Arguments = "rev-parse --abbrev-ref HEAD", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process is null) return "unknown"; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return process.ExitCode == 0 ? output : "unknown"; + } + catch + { + return "unknown"; + } +} + +/// +/// Determines whether a build is needed. +/// A build is needed if the script was modified since last build, +/// or the output directory is empty. +/// +/// The timestamp of the last build. +/// The script name to check for changes. +/// Path to the JavaScript output directory. +/// True if a build should be performed. +static bool CheckIfNeedsBuild(long timeStamp, string script, string outputDir, string scriptsDir) +{ + if (!GetScriptModifiedSince(script, timeStamp, scriptsDir)) + { + Trace.WriteLine("No change in script since last build."); + + // Check output directory for existing files + if (Directory.Exists(outputDir) && Directory.GetFiles(outputDir).Length > 0) + { + // DLLs and runtimeconfig.json files must exist for each script to function in build pipelines + string fileName = Path.GetFileNameWithoutExtension(script); + if (fileName == "ScriptBuilder") + { + // we always skip ScriptBuilder itself + return false; + } + string outputDll = Path.Combine(outputDir, fileName + ".dll"); + if (!File.Exists(outputDll)) + { + Trace.WriteLine($"Output DLL missing: {outputDll}. Proceeding with build."); + return true; + } + // Library projects (.csproj) don't produce runtimeconfig.json, only scripts (.cs) do + if (script.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + string outputRuntimeJson = Path.Combine(outputDir, fileName + ".runtimeconfig.json"); + if (!File.Exists(outputRuntimeJson)) + { + Trace.WriteLine($"Output runtime config missing: {outputRuntimeJson}. Proceeding with build."); + return true; + } + } + } + else + { + Trace.WriteLine("Output directory is empty. Proceeding with build."); + return true; + } + + return false; + } + + Trace.WriteLine("Changes detected in Scripts folder. Proceeding with build."); + return true; +} + +/// +/// Reads the last build record from the JSON file. +/// The record contains the timestamp of the last successful build and the branch name. +/// +/// Path to the .esbuild-record.json file. +/// A tuple containing the Unix timestamp (milliseconds) and branch name. +static (long Timestamp, string Branch) GetLastBuildRecord(string recordFilePath) +{ + if (!File.Exists(recordFilePath)) + { + return (0, "unknown"); + } + + try + { + string json = File.ReadAllText(recordFilePath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + long timestamp = root.TryGetProperty("timestamp", out var ts) ? ts.GetInt64() : 0; + string branch = root.TryGetProperty("branch", out var br) ? br.GetString() ?? "unknown" : "unknown"; + + return (timestamp, branch); + } + catch + { + return (0, "unknown"); + } +} + +/// +/// Checks if the script is newer than the compiled tool +/// +/// Path to the source script. +/// Unix timestamp (milliseconds) of the last build. +/// True if the file has been modified since the timestamp. +static bool GetScriptModifiedSince(string script, long lastTimestamp, string scriptsDir) +{ + var lastBuildTime = DateTimeOffset.FromUnixTimeMilliseconds(lastTimestamp).DateTime; + + return File.GetLastWriteTimeUtc(script) > lastBuildTime; +} + +/// +/// Saves the build record to a JSON file after a successful build. +/// Records the current timestamp and branch name for incremental build detection. +/// +/// Path where the record should be saved. +/// The current Git branch name. +static void SaveBuildRecord(string recordFilePath, string branch) +{ + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // Write JSON manually to avoid reflection-based serialization (not compatible with Native AOT) + string json = $$""" + { + "timestamp": {{timestamp}}, + "branch": "{{branch.Replace("\\", "\\\\").Replace("\"", "\\\"")}}" + } + """; + File.WriteAllText(recordFilePath, json); +} + +/// +/// Gets the relative directory containing the build scripts. +/// +static string GetScriptsDirectory([CallerFilePath] string? callerFilePath = null) +{ + string dllDirectory = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (dllDirectory.Contains("dotnet")) + { + // we are running from the C# script in build-scripts, so we can use the caller file path to find the script directory + return Path.GetDirectoryName(callerFilePath!)!; + } + + // otherwise the dll is stored in ./build-tools/{os}-{arch} + return Path.GetFullPath(Path.Combine(dllDirectory, "..", "build-scripts")); +} + + +static List GetScriptReferences(string scriptPath) +{ + List references = []; + + if (!File.Exists(scriptPath)) + { + return references; + } + + try + { + string scriptDir = Path.GetDirectoryName(scriptPath)!; + string scriptContent = File.ReadAllText(scriptPath); + var matches = Regex.Matches(scriptContent, @"#:project\s+([^\r\n]+)", RegexOptions.IgnoreCase); + foreach (Match match in matches) + { + string refPath = match.Groups[1].Value.Trim(); + // Resolve relative paths to full paths so they match updatedUtilities entries + references.Add(Path.GetFullPath(Path.Combine(scriptDir, refPath))); + } + return references; + } + catch + { + return references; + } +} \ No newline at end of file diff --git a/samples/build-tools/build-scripts/global.json b/samples/build-tools/build-scripts/global.json new file mode 100644 index 0000000..f98e01d --- /dev/null +++ b/samples/build-tools/build-scripts/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMinor" + } +} \ No newline at end of file diff --git a/samples/build-tools/build-scripts/runtimeconfig.template.json b/samples/build-tools/build-scripts/runtimeconfig.template.json new file mode 100644 index 0000000..835174f --- /dev/null +++ b/samples/build-tools/build-scripts/runtimeconfig.template.json @@ -0,0 +1,8 @@ +{ + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + } +} \ No newline at end of file diff --git a/samples/build-tools/build-tools.sln b/samples/build-tools/build-tools.sln new file mode 100644 index 0000000..e00e029 --- /dev/null +++ b/samples/build-tools/build-tools.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utilities", "utilities\Utilities.csproj", "{DD29BC01-3E6B-7E3C-8940-A61C0C42100C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DD29BC01-3E6B-7E3C-8940-A61C0C42100C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD29BC01-3E6B-7E3C-8940-A61C0C42100C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD29BC01-3E6B-7E3C-8940-A61C0C42100C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD29BC01-3E6B-7E3C-8940-A61C0C42100C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F16DB173-B233-4571-BFD9-F5B8767B3D67} + EndGlobalSection +EndGlobal diff --git a/samples/build-tools/linux-x64/.csbuild-record.json b/samples/build-tools/linux-x64/.csbuild-record.json new file mode 100644 index 0000000..b5039b0 --- /dev/null +++ b/samples/build-tools/linux-x64/.csbuild-record.json @@ -0,0 +1,4 @@ +{ + "timestamp": 1777059673117, + "branch": "feature/11-new-samples-and-styling" +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/BuildAppSettings b/samples/build-tools/linux-x64/BuildAppSettings new file mode 100644 index 0000000..b730a6e Binary files /dev/null and b/samples/build-tools/linux-x64/BuildAppSettings differ diff --git a/samples/build-tools/linux-x64/BuildAppSettings.deps.json b/samples/build-tools/linux-x64/BuildAppSettings.deps.json new file mode 100644 index 0000000..1e3059c --- /dev/null +++ b/samples/build-tools/linux-x64/BuildAppSettings.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/linux-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/linux-x64": { + "BuildAppSettings/1.0.0": { + "runtime": { + "BuildAppSettings.dll": {} + } + } + } + }, + "libraries": { + "BuildAppSettings/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/BuildAppSettings.dll b/samples/build-tools/linux-x64/BuildAppSettings.dll new file mode 100644 index 0000000..ca9c644 Binary files /dev/null and b/samples/build-tools/linux-x64/BuildAppSettings.dll differ diff --git a/samples/build-tools/linux-x64/BuildAppSettings.runtimeconfig.json b/samples/build-tools/linux-x64/BuildAppSettings.runtimeconfig.json new file mode 100644 index 0000000..d610b38 --- /dev/null +++ b/samples/build-tools/linux-x64/BuildAppSettings.runtimeconfig.json @@ -0,0 +1,43 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\BuildAppSettings.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.Security.UseManagedNtlm": false, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/ConsoleDialog b/samples/build-tools/linux-x64/ConsoleDialog new file mode 100644 index 0000000..483da38 Binary files /dev/null and b/samples/build-tools/linux-x64/ConsoleDialog differ diff --git a/samples/build-tools/linux-x64/ConsoleDialog.deps.json b/samples/build-tools/linux-x64/ConsoleDialog.deps.json new file mode 100644 index 0000000..066182f --- /dev/null +++ b/samples/build-tools/linux-x64/ConsoleDialog.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/linux-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/linux-x64": { + "ConsoleDialog/1.0.0": { + "runtime": { + "ConsoleDialog.dll": {} + } + } + } + }, + "libraries": { + "ConsoleDialog/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/ConsoleDialog.dll b/samples/build-tools/linux-x64/ConsoleDialog.dll new file mode 100644 index 0000000..85926ae Binary files /dev/null and b/samples/build-tools/linux-x64/ConsoleDialog.dll differ diff --git a/samples/build-tools/linux-x64/ConsoleDialog.runtimeconfig.json b/samples/build-tools/linux-x64/ConsoleDialog.runtimeconfig.json new file mode 100644 index 0000000..9fe2b98 --- /dev/null +++ b/samples/build-tools/linux-x64/ConsoleDialog.runtimeconfig.json @@ -0,0 +1,43 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\ConsoleDialog.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.Security.UseManagedNtlm": false, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/FetchNuGetVersion b/samples/build-tools/linux-x64/FetchNuGetVersion new file mode 100644 index 0000000..ef25959 Binary files /dev/null and b/samples/build-tools/linux-x64/FetchNuGetVersion differ diff --git a/samples/build-tools/linux-x64/FetchNuGetVersion.deps.json b/samples/build-tools/linux-x64/FetchNuGetVersion.deps.json new file mode 100644 index 0000000..21877df --- /dev/null +++ b/samples/build-tools/linux-x64/FetchNuGetVersion.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/linux-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/linux-x64": { + "FetchNuGetVersion/1.0.0": { + "runtime": { + "FetchNuGetVersion.dll": {} + } + } + } + }, + "libraries": { + "FetchNuGetVersion/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/FetchNuGetVersion.dll b/samples/build-tools/linux-x64/FetchNuGetVersion.dll new file mode 100644 index 0000000..d7d2e34 Binary files /dev/null and b/samples/build-tools/linux-x64/FetchNuGetVersion.dll differ diff --git a/samples/build-tools/linux-x64/FetchNuGetVersion.runtimeconfig.json b/samples/build-tools/linux-x64/FetchNuGetVersion.runtimeconfig.json new file mode 100644 index 0000000..f5d0603 --- /dev/null +++ b/samples/build-tools/linux-x64/FetchNuGetVersion.runtimeconfig.json @@ -0,0 +1,43 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\FetchNuGetVersion.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.Security.UseManagedNtlm": false, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/Utilities.deps.json b/samples/build-tools/linux-x64/Utilities.deps.json new file mode 100644 index 0000000..cf26cc7 --- /dev/null +++ b/samples/build-tools/linux-x64/Utilities.deps.json @@ -0,0 +1,42 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/linux-x64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/linux-x64": { + "Utilities/1.0.0": { + "dependencies": { + "Polly.Core": "8.6.5" + }, + "runtime": { + "Utilities.dll": {} + } + }, + "Polly.Core/8.6.5": { + "runtime": { + "lib/net8.0/Polly.Core.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.6.5.5194" + } + } + } + } + }, + "libraries": { + "Utilities/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Polly.Core/8.6.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==", + "path": "polly.core/8.6.5", + "hashPath": "polly.core.8.6.5.nupkg.sha512" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/linux-x64/Utilities.dll b/samples/build-tools/linux-x64/Utilities.dll new file mode 100644 index 0000000..c59c97a Binary files /dev/null and b/samples/build-tools/linux-x64/Utilities.dll differ diff --git a/samples/build-tools/osx-arm64/.csbuild-record.json b/samples/build-tools/osx-arm64/.csbuild-record.json new file mode 100644 index 0000000..e7b6ce0 --- /dev/null +++ b/samples/build-tools/osx-arm64/.csbuild-record.json @@ -0,0 +1,4 @@ +{ + "timestamp": 1777059694194, + "branch": "feature/11-new-samples-and-styling" +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/BuildAppSettings b/samples/build-tools/osx-arm64/BuildAppSettings new file mode 100644 index 0000000..9142405 Binary files /dev/null and b/samples/build-tools/osx-arm64/BuildAppSettings differ diff --git a/samples/build-tools/osx-arm64/BuildAppSettings.deps.json b/samples/build-tools/osx-arm64/BuildAppSettings.deps.json new file mode 100644 index 0000000..52bd999 --- /dev/null +++ b/samples/build-tools/osx-arm64/BuildAppSettings.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/osx-arm64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/osx-arm64": { + "BuildAppSettings/1.0.0": { + "runtime": { + "BuildAppSettings.dll": {} + } + } + } + }, + "libraries": { + "BuildAppSettings/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/BuildAppSettings.dll b/samples/build-tools/osx-arm64/BuildAppSettings.dll new file mode 100644 index 0000000..e3c19a3 Binary files /dev/null and b/samples/build-tools/osx-arm64/BuildAppSettings.dll differ diff --git a/samples/build-tools/osx-arm64/BuildAppSettings.runtimeconfig.json b/samples/build-tools/osx-arm64/BuildAppSettings.runtimeconfig.json new file mode 100644 index 0000000..8988bb2 --- /dev/null +++ b/samples/build-tools/osx-arm64/BuildAppSettings.runtimeconfig.json @@ -0,0 +1,42 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\BuildAppSettings.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/ConsoleDialog b/samples/build-tools/osx-arm64/ConsoleDialog new file mode 100644 index 0000000..1fa5b77 Binary files /dev/null and b/samples/build-tools/osx-arm64/ConsoleDialog differ diff --git a/samples/build-tools/osx-arm64/ConsoleDialog.deps.json b/samples/build-tools/osx-arm64/ConsoleDialog.deps.json new file mode 100644 index 0000000..d576a2e --- /dev/null +++ b/samples/build-tools/osx-arm64/ConsoleDialog.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/osx-arm64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/osx-arm64": { + "ConsoleDialog/1.0.0": { + "runtime": { + "ConsoleDialog.dll": {} + } + } + } + }, + "libraries": { + "ConsoleDialog/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/ConsoleDialog.dll b/samples/build-tools/osx-arm64/ConsoleDialog.dll new file mode 100644 index 0000000..b94e5b1 Binary files /dev/null and b/samples/build-tools/osx-arm64/ConsoleDialog.dll differ diff --git a/samples/build-tools/osx-arm64/ConsoleDialog.runtimeconfig.json b/samples/build-tools/osx-arm64/ConsoleDialog.runtimeconfig.json new file mode 100644 index 0000000..6f57400 --- /dev/null +++ b/samples/build-tools/osx-arm64/ConsoleDialog.runtimeconfig.json @@ -0,0 +1,42 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\ConsoleDialog.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/FetchNuGetVersion b/samples/build-tools/osx-arm64/FetchNuGetVersion new file mode 100644 index 0000000..c2a109f Binary files /dev/null and b/samples/build-tools/osx-arm64/FetchNuGetVersion differ diff --git a/samples/build-tools/osx-arm64/FetchNuGetVersion.deps.json b/samples/build-tools/osx-arm64/FetchNuGetVersion.deps.json new file mode 100644 index 0000000..ddc928d --- /dev/null +++ b/samples/build-tools/osx-arm64/FetchNuGetVersion.deps.json @@ -0,0 +1,24 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/osx-arm64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/osx-arm64": { + "FetchNuGetVersion/1.0.0": { + "runtime": { + "FetchNuGetVersion.dll": {} + } + } + } + }, + "libraries": { + "FetchNuGetVersion/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/FetchNuGetVersion.dll b/samples/build-tools/osx-arm64/FetchNuGetVersion.dll new file mode 100644 index 0000000..e71f6f5 Binary files /dev/null and b/samples/build-tools/osx-arm64/FetchNuGetVersion.dll differ diff --git a/samples/build-tools/osx-arm64/FetchNuGetVersion.runtimeconfig.json b/samples/build-tools/osx-arm64/FetchNuGetVersion.runtimeconfig.json new file mode 100644 index 0000000..18bc6f2 --- /dev/null +++ b/samples/build-tools/osx-arm64/FetchNuGetVersion.runtimeconfig.json @@ -0,0 +1,42 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + "runtimeOptions": { + "configProperties": { + "EntryPointFilePath": ".\\GBTest.cs", + "EntryPointFileDirectoryPath": "." + } + }, + "configProperties": { + "EntryPointFilePath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts\\FetchNuGetVersion.cs", + "EntryPointFileDirectoryPath": "D:\\GeoBlazor-Samples\\samples\\build-tools\\build-scripts", + "Microsoft.Extensions.DependencyInjection.VerifyOpenGenericServiceTrimmability": true, + "System.ComponentModel.DefaultValueAttribute.IsSupported": false, + "System.ComponentModel.Design.IDesignerHost.IsSupported": false, + "System.ComponentModel.TypeConverter.EnableUnsafeBinaryFormatterInDesigntimeLicenseContextSerialization": false, + "System.ComponentModel.TypeDescriptor.IsComObjectDescriptorSupported": false, + "System.Data.DataSet.XmlSerializationIsSupported": false, + "System.Diagnostics.Tracing.EventSource.IsSupported": false, + "System.Linq.Enumerable.IsSizeOptimized": true, + "System.Net.SocketsHttpHandler.Http3Support": false, + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Resources.ResourceManager.AllowCustomResourceTypes": false, + "System.Resources.UseSystemResourceKeys": false, + "System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported": false, + "System.Runtime.InteropServices.BuiltInComInterop.IsSupported": false, + "System.Runtime.InteropServices.EnableConsumingManagedCodeFromNativeHosting": false, + "System.Runtime.InteropServices.EnableCppCLIHostActivation": false, + "System.Runtime.InteropServices.Marshalling.EnableGeneratedComInterfaceComImportInterop": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "System.StartupHookProvider.IsSupported": false, + "System.Text.Encoding.EnableUnsafeUTF7Encoding": false, + "System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault": false, + "System.Threading.Thread.EnableAutoreleasePool": false, + "System.Linq.Expressions.CanEmitObjectArrayDelegate": false + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/Utilities.deps.json b/samples/build-tools/osx-arm64/Utilities.deps.json new file mode 100644 index 0000000..671f240 --- /dev/null +++ b/samples/build-tools/osx-arm64/Utilities.deps.json @@ -0,0 +1,42 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0/osx-arm64", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": {}, + ".NETCoreApp,Version=v10.0/osx-arm64": { + "Utilities/1.0.0": { + "dependencies": { + "Polly.Core": "8.6.5" + }, + "runtime": { + "Utilities.dll": {} + } + }, + "Polly.Core/8.6.5": { + "runtime": { + "lib/net8.0/Polly.Core.dll": { + "assemblyVersion": "8.0.0.0", + "fileVersion": "8.6.5.5194" + } + } + } + } + }, + "libraries": { + "Utilities/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Polly.Core/8.6.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==", + "path": "polly.core/8.6.5", + "hashPath": "polly.core.8.6.5.nupkg.sha512" + } + } +} \ No newline at end of file diff --git a/samples/build-tools/osx-arm64/Utilities.dll b/samples/build-tools/osx-arm64/Utilities.dll new file mode 100644 index 0000000..79ccbeb Binary files /dev/null and b/samples/build-tools/osx-arm64/Utilities.dll differ diff --git a/samples/build-tools/utilities/GbCli.cs b/samples/build-tools/utilities/GbCli.cs new file mode 100644 index 0000000..35f9485 --- /dev/null +++ b/samples/build-tools/utilities/GbCli.cs @@ -0,0 +1,90 @@ +namespace Utilities; + +public static class GbCli +{ + /// + /// Writes a formatted step header to the console with colored background. + /// + /// The step number. + /// A description of what this step does. + public static void WriteStepHeader(int step, string description) + { + int windowWidth = GetWindowWidth(); + + int stepLength = step.ToString().Length; + int descriptionLength = stepLength + 2 + description.Length; // 2 for period and space after step # + + while (descriptionLength > windowWidth) + { + descriptionLength -= windowWidth; // if the line is too long, let it wrap, but only count the last line + } + + string timestamp = DateTime.Now.ToString("HH:mm:ss"); + int timestampLength = timestamp.Length; + + // calculate the buffer space between the description and the timestamp, + // to place the timestamp 1 column from the right + int buffer = windowWidth - descriptionLength - timestampLength - 1; + + Console.WriteLine(); + Console.BackgroundColor = ConsoleColor.DarkMagenta; + Console.ForegroundColor = ConsoleColor.White; + + if (buffer > 0) + { + Console.Write($"{step}. {description}{new string(' ', buffer)}{timestamp}"); + } + else + { + // the description was too long, the timestamp doesn't fit on the same line, move it to the next line + // buffer to the end of the description line + buffer = windowWidth - descriptionLength - 1; + Console.WriteLine($"{step}. {description}{new string(' ', buffer)}"); + + // buffer to the end of the timestamp line, but start aligned with the description + buffer = windowWidth - timestampLength - stepLength - 3; + Console.Write($"{new string(' ', stepLength + 2)}{timestamp}{new string(' ', buffer)}"); + } + + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine(); + } + + /// + /// Writes a step completion message showing elapsed time. + /// + /// The step number that completed. + /// The time when this step started. + public static void WriteStepCompleted(int step, DateTime stepStartTime) + { + int windowWidth = GetWindowWidth(); + TimeSpan elapsed = DateTime.Now - stepStartTime; + Console.BackgroundColor = ConsoleColor.Magenta; + Console.ForegroundColor = ConsoleColor.Black; + string content = $"Step {step} completed in {elapsed}."; + int contentLength = content.Length; + int bufferLength = windowWidth - contentLength - 1; + Console.Write($"{content}{new string(' ', bufferLength)}"); + Console.ResetColor(); + Console.WriteLine(); + } + + public static int GetWindowWidth() + { + int windowWidth = 120; + + if (!Console.IsOutputRedirected) + { + windowWidth = Console.WindowWidth; + Environment.SetEnvironmentVariable("CONSOLE_WIDTH", windowWidth.ToString()); + } + else if (Environment.GetEnvironmentVariable("CONSOLE_WIDTH") is { } envWidthString + && int.TryParse(envWidthString, out int envWidth)) + { + windowWidth = envWidth - 2; // subtract 2 for child process indentation in ProcessRunner.cs + } + + return windowWidth; + } +} \ No newline at end of file diff --git a/samples/build-tools/utilities/PathFinder.cs b/samples/build-tools/utilities/PathFinder.cs new file mode 100644 index 0000000..2786b50 --- /dev/null +++ b/samples/build-tools/utilities/PathFinder.cs @@ -0,0 +1,23 @@ +using System.Runtime.CompilerServices; + +namespace Utilities; + +public static class PathFinder +{ + /// + /// Gets the relative directory containing the build scripts. + /// + public static string GetScriptsDirectory([CallerFilePath] string? callerFilePath = null) + { + string dllDirectory = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (dllDirectory.Contains("dotnet")) + { + // we are running from the C# script in build-scripts, so we can use the caller file path to find the script directory + return Path.GetDirectoryName(callerFilePath!)!; + } + + // otherwise the dll is stored in ./build-tools/{os}-{arch} + return Path.GetFullPath(Path.Combine(dllDirectory, "..", "build-scripts")); + } +} \ No newline at end of file diff --git a/samples/build-tools/utilities/ProcessKiller.cs b/samples/build-tools/utilities/ProcessKiller.cs new file mode 100644 index 0000000..39e2dc8 --- /dev/null +++ b/samples/build-tools/utilities/ProcessKiller.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + + +namespace Utilities; + +public static class ProcessKiller +{ + public static bool KillByProcessOrFileName(string fileName) + { + Process[] processes = Process.GetProcesses(); + bool killed = false; + + foreach (Process process in processes) + { + try + { + if (process.ProcessName.Contains(fileName, StringComparison.OrdinalIgnoreCase)) + { + process.Kill(); + killed = true; + + continue; + } + + if (process.Modules.Cast() + .Any(m => m.FileName + .Contains(fileName, StringComparison.OrdinalIgnoreCase))) + { + process.Kill(); + killed = true; + } + } + catch + { + // Can't access this process — skip it + } + } + + return killed; + } +} \ No newline at end of file diff --git a/samples/build-tools/utilities/ProcessRunner.cs b/samples/build-tools/utilities/ProcessRunner.cs new file mode 100644 index 0000000..fe58392 --- /dev/null +++ b/samples/build-tools/utilities/ProcessRunner.cs @@ -0,0 +1,270 @@ +using Polly; +using System.Diagnostics; +using System.Text.RegularExpressions; + + +namespace Utilities; + +public static partial class ProcessRunner +{ + public static bool FormatOutput => + !string.Equals(Environment.GetEnvironmentVariable("FORMAT_OUTPUT"), "false", StringComparison.OrdinalIgnoreCase); + + /// + /// Runs an npm command using PowerShell 7 for cross-platform compatibility. + /// Output is captured and also written to the Trace listeners. + /// + /// The directory to run the command in. + /// The npm command (e.g., "install", "run build"). + /// Environment variables to set for the process. + /// Cancellation token to cancel the operation. + /// Additional arguments to pass to the command. + public static Task RunNpmCommand(string workingDirectory, string command, + Dictionary? environmentVariables = null, + CancellationToken cancellationToken = default, params IEnumerable args) + { + return RunCommand(workingDirectory, "pwsh", "-Command", + environmentVariables, cancellationToken, + ["ERR!"], + $"\"npm {command} {string.Join(" ", args.Where(a => !string.IsNullOrWhiteSpace(a)))}\""); + } + + /// + /// Runs a dotnet command without capturing output. + /// + /// The working directory for the command. + /// The dotnet command (e.g., "build", "restore", "clean"). + /// Environment variables to set for the process. + /// Cancellation token to cancel the operation. + /// Additional arguments to pass to the command. + public static Task RunDotnetCommand(string workingDirectory, string command, + Dictionary? environmentVariables = null, + CancellationToken cancellationToken = default, params IEnumerable args) + { + // make sure there is a space after the word "Error" to avoid false positives on output like "0 Error(s)" + return RunCommand(workingDirectory, "dotnet", command, environmentVariables, cancellationToken, + ["Build FAILED", "Error "], args); + } + + /// + /// Runs a dotnet command without capturing output. + /// + /// The working directory for the command. + /// The executable file name (e.g., "dotnet"). + /// The dotnet command (e.g., "build", "restore", "clean"). + /// Environment variables to set for the process. + /// Cancellation token to cancel the operation. + /// Words in output that should trigger a failure. + /// Additional arguments to pass to the command. + public static async Task RunCommand(string workingDirectory, string fileName, string command, + Dictionary? environmentVariables, CancellationToken cancellationToken, + string[] failureTriggerWords, params IEnumerable args) + { + Stopwatch sw = new(); + sw.Start(); + string arguments = $"{command} {string.Join(" ", args.Where(a => !string.IsNullOrWhiteSpace(a)))}"; + + int windowWidth = GbCli.GetWindowWidth(); + environmentVariables ??= []; + environmentVariables["CONSOLE_WIDTH"] = windowWidth.ToString(); + + try + { + ProcessStartInfo psi = new() + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + + foreach (KeyValuePair kvp in environmentVariables) + { + psi.Environment[kvp.Key] = kvp.Value; + } + + await LaunchResilientTask($"dotnet {arguments}", async _ => + { + using Process? process = Process.Start(psi); + bool lineWasEmpty = false; + bool failureTriggered = false; + string? failureTriggerWord = null; + + if (process != null) + { + process.OutputDataReceived += (_, e) => + { + if (e.Data != null) + { + string line = e.Data; + + if (FormatOutput) + { + WriteFormattedLine(line, lineWasEmpty, windowWidth); + } + else + { + Console.WriteLine(line); + } + + lineWasEmpty = string.IsNullOrEmpty(line); + + foreach (string triggerWord in failureTriggerWords) + { + if (e.Data.Contains(triggerWord, StringComparison.OrdinalIgnoreCase)) + { + failureTriggered = true; + failureTriggerWord = triggerWord; + } + } + } + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + if (FormatOutput) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"| {e.Data}"); + Console.ResetColor(); + } + else + { + Console.WriteLine(e.Data); + } + + foreach (string triggerWord in failureTriggerWords) + { + if (e.Data.Contains(triggerWord, StringComparison.OrdinalIgnoreCase)) + { + failureTriggered = true; + failureTriggerWord = triggerWord; + } + } + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + if (failureTriggered) + { + throw new TaskFailureException($"Detected failure word '{failureTriggerWord}' in output of process {psi.FileName} {psi.Arguments}."); + } + + if (!process.HasExited) + { + process.Kill(true); + } + + if (process.ExitCode != 0) + { + throw new Exception($"Error code {process.ExitCode} for process {psi.FileName} {psi.Arguments + }"); + } + } + }, cancellationToken); + } + finally + { + sw.Stop(); + + Console.WriteLine($"Process {fileName} {arguments} completed in {sw.Elapsed.Minutes}m {sw.Elapsed.Seconds + }s."); + } + } + + private static async Task LaunchResilientTask(string taskName, Func task, + CancellationToken cancellationToken) + { + ResilienceContext context = ResilienceContextPool.Shared.Get( + new ResilienceContextCreationArguments(taskName, null, cancellationToken)); + await ResilienceSetup.AppRetryPipeline.ExecuteAsync(task, context); + + ResilienceContextPool.Shared.Return(context); + } + + private static void WriteFormattedLine(string line, bool lineWasEmpty, int windowWidth) + { + Console.Write("| "); + int lineSpace = windowWidth - 3; + + if (stepHeaderRegex.Match(line) is { Success: true } headerMatch && lineWasEmpty) + { + string indents = headerMatch.Groups["indents"].Value; + + Console.BackgroundColor = (indents.Length == 0 ? 0 : indents.Length / 2) switch + { + 0 => ConsoleColor.DarkBlue, + 1 => ConsoleColor.DarkGreen, + _ => ConsoleColor.DarkYellow + }; + Console.ForegroundColor = ConsoleColor.White; + string header = headerMatch.Groups["header"].Value; + string timestamp = headerMatch.Groups["timestamp"].Value; + int buffer = windowWidth - indents.Length - header.Length - timestamp.Length - 3; + + while (buffer < 0) + { + buffer += windowWidth; + } + + Console.Write($"{indents}{header}{new string(' ', buffer)}{timestamp}"); + Console.ResetColor(); + Console.WriteLine(); + } + else if (stepFooterRegex.Match(line) is { Success: true } footerMatch) + { + string indents = footerMatch.Groups["indents"].Value; + + Console.BackgroundColor = (indents.Length == 0 ? 0 : indents.Length / 2) switch + { + 0 => ConsoleColor.Blue, + 1 => ConsoleColor.Green, + _ => ConsoleColor.Yellow + }; + Console.BackgroundColor = ConsoleColor.DarkGray; + Console.ForegroundColor = ConsoleColor.White; + string footer = footerMatch.Groups["footer"].Value; + int buffer = windowWidth - indents.Length - footer.Length - 3; + + while (buffer < 0) + { + buffer += windowWidth; + } + + Console.Write($"{indents}{footer}{new string(' ', buffer)}"); + Console.ResetColor(); + Console.WriteLine(); + } + else + { + while (line.Length > lineSpace) + { + Console.WriteLine(line[..lineSpace]); + Console.Write("| "); + line = line[lineSpace..]; + } + + Console.WriteLine(line); + } + } + + [GeneratedRegex(@"^(?[|\s]*?)(?
\d+\.\s.*?)\s*(?[\d\:]+)", RegexOptions.Compiled)] + private static partial Regex StepHeaderRegex(); + + [GeneratedRegex(@"^(?[|\s]*?)(?