diff --git a/Application/DialogHandler.cs b/Application/DialogHandler.cs
new file mode 100644
index 00000000..23868c40
--- /dev/null
+++ b/Application/DialogHandler.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Linq;
+using System.Text;
+using System.Windows.Forms;
+using TweetDuck.Dialogs;
+using TweetDuck.Management;
+using TweetLib.Core.Application;
+using TweetLib.Core.Systems.Dialogs;
+
+namespace TweetDuck.Application {
+	sealed class DialogHandler : IAppDialogHandler {
+		public void Information(string caption, string text, string buttonAccept, string buttonCancel = null) {
+			FormManager.RunOnUIThreadAsync(() => FormMessage.Information(caption, text, buttonAccept, buttonCancel));
+		}
+
+		public void Error(string caption, string text, string buttonAccept, string buttonCancel = null) {
+			FormManager.RunOnUIThreadAsync(() => FormMessage.Error(caption, text, buttonAccept, buttonCancel));
+		}
+
+		public void SaveFile(SaveFileDialogSettings settings, Action<string> onAccepted) {
+			static string FormatFilter(FileDialogFilter filter) {
+				var builder = new StringBuilder();
+				builder.Append(filter.Name);
+
+				var extensions = string.Join(";", filter.Extensions.Select(ext => "*" + ext));
+				if (extensions.Length > 0) {
+					builder.Append(" (");
+					builder.Append(extensions);
+					builder.Append(")");
+				}
+
+				builder.Append('|');
+				builder.Append(extensions.Length == 0 ? "*.*" : extensions);
+				return builder.ToString();
+			}
+
+			FormManager.RunOnUIThreadAsync(() => {
+				using SaveFileDialog dialog = new SaveFileDialog {
+					AutoUpgradeEnabled = true,
+					OverwritePrompt = settings.OverwritePrompt,
+					Title = settings.DialogTitle,
+					FileName = settings.FileName,
+					Filter = settings.Filters == null ? null : string.Join("|", settings.Filters.Select(FormatFilter))
+				};
+
+				if (dialog.ShowDialog() == DialogResult.OK) {
+					onAccepted(dialog.FileName);
+				}
+			});
+		}
+	}
+}
diff --git a/Application/Logger.cs b/Application/Logger.cs
new file mode 100644
index 00000000..9ffa48f0
--- /dev/null
+++ b/Application/Logger.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using TweetLib.Core;
+using TweetLib.Core.Application;
+
+namespace TweetDuck.Application {
+	sealed class Logger : IAppLogger {
+		private string LogFilePath => Path.Combine(App.StoragePath, logFileName);
+
+		private readonly string logFileName;
+
+		public Logger(string logFileName) {
+			this.logFileName = logFileName;
+		}
+
+		bool IAppLogger.Debug(string message) {
+			#if DEBUG
+			return Log("DEBUG", message);
+			#else
+			return Arguments.HasFlag(Arguments.ArgLogging) && Log(message);
+			#endif
+		}
+
+		bool IAppLogger.Info(string message) {
+			return Log("INFO", message);
+		}
+
+		bool IAppLogger.Error(string message) {
+			return Log("ERROR", message);
+		}
+
+		bool IAppLogger.OpenLogFile() {
+			try {
+				using (Process.Start(LogFilePath)) {}
+			} catch (Exception) {
+				return false;
+			}
+
+			return true;
+		}
+
+		private bool Log(string level, string message) {
+			#if DEBUG
+			Debug.WriteLine("[" + level + "] " + message);
+			#endif
+
+			string logFilePath = LogFilePath;
+
+			StringBuilder build = new StringBuilder();
+
+			if (!File.Exists(logFilePath)) {
+				build.Append("Please, report all issues to: https://github.com/chylex/TweetDuck/issues\r\n\r\n");
+			}
+
+			build.Append("[").Append(DateTime.Now.ToString("G", Lib.Culture)).Append("]\r\n");
+			build.Append(message).Append("\r\n\r\n");
+
+			try {
+				File.AppendAllText(logFilePath, build.ToString(), Encoding.UTF8);
+				return true;
+			} catch {
+				return false;
+			}
+		}
+	}
+}
diff --git a/Application/SystemHandler.cs b/Application/SystemHandler.cs
index 2f9b677c..91191019 100644
--- a/Application/SystemHandler.cs
+++ b/Application/SystemHandler.cs
@@ -1,12 +1,19 @@
 using System;
 using System.Diagnostics;
+using System.Drawing;
 using System.IO;
+using System.Windows.Forms;
+using TweetDuck.Browser;
+using TweetDuck.Configuration;
+using TweetDuck.Dialogs;
+using TweetDuck.Management;
 using TweetLib.Core;
 using TweetLib.Core.Application;
+using TweetLib.Core.Features.Twitter;
 
 namespace TweetDuck.Application {
-	class SystemHandler : IAppSystemHandler {
-		void IAppSystemHandler.OpenAssociatedProgram(string path) {
+	sealed class SystemHandler : IAppSystemHandler {
+		public void OpenAssociatedProgram(string path) {
 			try {
 				using (Process.Start(new ProcessStartInfo {
 					FileName = path,
@@ -17,7 +24,66 @@ void IAppSystemHandler.OpenAssociatedProgram(string path) {
 			}
 		}
 
-		void IAppSystemHandler.OpenFileExplorer(string path) {
+		public void OpenBrowser(string url) {
+			if (string.IsNullOrWhiteSpace(url)) {
+				return;
+			}
+
+			FormManager.RunOnUIThreadAsync(() => {
+				var config = Program.Config.User;
+
+				switch (TwitterUrls.Check(url)) {
+					case TwitterUrls.UrlType.Fine:
+						string browserPath = config.BrowserPath;
+
+						if (browserPath == null || !File.Exists(browserPath)) {
+							OpenAssociatedProgram(url);
+						}
+						else {
+							string quotedUrl = '"' + url + '"';
+							string browserArgs = config.BrowserPathArgs == null ? quotedUrl : config.BrowserPathArgs + ' ' + quotedUrl;
+
+							try {
+								using (Process.Start(browserPath, browserArgs)) {}
+							} catch (Exception e) {
+								App.ErrorHandler.HandleException("Error Opening Browser", "Could not open the browser.", true, e);
+							}
+						}
+
+						break;
+
+					case TwitterUrls.UrlType.Tracking:
+						if (config.IgnoreTrackingUrlWarning) {
+							goto case TwitterUrls.UrlType.Fine;
+						}
+
+						using (FormMessage form = new FormMessage("Blocked URL", "TweetDuck has blocked a tracking url due to privacy concerns. Do you want to visit it anyway?\n" + url, MessageBoxIcon.Warning)) {
+							form.AddButton(FormMessage.No, DialogResult.No, ControlType.Cancel | ControlType.Focused);
+							form.AddButton(FormMessage.Yes, DialogResult.Yes, ControlType.Accept);
+							form.AddButton("Always Visit", DialogResult.Ignore);
+
+							DialogResult result = form.ShowDialog();
+
+							if (result == DialogResult.Ignore) {
+								config.IgnoreTrackingUrlWarning = true;
+								config.Save();
+							}
+
+							if (result == DialogResult.Ignore || result == DialogResult.Yes) {
+								goto case TwitterUrls.UrlType.Fine;
+							}
+						}
+
+						break;
+
+					case TwitterUrls.UrlType.Invalid:
+						FormMessage.Warning("Blocked URL", "A potentially malicious or invalid URL was blocked from opening:\n" + url, FormMessage.OK);
+						break;
+				}
+			});
+		}
+
+		public void OpenFileExplorer(string path) {
 			if (File.Exists(path)) {
 				using (Process.Start("explorer.exe", "/select,\"" + path.Replace('/', '\\') + "\"")) {}
 			}
@@ -25,5 +91,62 @@ void IAppSystemHandler.OpenFileExplorer(string path) {
 				using (Process.Start("explorer.exe", '"' + path.Replace('/', '\\') + '"')) {}
 			}
 		}
+
+		public void CopyImageFromFile(string path) {
+			FormManager.RunOnUIThreadAsync(() => {
+				Image image;
+
+				try {
+					image = Image.FromFile(path);
+				} catch (Exception ex) {
+					FormMessage.Error("Copy Image", "An error occurred while copying the image: " + ex.Message, FormMessage.OK);
+					return;
+				}
+
+				ClipboardManager.SetImage(image);
+			});
+		}
+
+		public void CopyText(string text) {
+			FormManager.RunOnUIThreadAsync(() => ClipboardManager.SetText(text, TextDataFormat.UnicodeText));
+		}
+
+		public void SearchText(string text) {
+			if (string.IsNullOrWhiteSpace(text)) {
+				return;
+			}
+
+			FormManager.RunOnUIThreadAsync(() => {
+				var config = Program.Config.User;
+				string searchUrl = config.SearchEngineUrl;
+
+				if (string.IsNullOrEmpty(searchUrl)) {
+					if (FormMessage.Question("Search Options", "You have not configured a default search engine yet, would you like to do it now?", FormMessage.Yes, FormMessage.No)) {
+						bool wereSettingsOpen = FormManager.TryFind<FormSettings>() != null;
+
+						FormManager.TryFind<FormBrowser>()?.OpenSettings();
+
+						if (wereSettingsOpen) {
+							return;
+						}
+
+						FormSettings settings = FormManager.TryFind<FormSettings>();
+
+						if (settings == null) {
+							return;
+						}
+
+						settings.FormClosed += (sender, args) => {
+							if (args.CloseReason == CloseReason.UserClosing && config.SearchEngineUrl != searchUrl) {
+								SearchText(text);
+							}
+						};
+					}
+				}
+				else {
+					OpenBrowser(searchUrl + Uri.EscapeUriString(text));
+				}
+			});
+		}
 	}
 }
diff --git a/Browser/Adapters/CefBrowserComponent.cs b/Browser/Adapters/CefBrowserComponent.cs
new file mode 100644
index 00000000..69d7edb9
--- /dev/null
+++ b/Browser/Adapters/CefBrowserComponent.cs
@@ -0,0 +1,108 @@
+using System;
+using CefSharp;
+using CefSharp.WinForms;
+using TweetDuck.Browser.Handling;
+using TweetDuck.Utils;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Events;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Utils.Static;
+using IContextMenuHandler = TweetLib.Browser.Interfaces.IContextMenuHandler;
+using IResourceRequestHandler = TweetLib.Browser.Interfaces.IResourceRequestHandler;
+
+namespace TweetDuck.Browser.Adapters {
+	internal abstract class CefBrowserComponent : IBrowserComponent {
+		public bool Ready { get; private set; }
+
+		public string Url => browser.Address;
+
+		public IFileDownloader FileDownloader => TwitterFileDownloader.Instance;
+
+		public event EventHandler<BrowserLoadedEventArgs> BrowserLoaded;
+		public event EventHandler<PageLoadEventArgs> PageLoadStart;
+		public event EventHandler<PageLoadEventArgs> PageLoadEnd;
+
+		private readonly ChromiumWebBrowser browser;
+
+		protected CefBrowserComponent(ChromiumWebBrowser browser) {
+			this.browser = browser;
+			this.browser.JsDialogHandler = new JavaScriptDialogHandler();
+			this.browser.LifeSpanHandler = new CustomLifeSpanHandler();
+			this.browser.LoadingStateChanged += OnLoadingStateChanged;
+			this.browser.LoadError += OnLoadError;
+			this.browser.FrameLoadStart += OnFrameLoadStart;
+			this.browser.FrameLoadEnd += OnFrameLoadEnd;
+			this.browser.SetupZoomEvents();
+		}
+
+		void IBrowserComponent.Setup(BrowserSetup setup) {
+			browser.MenuHandler = SetupContextMenu(setup.ContextMenuHandler);
+			browser.ResourceRequestHandlerFactory = SetupResourceHandlerFactory(setup.ResourceRequestHandler);
+		}
+
+		protected abstract ContextMenuBase SetupContextMenu(IContextMenuHandler handler);
+
+		protected abstract CefResourceHandlerFactory SetupResourceHandlerFactory(IResourceRequestHandler handler);
+
+		private void OnLoadingStateChanged(object sender, LoadingStateChangedEventArgs e) {
+			if (!e.IsLoading) {
+				Ready = true;
+				browser.LoadingStateChanged -= OnLoadingStateChanged;
+				BrowserLoaded?.Invoke(this, new BrowserLoadedEventArgsImpl(browser));
+				BrowserLoaded = null;
+			}
+		}
+
+		private sealed class BrowserLoadedEventArgsImpl : BrowserLoadedEventArgs {
+			private readonly IWebBrowser browser;
+
+			public BrowserLoadedEventArgsImpl(IWebBrowser browser) {
+				this.browser = browser;
+			}
+
+			public override void AddDictionaryWords(params string[] words) {
+				foreach (string word in words) {
+					browser.AddWordToDictionary(word);
+				}
+			}
+		}
+
+		private void OnLoadError(object sender, LoadErrorEventArgs e) {
+			if (e.ErrorCode == CefErrorCode.Aborted) {
+				return;
+			}
+
+			if (!e.FailedUrl.StartsWithOrdinal("td://resources/error/")) {
+				string errorName = Enum.GetName(typeof(CefErrorCode), e.ErrorCode);
+				string errorTitle = StringUtils.ConvertPascalCaseToScreamingSnakeCase(errorName ?? string.Empty);
+				browser.Load("td://resources/error/error.html#" + Uri.EscapeDataString(errorTitle));
+			}
+		}
+
+		private void OnFrameLoadStart(object sender, FrameLoadStartEventArgs e) {
+			if (e.Frame.IsMain) {
+				PageLoadStart?.Invoke(this, new PageLoadEventArgs(e.Url));
+			}
+		}
+
+		private void OnFrameLoadEnd(object sender, FrameLoadEndEventArgs e) {
+			if (e.Frame.IsMain) {
+				PageLoadEnd?.Invoke(this, new PageLoadEventArgs(e.Url));
+			}
+		}
+
+		public void AttachBridgeObject(string name, object bridge) {
+			browser.JavascriptObjectRepository.Settings.LegacyBindingEnabled = true;
+			browser.JavascriptObjectRepository.Register(name, bridge, isAsync: true, BindingOptions.DefaultBinder);
+		}
+
+		public void RunFunction(string name, params object[] args) {
+			browser.BrowserCore.ExecuteScriptAsync(name, args);
+		}
+
+		public void RunScript(string identifier, string script) {
+			using IFrame frame = browser.GetMainFrame();
+			frame.ExecuteJavaScriptAsync(script, identifier, 1);
+		}
+	}
+}
diff --git a/Browser/Adapters/CefContextMenuActionRegistry.cs b/Browser/Adapters/CefContextMenuActionRegistry.cs
new file mode 100644
index 00000000..b36866bc
--- /dev/null
+++ b/Browser/Adapters/CefContextMenuActionRegistry.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Collections.Generic;
+using CefSharp;
+
+namespace TweetDuck.Browser.Adapters {
+	internal sealed class CefContextMenuActionRegistry {
+		private readonly Dictionary<CefMenuCommand, Action> actions = new Dictionary<CefMenuCommand, Action>();
+
+		public CefMenuCommand AddAction(Action action) {
+			CefMenuCommand id = CefMenuCommand.UserFirst + 500 + actions.Count;
+			actions[id] = action;
+			return id;
+		}
+
+		public bool Execute(CefMenuCommand id) {
+			if (actions.TryGetValue(id, out var action)) {
+				action();
+				return true;
+			}
+
+			return false;
+		}
+
+		public void Clear() {
+			actions.Clear();
+		}
+	}
+}
diff --git a/Browser/Adapters/CefContextMenuModel.cs b/Browser/Adapters/CefContextMenuModel.cs
new file mode 100644
index 00000000..e1bca936
--- /dev/null
+++ b/Browser/Adapters/CefContextMenuModel.cs
@@ -0,0 +1,80 @@
+using System;
+using CefSharp;
+using TweetLib.Browser.Contexts;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.TweetDeck;
+using TweetLib.Core.Features.Twitter;
+
+namespace TweetDuck.Browser.Adapters {
+	internal sealed class CefContextMenuModel : IContextMenuBuilder {
+		private readonly IMenuModel model;
+		private readonly CefContextMenuActionRegistry actionRegistry;
+
+		public CefContextMenuModel(IMenuModel model, CefContextMenuActionRegistry actionRegistry) {
+			this.model = model;
+			this.actionRegistry = actionRegistry;
+		}
+
+		public void AddAction(string name, Action action) {
+			var id = actionRegistry.AddAction(action);
+			model.AddItem(id, name);
+		}
+
+		public void AddActionWithCheck(string name, bool isChecked, Action action) {
+			var id = actionRegistry.AddAction(action);
+			model.AddCheckItem(id, name);
+			model.SetChecked(id, isChecked);
+		}
+
+		public void AddSeparator() {
+			if (model.Count > 0 && model.GetTypeAt(model.Count - 1) != MenuItemType.Separator) { // do not add separators if there is nothing to separate
+				model.AddSeparator();
+			}
+		}
+
+		public static Context CreateContext(IContextMenuParams parameters, TweetDeckExtraContext extraContext, ImageQuality imageQuality) {
+			var context = new Context();
+			var flags = parameters.TypeFlags;
+
+			var tweet = extraContext?.Tweet;
+			if (tweet != null && !flags.HasFlag(ContextMenuType.Editable)) {
+				context.Tweet = tweet;
+			}
+
+			context.Link = GetLink(parameters, extraContext);
+			context.Media = GetMedia(parameters, extraContext, imageQuality);
+
+			if (flags.HasFlag(ContextMenuType.Selection)) {
+				context.Selection = new Selection(parameters.SelectionText, flags.HasFlag(ContextMenuType.Editable));
+			}
+
+			return context;
+		}
+
+		private static Link? GetLink(IContextMenuParams parameters, TweetDeckExtraContext extraContext) {
+			var link = extraContext?.Link;
+			if (link != null) {
+				return link;
+			}
+
+			if (parameters.TypeFlags.HasFlag(ContextMenuType.Link) && extraContext?.Media == null) {
+				return new Link(parameters.LinkUrl, parameters.UnfilteredLinkUrl);
+			}
+
+			return null;
+		}
+
+		private static Media? GetMedia(IContextMenuParams parameters, TweetDeckExtraContext extraContext, ImageQuality imageQuality) {
+			var media = extraContext?.Media;
+			if (media != null) {
+				return media;
+			}
+
+			if (parameters.TypeFlags.HasFlag(ContextMenuType.Media) && parameters.HasImageContents) {
+				return new Media(Media.Type.Image, TwitterUrls.GetMediaLink(parameters.SourceUrl, imageQuality));
+			}
+
+			return null;
+		}
+	}
+}
diff --git a/Browser/Adapters/CefResourceHandlerFactory.cs b/Browser/Adapters/CefResourceHandlerFactory.cs
new file mode 100644
index 00000000..ad41974e
--- /dev/null
+++ b/Browser/Adapters/CefResourceHandlerFactory.cs
@@ -0,0 +1,23 @@
+using System.Diagnostics.CodeAnalysis;
+using CefSharp;
+using IResourceRequestHandler = TweetLib.Browser.Interfaces.IResourceRequestHandler;
+
+namespace TweetDuck.Browser.Adapters {
+	sealed class CefResourceHandlerFactory : IResourceRequestHandlerFactory {
+		bool IResourceRequestHandlerFactory.HasHandlers => registry != null;
+
+		private readonly CefResourceRequestHandler resourceRequestHandler;
+		private readonly CefResourceHandlerRegistry registry;
+
+		public CefResourceHandlerFactory(IResourceRequestHandler resourceRequestHandler, CefResourceHandlerRegistry registry) {
+			this.resourceRequestHandler = new CefResourceRequestHandler(registry, resourceRequestHandler);
+			this.registry = registry;
+		}
+
+		[SuppressMessage("ReSharper", "RedundantAssignment")]
+		CefSharp.IResourceRequestHandler IResourceRequestHandlerFactory.GetResourceRequestHandler(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, bool isNavigation, bool isDownload, string requestInitiator, ref bool disableDefaultHandling) {
+			disableDefaultHandling = registry != null && registry.HasHandler(request.Url);
+			return resourceRequestHandler;
+		}
+	}
+}
diff --git a/Browser/Adapters/CefResourceHandlerRegistry.cs b/Browser/Adapters/CefResourceHandlerRegistry.cs
new file mode 100644
index 00000000..6dc65440
--- /dev/null
+++ b/Browser/Adapters/CefResourceHandlerRegistry.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Concurrent;
+using System.Text;
+using CefSharp;
+
+namespace TweetDuck.Browser.Adapters {
+	internal sealed class CefResourceHandlerRegistry {
+		private readonly ConcurrentDictionary<string, Func<IResourceHandler>> resourceHandlers = new ConcurrentDictionary<string, Func<IResourceHandler>>(StringComparer.OrdinalIgnoreCase);
+
+		public bool HasHandler(string url) {
+			return resourceHandlers.ContainsKey(url);
+		}
+
+		public IResourceHandler GetHandler(string url) {
+			return resourceHandlers.TryGetValue(url, out var handler) ? handler() : null;
+		}
+
+		private void Register(string url, Func<IResourceHandler> factory) {
+			if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) {
+				throw new ArgumentException("Resource handler URL must be absolute!");
+			}
+
+			resourceHandlers.AddOrUpdate(uri.AbsoluteUri, factory, (key, prev) => factory);
+		}
+
+		public void RegisterStatic(string url, byte[] staticData, string mimeType = ResourceHandler.DefaultMimeType) {
+			Register(url, () => ResourceHandler.FromByteArray(staticData, mimeType));
+		}
+
+		public void RegisterStatic(string url, string staticData, string mimeType = ResourceHandler.DefaultMimeType) {
+			Register(url, () => ResourceHandler.FromString(staticData, Encoding.UTF8, mimeType: mimeType));
+		}
+
+		public void RegisterDynamic(string url, IResourceHandler handler) {
+			Register(url, () => handler);
+		}
+
+		public void Unregister(string url) {
+			resourceHandlers.TryRemove(url, out _);
+		}
+	}
+}
diff --git a/Browser/Adapters/CefResourceProvider.cs b/Browser/Adapters/CefResourceProvider.cs
new file mode 100644
index 00000000..4fc9ff5c
--- /dev/null
+++ b/Browser/Adapters/CefResourceProvider.cs
@@ -0,0 +1,31 @@
+using System.IO;
+using System.Net;
+using System.Text;
+using CefSharp;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetDuck.Browser.Adapters {
+	internal sealed class ResourceProvider : IResourceProvider<IResourceHandler> {
+		public IResourceHandler Status(HttpStatusCode code, string message) {
+			var handler = CreateHandler(Encoding.UTF8.GetBytes(message));
+			handler.StatusCode = (int) code;
+			return handler;
+		}
+
+		public IResourceHandler File(byte[] contents, string extension) {
+			if (contents.Length == 0) {
+				return Status(HttpStatusCode.NoContent, "File is empty."); // FromByteArray crashes CEF internals with no contents
+			}
+
+			var handler = CreateHandler(contents);
+			handler.MimeType = Cef.GetMimeType(extension);
+			return handler;
+		}
+
+		private static ResourceHandler CreateHandler(byte[] bytes) {
+			var handler = ResourceHandler.FromStream(new MemoryStream(bytes), autoDisposeStream: true);
+			handler.Headers.Set("Access-Control-Allow-Origin", "*");
+			return handler;
+		}
+	}
+}
diff --git a/Browser/Adapters/CefResourceRequestHandler.cs b/Browser/Adapters/CefResourceRequestHandler.cs
new file mode 100644
index 00000000..71915076
--- /dev/null
+++ b/Browser/Adapters/CefResourceRequestHandler.cs
@@ -0,0 +1,77 @@
+using System.Collections.Generic;
+using CefSharp;
+using CefSharp.Handler;
+using TweetDuck.Browser.Handling;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Browser.Request;
+using IResourceRequestHandler = TweetLib.Browser.Interfaces.IResourceRequestHandler;
+using ResourceType = TweetLib.Browser.Request.ResourceType;
+
+namespace TweetDuck.Browser.Adapters {
+	sealed class CefResourceRequestHandler : ResourceRequestHandler {
+		private readonly CefResourceHandlerRegistry resourceHandlerRegistry;
+		private readonly IResourceRequestHandler resourceRequestHandler;
+		private readonly Dictionary<ulong, IResponseProcessor> responseProcessors = new Dictionary<ulong, IResponseProcessor>();
+
+		public CefResourceRequestHandler(CefResourceHandlerRegistry resourceHandlerRegistry, IResourceRequestHandler resourceRequestHandler) {
+			this.resourceHandlerRegistry = resourceHandlerRegistry;
+			this.resourceRequestHandler = resourceRequestHandler;
+		}
+
+		protected override CefReturnValue OnBeforeResourceLoad(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) {
+			if (request.ResourceType == CefSharp.ResourceType.CspReport) {
+				callback.Dispose();
+				return CefReturnValue.Cancel;
+			}
+
+			if (resourceRequestHandler != null) {
+				var result = resourceRequestHandler.Handle(request.Url, TranslateResourceType(request.ResourceType));
+
+				switch (result) {
+					case RequestHandleResult.Redirect redirect:
+						request.Url = redirect.Url;
+						break;
+
+					case RequestHandleResult.Process process:
+						request.SetHeaderByName("Accept-Encoding", "identity", overwrite: true);
+						responseProcessors[request.Identifier] = process.Processor;
+						break;
+
+					case RequestHandleResult.Cancel _:
+						callback.Dispose();
+						return CefReturnValue.Cancel;
+				}
+			}
+
+			return base.OnBeforeResourceLoad(chromiumWebBrowser, browser, frame, request, callback);
+		}
+
+		protected override IResourceHandler GetResourceHandler(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request) {
+			return resourceHandlerRegistry?.GetHandler(request.Url);
+		}
+
+		protected override IResponseFilter GetResourceResponseFilter(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) {
+			if (responseProcessors.TryGetValue(request.Identifier, out var processor) && int.TryParse(response.Headers["Content-Length"], out int totalBytes)) {
+				return new ResponseFilter(processor, totalBytes);
+			}
+
+			return base.GetResourceResponseFilter(browserControl, browser, frame, request, response);
+		}
+
+		protected override void OnResourceLoadComplete(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, IResponse response, UrlRequestStatus status, long receivedContentLength) {
+			responseProcessors.Remove(request.Identifier);
+			base.OnResourceLoadComplete(chromiumWebBrowser, browser, frame, request, response, status, receivedContentLength);
+		}
+
+		private static ResourceType TranslateResourceType(CefSharp.ResourceType resourceType) {
+			return resourceType switch {
+				CefSharp.ResourceType.MainFrame  => ResourceType.MainFrame,
+				CefSharp.ResourceType.Script     => ResourceType.Script,
+				CefSharp.ResourceType.Stylesheet => ResourceType.Stylesheet,
+				CefSharp.ResourceType.Xhr        => ResourceType.Xhr,
+				CefSharp.ResourceType.Image      => ResourceType.Image,
+				_                                => ResourceType.Unknown
+			};
+		}
+	}
+}
diff --git a/Browser/Adapters/CefSchemeHandlerFactory.cs b/Browser/Adapters/CefSchemeHandlerFactory.cs
new file mode 100644
index 00000000..9c6a7f56
--- /dev/null
+++ b/Browser/Adapters/CefSchemeHandlerFactory.cs
@@ -0,0 +1,29 @@
+using System;
+using CefSharp;
+using CefSharp.WinForms;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetDuck.Browser.Adapters {
+	internal sealed class CefSchemeHandlerFactory : ISchemeHandlerFactory {
+		public static void Register(CefSettings settings, ICustomSchemeHandler<IResourceHandler> handler) {
+			settings.RegisterScheme(new CefCustomScheme {
+				SchemeName = handler.Protocol,
+				IsStandard = false,
+				IsSecure = true,
+				IsCorsEnabled = true,
+				IsCSPBypassing = true,
+				SchemeHandlerFactory = new CefSchemeHandlerFactory(handler)
+			});
+		}
+
+		private readonly ICustomSchemeHandler<IResourceHandler> handler;
+
+		private CefSchemeHandlerFactory(ICustomSchemeHandler<IResourceHandler> handler) {
+			this.handler = handler;
+		}
+
+		public IResourceHandler Create(IBrowser browser, IFrame frame, string schemeName, IRequest request) {
+			return Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ? handler.Resolve(uri) : null;
+		}
+	}
+}
diff --git a/Browser/Adapters/CefScriptExecutor.cs b/Browser/Adapters/CefScriptExecutor.cs
deleted file mode 100644
index 8fcaeb9f..00000000
--- a/Browser/Adapters/CefScriptExecutor.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System.Collections.Generic;
-using System.IO;
-using CefSharp;
-using TweetDuck.Resources;
-using TweetDuck.Utils;
-using TweetLib.Core.Browser;
-
-namespace TweetDuck.Browser.Adapters {
-	sealed class CefScriptExecutor : IScriptExecutor {
-		private readonly IWebBrowser browser;
-
-		public CefScriptExecutor(IWebBrowser browser) {
-			this.browser = browser;
-		}
-
-		public void RunFunction(string name, params object[] args) {
-			browser.ExecuteJsAsync(name, args);
-		}
-
-		public void RunScript(string identifier, string script) {
-			using IFrame frame = browser.GetMainFrame();
-			RunScript(frame, script, identifier);
-		}
-
-		public void RunBootstrap(string moduleNamespace) {
-			using IFrame frame = browser.GetMainFrame();
-			RunBootstrap(frame, moduleNamespace);
-		}
-
-		// Helpers
-
-		public static void RunScript(IFrame frame, string script, string identifier) {
-			if (script != null) {
-				frame.ExecuteJavaScriptAsync(script, identifier, 1);
-			}
-		}
-
-		public static void RunBootstrap(IFrame frame, string moduleNamespace) {
-			string script = GetBootstrapScript(moduleNamespace, includeStylesheets: true);
-
-			if (script != null) {
-				RunScript(frame, script, "bootstrap");
-			}
-		}
-
-		public static string GetBootstrapScript(string moduleNamespace, bool includeStylesheets) {
-			string script = ResourceUtils.ReadFileOrNull("bootstrap.js");
-
-			if (script == null) {
-				return null;
-			}
-
-			string path = Path.Combine(Program.ResourcesPath, moduleNamespace);
-			var files = new DirectoryInfo(path).GetFiles();
-
-			var moduleNames = new List<string>();
-			var stylesheetNames = new List<string>();
-
-			foreach (var file in files) {
-				var ext = Path.GetExtension(file.Name);
-
-				var targetList = ext switch {
-					".js"  => moduleNames,
-					".css" => includeStylesheets ? stylesheetNames : null,
-					_      => null
-				};
-
-				targetList?.Add(Path.GetFileNameWithoutExtension(file.Name));
-			}
-
-			script = script.Replace("{{namespace}}", moduleNamespace);
-			script = script.Replace("{{modules}}", string.Join("|", moduleNames));
-			script = script.Replace("{{stylesheets}}", string.Join("|", stylesheetNames));
-
-			return script;
-		}
-	}
-}
diff --git a/Browser/Bridge/TweetDeckBridge.cs b/Browser/Bridge/TweetDeckBridge.cs
deleted file mode 100644
index a2646e79..00000000
--- a/Browser/Bridge/TweetDeckBridge.cs
+++ /dev/null
@@ -1,165 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-using System.Threading.Tasks;
-using System.Windows.Forms;
-using CefSharp;
-using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Notification;
-using TweetDuck.Controls;
-using TweetDuck.Dialogs;
-using TweetDuck.Management;
-using TweetDuck.Utils;
-using TweetLib.Core.Features.Notifications;
-using TweetLib.Utils.Static;
-
-namespace TweetDuck.Browser.Bridge {
-	[SuppressMessage("ReSharper", "UnusedMember.Global")]
-	class TweetDeckBridge {
-		public static void ResetStaticProperties() {
-			FormNotificationBase.FontSize = null;
-			FormNotificationBase.HeadLayout = null;
-		}
-
-		private readonly FormBrowser form;
-		private readonly FormNotificationMain notification;
-
-		private TweetDeckBridge(FormBrowser form, FormNotificationMain notification) {
-			this.form = form;
-			this.notification = notification;
-		}
-
-		// Browser only
-
-		public sealed class Browser : TweetDeckBridge {
-			public Browser(FormBrowser form, FormNotificationMain notification) : base(form, notification) {}
-
-			public void OnModulesLoaded(string moduleNamespace) {
-				form.InvokeAsyncSafe(() => form.OnModulesLoaded(moduleNamespace));
-			}
-
-			public void OpenContextMenu() {
-				form.InvokeAsyncSafe(form.OpenContextMenu);
-			}
-
-			public void OpenProfileImport() {
-				form.InvokeAsyncSafe(form.OpenProfileImport);
-			}
-
-			public void OnIntroductionClosed(bool showGuide) {
-				form.InvokeAsyncSafe(() => form.OnIntroductionClosed(showGuide));
-			}
-
-			public void LoadNotificationLayout(string fontSize, string headLayout) {
-				form.InvokeAsyncSafe(() => {
-					FormNotificationBase.FontSize = fontSize;
-					FormNotificationBase.HeadLayout = headLayout;
-				});
-			}
-
-			public void SetRightClickedLink(string type, string url) {
-				ContextMenuBase.CurrentInfo.SetLink(type, url);
-			}
-
-			public void SetRightClickedChirp(string columnId, string chirpId, string tweetUrl, string quoteUrl, string chirpAuthors, string chirpImages) {
-				ContextMenuBase.CurrentInfo.SetChirp(columnId, chirpId, tweetUrl, quoteUrl, chirpAuthors, chirpImages);
-			}
-
-			public void DisplayTooltip(string text) {
-				form.InvokeAsyncSafe(() => form.DisplayTooltip(text));
-			}
-		}
-
-		// Notification only
-
-		public sealed class Notification : TweetDeckBridge {
-			public Notification(FormBrowser form, FormNotificationMain notification) : base(form, notification) {}
-
-			public void DisplayTooltip(string text) {
-				notification.InvokeAsyncSafe(() => notification.DisplayTooltip(text));
-			}
-
-			public void LoadNextNotification() {
-				notification.InvokeAsyncSafe(notification.FinishCurrentNotification);
-			}
-
-			public void ShowTweetDetail() {
-				notification.InvokeAsyncSafe(notification.ShowTweetDetail);
-			}
-		}
-
-		// Global
-
-		public void OnTweetPopup(string columnId, string chirpId, string columnName, string tweetHtml, int tweetCharacters, string tweetUrl, string quoteUrl) {
-			notification.InvokeAsyncSafe(() => {
-				form.OnTweetNotification();
-				notification.ShowNotification(new DesktopNotification(columnId, chirpId, columnName, tweetHtml, tweetCharacters, tweetUrl, quoteUrl));
-			});
-		}
-
-		public void OnTweetSound() {
-			form.InvokeAsyncSafe(() => {
-				form.OnTweetNotification();
-				form.OnTweetSound();
-			});
-		}
-
-		public void ScreenshotTweet(string html, int width) {
-			form.InvokeAsyncSafe(() => form.OnTweetScreenshotReady(html, width));
-		}
-
-		public void PlayVideo(string videoUrl, string tweetUrl, string username, IJavascriptCallback callShowOverlay) {
-			form.InvokeAsyncSafe(() => form.PlayVideo(videoUrl, tweetUrl, username, callShowOverlay));
-		}
-
-		public void StopVideo() {
-			form.InvokeAsyncSafe(form.StopVideo);
-		}
-
-		public void FixClipboard() {
-			form.InvokeAsyncSafe(ClipboardManager.StripHtmlStyles);
-		}
-
-		public void OpenBrowser(string url) {
-			form.InvokeAsyncSafe(() => BrowserUtils.OpenExternalBrowser(url));
-		}
-
-		public void MakeGetRequest(string url, IJavascriptCallback onSuccess, IJavascriptCallback onError) {
-			Task.Run(async () => {
-				var client = WebUtils.NewClient(BrowserUtils.UserAgentVanilla);
-
-				try {
-					var result = await client.DownloadStringTaskAsync(url);
-					await onSuccess.ExecuteAsync(result);
-				} catch (Exception e) {
-					await onError.ExecuteAsync(e.Message);
-				} finally {
-					onSuccess.Dispose();
-					onError.Dispose();
-					client.Dispose();
-				}
-			});
-		}
-
-		public int GetIdleSeconds() {
-			return NativeMethods.GetIdleSeconds();
-		}
-
-		public void Alert(string type, string contents) {
-			MessageBoxIcon icon = type switch {
-				"error"   => MessageBoxIcon.Error,
-				"warning" => MessageBoxIcon.Warning,
-				"info"    => MessageBoxIcon.Information,
-				_         => MessageBoxIcon.None
-			};
-
-			FormMessage.Show("TweetDuck Browser Message", contents, icon, FormMessage.OK);
-		}
-
-		public void CrashDebug(string message) {
-			#if DEBUG
-			System.Diagnostics.Debug.WriteLine(message);
-			System.Diagnostics.Debugger.Break();
-			#endif
-		}
-	}
-}
diff --git a/Browser/Bridge/UpdateBridge.cs b/Browser/Bridge/UpdateBridge.cs
deleted file mode 100644
index e67ed6c3..00000000
--- a/Browser/Bridge/UpdateBridge.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-using System.Windows.Forms;
-using TweetDuck.Controls;
-using TweetLib.Core.Systems.Updates;
-
-namespace TweetDuck.Browser.Bridge {
-	[SuppressMessage("ReSharper", "UnusedMember.Global")]
-	class UpdateBridge {
-		private readonly UpdateHandler updates;
-		private readonly Control sync;
-
-		private UpdateInfo nextUpdate = null;
-
-		public event EventHandler<UpdateInfo> UpdateAccepted;
-		public event EventHandler<UpdateInfo> UpdateDismissed;
-
-		public UpdateBridge(UpdateHandler updates, Control sync) {
-			this.sync = sync;
-
-			this.updates = updates;
-			this.updates.CheckFinished += updates_CheckFinished;
-		}
-
-		internal void Cleanup() {
-			updates.CheckFinished -= updates_CheckFinished;
-			nextUpdate?.DeleteInstaller();
-		}
-
-		private void updates_CheckFinished(object sender, UpdateCheckEventArgs e) {
-			UpdateInfo foundUpdate = e.Result.HasValue ? e.Result.Value : null;
-
-			if (nextUpdate != null && !nextUpdate.Equals(foundUpdate)) {
-				nextUpdate.DeleteInstaller();
-			}
-
-			nextUpdate = foundUpdate;
-		}
-
-		private void HandleInteractionEvent(EventHandler<UpdateInfo> eventHandler) {
-			UpdateInfo tmpInfo = nextUpdate;
-
-			if (tmpInfo != null) {
-				sync.InvokeAsyncSafe(() => eventHandler?.Invoke(this, tmpInfo));
-			}
-		}
-
-		// Bridge methods
-
-		public void TriggerUpdateCheck() {
-			updates.Check(false);
-		}
-
-		public void OnUpdateAccepted() {
-			HandleInteractionEvent(UpdateAccepted);
-		}
-
-		public void OnUpdateDismissed() {
-			HandleInteractionEvent(UpdateDismissed);
-
-			nextUpdate?.DeleteInstaller();
-			nextUpdate = null;
-		}
-	}
-}
diff --git a/Browser/Data/ContextInfo.cs b/Browser/Data/ContextInfo.cs
deleted file mode 100644
index 431b10ed..00000000
--- a/Browser/Data/ContextInfo.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-using System;
-using CefSharp;
-using TweetLib.Utils.Static;
-
-namespace TweetDuck.Browser.Data {
-	sealed class ContextInfo {
-		private LinkInfo link;
-		private ChirpInfo? chirp;
-
-		public ContextInfo() {
-			Reset();
-		}
-
-		public void SetLink(string type, string url) {
-			link = string.IsNullOrEmpty(url) ? null : new LinkInfo(type, url);
-		}
-
-		public void SetChirp(string columnId, string chirpId, string tweetUrl, string quoteUrl, string chirpAuthors, string chirpImages) {
-			chirp = string.IsNullOrEmpty(tweetUrl) ? (ChirpInfo?) null : new ChirpInfo(columnId, chirpId, tweetUrl, quoteUrl, chirpAuthors, chirpImages);
-		}
-
-		public ContextData Reset() {
-			link = null;
-			chirp = null;
-			return ContextData.Empty;
-		}
-
-		public ContextData Create(IContextMenuParams parameters) {
-			ContextData.Builder builder = new ContextData.Builder();
-			builder.AddContext(parameters);
-
-			if (link != null) {
-				builder.AddOverride(link.Type, link.Url);
-			}
-
-			if (chirp.HasValue) {
-				builder.AddChirp(chirp.Value);
-			}
-
-			return builder.Build();
-		}
-
-		// Data structures
-
-		private sealed class LinkInfo {
-			public string Type { get; }
-			public string Url { get; }
-
-			public LinkInfo(string type, string url) {
-				this.Type = type;
-				this.Url = url;
-			}
-		}
-
-		public readonly struct ChirpInfo {
-			public string ColumnId { get; }
-			public string ChirpId { get; }
-
-			public string TweetUrl { get; }
-			public string QuoteUrl { get; }
-
-			public string[] Authors => chirpAuthors?.Split(';') ?? StringUtils.EmptyArray;
-			public string[] Images => chirpImages?.Split(';') ?? StringUtils.EmptyArray;
-
-			private readonly string chirpAuthors;
-			private readonly string chirpImages;
-
-			public ChirpInfo(string columnId, string chirpId, string tweetUrl, string quoteUrl, string chirpAuthors, string chirpImages) {
-				this.ColumnId = columnId;
-				this.ChirpId = chirpId;
-				this.TweetUrl = tweetUrl;
-				this.QuoteUrl = quoteUrl;
-				this.chirpAuthors = chirpAuthors;
-				this.chirpImages = chirpImages;
-			}
-		}
-
-		// Constructed context
-
-		[Flags]
-		public enum ContextType {
-			Unknown = 0,
-			Link    = 0b0001,
-			Image   = 0b0010,
-			Video   = 0b0100,
-			Chirp   = 0b1000
-		}
-
-		public sealed class ContextData {
-			public static readonly ContextData Empty = new Builder().Build();
-
-			public ContextType Types { get; }
-
-			public string LinkUrl { get; }
-			public string UnsafeLinkUrl { get; }
-			public string MediaUrl { get; }
-
-			public ChirpInfo Chirp { get; }
-
-			private ContextData(ContextType types, string linkUrl, string unsafeLinkUrl, string mediaUrl, ChirpInfo chirp) {
-				Types = types;
-				LinkUrl = linkUrl;
-				UnsafeLinkUrl = unsafeLinkUrl;
-				MediaUrl = mediaUrl;
-				Chirp = chirp;
-			}
-
-			public sealed class Builder {
-				private ContextType types = ContextType.Unknown;
-
-				private string linkUrl = string.Empty;
-				private string unsafeLinkUrl = string.Empty;
-				private string mediaUrl = string.Empty;
-
-				private ChirpInfo chirp = default;
-
-				public void AddContext(IContextMenuParams parameters) {
-					ContextMenuType flags = parameters.TypeFlags;
-
-					if (flags.HasFlag(ContextMenuType.Media) && parameters.HasImageContents) {
-						types |= ContextType.Image;
-						types &= ~ContextType.Video;
-						mediaUrl = parameters.SourceUrl;
-					}
-
-					if (flags.HasFlag(ContextMenuType.Link)) {
-						types |= ContextType.Link;
-						linkUrl = parameters.LinkUrl;
-						unsafeLinkUrl = parameters.UnfilteredLinkUrl;
-					}
-				}
-
-				public void AddOverride(string type, string url) {
-					switch (type) {
-						case "link":
-							types |= ContextType.Link;
-							linkUrl = url;
-							unsafeLinkUrl = url;
-							break;
-
-						case "image":
-							types |= ContextType.Image;
-							types &= ~(ContextType.Video | ContextType.Link);
-							mediaUrl = url;
-							break;
-
-						case "video":
-							types |= ContextType.Video;
-							types &= ~(ContextType.Image | ContextType.Link);
-							mediaUrl = url;
-							break;
-					}
-				}
-
-				public void AddChirp(ChirpInfo chirp) {
-					this.types |= ContextType.Chirp;
-					this.chirp = chirp;
-				}
-
-				public ContextData Build() {
-					return new ContextData(types, linkUrl, unsafeLinkUrl, mediaUrl, chirp);
-				}
-			}
-		}
-	}
-}
diff --git a/Browser/Data/ResourceHandlers.cs b/Browser/Data/ResourceHandlers.cs
deleted file mode 100644
index 8824ff3c..00000000
--- a/Browser/Data/ResourceHandlers.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using CefSharp;
-
-namespace TweetDuck.Browser.Data {
-	sealed class ResourceHandlers {
-		private readonly ConcurrentDictionary<string, Func<IResourceHandler>> handlers = new ConcurrentDictionary<string, Func<IResourceHandler>>(StringComparer.OrdinalIgnoreCase);
-
-		public bool HasHandler(IRequest request) {
-			return handlers.ContainsKey(request.Url);
-		}
-
-		public IResourceHandler GetHandler(IRequest request) {
-			return handlers.TryGetValue(request.Url, out var factory) ? factory() : null;
-		}
-
-		public bool Register(string url, Func<IResourceHandler> factory) {
-			if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) {
-				handlers.AddOrUpdate(uri.AbsoluteUri, factory, (key, prev) => factory);
-				return true;
-			}
-
-			return false;
-		}
-
-		public bool Register(ResourceLink link) {
-			return Register(link.Url, link.Factory);
-		}
-
-		public bool Unregister(string url) {
-			return handlers.TryRemove(url, out _);
-		}
-
-		public bool Unregister(ResourceLink link) {
-			return Unregister(link.Url);
-		}
-
-		public static Func<IResourceHandler> ForString(string str) {
-			return () => ResourceHandler.FromString(str);
-		}
-
-		public static Func<IResourceHandler> ForString(string str, string mimeType) {
-			return () => ResourceHandler.FromString(str, mimeType: mimeType);
-		}
-
-		public static Func<IResourceHandler> ForBytes(byte[] bytes, string mimeType) {
-			return () => ResourceHandler.FromByteArray(bytes, mimeType);
-		}
-	}
-}
diff --git a/Browser/Data/ResourceLink.cs b/Browser/Data/ResourceLink.cs
deleted file mode 100644
index b759e8df..00000000
--- a/Browser/Data/ResourceLink.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System;
-using CefSharp;
-
-namespace TweetDuck.Browser.Data {
-	sealed class ResourceLink {
-		public string Url { get; }
-		public Func<IResourceHandler> Factory { get; }
-
-		public ResourceLink(string url, Func<IResourceHandler> factory) {
-			this.Url = url;
-			this.Factory = factory;
-		}
-	}
-}
diff --git a/Browser/FormBrowser.Designer.cs b/Browser/FormBrowser.Designer.cs
index 95cc98c9..c98f32b9 100644
--- a/Browser/FormBrowser.Designer.cs
+++ b/Browser/FormBrowser.Designer.cs
@@ -27,7 +27,7 @@ private void InitializeComponent() {
             // 
             this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
             this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
-            this.BackColor = TweetDuck.Utils.TwitterUtils.BackgroundColor;
+            this.BackColor = TweetDuck.Browser.TweetDeckBrowser.BackgroundColor;
             this.ClientSize = new System.Drawing.Size(1008, 730);
             this.Icon = Properties.Resources.icon;
             this.Location = TweetDuck.Controls.ControlExtensions.InvisibleLocation;
diff --git a/Browser/FormBrowser.cs b/Browser/FormBrowser.cs
index 584a7b44..d4ff2ef3 100644
--- a/Browser/FormBrowser.cs
+++ b/Browser/FormBrowser.cs
@@ -5,9 +5,7 @@
 using System.Threading.Tasks;
 using System.Windows.Forms;
 using CefSharp;
-using TweetDuck.Browser.Bridge;
 using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Handling.General;
 using TweetDuck.Browser.Notification;
 using TweetDuck.Browser.Notification.Screenshot;
 using TweetDuck.Configuration;
@@ -15,17 +13,18 @@
 using TweetDuck.Dialogs;
 using TweetDuck.Dialogs.Settings;
 using TweetDuck.Management;
-using TweetDuck.Plugins;
 using TweetDuck.Resources;
 using TweetDuck.Updates;
 using TweetDuck.Utils;
 using TweetLib.Core;
+using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Plugins;
-using TweetLib.Core.Features.Plugins.Events;
+using TweetLib.Core.Features.TweetDeck;
+using TweetLib.Core.Resources;
 using TweetLib.Core.Systems.Updates;
 
 namespace TweetDuck.Browser {
-	sealed partial class FormBrowser : Form {
+	sealed partial class FormBrowser : Form, CustomKeyboardHandler.IBrowserKeyHandler {
 		private static UserConfig Config => Program.Config.User;
 
 		public bool IsWaiting {
@@ -46,18 +45,17 @@ public bool IsWaiting {
 		}
 
 		public UpdateInstaller UpdateInstaller { get; private set; }
-		private bool ignoreUpdateCheckError;
 
 		#pragma warning disable IDE0069 // Disposable fields should be disposed
 		private readonly TweetDeckBrowser browser;
 		private readonly FormNotificationTweet notification;
 		#pragma warning restore IDE0069 // Disposable fields should be disposed
 
-		private readonly ResourceProvider resourceProvider;
+		private readonly CachingResourceProvider<IResourceHandler> resourceProvider;
+		private readonly ITweetDeckInterface tweetDeckInterface;
 		private readonly PluginManager plugins;
-		private readonly UpdateHandler updates;
+		private readonly UpdateChecker updates;
 		private readonly ContextMenu contextMenu;
-		private readonly UpdateBridge updateBridge;
 
 		private bool isLoaded;
 		private FormWindowState prevState;
@@ -65,30 +63,25 @@ public bool IsWaiting {
 		private TweetScreenshotManager notificationScreenshotManager;
 		private VideoPlayer videoPlayer;
 
-		public FormBrowser(ResourceProvider resourceProvider, PluginSchemeFactory pluginScheme) {
+		public FormBrowser(CachingResourceProvider<IResourceHandler> resourceProvider, PluginManager pluginManager, IUpdateCheckClient updateCheckClient) {
 			InitializeComponent();
 
 			Text = Program.BrandName;
 
 			this.resourceProvider = resourceProvider;
 
-			this.plugins = new PluginManager(Program.Config.Plugins, Program.PluginPath, Program.PluginDataPath);
-			this.plugins.Reloaded += plugins_Reloaded;
-			this.plugins.Executed += plugins_Executed;
-			this.plugins.Reload();
-			pluginScheme.Setup(plugins);
+			this.plugins = pluginManager;
 
-			this.notification = new FormNotificationTweet(this, plugins);
+			this.tweetDeckInterface = new TweetDeckInterfaceImpl(this);
+
+			this.notification = new FormNotificationTweet(this, tweetDeckInterface, plugins);
 			this.notification.Show();
 
-			this.updates = new UpdateHandler(new UpdateCheckClient(Program.InstallerPath), TaskScheduler.FromCurrentSynchronizationContext());
-			this.updates.CheckFinished += updates_CheckFinished;
+			this.updates = new UpdateChecker(updateCheckClient, TaskScheduler.FromCurrentSynchronizationContext());
+			this.updates.InteractionManager.UpdateAccepted += updateInteractionManager_UpdateAccepted;
+			this.updates.InteractionManager.UpdateDismissed += updateInteractionManager_UpdateDismissed;
 
-			this.updateBridge = new UpdateBridge(updates, this);
-			this.updateBridge.UpdateAccepted += updateBridge_UpdateAccepted;
-			this.updateBridge.UpdateDismissed += updateBridge_UpdateDismissed;
-
-			this.browser = new TweetDeckBrowser(this, plugins, new TweetDeckBridge.Browser(this, notification), updateBridge);
+			this.browser = new TweetDeckBrowser(this, plugins, tweetDeckInterface, updates);
 			this.contextMenu = ContextMenuBrowser.CreateMenu(this);
 
 			Controls.Add(new MenuStrip { Visible = false }); // fixes Alt freezing the program in Win 10 Anniversary Update
@@ -233,7 +226,8 @@ private void FormBrowser_FormClosing(object sender, FormClosingEventArgs e) {
 
 		private void FormBrowser_FormClosed(object sender, FormClosedEventArgs e) {
 			if (isLoaded && UpdateInstaller == null) {
-				updateBridge.Cleanup();
+				updates.InteractionManager.ClearUpdate();
+				updates.InteractionManager.Dispose();
 			}
 		}
 
@@ -256,94 +250,61 @@ private void trayIcon_ClickClose(object sender, EventArgs e) {
 			ForceClose();
 		}
 
-		private void plugins_Reloaded(object sender, PluginErrorEventArgs e) {
-			if (e.HasErrors) {
-				FormMessage.Error("Error Loading Plugins", "The following plugins will not be available until the issues are resolved:\n\n" + string.Join("\n\n", e.Errors), FormMessage.OK);
-			}
+		private void updateInteractionManager_UpdateAccepted(object sender, UpdateInfo update) {
+			this.InvokeAsyncSafe(() => {
+				FormManager.CloseAllDialogs();
 
-			if (isLoaded) {
-				browser.ReloadToTweetDeck();
-			}
-		}
+				if (!string.IsNullOrEmpty(Config.DismissedUpdate)) {
+					Config.DismissedUpdate = null;
+					Config.Save();
+				}
 
-		private void plugins_Executed(object sender, PluginErrorEventArgs e) {
-			if (e.HasErrors) {
-				this.InvokeAsyncSafe(() => { FormMessage.Error("Error Executing Plugins", "Failed to execute the following plugins:\n\n" + string.Join("\n\n", e.Errors), FormMessage.OK); });
-			}
-		}
+				void OnFinished() {
+					UpdateDownloadStatus status = update.DownloadStatus;
 
-		private void updates_CheckFinished(object sender, UpdateCheckEventArgs e) {
-			e.Result.Handle(update => {
-				string tag = update.VersionTag;
+					if (status == UpdateDownloadStatus.Done) {
+						UpdateInstaller = new UpdateInstaller(update.InstallerPath);
+						ForceClose();
+					}
+					else if (status != UpdateDownloadStatus.Canceled && FormMessage.Error("Update Has Failed", "Could not automatically download the update: " + (update.DownloadError?.Message ?? "unknown error") + "\n\nWould you like to open the website and try downloading the update manually?", FormMessage.Yes, FormMessage.No)) {
+						App.SystemHandler.OpenBrowser(Program.Website);
+						ForceClose();
+					}
+					else {
+						Show();
+					}
+				}
 
-				if (tag != Program.VersionTag && tag != Config.DismissedUpdate) {
-					update.BeginSilentDownload();
-					browser.ShowUpdateNotification(tag, update.ReleaseNotes);
+				if (update.DownloadStatus.IsFinished(true)) {
+					OnFinished();
 				}
 				else {
-					updates.StartTimer();
-				}
-			}, ex => {
-				if (!ignoreUpdateCheckError) {
-					App.ErrorHandler.HandleException("Update Check Error", "An error occurred while checking for updates.", true, ex);
-					updates.StartTimer();
+					FormUpdateDownload downloadForm = new FormUpdateDownload(update);
+
+					downloadForm.VisibleChanged += (sender2, args2) => {
+						downloadForm.MoveToCenter(this);
+						Hide();
+					};
+
+					downloadForm.FormClosed += (sender2, args2) => {
+						if (downloadForm.DialogResult != DialogResult.OK) {
+							update.CancelDownload();
+						}
+
+						downloadForm.Dispose();
+						OnFinished();
+					};
+
+					downloadForm.Show();
 				}
 			});
-
-			ignoreUpdateCheckError = true;
 		}
 
-		private void updateBridge_UpdateAccepted(object sender, UpdateInfo update) {
-			FormManager.CloseAllDialogs();
-
-			if (!string.IsNullOrEmpty(Config.DismissedUpdate)) {
-				Config.DismissedUpdate = null;
+		private void updateInteractionManager_UpdateDismissed(object sender, UpdateInfo update) {
+			this.InvokeAsyncSafe(() => {
+				Config.DismissedUpdate = update.VersionTag;
 				Config.Save();
-			}
-
-			void OnFinished() {
-				UpdateDownloadStatus status = update.DownloadStatus;
-
-				if (status == UpdateDownloadStatus.Done) {
-					UpdateInstaller = new UpdateInstaller(update.InstallerPath);
-					ForceClose();
-				}
-				else if (status != UpdateDownloadStatus.Canceled && FormMessage.Error("Update Has Failed", "Could not automatically download the update: " + (update.DownloadError?.Message ?? "unknown error") + "\n\nWould you like to open the website and try downloading the update manually?", FormMessage.Yes, FormMessage.No)) {
-					BrowserUtils.OpenExternalBrowser(Program.Website);
-					ForceClose();
-				}
-				else {
-					Show();
-				}
-			}
-
-			if (update.DownloadStatus.IsFinished(true)) {
-				OnFinished();
-			}
-			else {
-				FormUpdateDownload downloadForm = new FormUpdateDownload(update);
-
-				downloadForm.VisibleChanged += (sender2, args2) => {
-					downloadForm.MoveToCenter(this);
-					Hide();
-				};
-
-				downloadForm.FormClosed += (sender2, args2) => {
-					if (downloadForm.DialogResult != DialogResult.OK) {
-						update.CancelDownload();
-					}
-
-					downloadForm.Dispose();
-					OnFinished();
-				};
-
-				downloadForm.Show();
-			}
-		}
-
-		private void updateBridge_UpdateDismissed(object sender, UpdateInfo update) {
-			Config.DismissedUpdate = update.VersionTag;
-			Config.Save();
+			});
 		}
 
 		protected override void WndProc(ref Message m) {
@@ -358,11 +319,11 @@ protected override void WndProc(ref Message m) {
 			}
 
 			if (browser.Ready && m.Msg == NativeMethods.WM_PARENTNOTIFY && (m.WParam.ToInt32() & 0xFFFF) == NativeMethods.WM_XBUTTONDOWN) {
-				if (videoPlayer != null && videoPlayer.Running) {
+				if (videoPlayer is { Running: true }) {
 					videoPlayer.Close();
 				}
 				else {
-					browser.OnMouseClickExtra(m.WParam);
+					browser.Functions.OnMouseClickExtra((m.WParam.ToInt32() >> 16) & 0xFFFF);
 				}
 
 				return;
@@ -373,10 +334,6 @@ protected override void WndProc(ref Message m) {
 
 		// bridge methods
 
-		public void OnModulesLoaded(string moduleNamespace) {
-			browser.OnModulesLoaded(moduleNamespace);
-		}
-
 		public void PauseNotification() {
 			notification.PauseNotification();
 		}
@@ -385,10 +342,6 @@ public void ResumeNotification() {
 			notification.ResumeNotification();
 		}
 
-		public void ReinjectCustomCSS(string css) {
-			browser.ReinjectCustomCSS(css);
-		}
-
 		public void ReloadToTweetDeck() {
 			#if DEBUG
 			ResourceHotSwap.Run();
@@ -399,30 +352,9 @@ public void ReloadToTweetDeck() {
 			}
 			#endif
 
-			ignoreUpdateCheckError = false;
 			browser.ReloadToTweetDeck();
 		}
 
-		public void AddSearchColumn(string query) {
-			browser.AddSearchColumn(query);
-		}
-
-		public void TriggerTweetScreenshot(string columnId, string chirpId) {
-			browser.TriggerTweetScreenshot(columnId, chirpId);
-		}
-
-		public void ReloadColumns() {
-			browser.ReloadColumns();
-		}
-
-		public void PlaySoundNotification() {
-			browser.PlaySoundNotification();
-		}
-
-		public void ApplyROT13() {
-			browser.ApplyROT13();
-		}
-
 		public void OpenDevTools() {
 			browser.OpenDevTools();
 		}
@@ -452,7 +384,7 @@ public void OpenSettings(Type startTab) {
 			if (!FormManager.TryBringToFront<FormSettings>()) {
 				bool prevEnableUpdateCheck = Config.EnableUpdateCheck;
 
-				FormSettings form = new FormSettings(this, plugins, updates, startTab);
+				FormSettings form = new FormSettings(this, plugins, updates, browser.Functions, startTab);
 
 				form.FormClosed += (sender, args) => {
 					if (!prevEnableUpdateCheck && Config.EnableUpdateCheck) {
@@ -473,7 +405,7 @@ public void OpenSettings(Type startTab) {
 						plugins.Reload(); // also reloads the browser
 					}
 					else {
-						browser.UpdateProperties();
+						Program.Config.User.TriggerOptionsDialogClosed();
 					}
 
 					notification.RequiresResize = true;
@@ -508,13 +440,19 @@ public void OpenProfileImport() {
 			}
 		}
 
+		public void ShowDesktopNotification(DesktopNotification notification) {
+			this.notification.ShowNotification(notification);
+		}
+
 		public void OnTweetNotification() { // may be called multiple times, once for each type of notification
 			if (Config.EnableTrayHighlight && !ContainsFocus) {
 				trayIcon.HasNotifications = true;
 			}
 		}
 
-		public void OnTweetSound() {}
+		public void SaveVideo(string url, string username) {
+			browser.SaveVideo(url, username);
+		}
 
 		public void PlayVideo(string videoUrl, string tweetUrl, string username, IJavascriptCallback callShowOverlay) {
 			string playerPath = Config.VideoPlayerPath;
@@ -548,25 +486,16 @@ public void StopVideo() {
 			videoPlayer?.Close();
 		}
 
-		public bool ProcessBrowserKey(Keys key) {
-			if (videoPlayer != null && videoPlayer.Running) {
-				videoPlayer.SendKeyEvent(key);
-				return true;
-			}
-
-			return false;
-		}
-
-		public void ShowTweetDetail(string columnId, string chirpId, string fallbackUrl) {
+		public bool ShowTweetDetail(string columnId, string chirpId, string fallbackUrl) {
 			Activate();
 
 			if (!browser.IsTweetDeckWebsite) {
 				FormMessage.Error("View Tweet Detail", "TweetDeck is not currently loaded.", FormMessage.OK);
-				return;
+				return false;
 			}
 
-			notification.FinishCurrentNotification();
-			browser.ShowTweetDetail(columnId, chirpId, fallbackUrl);
+			browser.Functions.ShowTweetDetail(columnId, chirpId, fallbackUrl);
+			return true;
 		}
 
 		public void OnTweetScreenshotReady(string html, int width) {
@@ -584,5 +513,18 @@ public void DisplayTooltip(string text) {
 				toolTip.Show(text, this, position);
 			}
 		}
+
+		public FormNotificationExample CreateExampleNotification() {
+			return new FormNotificationExample(this, tweetDeckInterface, plugins);
+		}
+
+		bool CustomKeyboardHandler.IBrowserKeyHandler.HandleBrowserKey(Keys key) {
+			if (videoPlayer is { Running: true }) {
+				videoPlayer.SendKeyEvent(key);
+				return true;
+			}
+
+			return false;
+		}
 	}
 }
diff --git a/Browser/Handling/General/BrowserProcessHandler.cs b/Browser/Handling/BrowserProcessHandler.cs
similarity index 95%
rename from Browser/Handling/General/BrowserProcessHandler.cs
rename to Browser/Handling/BrowserProcessHandler.cs
index 2e5e99ee..6054b668 100644
--- a/Browser/Handling/General/BrowserProcessHandler.cs
+++ b/Browser/Handling/BrowserProcessHandler.cs
@@ -3,7 +3,7 @@
 using CefSharp;
 using TweetDuck.Configuration;
 
-namespace TweetDuck.Browser.Handling.General {
+namespace TweetDuck.Browser.Handling {
 	sealed class BrowserProcessHandler : IBrowserProcessHandler {
 		public static Task UpdatePrefs() {
 			return Cef.UIThreadTaskFactory.StartNew(UpdatePrefsInternal);
diff --git a/Browser/Handling/ContextMenuBase.cs b/Browser/Handling/ContextMenuBase.cs
index 93199393..05c5d7e5 100644
--- a/Browser/Handling/ContextMenuBase.cs
+++ b/Browser/Handling/ContextMenuBase.cs
@@ -1,213 +1,92 @@
-using System;
+using System.Collections.Generic;
 using System.Drawing;
-using System.Linq;
-using System.Text.RegularExpressions;
-using System.Windows.Forms;
 using CefSharp;
 using TweetDuck.Browser.Adapters;
-using TweetDuck.Browser.Data;
-using TweetDuck.Browser.Notification;
 using TweetDuck.Configuration;
-using TweetDuck.Controls;
-using TweetDuck.Dialogs;
-using TweetDuck.Management;
 using TweetDuck.Utils;
-using TweetLib.Core.Features.Twitter;
-using TweetLib.Utils.Static;
+using TweetLib.Browser.Contexts;
 
 namespace TweetDuck.Browser.Handling {
 	abstract class ContextMenuBase : IContextMenuHandler {
-		public static ContextInfo CurrentInfo { get; } = new ContextInfo();
+		private const CefMenuCommand MenuOpenDevTools = (CefMenuCommand) 26500;
+
+		private static readonly HashSet<CefMenuCommand> AllowedCefCommands = new HashSet<CefMenuCommand> {
+			CefMenuCommand.NotFound,
+			CefMenuCommand.Undo,
+			CefMenuCommand.Redo,
+			CefMenuCommand.Cut,
+			CefMenuCommand.Copy,
+			CefMenuCommand.Paste,
+			CefMenuCommand.Delete,
+			CefMenuCommand.SelectAll,
+			CefMenuCommand.SpellCheckSuggestion0,
+			CefMenuCommand.SpellCheckSuggestion1,
+			CefMenuCommand.SpellCheckSuggestion2,
+			CefMenuCommand.SpellCheckSuggestion3,
+			CefMenuCommand.SpellCheckSuggestion4,
+			CefMenuCommand.SpellCheckNoSuggestions,
+			CefMenuCommand.AddToDictionary
+		};
 
 		protected static UserConfig Config => Program.Config.User;
-		private static ImageQuality ImageQuality => Config.TwitterImageQuality;
 
-		private const CefMenuCommand MenuOpenLinkUrl     = (CefMenuCommand) 26500;
-		private const CefMenuCommand MenuCopyLinkUrl     = (CefMenuCommand) 26501;
-		private const CefMenuCommand MenuCopyUsername    = (CefMenuCommand) 26502;
-		private const CefMenuCommand MenuViewImage       = (CefMenuCommand) 26503;
-		private const CefMenuCommand MenuOpenMediaUrl    = (CefMenuCommand) 26504;
-		private const CefMenuCommand MenuCopyMediaUrl    = (CefMenuCommand) 26505;
-		private const CefMenuCommand MenuCopyImage       = (CefMenuCommand) 26506;
-		private const CefMenuCommand MenuSaveMedia       = (CefMenuCommand) 26507;
-		private const CefMenuCommand MenuSaveTweetImages = (CefMenuCommand) 26508;
-		private const CefMenuCommand MenuSearchInBrowser = (CefMenuCommand) 26509;
-		private const CefMenuCommand MenuReadApplyROT13  = (CefMenuCommand) 26510;
-		private const CefMenuCommand MenuOpenDevTools    = (CefMenuCommand) 26599;
+		private readonly TweetLib.Browser.Interfaces.IContextMenuHandler handler;
+		private readonly CefContextMenuActionRegistry actionRegistry;
 
-		protected ContextInfo.ContextData Context { get; private set; }
+		protected ContextMenuBase(TweetLib.Browser.Interfaces.IContextMenuHandler handler) {
+			this.handler = handler;
+			this.actionRegistry = new CefContextMenuActionRegistry();
+		}
+
+		protected virtual Context CreateContext(IContextMenuParams parameters) {
+			return CefContextMenuModel.CreateContext(parameters, null, Config.TwitterImageQuality);
+		}
 
 		public virtual void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model) {
-			if (!TwitterUrls.IsTweetDeck(frame.Url) || browser.IsLoading) {
-				Context = CurrentInfo.Reset();
-			}
-			else {
-				Context = CurrentInfo.Create(parameters);
-			}
+			for (int i = model.Count - 1; i >= 0; i--) {
+				CefMenuCommand command = model.GetCommandIdAt(i);
 
-			if (parameters.TypeFlags.HasFlag(ContextMenuType.Selection) && !parameters.TypeFlags.HasFlag(ContextMenuType.Editable)) {
-				model.AddItem(MenuSearchInBrowser, "Search in browser");
-				model.AddSeparator();
-				model.AddItem(MenuReadApplyROT13, "Apply ROT13");
-				model.AddSeparator();
-			}
-
-			static string TextOpen(string name) => "Open " + name + " in browser";
-			static string TextCopy(string name) => "Copy " + name + " address";
-			static string TextSave(string name) => "Save " + name + " as...";
-
-			if (Context.Types.HasFlag(ContextInfo.ContextType.Link) && !Context.UnsafeLinkUrl.EndsWith("tweetdeck.twitter.com/#", StringComparison.Ordinal)) {
-				if (TwitterUrls.RegexAccount.IsMatch(Context.UnsafeLinkUrl)) {
-					model.AddItem(MenuOpenLinkUrl, TextOpen("account"));
-					model.AddItem(MenuCopyLinkUrl, TextCopy("account"));
-					model.AddItem(MenuCopyUsername, "Copy account username");
+				if (!AllowedCefCommands.Contains(command) && !(command >= CefMenuCommand.CustomFirst && command <= CefMenuCommand.CustomLast)) {
+					model.RemoveAt(i);
 				}
-				else {
-					model.AddItem(MenuOpenLinkUrl, TextOpen("link"));
-					model.AddItem(MenuCopyLinkUrl, TextCopy("link"));
+			}
+
+			for (int i = model.Count - 2; i >= 0; i--) {
+				if (model.GetTypeAt(i) == MenuItemType.Separator && model.GetTypeAt(i + 1) == MenuItemType.Separator) {
+					model.RemoveAt(i);
 				}
-
-				model.AddSeparator();
 			}
 
-			if (Context.Types.HasFlag(ContextInfo.ContextType.Video)) {
-				model.AddItem(MenuOpenMediaUrl, TextOpen("video"));
-				model.AddItem(MenuCopyMediaUrl, TextCopy("video"));
-				model.AddItem(MenuSaveMedia, TextSave("video"));
-				model.AddSeparator();
+			if (model.Count > 0 && model.GetTypeAt(0) == MenuItemType.Separator) {
+				model.RemoveAt(0);
 			}
-			else if (Context.Types.HasFlag(ContextInfo.ContextType.Image) && Context.MediaUrl != FormNotificationBase.AppLogo.Url) {
-				model.AddItem(MenuViewImage, "View image in photo viewer");
-				model.AddItem(MenuOpenMediaUrl, TextOpen("image"));
-				model.AddItem(MenuCopyMediaUrl, TextCopy("image"));
-				model.AddItem(MenuCopyImage, "Copy image");
-				model.AddItem(MenuSaveMedia, TextSave("image"));
 
-				if (Context.Chirp.Images.Length > 1) {
-					model.AddItem(MenuSaveTweetImages, TextSave("all images"));
-				}
-
-				model.AddSeparator();
-			}
+			AddSeparator(model);
+			handler.Show(new CefContextMenuModel(model, actionRegistry), CreateContext(parameters));
+			RemoveSeparatorIfLast(model);
 		}
 
 		public virtual bool OnContextMenuCommand(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, CefMenuCommand commandId, CefEventFlags eventFlags) {
-			Control control = browserControl.AsControl();
+			if (actionRegistry.Execute(commandId)) {
+				return true;
+			}
 
-			switch (commandId) {
-				case MenuOpenLinkUrl:
-					OpenBrowser(control, Context.LinkUrl);
-					break;
-
-				case MenuCopyLinkUrl:
-					SetClipboardText(control, Context.UnsafeLinkUrl);
-					break;
-
-				case MenuCopyUsername: {
-					string url = Context.UnsafeLinkUrl;
-					Match match = TwitterUrls.RegexAccount.Match(url);
-
-					SetClipboardText(control, match.Success ? match.Groups[1].Value : url);
-					break;
-				}
-
-				case MenuOpenMediaUrl:
-					OpenBrowser(control, TwitterUrls.GetMediaLink(Context.MediaUrl, ImageQuality));
-					break;
-
-				case MenuCopyMediaUrl:
-					SetClipboardText(control, TwitterUrls.GetMediaLink(Context.MediaUrl, ImageQuality));
-					break;
-
-				case MenuCopyImage: {
-					string url = Context.MediaUrl;
-
-					control.InvokeAsyncSafe(() => { TwitterUtils.CopyImage(url, ImageQuality); });
-
-					break;
-				}
-
-				case MenuViewImage: {
-					string url = Context.MediaUrl;
-
-					control.InvokeAsyncSafe(() => {
-						TwitterUtils.ViewImage(url, ImageQuality);
-					});
-
-					break;
-				}
-
-				case MenuSaveMedia: {
-					bool isVideo = Context.Types.HasFlag(ContextInfo.ContextType.Video);
-					string url = Context.MediaUrl;
-					string username = Context.Chirp.Authors.LastOrDefault();
-
-					control.InvokeAsyncSafe(() => {
-						if (isVideo) {
-							TwitterUtils.DownloadVideo(url, username);
-						}
-						else {
-							TwitterUtils.DownloadImage(url, username, ImageQuality);
-						}
-					});
-
-					break;
-				}
-
-				case MenuSaveTweetImages: {
-					string[] urls = Context.Chirp.Images;
-					string username = Context.Chirp.Authors.LastOrDefault();
-
-					control.InvokeAsyncSafe(() => {
-						TwitterUtils.DownloadImages(urls, username, ImageQuality);
-					});
-
-					break;
-				}
-
-				case MenuReadApplyROT13:
-					string selection = parameters.SelectionText;
-					control.InvokeAsyncSafe(() => FormMessage.Information("ROT13", StringUtils.ConvertRot13(selection), FormMessage.OK));
-					return true;
-
-				case MenuSearchInBrowser:
-					string query = parameters.SelectionText;
-					control.InvokeAsyncSafe(() => BrowserUtils.OpenExternalSearch(query));
-					DeselectAll(frame);
-					break;
-
-				case MenuOpenDevTools:
-					browserControl.OpenDevToolsCustom(new Point(parameters.XCoord, parameters.YCoord));
-					break;
+			if (commandId == MenuOpenDevTools) {
+				browserControl.OpenDevToolsCustom(new Point(parameters.XCoord, parameters.YCoord));
+				return true;
 			}
 
 			return false;
 		}
 
 		public virtual void OnContextMenuDismissed(IWebBrowser browserControl, IBrowser browser, IFrame frame) {
-			Context = CurrentInfo.Reset();
+			actionRegistry.Clear();
 		}
 
 		public virtual bool RunContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model, IRunContextMenuCallback callback) {
 			return false;
 		}
 
-		protected static void DeselectAll(IFrame frame) {
-			CefScriptExecutor.RunScript(frame, "window.getSelection().removeAllRanges()", "gen:deselect");
-		}
-
-		protected static void OpenBrowser(Control control, string url) {
-			control.InvokeAsyncSafe(() => BrowserUtils.OpenExternalBrowser(url));
-		}
-
-		protected static void SetClipboardText(Control control, string text) {
-			control.InvokeAsyncSafe(() => ClipboardManager.SetText(text, TextDataFormat.UnicodeText));
-		}
-
-		protected static void InsertSelectionSearchItem(IMenuModel model, CefMenuCommand insertCommand, string insertLabel) {
-			model.InsertItemAt(model.GetIndexOf(MenuSearchInBrowser) + 1, insertCommand, insertLabel);
-		}
-
 		protected static void AddDebugMenuItems(IMenuModel model) {
 			if (Config.DevToolsInContextMenu) {
 				AddSeparator(model);
diff --git a/Browser/Handling/ContextMenuBrowser.cs b/Browser/Handling/ContextMenuBrowser.cs
index 162bfd8b..689726cc 100644
--- a/Browser/Handling/ContextMenuBrowser.cs
+++ b/Browser/Handling/ContextMenuBrowser.cs
@@ -1,8 +1,12 @@
 using System.Windows.Forms;
 using CefSharp;
-using TweetDuck.Browser.Data;
+using TweetDuck.Browser.Adapters;
+using TweetDuck.Configuration;
 using TweetDuck.Controls;
+using TweetLib.Browser.Contexts;
+using TweetLib.Core.Features.TweetDeck;
 using TweetLib.Core.Features.Twitter;
+using IContextMenuHandler = TweetLib.Browser.Interfaces.IContextMenuHandler;
 
 namespace TweetDuck.Browser.Handling {
 	sealed class ContextMenuBrowser : ContextMenuBase {
@@ -12,14 +16,6 @@ sealed class ContextMenuBrowser : ContextMenuBase {
 		private const CefMenuCommand MenuPlugins  = (CefMenuCommand) 26003;
 		private const CefMenuCommand MenuAbout    = (CefMenuCommand) 26604;
 
-		private const CefMenuCommand MenuOpenTweetUrl       = (CefMenuCommand) 26610;
-		private const CefMenuCommand MenuCopyTweetUrl       = (CefMenuCommand) 26611;
-		private const CefMenuCommand MenuOpenQuotedTweetUrl = (CefMenuCommand) 26612;
-		private const CefMenuCommand MenuCopyQuotedTweetUrl = (CefMenuCommand) 26613;
-		private const CefMenuCommand MenuScreenshotTweet    = (CefMenuCommand) 26614;
-		private const CefMenuCommand MenuWriteApplyROT13    = (CefMenuCommand) 26615;
-		private const CefMenuCommand MenuSearchInColumn     = (CefMenuCommand) 26616;
-
 		private const string TitleReloadBrowser = "Reload browser";
 		private const string TitleMuteNotifications = "Mute notifications";
 		private const string TitleSettings = "Options";
@@ -27,49 +23,26 @@ sealed class ContextMenuBrowser : ContextMenuBase {
 		private const string TitleAboutProgram = "About " + Program.BrandName;
 
 		private readonly FormBrowser form;
+		private readonly TweetDeckExtraContext extraContext;
 
-		public ContextMenuBrowser(FormBrowser form) {
+		public ContextMenuBrowser(FormBrowser form, IContextMenuHandler handler, TweetDeckExtraContext extraContext) : base(handler) {
 			this.form = form;
+			this.extraContext = extraContext;
+		}
+
+		protected override Context CreateContext(IContextMenuParams parameters) {
+			return CefContextMenuModel.CreateContext(parameters, extraContext, Config.TwitterImageQuality);
 		}
 
 		public override void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model) {
-			bool isSelecting = parameters.TypeFlags.HasFlag(ContextMenuType.Selection);
-			bool isEditing = parameters.TypeFlags.HasFlag(ContextMenuType.Editable);
-
-			model.Remove(CefMenuCommand.Back);
-			model.Remove(CefMenuCommand.Forward);
-			model.Remove(CefMenuCommand.Print);
-			model.Remove(CefMenuCommand.ViewSource);
-			RemoveSeparatorIfLast(model);
-
-			if (isSelecting) {
-				if (isEditing) {
-					model.AddSeparator();
-					model.AddItem(MenuWriteApplyROT13, "Apply ROT13");
-				}
-
-				model.AddSeparator();
+			if (!TwitterUrls.IsTweetDeck(frame.Url) || browser.IsLoading) {
+				extraContext.Reset();
 			}
 
 			base.OnBeforeContextMenu(browserControl, browser, frame, parameters, model);
 
-			if (isSelecting && !isEditing && TwitterUrls.IsTweetDeck(frame.Url)) {
-				InsertSelectionSearchItem(model, MenuSearchInColumn, "Search in a column");
-			}
-
-			if (Context.Types.HasFlag(ContextInfo.ContextType.Chirp) && !isSelecting && !isEditing) {
-				model.AddItem(MenuOpenTweetUrl, "Open tweet in browser");
-				model.AddItem(MenuCopyTweetUrl, "Copy tweet address");
-				model.AddItem(MenuScreenshotTweet, "Screenshot tweet to clipboard");
-
-				if (!string.IsNullOrEmpty(Context.Chirp.QuoteUrl)) {
-					model.AddSeparator();
-					model.AddItem(MenuOpenQuotedTweetUrl, "Open quoted tweet in browser");
-					model.AddItem(MenuCopyQuotedTweetUrl, "Copy quoted tweet address");
-				}
-
-				model.AddSeparator();
-			}
+			bool isSelecting = parameters.TypeFlags.HasFlag(ContextMenuType.Selection);
+			bool isEditing = parameters.TypeFlags.HasFlag(ContextMenuType.Editable);
 
 			if (!isSelecting && !isEditing) {
 				AddSeparator(model);
@@ -87,8 +60,6 @@ public override void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser br
 
 				AddDebugMenuItems(globalMenu);
 			}
-
-			RemoveSeparatorIfLast(model);
 		}
 
 		public override bool OnContextMenuCommand(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, CefMenuCommand commandId, CefEventFlags eventFlags) {
@@ -117,41 +88,14 @@ public override bool OnContextMenuCommand(IWebBrowser browserControl, IBrowser b
 					form.InvokeAsyncSafe(ToggleMuteNotifications);
 					return true;
 
-				case MenuOpenTweetUrl:
-					OpenBrowser(form, Context.Chirp.TweetUrl);
-					return true;
-
-				case MenuCopyTweetUrl:
-					SetClipboardText(form, Context.Chirp.TweetUrl);
-					return true;
-
-				case MenuScreenshotTweet:
-					var chirp = Context.Chirp;
-
-					form.InvokeAsyncSafe(() => form.TriggerTweetScreenshot(chirp.ColumnId, chirp.ChirpId));
-
-					return true;
-
-				case MenuOpenQuotedTweetUrl:
-					OpenBrowser(form, Context.Chirp.QuoteUrl);
-					return true;
-
-				case MenuCopyQuotedTweetUrl:
-					SetClipboardText(form, Context.Chirp.QuoteUrl);
-					return true;
-
-				case MenuWriteApplyROT13:
-					form.InvokeAsyncSafe(form.ApplyROT13);
-					return true;
-
-				case MenuSearchInColumn:
-					string query = parameters.SelectionText;
-					form.InvokeAsyncSafe(() => form.AddSearchColumn(query));
-					DeselectAll(frame);
-					break;
+				default:
+					return false;
 			}
+		}
 
-			return false;
+		public override void OnContextMenuDismissed(IWebBrowser browserControl, IBrowser browser, IFrame frame) {
+			base.OnContextMenuDismissed(browserControl, browser, frame);
+			extraContext.Reset();
 		}
 
 		public static ContextMenu CreateMenu(FormBrowser form) {
diff --git a/Browser/Handling/ContextMenuGuide.cs b/Browser/Handling/ContextMenuGuide.cs
index a540da03..cde67b5c 100644
--- a/Browser/Handling/ContextMenuGuide.cs
+++ b/Browser/Handling/ContextMenuGuide.cs
@@ -1,9 +1,11 @@
 using CefSharp;
+using IContextMenuHandler = TweetLib.Browser.Interfaces.IContextMenuHandler;
 
 namespace TweetDuck.Browser.Handling {
 	sealed class ContextMenuGuide : ContextMenuBase {
+		public ContextMenuGuide(IContextMenuHandler handler) : base(handler) {}
+
 		public override void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model) {
-			model.Clear();
 			base.OnBeforeContextMenu(browserControl, browser, frame, parameters, model);
 			AddDebugMenuItems(model);
 		}
diff --git a/Browser/Handling/ContextMenuNotification.cs b/Browser/Handling/ContextMenuNotification.cs
index 49e53510..4be8edae 100644
--- a/Browser/Handling/ContextMenuNotification.cs
+++ b/Browser/Handling/ContextMenuNotification.cs
@@ -1,88 +1,27 @@
 using CefSharp;
 using TweetDuck.Browser.Notification;
 using TweetDuck.Controls;
+using TweetLib.Browser.Contexts;
+using IContextMenuHandler = TweetLib.Browser.Interfaces.IContextMenuHandler;
 
 namespace TweetDuck.Browser.Handling {
 	sealed class ContextMenuNotification : ContextMenuBase {
-		private const CefMenuCommand MenuViewDetail         = (CefMenuCommand) 26600;
-		private const CefMenuCommand MenuSkipTweet          = (CefMenuCommand) 26601;
-		private const CefMenuCommand MenuFreeze             = (CefMenuCommand) 26602;
-		private const CefMenuCommand MenuCopyTweetUrl       = (CefMenuCommand) 26603;
-		private const CefMenuCommand MenuCopyQuotedTweetUrl = (CefMenuCommand) 26604;
-
 		private readonly FormNotificationBase form;
-		private readonly bool enableCustomMenu;
 
-		public ContextMenuNotification(FormNotificationBase form, bool enableCustomMenu) {
+		public ContextMenuNotification(FormNotificationBase form, IContextMenuHandler handler) : base(handler) {
 			this.form = form;
-			this.enableCustomMenu = enableCustomMenu;
+		}
+
+		protected override Context CreateContext(IContextMenuParams parameters) {
+			Context context = base.CreateContext(parameters);
+			context.Notification = new TweetLib.Browser.Contexts.Notification(form.CurrentTweetUrl, form.CurrentQuoteUrl);
+			return context;
 		}
 
 		public override void OnBeforeContextMenu(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, IMenuModel model) {
-			model.Clear();
-
-			if (parameters.TypeFlags.HasFlag(ContextMenuType.Selection)) {
-				model.AddItem(CefMenuCommand.Copy, "Copy");
-				model.AddSeparator();
-			}
-
 			base.OnBeforeContextMenu(browserControl, browser, frame, parameters, model);
-
-			if (enableCustomMenu) {
-				if (form.CanViewDetail) {
-					model.AddItem(MenuViewDetail, "View detail");
-				}
-
-				model.AddItem(MenuSkipTweet, "Skip tweet");
-				model.AddCheckItem(MenuFreeze, "Freeze");
-				model.SetChecked(MenuFreeze, form.FreezeTimer);
-
-				if (!string.IsNullOrEmpty(form.CurrentTweetUrl)) {
-					model.AddSeparator();
-					model.AddItem(MenuCopyTweetUrl, "Copy tweet address");
-
-					if (!string.IsNullOrEmpty(form.CurrentQuoteUrl)) {
-						model.AddItem(MenuCopyQuotedTweetUrl, "Copy quoted tweet address");
-					}
-				}
-			}
-
 			AddDebugMenuItems(model);
-			RemoveSeparatorIfLast(model);
-
-			form.InvokeAsyncSafe(() => {
-				form.ContextMenuOpen = true;
-			});
-		}
-
-		public override bool OnContextMenuCommand(IWebBrowser browserControl, IBrowser browser, IFrame frame, IContextMenuParams parameters, CefMenuCommand commandId, CefEventFlags eventFlags) {
-			if (base.OnContextMenuCommand(browserControl, browser, frame, parameters, commandId, eventFlags)) {
-				return true;
-			}
-
-			switch (commandId) {
-				case MenuSkipTweet:
-					form.InvokeAsyncSafe(form.FinishCurrentNotification);
-					return true;
-
-				case MenuFreeze:
-					form.InvokeAsyncSafe(() => form.FreezeTimer = !form.FreezeTimer);
-					return true;
-
-				case MenuViewDetail:
-					form.InvokeSafe(form.ShowTweetDetail);
-					return true;
-
-				case MenuCopyTweetUrl:
-					SetClipboardText(form, form.CurrentTweetUrl);
-					return true;
-
-				case MenuCopyQuotedTweetUrl:
-					SetClipboardText(form, form.CurrentQuoteUrl);
-					return true;
-			}
-
-			return false;
+			form.InvokeAsyncSafe(() => form.ContextMenuOpen = true);
 		}
 
 		public override void OnContextMenuDismissed(IWebBrowser browserControl, IBrowser browser, IFrame frame) {
diff --git a/Browser/Handling/KeyboardHandlerBase.cs b/Browser/Handling/CustomKeyboardHandler.cs
similarity index 54%
rename from Browser/Handling/KeyboardHandlerBase.cs
rename to Browser/Handling/CustomKeyboardHandler.cs
index 6a11f605..58018394 100644
--- a/Browser/Handling/KeyboardHandlerBase.cs
+++ b/Browser/Handling/CustomKeyboardHandler.cs
@@ -1,32 +1,43 @@
 using System.Windows.Forms;
 using CefSharp;
 using TweetDuck.Utils;
+using TweetLib.Utils.Static;
 
 namespace TweetDuck.Browser.Handling {
-	class KeyboardHandlerBase : IKeyboardHandler {
-		protected virtual bool HandleRawKey(IWebBrowser browserControl, Keys key, CefEventFlags modifiers) {
+	sealed class CustomKeyboardHandler : IKeyboardHandler {
+		private readonly IBrowserKeyHandler handler;
+
+		public CustomKeyboardHandler(IBrowserKeyHandler handler) {
+			this.handler = handler;
+		}
+
+		bool IKeyboardHandler.OnPreKeyEvent(IWebBrowser browserControl, IBrowser browser, KeyType type, int windowsKeyCode, int nativeKeyCode, CefEventFlags modifiers, bool isSystemKey, ref bool isKeyboardShortcut) {
+			if (type != KeyType.RawKeyDown) {
+				return false;
+			}
+
+			using (var frame = browser.FocusedFrame) {
+				if (frame.Url.StartsWithOrdinal("devtools://")) {
+					return false;
+				}
+			}
+
+			Keys key = (Keys) windowsKeyCode;
+
 			if (modifiers == (CefEventFlags.ControlDown | CefEventFlags.ShiftDown) && key == Keys.I) {
 				browserControl.OpenDevToolsCustom();
 				return true;
 			}
 
-			return false;
-		}
-
-		bool IKeyboardHandler.OnPreKeyEvent(IWebBrowser browserControl, IBrowser browser, KeyType type, int windowsKeyCode, int nativeKeyCode, CefEventFlags modifiers, bool isSystemKey, ref bool isKeyboardShortcut) {
-			if (type == KeyType.RawKeyDown) {
-				using var frame = browser.FocusedFrame;
-
-				if (!frame.Url.StartsWith("devtools://")) {
-					return HandleRawKey(browserControl, (Keys) windowsKeyCode, modifiers);
-				}
-			}
-
-			return false;
+			return handler != null && handler.HandleBrowserKey(key);
 		}
 
 		bool IKeyboardHandler.OnKeyEvent(IWebBrowser browserControl, IBrowser browser, KeyType type, int windowsKeyCode, int nativeKeyCode, CefEventFlags modifiers, bool isSystemKey) {
 			return false;
 		}
+
+		public interface IBrowserKeyHandler {
+			bool HandleBrowserKey(Keys key);
+		}
 	}
 }
diff --git a/Browser/Handling/General/CustomLifeSpanHandler.cs b/Browser/Handling/CustomLifeSpanHandler.cs
similarity index 57%
rename from Browser/Handling/General/CustomLifeSpanHandler.cs
rename to Browser/Handling/CustomLifeSpanHandler.cs
index e17610c9..a8b8fb96 100644
--- a/Browser/Handling/General/CustomLifeSpanHandler.cs
+++ b/Browser/Handling/CustomLifeSpanHandler.cs
@@ -1,24 +1,23 @@
-using System;
-using CefSharp;
+using CefSharp;
 using CefSharp.Handler;
-using TweetDuck.Controls;
-using TweetDuck.Utils;
+using TweetLib.Core;
+using TweetLib.Utils.Static;
 
-namespace TweetDuck.Browser.Handling.General {
+namespace TweetDuck.Browser.Handling {
 	sealed class CustomLifeSpanHandler : LifeSpanHandler {
 		private static bool IsPopupAllowed(string url) {
-			return url.StartsWith("https://twitter.com/teams/authorize?", StringComparison.Ordinal) ||
-			       url.StartsWith("https://accounts.google.com/", StringComparison.Ordinal) ||
-			       url.StartsWith("https://appleid.apple.com/", StringComparison.Ordinal);
+			return url.StartsWithOrdinal("https://twitter.com/teams/authorize?") ||
+			       url.StartsWithOrdinal("https://accounts.google.com/") ||
+			       url.StartsWithOrdinal("https://appleid.apple.com/");
 		}
 
-		public static bool HandleLinkClick(IWebBrowser browserControl, WindowOpenDisposition targetDisposition, string targetUrl) {
+		public static bool HandleLinkClick(WindowOpenDisposition targetDisposition, string targetUrl) {
 			switch (targetDisposition) {
 				case WindowOpenDisposition.NewBackgroundTab:
 				case WindowOpenDisposition.NewForegroundTab:
 				case WindowOpenDisposition.NewPopup when !IsPopupAllowed(targetUrl):
 				case WindowOpenDisposition.NewWindow:
-					browserControl.AsControl().InvokeAsyncSafe(() => BrowserUtils.OpenExternalBrowser(targetUrl));
+					App.SystemHandler.OpenBrowser(targetUrl);
 					return true;
 
 				default:
@@ -28,7 +27,7 @@ public static bool HandleLinkClick(IWebBrowser browserControl, WindowOpenDisposi
 
 		protected override bool OnBeforePopup(IWebBrowser browserControl, IBrowser browser, IFrame frame, string targetUrl, string targetFrameName, WindowOpenDisposition targetDisposition, bool userGesture, IPopupFeatures popupFeatures, IWindowInfo windowInfo, IBrowserSettings browserSettings, ref bool noJavascriptAccess, out IWebBrowser newBrowser) {
 			newBrowser = null;
-			return HandleLinkClick(browserControl, targetDisposition, targetUrl);
+			return HandleLinkClick(targetDisposition, targetUrl);
 		}
 
 		protected override bool DoClose(IWebBrowser browserControl, IBrowser browser) {
diff --git a/Browser/Handling/DragHandlerBrowser.cs b/Browser/Handling/DragHandlerBrowser.cs
index ba26659e..85518f87 100644
--- a/Browser/Handling/DragHandlerBrowser.cs
+++ b/Browser/Handling/DragHandlerBrowser.cs
@@ -1,7 +1,6 @@
 using System.Collections.Generic;
 using CefSharp;
 using CefSharp.Enums;
-using TweetDuck.Utils;
 
 namespace TweetDuck.Browser.Handling {
 	sealed class DragHandlerBrowser : IDragHandler {
@@ -13,7 +12,7 @@ public DragHandlerBrowser(RequestHandlerBrowser requestHandler) {
 
 		public bool OnDragEnter(IWebBrowser browserControl, IBrowser browser, IDragData dragData, DragOperationsMask mask) {
 			void TriggerDragStart(string type, string data = null) {
-				browserControl.ExecuteJsAsync("window.TDGF_onGlobalDragStart", type, data);
+				browserControl.BrowserCore.ExecuteScriptAsync("window.TDGF_onGlobalDragStart", type, data);
 			}
 
 			requestHandler.BlockNextUserNavUrl = dragData.LinkUrl; // empty if not a link
diff --git a/Browser/Handling/General/FileDialogHandler.cs b/Browser/Handling/FileDialogHandler.cs
similarity index 95%
rename from Browser/Handling/General/FileDialogHandler.cs
rename to Browser/Handling/FileDialogHandler.cs
index 2529a223..a1751c25 100644
--- a/Browser/Handling/General/FileDialogHandler.cs
+++ b/Browser/Handling/FileDialogHandler.cs
@@ -1,11 +1,12 @@
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.IO;
 using System.Linq;
 using System.Windows.Forms;
 using CefSharp;
 using TweetLib.Utils.Static;
 
-namespace TweetDuck.Browser.Handling.General {
+namespace TweetDuck.Browser.Handling {
 	sealed class FileDialogHandler : IDialogHandler {
 		public bool OnFileDialog(IWebBrowser browserControl, IBrowser browser, CefFileDialogMode mode, CefFileDialogFlags flags, string title, string defaultFilePath, List<string> acceptFilters, int selectedAcceptFilter, IFileDialogCallback callback) {
 			if (mode == CefFileDialogMode.Open || mode == CefFileDialogMode.OpenMultiple) {
@@ -54,7 +55,7 @@ private static IEnumerable<string> ParseFileType(string type) {
 				case "video/quicktime": return new string[] { ".mov", ".qt" };
 			}
 
-			System.Diagnostics.Debugger.Break();
+			Debugger.Break();
 			return StringUtils.EmptyArray;
 		}
 	}
diff --git a/Browser/Handling/Filters/ResponseFilterVendor.cs b/Browser/Handling/Filters/ResponseFilterVendor.cs
deleted file mode 100644
index cc877112..00000000
--- a/Browser/Handling/Filters/ResponseFilterVendor.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.Text;
-using System.Text.RegularExpressions;
-
-namespace TweetDuck.Browser.Handling.Filters {
-	sealed class ResponseFilterVendor : ResponseFilterBase {
-		private static readonly Regex RegexRestoreJQuery = new Regex(@"(\w+)\.fn=\1\.prototype", RegexOptions.Compiled);
-
-		public ResponseFilterVendor(int totalBytes) : base(totalBytes, Encoding.UTF8) {}
-
-		public override bool InitFilter() {
-			return true;
-		}
-
-		protected override string ProcessResponse(string text) {
-			return RegexRestoreJQuery.Replace(text, "window.$$=$1;$&", 1);
-		}
-
-		public override void Dispose() {}
-	}
-}
diff --git a/Browser/Handling/General/JavaScriptDialogHandler.cs b/Browser/Handling/JavaScriptDialogHandler.cs
similarity index 95%
rename from Browser/Handling/General/JavaScriptDialogHandler.cs
rename to Browser/Handling/JavaScriptDialogHandler.cs
index 1ae855dc..e7f3baed 100644
--- a/Browser/Handling/General/JavaScriptDialogHandler.cs
+++ b/Browser/Handling/JavaScriptDialogHandler.cs
@@ -1,11 +1,12 @@
 using System.Drawing;
 using System.Windows.Forms;
 using CefSharp;
+using CefSharp.WinForms;
 using TweetDuck.Controls;
 using TweetDuck.Dialogs;
 using TweetDuck.Utils;
 
-namespace TweetDuck.Browser.Handling.General {
+namespace TweetDuck.Browser.Handling {
 	sealed class JavaScriptDialogHandler : IJsDialogHandler {
 		private static FormMessage CreateMessageForm(string caption, string text) {
 			MessageBoxIcon icon = MessageBoxIcon.None;
@@ -29,7 +30,9 @@ private static FormMessage CreateMessageForm(string caption, string text) {
 		}
 
 		bool IJsDialogHandler.OnJSDialog(IWebBrowser browserControl, IBrowser browser, string originUrl, CefJsDialogType dialogType, string messageText, string defaultPromptText, IJsDialogCallback callback, ref bool suppressMessage) {
-			browserControl.AsControl().InvokeSafe(() => {
+			var control = (ChromiumWebBrowser) browserControl;
+
+			control.InvokeSafe(() => {
 				FormMessage form;
 				TextBox input = null;
 
diff --git a/Browser/Handling/KeyboardHandlerBrowser.cs b/Browser/Handling/KeyboardHandlerBrowser.cs
deleted file mode 100644
index f476b2d7..00000000
--- a/Browser/Handling/KeyboardHandlerBrowser.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System.Windows.Forms;
-using CefSharp;
-
-namespace TweetDuck.Browser.Handling {
-	sealed class KeyboardHandlerBrowser : KeyboardHandlerBase {
-		private readonly FormBrowser form;
-
-		public KeyboardHandlerBrowser(FormBrowser form) {
-			this.form = form;
-		}
-
-		protected override bool HandleRawKey(IWebBrowser browserControl, Keys key, CefEventFlags modifiers) {
-			return base.HandleRawKey(browserControl, key, modifiers) || form.ProcessBrowserKey(key);
-		}
-	}
-}
diff --git a/Browser/Handling/KeyboardHandlerNotification.cs b/Browser/Handling/KeyboardHandlerNotification.cs
deleted file mode 100644
index f5d1feba..00000000
--- a/Browser/Handling/KeyboardHandlerNotification.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.Windows.Forms;
-using CefSharp;
-using TweetDuck.Browser.Notification;
-using TweetDuck.Controls;
-
-namespace TweetDuck.Browser.Handling {
-	sealed class KeyboardHandlerNotification : KeyboardHandlerBase {
-		private readonly FormNotificationBase notification;
-
-		public KeyboardHandlerNotification(FormNotificationBase notification) {
-			this.notification = notification;
-		}
-
-		protected override bool HandleRawKey(IWebBrowser browserControl, Keys key, CefEventFlags modifiers) {
-			if (base.HandleRawKey(browserControl, key, modifiers)) {
-				return true;
-			}
-
-			switch (key) {
-				case Keys.Enter:
-					notification.InvokeAsyncSafe(notification.FinishCurrentNotification);
-					return true;
-
-				case Keys.Escape:
-					notification.InvokeAsyncSafe(notification.HideNotification);
-					return true;
-
-				case Keys.Space:
-					notification.InvokeAsyncSafe(() => notification.FreezeTimer = !notification.FreezeTimer);
-					return true;
-
-				default:
-					return false;
-			}
-		}
-	}
-}
diff --git a/Browser/Handling/RequestHandlerBase.cs b/Browser/Handling/RequestHandlerBase.cs
index baf67beb..acd06833 100644
--- a/Browser/Handling/RequestHandlerBase.cs
+++ b/Browser/Handling/RequestHandlerBase.cs
@@ -1,6 +1,5 @@
 using CefSharp;
 using CefSharp.Handler;
-using TweetDuck.Browser.Handling.General;
 
 namespace TweetDuck.Browser.Handling {
 	class RequestHandlerBase : RequestHandler {
@@ -11,7 +10,7 @@ public RequestHandlerBase(bool autoReload) {
 		}
 
 		protected override bool OnOpenUrlFromTab(IWebBrowser browserControl, IBrowser browser, IFrame frame, string targetUrl, WindowOpenDisposition targetDisposition, bool userGesture) {
-			return CustomLifeSpanHandler.HandleLinkClick(browserControl, targetDisposition, targetUrl);
+			return CustomLifeSpanHandler.HandleLinkClick(targetDisposition, targetUrl);
 		}
 
 		protected override void OnRenderProcessTerminated(IWebBrowser browserControl, IBrowser browser, CefTerminationStatus status) {
diff --git a/Browser/Handling/ResourceRequestHandler.cs b/Browser/Handling/ResourceRequestHandler.cs
deleted file mode 100644
index 7b111119..00000000
--- a/Browser/Handling/ResourceRequestHandler.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using CefSharp;
-using TweetDuck.Browser.Data;
-
-namespace TweetDuck.Browser.Handling {
-	abstract class ResourceRequestHandler : CefSharp.Handler.ResourceRequestHandler {
-		private class SelfFactoryImpl : IResourceRequestHandlerFactory {
-			private readonly ResourceRequestHandler me;
-
-			public SelfFactoryImpl(ResourceRequestHandler me) {
-				this.me = me;
-			}
-
-			bool IResourceRequestHandlerFactory.HasHandlers => true;
-
-			[SuppressMessage("ReSharper", "RedundantAssignment")]
-			IResourceRequestHandler IResourceRequestHandlerFactory.GetResourceRequestHandler(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, bool isNavigation, bool isDownload, string requestInitiator, ref bool disableDefaultHandling) {
-				disableDefaultHandling = me.ResourceHandlers.HasHandler(request);
-				return me;
-			}
-		}
-
-		public IResourceRequestHandlerFactory SelfFactory { get; }
-		public ResourceHandlers ResourceHandlers { get; }
-
-		protected ResourceRequestHandler() {
-			this.SelfFactory = new SelfFactoryImpl(this);
-			this.ResourceHandlers = new ResourceHandlers();
-		}
-
-		protected override IResourceHandler GetResourceHandler(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request) {
-			return ResourceHandlers.GetHandler(request);
-		}
-	}
-}
diff --git a/Browser/Handling/ResourceRequestHandlerBase.cs b/Browser/Handling/ResourceRequestHandlerBase.cs
deleted file mode 100644
index 3b535540..00000000
--- a/Browser/Handling/ResourceRequestHandlerBase.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.Specialized;
-using System.Linq;
-using System.Text.RegularExpressions;
-using CefSharp;
-using TweetLib.Utils.Static;
-
-namespace TweetDuck.Browser.Handling {
-	class ResourceRequestHandlerBase : ResourceRequestHandler {
-		private static readonly Regex TweetDeckResourceUrl = new Regex(@"/dist/(.*?)\.(.*?)\.(css|js)$");
-		private static readonly SortedList<string, string> TweetDeckHashes = new SortedList<string, string>(4);
-
-		public static void LoadResourceRewriteRules(string rules) {
-			if (string.IsNullOrEmpty(rules)) {
-				return;
-			}
-
-			TweetDeckHashes.Clear();
-
-			foreach (string rule in rules.Replace(" ", "").ToLower().Split(',')) {
-				var (key, hash) = StringUtils.SplitInTwo(rule, '=') ?? throw new ArgumentException("A rule must have one '=' character: " + rule);
-
-				if (hash.All(chr => char.IsDigit(chr) || (chr >= 'a' && chr <= 'f'))) {
-					TweetDeckHashes.Add(key, hash);
-				}
-				else {
-					throw new ArgumentException("Invalid hash characters: " + rule);
-				}
-			}
-		}
-
-		protected override CefReturnValue OnBeforeResourceLoad(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) {
-			if (request.ResourceType == ResourceType.CspReport) {
-				callback.Dispose();
-				return CefReturnValue.Cancel;
-			}
-
-			NameValueCollection headers = request.Headers;
-			headers.Remove("x-devtools-emulate-network-conditions-client-id");
-			request.Headers = headers;
-
-			return base.OnBeforeResourceLoad(browserControl, browser, frame, request, callback);
-		}
-
-		protected override bool OnResourceResponse(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) {
-			if ((request.ResourceType == ResourceType.Script || request.ResourceType == ResourceType.Stylesheet) && TweetDeckHashes.Count > 0) {
-				string url = request.Url;
-				Match match = TweetDeckResourceUrl.Match(url);
-
-				if (match.Success && TweetDeckHashes.TryGetValue($"{match.Groups[1]}.{match.Groups[3]}", out string hash)) {
-					if (match.Groups[2].Value == hash) {
-						Program.Reporter.LogVerbose("[RequestHandlerBase] Accepting " + url);
-					}
-					else {
-						Program.Reporter.LogVerbose("[RequestHandlerBase] Replacing " + url + " hash with " + hash);
-						request.Url = TweetDeckResourceUrl.Replace(url, $"/dist/$1.{hash}.$3");
-						return true;
-					}
-				}
-			}
-
-			return base.OnResourceResponse(browserControl, browser, frame, request, response);
-		}
-	}
-}
diff --git a/Browser/Handling/ResourceRequestHandlerBrowser.cs b/Browser/Handling/ResourceRequestHandlerBrowser.cs
deleted file mode 100644
index 48ed409f..00000000
--- a/Browser/Handling/ResourceRequestHandlerBrowser.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using CefSharp;
-using TweetDuck.Browser.Handling.Filters;
-using TweetDuck.Utils;
-using TweetLib.Core.Features.Twitter;
-
-namespace TweetDuck.Browser.Handling {
-	class ResourceRequestHandlerBrowser : ResourceRequestHandlerBase {
-		private const string UrlVendorResource = "/dist/vendor";
-		private const string UrlLoadingSpinner = "/backgrounds/spinner_blue";
-		private const string UrlVersionCheck = "/web/dist/version.json";
-
-		protected override CefReturnValue OnBeforeResourceLoad(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) {
-			if (request.ResourceType == ResourceType.MainFrame) {
-				if (request.Url.EndsWith("//twitter.com/")) {
-					request.Url = TwitterUrls.TweetDeck; // redirect plain twitter.com requests, fixes bugs with login 2FA
-				}
-			}
-			else if (request.ResourceType == ResourceType.Image) {
-				if (request.Url.Contains(UrlLoadingSpinner)) {
-					request.Url = TwitterUtils.LoadingSpinner.Url;
-				}
-			}
-			else if (request.ResourceType == ResourceType.Script) {
-				string url = request.Url;
-
-				if (url.Contains("analytics.")) {
-					callback.Dispose();
-					return CefReturnValue.Cancel;
-				}
-				else if (url.Contains(UrlVendorResource)) {
-					request.SetHeaderByName("Accept-Encoding", "identity", overwrite: true);
-				}
-			}
-			else if (request.ResourceType == ResourceType.Xhr) {
-				if (request.Url.Contains(UrlVersionCheck)) {
-					callback.Dispose();
-					return CefReturnValue.Cancel;
-				}
-			}
-
-			return base.OnBeforeResourceLoad(browserControl, browser, frame, request, callback);
-		}
-
-		protected override IResponseFilter GetResourceResponseFilter(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) {
-			if (request.ResourceType == ResourceType.Script && request.Url.Contains(UrlVendorResource) && int.TryParse(response.Headers["Content-Length"], out int totalBytes)) {
-				return new ResponseFilterVendor(totalBytes);
-			}
-
-			return base.GetResourceResponseFilter(browserControl, browser, frame, request, response);
-		}
-	}
-}
diff --git a/Browser/Handling/Filters/ResponseFilterBase.cs b/Browser/Handling/ResponseFilter.cs
similarity index 74%
rename from Browser/Handling/Filters/ResponseFilterBase.cs
rename to Browser/Handling/ResponseFilter.cs
index 9b100c42..351d3285 100644
--- a/Browser/Handling/Filters/ResponseFilterBase.cs
+++ b/Browser/Handling/ResponseFilter.cs
@@ -1,28 +1,32 @@
 using System;
 using System.IO;
-using System.Text;
 using CefSharp;
+using TweetLib.Browser.Interfaces;
 
-namespace TweetDuck.Browser.Handling.Filters {
-	abstract class ResponseFilterBase : IResponseFilter {
+namespace TweetDuck.Browser.Handling {
+	sealed class ResponseFilter : IResponseFilter {
 		private enum State {
 			Reading,
 			Writing,
 			Done
 		}
 
-		private readonly Encoding encoding;
+		private readonly IResponseProcessor processor;
 		private byte[] responseData;
 
 		private State state;
 		private int offset;
 
-		protected ResponseFilterBase(int totalBytes, Encoding encoding) {
+		public ResponseFilter(IResponseProcessor processor, int totalBytes) {
+			this.processor = processor;
 			this.responseData = new byte[totalBytes];
-			this.encoding = encoding;
 			this.state = State.Reading;
 		}
 
+		public bool InitFilter() {
+			return true;
+		}
+
 		FilterStatus IResponseFilter.Filter(Stream dataIn, out long dataInRead, Stream dataOut, out long dataOutWritten) {
 			int responseLength = responseData.Length;
 
@@ -36,7 +40,7 @@ FilterStatus IResponseFilter.Filter(Stream dataIn, out long dataInRead, Stream d
 				dataOutWritten = 0;
 
 				if (offset >= responseLength) {
-					responseData = encoding.GetBytes(ProcessResponse(encoding.GetString(responseData)));
+					responseData = processor.Process(responseData);
 					state = State.Writing;
 					offset = 0;
 				}
@@ -67,8 +71,6 @@ FilterStatus IResponseFilter.Filter(Stream dataIn, out long dataInRead, Stream d
 			}
 		}
 
-		public abstract bool InitFilter();
-		protected abstract string ProcessResponse(string text);
-		public abstract void Dispose();
+		public void Dispose() {}
 	}
 }
diff --git a/Browser/Notification/FormNotificationBase.cs b/Browser/Notification/FormNotificationBase.cs
index 1b7a1490..1a2d4d69 100644
--- a/Browser/Notification/FormNotificationBase.cs
+++ b/Browser/Notification/FormNotificationBase.cs
@@ -1,35 +1,20 @@
 using System.Drawing;
 using System.Windows.Forms;
 using CefSharp.WinForms;
-using TweetDuck.Browser.Data;
+using TweetDuck.Browser.Adapters;
 using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Handling.General;
 using TweetDuck.Configuration;
 using TweetDuck.Controls;
 using TweetDuck.Utils;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Twitter;
 
 namespace TweetDuck.Browser.Notification {
 	abstract partial class FormNotificationBase : Form {
-		public static readonly ResourceLink AppLogo = new ResourceLink("https://ton.twimg.com/tduck/avatar", ResourceHandlers.ForBytes(Properties.Resources.avatar, "image/png"));
-
-		protected const string BlankURL = TwitterUrls.TweetDeck + "/?blank";
-
-		public static string FontSize = null;
-		public static string HeadLayout = null;
-
 		protected static UserConfig Config => Program.Config.User;
 
-		protected static int FontSizeLevel {
-			get => FontSize switch {
-				"largest"  => 4,
-				"large"    => 3,
-				"small"    => 1,
-				"smallest" => 0,
-				_          => 2
-			};
-		}
+		protected delegate NotificationBrowser CreateBrowserImplFunc(FormNotificationBase form, IBrowserComponent browserComponent);
 
 		protected virtual Point PrimaryLocation {
 			get {
@@ -100,6 +85,9 @@ protected virtual FormBorderStyle NotificationBorderStyle {
 
 		private readonly FormBrowser owner;
 
+		protected readonly IBrowserComponent browserComponent;
+		private readonly NotificationBrowser browserImpl;
+
 		#pragma warning disable IDE0069 // Disposable fields should be disposed
 		protected readonly ChromiumWebBrowser browser;
 		#pragma warning restore IDE0069 // Disposable fields should be disposed
@@ -112,43 +100,33 @@ protected virtual FormBorderStyle NotificationBorderStyle {
 		public string CurrentTweetUrl => currentNotification?.TweetUrl;
 		public string CurrentQuoteUrl => currentNotification?.QuoteUrl;
 
-		public bool CanViewDetail => currentNotification != null && !string.IsNullOrEmpty(currentNotification.ColumnId) && !string.IsNullOrEmpty(currentNotification.ChirpId);
-
 		protected bool IsPaused => pauseCounter > 0;
-		protected bool IsCursorOverBrowser => browser.Bounds.Contains(PointToClient(Cursor.Position));
+		protected internal bool IsCursorOverBrowser => browser.Bounds.Contains(PointToClient(Cursor.Position));
 
 		public bool FreezeTimer { get; set; }
 		public bool ContextMenuOpen { get; set; }
 
-		protected FormNotificationBase(FormBrowser owner, bool enableContextMenu) {
+		protected FormNotificationBase(FormBrowser owner, CreateBrowserImplFunc createBrowserImpl) {
 			InitializeComponent();
 
 			this.owner = owner;
 			this.owner.FormClosed += owner_FormClosed;
 
-			var resourceRequestHandler = new ResourceRequestHandlerBase();
-			var resourceHandlers = resourceRequestHandler.ResourceHandlers;
-
-			resourceHandlers.Register(BlankURL, ResourceHandlers.ForString(string.Empty));
-			resourceHandlers.Register(TwitterUrls.TweetDeck, () => this.resourceHandler);
-			resourceHandlers.Register(AppLogo);
-
-			this.browser = new ChromiumWebBrowser(BlankURL) {
-				MenuHandler = new ContextMenuNotification(this, enableContextMenu),
-				JsDialogHandler = new JavaScriptDialogHandler(),
-				LifeSpanHandler = new CustomLifeSpanHandler(),
-				RequestHandler = new RequestHandlerBase(false),
-				ResourceRequestHandlerFactory = resourceRequestHandler.SelfFactory
+			this.browser = new ChromiumWebBrowser(NotificationBrowser.BlankURL) {
+				RequestHandler = new RequestHandlerBase(false)
 			};
 
+			this.browserComponent = new ComponentImpl(browser, this);
+			this.browserImpl = createBrowserImpl(this, browserComponent);
+
 			this.browser.Dock = DockStyle.None;
 			this.browser.ClientSize = ClientSize;
-			this.browser.SetupZoomEvents();
 
 			Controls.Add(browser);
 
 			Disposed += (sender, args) => {
 				this.owner.FormClosed -= owner_FormClosed;
+				this.browserImpl.Dispose();
 				this.browser.Dispose();
 			};
 
@@ -158,6 +136,25 @@ protected FormNotificationBase(FormBrowser owner, bool enableContextMenu) {
 			UpdateTitle();
 		}
 
+		protected sealed class ComponentImpl : CefBrowserComponent {
+			private readonly FormNotificationBase owner;
+
+			public ComponentImpl(ChromiumWebBrowser browser, FormNotificationBase owner) : base(browser) {
+				this.owner = owner;
+			}
+
+			protected override ContextMenuBase SetupContextMenu(IContextMenuHandler handler) {
+				return new ContextMenuNotification(owner, handler);
+			}
+
+			protected override CefResourceHandlerFactory SetupResourceHandlerFactory(IResourceRequestHandler handler) {
+				var registry = new CefResourceHandlerRegistry();
+				registry.RegisterStatic(NotificationBrowser.BlankURL, string.Empty);
+				registry.RegisterDynamic(TwitterUrls.TweetDeck, owner.resourceHandler);
+				return new CefResourceHandlerFactory(handler, registry);
+			}
+		}
+
 		protected override void Dispose(bool disposing) {
 			if (disposing) {
 				components?.Dispose();
@@ -184,7 +181,7 @@ private void owner_FormClosed(object sender, FormClosedEventArgs e) {
 		// notification methods
 
 		public virtual void HideNotification() {
-			browser.Load(BlankURL);
+			browser.Load(NotificationBrowser.BlankURL);
 			DisplayTooltip(null);
 
 			Location = ControlExtensions.InvisibleLocation;
@@ -205,11 +202,9 @@ public virtual void ResumeNotification() {
 			}
 		}
 
-		protected abstract string GetTweetHTML(DesktopNotification tweet);
-
 		protected virtual void LoadTweet(DesktopNotification tweet) {
 			currentNotification = tweet;
-			resourceHandler.SetHTML(GetTweetHTML(tweet));
+			resourceHandler.SetHTML(browserImpl.GetTweetHTML(tweet));
 
 			browser.Load(TwitterUrls.TweetDeck);
 			DisplayTooltip(null);
@@ -225,8 +220,8 @@ protected virtual void UpdateTitle() {
 		}
 
 		public void ShowTweetDetail() {
-			if (currentNotification != null) {
-				owner.ShowTweetDetail(currentNotification.ColumnId, currentNotification.ChirpId, currentNotification.TweetUrl);
+			if (currentNotification != null && owner.ShowTweetDetail(currentNotification.ColumnId, currentNotification.ChirpId, currentNotification.TweetUrl)) {
+				FinishCurrentNotification();
 			}
 		}
 
diff --git a/Browser/Notification/Example/FormNotificationExample.cs b/Browser/Notification/FormNotificationExample.cs
similarity index 65%
rename from Browser/Notification/Example/FormNotificationExample.cs
rename to Browser/Notification/FormNotificationExample.cs
index 7a80c817..0951ba3f 100644
--- a/Browser/Notification/Example/FormNotificationExample.cs
+++ b/Browser/Notification/FormNotificationExample.cs
@@ -1,13 +1,18 @@
 using System;
 using System.Windows.Forms;
-using CefSharp;
 using TweetDuck.Controls;
-using TweetDuck.Resources;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.TweetDeck;
+using TweetLib.Core.Resources;
 
-namespace TweetDuck.Browser.Notification.Example {
+namespace TweetDuck.Browser.Notification {
 	sealed class FormNotificationExample : FormNotificationMain {
+		private static NotificationBrowser CreateBrowserImpl(IBrowserComponent browserComponent, INotificationInterface notificationInterface, ITweetDeckInterface tweetDeckInterface, PluginManager pluginManager) {
+			return new NotificationBrowser.Example(browserComponent, notificationInterface, tweetDeckInterface, pluginManager);
+		}
+
 		public override bool RequiresResize => true;
 		protected override bool CanDragWindow => Config.NotificationPosition == DesktopNotification.Position.Custom;
 
@@ -24,16 +29,16 @@ protected override FormBorderStyle NotificationBorderStyle {
 			}
 		}
 
-		protected override string BodyClasses => base.BodyClasses + " td-example";
-
 		public event EventHandler Ready;
 
 		private readonly DesktopNotification exampleNotification;
 
-		public FormNotificationExample(FormBrowser owner, PluginManager pluginManager) : base(owner, pluginManager, false) {
-			browser.LoadingStateChanged += browser_LoadingStateChanged;
+		public FormNotificationExample(FormBrowser owner, ITweetDeckInterface tweetDeckInterface, PluginManager pluginManager) : base(owner, (form, browserComponent) => CreateBrowserImpl(browserComponent, new NotificationInterfaceImpl(form), tweetDeckInterface, pluginManager)) {
+			browserComponent.BrowserLoaded += (sender, args) => {
+				Ready?.Invoke(this, EventArgs.Empty);
+			};
 
-			string exampleTweetHTML = ResourceUtils.ReadFileOrNull("notification/example/example.html")?.Replace("{avatar}", AppLogo.Url) ?? string.Empty;
+			string exampleTweetHTML = ResourceUtils.ReadFileOrNull("notification/example/example.html") ?? string.Empty;
 
 			#if DEBUG
 			exampleTweetHTML = exampleTweetHTML.Replace("</p>", @"</p><div style='margin-top:256px'>Scrollbar test padding...</div>");
@@ -42,13 +47,6 @@ public FormNotificationExample(FormBrowser owner, PluginManager pluginManager) :
 			exampleNotification = new DesktopNotification(string.Empty, string.Empty, "Home", exampleTweetHTML, 176, string.Empty, string.Empty);
 		}
 
-		private void browser_LoadingStateChanged(object sender, LoadingStateChangedEventArgs e) {
-			if (!e.IsLoading) {
-				Ready?.Invoke(this, EventArgs.Empty);
-				browser.LoadingStateChanged -= browser_LoadingStateChanged;
-			}
-		}
-
 		public override void HideNotification() {
 			Location = ControlExtensions.InvisibleLocation;
 		}
diff --git a/Browser/Notification/FormNotificationMain.cs b/Browser/Notification/FormNotificationMain.cs
index e3f51841..7ef45660 100644
--- a/Browser/Notification/FormNotificationMain.cs
+++ b/Browser/Notification/FormNotificationMain.cs
@@ -2,20 +2,50 @@
 using System.Drawing;
 using System.Windows.Forms;
 using CefSharp;
-using TweetDuck.Browser.Adapters;
-using TweetDuck.Browser.Bridge;
 using TweetDuck.Browser.Handling;
 using TweetDuck.Controls;
-using TweetDuck.Plugins;
 using TweetDuck.Utils;
 using TweetLib.Core.Features.Notifications;
-using TweetLib.Core.Features.Plugins;
-using TweetLib.Core.Features.Plugins.Enums;
-using TweetLib.Core.Features.Twitter;
 
 namespace TweetDuck.Browser.Notification {
-	abstract partial class FormNotificationMain : FormNotificationBase {
-		private readonly PluginManager plugins;
+	abstract partial class FormNotificationMain : FormNotificationBase, CustomKeyboardHandler.IBrowserKeyHandler {
+		protected sealed class NotificationInterfaceImpl : INotificationInterface {
+			public bool FreezeTimer {
+				get => notification.FreezeTimer;
+				set => notification.FreezeTimer = value;
+			}
+
+			public bool IsHovered => notification.IsCursorOverBrowser;
+
+			private readonly FormNotificationBase notification;
+
+			public NotificationInterfaceImpl(FormNotificationBase notification) {
+				this.notification = notification;
+			}
+
+			public void DisplayTooltip(string text) {
+				notification.InvokeAsyncSafe(() => notification.DisplayTooltip(text));
+			}
+
+			public void FinishCurrentNotification() {
+				notification.InvokeAsyncSafe(notification.FinishCurrentNotification);
+			}
+
+			public void ShowTweetDetail() {
+				notification.InvokeAsyncSafe(notification.ShowTweetDetail);
+			}
+		}
+
+		private static int FontSizeLevel {
+			get => NotificationBrowser.FontSize switch {
+				"largest"  => 4,
+				"large"    => 3,
+				"small"    => 1,
+				"smallest" => 0,
+				_          => 2
+			};
+		}
+
 		private readonly int timerBarHeight;
 
 		protected int timeLeft, totalTime;
@@ -59,29 +89,21 @@ private int BaseClientHeight {
 			};
 		}
 
-		protected virtual string BodyClasses => IsCursorOverBrowser ? "td-notification td-hover" : "td-notification";
-
 		public Size BrowserSize => Config.DisplayNotificationTimer ? new Size(ClientSize.Width, ClientSize.Height - timerBarHeight) : ClientSize;
 
-		protected FormNotificationMain(FormBrowser owner, PluginManager pluginManager, bool enableContextMenu) : base(owner, enableContextMenu) {
+		protected FormNotificationMain(FormBrowser owner, CreateBrowserImplFunc createBrowserImpl) : base(owner, createBrowserImpl) {
 			InitializeComponent();
 
-			this.plugins = pluginManager;
 			this.timerBarHeight = BrowserUtils.Scale(4, DpiScale);
 
-			browser.KeyboardHandler = new KeyboardHandlerNotification(this);
-			browser.RegisterJsBridge("$TD", new TweetDeckBridge.Notification(owner, this));
+			browser.KeyboardHandler = new CustomKeyboardHandler(this);
 
 			browser.LoadingStateChanged += Browser_LoadingStateChanged;
 
-			plugins.Register(PluginEnvironment.Notification, new PluginDispatcher(browser, url => TwitterUrls.IsTweetDeck(url) && url != BlankURL));
-
 			mouseHookDelegate = MouseHookProc;
 			Disposed += (sender, args) => StopMouseHook(true);
 		}
 
-		// helpers
-
 		private void SetOpacity(int opacity) {
 			if (currentOpacity != opacity) {
 				currentOpacity = opacity;
@@ -113,7 +135,7 @@ private IntPtr MouseHookProc(int nCode, IntPtr wParam, IntPtr lParam) {
 					int delta = BrowserUtils.Scale(NativeMethods.GetMouseHookData(lParam), Config.NotificationScrollSpeed * 0.01);
 
 					if (Config.EnableSmoothScrolling) {
-						browser.ExecuteJsAsync("window.TDGF_scrollSmoothly", (int) Math.Round(-delta / 0.6));
+						browser.BrowserCore.ExecuteScriptAsync("window.TDGF_scrollSmoothly", (int) Math.Round(-delta / 0.6));
 					}
 					else {
 						browser.SendMouseWheelEvent(0, 0, 0, delta, CefEventFlags.None);
@@ -158,7 +180,7 @@ private void FormNotification_FormClosing(object sender, FormClosingEventArgs e)
 		}
 
 		private void Browser_LoadingStateChanged(object sender, LoadingStateChangedEventArgs e) {
-			if (!e.IsLoading && browser.Address != BlankURL) {
+			if (!e.IsLoading && browser.Address != NotificationBrowser.BlankURL) {
 				this.InvokeSafe(() => {
 					Visible = true; // ensures repaint before moving the window to a visible location
 					timerDisplayDelay.Start();
@@ -236,13 +258,6 @@ public override void ResumeNotification() {
 			}
 		}
 
-		protected override string GetTweetHTML(DesktopNotification tweet) {
-			return tweet.GenerateHtml(BodyClasses, HeadLayout, Config.CustomNotificationCSS, plugins.NotificationInjections, new string[] {
-				PropertyBridge.GenerateScript(PropertyBridge.Environment.Notification),
-				CefScriptExecutor.GetBootstrapScript("notification", includeStylesheets: false)
-			});
-		}
-
 		protected override void LoadTweet(DesktopNotification tweet) {
 			timerProgress.Stop();
 			totalTime = timeLeft = tweet.GetDisplayDuration(Config.NotificationDurationValue);
@@ -278,5 +293,24 @@ protected virtual void OnNotificationReady() {
 			PrepareAndDisplayWindow();
 			timerProgress.Start();
 		}
+
+		bool CustomKeyboardHandler.IBrowserKeyHandler.HandleBrowserKey(Keys key) {
+			switch (key) {
+				case Keys.Enter:
+					this.InvokeAsyncSafe(FinishCurrentNotification);
+					return true;
+
+				case Keys.Escape:
+					this.InvokeAsyncSafe(HideNotification);
+					return true;
+
+				case Keys.Space:
+					this.InvokeAsyncSafe(() => FreezeTimer = !FreezeTimer);
+					return true;
+
+				default:
+					return false;
+			}
+		}
 	}
 }
diff --git a/Browser/Notification/FormNotificationTweet.cs b/Browser/Notification/FormNotificationTweet.cs
index efedfb1a..a4b79bad 100644
--- a/Browser/Notification/FormNotificationTweet.cs
+++ b/Browser/Notification/FormNotificationTweet.cs
@@ -3,11 +3,17 @@
 using System.Drawing;
 using System.Windows.Forms;
 using TweetDuck.Utils;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.TweetDeck;
 
 namespace TweetDuck.Browser.Notification {
 	sealed partial class FormNotificationTweet : FormNotificationMain {
+		private static NotificationBrowser CreateBrowserImpl(IBrowserComponent browserComponent, INotificationInterface notificationInterface, ITweetDeckInterface tweetDeckInterface, PluginManager pluginManager) {
+			return new NotificationBrowser.Tweet(browserComponent, notificationInterface, tweetDeckInterface, pluginManager);
+		}
+
 		private const int NonIntrusiveIdleLimit = 30;
 		private const int TrimMinimum = 32;
 
@@ -30,7 +36,7 @@ protected override bool CanDragWindow {
 		private bool needsTrim;
 		private bool hasTemporarilyMoved;
 
-		public FormNotificationTweet(FormBrowser owner, PluginManager pluginManager) : base(owner, pluginManager, true) {
+		public FormNotificationTweet(FormBrowser owner, ITweetDeckInterface tweetDeckInterface, PluginManager pluginManager) : base(owner, (form, browserComponent) => CreateBrowserImpl(browserComponent, new NotificationInterfaceImpl(form), tweetDeckInterface, pluginManager)) {
 			InitializeComponent();
 
 			Config.MuteToggled += Config_MuteToggled;
diff --git a/Browser/Notification/Screenshot/FormNotificationScreenshotable.cs b/Browser/Notification/Screenshot/FormNotificationScreenshotable.cs
index fb382477..ae350d1b 100644
--- a/Browser/Notification/Screenshot/FormNotificationScreenshotable.cs
+++ b/Browser/Notification/Screenshot/FormNotificationScreenshotable.cs
@@ -4,33 +4,30 @@
 using System.Threading.Tasks;
 using CefSharp;
 using CefSharp.DevTools.Page;
-using TweetDuck.Browser.Adapters;
 using TweetDuck.Controls;
 using TweetDuck.Dialogs;
-using TweetDuck.Resources;
 using TweetDuck.Utils;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Resources;
 
 namespace TweetDuck.Browser.Notification.Screenshot {
 	sealed class FormNotificationScreenshotable : FormNotificationBase {
+		private static NotificationBrowser CreateBrowserImpl( IBrowserComponent browserComponent, PluginManager pluginManager) {
+			return new NotificationBrowser.Screenshot(browserComponent, pluginManager.NotificationInjections);
+		}
+
 		protected override bool CanDragWindow => false;
 
-		private readonly PluginManager plugins;
 		private int height;
 
-		public FormNotificationScreenshotable(Action callback, FormBrowser owner, PluginManager pluginManager, string html, int width) : base(owner, false) {
-			this.plugins = pluginManager;
-
+		public FormNotificationScreenshotable(Action callback, FormBrowser owner, PluginManager pluginManager, string html, int width) : base(owner, (_, browserComponent) => CreateBrowserImpl(browserComponent, pluginManager)) {
 			int realWidth = BrowserUtils.Scale(width, DpiScale);
 
-			browser.RegisterJsBridge("$TD_NotificationScreenshot", new ScreenshotBridge(this, SetScreenshotHeight, callback));
-
-			browser.LoadingStateChanged += (sender, args) => {
-				if (args.IsLoading) {
-					return;
-				}
+			browserComponent.AttachBridgeObject("$TD_NotificationScreenshot", new ScreenshotBridge(this, SetScreenshotHeight, callback));
 
+			browserComponent.BrowserLoaded += (sender, args) => {
 				string script = ResourceUtils.ReadFileOrNull("notification/screenshot/screenshot.js");
 
 				if (script == null) {
@@ -38,18 +35,14 @@ public FormNotificationScreenshotable(Action callback, FormBrowser owner, Plugin
 					return;
 				}
 
-				using IFrame frame = args.Browser.MainFrame;
-				CefScriptExecutor.RunScript(frame, script.Replace("{width}", realWidth.ToString()).Replace("1/*FRAMES*/", TweetScreenshotManager.WaitFrames.ToString()), "gen:screenshot");
+				string substituted = script.Replace("{width}", realWidth.ToString()).Replace("1/*FRAMES*/", TweetScreenshotManager.WaitFrames.ToString());
+				browserComponent.RunScript("gen:screenshot", substituted);
 			};
 
 			SetNotificationSize(realWidth, 1024);
 			LoadTweet(new DesktopNotification(string.Empty, string.Empty, string.Empty, html, 0, string.Empty, string.Empty));
 		}
 
-		protected override string GetTweetHTML(DesktopNotification tweet) {
-			return tweet.GenerateHtml("td-screenshot", HeadLayout, Config.CustomNotificationCSS, plugins.NotificationInjections, Array.Empty<string>());
-		}
-
 		private void SetScreenshotHeight(int browserHeight) {
 			this.height = BrowserUtils.Scale(browserHeight, SizeScale);
 		}
diff --git a/Browser/Notification/SoundNotification.cs b/Browser/Notification/SoundNotification.cs
index c448d68f..10b69a7a 100644
--- a/Browser/Notification/SoundNotification.cs
+++ b/Browser/Notification/SoundNotification.cs
@@ -1,19 +1,36 @@
-using System;
-using System.Drawing;
+using System.Drawing;
 using System.IO;
 using System.Windows.Forms;
-using CefSharp;
-using TweetDuck.Browser.Data;
+using TweetDuck.Browser.Adapters;
 using TweetDuck.Controls;
 using TweetDuck.Dialogs;
 using TweetDuck.Dialogs.Settings;
 using TweetDuck.Management;
+using TweetLib.Core.Features.TweetDeck;
 
 namespace TweetDuck.Browser.Notification {
-	static class SoundNotification {
+	sealed class SoundNotification : ISoundNotificationHandler {
 		public const string SupportedFormats = "*.wav;*.ogg;*.mp3;*.flac;*.opus;*.weba;*.webm";
 
-		public static Func<IResourceHandler> CreateFileHandler(string path) {
+		private readonly CefResourceHandlerRegistry registry;
+
+		public SoundNotification(CefResourceHandlerRegistry registry) {
+			this.registry = registry;
+		}
+
+		public void Unregister(string url) {
+			registry.Unregister(url);
+		}
+
+		public void Register(string url, string path) {
+			var fileHandler = CreateFileHandler(path);
+			if (fileHandler.HasValue) {
+				var (data, mimeType) = fileHandler.Value;
+				registry.RegisterStatic(url, data, mimeType);
+			}
+		}
+
+		private static (byte[] data, string mimeType)? CreateFileHandler(string path) {
 			string mimeType = Path.GetExtension(path) switch {
 				".weba" => "audio/webm",
 				".webm" => "audio/webm",
@@ -26,7 +43,7 @@ public static Func<IResourceHandler> CreateFileHandler(string path) {
 			};
 
 			try {
-				return ResourceHandlers.ForBytes(File.ReadAllBytes(path), mimeType);
+				return (File.ReadAllBytes(path), mimeType);
 			} catch {
 				FormBrowser browser = FormManager.TryFind<FormBrowser>();
 
diff --git a/Browser/TweetDeckBrowser.cs b/Browser/TweetDeckBrowser.cs
index 61c88635..572c638a 100644
--- a/Browser/TweetDeckBrowser.cs
+++ b/Browser/TweetDeckBrowser.cs
@@ -1,31 +1,25 @@
 using System;
 using System.Drawing;
-using System.Text;
-using System.Windows.Forms;
 using CefSharp;
 using CefSharp.WinForms;
 using TweetDuck.Browser.Adapters;
-using TweetDuck.Browser.Bridge;
-using TweetDuck.Browser.Data;
 using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Handling.General;
 using TweetDuck.Browser.Notification;
 using TweetDuck.Configuration;
-using TweetDuck.Controls;
-using TweetDuck.Plugins;
 using TweetDuck.Utils;
 using TweetLib.Core.Features.Plugins;
-using TweetLib.Core.Features.Plugins.Enums;
+using TweetLib.Core.Features.TweetDeck;
 using TweetLib.Core.Features.Twitter;
-using TweetLib.Utils.Static;
+using TweetLib.Core.Systems.Updates;
+using IContextMenuHandler = TweetLib.Browser.Interfaces.IContextMenuHandler;
+using IResourceRequestHandler = TweetLib.Browser.Interfaces.IResourceRequestHandler;
+using TweetDeckBrowserImpl = TweetLib.Core.Features.TweetDeck.TweetDeckBrowser;
 
 namespace TweetDuck.Browser {
 	sealed class TweetDeckBrowser : IDisposable {
-		private static UserConfig Config => Program.Config.User;
+		public static readonly Color BackgroundColor = Color.FromArgb(28, 99, 153);
 
-		private const string NamespaceTweetDeck = "tweetdeck";
-
-		public bool Ready { get; private set; }
+		public bool Ready => browserComponent.Ready;
 
 		public bool Enabled {
 			get => browser.Enabled;
@@ -43,53 +37,62 @@ public bool IsTweetDeckWebsite {
 			}
 		}
 
+		public TweetDeckFunctions Functions => browserImpl.Functions;
+
+		private readonly CefBrowserComponent browserComponent;
+		private readonly TweetDeckBrowserImpl browserImpl;
 		private readonly ChromiumWebBrowser browser;
-		private readonly ResourceHandlers resourceHandlers;
-
-		private string prevSoundNotificationPath = null;
-
-		public TweetDeckBrowser(FormBrowser owner, PluginManager plugins, TweetDeckBridge tdBridge, UpdateBridge updateBridge) {
-			var resourceRequestHandler = new ResourceRequestHandlerBrowser();
-			resourceHandlers = resourceRequestHandler.ResourceHandlers;
-
-			resourceHandlers.Register(FormNotificationBase.AppLogo);
-			resourceHandlers.Register(TwitterUtils.LoadingSpinner);
 
+		public TweetDeckBrowser(FormBrowser owner, PluginManager pluginManager, ITweetDeckInterface tweetDeckInterface, UpdateChecker updateChecker) {
 			RequestHandlerBrowser requestHandler = new RequestHandlerBrowser();
 
 			this.browser = new ChromiumWebBrowser(TwitterUrls.TweetDeck) {
 				DialogHandler = new FileDialogHandler(),
 				DragHandler = new DragHandlerBrowser(requestHandler),
-				MenuHandler = new ContextMenuBrowser(owner),
-				JsDialogHandler = new JavaScriptDialogHandler(),
-				KeyboardHandler = new KeyboardHandlerBrowser(owner),
-				LifeSpanHandler = new CustomLifeSpanHandler(),
-				RequestHandler = requestHandler,
-				ResourceRequestHandlerFactory = resourceRequestHandler.SelfFactory
+				KeyboardHandler = new CustomKeyboardHandler(owner),
+				RequestHandler = requestHandler
 			};
 
-			this.browser.LoadingStateChanged += browser_LoadingStateChanged;
-			this.browser.FrameLoadStart += browser_FrameLoadStart;
-			this.browser.FrameLoadEnd += browser_FrameLoadEnd;
-			this.browser.LoadError += browser_LoadError;
-
-			this.browser.RegisterJsBridge("$TD", tdBridge);
-			this.browser.RegisterJsBridge("$TDU", updateBridge);
-
 			// ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable
-			this.browser.BrowserSettings.BackgroundColor = (uint) TwitterUtils.BackgroundColor.ToArgb();
-			this.browser.Dock = DockStyle.None;
-			this.browser.Location = ControlExtensions.InvisibleLocation;
-			this.browser.SetupZoomEvents();
+			this.browser.BrowserSettings.BackgroundColor = (uint) BackgroundColor.ToArgb();
+
+			var extraContext = new TweetDeckExtraContext();
+			var resourceHandlerRegistry = new CefResourceHandlerRegistry();
+			var soundNotificationHandler = new SoundNotification(resourceHandlerRegistry);
+
+			this.browserComponent = new ComponentImpl(browser, owner, extraContext, resourceHandlerRegistry);
+			this.browserImpl = new TweetDeckBrowserImpl(browserComponent, tweetDeckInterface, extraContext, soundNotificationHandler, pluginManager, updateChecker);
+
+			if (Arguments.HasFlag(Arguments.ArgIgnoreGDPR)) {
+				browserComponent.PageLoadEnd += (sender, args) => {
+					if (TwitterUrls.IsTweetDeck(args.Url)) {
+						browserImpl.ScriptExecutor.RunScript("gen:gdpr", "TD.storage.Account.prototype.requiresConsent = function() { return false; }");
+					}
+				};
+			}
 
 			owner.Controls.Add(browser);
-			plugins.Register(PluginEnvironment.Browser, new PluginDispatcher(browser, TwitterUrls.IsTweetDeck));
-
-			Config.MuteToggled += Config_MuteToggled;
-			Config.SoundNotificationChanged += Config_SoundNotificationInfoChanged;
 		}
 
-		// setup and management
+		private sealed class ComponentImpl : CefBrowserComponent {
+			private readonly FormBrowser owner;
+			private readonly TweetDeckExtraContext extraContext;
+			private readonly CefResourceHandlerRegistry registry;
+
+			public ComponentImpl(ChromiumWebBrowser browser, FormBrowser owner, TweetDeckExtraContext extraContext, CefResourceHandlerRegistry registry) : base(browser) {
+				this.owner = owner;
+				this.extraContext = extraContext;
+				this.registry = registry;
+			}
+
+			protected override ContextMenuBase SetupContextMenu(IContextMenuHandler handler) {
+				return new ContextMenuBrowser(owner, handler, extraContext);
+			}
+
+			protected override CefResourceHandlerFactory SetupResourceHandlerFactory(IResourceRequestHandler handler) {
+				return new CefResourceHandlerFactory(handler, registry);
+			}
+		}
 
 		public void PrepareSize(Size size) {
 			if (!Ready) {
@@ -97,178 +100,33 @@ public void PrepareSize(Size size) {
 			}
 		}
 
-		private void OnBrowserReady() {
-			if (!Ready) {
-				browser.Location = Point.Empty;
-				browser.Dock = DockStyle.Fill;
-				Ready = true;
-			}
+		public void Dispose() {
+			browserImpl.Dispose();
+			browser.Dispose();
 		}
 
 		public void Focus() {
 			browser.Focus();
 		}
 
-		public void Dispose() {
-			Config.MuteToggled -= Config_MuteToggled;
-			Config.SoundNotificationChanged -= Config_SoundNotificationInfoChanged;
-
-			browser.Dispose();
+		public void OpenDevTools() {
+			browser.OpenDevToolsCustom();
 		}
 
-		// event handlers
-
-		private void browser_LoadingStateChanged(object sender, LoadingStateChangedEventArgs e) {
-			if (!e.IsLoading) {
-				foreach (string word in TwitterUtils.DictionaryWords) {
-					browser.AddWordToDictionary(word);
-				}
-
-				browser.BeginInvoke(new Action(OnBrowserReady));
-				browser.LoadingStateChanged -= browser_LoadingStateChanged;
-			}
+		public void ReloadToTweetDeck() {
+			browserImpl.ReloadToTweetDeck();
 		}
 
-		private void browser_FrameLoadStart(object sender, FrameLoadStartEventArgs e) {
-			IFrame frame = e.Frame;
-
-			if (frame.IsMain) {
-				string url = frame.Url;
-
-				if (TwitterUrls.IsTweetDeck(url) || (TwitterUrls.IsTwitter(url) && !TwitterUrls.IsTwitterLogin2Factor(url))) {
-					frame.ExecuteJavaScriptAsync(TwitterUtils.BackgroundColorOverride);
-				}
-			}
+		public void SaveVideo(string url, string username) {
+			browserImpl.FileDownloadManager.SaveVideo(url, username);
 		}
 
-		private void browser_FrameLoadEnd(object sender, FrameLoadEndEventArgs e) {
-			IFrame frame = e.Frame;
-			string url = frame.Url;
-
-			if (frame.IsMain) {
-				if (TwitterUrls.IsTweetDeck(url)) {
-					UpdateProperties();
-					CefScriptExecutor.RunBootstrap(frame, NamespaceTweetDeck);
-
-					TweetDeckBridge.ResetStaticProperties();
-
-					if (Arguments.HasFlag(Arguments.ArgIgnoreGDPR)) {
-						CefScriptExecutor.RunScript(frame, "TD.storage.Account.prototype.requiresConsent = function(){ return false; }", "gen:gdpr");
-					}
-
-					if (Config.FirstRun) {
-						CefScriptExecutor.RunBootstrap(frame, "introduction");
-					}
-				}
-				else if (TwitterUrls.IsTwitter(url)) {
-					CefScriptExecutor.RunBootstrap(frame, "login");
-				}
-
-				CefScriptExecutor.RunBootstrap(frame, "update");
-			}
-		}
-
-		private void browser_LoadError(object sender, LoadErrorEventArgs e) {
-			if (e.ErrorCode == CefErrorCode.Aborted) {
-				return;
-			}
-
-			if (!e.FailedUrl.StartsWith("td://resources/error/", StringComparison.Ordinal)) {
-				string errorName = Enum.GetName(typeof(CefErrorCode), e.ErrorCode);
-				string errorTitle = StringUtils.ConvertPascalCaseToScreamingSnakeCase(errorName ?? string.Empty);
-				browser.Load("td://resources/error/error.html#" + Uri.EscapeDataString(errorTitle));
-			}
-		}
-
-		private void Config_MuteToggled(object sender, EventArgs e) {
-			UpdateProperties();
-		}
-
-		private void Config_SoundNotificationInfoChanged(object sender, EventArgs e) {
-			const string soundUrl = "https://ton.twimg.com/tduck/updatesnd";
-
-			bool hasCustomSound = Config.IsCustomSoundNotificationSet;
-			string newNotificationPath = Config.NotificationSoundPath;
-
-			if (prevSoundNotificationPath != newNotificationPath) {
-				prevSoundNotificationPath = newNotificationPath;
-
-				if (hasCustomSound) {
-					resourceHandlers.Register(soundUrl, SoundNotification.CreateFileHandler(newNotificationPath));
-				}
-				else {
-					resourceHandlers.Unregister(soundUrl);
-				}
-			}
-
-			browser.ExecuteJsAsync("TDGF_setSoundNotificationData", hasCustomSound, Config.NotificationSoundVolume);
-		}
-
-		// external handling
-
 		public void HideVideoOverlay(bool focus) {
 			if (focus) {
 				browser.GetBrowser().GetHost().SendFocusEvent(true);
 			}
 
-			browser.ExecuteJsAsync("$('#td-video-player-overlay').remove()");
-		}
-
-		// javascript calls
-
-		public void ReloadToTweetDeck() {
-			browser.ExecuteJsAsync($"if(window.TDGF_reload)window.TDGF_reload();else window.location.href='{TwitterUrls.TweetDeck}'");
-		}
-
-		public void OnModulesLoaded(string moduleNamespace) {
-			if (moduleNamespace == NamespaceTweetDeck) {
-				ReinjectCustomCSS(Config.CustomBrowserCSS);
-				Config_SoundNotificationInfoChanged(null, EventArgs.Empty);
-			}
-		}
-
-		public void UpdateProperties() {
-			browser.ExecuteJsAsync(PropertyBridge.GenerateScript(PropertyBridge.Environment.Browser));
-		}
-
-		public void ReinjectCustomCSS(string css) {
-			browser.ExecuteJsAsync("TDGF_reinjectCustomCSS", css?.Replace(Environment.NewLine, " ") ?? string.Empty);
-		}
-
-		public void OnMouseClickExtra(IntPtr param) {
-			browser.ExecuteJsAsync("TDGF_onMouseClickExtra", (param.ToInt32() >> 16) & 0xFFFF);
-		}
-
-		public void ShowTweetDetail(string columnId, string chirpId, string fallbackUrl) {
-			browser.ExecuteJsAsync("TDGF_showTweetDetail", columnId, chirpId, fallbackUrl);
-		}
-
-		public void AddSearchColumn(string query) {
-			browser.ExecuteJsAsync("TDGF_performSearch", query);
-		}
-
-		public void TriggerTweetScreenshot(string columnId, string chirpId) {
-			browser.ExecuteJsAsync("TDGF_triggerScreenshot", columnId, chirpId);
-		}
-
-		public void ReloadColumns() {
-			browser.ExecuteJsAsync("TDGF_reloadColumns()");
-		}
-
-		public void PlaySoundNotification() {
-			browser.ExecuteJsAsync("TDGF_playSoundNotification()");
-		}
-
-		public void ApplyROT13() {
-			browser.ExecuteJsAsync("TDGF_applyROT13()");
-		}
-
-		public void ShowUpdateNotification(string versionTag, string releaseNotes) {
-			browser.ExecuteJsAsync("TDUF_displayNotification", versionTag, Convert.ToBase64String(Encoding.GetEncoding("iso-8859-1").GetBytes(releaseNotes)));
-		}
-
-		public void OpenDevTools() {
-			browser.OpenDevToolsCustom();
+			browserImpl.ScriptExecutor.RunScript("gen:hidevideo", "$('#td-video-player-overlay').remove()");
 		}
 	}
 }
diff --git a/Browser/TweetDeckInterfaceImpl.cs b/Browser/TweetDeckInterfaceImpl.cs
new file mode 100644
index 00000000..548d7fc3
--- /dev/null
+++ b/Browser/TweetDeckInterfaceImpl.cs
@@ -0,0 +1,81 @@
+using System.Threading.Tasks;
+using System.Windows.Forms;
+using CefSharp;
+using TweetDuck.Controls;
+using TweetDuck.Dialogs;
+using TweetDuck.Management;
+using TweetDuck.Utils;
+using TweetLib.Core.Features.Notifications;
+using TweetLib.Core.Features.TweetDeck;
+
+namespace TweetDuck.Browser {
+	sealed class TweetDeckInterfaceImpl : ITweetDeckInterface {
+		private readonly FormBrowser form;
+
+		public TweetDeckInterfaceImpl(FormBrowser form) {
+			this.form = form;
+		}
+
+		public void Alert(string type, string contents) {
+			MessageBoxIcon icon = type switch {
+				"error"   => MessageBoxIcon.Error,
+				"warning" => MessageBoxIcon.Warning,
+				"info"    => MessageBoxIcon.Information,
+				_         => MessageBoxIcon.None
+			};
+
+			FormMessage.Show("TweetDuck Browser Message", contents, icon, FormMessage.OK);
+		}
+
+		public void DisplayTooltip(string text) {
+			form.InvokeAsyncSafe(() => form.DisplayTooltip(text));
+		}
+
+		public void FixClipboard() {
+			form.InvokeAsyncSafe(ClipboardManager.StripHtmlStyles);
+		}
+
+		public int GetIdleSeconds() {
+			return NativeMethods.GetIdleSeconds();
+		}
+
+		public void OnIntroductionClosed(bool showGuide) {
+			form.InvokeAsyncSafe(() => form.OnIntroductionClosed(showGuide));
+		}
+
+		public void OnSoundNotification() {
+			form.InvokeAsyncSafe(form.OnTweetNotification);
+		}
+
+		public void OpenContextMenu() {
+			form.InvokeAsyncSafe(form.OpenContextMenu);
+		}
+
+		public void OpenProfileImport() {
+			form.InvokeAsyncSafe(form.OpenProfileImport);
+		}
+
+		public void PlayVideo(string videoUrl, string tweetUrl, string username, object callShowOverlay) {
+			form.InvokeAsyncSafe(() => form.PlayVideo(videoUrl, tweetUrl, username, (IJavascriptCallback) callShowOverlay));
+		}
+
+		public void ScreenshotTweet(string html, int width) {
+			form.InvokeAsyncSafe(() => form.OnTweetScreenshotReady(html, width));
+		}
+
+		public void ShowDesktopNotification(DesktopNotification notification) {
+			form.InvokeAsyncSafe(() => {
+				form.OnTweetNotification();
+				form.ShowDesktopNotification(notification);
+			});
+		}
+
+		public void StopVideo() {
+			form.InvokeAsyncSafe(form.StopVideo);
+		}
+
+		public Task ExecuteCallback(object callback, params object[] parameters) {
+			return ((IJavascriptCallback) callback).ExecuteAsync(parameters);
+		}
+	}
+}
diff --git a/Configuration/ConfigManager.cs b/Configuration/ConfigManager.cs
index 679ca1e8..358074d3 100644
--- a/Configuration/ConfigManager.cs
+++ b/Configuration/ConfigManager.cs
@@ -1,6 +1,6 @@
 using System;
 using System.Drawing;
-using TweetDuck.Browser.Data;
+using TweetDuck.Dialogs;
 using TweetLib.Core.Features.Plugins.Config;
 using TweetLib.Core.Systems.Configuration;
 using TweetLib.Utils.Serialization.Converters;
@@ -8,6 +8,14 @@
 
 namespace TweetDuck.Configuration {
 	sealed class ConfigManager : IConfigManager {
+		internal sealed class Paths {
+			public string UserConfig { get; set; }
+			public string SystemConfig { get; set; }
+			public string PluginConfig { get; set; }
+		}
+
+		public Paths FilePaths { get; }
+
 		public UserConfig User { get; }
 		public SystemConfig System { get; }
 		public PluginConfig Plugins { get; }
@@ -20,15 +28,17 @@ sealed class ConfigManager : IConfigManager {
 
 		private readonly IConfigInstance<BaseConfig>[] infoList;
 
-		public ConfigManager() {
-			User = new UserConfig(this);
-			System = new SystemConfig(this);
-			Plugins = new PluginConfig(this);
+		public ConfigManager(UserConfig userConfig, Paths paths) {
+			FilePaths = paths;
+
+			User = userConfig;
+			System = new SystemConfig();
+			Plugins = new PluginConfig();
 
 			infoList = new IConfigInstance<BaseConfig>[] {
-				infoUser = new FileConfigInstance<UserConfig>(Program.UserConfigFilePath, User, "program options"),
-				infoSystem = new FileConfigInstance<SystemConfig>(Program.SystemConfigFilePath, System, "system options"),
-				infoPlugins = new PluginConfigInstance<PluginConfig>(Program.PluginConfigFilePath, Plugins)
+				infoUser = new FileConfigInstance<UserConfig>(paths.UserConfig, User, "program options"),
+				infoSystem = new FileConfigInstance<SystemConfig>(paths.SystemConfig, System, "system options"),
+				infoPlugins = new PluginConfigInstance<PluginConfig>(paths.PluginConfig, Plugins)
 			};
 
 			// TODO refactor further
@@ -70,7 +80,15 @@ public void ReloadAll() {
 			infoPlugins.Reload();
 		}
 
-		void IConfigManager.TriggerProgramRestartRequested() {
+		public void Save(BaseConfig instance) {
+			((IConfigManager) this).GetInstanceInfo(instance).Save();
+		}
+
+		public void Reset(BaseConfig instance) {
+			((IConfigManager) this).GetInstanceInfo(instance).Reset();
+		}
+
+		public void TriggerProgramRestartRequested() {
 			ProgramRestartRequested?.Invoke(this, EventArgs.Empty);
 		}
 
@@ -79,4 +97,14 @@ IConfigInstance<BaseConfig> IConfigManager.GetInstanceInfo(BaseConfig instance)
 			return Array.Find(infoList, info => info.Instance.GetType() == instanceType); // TODO handle null
 		}
 	}
+
+	static class ConfigManagerExtensions {
+		public static void Save(this BaseConfig instance) {
+			Program.Config.Save(instance);
+		}
+
+		public static void Reset(this BaseConfig instance) {
+			Program.Config.Reset(instance);
+		}
+	}
 }
diff --git a/Configuration/PluginConfig.cs b/Configuration/PluginConfig.cs
index 43b833a0..9e1283de 100644
--- a/Configuration/PluginConfig.cs
+++ b/Configuration/PluginConfig.cs
@@ -22,10 +22,8 @@ sealed class PluginConfig : BaseConfig, IPluginConfig {
 
 		// END OF CONFIG
 
-		public PluginConfig(IConfigManager configManager) : base(configManager) {}
-
-		protected override BaseConfig ConstructWithDefaults(IConfigManager configManager) {
-			return new PluginConfig(configManager);
+		protected override BaseConfig ConstructWithDefaults() {
+			return new PluginConfig();
 		}
 
 		// INTERFACE IMPLEMENTATION
@@ -40,7 +38,7 @@ void IPluginConfig.Reset(IEnumerable<string> newDisabledPlugins) {
 		public void SetEnabled(Plugin plugin, bool enabled) {
 			if ((enabled && disabled.Remove(plugin.Identifier)) || (!enabled && disabled.Add(plugin.Identifier))) {
 				PluginChangedState?.Invoke(this, new PluginChangedStateEventArgs(plugin, enabled));
-				Save();
+				this.Save();
 			}
 		}
 
diff --git a/Configuration/SystemConfig.cs b/Configuration/SystemConfig.cs
index b9aea015..5ddebef3 100644
--- a/Configuration/SystemConfig.cs
+++ b/Configuration/SystemConfig.cs
@@ -2,8 +2,6 @@
 
 namespace TweetDuck.Configuration {
 	sealed class SystemConfig : BaseConfig {
-		// CONFIGURATION DATA
-
 		private bool _hardwareAcceleration = true;
 
 		public bool ClearCacheAutomatically { get; set; } = true;
@@ -13,15 +11,13 @@ sealed class SystemConfig : BaseConfig {
 
 		public bool HardwareAcceleration {
 			get => _hardwareAcceleration;
-			set => UpdatePropertyWithRestartRequest(ref _hardwareAcceleration, value);
+			set => UpdatePropertyWithCallback(ref _hardwareAcceleration, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		// END OF CONFIG
 
-		public SystemConfig(IConfigManager configManager) : base(configManager) {}
-
-		protected override BaseConfig ConstructWithDefaults(IConfigManager configManager) {
-			return new SystemConfig(configManager);
+		protected override BaseConfig ConstructWithDefaults() {
+			return new SystemConfig();
 		}
 	}
 }
diff --git a/Configuration/UserConfig.cs b/Configuration/UserConfig.cs
index 58b10766..86c06dec 100644
--- a/Configuration/UserConfig.cs
+++ b/Configuration/UserConfig.cs
@@ -2,17 +2,16 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Drawing;
 using TweetDuck.Browser;
-using TweetDuck.Browser.Data;
 using TweetDuck.Controls;
+using TweetDuck.Dialogs;
+using TweetLib.Core.Application;
 using TweetLib.Core.Features.Notifications;
 using TweetLib.Core.Features.Twitter;
 using TweetLib.Core.Systems.Configuration;
 
 namespace TweetDuck.Configuration {
-	sealed class UserConfig : BaseConfig {
-		// CONFIGURATION DATA
-
-		public bool FirstRun            { get; set; } = true;
+	sealed class UserConfig : BaseConfig, IAppUserConfiguration {
+		public bool FirstRun { get; set; } = true;
 
 		[SuppressMessage("ReSharper", "UnusedMember.Global")]
 		public bool AllowDataCollection { get; set; } = false;
@@ -122,32 +121,32 @@ public TrayIcon.Behavior TrayBehavior {
 
 		public bool EnableSmoothScrolling {
 			get => _enableSmoothScrolling;
-			set => UpdatePropertyWithRestartRequest(ref _enableSmoothScrolling, value);
+			set => UpdatePropertyWithCallback(ref _enableSmoothScrolling, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		public bool EnableTouchAdjustment {
 			get => _enableTouchAdjustment;
-			set => UpdatePropertyWithRestartRequest(ref _enableTouchAdjustment, value);
+			set => UpdatePropertyWithCallback(ref _enableTouchAdjustment, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		public bool EnableColorProfileDetection {
 			get => _enableColorProfileDetection;
-			set => UpdatePropertyWithRestartRequest(ref _enableColorProfileDetection, value);
+			set => UpdatePropertyWithCallback(ref _enableColorProfileDetection, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		public bool UseSystemProxyForAllConnections {
 			get => _useSystemProxyForAllConnections;
-			set => UpdatePropertyWithRestartRequest(ref _useSystemProxyForAllConnections, value);
+			set => UpdatePropertyWithCallback(ref _useSystemProxyForAllConnections, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		public string CustomCefArgs {
 			get => _customCefArgs;
-			set => UpdatePropertyWithRestartRequest(ref _customCefArgs, value);
+			set => UpdatePropertyWithCallback(ref _customCefArgs, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		public string SpellCheckLanguage {
 			get => _spellCheckLanguage;
-			set => UpdatePropertyWithRestartRequest(ref _spellCheckLanguage, value);
+			set => UpdatePropertyWithCallback(ref _spellCheckLanguage, value, Program.Config.TriggerProgramRestartRequested);
 		}
 
 		// EVENTS
@@ -156,13 +155,16 @@ public string SpellCheckLanguage {
 		public event EventHandler ZoomLevelChanged;
 		public event EventHandler TrayBehaviorChanged;
 		public event EventHandler SoundNotificationChanged;
+		public event EventHandler OptionsDialogClosed;
+
+		public void TriggerOptionsDialogClosed() {
+			OptionsDialogClosed?.Invoke(this, EventArgs.Empty);
+		}
 
 		// END OF CONFIG
 
-		public UserConfig(IConfigManager configManager) : base(configManager) {}
-
-		protected override BaseConfig ConstructWithDefaults(IConfigManager configManager) {
-			return new UserConfig(configManager);
+		protected override BaseConfig ConstructWithDefaults() {
+			return new UserConfig();
 		}
 	}
 }
diff --git a/Controls/ControlExtensions.cs b/Controls/ControlExtensions.cs
index e1c8c259..b2654a1c 100644
--- a/Controls/ControlExtensions.cs
+++ b/Controls/ControlExtensions.cs
@@ -17,7 +17,12 @@ public static void InvokeSafe(this Control control, Action func) {
 		}
 
 		public static void InvokeAsyncSafe(this Control control, Action func) {
-			control.BeginInvoke(func);
+			if (control.InvokeRequired) {
+				control.BeginInvoke(func);
+			}
+			else {
+				func();
+			}
 		}
 
 		public static float GetDPIScale(this Control control) {
diff --git a/Dialogs/FormAbout.cs b/Dialogs/FormAbout.cs
index 2982ba9a..1a31434e 100644
--- a/Dialogs/FormAbout.cs
+++ b/Dialogs/FormAbout.cs
@@ -1,9 +1,10 @@
-using System.ComponentModel;
+using System;
+using System.ComponentModel;
 using System.Drawing;
 using System.IO;
 using System.Windows.Forms;
 using TweetDuck.Management;
-using TweetDuck.Utils;
+using TweetLib.Core;
 
 namespace TweetDuck.Dialogs {
 	sealed partial class FormAbout : Form, FormManager.IAppDialog {
@@ -21,13 +22,15 @@ public FormAbout() {
 			labelTips.Links.Add(new LinkLabel.Link(0, labelTips.Text.Length, TipsLink));
 			labelIssues.Links.Add(new LinkLabel.Link(0, labelIssues.Text.Length, IssuesLink));
 
-			MemoryStream logoStream = new MemoryStream(Properties.Resources.avatar);
-			pictureLogo.Image = Image.FromStream(logoStream);
-			Disposed += (sender, args) => logoStream.Dispose();
+			try {
+				pictureLogo.Image = Image.FromFile(Path.Combine(App.ResourcesPath, "images/logo.png"));
+			} catch (Exception) {
+				// ignore
+			}
 		}
 
 		private void OnLinkClicked(object sender, LinkLabelLinkClickedEventArgs e) {
-			BrowserUtils.OpenExternalBrowser(e.Link.LinkData as string);
+			App.SystemHandler.OpenBrowser(e.Link.LinkData as string);
 		}
 
 		private void FormAbout_HelpRequested(object sender, HelpEventArgs hlpevent) {
diff --git a/Dialogs/FormGuide.cs b/Dialogs/FormGuide.cs
index b8faf6da..6aba986c 100644
--- a/Dialogs/FormGuide.cs
+++ b/Dialogs/FormGuide.cs
@@ -1,20 +1,18 @@
 using System.Drawing;
 using System.Windows.Forms;
-using CefSharp;
 using CefSharp.WinForms;
 using TweetDuck.Browser;
-using TweetDuck.Browser.Data;
+using TweetDuck.Browser.Adapters;
 using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Handling.General;
 using TweetDuck.Controls;
 using TweetDuck.Management;
 using TweetDuck.Utils;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features;
 
 namespace TweetDuck.Dialogs {
 	sealed partial class FormGuide : Form, FormManager.IAppDialog {
-		private const string GuideUrl = @"td://guide/index.html";
-
-		private static readonly ResourceLink DummyPage = new ResourceLink("http://td/dummy", ResourceHandlers.ForString(string.Empty));
+		private const string GuideUrl = "td://guide/index.html";
 
 		public static void Show(string hash = null) {
 			string url = GuideUrl + (string.IsNullOrEmpty(hash) ? string.Empty : "#" + hash);
@@ -37,36 +35,43 @@ public static void Show(string hash = null) {
 		private readonly ChromiumWebBrowser browser;
 		#pragma warning restore IDE0069 // Disposable fields should be disposed
 
-		private string nextUrl;
-
-		private FormGuide(string url, FormBrowser owner) {
+		private FormGuide(string url, Form owner) {
 			InitializeComponent();
 
 			Text = Program.BrandName + " Guide";
 			Size = new Size(owner.Size.Width * 3 / 4, owner.Size.Height * 3 / 4);
 			VisibleChanged += (sender, args) => this.MoveToCenter(owner);
 
-			var resourceRequestHandler = new ResourceRequestHandlerBase();
-			resourceRequestHandler.ResourceHandlers.Register(DummyPage);
-
-			this.browser = new ChromiumWebBrowser(url) {
-				MenuHandler = new ContextMenuGuide(),
-				JsDialogHandler = new JavaScriptDialogHandler(),
-				KeyboardHandler = new KeyboardHandlerBase(),
-				LifeSpanHandler = new CustomLifeSpanHandler(),
-				RequestHandler = new RequestHandlerBase(true),
-				ResourceRequestHandlerFactory = resourceRequestHandler.SelfFactory
+			browser = new ChromiumWebBrowser(url) {
+				KeyboardHandler = new CustomKeyboardHandler(null),
+				RequestHandler = new RequestHandlerBase(true)
 			};
 
-			browser.LoadingStateChanged += browser_LoadingStateChanged;
-
 			browser.BrowserSettings.BackgroundColor = (uint) BackColor.ToArgb();
-			browser.Dock = DockStyle.None;
-			browser.Location = ControlExtensions.InvisibleLocation;
-			browser.SetupZoomEvents();
+
+			var browserComponent = new ComponentImpl(browser);
+			var browserImpl = new BaseBrowser(browserComponent);
+
+			BrowserUtils.SetupDockOnLoad(browserComponent, browser);
 
 			Controls.Add(browser);
-			Disposed += (sender, args) => browser.Dispose();
+
+			Disposed += (sender, args) => {
+				browserImpl.Dispose();
+				browser.Dispose();
+			};
+		}
+
+		private sealed class ComponentImpl : CefBrowserComponent {
+			public ComponentImpl(ChromiumWebBrowser browser) : base(browser) {}
+
+			protected override ContextMenuBase SetupContextMenu(IContextMenuHandler handler) {
+				return new ContextMenuGuide(handler);
+			}
+
+			protected override CefResourceHandlerFactory SetupResourceHandlerFactory(IResourceRequestHandler handler) {
+				return new CefResourceHandlerFactory(handler, null);
+			}
 		}
 
 		protected override void Dispose(bool disposing) {
@@ -78,27 +83,7 @@ protected override void Dispose(bool disposing) {
 		}
 
 		private void Reload(string url) {
-			nextUrl = url;
-			browser.LoadingStateChanged += browser_LoadingStateChanged;
-			browser.Dock = DockStyle.None;
-			browser.Location = ControlExtensions.InvisibleLocation;
-			browser.Load(DummyPage.Url);
-		}
-
-		private void browser_LoadingStateChanged(object sender, LoadingStateChangedEventArgs e) {
-			if (!e.IsLoading) {
-				if (browser.Address == DummyPage.Url) {
-					browser.Load(nextUrl);
-				}
-				else {
-					this.InvokeAsyncSafe(() => {
-						browser.Location = Point.Empty;
-						browser.Dock = DockStyle.Fill;
-					});
-
-					browser.LoadingStateChanged -= browser_LoadingStateChanged;
-				}
-			}
+			browser.Load(url);
 		}
 	}
 }
diff --git a/Dialogs/FormPlugins.cs b/Dialogs/FormPlugins.cs
index efe0f9c5..ae99d7a3 100644
--- a/Dialogs/FormPlugins.cs
+++ b/Dialogs/FormPlugins.cs
@@ -92,7 +92,7 @@ private void flowLayoutPlugins_Resize(object sender, EventArgs e) {
 		}
 
 		private void btnOpenFolder_Click(object sender, EventArgs e) {
-			App.SystemHandler.OpenFileExplorer(pluginManager.PathCustomPlugins);
+			App.SystemHandler.OpenFileExplorer(pluginManager.CustomPluginFolder);
 		}
 
 		private void btnReload_Click(object sender, EventArgs e) {
diff --git a/Dialogs/FormSettings.cs b/Dialogs/FormSettings.cs
index 3630bf5b..e61de79d 100644
--- a/Dialogs/FormSettings.cs
+++ b/Dialogs/FormSettings.cs
@@ -3,14 +3,14 @@
 using System.Drawing;
 using System.Windows.Forms;
 using TweetDuck.Browser;
-using TweetDuck.Browser.Handling.General;
-using TweetDuck.Browser.Notification.Example;
+using TweetDuck.Browser.Handling;
 using TweetDuck.Configuration;
 using TweetDuck.Controls;
 using TweetDuck.Dialogs.Settings;
 using TweetDuck.Management;
 using TweetDuck.Utils;
 using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.TweetDeck;
 using TweetLib.Core.Systems.Updates;
 
 namespace TweetDuck.Dialogs {
@@ -25,7 +25,7 @@ sealed partial class FormSettings : Form, FormManager.IAppDialog {
 		private readonly Dictionary<Type, SettingsTab> tabs = new Dictionary<Type, SettingsTab>(8);
 		private SettingsTab currentTab;
 
-		public FormSettings(FormBrowser browser, PluginManager plugins, UpdateHandler updates, Type startTab) {
+		public FormSettings(FormBrowser browser, PluginManager plugins, UpdateChecker updates, TweetDeckFunctions tweetDeckFunctions, Type startTab) {
 			InitializeComponent();
 
 			Text = Program.BrandName + " Options";
@@ -39,12 +39,12 @@ public FormSettings(FormBrowser browser, PluginManager plugins, UpdateHandler up
 
 			PrepareLoad();
 
-			AddButton("General", () => new TabSettingsGeneral(this.browser.ReloadColumns, updates));
-			AddButton("Notifications", () => new TabSettingsNotifications(new FormNotificationExample(this.browser, this.plugins)));
-			AddButton("Sounds", () => new TabSettingsSounds(this.browser.PlaySoundNotification));
+			AddButton("General", () => new TabSettingsGeneral(tweetDeckFunctions.ReloadColumns, updates));
+			AddButton("Notifications", () => new TabSettingsNotifications(this.browser.CreateExampleNotification()));
+			AddButton("Sounds", () => new TabSettingsSounds(tweetDeckFunctions.PlaySoundNotification));
 			AddButton("Tray", () => new TabSettingsTray());
 			AddButton("Feedback", () => new TabSettingsFeedback());
-			AddButton("Advanced", () => new TabSettingsAdvanced(this.browser.ReinjectCustomCSS, this.browser.OpenDevTools));
+			AddButton("Advanced", () => new TabSettingsAdvanced(tweetDeckFunctions.ReinjectCustomCSS, this.browser.OpenDevTools));
 
 			SelectTab(tabs[startTab ?? typeof(TabSettingsGeneral)]);
 		}
@@ -181,7 +181,7 @@ private void SelectTab(SettingsTab tab) {
 		}
 
 		private void control_MouseLeave(object sender, EventArgs e) {
-			if (sender is ComboBox cb && cb.DroppedDown) {
+			if (sender is ComboBox { DroppedDown: true } ) {
 				return; // prevents comboboxes from closing when MouseLeave event triggers during opening animation
 			}
 
diff --git a/Dialogs/Settings/DialogSettingsCefArgs.cs b/Dialogs/Settings/DialogSettingsCefArgs.cs
index bea68fe4..5b781cc2 100644
--- a/Dialogs/Settings/DialogSettingsCefArgs.cs
+++ b/Dialogs/Settings/DialogSettingsCefArgs.cs
@@ -1,7 +1,7 @@
 using System;
 using System.Windows.Forms;
 using TweetDuck.Controls;
-using TweetDuck.Utils;
+using TweetLib.Core;
 using TweetLib.Core.Features.Chromium;
 
 namespace TweetDuck.Dialogs.Settings {
@@ -21,7 +21,7 @@ public DialogSettingsCefArgs(string args) {
 		}
 
 		private void btnHelp_Click(object sender, EventArgs e) {
-			BrowserUtils.OpenExternalBrowser("http://peter.sh/experiments/chromium-command-line-switches/");
+			App.SystemHandler.OpenBrowser("http://peter.sh/experiments/chromium-command-line-switches/");
 		}
 
 		private void btnApply_Click(object sender, EventArgs e) {
diff --git a/Dialogs/Settings/DialogSettingsManage.cs b/Dialogs/Settings/DialogSettingsManage.cs
index 5f938dda..d70cbb80 100644
--- a/Dialogs/Settings/DialogSettingsManage.cs
+++ b/Dialogs/Settings/DialogSettingsManage.cs
@@ -149,7 +149,7 @@ private void btnContinue_Click(object sender, EventArgs e) {
 							Program.Config.Plugins.Reset();
 
 							try {
-								Directory.Delete(Program.PluginDataPath, true);
+								Directory.Delete(plugins.PluginDataFolder, true);
 							} catch (Exception ex) {
 								App.ErrorHandler.HandleException("Profile Reset", "Could not delete plugin data.", true, ex);
 							}
diff --git a/Dialogs/Settings/DialogSettingsRestart.cs b/Dialogs/Settings/DialogSettingsRestart.cs
index 050a82b5..43955d0f 100644
--- a/Dialogs/Settings/DialogSettingsRestart.cs
+++ b/Dialogs/Settings/DialogSettingsRestart.cs
@@ -1,6 +1,7 @@
 using System;
 using System.Windows.Forms;
 using TweetDuck.Configuration;
+using TweetLib.Core;
 using TweetLib.Utils.Collections;
 
 namespace TweetDuck.Dialogs.Settings {
@@ -13,7 +14,7 @@ public DialogSettingsRestart(CommandLineArgs currentArgs) {
 			cbLogging.Checked = currentArgs.HasFlag(Arguments.ArgLogging);
 			cbLogging.CheckedChanged += control_Change;
 
-			if (Program.IsPortable) {
+			if (App.IsPortable) {
 				tbDataFolder.Text = "Not available in portable version";
 				tbDataFolder.Enabled = false;
 			}
diff --git a/Dialogs/Settings/TabSettingsAdvanced.cs b/Dialogs/Settings/TabSettingsAdvanced.cs
index 41174f05..709e05cc 100644
--- a/Dialogs/Settings/TabSettingsAdvanced.cs
+++ b/Dialogs/Settings/TabSettingsAdvanced.cs
@@ -85,11 +85,11 @@ public override void OnClosing() {
 		#region Application
 
 		private void btnOpenAppFolder_Click(object sender, EventArgs e) {
-			App.SystemHandler.OpenFileExplorer(Program.ProgramPath);
+			App.SystemHandler.OpenFileExplorer(App.ProgramPath);
 		}
 
 		private void btnOpenDataFolder_Click(object sender, EventArgs e) {
-			App.SystemHandler.OpenFileExplorer(Program.StoragePath);
+			App.SystemHandler.OpenFileExplorer(App.StoragePath);
 		}
 
 		private void btnRestart_Click(object sender, EventArgs e) {
diff --git a/Dialogs/Settings/TabSettingsFeedback.cs b/Dialogs/Settings/TabSettingsFeedback.cs
index 9e0df28c..c03faeea 100644
--- a/Dialogs/Settings/TabSettingsFeedback.cs
+++ b/Dialogs/Settings/TabSettingsFeedback.cs
@@ -1,5 +1,5 @@
 using System;
-using TweetDuck.Utils;
+using TweetLib.Core;
 
 namespace TweetDuck.Dialogs.Settings {
 	sealed partial class TabSettingsFeedback : FormSettings.BaseTab {
@@ -14,7 +14,7 @@ public override void OnReady() {
 		#region Feedback
 
 		private void btnSendFeedback_Click(object sender, EventArgs e) {
-			BrowserUtils.OpenExternalBrowser("https://github.com/chylex/TweetDuck/issues/new");
+			App.SystemHandler.OpenBrowser("https://github.com/chylex/TweetDuck/issues/new");
 		}
 
 		#endregion
diff --git a/Dialogs/Settings/TabSettingsGeneral.cs b/Dialogs/Settings/TabSettingsGeneral.cs
index 70847086..87ebacea 100644
--- a/Dialogs/Settings/TabSettingsGeneral.cs
+++ b/Dialogs/Settings/TabSettingsGeneral.cs
@@ -2,7 +2,7 @@
 using System.IO;
 using System.Linq;
 using System.Windows.Forms;
-using TweetDuck.Browser.Handling.General;
+using TweetDuck.Browser.Handling;
 using TweetDuck.Controls;
 using TweetDuck.Utils;
 using TweetLib.Core;
@@ -15,7 +15,7 @@ namespace TweetDuck.Dialogs.Settings {
 	sealed partial class TabSettingsGeneral : FormSettings.BaseTab {
 		private readonly Action reloadColumns;
 
-		private readonly UpdateHandler updates;
+		private readonly UpdateChecker updates;
 		private int updateCheckEventId = -1;
 
 		private readonly int browserListIndexDefault;
@@ -27,7 +27,7 @@ sealed partial class TabSettingsGeneral : FormSettings.BaseTab {
 		private readonly int searchEngineIndexDefault;
 		private readonly int searchEngineIndexCustom;
 
-		public TabSettingsGeneral(Action reloadColumns, UpdateHandler updates) {
+		public TabSettingsGeneral(Action reloadColumns, UpdateChecker updates) {
 			InitializeComponent();
 
 			this.reloadColumns = reloadColumns;
diff --git a/Dialogs/Settings/TabSettingsNotifications.cs b/Dialogs/Settings/TabSettingsNotifications.cs
index 98c1c47b..cd472f18 100644
--- a/Dialogs/Settings/TabSettingsNotifications.cs
+++ b/Dialogs/Settings/TabSettingsNotifications.cs
@@ -1,6 +1,6 @@
 using System;
 using System.Windows.Forms;
-using TweetDuck.Browser.Notification.Example;
+using TweetDuck.Browser.Notification;
 using TweetDuck.Controls;
 using TweetLib.Core.Features.Notifications;
 
diff --git a/Browser/Data/WindowState.cs b/Dialogs/WindowState.cs
similarity index 97%
rename from Browser/Data/WindowState.cs
rename to Dialogs/WindowState.cs
index d393e25b..6df8f38a 100644
--- a/Browser/Data/WindowState.cs
+++ b/Dialogs/WindowState.cs
@@ -4,7 +4,7 @@
 using TweetLib.Utils.Serialization.Converters;
 using TweetLib.Utils.Static;
 
-namespace TweetDuck.Browser.Data {
+namespace TweetDuck.Dialogs {
 	sealed class WindowState {
 		private Rectangle rect;
 		private bool isMaximized;
diff --git a/Management/BrowserCache.cs b/Management/BrowserCache.cs
index d7fbb3af..018f0bed 100644
--- a/Management/BrowserCache.cs
+++ b/Management/BrowserCache.cs
@@ -3,10 +3,11 @@
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using TweetLib.Core;
 
 namespace TweetDuck.Management {
 	static class BrowserCache {
-		public static string CacheFolder => Path.Combine(Program.StoragePath, "Cache");
+		public static string CacheFolder => Path.Combine(App.StoragePath, "Cache");
 
 		private static bool clearOnExit;
 		private static Timer autoClearTimer;
@@ -22,7 +23,7 @@ private static long CalculateCacheSize() {
 		}
 
 		public static void GetCacheSize(Action<Task<long>> callbackBytes) {
-			Task<long> task = new Task<long>(CalculateCacheSize);
+			var task = new Task<long>(CalculateCacheSize);
 			task.ContinueWith(callbackBytes);
 			task.Start();
 		}
diff --git a/Management/FormManager.cs b/Management/FormManager.cs
index 6cbd9202..98ef0f69 100644
--- a/Management/FormManager.cs
+++ b/Management/FormManager.cs
@@ -1,10 +1,21 @@
-using System.Linq;
+using System;
+using System.Linq;
 using System.Windows.Forms;
+using TweetDuck.Browser;
+using TweetDuck.Controls;
 
 namespace TweetDuck.Management {
 	static class FormManager {
 		private static FormCollection OpenForms => System.Windows.Forms.Application.OpenForms;
 
+		public static void RunOnUIThread(Action action) {
+			TryFind<FormBrowser>()?.InvokeSafe(action);
+		}
+
+		public static void RunOnUIThreadAsync(Action action) {
+			TryFind<FormBrowser>()?.InvokeAsyncSafe(action);
+		}
+
 		public static T TryFind<T>() where T : Form {
 			return OpenForms.OfType<T>().FirstOrDefault();
 		}
diff --git a/Management/ProfileManager.cs b/Management/ProfileManager.cs
index 2f614cec..1fd4cd76 100644
--- a/Management/ProfileManager.cs
+++ b/Management/ProfileManager.cs
@@ -10,13 +10,13 @@
 
 namespace TweetDuck.Management {
 	sealed class ProfileManager {
-		private static readonly string CookiesPath = Path.Combine(Program.StoragePath, "Cookies");
-		private static readonly string LocalPrefsPath = Path.Combine(Program.StoragePath, "LocalPrefs.json");
+		private static readonly string CookiesPath = Path.Combine(App.StoragePath, "Cookies");
+		private static readonly string LocalPrefsPath = Path.Combine(App.StoragePath, "LocalPrefs.json");
 
-		private static readonly string TempCookiesPath = Path.Combine(Program.StoragePath, "CookiesTmp");
-		private static readonly string TempLocalPrefsPath = Path.Combine(Program.StoragePath, "LocalPrefsTmp.json");
+		private static readonly string TempCookiesPath = Path.Combine(App.StoragePath, "CookiesTmp");
+		private static readonly string TempLocalPrefsPath = Path.Combine(App.StoragePath, "LocalPrefsTmp.json");
 
-		private static readonly int SessionFileCount = 2;
+		private const int SessionFileCount = 2;
 
 		[Flags]
 		public enum Items {
@@ -41,15 +41,15 @@ public bool Export(Items items) {
 				using CombinedFileStream stream = new CombinedFileStream(new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.None));
 
 				if (items.HasFlag(Items.UserConfig)) {
-					stream.WriteFile("config", Program.UserConfigFilePath);
+					stream.WriteFile("config", Program.Config.FilePaths.UserConfig);
 				}
 
 				if (items.HasFlag(Items.SystemConfig)) {
-					stream.WriteFile("system", Program.SystemConfigFilePath);
+					stream.WriteFile("system", Program.Config.FilePaths.SystemConfig);
 				}
 
 				if (items.HasFlag(Items.PluginData)) {
-					stream.WriteFile("plugin.config", Program.PluginConfigFilePath);
+					stream.WriteFile("plugin.config", Program.Config.FilePaths.PluginConfig);
 
 					foreach (Plugin plugin in plugins.Plugins) {
 						foreach (PathInfo path in EnumerateFilesRelative(plugin.GetPluginFolder(PluginFolder.Data))) {
@@ -122,21 +122,21 @@ public bool Import(Items items) {
 						switch (entry.KeyName) {
 							case "config":
 								if (items.HasFlag(Items.UserConfig)) {
-									entry.WriteToFile(Program.UserConfigFilePath);
+									entry.WriteToFile(Program.Config.FilePaths.UserConfig);
 								}
 
 								break;
 
 							case "system":
 								if (items.HasFlag(Items.SystemConfig)) {
-									entry.WriteToFile(Program.SystemConfigFilePath);
+									entry.WriteToFile(Program.Config.FilePaths.SystemConfig);
 								}
 
 								break;
 
 							case "plugin.config":
 								if (items.HasFlag(Items.PluginData)) {
-									entry.WriteToFile(Program.PluginConfigFilePath);
+									entry.WriteToFile(Program.Config.FilePaths.PluginConfig);
 								}
 
 								break;
@@ -145,7 +145,7 @@ public bool Import(Items items) {
 								if (items.HasFlag(Items.PluginData)) {
 									string[] value = entry.KeyValue;
 
-									entry.WriteToFile(Path.Combine(Program.PluginDataPath, value[0], value[1]), true);
+									entry.WriteToFile(Path.Combine(plugins.PluginDataFolder, value[0], value[1]), true);
 
 									if (!plugins.Plugins.Any(plugin => plugin.Identifier.Equals(value[0]))) {
 										missingPlugins.Add(value[0]);
diff --git a/Management/VideoPlayer.cs b/Management/VideoPlayer.cs
index 662983ea..5a300158 100644
--- a/Management/VideoPlayer.cs
+++ b/Management/VideoPlayer.cs
@@ -6,7 +6,6 @@
 using TweetDuck.Configuration;
 using TweetDuck.Controls;
 using TweetDuck.Dialogs;
-using TweetDuck.Utils;
 using TweetLib.Communication.Pipe;
 using TweetLib.Core;
 
@@ -14,7 +13,7 @@ namespace TweetDuck.Management {
 	sealed class VideoPlayer : IDisposable {
 		private static UserConfig Config => Program.Config.User;
 
-		public bool Running => currentInstance != null && currentInstance.Running;
+		public bool Running => currentInstance is { Running: true };
 
 		public event EventHandler ProcessExited;
 
@@ -39,7 +38,7 @@ public void Launch(string videoUrl, string tweetUrl, string username) {
 				pipe.DataIn += pipe_DataIn;
 
 				ProcessStartInfo startInfo = new ProcessStartInfo {
-					FileName = Path.Combine(Program.ProgramPath, "TweetDuck.Video.exe"),
+					FileName = Path.Combine(App.ProgramPath, "TweetDuck.Video.exe"),
 					Arguments = $"{owner.Handle} {(int) Math.Floor(100F * owner.GetDPIScale())} {Config.VideoPlayerVolume} \"{videoUrl}\" \"{pipe.GenerateToken()}\"",
 					UseShellExecute = false,
 					RedirectStandardOutput = true
@@ -83,7 +82,7 @@ private void pipe_DataIn(object sender, DuplexPipe.PipeReadEventArgs e) {
 
 					case "download":
 						if (currentInstance != null) {
-							TwitterUtils.DownloadVideo(currentInstance.VideoUrl, currentInstance.Username);
+							owner.SaveVideo(currentInstance.VideoUrl, currentInstance.Username);
 						}
 
 						break;
@@ -137,7 +136,7 @@ private void owner_FormClosing(object sender, FormClosingEventArgs e) {
 
 		private void process_OutputDataReceived(object sender, DataReceivedEventArgs e) {
 			if (!string.IsNullOrEmpty(e.Data)) {
-				Program.Reporter.LogVerbose("[VideoPlayer] " + e.Data);
+				App.Logger.Debug("[VideoPlayer] " + e.Data);
 			}
 		}
 
@@ -155,14 +154,14 @@ private void process_Exited(object sender, EventArgs e) {
 			switch (exitCode) {
 				case 3: // CODE_LAUNCH_FAIL
 					if (FormMessage.Error("Video Playback Error", "Error launching video player, this may be caused by missing Windows Media Player. Do you want to open the video in your browser?", FormMessage.Yes, FormMessage.No)) {
-						BrowserUtils.OpenExternalBrowser(tweetUrl);
+						App.SystemHandler.OpenBrowser(tweetUrl);
 					}
 
 					break;
 
 				case 4: // CODE_MEDIA_ERROR
 					if (FormMessage.Error("Video Playback Error", "The video could not be loaded, most likely due to unknown format. Do you want to open the video in your browser?", FormMessage.Yes, FormMessage.No)) {
-						BrowserUtils.OpenExternalBrowser(tweetUrl);
+						App.SystemHandler.OpenBrowser(tweetUrl);
 					}
 
 					break;
diff --git a/Plugins/PluginControl.cs b/Plugins/PluginControl.cs
index 57db34c7..0d62491a 100644
--- a/Plugins/PluginControl.cs
+++ b/Plugins/PluginControl.cs
@@ -3,6 +3,7 @@
 using System.Windows.Forms;
 using TweetDuck.Controls;
 using TweetDuck.Utils;
+using TweetLib.Core;
 using TweetLib.Core.Features.Plugins;
 using TweetLib.Core.Features.Plugins.Enums;
 
@@ -80,7 +81,7 @@ private void panelDescription_Resize(object sender, EventArgs e) {
 
 		private void labelWebsite_Click(object sender, EventArgs e) {
 			if (labelWebsite.Text.Length > 0) {
-				BrowserUtils.OpenExternalBrowser(labelWebsite.Text);
+				App.SystemHandler.OpenBrowser(labelWebsite.Text);
 			}
 		}
 
diff --git a/Plugins/PluginDispatcher.cs b/Plugins/PluginDispatcher.cs
deleted file mode 100644
index 157e303b..00000000
--- a/Plugins/PluginDispatcher.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System;
-using CefSharp;
-using TweetDuck.Browser.Adapters;
-using TweetDuck.Utils;
-using TweetLib.Core.Browser;
-using TweetLib.Core.Features.Plugins;
-using TweetLib.Core.Features.Plugins.Events;
-
-namespace TweetDuck.Plugins {
-	sealed class PluginDispatcher : IPluginDispatcher {
-		public event EventHandler<PluginDispatchEventArgs> Ready;
-
-		private readonly IWebBrowser browser;
-		private readonly IScriptExecutor executor;
-		private readonly Func<string, bool> executeOnUrl;
-
-		public PluginDispatcher(IWebBrowser browser, Func<string, bool> executeOnUrl) {
-			this.executeOnUrl = executeOnUrl;
-			this.browser = browser;
-			this.browser.FrameLoadEnd += browser_FrameLoadEnd;
-			this.executor = new CefScriptExecutor(browser);
-		}
-
-		void IPluginDispatcher.AttachBridge(string name, object bridge) {
-			browser.RegisterJsBridge(name, bridge);
-		}
-
-		private void browser_FrameLoadEnd(object sender, FrameLoadEndEventArgs e) {
-			IFrame frame = e.Frame;
-
-			if (frame.IsMain && executeOnUrl(frame.Url)) {
-				Ready?.Invoke(this, new PluginDispatchEventArgs(executor));
-			}
-		}
-	}
-}
diff --git a/Plugins/PluginSchemeFactory.cs b/Plugins/PluginSchemeFactory.cs
deleted file mode 100644
index 9095f60e..00000000
--- a/Plugins/PluginSchemeFactory.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using CefSharp;
-using TweetDuck.Resources;
-using TweetLib.Core.Features.Plugins;
-
-namespace TweetDuck.Plugins {
-	sealed class PluginSchemeFactory : ISchemeHandlerFactory {
-		public const string Name = PluginSchemeHandler<IResourceHandler>.Name;
-
-		private readonly PluginSchemeHandler<IResourceHandler> handler;
-
-		public PluginSchemeFactory(ResourceProvider resourceProvider) {
-			handler = new PluginSchemeHandler<IResourceHandler>(resourceProvider);
-		}
-
-		internal void Setup(PluginManager plugins) {
-			handler.Setup(plugins);
-		}
-
-		public IResourceHandler Create(IBrowser browser, IFrame frame, string schemeName, IRequest request) {
-			return handler.Process(request.Url);
-		}
-	}
-}
diff --git a/Program.cs b/Program.cs
index b9a414e3..da18b52a 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,21 +1,24 @@
 using System;
 using System.Diagnostics;
 using System.IO;
-using System.Linq;
 using CefSharp;
 using CefSharp.WinForms;
 using TweetDuck.Application;
 using TweetDuck.Browser;
+using TweetDuck.Browser.Adapters;
 using TweetDuck.Browser.Handling;
-using TweetDuck.Browser.Handling.General;
 using TweetDuck.Configuration;
 using TweetDuck.Dialogs;
 using TweetDuck.Management;
-using TweetDuck.Plugins;
-using TweetDuck.Resources;
+using TweetDuck.Updates;
 using TweetDuck.Utils;
 using TweetLib.Core;
+using TweetLib.Core.Application;
+using TweetLib.Core.Features;
 using TweetLib.Core.Features.Chromium;
+using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.TweetDeck;
+using TweetLib.Core.Resources;
 using TweetLib.Utils.Collections;
 using TweetLib.Utils.Static;
 using Win = System.Windows.Forms;
@@ -27,47 +30,22 @@ static class Program {
 
 		public const string Website = "https://tweetduck.chylex.com";
 
-		public static readonly string ProgramPath = AppDomain.CurrentDomain.BaseDirectory;
-		public static readonly string ExecutablePath = Win.Application.ExecutablePath;
+		private const string PluginDataFolder = "TD_Plugins";
+		private const string InstallerFolder = "TD_Updates";
+		private const string CefDataFolder = "TD_Chromium";
 
-		public static readonly bool IsPortable = File.Exists(Path.Combine(ProgramPath, "makeportable"));
+		private const string ProgramLogFile = "TD_Log.txt";
+		private const string ConsoleLogFile = "TD_Console.txt";
 
-		public static readonly string ResourcesPath = Path.Combine(ProgramPath, "resources");
-		public static readonly string PluginPath = Path.Combine(ProgramPath, "plugins");
-		public static readonly string GuidePath = Path.Combine(ProgramPath, "guide");
-
-		public static readonly string StoragePath = IsPortable ? Path.Combine(ProgramPath, "portable", "storage") : GetDataStoragePath();
-
-		public static readonly string PluginDataPath = Path.Combine(StoragePath, "TD_Plugins");
-		public static readonly string InstallerPath = Path.Combine(StoragePath, "TD_Updates");
-		private static readonly string CefDataPath = Path.Combine(StoragePath, "TD_Chromium");
-
-		public static string UserConfigFilePath => Path.Combine(StoragePath, "TD_UserConfig.cfg");
-		public static string SystemConfigFilePath => Path.Combine(StoragePath, "TD_SystemConfig.cfg");
-		public static string PluginConfigFilePath => Path.Combine(StoragePath, "TD_PluginConfig.cfg");
-
-		private static string ErrorLogFilePath => Path.Combine(StoragePath, "TD_Log.txt");
-		private static string ConsoleLogFilePath => Path.Combine(StoragePath, "TD_Console.txt");
+		public static string ExecutablePath => Win.Application.ExecutablePath;
 
 		public static uint WindowRestoreMessage;
 
-		private static readonly LockManager LockManager = new LockManager(Path.Combine(StoragePath, ".lock"));
+		private static LockManager lockManager;
+		private static Reporter errorReporter;
 		private static bool hasCleanedUp;
 
-		public static Reporter Reporter { get; }
-		public static ConfigManager Config { get; }
-
-		static Program() {
-			Reporter = new Reporter(ErrorLogFilePath);
-			Reporter.SetupUnhandledExceptionHandler("TweetDuck Has Failed :(");
-
-			Config = new ConfigManager();
-
-			Lib.Initialize(new App.Builder {
-				ErrorHandler = Reporter,
-				SystemHandler = new SystemHandler(),
-			});
-		}
+		public static ConfigManager Config { get; private set; }
 
 		internal static void SetupWinForms() {
 			Win.Application.EnableVisualStyles();
@@ -76,17 +54,46 @@ internal static void SetupWinForms() {
 
 		[STAThread]
 		private static void Main() {
+			AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
+
 			SetupWinForms();
 			Cef.EnableHighDPISupport();
 
+			var startup = new AppStartup {
+				CustomDataFolder = Arguments.GetValue(Arguments.ArgDataFolder)
+			};
+
+			var reporter = new Reporter();
+			var userConfig = new UserConfig();
+
+			Lib.Initialize(new AppBuilder {
+				Startup = startup,
+				Logger = new Logger(ProgramLogFile),
+				ErrorHandler = reporter,
+				SystemHandler = new SystemHandler(),
+				DialogHandler = new DialogHandler(),
+				UserConfiguration = userConfig
+			});
+
+			LaunchApp(reporter, userConfig);
+		}
+
+		private static void LaunchApp(Reporter reporter, UserConfig userConfig) {
+			App.Launch();
+
+			errorReporter = reporter;
+			string storagePath = App.StoragePath;
+
+			Config = new ConfigManager(userConfig, new ConfigManager.Paths {
+				UserConfig   = Path.Combine(storagePath, "TD_UserConfig.cfg"),
+				SystemConfig = Path.Combine(storagePath, "TD_SystemConfig.cfg"),
+				PluginConfig = Path.Combine(storagePath, "TD_PluginConfig.cfg")
+			});
+
+			lockManager = new LockManager(Path.Combine(storagePath, ".lock"));
 			WindowRestoreMessage = NativeMethods.RegisterWindowMessage("TweetDuckRestore");
 
-			if (!FileUtils.CheckFolderWritePermission(StoragePath)) {
-				FormMessage.Warning("Permission Error", "TweetDuck does not have write permissions to the storage folder: " + StoragePath, FormMessage.OK);
-				return;
-			}
-
-			if (!LockManager.Lock(Arguments.HasFlag(Arguments.ArgRestart))) {
+			if (!lockManager.Lock(Arguments.HasFlag(Arguments.ArgRestart))) {
 				return;
 			}
 
@@ -99,19 +106,23 @@ private static void Main() {
 				ProfileManager.DeleteCookies();
 			}
 
+			var installerFolderPath = Path.Combine(storagePath, InstallerFolder);
+
 			if (Arguments.HasFlag(Arguments.ArgUpdated)) {
-				WindowsUtils.TryDeleteFolderWhenAble(InstallerPath, 8000);
-				WindowsUtils.TryDeleteFolderWhenAble(Path.Combine(StoragePath, "Service Worker"), 4000);
+				WindowsUtils.TryDeleteFolderWhenAble(installerFolderPath, 8000);
+				WindowsUtils.TryDeleteFolderWhenAble(Path.Combine(storagePath, "Service Worker"), 4000);
 				BrowserCache.TryClearNow();
 			}
 
 			try {
-				ResourceRequestHandlerBase.LoadResourceRewriteRules(Arguments.GetValue(Arguments.ArgFreeze));
+				BaseResourceRequestHandler.LoadResourceRewriteRules(Arguments.GetValue(Arguments.ArgFreeze));
 			} catch (Exception e) {
 				FormMessage.Error("Resource Freeze", "Error parsing resource rewrite rules: " + e.Message, FormMessage.OK);
 				return;
 			}
 
+			WebUtils.DefaultUserAgent = BrowserUtils.UserAgentVanilla;
+
 			if (Config.User.UseSystemProxyForAllConnections) {
 				WebUtils.EnableSystemProxy();
 			}
@@ -122,21 +133,20 @@ private static void Main() {
 
 			CefSettings settings = new CefSettings {
 				UserAgent = BrowserUtils.UserAgentChrome,
-				BrowserSubprocessPath = Path.Combine(ProgramPath, BrandName + ".Browser.exe"),
-				CachePath = StoragePath,
-				UserDataPath = CefDataPath,
-				LogFile = ConsoleLogFilePath,
+				BrowserSubprocessPath = Path.Combine(App.ProgramPath, BrandName + ".Browser.exe"),
+				CachePath = storagePath,
+				UserDataPath = Path.Combine(storagePath, CefDataFolder),
+				LogFile = Path.Combine(storagePath, ConsoleLogFile),
 				#if !DEBUG
 				LogSeverity = Arguments.HasFlag(Arguments.ArgLogging) ? LogSeverity.Info : LogSeverity.Disable
 				#endif
 			};
 
-			var resourceProvider = new ResourceProvider();
-			var resourceScheme = new ResourceSchemeFactory(resourceProvider);
-			var pluginScheme = new PluginSchemeFactory(resourceProvider);
+			var resourceProvider = new CachingResourceProvider<IResourceHandler>(new ResourceProvider());
+			var pluginManager = new PluginManager(Config.Plugins, Path.Combine(storagePath, PluginDataFolder));
 
-			settings.SetupCustomScheme(ResourceSchemeFactory.Name, resourceScheme);
-			settings.SetupCustomScheme(PluginSchemeFactory.Name, pluginScheme);
+			CefSchemeHandlerFactory.Register(settings, new TweetDuckSchemeHandler<IResourceHandler>(resourceProvider));
+			CefSchemeHandlerFactory.Register(settings, new PluginSchemeHandler<IResourceHandler>(resourceProvider, pluginManager));
 
 			CefUtils.ParseCommandLineArguments(Config.User.CustomCefArgs).ToDictionary(settings.CefCommandLineArgs);
 			BrowserUtils.SetupCefArgs(settings.CefCommandLineArgs);
@@ -144,8 +154,7 @@ private static void Main() {
 			Cef.Initialize(settings, false, new BrowserProcessHandler());
 
 			Win.Application.ApplicationExit += (sender, args) => ExitCleanup();
-
-			FormBrowser mainForm = new FormBrowser(resourceProvider, pluginScheme);
+			FormBrowser mainForm = new FormBrowser(resourceProvider, pluginManager, new UpdateCheckClient(installerFolderPath));
 			Win.Application.Run(mainForm);
 
 			if (mainForm.UpdateInstaller != null) {
@@ -160,21 +169,19 @@ private static void Main() {
 			}
 		}
 
-		private static string GetDataStoragePath() {
-			string custom = Arguments.GetValue(Arguments.ArgDataFolder);
+		private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) {
+			if (e.ExceptionObject is Exception ex) {
+				AppException appEx = ex.GetBaseException() as AppException;
+				string title = appEx?.Title ?? "TweetDuck Has Failed :(";
+				string message = appEx?.Message ?? "An unhandled exception has occurred: " + ex.Message;
 
-			if (custom != null && (custom.Contains(Path.DirectorySeparatorChar) || custom.Contains(Path.AltDirectorySeparatorChar))) {
-				if (Path.GetInvalidPathChars().Any(custom.Contains)) {
-					Reporter.HandleEarlyFailure("Data Folder Invalid", "The data folder contains invalid characters:\n" + custom);
+				if (errorReporter == null) {
+					Debug.WriteLine(ex);
+					Reporter.HandleEarlyFailure(title, message);
 				}
-				else if (!Path.IsPathRooted(custom)) {
-					Reporter.HandleEarlyFailure("Data Folder Invalid", "The data folder has to be either a simple folder name, or a full path:\n" + custom);
+				else {
+					errorReporter.HandleException(title, message, false, ex);
 				}
-
-				return Environment.ExpandEnvironmentVariables(custom);
-			}
-			else {
-				return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), custom ?? BrandName);
 			}
 		}
 
@@ -211,7 +218,7 @@ private static void ExitCleanup() {
 			Cef.Shutdown();
 			BrowserCache.Exit();
 
-			LockManager.Unlock();
+			lockManager.Unlock();
 			hasCleanedUp = true;
 		}
 	}
diff --git a/Properties/Resources.Designer.cs b/Properties/Resources.Designer.cs
index 804017fe..9006c507 100644
--- a/Properties/Resources.Designer.cs
+++ b/Properties/Resources.Designer.cs
@@ -60,16 +60,6 @@ internal Resources() {
             }
         }
         
-        /// <summary>
-        ///   Looks up a localized resource of type System.Byte[].
-        /// </summary>
-        internal static byte[] avatar {
-            get {
-                object obj = ResourceManager.GetObject("avatar", resourceCulture);
-                return ((byte[])(obj));
-            }
-        }
-        
         /// <summary>
         ///   Looks up a localized resource of type System.Drawing.Icon similar to (Icon).
         /// </summary>
@@ -119,15 +109,5 @@ internal static System.Drawing.Icon icon_tray_new {
                 return ((System.Drawing.Icon)(obj));
             }
         }
-        
-        /// <summary>
-        ///   Looks up a localized resource of type System.Byte[].
-        /// </summary>
-        internal static byte[] spinner {
-            get {
-                object obj = ResourceManager.GetObject("spinner", resourceCulture);
-                return ((byte[])(obj));
-            }
-        }
     }
 }
diff --git a/Properties/Resources.resx b/Properties/Resources.resx
index dd2f8021..8dcccc78 100644
--- a/Properties/Resources.resx
+++ b/Properties/Resources.resx
@@ -118,9 +118,6 @@
     <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
   </resheader>
   <assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
-  <data name="avatar" type="System.Resources.ResXFileRef, System.Windows.Forms">
-    <value>..\Resources\Images\avatar.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-  </data>
   <data name="icon" type="System.Resources.ResXFileRef, System.Windows.Forms">
     <value>..\Resources\Images\icon.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
   </data>
@@ -136,7 +133,4 @@
   <data name="icon_tray_new" type="System.Resources.ResXFileRef, System.Windows.Forms">
     <value>..\Resources\Images\icon-tray-new.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
   </data>
-  <data name="spinner" type="System.Resources.ResXFileRef, System.Windows.Forms">
-    <value>..\Resources\Images\spinner.apng;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
-  </data>
 </root>
\ No newline at end of file
diff --git a/Reporter.cs b/Reporter.cs
index 187f7fa1..5c75a443 100644
--- a/Reporter.cs
+++ b/Reporter.cs
@@ -1,109 +1,64 @@
 using System;
 using System.Diagnostics;
 using System.Drawing;
-using System.IO;
-using System.Text;
 using System.Windows.Forms;
-using TweetDuck.Configuration;
 using TweetDuck.Dialogs;
+using TweetDuck.Management;
 using TweetLib.Core;
 using TweetLib.Core.Application;
 
 namespace TweetDuck {
 	sealed class Reporter : IAppErrorHandler {
-		private readonly string logFile;
-
-		public Reporter(string logFile) {
-			this.logFile = logFile;
-		}
-
-		public void SetupUnhandledExceptionHandler(string caption) {
-			AppDomain.CurrentDomain.UnhandledException += (sender, args) => {
-				if (args.ExceptionObject is Exception ex) {
-					HandleException(caption, "An unhandled exception has occurred.", false, ex);
-				}
-			};
-		}
-
-		public bool LogVerbose(string data) {
-			return Arguments.HasFlag(Arguments.ArgLogging) && LogImportant(data);
-		}
-
-		public bool LogImportant(string data) {
-			return ((IAppErrorHandler) this).Log(data);
-		}
-
-		bool IAppErrorHandler.Log(string text) {
-			#if DEBUG
-			Debug.WriteLine(text);
-			#endif
-
-			StringBuilder build = new StringBuilder();
-
-			if (!File.Exists(logFile)) {
-				build.Append("Please, report all issues to: https://github.com/chylex/TweetDuck/issues\r\n\r\n");
-			}
-
-			build.Append("[").Append(DateTime.Now.ToString("G", Lib.Culture)).Append("]\r\n");
-			build.Append(text).Append("\r\n\r\n");
-
-			try {
-				File.AppendAllText(logFile, build.ToString(), Encoding.UTF8);
-				return true;
-			} catch {
-				return false;
-			}
-		}
-
-		public void HandleException(string caption, string message, bool canIgnore, Exception e) {
-			bool loggedSuccessfully = LogImportant(e.ToString());
-
-			string exceptionText = e is ExpandedLogException ? e.Message + "\n\nDetails with potentially sensitive information are in the Error Log." : e.Message;
-			FormMessage form = new FormMessage(caption, message + "\nError: " + exceptionText, canIgnore ? MessageBoxIcon.Warning : MessageBoxIcon.Error);
-
-			Button btnExit = form.AddButton(FormMessage.Exit);
-			Button btnIgnore = form.AddButton(FormMessage.Ignore, DialogResult.Ignore, ControlType.Cancel);
-
-			btnIgnore.Enabled = canIgnore;
-			form.ActiveControl = canIgnore ? btnIgnore : btnExit;
-
-			Button btnOpenLog = new Button {
-				Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
-				Enabled = loggedSuccessfully,
-				Font = SystemFonts.MessageBoxFont,
-				Location = new Point(9, 12),
-				Margin = new Padding(0, 0, 48, 0),
-				Size = new Size(106, 26),
-				Text = "Show Error Log",
-				UseVisualStyleBackColor = true
-			};
-
-			btnOpenLog.Click += (sender, args) => {
-				using (Process.Start(logFile)) {}
-			};
-
-			form.AddActionControl(btnOpenLog);
-
-			if (form.ShowDialog() == DialogResult.Ignore) {
-				return;
-			}
-
+		private static void Exit(string message, Exception ex = null) {
 			try {
 				Process.GetCurrentProcess().Kill();
 			} catch {
-				Environment.FailFast(message, e);
+				Environment.FailFast(message, ex ?? new Exception(message));
 			}
 		}
 
 		public static void HandleEarlyFailure(string caption, string message) {
 			Program.SetupWinForms();
 			FormMessage.Error(caption, message, "Exit");
+			Exit(message);
+		}
 
-			try {
-				Process.GetCurrentProcess().Kill();
-			} catch {
-				Environment.FailFast(message, new Exception(message));
-			}
+		public void HandleException(string caption, string message, bool canIgnore, Exception e) {
+			bool loggedSuccessfully = App.Logger.Error(e.ToString());
+
+			FormManager.RunOnUIThread(() => {
+				string exceptionText = e is ExpandedLogException ? e.Message + "\n\nDetails with potentially sensitive information are in the Error Log." : e.Message;
+				FormMessage form = new FormMessage(caption, message + "\nError: " + exceptionText, canIgnore ? MessageBoxIcon.Warning : MessageBoxIcon.Error);
+
+				Button btnExit = form.AddButton(FormMessage.Exit);
+				Button btnIgnore = form.AddButton(FormMessage.Ignore, DialogResult.Ignore, ControlType.Cancel);
+
+				btnIgnore.Enabled = canIgnore;
+				form.ActiveControl = canIgnore ? btnIgnore : btnExit;
+
+				Button btnOpenLog = new Button {
+					Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
+					Enabled = loggedSuccessfully,
+					Font = SystemFonts.MessageBoxFont,
+					Location = new Point(9, 12),
+					Margin = new Padding(0, 0, 48, 0),
+					Size = new Size(106, 26),
+					Text = "Show Error Log",
+					UseVisualStyleBackColor = true
+				};
+
+				btnOpenLog.Click += (sender, args) => {
+					if (!App.Logger.OpenLogFile()) {
+						FormMessage.Error("Error Log", "Cannot open error log.", FormMessage.OK);
+					}
+				};
+
+				form.AddActionControl(btnOpenLog);
+
+				if (form.ShowDialog() != DialogResult.Ignore) {
+					Exit(message, e);
+				}
+			});
 		}
 
 		public sealed class ExpandedLogException : Exception {
diff --git a/Resources/Images/avatar.png b/Resources/Content/images/logo.png
similarity index 100%
rename from Resources/Images/avatar.png
rename to Resources/Content/images/logo.png
diff --git a/Resources/Images/spinner.apng b/Resources/Content/images/spinner.apng
similarity index 100%
rename from Resources/Images/spinner.apng
rename to Resources/Content/images/spinner.apng
diff --git a/Resources/Content/notification/example/example.html b/Resources/Content/notification/example/example.html
index 7916b4cc..222df81f 100644
--- a/Resources/Content/notification/example/example.html
+++ b/Resources/Content/notification/example/example.html
@@ -7,7 +7,7 @@
         </time>
         <a target="_blank" rel="user" href="https://twitter.com/TryMyAwesomeApp" class="account-link link-complex block">
           <div class="obj-left item-img tweet-img">
-            <img width="48" height="48" alt="TryMyAwesomeApp's avatar" src="{avatar}" class="tweet-avatar avatar pull-right">
+            <img width="48" height="48" alt="TryMyAwesomeApp's avatar" src="td://resources/images/logo.png" class="tweet-avatar avatar pull-right">
           </div>
           <div class="nbfc">
             <span class="account-inline txt-ellipsis">
diff --git a/Resources/Content/tweetdeck/setup_tweetduck_account_bamboozle.js b/Resources/Content/tweetdeck/setup_tweetduck_account_bamboozle.js
index 01c51171..d7004204 100644
--- a/Resources/Content/tweetdeck/setup_tweetduck_account_bamboozle.js
+++ b/Resources/Content/tweetdeck/setup_tweetduck_account_bamboozle.js
@@ -7,7 +7,7 @@ import { checkPropertyExists } from "../api/utils.js";
  */
 export default function() {
 	const realDisplayName = "TweetDuck";
-	const realAvatar = "https://ton.twimg.com/tduck/avatar";
+	const realAvatar = "td://resources/images/logo.png";
 	const accountId = "957608948189880320";
 	
 	if (checkPropertyExists(TD, "services", "TwitterUser", "prototype")) {
diff --git a/Resources/Guide/script.js b/Resources/Guide/script.js
index 69761457..1059aa7f 100644
--- a/Resources/Guide/script.js
+++ b/Resources/Guide/script.js
@@ -34,3 +34,7 @@ if (location.hash.length > 1) {
 		}
 	}
 }
+
+window.addEventListener("hashchange", function() {
+	location.reload();
+});
diff --git a/Resources/ResourceHotSwap.cs b/Resources/ResourceHotSwap.cs
index 4f9e2385..76fa8f68 100644
--- a/Resources/ResourceHotSwap.cs
+++ b/Resources/ResourceHotSwap.cs
@@ -1,10 +1,11 @@
 #if DEBUG
 using System.Diagnostics;
 using System.IO;
+using TweetLib.Core;
 
 namespace TweetDuck.Resources {
 	static class ResourceHotSwap {
-		private static readonly string HotSwapProjectRoot = FixPathSlash(Path.GetFullPath(Path.Combine(Program.ProgramPath, "../../../")));
+		private static readonly string HotSwapProjectRoot = FixPathSlash(Path.GetFullPath(Path.Combine(App.ProgramPath, "../../../")));
 		private static readonly string HotSwapTargetDir = FixPathSlash(Path.Combine(HotSwapProjectRoot, "bin", "tmp"));
 		private static readonly string HotSwapRebuildScript = Path.Combine(HotSwapProjectRoot, "bld", "post_build.exe");
 
@@ -44,11 +45,11 @@ public static void Run() {
 			sw.Stop();
 			Debug.WriteLine($"Finished rebuild script in {sw.ElapsedMilliseconds} ms");
 
-			Directory.Delete(Program.ResourcesPath, true);
-			Directory.Delete(Program.PluginPath, true);
+			Directory.Delete(App.ResourcesPath, true);
+			Directory.Delete(App.PluginPath, true);
 
-			Directory.Move(Path.Combine(HotSwapTargetDir, "resources"), Program.ResourcesPath);
-			Directory.Move(Path.Combine(HotSwapTargetDir, "plugins"), Program.PluginPath);
+			Directory.Move(Path.Combine(HotSwapTargetDir, "resources"), App.ResourcesPath);
+			Directory.Move(Path.Combine(HotSwapTargetDir, "plugins"), App.PluginPath);
 
 			DeleteHotSwapFolder();
 		}
diff --git a/Resources/ResourceProvider.cs b/Resources/ResourceProvider.cs
deleted file mode 100644
index 78e2884d..00000000
--- a/Resources/ResourceProvider.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Net;
-using System.Text;
-using CefSharp;
-using TweetLib.Core.Browser;
-
-namespace TweetDuck.Resources {
-	internal sealed class ResourceProvider : IResourceProvider<IResourceHandler> {
-		private readonly Dictionary<string, ICachedResource> cache = new Dictionary<string, ICachedResource>();
-
-		public IResourceHandler Status(HttpStatusCode code, string message) {
-			return CreateStatusHandler(code, message);
-		}
-
-		public IResourceHandler File(string path) {
-			string key = new Uri(path).LocalPath;
-
-			if (cache.TryGetValue(key, out var cachedResource)) {
-				return cachedResource.GetResource();
-			}
-
-			cachedResource = FileWithCaching(path);
-			cache[key] = cachedResource;
-			return cachedResource.GetResource();
-		}
-
-		private ICachedResource FileWithCaching(string path) {
-			try {
-				return new CachedFile(System.IO.File.ReadAllBytes(path), Path.GetExtension(path));
-			} catch (FileNotFoundException) {
-				return new CachedStatus(HttpStatusCode.NotFound, "File not found.");
-			} catch (DirectoryNotFoundException) {
-				return new CachedStatus(HttpStatusCode.NotFound, "Directory not found.");
-			} catch (Exception e) {
-				return new CachedStatus(HttpStatusCode.InternalServerError, e.Message);
-			}
-		}
-
-		public void ClearCache() {
-			cache.Clear();
-		}
-
-		private static ResourceHandler CreateHandler(byte[] bytes) {
-			var handler = ResourceHandler.FromStream(new MemoryStream(bytes), autoDisposeStream: true);
-			handler.Headers.Set("Access-Control-Allow-Origin", "*");
-			return handler;
-		}
-
-		private static IResourceHandler CreateFileContentsHandler(byte[] bytes, string extension) {
-			if (bytes.Length == 0) {
-				return CreateStatusHandler(HttpStatusCode.NoContent, "File is empty."); // FromByteArray crashes CEF internals with no contents
-			}
-			else {
-				var handler = CreateHandler(bytes);
-				handler.MimeType = Cef.GetMimeType(extension);
-				return handler;
-			}
-		}
-
-		private static IResourceHandler CreateStatusHandler(HttpStatusCode code, string message) {
-			var handler = CreateHandler(Encoding.UTF8.GetBytes(message));
-			handler.StatusCode = (int) code;
-			return handler;
-		}
-
-		private interface ICachedResource {
-			IResourceHandler GetResource();
-		}
-
-		private sealed class CachedFile : ICachedResource {
-			private readonly byte[] bytes;
-			private readonly string extension;
-
-			public CachedFile(byte[] bytes, string extension) {
-				this.bytes = bytes;
-				this.extension = extension;
-			}
-
-			public IResourceHandler GetResource() {
-				return CreateFileContentsHandler(bytes, extension);
-			}
-		}
-
-		private sealed class CachedStatus : ICachedResource {
-			private readonly HttpStatusCode code;
-			private readonly string message;
-
-			public CachedStatus(HttpStatusCode code, string message) {
-				this.code = code;
-				this.message = message;
-			}
-
-			public IResourceHandler GetResource() {
-				return CreateStatusHandler(code, message);
-			}
-		}
-	}
-}
diff --git a/Resources/ResourceUtils.cs b/Resources/ResourceUtils.cs
deleted file mode 100644
index 7164cdd5..00000000
--- a/Resources/ResourceUtils.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-using TweetLib.Core;
-
-namespace TweetDuck.Resources {
-	public static class ResourceUtils {
-		public static string ReadFileOrNull(string relativePath) {
-			string path = Path.Combine(Program.ResourcesPath, relativePath);
-
-			try {
-				return File.ReadAllText(path, Encoding.UTF8);
-			} catch (Exception e) {
-				App.ErrorHandler.Log("Error reading file: " + path);
-				App.ErrorHandler.Log(e.ToString());
-				return null;
-			}
-		}
-	}
-}
diff --git a/Resources/ResourcesSchemeFactory.cs b/Resources/ResourcesSchemeFactory.cs
deleted file mode 100644
index 5a0ae701..00000000
--- a/Resources/ResourcesSchemeFactory.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System;
-using System.Net;
-using CefSharp;
-using TweetLib.Core.Browser;
-using TweetLib.Utils.Static;
-
-namespace TweetDuck.Resources {
-	public class ResourceSchemeFactory : ISchemeHandlerFactory {
-		public const string Name = "td";
-
-		private readonly IResourceProvider<IResourceHandler> resourceProvider;
-
-		public ResourceSchemeFactory(IResourceProvider<IResourceHandler> resourceProvider) {
-			this.resourceProvider = resourceProvider;
-		}
-
-		public IResourceHandler Create(IBrowser browser, IFrame frame, string schemeName, IRequest request) {
-			if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || uri.Scheme != Name) {
-				return null;
-			}
-
-			string rootPath = uri.Authority switch {
-				"resources" => Program.ResourcesPath,
-				"guide"     => Program.GuidePath,
-				_           => null
-			};
-
-			if (rootPath == null) {
-				return resourceProvider.Status(HttpStatusCode.NotFound, "Invalid URL.");
-			}
-
-			string filePath = FileUtils.ResolveRelativePathSafely(rootPath, uri.AbsolutePath.TrimStart('/'));
-			return filePath.Length == 0 ? resourceProvider.Status(HttpStatusCode.Forbidden, "File path has to be relative to the root folder.") : resourceProvider.File(filePath);
-		}
-	}
-}
diff --git a/TweetDuck.csproj b/TweetDuck.csproj
index f70d86f9..6917f1d5 100644
--- a/TweetDuck.csproj
+++ b/TweetDuck.csproj
@@ -62,36 +62,37 @@
     <Reference Include="System.Windows.Forms" />
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="Application\DialogHandler.cs" />
+    <Compile Include="Application\Logger.cs" />
+    <Compile Include="Browser\Adapters\CefBrowserComponent.cs" />
+    <Compile Include="Browser\Adapters\CefContextMenuActionRegistry.cs" />
+    <Compile Include="Browser\Adapters\CefContextMenuModel.cs" />
+    <Compile Include="Browser\Adapters\CefResourceHandlerFactory.cs" />
+    <Compile Include="Browser\Adapters\CefResourceHandlerRegistry.cs" />
+    <Compile Include="Browser\Adapters\CefResourceProvider.cs" />
+    <Compile Include="Browser\Adapters\CefResourceRequestHandler.cs" />
+    <Compile Include="Browser\Adapters\CefSchemeHandlerFactory.cs" />
+    <Compile Include="Browser\Handling\BrowserProcessHandler.cs" />
+    <Compile Include="Browser\Handling\CustomLifeSpanHandler.cs" />
+    <Compile Include="Browser\Handling\FileDialogHandler.cs" />
+    <Compile Include="Browser\Handling\JavaScriptDialogHandler.cs" />
+    <Compile Include="Browser\Handling\ResponseFilter.cs" />
+    <Compile Include="Browser\Notification\FormNotificationExample.cs">
+      <SubType>Form</SubType>
+    </Compile>
+    <Compile Include="Browser\TweetDeckInterfaceImpl.cs" />
+    <Compile Include="Dialogs\WindowState.cs" />
     <Compile Include="Management\LockManager.cs" />
     <Compile Include="Application\SystemHandler.cs" />
-    <Compile Include="Browser\Adapters\CefScriptExecutor.cs" />
-    <Compile Include="Browser\Bridge\PropertyBridge.cs" />
-    <Compile Include="Browser\Bridge\TweetDeckBridge.cs" />
-    <Compile Include="Browser\Bridge\UpdateBridge.cs" />
-    <Compile Include="Browser\Data\ContextInfo.cs" />
-    <Compile Include="Browser\Data\ResourceHandlers.cs" />
-    <Compile Include="Browser\Data\ResourceLink.cs" />
-    <Compile Include="Browser\Data\WindowState.cs" />
     <Compile Include="Browser\Handling\ContextMenuBase.cs" />
     <Compile Include="Browser\Handling\ContextMenuBrowser.cs" />
     <Compile Include="Browser\Handling\ContextMenuGuide.cs" />
     <Compile Include="Browser\Handling\ContextMenuNotification.cs" />
     <Compile Include="Browser\Handling\DragHandlerBrowser.cs" />
-    <Compile Include="Browser\Handling\Filters\ResponseFilterBase.cs" />
-    <Compile Include="Browser\Handling\Filters\ResponseFilterVendor.cs" />
-    <Compile Include="Browser\Handling\General\BrowserProcessHandler.cs" />
-    <Compile Include="Browser\Handling\General\FileDialogHandler.cs" />
-    <Compile Include="Browser\Handling\General\JavaScriptDialogHandler.cs" />
-    <Compile Include="Browser\Handling\General\CustomLifeSpanHandler.cs" />
-    <Compile Include="Browser\Handling\KeyboardHandlerBase.cs" />
-    <Compile Include="Browser\Handling\KeyboardHandlerBrowser.cs" />
-    <Compile Include="Browser\Handling\KeyboardHandlerNotification.cs" />
+    <Compile Include="Browser\Handling\CustomKeyboardHandler.cs" />
     <Compile Include="Browser\Handling\RequestHandlerBase.cs" />
     <Compile Include="Browser\Handling\RequestHandlerBrowser.cs" />
     <Compile Include="Browser\Handling\ResourceHandlerNotification.cs" />
-    <Compile Include="Browser\Handling\ResourceRequestHandler.cs" />
-    <Compile Include="Browser\Handling\ResourceRequestHandlerBase.cs" />
-    <Compile Include="Browser\Handling\ResourceRequestHandlerBrowser.cs" />
     <Compile Include="Browser\Notification\Screenshot\ScreenshotBridge.cs" />
     <Compile Include="Browser\Notification\Screenshot\TweetScreenshotManager.cs" />
     <Compile Include="Browser\Notification\SoundNotification.cs" />
@@ -107,20 +108,15 @@
     <Compile Include="Management\FormManager.cs" />
     <Compile Include="Management\ProfileManager.cs" />
     <Compile Include="Management\VideoPlayer.cs" />
-    <Compile Include="Plugins\PluginDispatcher.cs" />
-    <Compile Include="Plugins\PluginSchemeFactory.cs" />
     <Compile Include="Program.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />
     <Compile Include="Reporter.cs" />
-    <Compile Include="Resources\ResourcesSchemeFactory.cs" />
     <Compile Include="Resources\ResourceHotSwap.cs" />
-    <Compile Include="Resources\ResourceProvider.cs" />
-    <Compile Include="Resources\ResourceUtils.cs" />
     <Compile Include="Updates\UpdateCheckClient.cs" />
     <Compile Include="Updates\UpdateInstaller.cs" />
     <Compile Include="Utils\BrowserUtils.cs" />
     <Compile Include="Utils\NativeMethods.cs" />
-    <Compile Include="Utils\TwitterUtils.cs" />
+    <Compile Include="Utils\TwitterFileDownloader.cs" />
     <Compile Include="Utils\WindowsUtils.cs" />
     <Compile Include="Version.cs" />
   </ItemGroup>
@@ -143,9 +139,6 @@
     <Compile Include="Browser\FormBrowser.Designer.cs">
       <DependentUpon>FormBrowser.cs</DependentUpon>
     </Compile>
-    <Compile Include="Browser\Notification\Example\FormNotificationExample.cs">
-      <SubType>Form</SubType>
-    </Compile>
     <Compile Include="Browser\Notification\FormNotificationMain.cs">
       <SubType>Form</SubType>
     </Compile>
@@ -323,19 +316,19 @@
     <None Include="app.config" />
     <None Include="packages.config" />
     <None Include="Resources\Content\error\error.html" />
+    <None Include="Resources\Content\images\logo.png" />
+    <None Include="Resources\Content\images\spinner.apng" />
     <None Include="Resources\Content\notification\example\example.html" />
     <None Include="Resources\Content\notification\notification.css" />
     <None Include="Resources\Content\notification\screenshot\screenshot.js" />
     <None Include="Resources\Content\plugins\notification\plugins.js" />
     <None Include="Resources\Content\plugins\tweetdeck\plugins.js" />
-    <None Include="Resources\Images\avatar.png" />
     <None Include="Resources\Images\icon-muted.ico" />
     <None Include="Resources\Images\icon-small.ico" />
     <None Include="Resources\Images\icon-tray-muted.ico" />
     <None Include="Resources\Images\icon-tray-new.ico" />
     <None Include="Resources\Images\icon-tray.ico" />
     <None Include="Resources\Images\icon.ico" />
-    <None Include="Resources\Images\spinner.apng" />
     <None Include="Resources\Plugins\.debug\.meta" />
     <None Include="Resources\Plugins\.debug\browser.js" />
     <None Include="Resources\Plugins\.debug\notification.js" />
@@ -364,6 +357,10 @@
     <Redist Include="$(ProjectDir)bld\Redist\*.*" Visible="false" />
   </ItemGroup>
   <ItemGroup>
+    <ProjectReference Include="lib\TweetLib.Browser\TweetLib.Browser.csproj">
+      <Project>{eefb1f37-7cad-46bd-8042-66e7b502ab02}</Project>
+      <Name>TweetLib.Browser</Name>
+    </ProjectReference>
     <ProjectReference Include="lib\TweetLib.Core\TweetLib.Core.csproj">
       <Project>{93ba3cb4-a812-4949-b07d-8d393fb38937}</Project>
       <Name>TweetLib.Core</Name>
diff --git a/TweetDuck.sln b/TweetDuck.sln
index dacd61bd..f2a4cde9 100644
--- a/TweetDuck.sln
+++ b/TweetDuck.sln
@@ -10,7 +10,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetDuck.Video", "video\Tw
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Communication", "lib\TweetLib.Communication\TweetLib.Communication.csproj", "{72473763-4B9D-4FB6-A923-9364B2680F06}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetDuck.Core", "lib\TweetLib.Core\TweetLib.Core.csproj", "{93BA3CB4-A812-4949-B07D-8D393FB38937}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Core", "lib\TweetLib.Core\TweetLib.Core.csproj", "{93BA3CB4-A812-4949-B07D-8D393FB38937}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Browser", "lib\TweetLib.Browser\TweetLib.Browser.csproj", "{EEFB1F37-7CAD-46BD-8042-66E7B502AB02}"
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TweetLib.Utils", "lib\TweetLib.Utils\TweetLib.Utils.csproj", "{476B1007-B12C-447F-B855-9886048201D6}"
 EndProject
@@ -44,6 +46,10 @@ Global
 		{93BA3CB4-A812-4949-B07D-8D393FB38937}.Debug|x86.Build.0 = Debug|x86
 		{93BA3CB4-A812-4949-B07D-8D393FB38937}.Release|x86.ActiveCfg = Release|x86
 		{93BA3CB4-A812-4949-B07D-8D393FB38937}.Release|x86.Build.0 = Release|x86
+		{EEFB1F37-7CAD-46BD-8042-66E7B502AB02}.Debug|x86.ActiveCfg = Debug|x86
+		{EEFB1F37-7CAD-46BD-8042-66E7B502AB02}.Debug|x86.Build.0 = Debug|x86
+		{EEFB1F37-7CAD-46BD-8042-66E7B502AB02}.Release|x86.ActiveCfg = Release|x86
+		{EEFB1F37-7CAD-46BD-8042-66E7B502AB02}.Release|x86.Build.0 = Release|x86
 		{476B1007-B12C-447F-B855-9886048201D6}.Debug|x86.ActiveCfg = Debug|x86
 		{476B1007-B12C-447F-B855-9886048201D6}.Debug|x86.Build.0 = Debug|x86
 		{476B1007-B12C-447F-B855-9886048201D6}.Release|x86.ActiveCfg = Release|x86
diff --git a/Updates/UpdateCheckClient.cs b/Updates/UpdateCheckClient.cs
index 1b5d6417..b590dedb 100644
--- a/Updates/UpdateCheckClient.cs
+++ b/Updates/UpdateCheckClient.cs
@@ -5,7 +5,6 @@
 using System.Text;
 using System.Threading.Tasks;
 using System.Web.Script.Serialization;
-using TweetDuck.Utils;
 using TweetLib.Core.Systems.Updates;
 using TweetLib.Utils.Static;
 using JsonObject = System.Collections.Generic.IDictionary<string, object>;
@@ -24,9 +23,9 @@ public UpdateCheckClient(string installerFolder) {
 		bool IUpdateCheckClient.CanCheck => Program.Config.User.EnableUpdateCheck;
 
 		Task<UpdateInfo> IUpdateCheckClient.Check() {
-			TaskCompletionSource<UpdateInfo> result = new TaskCompletionSource<UpdateInfo>();
+			var result = new TaskCompletionSource<UpdateInfo>();
 
-			WebClient client = WebUtils.NewClient(BrowserUtils.UserAgentVanilla);
+			WebClient client = WebUtils.NewClient();
 			client.Headers[HttpRequestHeader.Accept] = "application/vnd.github.v3+json";
 
 			client.DownloadStringTaskAsync(ApiLatestRelease).ContinueWith(task => {
@@ -57,7 +56,7 @@ static string AssetDownloadUrl(JsonObject obj) {
 				return (string) obj["browser_download_url"];
 			}
 
-			JsonObject root = (JsonObject) new JavaScriptSerializer().DeserializeObject(json);
+			var root = (JsonObject) new JavaScriptSerializer().DeserializeObject(json);
 
 			string versionTag = (string) root["tag_name"];
 			string releaseNotes = (string) root["body"];
@@ -67,7 +66,7 @@ static string AssetDownloadUrl(JsonObject obj) {
 		}
 
 		private static Exception ExpandWebException(Exception e) {
-			if (e is WebException we && we.Response is HttpWebResponse response) {
+			if (e is WebException { Response: HttpWebResponse response } ) {
 				try {
 					using var stream = response.GetResponseStream();
 					using var reader = new StreamReader(stream, Encoding.GetEncoding(response.CharacterSet ?? "utf-8"));
diff --git a/Updates/UpdateInstaller.cs b/Updates/UpdateInstaller.cs
index 25cbc5f1..e5c23333 100644
--- a/Updates/UpdateInstaller.cs
+++ b/Updates/UpdateInstaller.cs
@@ -15,8 +15,8 @@ public UpdateInstaller(string path) {
 
 		public bool Launch() {
 			// ProgramPath has a trailing backslash
-			string arguments = "/SP- /SILENT /FORCECLOSEAPPLICATIONS /UPDATEPATH=\"" + Program.ProgramPath + "\" /RUNARGS=\"" + Arguments.GetCurrentForInstallerCmd() + "\"" + (Program.IsPortable ? " /PORTABLE=1" : "");
-			bool runElevated = !Program.IsPortable || !FileUtils.CheckFolderWritePermission(Program.ProgramPath);
+			string arguments = "/SP- /SILENT /FORCECLOSEAPPLICATIONS /UPDATEPATH=\"" + App.ProgramPath + "\" /RUNARGS=\"" + Arguments.GetCurrentForInstallerCmd() + "\"" + (App.IsPortable ? " /PORTABLE=1" : "");
+			bool runElevated = !App.IsPortable || !FileUtils.CheckFolderWritePermission(App.ProgramPath);
 
 			try {
 				using (Process.Start(new ProcessStartInfo {
diff --git a/Utils/BrowserUtils.cs b/Utils/BrowserUtils.cs
index 49f3872b..06a6fbc4 100644
--- a/Utils/BrowserUtils.cs
+++ b/Utils/BrowserUtils.cs
@@ -1,17 +1,12 @@
 using System;
 using System.Collections.Generic;
-using System.Diagnostics;
 using System.Drawing;
-using System.IO;
 using System.Windows.Forms;
 using CefSharp;
 using CefSharp.WinForms;
-using TweetDuck.Browser;
 using TweetDuck.Configuration;
-using TweetDuck.Dialogs;
-using TweetDuck.Management;
-using TweetLib.Core;
-using TweetLib.Core.Features.Twitter;
+using TweetDuck.Controls;
+using TweetLib.Browser.Interfaces;
 
 namespace TweetDuck.Utils {
 	static class BrowserUtils {
@@ -52,19 +47,16 @@ public static void SetupCefArgs(IDictionary<string, string> args) {
 			}
 		}
 
-		public static void SetupCustomScheme(this CefSettings settings, string name, ISchemeHandlerFactory factory) {
-			settings.RegisterScheme(new CefCustomScheme {
-				SchemeName = name,
-				IsStandard = false,
-				IsSecure = true,
-				IsCorsEnabled = true,
-				IsCSPBypassing = true,
-				SchemeHandlerFactory = factory
-			});
-		}
+		public static void SetupDockOnLoad(IBrowserComponent browserComponent, ChromiumWebBrowser browser) {
+			browser.Dock = DockStyle.None;
+			browser.Location = ControlExtensions.InvisibleLocation;
 
-		public static ChromiumWebBrowser AsControl(this IWebBrowser browserControl) {
-			return (ChromiumWebBrowser) browserControl;
+			browserComponent.BrowserLoaded += (sender, args) => {
+				browser.InvokeAsyncSafe(() => {
+					browser.Location = Point.Empty;
+					browser.Dock = DockStyle.Fill;
+				});
+			};
 		}
 
 		public static void SetupZoomEvents(this ChromiumWebBrowser browser) {
@@ -86,20 +78,6 @@ void UpdateZoomLevel(object sender, EventArgs args) {
 			};
 		}
 
-		public static void RegisterJsBridge(this IWebBrowser browserControl, string name, object bridge) {
-			browserControl.JavascriptObjectRepository.Settings.LegacyBindingEnabled = true;
-			browserControl.JavascriptObjectRepository.Register(name, bridge, isAsync: true, BindingOptions.DefaultBinder);
-		}
-
-		public static void ExecuteJsAsync(this IWebBrowser browserControl, string scriptOrMethodName, params object[] args) {
-			if (args.Length == 0) {
-				browserControl.BrowserCore.ExecuteScriptAsync(scriptOrMethodName);
-			}
-			else {
-				browserControl.BrowserCore.ExecuteScriptAsync(scriptOrMethodName, args);
-			}
-		}
-
 		public static void OpenDevToolsCustom(this IWebBrowser browser, Point? inspectPoint = null) {
 			var info = new WindowInfo();
 			info.SetAsPopup(IntPtr.Zero, "Dev Tools");
@@ -112,96 +90,6 @@ public static void OpenDevToolsCustom(this IWebBrowser browser, Point? inspectPo
 			browser.GetBrowserHost().ShowDevTools(info, p.X, p.Y);
 		}
 
-		public static void OpenExternalBrowser(string url) {
-			if (string.IsNullOrWhiteSpace(url)) {
-				return;
-			}
-
-			switch (TwitterUrls.Check(url)) {
-				case TwitterUrls.UrlType.Fine:
-					string browserPath = Config.BrowserPath;
-
-					if (browserPath == null || !File.Exists(browserPath)) {
-						App.SystemHandler.OpenAssociatedProgram(url);
-					}
-					else {
-						string quotedUrl = '"' + url + '"';
-						string browserArgs = Config.BrowserPathArgs == null ? quotedUrl : Config.BrowserPathArgs + ' ' + quotedUrl;
-
-						try {
-							using (Process.Start(browserPath, browserArgs)) {}
-						} catch (Exception e) {
-							App.ErrorHandler.HandleException("Error Opening Browser", "Could not open the browser.", true, e);
-						}
-					}
-
-					break;
-
-				case TwitterUrls.UrlType.Tracking:
-					if (Config.IgnoreTrackingUrlWarning) {
-						goto case TwitterUrls.UrlType.Fine;
-					}
-
-					using (FormMessage form = new FormMessage("Blocked URL", "TweetDuck has blocked a tracking url due to privacy concerns. Do you want to visit it anyway?\n" + url, MessageBoxIcon.Warning)) {
-						form.AddButton(FormMessage.No, DialogResult.No, ControlType.Cancel | ControlType.Focused);
-						form.AddButton(FormMessage.Yes, DialogResult.Yes, ControlType.Accept);
-						form.AddButton("Always Visit", DialogResult.Ignore);
-
-						DialogResult result = form.ShowDialog();
-
-						if (result == DialogResult.Ignore) {
-							Config.IgnoreTrackingUrlWarning = true;
-							Config.Save();
-						}
-
-						if (result == DialogResult.Ignore || result == DialogResult.Yes) {
-							goto case TwitterUrls.UrlType.Fine;
-						}
-					}
-
-					break;
-
-				case TwitterUrls.UrlType.Invalid:
-					FormMessage.Warning("Blocked URL", "A potentially malicious or invalid URL was blocked from opening:\n" + url, FormMessage.OK);
-					break;
-			}
-		}
-
-		public static void OpenExternalSearch(string query) {
-			if (string.IsNullOrWhiteSpace(query)) {
-				return;
-			}
-
-			string searchUrl = Config.SearchEngineUrl;
-
-			if (string.IsNullOrEmpty(searchUrl)) {
-				if (FormMessage.Question("Search Options", "You have not configured a default search engine yet, would you like to do it now?", FormMessage.Yes, FormMessage.No)) {
-					bool wereSettingsOpen = FormManager.TryFind<FormSettings>() != null;
-
-					FormManager.TryFind<FormBrowser>()?.OpenSettings();
-
-					if (wereSettingsOpen) {
-						return;
-					}
-
-					FormSettings settings = FormManager.TryFind<FormSettings>();
-
-					if (settings == null) {
-						return;
-					}
-
-					settings.FormClosed += (sender, args) => {
-						if (args.CloseReason == CloseReason.UserClosing && Config.SearchEngineUrl != searchUrl) {
-							OpenExternalSearch(query);
-						}
-					};
-				}
-			}
-			else {
-				OpenExternalBrowser(searchUrl + Uri.EscapeUriString(query));
-			}
-		}
-
 		public static int Scale(int baseValue, double scaleFactor) {
 			return (int) Math.Round(baseValue * scaleFactor);
 		}
diff --git a/Utils/NativeMethods.cs b/Utils/NativeMethods.cs
index 83a1a2ec..cee7fde7 100644
--- a/Utils/NativeMethods.cs
+++ b/Utils/NativeMethods.cs
@@ -6,6 +6,7 @@
 namespace TweetDuck.Utils {
 	[SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Local")]
 	[SuppressMessage("ReSharper", "MemberCanBePrivate.Local")]
+	[SuppressMessage("ReSharper", "InconsistentNaming")]
 	static class NativeMethods {
 		public static readonly IntPtr HWND_BROADCAST = new IntPtr(0xFFFF);
 		public static readonly IntPtr HOOK_HANDLED = new IntPtr(-1);
diff --git a/Utils/TwitterFileDownloader.cs b/Utils/TwitterFileDownloader.cs
new file mode 100644
index 00000000..7bed4b88
--- /dev/null
+++ b/Utils/TwitterFileDownloader.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using CefSharp;
+using TweetDuck.Management;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Utils.Static;
+using Cookie = CefSharp.Cookie;
+
+namespace TweetDuck.Utils {
+	sealed class TwitterFileDownloader : IFileDownloader {
+		public static TwitterFileDownloader Instance { get; } = new TwitterFileDownloader();
+
+		private TwitterFileDownloader() {}
+
+		public string CacheFolder => BrowserCache.CacheFolder;
+
+		public void DownloadFile(string url, string path, Action onSuccess, Action<Exception> onFailure) {
+			const string authCookieName = "auth_token";
+
+			using ICookieManager cookies = Cef.GetGlobalCookieManager();
+
+			cookies.VisitUrlCookiesAsync(url, true).ContinueWith(task => {
+				string cookieStr = null;
+
+				if (task.Status == TaskStatus.RanToCompletion) {
+					Cookie found = task.Result?.Find(cookie => cookie.Name == authCookieName); // the list may be null
+
+					if (found != null) {
+						cookieStr = $"{found.Name}={found.Value}";
+					}
+				}
+
+				WebClient client = WebUtils.NewClient(BrowserUtils.UserAgentChrome);
+				client.Headers[HttpRequestHeader.Cookie] = cookieStr;
+				client.DownloadFileCompleted += WebUtils.FileDownloadCallback(path, onSuccess, onFailure);
+				client.DownloadFileAsync(new Uri(url), path);
+			});
+		}
+	}
+}
diff --git a/Utils/TwitterUtils.cs b/Utils/TwitterUtils.cs
deleted file mode 100644
index 453dd306..00000000
--- a/Utils/TwitterUtils.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-using System;
-using System.Drawing;
-using System.IO;
-using System.Linq;
-using System.Net;
-using System.Threading.Tasks;
-using System.Windows.Forms;
-using CefSharp;
-using TweetDuck.Browser.Data;
-using TweetDuck.Dialogs;
-using TweetDuck.Management;
-using TweetLib.Core;
-using TweetLib.Core.Features.Twitter;
-using TweetLib.Utils.Static;
-using Cookie = CefSharp.Cookie;
-
-namespace TweetDuck.Utils {
-	static class TwitterUtils {
-		public static readonly Color BackgroundColor = Color.FromArgb(28, 99, 153);
-		public const string BackgroundColorOverride = "setTimeout(function f(){let h=document.head;if(!h){setTimeout(f,5);return;}let e=document.createElement('style');e.innerHTML='body,body::before{background:#1c6399!important;margin:0}';h.appendChild(e);},1)";
-
-		public static readonly ResourceLink LoadingSpinner = new ResourceLink("https://ton.twimg.com/tduck/spinner", ResourceHandlers.ForBytes(Properties.Resources.spinner, "image/apng"));
-
-		public static readonly string[] DictionaryWords = {
-			"tweetdeck", "TweetDeck", "tweetduck", "TweetDuck", "TD"
-		};
-
-		private static void DownloadTempImage(string url, ImageQuality quality, Action<string> process) {
-			string file = Path.Combine(BrowserCache.CacheFolder, TwitterUrls.GetImageFileName(url) ?? Path.GetRandomFileName());
-
-			if (FileUtils.FileExistsAndNotEmpty(file)) {
-				process(file);
-			}
-			else {
-				DownloadFileAuth(TwitterUrls.GetMediaLink(url, quality), file, () => {
-					process(file);
-				}, ex => {
-					FormMessage.Error("Image Download", "An error occurred while downloading the image: " + ex.Message, FormMessage.OK);
-				});
-			}
-		}
-
-		public static void ViewImage(string url, ImageQuality quality) {
-			DownloadTempImage(url, quality, path => {
-				string ext = Path.GetExtension(path);
-
-				if (ImageUrl.ValidExtensions.Contains(ext)) {
-					App.SystemHandler.OpenAssociatedProgram(path);
-				}
-				else {
-					FormMessage.Error("Image Download", "Invalid file extension " + ext, FormMessage.OK);
-				}
-			});
-		}
-
-		public static void CopyImage(string url, ImageQuality quality) {
-			DownloadTempImage(url, quality, path => {
-				Image image;
-
-				try {
-					image = Image.FromFile(path);
-				} catch (Exception ex) {
-					FormMessage.Error("Copy Image", "An error occurred while copying the image: " + ex.Message, FormMessage.OK);
-					return;
-				}
-
-				ClipboardManager.SetImage(image);
-			});
-		}
-
-		public static void DownloadImage(string url, string username, ImageQuality quality) {
-			DownloadImages(new string[] { url }, username, quality);
-		}
-
-		public static void DownloadImages(string[] urls, string username, ImageQuality quality) {
-			if (urls.Length == 0) {
-				return;
-			}
-
-			string firstImageLink = TwitterUrls.GetMediaLink(urls[0], quality);
-			int qualityIndex = firstImageLink.IndexOf(':', firstImageLink.LastIndexOf('/'));
-
-			string filename = TwitterUrls.GetImageFileName(firstImageLink);
-			string ext = Path.GetExtension(filename); // includes dot
-
-			using SaveFileDialog dialog = new SaveFileDialog {
-				AutoUpgradeEnabled = true,
-				OverwritePrompt = urls.Length == 1,
-				Title = "Save Image",
-				FileName = qualityIndex == -1 ? filename : $"{username} {Path.ChangeExtension(filename, null)} {firstImageLink.Substring(qualityIndex + 1)}".Trim() + ext,
-				Filter = (urls.Length == 1 ? "Image" : "Images") + (string.IsNullOrEmpty(ext) ? " (unknown)|*.*" : $" (*{ext})|*{ext}")
-			};
-
-			if (dialog.ShowDialog() == DialogResult.OK) {
-				static void OnFailure(Exception ex) {
-					FormMessage.Error("Image Download", "An error occurred while downloading the image: " + ex.Message, FormMessage.OK);
-				}
-
-				if (urls.Length == 1) {
-					DownloadFileAuth(firstImageLink, dialog.FileName, null, OnFailure);
-				}
-				else {
-					string pathBase = Path.ChangeExtension(dialog.FileName, null);
-					string pathExt = Path.GetExtension(dialog.FileName);
-
-					for (int index = 0; index < urls.Length; index++) {
-						DownloadFileAuth(TwitterUrls.GetMediaLink(urls[index], quality), $"{pathBase} {index + 1}{pathExt}", null, OnFailure);
-					}
-				}
-			}
-		}
-
-		public static void DownloadVideo(string url, string username) {
-			string filename = TwitterUrls.GetFileNameFromUrl(url);
-			string ext = Path.GetExtension(filename);
-
-			using SaveFileDialog dialog = new SaveFileDialog {
-				AutoUpgradeEnabled = true,
-				OverwritePrompt = true,
-				Title = "Save Video",
-				FileName = string.IsNullOrEmpty(username) ? filename : $"{username} {filename}".TrimStart(),
-				Filter = "Video" + (string.IsNullOrEmpty(ext) ? " (unknown)|*.*" : $" (*{ext})|*{ext}")
-			};
-
-			if (dialog.ShowDialog() == DialogResult.OK) {
-				DownloadFileAuth(url, dialog.FileName, null, ex => {
-					FormMessage.Error("Video Download", "An error occurred while downloading the video: " + ex.Message, FormMessage.OK);
-				});
-			}
-		}
-
-		private static void DownloadFileAuth(string url, string target, Action onSuccess, Action<Exception> onFailure) {
-			const string authCookieName = "auth_token";
-
-			TaskScheduler scheduler = TaskScheduler.FromCurrentSynchronizationContext();
-			using ICookieManager cookies = Cef.GetGlobalCookieManager();
-
-			cookies.VisitUrlCookiesAsync(url, true).ContinueWith(task => {
-				string cookieStr = null;
-
-				if (task.Status == TaskStatus.RanToCompletion) {
-					Cookie found = task.Result?.Find(cookie => cookie.Name == authCookieName); // the list may be null
-
-					if (found != null) {
-						cookieStr = $"{found.Name}={found.Value}";
-					}
-				}
-
-				WebClient client = WebUtils.NewClient(BrowserUtils.UserAgentChrome);
-				client.Headers[HttpRequestHeader.Cookie] = cookieStr;
-				client.DownloadFileCompleted += WebUtils.FileDownloadCallback(target, onSuccess, onFailure);
-				client.DownloadFileAsync(new Uri(url), target);
-			}, scheduler);
-		}
-	}
-}
diff --git a/Utils/WindowsUtils.cs b/Utils/WindowsUtils.cs
index 6b48ad1c..aa954e4e 100644
--- a/Utils/WindowsUtils.cs
+++ b/Utils/WindowsUtils.cs
@@ -72,7 +72,7 @@ static IEnumerable<Browser> ReadBrowsersFromKey(RegistryHive hive) {
 				}
 			}
 
-			HashSet<Browser> browsers = new HashSet<Browser>();
+			var browsers = new HashSet<Browser>();
 
 			try {
 				browsers.UnionWith(ReadBrowsersFromKey(RegistryHive.CurrentUser));
diff --git a/lib/TweetLib.Browser/Base/BaseBrowser.cs b/lib/TweetLib.Browser/Base/BaseBrowser.cs
new file mode 100644
index 00000000..21a67218
--- /dev/null
+++ b/lib/TweetLib.Browser/Base/BaseBrowser.cs
@@ -0,0 +1,19 @@
+using System;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Browser.Base {
+	public class BaseBrowser<T> : IDisposable where T : BaseBrowser<T> {
+		public IScriptExecutor ScriptExecutor { get; }
+
+		protected readonly IBrowserComponent browserComponent;
+
+		protected BaseBrowser(IBrowserComponent browserComponent, Func<T, BrowserSetup> setup) {
+			this.browserComponent = browserComponent;
+			this.browserComponent.Setup(setup((T) this));
+
+			this.ScriptExecutor = browserComponent;
+		}
+
+		public virtual void Dispose() {}
+	}
+}
diff --git a/lib/TweetLib.Browser/Base/BrowserSetup.cs b/lib/TweetLib.Browser/Base/BrowserSetup.cs
new file mode 100644
index 00000000..4badb032
--- /dev/null
+++ b/lib/TweetLib.Browser/Base/BrowserSetup.cs
@@ -0,0 +1,8 @@
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Browser.Base {
+	public sealed class BrowserSetup {
+		public IContextMenuHandler? ContextMenuHandler { get; set; }
+		public IResourceRequestHandler? ResourceRequestHandler { get; set; }
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Context.cs b/lib/TweetLib.Browser/Contexts/Context.cs
new file mode 100644
index 00000000..81a5eba0
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Context.cs
@@ -0,0 +1,9 @@
+namespace TweetLib.Browser.Contexts {
+	public sealed class Context {
+		public Tweet? Tweet { get; set; }
+		public Link? Link { get; set; }
+		public Media? Media { get; set; }
+		public Selection? Selection { get; set; }
+		public Notification? Notification { get; set; }
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Link.cs b/lib/TweetLib.Browser/Contexts/Link.cs
new file mode 100644
index 00000000..96d8d61b
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Link.cs
@@ -0,0 +1,11 @@
+namespace TweetLib.Browser.Contexts {
+	public readonly struct Link {
+		public string Url { get; }
+		public string CopyUrl { get; }
+
+		public Link(string url, string copyUrl) {
+			Url = url;
+			CopyUrl = copyUrl;
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Media.cs b/lib/TweetLib.Browser/Contexts/Media.cs
new file mode 100644
index 00000000..0deddff1
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Media.cs
@@ -0,0 +1,16 @@
+namespace TweetLib.Browser.Contexts {
+	public readonly struct Media {
+		public Type MediaType { get; }
+		public string Url { get; }
+
+		public Media(Type mediaType, string url) {
+			MediaType = mediaType;
+			Url = url;
+		}
+
+		public enum Type {
+			Image,
+			Video
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Notification.cs b/lib/TweetLib.Browser/Contexts/Notification.cs
new file mode 100644
index 00000000..502f6af6
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Notification.cs
@@ -0,0 +1,11 @@
+namespace TweetLib.Browser.Contexts {
+	public struct Notification {
+		public string TweetUrl { get; }
+		public string? QuoteUrl { get; }
+
+		public Notification(string tweetUrl, string? quoteUrl) {
+			TweetUrl = tweetUrl;
+			QuoteUrl = quoteUrl;
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Selection.cs b/lib/TweetLib.Browser/Contexts/Selection.cs
new file mode 100644
index 00000000..e131f009
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Selection.cs
@@ -0,0 +1,11 @@
+namespace TweetLib.Browser.Contexts {
+	public readonly struct Selection {
+		public string Text { get; }
+		public bool Editable { get; }
+
+		public Selection(string text, bool editable) {
+			Text = text;
+			Editable = editable;
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Contexts/Tweet.cs b/lib/TweetLib.Browser/Contexts/Tweet.cs
new file mode 100644
index 00000000..1bacd220
--- /dev/null
+++ b/lib/TweetLib.Browser/Contexts/Tweet.cs
@@ -0,0 +1,24 @@
+namespace TweetLib.Browser.Contexts {
+	public readonly struct Tweet {
+		public string Url { get; }
+		public string? QuoteUrl { get; }
+		public string? Author { get; }
+		public string? QuoteAuthor { get; }
+		public string[] ImageUrls { get; }
+
+		public string ColumnId { get; }
+		public string ChirpId { get; }
+
+		public string? MediaAuthor => QuoteAuthor ?? Author;
+
+		public Tweet(string url, string? quoteUrl, string? author, string? quoteAuthor, string[] imageUrls, string columnId, string chirpId) {
+			Url = url;
+			QuoteUrl = quoteUrl;
+			Author = author;
+			QuoteAuthor = quoteAuthor;
+			ImageUrls = imageUrls;
+			ColumnId = columnId;
+			ChirpId = chirpId;
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Events/BrowserLoadedEventArgs.cs b/lib/TweetLib.Browser/Events/BrowserLoadedEventArgs.cs
new file mode 100644
index 00000000..31459262
--- /dev/null
+++ b/lib/TweetLib.Browser/Events/BrowserLoadedEventArgs.cs
@@ -0,0 +1,7 @@
+using System;
+
+namespace TweetLib.Browser.Events {
+	public abstract class BrowserLoadedEventArgs : EventArgs {
+		public abstract void AddDictionaryWords(params string[] word);
+	}
+}
diff --git a/lib/TweetLib.Browser/Events/PageLoadEventArgs.cs b/lib/TweetLib.Browser/Events/PageLoadEventArgs.cs
new file mode 100644
index 00000000..63afb00d
--- /dev/null
+++ b/lib/TweetLib.Browser/Events/PageLoadEventArgs.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace TweetLib.Browser.Events {
+	public sealed class PageLoadEventArgs : EventArgs {
+		public string Url { get; }
+
+		public PageLoadEventArgs(string url) {
+			Url = url;
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/IBrowserComponent.cs b/lib/TweetLib.Browser/Interfaces/IBrowserComponent.cs
new file mode 100644
index 00000000..543f3211
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IBrowserComponent.cs
@@ -0,0 +1,18 @@
+using System;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Events;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface IBrowserComponent : IScriptExecutor {
+		string Url { get; }
+
+		IFileDownloader FileDownloader { get; }
+
+		event EventHandler<BrowserLoadedEventArgs> BrowserLoaded;
+		event EventHandler<PageLoadEventArgs> PageLoadStart;
+		event EventHandler<PageLoadEventArgs> PageLoadEnd;
+
+		void Setup(BrowserSetup setup);
+		void AttachBridgeObject(string name, object bridge);
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/IContextMenuBuilder.cs b/lib/TweetLib.Browser/Interfaces/IContextMenuBuilder.cs
new file mode 100644
index 00000000..862d98fa
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IContextMenuBuilder.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface IContextMenuBuilder {
+		void AddAction(string name, Action action);
+		void AddActionWithCheck(string name, bool isChecked, Action action);
+		void AddSeparator();
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/IContextMenuHandler.cs b/lib/TweetLib.Browser/Interfaces/IContextMenuHandler.cs
new file mode 100644
index 00000000..65241d32
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IContextMenuHandler.cs
@@ -0,0 +1,7 @@
+using TweetLib.Browser.Contexts;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface IContextMenuHandler {
+		void Show(IContextMenuBuilder menu, Context context);
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/ICustomSchemeHandler.cs b/lib/TweetLib.Browser/Interfaces/ICustomSchemeHandler.cs
new file mode 100644
index 00000000..5974960e
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/ICustomSchemeHandler.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface ICustomSchemeHandler<T> where T : class {
+		string Protocol { get; }
+		T? Resolve(Uri uri);
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/IFileDownloader.cs b/lib/TweetLib.Browser/Interfaces/IFileDownloader.cs
new file mode 100644
index 00000000..f66ac083
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IFileDownloader.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface IFileDownloader {
+		string CacheFolder { get; }
+		void DownloadFile(string url, string path, Action? onSuccess, Action<Exception>? onError);
+	}
+}
diff --git a/lib/TweetLib.Core/Browser/IResourceProvider.cs b/lib/TweetLib.Browser/Interfaces/IResourceProvider.cs
similarity index 57%
rename from lib/TweetLib.Core/Browser/IResourceProvider.cs
rename to lib/TweetLib.Browser/Interfaces/IResourceProvider.cs
index 93520597..71eaa48b 100644
--- a/lib/TweetLib.Core/Browser/IResourceProvider.cs
+++ b/lib/TweetLib.Browser/Interfaces/IResourceProvider.cs
@@ -1,8 +1,8 @@
 using System.Net;
 
-namespace TweetLib.Core.Browser {
+namespace TweetLib.Browser.Interfaces {
 	public interface IResourceProvider<T> {
 		T Status(HttpStatusCode code, string message);
-		T File(string path);
+		T File(byte[] contents, string extension);
 	}
 }
diff --git a/lib/TweetLib.Browser/Interfaces/IResourceRequestHandler.cs b/lib/TweetLib.Browser/Interfaces/IResourceRequestHandler.cs
new file mode 100644
index 00000000..127ac149
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IResourceRequestHandler.cs
@@ -0,0 +1,7 @@
+using TweetLib.Browser.Request;
+
+namespace TweetLib.Browser.Interfaces {
+	public interface IResourceRequestHandler {
+		RequestHandleResult? Handle(string url, ResourceType resourceType);
+	}
+}
diff --git a/lib/TweetLib.Browser/Interfaces/IResponseProcessor.cs b/lib/TweetLib.Browser/Interfaces/IResponseProcessor.cs
new file mode 100644
index 00000000..04bc230c
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/IResponseProcessor.cs
@@ -0,0 +1,5 @@
+namespace TweetLib.Browser.Interfaces {
+	public interface IResponseProcessor {
+		byte[] Process(byte[] response);
+	}
+}
diff --git a/lib/TweetLib.Core/Browser/IScriptExecutor.cs b/lib/TweetLib.Browser/Interfaces/IScriptExecutor.cs
similarity index 64%
rename from lib/TweetLib.Core/Browser/IScriptExecutor.cs
rename to lib/TweetLib.Browser/Interfaces/IScriptExecutor.cs
index 1daef2c9..da73347e 100644
--- a/lib/TweetLib.Core/Browser/IScriptExecutor.cs
+++ b/lib/TweetLib.Browser/Interfaces/IScriptExecutor.cs
@@ -1,7 +1,6 @@
-namespace TweetLib.Core.Browser {
+namespace TweetLib.Browser.Interfaces {
 	public interface IScriptExecutor {
 		void RunFunction(string name, params object[] args);
 		void RunScript(string identifier, string script);
-		void RunBootstrap(string moduleNamespace);
 	}
 }
diff --git a/lib/TweetLib.Browser/Interfaces/ResponseProcessorUtf8.cs b/lib/TweetLib.Browser/Interfaces/ResponseProcessorUtf8.cs
new file mode 100644
index 00000000..aad6d6fb
--- /dev/null
+++ b/lib/TweetLib.Browser/Interfaces/ResponseProcessorUtf8.cs
@@ -0,0 +1,11 @@
+using System.Text;
+
+namespace TweetLib.Browser.Interfaces {
+	public abstract class ResponseProcessorUtf8 : IResponseProcessor {
+		byte[] IResponseProcessor.Process(byte[] response) {
+			return Encoding.UTF8.GetBytes(Process(Encoding.UTF8.GetString(response)));
+		}
+
+		protected abstract string Process(string response);
+	}
+}
diff --git a/lib/TweetLib.Browser/Lib.cs b/lib/TweetLib.Browser/Lib.cs
new file mode 100644
index 00000000..17db32e0
--- /dev/null
+++ b/lib/TweetLib.Browser/Lib.cs
@@ -0,0 +1,5 @@
+using System.Reflection;
+
+[assembly: AssemblyTitle("TweetDuck Browser Library")]
+[assembly: AssemblyDescription("TweetDuck Browser Library")]
+[assembly: AssemblyProduct("TweetLib.Browser")]
diff --git a/lib/TweetLib.Browser/Request/RequestHandleResult.cs b/lib/TweetLib.Browser/Request/RequestHandleResult.cs
new file mode 100644
index 00000000..a905528b
--- /dev/null
+++ b/lib/TweetLib.Browser/Request/RequestHandleResult.cs
@@ -0,0 +1,29 @@
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Browser.Request {
+	public abstract class RequestHandleResult {
+		private RequestHandleResult() {}
+
+		public sealed class Redirect : RequestHandleResult {
+			public string Url { get; }
+
+			public Redirect(string url) {
+				Url = url;
+			}
+		}
+
+		public sealed class Process : RequestHandleResult {
+			public IResponseProcessor Processor { get; }
+
+			public Process(IResponseProcessor processor) {
+				Processor = processor;
+			}
+		}
+
+		public sealed class Cancel : RequestHandleResult {
+			public static Cancel Instance { get; } = new ();
+
+			private Cancel() {}
+		}
+	}
+}
diff --git a/lib/TweetLib.Browser/Request/ResourceType.cs b/lib/TweetLib.Browser/Request/ResourceType.cs
new file mode 100644
index 00000000..455f6b7b
--- /dev/null
+++ b/lib/TweetLib.Browser/Request/ResourceType.cs
@@ -0,0 +1,10 @@
+namespace TweetLib.Browser.Request {
+	public enum ResourceType {
+		Unknown,
+		MainFrame,
+		Script,
+		Stylesheet,
+		Xhr,
+		Image
+	}
+}
diff --git a/lib/TweetLib.Browser/TweetLib.Browser.csproj b/lib/TweetLib.Browser/TweetLib.Browser.csproj
new file mode 100644
index 00000000..b8c0b066
--- /dev/null
+++ b/lib/TweetLib.Browser/TweetLib.Browser.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  
+  <PropertyGroup>
+    <TargetFramework>netstandard2.0</TargetFramework>
+    <Platforms>x86</Platforms>
+    <LangVersion>9</LangVersion>
+    <Nullable>enable</Nullable>
+    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+  </PropertyGroup>
+  
+  <ItemGroup>
+    <Compile Include="..\..\Version.cs" Link="Version.cs" />
+  </ItemGroup>
+
+</Project>
diff --git a/lib/TweetLib.Core/App.cs b/lib/TweetLib.Core/App.cs
index c96728fb..6fd92fea 100644
--- a/lib/TweetLib.Core/App.cs
+++ b/lib/TweetLib.Core/App.cs
@@ -1,31 +1,64 @@
 using System;
-using System.Diagnostics.CodeAnalysis;
+using System.IO;
 using TweetLib.Core.Application;
+using TweetLib.Utils.Static;
 
 namespace TweetLib.Core {
-	public sealed class App {
-		#pragma warning disable CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable.
-		public static IAppErrorHandler ErrorHandler { get; private set; }
-		public static IAppSystemHandler SystemHandler { get; private set; }
-		#pragma warning restore CS8618 // Non-nullable field is uninitialized. Consider declaring as nullable.
+	public static class App {
+		public static readonly string ProgramPath = AppDomain.CurrentDomain.BaseDirectory;
+		public static readonly bool IsPortable = File.Exists(Path.Combine(ProgramPath, "makeportable"));
 
-		// Builder
+		public static readonly string ResourcesPath = Path.Combine(ProgramPath, "resources");
+		public static readonly string PluginPath    = Path.Combine(ProgramPath, "plugins");
+		public static readonly string GuidePath     = Path.Combine(ProgramPath, "guide");
 
-		[SuppressMessage("ReSharper", "MemberHidesStaticFromOuterClass")]
-		public sealed class Builder {
-			public IAppErrorHandler? ErrorHandler { get; set; }
-			public IAppSystemHandler? SystemHandler { get; set; }
+		public static readonly string StoragePath = IsPortable ? Path.Combine(ProgramPath, "portable", "storage") : Validate(Builder.Startup, nameof(Builder.Startup)).GetDataFolder();
 
-			// Validation
+		public static IAppLogger Logger                       { get; } = Validate(Builder.Logger, nameof(Builder.Logger));
+		public static IAppErrorHandler ErrorHandler           { get; } = Validate(Builder.ErrorHandler, nameof(Builder.ErrorHandler));
+		public static IAppSystemHandler SystemHandler         { get; } = Validate(Builder.SystemHandler, nameof(Builder.SystemHandler));
+		public static IAppDialogHandler DialogHandler         { get; } = Validate(Builder.DialogHandler, nameof(Builder.DialogHandler));
+		public static IAppUserConfiguration UserConfiguration { get; } = Validate(Builder.UserConfiguration, nameof(Builder.UserConfiguration));
 
-			internal void Initialize() {
-				App.ErrorHandler = Validate(ErrorHandler, nameof(ErrorHandler));
-				App.SystemHandler = Validate(SystemHandler, nameof(SystemHandler));
+		public static void Launch() {
+			if (!FileUtils.CheckFolderWritePermission(StoragePath)) {
+				throw new AppException("Permission Error", "TweetDuck does not have write permissions to the storage folder: " + StoragePath);
+			}
+		}
+
+		// Setup
+
+		private static AppBuilder Builder => AppBuilder.Instance ?? throw new InvalidOperationException("App is initializing too early");
+
+		private static bool isInitialized = false;
+
+		internal static void Initialize() {
+			if (isInitialized) {
+				throw new InvalidOperationException("App is already initialized");
 			}
 
-			private T Validate<T>(T? obj, string name) where T : class {
-				return obj ?? throw new InvalidOperationException("Missing property " + name + " on the provided App.");
-			}
+			isInitialized = true;
+		}
+
+		private static T Validate<T>(T? obj, string name) where T : class {
+			return obj ?? throw new InvalidOperationException("Missing property " + name + " on the provided App");
+		}
+	}
+
+	public sealed class AppBuilder {
+		public AppStartup? Startup { get; set; }
+		public IAppLogger? Logger { get; set; }
+		public IAppErrorHandler? ErrorHandler { get; set; }
+		public IAppSystemHandler? SystemHandler { get; set; }
+		public IAppDialogHandler? DialogHandler { get; set; }
+		public IAppUserConfiguration? UserConfiguration { get; set; }
+
+		internal static AppBuilder? Instance { get; private set; }
+
+		internal void Build() {
+			Instance = this;
+			App.Initialize();
+			Instance = null;
 		}
 	}
 }
diff --git a/lib/TweetLib.Core/Application/AppException.cs b/lib/TweetLib.Core/Application/AppException.cs
new file mode 100644
index 00000000..f3555522
--- /dev/null
+++ b/lib/TweetLib.Core/Application/AppException.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace TweetLib.Core.Application {
+	public sealed class AppException : Exception {
+		public string Title { get; }
+
+		internal AppException(string title) {
+			this.Title = title;
+		}
+
+		internal AppException(string title, string message) : base(message) {
+			this.Title = title;
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Application/AppStartup.cs b/lib/TweetLib.Core/Application/AppStartup.cs
new file mode 100644
index 00000000..22adcd68
--- /dev/null
+++ b/lib/TweetLib.Core/Application/AppStartup.cs
@@ -0,0 +1,27 @@
+using System;
+using System.IO;
+using System.Linq;
+
+namespace TweetLib.Core.Application {
+	public sealed class AppStartup {
+		public string? CustomDataFolder { get; set; }
+
+		internal string GetDataFolder() {
+			string? custom = CustomDataFolder;
+
+			if (custom != null && (custom.Contains(Path.DirectorySeparatorChar) || custom.Contains(Path.AltDirectorySeparatorChar))) {
+				if (Path.GetInvalidPathChars().Any(custom.Contains)) {
+					throw new AppException("Data Folder Invalid", "The data folder contains invalid characters:\n" + custom);
+				}
+				else if (!Path.IsPathRooted(custom)) {
+					throw new AppException("Data Folder Invalid", "The data folder has to be either a simple folder name, or a full path:\n" + custom);
+				}
+
+				return Environment.ExpandEnvironmentVariables(custom);
+			}
+			else {
+				return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), custom ?? Lib.BrandName);
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Application/IAppDialogHandler.cs b/lib/TweetLib.Core/Application/IAppDialogHandler.cs
new file mode 100644
index 00000000..a8fe3aa2
--- /dev/null
+++ b/lib/TweetLib.Core/Application/IAppDialogHandler.cs
@@ -0,0 +1,11 @@
+using System;
+using TweetLib.Core.Systems.Dialogs;
+
+namespace TweetLib.Core.Application {
+	public interface IAppDialogHandler {
+		void Information(string caption, string text, string buttonAccept, string? buttonCancel = null);
+		void Error(string caption, string text, string buttonAccept, string? buttonCancel = null);
+
+		void SaveFile(SaveFileDialogSettings settings, Action<string> onAccepted);
+	}
+}
diff --git a/lib/TweetLib.Core/Application/IAppErrorHandler.cs b/lib/TweetLib.Core/Application/IAppErrorHandler.cs
index eb695e48..88210d92 100644
--- a/lib/TweetLib.Core/Application/IAppErrorHandler.cs
+++ b/lib/TweetLib.Core/Application/IAppErrorHandler.cs
@@ -2,7 +2,6 @@
 
 namespace TweetLib.Core.Application {
 	public interface IAppErrorHandler {
-		bool Log(string text);
 		void HandleException(string caption, string message, bool canIgnore, Exception e);
 	}
 }
diff --git a/lib/TweetLib.Core/Application/IAppLogger.cs b/lib/TweetLib.Core/Application/IAppLogger.cs
new file mode 100644
index 00000000..9389b283
--- /dev/null
+++ b/lib/TweetLib.Core/Application/IAppLogger.cs
@@ -0,0 +1,8 @@
+namespace TweetLib.Core.Application {
+	public interface IAppLogger {
+		bool Debug(string message);
+		bool Info(string message);
+		bool Error(string message);
+		bool OpenLogFile();
+	}
+}
diff --git a/lib/TweetLib.Core/Application/IAppSystemHandler.cs b/lib/TweetLib.Core/Application/IAppSystemHandler.cs
index 332c36da..2eda33c7 100644
--- a/lib/TweetLib.Core/Application/IAppSystemHandler.cs
+++ b/lib/TweetLib.Core/Application/IAppSystemHandler.cs
@@ -1,6 +1,10 @@
 namespace TweetLib.Core.Application {
 	public interface IAppSystemHandler {
 		void OpenAssociatedProgram(string path);
+		void OpenBrowser(string? url);
 		void OpenFileExplorer(string path);
+		void CopyImageFromFile(string path);
+		void CopyText(string text);
+		void SearchText(string text);
 	}
 }
diff --git a/lib/TweetLib.Core/Application/IAppUserConfiguration.cs b/lib/TweetLib.Core/Application/IAppUserConfiguration.cs
new file mode 100644
index 00000000..b9f6a31c
--- /dev/null
+++ b/lib/TweetLib.Core/Application/IAppUserConfiguration.cs
@@ -0,0 +1,28 @@
+using System;
+using TweetLib.Core.Features.Twitter;
+
+namespace TweetLib.Core.Application {
+	public interface IAppUserConfiguration {
+		string CustomBrowserCSS { get; }
+		string CustomNotificationCSS { get; }
+		string? DismissedUpdate { get; }
+		bool ExpandLinksOnHover { get; }
+		bool FirstRun { get; }
+		bool FocusDmInput { get; }
+		bool IsCustomSoundNotificationSet { get; }
+		bool KeepLikeFollowDialogsOpen { get; }
+		bool MuteNotifications { get; }
+		bool NotificationMediaPreviews { get; }
+		bool NotificationSkipOnLinkClick { get; }
+		string NotificationSoundPath { get; }
+		int NotificationSoundVolume { get; }
+		bool OpenSearchInFirstColumn { get; }
+		int CalendarFirstDay { get; }
+		string TranslationTarget { get; }
+		ImageQuality TwitterImageQuality { get; }
+
+		event EventHandler MuteToggled;
+		event EventHandler OptionsDialogClosed;
+		event EventHandler SoundNotificationChanged;
+	}
+}
diff --git a/lib/TweetLib.Core/Features/BaseBrowser.cs b/lib/TweetLib.Core/Features/BaseBrowser.cs
new file mode 100644
index 00000000..ea6e4501
--- /dev/null
+++ b/lib/TweetLib.Core/Features/BaseBrowser.cs
@@ -0,0 +1,22 @@
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Core.Features {
+	public sealed class BaseBrowser : BaseBrowser<BaseBrowser> {
+		public BaseBrowser(IBrowserComponent browserComponent) : base(browserComponent, CreateSetupObject) {}
+
+		internal static BrowserSetup CreateSetupObject(IBrowserComponent browserComponent, BrowserSetup setup) {
+			setup.ContextMenuHandler ??= new BaseContextMenu(browserComponent);
+			setup.ResourceRequestHandler ??= new BaseResourceRequestHandler();
+			return setup;
+		}
+
+		internal static BrowserSetup CreateSetupObject(IBrowserComponent browserComponent) {
+			return CreateSetupObject(browserComponent, new BrowserSetup());
+		}
+
+		private static BrowserSetup CreateSetupObject(BaseBrowser browser) {
+			return CreateSetupObject(browser.browserComponent);
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/BaseContextMenu.cs b/lib/TweetLib.Core/Features/BaseContextMenu.cs
new file mode 100644
index 00000000..fb62408c
--- /dev/null
+++ b/lib/TweetLib.Core/Features/BaseContextMenu.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Text.RegularExpressions;
+using TweetLib.Browser.Contexts;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.Twitter;
+using TweetLib.Core.Systems.Dialogs;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features {
+	internal class BaseContextMenu : IContextMenuHandler {
+		private readonly IBrowserComponent browser;
+		private readonly FileDownloadManager fileDownloadManager;
+
+		public BaseContextMenu(IBrowserComponent browser) {
+			this.browser = browser;
+			this.fileDownloadManager = new FileDownloadManager(browser);
+		}
+
+		public virtual void Show(IContextMenuBuilder menu, Context context) {
+			if (context.Selection is { Editable: false } selection) {
+				AddSearchSelectionItems(menu, selection.Text);
+				menu.AddSeparator();
+				menu.AddAction("Apply ROT13", () => App.DialogHandler.Information("ROT13", StringUtils.ConvertRot13(selection.Text), Dialogs.OK));
+				menu.AddSeparator();
+			}
+
+			static string TextOpen(string name) => "Open " + name + " in browser";
+			static string TextCopy(string name) => "Copy " + name + " address";
+			static string TextSave(string name) => "Save " + name + " as...";
+
+			if (context.Link is {} link && !link.CopyUrl.EndsWithOrdinal("tweetdeck.twitter.com/#") && !link.CopyUrl.StartsWithOrdinal("td://")) {
+				Match match = TwitterUrls.RegexAccount.Match(link.CopyUrl);
+
+				if (match.Success) {
+					menu.AddAction(TextOpen("account"), OpenLink(link.Url));
+					menu.AddAction(TextCopy("account"), CopyText(link.CopyUrl));
+					menu.AddAction("Copy account username", CopyText(match.Groups[1].Value));
+				}
+				else {
+					menu.AddAction(TextOpen("link"), OpenLink(link.Url));
+					menu.AddAction(TextCopy("link"), CopyText(link.CopyUrl));
+				}
+
+				menu.AddSeparator();
+			}
+
+			if (context.Media is {} media && !media.Url.StartsWithOrdinal("td://")) {
+				var tweet = context.Tweet;
+
+				switch (media.MediaType) {
+					case Media.Type.Image:
+						menu.AddAction("View image in photo viewer", () => fileDownloadManager.ViewImage(media.Url));
+						menu.AddAction(TextOpen("image"), OpenLink(media.Url));
+						menu.AddAction(TextCopy("image"), CopyText(media.Url));
+						menu.AddAction("Copy image", () => fileDownloadManager.CopyImage(media.Url));
+						menu.AddAction(TextSave("image"), () => fileDownloadManager.SaveImages(new string[] { media.Url }, tweet?.MediaAuthor));
+
+						var imageUrls = tweet?.ImageUrls;
+						if (imageUrls?.Length > 1) {
+							menu.AddAction(TextSave("all images"), () => fileDownloadManager.SaveImages(imageUrls, tweet?.MediaAuthor));
+						}
+
+						menu.AddSeparator();
+						break;
+
+					case Media.Type.Video:
+						menu.AddAction(TextOpen("video"), OpenLink(media.Url));
+						menu.AddAction(TextCopy("video"), CopyText(media.Url));
+						menu.AddAction(TextSave("video"), () => fileDownloadManager.SaveVideo(media.Url, tweet?.MediaAuthor));
+						menu.AddSeparator();
+						break;
+				}
+			}
+		}
+
+		protected virtual void AddSearchSelectionItems(IContextMenuBuilder menu, string selectedText) {
+			menu.AddAction("Search in browser", () => {
+				App.SystemHandler.SearchText(selectedText);
+				DeselectAll();
+			});
+		}
+
+		protected void DeselectAll() {
+			browser.RunScript("gen:deselect", "window.getSelection().removeAllRanges()");
+		}
+
+		protected static Action OpenLink(string url) {
+			return () => App.SystemHandler.OpenBrowser(url);
+		}
+
+		protected static Action CopyText(string text) {
+			return () => App.SystemHandler.CopyText(text);
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/BaseResourceRequestHandler.cs b/lib/TweetLib.Core/Features/BaseResourceRequestHandler.cs
new file mode 100644
index 00000000..d4176146
--- /dev/null
+++ b/lib/TweetLib.Core/Features/BaseResourceRequestHandler.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Browser.Request;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features {
+	public class BaseResourceRequestHandler : IResourceRequestHandler {
+		private static readonly Regex TweetDeckResourceUrl = new (@"/dist/(.*?)\.(.*?)\.(css|js)$");
+		private static readonly SortedList<string, string> TweetDeckHashes = new (4);
+
+		public static void LoadResourceRewriteRules(string rules) {
+			if (string.IsNullOrEmpty(rules)) {
+				return;
+			}
+
+			TweetDeckHashes.Clear();
+
+			foreach (string rule in rules.Replace(" ", "").ToLower().Split(',')) {
+				var (key, hash) = StringUtils.SplitInTwo(rule, '=') ?? throw new ArgumentException("A rule must have one '=' character: " + rule);
+
+				if (hash.All(static chr => char.IsDigit(chr) || chr is >= 'a' and <= 'f')) {
+					TweetDeckHashes.Add(key, hash);
+				}
+				else {
+					throw new ArgumentException("Invalid hash characters: " + rule);
+				}
+			}
+		}
+
+		public virtual RequestHandleResult? Handle(string url, ResourceType resourceType) {
+			if (resourceType is ResourceType.Script or ResourceType.Stylesheet && TweetDeckHashes.Count > 0) {
+				Match match = TweetDeckResourceUrl.Match(url);
+
+				if (match.Success && TweetDeckHashes.TryGetValue($"{match.Groups[1]}.{match.Groups[3]}", out string hash)) {
+					if (match.Groups[2].Value == hash) {
+						App.Logger.Debug("[RequestHandlerBase] Accepting " + url);
+					}
+					else {
+						App.Logger.Debug("[RequestHandlerBase] Replacing " + url + " hash with " + hash);
+						return new RequestHandleResult.Redirect(TweetDeckResourceUrl.Replace(url, $"/dist/$1.{hash}.$3"));
+					}
+				}
+			}
+
+			return null;
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Chromium/SpellCheck.cs b/lib/TweetLib.Core/Features/Chromium/SpellCheck.cs
index b772fa82..c29af9d7 100644
--- a/lib/TweetLib.Core/Features/Chromium/SpellCheck.cs
+++ b/lib/TweetLib.Core/Features/Chromium/SpellCheck.cs
@@ -14,9 +14,9 @@ public static class SpellCheck {
 			"lv-LV", "nb-NO", "nl-NL", "pl-PL", "pt-BR", "pt-PT",
 			"ro-RO", "ru-RU", "sk-SK", "sl-SI", "sq"   , "sr",
 			"sv-SE", "ta-IN", "tg-TG", "tr"   , "uk-UA", "vi-VN"
-		}.Select(code => {
+		}.Select(static code => {
 			string lang = StringUtils.ExtractBefore(code, '-', 2);
-			return lang == "en" || lang == "pt" ? new Language(code) : new Language(code, lang);
-		}).OrderBy(code => code).ToList();
+			return lang is "en" or "pt" ? new Language(code) : new Language(code, lang);
+		}).OrderBy(static code => code).ToList();
 	}
 }
diff --git a/lib/TweetLib.Core/Features/CommonBridgeObject.cs b/lib/TweetLib.Core/Features/CommonBridgeObject.cs
new file mode 100644
index 00000000..f488766a
--- /dev/null
+++ b/lib/TweetLib.Core/Features/CommonBridgeObject.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using TweetLib.Core.Features.Notifications;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features {
+	[SuppressMessage("ReSharper", "UnusedMember.Global")]
+	internal class CommonBridge {
+		private readonly ICommonInterface i;
+
+		protected CommonBridge(ICommonInterface i) {
+			this.i = i;
+		}
+
+		public void OnTweetPopup(string columnId, string chirpId, string columnName, string tweetHtml, int tweetCharacters, string tweetUrl, string quoteUrl) {
+			i.ShowDesktopNotification(new DesktopNotification(columnId, chirpId, columnName, tweetHtml, tweetCharacters, tweetUrl, quoteUrl));
+		}
+
+		public void OnTweetSound() {
+			i.OnSoundNotification();
+		}
+
+		public void ScreenshotTweet(string html, int width) {
+			i.ScreenshotTweet(html, width);
+		}
+
+		public void PlayVideo(string videoUrl, string tweetUrl, string username, IDisposable callShowOverlay) {
+			i.PlayVideo(videoUrl, tweetUrl, username, callShowOverlay);
+		}
+
+		public void StopVideo() {
+			i.StopVideo();
+		}
+
+		public void FixClipboard() {
+			i.FixClipboard();
+		}
+
+		public void OpenBrowser(string url) {
+			App.SystemHandler.OpenBrowser(url);
+		}
+
+		public void MakeGetRequest(string url, IDisposable onSuccess, IDisposable onError) {
+			Task.Run(async () => {
+				var client = WebUtils.NewClient();
+
+				try {
+					var result = await client.DownloadStringTaskAsync(url);
+					await i.ExecuteCallback(onSuccess, result);
+				} catch (Exception e) {
+					await i.ExecuteCallback(onError, e.Message);
+				} finally {
+					onSuccess.Dispose();
+					onError.Dispose();
+					client.Dispose();
+				}
+			});
+		}
+
+		public int GetIdleSeconds() {
+			return i.GetIdleSeconds();
+		}
+
+		public void Alert(string type, string contents) {
+			i.Alert(type, contents);
+		}
+
+		public void CrashDebug(string message) {
+			#if DEBUG
+			System.Diagnostics.Debug.WriteLine(message);
+			System.Diagnostics.Debugger.Break();
+			#endif
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/FileDownloadManager.cs b/lib/TweetLib.Core/Features/FileDownloadManager.cs
new file mode 100644
index 00000000..623c2463
--- /dev/null
+++ b/lib/TweetLib.Core/Features/FileDownloadManager.cs
@@ -0,0 +1,112 @@
+using System;
+using System.IO;
+using System.Linq;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.Twitter;
+using TweetLib.Core.Systems.Dialogs;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features {
+	public sealed class FileDownloadManager {
+		private readonly IBrowserComponent browser;
+
+		internal FileDownloadManager(IBrowserComponent browser) {
+			this.browser = browser;
+		}
+
+		private void DownloadTempImage(string url, Action<string> process) {
+			string? staticFileName = TwitterUrls.GetImageFileName(url);
+			string file = Path.Combine(browser.FileDownloader.CacheFolder, staticFileName ?? Path.GetRandomFileName());
+
+			if (staticFileName != null && FileUtils.FileExistsAndNotEmpty(file)) {
+				process(file);
+			}
+			else {
+				void OnSuccess() {
+					process(file);
+				}
+
+				static void OnFailure(Exception ex) {
+					App.DialogHandler.Error("Image Download", "An error occurred while downloading the image: " + ex.Message, Dialogs.OK);
+				}
+
+				browser.FileDownloader.DownloadFile(url, file, OnSuccess, OnFailure);
+			}
+		}
+
+		public void ViewImage(string url) {
+			DownloadTempImage(url, static path => {
+				string ext = Path.GetExtension(path);
+
+				if (ImageUrl.ValidExtensions.Contains(ext)) {
+					App.SystemHandler.OpenAssociatedProgram(path);
+				}
+				else {
+					App.DialogHandler.Error("Image Download", "Unknown image file extension: " + ext, Dialogs.OK);
+				}
+			});
+		}
+
+		public void CopyImage(string url) {
+			DownloadTempImage(url, App.SystemHandler.CopyImageFromFile);
+		}
+
+		public void SaveImages(string[] urls, string? author) {
+			if (urls.Length == 0) {
+				return;
+			}
+
+			bool oneImage = urls.Length == 1;
+			string firstImageLink = urls[0];
+			int qualityIndex = firstImageLink.IndexOf(':', firstImageLink.LastIndexOf('/'));
+
+			string? filename = TwitterUrls.GetImageFileName(firstImageLink);
+			string? ext = Path.GetExtension(filename); // includes dot
+
+			var settings = new SaveFileDialogSettings {
+				DialogTitle = oneImage ? "Save Image" : "Save Images",
+				OverwritePrompt = oneImage,
+				FileName = qualityIndex == -1 ? filename : $"{author} {Path.ChangeExtension(filename, null)} {firstImageLink.Substring(qualityIndex + 1)}".Trim() + ext,
+				Filters = new [] { new FileDialogFilter(oneImage ? "Image" : "Images", string.IsNullOrEmpty(ext) ? Array.Empty<string>() : new [] { ext }) }
+			};
+
+			App.DialogHandler.SaveFile(settings, path => {
+				static void OnFailure(Exception ex) {
+					App.DialogHandler.Error("Image Download", "An error occurred while downloading the image: " + ex.Message, Dialogs.OK);
+				}
+
+				if (oneImage) {
+					browser.FileDownloader.DownloadFile(firstImageLink, path, null, OnFailure);
+				}
+				else {
+					string pathBase = Path.ChangeExtension(path, null);
+					string pathExt = Path.GetExtension(path);
+
+					for (int index = 0; index < urls.Length; index++) {
+						browser.FileDownloader.DownloadFile(urls[index], $"{pathBase} {index + 1}{pathExt}", null, OnFailure);
+					}
+				}
+			});
+		}
+
+		public void SaveVideo(string url, string? author) {
+			string? filename = TwitterUrls.GetFileNameFromUrl(url);
+			string? ext = Path.GetExtension(filename);
+
+			var settings = new SaveFileDialogSettings {
+				DialogTitle = "Save Video",
+				OverwritePrompt = true,
+				FileName = string.IsNullOrEmpty(author) ? filename : $"{author} {filename}".TrimStart(),
+				Filters = new [] { new FileDialogFilter("Video", string.IsNullOrEmpty(ext) ? Array.Empty<string>() : new [] { ext }) }
+			};
+
+			App.DialogHandler.SaveFile(settings, path => {
+				static void OnError(Exception ex) {
+					App.DialogHandler.Error("Video Download", "An error occurred while downloading the video: " + ex.Message, Dialogs.OK);
+				}
+
+				browser.FileDownloader.DownloadFile(url, path, null, OnError);
+			});
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/ICommonInterface.cs b/lib/TweetLib.Core/Features/ICommonInterface.cs
new file mode 100644
index 00000000..cb8f0eba
--- /dev/null
+++ b/lib/TweetLib.Core/Features/ICommonInterface.cs
@@ -0,0 +1,18 @@
+using System.Threading.Tasks;
+using TweetLib.Core.Features.Notifications;
+
+namespace TweetLib.Core.Features {
+	public interface ICommonInterface {
+		void Alert(string type, string contents);
+		void DisplayTooltip(string text);
+		void FixClipboard();
+		int GetIdleSeconds();
+		void OnSoundNotification();
+		void PlayVideo(string videoUrl, string tweetUrl, string username, object callShowOverlay);
+		void ScreenshotTweet(string html, int width);
+		void ShowDesktopNotification(DesktopNotification notification);
+		void StopVideo();
+
+		Task ExecuteCallback(object callback, params object[] parameters);
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/INotificationInterface.cs b/lib/TweetLib.Core/Features/Notifications/INotificationInterface.cs
new file mode 100644
index 00000000..5fcdb1d6
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/INotificationInterface.cs
@@ -0,0 +1,10 @@
+namespace TweetLib.Core.Features.Notifications {
+	public interface INotificationInterface {
+		bool FreezeTimer { get; set; }
+		bool IsHovered { get; }
+
+		void DisplayTooltip(string text);
+		void FinishCurrentNotification();
+		void ShowTweetDetail();
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/NotificationBridgeObject.cs b/lib/TweetLib.Core/Features/Notifications/NotificationBridgeObject.cs
new file mode 100644
index 00000000..74aaaf73
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/NotificationBridgeObject.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace TweetLib.Core.Features.Notifications {
+	[SuppressMessage("ReSharper", "UnusedMember.Global")]
+	sealed class NotificationBridgeObject : CommonBridge {
+		private readonly INotificationInterface i;
+
+		public NotificationBridgeObject(INotificationInterface notificationInterface, ICommonInterface commonInterface) : base(commonInterface) {
+			this.i = notificationInterface;
+		}
+
+		public void DisplayTooltip(string text) {
+			i.DisplayTooltip(text);
+		}
+
+		public void LoadNextNotification() {
+			i.FinishCurrentNotification();
+		}
+
+		public void ShowTweetDetail() {
+			i.ShowTweetDetail();
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Example.cs b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Example.cs
new file mode 100644
index 00000000..85d4767a
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Example.cs
@@ -0,0 +1,17 @@
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.Plugins;
+
+namespace TweetLib.Core.Features.Notifications {
+	public abstract partial class NotificationBrowser {
+		public sealed class Example : Tweet {
+			protected override string BodyClasses => base.BodyClasses + " td-example";
+
+			public Example(IBrowserComponent browserComponent, INotificationInterface notificationInterface, ICommonInterface commonInterface, PluginManager pluginManager) : base(browserComponent, notificationInterface, commonInterface, pluginManager, CreateSetupObject) {}
+
+			private static BrowserSetup CreateSetupObject(NotificationBrowser browser) {
+				return BaseBrowser.CreateSetupObject(browser.browserComponent);
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Screenshot.cs b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Screenshot.cs
new file mode 100644
index 00000000..2f3a0d52
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Screenshot.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Utils.Data;
+
+namespace TweetLib.Core.Features.Notifications {
+	public abstract partial class NotificationBrowser {
+		public sealed class Screenshot : NotificationBrowser {
+			private readonly IEnumerable<InjectedString> notificationInjections;
+
+			public Screenshot(IBrowserComponent browserComponent, IEnumerable<InjectedString> notificationInjections) : base(browserComponent, CreateSetupObject) {
+				this.notificationInjections = notificationInjections;
+			}
+
+			public override string GetTweetHTML(DesktopNotification notification) {
+				return notification.GenerateHtml("td-screenshot", HeadLayout, App.UserConfiguration.CustomNotificationCSS, notificationInjections, Array.Empty<string>());
+			}
+
+			private static BrowserSetup CreateSetupObject(NotificationBrowser browser) {
+				return BaseBrowser.CreateSetupObject(browser.browserComponent);
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Tweet.cs b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Tweet.cs
new file mode 100644
index 00000000..565cbfa1
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.Tweet.cs
@@ -0,0 +1,84 @@
+using System;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Contexts;
+using TweetLib.Browser.Events;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.Plugins.Enums;
+using TweetLib.Core.Features.Twitter;
+using TweetLib.Core.Resources;
+
+namespace TweetLib.Core.Features.Notifications {
+	public abstract partial class NotificationBrowser {
+		public class Tweet : NotificationBrowser {
+			protected virtual string BodyClasses => notificationInterface.IsHovered ? "td-notification td-hover" : "td-notification";
+
+			private readonly INotificationInterface notificationInterface;
+			private readonly PluginManager pluginManager;
+
+			protected Tweet(IBrowserComponent browserComponent, INotificationInterface notificationInterface, ICommonInterface commonInterface, PluginManager pluginManager, Func<NotificationBrowser, BrowserSetup> setup) : base(browserComponent, setup) {
+				this.browserComponent.AttachBridgeObject("$TD", new NotificationBridgeObject(notificationInterface, commonInterface));
+				this.browserComponent.PageLoadEnd += BrowserComponentOnPageLoadEnd;
+
+				this.notificationInterface = notificationInterface;
+				this.pluginManager = pluginManager;
+				this.pluginManager.Register(PluginEnvironment.Notification, browserComponent);
+			}
+
+			public Tweet(IBrowserComponent browserComponent, INotificationInterface notificationInterface, ICommonInterface commonInterface, PluginManager pluginManager) : this(browserComponent, notificationInterface, commonInterface, pluginManager, browser => CreateSetupObject(browser, notificationInterface)) {}
+
+			public override string GetTweetHTML(DesktopNotification notification) {
+				return notification.GenerateHtml(BodyClasses, HeadLayout, App.UserConfiguration.CustomNotificationCSS, pluginManager.NotificationInjections, new string[] {
+					PropertyObjectScript.Generate(App.UserConfiguration, PropertyObjectScript.Environment.Notification),
+					ResourceUtils.GetBootstrapScript("notification", includeStylesheets: false) ?? string.Empty
+				});
+			}
+
+			private static BrowserSetup CreateSetupObject(NotificationBrowser browser, INotificationInterface notificationInterface) {
+				return BaseBrowser.CreateSetupObject(browser.browserComponent, new BrowserSetup {
+					ContextMenuHandler = new ContextMenu(browser, notificationInterface)
+				});
+			}
+
+			private sealed class ContextMenu : BaseContextMenu {
+				private readonly INotificationInterface notificationInterface;
+
+				public ContextMenu(NotificationBrowser browser, INotificationInterface notificationInterface) : base(browser.browserComponent) {
+					this.notificationInterface = notificationInterface;
+				}
+
+				public override void Show(IContextMenuBuilder menu, Context context) {
+					base.Show(menu, context);
+
+					menu.AddAction("View detail", notificationInterface.ShowTweetDetail);
+					menu.AddAction("Skip tweet", notificationInterface.FinishCurrentNotification);
+					menu.AddActionWithCheck("Freeze", notificationInterface.FreezeTimer, () => notificationInterface.FreezeTimer = !notificationInterface.FreezeTimer);
+					menu.AddSeparator();
+
+					if (context.Notification is {} notification) {
+						menu.AddAction("Copy tweet address", CopyText(notification.TweetUrl));
+
+						if (!string.IsNullOrEmpty(notification.QuoteUrl)) {
+							menu.AddAction("Copy quoted tweet address", CopyText(notification.QuoteUrl!));
+						}
+
+						menu.AddSeparator();
+					}
+				}
+			}
+
+			public override void Dispose() {
+				base.Dispose();
+				this.browserComponent.PageLoadEnd -= BrowserComponentOnPageLoadEnd;
+			}
+
+			private void BrowserComponentOnPageLoadEnd(object sender, PageLoadEventArgs e) {
+				string url = e.Url;
+
+				if (TwitterUrls.IsTweetDeck(url) && url != BlankURL) {
+					pluginManager.Execute(PluginEnvironment.Notification, browserComponent);
+				}
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.cs b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.cs
new file mode 100644
index 00000000..360af353
--- /dev/null
+++ b/lib/TweetLib.Core/Features/Notifications/NotificationBrowser.cs
@@ -0,0 +1,22 @@
+using System;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Features.Twitter;
+
+namespace TweetLib.Core.Features.Notifications {
+	public abstract partial class NotificationBrowser : BaseBrowser<NotificationBrowser> {
+		public const string BlankURL = TwitterUrls.TweetDeck + "/?blank";
+
+		internal static void SetNotificationLayout(string? fontSize, string? headLayout) {
+			FontSize = fontSize;
+			HeadLayout = headLayout;
+		}
+
+		public static string? FontSize { get; private set; }
+		public static string? HeadLayout { get; private set; }
+
+		private NotificationBrowser(IBrowserComponent browserComponent, Func<NotificationBrowser, BrowserSetup> setup) : base(browserComponent, setup) {}
+
+		public abstract string GetTweetHTML(DesktopNotification notification);
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Plugins/Config/PluginConfigInstance.cs b/lib/TweetLib.Core/Features/Plugins/Config/PluginConfigInstance.cs
index 7ac86703..b88d5a32 100644
--- a/lib/TweetLib.Core/Features/Plugins/Config/PluginConfigInstance.cs
+++ b/lib/TweetLib.Core/Features/Plugins/Config/PluginConfigInstance.cs
@@ -21,7 +21,7 @@ public void Load() {
 				string? line = reader.ReadLine();
 
 				if (line == "#Disabled") {
-					HashSet<string> newDisabled = new HashSet<string>();
+					var newDisabled = new HashSet<string>();
 
 					while ((line = reader.ReadLine()) != null) {
 						newDisabled.Add(line);
diff --git a/lib/TweetLib.Core/Features/Plugins/Enums/PluginEnvironment.cs b/lib/TweetLib.Core/Features/Plugins/Enums/PluginEnvironment.cs
index c7a6fdeb..281906b3 100644
--- a/lib/TweetLib.Core/Features/Plugins/Enums/PluginEnvironment.cs
+++ b/lib/TweetLib.Core/Features/Plugins/Enums/PluginEnvironment.cs
@@ -7,7 +7,7 @@ public enum PluginEnvironment {
 		Notification
 	}
 
-	public static class PluginEnvironments {
+	internal static class PluginEnvironments {
 		public static IEnumerable<PluginEnvironment> All { get; } = new PluginEnvironment[] {
 			PluginEnvironment.Browser,
 			PluginEnvironment.Notification
diff --git a/lib/TweetLib.Core/Features/Plugins/Enums/PluginGroup.cs b/lib/TweetLib.Core/Features/Plugins/Enums/PluginGroup.cs
index 6ccf1c26..7190a094 100644
--- a/lib/TweetLib.Core/Features/Plugins/Enums/PluginGroup.cs
+++ b/lib/TweetLib.Core/Features/Plugins/Enums/PluginGroup.cs
@@ -7,7 +7,7 @@ public enum PluginGroup {
 		Custom
 	}
 
-	public static class PluginGroups {
+	internal static class PluginGroups {
 		public static IEnumerable<PluginGroup> All { get; } = new PluginGroup[] {
 			PluginGroup.Official,
 			PluginGroup.Custom
diff --git a/lib/TweetLib.Core/Features/Plugins/Events/PluginDispatchEventArgs.cs b/lib/TweetLib.Core/Features/Plugins/Events/PluginDispatchEventArgs.cs
deleted file mode 100644
index 3069102a..00000000
--- a/lib/TweetLib.Core/Features/Plugins/Events/PluginDispatchEventArgs.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System;
-using TweetLib.Core.Browser;
-
-namespace TweetLib.Core.Features.Plugins.Events {
-	public sealed class PluginDispatchEventArgs : EventArgs {
-		public IScriptExecutor Executor { get; }
-
-		public PluginDispatchEventArgs(IScriptExecutor executor) {
-			this.Executor = executor;
-		}
-	}
-}
diff --git a/lib/TweetLib.Core/Features/Plugins/Events/PluginErrorEventArgs.cs b/lib/TweetLib.Core/Features/Plugins/Events/PluginErrorEventArgs.cs
index e55dd30e..b1b9bb75 100644
--- a/lib/TweetLib.Core/Features/Plugins/Events/PluginErrorEventArgs.cs
+++ b/lib/TweetLib.Core/Features/Plugins/Events/PluginErrorEventArgs.cs
@@ -7,7 +7,7 @@ public sealed class PluginErrorEventArgs : EventArgs {
 
 		public IList<string> Errors { get; }
 
-		public PluginErrorEventArgs(IList<string> errors) {
+		internal PluginErrorEventArgs(IList<string> errors) {
 			this.Errors = errors;
 		}
 	}
diff --git a/lib/TweetLib.Core/Features/Plugins/IPluginDispatcher.cs b/lib/TweetLib.Core/Features/Plugins/IPluginDispatcher.cs
deleted file mode 100644
index 53d3f557..00000000
--- a/lib/TweetLib.Core/Features/Plugins/IPluginDispatcher.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System;
-using TweetLib.Core.Features.Plugins.Events;
-
-namespace TweetLib.Core.Features.Plugins {
-	public interface IPluginDispatcher {
-		event EventHandler<PluginDispatchEventArgs> Ready;
-		void AttachBridge(string name, object bridge);
-	}
-}
diff --git a/lib/TweetLib.Core/Features/Plugins/Plugin.cs b/lib/TweetLib.Core/Features/Plugins/Plugin.cs
index ebf1434b..d60f72f3 100644
--- a/lib/TweetLib.Core/Features/Plugins/Plugin.cs
+++ b/lib/TweetLib.Core/Features/Plugins/Plugin.cs
@@ -7,7 +7,7 @@
 
 namespace TweetLib.Core.Features.Plugins {
 	public sealed class Plugin {
-		private static readonly Version AppVersion = new Version(TweetDuck.Version.Tag);
+		private static readonly Version AppVersion = new (TweetDuck.Version.Tag);
 
 		public string Identifier { get; }
 		public PluginGroup Group { get; }
@@ -99,7 +99,7 @@ public override bool Equals(object obj) {
 		// Builder
 
 		public sealed class Builder {
-			private static readonly Version DefaultRequiredVersion = new Version(0, 0, 0, 0);
+			private static readonly Version DefaultRequiredVersion = new (0, 0, 0, 0);
 
 			public string Name             { get; set; } = string.Empty;
 			public string Description      { get; set; } = string.Empty;
diff --git a/lib/TweetLib.Core/Features/Plugins/PluginBridge.cs b/lib/TweetLib.Core/Features/Plugins/PluginBridge.cs
index c55de8fd..231ff649 100644
--- a/lib/TweetLib.Core/Features/Plugins/PluginBridge.cs
+++ b/lib/TweetLib.Core/Features/Plugins/PluginBridge.cs
@@ -12,11 +12,11 @@
 namespace TweetLib.Core.Features.Plugins {
 	[SuppressMessage("ReSharper", "UnusedMember.Global")]
 	internal sealed class PluginBridge {
-		private readonly Dictionary<int, Plugin> tokens = new Dictionary<int, Plugin>();
-		private readonly Random rand = new Random();
+		private readonly Dictionary<int, Plugin> tokens = new ();
+		private readonly Random rand = new ();
 
-		private readonly FileCache fileCache = new FileCache();
-		private readonly TwoKeyDictionary<int, string, InjectedString> notificationInjections = new TwoKeyDictionary<int, string, InjectedString>(4, 1);
+		private readonly FileCache fileCache = new ();
+		private readonly TwoKeyDictionary<int, string, InjectedString> notificationInjections = new (4, 1);
 
 		internal IEnumerable<InjectedString> NotificationInjections => notificationInjections.InnerValues;
 		internal ISet<Plugin> WithConfigureFunction { get; } = new HashSet<Plugin>();
@@ -151,7 +151,7 @@ public void SetConfigurable(int token) {
 		}
 
 		private sealed class FileCache {
-			private readonly TwoKeyDictionary<int, string, string> cache = new TwoKeyDictionary<int, string, string>(4, 2);
+			private readonly TwoKeyDictionary<int, string, string> cache = new (4, 2);
 
 			public string this[int token, PluginFolder folder, string path] {
 				set => cache[token, Key(folder, path)] = value;
diff --git a/lib/TweetLib.Core/Features/Plugins/PluginLoader.cs b/lib/TweetLib.Core/Features/Plugins/PluginLoader.cs
index 0c093214..e3421e0b 100644
--- a/lib/TweetLib.Core/Features/Plugins/PluginLoader.cs
+++ b/lib/TweetLib.Core/Features/Plugins/PluginLoader.cs
@@ -7,7 +7,7 @@
 using TweetLib.Utils.Data;
 
 namespace TweetLib.Core.Features.Plugins {
-	public static class PluginLoader {
+	internal static class PluginLoader {
 		private static readonly string[] EndTag = { "[END]" };
 
 		public static IEnumerable<Result<Plugin>> AllInFolder(string pluginFolder, string pluginDataFolder, PluginGroup group) {
@@ -54,7 +54,7 @@ public static Plugin FromFolder(string name, string pathRoot, string pathData, P
 			string? currentTag = null;
 			string currentContents = string.Empty;
 
-			foreach (string line in File.ReadAllLines(metaFile, Encoding.UTF8).Concat(EndTag).Select(line => line.TrimEnd()).Where(line => line.Length > 0)) {
+			foreach (string line in File.ReadAllLines(metaFile, Encoding.UTF8).Concat(EndTag).Select(static line => line.TrimEnd()).Where(static line => line.Length > 0)) {
 				if (line[0] == '[' && line[line.Length - 1] == ']') {
 					if (currentTag != null) {
 						SetProperty(builder, currentTag, currentContents);
diff --git a/lib/TweetLib.Core/Features/Plugins/PluginManager.cs b/lib/TweetLib.Core/Features/Plugins/PluginManager.cs
index b419654a..1f3008d6 100644
--- a/lib/TweetLib.Core/Features/Plugins/PluginManager.cs
+++ b/lib/TweetLib.Core/Features/Plugins/PluginManager.cs
@@ -2,61 +2,52 @@
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
-using TweetLib.Core.Browser;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Plugins.Config;
 using TweetLib.Core.Features.Plugins.Enums;
 using TweetLib.Core.Features.Plugins.Events;
+using TweetLib.Core.Resources;
 using TweetLib.Utils.Data;
 
 namespace TweetLib.Core.Features.Plugins {
 	public sealed class PluginManager {
-		public string PathCustomPlugins => Path.Combine(pluginFolder, PluginGroup.Custom.GetSubFolder());
+		public string CustomPluginFolder => Path.Combine(App.PluginPath, PluginGroup.Custom.GetSubFolder());
 
 		public IEnumerable<Plugin> Plugins => plugins;
 		public IEnumerable<InjectedString> NotificationInjections => bridge.NotificationInjections;
 
 		public IPluginConfig Config { get; }
+		public string PluginDataFolder { get; }
 
 		public event EventHandler<PluginErrorEventArgs>? Reloaded;
 		public event EventHandler<PluginErrorEventArgs>? Executed;
 
-		private readonly string pluginFolder;
-		private readonly string pluginDataFolder;
-
 		internal readonly PluginBridge bridge;
 		private IScriptExecutor? browserExecutor;
 
 		private readonly HashSet<Plugin> plugins = new ();
 
-		public PluginManager(IPluginConfig config, string pluginFolder, string pluginDataFolder) {
+		public PluginManager(IPluginConfig config, string pluginDataFolder) {
 			this.Config = config;
 			this.Config.PluginChangedState += Config_PluginChangedState;
-
-			this.pluginFolder = pluginFolder;
-			this.pluginDataFolder = pluginDataFolder;
-
+			this.PluginDataFolder = pluginDataFolder;
 			this.bridge = new PluginBridge(this);
 		}
 
-		public void Register(PluginEnvironment environment, IPluginDispatcher dispatcher) {
-			dispatcher.AttachBridge("$TDP", bridge);
-			dispatcher.Ready += (_, args) => {
-				IScriptExecutor executor = args.Executor;
+		internal void Register(PluginEnvironment environment, IBrowserComponent browserComponent) {
+			browserComponent.AttachBridgeObject("$TDP", bridge);
 
-				if (environment == PluginEnvironment.Browser) {
-					browserExecutor = executor;
-				}
-
-				Execute(environment, executor);
-			};
+			if (environment == PluginEnvironment.Browser) {
+				browserExecutor = browserComponent;
+			}
 		}
 
 		public void Reload() {
 			plugins.Clear();
 
-			List<string> errors = new List<string>(1);
+			var errors = new List<string>(1);
 
-			foreach (var result in PluginGroups.All.SelectMany(group => PluginLoader.AllInFolder(pluginFolder, pluginDataFolder, group))) {
+			foreach (var result in PluginGroups.All.SelectMany(group => PluginLoader.AllInFolder(App.PluginPath, PluginDataFolder, group))) {
 				if (result.HasValue) {
 					plugins.Add(result.Value);
 				}
@@ -68,7 +59,7 @@ public void Reload() {
 			Reloaded?.Invoke(this, new PluginErrorEventArgs(errors));
 		}
 
-		private void Execute(PluginEnvironment environment, IScriptExecutor executor) {
+		internal void Execute(PluginEnvironment environment, IScriptExecutor executor) {
 			if (!plugins.Any(plugin => plugin.HasEnvironment(environment))) {
 				return;
 			}
@@ -81,7 +72,7 @@ private void Execute(PluginEnvironment environment, IScriptExecutor executor) {
 				executor.RunScript("gen:pluginconfig", PluginScriptGenerator.GenerateConfig(Config));
 			}
 
-			List<string> errors = new List<string>(1);
+			var errors = new List<string>(1);
 
 			foreach (Plugin plugin in Plugins) {
 				string path = plugin.GetScriptPath(environment);
diff --git a/lib/TweetLib.Core/Features/Plugins/PluginSchemeHandler.cs b/lib/TweetLib.Core/Features/Plugins/PluginSchemeHandler.cs
index d343ff1f..0ae9f242 100644
--- a/lib/TweetLib.Core/Features/Plugins/PluginSchemeHandler.cs
+++ b/lib/TweetLib.Core/Features/Plugins/PluginSchemeHandler.cs
@@ -1,34 +1,28 @@
 using System;
 using System.Linq;
 using System.Net;
-using TweetLib.Core.Browser;
+using TweetLib.Browser.Interfaces;
 using TweetLib.Core.Features.Plugins.Enums;
+using TweetLib.Core.Resources;
 
 namespace TweetLib.Core.Features.Plugins {
-	public sealed class PluginSchemeHandler<T> where T : class {
-		public const string Name = "tdp";
+	public sealed class PluginSchemeHandler<T> : ICustomSchemeHandler<T> where T : class {
+		public string Protocol => "tdp";
 
-		private readonly IResourceProvider<T> resourceProvider;
-		private PluginBridge? bridge = null;
+		private readonly CachingResourceProvider<T> resourceProvider;
+		private readonly PluginBridge bridge;
 
-		public PluginSchemeHandler(IResourceProvider<T> resourceProvider) {
+		public PluginSchemeHandler(CachingResourceProvider<T> resourceProvider, PluginManager pluginManager) {
 			this.resourceProvider = resourceProvider;
+			this.bridge = pluginManager.bridge;
 		}
 
-		public void Setup(PluginManager plugins) {
-			if (this.bridge != null) {
-				throw new InvalidOperationException("Plugin scheme handler is already setup.");
-			}
-
-			this.bridge = plugins.bridge;
-		}
-
-		public T? Process(string url) {
-			if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Name || !int.TryParse(uri.Authority, out var identifier)) {
+		public T? Resolve(Uri uri) {
+			if (!uri.IsAbsoluteUri || uri.Scheme != Protocol || !int.TryParse(uri.Authority, out var identifier)) {
 				return null;
 			}
 
-			var segments = uri.Segments.Select(segment => segment.TrimEnd('/')).Where(segment => !string.IsNullOrEmpty(segment)).ToArray();
+			var segments = uri.Segments.Select(static segment => segment.TrimEnd('/')).Where(static segment => !string.IsNullOrEmpty(segment)).ToArray();
 
 			if (segments.Length > 0) {
 				var handler = segments[0] switch {
@@ -47,9 +41,9 @@ public void Setup(PluginManager plugins) {
 		private T DoReadRootFile(int identifier, string[] segments) {
 			string path = string.Join("/", segments, 1, segments.Length - 1);
 
-			Plugin? plugin = bridge?.GetPluginFromToken(identifier);
+			Plugin? plugin = bridge.GetPluginFromToken(identifier);
 			string fullPath = plugin == null ? string.Empty : plugin.GetFullPathIfSafe(PluginFolder.Root, path);
-			return fullPath.Length == 0 ? resourceProvider.Status(HttpStatusCode.Forbidden, "File path has to be relative to the plugin root folder.") : resourceProvider.File(fullPath);
+			return fullPath.Length == 0 ? resourceProvider.Status(HttpStatusCode.Forbidden, "File path has to be relative to the plugin root folder.") : resourceProvider.CachedFile(fullPath);
 		}
 	}
 }
diff --git a/lib/TweetLib.Core/Features/Plugins/PluginScriptGenerator.cs b/lib/TweetLib.Core/Features/Plugins/PluginScriptGenerator.cs
index 2c8706b2..d649d684 100644
--- a/lib/TweetLib.Core/Features/Plugins/PluginScriptGenerator.cs
+++ b/lib/TweetLib.Core/Features/Plugins/PluginScriptGenerator.cs
@@ -3,9 +3,9 @@
 using TweetLib.Core.Features.Plugins.Enums;
 
 namespace TweetLib.Core.Features.Plugins {
-	public static class PluginScriptGenerator {
+	internal static class PluginScriptGenerator {
 		public static string GenerateConfig(IPluginConfig config) {
-			return "window.TD_PLUGINS_DISABLE = [" + string.Join(",", config.DisabledPlugins.Select(id => '"' + id + '"')) + "]";
+			return "window.TD_PLUGINS_DISABLE = [" + string.Join(",", config.DisabledPlugins.Select(static id => '"' + id + '"')) + "]";
 		}
 
 		public static string GenerateInstaller() {
diff --git a/Browser/Bridge/PropertyBridge.cs b/lib/TweetLib.Core/Features/PropertyObjectScript.cs
similarity index 86%
rename from Browser/Bridge/PropertyBridge.cs
rename to lib/TweetLib.Core/Features/PropertyObjectScript.cs
index e046329b..5ac6186e 100644
--- a/Browser/Bridge/PropertyBridge.cs
+++ b/lib/TweetLib.Core/Features/PropertyObjectScript.cs
@@ -1,20 +1,18 @@
 using System.Text;
-using TweetDuck.Configuration;
-using TweetLib.Core;
+using TweetLib.Core.Application;
 using TweetLib.Core.Features.Twitter;
 
-namespace TweetDuck.Browser.Bridge {
-	static class PropertyBridge {
+namespace TweetLib.Core.Features {
+	public static class PropertyObjectScript {
 		public enum Environment {
 			Browser,
 			Notification
 		}
 
-		public static string GenerateScript(Environment environment) {
+		internal static string Generate(IAppUserConfiguration config, Environment environment) {
 			static string Bool(bool value) => value ? "true;" : "false;";
 			static string Str(string value) => $"\"{value}\";";
 
-			UserConfig config = Program.Config.User;
 			StringBuilder build = new StringBuilder(384).Append("(function(x){");
 
 			build.Append("x.expandLinksOnHover=").Append(Bool(config.ExpandLinksOnHover));
diff --git a/lib/TweetLib.Core/Features/TweetDeck/ISoundNotificationHandler.cs b/lib/TweetLib.Core/Features/TweetDeck/ISoundNotificationHandler.cs
new file mode 100644
index 00000000..20e3114f
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/ISoundNotificationHandler.cs
@@ -0,0 +1,6 @@
+namespace TweetLib.Core.Features.TweetDeck {
+	public interface ISoundNotificationHandler {
+		void Unregister(string url);
+		void Register(string url, string path);
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/ITweetDeckInterface.cs b/lib/TweetLib.Core/Features/TweetDeck/ITweetDeckInterface.cs
new file mode 100644
index 00000000..777a9154
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/ITweetDeckInterface.cs
@@ -0,0 +1,7 @@
+namespace TweetLib.Core.Features.TweetDeck {
+	public interface ITweetDeckInterface : ICommonInterface {
+		void OnIntroductionClosed(bool showGuide);
+		void OpenContextMenu();
+		void OpenProfileImport();
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBridgeObject.cs b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBridgeObject.cs
new file mode 100644
index 00000000..0c72e162
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBridgeObject.cs
@@ -0,0 +1,63 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using TweetLib.Browser.Contexts;
+using TweetLib.Core.Features.Notifications;
+using TweetLib.Core.Features.Twitter;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	[SuppressMessage("ReSharper", "UnusedMember.Global")]
+	sealed class TweetDeckBridgeObject : CommonBridge {
+		private readonly ITweetDeckInterface i;
+		private readonly TweetDeckBrowser browser;
+		private readonly TweetDeckExtraContext extraContext;
+
+		public TweetDeckBridgeObject(ITweetDeckInterface tweetDeckInterface, TweetDeckBrowser browser, TweetDeckExtraContext extraContext) : base(tweetDeckInterface) {
+			this.i = tweetDeckInterface;
+			this.browser = browser;
+			this.extraContext = extraContext;
+		}
+
+		public void OnModulesLoaded(string moduleNamespace) {
+			browser.OnModulesLoaded(moduleNamespace);
+		}
+
+		public void OpenContextMenu() {
+			i.OpenContextMenu();
+		}
+
+		public void OpenProfileImport() {
+			i.OpenProfileImport();
+		}
+
+		public void OnIntroductionClosed(bool showGuide) {
+			i.OnIntroductionClosed(showGuide);
+		}
+
+		public void LoadNotificationLayout(string fontSize, string headLayout) {
+			NotificationBrowser.SetNotificationLayout(fontSize, headLayout);
+		}
+
+		public void DisplayTooltip(string text) {
+			i.DisplayTooltip(text);
+		}
+
+		public void SetRightClickedChirp(string columnId, string chirpId, string? tweetUrl, string? quoteUrl, string? chirpAuthors, string? chirpImages) {
+			if (tweetUrl == null) {
+				extraContext.SetTweet(null);
+				return;
+			}
+
+			var authors = chirpAuthors?.Split(';') ?? StringUtils.EmptyArray;
+			var images = chirpImages?.Split(';') ?? StringUtils.EmptyArray;
+			var tweetAuthor = authors.Length >= 1 ? authors[0] : null;
+			var quoteAuthor = authors.Length >= 2 ? authors[1] : null;
+			var imageUrls = images.Length > 0 ? images.Select(static url => TwitterUrls.GetMediaLink(url, App.UserConfiguration.TwitterImageQuality)).ToArray() : StringUtils.EmptyArray;
+			extraContext.SetTweet(new Tweet(tweetUrl, quoteUrl, tweetAuthor, quoteAuthor, imageUrls, columnId, chirpId));
+		}
+
+		public void SetRightClickedLink(string type, string? url) {
+			extraContext.SetLink(type, url);
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBrowser.cs b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBrowser.cs
new file mode 100644
index 00000000..4a9df223
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckBrowser.cs
@@ -0,0 +1,257 @@
+using System;
+using TweetLib.Browser.Base;
+using TweetLib.Browser.Contexts;
+using TweetLib.Browser.Events;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Browser.Request;
+using TweetLib.Core.Features.Notifications;
+using TweetLib.Core.Features.Plugins;
+using TweetLib.Core.Features.Plugins.Enums;
+using TweetLib.Core.Features.Plugins.Events;
+using TweetLib.Core.Features.Twitter;
+using TweetLib.Core.Resources;
+using TweetLib.Core.Systems.Dialogs;
+using TweetLib.Core.Systems.Updates;
+using TweetLib.Utils.Static;
+using Version = TweetDuck.Version;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	public sealed class TweetDeckBrowser : BaseBrowser<TweetDeckBrowser> {
+		private const string NamespaceTweetDeck = "tweetdeck";
+		private const string BackgroundColorOverride = "setTimeout(function f(){let h=document.head;if(!h){setTimeout(f,5);return;}let e=document.createElement('style');e.innerHTML='body,body::before{background:#1c6399!important;margin:0}';h.appendChild(e);},1)";
+
+		public TweetDeckFunctions Functions { get; }
+		public FileDownloadManager FileDownloadManager => new (browserComponent);
+
+		private readonly ISoundNotificationHandler soundNotificationHandler;
+		private readonly PluginManager pluginManager;
+		private readonly UpdateChecker updateChecker;
+
+		private bool isBrowserReady;
+		private bool ignoreUpdateCheckError;
+		private string? prevSoundNotificationPath = null;
+
+		public TweetDeckBrowser(IBrowserComponent browserComponent, ITweetDeckInterface tweetDeckInterface, TweetDeckExtraContext extraContext, ISoundNotificationHandler soundNotificationHandler, PluginManager pluginManager, UpdateChecker updateChecker) : base(browserComponent, CreateSetupObject) {
+			this.Functions = new TweetDeckFunctions(this.browserComponent);
+
+			this.browserComponent.AttachBridgeObject("$TD", new TweetDeckBridgeObject(tweetDeckInterface, this, extraContext));
+			this.browserComponent.AttachBridgeObject("$TDU", updateChecker.InteractionManager.BridgeObject);
+
+			this.soundNotificationHandler = soundNotificationHandler;
+
+			this.pluginManager = pluginManager;
+			this.pluginManager.Register(PluginEnvironment.Browser, this.browserComponent);
+			this.pluginManager.Reloaded += pluginManager_Reloaded;
+			this.pluginManager.Executed += pluginManager_Executed;
+			this.pluginManager.Reload();
+
+			this.updateChecker = updateChecker;
+			this.updateChecker.CheckFinished += updateChecker_CheckFinished;
+
+			this.browserComponent.BrowserLoaded += browserComponent_BrowserLoaded;
+			this.browserComponent.PageLoadStart += browserComponent_PageLoadStart;
+			this.browserComponent.PageLoadEnd += browserComponent_PageLoadEnd;
+
+			App.UserConfiguration.MuteToggled += UserConfiguration_GeneralEventHandler;
+			App.UserConfiguration.OptionsDialogClosed += UserConfiguration_GeneralEventHandler;
+			App.UserConfiguration.SoundNotificationChanged += UserConfiguration_SoundNotificationChanged;
+		}
+
+		public override void Dispose() {
+			base.Dispose();
+
+			this.browserComponent.BrowserLoaded -= browserComponent_BrowserLoaded;
+			this.browserComponent.PageLoadStart -= browserComponent_PageLoadStart;
+			this.browserComponent.PageLoadEnd -= browserComponent_PageLoadEnd;
+
+			App.UserConfiguration.MuteToggled -= UserConfiguration_GeneralEventHandler;
+			App.UserConfiguration.OptionsDialogClosed -= UserConfiguration_GeneralEventHandler;
+			App.UserConfiguration.SoundNotificationChanged -= UserConfiguration_SoundNotificationChanged;
+		}
+
+		private void browserComponent_BrowserLoaded(object sender, BrowserLoadedEventArgs e) {
+			e.AddDictionaryWords("tweetdeck", "TweetDeck", "tweetduck", "TweetDuck", "TD");
+			isBrowserReady = true;
+		}
+
+		private void browserComponent_PageLoadStart(object sender, PageLoadEventArgs e) {
+			string url = e.Url;
+
+			if (TwitterUrls.IsTweetDeck(url) || (TwitterUrls.IsTwitter(url) && !TwitterUrls.IsTwitterLogin2Factor(url))) {
+				browserComponent.RunScript("gen:backgroundcolor", BackgroundColorOverride);
+			}
+		}
+
+		private void browserComponent_PageLoadEnd(object sender, PageLoadEventArgs e) {
+			string url = e.Url;
+
+			if (TwitterUrls.IsTweetDeck(url)) {
+				NotificationBrowser.SetNotificationLayout(null, null);
+
+				UpdatePropertyObject();
+				browserComponent.RunBootstrap(NamespaceTweetDeck);
+				pluginManager.Execute(PluginEnvironment.Browser, browserComponent);
+
+				if (App.UserConfiguration.FirstRun) {
+					browserComponent.RunBootstrap("introduction");
+				}
+			}
+			else if (TwitterUrls.IsTwitter(url)) {
+				browserComponent.RunBootstrap("login");
+			}
+
+			browserComponent.RunBootstrap("update");
+		}
+
+		private void pluginManager_Reloaded(object sender, PluginErrorEventArgs e) {
+			if (e.HasErrors) {
+				App.DialogHandler.Error("Error Loading Plugins", "The following plugins will not be available until the issues are resolved:\n\n" + string.Join("\n\n", e.Errors), Dialogs.OK);
+			}
+
+			if (isBrowserReady) {
+				ReloadToTweetDeck();
+			}
+		}
+
+		private void pluginManager_Executed(object sender, PluginErrorEventArgs e) {
+			if (e.HasErrors) {
+				App.DialogHandler.Error("Error Executing Plugins", "Failed to execute the following plugins:\n\n" + string.Join("\n\n", e.Errors), Dialogs.OK);
+			}
+		}
+
+		private void updateChecker_CheckFinished(object sender, UpdateCheckEventArgs e) {
+			e.Result.Handle(update => {
+				string tag = update.VersionTag;
+
+				if (tag != Version.Tag && tag != App.UserConfiguration.DismissedUpdate) {
+					update.BeginSilentDownload();
+					Functions.ShowUpdateNotification(tag, update.ReleaseNotes);
+				}
+				else {
+					updateChecker.StartTimer();
+				}
+			}, ex => {
+				if (!ignoreUpdateCheckError) {
+					App.ErrorHandler.HandleException("Update Check Error", "An error occurred while checking for updates.", true, ex);
+					updateChecker.StartTimer();
+				}
+			});
+
+			ignoreUpdateCheckError = true;
+		}
+
+		private void UserConfiguration_GeneralEventHandler(object sender, EventArgs e) {
+			UpdatePropertyObject();
+		}
+
+		private void UserConfiguration_SoundNotificationChanged(object? sender, EventArgs e) {
+			const string soundUrl = "https://ton.twimg.com/tduck/updatesnd";
+
+			bool hasCustomSound = App.UserConfiguration.IsCustomSoundNotificationSet;
+			string newNotificationPath = App.UserConfiguration.NotificationSoundPath;
+
+			if (prevSoundNotificationPath != newNotificationPath) {
+				prevSoundNotificationPath = newNotificationPath;
+				soundNotificationHandler.Unregister(soundUrl);
+
+				if (hasCustomSound) {
+					soundNotificationHandler.Register(soundUrl, newNotificationPath);
+				}
+			}
+
+			Functions.SetSoundNotificationData(hasCustomSound, App.UserConfiguration.NotificationSoundVolume);
+		}
+
+		internal void OnModulesLoaded(string moduleNamespace) {
+			if (moduleNamespace == NamespaceTweetDeck) {
+				Functions.ReinjectCustomCSS(App.UserConfiguration.CustomBrowserCSS);
+				UserConfiguration_SoundNotificationChanged(null, EventArgs.Empty);
+			}
+		}
+
+		private void UpdatePropertyObject() {
+			browserComponent.RunScript("gen:propertyobj", PropertyObjectScript.Generate(App.UserConfiguration, PropertyObjectScript.Environment.Browser));
+		}
+
+		public void ReloadToTweetDeck() {
+			ignoreUpdateCheckError = false;
+			browserComponent.RunScript("gen:reload", $"if(window.TDGF_reload)window.TDGF_reload();else window.location.href='{TwitterUrls.TweetDeck}'");
+		}
+
+		private static BrowserSetup CreateSetupObject(TweetDeckBrowser browser) {
+			return BaseBrowser.CreateSetupObject(browser.browserComponent, new BrowserSetup {
+				ContextMenuHandler = new ContextMenu(browser),
+				ResourceRequestHandler = new ResourceRequestHandler()
+			});
+		}
+
+		private sealed class ContextMenu : BaseContextMenu {
+			private readonly TweetDeckBrowser owner;
+
+			public ContextMenu(TweetDeckBrowser owner) : base(owner.browserComponent) {
+				this.owner = owner;
+			}
+
+			public override void Show(IContextMenuBuilder menu, Context context) {
+				if (context.Selection is { Editable: true } ) {
+					menu.AddAction("Apply ROT13", owner.Functions.ApplyROT13);
+					menu.AddSeparator();
+				}
+
+				base.Show(menu, context);
+
+				if (context.Selection == null && context.Tweet is {} tweet) {
+					menu.AddAction("Open tweet in browser", OpenLink(tweet.Url));
+					menu.AddAction("Copy tweet address", CopyText(tweet.Url));
+					menu.AddAction("Screenshot tweet to clipboard", () => owner.Functions.TriggerTweetScreenshot(tweet.ColumnId, tweet.ChirpId));
+					menu.AddSeparator();
+
+					if (!string.IsNullOrEmpty(tweet.QuoteUrl)) {
+						menu.AddAction("Open quoted tweet in browser", OpenLink(tweet.QuoteUrl!));
+						menu.AddAction("Copy quoted tweet address", CopyText(tweet.QuoteUrl!));
+						menu.AddSeparator();
+					}
+				}
+			}
+
+			protected override void AddSearchSelectionItems(IContextMenuBuilder menu, string selectedText) {
+				base.AddSearchSelectionItems(menu, selectedText);
+
+				if (TwitterUrls.IsTweetDeck(owner.browserComponent.Url)) {
+					menu.AddAction("Search in a column", () => {
+						owner.Functions.AddSearchColumn(selectedText);
+						DeselectAll();
+					});
+				}
+			}
+		}
+
+		private sealed class ResourceRequestHandler : BaseResourceRequestHandler {
+			private const string UrlLoadingSpinner = "/backgrounds/spinner_blue";
+			private const string UrlVendorResource = "/dist/vendor";
+			private const string UrlVersionCheck = "/web/dist/version.json";
+
+			public override RequestHandleResult? Handle(string url, ResourceType resourceType) {
+				switch (resourceType) {
+					case ResourceType.MainFrame when url.EndsWithOrdinal("://twitter.com/"):
+						return new RequestHandleResult.Redirect(TwitterUrls.TweetDeck); // redirect plain twitter.com requests, fixes bugs with login 2FA
+
+					case ResourceType.Image when url.Contains(UrlLoadingSpinner):
+						return new RequestHandleResult.Redirect("td://resources/images/spinner.apng");
+
+					case ResourceType.Script when url.Contains(UrlVendorResource):
+						return new RequestHandleResult.Process(VendorScriptProcessor.Instance);
+
+					case ResourceType.Script when url.Contains("analytics."):
+						return RequestHandleResult.Cancel.Instance;
+
+					case ResourceType.Xhr when url.Contains(UrlVersionCheck):
+						return RequestHandleResult.Cancel.Instance;
+
+					default:
+						return base.Handle(url, resourceType);
+				}
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/TweetDeckExtraContext.cs b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckExtraContext.cs
new file mode 100644
index 00000000..d4f2bc9b
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckExtraContext.cs
@@ -0,0 +1,44 @@
+using TweetLib.Browser.Contexts;
+using TweetLib.Core.Features.Twitter;
+using static TweetLib.Browser.Contexts.Media;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	public sealed class TweetDeckExtraContext {
+		public Link? Link { get; private set; }
+		public Media? Media { get; private set; }
+		public Tweet? Tweet { get; private set; }
+
+		public void Reset() {
+			Link = null;
+			Media = null;
+			Tweet = null;
+		}
+
+		public void SetLink(string type, string? url) {
+			Link = null;
+			Media = null;
+
+			if (string.IsNullOrEmpty(url)) {
+				return;
+			}
+
+			switch (type) {
+				case "link":
+					Link = new Link(url!, url!);
+					break;
+
+				case "image":
+					Media = new Media(Type.Image, TwitterUrls.GetMediaLink(url!, App.UserConfiguration.TwitterImageQuality));
+					break;
+
+				case "video":
+					Media = new Media(Type.Video, url!);
+					break;
+			}
+		}
+
+		public void SetTweet(Tweet? tweet) {
+			Tweet = tweet;
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/TweetDeckFunctions.cs b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckFunctions.cs
new file mode 100644
index 00000000..25e83dfd
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/TweetDeckFunctions.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Text;
+using System.Text.RegularExpressions;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	public sealed class TweetDeckFunctions {
+		private readonly IScriptExecutor executor;
+
+		internal TweetDeckFunctions(IScriptExecutor executor) {
+			this.executor = executor;
+		}
+
+		public void ReinjectCustomCSS(string? css) {
+			executor.RunFunction("TDGF_reinjectCustomCSS", css == null ? string.Empty : Regex.Replace(css, "\r?\n", " "));
+		}
+
+		public void OnMouseClickExtra(int button) {
+			executor.RunFunction("TDGF_onMouseClickExtra", button);
+		}
+
+		public void ShowTweetDetail(string columnId, string chirpId, string fallbackUrl) {
+			executor.RunFunction("TDGF_showTweetDetail", columnId, chirpId, fallbackUrl);
+		}
+
+		public void AddSearchColumn(string query) {
+			executor.RunFunction("TDGF_performSearch", query);
+		}
+
+		public void TriggerTweetScreenshot(string columnId, string chirpId) {
+			executor.RunFunction("TDGF_triggerScreenshot", columnId, chirpId);
+		}
+
+		public void ReloadColumns() {
+			executor.RunFunction("TDGF_reloadColumns");
+		}
+
+		public void PlaySoundNotification() {
+			executor.RunFunction("TDGF_playSoundNotification");
+		}
+
+		public void ApplyROT13() {
+			executor.RunFunction("TDGF_applyROT13");
+		}
+
+		public void SetSoundNotificationData(bool isCustom, int volume) {
+			executor.RunFunction("TDGF_setSoundNotificationData", isCustom, volume);
+		}
+
+		public void ShowUpdateNotification(string versionTag, string releaseNotes) {
+			executor.RunFunction("TDUF_displayNotification", versionTag, Convert.ToBase64String(Encoding.GetEncoding("iso-8859-1").GetBytes(releaseNotes)));
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/TweetDuckSchemeHandler.cs b/lib/TweetLib.Core/Features/TweetDeck/TweetDuckSchemeHandler.cs
new file mode 100644
index 00000000..edd0c902
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/TweetDuckSchemeHandler.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Net;
+using TweetLib.Browser.Interfaces;
+using TweetLib.Core.Resources;
+using TweetLib.Utils.Static;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	public sealed class TweetDuckSchemeHandler<T> : ICustomSchemeHandler<T> where T : class {
+		public string Protocol => "td";
+
+		private readonly CachingResourceProvider<T> resourceProvider;
+
+		public TweetDuckSchemeHandler(CachingResourceProvider<T> resourceProvider) {
+			this.resourceProvider = resourceProvider;
+		}
+
+		public T Resolve(Uri uri) {
+			string? rootPath = uri.Authority switch {
+				"resources" => App.ResourcesPath,
+				"guide"     => App.GuidePath,
+				_           => null
+			};
+
+			if (rootPath == null) {
+				return resourceProvider.Status(HttpStatusCode.NotFound, "Invalid URL.");
+			}
+
+			string filePath = FileUtils.ResolveRelativePathSafely(rootPath, uri.AbsolutePath.TrimStart('/'));
+			return filePath.Length == 0 ? resourceProvider.Status(HttpStatusCode.Forbidden, "File path has to be relative to the root folder.") : resourceProvider.CachedFile(filePath);
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/TweetDeck/VendorScriptProcessor.cs b/lib/TweetLib.Core/Features/TweetDeck/VendorScriptProcessor.cs
new file mode 100644
index 00000000..29b5f0d4
--- /dev/null
+++ b/lib/TweetLib.Core/Features/TweetDeck/VendorScriptProcessor.cs
@@ -0,0 +1,16 @@
+using System.Text.RegularExpressions;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Core.Features.TweetDeck {
+	sealed class VendorScriptProcessor : ResponseProcessorUtf8 {
+		public static VendorScriptProcessor Instance { get; } = new ();
+
+		private static readonly Regex RegexRestoreJQuery = new (@"(\w+)\.fn=\1\.prototype", RegexOptions.Compiled);
+
+		private VendorScriptProcessor() {}
+
+		protected override string Process(string response) {
+			return RegexRestoreJQuery.Replace(response, "window.$$=$1;$&", 1);
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Features/Twitter/ImageUrl.cs b/lib/TweetLib.Core/Features/Twitter/ImageUrl.cs
index cae71a85..3e1d59ca 100644
--- a/lib/TweetLib.Core/Features/Twitter/ImageUrl.cs
+++ b/lib/TweetLib.Core/Features/Twitter/ImageUrl.cs
@@ -5,8 +5,8 @@
 using TweetLib.Utils.Static;
 
 namespace TweetLib.Core.Features.Twitter {
-	public class ImageUrl {
-		private static readonly Regex RegexImageUrlParams = new Regex(@"(format|name)=(\w+)", RegexOptions.IgnoreCase);
+	sealed class ImageUrl {
+		private static readonly Regex RegexImageUrlParams = new (@"(format|name)=(\w+)", RegexOptions.IgnoreCase);
 
 		public static readonly string[] ValidExtensions = {
 			".jpg", ".jpeg", ".png", ".gif"
@@ -76,10 +76,10 @@ private ImageUrl(string baseUrl, string quality) {
 
 		public string WithQuality(ImageQuality newQuality) {
 			if (newQuality == ImageQuality.Best) {
-				if (baseUrl.Contains("//ton.twitter.com/") && baseUrl.Contains("/ton/data/dm/")) {
+				if (baseUrl.Contains("://ton.twitter.com/") && baseUrl.Contains("/ton/data/dm/")) {
 					return baseUrl + ":large";
 				}
-				else if (baseUrl.Contains("//pbs.twimg.com/media/")) {
+				else if (baseUrl.Contains("://pbs.twimg.com/media/")) {
 					return baseUrl + ":orig";
 				}
 			}
diff --git a/lib/TweetLib.Core/Features/Twitter/TweetDeckTranslations.cs b/lib/TweetLib.Core/Features/Twitter/TweetDeckTranslations.cs
index b7cb98b9..1081870d 100644
--- a/lib/TweetLib.Core/Features/Twitter/TweetDeckTranslations.cs
+++ b/lib/TweetLib.Core/Features/Twitter/TweetDeckTranslations.cs
@@ -11,6 +11,6 @@ public static class TweetDeckTranslations {
 			"hu", "id", "it", "ja", "ko", "lv", "lt", "no",
 			"pl", "pt", "ro", "ru", "sk", "sl", "es", "sv",
 			"th", "tr", "uk", "vi", "ar", "fa"
-		}.Select(code => new Language(code)).OrderBy(code => code).ToList();
+		}.Select(static code => new Language(code)).OrderBy(static code => code).ToList();
 	}
 }
diff --git a/lib/TweetLib.Core/Features/Twitter/TwitterUrls.cs b/lib/TweetLib.Core/Features/Twitter/TwitterUrls.cs
index 57de5b3b..35f3cee3 100644
--- a/lib/TweetLib.Core/Features/Twitter/TwitterUrls.cs
+++ b/lib/TweetLib.Core/Features/Twitter/TwitterUrls.cs
@@ -8,18 +8,18 @@ public static class TwitterUrls {
 		public const string TweetDeck = "https://tweetdeck.twitter.com";
 		private const string TwitterTrackingUrl = "t.co";
 
-		public static Regex RegexAccount { get; } = new Regex(@"^https?://twitter\.com/(?!signup$|tos$|privacy$|search$|search-)([^/?]+)/?$");
+		public static Regex RegexAccount { get; } = new (@"^https?://twitter\.com/(?!signup$|tos$|privacy$|search$|search-)([^/?]+)/?$");
 
 		public static bool IsTweetDeck(string url) {
-			return url.Contains("//tweetdeck.twitter.com/");
+			return url.Contains("://tweetdeck.twitter.com/");
 		}
 
 		public static bool IsTwitter(string url) {
-			return url.Contains("//twitter.com/") || url.Contains("//mobile.twitter.com/");
+			return url.Contains("://twitter.com/") || url.Contains("://mobile.twitter.com/");
 		}
 
 		public static bool IsTwitterLogin2Factor(string url) {
-			return url.Contains("//twitter.com/account/login_verification") || url.Contains("//mobile.twitter.com/account/login_verification");
+			return url.Contains("://twitter.com/account/login_verification") || url.Contains("://mobile.twitter.com/account/login_verification");
 		}
 
 		public static string? GetFileNameFromUrl(string url) {
diff --git a/lib/TweetLib.Core/Lib.cs b/lib/TweetLib.Core/Lib.cs
index a731a3c0..3b02607e 100644
--- a/lib/TweetLib.Core/Lib.cs
+++ b/lib/TweetLib.Core/Lib.cs
@@ -12,7 +12,7 @@ public static class Lib {
 
 		public static CultureInfo Culture { get; } = CultureInfo.CurrentCulture;
 
-		public static void Initialize(App.Builder app) {
+		public static void Initialize(AppBuilder app) {
 			Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
 			CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
 
@@ -20,7 +20,7 @@ public static void Initialize(App.Builder app) {
 			CultureInfo.DefaultThreadCurrentUICulture = Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-us"); // force english exceptions
 			#endif
 
-			app.Initialize();
+			app.Build();
 		}
 	}
 }
diff --git a/lib/TweetLib.Core/Resources/CachingResourceProvider.cs b/lib/TweetLib.Core/Resources/CachingResourceProvider.cs
new file mode 100644
index 00000000..543a98ea
--- /dev/null
+++ b/lib/TweetLib.Core/Resources/CachingResourceProvider.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using TweetLib.Browser.Interfaces;
+using IOFile = System.IO.File;
+
+namespace TweetLib.Core.Resources {
+	public sealed class CachingResourceProvider<T> : IResourceProvider<T> {
+		private readonly IResourceProvider<T> resourceProvider;
+		private readonly Dictionary<string, ICachedResource> cache = new ();
+
+		public CachingResourceProvider(IResourceProvider<T> resourceProvider) {
+			this.resourceProvider = resourceProvider;
+		}
+
+		public void ClearCache() {
+			cache.Clear();
+		}
+
+		public T Status(HttpStatusCode code, string message) {
+			return resourceProvider.Status(code, message);
+		}
+
+		public T File(byte[] contents, string extension) {
+			return resourceProvider.File(contents, extension);
+		}
+
+		internal T CachedFile(string path) {
+			string key = new Uri(path).LocalPath;
+
+			if (cache.TryGetValue(key, out var cachedResource)) {
+				return cachedResource.GetResource(resourceProvider);
+			}
+
+			ICachedResource resource;
+			try {
+				resource = new CachedFileResource(IOFile.ReadAllBytes(path), Path.GetExtension(path));
+			} catch (FileNotFoundException) {
+				resource = new CachedStatusResource(HttpStatusCode.NotFound, "File not found.");
+			} catch (DirectoryNotFoundException) {
+				resource = new CachedStatusResource(HttpStatusCode.NotFound, "Directory not found.");
+			} catch (Exception e) {
+				resource = new CachedStatusResource(HttpStatusCode.InternalServerError, e.Message);
+			}
+
+			cache[key] = resource;
+			return resource.GetResource(resourceProvider);
+		}
+
+		private interface ICachedResource {
+			T GetResource(IResourceProvider<T> resourceProvider);
+		}
+
+		private sealed class CachedFileResource : ICachedResource {
+			private readonly byte[] contents;
+			private readonly string extension;
+
+			public CachedFileResource(byte[] contents, string extension) {
+				this.contents = contents;
+				this.extension = extension;
+			}
+
+			T ICachedResource.GetResource(IResourceProvider<T> resourceProvider) {
+				return resourceProvider.File(contents, extension);
+			}
+		}
+
+		private sealed class CachedStatusResource : ICachedResource {
+			private readonly HttpStatusCode code;
+			private readonly string message;
+
+			public CachedStatusResource(HttpStatusCode code, string message) {
+				this.code = code;
+				this.message = message;
+			}
+
+			public T GetResource(IResourceProvider<T> resourceProvider) {
+				return resourceProvider.Status(code, message);
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Resources/ResourceUtils.cs b/lib/TweetLib.Core/Resources/ResourceUtils.cs
new file mode 100644
index 00000000..a1e9aa12
--- /dev/null
+++ b/lib/TweetLib.Core/Resources/ResourceUtils.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using TweetLib.Browser.Interfaces;
+
+namespace TweetLib.Core.Resources {
+	public static class ResourceUtils {
+		public static string? ReadFileOrNull(string relativePath) {
+			string path = Path.Combine(App.ResourcesPath, relativePath);
+
+			try {
+				return File.ReadAllText(path, Encoding.UTF8);
+			} catch (Exception e) {
+				App.Logger.Error("Error reading file: " + path);
+				App.Logger.Error(e.ToString());
+				return null;
+			}
+		}
+
+		internal static string? GetBootstrapScript(string moduleNamespace, bool includeStylesheets) {
+			string? script = ReadFileOrNull("bootstrap.js");
+
+			if (script == null) {
+				return null;
+			}
+
+			string path = Path.Combine(App.ResourcesPath, moduleNamespace);
+			var files = new DirectoryInfo(path).GetFiles();
+
+			var moduleNames = new List<string>();
+			var stylesheetNames = new List<string>();
+
+			foreach (var file in files) {
+				var ext = Path.GetExtension(file.Name);
+
+				var targetList = ext switch {
+					".js"  => moduleNames,
+					".css" => includeStylesheets ? stylesheetNames : null,
+					_      => null
+				};
+
+				targetList?.Add(Path.GetFileNameWithoutExtension(file.Name));
+			}
+
+			script = script.Replace("{{namespace}}", moduleNamespace);
+			script = script.Replace("{{modules}}", string.Join("|", moduleNames));
+			script = script.Replace("{{stylesheets}}", string.Join("|", stylesheetNames));
+
+			return script;
+		}
+
+		internal static void RunBootstrap(this IScriptExecutor executor, string moduleNamespace) {
+			var script = GetBootstrapScript(moduleNamespace, includeStylesheets: true);
+			if (script != null) {
+				executor.RunScript("bootstrap", script);
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Systems/Configuration/BaseConfig.cs b/lib/TweetLib.Core/Systems/Configuration/BaseConfig.cs
index 93f38674..7f439160 100644
--- a/lib/TweetLib.Core/Systems/Configuration/BaseConfig.cs
+++ b/lib/TweetLib.Core/Systems/Configuration/BaseConfig.cs
@@ -3,35 +3,11 @@
 
 namespace TweetLib.Core.Systems.Configuration {
 	public abstract class BaseConfig {
-		private readonly IConfigManager configManager;
-
-		protected BaseConfig(IConfigManager configManager) {
-			this.configManager = configManager;
+		internal T ConstructWithDefaults<T>() where T : BaseConfig {
+			return (T) ConstructWithDefaults();
 		}
 
-		// Management
-
-		public void Save() {
-			configManager.GetInstanceInfo(this).Save();
-		}
-
-		public void Reload() {
-			configManager.GetInstanceInfo(this).Reload();
-		}
-
-		public void Reset() {
-			configManager.GetInstanceInfo(this).Reset();
-		}
-
-		// Construction methods
-
-		public T ConstructWithDefaults<T>() where T : BaseConfig {
-			return (T) ConstructWithDefaults(configManager);
-		}
-
-		protected abstract BaseConfig ConstructWithDefaults(IConfigManager configManager);
-
-		// Utility methods
+		protected abstract BaseConfig ConstructWithDefaults();
 
 		protected void UpdatePropertyWithEvent<T>(ref T field, T value, EventHandler? eventHandler) {
 			if (!EqualityComparer<T>.Default.Equals(field, value)) {
@@ -40,10 +16,10 @@ protected void UpdatePropertyWithEvent<T>(ref T field, T value, EventHandler? ev
 			}
 		}
 
-		protected void UpdatePropertyWithRestartRequest<T>(ref T field, T value) {
+		protected void UpdatePropertyWithCallback<T>(ref T field, T value, Action action) {
 			if (!EqualityComparer<T>.Default.Equals(field, value)) {
 				field = value;
-				configManager.TriggerProgramRestartRequested();
+				action();
 			}
 		}
 	}
diff --git a/lib/TweetLib.Core/Systems/Configuration/FileConfigInstance.cs b/lib/TweetLib.Core/Systems/Configuration/FileConfigInstance.cs
index 1a09b071..e64b9274 100644
--- a/lib/TweetLib.Core/Systems/Configuration/FileConfigInstance.cs
+++ b/lib/TweetLib.Core/Systems/Configuration/FileConfigInstance.cs
@@ -12,7 +12,7 @@ public sealed class FileConfigInstance<T> : IConfigInstance<T> where T : BaseCon
 		private readonly string identifier;
 
 		public FileConfigInstance(string filename, T instance, string identifier) {
-			this.filenameMain = filename;
+			this.filenameMain = filename ?? throw new ArgumentNullException(nameof(filename), "Config file name must not be null!");
 			this.filenameBackup = filename + ".bak";
 			this.identifier = identifier;
 
@@ -32,7 +32,7 @@ public void Load() {
 					LoadInternal(attempt > 0);
 
 					if (firstException != null) { // silently log exception that caused a backup restore
-						App.ErrorHandler.Log(firstException.ToString());
+						App.Logger.Error(firstException.ToString());
 					}
 
 					return;
diff --git a/lib/TweetLib.Core/Systems/Configuration/IConfigManager.cs b/lib/TweetLib.Core/Systems/Configuration/IConfigManager.cs
index 02e6fccb..acf534ad 100644
--- a/lib/TweetLib.Core/Systems/Configuration/IConfigManager.cs
+++ b/lib/TweetLib.Core/Systems/Configuration/IConfigManager.cs
@@ -1,6 +1,5 @@
 namespace TweetLib.Core.Systems.Configuration {
 	public interface IConfigManager {
-		void TriggerProgramRestartRequested();
 		IConfigInstance<BaseConfig> GetInstanceInfo(BaseConfig instance);
 	}
 }
diff --git a/lib/TweetLib.Core/Systems/Dialogs/Dialogs.cs b/lib/TweetLib.Core/Systems/Dialogs/Dialogs.cs
new file mode 100644
index 00000000..bfd1f6db
--- /dev/null
+++ b/lib/TweetLib.Core/Systems/Dialogs/Dialogs.cs
@@ -0,0 +1,5 @@
+namespace TweetLib.Core.Systems.Dialogs {
+	public static class Dialogs {
+		public const string OK = "OK";
+	}
+}
diff --git a/lib/TweetLib.Core/Systems/Dialogs/FileDialogFilter.cs b/lib/TweetLib.Core/Systems/Dialogs/FileDialogFilter.cs
new file mode 100644
index 00000000..80ca2da5
--- /dev/null
+++ b/lib/TweetLib.Core/Systems/Dialogs/FileDialogFilter.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+
+namespace TweetLib.Core.Systems.Dialogs {
+	public sealed class FileDialogFilter {
+		public string Name { get; }
+		public IReadOnlyList<string> Extensions { get; }
+
+		public FileDialogFilter(string name, params string[] extensions) {
+			Name = name;
+			Extensions = extensions;
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/Systems/Dialogs/SaveFileDialogSettings.cs b/lib/TweetLib.Core/Systems/Dialogs/SaveFileDialogSettings.cs
new file mode 100644
index 00000000..c6615867
--- /dev/null
+++ b/lib/TweetLib.Core/Systems/Dialogs/SaveFileDialogSettings.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+
+namespace TweetLib.Core.Systems.Dialogs {
+	public sealed class SaveFileDialogSettings {
+		public string DialogTitle { get; internal set; } = "Save File";
+		public bool OverwritePrompt { get; internal set; } = true;
+		public string? FileName { get; internal set; }
+		public IReadOnlyList<FileDialogFilter>? Filters { get; internal set; }
+	}
+}
diff --git a/lib/TweetLib.Core/Systems/Startup/LockFile.cs b/lib/TweetLib.Core/Systems/Startup/LockFile.cs
index 88a69b7c..93fe657e 100644
--- a/lib/TweetLib.Core/Systems/Startup/LockFile.cs
+++ b/lib/TweetLib.Core/Systems/Startup/LockFile.cs
@@ -84,7 +84,7 @@ public bool Unlock() {
 				try {
 					File.Delete(path);
 				} catch (Exception e) {
-					App.ErrorHandler.Log(e.ToString());
+					App.Logger.Error(e.ToString());
 					return false;
 				}
 			}
diff --git a/lib/TweetLib.Core/Systems/Startup/LockResult.cs b/lib/TweetLib.Core/Systems/Startup/LockResult.cs
index cc8dd0ca..fc320fc0 100644
--- a/lib/TweetLib.Core/Systems/Startup/LockResult.cs
+++ b/lib/TweetLib.Core/Systems/Startup/LockResult.cs
@@ -13,12 +13,12 @@ public override string ToString() {
 			return name;
 		}
 
-		public static LockResult Success { get; } = new LockResult("Success");
+		public static LockResult Success { get; } = new ("Success");
 
 		public sealed class Fail : LockResult {
 			public Exception Exception { get; }
 
-			public Fail(Exception exception) : base("Fail") {
+			internal Fail(Exception exception) : base("Fail") {
 				this.Exception = exception;
 			}
 		}
@@ -26,7 +26,7 @@ public Fail(Exception exception) : base("Fail") {
 		public sealed class HasProcess : LockResult, IDisposable {
 			public Process Process { get; }
 
-			public HasProcess(Process process) : base("HasProcess") {
+			internal HasProcess(Process process) : base("HasProcess") {
 				this.Process = process;
 			}
 
diff --git a/lib/TweetLib.Core/Systems/Updates/UpdateCheckEventArgs.cs b/lib/TweetLib.Core/Systems/Updates/UpdateCheckEventArgs.cs
index 69da4a99..1713b0a6 100644
--- a/lib/TweetLib.Core/Systems/Updates/UpdateCheckEventArgs.cs
+++ b/lib/TweetLib.Core/Systems/Updates/UpdateCheckEventArgs.cs
@@ -6,7 +6,7 @@ public sealed class UpdateCheckEventArgs : EventArgs {
 		public int EventId { get; }
 		public Result<UpdateInfo> Result { get; }
 
-		public UpdateCheckEventArgs(int eventId, Result<UpdateInfo> result) {
+		internal UpdateCheckEventArgs(int eventId, Result<UpdateInfo> result) {
 			this.EventId = eventId;
 			this.Result = result;
 		}
diff --git a/lib/TweetLib.Core/Systems/Updates/UpdateHandler.cs b/lib/TweetLib.Core/Systems/Updates/UpdateChecker.cs
similarity index 85%
rename from lib/TweetLib.Core/Systems/Updates/UpdateHandler.cs
rename to lib/TweetLib.Core/Systems/Updates/UpdateChecker.cs
index a02fb99b..5b2d0fa5 100644
--- a/lib/TweetLib.Core/Systems/Updates/UpdateHandler.cs
+++ b/lib/TweetLib.Core/Systems/Updates/UpdateChecker.cs
@@ -6,8 +6,10 @@
 using Timer = System.Timers.Timer;
 
 namespace TweetLib.Core.Systems.Updates {
-	public sealed class UpdateHandler : IDisposable {
-		public const int CheckCodeUpdatesDisabled = -1;
+	public sealed class UpdateChecker : IDisposable {
+		private const int CheckCodeUpdatesDisabled = -1;
+
+		public UpdateInteractionManager InteractionManager { get; }
 
 		private readonly IUpdateCheckClient client;
 		private readonly TaskScheduler scheduler;
@@ -16,7 +18,7 @@ public sealed class UpdateHandler : IDisposable {
 		public event EventHandler<UpdateCheckEventArgs>? CheckFinished;
 		private ushort lastEventId;
 
-		public UpdateHandler(IUpdateCheckClient client, TaskScheduler scheduler) {
+		public UpdateChecker(IUpdateCheckClient client, TaskScheduler scheduler) {
 			this.client = client;
 			this.scheduler = scheduler;
 
@@ -26,17 +28,20 @@ public UpdateHandler(IUpdateCheckClient client, TaskScheduler scheduler) {
 			};
 
 			this.timer.Elapsed += timer_Elapsed;
+
+			this.InteractionManager = new UpdateInteractionManager(this);
 		}
 
 		public void Dispose() {
 			timer.Dispose();
+			InteractionManager.Dispose();
 		}
 
 		private void timer_Elapsed(object sender, ElapsedEventArgs e) {
 			Check(false);
 		}
 
-		public void StartTimer() {
+		internal void StartTimer() {
 			if (timer.Enabled) {
 				return;
 			}
diff --git a/lib/TweetLib.Core/Systems/Updates/UpdateInfo.cs b/lib/TweetLib.Core/Systems/Updates/UpdateInfo.cs
index 719ea4c4..587214ac 100644
--- a/lib/TweetLib.Core/Systems/Updates/UpdateInfo.cs
+++ b/lib/TweetLib.Core/Systems/Updates/UpdateInfo.cs
@@ -2,6 +2,7 @@
 using System.IO;
 using System.Net;
 using TweetLib.Utils.Static;
+using Version = TweetDuck.Version;
 
 namespace TweetLib.Core.Systems.Updates {
 	public sealed class UpdateInfo {
@@ -12,11 +13,11 @@ public sealed class UpdateInfo {
 		public UpdateDownloadStatus DownloadStatus { get; private set; }
 		public Exception? DownloadError { get; private set; }
 
-		private readonly string downloadUrl;
+		private readonly string? downloadUrl;
 		private readonly string installerFolder;
 		private WebClient? currentDownload;
 
-		public UpdateInfo(string versionTag, string releaseNotes, string downloadUrl, string installerFolder) {
+		public UpdateInfo(string versionTag, string releaseNotes, string? downloadUrl, string installerFolder) {
 			this.downloadUrl = downloadUrl;
 			this.installerFolder = installerFolder;
 
@@ -25,13 +26,13 @@ public UpdateInfo(string versionTag, string releaseNotes, string downloadUrl, st
 			this.InstallerPath = Path.Combine(installerFolder, $"{Lib.BrandName}.{versionTag}.exe");
 		}
 
-		public void BeginSilentDownload() {
+		internal void BeginSilentDownload() {
 			if (FileUtils.FileExistsAndNotEmpty(InstallerPath)) {
 				DownloadStatus = UpdateDownloadStatus.Done;
 				return;
 			}
 
-			if (DownloadStatus == UpdateDownloadStatus.None || DownloadStatus == UpdateDownloadStatus.Failed) {
+			if (DownloadStatus is UpdateDownloadStatus.None or UpdateDownloadStatus.Failed) {
 				DownloadStatus = UpdateDownloadStatus.InProgress;
 
 				if (string.IsNullOrEmpty(downloadUrl)) {
@@ -48,7 +49,7 @@ public void BeginSilentDownload() {
 					return;
 				}
 
-				WebClient client = WebUtils.NewClient($"{Lib.BrandName} {TweetDuck.Version.Tag}");
+				WebClient client = WebUtils.NewClient($"{Lib.BrandName} {Version.Tag}");
 
 				client.DownloadFileCompleted += WebUtils.FileDownloadCallback(InstallerPath, () => {
 					DownloadStatus = UpdateDownloadStatus.Done;
@@ -59,11 +60,11 @@ public void BeginSilentDownload() {
 					currentDownload = null;
 				});
 
-				client.DownloadFileAsync(new Uri(downloadUrl), InstallerPath);
+				client.DownloadFileAsync(new Uri(downloadUrl!), InstallerPath);
 			}
 		}
 
-		public void DeleteInstaller() {
+		internal void DeleteInstaller() {
 			DownloadStatus = UpdateDownloadStatus.None;
 
 			if (currentDownload != null && currentDownload.IsBusy) {
@@ -83,7 +84,7 @@ public void CancelDownload() {
 			DownloadStatus = UpdateDownloadStatus.Canceled;
 		}
 
-		public override bool Equals(object obj) {
+		public override bool Equals(object? obj) {
 			return obj is UpdateInfo info && VersionTag == info.VersionTag;
 		}
 
diff --git a/lib/TweetLib.Core/Systems/Updates/UpdateInteractionManager.cs b/lib/TweetLib.Core/Systems/Updates/UpdateInteractionManager.cs
new file mode 100644
index 00000000..041ee2c3
--- /dev/null
+++ b/lib/TweetLib.Core/Systems/Updates/UpdateInteractionManager.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace TweetLib.Core.Systems.Updates {
+	public sealed class UpdateInteractionManager : IDisposable {
+		public event EventHandler<UpdateInfo>? UpdateAccepted;
+		public event EventHandler<UpdateInfo>? UpdateDismissed;
+
+		internal object BridgeObject { get; }
+
+		private readonly UpdateChecker updates;
+		private UpdateInfo? nextUpdate = null;
+
+		internal UpdateInteractionManager(UpdateChecker updates) {
+			this.updates = updates;
+			this.updates.CheckFinished += updates_CheckFinished;
+			this.BridgeObject = new Bridge(this);
+		}
+
+		public void Dispose() {
+			updates.CheckFinished -= updates_CheckFinished;
+		}
+
+		public void ClearUpdate() {
+			nextUpdate?.DeleteInstaller();
+			nextUpdate = null;
+		}
+
+		private void updates_CheckFinished(object sender, UpdateCheckEventArgs e) {
+			UpdateInfo? foundUpdate = e.Result.HasValue ? e.Result.Value : null;
+
+			if (nextUpdate != null && !nextUpdate.Equals(foundUpdate)) {
+				nextUpdate.DeleteInstaller();
+			}
+
+			nextUpdate = foundUpdate;
+		}
+
+		private void HandleInteractionEvent(EventHandler<UpdateInfo>? eventHandler) {
+			UpdateInfo? updateInfo = nextUpdate;
+
+			if (updateInfo != null) {
+				eventHandler?.Invoke(this, updateInfo);
+			}
+		}
+
+		[SuppressMessage("ReSharper", "UnusedMember.Local")]
+		private sealed class Bridge {
+			private readonly UpdateInteractionManager owner;
+
+			public Bridge(UpdateInteractionManager owner) {
+				this.owner = owner;
+			}
+
+			public void TriggerUpdateCheck() {
+				owner.updates.Check(false);
+			}
+
+			public void OnUpdateAccepted() {
+				owner.HandleInteractionEvent(owner.UpdateAccepted);
+			}
+
+			public void OnUpdateDismissed() {
+				owner.HandleInteractionEvent(owner.UpdateDismissed);
+				owner.ClearUpdate();
+			}
+		}
+	}
+}
diff --git a/lib/TweetLib.Core/TweetLib.Core.csproj b/lib/TweetLib.Core/TweetLib.Core.csproj
index 3583203a..e3409ab9 100644
--- a/lib/TweetLib.Core/TweetLib.Core.csproj
+++ b/lib/TweetLib.Core/TweetLib.Core.csproj
@@ -13,6 +13,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <ProjectReference Include="..\TweetLib.Browser\TweetLib.Browser.csproj" />
     <ProjectReference Include="..\TweetLib.Utils\TweetLib.Utils.csproj" />
   </ItemGroup>
 
diff --git a/lib/TweetLib.Utils/Collections/TwoKeyDictionary.cs b/lib/TweetLib.Utils/Collections/TwoKeyDictionary.cs
index 598f6cf5..ec92162b 100644
--- a/lib/TweetLib.Utils/Collections/TwoKeyDictionary.cs
+++ b/lib/TweetLib.Utils/Collections/TwoKeyDictionary.cs
@@ -104,7 +104,7 @@ public bool Contains(K1 outerKey, K2 innerKey) {
 		/// Returns the number of values in the dictionary.
 		/// </summary>
 		public int Count() {
-			return dict.Values.Sum(d => d.Count);
+			return dict.Values.Sum(static d => d.Count);
 		}
 
 		/// <summary>
diff --git a/lib/TweetLib.Utils/IO/CombinedFileStream.cs b/lib/TweetLib.Utils/IO/CombinedFileStream.cs
index 0ee5102d..6567fc9b 100644
--- a/lib/TweetLib.Utils/IO/CombinedFileStream.cs
+++ b/lib/TweetLib.Utils/IO/CombinedFileStream.cs
@@ -133,7 +133,7 @@ public string[] KeyValue {
 
 			private readonly byte[] contents;
 
-			public Entry(string identifier, byte[] contents) {
+			internal Entry(string identifier, byte[] contents) {
 				this.Identifier = identifier;
 				this.contents = contents;
 			}
diff --git a/lib/TweetLib.Utils/Serialization/SerializationSoftException.cs b/lib/TweetLib.Utils/Serialization/SerializationSoftException.cs
index ac88be49..a8735fcc 100644
--- a/lib/TweetLib.Utils/Serialization/SerializationSoftException.cs
+++ b/lib/TweetLib.Utils/Serialization/SerializationSoftException.cs
@@ -5,7 +5,7 @@ namespace TweetLib.Utils.Serialization {
 	public sealed class SerializationSoftException : Exception {
 		public IList<string> Errors { get; }
 
-		public SerializationSoftException(IList<string> errors) : base(string.Join(Environment.NewLine, errors)) {
+		internal SerializationSoftException(IList<string> errors) : base(string.Join(Environment.NewLine, errors)) {
 			this.Errors = errors;
 		}
 	}
diff --git a/lib/TweetLib.Utils/Serialization/SimpleObjectSerializer.cs b/lib/TweetLib.Utils/Serialization/SimpleObjectSerializer.cs
index dc37612a..332efae5 100644
--- a/lib/TweetLib.Utils/Serialization/SimpleObjectSerializer.cs
+++ b/lib/TweetLib.Utils/Serialization/SimpleObjectSerializer.cs
@@ -54,7 +54,7 @@ private static string UnescapeStream(StreamReader reader) {
 		private readonly Dictionary<Type, ITypeConverter> converters;
 
 		public SimpleObjectSerializer() {
-			this.props = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(prop => prop.CanWrite).ToDictionary(prop => prop.Name);
+			this.props = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static prop => prop.CanWrite).ToDictionary(static prop => prop.Name);
 			this.converters = new Dictionary<Type, ITypeConverter>();
 		}
 
@@ -63,7 +63,7 @@ public void RegisterTypeConverter(Type type, ITypeConverter converter) {
 		}
 
 		public void Write(string file, T obj) {
-			LinkedList<string> errors = new LinkedList<string>();
+			var errors = new LinkedList<string>();
 
 			FileUtils.CreateDirectoryForFile(file);
 
@@ -109,7 +109,7 @@ public void Read(string file, T obj) {
 				throw new FormatException("Input appears to be a binary file.");
 			}
 
-			LinkedList<string> errors = new LinkedList<string>();
+			var errors = new LinkedList<string>();
 			int currentPos = 0;
 
 			do {
diff --git a/lib/TweetLib.Utils/Static/StringUtils.cs b/lib/TweetLib.Utils/Static/StringUtils.cs
index 87b50c9e..bff10b66 100644
--- a/lib/TweetLib.Utils/Static/StringUtils.cs
+++ b/lib/TweetLib.Utils/Static/StringUtils.cs
@@ -13,6 +13,20 @@ public static class StringUtils {
 			return string.IsNullOrEmpty(str) ? null : str;
 		}
 
+		/// <summary>
+		/// Returns whether the <paramref name="str"/> starts with <paramref name="search"/>, using ordinal comparison.
+		/// </summary>
+		public static bool StartsWithOrdinal(this string str, string search) {
+			return str.StartsWith(search, StringComparison.Ordinal);
+		}
+
+		/// <summary>
+		/// Returns whether the <paramref name="str"/> ends with <paramref name="search"/>, using ordinal comparison.
+		/// </summary>
+		public static bool EndsWithOrdinal(this string str, string search) {
+			return str.EndsWith(search, StringComparison.Ordinal);
+		}
+
 		/// <summary>
 		/// Splits <paramref name="str"/> into two parts:
 		///  - A substring from the beginning until <paramref name="search"/> (exclusive)
@@ -57,7 +71,7 @@ public static string ConvertPascalCaseToScreamingSnakeCase(string str) {
 		/// Applies the ROT13 cipher to <paramref name="str"/>.
 		/// </summary>
 		public static string ConvertRot13(string str) {
-			return Regex.Replace(str, @"[a-zA-Z]", match => {
+			return Regex.Replace(str, @"[a-zA-Z]", static match => {
 				int code = match.Value[0];
 				int start = code <= 90 ? 65 : 97;
 				return ((char) (start + (code - start + 13) % 26)).ToString();
diff --git a/lib/TweetLib.Utils/Static/WebUtils.cs b/lib/TweetLib.Utils/Static/WebUtils.cs
index 0e69a6a9..d98049ba 100644
--- a/lib/TweetLib.Utils/Static/WebUtils.cs
+++ b/lib/TweetLib.Utils/Static/WebUtils.cs
@@ -5,6 +5,8 @@
 
 namespace TweetLib.Utils.Static {
 	public static class WebUtils {
+		public static string DefaultUserAgent { get; set; } = "";
+
 		private static bool hasMicrosoftBeenBroughtTo2008Yet;
 		private static bool hasSystemProxyBeenEnabled;
 
@@ -25,7 +27,7 @@ public static void EnableSystemProxy() {
 			}
 		}
 
-		public static WebClient NewClient(string userAgent) {
+		public static WebClient NewClient(string? userAgent = null) {
 			EnsureTLS12();
 
 			WebClient client = new WebClient();
@@ -34,12 +36,12 @@ public static WebClient NewClient(string userAgent) {
 				client.Proxy = null;
 			}
 
-			client.Headers[HttpRequestHeader.UserAgent] = userAgent;
+			client.Headers[HttpRequestHeader.UserAgent] = userAgent ?? DefaultUserAgent;
 			return client;
 		}
 
 		public static AsyncCompletedEventHandler FileDownloadCallback(string file, Action? onSuccess, Action<Exception>? onFailure) {
-			return (sender, args) => {
+			return (_, args) => {
 				if (args.Cancelled) {
 					TryDeleteFile(file);
 				}
diff --git a/video/Controls/LabelTooltip.cs b/video/Controls/LabelTooltip.cs
index 3dd2dac9..d83a4e12 100644
--- a/video/Controls/LabelTooltip.cs
+++ b/video/Controls/LabelTooltip.cs
@@ -1,4 +1,5 @@
 using System;
+using System.Diagnostics;
 using System.Drawing;
 using System.Windows.Forms;
 
@@ -17,7 +18,7 @@ public void AttachTooltip(Control control, bool followCursor, Func<MouseEventArg
 				SuspendLayout();
 
 				Form? form = control.FindForm();
-				System.Diagnostics.Debug.Assert(form != null);
+				Debug.Assert(form != null);
 
 				string? text = tooltipFunc(args);
 
diff --git a/video/FormPlayer.cs b/video/FormPlayer.cs
index f73ea9b8..d2b307f6 100644
--- a/video/FormPlayer.cs
+++ b/video/FormPlayer.cs
@@ -197,7 +197,7 @@ private void player_MediaError(object pMediaObject) {
 
 			Marshal.ReleaseComObject(error);
 			Marshal.ReleaseComObject(pMediaObject);
-			Environment.Exit(Program.CODE_MEDIA_ERROR);
+			Environment.Exit(Program.CodeMediaError);
 		}
 
 		private void player_PositionChange(double oldPosition, double newPosition) {
@@ -309,7 +309,7 @@ private void timerSync_Tick(object sender, EventArgs e) {
 				Marshal.ReleaseComObject(controls);
 			}
 			else {
-				Environment.Exit(Program.CODE_OWNER_GONE);
+				Environment.Exit(Program.CodeOwnerGone);
 			}
 		}
 
@@ -435,7 +435,7 @@ bool IMessageFilter.PreFilterMessage(ref Message m) {
 				}
 				else if (m.Msg == 0x020B && ((m.WParam.ToInt32() >> 16) & 0xFFFF) == 1) { // WM_XBUTTONDOWN
 					NativeMethods.SetForegroundWindow(form.ownerHandle);
-					Environment.Exit(Program.CODE_USER_REQUESTED);
+					Environment.Exit(Program.CodeUserRequested);
 				}
 
 				return false;
diff --git a/video/NativeMethods.cs b/video/NativeMethods.cs
index 1b1c63ee..bf54603d 100644
--- a/video/NativeMethods.cs
+++ b/video/NativeMethods.cs
@@ -3,6 +3,7 @@
 using System.Runtime.InteropServices;
 
 namespace TweetDuck.Video {
+	[SuppressMessage("ReSharper", "InconsistentNaming")]
 	static class NativeMethods {
 		private const int GWL_HWNDPARENT = -8;
 
@@ -28,6 +29,7 @@ static class NativeMethods {
 
 		[StructLayout(LayoutKind.Sequential)]
 		[SuppressMessage("ReSharper", "InconsistentNaming")]
+		[SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Global")]
 		public struct RECT {
 			public int Left;
 			public int Top;
diff --git a/video/Program.cs b/video/Program.cs
index 5d871885..23df8b27 100644
--- a/video/Program.cs
+++ b/video/Program.cs
@@ -6,12 +6,12 @@
 namespace TweetDuck.Video {
 	static class Program {
 		// referenced in VideoPlayer
-		// set by task manager -- public const int CODE_PROCESS_KILLED = 1;
-		public const int CODE_INVALID_ARGS = 2;
-		public const int CODE_LAUNCH_FAIL = 3;
-		public const int CODE_MEDIA_ERROR = 4;
-		public const int CODE_OWNER_GONE = 5;
-		public const int CODE_USER_REQUESTED = 6;
+		// set by task manager -- public const int "CodeProcessKilled" = 1;
+		public const int CodeInvalidArgs = 2;
+		public const int CodeLaunchFail = 3;
+		public const int CodeMediaError = 4;
+		public const int CodeOwnerGone = 5;
+		public const int CodeUserRequested = 6;
 
 		[STAThread]
 		private static int Main(string[] args) {
@@ -35,14 +35,14 @@ private static int Main(string[] args) {
 				videoUrl = new Uri(args[3], UriKind.Absolute).AbsoluteUri;
 				pipeToken = args[4];
 			} catch {
-				return CODE_INVALID_ARGS;
+				return CodeInvalidArgs;
 			}
 
 			try {
 				Application.Run(new FormPlayer(ownerHandle, ownerDpi, defaultVolume, videoUrl, pipeToken));
 			} catch (Exception e) {
 				Console.Out.WriteLine(e);
-				return CODE_LAUNCH_FAIL;
+				return CodeLaunchFail;
 			}
 
 			return 0;