diff --git a/MainWindow.xaml b/MainWindow.xaml index 16d2eea..e7d1b37 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -69,6 +69,8 @@ + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 451496b..b810677 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -22,6 +22,7 @@ public partial class MainWindow : Window private List? selectedPath; private string? loadedAutoIconPath; private System.Windows.Point dragStartPoint; + private sealed record TargetValidationIssue(string ItemPath, string Problem); public MainWindow() { @@ -1343,6 +1344,159 @@ public partial class MainWindow : Window ConfigService.OpenConfigFolder(); } + private void ValidateTargets_Click(object sender, RoutedEventArgs e) + { + if (controller is null) + { + return; + } + + ApplyConfigFields(); + List issues = []; + ValidateTargets(controller.Config.Items, [], issues); + + if (issues.Count == 0) + { + StatusText.Text = "Validation complete. No broken targets found."; + MessageBox.Show("No broken targets found.", "Taskbar Launcher"); + return; + } + + string message = $"Found {issues.Count} possible broken target(s):\n\n" + + string.Join("\n", issues.Take(20).Select(issue => $"- {issue.ItemPath}: {issue.Problem}")); + if (issues.Count > 20) + { + message += $"\n\nShowing first 20 of {issues.Count}."; + } + + StatusText.Text = $"Validation found {issues.Count} possible broken target(s)."; + MessageBox.Show(message, "Taskbar Launcher"); + } + + private static void ValidateTargets(IEnumerable items, List parentNames, List issues) + { + foreach (LauncherItem item in items) + { + List itemPath = [.. parentNames, string.IsNullOrWhiteSpace(item.Title) ? "Untitled" : item.Title]; + if (!item.IsDisabled && TryGetTargetIssue(item, out string? problem) && problem is not null) + { + issues.Add(new TargetValidationIssue(string.Join(" > ", itemPath), problem)); + } + + if (item.Children.Count > 0) + { + ValidateTargets(item.Children, itemPath, issues); + } + } + } + + private static bool TryGetTargetIssue(LauncherItem item, out string? problem) + { + problem = null; + + if (item.Type == LauncherItemType.Menu || item.IsDisabled) + { + return false; + } + + if (string.IsNullOrWhiteSpace(item.Target)) + { + problem = "Target is blank."; + return true; + } + + string target = Environment.ExpandEnvironmentVariables(item.Target.Trim()); + switch (item.Type) + { + case LauncherItemType.Folder: + if (!Directory.Exists(target)) + { + problem = "Folder does not exist."; + return true; + } + + return false; + + case LauncherItemType.Website: + if (!Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + problem = "Website target is not a valid http or https URL."; + return true; + } + + return false; + + case LauncherItemType.App: + if (!File.Exists(target) && !CommandExistsOnPath(target)) + { + problem = "Application or shortcut was not found."; + return true; + } + + return false; + + case LauncherItemType.File: + if (!File.Exists(target)) + { + problem = "File does not exist."; + return true; + } + + return false; + + default: + problem = "Item type is not recognized."; + return true; + } + } + + private static bool CommandExistsOnPath(string target) + { + if (target.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0) + { + return false; + } + + string? pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + { + return false; + } + + string extension = Path.GetExtension(target); + IEnumerable candidateNames = string.IsNullOrWhiteSpace(extension) + ? GetExecutableCandidateNames(target) + : [target]; + + foreach (string directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (string candidateName in candidateNames) + { + string candidatePath = Path.Combine(directory, candidateName); + if (File.Exists(candidatePath)) + { + return true; + } + } + } + + return false; + } + + private static IEnumerable GetExecutableCandidateNames(string target) + { + string? pathExtValue = Environment.GetEnvironmentVariable("PATHEXT"); + string[] extensions = string.IsNullOrWhiteSpace(pathExtValue) + ? [".EXE", ".CMD", ".BAT", ".COM"] + : pathExtValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (string extension in extensions) + { + yield return target + extension; + } + } + private void About_Click(object sender, RoutedEventArgs e) { AboutWindow aboutWindow = new() diff --git a/README.md b/README.md index b43f9eb..ee1e764 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Download the compiled Windows release from: - Right-click launcher entries to pin or unpin them from the top - Right-click launcher entries or Settings items to temporarily disable them - Right-click app/file entries to open their containing folder +- Validate missing app, file, and folder targets from Settings - Right-click item duplication in Settings - Right-click items in Settings to pin them to the top of their menu - Per-item icon and display mode diff --git a/TaskbarLauncher.csproj b/TaskbarLauncher.csproj index 9cf2d89..44749eb 100644 --- a/TaskbarLauncher.csproj +++ b/TaskbarLauncher.csproj @@ -12,8 +12,8 @@ Ray Berezowski Assets\AppIcon.ico 1.0.1 - 1.0.1.9 - 1.0.1.9 + 1.0.1.10 + 1.0.1.10 1.0.1 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ae63b9f..4998bae 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -41,6 +41,7 @@ From Settings you can: - Right-click an item and choose **Pin to Top** to show it first in its menu - Right-click an item and choose **Disable Item** or **Enable Item** to hide or restore it - Right-click an app or file item and choose **Open Containing Folder** +- Use **Config... > Validate Targets** to check for missing app, file, and folder targets - Set per-item icon sizes - Back up and restore `config.json` - Enable Start with Windows