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