From 6b5f5bfdd96e8d292fa8f72e1aabf4223c69ff71 Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 21:47:27 +0200 Subject: [PATCH 1/8] Add local URL security hints and picker polish Derives picker URL presentation locally for scheme, registrable domain, IDN/punycode, and Windows file path hints without contacting the target host. Adds safer remember-default choices, prevents hostname defaults from matching file URLs, tightens picker row/resize behavior, and adds xUnit/CI coverage for the URL parsing rules. Made-with: Cursor --- .github/workflows/ci.yml | 14 + .github/workflows/pr-validation.yml | 14 + BrowserPicker.sln | 47 ++ src/BrowserPicker.Common/DefaultSetting.cs | 4 +- src/BrowserPicker.Common/UrlHandler.cs | 44 +- .../UrlSecurityPresentation.cs | 548 ++++++++++++++++++ src/BrowserPicker.UI/UrlTextBlockSegments.cs | 96 +++ .../ViewModels/BrowserViewModel.cs | 8 +- .../ViewModels/ConfigurationViewModel.cs | 30 +- src/BrowserPicker.UI/Views/BrowserList.xaml | 92 ++- .../Views/BrowserList.xaml.cs | 22 - src/BrowserPicker.UI/Views/MainWindow.xaml | 4 +- src/BrowserPicker.UI/Views/MainWindow.xaml.cs | 64 +- .../BrowserPicker.Common.Tests.csproj | 26 + tests/BrowserPicker.Common.Tests/UnitTest1.cs | 127 ++++ 15 files changed, 1076 insertions(+), 64 deletions(-) create mode 100644 src/BrowserPicker.Common/UrlSecurityPresentation.cs create mode 100644 src/BrowserPicker.UI/UrlTextBlockSegments.cs create mode 100644 tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj create mode 100644 tests/BrowserPicker.Common.Tests/UnitTest1.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6a06bf..253d92a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,3 +14,17 @@ jobs: uses: mortenn/BrowserPicker.Actions/.github/workflows/ci.yml@acb9b119377245e0bb213408e8da15f7b0a678b5 with: dotnet_version: 10.x + + tests: + name: Tests + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Run unit tests + run: dotnet test tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj -p:Version=1.0.0 -p:Platform=x64 --verbosity normal diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index daf2def..b62ab50 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -14,3 +14,17 @@ jobs: uses: mortenn/BrowserPicker.Actions/.github/workflows/pr-validation.yml@acb9b119377245e0bb213408e8da15f7b0a678b5 with: dotnet_version: 10.x + + tests: + name: Tests + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Run unit tests + run: dotnet test tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj -p:Version=1.0.0 -p:Platform=x64 --verbosity normal diff --git a/BrowserPicker.sln b/BrowserPicker.sln index a2316ae..88a936f 100644 --- a/BrowserPicker.sln +++ b/BrowserPicker.sln @@ -41,14 +41,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{4F9A9F0E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BrowserPicker.SchemaGen", "tools\BrowserPicker.SchemaGen\BrowserPicker.SchemaGen.csproj", "{9D34ABBC-3A7D-BA41-F4CD-EED86E736900}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BrowserPicker.Common.Tests", "tests\BrowserPicker.Common.Tests\BrowserPicker.Common.Tests.csproj", "{74803C00-9215-44D9-B3DC-FD1BFB45B020}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 + Debug|Any CPU = Debug|Any CPU Release|ARM64 = Release|ARM64 Release|x64 = Release|x64 Release|x86 = Release|x86 + Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|ARM64.ActiveCfg = Debug|x64 @@ -57,61 +63,101 @@ Global {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x64.Build.0 = Debug|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x86.ActiveCfg = Debug|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|x86.Build.0 = Debug|x64 + {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7695535-9C0D-4983-B8F7-09B067347E7E}.Debug|Any CPU.Build.0 = Debug|Any CPU {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|ARM64.ActiveCfg = Release|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|ARM64.Build.0 = Release|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x64.ActiveCfg = Release|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x64.Build.0 = Release|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x86.ActiveCfg = Release|x64 {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|x86.Build.0 = Release|x64 + {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7695535-9C0D-4983-B8F7-09B067347E7E}.Release|Any CPU.Build.0 = Release|Any CPU {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|ARM64.ActiveCfg = Debug|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|ARM64.Build.0 = Debug|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x64.ActiveCfg = Debug|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x64.Build.0 = Debug|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x86.ActiveCfg = Debug|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|x86.Build.0 = Debug|x64 + {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Debug|Any CPU.Build.0 = Debug|Any CPU {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|ARM64.ActiveCfg = Release|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|ARM64.Build.0 = Release|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x64.ActiveCfg = Release|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x64.Build.0 = Release|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x86.ActiveCfg = Release|x64 {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|x86.Build.0 = Release|x64 + {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B875AE86-5212-4F7F-BB1C-2BAA1FC110BC}.Release|Any CPU.Build.0 = Release|Any CPU {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|ARM64.ActiveCfg = Debug|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|ARM64.Build.0 = Debug|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x64.ActiveCfg = Debug|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x64.Build.0 = Debug|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x86.ActiveCfg = Debug|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|x86.Build.0 = Debug|x64 + {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|ARM64.ActiveCfg = Release|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|ARM64.Build.0 = Release|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x64.ActiveCfg = Release|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x64.Build.0 = Release|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x86.ActiveCfg = Release|x64 {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|x86.Build.0 = Release|x64 + {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3CE17EA-BAAE-4DE7-AFB2-319D50ECB2C8}.Release|Any CPU.Build.0 = Release|Any CPU {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|ARM64.ActiveCfg = Debug|ARM64 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|x64.ActiveCfg = Debug|x64 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|x86.ActiveCfg = Debug|x86 + {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|ARM64.ActiveCfg = Release|ARM64 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|ARM64.Build.0 = Release|ARM64 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x64.ActiveCfg = Release|x64 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x86.ActiveCfg = Release|x86 {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|x86.Build.0 = Release|x86 + {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9D9359C-6DA4-463E-86B9-505E04E01C3A}.Release|Any CPU.Build.0 = Release|Any CPU {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|ARM64.ActiveCfg = Debug|ARM64 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|x64.ActiveCfg = Debug|x64 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|x86.ActiveCfg = Debug|x86 + {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Debug|Any CPU.Build.0 = Debug|Any CPU {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|ARM64.ActiveCfg = Release|ARM64 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|ARM64.Build.0 = Release|ARM64 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x64.ActiveCfg = Release|x64 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x86.ActiveCfg = Release|x86 {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|x86.Build.0 = Release|x86 + {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA}.Release|Any CPU.Build.0 = Release|Any CPU {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Debug|ARM64.ActiveCfg = Debug|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Debug|x64.ActiveCfg = Debug|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Debug|x86.ActiveCfg = Debug|x64 + {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Debug|Any CPU.Build.0 = Debug|Any CPU {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|ARM64.ActiveCfg = Release|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|ARM64.Build.0 = Release|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|x64.ActiveCfg = Release|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|x64.Build.0 = Release|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|x86.ActiveCfg = Release|x64 {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|x86.Build.0 = Release|x64 + {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D34ABBC-3A7D-BA41-F4CD-EED86E736900}.Release|Any CPU.Build.0 = Release|Any CPU + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|ARM64.ActiveCfg = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|ARM64.Build.0 = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|x64.ActiveCfg = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|x64.Build.0 = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|x86.ActiveCfg = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|x86.Build.0 = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|Any CPU.ActiveCfg = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Debug|Any CPU.Build.0 = Debug|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|ARM64.ActiveCfg = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|ARM64.Build.0 = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|x64.ActiveCfg = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|x64.Build.0 = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|x86.ActiveCfg = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|x86.Build.0 = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|Any CPU.ActiveCfg = Release|x64 + {74803C00-9215-44D9-B3DC-FD1BFB45B020}.Release|Any CPU.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -123,6 +169,7 @@ Global {F9D9359C-6DA4-463E-86B9-505E04E01C3A} = {CD1CE02F-9EBF-48E0-81E4-E047F20F10C8} {FADE3CC5-5631-4BF0-A92B-A3464BA5A5EA} = {CD1CE02F-9EBF-48E0-81E4-E047F20F10C8} {9D34ABBC-3A7D-BA41-F4CD-EED86E736900} = {4F9A9F0E-98B5-459D-BFD1-9CFEAFE5D116} + {74803C00-9215-44D9-B3DC-FD1BFB45B020} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FA02F9A8-CC4F-4C63-A345-418FC0D10D32} diff --git a/src/BrowserPicker.Common/DefaultSetting.cs b/src/BrowserPicker.Common/DefaultSetting.cs index 7d35237..f409153 100644 --- a/src/BrowserPicker.Common/DefaultSetting.cs +++ b/src/BrowserPicker.Common/DefaultSetting.cs @@ -178,7 +178,9 @@ public int MatchLength(Uri url) return Type switch { MatchType.Default => 1, - MatchType.Hostname when pattern is not null => url.Host.EndsWith(pattern) ? pattern.Length : 0, + MatchType.Hostname when pattern is not null && !url.IsFile => url.Host.EndsWith(pattern) + ? pattern.Length + : 0, MatchType.Prefix when pattern is not null => url.OriginalString.StartsWith(pattern) ? pattern.Length : 0, MatchType.Regex when pattern is not null => Regex.Match(url.OriginalString, pattern).Length, MatchType.Contains when pattern is not null => url.OriginalString.Contains(pattern) ? pattern.Length : 0, diff --git a/src/BrowserPicker.Common/UrlHandler.cs b/src/BrowserPicker.Common/UrlHandler.cs index 78696eb..603edb4 100644 --- a/src/BrowserPicker.Common/UrlHandler.cs +++ b/src/BrowserPicker.Common/UrlHandler.cs @@ -60,7 +60,7 @@ public UrlHandler(ILogger logger, string? requestedUrl, IApplication try { uri = new Uri(requestedUrl); - host_name = uri.Host; + host_name = uri.IsFile ? null : uri.Host; } catch { @@ -99,7 +99,7 @@ public async Task Start(CancellationToken cancellationToken) { return; } - HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host; + HostName = uri.IsFile ? null : uri.Host; while (true) { var jump = ResolveJumpPage(uri); @@ -108,7 +108,7 @@ public async Task Start(CancellationToken cancellationToken) logger.LogJumpUrl(uri); UnderlyingTargetURL = jump; uri = new Uri(jump); - HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host; + HostName = uri.IsFile ? null : uri.Host; continue; } @@ -119,7 +119,7 @@ public async Task Start(CancellationToken cancellationToken) IsShortenedURL = true; UnderlyingTargetURL = shortened; uri = new Uri(shortened); - HostName = uri.IsFile && !uri.IsUnc ? null : uri.Host; + HostName = uri.IsFile ? null : uri.Host; continue; } @@ -462,6 +462,9 @@ public string? UnderlyingTargetURL if (SetProperty(ref underlying_target_url, value)) { OnPropertyChanged(nameof(DisplayURL)); + OnPropertyChanged(nameof(SecurityPresentation)); + OnPropertyChanged(nameof(RegistrableDomain)); + OnPropertyChanged(nameof(CanRememberRegistrableDomain)); } } } @@ -476,14 +479,38 @@ public bool IsShortenedURL } /// - /// Host name of the (possibly resolved) URL; null for file URLs when not UNC. + /// Host name of the (possibly resolved) URL; null for file URLs. /// public string? HostName { get => host_name; - set => SetProperty(ref host_name, value); + set + { + if (SetProperty(ref host_name, value)) + { + OnPropertyChanged(nameof(CanRememberChoice)); + OnPropertyChanged(nameof(CanRememberRegistrableDomain)); + } + } } + /// + /// True when the current URL has a host that can be stored as a hostname default. + /// + public bool CanRememberChoice => !string.IsNullOrWhiteSpace(HostName); + + /// + /// Registrable domain derived from the current display URL, if it can be classified locally. + /// + public string? RegistrableDomain => SecurityPresentation.RegistrableDomain; + + /// + /// True when the registrable domain is a useful broader default than the full host. + /// + public bool CanRememberRegistrableDomain => + !string.IsNullOrWhiteSpace(RegistrableDomain) + && !string.Equals(RegistrableDomain, HostName, StringComparison.OrdinalIgnoreCase); + /// /// Favicon image bytes loaded from the target page, if available. /// @@ -507,6 +534,11 @@ public bool FavIconProbed /// public string? DisplayURL => UnderlyingTargetURL ?? TargetURL; + /// + /// Local-only URL presentation hints derived from . + /// + public UrlSecurityPresentation SecurityPresentation => UrlSecurityPresentation.FromDisplayUrl(DisplayURL); + /// /// Default list of URL shortener host names used to resolve redirects. /// diff --git a/src/BrowserPicker.Common/UrlSecurityPresentation.cs b/src/BrowserPicker.Common/UrlSecurityPresentation.cs new file mode 100644 index 0000000..43a5721 --- /dev/null +++ b/src/BrowserPicker.Common/UrlSecurityPresentation.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; + +namespace BrowserPicker.Common; + +public enum UrlSecuritySchemeState +{ + Secure, + Insecure, + Neutral, +} + +public enum UrlDisplaySegmentKind +{ + Other, + Scheme, + Host, + RegistrableDomain, + NonAsciiHost, + FileRoot, + FileName, + Path, + Query, + Fragment, +} + +public sealed record UrlDisplaySegment(string Text, UrlDisplaySegmentKind Kind); + +public sealed record UrlSecurityPresentation( + IReadOnlyList Segments, + string SchemeLabel, + UrlSecuritySchemeState SchemeState, + string SchemeToolTip, + string ToolTip, + string? RegistrableDomain +) +{ + private static readonly IdnMapping Idn = new(); + + private static readonly string[] KnownMultiPartSuffixes = + [ + "co.uk", + "org.uk", + "ac.uk", + "gov.uk", + "com.au", + "net.au", + "org.au", + "co.nz", + "com.br", + "com.tr", + "co.jp", + ]; + + public static UrlSecurityPresentation FromDisplayUrl(string? displayUrl) + { + if (string.IsNullOrWhiteSpace(displayUrl)) + { + return new( + Array.Empty(), + string.Empty, + UrlSecuritySchemeState.Neutral, + "No URL to inspect.", + string.Empty, + null + ); + } + + if (!Uri.TryCreate(displayUrl, UriKind.Absolute, out var uri)) + { + return new( + [new UrlDisplaySegment(displayUrl, UrlDisplaySegmentKind.Other)], + "URL", + UrlSecuritySchemeState.Neutral, + "This text is not an absolute URL.", + displayUrl, + null + ); + } + + var schemeState = GetSchemeState(uri); + var schemeLabel = uri.Scheme.ToUpperInvariant(); + if (uri.IsFile) + { + var filePath = GetCanonicalFilePath(uri); + return new( + BuildFileSegments(filePath), + schemeLabel, + schemeState, + BuildSchemeToolTip(uri, schemeState), + BuildFileToolTip(displayUrl, filePath), + null + ); + } + + var segments = BuildSegments( + displayUrl, + uri, + out var registrableDomain, + out var unicodeHost, + out var asciiHost + ); + var schemeToolTip = BuildSchemeToolTip(uri, schemeState); + var toolTip = BuildToolTip(displayUrl, schemeToolTip, registrableDomain, unicodeHost, asciiHost); + + return new(segments, schemeLabel, schemeState, schemeToolTip, toolTip, registrableDomain); + } + + private static string GetCanonicalFilePath(Uri uri) + { + var path = uri.LocalPath.Replace('/', '\\'); + if (path.Length >= 2 && path[1] == ':') + { + path = char.ToUpperInvariant(path[0]) + path[1..]; + } + + return path; + } + + private static IReadOnlyList BuildFileSegments(string filePath) + { + if (filePath.Length >= 2 && filePath[1] == ':') + { + return BuildRootedFileSegments(filePath[..2], filePath[2..]); + } + + if (filePath.StartsWith(@"\\", StringComparison.Ordinal)) + { + var serverEnd = filePath.IndexOf('\\', 2); + if (serverEnd > 2) + { + return BuildRootedFileSegments(filePath[..serverEnd], filePath[serverEnd..]); + } + } + + return BuildPathSegments(filePath); + } + + private static IReadOnlyList BuildRootedFileSegments(string root, string remainingPath) + { + var segments = new List { new(root, UrlDisplaySegmentKind.FileRoot) }; + segments.AddRange(BuildPathSegments(remainingPath)); + return segments; + } + + private static IReadOnlyList BuildPathSegments(string path) + { + if (string.IsNullOrEmpty(path)) + { + return []; + } + + var fileNameStart = path.LastIndexOf('\\') + 1; + if (fileNameStart <= 0 || fileNameStart >= path.Length) + { + return [new UrlDisplaySegment(path, UrlDisplaySegmentKind.Path)]; + } + + return + [ + new UrlDisplaySegment(path[..fileNameStart], UrlDisplaySegmentKind.Path), + new UrlDisplaySegment(path[fileNameStart..], UrlDisplaySegmentKind.FileName), + ]; + } + + private static string BuildFileToolTip(string displayUrl, string filePath) + { + return string.Join( + Environment.NewLine, + filePath, + $"Original URL: {displayUrl}", + "Local file path: no host is contacted and certificates are not checked." + ); + } + + private static UrlSecuritySchemeState GetSchemeState(Uri uri) + { + return uri.Scheme switch + { + "https" => UrlSecuritySchemeState.Secure, + "http" => UrlSecuritySchemeState.Insecure, + _ => UrlSecuritySchemeState.Neutral, + }; + } + + private static string BuildSchemeToolTip(Uri uri, UrlSecuritySchemeState schemeState) + { + var state = schemeState switch + { + UrlSecuritySchemeState.Secure => "HTTPS URL. This is a local scheme hint; TLS is not checked.", + UrlSecuritySchemeState.Insecure => "HTTP URL. This is a local scheme hint; no connection is made.", + _ => $"{uri.Scheme.ToUpperInvariant()} URL. This is a local scheme hint.", + }; + + return state; + } + + private static string BuildToolTip( + string displayUrl, + string schemeToolTip, + string? registrableDomain, + string? unicodeHost, + string? asciiHost + ) + { + var lines = new List { displayUrl, schemeToolTip }; + + if (!string.IsNullOrWhiteSpace(unicodeHost)) + { + lines.Add($"Host: {unicodeHost}"); + } + + if (!string.IsNullOrWhiteSpace(registrableDomain)) + { + lines.Add($"Highlighted domain: {registrableDomain}"); + } + + if ( + !string.IsNullOrWhiteSpace(unicodeHost) + && !string.IsNullOrWhiteSpace(asciiHost) + && !string.Equals(unicodeHost, asciiHost, StringComparison.OrdinalIgnoreCase) + ) + { + lines.Add($"IDN: {unicodeHost} (ASCII: {asciiHost})"); + } + + lines.Add("Local only: no host is contacted and certificates are not checked."); + return string.Join(Environment.NewLine, lines); + } + + private static IReadOnlyList BuildSegments( + string displayUrl, + Uri uri, + out string? registrableDomain, + out string? unicodeHost, + out string? asciiHost + ) + { + registrableDomain = null; + unicodeHost = null; + asciiHost = null; + + if (!TryGetHostRange(displayUrl, out var hostStart, out var hostLength)) + { + return BuildSchemeOnlySegments(displayUrl); + } + + var segments = new List(); + AddIfNotEmpty(segments, displayUrl[..hostStart], UrlDisplaySegmentKind.Scheme); + + var hostText = displayUrl.Substring(hostStart, hostLength); + asciiHost = GetAsciiHost(uri, hostText); + unicodeHost = GetUnicodeHost(asciiHost); + var hostDisplayText = + !string.IsNullOrWhiteSpace(unicodeHost) + && !string.Equals(unicodeHost, asciiHost, StringComparison.OrdinalIgnoreCase) + ? unicodeHost + : hostText; + AddHostSegments(segments, hostDisplayText, asciiHost, out registrableDomain); + + AddTrailingSegments(segments, displayUrl[(hostStart + hostLength)..]); + + return segments; + } + + private static IReadOnlyList BuildSchemeOnlySegments(string displayUrl) + { + var schemeEnd = displayUrl.IndexOf(':'); + if (schemeEnd < 0) + { + return [new UrlDisplaySegment(displayUrl, UrlDisplaySegmentKind.Other)]; + } + + var segments = new List(); + var pathStart = schemeEnd + 1; + if (displayUrl[pathStart..].StartsWith("//", StringComparison.Ordinal)) + { + pathStart += 2; + } + + AddIfNotEmpty(segments, displayUrl[..pathStart], UrlDisplaySegmentKind.Scheme); + AddTrailingSegments(segments, displayUrl[pathStart..]); + return segments; + } + + private static bool TryGetHostRange(string displayUrl, out int hostStart, out int hostLength) + { + hostStart = 0; + hostLength = 0; + + var schemeSeparator = displayUrl.IndexOf(':'); + if (schemeSeparator < 0 || schemeSeparator + 2 >= displayUrl.Length) + { + return false; + } + + var authorityStart = schemeSeparator + 1; + if (!displayUrl[authorityStart..].StartsWith("//", StringComparison.Ordinal)) + { + return false; + } + + authorityStart += 2; + var authorityEnd = displayUrl.IndexOfAny(['/', '?', '#'], authorityStart); + if (authorityEnd < 0) + { + authorityEnd = displayUrl.Length; + } + + var authority = displayUrl[authorityStart..authorityEnd]; + var userInfoEnd = authority.LastIndexOf('@'); + hostStart = authorityStart + userInfoEnd + 1; + if (hostStart >= authorityEnd) + { + return false; + } + + if (displayUrl[hostStart] == '[') + { + var bracketEnd = displayUrl.IndexOf(']', hostStart + 1); + if (bracketEnd < 0 || bracketEnd >= authorityEnd) + { + return false; + } + + hostLength = bracketEnd - hostStart + 1; + return true; + } + + var hostEnd = displayUrl.IndexOf(':', hostStart, authorityEnd - hostStart); + if (hostEnd < 0) + { + hostEnd = authorityEnd; + } + + hostLength = hostEnd - hostStart; + return hostLength > 0; + } + + private static string? GetAsciiHost(Uri uri, string hostText) + { + var host = TrimHost(hostText); + if (string.IsNullOrWhiteSpace(host)) + { + return null; + } + + if (IPAddress.TryParse(host, out _)) + { + return host; + } + + try + { + return Idn.GetAscii(host).TrimEnd('.').ToLowerInvariant(); + } + catch + { + return string.IsNullOrWhiteSpace(uri.IdnHost) ? host : uri.IdnHost.TrimEnd('.').ToLowerInvariant(); + } + } + + private static string? GetUnicodeHost(string? asciiHost) + { + if (string.IsNullOrWhiteSpace(asciiHost) || IPAddress.TryParse(asciiHost, out _)) + { + return asciiHost; + } + + try + { + return Idn.GetUnicode(asciiHost).TrimEnd('.'); + } + catch + { + return asciiHost; + } + } + + private static void AddHostSegments( + List segments, + string hostText, + string? asciiHost, + out string? registrableDomain + ) + { + registrableDomain = null; + var displayHost = TrimHost(hostText); + if (string.IsNullOrWhiteSpace(displayHost) || string.IsNullOrWhiteSpace(asciiHost)) + { + AddIfNotEmpty(segments, hostText, UrlDisplaySegmentKind.Host); + return; + } + + var labels = displayHost.TrimEnd('.').Split('.'); + var registrableLabelCount = GetRegistrableLabelCount(asciiHost); + if (registrableLabelCount is null || labels.Length < registrableLabelCount) + { + AddIfNotEmpty( + segments, + hostText, + ContainsNonAscii(hostText) ? UrlDisplaySegmentKind.NonAsciiHost : UrlDisplaySegmentKind.Host + ); + return; + } + + var registrableStart = labels.Length - registrableLabelCount.Value; + registrableDomain = GetRegistrableDomain(asciiHost, registrableLabelCount.Value); + + for (var i = 0; i < labels.Length; i++) + { + if (i > 0) + { + segments.Add( + new UrlDisplaySegment( + ".", + i >= registrableStart ? UrlDisplaySegmentKind.RegistrableDomain : UrlDisplaySegmentKind.Host + ) + ); + } + + var kind = i >= registrableStart ? UrlDisplaySegmentKind.RegistrableDomain : UrlDisplaySegmentKind.Host; + AddHostLabelSegments(segments, labels[i], kind); + } + + if (hostText.EndsWith(".", StringComparison.Ordinal)) + { + segments.Add(new UrlDisplaySegment(".", UrlDisplaySegmentKind.Host)); + } + } + + private static int? GetRegistrableLabelCount(string asciiHost) + { + var normalized = asciiHost.TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalized) || IPAddress.TryParse(normalized, out _)) + { + return null; + } + + var labels = normalized.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (labels.Length < 2) + { + return null; + } + + foreach (var suffix in KnownMultiPartSuffixes) + { + if ( + normalized.EndsWith($".{suffix}", StringComparison.OrdinalIgnoreCase) + && labels.Length > suffix.Count(c => c == '.') + 1 + ) + { + return suffix.Count(c => c == '.') + 2; + } + } + + return labels[^1].All(c => c is >= 'a' and <= 'z') ? 2 : null; + } + + private static string GetRegistrableDomain(string asciiHost, int registrableLabelCount) + { + var labels = asciiHost.TrimEnd('.').Split('.', StringSplitOptions.RemoveEmptyEntries); + return string.Join('.', labels.Skip(labels.Length - registrableLabelCount)); + } + + private static string TrimHost(string hostText) + { + var host = hostText; + if (host.StartsWith("[", StringComparison.Ordinal) && host.EndsWith("]", StringComparison.Ordinal)) + { + host = host[1..^1]; + } + + return host.TrimEnd('.'); + } + + private static void AddTrailingSegments(List segments, string text) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + var queryStart = text.IndexOf('?'); + var fragmentStart = text.IndexOf('#'); + var pathEnd = text.Length; + if (queryStart >= 0) + { + pathEnd = queryStart; + } + else if (fragmentStart >= 0) + { + pathEnd = fragmentStart; + } + + AddIfNotEmpty(segments, text[..pathEnd], UrlDisplaySegmentKind.Path); + + if (queryStart >= 0) + { + var queryEnd = fragmentStart >= 0 && fragmentStart > queryStart ? fragmentStart : text.Length; + AddIfNotEmpty(segments, text[queryStart..queryEnd], UrlDisplaySegmentKind.Query); + } + + if (fragmentStart >= 0) + { + AddIfNotEmpty(segments, text[fragmentStart..], UrlDisplaySegmentKind.Fragment); + } + } + + private static bool ContainsNonAscii(string text) + { + return text.Any(c => c > 0x7f); + } + + private static void AddHostLabelSegments(List segments, string label, UrlDisplaySegmentKind kind) + { + if (string.IsNullOrEmpty(label)) + { + return; + } + + var segmentStart = 0; + for (var i = 0; i < label.Length; i++) + { + if (label[i] <= 0x7f) + { + continue; + } + + AddIfNotEmpty(segments, label[segmentStart..i], kind); + segments.Add(new UrlDisplaySegment(label[i].ToString(), UrlDisplaySegmentKind.NonAsciiHost)); + segmentStart = i + 1; + } + + AddIfNotEmpty(segments, label[segmentStart..], kind); + } + + private static void AddIfNotEmpty(List segments, string text, UrlDisplaySegmentKind kind) + { + if (!string.IsNullOrEmpty(text)) + { + segments.Add(new UrlDisplaySegment(text, kind)); + } + } +} diff --git a/src/BrowserPicker.UI/UrlTextBlockSegments.cs b/src/BrowserPicker.UI/UrlTextBlockSegments.cs new file mode 100644 index 0000000..8b474ca --- /dev/null +++ b/src/BrowserPicker.UI/UrlTextBlockSegments.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using BrowserPicker.Common; + +namespace BrowserPicker.UI; + +public static class UrlTextBlockSegments +{ + public static readonly DependencyProperty SegmentsProperty = DependencyProperty.RegisterAttached( + "Segments", + typeof(IEnumerable), + typeof(UrlTextBlockSegments), + new PropertyMetadata(null, OnSegmentsChanged) + ); + + // ReSharper disable once UnusedMember.Global + public static void SetSegments(DependencyObject element, IEnumerable? value) => + element.SetValue(SegmentsProperty, value); + + // ReSharper disable once UnusedMember.Global + public static IEnumerable? GetSegments(DependencyObject element) => + (IEnumerable?)element.GetValue(SegmentsProperty); + + private static void OnSegmentsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBlock textBlock) + { + return; + } + + textBlock.Inlines.Clear(); + if (e.NewValue is not IEnumerable segments) + { + return; + } + + foreach (var segment in segments) + { + var run = new Run(segment.Text); + ApplyStyle(textBlock, run, segment); + textBlock.Inlines.Add(run); + } + } + + private static void ApplyStyle(TextBlock textBlock, Run run, UrlDisplaySegment segment) + { + switch (segment.Kind) + { + case UrlDisplaySegmentKind.Scheme: + run.Foreground = GetSchemeBrush(segment.Text); + break; + case UrlDisplaySegmentKind.RegistrableDomain: + case UrlDisplaySegmentKind.FileRoot: + case UrlDisplaySegmentKind.FileName: + run.Foreground = GetUrlBarBrush(textBlock); + run.FontWeight = FontWeights.SemiBold; + break; + case UrlDisplaySegmentKind.NonAsciiHost: + run.Foreground = new SolidColorBrush(Color.FromRgb(0x8A, 0x4B, 0x00)); + run.FontWeight = FontWeights.SemiBold; + run.TextDecorations = TextDecorations.Underline; + break; + case UrlDisplaySegmentKind.Path: + case UrlDisplaySegmentKind.Query: + case UrlDisplaySegmentKind.Fragment: + run.Foreground = new SolidColorBrush(Color.FromRgb(0x5C, 0x62, 0x68)); + break; + default: + run.Foreground = GetUrlBarBrush(textBlock); + break; + } + } + + private static Brush GetSchemeBrush(string text) + { + if (text.StartsWith("https:", System.StringComparison.OrdinalIgnoreCase)) + { + return new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); + } + + if (text.StartsWith("http:", System.StringComparison.OrdinalIgnoreCase)) + { + return new SolidColorBrush(Color.FromRgb(0xB0, 0x00, 0x20)); + } + + return new SolidColorBrush(Color.FromRgb(0x5C, 0x62, 0x68)); + } + + private static Brush GetUrlBarBrush(TextBlock textBlock) + { + return textBlock.TryFindResource(App.UrlBarForegroundBrushKey) as Brush ?? textBlock.Foreground; + } +} diff --git a/src/BrowserPicker.UI/ViewModels/BrowserViewModel.cs b/src/BrowserPicker.UI/ViewModels/BrowserViewModel.cs index bad8887..106fc0b 100644 --- a/src/BrowserPicker.UI/ViewModels/BrowserViewModel.cs +++ b/src/BrowserPicker.UI/ViewModels/BrowserViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; @@ -518,7 +518,11 @@ private void Launch(bool privacy, BrowserProfile? profile = null) } } - parent_view_model.Configuration.UrlOpened(parent_view_model.Url.HostName, Model.Id); + parent_view_model.Configuration.UrlOpened( + parent_view_model.Url.HostName, + parent_view_model.Url.RegistrableDomain, + Model.Id + ); var profileArgs = profile?.CommandArgs ?? string.Empty; var newArgs = privacy ? Model.PrivacyArgs : string.Empty; diff --git a/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs b/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs index ce121bf..70d6a65 100644 --- a/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs +++ b/src/BrowserPicker.UI/ViewModels/ConfigurationViewModel.cs @@ -335,6 +335,16 @@ public bool AutoAddDefault set => SetProperty(ref auto_add_default, value); } + /// + /// Gets or sets a value indicating whether defaults should be automatically added + /// for the registrable domain of the opened URL. + /// + public bool AutoAddRegistrableDefault + { + get => auto_add_registrable_default; + set => SetProperty(ref auto_add_registrable_default, value); + } + /// /// Gets or sets whether the picker should close automatically when it loses focus. /// JSON-backed settings persist this; legacy registry-backed migration defaults to being enabled. @@ -707,17 +717,30 @@ private void Editor_Closing(object? sender, CancelEventArgs e) /// Called when a URL was opened with a browser; may add a hostname default if is set. /// /// Host name of the opened URL. + /// Registrable domain of the opened URL, when it differs from the full host. /// The browser id of the browser that was used. - internal void UrlOpened(string? hostName, string browserId) + internal void UrlOpened(string? hostName, string? registrableDomain, string browserId) { - if (!AutoAddDefault || hostName == null) + if (!AutoAddDefault && !AutoAddRegistrableDefault) { return; } try { - AddNewDefault(MatchType.Hostname, hostName, browserId); + if (AutoAddDefault && !string.IsNullOrWhiteSpace(hostName)) + { + AddNewDefault(MatchType.Hostname, hostName, browserId); + } + + if ( + AutoAddRegistrableDefault + && !string.IsNullOrWhiteSpace(registrableDomain) + && !string.Equals(registrableDomain, hostName, StringComparison.OrdinalIgnoreCase) + ) + { + AddNewDefault(MatchType.Hostname, registrableDomain, browserId); + } } catch { @@ -988,6 +1011,7 @@ private void FindBrowsers() private string new_fragment_browser = string.Empty; private string? new_fragment_profile; private bool auto_add_default; + private bool auto_add_registrable_default; private bool welcome; private int selected_tab_index = BrowsersTabIndex; private DelegateCommand? add_default; diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index f7b73ed..9bf3518 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -1,4 +1,4 @@ - - + @@ -82,7 +82,7 @@ - + + ToolTip="{Binding Url.SecurityPresentation.ToolTip}"> @@ -121,20 +121,22 @@ MaxHeight="76" HorizontalAlignment="Stretch" VerticalAlignment="Top" + ui:UrlTextBlockSegments.Segments="{Binding Url.SecurityPresentation.Segments}" Foreground="{DynamicResource {x:Static ui:App.UrlBarForegroundBrushKey}}" - Text="{Binding Url.DisplayURL, Mode=OneWay}" + LineHeight="18" + LineStackingStrategy="BlockLineHeight" TextAlignment="Left" TextTrimming="CharacterEllipsis" TextWrapping="Wrap" /> - + + + + @@ -177,7 +179,7 @@ - + - + - + - - - - - - + HorizontalAlignment="Left"> + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml.cs b/src/BrowserPicker.UI/Views/BrowserList.xaml.cs index 4e81677..6336eca 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml.cs +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml.cs @@ -1,6 +1,3 @@ -using System.Windows; -using System.Windows.Threading; - namespace BrowserPicker.UI.Views; /// @@ -12,23 +9,4 @@ public BrowserList() { InitializeComponent(); } - - private void BrowserListScroll_SizeChanged(object? sender, SizeChangedEventArgs e) - { - Dispatcher.BeginInvoke( - () => - { - if (BrowserListScroll == null) - return; - var pad = - BrowserListScroll.ExtentHeight > BrowserListScroll.ViewportHeight - && BrowserListScroll.ViewportHeight > 0 - ? new Thickness(0, 0, 18, 0) - : new Thickness(0); - if (BrowserListScroll.Padding != pad) - BrowserListScroll.Padding = pad; - }, - DispatcherPriority.Loaded - ); - } } diff --git a/src/BrowserPicker.UI/Views/MainWindow.xaml b/src/BrowserPicker.UI/Views/MainWindow.xaml index c7a52f0..c262a22 100644 --- a/src/BrowserPicker.UI/Views/MainWindow.xaml +++ b/src/BrowserPicker.UI/Views/MainWindow.xaml @@ -8,7 +8,7 @@ xmlns:viewModels="clr-namespace:BrowserPicker.UI.ViewModels" xmlns:views="clr-namespace:BrowserPicker.UI.Views" Title="Browser Picker" - MinWidth="320" + MinWidth="430" MinHeight="200" d:DataContext="{d:DesignInstance viewModels:ApplicationViewModel, d:IsDesignTimeCreatable=true}" @@ -63,7 +63,7 @@ - + @@ -90,8 +96,12 @@ Background="{DynamicResource {x:Static ui:App.UrlBarBackgroundBrushKey}}" BorderBrush="{DynamicResource {x:Static ui:App.UrlBarBorderBrushKey}}" BorderThickness="1" - CornerRadius="5" - ToolTip="{Binding Url.SecurityPresentation.ToolTip}"> + CornerRadius="5"> + + + + + @@ -132,11 +142,6 @@ - - - @@ -612,13 +617,7 @@ - + @@ -635,13 +634,7 @@ - + diff --git a/tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj b/tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj index 96f550a..c06dd0b 100644 --- a/tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj +++ b/tests/BrowserPicker.Common.Tests/BrowserPicker.Common.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/BrowserPicker.Common.Tests/UnitTest1.cs b/tests/BrowserPicker.Common.Tests/UnitTest1.cs index b3f25a3..6116a69 100644 --- a/tests/BrowserPicker.Common.Tests/UnitTest1.cs +++ b/tests/BrowserPicker.Common.Tests/UnitTest1.cs @@ -1,3 +1,5 @@ +using AwesomeAssertions; + namespace BrowserPicker.Common.Tests; public class UrlSecurityPresentationTests @@ -14,14 +16,73 @@ string expectedDomain { var presentation = UrlSecurityPresentation.FromDisplayUrl(url); - Assert.Equal(expectedSchemeState, presentation.SchemeState); - Assert.Contains( - presentation.Segments, - segment => + presentation.SchemeState.Should().Be(expectedSchemeState); + presentation + .Segments.Should() + .Contain(segment => segment.Kind == UrlDisplaySegmentKind.RegistrableDomain && segment.Text == expectedDomain.Split('.')[0] - ); - Assert.Equal(expectedDomain, presentation.RegistrableDomain); - Assert.Contains(expectedDomain, presentation.ToolTip); + ); + presentation.RegistrableDomain.Should().Be(expectedDomain); + presentation.ToolTip.Should().Contain(expectedDomain); + } + + [Fact] + public void FromDisplayUrlBuildsCompactTooltip() + { + var presentation = UrlSecurityPresentation.FromDisplayUrl("https://www.github.com/mortenn/BrowserPicker"); + + presentation + .ToolTip.Should() + .Be( + string.Join( + Environment.NewLine, + "Local URL hints", + "Scheme: HTTPS (secure scheme; TLS was not checked)", + "Host: www.github.com", + "Highlighted domain: github.com", + "No network request was made." + ) + ); + } + + [Fact] + public void FromDisplayUrlBuildsCompactIdnTooltip() + { + var presentation = UrlSecurityPresentation.FromDisplayUrl("https://xn--bcher-kva.example/"); + + presentation + .ToolTip.Should() + .Be( + string.Join( + Environment.NewLine, + "Local URL hints", + "Scheme: HTTPS (secure scheme; TLS was not checked)", + "Host: bücher.example", + "Highlighted domain: xn--bcher-kva.example", + "IDN ASCII: xn--bcher-kva.example", + "No network request was made." + ) + ); + } + + [Fact] + public void FromDisplayUrlBuildsCompactFileTooltip() + { + const string url = "file:///c:/windows/win.ini"; + var presentation = UrlSecurityPresentation.FromDisplayUrl(url); + + presentation + .ToolTip.Should() + .Be( + string.Join( + Environment.NewLine, + "Local URL hints", + "Scheme: FILE (local scheme)", + @"Path: C:\windows\win.ini", + $"Original URL: {url}", + "No network request was made." + ) + ); } [Fact] @@ -29,19 +90,15 @@ public void FromDisplayUrlShowsUnicodeAndAsciiForIdn() { var presentation = UrlSecurityPresentation.FromDisplayUrl("https://xn--bcher-kva.example/"); - Assert.Equal(UrlSecuritySchemeState.Secure, presentation.SchemeState); - Assert.DoesNotContain( - presentation.Segments, - segment => segment.Text.Contains("xn--", StringComparison.Ordinal) - ); - Assert.Contains( - presentation.Segments, - segment => segment.Kind == UrlDisplaySegmentKind.NonAsciiHost && segment.Text == "ü" - ); - Assert.Equal("https://bücher.example/", string.Concat(presentation.Segments.Select(segment => segment.Text))); - Assert.Contains("bücher.example", presentation.ToolTip); - Assert.Contains("xn--bcher-kva.example", presentation.ToolTip); - Assert.Equal("xn--bcher-kva.example", presentation.RegistrableDomain); + presentation.SchemeState.Should().Be(UrlSecuritySchemeState.Secure); + presentation.Segments.Should().NotContain(segment => segment.Text.Contains("xn--", StringComparison.Ordinal)); + presentation + .Segments.Should() + .Contain(segment => segment.Kind == UrlDisplaySegmentKind.NonAsciiHost && segment.Text == "ü"); + string.Concat(presentation.Segments.Select(segment => segment.Text)).Should().Be("https://bücher.example/"); + presentation.ToolTip.Should().Contain("bücher.example"); + presentation.ToolTip.Should().Contain("xn--bcher-kva.example"); + presentation.RegistrableDomain.Should().Be("xn--bcher-kva.example"); } [Fact] @@ -49,17 +106,14 @@ public void FromDisplayUrlFallsBackForUnclassifiedHost() { var presentation = UrlSecurityPresentation.FromDisplayUrl("https://example.invalid-tld123/path"); - Assert.DoesNotContain( - presentation.Segments, - segment => segment.Kind == UrlDisplaySegmentKind.RegistrableDomain - ); - Assert.Null(presentation.RegistrableDomain); - Assert.Contains( - presentation.Segments, - segment => + presentation.Segments.Should().NotContain(segment => segment.Kind == UrlDisplaySegmentKind.RegistrableDomain); + presentation.RegistrableDomain.Should().BeNull(); + presentation + .Segments.Should() + .Contain(segment => segment.Kind == UrlDisplaySegmentKind.Host && segment.Text.Contains("example.invalid-tld123", StringComparison.Ordinal) - ); + ); } [Fact] @@ -68,16 +122,15 @@ public void FromDisplayUrlShowsDriveFileUrlsAsWindowsPathsWithEmphasizedDrive() const string url = "file:///c:/windows/win.ini"; var presentation = UrlSecurityPresentation.FromDisplayUrl(url); - Assert.Equal(UrlSecuritySchemeState.Neutral, presentation.SchemeState); - Assert.Equal( - [ + presentation.SchemeState.Should().Be(UrlSecuritySchemeState.Neutral); + presentation + .Segments.Should() + .Equal([ new UrlDisplaySegment("C:", UrlDisplaySegmentKind.FileRoot), new UrlDisplaySegment("\\windows\\", UrlDisplaySegmentKind.Path), new UrlDisplaySegment("win.ini", UrlDisplaySegmentKind.FileName), - ], - presentation.Segments - ); - Assert.Contains($"Original URL: {url}", presentation.ToolTip); + ]); + presentation.ToolTip.Should().Contain($"Original URL: {url}"); } [Fact] @@ -86,16 +139,15 @@ public void FromDisplayUrlShowsUncFileUrlsAsWindowsPaths() const string url = "file://server/share/file.txt"; var presentation = UrlSecurityPresentation.FromDisplayUrl(url); - Assert.Equal(UrlSecuritySchemeState.Neutral, presentation.SchemeState); - Assert.Equal( - [ + presentation.SchemeState.Should().Be(UrlSecuritySchemeState.Neutral); + presentation + .Segments.Should() + .Equal([ new UrlDisplaySegment(@"\\server", UrlDisplaySegmentKind.FileRoot), new UrlDisplaySegment(@"\share\", UrlDisplaySegmentKind.Path), new UrlDisplaySegment("file.txt", UrlDisplaySegmentKind.FileName), - ], - presentation.Segments - ); - Assert.Contains($"Original URL: {url}", presentation.ToolTip); + ]); + presentation.ToolTip.Should().Contain($"Original URL: {url}"); } [Fact] @@ -103,15 +155,13 @@ public void FromDisplayUrlSegmentsNonFileUrlsWithoutAuthorityHost() { var presentation = UrlSecurityPresentation.FromDisplayUrl("mailto:user@example.com"); - Assert.Equal(UrlSecuritySchemeState.Neutral, presentation.SchemeState); - Assert.Contains( - presentation.Segments, - segment => segment.Kind == UrlDisplaySegmentKind.Scheme && segment.Text == "mailto:" - ); - Assert.Contains( - presentation.Segments, - segment => segment.Kind == UrlDisplaySegmentKind.Path && segment.Text == "user@example.com" - ); + presentation.SchemeState.Should().Be(UrlSecuritySchemeState.Neutral); + presentation + .Segments.Should() + .Contain(segment => segment.Kind == UrlDisplaySegmentKind.Scheme && segment.Text == "mailto:"); + presentation + .Segments.Should() + .Contain(segment => segment.Kind == UrlDisplaySegmentKind.Path && segment.Text == "user@example.com"); } [Fact] @@ -121,7 +171,7 @@ public void HostnameDefaultsDoNotMatchFileUrls() var fileUrl = new Uri("file://server.company.com/share/file.txt"); var httpsUrl = new Uri("https://server.company.com/path"); - Assert.Equal(0, setting.MatchLength(fileUrl)); - Assert.Equal("company.com".Length, setting.MatchLength(httpsUrl)); + setting.MatchLength(fileUrl).Should().Be(0); + setting.MatchLength(httpsUrl).Should().Be("company.com".Length); } } From efc53cb0f483ad9b0ef701d460499dbc7fc33f16 Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 22:41:03 +0200 Subject: [PATCH 3/8] Update shared workflow pin Pins BrowserPicker workflow wrappers to the merged BrowserPicker.Actions fix for PR validation artifact uploads. Made-with: Cursor --- .github/workflows/ci.yml | 2 +- .github/workflows/pr-validation.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7a94ce..9d94fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,6 @@ jobs: ci: permissions: contents: read - uses: mortenn/BrowserPicker.Actions/.github/workflows/ci.yml@d6c7563889c25c3df4ab20c3535fea675d6e9d12 + uses: mortenn/BrowserPicker.Actions/.github/workflows/ci.yml@1f35242bb89d3bc12c83ca4bd4727f3414204340 with: dotnet_version: 10.x diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 9cef439..871a149 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -11,6 +11,6 @@ jobs: validate: permissions: contents: read - uses: mortenn/BrowserPicker.Actions/.github/workflows/pr-validation.yml@d6c7563889c25c3df4ab20c3535fea675d6e9d12 + uses: mortenn/BrowserPicker.Actions/.github/workflows/pr-validation.yml@1f35242bb89d3bc12c83ca4bd4727f3414204340 with: dotnet_version: 10.x From 0aad823d4ae56893192c764ed124fc034c86d095 Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 22:48:53 +0200 Subject: [PATCH 4/8] Polish URL hint tooltip Restores the full URL at the top of the picker tooltip, widens wrapping, and separates it visually from the local hint details. Made-with: Cursor --- src/BrowserPicker.UI/Views/BrowserList.xaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index 9ba9e1f..f50db11 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -99,7 +99,10 @@ CornerRadius="5"> - + + + + From 0845df64308e2e317a73c99248bfce5777a92fb1 Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 22:54:30 +0200 Subject: [PATCH 5/8] Distinguish tooltip labels from values Adds structured URL tooltip lines so the picker can render muted labels next to normal value text while preserving the full URL header. Made-with: Cursor --- .../UrlSecurityPresentation.cs | 64 ++++++++++++------- src/BrowserPicker.UI/Views/BrowserList.xaml | 15 ++++- tests/BrowserPicker.Common.Tests/UnitTest1.cs | 22 ++++--- 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/BrowserPicker.Common/UrlSecurityPresentation.cs b/src/BrowserPicker.Common/UrlSecurityPresentation.cs index 859d634..d366d86 100644 --- a/src/BrowserPicker.Common/UrlSecurityPresentation.cs +++ b/src/BrowserPicker.Common/UrlSecurityPresentation.cs @@ -29,12 +29,15 @@ public enum UrlDisplaySegmentKind public sealed record UrlDisplaySegment(string Text, UrlDisplaySegmentKind Kind); +public sealed record UrlToolTipLine(string Label, string Value); + public sealed record UrlSecurityPresentation( IReadOnlyList Segments, string SchemeLabel, UrlSecuritySchemeState SchemeState, string SchemeToolTip, string ToolTip, + IReadOnlyList ToolTipLines, string? RegistrableDomain ) { @@ -65,6 +68,7 @@ public static UrlSecurityPresentation FromDisplayUrl(string? displayUrl) UrlSecuritySchemeState.Neutral, "No URL to inspect.", string.Empty, + Array.Empty(), null ); } @@ -77,6 +81,7 @@ [new UrlDisplaySegment(displayUrl, UrlDisplaySegmentKind.Other)], UrlSecuritySchemeState.Neutral, "This text is not an absolute URL.", displayUrl, + [new UrlToolTipLine("URL", "This text is not an absolute URL.")], null ); } @@ -86,12 +91,14 @@ [new UrlDisplaySegment(displayUrl, UrlDisplaySegmentKind.Other)], if (uri.IsFile) { var filePath = GetCanonicalFilePath(uri); + var fileToolTipLines = BuildFileToolTipLines(uri, filePath); return new( BuildFileSegments(filePath), schemeLabel, schemeState, BuildSchemeToolTip(uri, schemeState), - BuildFileToolTip(displayUrl, filePath), + BuildToolTip(fileToolTipLines), + fileToolTipLines, null ); } @@ -104,9 +111,17 @@ [new UrlDisplaySegment(displayUrl, UrlDisplaySegmentKind.Other)], out var asciiHost ); var schemeToolTip = BuildSchemeToolTip(uri, schemeState); - var toolTip = BuildToolTip(schemeToolTip, registrableDomain, unicodeHost, asciiHost); - - return new(segments, schemeLabel, schemeState, schemeToolTip, toolTip, registrableDomain); + var toolTipLines = BuildToolTipLines(schemeToolTip, registrableDomain, unicodeHost, asciiHost); + + return new( + segments, + schemeLabel, + schemeState, + schemeToolTip, + BuildToolTip(toolTipLines), + toolTipLines, + registrableDomain + ); } private static string GetCanonicalFilePath(Uri uri) @@ -166,16 +181,14 @@ private static IReadOnlyList BuildPathSegments(string path) ]; } - private static string BuildFileToolTip(string displayUrl, string filePath) + private static IReadOnlyList BuildFileToolTipLines(Uri uri, string filePath) { - return string.Join( - Environment.NewLine, - "Local URL hints", - "Scheme: FILE (local scheme)", - $"Path: {filePath}", - $"Original URL: {displayUrl}", - "No network request was made." - ); + return + [ + new UrlToolTipLine("Scheme", BuildSchemeToolTip(uri, UrlSecuritySchemeState.Neutral)), + new UrlToolTipLine("Path", filePath), + new UrlToolTipLine("Note", "No network request was made."), + ]; } private static UrlSecuritySchemeState GetSchemeState(Uri uri) @@ -192,29 +205,29 @@ private static string BuildSchemeToolTip(Uri uri, UrlSecuritySchemeState schemeS { return schemeState switch { - UrlSecuritySchemeState.Secure => "Scheme: HTTPS (secure scheme; TLS was not checked)", - UrlSecuritySchemeState.Insecure => "Scheme: HTTP (not encrypted)", - _ => $"Scheme: {uri.Scheme.ToUpperInvariant()} (local scheme)", + UrlSecuritySchemeState.Secure => "HTTPS (secure scheme; TLS was not checked)", + UrlSecuritySchemeState.Insecure => "HTTP (not encrypted)", + _ => $"{uri.Scheme.ToUpperInvariant()} (local scheme)", }; } - private static string BuildToolTip( + private static IReadOnlyList BuildToolTipLines( string schemeToolTip, string? registrableDomain, string? unicodeHost, string? asciiHost ) { - var lines = new List { "Local URL hints", schemeToolTip }; + var lines = new List { new("Scheme", schemeToolTip) }; if (!string.IsNullOrWhiteSpace(unicodeHost)) { - lines.Add($"Host: {unicodeHost}"); + lines.Add(new UrlToolTipLine("Host", unicodeHost)); } if (!string.IsNullOrWhiteSpace(registrableDomain)) { - lines.Add($"Highlighted domain: {registrableDomain}"); + lines.Add(new UrlToolTipLine("Highlighted domain", registrableDomain)); } if ( @@ -223,11 +236,16 @@ private static string BuildToolTip( && !string.Equals(unicodeHost, asciiHost, StringComparison.OrdinalIgnoreCase) ) { - lines.Add($"IDN ASCII: {asciiHost}"); + lines.Add(new UrlToolTipLine("IDN ASCII", asciiHost)); } - lines.Add("No network request was made."); - return string.Join(Environment.NewLine, lines); + lines.Add(new UrlToolTipLine("Note", "No network request was made.")); + return lines; + } + + private static string BuildToolTip(IReadOnlyList lines) + { + return string.Join(Environment.NewLine, lines.Select(line => $"{line.Label}: {line.Value}")); } private static IReadOnlyList BuildSegments( diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index f50db11..becce4e 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -101,7 +101,20 @@ - + + + + + + + + + + + + + + diff --git a/tests/BrowserPicker.Common.Tests/UnitTest1.cs b/tests/BrowserPicker.Common.Tests/UnitTest1.cs index 6116a69..fa9574c 100644 --- a/tests/BrowserPicker.Common.Tests/UnitTest1.cs +++ b/tests/BrowserPicker.Common.Tests/UnitTest1.cs @@ -36,13 +36,20 @@ public void FromDisplayUrlBuildsCompactTooltip() .Be( string.Join( Environment.NewLine, - "Local URL hints", "Scheme: HTTPS (secure scheme; TLS was not checked)", "Host: www.github.com", "Highlighted domain: github.com", - "No network request was made." + "Note: No network request was made." ) ); + presentation + .ToolTipLines.Should() + .Equal( + new UrlToolTipLine("Scheme", "HTTPS (secure scheme; TLS was not checked)"), + new UrlToolTipLine("Host", "www.github.com"), + new UrlToolTipLine("Highlighted domain", "github.com"), + new UrlToolTipLine("Note", "No network request was made.") + ); } [Fact] @@ -55,12 +62,11 @@ public void FromDisplayUrlBuildsCompactIdnTooltip() .Be( string.Join( Environment.NewLine, - "Local URL hints", "Scheme: HTTPS (secure scheme; TLS was not checked)", "Host: bücher.example", "Highlighted domain: xn--bcher-kva.example", "IDN ASCII: xn--bcher-kva.example", - "No network request was made." + "Note: No network request was made." ) ); } @@ -76,11 +82,9 @@ public void FromDisplayUrlBuildsCompactFileTooltip() .Be( string.Join( Environment.NewLine, - "Local URL hints", "Scheme: FILE (local scheme)", @"Path: C:\windows\win.ini", - $"Original URL: {url}", - "No network request was made." + "Note: No network request was made." ) ); } @@ -130,7 +134,7 @@ public void FromDisplayUrlShowsDriveFileUrlsAsWindowsPathsWithEmphasizedDrive() new UrlDisplaySegment("\\windows\\", UrlDisplaySegmentKind.Path), new UrlDisplaySegment("win.ini", UrlDisplaySegmentKind.FileName), ]); - presentation.ToolTip.Should().Contain($"Original URL: {url}"); + presentation.ToolTip.Should().Contain(@"Path: C:\windows\win.ini"); } [Fact] @@ -147,7 +151,7 @@ public void FromDisplayUrlShowsUncFileUrlsAsWindowsPaths() new UrlDisplaySegment(@"\share\", UrlDisplaySegmentKind.Path), new UrlDisplaySegment("file.txt", UrlDisplaySegmentKind.FileName), ]); - presentation.ToolTip.Should().Contain($"Original URL: {url}"); + presentation.ToolTip.Should().Contain(@"Path: \\server\share\file.txt"); } [Fact] From fff01990c5013c3a0aac877ee254584015e6854d Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 22:56:49 +0200 Subject: [PATCH 6/8] Relax tooltip hint layout Renders hint labels inline with muted styling so long values can wrap across the full tooltip width. Made-with: Cursor --- src/BrowserPicker.UI/Views/BrowserList.xaml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index becce4e..c38ba8b 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -1,4 +1,4 @@ - - - - - - - - - + + + + + From a0def9952c85e1e3a8ee04aec3ad2e8390654fd7 Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 22:59:59 +0200 Subject: [PATCH 7/8] Use contrast-safe tooltip labels Uses inherited tooltip foreground with semibold labels instead of gray text on a gray tooltip background. Made-with: Cursor --- src/BrowserPicker.UI/Views/BrowserList.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index c38ba8b..a8073d2 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -105,8 +105,8 @@ - - + + From 42f3c14f0b9c601047e44dcd7bd53b149bce426b Mon Sep 17 00:00:00 2001 From: Morten Nilsen Date: Wed, 29 Apr 2026 23:04:16 +0200 Subject: [PATCH 8/8] Use theme accent for tooltip labels Uses the system highlight brush for tooltip labels so they stay distinct in both dark and light tooltip themes. Made-with: Cursor --- src/BrowserPicker.UI/Views/BrowserList.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BrowserPicker.UI/Views/BrowserList.xaml b/src/BrowserPicker.UI/Views/BrowserList.xaml index a8073d2..cacab54 100644 --- a/src/BrowserPicker.UI/Views/BrowserList.xaml +++ b/src/BrowserPicker.UI/Views/BrowserList.xaml @@ -105,8 +105,8 @@ - - + +