diff --git a/README.md b/README.md index dfcfe02..705d0c0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ them permanently. - **Registers as a browser** — per-user registry (no admin), guided first-run setup, health checks with one-click fixes - **Browser & profile discovery** — installed browsers, Chromium profiles, Firefox profiles, Firefox Multi-Account Containers, incognito/private entries, with real profile avatars - **Rules engine** — substring/regex match on whole URL, domain or path; drag-to-reorder priority; fallback default profile; bt-compatible rule syntax (`scope:domain|priority:2|github.com`) +- **Optional local HTML routing** — when enabled in Settings, Browser Wrangler advertises `.htm/.html` support in Windows so local HTML files can be routed by your rules - **URL pipeline** — Outlook Safelinks unwrap, optional shortened-link expansion, and find/replace substitutions (`substr|find|replace`, `rgx|find|replace`) - **Picker** — popup listing browsers/profiles with number-key shortcuts; triggered by hotkeys (Ctrl+Shift etc.), rule conflicts, or no-match - **Toast** — brief notification showing which rule routed to which browser diff --git a/src/BrowserWrangler.Core/AppInfo.cs b/src/BrowserWrangler.Core/AppInfo.cs index ea7818b..1836ce4 100644 --- a/src/BrowserWrangler.Core/AppInfo.cs +++ b/src/BrowserWrangler.Core/AppInfo.cs @@ -10,7 +10,7 @@ public static class AppInfo public const bool IsDevChannel = true; public const string Name = DevName; - /// ProgId registered for http/https URL associations. + /// ProgId registered for URL associations (and optional .htm/.html file associations). public const string ProgId = "BrowserWranglerHTM.Dev"; /// Custom protocol for browser extensions ("x-bw:..."). @@ -22,7 +22,7 @@ public static class AppInfo public const bool IsDevChannel = false; public const string Name = StableName; - /// ProgId registered for http/https URL associations. + /// ProgId registered for URL associations (and optional .htm/.html file associations). public const string ProgId = "BrowserWranglerHTM"; /// Custom protocol for browser extensions ("x-bw:..."). diff --git a/src/BrowserWrangler.Core/Configuration/AppConfig.cs b/src/BrowserWrangler.Core/Configuration/AppConfig.cs index d10313b..7c30f25 100644 --- a/src/BrowserWrangler.Core/Configuration/AppConfig.cs +++ b/src/BrowserWrangler.Core/Configuration/AppConfig.cs @@ -94,6 +94,12 @@ public sealed class AppConfig /// Set once the user has been through (or dismissed) the first-run setup window. public bool SetupCompleted { get; set; } + /// + /// When enabled, Browser Wrangler advertises support for .htm/.html files and routes them + /// through the same rule engine as links. + /// + public bool HandleHtmlFiles { get; set; } + public PickerSettings Picker { get; set; } = new(); public ToastSettings Toast { get; set; } = new(); diff --git a/src/BrowserWrangler.Core/Launching/LaunchTargetParser.cs b/src/BrowserWrangler.Core/Launching/LaunchTargetParser.cs new file mode 100644 index 0000000..b663766 --- /dev/null +++ b/src/BrowserWrangler.Core/Launching/LaunchTargetParser.cs @@ -0,0 +1,80 @@ +namespace BrowserWrangler.Core.Launching; + +/// Parses and normalizes incoming launch arguments into routable targets. +public static class LaunchTargetParser +{ + public static bool TryGetLaunchTargetUrl( + IEnumerable args, + string customProtocol, + bool allowHtmlFileTargets, + out string launchTargetUrl) + { + foreach (string arg in args) + { + if (TryNormalizeLaunchTarget(arg, customProtocol, allowHtmlFileTargets, out launchTargetUrl)) + { + return true; + } + } + + launchTargetUrl = string.Empty; + return false; + } + + public static bool TryNormalizeLaunchTarget( + string candidate, + string customProtocol, + bool allowHtmlFileTargets, + out string launchTargetUrl) + { + launchTargetUrl = string.Empty; + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + if (candidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || candidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || candidate.StartsWith(customProtocol + ":", StringComparison.OrdinalIgnoreCase)) + { + launchTargetUrl = candidate; + return true; + } + + if (!allowHtmlFileTargets) + { + return false; + } + + if (Uri.TryCreate(candidate, UriKind.Absolute, out Uri? parsedUri) + && parsedUri.Scheme == Uri.UriSchemeFile + && IsHtmlPath(parsedUri.LocalPath)) + { + launchTargetUrl = parsedUri.AbsoluteUri; + return true; + } + + if (!Path.IsPathRooted(candidate) || !IsHtmlPath(candidate)) + { + return false; + } + + try + { + string fullPath = Path.GetFullPath(candidate); + launchTargetUrl = new Uri(fullPath).AbsoluteUri; + return true; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException or UriFormatException) + { + return false; + } + } + + private static bool IsHtmlPath(string path) + { + string extension = Path.GetExtension(path); + return extension.Equals(".htm", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".html", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/BrowserWrangler.Core/Setup/BrowserRegistration.cs b/src/BrowserWrangler.Core/Setup/BrowserRegistration.cs index 42abf3f..b85e37b 100644 --- a/src/BrowserWrangler.Core/Setup/BrowserRegistration.cs +++ b/src/BrowserWrangler.Core/Setup/BrowserRegistration.cs @@ -59,10 +59,10 @@ public static class BrowserRegistration /// Full path of the executable to register; defaults to the current process. public static string ExecutablePath { get; set; } = Environment.ProcessPath ?? string.Empty; - public static void RegisterAll() + public static void RegisterAll(bool includeHtmlFileAssociations = false) { RegisterCustomProtocol(); - RegisterBrowser(); + RegisterBrowser(includeHtmlFileAssociations); NotifyShellAssociationsChanged(); } @@ -82,16 +82,21 @@ public static void UnregisterAll() /// single registry read in the common case and never throws. /// /// True when a repair was performed. - public static bool EnsureRegistered() + public static bool EnsureRegistered(bool includeHtmlFileAssociations = false) { try { - if (ExecutablePath.Length == 0 || IsRegisteredAsBrowser(out _)) + if (ExecutablePath.Length == 0) { return false; } - RegisterAll(); + if (IsRegisteredAsBrowser(out _) && HasExpectedHtmlFileAssociations(includeHtmlFileAssociations)) + { + return false; + } + + RegisterAll(includeHtmlFileAssociations); return true; } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or System.Security.SecurityException) @@ -101,11 +106,28 @@ public static bool EnsureRegistered() } } - /// Registers under StartMenuInternet with http/https capabilities. - public static void RegisterBrowser() + public static IReadOnlyList GetRegisteredUrlProtocols() => + [ + "https", + "http", + AppInfo.CustomProtocol, + ]; + + public static IReadOnlyList GetRegisteredFileExtensions(bool includeHtmlFileAssociations) => + includeHtmlFileAssociations + ? + [ + ".htm", + ".html", + ] + : []; + + /// Registers under StartMenuInternet with URL capabilities and optional HTML file associations. + public static void RegisterBrowser(bool includeHtmlFileAssociations = false) { string appPath = ExecutablePath; string capRoot = BrowserRegPath + @"\Capabilities"; + string fileAssociationsPath = capRoot + @"\FileAssociations"; SetValue(BrowserRegPath, null, AppInfo.Name); @@ -113,11 +135,17 @@ public static void RegisterBrowser() SetValue(capRoot, "ApplicationDescription", AppInfo.Description); SetValue(capRoot, "ApplicationIcon", appPath + ",0"); - foreach (string protocol in new[] { "https", "http", AppInfo.CustomProtocol }) + foreach (string protocol in GetRegisteredUrlProtocols()) { SetValue(capRoot + @"\URLAssociations", protocol, AppInfo.ProgId); } + Registry.CurrentUser.DeleteSubKeyTree(fileAssociationsPath, throwOnMissingSubKey: false); + foreach (string extension in GetRegisteredFileExtensions(includeHtmlFileAssociations)) + { + SetValue(fileAssociationsPath, extension, AppInfo.ProgId); + } + SetValue(BrowserRegPath + @"\DefaultIcon", null, appPath + ",0"); SetValue(BrowserRegPath + @"\shell\open\command", null, $"\"{appPath}\""); @@ -149,6 +177,31 @@ public static bool IsRegisteredAsBrowser(out string error) return ok; } + private static bool HasExpectedHtmlFileAssociations(bool includeHtmlFileAssociations) + { + using RegistryKey? fileAssociations = Registry.CurrentUser.OpenSubKey(BrowserRegPath + @"\Capabilities\FileAssociations"); + IReadOnlyList extensions = GetRegisteredFileExtensions(includeHtmlFileAssociations); + if (extensions.Count == 0) + { + return fileAssociations is null; + } + + if (fileAssociations is null) + { + return false; + } + + foreach (string extension in extensions) + { + if (!string.Equals(fileAssociations.GetValue(extension) as string, AppInfo.ProgId, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + /// True when http/https default handlers point at our ProgId. public static bool IsDefaultBrowser(out bool http, out bool https) { @@ -209,7 +262,7 @@ public static List GetChecks() => bool ok = IsRegisteredAsBrowser(out string error); return (ok, error); }, - RegisterAll), + () => RegisterAll()), new SystemCheck( "proto_http", "HTTP Protocol Handler", diff --git a/src/BrowserWrangler/MainWindow.xaml.cs b/src/BrowserWrangler/MainWindow.xaml.cs index fa50162..63919ce 100644 --- a/src/BrowserWrangler/MainWindow.xaml.cs +++ b/src/BrowserWrangler/MainWindow.xaml.cs @@ -44,7 +44,7 @@ private void OnActivated(object sender, WindowActivatedEventArgs args) private void FixDefaultBrowser_Click(object sender, RoutedEventArgs e) { - BrowserRegistration.EnsureRegistered(); + BrowserRegistration.EnsureRegistered(AppState.Config.HandleHtmlFiles); BrowserRegistration.OpenDefaultAppsSettings(); } diff --git a/src/BrowserWrangler/Pages/HealthPage.xaml.cs b/src/BrowserWrangler/Pages/HealthPage.xaml.cs index 4443da0..d35c0dd 100644 --- a/src/BrowserWrangler/Pages/HealthPage.xaml.cs +++ b/src/BrowserWrangler/Pages/HealthPage.xaml.cs @@ -1,4 +1,5 @@ using BrowserWrangler.Core.Setup; +using BrowserWrangler.Services; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -72,7 +73,7 @@ private void Recheck() private void Register_Click(object sender, RoutedEventArgs e) { - BrowserRegistration.RegisterAll(); + BrowserRegistration.RegisterAll(AppState.Config.HandleHtmlFiles); Recheck(); } diff --git a/src/BrowserWrangler/Pages/SettingsPage.xaml b/src/BrowserWrangler/Pages/SettingsPage.xaml index 8490bbf..485d198 100644 --- a/src/BrowserWrangler/Pages/SettingsPage.xaml +++ b/src/BrowserWrangler/Pages/SettingsPage.xaml @@ -55,6 +55,17 @@ TextWrapping="Wrap" Text="Follows HTTP redirects before matching so rules can see the final destination. This can add latency and shares clicked URLs with redirect services." /> + + + + { oldAutoCheckEnabled = config.Updates.AutoCheckEnabled; + oldHandleHtmlFiles = config.HandleHtmlFiles; PickerSettings p = config.Picker; p.OnCtrlShift = PickerCtrlShift.IsChecked == true; p.OnCtrlAlt = PickerCtrlAlt.IsChecked == true; @@ -78,16 +83,23 @@ private void Setting_Changed(object sender, RoutedEventArgs e) config.Toast.ShowOnOpen = ToastEnabled.IsOn; config.Pipeline.UnwrapSafelinks = SafelinksEnabled.IsOn; config.Pipeline.ExpandShortenedUrls = ExpandShortLinksEnabled.IsOn; + config.HandleHtmlFiles = HandleHtmlFilesEnabled.IsOn; config.LogRuleHits = LogRuleHitsEnabled.IsOn; config.Updates.AutoCheckEnabled = AutoCheckUpdatesEnabled.IsOn; config.Updates.AutoDownloadInstaller = AutoDownloadInstallerEnabled.IsOn; newAutoCheckEnabled = config.Updates.AutoCheckEnabled; + newHandleHtmlFiles = config.HandleHtmlFiles; }); if (oldAutoCheckEnabled != newAutoCheckEnabled) { AppState.Updates.NotifyScheduleChanged(); } + + if (oldHandleHtmlFiles != newHandleHtmlFiles) + { + BrowserRegistration.RegisterAll(newHandleHtmlFiles); + } } private void Slider_Changed(object sender, RangeBaseValueChangedEventArgs e) diff --git a/src/BrowserWrangler/Program.cs b/src/BrowserWrangler/Program.cs index 8b0d86b..7ad7a18 100644 --- a/src/BrowserWrangler/Program.cs +++ b/src/BrowserWrangler/Program.cs @@ -47,7 +47,7 @@ public static int Main(string[] args) // installer hooks: silent register/unregister of the browser (per-user registry) if (args.Contains("--register", StringComparer.OrdinalIgnoreCase)) { - Core.Setup.BrowserRegistration.RegisterAll(); + Core.Setup.BrowserRegistration.RegisterAll(config.HandleHtmlFiles); return 0; } @@ -61,22 +61,17 @@ public static int Main(string[] args) bool firstRun = args.Contains("--first-run", StringComparer.OrdinalIgnoreCase); if (firstRun) { - Core.Setup.BrowserRegistration.EnsureRegistered(); + Core.Setup.BrowserRegistration.EnsureRegistered(config.HandleHtmlFiles); return StartConfigInstance(LaunchMode.Welcome); } - string? url = args.FirstOrDefault(a => - a.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - a.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - a.StartsWith(Core.AppInfo.CustomProtocol + ":", StringComparison.OrdinalIgnoreCase)); - - if (url is not null) + if (LaunchTargetParser.TryGetLaunchTargetUrl(args, Core.AppInfo.CustomProtocol, config.HandleHtmlFiles, out string url)) { return HandleUrl(config, url); } // a moved or upgraded install leaves stale registry paths behind - repair them silently - Core.Setup.BrowserRegistration.EnsureRegistered(); + Core.Setup.BrowserRegistration.EnsureRegistered(config.HandleHtmlFiles); // users who never saw the post-install window get it the first time they open the app bool needsSetup = !config.SetupCompleted && diff --git a/src/BrowserWrangler/WelcomeWindow.xaml.cs b/src/BrowserWrangler/WelcomeWindow.xaml.cs index cbaf1ef..208bbe3 100644 --- a/src/BrowserWrangler/WelcomeWindow.xaml.cs +++ b/src/BrowserWrangler/WelcomeWindow.xaml.cs @@ -142,7 +142,7 @@ private static void MarkSetupCompleted() private void Register_Click(object sender, RoutedEventArgs e) { - BrowserRegistration.RegisterAll(); + BrowserRegistration.RegisterAll(AppState.Config.HandleHtmlFiles); Refresh(); } diff --git a/tests/BrowserWrangler.Core.Tests/BrowserRegistrationTests.cs b/tests/BrowserWrangler.Core.Tests/BrowserRegistrationTests.cs index 5071740..9e9aa44 100644 --- a/tests/BrowserWrangler.Core.Tests/BrowserRegistrationTests.cs +++ b/tests/BrowserWrangler.Core.Tests/BrowserRegistrationTests.cs @@ -1,9 +1,32 @@ +using BrowserWrangler.Core; using BrowserWrangler.Core.Setup; namespace BrowserWrangler.Core.Tests; public class BrowserRegistrationTests { + [Fact] + public void Url_protocol_capabilities_include_http_https_and_custom_protocol() + { + IReadOnlyList protocols = BrowserRegistration.GetRegisteredUrlProtocols(); + Assert.Contains("http", protocols); + Assert.Contains("https", protocols); + Assert.Contains(AppInfo.CustomProtocol, protocols); + } + + [Fact] + public void Html_file_capabilities_are_present_when_enabled() + { + IReadOnlyList extensions = BrowserRegistration.GetRegisteredFileExtensions(includeHtmlFileAssociations: true); + Assert.Equal(new[] { ".htm", ".html" }, extensions); + } + + [Fact] + public void Html_file_capabilities_are_empty_when_disabled() + { + Assert.Empty(BrowserRegistration.GetRegisteredFileExtensions(includeHtmlFileAssociations: false)); + } + [Fact] public void Default_apps_uri_deep_links_to_our_entry_on_windows_11() { diff --git a/tests/BrowserWrangler.Core.Tests/ConfigStoreTests.cs b/tests/BrowserWrangler.Core.Tests/ConfigStoreTests.cs index 350509a..5a05089 100644 --- a/tests/BrowserWrangler.Core.Tests/ConfigStoreTests.cs +++ b/tests/BrowserWrangler.Core.Tests/ConfigStoreTests.cs @@ -25,6 +25,7 @@ public void Load_returns_defaults_when_file_missing() Assert.True(config.Picker.OnCtrlShift); Assert.True(config.Pipeline.UnwrapSafelinks); Assert.False(config.Pipeline.ExpandShortenedUrls); + Assert.False(config.HandleHtmlFiles); Assert.True(config.Updates.AutoCheckEnabled); Assert.Equal(24, config.Updates.CheckIntervalHours); Assert.Empty(config.Browsers); @@ -53,6 +54,7 @@ public void Save_and_load_roundtrips_browsers_rules_and_settings() config.Pipeline.ExpandShortenedUrls = true; config.Pipeline.Substitute = false; config.Pipeline.Substitutions.Add("substr|http://|https://"); + config.HandleHtmlFiles = true; config.Updates.AutoCheckEnabled = false; config.Updates.CheckIntervalHours = 48; config.Updates.AutoDownloadInstaller = true; @@ -77,6 +79,7 @@ public void Save_and_load_roundtrips_browsers_rules_and_settings() Assert.True(loaded.Pipeline.ExpandShortenedUrls); Assert.False(loaded.Pipeline.Substitute); Assert.Equal("substr|http://|https://", loaded.Pipeline.Substitutions[0]); + Assert.True(loaded.HandleHtmlFiles); Assert.False(loaded.Updates.AutoCheckEnabled); Assert.Equal(48, loaded.Updates.CheckIntervalHours); Assert.True(loaded.Updates.AutoDownloadInstaller); diff --git a/tests/BrowserWrangler.Core.Tests/LaunchTargetParserTests.cs b/tests/BrowserWrangler.Core.Tests/LaunchTargetParserTests.cs new file mode 100644 index 0000000..8651f72 --- /dev/null +++ b/tests/BrowserWrangler.Core.Tests/LaunchTargetParserTests.cs @@ -0,0 +1,66 @@ +using BrowserWrangler.Core.Launching; + +namespace BrowserWrangler.Core.Tests; + +public class LaunchTargetParserTests +{ + [Theory] + [InlineData("http://example.com")] + [InlineData("https://example.com/path?q=1")] + [InlineData("x-bw:https://example.com")] + public void Url_and_custom_protocol_targets_are_always_accepted(string candidate) + { + bool ok = LaunchTargetParser.TryNormalizeLaunchTarget(candidate, "x-bw", allowHtmlFileTargets: false, out string parsed); + + Assert.True(ok); + Assert.Equal(candidate, parsed); + } + + [Fact] + public void Html_file_targets_are_rejected_when_disabled() + { + Assert.False(LaunchTargetParser.TryNormalizeLaunchTarget("file:///C:/temp/page.html", "x-bw", allowHtmlFileTargets: false, out _)); + Assert.False(LaunchTargetParser.TryNormalizeLaunchTarget(@"C:\temp\page.html", "x-bw", allowHtmlFileTargets: false, out _)); + } + + [Fact] + public void Html_file_uri_is_accepted_when_enabled() + { + bool ok = LaunchTargetParser.TryNormalizeLaunchTarget("file:///C:/temp/page.HTM", "x-bw", allowHtmlFileTargets: true, out string parsed); + + Assert.True(ok); + Assert.StartsWith("file:///", parsed, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith("/page.HTM", parsed, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Local_html_path_is_normalized_to_file_uri_when_enabled() + { + string localPath = Path.Combine(Path.GetTempPath(), "Browser Wrangler", "Page.html"); + + bool ok = LaunchTargetParser.TryNormalizeLaunchTarget(localPath, "x-bw", allowHtmlFileTargets: true, out string parsed); + + Assert.True(ok); + Assert.StartsWith("file:///", parsed, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Page.html", parsed, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Non_html_file_targets_are_rejected() + { + Assert.False(LaunchTargetParser.TryNormalizeLaunchTarget("file:///C:/temp/page.txt", "x-bw", allowHtmlFileTargets: true, out _)); + Assert.False(LaunchTargetParser.TryNormalizeLaunchTarget(@"C:\temp\page.txt", "x-bw", allowHtmlFileTargets: true, out _)); + } + + [Fact] + public void Parser_returns_first_supported_target_in_argument_list() + { + string[] args = ["--first-run", @"C:\temp\doc.html", "https://example.com"]; + + bool ok = LaunchTargetParser.TryGetLaunchTargetUrl(args, "x-bw", allowHtmlFileTargets: true, out string parsed); + + Assert.True(ok); + Assert.StartsWith("file:///", parsed, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith("/doc.html", parsed, StringComparison.OrdinalIgnoreCase); + } +}