Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/BrowserWrangler.Core/AppInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public static class AppInfo
public const bool IsDevChannel = true;
public const string Name = DevName;

/// <summary>ProgId registered for http/https URL associations.</summary>
/// <summary>ProgId registered for URL associations (and optional .htm/.html file associations).</summary>
public const string ProgId = "BrowserWranglerHTM.Dev";

/// <summary>Custom protocol for browser extensions ("x-bw:...").</summary>
Expand All @@ -22,7 +22,7 @@ public static class AppInfo
public const bool IsDevChannel = false;
public const string Name = StableName;

/// <summary>ProgId registered for http/https URL associations.</summary>
/// <summary>ProgId registered for URL associations (and optional .htm/.html file associations).</summary>
public const string ProgId = "BrowserWranglerHTM";

/// <summary>Custom protocol for browser extensions ("x-bw:...").</summary>
Expand Down
6 changes: 6 additions & 0 deletions src/BrowserWrangler.Core/Configuration/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ public sealed class AppConfig
/// <summary>Set once the user has been through (or dismissed) the first-run setup window.</summary>
public bool SetupCompleted { get; set; }

/// <summary>
/// When enabled, Browser Wrangler advertises support for .htm/.html files and routes them
/// through the same rule engine as links.
/// </summary>
public bool HandleHtmlFiles { get; set; }

public PickerSettings Picker { get; set; } = new();

public ToastSettings Toast { get; set; } = new();
Expand Down
80 changes: 80 additions & 0 deletions src/BrowserWrangler.Core/Launching/LaunchTargetParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace BrowserWrangler.Core.Launching;

/// <summary>Parses and normalizes incoming launch arguments into routable targets.</summary>
public static class LaunchTargetParser
{
public static bool TryGetLaunchTargetUrl(
IEnumerable<string> 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);
}
}
71 changes: 62 additions & 9 deletions src/BrowserWrangler.Core/Setup/BrowserRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ public static class BrowserRegistration
/// <summary>Full path of the executable to register; defaults to the current process.</summary>
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();
}

Expand All @@ -82,16 +82,21 @@ public static void UnregisterAll()
/// single registry read in the common case and never throws.
/// </summary>
/// <returns>True when a repair was performed.</returns>
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)
Expand All @@ -101,23 +106,46 @@ public static bool EnsureRegistered()
}
}

/// <summary>Registers under StartMenuInternet with http/https capabilities.</summary>
public static void RegisterBrowser()
public static IReadOnlyList<string> GetRegisteredUrlProtocols() =>
[
"https",
"http",
AppInfo.CustomProtocol,
];

public static IReadOnlyList<string> GetRegisteredFileExtensions(bool includeHtmlFileAssociations) =>
includeHtmlFileAssociations
?
[
".htm",
".html",
]
: [];

/// <summary>Registers under StartMenuInternet with URL capabilities and optional HTML file associations.</summary>
public static void RegisterBrowser(bool includeHtmlFileAssociations = false)
{
string appPath = ExecutablePath;
string capRoot = BrowserRegPath + @"\Capabilities";
string fileAssociationsPath = capRoot + @"\FileAssociations";

SetValue(BrowserRegPath, null, AppInfo.Name);

SetValue(capRoot, "ApplicationName", AppInfo.Name);
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}\"");

Expand Down Expand Up @@ -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<string> 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;
}

/// <summary>True when http/https default handlers point at our ProgId.</summary>
public static bool IsDefaultBrowser(out bool http, out bool https)
{
Expand Down Expand Up @@ -209,7 +262,7 @@ public static List<SystemCheck> GetChecks() =>
bool ok = IsRegisteredAsBrowser(out string error);
return (ok, error);
},
RegisterAll),
() => RegisterAll()),
new SystemCheck(
"proto_http",
"HTTP Protocol Handler",
Expand Down
2 changes: 1 addition & 1 deletion src/BrowserWrangler/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
3 changes: 2 additions & 1 deletion src/BrowserWrangler/Pages/HealthPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using BrowserWrangler.Core.Setup;
using BrowserWrangler.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

Expand Down Expand Up @@ -72,7 +73,7 @@ private void Recheck()

private void Register_Click(object sender, RoutedEventArgs e)
{
BrowserRegistration.RegisterAll();
BrowserRegistration.RegisterAll(AppState.Config.HandleHtmlFiles);
Recheck();
}

Expand Down
11 changes: 11 additions & 0 deletions src/BrowserWrangler/Pages/SettingsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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." />

<TextBlock Margin="0,16,0,0" Style="{StaticResource SubtitleTextBlockStyle}" Text="File routing" />
<ToggleSwitch
x:Name="HandleHtmlFilesEnabled"
Header="Route local .htm/.html files"
Toggled="Setting_Changed" />
<TextBlock
FontSize="12"
Opacity="0.7"
TextWrapping="Wrap"
Text="When enabled, Browser Wrangler advertises support for .htm/.html in Windows so those files can be routed by your rules." />

<TextBlock Margin="0,16,0,0" Style="{StaticResource SubtitleTextBlockStyle}" Text="Toast" />
<ToggleSwitch x:Name="ToastEnabled" Header="Show toast when a URL is routed" Toggled="Setting_Changed" />
<Slider
Expand Down
12 changes: 12 additions & 0 deletions src/BrowserWrangler/Pages/SettingsPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using BrowserWrangler.Core.Configuration;
using BrowserWrangler.Core.Setup;
using BrowserWrangler.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
Expand Down Expand Up @@ -33,6 +34,7 @@ public SettingsPage()
ToastDuration.Value = AppState.Config.Toast.VisibleSeconds;
SafelinksEnabled.IsOn = AppState.Config.Pipeline.UnwrapSafelinks;
ExpandShortLinksEnabled.IsOn = AppState.Config.Pipeline.ExpandShortenedUrls;
HandleHtmlFilesEnabled.IsOn = AppState.Config.HandleHtmlFiles;
LogRuleHitsEnabled.IsOn = AppState.Config.LogRuleHits;
AutoCheckUpdatesEnabled.IsOn = AppState.Config.Updates.AutoCheckEnabled;
UpdateIntervalHours.Value = AppState.Config.Updates.CheckIntervalHours;
Expand Down Expand Up @@ -66,9 +68,12 @@ private void Setting_Changed(object sender, RoutedEventArgs e)

bool oldAutoCheckEnabled = false;
bool newAutoCheckEnabled = false;
bool oldHandleHtmlFiles = false;
bool newHandleHtmlFiles = false;
AppState.MutateAndSave(config =>
{
oldAutoCheckEnabled = config.Updates.AutoCheckEnabled;
oldHandleHtmlFiles = config.HandleHtmlFiles;
PickerSettings p = config.Picker;
p.OnCtrlShift = PickerCtrlShift.IsChecked == true;
p.OnCtrlAlt = PickerCtrlAlt.IsChecked == true;
Expand All @@ -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)
Expand Down
13 changes: 4 additions & 9 deletions src/BrowserWrangler/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 &&
Expand Down
2 changes: 1 addition & 1 deletion src/BrowserWrangler/WelcomeWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ private static void MarkSetupCompleted()

private void Register_Click(object sender, RoutedEventArgs e)
{
BrowserRegistration.RegisterAll();
BrowserRegistration.RegisterAll(AppState.Config.HandleHtmlFiles);
Refresh();
}

Expand Down
Loading