using System.Threading.Tasks; using System.Linq; using System.IO; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AyaNova.Util; using AyaNova.Api.ControllerHelpers; using AyaNova.Models; using EnumsNET; using PuppeteerSharp; using Newtonsoft.Json.Linq; using System.Threading; namespace AyaNova.Biz { internal class ReportBiz : BizObject, ISearchAbleObject { internal ReportBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.Report; } internal static ReportBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null) { if (httpContext != null) return new ReportBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items)); else return new ReportBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdminFull); } //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS internal async Task ExistsAsync(long id) { return await ct.Report.AnyAsync(z => z.Id == id); } //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE // internal async Task CreateAsync(Report newObject) { await ValidateAsync(newObject, null); if (HasErrors) return null; else { await ct.Report.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); await SearchIndexAsync(newObject, true); return newObject; } } //////////////////////////////////////////////////////////////////////////////////////////////// //DUPLICATE // internal async Task DuplicateAsync(long id) { Report dbObject = await GetAsync(id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } Report newObject = new Report(); CopyObject.Copy(dbObject, newObject); string newUniqueName = string.Empty; bool NotUnique = true; long l = 1; do { newUniqueName = Util.StringUtil.UniqueNameBuilder(dbObject.Name, l++, 255); NotUnique = await ct.Report.AnyAsync(z => z.Name == newUniqueName); } while (NotUnique); newObject.Name = newUniqueName; newObject.Id = 0; newObject.Concurrency = 0; await ct.Report.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); await SearchIndexAsync(newObject, true); return newObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //IMPORT // internal async Task ImportAsync(JObject o) { //Report newObject = new Report(); var newObject = o.ToObject(); var proposedName = (string)o["Name"]; string newUniqueName = proposedName; bool NotUnique = true; long l = 1; do { NotUnique = await ct.Report.AnyAsync(z => z.Name == newUniqueName); if (NotUnique) newUniqueName = Util.StringUtil.UniqueNameBuilder(proposedName, l++, 255); } while (NotUnique); newObject.Name = newUniqueName; // This was stupid considering JObject can serialize directly to a report type //but leaving in for a little while just to be sure it's all good // newObject.Active = (bool)o["Active"]; // newObject.JsHelpers = (string)o["JsHelpers"]; // newObject.JsPrerender = (string)o["JsPrerender"]; // newObject.Notes = (string)o["Notes"]; // newObject.ObjectType = (AyaType)(int)o["ObjectType"]; // newObject.RenderType = (ReportRenderType)(int)o["RenderType"]; // newObject.Roles = (AuthorizationRoles)(int)o["Roles"]; // newObject.Style = (string)o["Style"]; // newObject.Template = (string)o["Template"]; // //pdf options // newObject.HeaderTemplate = (string)o["HeaderTemplate"]; // newObject.FooterTemplate = (string)o["FooterTemplate"]; // newObject.MarginOptionsBottom = (string)o["MarginOptionsBottom"]; // newObject.MarginOptionsLeft = (string)o["MarginOptionsLeft"]; // newObject.MarginOptionsRight = (string)o["MarginOptionsRight"]; // newObject.MarginOptionsTop = (string)o["MarginOptionsTop"]; // newObject.PageRanges = (string)o["PageRanges"]; // newObject.PaperFormat = (ReportPaperFormat)(int)o["PaperFormat"]; // newObject.DisplayHeaderFooter = (bool)o["DisplayHeaderFooter"]; // newObject.Landscape = (bool)o["Landscape"]; // newObject.PreferCSSPageSize = (bool)o["PreferCSSPageSize"]; // newObject.PrintBackground = (bool)o["PrintBackground"]; // newObject.Scale = (decimal)o["Scale"]; await ValidateAsync(newObject, null); if (HasErrors) return false; await ct.Report.AddAsync(newObject); await ct.SaveChangesAsync(); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); return true; } //////////////////////////////////////////////////////////////////////////////////////////////// //GET // internal async Task GetAsync(long id, bool logTheGetEvent = true) { var ret = await ct.Report.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); if (logTheGetEvent && ret != null) await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); return ret; } //////////////////////////////////////////////////////////////////////////////////////////////// //UPDATE // internal async Task PutAsync(Report putObject) { var dbObject = await GetAsync(putObject.Id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } if (dbObject.Concurrency != putObject.Concurrency) { AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await ValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); try { await ct.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!await ExistsAsync(putObject.Id)) AddError(ApiErrorCode.NOT_FOUND); else AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct); await SearchIndexAsync(putObject, false); return putObject; } //////////////////////////////////////////////////////////////////////////////////////////////// //DELETE // internal async Task DeleteAsync(long id) { using (var transaction = await ct.Database.BeginTransactionAsync()) { try { var dbObject = await GetAsync(id, false); if (dbObject == null) { AddError(ApiErrorCode.NOT_FOUND); return false; } await ValidateCanDelete(dbObject); if (HasErrors) return false; ct.Report.Remove(dbObject); await ct.SaveChangesAsync(); await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Name, ct); await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType, ct); await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); await transaction.CommitAsync(); } catch { //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here throw; } return true; } } //////////////////////////////////////////////////////////////////////////////////////////////// //GET LIST // internal async Task> GetReportListAsync(AyaType ayType) { var rpts = await ct.Report.AsNoTracking().Where(z => z.ObjectType == ayType && z.Active == true).Select(z => new { id = z.Id, name = z.Name, roles = z.Roles }).ToListAsync(); var ret = new List(); foreach (var item in rpts) { if (CurrentUserRoles.HasAnyFlags(item.roles)) { ret.Add(new NameIdItem() { Name = item.name, Id = item.id }); } } //Sort by name return ret.OrderBy(z => z.Name).ToList(); } //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH // private async Task SearchIndexAsync(Report obj, bool isNew) { var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType); DigestSearchText(obj, SearchParams); if (isNew) await Search.ProcessNewObjectKeywordsAsync(SearchParams); else await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); } public async Task GetSearchResultSummary(long id) { var obj = await GetAsync(id, false); var SearchParams = new Search.SearchIndexProcessObjectParameters(); DigestSearchText(obj, SearchParams); return SearchParams; } public void DigestSearchText(Report obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) searchParams.AddText(obj.Notes) .AddText(obj.Name) .AddText(obj.Template) .AddText(obj.Style) .AddText(obj.JsPrerender) .AddText(obj.JsHelpers) .AddText(obj.HeaderTemplate) .AddText(obj.FooterTemplate); } //////////////////////////////////////////////////////////////////////////////////////////////// //VALIDATION // private async Task ValidateAsync(Report proposedObj, Report currentObj) { bool isNew = currentObj == null; //Name required if (string.IsNullOrWhiteSpace(proposedObj.Name)) AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name"); //If name is otherwise OK, check that name is unique if (!PropertyHasErrors("Name")) { //Use Any command is efficient way to check existance, it doesn't return the record, just a true or false if (await ct.Report.AnyAsync(z => z.Name == proposedObj.Name && z.Id != proposedObj.Id)) { AddError(ApiErrorCode.VALIDATION_NOT_UNIQUE, "Name"); } } } private async Task ValidateCanDelete(Report inObj) { //Referential integrity error if (await ct.NotifySubscription.AnyAsync(z => z.LinkReportId == inObj.Id) == true) { //Note: errorbox will ensure it appears in the general errror box and not field specific //the translation key is to indicate what the linked object is that is causing the error AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("NotifySubscription")); } } //////////////////////////////////////////////////////////////////////////////////////////////// //REPORT DATA //Data fetched to return to report designer for Client report design usage public async Task GetReportData(DataListSelectedRequest selectedRequest) { var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::GetReportData"); AuthorizationRoles effectiveRoles = CurrentUserRoles; if (selectedRequest.ObjectType == AyaType.NoType) { AddError(ApiErrorCode.VALIDATION_REQUIRED, null, $"ObjectType is required"); return null; } if (!AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(effectiveRoles, selectedRequest.ObjectType)) { AddError(ApiErrorCode.NOT_AUTHORIZED, null, $"User not authorized for {selectedRequest.ObjectType} type object"); return null; } //Do we need to rehydrate the ID List from a DataList? if (selectedRequest.SelectedRowIds.Length == 0) selectedRequest.SelectedRowIds = await DataListSelectedProcessingOptions.RehydrateIdList(selectedRequest, ct, effectiveRoles, log, UserId, UserTranslationId); log.LogDebug($"Instantiating biz object handler for {selectedRequest.ObjectType}"); var biz = BizObjectFactory.GetBizObject(selectedRequest.ObjectType, ct, UserId, CurrentUserRoles, UserTranslationId); log.LogDebug($"Fetching data for {selectedRequest.SelectedRowIds.Length} {selectedRequest.ObjectType} items"); return await ((IReportAbleObject)biz).GetReportData(selectedRequest.SelectedRowIds); } //////////////////////////////////////////////////////////////////////////////////////////////// //RENDER // public async Task RenderReport(DataListReportRequest reportRequest, string apiUrl) { var log = AyaNova.Util.ApplicationLogging.CreateLogger("ReportBiz::RenderReport"); //get report, vet security, see what we need before init in case of issue var report = await ct.Report.FirstOrDefaultAsync(z => z.Id == reportRequest.ReportId); if (report == null) { AddError(ApiErrorCode.NOT_FOUND, "id"); return null; } AuthorizationRoles effectiveRoles = CurrentUserRoles; if (!AyaNova.Api.ControllerHelpers.Authorized.HasReadFullRole(effectiveRoles, report.ObjectType)) { AddError(ApiErrorCode.NOT_AUTHORIZED, null, $"User not authorized for {report.ObjectType} type object"); return null; } //Client meta data is required if (reportRequest.ClientMeta == null) { AddError(ApiErrorCode.VALIDATION_MISSING_PROPERTY, null, "ClientMeta parameter is missing and required to render report"); return null; } //Default timeout for each operation of report generation var WaitTimeout = new WaitForFunctionOptions() { Timeout = ServerBootConfig.REPORT_RENDERING_OPERATION_TIMEOUT }; //Get data var ReportData = await GetReportData(reportRequest); //if GetReportData errored then will return null so need to return that as well here if(ReportData==null){ return null; } //initialization log.LogDebug("Initializing report system"); var ReportJSFolderPath = Path.Combine(ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH, "resource", "rpt"); var lo = new LaunchOptions { Headless = true }; bool isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); if (!isWindows) { //LINUX / MAC / NOT WINDOWS log.LogDebug($"Not Windows: setting executable path for chrome to expected '/usr/bin/chromium-browser'"); lo.ExecutablePath = "/usr/bin/chromium-browser";//this is the default path for docker based alpine dist, but maybe not others, need to make a config setting likely // lo.Args = new string[] { "--no-sandbox" }; /* troubleshooting links: https://developers.google.com/web/tools/puppeteer/troubleshooting Note: plenty of zombie process issues in regular puppeteer as well These links might be worth looking at in future if diagnosing other issues: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-on-alpine https://github.com/puppeteer/puppeteer/issues/1825 const chromeFlags = [ '--headless', '--no-sandbox', "--disable-gpu", "--single-process", "--no-zygote" ] */ lo.Args = new string[] { "--disable-dev-shm-usage --no-sandbox --disable-gpu --single-process --no-zygote " }; } else { //WINDOWS ONLY log.LogDebug($"Windows: Calling browserFetcher download async now:"); await new BrowserFetcher().DownloadAsync(BrowserFetcher.DefaultRevision); } System.Text.StringBuilder PageLog = new System.Text.StringBuilder(); //API DOCS http://www.puppeteersharp.com/api/index.html log.LogDebug($"Launching headless Chrome now:"); using (var browser = await Puppeteer.LaunchAsync(lo)) using (var page = await browser.NewPageAsync()) { //see catch block // var ChromiumProcessID = browser.Process.Id; try { //info and error logging page.Console += async (sender, args) => { switch (args.Message.Type) { case ConsoleType.Error: try { var errorArgs = await Task.WhenAll(args.Message.Args.Select(arg => arg.ExecutionContext.EvaluateFunctionAsync("(arg) => arg instanceof Error ? arg.message : arg", arg))); PageLog.AppendLine($"ERROR: {args.Message.Text} args: [{string.Join(", ", errorArgs)}]"); } catch { } break; case ConsoleType.Warning: PageLog.AppendLine($"WARNING: {args.Message.Text}"); break; default: PageLog.AppendLine($"INFO: {args.Message.Text}"); break; } }; log.LogDebug($"Preparing page: adding base reporting scripts to page"); //Add Handlebars JS for compiling and presenting //https://handlebarsjs.com/ await page.AddScriptTagAsync(new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-hb.js") }); //add Marked for markdown processing //https://github.com/markedjs/marked await page.AddScriptTagAsync(new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-md.js") }); //add DOM Purify for markdown template sanitization processing //https://github.com/cure53/DOMPurify await page.AddScriptTagAsync(new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-pf.js") }); //add Bar code library if our bar code helper is referenced //https://github.com/metafloor/bwip-js if (report.Template.Contains("ayBC ") || report.JsHelpers.Contains("ayBC ")) await page.AddScriptTagAsync(new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-bc.js") }); //add stock helpers await page.AddScriptTagAsync(new AddTagOptions() { Path = Path.Combine(ReportJSFolderPath, "ay-report.js") }); //execute to add to handlebars await page.EvaluateExpressionAsync("ayRegisterHelpers();"); log.LogDebug($"Preparing page: adding this report's scripts, style and templates to page"); //add report pre-render, helpers and style if (string.IsNullOrWhiteSpace(report.JsPrerender)) { report.JsPrerender = "async function ayPrepareData(reportData){return reportData;}"; } await page.AddScriptTagAsync(new AddTagOptions() { Content = report.JsPrerender }); if (!string.IsNullOrWhiteSpace(report.JsHelpers)) await page.AddScriptTagAsync(new AddTagOptions() { Content = report.JsHelpers }); if (!string.IsNullOrWhiteSpace(report.Style)) await page.AddStyleTagAsync(new AddTagOptions() { Content = report.Style }); log.LogDebug($"Preparing page: adding Client meta data"); //Client meta data to JSON string var clientMeta = reportRequest.ClientMeta.ToString(); log.LogDebug($"Preparing page: adding Server meta data"); //Server meta data var logo = await ct.Logo.AsNoTracking().SingleOrDefaultAsync(); var HasSmallLogo = "false"; var HasMediumLogo = "false"; var HasLargeLogo = "false"; if (logo != null) { if (logo.Small != null) HasSmallLogo = "true"; if (logo.Medium != null) HasMediumLogo = "true"; if (logo.Large != null) HasLargeLogo = "true"; } var serverMeta = $"{{ayApiUrl:`{apiUrl}`, HasSmallLogo:{HasSmallLogo}, HasMediumLogo:{HasMediumLogo}, HasLargeLogo:{HasLargeLogo},}}"; log.LogDebug($"Preparing page: adding Report meta data"); //Custom fields definition for report usage string CustomFieldsTemplate = "null"; var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == report.ObjectType.ToString()); if (FormCustomization != null) { CustomFieldsTemplate = FormCustomization.Template; } //Report meta data var reportMeta = $"{{Id:{report.Id},Name:`{report.Name}`,Notes:`{report.Notes}`,ObjectType:`{report.ObjectType}`,CustomFieldsDefinition:{CustomFieldsTemplate},DataListKey:`{reportRequest.DataListKey}`,SelectedRowIds: `{string.Join(",", reportRequest.SelectedRowIds)}`}}"; //duplicate meta data in report page wide variable for use by our internal functions await page.AddScriptTagAsync(new AddTagOptions() { Content = $"var AYMETA={{ ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}" }); #if (DEBUG) //view page contents var pagecontent = await page.GetContentAsync(); #endif //prePareData / preRender var ReportDataObject = $"{{ ayReportData:{ReportData}, ayReportMetaData:{reportMeta}, ayClientMetaData:{clientMeta}, ayServerMetaData:{serverMeta} }}"; log.LogDebug($"Calling ayPreRender..."); //PRE_RENDER WITH TIMEOUT //Note: defaults to 30 seconds, going to leave that in as the default //in future may need to adjust but would probably do it in the context of a setting of some kind await page.WaitForExpressionAsync($"ayPreRender({ReportDataObject})", WaitTimeout); //compile the template //NOTE: TIMEOUT? log.LogDebug($"Calling Handlebars.compile..."); var compileScript = $"Handlebars.compile(`{report.Template}`)(PreParedReportDataObject);"; var compiledHTML = await page.EvaluateExpressionAsync(compileScript); //render report as HTML log.LogDebug($"Setting page content to compiled HTML"); await page.SetContentAsync(compiledHTML); //add style (after page or it won't work) if (!string.IsNullOrWhiteSpace(report.Style)) { log.LogDebug($"Adding report template Style CSS"); await page.AddStyleTagAsync(new AddTagOptions { Content = report.Style }); } string outputFileName = StringUtil.ReplaceLastOccurrence(FileUtil.NewRandomFileName, ".", "") + ".pdf"; string outputFullPath = System.IO.Path.Combine(FileUtil.TemporaryFilesFolder, outputFileName); //Set PDF options //https://pptr.dev/#?product=Puppeteer&version=v5.3.0&show=api-pagepdfoptions log.LogDebug($"Resolving PDF Options from report settings"); var PdfOptions = new PdfOptions() { }; PdfOptions.DisplayHeaderFooter = report.DisplayHeaderFooter; if (report.DisplayHeaderFooter) { PdfOptions.HeaderTemplate = report.HeaderTemplate; PdfOptions.FooterTemplate = report.FooterTemplate; } if (report.PaperFormat != ReportPaperFormat.NotSet) { switch (report.PaperFormat) { case ReportPaperFormat.A0: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A0; break; case ReportPaperFormat.A1: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A1; break; case ReportPaperFormat.A2: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A2; break; case ReportPaperFormat.A3: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A3; break; case ReportPaperFormat.A4: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A4; break; case ReportPaperFormat.A5: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A5; break; case ReportPaperFormat.A6: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.A6; break; case ReportPaperFormat.Ledger: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Ledger; break; case ReportPaperFormat.Legal: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Legal; break; case ReportPaperFormat.Letter: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Letter; break; case ReportPaperFormat.Tabloid: PdfOptions.Format = PuppeteerSharp.Media.PaperFormat.Tabloid; break; } } PdfOptions.Landscape = report.Landscape; if (!string.IsNullOrWhiteSpace(report.MarginOptionsBottom)) PdfOptions.MarginOptions.Bottom = report.MarginOptionsBottom; if (!string.IsNullOrWhiteSpace(report.MarginOptionsLeft)) PdfOptions.MarginOptions.Left = report.MarginOptionsLeft; if (!string.IsNullOrWhiteSpace(report.MarginOptionsRight)) PdfOptions.MarginOptions.Right = report.MarginOptionsRight; if (!string.IsNullOrWhiteSpace(report.MarginOptionsTop)) PdfOptions.MarginOptions.Top = report.MarginOptionsTop; //holding this back until figure it out //it's not really a report property, but a print time / render property //PdfOptions.PageRanges=report.PageRanges; PdfOptions.PreferCSSPageSize = report.PreferCSSPageSize; PdfOptions.PrintBackground = report.PrintBackground; //Defaults to 1. Scale amount must be between 0.1 and 2. PdfOptions.Scale = report.Scale; //render to pdf and return log.LogDebug($"Calling render page contents to PDF"); await page.PdfAsync(outputFullPath, PdfOptions); log.LogDebug($"Completed, returning results"); return outputFileName; } catch { //NOTE: in future may need to kill the chromium process if it's found to be hanging around //my preliminary thinking is to keep track of the browser.process.processid above and check if still present and zap if so //maybe *from* the controller (return pid as a return property from here? Kill it if still hanging around) //https://stackoverflow.com/questions/1642231/how-to-kill-a-c-sharp-process //This is the error when a helper is used on the template but doesn't exist: //Evaluation failed: d //(it might also mean other things wrong with template) if (PageLog.Length > 0) { log.LogInformation($"Exception caught while rendering report \"{report.Name}\", report Page console log:"); log.LogInformation(PageLog.ToString()); } // var v=await page.GetContentAsync(); throw; } finally { log.LogDebug($"Closing browser"); await browser.CloseAsync(); //this probably isn't absolutely necessary but insurance //bugbug: crashes linux? // var process = System.Diagnostics.Process.GetProcessById(ChromiumProcessID); // if (ChromiumProcessID > 0 && process?.HasExited == false) // { // log.LogError($"Error during render, Chromium process (pid {ChromiumProcessID}) still active, forcing it to stop now"); // process.Kill(); // } } } } //////////////////////////////////////////////////////////////////////////////////////////////// //JOB / OPERATIONS // //Other job handlers here... ///////////////////////////////////////////////////////////////////// }//eoc }//eons