From f0e44c0f87ec174ea7c7c8a0460e907b563b9dbb Mon Sep 17 00:00:00 2001 From: lincube Date: Sun, 1 Mar 2026 00:34:07 +0800 Subject: [PATCH] 0.19 --- .../ComponentSystem/BuiltInComponentIds.cs | 1 + .../ComponentSystem/ComponentRegistry.cs | 9 + LanMontainDesktop/LanMontainDesktop.csproj | 2 + LanMontainDesktop/Localization/en-US.json | 7 + LanMontainDesktop/Localization/zh-CN.json | 22 +- .../Models/AppSettingsSnapshot.cs | 4 + LanMontainDesktop/Models/StartMenuAppEntry.cs | 12 + .../Models/StartMenuFolderNode.cs | 24 + .../Services/GlassEffectService.cs | 12 +- .../Services/UwpManifestIconResolver.cs | 475 ++++++ .../Services/WindowsIconService.cs | 859 +++++++++++ .../Services/WindowsStartMenuService.cs | 232 +++ LanMontainDesktop/Styles/GlassModule.axaml | 8 + .../Views/MainWindow.ComponentSystem.cs | 79 +- .../Views/MainWindow.DesktopPaging.cs | 785 ++++++++++ .../Views/MainWindow.Localization.cs | 49 +- .../Views/MainWindow.Settings.cs | 92 +- LanMontainDesktop/Views/MainWindow.axaml | 1267 +++++++++-------- LanMontainDesktop/Views/MainWindow.axaml.cs | 135 +- LanMontainDesktop/build_output.txt | Bin 0 -> 498 bytes testicon/Program.cs | 1 + testicon/testicon.csproj | 10 + 22 files changed, 3388 insertions(+), 697 deletions(-) create mode 100644 LanMontainDesktop/Models/StartMenuAppEntry.cs create mode 100644 LanMontainDesktop/Models/StartMenuFolderNode.cs create mode 100644 LanMontainDesktop/Services/UwpManifestIconResolver.cs create mode 100644 LanMontainDesktop/Services/WindowsIconService.cs create mode 100644 LanMontainDesktop/Services/WindowsStartMenuService.cs create mode 100644 LanMontainDesktop/Views/MainWindow.DesktopPaging.cs create mode 100644 LanMontainDesktop/build_output.txt create mode 100644 testicon/Program.cs create mode 100644 testicon/testicon.csproj 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 @@ + +