[PowerToys Run] Add Support for Uris (#5160)

* url handler plugin

* updates

* Add seperate interface classes
rename to uri module

* Update path

* Update implementation to remove slow DNS lookup ( and let browser handle it)

* tabs to spaces

* - Update icon/assets
- Finalize Project

* Update wix project

* Implement UpdateBrowserIconPath

* Implemented Microsoft.CodeAnalysis.FxCopAnalyzers

* Add Language component to installer

* Update logic to determine icon

* Update Translation File to "Open in browser"

* Added test for typing http://test.com and which result to expect on each keystoke

* Implement StyleCop

* Added ipv6 tests

* Fix Solution LineBreaks

* Added Microsoft.Plugin.Uri as build Dependency

* Use ArgumentNullException instead of InvalidOperationException

* Fix wrong Directory in wix installer

Co-authored-by: Roy <royvou@hotmailcom>
This commit is contained in:
Roy
2020-08-11 00:53:43 +02:00
committed by GitHub
parent 3781d1e06b
commit ba2ef23414
24 changed files with 624 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<Platforms>x64</Platforms>
<RootNamespace>Microsoft.Plugin.Uri.UnitTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="nunit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Plugin.Uri\Microsoft.Plugin.Uri.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,53 @@
using Microsoft.Plugin.Uri.UriHelper;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using NUnit.Framework;
namespace Microsoft.Plugin.Uri.UnitTests.UriHelper
{
[TestFixture]
public class ExtendedUriParserTests
{
[TestCase("google.com", true, "http://google.com/")]
[TestCase("localhost", true, "http://localhost/")]
[TestCase("127.0.0.1", true, "http://127.0.0.1/")]
[TestCase("127.0.0.1:80", true, "http://127.0.0.1/")]
[TestCase("127", true, "http://0.0.0.127/")]
[TestCase("", false, null)]
[TestCase("https://google.com", true, "https://google.com/")]
[TestCase("ftps://google.com", true, "ftps://google.com/")]
[TestCase(null, false, null)]
[TestCase("bing.com/search?q=gmx", true, "http://bing.com/search?q=gmx")]
[TestCase("h", true, "http://h/")]
[TestCase("ht", true, "http://ht/")]
[TestCase("htt", true, "http://htt/")]
[TestCase("http", true, "http://http/")]
[TestCase("http:", false, null)]
[TestCase("http:/", false, null)]
[TestCase("http://", false, null)]
[TestCase("http://t", true, "http://t/")]
[TestCase("http://te", true, "http://te/")]
[TestCase("http://tes", true, "http://tes/")]
[TestCase("http://test", true, "http://test/")]
[TestCase("http://test.", false, null)]
[TestCase("http://test.c", true, "http://test.c/")]
[TestCase("http://test.co", true, "http://test.co/")]
[TestCase("http://test.com", true, "http://test.com/")]
[TestCase("http:3", true,"http://http:3/")]
[TestCase("[::]", true, "http://[::]")]
[TestCase("[2001:0DB8::1]", true, "http://[2001:0DB8::1]/")]
[TestCase("[2001:0DB8::1]:80",true, "http://[2001:0DB8::1]/")]
public void TryParse_CanParseHostName(string query, bool expectedSuccess, string expectedResult)
{
// Arrange
var parser = new ExtendedUriParser();
// Act
var success = parser.TryParse(query, out var result);
// Assert
Assert.AreEqual(expectedResult, result?.ToString());
Assert.AreEqual(expectedSuccess, success);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,11 @@
// 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.Plugin.Uri.Interfaces
{
public interface IRegistryWrapper
{
string GetRegistryValue(string registryLocation, string valueName);
}
}

View File

@@ -0,0 +1,11 @@
// 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.Plugin.Uri.Interfaces
{
public interface IUriParser
{
bool TryParse(string input, out System.Uri result);
}
}

View File

@@ -0,0 +1,11 @@
// 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.Plugin.Uri.Interfaces
{
public interface IUrlResolver
{
bool IsValidHost(System.Uri uri);
}
}

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,5 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:system="clr-namespace:System;assembly=mscorlib">
<system:String x:Key="Microsoft_plugin_uri_website">Open in browser</system:String>
</ResourceDictionary>

View File

@@ -0,0 +1,179 @@
// 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.Diagnostics;
using System.IO;
using System.Text;
using Microsoft.Plugin.Uri.UriHelper;
using Wox.Infrastructure.Logger;
using Wox.Infrastructure.Storage;
using Wox.Plugin;
namespace Microsoft.Plugin.Uri
{
public class Main : IPlugin, IPluginI18n, IContextMenu, ISavable, IDisposable
{
private readonly ExtendedUriParser _uriParser;
private readonly UriResolver _uriResolver;
private readonly PluginJsonStorage<UriSettings> _storage;
private bool _disposed;
private UriSettings _uriSettings;
private RegisteryWrapper _registeryWrapper;
public Main()
{
_storage = new PluginJsonStorage<UriSettings>();
_uriSettings = _storage.Load();
_uriParser = new ExtendedUriParser();
_uriResolver = new UriResolver();
_registeryWrapper = new RegisteryWrapper();
}
public string BrowserIconPath { get; set; }
public string DefaultIconPath { get; set; }
public PluginInitContext Context { get; protected set; }
public List<ContextMenuResult> LoadContextMenus(Result selectedResult)
{
return new List<ContextMenuResult>(0);
}
public List<Result> Query(Query query)
{
var results = new List<Result>();
if (!string.IsNullOrEmpty(query?.Search)
&& _uriParser.TryParse(query.Search, out var uriResult)
&& _uriResolver.IsValidHost(uriResult))
{
var uriResultString = uriResult.ToString();
results.Add(new Result
{
Title = uriResultString,
SubTitle = Context.API.GetTranslation("Microsoft_plugin_uri_website"),
IcoPath = _uriSettings.ShowBrowserIcon
? BrowserIconPath
: DefaultIconPath,
Action = action =>
{
Process.Start(new ProcessStartInfo(uriResultString)
{
UseShellExecute = true,
});
return true;
},
});
}
return results;
}
public void Init(PluginInitContext context)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
Context.API.ThemeChanged += OnThemeChanged;
UpdateIconPath(Context.API.GetCurrentTheme());
UpdateBrowserIconPath(Context.API.GetCurrentTheme());
}
public string GetTranslatedPluginTitle()
{
return "Url Handler";
}
public string GetTranslatedPluginDescription()
{
return "Handles urls";
}
public void Save()
{
_storage.Save();
}
private void OnThemeChanged(Theme oldtheme, Theme newTheme)
{
UpdateIconPath(newTheme);
UpdateBrowserIconPath(newTheme);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We want to keep the process alive but will log the exception")]
private void UpdateBrowserIconPath(Theme newTheme)
{
try
{
var progId = _registeryWrapper.GetRegistryValue("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice", "ProgId");
var programLocation =
// Resolve App Icon (UWP)
_registeryWrapper.GetRegistryValue("HKEY_CLASSES_ROOT\\" + progId + "\\Application", "ApplicationIcon")
// Resolves default file association icon (UWP + Normal)
?? _registeryWrapper.GetRegistryValue("HKEY_CLASSES_ROOT\\" + progId + "\\DefaultIcon", null);
// "Handles 'Indirect Strings' (UWP programs)"
if (programLocation.StartsWith("@", StringComparison.Ordinal))
{
var directProgramLocationStringBuilder = new StringBuilder(128);
if (NativeMethods.SHLoadIndirectString(programLocation, directProgramLocationStringBuilder, (uint)directProgramLocationStringBuilder.Capacity, IntPtr.Zero) ==
NativeMethods.Hresult.Ok)
{
// Check if there's a postfix with contract-white/contrast-black icon is available and use that instead
var directProgramLocation = directProgramLocationStringBuilder.ToString();
var themeIcon = newTheme == Theme.Light || newTheme == Theme.HighContrastWhite ? "contrast-white" : "contrast-black";
var extension = Path.GetExtension(directProgramLocation);
var themedProgLocation = $"{directProgramLocation.Substring(0, directProgramLocation.Length - extension.Length)}_{themeIcon}{extension}";
BrowserIconPath = File.Exists(themedProgLocation)
? themedProgLocation
: directProgramLocation;
}
}
else
{
var indexOfComma = programLocation.IndexOf(',', StringComparison.Ordinal);
BrowserIconPath = indexOfComma > 0
? programLocation.Substring(0, indexOfComma)
: programLocation;
}
}
catch (Exception e)
{
BrowserIconPath = DefaultIconPath;
Log.Exception("Exception when retreiving icon", e);
}
}
private void UpdateIconPath(Theme theme)
{
if (theme == Theme.Light || theme == Theme.HighContrastWhite)
{
DefaultIconPath = "Images/uri.light.png";
}
else
{
DefaultIconPath = "Images/uri.dark.png";
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
Context.API.ThemeChanged -= OnThemeChanged;
_disposed = true;
}
}
}
}

View File

@@ -0,0 +1,133 @@
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<ProjectGuid>{03276a39-d4e9-417c-8ffd-200b0ee5e871}</ProjectGuid>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>Microsoft.Plugin.Uri</RootNamespace>
<AssemblyName>Microsoft.Plugin.Uri</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Platforms>x64</Platforms>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>..\..\..\..\..\x64\Debug\modules\launcher\Plugins\Microsoft.Plugin.Uri\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
<WarningLevel>4</WarningLevel>
<Optimize>false</Optimize>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<OutputPath>..\..\..\..\..\x64\Release\modules\launcher\Plugins\Microsoft.Plugin.Uri\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x64</PlatformTarget>
<LangVersion>7.3</LangVersion>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
<WarningLevel>4</WarningLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\..\codeAnalysis\GlobalSuppressions.cs">
<Link>GlobalSuppressions.cs</Link>
</Compile>
<AdditionalFiles Include="..\..\..\..\codeAnalysis\StyleCop.json">
<Link>StyleCop.json</Link>
</AdditionalFiles>
</ItemGroup>
<ItemGroup>
<None Remove="Languages\de.xaml" />
<None Remove="Languages\en.xaml" />
<None Remove="Languages\ja.xaml" />
<None Remove="Languages\pl.xaml" />
<None Remove="Languages\tr.xaml" />
<None Remove="Languages\zh-cn.xaml" />
<None Remove="Languages\zh-tw.xaml" />
</ItemGroup>
<ItemGroup>
<Content Include="Languages\zh-tw.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\zh-cn.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\tr.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\pl.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\ja.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\de.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
<Content Include="Languages\en.xaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Wox.Infrastructure\Wox.Infrastructure.csproj" />
<ProjectReference Include="..\..\Wox.Plugin\Wox.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" Version="2020.1.0" />
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="3.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.Runtime" Version="4.3.1" />
</ItemGroup>
<ItemGroup>
<None Update="Images\uri.dark.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Images\uri.light.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
// 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;
using System.Text;
namespace Microsoft.Plugin.Uri
{
internal static class NativeMethods
{
internal enum Hresult : uint
{
Ok = 0x0000,
}
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
internal static extern Hresult SHLoadIndirectString(string pszSource, StringBuilder pszOutBuf, uint cchOutBuf, IntPtr ppvReserved);
}
}

View File

@@ -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.
using Microsoft.Plugin.Uri.Interfaces;
using Microsoft.Win32;
namespace Microsoft.Plugin.Uri
{
public class RegisteryWrapper : IRegistryWrapper
{
public string GetRegistryValue(string registryLocation, string valueName)
{
return Registry.GetValue(registryLocation, valueName, null) as string;
}
}
}

View File

@@ -0,0 +1,43 @@
// 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.Plugin.Uri.Interfaces;
namespace Microsoft.Plugin.Uri.UriHelper
{
public class ExtendedUriParser : IUriParser
{
public bool TryParse(string input, out System.Uri result)
{
if (string.IsNullOrEmpty(input))
{
result = default;
return false;
}
// Handle common cases UriBuilder does not handle
if (input.EndsWith(":", StringComparison.Ordinal)
|| input.EndsWith(".", StringComparison.Ordinal)
|| input.EndsWith(":/", StringComparison.Ordinal))
{
result = default;
return false;
}
try
{
var urlBuilder = new UriBuilder(input);
result = urlBuilder.Uri;
return true;
}
catch (System.UriFormatException)
{
result = default;
return false;
}
}
}
}

View File

@@ -0,0 +1,16 @@
// 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.Plugin.Uri.Interfaces;
namespace Microsoft.Plugin.Uri.UriHelper
{
public class UriResolver : IUrlResolver
{
public bool IsValidHost(System.Uri uri)
{
return true;
}
}
}

View File

@@ -0,0 +1,11 @@
// 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.Plugin.Uri
{
public class UriSettings
{
public bool ShowBrowserIcon { get; set; } = true;
}
}

View File

@@ -0,0 +1,12 @@
{
"ID": "03276A39D4E9417C8FFD200B0EE5E871",
"ActionKeyword": "*",
"Name": "Windows Uri Handler",
"Description": "Handles urls",
"Author": "Microsoft",
"Version": "1.0.0",
"Language": "csharp",
"Website": "http://aka.ms/PowerToys",
"ExecuteFileName": "Microsoft.Plugin.Uri.dll",
"IcoPath": "Images\\uri.dark.png"
}