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);
+ }
+}