diff --git a/tools/PI/DevHome.PI/Controls/AddToolControl.xaml b/tools/PI/DevHome.PI/Controls/AddToolControl.xaml
index 672b6c1083..4d59b92304 100644
--- a/tools/PI/DevHome.PI/Controls/AddToolControl.xaml
+++ b/tools/PI/DevHome.PI/Controls/AddToolControl.xaml
@@ -5,6 +5,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
+ xmlns:models="using:DevHome.PI.Models"
+ xmlns:local="using:DevHome.PI.Controls"
mc:Ignorable="d">
@@ -17,21 +20,64 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ x:Name="ToolBrowseButton" x:Uid="ToolBrowseButton" HorizontalAlignment="Left" MinWidth="100" Click="ToolBrowseButton_Click"/>
+ x:Name="ToolPathTextBox" Grid.Column="1" MinWidth="800" Margin="8,0,0,0" IsReadOnly="True" HorizontalAlignment="Stretch"/>
-
+
+
@@ -39,67 +85,43 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Grid.Row="5" HorizontalAlignment="Left" IsOn="True" Margin="0,-6,0,0"/>
-
+
diff --git a/tools/PI/DevHome.PI/Controls/AddToolControl.xaml.cs b/tools/PI/DevHome.PI/Controls/AddToolControl.xaml.cs
index 3dd4b78e56..28c8a1abd6 100644
--- a/tools/PI/DevHome.PI/Controls/AddToolControl.xaml.cs
+++ b/tools/PI/DevHome.PI/Controls/AddToolControl.xaml.cs
@@ -1,24 +1,78 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
using System.Runtime.InteropServices;
+using System.Threading.Tasks;
using DevHome.Common.Extensions;
using DevHome.PI.Helpers;
+using DevHome.PI.Models;
+using IWshRuntimeLibrary;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Serilog;
+using Windows.ApplicationModel;
+using Windows.Management.Deployment;
using Windows.Win32;
using Windows.Win32.UI.Controls.Dialogs;
namespace DevHome.PI.Controls;
-public sealed partial class AddToolControl : UserControl
+public sealed partial class AddToolControl : UserControl, INotifyPropertyChanged
{
- private readonly string invalidToolInfo = CommonHelper.GetLocalizedString("InvalidToolInfoMessage");
- private readonly string messageCloseText = CommonHelper.GetLocalizedString("MessageCloseText");
+ private static readonly ILogger _log = Log.ForContext("SourceContext", nameof(AddToolControl));
+
+ private readonly string _invalidToolInfo = CommonHelper.GetLocalizedString("InvalidToolInfoMessage");
+ private readonly string _messageCloseText = CommonHelper.GetLocalizedString("MessageCloseText");
+
+ // We have 3 sets of operations, and we arbitrarily divide the progress timing into 3 equal segments.
+ private const int ShortcutProcessingEndIndex = 33;
+ private const int PackageProcessingEndIndex = 67;
+
+ private InstalledAppInfo? _selectedApp;
+ private List? _shortcuts;
+ private List? _packages;
+ private List _allApps = [];
+ private int _itemCount;
+
+ public ObservableCollection SortedApps { get; set; } = [];
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private bool _isLoading;
+
+ public bool IsLoading
+ {
+ get => _isLoading;
+ set
+ {
+ _isLoading = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading)));
+ }
+ }
+
+ private int _progressPercentage;
+
+ public int ProgressPercentage
+ {
+ get => _progressPercentage;
+ set
+ {
+ _progressPercentage = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProgressPercentage)));
+ }
+ }
public AddToolControl()
{
InitializeComponent();
+ LoadingProgressTextRing.DataContext = this;
}
private void ToolBrowseButton_Click(object sender, RoutedEventArgs e)
@@ -45,8 +99,6 @@ private void HandleBrowseButton()
lpstrFilter = pFilter,
nFilterIndex = 1,
nMaxFile = 255,
-
- // TODO - This should be the Settings window, not the bar window
hwndOwner = barWindow?.CurrentHwnd ?? Windows.Win32.Foundation.HWND.Null,
};
@@ -57,13 +109,10 @@ private void HandleBrowseButton()
}
}
- if (fileName != string.Empty)
+ if (!string.IsNullOrEmpty(fileName))
{
ToolPathTextBox.Text = fileName;
- if (ToolNameTextBox.Text == string.Empty)
- {
- ToolNameTextBox.Text = System.IO.Path.GetFileNameWithoutExtension(fileName);
- }
+ ToolNameTextBox.Text = Path.GetFileNameWithoutExtension(fileName);
}
return;
@@ -79,7 +128,7 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
ExternalToolsHelper.Instance.AddExternalTool(tool);
var toolRegisteredMessage = CommonHelper.GetLocalizedString("ToolRegisteredMessage", ToolNameTextBox.Text);
- WindowHelper.ShowTimedMessageDialog(this, toolRegisteredMessage, messageCloseText);
+ WindowHelper.ShowTimedMessageDialog(this, toolRegisteredMessage, _messageCloseText);
ClearValues();
}
@@ -87,69 +136,285 @@ private void ClearValues()
{
ToolNameTextBox.Text = string.Empty;
ToolPathTextBox.Text = string.Empty;
- NoneRadio.IsChecked = true;
- PrefixTextBox.Text = string.Empty;
- OtherArgsTextBox.Text = string.Empty;
+ LaunchRadio.IsChecked = true;
+ ArgumentsTextBox.Text = string.Empty;
IsPinnedToggleSwitch.IsOn = true;
+ _selectedApp = null;
}
private ExternalTool? GetCurrentToolDefinition()
{
if (string.IsNullOrEmpty(ToolNameTextBox.Text) || string.IsNullOrEmpty(ToolPathTextBox.Text))
{
- WindowHelper.ShowTimedMessageDialog(this, invalidToolInfo, messageCloseText);
+ WindowHelper.ShowTimedMessageDialog(this, _invalidToolInfo, _messageCloseText);
return null;
}
- var argType = ExternalToolArgType.None;
-
- if (HwndRadio.IsChecked ?? false)
+ var activationType = ToolActivationType.Launch;
+ if (ProtocolRadio.IsChecked ?? false)
{
- argType = ExternalToolArgType.Hwnd;
+ activationType = ToolActivationType.Protocol;
}
- else if (ProcessIdRadio.IsChecked ?? false)
+ else if (_selectedApp is not null && _selectedApp.IsMsix)
{
- argType = ExternalToolArgType.ProcessId;
+ activationType = ToolActivationType.Msix;
}
return new(
ToolNameTextBox.Text,
ToolPathTextBox.Text,
- argType,
- PrefixTextBox.Text ?? string.Empty,
- OtherArgsTextBox.Text ?? string.Empty,
+ activationType,
+ ArgumentsTextBox.Text,
+ _selectedApp?.AppUserModelId ?? string.Empty,
+ _selectedApp?.IconFilePath ?? string.Empty,
IsPinnedToggleSwitch.IsOn);
}
- private void UpdateSampleCommandline(object sender, TextChangedEventArgs e)
+ private void CancelButton_Click(object sender, RoutedEventArgs e)
+ {
+ ClearValues();
+ }
+
+ private async void AppListButton_Click(object sender, RoutedEventArgs e)
+ {
+ _allApps.Clear();
+ SortedApps.Clear();
+
+ _itemCount = GetShortcuts();
+ if (_itemCount == 0)
+ {
+ _log.Error("Error getting _shortcuts");
+ }
+
+ var packageCount = GetPackages();
+ if (packageCount == 0)
+ {
+ _log.Error("Error getting _packages");
+ }
+
+ _itemCount += packageCount;
+ if (_itemCount == 0)
+ {
+ _log.Error("Error getting list of installed apps");
+ return;
+ }
+
+ // We get most of the data on a background thread, which
+ // reports intermittent progress.
+ IsLoading = true;
+ var progress = new Progress(percent =>
+ {
+ // Update the progress report.
+ LoadingProgressTextRing.Value = percent;
+ });
+
+ await ProcessItemsAsync(progress);
+
+ foreach (var app in _allApps)
+ {
+ SortedApps.Add(app);
+ }
+
+ IsLoading = false;
+ }
+
+ private int GetShortcuts()
+ {
+ int count;
+
+ // Search for .lnk files in the per-user and all-users Start Menu Programs directories.
+ // %APPDATA%\Microsoft\Windows\Start Menu\Programs
+ var startMenuProgramsPath =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs");
+
+ // %ProgramData%\Microsoft\Windows\Start Menu\Programs
+ var commonStartMenuProgramsPath =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), "Programs");
+
+ _shortcuts = [];
+ if (Directory.Exists(startMenuProgramsPath))
+ {
+ _shortcuts.AddRange(Directory.GetFiles(startMenuProgramsPath, "*.lnk", SearchOption.AllDirectories));
+ }
+
+ if (Directory.Exists(commonStartMenuProgramsPath))
+ {
+ _shortcuts.AddRange(Directory.GetFiles(commonStartMenuProgramsPath, "*.lnk", SearchOption.AllDirectories));
+ }
+
+ count = _shortcuts?.Count ?? 0;
+ return count;
+ }
+
+ private int GetPackages()
{
- UpdateSampleCommandline();
+ var count = 0;
+ var packageManager = new PackageManager();
+ _packages = packageManager.FindPackagesForUserWithPackageTypes(string.Empty, PackageTypes.Main).ToList();
+ if (_packages is not null)
+ {
+ count = _packages.Count;
+ }
+
+ return count;
}
- private void UpdateSampleCommandline(object sender, RoutedEventArgs e)
+ private async Task ProcessItemsAsync(IProgress progress)
{
- UpdateSampleCommandline();
+ // Process all the shortcut files.
+ var currentCount = 0;
+ for (var i = 0; i < _shortcuts?.Count; i++)
+ {
+ await Task.Run(() => ProcessShortcut(_shortcuts[i]));
+
+ // Report progress.
+ currentCount++;
+ var percentComplete = (i + 1) * ShortcutProcessingEndIndex / _itemCount;
+ progress.Report(percentComplete);
+ }
+
+ for (var j = 0; j < _packages?.Count; j++)
+ {
+ await Task.Run(() => ProcessPackage(_packages[j]));
+
+ // Report progress.
+ currentCount++;
+ var percentComplete = ShortcutProcessingEndIndex + ((j + 1) * ShortcutProcessingEndIndex / _itemCount);
+ progress.Report(percentComplete);
+ }
+
+ _allApps = _allApps.OrderBy(app => app.Name).ToList();
+
+ // We get the icon data on the UI thread, because BitmapImages must be created on the UI thread.
+ for (var k = 0; k < _allApps.Count; k++)
+ {
+ var app = _allApps[k];
+ if (app.IsMsix)
+ {
+ if (app.AppPackage is not null)
+ {
+ try
+ {
+ // The package might be in a bad state, and accessing its
+ // properties might throw an exception.
+ app.Icon = new BitmapImage(app.AppPackage.Logo);
+ app.IconFilePath = app.AppPackage.Logo.LocalPath;
+ }
+ catch
+ {
+ _log.Error("Error getting icon from package");
+ }
+ }
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(app.TargetPath))
+ {
+ app.Icon = WindowHelper.GetBitmapImageFromFile(app.TargetPath);
+ }
+ }
+
+ currentCount++;
+ var percentComplete = PackageProcessingEndIndex + ((k + 1) * ShortcutProcessingEndIndex / _itemCount);
+ progress.Report(percentComplete);
+
+ // Yield to make sure the UI thread can update the progress output.
+ await Task.Delay(1);
+ }
}
- private void UpdateSampleCommandline()
+ public void ProcessShortcut(string filePath)
{
- if (SampleCommandTextBox is null)
+ var appName = Path.GetFileNameWithoutExtension(filePath);
+
+ // Exclude Microsoft Virtual Desktop _shortcuts.
+ if (appName.Contains("Microsoft Virtual Desktop", StringComparison.OrdinalIgnoreCase))
{
- // The window is still initializing.
return;
}
- var tool = GetCurrentToolDefinition();
- if (tool is null)
+ var wshShell = new WshShell();
+ if (wshShell.CreateShortcut(filePath) is not IWshShortcut shortcut)
{
+ _log.Error("Error getting shortcut");
return;
}
- SampleCommandTextBox.Text = tool.CreateFullCommandLine(123, (Windows.Win32.Foundation.HWND)123);
+ // Proceed with using the shortcut object.
+ var targetPath = shortcut.TargetPath;
+
+ // Exclude _shortcuts that point to empty targets or filesystem folders.
+ if (string.IsNullOrEmpty(targetPath) || Directory.Exists(targetPath))
+ {
+ return;
+ }
+
+ // Exclude *.chm, *.url, *.html, *.ico targets.
+ var extension = Path.GetExtension(targetPath);
+ if (extension.Equals(".chm", StringComparison.OrdinalIgnoreCase)
+ || extension.Equals(".url", StringComparison.OrdinalIgnoreCase)
+ || extension.Equals(".html", StringComparison.OrdinalIgnoreCase)
+ || extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ // Add the app for this shortcut to the list.
+ _allApps.Add(new InstalledAppInfo
+ {
+ Name = appName,
+ ShortcutFilePath = filePath,
+ TargetPath = targetPath,
+ });
}
- private void CancelButton_Click(object sender, RoutedEventArgs e)
+ public void ProcessPackage(Package package)
{
- ClearValues();
+ var op = package.GetAppListEntriesAsync();
+ var task = op.AsTask();
+ task.Wait();
+ var entries = task.Result;
+
+ // We only get the icon for apps that have an AppListEntry,
+ // because the others are not likely to be activatable from the UI.
+ if (entries.Count > 0)
+ {
+ // We only get the icon for the first AppListEntry, ignoring MAPs.
+ // Note we use Package.Logo, not AppListEntry.DisplayInfo.GetLogo
+ // because the latter doesn't get consistently-sized icons.
+ var appListEntry = entries[0];
+
+ // Add the app for this package to the list.
+ _allApps.Add(new InstalledAppInfo
+ {
+ Name = package.DisplayName,
+ AppUserModelId = appListEntry.AppUserModelId,
+ AppPackage = package,
+ });
+ }
+ }
+
+ private void AppsListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (AppsListView.SelectedItem is InstalledAppInfo app)
+ {
+ _selectedApp = app;
+ if (!string.IsNullOrEmpty(app.TargetPath))
+ {
+ ToolPathTextBox.Text = app.TargetPath;
+ }
+ else if (!string.IsNullOrEmpty(app.AppUserModelId))
+ {
+ ToolPathTextBox.Text = app.AppUserModelId;
+ }
+
+ ToolNameTextBox.Text = app.Name;
+ }
+ else
+ {
+ _selectedApp = null;
+ ToolPathTextBox.Text = string.Empty;
+ ToolNameTextBox.Text = string.Empty;
+ }
}
}
diff --git a/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml b/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml
new file mode 100644
index 0000000000..20b3df7c18
--- /dev/null
+++ b/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml.cs b/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml.cs
new file mode 100644
index 0000000000..f9c3f6e7de
--- /dev/null
+++ b/tools/PI/DevHome.PI/Controls/ProgressTextRing.xaml.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.ComponentModel;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace DevHome.PI.Controls;
+
+public sealed partial class ProgressTextRing : UserControl, INotifyPropertyChanged
+{
+ public ProgressTextRing()
+ {
+ InitializeComponent();
+ }
+
+ public static readonly DependencyProperty IsActiveProperty =
+ DependencyProperty.Register(nameof(IsActive), typeof(bool), typeof(ProgressTextRing), new PropertyMetadata(false, OnIsActivePropertyChanged));
+
+ public bool IsActive
+ {
+ get => (bool)GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ private static void OnIsActivePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (ProgressTextRing)d;
+ control.OnPropertyChanged(nameof(TextBlockVisibility));
+ }
+
+ public static readonly DependencyProperty DiameterProperty =
+ DependencyProperty.Register(nameof(Diameter), typeof(double), typeof(ProgressTextRing), new PropertyMetadata(0.0, OnDiameterPropertyChanged));
+
+ public double Diameter
+ {
+ get => (double)GetValue(DiameterProperty);
+ set => SetValue(DiameterProperty, value);
+ }
+
+ private static void OnDiameterPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (ProgressTextRing)d;
+ control.OnPropertyChanged(nameof(TextBlockFontSize));
+ }
+
+ public static readonly DependencyProperty ValueProperty =
+ DependencyProperty.Register(nameof(Value), typeof(double), typeof(ProgressTextRing), new PropertyMetadata(0.0, OnValuePropertyChanged));
+
+ public double Value
+ {
+ get => (double)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ private static void OnValuePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (ProgressTextRing)d;
+ control.OnPropertyChanged(nameof(PercentageText));
+ }
+
+ private Visibility TextBlockVisibility => IsActive ? Visibility.Visible : Visibility.Collapsed;
+
+ private double TextBlockFontSize => FontSize > 0 ? FontSize : Diameter / 3.6;
+
+ public string PercentageText => $"{Value}%";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void OnPropertyChanged(string propertyName)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/tools/PI/DevHome.PI/DevHome.PI.csproj b/tools/PI/DevHome.PI/DevHome.PI.csproj
index 4ddc6e5e8a..d8f4da35ac 100644
--- a/tools/PI/DevHome.PI/DevHome.PI.csproj
+++ b/tools/PI/DevHome.PI/DevHome.PI.csproj
@@ -29,6 +29,7 @@
+
@@ -99,6 +100,18 @@
+
+
+ tlbimp
+ 0
+ 1
+ f935dc20-1cf0-11d0-adb9-00c04fd58a0b
+ 0
+ false
+ true
+
+
+