GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
ChromeProfileManager.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.Generic;
19using System.Diagnostics;
20using System.IO;
21using System.Linq;
22using System.Threading;
23
24//using Microsoft.Data.Sqlite;
25using Newtonsoft.Json;
26using Newtonsoft.Json.Linq;
27using static GenerallyPositive.Enums;
28using static Org.BouncyCastle.Asn1.Cmp.Challenge;
29
31{
32 internal class ChromeProfileManager
33 {
44 private static readonly Random random = new Random();
45
46 // Prefix on every temp profile directory, chromium and firefox alike. Nothing without it is touched by
47 // the sweep
48 private const string TempProfilePrefix = "BrowserProfile_";
49
50 // What a directory is renamed to before it is deleted. A sweep that dies part way through the delete
51 // leaves one of these, which the next sweep finishes without waiting for it to age
52 private const string SweepingSuffix = ".sweeping";
53
54 // Minimum age before a temp profile is considered abandoned. A concurrent GPAL process may have just
55 // created one and not yet launched into it, so it is unlocked but in use
56 private const int AbandonedAfterMinutes = 1;
57
58 // Seconds between sweeps. This runs on every launch and every close, which on a run that opens a browser
59 // per site is far more often than there is anything to find
60 private const int SweepEverySeconds = 30;
61
62 private static DateTime lastSweep = DateTime.MinValue;
63 private static int sweeping = 0;
64
70 internal static string NewTempProfilePath()
71 {
72 return Path.Combine(GPAL.TempProfileDirectory, $"{TempProfilePrefix}{Guid.NewGuid():N}");
73 }
74
83 internal static bool DeleteWhenNothingIsUsingIt(string directory)
84 {
85 bool deleted = false;
86
87 try
88 {
89 string renamedTo = directory;
90
91 // one already renamed was proved free by the sweep that renamed it, and renaming it again would
92 // only lose track of it
93 if (false == directory.EndsWith(SweepingSuffix))
94 {
95 renamedTo = Path.Combine(Path.GetDirectoryName(directory), $"{TempProfilePrefix}{Guid.NewGuid():N}{SweepingSuffix}");
96 Directory.Move(directory, renamedTo);
97 }
98
99 Directory.Delete(renamedTo, true);
100 deleted = true;
101 }
102 catch (Exception)
103 {
104 // The rename failed, so a browser still has the profile and nothing here was touched, or the
105 // delete failed part way and the next sweep picks up the renamed directory
106 }
107
108 return deleted;
109 }
110
117 internal static void SweepAbandonedTempProfiles()
118 {
119 // One sweep at a time, not too often, and off the thread that is launching a browser
120 if (DateTime.Now < lastSweep.AddSeconds(SweepEverySeconds))
121 return;
122
123 if (0 != Interlocked.Exchange(ref sweeping, 1))
124 return;
125
126 lastSweep = DateTime.Now;
127
128 System.Threading.Tasks.Task.Run(() =>
129 {
130 try
131 {
132 string root = GPAL.TempProfileDirectory;
133
134 if (false == string.IsNullOrEmpty(root) && true == Directory.Exists(root))
135 {
136 DateTime olderThan = DateTime.Now.AddMinutes(-AbandonedAfterMinutes);
137 int swept = 0;
138
139 foreach (string directory in Directory.GetDirectories(root, TempProfilePrefix + "*"))
140 {
141 // age only says a browser may not have opened its files yet, which a directory an
142 // earlier sweep already renamed is past
143 if (false == directory.EndsWith(SweepingSuffix) && Directory.GetCreationTime(directory) > olderThan)
144 continue;
145
146 if (true == DeleteWhenNothingIsUsingIt(directory))
147 swept++;
148 }
149
150 if (0 < swept)
151 GPAL.PublishSimpleEvent(Enums.GPALEventType.INFO, $"Cleared [{swept}] temporary browser profile(s) left behind by earlier runs from [{root}]");
152 }
153 }
154 catch (Exception ex)
155 {
156 // A sweep that cannot run leaves the directories in place
157 GPAL.PublishSimpleEvent(Enums.GPALEventType.WARNING, $"Could not clear old temporary browser profiles", null, GPALObjectType.None, ex);
158 }
159 finally
160 {
161 Interlocked.Exchange(ref sweeping, 0);
162 }
163 });
164 }
165
166 internal static string CreateTempUserProfile(
167 string downloadDirectory,
168 bool promptForDownload,
169 bool openPDFExternally,
170 bool loadImages,
171 bool useOttoMagic,
172 Dictionary<string, object> additionalPrefs = null)
173 {
174 try
175 {
176 if (!Directory.Exists(downloadDirectory))
177 Directory.CreateDirectory(downloadDirectory);
178
179 // Create brand new empty profile root
180 string tempProfileDir = NewTempProfilePath();
181
182 string defaultDir = Path.Combine(tempProfileDir, "Default");
183
184 Directory.CreateDirectory(defaultDir);
185
186 Base64Helper base64Helper = new Base64Helper();
187 base64Helper.WithInput(GPAL.GPALSettings.Preferences).SaveTo(Path.Combine(defaultDir, "Preferences"));
188 base64Helper.WithInput(GPAL.GPALSettings.SecurePreferences).SaveTo(Path.Combine(defaultDir, "Secure Preferences"));
189 base64Helper.WithInput(GPAL.GPALSettings.LocalState).SaveTo(Path.Combine(tempProfileDir, "Local State"));
190
191 // the canned Preferences blob says whatever it was captured with, so the workflow's own settings
192 // are written over it the same way a supplied profile gets them. Without this a temp profile
193 // ignored the download prompt, the pdf viewer, images and anything from .WithAdditionalPrefs,
194 // which is how those four arguments came to be accepted and never read
195 ApplyDownloadPreferences(tempProfileDir, downloadDirectory, promptForDownload, openPDFExternally);
196
197 ApplyImagePreference(tempProfileDir, loadImages);
198
199 if (null != additionalPrefs && 0 < additionalPrefs.Count)
200 ApplyAdditionalPreferences(tempProfileDir, additionalPrefs);
201
202 return tempProfileDir;
203 }
204 catch (Exception ex)
205 {
206 GPAL.PublishSimpleEvent(
207 Enums.GPALEventType.EXCEPTION,
208 $"Failed creating temp Chrome profile folder",
209 null,
210 Enums.GPALObjectType.None,
211 ex);
212
213 return null;
214 }
215 }
216
217
218 // reddit consent banner - test to see if injecting this works
219 // "Your request has been blocked by network security. Please try to login with your Reddit account."
220 // https://www.reddit.com/r/firefox/comments/1mqmsv5/your_request_has_been_blocked_by_network_security/
221
222 // the preferences GPAL sets on a supplied profile, by their chrome names, so apply and restore cannot
223 // drift apart.
224 // session.restore_on_startup does NOT belong here and must not be added. chromium keeps an HMAC for it in
225 // Secure Preferences, along with the homepage and the search provider, so that malware cannot hijack what
226 // the browser opens with. writing it from outside breaks that mac, and chromium answers by resetting the
227 // preference and taking a good part of the rest of the file with it. what actually stops a session being
228 // restored is profile.exit_type below: the restore was crash recovery, not the startup setting
229 private static readonly string[] downloadPreferenceKeys =
230 {
231 "download.prompt_for_download",
232 "download.default_directory",
233 "plugins.always_open_pdf_externally",
234 "browser.show_hub_popup_on_download_start",
235 };
236
237
245 private static void SetByPath(JObject root, string path, JToken value)
246 {
247 string[] parts = path.Split('.');
248 JObject node = root;
249
250 for (int idx = 0; idx < parts.Length - 1; idx++)
251 {
252 if (false == (node[parts[idx]] is JObject child))
253 {
254 child = new JObject();
255 node[parts[idx]] = child;
256 }
257
258 node = child;
259 }
260
261 node[parts[parts.Length - 1]] = value;
262 }
269 private static string PreferencesFileFor(string profileDirectory)
270 {
271 string preferencesPath = null;
272
273 if (false == string.IsNullOrEmpty(profileDirectory))
274 {
275 string inDefault = Path.Combine(profileDirectory, "Default", "Preferences");
276 string inProfile = Path.Combine(profileDirectory, "Preferences");
277
278 preferencesPath = File.Exists(inDefault) ? inDefault : File.Exists(inProfile) ? inProfile : null;
279 }
280
281 return preferencesPath;
282 }
283
295 internal static Dictionary<string, JToken> ApplyDownloadPreferences(string profileDirectory, string downloadDirectory, bool promptForDownload, bool openPDFExternally)
296 {
297 Dictionary<string, JToken> previous = null;
298 string preferencesPath = PreferencesFileFor(profileDirectory);
299
300 if (null == preferencesPath)
301 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No chrome Preferences file under [{profileDirectory}], so prompt for download and open pdf externally cannot be set for this profile.", null, GPALObjectType.None);
302 else
303 try
304 {
305 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
306
307 previous = new Dictionary<string, JToken>();
308 foreach (string key in downloadPreferenceKeys)
309 previous[key] = preferences.SelectToken(key);
310
311 SetByPath(preferences, "download.prompt_for_download", promptForDownload);
312 SetByPath(preferences, "plugins.always_open_pdf_externally", openPDFExternally);
313
314 // the flyout edge opens when a download starts. it takes focus and covers the top right of the
315 // page, which is somewhere a workflow may be about to click
316 SetByPath(preferences, "browser.show_hub_popup_on_download_start", false);
317
318 // not a setting and not restored: it is how the browser records its own last shutdown, and a
319 // killed browser leaves it Crashed. chromium reopens the previous session after a crash no
320 // matter what session.restore_on_startup says, so every run GPAL kills teaches the next run to
321 // come up holding the last one's tabs. saying it exited normally is what breaks that loop
322 SetByPath(preferences, "profile.exit_type", "Normal");
323
324 if (false == string.IsNullOrEmpty(downloadDirectory))
325 SetByPath(preferences, "download.default_directory", downloadDirectory);
326
327 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences)); // not JToken.ToString(Formatting), whose overload binds against whichever Newtonsoft the host app loaded
328
329 // which file, what it said, what it says now. a profile is the one thing here nobody can see
330 // from the outside, so when a browser goes on prompting after being told not to, this is the
331 // difference between knowing and guessing
332 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] prompt for download [{previous["download.prompt_for_download"]?.ToString() ?? "unset"}] -> [{promptForDownload}], open pdf externally [{previous["plugins.always_open_pdf_externally"]?.ToString() ?? "unset"}] -> [{openPDFExternally}], download directory [{previous["download.default_directory"]?.ToString() ?? "unset"}] -> [{downloadDirectory ?? "left alone"}]", null, GPALObjectType.None);
333 }
334 catch (Exception ex)
335 {
336 previous = null;
337 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not write download preferences into [{preferencesPath}]", null, GPALObjectType.None, ex);
338 }
339
340 return previous;
341 }
342
352 internal static void ApplyImagePreference(string profileDirectory, bool loadImages)
353 {
354 string preferencesPath = PreferencesFileFor(profileDirectory);
355
356 if (null == preferencesPath)
357 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No chrome Preferences file under [{profileDirectory}], so load images cannot be set for this profile.", null, GPALObjectType.None);
358 else
359 try
360 {
361 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
362
363 SetByPath(preferences, "profile.default_content_setting_values.images", true == loadImages ? 1 : 2);
364
365 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences));
366
367 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] load images [{loadImages}]", null, GPALObjectType.None);
368 }
369 catch (Exception ex)
370 {
371 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not write the load images preference into [{preferencesPath}]", null, GPALObjectType.None, ex);
372 }
373 }
374
381 internal static void ApplyAdditionalPreferences(string profileDirectory, Dictionary<string, object> additionalPrefs)
382 {
383 string preferencesPath = PreferencesFileFor(profileDirectory);
384
385 if (null == preferencesPath)
386 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No chrome Preferences file under [{profileDirectory}], so the additional preferences cannot be set for this profile.", null, GPALObjectType.None);
387 else
388 try
389 {
390 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
391
392 foreach (KeyValuePair<string, object> pref in additionalPrefs)
393 SetByPath(preferences, pref.Key, null == pref.Value ? JValue.CreateNull() : JToken.FromObject(pref.Value));
394
395 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences));
396
397 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] took [{additionalPrefs.Count}] additional preference(s): [{string.Join(", ", additionalPrefs.Keys)}]", null, GPALObjectType.None);
398 }
399 catch (Exception ex)
400 {
401 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not write the additional preferences into [{preferencesPath}]", null, GPALObjectType.None, ex);
402 }
403 }
404
411 internal static void RestoreDownloadPreferences(string profileDirectory, Dictionary<string, JToken> previous)
412 {
413 string preferencesPath = null == previous ? null : PreferencesFileFor(profileDirectory);
414
415 if (null == preferencesPath)
416 return;
417
418 try
419 {
420 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
421
422 foreach (string key in downloadPreferenceKeys)
423 if (null == previous[key])
424 preferences.SelectToken(key)?.Parent?.Remove(); // it was not there before, so it goes back to not being there
425 else
426 SetByPath(preferences, key, previous[key]);
427
428 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences)); // not JToken.ToString(Formatting), whose overload binds against whichever Newtonsoft the host app loaded
429
430 // the other half of what Apply said. a browser writes this file itself as it shuts down, so which
431 // of the two wrote last decides what the next run reads, and that only shows up in the timing
432 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] put back, prompt for download [{previous["download.prompt_for_download"]?.ToString() ?? "unset"}], download directory [{previous["download.default_directory"]?.ToString() ?? "unset"}]", null, GPALObjectType.None);
433 }
434 catch (Exception ex)
435 {
436 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not put the download preferences back in [{preferencesPath}]", null, GPALObjectType.None, ex);
437 }
438 }
439
440 internal static void RemoveTempUserProfile(string profileDirectory)
441 {
442 Exception innerEx = null;
443 if (!Directory.Exists(profileDirectory))
444 return;
445
446 // the browser is shutting down as this runs, so a first pass usually finds files still open. the
447 // rename inside DeleteWhenNothingIsUsingIt is what makes retrying safe: a pass that is too early
448 // leaves the profile whole instead of taking half of it with it
449 for (int i = 0; i < 10; i++)
450 {
451 try
452 {
453 if (true == DeleteWhenNothingIsUsingIt(profileDirectory))
454 {
455 GPAL.PublishSimpleEvent(Enums.GPALEventType.INFO, $"Deleted temporary user profile at [{profileDirectory}]");
456 return;
457 }
458
459 System.Threading.Thread.Sleep(500);
460 }
461 catch (Exception ex)
462 {
463 innerEx = ex;
464 System.Threading.Thread.Sleep(500);
465 GPAL.PublishSimpleEvent(Enums.GPALEventType.EXCEPTION, $"Failed to delete temporary user profile at [{profileDirectory}][{innerEx.Message}]", null, GPALObjectType.None, innerEx);
466 }
467 }
468
469 // left whole and named the way the sweep looks for, so the next run clears it
470 GPAL.PublishSimpleEvent(Enums.GPALEventType.WARNING, $"Still in use after 10 tries, leaving temporary user profile at [{profileDirectory}] for the next run to clear");
471 }
472 }
473}