From 0de60445ea99aa97b4c640d192c5f55fdbda7629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 27 Nov 2025 16:31:10 +0100 Subject: [PATCH] CmdPal: Use Shell API to determine the default browser in WebSearch (#43339) ## Summary of the Pull Request This PR introduces a new method for determining the default browser using the Windows Shell API. The new provider selects the browser associated with the HTTPS protocol (falling back to HTTP if necessary). The original implementation is retained as a fallback for now, and the codebase is prepared for future extensions (e.g., manual default-browser selection). As a flyby, it also fixes an issue where commands continued showing the previous browser name if the user changed their default browser while the Command Palette was running. ## One-liner for change log Fixed default browser selection in the Web Search built-in extension. ## PR Checklist - [x] Closes: #42343 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + .../MockBrowserInfoService.cs | 12 + .../QueryTests.cs | 12 +- .../SettingsManagerTests.cs | 4 +- .../Commands/OpenURLCommand.cs | 18 +- .../Commands/SearchWebCommand.cs | 14 +- .../FallbackExecuteSearchItem.cs | 25 +- .../FallbackOpenURLItem.cs | 23 +- .../Helpers/Browser/BrowserInfo.cs | 14 ++ .../Browser/BrowserInfoServiceExtensions.cs | 32 +++ .../Browser/DefaultBrowserInfoService.cs | 99 ++++++++ .../Helpers/Browser/IBrowserInfoService.cs | 17 ++ .../Browser/Providers/AssociatedApp.cs | 7 + .../Providers/AssociationProviderBase.cs | 154 +++++++++++++ .../FallbackMsEdgeBrowserProvider.cs | 31 +++ .../Providers/IDefaultBrowserProvider.cs | 13 ++ .../LegacyRegistryAssociationProvider.cs | 46 ++++ .../Providers/ShellAssociationProvider.cs | 64 ++++++ .../Helpers/DefaultBrowserInfo.cs | 215 ------------------ .../Helpers/NativeMethods.cs | 54 +++++ .../Pages/WebSearchListPage.cs | 20 +- .../Properties/Resources.Designer.cs | 11 +- .../Properties/Resources.resx | 3 + .../WebSearchCommandsProvider.cs | 8 +- .../WebSearchTopLevelCommandItem.cs | 11 +- 25 files changed, 637 insertions(+), 271 deletions(-) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 1ce6f88899..9f18bbb300 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1692,6 +1692,7 @@ stringtable stringval Strm strret +STRSAFE stscanf sttngs Stubless diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs new file mode 100644 index 0000000000..ee27aa737e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockBrowserInfoService : IBrowserInfoService +{ + public BrowserInfo GetDefaultBrowser() => new() { Name = "mocked browser", Path = "C:\\mockery\\mock.exe" }; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs index 00f1235c0e..63e35314cd 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs @@ -26,8 +26,9 @@ public class QueryTests : CommandPaletteUnitTestBase { // Setup var settings = new MockSettingsInterface(); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText(string.Empty, query); @@ -55,8 +56,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText("abcdef", string.Empty); @@ -90,8 +92,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); mockHistoryItems.Add(new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture))); @@ -123,8 +126,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 0); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText("abcdef", string.Empty); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs index fd19427ca1..2ec5546daa 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs @@ -20,7 +20,9 @@ public class SettingsManagerTests : CommandPaletteUnitTestBase { // Setup var settings = new MockSettingsInterface(historyItemCount: 5); - var page = new WebSearchListPage(settings); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); var eventRaised = false; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs index 08d0a114f5..937be16ac2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs @@ -2,32 +2,28 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class OpenURLCommand : InvokableCommand { + private readonly IBrowserInfoService _browserInfoService; + public string Url { get; internal set; } = string.Empty; - internal OpenURLCommand(string url) + internal OpenURLCommand(string url, IBrowserInfoService browserInfoService) { + _browserInfoService = browserInfoService; Url = url; - BrowserInfo.UpdateIfTimePassed(); Icon = Icons.WebSearch; Name = string.Empty; } public override CommandResult Invoke() { - if (!ShellHelpers.OpenCommandInShell(BrowserInfo.Path, BrowserInfo.ArgumentsPattern, $"{Url}")) - { - // TODO GH# 138 --> actually display feedback from the extension somewhere. - return CommandResult.KeepOpen(); - } - - return CommandResult.Dismiss(); + // TODO GH# 138 --> actually display feedback from the extension somewhere. + return _browserInfoService.Open(Url) ? CommandResult.Dismiss() : CommandResult.KeepOpen(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs index 2cc8953048..98921aa8ae 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -4,31 +4,31 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class SearchWebCommand : InvokableCommand { private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; - public string Arguments { get; internal set; } = string.Empty; + public string Arguments { get; internal set; } - internal SearchWebCommand(string arguments, ISettingsInterface settingsManager) + internal SearchWebCommand(string arguments, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { Arguments = arguments; - BrowserInfo.UpdateIfTimePassed(); Icon = Icons.WebSearch; - Name = Properties.Resources.open_in_default_browser; + Name = Resources.open_in_default_browser; _settingsManager = settingsManager; + _browserInfoService = browserInfoService; } public override CommandResult Invoke() { - if (!ShellHelpers.OpenCommandInShell(BrowserInfo.Path, BrowserInfo.ArgumentsPattern, $"? {Arguments}")) + if (!_browserInfoService.Open($"? {Arguments}")) { // TODO GH# 138 --> actually display feedback from the extension somewhere. return CommandResult.KeepOpen(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs index c942e668d3..61557d996a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs @@ -5,9 +5,9 @@ using System.Globalization; using System.Text; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch.Commands; @@ -16,25 +16,34 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem private readonly SearchWebCommand _executeItem; private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private static readonly CompositeFormat SubtitleText = System.Text.CompositeFormat.Parse(Properties.Resources.web_search_fallback_subtitle); - private string _title; - public FallbackExecuteSearchItem(SettingsManager settings) - : base(new SearchWebCommand(string.Empty, settings) { Id = "com.microsoft.websearch.fallback" }, Resources.command_item_title) + private readonly IBrowserInfoService _browserInfoService; + + public FallbackExecuteSearchItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new SearchWebCommand(string.Empty, settings, browserInfoService) { Id = "com.microsoft.websearch.fallback" }, Resources.command_item_title) { - _executeItem = (SearchWebCommand)this.Command!; + _executeItem = (SearchWebCommand)Command!; + _browserInfoService = browserInfoService; Title = string.Empty; Subtitle = string.Empty; _executeItem.Name = string.Empty; - _title = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); Icon = Icons.WebSearch; } + private static string UpdateBrowserName(IBrowserInfoService browserInfoService) + { + var browserName = browserInfoService.GetDefaultBrowser()?.Name; + return string.IsNullOrWhiteSpace(browserName) + ? Resources.open_in_default_browser + : string.Format(CultureInfo.CurrentCulture, PluginOpen, browserName); + } + public override void UpdateQuery(string query) { _executeItem.Arguments = query; var isEmpty = string.IsNullOrEmpty(query); - _executeItem.Name = isEmpty ? string.Empty : Properties.Resources.open_in_default_browser; - Title = isEmpty ? string.Empty : _title; + _executeItem.Name = isEmpty ? string.Empty : Resources.open_in_default_browser; + Title = isEmpty ? string.Empty : UpdateBrowserName(_browserInfoService); Subtitle = string.Format(CultureInfo.CurrentCulture, SubtitleText, query); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs index 9f5d9d86ca..7feb53b1de 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs @@ -7,21 +7,26 @@ using System.Globalization; using System.Text; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch; internal sealed partial class FallbackOpenURLItem : FallbackCommandItem { + private readonly IBrowserInfoService _browserInfoService; private readonly OpenURLCommand _executeItem; private static readonly CompositeFormat PluginOpenURL = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url); private static readonly CompositeFormat PluginOpenUrlInBrowser = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url_in_browser); - public FallbackOpenURLItem(SettingsManager settings) - : base(new OpenURLCommand(string.Empty), Properties.Resources.open_url_fallback_title) + public FallbackOpenURLItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new OpenURLCommand(string.Empty, browserInfoService), Resources.open_url_fallback_title) { - _executeItem = (OpenURLCommand)this.Command!; + ArgumentNullException.ThrowIfNull(browserInfoService); + + _browserInfoService = browserInfoService; + _executeItem = (OpenURLCommand)Command!; Title = string.Empty; _executeItem.Name = string.Empty; Subtitle = string.Empty; @@ -39,7 +44,7 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem return; } - var success = Uri.TryCreate(query, UriKind.Absolute, out var uri); + var success = Uri.TryCreate(query, UriKind.Absolute, out _); // if url not contain schema, add http:// by default. if (!success) @@ -48,13 +53,15 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem } _executeItem.Url = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.open_in_default_browser; + _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Resources.open_in_default_browser; Title = string.Format(CultureInfo.CurrentCulture, PluginOpenURL, query); - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); + + var browserName = _browserInfoService.GetDefaultBrowser()?.Name; + Subtitle = string.IsNullOrWhiteSpace(browserName) ? Resources.open_in_default_browser : string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, browserName); } - public static bool IsValidUrl(string url) + private static bool IsValidUrl(string url) { if (string.IsNullOrWhiteSpace(url)) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs new file mode 100644 index 0000000000..9da978f481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +public record BrowserInfo +{ + public required string Path { get; init; } + + public required string Name { get; init; } + + public string? ArgumentsPattern { get; init; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs new file mode 100644 index 0000000000..1614273d83 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Extension methods for . +/// +/// +internal static class BrowserInfoServiceExtensions +{ + /// + /// Opens the specified URL in the system's default web browser. + /// + /// The browser information service used to resolve the system's default browser. + /// The URL to open. + /// + /// if a default browser is found and the URL launch command is issued successfully; + /// otherwise, . + /// + /// + /// Returns if the default browser cannot be determined. + /// + public static bool Open(this IBrowserInfoService browserInfoService, string url) + { + var defaultBrowser = browserInfoService.GetDefaultBrowser(); + return defaultBrowser != null && ShellHelpers.OpenCommandInShell(defaultBrowser.Path, defaultBrowser.ArgumentsPattern, url); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs new file mode 100644 index 0000000000..51312fe4c0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using ManagedCommon; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Service to get information about the default browser. +/// +internal class DefaultBrowserInfoService : IBrowserInfoService +{ + private static readonly IDefaultBrowserProvider[] Providers = + [ + new ShellAssociationProvider(), + new LegacyRegistryAssociationProvider(), + new FallbackMsEdgeBrowserProvider(), + ]; + + private readonly Lock _updateLock = new(); + + private readonly Dictionary _lastLoggedErrors = []; + + private const long UpdateTimeout = 3000; + private long _lastUpdateTickCount = -UpdateTimeout; + + private BrowserInfo? _defaultBrowser; + + public BrowserInfo? GetDefaultBrowser() + { + try + { + UpdateIfTimePassed(); + } + catch (Exception) + { + // exception is already logged at this point + } + + return _defaultBrowser; + } + + /// + /// Updates only if at least more than 3000ms has passed since the last update, to avoid multiple calls to . + /// (because of multiple plugins calling update at the same time.) + /// + private void UpdateIfTimePassed() + { + lock (_updateLock) + { + var curTickCount = Environment.TickCount64; + if (curTickCount - _lastUpdateTickCount < UpdateTimeout && _defaultBrowser != null) + { + return; + } + + var newDefaultBrowser = UpdateCore(); + _defaultBrowser = newDefaultBrowser; + _lastUpdateTickCount = curTickCount; + } + } + + /// + /// Consider using to avoid updating multiple times. + /// (because of multiple plugins calling update at the same time.) + /// + private BrowserInfo UpdateCore() + { + foreach (var provider in Providers) + { + try + { + var result = provider.GetDefaultBrowserInfo(); +#if DEBUG + result = result with { Name = result.Name + " (" + provider.GetType().Name + ")" }; +#endif + return result; + } + catch (Exception ex) + { + // since we run this fairly often, avoid logging the same error multiple times + var lastLoggedError = _lastLoggedErrors.GetValueOrDefault(provider.GetType()); + var error = ex.ToString(); + if (error != lastLoggedError) + { + _lastLoggedErrors[provider.GetType()] = error; + Logger.LogError($"Exception when retrieving browser using provider {provider.GetType()}", ex); + } + } + } + + throw new InvalidOperationException("Unable to determine default browser"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs new file mode 100644 index 0000000000..5d82193e5d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Provides functionality to retrieve information about the system's default web browser. +/// +public interface IBrowserInfoService +{ + /// + /// Gets information about the system's default web browser. + /// + /// + BrowserInfo? GetDefaultBrowser(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs new file mode 100644 index 0000000000..3c6ba74d67 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +internal record AssociatedApp(string? Command, string? FriendlyName); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs new file mode 100644 index 0000000000..43ed130401 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Windows.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Base class for providers that determine the default browser via application associations. +/// +internal abstract class AssociationProviderBase : IDefaultBrowserProvider +{ + protected abstract AssociatedApp? FindAssociation(); + + public BrowserInfo GetDefaultBrowserInfo() + { + var appAssociation = FindAssociation(); + if (appAssociation is null) + { + throw new ArgumentNullException(nameof(appAssociation), "Could not determine default browser application."); + } + + var commandPattern = appAssociation.Command; + var appAndArgs = SplitAppAndArgs(commandPattern); + + if (string.IsNullOrEmpty(appAndArgs.Path)) + { + throw new ArgumentOutOfRangeException(nameof(appAndArgs.Path), "Default browser program path could not be determined."); + } + + // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App + if (!Path.Exists(appAndArgs.Path) && !Uri.TryCreate(appAndArgs.Path, UriKind.Absolute, out _)) + { + throw new ArgumentException($"Command validation failed: {commandPattern}", nameof(commandPattern)); + } + + return new BrowserInfo + { + Path = appAndArgs.Path, + Name = appAssociation.FriendlyName ?? Path.GetFileNameWithoutExtension(appAndArgs.Path), + ArgumentsPattern = appAndArgs.Arguments, + }; + } + + private static (string? Path, string? Arguments) SplitAppAndArgs(string? commandPattern) + { + if (string.IsNullOrEmpty(commandPattern)) + { + throw new ArgumentOutOfRangeException(nameof(commandPattern), "Default browser program command is not specified."); + } + + commandPattern = GetIndirectString(commandPattern); + + // HACK: for firefox installed through Microsoft store + // When installed through Microsoft Firefox the commandPattern does not have + // quotes for the path. As the Program Files does have a space + // the extracted path would be invalid, here we add the quotes to fix it + const string FirefoxExecutableName = "firefox.exe"; + if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && + !commandPattern.StartsWith('\"')) + { + var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + + FirefoxExecutableName.Length; + commandPattern = commandPattern.Insert(pathEndIndex, "\""); + commandPattern = commandPattern.Insert(0, "\""); + } + + if (commandPattern.StartsWith('\"')) + { + var endQuoteIndex = commandPattern.IndexOf('\"', 1); + if (endQuoteIndex != -1) + { + return (commandPattern[1..endQuoteIndex], commandPattern[(endQuoteIndex + 1)..].Trim()); + } + } + else + { + var spaceIndex = commandPattern.IndexOf(' '); + if (spaceIndex != -1) + { + return (commandPattern[..spaceIndex], commandPattern[(spaceIndex + 1)..].Trim()); + } + } + + return (null, null); + } + + protected static string GetIndirectString(string str) + { + if (string.IsNullOrEmpty(str) || str[0] != '@') + { + return str; + } + + const int initialCapacity = 128; + const int maxCapacity = 8192; // Reasonable upper limit + int hresult; + + unsafe + { + // Try with stack allocation first for common cases + var stackBuffer = stackalloc char[initialCapacity]; + + fixed (char* pszSource = str) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + stackBuffer, + initialCapacity, + null); + + // S_OK (0) means success + if (hresult == 0) + { + return new string(stackBuffer); + } + + // STRSAFE_E_INSUFFICIENT_BUFFER (0x8007007A) means buffer too small + // Try with progressively larger heap buffers + if (unchecked((uint)hresult) == 0x8007007A) + { + for (var capacity = initialCapacity * 2; capacity <= maxCapacity; capacity *= 2) + { + var heapBuffer = new char[capacity]; + fixed (char* pBuffer = heapBuffer) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + pBuffer, + (uint)capacity, + null); + + if (hresult == 0) + { + return new string(pBuffer); + } + + if (unchecked((uint)hresult) != 0x8007007A) + { + break; // Different error, stop retrying + } + } + } + } + } + } + + throw new InvalidOperationException( + $"Could not load indirect string. HRESULT: 0x{unchecked((uint)hresult):X8}"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs new file mode 100644 index 0000000000..8489362004 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Provides a fallback implementation of the default browser provider that returns information for Microsoft Edge. +/// +/// This class is used when no other default browser provider is available. It supplies the path, +/// arguments pattern, and name for Microsoft Edge as the default browser information. +internal sealed class FallbackMsEdgeBrowserProvider : IDefaultBrowserProvider +{ + private const string MsEdgeArgumentsPattern = "--single-argument %1"; + + private const string MsEdgeName = "Microsoft Edge"; + + private static string MsEdgePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + @"Microsoft\Edge\Application\msedge.exe"); + + public BrowserInfo GetDefaultBrowserInfo() => new() + { + Path = MsEdgePath, + ArgumentsPattern = MsEdgeArgumentsPattern, + Name = MsEdgeName, + }; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs new file mode 100644 index 0000000000..82a0b679fb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Retrieves information about the default browser. +/// +internal interface IDefaultBrowserProvider +{ + BrowserInfo GetDefaultBrowserInfo(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs new file mode 100644 index 0000000000..28fe40f995 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Provides the default web browser by reading registry keys. This is a legacy method and may not work on all systems. +/// +internal sealed class LegacyRegistryAssociationProvider : AssociationProviderBase +{ + protected override AssociatedApp? FindAssociation() + { + var progId = GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", + "ProgId") + ?? GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + "ProgId"); + var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") + ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); + + if (appName is not null) + { + appName = GetIndirectString(appName); + appName = appName + .Replace("URL", null, StringComparison.OrdinalIgnoreCase) + .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) + .Replace("Document", null, StringComparison.OrdinalIgnoreCase) + .Replace("Web", null, StringComparison.OrdinalIgnoreCase) + .TrimEnd(); + } + + var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); + + return commandPattern is null ? null : new AssociatedApp(commandPattern, appName); + + static string? GetRegistryValue(string registryLocation, string? valueName) + { + return Registry.GetValue(registryLocation, valueName, null) as string; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs new file mode 100644 index 0000000000..a70c3476d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Retrieves the default web browser using the system shell functions. +/// +internal sealed class ShellAssociationProvider : AssociationProviderBase +{ + private static readonly string[] Protocols = ["https", "http"]; + + protected override AssociatedApp FindAssociation() + { + foreach (var protocol in Protocols) + { + var command = AssocQueryStringSafe(NativeMethods.AssocStr.Command, protocol); + if (string.IsNullOrWhiteSpace(command)) + { + continue; + } + + var appName = AssocQueryStringSafe(NativeMethods.AssocStr.FriendlyAppName, protocol); + + return new AssociatedApp(command, appName); + } + + return new AssociatedApp(null, null); + } + + private static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span[..len]); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs deleted file mode 100644 index f6b82ecfbb..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text; -using System.Threading; -using ManagedCommon; - -namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; - -/// -/// Contains information (e.g. path to executable, name...) about the default browser. -/// -public static class DefaultBrowserInfo -{ - private static readonly Lock _updateLock = new(); - - /// Gets the path to the MS Edge browser executable. - public static string MSEdgePath => System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - @"Microsoft\Edge\Application\msedge.exe"); - - /// Gets the command line pattern of the MS Edge. - public const string MSEdgeArgumentsPattern = "--single-argument %1"; - - public const string MSEdgeName = "Microsoft Edge"; - - /// Gets the path to default browser's executable. - public static string? Path { get; private set; } - - /// Gets since the icon is embedded in the executable. - public static string? IconPath => Path; - - /// Gets the user-friendly name of the default browser. - public static string? Name { get; private set; } - - /// Gets the command line pattern of the default browser. - public static string? ArgumentsPattern { get; private set; } - - public static bool IsDefaultBrowserSet => !string.IsNullOrEmpty(Path); - - public const long UpdateTimeout = 300; - - private static long _lastUpdateTickCount = -UpdateTimeout; - - private static bool _updatedOnce; - private static bool _errorLogged; - - /// - /// Updates only if at least more than 300ms has passed since the last update, to avoid multiple calls to . - /// (because of multiple plugins calling update at the same time.) - /// - public static void UpdateIfTimePassed() - { - var curTickCount = Environment.TickCount64; - if (curTickCount - _lastUpdateTickCount >= UpdateTimeout) - { - _lastUpdateTickCount = curTickCount; - Update(); - } - } - - /// - /// Consider using to avoid updating multiple times. - /// (because of multiple plugins calling update at the same time.) - /// - public static void Update() - { - lock (_updateLock) - { - if (!_updatedOnce) - { - // Log.Info("I've tried updating the chosen Web Browser info at least once.", typeof(DefaultBrowserInfo)); - _updatedOnce = true; - } - - try - { - var progId = GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", - "ProgId") - ?? GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", - "ProgId"); - var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") - ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); - - if (appName is not null) - { - // Handle indirect strings: - if (appName.StartsWith('@')) - { - appName = GetIndirectString(appName); - } - - appName = appName - .Replace("URL", null, StringComparison.OrdinalIgnoreCase) - .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) - .Replace("Document", null, StringComparison.OrdinalIgnoreCase) - .Replace("Web", null, StringComparison.OrdinalIgnoreCase) - .TrimEnd(); - } - - Name = appName; - - var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); - - if (string.IsNullOrEmpty(commandPattern)) - { - throw new ArgumentOutOfRangeException( - nameof(commandPattern), - "Default browser program command is not specified."); - } - - if (commandPattern.StartsWith('@')) - { - commandPattern = GetIndirectString(commandPattern); - } - - // HACK: for firefox installed through Microsoft store - // When installed through Microsoft Firefox the commandPattern does not have - // quotes for the path. As the Program Files does have a space - // the extracted path would be invalid, here we add the quotes to fix it - const string FirefoxExecutableName = "firefox.exe"; - if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && (!commandPattern.StartsWith('\"'))) - { - var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + FirefoxExecutableName.Length; - commandPattern = commandPattern.Insert(pathEndIndex, "\""); - commandPattern = commandPattern.Insert(0, "\""); - } - - if (commandPattern.StartsWith('\"')) - { - var endQuoteIndex = commandPattern.IndexOf('\"', 1); - if (endQuoteIndex != -1) - { - Path = commandPattern.Substring(1, endQuoteIndex - 1); - ArgumentsPattern = commandPattern.Substring(endQuoteIndex + 1).Trim(); - } - } - else - { - var spaceIndex = commandPattern.IndexOf(' '); - if (spaceIndex != -1) - { - Path = commandPattern.Substring(0, spaceIndex); - ArgumentsPattern = commandPattern.Substring(spaceIndex + 1).Trim(); - } - } - - // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App - if (!System.IO.Path.Exists(Path) && !Uri.TryCreate(Path, UriKind.Absolute, out _)) - { - throw new ArgumentException( - $"Command validation failed: {commandPattern}", - nameof(commandPattern)); - } - - if (string.IsNullOrEmpty(Path)) - { - throw new ArgumentOutOfRangeException( - nameof(Path), - "Default browser program path could not be determined."); - } - } - catch (Exception) - { - // Fallback to MS Edge - Path = MSEdgePath; - Name = MSEdgeName; - ArgumentsPattern = MSEdgeArgumentsPattern; - - if (!_errorLogged) - { - // Log.Exception("Exception when retrieving browser path/name. Path and Name are set to use Microsoft Edge.", e, typeof(DefaultBrowserInfo)); - Logger.LogError("Exception when retrieving browser path/name. Path and Name are set to use Microsoft Edge."); - _errorLogged = true; - } - } - - string? GetRegistryValue(string registryLocation, string? valueName) - { - return Microsoft.Win32.Registry.GetValue(registryLocation, valueName, null) as string; - } - - string GetIndirectString(string str) - { - var stringBuilder = new StringBuilder(128); - unsafe - { - var buffer = stackalloc char[128]; - var capacity = 128; - var firstChar = str[0]; - var strPtr = &firstChar; - - // S_OK == 0 - fixed (char* pszSourceLocal = str) - { - if (global::Windows.Win32.PInvoke.SHLoadIndirectString( - pszSourceLocal, - buffer, - (uint)capacity, - default) == 0) - { - return new string(buffer); - } - } - } - - throw new ArgumentNullException(nameof(str), "Could not load indirect string."); - } - } - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..dee5b33fc5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs index 641d5f6135..bf21f7c912 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -9,23 +9,24 @@ using System.Text; using System.Threading; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch.Pages; internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable { private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; private readonly Lock _sync = new(); private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private IListItem[] _allItems = []; private List _historyItems = []; - public WebSearchListPage(ISettingsInterface settingsManager) + public WebSearchListPage(ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { ArgumentNullException.ThrowIfNull(settingsManager); @@ -35,6 +36,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable Id = "com.microsoft.cmdpal.websearch"; _settingsManager = settingsManager; + _browserInfoService = browserInfoService; _settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged; // It just looks viewer to have string twice on the page, and default placeholder is good enough @@ -43,8 +45,8 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable EmptyContent = new CommandItem(new NoOpCommand()) { Icon = Icon, - Title = Properties.Resources.plugin_description, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), + Title = Resources.plugin_description, + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), }; UpdateHistory(); @@ -67,7 +69,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable for (var index = items.Count - 1; index >= 0; index--) { var historyItem = items[index]; - history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager)) + history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager, _browserInfoService)) { Icon = Icons.History, Title = historyItem.SearchString, @@ -82,7 +84,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable } } - private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager) + private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { ArgumentNullException.ThrowIfNull(query); @@ -95,10 +97,10 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable if (!string.IsNullOrEmpty(query)) { var searchTerm = query; - var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager)) + var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager, browserInfoService)) { Title = searchTerm, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), Icon = Icons.Search, }; results.Add(result); @@ -117,7 +119,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable historySnapshot = _historyItems; } - var items = Query(search ?? string.Empty, historySnapshot, _settingsManager); + var items = Query(search ?? string.Empty, historySnapshot, _settingsManager, _browserInfoService); lock (_sync) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs index 39ebd6bf2b..090b54375d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -69,6 +69,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// + /// Looks up a localized string similar to default browser. + /// + public static string default_browser { + get { + return ResourceManager.GetString("default_browser", resourceCulture); + } + } + /// /// Looks up a localized string similar to Web Search. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx index 5a406eca60..032f89e7a5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx @@ -184,4 +184,7 @@ Open URL + + default browser + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs index 1a15991120..89cfe5a183 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs @@ -5,6 +5,7 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -19,6 +20,7 @@ public sealed partial class WebSearchCommandsProvider : CommandProvider private readonly WebSearchTopLevelCommandItem _webSearchTopLevelItem; private readonly ICommandItem[] _topLevelItems; private readonly IFallbackCommandItem[] _fallbackCommands; + private readonly IBrowserInfoService _browserInfoService = new DefaultBrowserInfoService(); public WebSearchCommandsProvider() { @@ -27,10 +29,10 @@ public sealed partial class WebSearchCommandsProvider : CommandProvider Icon = Icons.WebSearch; Settings = _settingsManager.Settings; - _fallbackItem = new FallbackExecuteSearchItem(_settingsManager); - _openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager); + _fallbackItem = new FallbackExecuteSearchItem(_settingsManager, _browserInfoService); + _openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager, _browserInfoService); - _webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager) + _webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager, _browserInfoService) { MoreCommands = [ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs index bc161991ca..b2aaa95f5e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs @@ -5,6 +5,7 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Pages; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; @@ -15,13 +16,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch; public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler, IDisposable { private readonly SettingsManager _settingsManager; + private readonly IBrowserInfoService _browserInfoService; - public WebSearchTopLevelCommandItem(SettingsManager settingsManager) - : base(new WebSearchListPage(settingsManager)) + public WebSearchTopLevelCommandItem(SettingsManager settingsManager, IBrowserInfoService browserInfoService) + : base(new WebSearchListPage(settingsManager, browserInfoService)) { Icon = Icons.WebSearch; SetDefaultTitle(); _settingsManager = settingsManager; + _browserInfoService = browserInfoService; } private void SetDefaultTitle() => Title = Resources.command_item_title; @@ -37,12 +40,12 @@ public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandle if (string.IsNullOrEmpty(query)) { SetDefaultTitle(); - ReplaceCommand(new WebSearchListPage(_settingsManager)); + ReplaceCommand(new WebSearchListPage(_settingsManager, _browserInfoService)); } else { Title = query; - ReplaceCommand(new SearchWebCommand(query, _settingsManager)); + ReplaceCommand(new SearchWebCommand(query, _settingsManager, _browserInfoService)); } }