GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
BrowserHelper.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Concurrent;
19using System.Collections.Generic;
20using System.Collections.ObjectModel;
21using System.Diagnostics;
22using System.Drawing;
23using System.Drawing.Imaging;
24using System.IO;
25using System.Linq;
26using System.Net.Http;
27using System.Net.Sockets;
28using System.Net.WebSockets;
29using System.Runtime.InteropServices;
30using System.Text;
31using System.Text.Json;
32using System.Threading;
33using System.Threading.Tasks;
34using System.Windows.Controls.Primitives;
35using System.Windows.Forms;
36using DocumentFormat.OpenXml.Bibliography;
37using Microsoft.Win32;
38using Newtonsoft.Json.Linq;
39using OpenQA.Selenium;
40using OpenQA.Selenium.Chrome;
41using OpenQA.Selenium.Chromium;
42using OpenQA.Selenium.DevTools;
43using OpenQA.Selenium.Edge;
44using OpenQA.Selenium.Firefox;
45using OpenQA.Selenium.IE;
46using OpenQA.Selenium.Support.UI;
48using static GenerallyPositive.Enums;
49using System.Net.NetworkInformation;
50using System.Net;
51using System.Text.RegularExpressions;
52
53
55{
56 public class BrowserHelper
57 {
58 static bool alreadyUpdating = false;
59
60 static List<string> lastErrorMessage = new List<string>();
61 static bool supressedMessage = false;
62 static string lastSessionToken;
63
71 public static IWebDriver GetBrowserDriver(BrowserSettings browserSettings)
72 {
73 IWebDriver tmpDriver = null;
74 ChromiumOptions options = null;
75 bool waitForDebugger = false;
76 string userAgent = GPAL.GPALSettings.UserAgent;
77
78 if (null != browserSettings.BrowserDriver)
79 return browserSettings.BrowserDriver;
80
81 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Running workflow for Automation Engine [{browserSettings.AutomationEngine}]", null, GPALObjectType.None);
82
83 if (null == browserSettings.DriverLocation)
84 // driver is located with executable
85 browserSettings.DriverLocation = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase).Substring(6); // skip file:\\
86
87 if (true == GPAL.GPALSettings.AutoUpdateWebDriver && false == alreadyUpdating)
88 {
89 alreadyUpdating = true;
90 DriverHelper.UpdateDriver(browserSettings);
91 // NOTE: no more port overlap, can have multiple running, each one exits when its own browser closes
92 // its stdin, no need to kill any longer, we'll just get the next available port
93 // BrowserHelper.KillProcess("GPALRestAPI"); // could be started by browser that has ottomagic installed
94 }
95
96 try
97 {
98 if (BrowserType.Chrome == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
99 {
100 if (BrowserType.Chrome == browserSettings.BrowserType)
101 options = new ChromeOptions();
102 else
103 options = new EdgeOptions();
104
105 // the driver answers alert, confirm and prompt itself, which is a webdriver capability and
106 // not something the page can see. nothing is overridden and nothing is injected
107 if (null != browserSettings.DialogsAccepted)
108 options.UnhandledPromptBehavior = true == browserSettings.DialogsAccepted
109 ? UnhandledPromptBehavior.Accept
110 : UnhandledPromptBehavior.Dismiss;
111
112 if (true == browserSettings.UseExistingBrowser)
113 {
114 options.DebuggerAddress = "127.0.0.1:" + browserSettings.ExistingBrowserPort.ToString();
115 waitForDebugger = true;
116 }
117 else
118 {
119 if (null == browserSettings.DebugPort && true == browserSettings.UsePuppeteer && false == browserSettings.DebugPipe) // make sure puppeteerURL is properly set - if debugport is set, it will update url, if not, set the debugport to default
120 {
121 // the first free port from 0xdead up, so this is still 57005 on a quiet machine and
122 // moves along when a browser from another run or another bot is already on it
123 browserSettings.DebugPort = FindFreePort();
124 browserSettings.PuppeteerUrl = $"http://localhost:{browserSettings.DebugPort}";
125 }
126
128 if (false == string.IsNullOrEmpty(browserSettings.DownloadLocation))
129 options.AddUserProfilePreference("download.default_directory", browserSettings.DownloadLocation);
130
131 // NOTE: chrome 136 requires a user-data-dir for security reasons
132 // Profile settings
133 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) || false == string.IsNullOrEmpty(browserSettings.ProfileName))
134 {
135 browserSettings.ProfileDataDirectory = GetBrowserProfileDirectory(browserSettings);
136
137 if (false == string.IsNullOrEmpty(browserSettings.ProfileName))
138 {
139 // the profile name might not be what we expect, which is a directory name.
140 // this will iterate thru all directories under user-data-dir, open the Preferences file and try to match token profile.name
141 // if it matches, then the directory containing that Preferences file is returned
142 string profileName = FindProfileDirectory(browserSettings.ProfileDataDirectory, browserSettings.ProfileName);
143 options.AddArguments($"--profile-directory={profileName ?? browserSettings.ProfileName}");
144 }
145 }
146 else if (true == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
147 {
148 // Create temporary profile if none specified
149 browserSettings.ProfileDataDirectory = ChromeProfileManager.CreateTempUserProfile(
150 browserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser),
151 browserSettings.PromptForDownload,
152 browserSettings.OpenPDFExternally,
153 browserSettings.LoadImages,
154 browserSettings.UseOttoMagic
155 );
156 browserSettings.TempProfileCreated = true;
157 }
158
159 // a supplied profile is the user's own. selenium writes the download preferences into it at
160 // launch and chrome keeps them, so read what is there now and Browser.Close puts it back.
161 // a temp profile is deleted on close, so there is nothing to preserve there
162 if (false == browserSettings.TempProfileCreated && false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
163 browserSettings.PreviousDownloadPreferences = ChromeProfileManager.ApplyDownloadPreferences(
164 browserSettings.ProfileDataDirectory,
165 browserSettings.DownloadLocation,
166 browserSettings.PromptForDownload,
167 browserSettings.OpenPDFExternally);
168
169 // a profile can only be owned by one browser. launching onto one that is already open hands
170 // the command line to that browser and exits, so the debug port never opens and the run
171 // waits for a browser that was never started. said here, where it can still be acted on
172 if (false == browserSettings.TempProfileCreated)
173 RefuseIfProfileIsOpen(browserSettings);
174
175 if (false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
176 options.AddArgument($"--user-data-dir={browserSettings.ProfileDataDirectory.Replace("\\", "/")}");
177 /* prolly no longer a use case for this - edge has some annoying popups that block selenium unless logged in as guest
178 else if (true == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory) && BrowserType.Edge == browserSettings.BrowserType)
179 options.AddArgument("--guest"); // in msedge, an annoyting 'personalize your experience' nag and options bar pops up blocking automation, this prevents that
180 */
181
182 // *******************************************************************
183 // NOTE: CAVEAT: suppose a time comes to run with some sort of extension, this isn't causing any harm, just annoying...
184 // not keen on disabling extensions, they make the bot look alive, but somehow we score low on recaptcha with ottomagic, no idea why, puppeteer is awesome
185 // *******************************************************************
186 options.AddArguments("--disable-extensions"); // no ottomagic - selenium and gpalrestapi/ottomagic DO NOT get along at all
187 // *******************************************************************
188 // *******************************************************************
189 // one --disable-features switch only, chromium takes the last one rather than merging them.
190 // edge's shopping content scripts mutate retail pages constantly, which is what wedges
191 // DOM.getDocument on an item page that chrome handles fine
192 string disableFeatures = "TranslateUI,Translate";
193
194 if (BrowserType.Edge == browserSettings.BrowserType)
195 disableFeatures += ",msShoppingTrigger,msShopping,msEdgeShoppingUI,msEdgeShoppingList";
196
197 options.AddArgument($"--disable-features={disableFeatures}");
198
199 // Disable infobars and any chrome overlays
200 options.AddArgument("--disable-infobars");
201 options.AddArgument("--disable-session-crashed-bubble");
202 options.AddArgument("--hide-crash-restore-bubble");
203 options.AddArgument("--noerrdialogs");
204
205 if (true == browserSettings.UseHeadless)
206 {
207 // Set headless mode
208 options.AddArgument("--headless=new");
209 // Disable GPU
210 //options.AddArgument("--disable-gpu");
211 // Disable software rasterizer
212 //options.AddArgument("--disable-software-rasterizer");
213 // Disable sandbox
214 //options.AddArgument("--no-sandbox");
215
216 // Disable notifications
217 options.AddArgument("--disable-notifications");
218 // Disable popup blocking
219 options.AddArgument("--disable-popup-blocking");
220 // Disable setuid sandbox
221 //options.AddArgument("--disable-setuid-sandbox");
222
223 // a headless browser says so in its user agent, so the word has to come out of it.
224 // Reading it from a real browser costs a whole launch, so that only happens when the
225 // workflow asked for it; otherwise the answer comes from what is already known
226 string currUserAgent;
227
228 if (false == browserSettings.UserAgentFromBrowser)
229 currUserAgent = ResolveUserAgent(browserSettings);
230 else
231 {
232 if (BrowserType.Chrome == browserSettings.BrowserType)
233 tmpDriver = new ChromeDriver(browserSettings.DriverLocation, (ChromeOptions)options);
234 else
235 tmpDriver = new EdgeDriver(browserSettings.DriverLocation, (EdgeOptions)options);
236
237 IJavaScriptExecutor js = (IJavaScriptExecutor)tmpDriver;
238 currUserAgent = (String)js.ExecuteScript("return navigator.userAgent");
239 tmpDriver.Quit();
240 }
241
242 options.AddArgument($"--user-agent={currUserAgent.Replace("Headless", "")}");
243
244 options.AddUserProfilePreference("download.prompt_for_download", false);
245 }
246 else
247 {
248 if (true == browserSettings.PromptForDownload)
249 options.AddUserProfilePreference("download.prompt_for_download", true);
250 else
251 options.AddUserProfilePreference("download.prompt_for_download", false);
252 }
253
254 // default for chrome is false - so turn on this safeguard
255 // Purpose: When set to true, it allows Chrome to automatically handle cases where the specified download directory
256 // (set via "download.default_directory") is invalid, inaccessible, or doesn't exist. Chrome may attempt to upgrade
257 // or fall back to a valid directory, ensuring downloads proceed without errors.
258 options.AddUserProfilePreference("download.directory_upgrade", true);
259
260 options.AddUserProfilePreference("plugins.always_open_pdf_externally", browserSettings.OpenPDFExternally);
261 options.AddArguments($"--blink-settings=imagesEnabled={browserSettings.LoadImages.ToString().ToLower()}");
262 options.AddUserProfilePreference("disable-popup-blocking", $"{!browserSettings.BlockPopUps}");
263
264 // turn on dark mode
265 if (browserSettings.StealthType.HasFlag(StealthType.DarkMode))
266 options.AddArgument("--force-dark-mode");
267
268 // Set remote debugging pipe or portt
269 if (true == browserSettings.DebugPipe)
270 options.AddArgument($"--remote-debugging-pipe --enable-unsafe-extension-debugging");
271 else if (null != browserSettings.DebugPort)
272 options.AddArgument($"--remote-debugging-port={browserSettings.DebugPort.Value}");
273
274 //options.AddUserProfilePreference("safebrowsing.enabled", true); // CAVEAT: should this be configurable? disables warnings while downloading files... 'this may harm' seems we don't want that during automation
275
276 // obscure anti-bot detections
277 // Adding argument to disable the AutomationControlled flag
278 // no longer supported, throws infobar message 5/12/25
279 options.AddArguments("--disable-blink-features=AutomationControlled");
280
281 // Exclude the collection of enable-automation switches
282 options.AddExcludedArgument("enable-automation");
283 if (options is ChromeOptions chromeOpts)
284 chromeOpts.AddAdditionalChromeOption("useAutomationExtension", false);
285 else if (options is EdgeOptions edgeOpts)
286 edgeOpts.AddAdditionalEdgeOption("useAutomationExtension", false);
287 }
288
289 if (browserSettings.StealthType.HasFlag(StealthType.PatchDriver))
290 {
291 if (BrowserType.Edge == browserSettings.BrowserType)
292 EdgePatcher.PatchEdgeDriver(browserSettings.Browser);
293 else
294 ChromePatcher.PatchChromeDriver(browserSettings.Browser);
295 }
296
297 // selenium will not accept a url as a commandline option, adds -- in front, there is no
298 // options.AddArgument(browserSettings.CurrentURL); // doesn't always work, some enterprise browsers have a mandatory first page that bypasses commandline url
299
300 // Use this to query the logs to get the server response code for the goto
301 // NOTE: CAVEAT: this might make us more detectable in selenium??
302 // this is not a required feature, to return the navigation status code in SeleniumGoto
303 options.SetLoggingPreference(OpenQA.Selenium.LogType.Performance, LogLevel.All);
304
305 tmpDriver = GetWebDriver(browserSettings, options);
306
307 //if (BrowserType.Chrome == browserSettings.BrowserType)
308 // tmpDriver = new ChromeDriver(browserSettings.DriverLocation, (ChromeOptions)options);
309 //else
310 // tmpDriver = new EdgeDriver(browserSettings.DriverLocation, (EdgeOptions)options);
311 }
312 else if (BrowserType.FireFox == browserSettings.BrowserType)
313 {
314 // read more: https://github.com/mozilla/geckodriver/issues/430
315 FirefoxDriverService firefoxDriverService = FirefoxDriverService.CreateDefaultService(browserSettings.DriverLocation);
316 FirefoxOptions ffOptions = new FirefoxOptions();
317
318 if (null != browserSettings.DialogsAccepted)
319 ffOptions.UnhandledPromptBehavior = true == browserSettings.DialogsAccepted
320 ? UnhandledPromptBehavior.Accept
321 : UnhandledPromptBehavior.Dismiss;
322 if (true == browserSettings.UseExistingBrowser)
323 {
324 firefoxDriverService.HideCommandPromptWindow = true;
325 firefoxDriverService.Port = 0; // let the driver take any free port for its own http server
326 firefoxDriverService.Host = "127.0.0.1";
327 firefoxDriverService.ConnectToRunningBrowser = true;
328 // marionette is Firefox's side of the connection, the same role the debugger port plays for
329 // Chrome and Edge above. 2828 is Firefox's own default when the workflow did not name one
330 firefoxDriverService.BrowserCommunicationPort = browserSettings.ExistingBrowserPort ?? 2828;
331
332 tmpDriver = new FirefoxDriver(firefoxDriverService, ffOptions);
333
334 waitForDebugger = true;
335 }
336 else
337 {
338 // Set window size
339 ffOptions.AddArgument($"-width={browserSettings.WindowSize.Width}");
340 ffOptions.AddArgument($"-height={browserSettings.WindowSize.Height}");
341
342 if (true == browserSettings.UseHeadless)
343 {
344
345 // Set headless mode
346 ffOptions.AddArgument("-headless");
347
348 // Disable notifications
349 ffOptions.AddArgument("-disable-notifications");
350
351 // Set user agent - this makes firefox not start byt gives error
352 // if (null != userAgent)
353 // ffOptions.AddArgument($"--user-agent={userAgent}");
354 }
355
356 if (null != browserSettings.DebugPort)
357 {
358 //firefoxDriverService.HideCommandPromptWindow = false;
359 //firefoxDriverService.BrowserCommunicationPort = 2828;
360 firefoxDriverService.Port = (int)browserSettings.DebugPort.Value;
361 //firefoxDriverService.Host = "127.0.0.1";
362 //firefoxDriverService.ConnectToRunningBrowser = true;
363 // firefoxDriverService.Start();
364
365 if (true == browserSettings.UsePuppeteer)
366 browserSettings.PuppeteerUrl = $"http://localhost:{browserSettings.DebugPort}";
367
368 if (string.IsNullOrEmpty(browserSettings.ProfileDataDirectory) && string.IsNullOrEmpty(browserSettings.ProfileName) && string.IsNullOrEmpty(browserSettings.ProfileUserName))
369 ffOptions.Profile = new OpenQA.Selenium.Firefox.FirefoxProfileManager().GetProfile("default");
370 }
371 else if (true == browserSettings.UsePuppeteer) // make sure puppeteerURL is properly set - if debugport is set, it will update url, if not, set the debugport to default
372 {
373 // the first free port from 0xdead up, so this is still 57005 on a quiet machine and
374 // moves along when a browser from another run or another bot is already on it.
375 // asked for once: a browser that already has a port keeps it, and taking a second
376 // leaves the first reserved against a browser that will never use it
377 if (null == browserSettings.DebugPort)
378 browserSettings.DebugPort = FindFreePort();
379
380 browserSettings.PuppeteerUrl = $"http://localhost:{browserSettings.DebugPort}";
381 }
382
383 if (null != browserSettings.DebugPort)
384 ffOptions.AddArgument($"--remote-debugging-port={browserSettings.DebugPort.Value} ");
385
386 ffOptions.SetPreference("print.always_print_silent", true);
387 ffOptions.SetPreference("print.show_print_progress", false);
388
389
390 // headless turns the viewer off whatever the workflow asked. there is nobody to render a
391 // pdf for, and Get() sets OpenPDFExternally false for headless, which would otherwise
392 // switch pdfjs back on and leave every pdf displayed to no one instead of downloaded
393 ffOptions.SetPreference("pdfjs.disabled", browserSettings.OpenPDFExternally || browserSettings.UseHeadless);
394
395 if (false == browserSettings.PromptForDownload)
396 ffOptions.SetPreference("browser.helperApps.neverAsk.saveToDisk", GPAL.GPALSettings.FirefoxDirectDownloadMimeTypes);
397 else
398 ffOptions.SetPreference("browser.helperApps.neverAsk.saveToDisk", "");
399
400 if (false == browserSettings.LoadImages)
401 ffOptions.SetPreference("permissions.default.image", 0);
402 else
403 ffOptions.SetPreference("permissions.default.image", 1);
404
405 // how many popups one event may open. zero is none, and twenty is what firefox ships
406 // with, so blocking is the low number and the sense of this reads backwards
407 ffOptions.SetPreference("dom.popup_maximum", true == browserSettings.BlockPopUps ? 0 : 20);
408
409 ffOptions.SetPreference("safebrowsing.enabled", false); // TODO: should this be configurable? disables warnings while downloading files... 'this may harm' seems we don't want that during automation
410
411 if (false == string.IsNullOrEmpty(browserSettings.DownloadLocation))
412 {
413 ffOptions.SetPreference("browser.download.dir", browserSettings.DownloadLocation);
414 ffOptions.SetPreference("browser.download.folderList", 2); // 2: Use the custom download directory
415 }
416
417 ffOptions.SetPreference("dom.webdriver.enabled", false);
418
419 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) || false == string.IsNullOrEmpty(browserSettings.ProfileName))
420 // browserSettings.ProfileDataDirectory = GetBrowserProfileDirectory(browserSettings); // not sure this is applicable to firefox
421 ffOptions.AddArgument($"-profile {browserSettings.ProfileName ?? browserSettings.ProfileUserName}");
422 else if (false == string.IsNullOrEmpty(browserSettings.ProfileDataDirectory))
423 ffOptions.AddArgument($"-profile {browserSettings.ProfileDataDirectory}");
424
425 // Ensure that debugging is enabled in Firefox, F12, ..., settings, enable browser chrome and bugging toolboxes checkbox, enable remote debugging checkbox
426 //options.AddArguments("-foreground");
427 //options.AddArguments("-start-debugger-server 2828");
428 //tmpDriver = new FirefoxDriver(browserSettings.driverLocation, options);
429
430 // kludge on opening first window
431 //Uri safe = RemoteWebDriverExtended.FirefoxURL;
432 //RemoteWebDriverExtended.FirefoxURL = new Uri(browserSettings.currentURL);
433 //tmpDriver = RemoteWebDriverExtended.GetFireFoxDriver(browserSettings.driverLocation);
434 //RemoteWebDriverExtended.FirefoxURL = safe;
435 }
436
437 ffOptions.BinaryLocation = FindFirefoxBinaryLocation();
438
439// tmpDriver = new FirefoxDriver(browserSettings.DriverLocation, ffOptions); // would ever start up a session with no options?
440 // Explicit service for Firefox to get geckodriver PID
441 if (true == browserSettings.HiddenDesktop)
442 {
443 // the driver is started by hand so it can be named a desktop, and Selenium is handed the
444 // port rather than a service. StartHiddenDriver sets ServiceDriverPid from the launch
445 int hiddenPort = StartHiddenDriver(browserSettings);
446
447 tmpDriver = new OpenQA.Selenium.Remote.RemoteWebDriver(new Uri($"http://localhost:{hiddenPort}"), ffOptions);
448 }
449 else
450 {
451 FirefoxDriverService firefoxService = FirefoxDriverService.CreateDefaultService(browserSettings.DriverLocation);
452 firefoxService.HideCommandPromptWindow = true; // hides the console window
453
454 tmpDriver = new FirefoxDriver(firefoxService, ffOptions);
455
456 // Capture geckodriver.exe PID — reuse the same property
457 browserSettings.ServiceDriverPid = firefoxService.ProcessId;
458 }
459
460 // and firefox's own, which it reports as a capability. the driver launches the browser, so
461 // nothing else here has a handle on it, and every fallback in the teardown is written around
462 // having one: with no handle there is no wait for it to go, no warning that it did not, and
463 // no kill. geckodriver is killed by pid at the end, and a firefox that outlived its driver is
464 // then orphaned and runs on
465 browserSettings.Process = SeleniumBrowserProcess(tmpDriver, "moz:processID");
466
467 //tmpDriver = new FirefoxDriver(firefoxDriverService, ffOptions);
468 }
469 }
470 catch (GPALException)
471 {
472 throw;
473 }
474 catch (Exception ex)
475 {
476 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to start browser driver for [{browserSettings.BrowserType}].", browserSettings.Browser, GPALObjectType.Browser, ex);
477 // unrecoverable error, the only time we rethrow
478 // return null;
479 throw;
480 }
481 if (null != tmpDriver)
482 {
483 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Browser [{browserSettings.BrowserType}] launched.", browserSettings, GPALObjectType.Other);
484
485 browserSettings.BrowserDriver = tmpDriver;
486
487 // Edge preloads its New Tab Page into a hidden background window that appears asynchronously as an
488 // extra WindowHandle and corrupts our sequential tab-index tracking. At this instant (before we
489 // navigate) the driver's url is that preload's url, so capture it as a runtime signature - later we
490 // positively match the phantom tab by this url, never by position (which races the async insert)
491 // nor a hardcoded string.
492 var edgeGhostBrowser = browserSettings.Browser as Browser;
493 if (BrowserType.Edge == browserSettings.BrowserType && null != edgeGhostBrowser)
494 try { edgeGhostBrowser.GhostTabUrl = tmpDriver.Url; }
495 catch { /* non-fatal: without a signature we simply never skip a ghost */ }
496
497 // pageload timeout to connect 30 seconds
498 tmpDriver.Manage().Timeouts().PageLoad.Add(System.TimeSpan.FromSeconds(30)); // NOTE: hardcoded value
499
500 //BrowserHelper.SeleniumGoToUrl(browserSettings.CurrentURL, browserSettings);
501
502 browserSettings.CurrentURL = "https://google.com";
503
504 if (true == browserSettings.Maximize)
505 tmpDriver.Manage().Window.Maximize();
506 else if (true == browserSettings.Minimize)
507 tmpDriver.Manage().Window.Minimize();
508 else if (true == browserSettings.FullScreen)
509 tmpDriver.Manage().Window.FullScreen();
510 else if (false == browserSettings.WindowSize.IsEmpty)
511 {
512 // Set the desired window size
513 var windowSize = new Size(browserSettings.WindowSize.Width, browserSettings.WindowSize.Height);
514 tmpDriver.Manage().Window.Size = windowSize;
515
516 // Set the desired window position (top-left corner). Point is x then y, which is Left then Top
517 var windowPosition = new Point(browserSettings.WindowSize.Left, browserSettings.WindowSize.Top);
518 tmpDriver.Manage().Window.Position = windowPosition;
519 }
520
521 if (true == waitForDebugger)
522 Thread.Sleep(2000);
523
524 if (BrowserType.Chrome == browserSettings.BrowserType)
525 {
526 DriverHelper.GetChromeVersion(out browserSettings.Version);
527 }
528 else if (BrowserType.Edge == browserSettings.BrowserType)
529 {
530 DriverHelper.GetEdgeVersion(out browserSettings.Version);
531 }
532 else if (BrowserType.FireFox == browserSettings.BrowserType)
533 {
534 DriverHelper.GetFirefoxVersion(out browserSettings.Version);
535 }
536
537 CheckDocumentReady(((Browser)browserSettings.Browser), true);
538 }
539
540 return tmpDriver;
541 }
542
555 public static void AllowDownloads(BrowserSettings browserSettings, string directory)
556 {
557 var parameters = new Dictionary<string, object>
558 {
559 { "behavior", "allow" },
560 { "downloadPath", directory }
561 };
562
563 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.setDownloadBehavior", parameters);
564 }
565
576 internal static string ResolveUserAgent(BrowserSettings browserSettings)
577 {
578 string userAgent = browserSettings.OverrideUserAgent;
579
580 if (true == string.IsNullOrWhiteSpace(userAgent))
581 userAgent = GPAL.GPALSettings.UserAgent;
582
583 if (true == string.IsNullOrWhiteSpace(userAgent))
584 userAgent = MagicHelper.GetUserAgentString(browserSettings.BrowserType);
585
586 return userAgent;
587 }
601 internal static void PresentCredentials(Browser browser, GPALUrl url)
602 {
603 Credentials credential = (Credentials)browser.BrowserSettings.Credentials;
604 WebAuthType authType = credential.WebAuthType;
605 string header = AuthorizationHeader(credential, authType);
606
607 browser.BrowserSettings.CredentialsPresented = DateTime.UtcNow;
608
609 // a form is a page, and a page is the workflow's job. Holding the credential is all that was asked for
610 if (WebAuthType.Form == authType || WebAuthType.None == authType)
611 return;
612
613 if (true == string.IsNullOrEmpty(header) || BrowserType.FireFox == browser.BrowserSettings.BrowserType || true == browser.UseOttoMagic)
614 CredentialsInUrl(browser, url, credential);
615 else
616 {
617 string name = AuthorizationHeaderName(authType);
618 Dictionary<string, object> headers = new Dictionary<string, object> { { name, header } };
619
620 if (true == browser.UsePuppeteer)
621 browser.PuppeteerCommunicator.SetExtraHttpHeaders(headers).GetAwaiter().GetResult();
622 else
623 ((OpenQA.Selenium.Chromium.ChromiumDriver)browser.BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders",
624 new Dictionary<string, object> { { "headers", headers } });
625
626 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Credentials presented as [{authType}] for [{url?.Url}]", browser, GPALObjectType.Browser);
627 }
628 }
635 internal static string AuthorizationHeader(Credentials credential, WebAuthType authType)
636 {
637 string value = null;
638
639 if (WebAuthType.Basic == authType || WebAuthType.Proxy == authType)
640 value = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"));
641 else if (WebAuthType.Bearer == authType)
642 value = "Bearer " + credential.AccessToken;
643 else if (WebAuthType.XAuthToken == authType || WebAuthType.XApiKey == authType || WebAuthType.ApiKey == authType)
644 value = credential.AccessToken; // their own header, so no scheme word in front of it
645
646 return value;
647 }
648
654 internal static string AuthorizationHeaderName(WebAuthType authType)
655 {
656 string name = "Authorization";
657
658 if (WebAuthType.Proxy == authType)
659 name = "Proxy-Authorization";
660 else if (WebAuthType.XAuthToken == authType)
661 name = "X-Auth-Token";
662 else if (WebAuthType.XApiKey == authType)
663 name = "X-API-Key";
664 else if (WebAuthType.ApiKey == authType)
665 name = "api-key";
666
667 return name;
668 }
677 static void CredentialsInUrl(Browser browser, GPALUrl url, Credentials credential)
678 {
679 UriBuilder builder = new UriBuilder(url?.Url)
680 {
681 UserName = Uri.EscapeDataString(credential.Username ?? string.Empty),
682 Password = Uri.EscapeDataString(credential.Password ?? string.Empty)
683 };
684 GPALUrl credentialed = new GPALUrl(builder.Uri.ToString());
685
686 // the engines are called directly rather than through .GoTo, because .GoTo announces where it is
687 // going and this url is a password
688 if (true == browser.UseOttoMagic)
689 browser.MagicHelper.GoTo(credentialed);
690 else if (true == browser.UsePuppeteer)
691 browser.PuppeteerClient.GoTo(credentialed).Execute();
692 else
693 SeleniumGoToUrl(credentialed, browser.BrowserSettings);
694
695 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Credentials presented to [{builder.Host}], answering its challenge", browser, GPALObjectType.Browser);
696 }
697
712 private static Process SeleniumBrowserProcess(IWebDriver driver, string capability)
713 {
714 Process retVal = null;
715
716 try
717 {
718 object pid = (driver as IHasCapabilities)?.Capabilities?.GetCapability(capability);
719
720 if (null != pid)
721 retVal = Process.GetProcessById(Convert.ToInt32(pid));
722 }
723 catch (Exception ex)
724 {
725 // a pid that names nothing is a browser that is already gone, which is not worth a warning here.
726 // the teardown treats a null the same as it always did
727 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"No browser process behind the driver for [{capability}]", null, GPALObjectType.Browser, ex);
728 }
729
730 return retVal;
731 }
732 internal static void WaitForBrowserToExit(BrowserSettings browserSettings, int timeoutMs)
733 {
734 try
735 {
736 browserSettings.Process?.WaitForExit(timeoutMs);
737 }
738 catch (Exception ex)
739 {
740 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not wait on the browser process before restoring [{browserSettings.ProfileDataDirectory}]", browserSettings.Browser, GPALObjectType.Browser, ex);
741 }
742 }
743
757 public static void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
758 {
759 string directory = Path.GetDirectoryName(destination);
760 string filename = Path.GetFileName(destination);
761
762 var enableDownloadCommandParameters = new Dictionary<string, object>
763 {
764 { "behavior", "allow" },
765 { "downloadPath", directory },
766 { "filename", filename }
767 };
768
769 if (BrowserType.Chrome == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
770 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.setDownloadBehavior", enableDownloadCommandParameters);
771 }
772
787 internal const string CallRecorderScript = @"
788 (function () {
789 if (window.__gpalCalls) return;
790
791 window.__gpalCalls = [];
792
793 var record = function (url, method, headers, body, type) {
794 var call = { url: String(url), method: method || 'GET', type: type, headers: headers || {}, postData: body || null, status: 0 };
795 window.__gpalCalls.push(call);
796 return call;
797 };
798
799 var nativeFetch = window.fetch;
800
801 if (nativeFetch)
802 window.fetch = function (input, init) {
803 var url = input && input.url ? input.url : input;
804 var headers = {};
805 var options = init || {};
806
807 // a Headers object, a plain object or an array of pairs, all of which fetch accepts
808 if (options.headers && options.headers.forEach)
809 options.headers.forEach(function (v, k) { headers[k] = v; });
810 else if (options.headers)
811 Object.keys(options.headers).forEach(function (k) { headers[k] = options.headers[k]; });
812
813 var call = record(url, options.method, headers, options.body, 'Fetch');
814
815 return nativeFetch.apply(this, arguments).then(function (response) {
816 call.status = response.status;
817 return response;
818 }, function (error) {
819 call.status = 0;
820 throw error;
821 });
822 };
823
824 var nativeOpen = XMLHttpRequest.prototype.open;
825 var nativeSend = XMLHttpRequest.prototype.send;
826 var nativeSetHeader = XMLHttpRequest.prototype.setRequestHeader;
827
828 XMLHttpRequest.prototype.open = function (method, url) {
829 this.__gpalCall = { method: method, url: url, headers: {} };
830 return nativeOpen.apply(this, arguments);
831 };
832
833 XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
834 if (this.__gpalCall) this.__gpalCall.headers[name] = value;
835 return nativeSetHeader.apply(this, arguments);
836 };
837
838 XMLHttpRequest.prototype.send = function (body) {
839 var pending = this.__gpalCall;
840
841 if (pending) {
842 var call = record(pending.url, pending.method, pending.headers, body, 'XHR');
843 this.addEventListener('loadend', function () { call.status = this.status; });
844 }
845
846 return nativeSend.apply(this, arguments);
847 };
848 })();
849 ";
850
851 public static string SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string script)
852 {
853 if (BrowserType.Chrome != browserSettings.BrowserType && BrowserType.Edge != browserSettings.BrowserType)
854 {
855 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.addScriptToEvaluateOnNewDocument", browserSettings.Browser, GPALObjectType.Browser);
856 return null;
857 }
858
859 var result = ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
860 {
861 { "source", script }
862 }) as Dictionary<string, object>;
863
864 return result?["identifier"]?.ToString();
865 }
866
873 public static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
874 {
875 if (BrowserType.Chrome != browserSettings.BrowserType && BrowserType.Edge != browserSettings.BrowserType)
876 {
877 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.removeScriptToEvaluateOnNewDocument", browserSettings.Browser, GPALObjectType.Browser);
878 return;
879 }
880
881 ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.removeScriptToEvaluateOnNewDocument", new Dictionary<string, object>
882 {
883 { "identifier", identifier }
884 });
885 }
886
892 public static string GetBrowserProfileDirectory(BrowserSettings browserSettings, bool getDefaultDirectory = false)
893 {
894 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
895 string profilePath = string.Empty;
896
897 switch (browserSettings.BrowserType)
898 {
899 case BrowserType.Chrome:
900 profilePath = Path.Combine(localAppData, "Google", "Chrome", "User Data");
901 break;
902 case BrowserType.FireFox:
903 profilePath = Path.Combine(localAppData, "Mozilla", "Firefox", "Profiles");
904 break;
905 case BrowserType.Edge:
906 profilePath = Path.Combine(localAppData, "Microsoft", "Edge", "User Data");
907 break;
908 default:
909 Exception ex = new Exception("Unsupported Browser Type"); // let's force this as an exception to post to the exception channel
910 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unsupported browser type for loading user profile [{browserSettings.ProfileUserName}]: [{browserSettings.BrowserType.ToString()}]", ex);
911 break;
912 }
913
914 string newProfilePath = profilePath;
915 // chrome 136 will not let the default directory be specified as the user-data-dir if running via selenium (no access to user cookies)
916 // - copy to a new folder is a workaround, we will guard against defining them the same
917 if (false == string.IsNullOrEmpty(browserSettings.ProfileUserName) && false == getDefaultDirectory)
918 {
919 newProfilePath = Path.Combine(profilePath, browserSettings.ProfileUserName);
920 // Check if the profile directory exists
921 if (!Directory.Exists(newProfilePath))
922 {
923 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"User name [{browserSettings.ProfileUserName}] profile directory does not exist : [{browserSettings.BrowserType.ToString()}]");
924 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Returning [{profilePath}]");
925 return profilePath;
926 }
927 }
928 return newProfilePath;
929 }
930
939 public static string FindProfileDirectory(string userDataDirectory, string profileName)
940 {
941 string[] profileDirectories = Directory.GetDirectories(userDataDirectory);
942
943 foreach (string profileDirectory in profileDirectories)
944 {
945 string preferencesFile = Path.Combine(profileDirectory, "Preferences");
946
947 if (File.Exists(preferencesFile))
948 {
949 string profileNameFromFile = GetProfileNameFromPreferencesFile(preferencesFile);
950 if (null != profileNameFromFile && profileNameFromFile.Equals(profileName, StringComparison.OrdinalIgnoreCase))
951 {
952 return new DirectoryInfo(profileDirectory).Name;
953 }
954 }
955 }
956 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Unable to find profile for [{profileName}]");
957 return null; // Profile not found
958 }
959
965 static string GetProfileNameFromPreferencesFile(string preferencesFilePath)
966 {
967 try
968 {
969 string preferencesContent = File.ReadAllText(preferencesFilePath);
970 JObject preferencesJson = JObject.Parse(preferencesContent);
971
972 // Extract the given_name from the JSON structure
973 JToken givenNameToken = preferencesJson.SelectToken("profile.name");
974 if (givenNameToken != null)
975 {
976 return givenNameToken.ToString();
977 }
978
979 return null; // given_name not found
980 }
981 catch
982 {
983 return null; // ERROR occurred while parsing JSON or accessing values
984 }
985 }
986
992 public static string FindFirefoxBinaryLocation()
993 {
994 // Open the registry key corresponding to Firefox installation
995 using (RegistryKey key = Registry.LocalMachine.OpenSubKey(GPAL.GPALSettings.FirefoxBinaryPathRegistry))
996 {
997 if (key != null)
998 {
999 // Retrieve the version of Firefox installed
1000 string firefoxPath = key.GetValue(null)?.ToString(); // get (Default) value
1001 if (!string.IsNullOrEmpty(firefoxPath))
1002 return firefoxPath;
1003 }
1004 }
1005
1006 return null; // Firefox binary location not found
1007 }
1008
1015 public static string FindTab(BrowserSettings browserSettings, string URL)
1016 {
1017 var handles = browserSettings.BrowserDriver.WindowHandles;
1018 foreach (var handle in handles)
1019 {
1020 browserSettings.BrowserDriver.SwitchTo().Window(handle);
1021 if (URL.Contains(browserSettings.BrowserDriver.Url) || browserSettings.BrowserDriver.Url.Contains(URL))
1022 {
1023 return handle;
1024 }
1025 }
1026 return null;
1027 }
1028
1035 public static bool GotoNextPage(Browser browser)
1036 {
1037
1038 List<GPALElement> matchedElems = null;
1039 ReadOnlyCollection<GPALElement> elems = null;
1040 bool clicked = false;
1041 byte VK_END = 0x23;
1042 int withAllThatMatchSave = browser.CurrentUOW.WithAllThatMatch;
1043
1044 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Getting next page.", browser, GPALObjectType.Browser);
1045 browser.WithAllThatMatch(1);
1046
1047
1048 // CAVEAT: TODO: rework, scroll and scrape, scrolling might scroll elements off page or unrender them
1049 if (false == browser.CurrentUOW.InfiniteScroll)
1050 {
1051 // we cannot used cached results knowing we are going to a new page
1052 browser.CurrentUOW.NextPageButton.WebSelectorFoundResults = null;
1053 browser.CurrentUOW.NextPageButton.WebSelectorMatchedResults = null;
1054 foreach (var sel in browser.CurrentUOW.WithSelectorList)
1055 {
1056 sel.WebSelectorFoundResults = null;
1057 sel.WebSelectorMatchedResults = null;
1058 }
1059
1060 elems = ElementHelper.FindWebElements(browser, browser.CurrentUOW, browser.CurrentUOW.NextPageButton, out bool matchedAll, out matchedElems, null, false);
1061
1062 // can only be one match
1063 if (null != elems && 0 < elems.Count && true == matchedAll)
1064 {
1065 try
1066 {
1067
1068 if (false == string.IsNullOrEmpty(elems[0].Href))
1069 {
1070 string href = elems[0].Href;
1071 UnitOfWork saveUOW = browser.CurrentUOW; // keep this in
1072 bool obeySave = browser.BrowserSettings.ObeyRobotsTxt;
1073 browser.BrowserSettings.ObeyRobotsTxt = false;
1074 browser.GoTo(href); // checkdocumentready is in goto - do not perform robots check on navigation
1075 browser.CurrentUOW = saveUOW;
1076 browser.BrowserSettings.ObeyRobotsTxt = obeySave;
1077 browser.BrowserSettings.CurrentURL = href;
1078 }
1079 else
1080 {
1081 // can be only one next page button elems[0] - on ebay, the dimensions are weird > 10k
1082 elems[0].Click();
1083 //CheckDocumentReady(browser);
1084 }
1085
1086 clicked = true;
1087 }
1088 catch (GPALException)
1089 {
1090 throw;
1091 }
1092 catch (Exception ex)
1093 {
1094 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception thrown", browser, GPALObjectType.Browser, ex);
1095 }
1096 }
1097 else
1098 {
1099 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Unable to find Next Page Button", browser, GPALObjectType.Browser);
1100 }
1101 }
1102 else // infinite scroll, go to page end and wait for load
1103 {
1104 if (false == browser.BrowserSettings.UseHeadless && (true == browser.BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
1105 HardwareHelper.SendKey(VK_END);
1106 else
1107 _ = browser.PageEnd;
1108
1109 CheckDocumentReady(browser);
1110
1111 clicked = true;
1112 }
1113
1114 browser.WithAllThatMatch(withAllThatMatchSave);
1115
1116 return clicked;
1117 }
1118
1129 public static void FillInWithTokens(Browser browser, IGPALGrid<string> tokens, WriteMode writeMode)
1130 {
1131 int tokenIdx = 0;
1132 List<ReadOnlyCollection<GPALElement>> rowsOfColumns = new List<ReadOnlyCollection<GPALElement>>();
1133 List<ReadOnlyCollection<GPALElement>> rowsOfElements = new List<ReadOnlyCollection<GPALElement>>();
1134 // a column-wise list of the selector info to match with the column of matched/returned elements to know how to interact with that column of elemnts
1135 // of course selectors are always a 'column'
1136 List<SmallSelectorNode> interactionInfo = new List<SmallSelectorNode>();
1137
1138 while (true)
1139 {
1140
1141 // waitfor did not match if returns false
1142 int rowCnt = 0;
1143 int lastRowCnt = 0;
1144 int elementIdx = 0;
1145
1146 interactionInfo.Clear(); // are we looping? of course, the CallAfterFillIn method workflow has returned for the next iteration.
1147 rowsOfElements.Clear();
1148 rowsOfColumns.Clear();
1149 browser.CurrentUOW.MatchedRowIndexes.Clear();
1150 browser.CurrentUOW.ColCount = 0;
1151 browser.CurrentUOW.RowCount = 0; // Math.Max below only ever raises it, so a pass that finds fewer elements than the last would size the grid for rows it has no elements to fill
1152 browser.CurrentUOW.PartialMatch = false; // only ever |= true below, so one pass that did not match everything would put every later pass down the partial-match path
1153
1154 foreach (Selector sel in browser.CurrentUOW.WithSelectorList)
1155 {
1156 if (SelectorType.Selector != sel.SelectorType)
1157 continue; // we don't look up data literals
1158
1159 // TODO: what to do on partial match?
1160 List<GPALElement> matchedElems = null;
1161 ReadOnlyCollection<GPALElement> tmpElems = ElementHelper.FindWebElements(browser, browser.CurrentUOW, sel, out bool matchedAll, out matchedElems);
1162 rowsOfElements.Add(tmpElems);
1163 browser.CurrentUOW.ColCount++;
1164
1165 if (false == matchedAll)
1166 browser.CurrentUOW.PartialMatch |= true;
1167
1168 if (0 < tmpElems?.Count && true == matchedAll)
1169 {
1170 // RowCount is the total count of elements found or just the WithAllThatMatch row count
1171 browser.CurrentUOW.RowCount = Math.Max(browser.CurrentUOW.RowCount, (int.MaxValue == browser.CurrentUOW.WithAllThatMatch ? tmpElems.Count : browser.CurrentUOW.WithAllThatMatch));
1172 }
1173 else if (0 < matchedElems?.Count && false == matchedAll)
1174 {
1175 // partial match
1176 int tmpIdx = 0;
1177 // add the indices of the matched items
1178 // when we build the grid, we will only get rows that matched
1179 // NO 'AND' on selector MATCH
1180 // only 'OR' operator, all those that match on different selectors
1181 foreach (GPALElement webElement in matchedElems)
1182 if (-1 != (tmpIdx = tmpElems.IndexOf(webElement)))
1183 if (false == browser.CurrentUOW.MatchedRowIndexes.Contains(new KeyValuePair<int, string>(tmpIdx, sel.AttributeName)))
1184 browser.CurrentUOW.MatchedRowIndexes.Add(tmpIdx, sel.AttributeName);
1185
1186 // matched row indexes is the row count we will extract data from on partial matches, so however many indices we have, that is our MAX row count
1187 // up to WithAllThatMatch row count if not int.MaxValue
1188 browser.CurrentUOW.RowCount = (int.MaxValue == browser.CurrentUOW.WithAllThatMatch ? browser.CurrentUOW.MatchedRowIndexes.Count : browser.CurrentUOW.WithAllThatMatch);
1189 }
1190
1191 // if we returned elements, add an entry for this selector in interactionInfo, we consume it below
1192 if (0 < matchedElems?.Count || 0 < tmpElems?.Count)
1193 interactionInfo.Add(new SmallSelectorNode() { InteractionType = sel.InteractionType, OffsetX = sel.OffsetX, OffsetY = sel.OffsetY, DeltaX = sel.DeltaX, DeltaY = sel.DeltaY });
1194 }
1195
1196 // we have partial matches, so construct the grid only from matched rows
1197 if (true == browser.CurrentUOW.PartialMatch)
1198 {
1199 List<GPALElement> newColumn = new List<GPALElement>();
1200
1201 browser.CurrentUOW.RowCount = browser.CurrentUOW.MatchedRowIndexes.Count;
1202 // get a list of all the elements in one column
1203 // iterate thru each column pulling out matched rows
1204 // create the return grid as only matched elements
1205 foreach (ReadOnlyCollection<GPALElement> webElements in rowsOfElements)
1206 {
1207 // create a newCOlumn from all elements via indexes where rows 'matched' on some selector value
1208 // we have multiple selectors contributing to matched rows based upon that selectors 'match' criteria
1209 foreach (KeyValuePair<int, string> idx in browser.CurrentUOW.MatchedRowIndexes)
1210 newColumn.Add(webElements[idx.Key]);
1211 rowsOfColumns.Add(new ReadOnlyCollection<GPALElement>(newColumn));
1212 newColumn = new List<GPALElement>();
1213 }
1214 }
1215 else
1216 {
1217 foreach (ReadOnlyCollection<GPALElement> webElements in rowsOfElements)
1218 rowsOfColumns.Add(webElements);
1219 }
1220
1221 // if we are looping, we have a new 'page' and new elements to interact with, our old list is stale
1222 browser.CurrentUOW.ElementGrid.Clear();
1223
1224 // we should have one column for each selector (that found elements)
1225 if (0 != browser.CurrentUOW.ColCount)
1226 {
1227 for (int cnt = 0; cnt < browser.CurrentUOW.RowCount; cnt++)
1228 browser.CurrentUOW.ElementGrid.AddRow(new List<UnitOfWork.ElementNode>(browser.CurrentUOW.ColCount)); // create a row of columns for the number of elements found
1229
1230 int columnIdx = 0; ; // start with the first column in the row
1231
1232 // grab a row of columns
1233 // construct our grid by pivoting the data from columns, to rows
1234 // iterate each column elements (rows) add an element to each row of the element grid
1235 foreach (ReadOnlyCollection<GPALElement> column in rowsOfColumns)
1236 {
1237 rowCnt = lastRowCnt; // if we are on subsequent pages, we have to add at the end of the list, up to WithAllThatMatch row count (or all rows)
1238 foreach (GPALElement elem in column)
1239 {
1240 if (true == ElementHelper.IsElementEditable(elem))
1241 browser.CurrentUOW.ElementGrid[rowCnt].Add(
1243 {
1244 GPALElement = elem,
1245 InteractionType = interactionInfo[columnIdx].InteractionType,
1246 OffsetX = interactionInfo[columnIdx].OffsetX,
1247 OffsetY = interactionInfo[columnIdx].OffsetY
1248 });
1249
1250 // only add as many rows as was asked for
1251 // all or only save up to withallthatmatch row count
1252 if (int.MaxValue != browser.CurrentUOW.WithAllThatMatch && rowCnt == browser.CurrentUOW.WithAllThatMatch) // only save the first one, we do not have the WithAll directive
1253 break;
1254
1255 rowCnt++;
1256 }
1257 }
1258 }
1259
1260 // TODO: CAVEAT: do we have concept of next page in apps? prolly? if we still have tokens and are done, shouldn't we get a next page or bail
1261 // BUG: IF WE consume all our tokens, why would we loop again (while true)
1262
1263 // keep looping while we have tokens
1264 // but after dealing with the first round of tokens, with more to go
1265 if (tokenIdx < tokens.Count())
1266 {
1267 // we now have our elements in rows and columns
1268 // iterate over the tokens, in rows and columns to fill in the text
1269 foreach (List<UnitOfWork.ElementNode> elementNode in browser.CurrentUOW.ElementGrid) // get one row of elements
1270 {
1271 elementIdx = 0;
1272 List<string> currentRow = tokens[tokenIdx];
1273
1274 foreach (string token in currentRow) // iterate over tokens, filling in the corresponding input/textarea webelement
1275 {
1276 if (elementIdx < elementNode.Count && null != elementNode[elementIdx])
1277 {
1278 ElementHelper.ScrollIntoView(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].InteractionType);
1279
1280 if (true == browser.UseOttoMagic)
1281 {
1282 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1283 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1284 else
1285 switch (writeMode)
1286 {
1287 case WriteMode.Append:
1288 browser.BrowserSettings.MagicHelper.FillInAppend(elementNode[elementIdx].GPALElement.Css, token.ToString());
1289 break;
1290
1291 case WriteMode.Insert:
1292 browser.BrowserSettings.MagicHelper.FillInInsert(elementNode[elementIdx].GPALElement.Css, token.ToString());
1293 break;
1294
1295 case WriteMode.Overwrite:
1296 browser.BrowserSettings.MagicHelper.FillInOverwrite(elementNode[elementIdx].GPALElement.Css, token.ToString());
1297 break;
1298 }
1299 }
1300 else if (true == browser.UsePuppeteer)
1301 {
1302 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1303 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1304 else
1305 switch (writeMode)
1306 {
1307 case WriteMode.Append:
1308 browser.PuppeteerClient.FillInAppend(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1309 break;
1310
1311 case WriteMode.Insert:
1312 browser.PuppeteerClient.FillInInsert(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1313 break;
1314
1315 case WriteMode.Overwrite:
1316 browser.PuppeteerClient.FillInOverwrite(elementNode[elementIdx].GPALElement.Css).WithText(token.ToString()).Execute();
1317 break;
1318 }
1319 }
1320 else // selenium
1321 {
1322 if (InteractionType.Hardware == elementNode[elementIdx].InteractionType)
1323 ElementHelper.HardwareFillInFrom(browser, elementNode[elementIdx].GPALElement, elementNode[elementIdx].OffsetX, elementNode[elementIdx].OffsetY, token.ToString(), writeMode);
1324 else if (InteractionType.JavaScript == elementNode[elementIdx].InteractionType)
1325 ElementHelper.JavaScriptFillInFrom(browser, elementNode[elementIdx].GPALElement, token.ToString(), writeMode);
1326 else
1327 ElementHelper.SeleniumFillInFrom(browser, elementNode[elementIdx].GPALElement, token.ToString(), writeMode);
1328 }
1329
1330 elementIdx++; // increment the column [element] count for each token. if we have a token, we are expecting elements for it.
1331 }
1332 else
1333 // detect if we have too many tokens to consume and publish an information event [entirely possible this scenario is fine]
1334 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unused token [{token}] from tokens [{string.Join(",", currentRow)}] in row [{tokenIdx + 1}]", browser, GPALObjectType.Browser);
1335
1336 try
1337 {
1338 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{writeMode}] text [{token.ToString()}] in element [{elementNode[elementIdx - 1].GPALElement.TagName}]", browser, GPALObjectType.Browser);
1339 }
1340 catch
1341 {
1342 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"NOT WRITTEN: [{writeMode}] text [{token.ToString()}] in element [NOT FOUND].", browser, GPALObjectType.Browser);
1343 }
1344 }
1345
1346 // detect if we do not have enough tokens for all the form fields [entirely possible this scenario is fine]
1347 // we got here and finished our tokens but have not exhausted our columns [elements]
1348 while (elementIdx < elementNode.Count && null != elementNode[elementIdx++])
1349 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No token for control: <[{elementNode[elementIdx - 1].GPALElement.GetType()}]> : [{elementNode[elementIdx - 1].GPALElement.Text}]", browser, GPALObjectType.Browser);
1350 }
1351
1352 UnitOfWork safeUOW = browser.CurrentUOW;
1353 CallIfStatus handled = 0;
1354
1355 // CAVEAT: there is no concept of 'handled (1)' vs 'not handled (0)' but definitely can request to exit
1356 // TODO: CAVEAT: is cast to dynamic best?
1357 if (null != browser.CurrentUOW.CallAfterFillIn && true == rowsOfColumns.Any())
1358 {
1359 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallAfterFillIn [{browser.CurrentUOW.CallAfterFillIn.Method.Name}]", browser, GPALObjectType.Browser);
1360 handled = browser.CurrentUOW.CallAfterFillIn(browser, tokens, tokenIdx);
1361 }
1362
1363 // CAVEAT: kludge - the call after handler can and probably will set a new current unit of work, but we need our old current unit
1364 // the call after handler UOW is no longer in scope, so restore our UOW
1365 browser.CurrentUOW = safeUOW;
1366
1367 // CAVEAT: elements may go stale because of callafterfillin handler actions
1368 // test if elements still valid by testing the first one
1369 //try
1370 //{
1371 // ((UnitOfWork.ElementNode)(browser.CurrentUOW.ElementGrid[0])[0]).GPALElement.GetAttribute("tag");
1372 //}
1373 //catch (Exception ex) // failed, invalidate elementgrid
1374 //{
1375 // browser.CurrentUOW.ElementGrid.Clear();
1376 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"CallAfterFillIn handler [{browser.CurrentUOW.CallAfterFillIn.GetInvocationList()[0].Method.Name}] invalidated browser elements, clearing cache. Exception follows: [{ex.Message}]", browser, GPALObjectType.Browser, ex);
1377 // break;
1378 //}
1379
1380 if (CallIfStatus.Terminate == handled)
1381 {
1382 string str = $"CallAfterFillIn handler [{browser.CurrentUOW.CallAfterFillIn.GetInvocationList()[0].Method.Name}] requested program termination.";
1383 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, browser, GPALObjectType.Browser);
1384 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
1385 }
1386
1387 tokenIdx++;
1388 }
1389 else
1390 break;
1391
1392 lastRowCnt += rowCnt;
1393 // TODO: are we done or do we have to go to the next page?
1394
1395 // we are out of tokens, break out
1396 // if we have more tokens, shouldn't we go to another page?
1397 // CAVEAT: BUG: we are just looping back thru the same selectors and consuming more tokens, so does that mean CallAfterFillIn will do something and this is the behavior we want?
1398 // or do we break because we have more tokens then elements? or should we loop?
1399 // why did i put this in a while true loop?
1400 if (tokenIdx == tokens.Count())
1401 break;
1402 }
1403 }
1404
1405 // this is failing after clicks on wells fargo because it's checking faster than the page can load, but i'm reticent to add too much of a delay in jswait
1406 // since this is not entirely critical, we are just going to not print out any messages about this failing, but we also updated execute javascript to return 'fail' instead of 'complete' on catching an exception
1407 // that will result in this waiting the full 30 seconds timeout. not sure if that is good or bad, we will see.
1408 private static bool doNotPublishEvent = false; // special flag to not output any error if this fails, it's not critical
1409
1410 // gap between url reads while waiting to see if a click navigated. Each read is a driver round trip, so this
1411 // is not a hot loop the way WaitForValueOrTime is
1412 private const int NavigationPollMs = 50;
1413
1414 // chromium window handles are the CDP target id behind this prefix; firefox reports its pid in capabilities
1415 private const string CdpWindowPrefix = "CDwindow-";
1416 private const string MozProcessIdCapability = "moz:processID";
1435 static string ReadyStatusOrNavError(Browser browser, string sessionToken)
1436 {
1437 string status = browser.MagicHelper.GetReadyStatus(sessionToken)?.Trim('"');
1438
1439 if (true == status?.ToLower().Contains("nav-error"))
1440 {
1441 // GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"The page did not load, so there is nothing to work with. [{status}]", browser, GPALObjectType.Browser);
1442 browser.RaiseOnFail(GPALFailure.Navigation, status);
1443 }
1444
1445 return status;
1446 }
1447 public static void CheckDocumentReady(Browser browser, bool overrideSetting = false)
1448 {
1449 bool hasURL = null != browser?.BrowserSettings.CurrentURL && false == "https://google.com".Equals(browser.BrowserSettings.CurrentURL);
1450
1451 doNotPublishEvent = true;
1452 string sessionToken = Guid.NewGuid().ToString();
1453
1454 if ((true == browser?.BrowserSettings.WaitOnDocumentReady && true == hasURL) || true == overrideSetting)
1455 {
1456 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Waiting up to [{browser.BrowserSettings.WaitForDocumentReadyTimeoutMs}] ms on document.ready");
1457
1458 // nav-error is not a ready state, it is the browser saying the page is never going to load. Waiting
1459 // it out would only end in the same place several seconds later, having said nothing useful
1460 if (true == browser?.UseOttoMagic)
1461 WaitForValueOrTime(browser, "complete, interactive, nav-error", () => ReadyStatusOrNavError(browser, sessionToken), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1462 else if (true == browser?.UsePuppeteer)
1463 WaitForValueOrTime(browser, "complete, interactive", () => browser.PuppeteerClient.GetReadyStatus(sessionToken).Execute<object>(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1464 else // handles selenium
1465 WaitForValueOrTime(browser, "complete, interactive", () => JsCheckReadyState(browser, sessionToken), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1466 }
1467 else if (true == browser.BrowserSettings.WaitOnNetworkIdle && true == hasURL)
1468 {
1469 if (true == browser.UsePuppeteer)
1470 WaitForValueOrTime(browser, "true", () => browser.PuppeteerClient.CheckNetworkIdle().WithSessionToken(sessionToken).Execute<bool>().ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1471 else if (true == browser.BrowserSettings.UseOttoMagic)
1472 WaitForValueOrTime(browser, "true", () => WaitForNetworkIdle(browser, browser.BrowserSettings.NetworkIdleTimeoutMs, browser.BrowserSettings.NetworkIdleMaxConnections, browser.BrowserSettings.NetworkIdlePruneMs, sessionToken).ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1473 else
1474 WaitForValueOrTime(browser, "true", () => WaitForNetworkIdle(browser, sessionToken).ToString(), browser.BrowserSettings.WaitForDocumentReadyTimeoutMs);
1475 }
1476 doNotPublishEvent = false;
1477 }
1478
1500 internal static void FetchWithTokens(Browser browser, GPALRequest request)
1501 {
1502 IGPALGrid<string> tokens = browser.CurrentUOW.FetchTokens;
1503 int required = request.TokenCount();
1504 string name = request.Name ?? request.Path;
1505
1506 if (null == tokens && 0 < required)
1507 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch of [{name}] uses [{required}] tokens but no token source was given. Use .WithTokensFrom before .Fetch.", browser, GPALObjectType.Browser);
1508 else
1509 {
1510 // no token source is still one pass, the request exactly as it was written
1511 int rowCount = tokens?.Rows ?? 1;
1512
1513 for (int tokenIdx = 0; tokenIdx < rowCount; tokenIdx++)
1514 {
1515 List<string> row = null == tokens ? null : tokens[tokenIdx];
1516
1517 // a row with a hole in it would send a body with a token still in it, which is worse than not
1518 // sending it at all
1519 if (null != row && row.Count < required)
1520 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch of [{name}] needs [{required}] tokens but row [{tokenIdx + 1}] has [{row.Count}]. Skipping the row.", browser, GPALObjectType.Browser);
1521 else
1522 FetchRow(browser, request, row, tokenIdx);
1523 }
1524 }
1525 }
1534 static void FetchRow(Browser browser, GPALRequest request, List<string> row, int tokenIdx)
1535 {
1536 int mark = browser.CurrentUOW.FetchResults.Count;
1537 string name = request.Name ?? request.Path;
1538
1539 for (int page = 0; page < browser.CurrentUOW.PageCount; page++)
1540 {
1541 string where = null == row ? $"Page [{page + 1}]" : $"Row [{tokenIdx + 1}] page [{page + 1}]";
1542
1543 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Fetching [{name}]. {where}.", browser, GPALObjectType.Browser);
1544
1545 string body = FetchInPage(browser, request, page, row);
1546
1547 // nothing here stops the run. one page failing says nothing about the next one, or about the rows
1548 // after it, and whatever went wrong is written into the results so it reaches .SaveTo and
1549 // CallAfterFetch rather than living only in the log. every page still produces an entry, so
1550 // position in the results always maps to the row and page that made it
1551 if (null == body)
1552 {
1553 string why = $"Fetch of [{name}] returned nothing on {where.ToLower()}.";
1554
1555 GPAL.PublishSimpleEvent(GPALEventType.ERROR, why, browser, GPALObjectType.Browser);
1556 body = why;
1557 }
1558 else if (200 > browser.ServerResponseCode || 300 <= browser.ServerResponseCode)
1559 {
1560 // a server that answered is data whatever it answered, and the body is usually the only thing
1561 // that says why, so it is kept with the reason in front of it
1562 string why = $"Fetch of [{name}] returned HTTP [{browser.ServerResponseCode}] on {where.ToLower()}.";
1563
1564 GPAL.PublishSimpleEvent(GPALEventType.WARNING, why, browser, GPALObjectType.Browser);
1565 body = why + Environment.NewLine + body;
1566 }
1567
1568 browser.CurrentUOW.FetchResults.Add(body);
1569 }
1570
1571 if (null != request.AfterFetch)
1572 {
1573 UnitOfWork safeUOW = browser.CurrentUOW;
1574 List<string> results = browser.CurrentUOW.FetchResults.GetRange(mark, browser.CurrentUOW.FetchResults.Count - mark);
1575
1576 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallAfterFetch [{request.AfterFetch.Method.Name}]", browser, GPALObjectType.Browser);
1577
1578 CallIfStatus handled = request.AfterFetch(browser, results, browser.CurrentUOW.FetchTokens, tokenIdx);
1579
1580 // the handler can and probably will start its own unit of work, so restore ours
1581 browser.CurrentUOW = safeUOW;
1582
1583 if (CallIfStatus.Terminate == handled)
1584 {
1585 string str = $"CallAfterFetch handler [{request.AfterFetch.Method.Name}] requested program termination.";
1586 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, browser, GPALObjectType.Browser);
1587 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
1588 }
1589 }
1590 }
1596 internal static string FetchInPage(Browser browser, GPALRequest request, int page, List<string> row)
1597 {
1598 // GPALRequest, paging and tokens are GPAL ideas, so they are resolved here and the layers below only
1599 // ever see a finished url and plain values, the same as every other endpoint
1600 string envelope = FetchEnvelope(browser, request.ResolveUrl(page, row), request.Method, request.ResolveBody(page, row), request.ContentType, request.HeaderPairs(page, row), false);
1601
1602 return UnpackFetchResponse(browser, envelope);
1603 }
1617 static string FetchEnvelope(Browser browser, string url, string method, string body, string contentType, string[] headers, bool asBytes)
1618 {
1619 string envelope = null;
1620
1621 if (true == browser.UseSelenium)
1622 envelope = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteAsyncScript(
1623 FetchSetupScript(url, method, body, contentType, headers, asBytes) + @"
1624 var done = arguments[arguments.length - 1];
1625
1626 fetch(target.toString(), options)
1627 .then(gpalEnvelope)
1628 .then(done)
1629 .catch(function (error) { done(null); });
1630 ") as string;
1631 else if (true == browser.UsePuppeteer)
1632 envelope = browser.PuppeteerClient
1633 .Fetch(url)
1634 .WithVerb(method)
1635 .WithBody(body)
1636 .WithContentType(contentType)
1637 .WithHeaders(headers)
1638 .WithBytes(asBytes)
1639 .Execute();
1640 else if (true == browser.UseOttoMagic)
1641 envelope = browser.MagicHelper.Fetch(url, method, body, contentType, headers, asBytes);
1642 else
1643 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch is not implemented for [{browser.BrowserSettings.AutomationEngine}] yet.", browser, GPALObjectType.Browser);
1644
1645 return envelope;
1646 }
1656 internal static string LiveUserAgent(Browser browser)
1657 {
1658 string retVal = null;
1659
1660 if (true == browser.UseSelenium)
1661 retVal = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteScript("return navigator.userAgent") as string;
1662 else if (true == browser.UsePuppeteer)
1663 retVal = browser.PuppeteerClient.GetUserAgent().Execute();
1664 else if (true == browser.UseOttoMagic)
1665 retVal = Unquoted(browser.MagicHelper.GetUserAgent());
1666
1667 return retVal;
1668 }
1669
1677 static string Unquoted(string value)
1678 {
1679 string retVal = value;
1680
1681 while (false == string.IsNullOrEmpty(retVal) && true == retVal.StartsWith("\"") && true == retVal.EndsWith("\"") && 1 < retVal.Length)
1682 {
1683 string unwrapped = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(retVal);
1684
1685 if (unwrapped == retVal)
1686 break;
1687
1688 retVal = unwrapped;
1689 }
1690
1691 return retVal;
1692 }
1702 internal static string LiveAcceptLanguage(Browser browser)
1703 {
1704 string languages = null;
1705 string retVal = null;
1706
1707 if (true == browser.UseSelenium)
1708 languages = ((IJavaScriptExecutor)browser.BrowserDriver).ExecuteScript("return navigator.languages.join(',')") as string;
1709 else if (true == browser.UsePuppeteer)
1710 languages = browser.PuppeteerClient.GetLanguages().Execute();
1711 else if (true == browser.UseOttoMagic)
1712 languages = Unquoted(browser.MagicHelper.GetLanguages());
1713
1714 if (false == string.IsNullOrWhiteSpace(languages))
1715 retVal = AcceptLanguageFrom(languages.Split(','), browser.BrowserSettings.BrowserType);
1716
1717 return retVal;
1718 }
1719
1733 static string AcceptLanguageFrom(string[] languages, Enums.BrowserType browserType)
1734 {
1735 List<string> weighted = new List<string>();
1736
1737 for (int index = 0; index < languages.Length; index++)
1738 {
1739 string language = languages[index].Trim();
1740
1741 if (true == string.IsNullOrEmpty(language))
1742 continue;
1743
1744 if (Enums.BrowserType.FireFox == browserType)
1745 language = language.ToLowerInvariant();
1746
1747 if (0 == index)
1748 weighted.Add(language);
1749 else
1750 {
1751 // a tenth is as fine as the header goes, and a language weighted to nothing is left off
1752 // rather than sent as q=0.0, which asks the server not to answer in it at all
1753 double quality = Math.Round(1.0 - (0.1 * index), 1);
1754
1755 if (0 < quality)
1756 weighted.Add($"{language};q={quality.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}");
1757 }
1758 }
1759
1760 return string.Join(",", weighted);
1761 }
1774 internal static byte[] FetchBytes(Browser browser, string url, string[] headers, out string lastModified)
1775 {
1776 byte[] bytes = null;
1777 JObject response = UnpackFetchEnvelope(browser, FetchEnvelope(browser, url, "GET", null, null, headers, true));
1778 string encoded = response?["body"]?.ToString();
1779
1780 lastModified = response?["lastModified"]?.ToString();
1781
1782 if (false == string.IsNullOrEmpty(encoded))
1783 bytes = Convert.FromBase64String(encoded);
1784
1785 return bytes;
1786 }
1795 static string UnpackFetchResponse(Browser browser, string envelope)
1796 {
1797 return UnpackFetchEnvelope(browser, envelope)?["body"]?.ToString();
1798 }
1806 static JObject UnpackFetchEnvelope(Browser browser, string envelope)
1807 {
1808 JObject response = null;
1809
1810 // a fetch that could not be made has no status of its own, and leaving the last one standing would
1811 // let a caller read a stale 200 or 304 as though it belonged to this request
1812 browser.ServerResponseCode = 0;
1813
1814 if (false == string.IsNullOrEmpty(envelope))
1815 {
1816 GPAL.PublishSimpleEvent(GPALEventType.DEEPDEBUG, $"Fetch envelope [{envelope}]", browser, GPALObjectType.Browser);
1817
1818 // a transport that carries the envelope as a json string rather than an object hands it back
1819 // quoted and escaped, so unwrap that once to get at the envelope inside
1820 string unpacked = envelope.TrimStart().StartsWith("\"")
1821 ? Newtonsoft.Json.JsonConvert.DeserializeObject<string>(envelope)
1822 : envelope;
1823
1824 // never parse blind. a shape we do not recognize is reported with what actually arrived, because
1825 // a stack trace out of a json parser says nothing about what the page sent
1826 if (false == (unpacked?.TrimStart().StartsWith("{") ?? false))
1827 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Fetch response was not understood. Received [{unpacked}]", browser, GPALObjectType.Browser);
1828 else
1829 {
1830 response = JObject.Parse(unpacked);
1831 browser.ServerResponseCode = response["status"]?.Value<int>() ?? 0;
1832
1833 // the page could not make the request at all and said why. Without this the caller only knows
1834 // that nothing came back, which is the same thing a refusal and a bug look like
1835 if (false == string.IsNullOrEmpty(response["error"]?.ToString()))
1836 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The page could not make the request [{response["error"]}]", browser, GPALObjectType.Browser);
1837 }
1838 }
1839
1840 return response;
1841 }
1856 internal static string FetchSetupScript(string url, string method, string body, string contentType, string[] headers, bool asBytes)
1857 {
1858 return $@"
1859 var target = new URL({Newtonsoft.Json.JsonConvert.SerializeObject(url)}, location.href);
1860 var reqBody = {Newtonsoft.Json.JsonConvert.SerializeObject(body)};
1861 var contentType = {Newtonsoft.Json.JsonConvert.SerializeObject(contentType)};
1862 var headerPairs = {Newtonsoft.Json.JsonConvert.SerializeObject(headers ?? new string[0])};
1863 var asBytes = {(true == asBytes ? "true" : "false")};
1864
1865 var options = {{ method: {Newtonsoft.Json.JsonConvert.SerializeObject(method ?? "GET")}, credentials: 'include', headers: {{}} }};
1866 for (var h = 0; h < headerPairs.length; h += 2)
1867 options.headers[headerPairs[h]] = headerPairs[h + 1];
1868
1869 if (reqBody) {{
1870 options.body = reqBody;
1871 if (!options.headers['Content-Type'])
1872 options.headers['Content-Type'] = contentType;
1873 }}
1874
1875 // one envelope for every engine, so the C# side has a single shape to unpack. text() would put a
1876 // replacement character wherever a byte is not valid utf-8, which is most of any real file, so a
1877 // file is read as an arraybuffer and base64 encoded instead. Written with promises rather than
1878 // await so the same function serves selenium's callback and puppeteer's awaited expression.
1879 // String.fromCharCode is applied over slices because one call with a few hundred thousand
1880 // arguments overflows the stack
1881 function gpalEnvelope(response) {{
1882 return (asBytes
1883 ? response.arrayBuffer().then(function (buffer) {{
1884 var bytes = new Uint8Array(buffer), binary = '', slice = 0x8000;
1885 for (var i = 0; i < bytes.length; i += slice)
1886 binary += String.fromCharCode.apply(null, bytes.subarray(i, i + slice));
1887 return btoa(binary);
1888 }})
1889 : response.text()
1890 ).then(function (payload) {{
1891 return JSON.stringify({{
1892 status: response.status,
1893 body: payload,
1894 encoding: asBytes ? 'base64' : null,
1895 lastModified: response.headers.get('Last-Modified')
1896 }});
1897 }});
1898 }}
1899 ";
1900 }
1901 public static object ExecuteJavaScriptObj(string executeMe, IBrowser browser, params object[] args)
1902 {
1903 dynamic retVal = null;
1904
1905 try
1906 {
1907 if (browser.UseOttoMagic)
1908 {
1909 retVal = browser.MagicHelper.ExecuteJavaScript(executeMe);
1910 }
1911 else if (true == browser.UsePuppeteer)
1912 {
1913 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).WithParameters(args).Execute<object>();
1914 retVal = retVal.result.value;
1915 }
1916 else // beginning chrome 136, dynamic javascript execution, even by a browser extension, is no longer possihle. has to be baked in code.
1917 {
1918 IJavaScriptExecutor js = (IJavaScriptExecutor)browser.BrowserDriver;
1919 retVal = js.ExecuteScript(executeMe, args);
1920 }
1921 }
1922 catch (GPALException)
1923 {
1924 throw;
1925 }
1926 catch (Exception ex)
1927 {
1928 if (false == doNotPublishEvent)
1929 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $@"Failed to execute javascript. Continuing.", browser, GPALObjectType.Browser, ex);
1930 }
1931
1932 return retVal;
1933 }
1934 // NOTE: if this fails we could fallback to puppeteer, but could we do anything with selenium if this doesn't work?
1956 private static string UnwrapScriptResult(string result)
1957 {
1958 string retVal = result;
1959
1960
1961 // the RemoteObject describes the result: a type, and the value it is describing. anything else is a
1962 // returned object that happens to be json, and belongs to the caller untouched
1963 if (false == string.IsNullOrWhiteSpace(retVal) && true == retVal.TrimStart().StartsWith("{"))
1964 {
1965 try
1966 {
1967 Newtonsoft.Json.Linq.JObject remote = Newtonsoft.Json.Linq.JObject.Parse(retVal);
1968
1969 if (null != remote["type"] && null != remote["value"]
1970 && remote.Properties().All(each => "type" == each.Name || "value" == each.Name || "description" == each.Name || "className" == each.Name || "subtype" == each.Name))
1971 {
1972 Newtonsoft.Json.Linq.JToken value = remote["value"];
1973
1974 retVal = Newtonsoft.Json.Linq.JTokenType.String == value.Type
1975 ? value.ToString()
1976 : Newtonsoft.Json.JsonConvert.SerializeObject(value);
1977 }
1978 }
1979 catch (Exception)
1981 // shaping the answer is never a reason to fail the call
1982 }
1983 }
1984
1985 // a json encoded string, one layer at a time, until it stops being one
1986 while (false == string.IsNullOrEmpty(retVal) && retVal.StartsWith("\"") && retVal.EndsWith("\""))
1987 {
1988 try
1989 {
1990 string peeled = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(retVal);
1991
1992 if (peeled == retVal)
1993 break;
1994
1995 retVal = peeled;
1996 }
1997 catch (Exception)
1998 {
1999 break; // not an encoded string after all, so it is the value already
2000 }
2001 }
2002
2003 return retVal;
2004 }
2005
2021 public static string ExecuteJavaScript(IBrowser browser, string executeMe, params object[] args)
2022 {
2023 string retVal = RunScript(browser, executeMe, args);
2024
2025 // V8 says exactly this, and says it only for a return outside a function
2026 if (false == browser.UseSelenium && true == retVal?.Contains("Illegal return statement"))
2027 retVal = RunScript(browser, "(function(){" + Environment.NewLine + executeMe + Environment.NewLine + "})()", args);
2028
2029 return retVal;
2030 }
2031
2032 private static string RunScript(IBrowser browser, string executeMe, params object[] args)
2033 {
2034 dynamic retVal = null;
2035
2036 try
2037 {
2038 if (browser.UseOttoMagic)
2039 {
2040 retVal = browser.MagicHelper.ExecuteJavaScript(executeMe);
2041 }
2042 else if (true == browser.UsePuppeteer)
2043 {
2044 if (true == args.Any())
2045 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).WithParameters(args).Execute<object>();
2046 else
2047 retVal = browser.PuppeteerClient.ExecuteJavaScript(executeMe).Execute<object>();
2048
2049 if (Puppeteer.resultExtractors != null && Puppeteer.resultExtractors.TryGetValue(DevToolsMethods.RuntimeEvaluate, out var extractor))
2050 retVal = extractor((Newtonsoft.Json.Linq.JObject)retVal.result);
2051 else
2052 retVal = retVal.result.ToObject();
2053 }
2054 else // beginning chrome 136, dynamic javascript execution, even by a browser extension, is no longer possihle. has to be baked in code.
2055 {
2056 IJavaScriptExecutor js = (IJavaScriptExecutor)browser.BrowserDriver;
2057 if (true == args?.Any())
2058 retVal = js.ExecuteScript(executeMe, args);
2059 else
2060 retVal = js.ExecuteScript(executeMe);
2061
2062
2063 }
2064 }
2065 catch (GPALException)
2066 {
2067 throw;
2068 }
2069 catch (Exception ex)
2071 if (false == doNotPublishEvent)
2072 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $@"Failed to execute javascript. Continuing.", browser, GPALObjectType.Browser, ex);
2073 retVal = "fail";
2074 }
2075
2076 return UnwrapScriptResult(retVal?.ToString());
2077 }
2078
2085 public static string GetCurrentUrl(BrowserSettings browserSettings)
2086 {
2087 string currentUrl = null;
2088
2089 if (true == browserSettings.UseOttoMagic)
2090 currentUrl = browserSettings.MagicHelper.GetCurrentUrl();
2091 else if (true == browserSettings.UsePuppeteer)
2092 currentUrl = browserSettings.PuppeteerClient.GetCurrentUrl().Execute();
2093 else
2094 currentUrl = browserSettings.Browser.BrowserDriver.Url;
2095
2096 // ottomagic and puppeteer answer over the rest layer, whose untyped Execute hands back the response
2097 // body as it stands - a json string, quotes and all. deserializing rather than trimming so an escaped
2098 // quote inside the url comes back as the character it is
2099 if (true == currentUrl?.StartsWith("\"") && true == currentUrl.EndsWith("\""))
2100 currentUrl = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(currentUrl);
2101
2102 return currentUrl;
2103 }
2111 public static string JsCheckReadyState(dynamic browser, string sessionToken)
2112 {
2113 string readyState = "loading";
2114
2115 if (lastSessionToken != sessionToken)
2116 {
2117 lastErrorMessage.Clear();
2118 supressedMessage = false;
2119 lastSessionToken = sessionToken;
2120 }
2121
2122 try
2123 {
2124 if (browser.UseOttoMagic)
2125 {
2126 readyState = browser.BrowserSettings.MagicHelper.GetReadyStatus();
2127 if (null == readyState)
2128 readyState = @"loading";
2129 else
2130 readyState = readyState.Trim('"');
2131 }
2132 else
2133 {
2134 bool withReturn = false == browser.UsePuppeteer;
2136 // use javascript to get our html document location, but it's off by 2 pixels compared to uiautomation. uiautomation, however, failed so often that is was unreliable
2137 readyState = ExecuteJavaScript(browser, $"{(true == withReturn ? "return " : "")}document.readyState");
2138 }
2139 }
2140 catch (GPALException)
2141 {
2142 throw;
2143 }
2144 catch (Exception ex)
2145 {
2146 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
2147 $"Error executing JavaScript",
2148 null, GPALObjectType.None, ex);
2149 }
2150
2151 string currentErrorMessage = $"Document.Ready status [{readyState}]";
2152
2153 if (false == lastErrorMessage.Contains(currentErrorMessage))
2154 {
2155 lastErrorMessage.Add(currentErrorMessage);
2156 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
2157 supressedMessage = false;
2158 }
2159 else if (false == supressedMessage)
2160 {
2161 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
2162 supressedMessage = true;
2163 }
2164
2165 return readyState;
2166 }
2167
2168 // written by grok
2176 public static void SeleniumGoToUrl(GPALUrl url, BrowserSettings browserSettings)
2177 {
2178 string normalizedTarget = url.ToString().TrimEnd('/').ToLowerInvariant();
2179
2180 try
2181 {
2182 // 1. Perform navigation
2183 browserSettings.BrowserDriver.Navigate().GoToUrl(url);
2184
2185 // Give Chrome a brief moment for network events (helps on fast 304s / cached pages)
2186 System.Threading.Thread.Sleep(400); // 400–800 ms usually enough; can be replaced with WebDriverWait if preferred
2187
2188 // 2. Fetch performance logs (Chrome/Edge only — geckodriver does not support getLog)
2189 if (browserSettings.BrowserType != BrowserType.FireFox)
2190 {
2191
2192 var performanceLogs = browserSettings.BrowserDriver.Manage().Logs.GetLog("performance");
2193
2194 int? bestStatus = null;
2195 string bestMatchedUrl = null;
2196 int candidatePriority = int.MaxValue;
2197
2198 foreach (var entry in performanceLogs)
2199 {
2200 string message = entry.Message;
2201
2202 if (message.IndexOf("Network.responseReceived", StringComparison.Ordinal) == -1)
2203 continue;
2204
2205 // Quick filter to reduce sub-resource noise
2206 if (message.IndexOf("\"url\":\"", StringComparison.Ordinal) == -1 ||
2207 message.IndexOf(normalizedTarget, StringComparison.OrdinalIgnoreCase) == -1)
2208 continue;
2209
2210 // Extract status
2211 var statusMatch = System.Text.RegularExpressions.Regex.Match(
2212 message,
2213 @"""status(?:Code)?""\s*:\s*(\d+)",
2214 System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant
2215 );
2216
2217 if (!statusMatch.Success)
2218 continue;
2219
2220 int status;
2221 if (!int.TryParse(statusMatch.Groups[1].Value, out status))
2222 continue;
2223
2224 if (status < 100 || status > 599)
2225 continue;
2226
2227 // Priority: lower number = better candidate
2228 int priority;
2229 if (status == 304)
2230 priority = 0; // jackpot: cache hit
2231 else if (status >= 200 && status < 300)
2232 priority = 10; // success
2233 else if (status >= 300 && status < 400)
2234 priority = 20; // redirect (often intermediate)
2235 else if (status >= 400 && status < 500)
2236 priority = 30; // client error (useful to know)
2237 else if (status >= 500)
2238 priority = 40; // server error
2239 else
2240 priority = 50;
2241
2242 bool shouldUpdate = false;
2243
2244 if (!bestStatus.HasValue)
2245 {
2246 shouldUpdate = true;
2247 }
2248 else if (priority < candidatePriority)
2249 {
2250 shouldUpdate = true;
2251 }
2252 else if (priority == candidatePriority && status == 304)
2253 {
2254 shouldUpdate = true;
2255 }
2256
2257 if (shouldUpdate)
2258 {
2259 bestStatus = status;
2260 candidatePriority = priority;
2261
2262 // Extract matched URL for debug (optional)
2263 var urlMatch = System.Text.RegularExpressions.Regex.Match(message, @"""url"":""([^""]+)""");
2264 if (urlMatch.Success)
2265 {
2266 bestMatchedUrl = urlMatch.Groups[1].Value;
2267 }
2268 }
2269 }
2270
2271 if (bestStatus.HasValue)
2272 {
2273 browserSettings.ServerResponseCode = bestStatus.Value;
2274
2275 // Optional: log what we decided (uncomment for debugging)
2276 /*
2277 GPAL.PublishSimpleEvent(
2278 GPALEventType.DEBUG,
2279 $"Captured final status {bestStatus.Value} (priority {candidatePriority}) for ~{bestMatchedUrl ?? "unknown"}",
2280 browserSettings,
2281 GPALObjectType.Other
2282 );
2283 */
2284 }
2285 else
2286 {
2287 // Rare: no plausible status found despite navigation
2288 browserSettings.ServerResponseCode = 0;
2289 GPAL.PublishSimpleEvent(
2290 GPALEventType.DEBUG,
2291 $"No valid HTTP status captured in performance logs for [{url}]",
2292 browserSettings,
2293 GPALObjectType.Other
2294 );
2295 }
2296
2297 } // end Chrome/Edge only block
2298 else
2299 {
2300 try
2301 {
2302 var result = ((OpenQA.Selenium.IJavaScriptExecutor)browserSettings.BrowserDriver)
2303 .ExecuteScript("return performance.getEntriesByType('navigation')[0]?.responseStatus ?? 0;");
2304 browserSettings.ServerResponseCode = result != null && int.TryParse(result.ToString(), out int ffStatus) && ffStatus > 0 ? ffStatus : 0;
2305 }
2306 catch
2307 {
2308 browserSettings.ServerResponseCode = 0;
2309 }
2310 }
2311 }
2312 catch (GPALException)
2313 {
2314 throw;
2315 }
2316 catch (Exception ex)
2317 {
2318 browserSettings.ServerResponseCode = -1;
2320 GPALEventType.EXCEPTION,
2321 $"Unexpected error navigting to [{url}]",
2322 browserSettings,
2323 GPALObjectType.Other,
2324 ex
2325 );
2326 }
2327 }
2328
2339 public static KillProcessesResult KillProcessesByName(params string[] namesOfProcessesToKill)
2340 {
2341 KillProcessesResult result = null;
2342
2343 foreach (var name in namesOfProcessesToKill)
2344 result = KillProcess(name);
2345
2346 return result;
2347 }
2350
2357 /// <param name="browser">Browser to query</param>
2358 /// <returns>The live user agent string, or a templated fallback if it could not be determined</returns>
2359 public static string GetUserAgent(IBrowser browser)
2360 {
2361 string userAgent = null;
2362
2363 try
2364 {
2365 if (true == browser.UseOttoMagic)
2366 userAgent = ((Browser)browser).MagicHelper.GetUserAgent();
2367 else if (true == browser.UsePuppeteer)
2368 userAgent = browser.PuppeteerClient.GetUserAgent().Execute<string>();
2369 else
2370 userAgent = ExecuteJavaScript(browser, "return navigator.userAgent");
2371 }
2372 catch (GPALException)
2373 {
2374 throw;
2375 }
2376 catch (Exception ex)
2377 {
2378 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get live user agent.", browser, GPALObjectType.Browser, ex);
2379 }
2380
2381 if (true == string.IsNullOrEmpty(userAgent))
2382 userAgent = MagicHelper.GetUserAgentString(browser.BrowserType);
2383
2384 // ottomagic and puppeteer answer over the rest layer, whose untyped Execute hands back the response
2385 // body as it stands - a json string, quotes and all - and a quote is not a legal header value
2386 if (true == userAgent?.StartsWith("\"") && true == userAgent.EndsWith("\""))
2387 userAgent = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(userAgent);
2388
2389 return userAgent;
2390 }
2398 public static bool TestUrlForPDF(Browser browser, string url)
2399 {
2400 if (null == url)
2401 return false;
2402
2403 bool retVal = false;
2404
2405 // does the url contain .pdf?
2406 if (0 <= url.IndexOf(".pdf", 0, StringComparison.OrdinalIgnoreCase))
2407 retVal = true;
2408 else
2409 using (HttpClient client = new HttpClient())
2410 {
2411 client.DefaultRequestHeaders.Add("User-Agent", GetUserAgent(browser));
2412
2413 try
2414 {
2415 // Use HttpMethod.Head to retrieve headers ONLY (minimal overhead)
2416 using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Head, url))
2417 {
2418 HttpResponseMessage response = client.SendAsync(request).Result;
2419
2420 if (response.IsSuccessStatusCode)
2421 {
2422 // 1. Check Content-Type for application/pdf
2423 string contentType = response.Content.Headers.ContentType?.MediaType;
2424 if (contentType != null && contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
2425 {
2426 retVal = true;
2427 }
2428 // 2. Fallback: Check Content-Disposition for a .pdf filename
2429 else if (response.Content.Headers.ContentDisposition != null)
2430 {
2431 string fileName = response.Content.Headers.ContentDisposition.FileName;
2432 if (!string.IsNullOrEmpty(fileName) && fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
2433 {
2434 retVal = true;
2435 }
2436 }
2437 }
2438 else
2439 {
2440 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to verify URL [{url}] via HEAD request. Status code [{response.StatusCode}]");
2441 }
2442 }
2443 }
2444 catch (GPALException)
2445 {
2446 throw;
2447 }
2448 catch (Exception ex)
2449 {
2450 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Exception during HEAD request for [{url}]", null, GPALObjectType.None, ex);
2451 }
2452 }
2453
2454 if (true == retVal)
2455 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GPAL detected PDF file opened in browser [{url}]");
2456
2457 return retVal;
2458 }
2468 // how many times the file is asked for before giving up on the page in front of it clearing, and how long
2469 // to leave between asks
2470 internal const int PdfFetchAttempts = 3;
2471 internal const int PdfFetchWaitMs = 1500;
2472
2480 internal static bool IsPdf(byte[] content)
2481 {
2482 return 4 < content?.Length
2483 && '%' == content[0] && 'P' == content[1] && 'D' == content[2] && 'F' == content[3];
2484 }
2485 public static bool DownloadPdfFile(string pdfUrl, string savePath, IBrowser browser)
2486 {
2487 // we clicked first, it could've downloaded the file, so look
2488 string filePath = $@"{FileHelper.EnsureDirectoryEndsWithBackslash(Path.GetDirectoryName(savePath))}{System.IO.Path.GetFileName(pdfUrl)}";
2489 if (true == File.Exists(filePath))
2490 {
2491 File.Move(filePath, savePath);
2492 return true;
2493 }
2494 else
2495 {
2496 filePath = $@"{FileHelper.GetDefaultDownloadDirectory(browser)}{Path.GetFileName(pdfUrl)}";
2497 if (true == File.Exists(filePath))
2498 {
2499 File.Move(filePath, savePath);
2500 return true;
2501 }
2502 }
2503
2504 // fetch first, from inside the page, so the file comes down the connection the browser already has:
2505 // its cookies, its tls fingerprint, its clearance. a url the site signed for this session only is
2506 // served to that connection and refused to the bare client below.
2507 // the click may still be on its way to the file, and a site that shows a redirect page first serves
2508 // that page at this very url until it is. so what came back has to be a pdf before it is written, and
2509 // when it is not we wait, ask where the browser is now, and try that
2510 for (int attempt = 0; attempt < PdfFetchAttempts; attempt++)
2511 {
2512 byte[] fetched = FetchBytes((Browser)browser, pdfUrl, null, out string _);
2513
2514 if (true == IsPdf(fetched))
2515 {
2516 File.WriteAllBytes(savePath, fetched);
2517 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"PDF file [{pdfUrl}] fetched from the page to [{savePath}]");
2518 return true;
2519 }
2520
2521 if (attempt < PdfFetchAttempts - 1)
2522 {
2523 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{pdfUrl}] answered with [{fetched?.Length ?? 0}] bytes that are not a pdf, waiting for the file itself", browser, GPALObjectType.Browser);
2524 Thread.Sleep(PdfFetchWaitMs);
2525 pdfUrl = GetCurrentUrl(((Browser)browser).BrowserSettings) ?? pdfUrl;
2526 }
2527 else
2528 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{pdfUrl}] never answered with a pdf, the page in front of the file did. Nothing written to [{savePath}]", browser, GPALObjectType.Browser);
2529 }
2530
2531 // fetch is subject to the page's origin, so a pdf served from somewhere else can be refused where a
2532 // plain request is not. that is what this is here for
2533 using (HttpClient client = new HttpClient())
2534 {
2535 client.DefaultRequestHeaders.Add("User-Agent", GetUserAgent(browser));
2536
2537 try
2538 {
2539 // Download the PDF file content synchronously
2540 byte[] pdfContent = client.GetByteArrayAsync(pdfUrl).Result;
2541
2542 if (false == IsPdf(pdfContent))
2543 {
2544 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{pdfUrl}] answered with [{pdfContent?.Length ?? 0}] bytes that are not a pdf. Nothing written to [{savePath}]", browser, GPALObjectType.Browser);
2545 return false;
2546 }
2547
2548 // Save the PDF file to disk
2549 File.WriteAllBytes(savePath, pdfContent);
2550 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"PDF file [{pdfUrl}] downloaded successfully to [{savePath}]");
2551 return true;
2552 }
2553 catch (GPALException)
2554 {
2555 throw;
2556 }
2557 catch (Exception ex)
2558 {
2559 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"PDF file NOT downloaded from [{pdfUrl}]", ex);
2560 }
2561 }
2562 return false;
2563 }
2564
2573 internal static IWebDriver GetWebDriver(BrowserSettings browserSettings, dynamic options)
2574 {
2575 IWebDriver tmpDriver = null;
2576
2577 try
2578 {
2579 if (true == browserSettings.HiddenDesktop)
2580 {
2581 // the driver is started by hand so it can be named a desktop, and Selenium is handed the port
2582 // rather than a service. StartHiddenDriver sets ServiceDriverPid from the launch
2583 int hiddenPort = StartHiddenDriver(browserSettings);
2584
2585 tmpDriver = new OpenQA.Selenium.Remote.RemoteWebDriver(new Uri($"http://localhost:{hiddenPort}"), options);
2586 }
2587 else if (BrowserType.Chrome == browserSettings.BrowserType)
2588 {
2589 // Create the service explicitly so we can get the PID
2590 ChromeDriverService chromeService = ChromeDriverService.CreateDefaultService(browserSettings.DriverLocation);
2591 chromeService.HideCommandPromptWindow = true; // optional but recommended
2592
2593 ChromeOptions chromeOptions = (ChromeOptions)options;
2594
2595 tmpDriver = new ChromeDriver(chromeService, chromeOptions);
2596
2597 // *** Capture the chromedriver.exe PID ***
2598 browserSettings.ServiceDriverPid = chromeService.ProcessId;
2599 }
2600 else // Edge
2601 {
2602 EdgeDriverService edgeService = EdgeDriverService.CreateDefaultService(browserSettings.DriverLocation);
2603 edgeService.HideCommandPromptWindow = true; // optional
2604
2605 EdgeOptions edgeOptions = (EdgeOptions)options;
2606
2607 tmpDriver = new EdgeDriver(edgeService, edgeOptions);
2608
2609 // *** Capture the msedgedriver.exe PID (reuse the same property) ***
2610 browserSettings.ServiceDriverPid = edgeService.ProcessId;
2611 }
2612 } catch (Exception ex)
2613 {
2614 string url = string.Empty;
2615 switch (browserSettings.BrowserType)
2616 {
2617 case Enums.BrowserType.Chrome:
2618 url = GPAL.GPALSettings.ChromeDriverUpdateURL;
2619 break;
2620 case Enums.BrowserType.Edge:
2621 url = GPAL.GPALSettings.EdgeDriverUpdateURL;
2622 break;
2623 case Enums.BrowserType.FireFox:
2624 url = GPAL.GPALSettings.FirefoxDriverUpdateURL;
2625 break;
2626 }
2627 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Please manually update the driver @ [{url}]..", browserSettings.Browser, GPALObjectType.Browser, ex);
2628 }
2629 return tmpDriver;
2630 }
2631 #region <KillProcess>
2632
2636
2637 private static class DriverInfos
2638 {
2639 public static DriverInfo Chrome = new DriverInfo(
2640 executableFileName: GPAL.GPALSettings.ChromeDriverFilename,
2641 processName: "chromedriver",
2642 browserName: "chrome"
2643 );
2644
2645 public static DriverInfo FireFox = new DriverInfo(
2646 executableFileName: GPAL.GPALSettings.FirefoxDriverFilename,
2647 processName: "geckodriver",
2648 browserName: "firefox"
2649 );
2650
2651 //public static DriverInfo InternetExplorer = new DriverInfo(
2652 // executableFileName: "IEDriverServer64.exe",
2653 // processName: "IEDriverServer64",
2654 // browserName: "internet explorer"
2655 // );
2656
2657 public static DriverInfo InternetExplorer = new DriverInfo(
2658 executableFileName: "IEDriverServer.exe",
2659 processName: "IEDriverServer",
2660 browserName: "internet explorer"
2661 );
2662
2663 public static DriverInfo Edge = new DriverInfo(
2664 executableFileName: GPAL.GPALSettings.EdgeDriverFilename,
2665 processName: "MSEdgeDriver",
2666 browserName: "edge"
2667 );
2669 }
2670
2675 private class DriverInfo
2676 {
2683 public DriverInfo(string executableFileName, string processName, string browserName)
2684 {
2685 ExecutableFileName = executableFileName;
2686 ProcessName = processName;
2687 BrowserName = browserName;
2688 }
2689 public string ExecutableFileName { get; private set; }
2690 public string ProcessName { get; private set; }
2691 public string BrowserName { get; private set; }
2692 }
2696 public class KillProcessesResult
2697 {
2698 public string Name { get; set; }
2699 public int Found { get; set; } = 0;
2700 public int Killed { get; set; } = 0;
2701 public bool Success { get; set; } = false;
2702 }
2703
2710 {
2711 List<KillProcessesResult> result = new List<KillProcessesResult>();
2712 KillProcessesResult[] kpa = result.ToArray();
2713 alreadyUpdating = false;
2714
2715 try
2716 {
2717 result.Add(KillProcess(DriverInfos.FireFox.ProcessName));
2718 result.Add(KillProcess(DriverInfos.Chrome.ProcessName));
2719 result.Add(KillProcess(DriverInfos.InternetExplorer.ProcessName));
2720 result.Add(KillProcess(DriverInfos.Edge.ProcessName));
2721
2722 // zero out all browsers
2723 //GPAL.Browsers.Clear();
2724 } catch (Exception ex)
2725 {
2726 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to kill processes", ex);
2727 }
2728
2729 return kpa;
2730 }
2736 private const int MaxParentHops = 8;
2737
2738 internal const int GracefulExitMs = 10000; // long enough for a browser to write its profile out, short enough not to hold up a run
2739
2750 private static void CloseBrowserGracefully(Browser aBrowser, bool endItAll)
2751 {
2752 try
2753 {
2754 if (true == aBrowser.UsePuppeteer)
2755 {
2756 // Browser.close is CDP's own orderly shutdown
2757 if (false == aBrowser.BrowserSettings.Process?.HasExited)
2758 {
2759 aBrowser.BrowserSettings.PuppeteerCommunicator.SendCommand(DevToolsMethods.BrowserClose, new Dictionary<string, object>(), null).GetAwaiter().GetResult();
2760 aBrowser.PuppeteerCommunicator._readerReadyTcs.SetCanceled();
2761
2762 if (false == aBrowser.PuppeteerCommunicator._usePipes)
2763 aBrowser.PuppeteerCommunicator.CloseOutputWebSocketAsync().GetAwaiter();
2764 }
2765 }
2766 else if (true == aBrowser.UseSelenium && null != aBrowser.BrowserDriver)
2767 {
2768 // Quit ends the session and closes every window; Close leaves the others alone when this is
2769 // one of several GPAL browsers sharing the driver.
2770 // EdgeDriver has a habit of sitting on Quit for its whole command timeout, which is what had
2771 // this skipped altogether, so it gets its own budget here and the caller kills what is left
2772 Task closing = Task.Run(() =>
2773 {
2774 if (true == endItAll)
2775 aBrowser.BrowserDriver?.Quit();
2776 else
2777 aBrowser.BrowserDriver?.Close(); // NOTE: CAVEAT: closes the currently focused window
2778 });
2779
2780 closing.Wait(GracefulExitMs);
2781 }
2782 else
2783 {
2784 // ottomagic. an extension cannot close the browser hosting it, and a headless browser has no
2785 // window for a message to reach. the browser's own windows it can close, and they are there
2786 // in headless the same as anywhere, so those go first and the window message is left as the
2787 // fallback for whatever survives it
2788 // an extension cannot quit the browser hosting it, but it can close every window it has,
2789 // and closing the last one is an ordinary quit: the profile is written and the exit is
2790 // recorded clean rather than as a crash. this reaches headless, where a window message has
2791 // nothing to arrive at.
2792 // the extension enumerates them, so a window a page opened counts the same as one GPAL did,
2793 // and nothing here has to know how many there are
2794 // asking a browser that has already gone costs a full round of retries against a port
2795 // nothing is listening on, and that shows up as a slow start on the next run
2796 if (true == aBrowser.UseOttoMagic && true == aBrowser.IsAlive)
2797 aBrowser.MagicHelper.CloseBrowser();
2798
2799 if (false == aBrowser.BrowserSettings.Process?.HasExited)
2800 aBrowser.BrowserSettings.Process?.CloseMainWindow();
2801 }
2802 }
2803 catch
2804 {
2805 }
2806 }
2807
2808 public static KillProcessesResult[] KillAllRunningProcesses(bool killWebDrivers, IBrowser browser)
2809 {
2810 List<KillProcessesResult> result = new List<KillProcessesResult>();
2811 KillProcessesResult[] kpa = result.ToArray();
2812
2813 try
2814 {
2815 int browserCount = GPAL.Browsers.Count;
2816
2817 if (0 == browserCount)
2818 {
2819 // forms or converter or application, this is expected...
2820 //GPAL.PublishSimpleEvent(GPALEventType.INFO, "No browsers to kill? How'd we get here?", browser, GPALObjectType.Browser);
2821 return kpa;
2822 }
2823
2824 // suppose we have two windows open, we will have two GPAL browsers in the list, both with the same driver
2825 // what happens if one workflow issues a .close() which ends the workflow instead of a .CloseWindow()
2826 // we can't perform any operation on the browserdriver as that will affect both windows
2827 // we could see the browser count > 1 and if killWebDrivers == false, assume it's not the end
2828 // then we can look at the browsersettings, get the CUrrentUrl and then CloseWindow(browserSettings.CurrentUrl)?
2829 foreach (Browser aBrowser in GPAL.Browsers)
2830 {
2831 if (aBrowser == browser || null == browser) // null browser means unhandled exception, kill it all; browser not null = close(), killwebdrivers ends it all
2832 try
2833 {
2834 bool endItAll = (aBrowser == browser && true == killWebDrivers) || null == browser;
2835
2836 // ask before killing. a browser that is killed records its own exit as a crash, and
2837 // chromium reopens the previous session after a crash whatever session.restore_on_startup
2838 // says, so every run GPAL killed taught the next one to come up holding its tabs. a
2839 // killed browser also lingers holding the profile, long enough to write its own
2840 // Preferences back over what GPAL set before launch
2841 CloseBrowserGracefully(aBrowser, endItAll);
2842
2843 if (null != aBrowser.BrowserSettings.Process)
2844 try
2845 {
2846 // it was asked and it went, so there is nothing to kill. the kill is the
2847 // fallback it was always meant to be rather than the way this normally ends
2848 if (false == aBrowser.BrowserSettings.Process.WaitForExit(GracefulExitMs))
2849 {
2850 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{aBrowser.BrowserSettings.BrowserType}] did not shut down within [{GracefulExitMs / 1000}] seconds, killing it. Its profile will read as crashed.", aBrowser, GPALObjectType.Browser);
2851 result.Add(KillProcess(aBrowser.BrowserSettings.Process));
2852 }
2853 }
2854 catch
2855 { }
2856
2857 // the webdriver service is ours, not the user's, and holds no profile to leave in a
2858 // bad state, so it is killed outright once the browser it was driving is gone
2859 if (true == aBrowser.UseSelenium && true == endItAll)
2860 {
2861 int pid = aBrowser.BrowserSettings.ServiceDriverPid;
2862
2863 GPAL.PublishSimpleEvent(GPALEventType.INFO, ">>> Disposing browser driver");
2864
2865 try
2866 {
2867 if (0 < pid)
2868 KillProcessTree(pid);
2869
2870 // fallback - kill any stray driver processes by name
2871 var kpaDrivers = KillAllRunningWebDrivers();
2872
2873 foreach (KillProcessesResult killProcessesResult in kpaDrivers)
2874 result.Add(killProcessesResult);
2875 }
2876 catch { /* ignore */ }
2877 finally
2878 {
2879 aBrowser.BrowserSettings.ServiceDriverPid = 0;
2880 }
2881 }
2882
2883 Thread.Sleep(1_000);
2884 }
2885 catch
2886 {
2887
2888 }
2889 finally
2890 {
2891 if (true == aBrowser.BrowserSettings.TempProfileCreated)
2892 ChromeProfileManager.RemoveTempUserProfile(aBrowser.BrowserSettings.ProfileDataDirectory);
2893 }
2894 }
2895 }
2896 catch (Exception ex)
2897 {
2898 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to kill processes", ex);
2899 }
2900
2901 // obsolete now that we have kill process tree since capturing the driver pid
2902 //try
2903 //{
2904 // if (true == killWebDrivers)
2905 // {
2906 // var kpaDrivers = KillAllRunningWebDrivers();
2907
2908 // foreach (KillProcessesResult killProcessesResult in kpaDrivers)
2909 // result.Add(killProcessesResult);
2910
2911 // foreach (IBrowser aBrowser in GPAL.Browsers)
2912 // switch (aBrowser.BrowserType)
2913 // {
2914 // case BrowserType.Chrome:
2915 // KillProcess(DriverInfos.Chrome.BrowserName);
2916 // break;
2917
2918 // case BrowserType.Edge:
2919 // KillProcess(DriverInfos.Edge.BrowserName);
2920 // break;
2921
2922 // case BrowserType.FireFox:
2923 // KillProcess(DriverInfos.FireFox.BrowserName);
2924 // break;
2925 // }
2926 // }
2927 //}
2928 //catch { }
2929
2930 return result.ToArray();
2931 }
2948 internal static void EndWindowlessSuccessor(BrowserSettings browserSettings)
2949 {
2950 int ourPid = browserSettings.Process?.Id ?? 0;
2951
2952 if (0 < ourPid)
2953 {
2954 // the replacement is started as our browser goes, so there is nothing to find until it has gone
2955 WaitForBrowserToExit(browserSettings, Browser.ProfileRestoreWaitMs);
2956
2957 // caught rather than thrown: WMI is not always answerable, and a browser we could not ask about
2958 // is not a reason to fail a close that has otherwise finished
2959 try
2960 {
2961 using (var search = new System.Management.ManagementObjectSearcher(
2962 $"SELECT ProcessId, CommandLine FROM Win32_Process WHERE ParentProcessId = {ourPid}"))
2963 foreach (System.Management.ManagementObject found in search.Get())
2964 {
2965 string commandLine = found["CommandLine"]?.ToString() ?? string.Empty;
2966
2967 if (true == commandLine.Contains("--no-startup-window"))
2968 {
2969 int successor = Convert.ToInt32(found["ProcessId"]);
2970
2971 GPAL.PublishSimpleEvent(GPALEventType.INFO,
2972 $"Ending the windowless browser [{successor}] left behind by [{ourPid}], which would hold the profile open",
2973 browserSettings, GPALObjectType.Browser);
2974
2975 KillProcessTree(successor);
2976 }
2977 }
2978 }
2979 catch (Exception ex)
2980 {
2981 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not look for a windowless browser left behind by [{ourPid}]",
2982 browserSettings, GPALObjectType.Browser, ex);
2983 }
2984 }
2985 }
2986
2987 private static void KillProcessTree(int pid)
2988 {
2989 if (pid <= 0) return;
2990
2991 try
2992 {
2993 var startInfo = new ProcessStartInfo
2994 {
2995 FileName = "taskkill",
2996 Arguments = $"/PID {pid} /T /F", // /T = tree, /F = force
2997 WindowStyle = ProcessWindowStyle.Hidden,
2998 CreateNoWindow = true,
2999 UseShellExecute = false
3000 };
3001
3002 using (Process taskkillProcess = Process.Start(startInfo))
3003 {
3004 taskkillProcess?.WaitForExit(5000); // optional timeout
3005 }
3006 }
3007 catch
3008 {
3009 // Ignore errors during cleanup (access denied, already dead, etc.)
3010 }
3011 }
3013 /// Kill the process (browser launched by MagicHelper or puppeteer
3014 /// </summary>
3015 /// <param name="process"></param>
3016 public static KillProcessesResult KillProcess(Process process)
3017 {
3018 if (null != process)
3019 {
3021
3022 string Name = "";
3023 try
3024 {
3025 Name = process.ProcessName;
3026 result.Name = Name;
3027 }
3028 catch (Exception)
3029 {
3030 // process has already exited, nothing to do
3031 result.Success = true;
3032 return result;
3033 }
3034
3035 process.Kill();
3036 process.WaitForExit();
3037
3038 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Killed process [{result.Name}]");
3039 return result;
3040 }
3041 return null;
3042 }
3043
3052
3054 public static KillProcessesResult KillProcess(string processName)
3055 {
3056 KillProcessesResult result = new KillProcessesResult { Name = processName };
3057
3058 var processes = Process.GetProcessesByName(processName);
3059
3060 result.Found = processes.Count();
3061
3062 if (result.Found > 0)
3063 {
3064 foreach (var process in processes)
3065 {
3066 do
3067 {
3068 try
3069 {
3070 process.Kill();
3071 process.WaitForExit(); // Wait for the process to finish
3072 }
3073 catch
3074 {
3075
3076 }
3077 } while (false == process.HasExited);
3078
3079 result.Killed++;
3080 result.Success = true;
3081 result.Found++;
3082
3083 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Killed process [{processName}]");
3084 }
3085 }
3086 else
3087 {
3088 result.Success = true;
3089 }
3090
3091 return result;
3092 }
3093 #endregion <KillProcess>
3094
3095 private static Stopwatch watch = new System.Diagnostics.Stopwatch();
3096 private static int lastX = 0, lastY = 0;
3097
3098 // https://stackoverflow.com/questions/8438483/bring-browser-from-back-to-front-selenium-web-driver-java
3099 // NOTE: we prolly should restore the window, too, if it's minimized? or do a hardware top for all?
3100 // NOTE: maybe we only need to top if we have image selectors? otherwise, we don't need to be on top...
3104 /// Headless browsers, OttoMagic/Puppeteer without image selectors, and repeated calls within
3105 /// 1 second are skipped. Uses a hardware click on the window's top-left corner for Firefox/hardware
3106 /// mode, Selenium's SwitchTo().Window for Selenium, or WindowHelper.TopBrowser otherwise.
3107 /// </summary>
3108 /// <param name="browser">Browser to bring to the front</param>
3109 /// <param name="moveDontClick">If true, only move the mouse off the browser instead of clicking to top it</param>
3110 /// <param name="forceClick">If true, force a hardware click to top the browser even if not otherwise required</param>
3111 /// <summary>
3112 /// Asks whatever is on that address whether it is a GPALRestAPI, by calling its status endpoint. The
3113 /// answer is immediate or it is not GPALRestAPI, so this waits a second and no longer: something
3114 /// listening but wedged is not something to hand work to.<br/><br/>
3115 /// GPALRestAPI exits when the browser that started it goes away, so an answer here also means that
3116 /// browser is still running, which is what <see cref="Browser.IsAlive"/> uses it for.
3117 /// </summary>
3118 /// <param name="baseUrl">The base url to ask, e.g. http://localhost:3117/</param>
3119 /// <returns>True when a GPALRestAPI answered.</returns>
3120 /// <summary>Where GPALRestAPI keeps its live host list, beside its own exe.</summary>
3121 private const string PortsLogName = "ports-in-use.log";
3122
3124 private const string NativeHostName = "gpal.rest.api.nativeapp";
3125
3130 private static readonly string[] NativeHostKeys =
3131 {
3132 @"Software\\Google\\Chrome\\NativeMessagingHosts\\" + NativeHostName,
3133 @"Software\\Chromium\\NativeMessagingHosts\\" + NativeHostName,
3134 @"Software\\Microsoft\\Edge\\NativeMessagingHosts\\" + NativeHostName,
3135 @"Software\\WOW6432Node\\Google\\Chrome\\NativeMessagingHosts\\" + NativeHostName,
3136 @"Software\\WOW6432Node\\Chromium\\NativeMessagingHosts\\" + NativeHostName,
3137 @"Software\\WOW6432Node\\Microsoft\\Edge\\NativeMessagingHosts\\" + NativeHostName
3138 };
3139
3143
3145 public static string RestApiLocation()
3146 {
3147 string retVal = null;
3148
3149 foreach (RegistryKey root in new[] { Registry.CurrentUser, Registry.LocalMachine })
3150 {
3151 foreach (string keyPath in NativeHostKeys)
3152 try
3153 {
3154 using (RegistryKey key = root.OpenSubKey(keyPath))
3155 {
3156 string manifestPath = key?.GetValue(null) as string;
3157
3158 if (true == string.IsNullOrWhiteSpace(manifestPath) || false == File.Exists(manifestPath))
3159 continue;
3160
3161 // the manifest is the browser's own record of where the host lives, so it is the
3162 // one place that cannot disagree with what the browser will actually launch
3163 dynamic manifest = Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText(manifestPath));
3164 string exePath = manifest?.path;
3165
3166 if (false == string.IsNullOrWhiteSpace(exePath) && true == File.Exists(exePath))
3167 {
3168 retVal = exePath;
3169 break;
3170 }
3171 }
3172 }
3173 catch (Exception ex)
3174 {
3175 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not read the native host registration at [{keyPath}]", null, GPALObjectType.None, ex);
3176 }
3177
3178 if (false == string.IsNullOrWhiteSpace(retVal))
3179 break;
3180 }
3181
3182 return retVal;
3183 }
3184
3194
3200 public static int LiveRestApiHosts(out List<(int processId, int port)> hosts)
3201 {
3202 hosts = new List<(int processId, int port)>();
3203
3204 string exePath = RestApiLocation();
3205 string folder = true == string.IsNullOrWhiteSpace(exePath) ? null : Path.GetDirectoryName(exePath);
3206 string path = true == string.IsNullOrWhiteSpace(folder) ? null : Path.Combine(folder, PortsLogName);
3207
3208 // no file is the ordinary answer when no ottomagic browser is up, because the last host out deletes it
3209 if (true == string.IsNullOrWhiteSpace(path) || false == File.Exists(path))
3210 return hosts.Count;
3211
3212 try
3213 {
3214 // opened sharing rather than locking, so reading never holds a host up mid write
3215 using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
3216 using (StreamReader reader = new StreamReader(stream))
3217 foreach (string line in reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
3218 {
3219 string[] halves = line.Split(':');
3220
3221 if (2 == halves.Length
3222 && true == int.TryParse(halves[0], out int pid)
3223 && true == int.TryParse(halves[1], out int port)
3224 && false == hosts.Any(each => each.processId == pid))
3225 hosts.Add((pid, port));
3226 }
3227 }
3228 catch (Exception ex)
3229 {
3230 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not read the host list at [{path}]", null, GPALObjectType.None, ex);
3231 }
3232
3233 return hosts.Count;
3234 }
3235
3236 internal static bool RestApiAnswering(string baseUrl)
3237 {
3238 bool retVal = false;
3239
3240 if (false == string.IsNullOrWhiteSpace(baseUrl))
3241 try
3242 {
3243 System.Net.HttpWebRequest ask = (System.Net.HttpWebRequest)System.Net.WebRequest.Create($"{baseUrl}status");
3244 ask.Timeout = 1000;
3245 ask.ReadWriteTimeout = 1000;
3246
3247 using (System.Net.HttpWebResponse answer = (System.Net.HttpWebResponse)ask.GetResponse())
3248 retVal = System.Net.HttpStatusCode.OK == answer.StatusCode;
3249 }
3250 catch (System.Net.WebException)
3251 {
3252 // refused, timed out, or answered something other than a running server
3253 }
3254
3255 return retVal;
3256 }
3257
3258 public static void TopBrowser(Browser browser, bool moveDontClick, bool forceClick = false) // AFTER HARDWARE CLICK, we want to MOVE NOT CLICK mouse off browser into a safe area, so reusing this method
3259 {
3260 int x, y;
3261
3262 // only top if we are interacting with the page using hardware or image mathcing on the current selector
3263 // NOTE: null-safe throughout - CurrentUOW/CurrentSelector are null when we are not on a real page, and
3264 // a (bool) cast on a null bool? (from the ?. chain) would throw.
3265 bool needsTopping = (browser.CurrentUOW?.CurrentSelector?.SelectorSettings?.SelectorPaths
3266 ?.Any(path => path.SelectorPathType == SelectorPathType.Image) ?? false)
3267 || InteractionType.Hardware == browser.CurrentUOW?.CurrentSelector?.InteractionType
3268 || true == browser.BrowserSettings.UseHardware;
3269
3270 // don't do anything, we are always in control and only need it for image matching potentially
3271 if (true == browser.BrowserSettings.UseHeadless || false == needsTopping)
3272 return;
3273
3274 // NOTE: moving the mouse off the browser after a click exists only to keep stray :hover effects
3275 // (tooltips, dropdowns, sticky menus) from interfering with subsequent IMAGE selector matching -
3276 // CSS/DOM selectors don't care about hover state. If this unit of work has no image selectors,
3277 // skip the move entirely: teleporting the cursor off-viewport after every single click is itself
3278 // a mechanical, easily-fingerprinted pattern, so we only pay that cost when it's actually needed.
3279 if (true == moveDontClick || true == forceClick) // selenium control - click the topmost left corner of the browser to top it, only thing that works 100%
3280 {
3281 if (true == watch.IsRunning)
3282 {
3283 if (1000 > watch.ElapsedMilliseconds) // don't do anything if we were called less than 1 second ago, avoid double clicking the browser (max window)
3284 return;
3285 watch.Stop();
3286 }
3287 watch = System.Diagnostics.Stopwatch.StartNew();
3288
3289 HardwareHelper.GetCursorPos(out Point currCursorPos);
3290
3291 if (true == browser.BrowserSettings.UseHardware || true == GPAL.UseHardware || Enums.BrowserType.FireFox == browser.BrowserSettings.BrowserType || true == moveDontClick || true == forceClick)
3292 {
3293 try
3294 {
3295 Rectangle rect = ElementHelper.GetWindowRectangle(browser);
3296 // true screen origin/size of the window (window.screenLeft/screenTop + outer size), so
3297 // nothing has to guess at the height of a browser's chrome
3298 Rectangle outerRect = ElementHelper.GetWindowOuterRectangle(browser);
3299
3300 if (true == moveDontClick)
3301 {
3302 // NOTE: park the mouse at a randomly chosen edge/corner of the window (above, below,
3303 // left, or right) instead of always "above the window" - landing in the same relative
3304 // spot after every click is itself a mechanical, easily-fingerprinted pattern.
3305 var rnd = new Random();
3306 switch (rnd.Next(4))
3307 {
3308 case 0: x = rect.Left + rnd.Next(-50, rect.Width + 50); y = outerRect.Top - 20; break; // above the true window top
3309 case 1: x = rect.Left + rnd.Next(-50, rect.Width + 50); y = rect.Bottom + 50; break; // below
3310 case 2: x = rect.Left - 60; y = rect.Top + rnd.Next(0, rect.Height); break; // left
3311 default: x = rect.Right + 60; y = rect.Top + rnd.Next(0, rect.Height); break; // right
3312 }
3313 // keep the parked cursor on the desktop working area - "below" a maximized window is
3314 // the taskbar, and hovering there pops window previews that then sit over the page
3315 // and corrupt the very image matching this parking exists to protect
3316 Rectangle workingArea = Screen.PrimaryScreen.WorkingArea;
3317 x = Math.Min(Math.Max(workingArea.Left, x), workingArea.Right - 1);
3318 y = Math.Min(Math.Max(workingArea.Top, y), workingArea.Bottom - 1);
3319 }
3320 else
3321 {
3322 // click a few pixels into the true top-left corner of the browser window to top it -
3323 // just inside the corner so we land on the window frame/title, off the resize hotzone,
3324 // and never on a tab or page content
3325 x = outerRect.Left + 3;
3326 y = outerRect.Top + 3;
3327 }
3328 lastX = x;
3329 lastY = y;
3330 //x = browser.BrowserDriver.Manage().Window.Position.X + 6;
3331 //y = x = browser.BrowserDriver.Manage().Window.Position.Y + 6;
3332 }
3333 catch (GPALException)
3334 {
3335 throw;
3336 }
3337 catch (Exception ex)
3338 {
3339 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to get browser coords. Retrying.", browser, GPALObjectType.Browser, ex);
3340 x = lastX;
3341 y = lastY;
3342 }
3343
3344 if (true == GPAL.SimulateMouse || true == browser.CurrentUOW.CurrentSelector?.SimulateMouse)
3345 HardwareHelper.MoveMouse(x, y, 10, 10); // if we are moving the mouse out of the way, just move it up from the current spot
3346 else
3347 HardwareHelper.MoveMouse(x, y);
3348
3349 if (false == moveDontClick)
3350 {
3351 HardwareHelper.HardwareClick(x, y, ClickType.LeftClick); // firefox will not take software topping requests, only hardware clicks
3352 Thread.Sleep(10);
3353
3354 if (true == GPAL.SimulateMouse || true == browser.CurrentUOW.CurrentSelector?.SimulateMouse)
3355 HardwareHelper.MoveMouse(currCursorPos.X, currCursorPos.Y, 10, 10);
3356 else
3357 HardwareHelper.MoveMouse(currCursorPos.X, currCursorPos.Y);
3358 }
3359 }
3360 else if (true == browser.UseSelenium)
3361 {
3362 try
3363 {
3364 // Top the browser by re-focusing whatever tab Selenium currently has active - a no-op for
3365 // tab selection but it still brings the window forward. Do NOT switch by our own
3366 // CurrentTabIdx here: if that index is stale it yanks focus to the wrong tab right before
3367 // a click. If even this is problematic, the catch falls back to the WindowHelper route.
3368 browser.BrowserDriver.SwitchTo().Window(browser.BrowserDriver.CurrentWindowHandle);
3369 }
3370 catch (GPALException)
3371 {
3372 throw;
3373 }
3374 catch (Exception ex)
3375 {
3376 string tmpStr = "Faling back to windows topping.";
3377 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to top browser using Selenium. {(false == GPAL.NoFallbackRecoveryActions ? tmpStr : String.Empty)})", browser, GPALObjectType.Browser, ex);
3378 if (false == GPAL.NoFallbackRecoveryActions)
3379 WindowHelper.TopBrowser(browser.BrowserSettings.Process, browser.BrowserSettings.ServiceDriverPid);
3380 }
3381 }
3382 }
3383 else
3384 { // we have an image selector or we use ottomagic or puppeteer so use windows to top the browser
3385 if (false == WindowHelper.TopBrowser(browser.BrowserSettings.Process, browser.BrowserSettings.ServiceDriverPid))
3386 throw new GPALException("Browser has exited");
3387 }
3388 }
3389
3390 // New utility method to find a free port
3416 internal static bool CameFromProcess(int processId, int ancestorId)
3417 {
3418 bool retVal = false;
3419 int walking = processId;
3420
3421 for (int hop = 0; hop < MaxParentHops && 0 != walking && false == retVal; hop++)
3422 {
3423 if (walking == ancestorId)
3424 retVal = true;
3425 else
3426 walking = ParentOf(walking);
3427 }
3428
3429 return retVal;
3430 }
3431
3438 private static int ParentOf(int processId)
3439 {
3440 int retVal = 0;
3441
3442 try
3443 {
3444 using (var searcher = new System.Management.ManagementObjectSearcher(
3445 $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {processId}"))
3446 foreach (System.Management.ManagementObject each in searcher.Get())
3447 using (each)
3448 retVal = Convert.ToInt32(each["ParentProcessId"]);
3449 }
3450 catch (Exception ex)
3451 {
3452 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not ask who started process [{processId}]", null, GPALObjectType.Browser, ex);
3453 }
3454
3455 return retVal;
3456 }
3457
3458 internal static void RefuseIfProfileIsOpen(BrowserSettings browserSettings)
3459 {
3460 string profile = browserSettings.ProfileDataDirectory;
3461
3462 if (false == string.IsNullOrEmpty(profile))
3463 {
3464 string wanted = NormalizedDirectory(profile);
3465 bool isFirefox = BrowserType.FireFox == browserSettings.BrowserType;
3466
3467 string processName = true == isFirefox ? "firefox.exe"
3468 : BrowserType.Edge == browserSettings.BrowserType ? "msedge.exe"
3469 : "chrome.exe";
3470
3471 // chromium names the profile with --user-data-dir=, firefox with -profile and the path after it
3472 string profileArgument = true == isFirefox
3473 ? "-profile\\s+(\"[^\"]*\"|[^\\s]*)"
3474 : "--user-data-dir=(\"[^\"]*\"|[^\\s]*)";
3475 int holder = 0;
3476
3477 try
3478 {
3479 // the command line is the only place the profile a running browser is on can be read from
3480 using (var search = new System.Management.ManagementObjectSearcher(
3481 $"SELECT ProcessId, CommandLine FROM Win32_Process WHERE Name = '{processName}'"))
3482 {
3483 foreach (System.Management.ManagementObject found in search.Get())
3484 {
3485 string commandLine = found["CommandLine"]?.ToString();
3486
3487 if (true == string.IsNullOrEmpty(commandLine)) continue;
3488
3489 Match match = Regex.Match(commandLine, profileArgument);
3490
3491 if (false == match.Success) continue;
3492
3493 if (true == wanted.Equals(NormalizedDirectory(match.Groups[1].Value.Trim('"')), StringComparison.OrdinalIgnoreCase))
3494 {
3495 holder = Convert.ToInt32(found["ProcessId"]);
3496 break;
3497 }
3498 }
3499 }
3500 }
3501 catch (GPALException)
3502 {
3503 throw;
3504 }
3505 catch (Exception ex)
3506 {
3507 // asking is a courtesy, not the job. a machine that will not answer wmi gets the old behavior
3508 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not check whether [{profile}] is already open", null, GPALObjectType.Browser, ex);
3509 }
3510
3511 if (0 != holder)
3512 {
3513 string message = $"Profile [{profile}] is already open in [{processName}] process [{holder}]. Close it, or give this run a profile of its own";
3514
3515 GPAL.PublishSimpleEvent(GPALEventType.ERROR, message, null, GPALObjectType.Browser);
3516
3517 throw new GPALException($"{GPAL.MyMethodName()}: {message}");
3518 }
3519 }
3520 }
3521
3522 // trailing slashes and relative segments make two spellings of one directory, and a command line can carry
3523 // either. compared as full paths, they are the same directory or they are not
3524 private static string NormalizedDirectory(string path)
3525 {
3526 string retVal = path;
3527
3528 try
3529 {
3530 retVal = Path.GetFullPath(path).TrimEnd('\\', '/');
3531 }
3532 catch
3533 {
3534 retVal = path.TrimEnd('\\', '/');
3535 }
3536
3537 return retVal;
3538 }
3539
3540 // a browser sets these for itself, and a fetch issued in the page refuses to be told otherwise. copying
3541 // them into a request achieves nothing; the ones that matter are whatever the site's own code added
3542 private static readonly string[] browserOwnedHeaders =
3543 {
3544 "referer", "user-agent", "host", "origin", "cookie", "content-length",
3545 "connection", "accept-encoding", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform",
3546 "sec-ch-ua-arch", "sec-ch-ua-model", "sec-ch-ua-full-version-list", "sec-ch-device-memory",
3547 "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site"
3548 };
3549
3555 internal static bool BrowserOwnedHeader(string name)
3556 {
3557 return Array.Exists(browserOwnedHeaders, owned => owned.Equals(name, StringComparison.OrdinalIgnoreCase));
3558 }
3559
3560 // ports already handed out, and when. a browser takes seconds to start listening, and in that gap the
3561 // port it was given still looks free to anyone who asks. holding the number for a while is what stops
3562 // the second browser being sent to the first one's port
3563 // every port this process has given to a browser. nothing is ever taken off it: a browser can be
3564 // slow to start, slow to bind, and still running an hour later, and there are eight thousand ports
3565 // above the starting one, so reuse buys nothing and costs two browsers sharing a session
3566 private static readonly HashSet<int> portsHandedOut = new HashSet<int>();
3567
3568 // where the next search begins, so ports climb rather than being rescanned from the bottom every time
3569 private static int lastPortHandedOut = 0;
3570
3579 internal static int StartHiddenDriver(BrowserSettings browserSettings)
3580 {
3581 int retVal = FindFreePort();
3582 string driverName;
3583 string portArgument;
3584
3585 switch (browserSettings.BrowserType)
3586 {
3587 case BrowserType.FireFox:
3588 driverName = "geckodriver.exe";
3589 portArgument = $"--port {retVal}";
3590 break;
3591 case BrowserType.Edge:
3592 driverName = "msedgedriver.exe";
3593 portArgument = $"--port={retVal}";
3594 break;
3595 default:
3596 driverName = "chromedriver.exe";
3597 portArgument = $"--port={retVal}";
3598 break;
3599 }
3600
3601 string driverPath = Path.Combine(browserSettings.DriverLocation ?? AppDomain.CurrentDomain.BaseDirectory, driverName);
3602
3603 // Selenium Manager fetches a driver that is not there, and it is not on this path. A hidden run needs
3604 // the driver already in place, so it says which one and where rather than failing further along
3605 if (false == File.Exists(driverPath))
3606 throw new GPALException($"No [{driverName}] in [{Path.GetDirectoryName(driverPath)}], which a hidden run needs because Selenium Manager cannot fetch one here");
3607
3608 Native.STARTUPINFO startup = new Native.STARTUPINFO
3609 {
3610 cb = (uint)Marshal.SizeOf<Native.STARTUPINFO>(),
3611 lpDesktop = HiddenDesktop.StartupNameFor(browserSettings.HiddenDesktopName)
3612 };
3613
3614 if (false == Native.CreateProcess(null, $@"""{driverPath}"" {portArgument}", IntPtr.Zero, IntPtr.Zero, false,
3615 Native.CREATE_NO_WINDOW, IntPtr.Zero, null, ref startup, out var started))
3616 throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), $"Could not start [{driverName}] on [{browserSettings.HiddenDesktopName}]");
3617
3618 Native.CloseHandle(started.hProcess);
3619 Native.CloseHandle(started.hThread);
3620
3621 // the pid the teardown kills by, taken straight from the launch rather than out of a DriverService.
3622 // nothing else holds the browser the driver launches, so a lost pid is a browser that outlives the run
3623 browserSettings.ServiceDriverPid = (int)started.dwProcessId;
3624
3625 if (false == WaitForPort(retVal, 30_000))
3626 throw new GPALException($"[{driverName}] never answered on port [{retVal}]");
3627
3628 return retVal;
3629 }
3630
3637 private static bool WaitForPort(int port, int timeoutInMs)
3638 {
3639 bool retVal = false;
3640 DateTime giveUpAt = DateTime.Now.AddMilliseconds(timeoutInMs);
3641
3642 while (false == retVal && DateTime.Now < giveUpAt)
3643 {
3644 try
3645 {
3646 using (TcpClient probe = new TcpClient())
3647 {
3648 probe.Connect("127.0.0.1", port);
3649 retVal = true;
3650 }
3651 }
3652 catch (SocketException)
3653 {
3654 Thread.Sleep(200);
3655 }
3656 }
3657
3658 return retVal;
3659 }
3660
3667 internal static int FindFreePort(int startPort = 0xdead)
3668 {
3669 int retVal = 0;
3670
3671 lock (portsHandedOut)
3672 {
3673 // carrying on from the last one rather than starting over, so a browser that has not bound
3674 // its port yet is never probed and found free
3675 int from = Math.Max(startPort, lastPortHandedOut + 1);
3676
3677 for (int port = from; port <= 65535 && 0 == retVal; port++)
3678 {
3679 if (true == portsHandedOut.Contains(port))
3680 continue;
3681
3682 // binding is the only question that answers itself. connecting says free for a port nobody
3683 // is listening on yet, which is every port a browser has been told to use and not reached
3684 TcpListener probe = null;
3685
3686 try
3687 {
3688 probe = new TcpListener(System.Net.IPAddress.Loopback, port);
3689 probe.Start();
3690
3691 // claimed while the lock is still held, so the next thread in never sees this one free
3692 portsHandedOut.Add(port);
3693 lastPortHandedOut = port;
3694 retVal = port;
3695 }
3696 catch (SocketException)
3697 {
3698 // somebody holds it, so move along
3699 }
3700 finally
3701 {
3702 probe?.Stop();
3703 }
3704 }
3705 }
3706
3707 if (0 == retVal)
3708 throw new GPALException($"No free ports available between [{startPort}] and [65535].");
3709
3710 return retVal;
3711 }
3712
3721 internal static object MarkDocument(Browser browser)
3722 {
3723 object mark = null;
3724
3725 if (true == browser.UseSelenium)
3726 mark = browser.BrowserDriver.FindElement(By.TagName("html"));
3727 else if (true == browser.UsePuppeteer)
3728 mark = browser.PuppeteerCommunicator.DocumentMark().GetAwaiter().GetResult();
3729
3730 return mark;
3731 }
3741 internal static bool DocumentReplaced(Browser browser, object mark)
3742 {
3743 bool replaced = false;
3744
3745 if (true == browser.UseSelenium && mark is IWebElement html)
3746 try { _ = html.TagName; }
3747 catch (StaleElementReferenceException) { replaced = true; }
3748 catch (NoSuchElementException) { replaced = true; }
3749 else if (true == browser.UsePuppeteer && mark is long marked && 0 < marked)
3750 replaced = marked != browser.PuppeteerCommunicator.DocumentMark().GetAwaiter().GetResult();
3751
3752 return replaced;
3753 }
3767 internal static bool WaitForNavigationOrTime(Browser browser, string priorUrl, object documentMark, int timeOutAfterMs)
3768 {
3769 bool navigated = false;
3770 bool documentGone = false;
3771 bool urlChanged = false;
3772 string lastUrl = priorUrl;
3773 DateTime timeout = DateTime.Now.AddMilliseconds(timeOutAfterMs);
3774 GPALEventType publishToConsole = GPAL.GPALSettings.ConsoleEvents;
3775 GPALEventType publishToDebug = GPAL.GPALSettings.DebugEvents;
3776
3777 GPAL.GPALSettings.ConsoleEvents = GPALEventType.NONE;
3778 GPAL.GPALSettings.DebugEvents = GPALEventType.NONE;
3779
3780 try
3781 {
3782 if (true == browser.IsAlive)
3783 do
3784 {
3785 documentGone = DocumentReplaced(browser, documentMark);
3786 lastUrl = browser.GetSetCurrentUrl();
3787 urlChanged = false == UrlHelper.AreEquivalent(priorUrl, lastUrl);
3788
3789 navigated = true == documentGone || true == urlChanged;
3790
3791 // only sleep if we are going round again, so a grace of 0 costs exactly one look
3792 if (false == navigated && DateTime.Now < timeout)
3793 Thread.Sleep(NavigationPollMs);
3794 }
3795 while (false == navigated && DateTime.Now < timeout);
3796 }
3797 finally
3798 {
3799 GPAL.GPALSettings.ConsoleEvents = publishToConsole;
3800 GPAL.GPALSettings.DebugEvents = publishToDebug;
3801 }
3802
3803 // said once per click, after publishing is back on, so we can see which of the two signals answered and
3804 // whether the engine gave us a document to watch at all
3805 GPAL.PublishSimpleEvent(GPALEventType.DEBUG,
3806 $"Navigation watch [{(navigated ? "navigated" : "stayed")}] document [{(documentGone ? "replaced" : "same")}] url [{(urlChanged ? "changed" : "same")}] mark [{(documentMark is long mark ? mark.ToString() : null == documentMark ? "none" : "held")}] from [{priorUrl}] to [{lastUrl}]",
3807 browser, GPALObjectType.Browser);
3808
3809 return navigated;
3810 }
3820 // TODO - pass in a unique GUID to each method (add a parm to them) so the called methods can suppress repeat messages knowing they are called with the same token (so the same session)
3821 // gap between asks while waiting on a page state. short enough that a page which is already there is not
3822 // held up noticeably, long enough that a page taking seconds is asked tens of times rather than hundreds
3823 internal const int WaitForValuePollMs = 250;
3824
3825 internal static void WaitForValueOrTime(Browser browser, string resultsExpected, Func<dynamic> action, int timeOutAfterMs)
3826 {
3827 DateTime timeout = DateTime.Now.AddMilliseconds(timeOutAfterMs);
3828 string taskReturnValue;
3829 string[] tokens = resultsExpected.Split(',');
3830
3831 if (true == browser.IsAlive)
3832 do
3833 {
3834 dynamic result = action();
3835
3836 try
3837 {
3838 // Try to treat as Task
3839 var task = (Task<string>)result; // will throw if not a Task
3840 task.GetAwaiter().GetResult(); // block until complete
3841
3842 // If generic Task<T>, get the Result property
3843 var taskType = task.GetType();
3844 if (taskType.IsGenericType)
3845 {
3846 var taskResult = taskType.GetProperty("Result")?.GetValue(task);
3847 taskReturnValue = taskResult?.ToString() ?? resultsExpected;
3848 }
3849 else
3850 {
3851 taskReturnValue = tokens[0]; // non-generic Task
3852 }
3853 }
3854 catch
3855 {
3856 // Not a Task — treat as a string
3857 taskReturnValue = result?.ToString() ?? resultsExpected;
3858 }
3859
3860 foreach (string token in tokens)
3861 if (true == taskReturnValue.ToLower().Contains(token))
3862 return;
3863
3864 // the loop had no pace of its own, so it asked again the instant the last answer landed and
3865 // a slow page took hundreds of round trips to report one state change. a page does not become
3866 // ready any sooner for being asked more often
3867 Thread.Sleep(WaitForValuePollMs);
3868 }
3869 while (DateTime.Now < timeout);
3870 }
3871
3872 #region NetworkHelpers
3881 public static bool WaitForNetworkIdle(Browser browser, string sessionToken)
3882 {
3883 int timeoutMs = browser.BrowserSettings.NetworkIdleTimeoutMs;
3884 int maxConnections = browser.BrowserSettings.NetworkIdleMaxConnections;
3885 int pruneMs = browser.BrowserSettings.NetworkIdlePruneMs;
3886
3887 DateTime timeout = DateTime.Now.AddMilliseconds(timeoutMs);
3888 DateTime lastChange = DateTime.Now;
3889
3890 if (lastSessionToken != sessionToken)
3891 {
3892 lastErrorMessage.Clear();
3893 supressedMessage = false;
3894 lastSessionToken = sessionToken;
3895 }
3896
3897 int lastCount = -1;
3898
3899 string currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
3900
3901 if (false == lastErrorMessage.Contains(currentErrorMessage))
3902 {
3903 lastErrorMessage.Add(currentErrorMessage);
3904 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, browser, GPALObjectType.Browser);
3905 supressedMessage = false;
3906 }
3907 else if (false == supressedMessage)
3908 {
3909 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
3910 supressedMessage = true;
3911 }
3912
3913 while (DateTime.Now < timeout)
3914 {
3915 // Get current inflight requests
3916 int inflight = Convert.ToInt32(BrowserHelper.ExecuteJavaScriptObj(
3917 "return window.__gpalNetworkMonitor ? window.__gpalNetworkMonitor.inflight : 0;",
3918 browser
3919 ));
3920
3921 if (inflight != lastCount)
3922 {
3923 lastCount = inflight;
3924 lastChange = DateTime.Now;
3925 }
3926
3927 // If inflight is 0 for pruneMs, we consider network idle
3928 if (inflight == 0 && (DateTime.Now - lastChange).TotalMilliseconds >= pruneMs)
3929 {
3930 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Network IS Idle. Status [true]");
3931 return true;
3932 }
3933
3934 Thread.Sleep(100); // Poll every 100ms
3935 }
3936
3937 currentErrorMessage = $"Network IS NOT Idle. Status [false]";
3938
3939 if (false == lastErrorMessage.Contains(currentErrorMessage))
3940 {
3941 lastErrorMessage.Add(currentErrorMessage);
3942 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage, browser, GPALObjectType.Browser);
3943 supressedMessage = false;
3944 }
3945 else if (false == supressedMessage)
3946 {
3947 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
3948 supressedMessage = true;
3949 }
3950 return false; // Timeout
3951 }
3952
3953 // requires selenium.webdriver update, we are using the framework version :(
3954 //public static string WaitForNetworkIdle(Browser browser)
3955 //{
3956 // var task = WaitForNetworkIdleAsyncInternal(browser);
3957 // return task.GetAwaiter().GetResult();
3958 //}
3959 //public static async Task<string> WaitForNetworkIdleAsyncInternal(Browser browser)
3960 //{
3961 // int timeoutMs = browser.BrowserSettings.NetworkIdleTimeoutMs;
3962 // int maxConnections = browser.BrowserSettings.NetworkIdleMaxConnections;
3963 // int pruneMs = browser.BrowserSettings.NetworkIdlePruneMs;
3964 // IWebDriver driver = browser.BrowserDriver;
3965
3966
3967 // if (!(driver is IDevTools devToolsDriver))
3968 // {
3969 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, "This driver does not support check network idle.", browser, GPALObjectType.Browser);
3970 // return "true";
3971 // }
3972
3973 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms", browser, GPALObjectType.Browser);
3974
3975 // var network = driver.Manage().Network;
3976 // var requests = new ConcurrentBag<string>();
3977 // bool isIdle = false;
3978
3979 // var activeRequests = new ConcurrentDictionary<string, byte>();
3980
3981 // // Handlers
3982 // EventHandler<NetworkRequestSentEventArgs> requestSentHandler = (sender, e) =>
3983 // {
3984 // activeRequests[e.RequestId] = 0;
3985 // };
3986
3987 // EventHandler<NetworkResponseReceivedEventArgs> responseReceivedHandler = (sender, e) =>
3988 // {
3989 // activeRequests.TryRemove(e.RequestId, out _);
3990 // };
3991
3992 // // Attach
3993 // network.NetworkRequestSent += requestSentHandler;
3994 // network.NetworkResponseReceived += responseReceivedHandler;
3995
3996 // try
3997 // {
3998 // await network.StartMonitoring();
3999
4000 // var overallTimeout = DateTime.UtcNow.AddMilliseconds(timeoutMs);
4001 // DateTime? idleSince = null;
4002
4003 // while (DateTime.UtcNow < overallTimeout)
4004 // {
4005 // int openConnections = activeRequests.Count;
4006
4007 // if (openConnections <= maxConnections)
4008 // {
4009 // idleSince ??= DateTime.UtcNow; // mark when idle started
4010 // if ((DateTime.UtcNow - idleSince.Value).TotalMilliseconds >= pruneMs)
4011 // {
4012 // isIdle = true;
4013 // break;
4014 // }
4015 // }
4016 // else
4017 // {
4018 // idleSince = null; // reset idle timer if activity resumed
4019 // }
4020
4021 // await Task.Delay(250); // small polling interval
4022 // }
4023
4024 // if (!isIdle)
4025 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Network idle timeout reached.", browser, GPALObjectType.Browser);
4026 // }
4027 // finally
4028 // {
4029 // network.NetworkRequestSent -= requestSentHandler;
4030 // network.NetworkResponseReceived -= responseReceivedHandler;
4031 // await network.StopMonitoring();
4032 // }
4033
4034 // return isIdle.ToString();
4035 //}
4045 public static bool WaitForNetworkIdle(
4046 Browser browser,
4047 int timeoutMs = 500,
4048 int maxConnections = 0,
4049 int pruneMs = 3000,
4050 string sessionToken = null)
4051 {
4052 Exception innerEx = null;
4053 try
4054 {
4055 if (lastSessionToken != sessionToken)
4056 {
4057 lastErrorMessage.Clear();
4058 supressedMessage = false;
4059 lastSessionToken = sessionToken;
4060 }
4061
4062 string currentErrorMessage = $"Waiting up to [{timeoutMs}] ms for network to idle to [{maxConnections}] connections for [{pruneMs}] ms";
4063
4064 if (false == lastErrorMessage.Contains(currentErrorMessage))
4065 {
4066 lastErrorMessage.Add(currentErrorMessage);
4067 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
4068 supressedMessage = false;
4069 }
4070 else if (false == supressedMessage)
4071 {
4072 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4073 supressedMessage = true;
4074 }
4075
4076 DateTime startTime = DateTime.Now;
4077 while ((DateTime.Now - startTime).TotalMilliseconds < timeoutMs)
4078 {
4079 string response = browser.OttoMagicClient.WithEndpoint(ApiEndpoint.CheckNetworkIdle).WithMaxConnections(maxConnections).WithPruneMs(pruneMs).WithTimeoutMs(timeoutMs).Execute();
4080
4081 if (false == string.IsNullOrEmpty(response))
4083 bool retVal = response.Contains("idle");
4084
4085 currentErrorMessage = $"Network IS{(retVal ? "" : " NOT")} Idle. Status [{retVal}]";
4086
4087 if (false == lastErrorMessage.Contains(currentErrorMessage))
4088 {
4089 lastErrorMessage.Add(currentErrorMessage);
4090 GPAL.PublishSimpleEvent(GPALEventType.INFO, currentErrorMessage);
4091 supressedMessage = false;
4092 }
4093 else if (false == supressedMessage)
4094 {
4095 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", currentErrorMessage, GPALObjectType.Other);
4096 supressedMessage = true;
4097 }
4099 return retVal;
4100 }
4101
4102 Thread.Sleep(100);
4103 }
4104 }
4105 catch (GPALException)
4106 {
4107 throw;
4108 }
4109 catch (Exception ex)
4110 {
4111 innerEx = ex;
4112 }
4113 // Log timeout
4114 GPAL.PublishSimpleEvent(null != innerEx ? GPALEventType.EXCEPTION : GPALEventType.WARNING,
4115 $"Page did not reach network idle state within [{timeoutMs}] milliseconds",
4116 null, GPALObjectType.None, innerEx);
4117 return false;
4118 }
4123 public static IPEndPoint[] GetAllTcpListeners()
4124 {
4125 IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
4126 return ipGlobalProperties.GetActiveTcpListeners();
4127 }
4139 public static bool IsPortListening(int port)
4140 {
4141 IPEndPoint[] tcpListeners = GetAllTcpListeners();
4142 return tcpListeners.Any(ep => ep.Port == port);
4143 }
4144 #endregion NetworkHelpers
4145 }
4146}
4147
Result of attempting to kill one or more processes by name.
static string ExecuteJavaScript(IBrowser browser, string executeMe, params object[] args)
Runs a script and hands back what it produced. Selenium's executor is a function body,...
static void FillInWithTokens(Browser browser, IGPALGrid< string > tokens, WriteMode writeMode)
Finds the elements matching the unit of work's selectors, arranges them into a grid of rows and colum...
static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
Removes a script previously registered via SeleniumAddScriptToEvaluateOnNewDocument,...
static string FindTab(BrowserSettings browserSettings, string URL)
Searches all open browser window handles for one whose URL matches (or is matched by) URL ,...
static KillProcessesResult KillProcessesByName(params string[] namesOfProcessesToKill)
Kills all running processes whose name matches any of namesOfProcessesToKill .
static string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
static string RestApiLocation()
Where GPALRestAPI is installed, worked out the way the browser works it out: a registry key names the...
static string FindFirefoxBinaryLocation()
Looks up the installed Firefox executable path from the registry key configured in GPAL....
static string JsCheckReadyState(dynamic browser, string sessionToken)
Use javascript to check the page document.ready state, only called from one place that does the timin...
static void AllowDownloads(BrowserSettings browserSettings, string directory)
Accept downloads into directory without asking where to put them, whatever the profile says....
static void SeleniumGoToUrl(GPALUrl url, BrowserSettings browserSettings)
Navigates the Selenium WebDriver to url and inspects the browser's performance logs (Network....
static bool TestUrlForPDF(Browser browser, string url)
checking the extension of the url, but also calling the URL to see if it returns a pdf response heade...
static string FindProfileDirectory(string userDataDirectory, string profileName)
the profile name might not be what we expect, which is a directory name. this will iterate thru all d...
static KillProcessesResult KillProcess(Process process)
Kill the process (browser launched by MagicHelper or puppeteer.
static bool IsPortListening(int port)
Checks whether anything is currently listening on the given TCP port.
static string GetUserAgent(IBrowser browser)
Test the url to determine whether it is a PDF file by.
static string GetBrowserProfileDirectory(BrowserSettings browserSettings, bool getDefaultDirectory=false)
Get the browserSettings.ProfileUserName profile directory under the default user directory.
static bool WaitForNetworkIdle(Browser browser, string sessionToken)
Polls a custom javascript network monitor (window.__gpalNetworkMonitor) until there are 0 in-flight r...
static IPEndPoint[] GetAllTcpListeners()
Returns all active TCP listener endpoints on this machine.
static void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
Instructs the underlying Chromium driver (via CDP Page.setDownloadBehavior) to send the next download...
static KillProcessesResult[] KillAllRunningWebDrivers()
Kills all known webdriver processes (geckodriver, chromedriver, IEDriverServer, MSEdgeDriver) and res...
static int LiveRestApiHosts(out List<(int processId, int port)> hosts)
Every GPALRestAPI running on this machine right now, as the process that hosts it and the port it lis...
static IWebDriver GetBrowserDriver(BrowserSettings browserSettings)
Create (or return the existing) Selenium WebDriver for the given BrowserSettings. Configures Chrome,...
static bool GotoNextPage(Browser browser)
Navigates to the next page of results, either by clicking/following the configured "next page" select...
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
BrowserSettings BrowserSettings
The settings backing this Browser, including configuration, state, and engine handles.
Definition Browser.cs:7048
IAllowBrowserActionOrAnySelector PageEnd
Scroll the current tab window to the bottom of the page.
Definition Browser.cs:2367
IAllowBrowserActionOrAnySelector GoTo(GPALUrl URL)
Navigate to the URL specified. If no browser is open, one will be launched. If using an already ope...
Definition Browser.cs:2623
bool UseOttoMagic
True if this Browser is using the OttoMagic automation engine.
Definition Browser.cs:6949
bool UsePuppeteer
True if this Browser is using the Puppeteer automation engine.
Definition Browser.cs:6942
MagicHelper MagicHelper
The MagicHelper used to issue commands when this Browser is using the OttoMagic engine.
Definition Browser.cs:7109
IPuppeteerClient PuppeteerClient
The Puppeteer client used to issue commands when this Browser is using the Puppeteer engine.
Definition Browser.cs:7101
IAllowAllBrowserAndAllSelector Maximize
Maximize the browser to full-screen.
Definition Browser.cs:2492
IAllowAfterAnySelectorExceptWithAll WithAllThatMatch(int rowCount=int.MaxValue)
Indicates the Selectors refer to/match repeating, multiple elements on the page. Use after all your ...
Definition Browser.cs:2187
IMagicHelper MagicHelper
This browser's own MagicHelper, talking to this browser's OttoMagic port. Assigned when the browser i...
string GetReadyStatus(string sessionToken)
Gets the ready status NOTE: if no page is loaded, this will return null cause there is no content scr...
string GetUserAgent()
Gets the user agent string userAgent = browser.MagicHelper.GetUserAgent();.
static string GetUserAgentString(BrowserType browserType)
Get chrome or edge useragent string based upon what is installed on this system This looks up the ver...
File-side plumbing behind the fluent chain: writing a unit of work's data out in a delimited format,...
Definition FileHelper.cs:55
static string GetDefaultDownloadDirectory(IBrowser browser)
Returns the download directory this browser will actually use, read out of its own configuration rath...
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
string Css
CSS selector used to locate this element.
Thrown where GPAL deliberately ends the workflow, such as a CallIf handler returning CallIfStatus....
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2406
GPAL Selector used to locate Application and Browser elements. Instantiated with GPAL....
Definition Selector.cs:56
int DeltaX
Drag-and-drop deltaX to move from the elemenet.X + OffsetX.
Definition Selector.cs:826
int OffsetY
Y offset fromm the top left corner to interact with the element, image match included....
Definition Selector.cs:811
InteractionType InteractionType
Defined method to interact with this element. NOTE: This can be overridden in the workflow.
Definition Selector.cs:731
int OffsetX
X offset from the top left corner to interact with the element, image match included....
Definition Selector.cs:795
Everything revolves around the Unit of Work. A Unit of Work is defined as one or more selectors betw...
Definition UnitOfWork.cs:39
int WithAllThatMatch
Controls whether the selectors in scope are multiple elements on screen, and to get all Use ....
Selector NextPageButton
The next page button. When scraping multiple pages of results, this element is pressed for a new page...
Definition UnitOfWork.cs:66
List< Selector > WithSelectorList
List of With selectors Use .WithSelector to add selectors to this list.
Definition UnitOfWork.cs:45
bool PartialMatch
Only relevant if there is a match criteria or matching function. Of all the found selectors,...
bool InfiniteScroll
Go to page end to get the next page of results. Use with .WithPages() to retrieve multiple pages of ...
Definition UnitOfWork.cs:74
IAllowPuppeteerInputText FillInInsert(dynamic elementOrelementId, int delayMs=0)
IAllowPuppeteerInputText FillInOverwrite(dynamic elementOrelementId, int delayMs=0)
IAllowPuppeteerInputText FillInAppend(dynamic elementOrelementId, int delayMs=0)
Sets the element's value directly (fast path) - does NOT honor GPAL.TypingDelay. delayMs only paces ...