diff --git a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
index d533e18..1a318eb 100644
--- a/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
+++ b/LanMontainDesktop/ComponentSystem/BuiltInComponentIds.cs
@@ -3,4 +3,5 @@ namespace LanMontainDesktop.ComponentSystem;
public static class BuiltInComponentIds
{
public const string Clock = "Clock";
+ public const string Blank2x4 = "Blank2x4";
}
diff --git a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
index 18ffc32..8ff2c83 100644
--- a/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMontainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -29,6 +29,15 @@ public sealed class ComponentRegistry
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: true,
+ AllowDesktopPlacement: true),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.Blank2x4,
+ "Blank 2x4",
+ "Rectangle",
+ "Layout",
+ MinWidthCells: 2,
+ MinHeightCells: 4,
+ AllowStatusBarPlacement: false,
AllowDesktopPlacement: true)
};
diff --git a/LanMontainDesktop/LanMontainDesktop.csproj b/LanMontainDesktop/LanMontainDesktop.csproj
index 99e93c0..0d1073d 100644
--- a/LanMontainDesktop/LanMontainDesktop.csproj
+++ b/LanMontainDesktop/LanMontainDesktop.csproj
@@ -28,7 +28,9 @@
+
+
diff --git a/LanMontainDesktop/Localization/en-US.json b/LanMontainDesktop/Localization/en-US.json
index 4de8237..37e3b4b 100644
--- a/LanMontainDesktop/Localization/en-US.json
+++ b/LanMontainDesktop/Localization/en-US.json
@@ -74,9 +74,16 @@
"filepicker.video_files": "Video files",
"common.day": "Day",
"common.night": "Night",
+ "common.back": "Back",
"common.close": "Close",
"common.recommended": "Recommended",
"common.monet": "Monet",
+ "desktop.page_index_format": "Desktop {0}",
+ "launcher.title": "App Launcher",
+ "launcher.subtitle": "Apps and folders from Windows Start Menu",
+ "launcher.empty": "No Start Menu entries found.",
+ "launcher.empty_folder": "This folder is empty.",
+ "launcher.folder_items_format": "{0} apps",
"button.component_library": "Component Library",
"tooltip.component_library": "Component Library",
"component_library.title": "Component Library",
diff --git a/LanMontainDesktop/Localization/zh-CN.json b/LanMontainDesktop/Localization/zh-CN.json
index 6f72fec..5dae517 100644
--- a/LanMontainDesktop/Localization/zh-CN.json
+++ b/LanMontainDesktop/Localization/zh-CN.json
@@ -12,7 +12,7 @@
"settings.nav.status_bar": "状态栏",
"settings.nav.region": "地区",
"settings.wallpaper.title": "壁纸",
- "settings.wallpaper.description": "选择图片或视频后可立刻设为应用窗口壁纸。",
+ "settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.pick_button": "浏览文件",
@@ -28,10 +28,10 @@
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.cleared": "背景已恢复为纯色。",
"settings.wallpaper.default_status": "当前使用纯色背景。",
- "settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,使用纯色背景。",
+ "settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
"settings.wallpaper.restored": "已恢复保存的壁纸。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
- "settings.wallpaper.restore_failed": "恢复已保存壁纸失败,使用纯色背景。",
+ "settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
@@ -54,8 +54,8 @@
"settings.color.monet_refreshed": "莫奈色已刷新。",
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
- "settings.color.theme_updated_wallpaper": "壁纸更新,莫奈色已刷新。",
- "settings.color.theme_updated_video": "视频壁纸更新,主题色已刷新。",
+ "settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
+ "settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
@@ -74,8 +74,20 @@
"filepicker.video_files": "视频文件",
"common.day": "日间",
"common.night": "夜间",
+ "common.back": "返回",
+ "common.close": "关闭",
"common.recommended": "推荐",
"common.monet": "莫奈",
+ "desktop.page_index_format": "桌面 {0}",
+ "launcher.title": "应用启动台",
+ "launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
+ "launcher.empty": "未找到开始菜单条目。",
+ "launcher.empty_folder": "此文件夹为空。",
+ "launcher.folder_items_format": "{0} 个应用",
+ "button.component_library": "组件库",
+ "tooltip.component_library": "组件库",
+ "component_library.title": "组件库",
+ "component_library.empty": "暂无组件,后续会在这里显示。",
"placement.fill": "填充",
"placement.fit": "适应",
"placement.stretch": "拉伸",
diff --git a/LanMontainDesktop/Models/AppSettingsSnapshot.cs b/LanMontainDesktop/Models/AppSettingsSnapshot.cs
index b027c37..78647fe 100644
--- a/LanMontainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMontainDesktop/Models/AppSettingsSnapshot.cs
@@ -29,4 +29,8 @@ public sealed class AppSettingsSnapshot
public bool EnableDynamicTaskbarActions { get; set; } = false;
public string TaskbarLayoutMode { get; set; } = "BottomFullRowMacStyle";
+
+ public int DesktopPageCount { get; set; } = 1;
+
+ public int CurrentDesktopSurfaceIndex { get; set; } = 0;
}
diff --git a/LanMontainDesktop/Models/StartMenuAppEntry.cs b/LanMontainDesktop/Models/StartMenuAppEntry.cs
new file mode 100644
index 0000000..5dbb3dc
--- /dev/null
+++ b/LanMontainDesktop/Models/StartMenuAppEntry.cs
@@ -0,0 +1,12 @@
+namespace LanMontainDesktop.Models;
+
+public sealed class StartMenuAppEntry
+{
+ public required string DisplayName { get; init; }
+
+ public required string FilePath { get; init; }
+
+ public required string RelativePath { get; init; }
+
+ public byte[]? IconPngBytes { get; init; }
+}
diff --git a/LanMontainDesktop/Models/StartMenuFolderNode.cs b/LanMontainDesktop/Models/StartMenuFolderNode.cs
new file mode 100644
index 0000000..1c7b652
--- /dev/null
+++ b/LanMontainDesktop/Models/StartMenuFolderNode.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LanMontainDesktop.Models;
+
+public sealed class StartMenuFolderNode
+{
+ public StartMenuFolderNode(string name, string relativePath)
+ {
+ Name = name;
+ RelativePath = relativePath;
+ }
+
+ public string Name { get; }
+
+ public string RelativePath { get; }
+
+ public List Folders { get; } = [];
+
+ public List Apps { get; } = [];
+
+ public int TotalAppCount => Apps.Count + Folders.Sum(folder => folder.TotalAppCount);
+}
+
diff --git a/LanMontainDesktop/Services/GlassEffectService.cs b/LanMontainDesktop/Services/GlassEffectService.cs
index 0df5d16..092a54d 100644
--- a/LanMontainDesktop/Services/GlassEffectService.cs
+++ b/LanMontainDesktop/Services/GlassEffectService.cs
@@ -41,17 +41,17 @@ public static class GlassEffectService
// 面板颜色 - 使用 Mica 材质
resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2,
+ Color.FromArgb(context.IsNightMode ? (byte)0xF0 : (byte)0xF8,
micaBackground.R, micaBackground.G, micaBackground.B));
resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(
Color.FromArgb(0x1F, neutralElevated.R, neutralElevated.G, neutralElevated.B));
resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF5,
+ Color.FromArgb(context.IsNightMode ? (byte)0xF4 : (byte)0xFB,
micaElevated.R, micaElevated.G, micaElevated.B));
resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(
Color.FromArgb(0x29, neutralElevated.R, neutralElevated.G, neutralElevated.B));
resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xCC : (byte)0xE6,
+ Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2,
micaBackground.R, micaBackground.G, micaBackground.B));
// 模糊半径(Mica 不需要强模糊)
@@ -60,9 +60,9 @@ public static class GlassEffectService
resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 30.0 : 40.0;
// 不透明度(Mica 材质接近不透明)
- resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.95 : 0.98;
- resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 0.97 : 0.99;
- resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.85 : 0.92;
+ resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.99 : 1.0;
+ resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 1.0 : 1.0;
+ resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.94 : 0.97;
resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.01 : 0.008;
}
}
diff --git a/LanMontainDesktop/Services/UwpManifestIconResolver.cs b/LanMontainDesktop/Services/UwpManifestIconResolver.cs
new file mode 100644
index 0000000..3547a2d
--- /dev/null
+++ b/LanMontainDesktop/Services/UwpManifestIconResolver.cs
@@ -0,0 +1,475 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+
+namespace LanMontainDesktop.Services;
+
+[SupportedOSPlatform("windows")]
+internal static class UwpManifestIconResolver
+{
+ private const int ErrorSuccess = 0;
+ private const int ErrorInsufficientBuffer = 122;
+
+ private static readonly Regex ScaleRegex =
+ new(@"scale-(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex TargetSizeRegex =
+ new(@"targetsize-(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly string[] ManifestLogoAttributePriority =
+ [
+ "Square44x44Logo",
+ "Square150x150Logo",
+ "Square310x310Logo",
+ "Square71x71Logo",
+ "Wide310x150Logo",
+ "Logo",
+ "SmallLogo"
+ ];
+
+ private static readonly Dictionary IconCache = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly object CacheLock = new();
+
+ public static bool TryGetIconPngBytesFromAumid(string aumid, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ if (string.IsNullOrWhiteSpace(aumid))
+ {
+ return false;
+ }
+
+ lock (CacheLock)
+ {
+ if (IconCache.TryGetValue(aumid, out var cached))
+ {
+ pngBytes = cached;
+ return cached is not null;
+ }
+ }
+
+ if (!TrySplitAumid(aumid, out var packageFamilyName, out var appId))
+ {
+ return false;
+ }
+
+ foreach (var installLocation in GetPackageInstallLocations(packageFamilyName))
+ {
+ if (TryExtractIconFromManifestInstallLocation(installLocation, appId, out pngBytes))
+ {
+ lock (CacheLock)
+ {
+ IconCache[aumid] = pngBytes;
+ }
+
+ return true;
+ }
+ }
+
+ lock (CacheLock)
+ {
+ IconCache[aumid] = null;
+ }
+
+ return false;
+ }
+
+ private static bool TrySplitAumid(string aumid, out string packageFamilyName, out string appId)
+ {
+ packageFamilyName = string.Empty;
+ appId = string.Empty;
+ var separatorIndex = aumid.IndexOf('!');
+ if (separatorIndex <= 0 || separatorIndex >= aumid.Length - 1)
+ {
+ return false;
+ }
+
+ packageFamilyName = aumid[..separatorIndex].Trim();
+ appId = aumid[(separatorIndex + 1)..].Trim();
+ return !string.IsNullOrWhiteSpace(packageFamilyName) && !string.IsNullOrWhiteSpace(appId);
+ }
+
+ private static IReadOnlyList GetPackageInstallLocations(string packageFamilyName)
+ {
+ var fromApi = GetPackageInstallLocationsByFamilyApi(packageFamilyName);
+ return fromApi.Count > 0
+ ? fromApi
+ : GetPackageInstallLocationsByDirectoryScan(packageFamilyName);
+ }
+
+ private static IReadOnlyList GetPackageInstallLocationsByFamilyApi(string packageFamilyName)
+ {
+ var results = new List<(string PackageFullName, string InstallLocation)>();
+
+ uint packageCount = 0;
+ uint bufferLength = 0;
+ var firstCall = GetPackagesByPackageFamily(
+ packageFamilyName,
+ ref packageCount,
+ null,
+ ref bufferLength,
+ null);
+ if (firstCall != ErrorInsufficientBuffer && firstCall != ErrorSuccess)
+ {
+ return [];
+ }
+
+ if (packageCount == 0)
+ {
+ return [];
+ }
+
+ var packageFullNamePointers = new IntPtr[packageCount];
+ var packageNamesBuffer = new char[Math.Max(1, (int)bufferLength)];
+ var secondCall = GetPackagesByPackageFamily(
+ packageFamilyName,
+ ref packageCount,
+ packageFullNamePointers,
+ ref bufferLength,
+ packageNamesBuffer);
+ if (secondCall != ErrorSuccess)
+ {
+ return [];
+ }
+
+ for (var i = 0; i < packageCount; i++)
+ {
+ var packageFullName = Marshal.PtrToStringUni(packageFullNamePointers[i]);
+ if (string.IsNullOrWhiteSpace(packageFullName))
+ {
+ continue;
+ }
+
+ uint pathLength = 0;
+ var getPathFirst = GetPackagePathByFullName(packageFullName, ref pathLength, null);
+ if (getPathFirst != ErrorInsufficientBuffer || pathLength == 0)
+ {
+ continue;
+ }
+
+ var pathBuilder = new StringBuilder((int)pathLength);
+ var getPathSecond = GetPackagePathByFullName(packageFullName, ref pathLength, pathBuilder);
+ if (getPathSecond != ErrorSuccess)
+ {
+ continue;
+ }
+
+ var installPath = pathBuilder.ToString().TrimEnd('\0').Trim();
+ if (string.IsNullOrWhiteSpace(installPath) || !Directory.Exists(installPath))
+ {
+ continue;
+ }
+
+ results.Add((packageFullName, installPath));
+ }
+
+ return results
+ .OrderByDescending(item => ParsePackageVersion(item.PackageFullName))
+ .Select(item => item.InstallLocation)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static IReadOnlyList GetPackageInstallLocationsByDirectoryScan(string packageFamilyName)
+ {
+ if (!TrySplitPackageFamilyName(packageFamilyName, out var packageName, out var publisherId))
+ {
+ return [];
+ }
+
+ var windowsAppsDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
+ "WindowsApps");
+
+ if (!Directory.Exists(windowsAppsDirectory))
+ {
+ return [];
+ }
+
+ try
+ {
+ var directories = Directory
+ .EnumerateDirectories(windowsAppsDirectory)
+ .Where(directoryPath =>
+ {
+ var directoryName = Path.GetFileName(directoryPath);
+ if (string.IsNullOrWhiteSpace(directoryName))
+ {
+ return false;
+ }
+
+ return directoryName.StartsWith(packageName + "_", StringComparison.OrdinalIgnoreCase) &&
+ directoryName.Contains("__" + publisherId, StringComparison.OrdinalIgnoreCase);
+ })
+ .OrderByDescending(directoryPath => ParsePackageVersion(Path.GetFileName(directoryPath)))
+ .ToList();
+
+ return directories;
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static bool TrySplitPackageFamilyName(string packageFamilyName, out string packageName, out string publisherId)
+ {
+ packageName = string.Empty;
+ publisherId = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(packageFamilyName))
+ {
+ return false;
+ }
+
+ var separatorIndex = packageFamilyName.LastIndexOf('_');
+ if (separatorIndex <= 0 || separatorIndex >= packageFamilyName.Length - 1)
+ {
+ return false;
+ }
+
+ packageName = packageFamilyName[..separatorIndex].Trim();
+ publisherId = packageFamilyName[(separatorIndex + 1)..].Trim();
+ return !string.IsNullOrWhiteSpace(packageName) && !string.IsNullOrWhiteSpace(publisherId);
+ }
+
+ private static Version ParsePackageVersion(string? packageIdentity)
+ {
+ if (string.IsNullOrWhiteSpace(packageIdentity))
+ {
+ return new Version(0, 0);
+ }
+
+ var identity = packageIdentity.Trim();
+ var firstUnderscore = identity.IndexOf('_');
+ if (firstUnderscore < 0 || firstUnderscore >= identity.Length - 1)
+ {
+ return new Version(0, 0);
+ }
+
+ var secondUnderscore = identity.IndexOf('_', firstUnderscore + 1);
+ if (secondUnderscore < 0)
+ {
+ return new Version(0, 0);
+ }
+
+ var versionText = identity[(firstUnderscore + 1)..secondUnderscore];
+ return Version.TryParse(versionText, out var version)
+ ? version
+ : new Version(0, 0);
+ }
+
+ private static bool TryExtractIconFromManifestInstallLocation(string installLocation, string appId, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ var manifestPath = Path.Combine(installLocation, "AppxManifest.xml");
+ if (!File.Exists(manifestPath))
+ {
+ return false;
+ }
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(manifestPath);
+ }
+ catch
+ {
+ return false;
+ }
+
+ var applicationNodes = document
+ .Descendants()
+ .Where(node => string.Equals(node.Name.LocalName, "Application", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (applicationNodes.Count == 0)
+ {
+ return false;
+ }
+
+ var applicationNode = applicationNodes.FirstOrDefault(node =>
+ string.Equals(
+ node.Attributes().FirstOrDefault(attr => string.Equals(attr.Name.LocalName, "Id", StringComparison.OrdinalIgnoreCase))?.Value,
+ appId,
+ StringComparison.OrdinalIgnoreCase)) ?? applicationNodes.First();
+
+ var logoCandidates = new List();
+ CollectManifestLogoCandidates(applicationNode, logoCandidates);
+
+ foreach (var rawLogoPath in logoCandidates.Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ foreach (var candidateFilePath in EnumerateManifestLogoFiles(installLocation, rawLogoPath))
+ {
+ if (TryConvertImageFileToPngBytes(candidateFilePath, out pngBytes))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static void CollectManifestLogoCandidates(XElement applicationNode, List output)
+ {
+ foreach (var node in applicationNode.DescendantsAndSelf())
+ {
+ var localName = node.Name.LocalName;
+ if (!string.Equals(localName, "VisualElements", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(localName, "DefaultTile", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ foreach (var attributeName in ManifestLogoAttributePriority)
+ {
+ var attribute = node
+ .Attributes()
+ .FirstOrDefault(attr => string.Equals(attr.Name.LocalName, attributeName, StringComparison.OrdinalIgnoreCase));
+ if (attribute is not null && !string.IsNullOrWhiteSpace(attribute.Value))
+ {
+ output.Add(attribute.Value.Trim());
+ }
+ }
+ }
+ }
+
+ private static IEnumerable EnumerateManifestLogoFiles(string installLocation, string rawLogoPath)
+ {
+ var normalizedAssetPath = NormalizeManifestAssetPath(rawLogoPath);
+ if (string.IsNullOrWhiteSpace(normalizedAssetPath))
+ {
+ return [];
+ }
+
+ var baseCandidatePath = Path.GetFullPath(Path.Combine(installLocation, normalizedAssetPath));
+ var files = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ if (File.Exists(baseCandidatePath))
+ {
+ files.Add(baseCandidatePath);
+ }
+
+ var directoryPath = Path.GetDirectoryName(baseCandidatePath);
+ if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
+ {
+ return files;
+ }
+
+ var baseName = Path.GetFileNameWithoutExtension(baseCandidatePath);
+ var extension = Path.GetExtension(baseCandidatePath);
+ var searchPattern = string.IsNullOrWhiteSpace(extension)
+ ? baseName + ".*"
+ : baseName + "*" + extension;
+
+ try
+ {
+ foreach (var filePath in Directory.EnumerateFiles(directoryPath, searchPattern, SearchOption.TopDirectoryOnly))
+ {
+ files.Add(filePath);
+ }
+ }
+ catch
+ {
+ // ignore inaccessible folders
+ }
+
+ return files
+ .OrderByDescending(ScoreManifestIconCandidate)
+ .ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static string NormalizeManifestAssetPath(string rawValue)
+ {
+ if (string.IsNullOrWhiteSpace(rawValue))
+ {
+ return string.Empty;
+ }
+
+ var cleaned = rawValue.Trim().Trim('"');
+ if (cleaned.StartsWith("ms-resource:", StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Empty;
+ }
+
+ if (cleaned.StartsWith("ms-appx:///", StringComparison.OrdinalIgnoreCase))
+ {
+ cleaned = cleaned["ms-appx:///".Length..];
+ }
+
+ cleaned = cleaned
+ .Replace('/', Path.DirectorySeparatorChar)
+ .Replace('\\', Path.DirectorySeparatorChar)
+ .TrimStart(Path.DirectorySeparatorChar);
+
+ return cleaned;
+ }
+
+ private static int ScoreManifestIconCandidate(string filePath)
+ {
+ var score = 0;
+ var fileName = Path.GetFileName(filePath);
+
+ var targetSizeMatch = TargetSizeRegex.Match(fileName);
+ if (targetSizeMatch.Success && int.TryParse(targetSizeMatch.Groups["n"].Value, out var targetSize))
+ {
+ score += targetSize * 100;
+ }
+
+ var scaleMatch = ScaleRegex.Match(fileName);
+ if (scaleMatch.Success && int.TryParse(scaleMatch.Groups["n"].Value, out var scale))
+ {
+ score += scale * 10;
+ }
+
+ if (fileName.Contains("altform-unplated", StringComparison.OrdinalIgnoreCase))
+ {
+ score += 300;
+ }
+
+ if (Path.GetExtension(fileName).Equals(".png", StringComparison.OrdinalIgnoreCase))
+ {
+ score += 80;
+ }
+
+ return score;
+ }
+
+ private static bool TryConvertImageFileToPngBytes(string filePath, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ try
+ {
+ using var image = Image.FromFile(filePath);
+ using var stream = new MemoryStream();
+ image.Save(stream, ImageFormat.Png);
+ pngBytes = stream.ToArray();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern int GetPackagesByPackageFamily(
+ string packageFamilyName,
+ ref uint count,
+ IntPtr[]? packageFullNames,
+ ref uint bufferLength,
+ char[]? buffer);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern int GetPackagePathByFullName(
+ string packageFullName,
+ ref uint pathLength,
+ StringBuilder? path);
+}
+
diff --git a/LanMontainDesktop/Services/WindowsIconService.cs b/LanMontainDesktop/Services/WindowsIconService.cs
new file mode 100644
index 0000000..2575447
--- /dev/null
+++ b/LanMontainDesktop/Services/WindowsIconService.cs
@@ -0,0 +1,859 @@
+using System;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+using System.Runtime.Versioning;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace LanMontainDesktop.Services;
+
+[SupportedOSPlatform("windows")]
+internal static class WindowsIconService
+{
+ private const int HighResolutionIconSize = 256;
+ private const int MaxShellPath = 1024;
+ private const int StgmRead = 0x00000000;
+ private const uint SiigbfBiggerSizeOk = 0x00000001;
+ private const uint SiigbfIconOnly = 0x00000004;
+ private const uint ShgfiIcon = 0x00000100;
+ private const uint ShgfiLargeIcon = 0x00000000;
+ private const uint ShgfiUseFileAttributes = 0x00000010;
+ private const uint FileAttributeNormal = 0x00000080;
+ private const uint FileAttributeDirectory = 0x00000010;
+ private const uint CoinitApartmentThreaded = 0x2;
+ private const int SOk = 0;
+ private const int SFalse = 1;
+ private const int RpcEChangedMode = unchecked((int)0x80010106);
+ private static readonly Guid CLSID_ShellLink = new("00021401-0000-0000-C000-000000000046");
+ private static readonly Guid IID_IShellItemImageFactory = new("BCC18B79-BA16-442F-80C4-8A59C30C463B");
+ private static readonly Regex AumidRegex =
+ new(@"shell:AppsFolder\\(?[^\s""]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ public static byte[]? TryGetIconPngBytes(string filePath)
+ {
+ if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(filePath))
+ {
+ return null;
+ }
+
+ var normalizedEntryPath = Path.GetFullPath(filePath);
+ if (!File.Exists(normalizedEntryPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ var extension = Path.GetExtension(normalizedEntryPath);
+ if (extension.Equals(".lnk", StringComparison.OrdinalIgnoreCase))
+ {
+ if (TryReadLnkIconLocation(normalizedEntryPath, out var iconLocation, out var iconIndex) &&
+ TryResolveIconPath(iconLocation, normalizedEntryPath, out var resolvedIconPath) &&
+ TryExtractIconFromResourceFile(resolvedIconPath, iconIndex, out var pngBytesFromLnkIconLocation))
+ {
+ return pngBytesFromLnkIconLocation;
+ }
+
+ if (TryReadLnkArguments(normalizedEntryPath, out var arguments) &&
+ TryParseAumidFromArguments(arguments, out var aumid))
+ {
+ var appsFolderPath = $"shell:AppsFolder\\{aumid}";
+ if (TryExtractIconWithShellItemImageFactory(appsFolderPath, out var pngBytesFromAppsFolder))
+ {
+ return pngBytesFromAppsFolder;
+ }
+
+ if (UwpManifestIconResolver.TryGetIconPngBytesFromAumid(aumid, out var pngBytesFromManifest))
+ {
+ return pngBytesFromManifest;
+ }
+ }
+
+ if (TryReadLnkTargetPath(normalizedEntryPath, out var targetPath) &&
+ TryExtractIconFromResourceFile(targetPath, 0, out var pngBytesFromLnkTarget))
+ {
+ return pngBytesFromLnkTarget;
+ }
+ }
+ else if (extension.Equals(".url", StringComparison.OrdinalIgnoreCase))
+ {
+ if (TryReadUrlIconLocation(normalizedEntryPath, out var iconFile, out var iconIndex) &&
+ TryResolveIconPath(iconFile, normalizedEntryPath, out var resolvedIconPath) &&
+ TryExtractIconFromResourceFile(resolvedIconPath, iconIndex, out var pngBytesFromUrlIconLocation))
+ {
+ return pngBytesFromUrlIconLocation;
+ }
+ }
+
+ if (TryExtractIconWithShellItemImageFactory(normalizedEntryPath, out var pngBytesFromShellItem))
+ {
+ return pngBytesFromShellItem;
+ }
+
+ if (TryExtractIconWithShGetFileInfo(normalizedEntryPath, out var pngBytesFromShGetFileInfo))
+ {
+ return pngBytesFromShGetFileInfo;
+ }
+
+ return null;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static byte[]? TryGetSystemFolderIconPngBytes()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ // Prefer the HICON-based path first to preserve alpha better for folder glyphs.
+ if (TryExtractFolderIconWithShGetFileInfo(out var shGetFolderIcon) &&
+ shGetFolderIcon is not null)
+ {
+ return shGetFolderIcon;
+ }
+
+ var isWin11 = OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000);
+ var preferredProbePaths = isWin11
+ ? new[]
+ {
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
+ Environment.SystemDirectory
+ }
+ : new[]
+ {
+ Environment.SystemDirectory,
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
+ };
+
+ foreach (var probePath in preferredProbePaths)
+ {
+ if (string.IsNullOrWhiteSpace(probePath) || !Directory.Exists(probePath))
+ {
+ continue;
+ }
+
+ if (TryExtractIconWithShellItemImageFactory(probePath, out var shellFolderIcon))
+ {
+ return shellFolderIcon;
+ }
+ }
+
+ return null;
+ }
+
+ private static bool TryParseAumidFromArguments(string arguments, out string aumid)
+ {
+ aumid = string.Empty;
+ if (string.IsNullOrWhiteSpace(arguments))
+ {
+ return false;
+ }
+
+ var match = AumidRegex.Match(arguments);
+ if (!match.Success)
+ {
+ return false;
+ }
+
+ aumid = match.Groups["aumid"].Value.Trim().Trim('"');
+ return !string.IsNullOrWhiteSpace(aumid);
+ }
+
+ private static bool TryReadLnkIconLocation(string lnkFilePath, out string iconLocation, out int iconIndex)
+ {
+ iconLocation = string.Empty;
+ iconIndex = 0;
+ if (!TryInitializeCom(out var shouldUninitialize))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryCreateShellLink(out var shellLink))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryLoadShellLink(shellLink, lnkFilePath))
+ {
+ return false;
+ }
+
+ var iconPathBuilder = new StringBuilder(MaxShellPath);
+ if (shellLink.GetIconLocation(iconPathBuilder, iconPathBuilder.Capacity, out iconIndex) < 0)
+ {
+ return false;
+ }
+
+ iconLocation = iconPathBuilder.ToString().Trim();
+ return !string.IsNullOrWhiteSpace(iconLocation);
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject(shellLink);
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ UninitializeCom(shouldUninitialize);
+ }
+ }
+
+ private static bool TryReadLnkTargetPath(string lnkFilePath, out string targetPath)
+ {
+ targetPath = string.Empty;
+ if (!TryInitializeCom(out var shouldUninitialize))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryCreateShellLink(out var shellLink))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryLoadShellLink(shellLink, lnkFilePath))
+ {
+ return false;
+ }
+
+ var targetPathBuilder = new StringBuilder(MaxShellPath);
+ if (shellLink.GetPath(targetPathBuilder, targetPathBuilder.Capacity, IntPtr.Zero, 0) < 0)
+ {
+ return false;
+ }
+
+ targetPath = targetPathBuilder.ToString().Trim();
+ return !string.IsNullOrWhiteSpace(targetPath);
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject(shellLink);
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ UninitializeCom(shouldUninitialize);
+ }
+ }
+
+ private static bool TryReadLnkArguments(string lnkFilePath, out string arguments)
+ {
+ arguments = string.Empty;
+ if (!TryInitializeCom(out var shouldUninitialize))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryCreateShellLink(out var shellLink))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (!TryLoadShellLink(shellLink, lnkFilePath))
+ {
+ return false;
+ }
+
+ var argumentsBuilder = new StringBuilder(MaxShellPath);
+ if (shellLink.GetArguments(argumentsBuilder, argumentsBuilder.Capacity) < 0)
+ {
+ return false;
+ }
+
+ arguments = argumentsBuilder.ToString().Trim();
+ return !string.IsNullOrWhiteSpace(arguments);
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject(shellLink);
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ UninitializeCom(shouldUninitialize);
+ }
+ }
+
+ private static bool TryCreateShellLink(out IShellLinkW shellLink)
+ {
+ shellLink = null!;
+ var shellLinkType = Type.GetTypeFromCLSID(CLSID_ShellLink);
+ if (shellLinkType is null)
+ {
+ return false;
+ }
+
+ shellLink = (IShellLinkW?)Activator.CreateInstance(shellLinkType)!;
+ return shellLink is not null;
+ }
+
+ private static bool TryLoadShellLink(IShellLinkW shellLink, string lnkFilePath)
+ {
+ if (shellLink is not IPersistFile persistFile)
+ {
+ return false;
+ }
+
+ try
+ {
+ persistFile.Load(lnkFilePath, StgmRead);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryReadUrlIconLocation(string urlFilePath, out string iconFile, out int iconIndex)
+ {
+ iconFile = string.Empty;
+ iconIndex = 0;
+ if (!File.Exists(urlFilePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ foreach (var rawLine in File.ReadLines(urlFilePath))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("IconFile=", StringComparison.OrdinalIgnoreCase))
+ {
+ iconFile = line["IconFile=".Length..].Trim();
+ continue;
+ }
+
+ if (line.StartsWith("IconIndex=", StringComparison.OrdinalIgnoreCase) &&
+ int.TryParse(line["IconIndex=".Length..].Trim(), out var parsedIndex))
+ {
+ iconIndex = parsedIndex;
+ }
+ }
+ }
+ catch
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(iconFile))
+ {
+ return false;
+ }
+
+ if (TrySplitIconFileAndIndex(iconFile, out var splitIconFile, out var splitIndex))
+ {
+ iconFile = splitIconFile;
+ if (iconIndex == 0)
+ {
+ iconIndex = splitIndex;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool TryResolveIconPath(string rawIconLocation, string shortcutPath, out string resolvedIconPath)
+ {
+ resolvedIconPath = string.Empty;
+ if (string.IsNullOrWhiteSpace(rawIconLocation))
+ {
+ return false;
+ }
+
+ var cleaned = rawIconLocation.Trim().Trim('"');
+ if (cleaned.StartsWith("@", StringComparison.Ordinal))
+ {
+ cleaned = cleaned[1..];
+ }
+
+ if (TrySplitIconFileAndIndex(cleaned, out var splitPath, out _))
+ {
+ cleaned = splitPath;
+ }
+
+ cleaned = Environment.ExpandEnvironmentVariables(cleaned);
+ if (cleaned.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ cleaned.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ if (!Path.IsPathRooted(cleaned))
+ {
+ var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
+ if (!string.IsNullOrWhiteSpace(shortcutDirectory))
+ {
+ var relativeResolved = Path.GetFullPath(Path.Combine(shortcutDirectory, cleaned));
+ if (File.Exists(relativeResolved))
+ {
+ resolvedIconPath = relativeResolved;
+ return true;
+ }
+ }
+
+ var systemResolved = Path.Combine(Environment.SystemDirectory, cleaned);
+ if (File.Exists(systemResolved))
+ {
+ resolvedIconPath = systemResolved;
+ return true;
+ }
+ }
+
+ if (File.Exists(cleaned))
+ {
+ resolvedIconPath = cleaned;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TrySplitIconFileAndIndex(string rawValue, out string iconFile, out int iconIndex)
+ {
+ iconFile = rawValue.Trim();
+ iconIndex = 0;
+ if (string.IsNullOrWhiteSpace(iconFile))
+ {
+ return false;
+ }
+
+ var commaIndex = iconFile.LastIndexOf(',');
+ if (commaIndex <= 0 || commaIndex >= iconFile.Length - 1)
+ {
+ return false;
+ }
+
+ var possibleIndex = iconFile[(commaIndex + 1)..].Trim();
+ if (!int.TryParse(possibleIndex, out iconIndex))
+ {
+ return false;
+ }
+
+ iconFile = iconFile[..commaIndex].Trim().Trim('"');
+ return !string.IsNullOrWhiteSpace(iconFile);
+ }
+
+ private static bool TryExtractIconFromResourceFile(string resourceFilePath, int iconIndex, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ if (string.IsNullOrWhiteSpace(resourceFilePath) || !File.Exists(resourceFilePath))
+ {
+ return false;
+ }
+
+ if (TryExtractIconWithShDefExtractIcon(resourceFilePath, iconIndex, out pngBytes))
+ {
+ return true;
+ }
+
+ if (TryExtractIconWithPrivateExtractIcons(resourceFilePath, iconIndex, out pngBytes))
+ {
+ return true;
+ }
+
+ return TryExtractIconWithExtractIconEx(resourceFilePath, iconIndex, out pngBytes);
+ }
+
+ private static bool TryExtractIconWithShDefExtractIcon(string filePath, int iconIndex, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ var requestedSize = MakeLong(HighResolutionIconSize, HighResolutionIconSize);
+ var hr = SHDefExtractIcon(filePath, iconIndex, 0, out var largeIcon, out _, (uint)requestedSize);
+ if (hr < 0 || largeIcon == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHiconToPngBytes(largeIcon);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DestroyIcon(largeIcon);
+ }
+ }
+
+ private static bool TryExtractIconWithPrivateExtractIcons(string filePath, int iconIndex, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ var iconHandles = new IntPtr[1];
+ var iconIds = new uint[1];
+ var extracted = PrivateExtractIcons(
+ filePath,
+ iconIndex,
+ HighResolutionIconSize,
+ HighResolutionIconSize,
+ iconHandles,
+ iconIds,
+ 1,
+ 0);
+ if (extracted == 0 || extracted == 0xFFFFFFFF || iconHandles[0] == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHiconToPngBytes(iconHandles[0]);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DestroyIcon(iconHandles[0]);
+ }
+ }
+
+ private static bool TryExtractIconWithExtractIconEx(string filePath, int iconIndex, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ var largeIcons = new IntPtr[1];
+ var extracted = ExtractIconEx(filePath, iconIndex, largeIcons, null, 1);
+ if (extracted <= 0 || largeIcons[0] == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHiconToPngBytes(largeIcons[0]);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DestroyIcon(largeIcons[0]);
+ }
+ }
+
+ private static bool TryExtractIconWithShellItemImageFactory(string filePath, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ if (SHCreateItemFromParsingName(filePath, IntPtr.Zero, IID_IShellItemImageFactory, out var imageFactoryObject) < 0 ||
+ imageFactoryObject is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ var imageFactory = (IShellItemImageFactory)imageFactoryObject;
+ var size = new SizeStruct { cx = HighResolutionIconSize, cy = HighResolutionIconSize };
+ var flags = SiigbfIconOnly | SiigbfBiggerSizeOk;
+ if (imageFactory.GetImage(size, flags, out var hBitmap) < 0 || hBitmap == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHbitmapToPngBytes(hBitmap);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DeleteObject(hBitmap);
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ Marshal.FinalReleaseComObject(imageFactoryObject);
+ }
+ }
+
+ private static bool TryExtractIconWithShGetFileInfo(string filePath, out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ if (SHGetFileInfo(
+ filePath,
+ FileAttributeNormal,
+ out var fileInfo,
+ (uint)Marshal.SizeOf(),
+ ShgfiIcon | ShgfiUseFileAttributes) == IntPtr.Zero ||
+ fileInfo.hIcon == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHiconToPngBytes(fileInfo.hIcon);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DestroyIcon(fileInfo.hIcon);
+ }
+ }
+
+ private static bool TryExtractFolderIconWithShGetFileInfo(out byte[]? pngBytes)
+ {
+ pngBytes = null;
+ if (SHGetFileInfo(
+ "folder",
+ FileAttributeDirectory,
+ out var fileInfo,
+ (uint)Marshal.SizeOf(),
+ ShgfiIcon | ShgfiLargeIcon | ShgfiUseFileAttributes) == IntPtr.Zero ||
+ fileInfo.hIcon == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ pngBytes = ConvertHiconToPngBytes(fileInfo.hIcon);
+ return pngBytes is not null;
+ }
+ finally
+ {
+ _ = DestroyIcon(fileInfo.hIcon);
+ }
+ }
+
+ private static byte[]? ConvertHiconToPngBytes(IntPtr iconHandle)
+ {
+ if (iconHandle == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var icon = Icon.FromHandle(iconHandle);
+ var width = Math.Max(16, icon.Width);
+ var height = Math.Max(16, icon.Height);
+ using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ graphics.Clear(Color.Transparent);
+ graphics.CompositingMode = CompositingMode.SourceOver;
+ graphics.CompositingQuality = CompositingQuality.HighQuality;
+ graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
+ graphics.SmoothingMode = SmoothingMode.HighQuality;
+ graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ graphics.DrawIcon(icon, new Rectangle(0, 0, width, height));
+ }
+
+ using var stream = new MemoryStream();
+ bitmap.Save(stream, ImageFormat.Png);
+ return stream.ToArray();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static byte[]? ConvertHbitmapToPngBytes(IntPtr bitmapHandle)
+ {
+ if (bitmapHandle == IntPtr.Zero)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var source = Image.FromHbitmap(bitmapHandle);
+ using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ graphics.Clear(Color.Transparent);
+ graphics.CompositingMode = CompositingMode.SourceOver;
+ graphics.CompositingQuality = CompositingQuality.HighQuality;
+ graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
+ graphics.SmoothingMode = SmoothingMode.HighQuality;
+ graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ graphics.DrawImage(source, 0, 0, source.Width, source.Height);
+ }
+
+ using var stream = new MemoryStream();
+ bitmap.Save(stream, ImageFormat.Png);
+ return stream.ToArray();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool TryInitializeCom(out bool shouldUninitialize)
+ {
+ shouldUninitialize = false;
+ var result = CoInitializeEx(IntPtr.Zero, CoinitApartmentThreaded);
+ if (result is SOk or SFalse)
+ {
+ shouldUninitialize = true;
+ return true;
+ }
+
+ return result == RpcEChangedMode;
+ }
+
+ private static void UninitializeCom(bool shouldUninitialize)
+ {
+ if (shouldUninitialize)
+ {
+ CoUninitialize();
+ }
+ }
+
+ private static int MakeLong(int lowWord, int highWord)
+ {
+ return (highWord << 16) | (lowWord & 0xFFFF);
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct SHFILEINFO
+ {
+ public IntPtr hIcon;
+ public int iIcon;
+ public uint dwAttributes;
+
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+ public string szDisplayName;
+
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
+ public string szTypeName;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SizeStruct
+ {
+ public int cx;
+ public int cy;
+ }
+
+ [ComImport]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ [Guid("000214F9-0000-0000-C000-000000000046")]
+ private interface IShellLinkW
+ {
+ [PreserveSig]
+ int GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, IntPtr pfd, uint fFlags);
+ [PreserveSig] int GetIDList(out IntPtr ppidl);
+ [PreserveSig] int SetIDList(IntPtr pidl);
+ [PreserveSig] int GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch);
+ [PreserveSig] int SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
+ [PreserveSig] int GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch);
+ [PreserveSig] int SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
+ [PreserveSig] int GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch);
+ [PreserveSig] int SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
+ [PreserveSig] int GetHotkey(out short pwHotkey);
+ [PreserveSig] int SetHotkey(short wHotkey);
+ [PreserveSig] int GetShowCmd(out int piShowCmd);
+ [PreserveSig] int SetShowCmd(int iShowCmd);
+ [PreserveSig] int GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int iIcon);
+ [PreserveSig] int SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
+ [PreserveSig] int SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved);
+ [PreserveSig] int Resolve(IntPtr hwnd, uint fFlags);
+ [PreserveSig] int SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
+ }
+
+ [ComImport]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ [Guid("BCC18B79-BA16-442F-80C4-8A59C30C463B")]
+ private interface IShellItemImageFactory
+ {
+ [PreserveSig]
+ int GetImage(SizeStruct size, uint flags, out IntPtr phbm);
+ }
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr SHGetFileInfo(
+ string pszPath,
+ uint dwFileAttributes,
+ out SHFILEINFO psfi,
+ uint cbFileInfo,
+ uint uFlags);
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ private static extern int SHDefExtractIcon(
+ string pszIconFile,
+ int iIndex,
+ uint uFlags,
+ out IntPtr phiconLarge,
+ out IntPtr phiconSmall,
+ uint nIconSize);
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ private static extern int SHCreateItemFromParsingName(
+ [MarshalAs(UnmanagedType.LPWStr)] string pszPath,
+ IntPtr pbc,
+ [MarshalAs(UnmanagedType.LPStruct)] Guid riid,
+ [MarshalAs(UnmanagedType.Interface)] out object ppv);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern uint PrivateExtractIcons(
+ string szFileName,
+ int nIconIndex,
+ int cxIcon,
+ int cyIcon,
+ IntPtr[] phicon,
+ uint[] piconid,
+ uint nIcons,
+ uint flags);
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
+ private static extern uint ExtractIconEx(
+ string lpszFile,
+ int nIconIndex,
+ IntPtr[]? phiconLarge,
+ IntPtr[]? phiconSmall,
+ uint nIcons);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DestroyIcon(IntPtr hIcon);
+
+ [DllImport("gdi32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DeleteObject(IntPtr hObject);
+
+ [DllImport("ole32.dll")]
+ private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
+
+ [DllImport("ole32.dll")]
+ private static extern void CoUninitialize();
+}
diff --git a/LanMontainDesktop/Services/WindowsStartMenuService.cs b/LanMontainDesktop/Services/WindowsStartMenuService.cs
new file mode 100644
index 0000000..106b10f
--- /dev/null
+++ b/LanMontainDesktop/Services/WindowsStartMenuService.cs
@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using LanMontainDesktop.Models;
+
+namespace LanMontainDesktop.Services;
+
+public sealed class WindowsStartMenuService
+{
+ private static readonly CompareInfo SortCompareInfo = CultureInfo.GetCultureInfo("zh-CN").CompareInfo;
+ private static readonly CompareOptions SortOptions =
+ CompareOptions.IgnoreCase |
+ CompareOptions.IgnoreKanaType |
+ CompareOptions.IgnoreWidth |
+ CompareOptions.StringSort;
+
+ private static readonly HashSet SupportedEntryExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".lnk",
+ ".url",
+ ".appref-ms"
+ };
+
+ public StartMenuFolderNode Load()
+ {
+ var root = new StartMenuFolderNode("All Apps", string.Empty);
+ if (!OperatingSystem.IsWindows())
+ {
+ return root;
+ }
+
+ foreach (var programsRoot in EnumerateProgramsRoots())
+ {
+ try
+ {
+ if (!Directory.Exists(programsRoot))
+ {
+ continue;
+ }
+
+ var scannedRoot = ScanFolder(programsRoot, programsRoot, "All Apps");
+ MergeFolder(root, scannedRoot);
+ }
+ catch
+ {
+ // Ignore unreadable start menu roots to keep launcher rendering resilient.
+ }
+ }
+
+ NormalizeFolderHierarchy(root);
+ SortFolder(root);
+ return root;
+ }
+
+ private static IEnumerable EnumerateProgramsRoots()
+ {
+ var userStartMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
+ var commonStartMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
+
+ var candidates = new[]
+ {
+ Path.Combine(userStartMenu, "Programs"),
+ Path.Combine(commonStartMenu, "Programs")
+ };
+
+ return candidates
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static StartMenuFolderNode ScanFolder(string folderPath, string rootPath, string? nameOverride = null)
+ {
+ var relativePath = Path.GetRelativePath(rootPath, folderPath);
+ if (string.Equals(relativePath, ".", StringComparison.Ordinal))
+ {
+ relativePath = string.Empty;
+ }
+
+ var folder = new StartMenuFolderNode(
+ nameOverride ?? Path.GetFileName(folderPath),
+ relativePath);
+
+ foreach (var subFolderPath in Directory.EnumerateDirectories(folderPath))
+ {
+ var folderName = Path.GetFileName(subFolderPath);
+ if (folderName.StartsWith(".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ folder.Folders.Add(ScanFolder(subFolderPath, rootPath));
+ }
+
+ foreach (var filePath in Directory.EnumerateFiles(folderPath))
+ {
+ var extension = Path.GetExtension(filePath);
+ if (!SupportedEntryExtensions.Contains(extension))
+ {
+ continue;
+ }
+
+ var fileName = Path.GetFileNameWithoutExtension(filePath);
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ continue;
+ }
+
+ var normalizedName = fileName.Replace('_', ' ').Trim();
+ folder.Apps.Add(new StartMenuAppEntry
+ {
+ DisplayName = normalizedName,
+ FilePath = filePath,
+ RelativePath = Path.GetRelativePath(rootPath, filePath),
+ IconPngBytes = OperatingSystem.IsWindows()
+ ? WindowsIconService.TryGetIconPngBytes(filePath)
+ : null
+ });
+ }
+
+ return folder;
+ }
+
+ private static void MergeFolder(StartMenuFolderNode target, StartMenuFolderNode source)
+ {
+ var appPathSet = new HashSet(
+ target.Apps.Select(app => app.RelativePath),
+ StringComparer.OrdinalIgnoreCase);
+ foreach (var app in source.Apps)
+ {
+ if (appPathSet.Add(app.RelativePath))
+ {
+ target.Apps.Add(app);
+ }
+ }
+
+ foreach (var sourceFolder in source.Folders)
+ {
+ var existing = target.Folders.FirstOrDefault(folder =>
+ string.Equals(folder.Name, sourceFolder.Name, StringComparison.OrdinalIgnoreCase));
+ if (existing is null)
+ {
+ target.Folders.Add(sourceFolder);
+ continue;
+ }
+
+ MergeFolder(existing, sourceFolder);
+ }
+ }
+
+ private static void SortFolder(StartMenuFolderNode folder)
+ {
+ folder.Folders.Sort((left, right) => CompareDisplayName(left.Name, right.Name));
+ folder.Apps.Sort((left, right) => CompareDisplayName(left.DisplayName, right.DisplayName));
+ foreach (var child in folder.Folders)
+ {
+ SortFolder(child);
+ }
+ }
+
+ private static int CompareDisplayName(string? left, string? right)
+ {
+ var normalizedLeft = NormalizeForSort(left);
+ var normalizedRight = NormalizeForSort(right);
+ return SortCompareInfo.Compare(normalizedLeft, normalizedRight, SortOptions);
+ }
+
+ private static string NormalizeForSort(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return "~";
+ }
+
+ return text.Trim();
+ }
+
+ private static void NormalizeFolderHierarchy(StartMenuFolderNode root)
+ {
+ if (root.Folders.Count == 0)
+ {
+ return;
+ }
+
+ var normalizedChildren = new List();
+ foreach (var child in root.Folders)
+ {
+ var normalizedChild = CollapseSingleChildFolders(child);
+ MergeIntoFolderList(normalizedChildren, normalizedChild);
+ }
+
+ root.Folders.Clear();
+ root.Folders.AddRange(normalizedChildren);
+ }
+
+ private static StartMenuFolderNode CollapseSingleChildFolders(StartMenuFolderNode folder)
+ {
+ if (folder.Folders.Count > 0)
+ {
+ var normalizedChildren = new List();
+ foreach (var child in folder.Folders)
+ {
+ var normalizedChild = CollapseSingleChildFolders(child);
+ MergeIntoFolderList(normalizedChildren, normalizedChild);
+ }
+
+ folder.Folders.Clear();
+ folder.Folders.AddRange(normalizedChildren);
+ }
+
+ while (folder.Apps.Count == 0 && folder.Folders.Count == 1)
+ {
+ folder = folder.Folders[0];
+ }
+
+ return folder;
+ }
+
+ private static void MergeIntoFolderList(List folders, StartMenuFolderNode source)
+ {
+ var existing = folders.FirstOrDefault(folder =>
+ string.Equals(folder.Name, source.Name, StringComparison.OrdinalIgnoreCase));
+ if (existing is null)
+ {
+ folders.Add(source);
+ return;
+ }
+
+ MergeFolder(existing, source);
+ }
+}
diff --git a/LanMontainDesktop/Styles/GlassModule.axaml b/LanMontainDesktop/Styles/GlassModule.axaml
index 23888c9..f7bfb27 100644
--- a/LanMontainDesktop/Styles/GlassModule.axaml
+++ b/LanMontainDesktop/Styles/GlassModule.axaml
@@ -85,6 +85,14 @@
+
+