From 0d14675cc05ce28782843e7593d38d403d07c2db Mon Sep 17 00:00:00 2001 From: lincube Date: Sat, 7 Mar 2026 00:58:52 +0800 Subject: [PATCH] 0.4.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux相关版本适配 --- .github/workflows/release.yml | 32 ++ LanMountainDesktop/App.axaml.cs | 2 + LanMountainDesktop/Localization/en-US.json | 2 + LanMountainDesktop/Localization/zh-CN.json | 2 + .../Models/StartMenuAppEntry.cs | 10 +- .../Services/LinuxDesktopEntryInstaller.cs | 192 +++++++++ .../Services/LinuxDesktopEntryService.cs | 371 ++++++++++++++++++ .../Services/LinuxIconService.cs | 214 ++++++++++ .../Views/MainWindow.DesktopPaging.cs | 37 +- .../Views/MainWindow.Localization.cs | 10 +- .../linux/LanMountainDesktop.desktop | 10 + LanMountainDesktop/packaging/linux/install.sh | 33 ++ .../packaging/linux/lanmountaindesktop.png | Bin 0 -> 13510 bytes LanMountainDesktop/scripts/package.ps1 | 31 ++ 14 files changed, 940 insertions(+), 6 deletions(-) create mode 100644 LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs create mode 100644 LanMountainDesktop/Services/LinuxDesktopEntryService.cs create mode 100644 LanMountainDesktop/Services/LinuxIconService.cs create mode 100644 LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop create mode 100644 LanMountainDesktop/packaging/linux/install.sh create mode 100644 LanMountainDesktop/packaging/linux/lanmountaindesktop.png diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90a5352..523423c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -275,6 +275,8 @@ jobs: package_name="LanMountainDesktop" package_version="${version}" arch="amd64" + desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop" + icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png" # Verify source directory exists if [ ! -d "$source" ]; then @@ -288,6 +290,7 @@ jobs: mkdir -p "build-deb/usr/local/bin" mkdir -p "build-deb/usr/share/applications" mkdir -p "build-deb/usr/share/pixmaps" + mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps" # Copy application files cp -r "$source"/* "build-deb/usr/local/bin/" @@ -300,6 +303,31 @@ jobs: echo "Error: DEB package is empty after copy" exit 1 fi + + if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then + echo "Error: Linux desktop resources are missing" + ls -la "LanMountainDesktop/packaging/linux" || true + exit 1 + fi + + sed \ + -e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \ + -e "s|@@ICON@@|lanmountaindesktop|g" \ + "$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop" + + cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png" + cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" + + { + printf '%s\n' '#!/bin/sh' + printf '%s\n' 'set -e' + printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then' + printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then' + printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true' + printf '%s\n' 'fi' + } > "build-deb/DEBIAN/postinst" # Create control file (NOTE: No leading spaces in control file) { @@ -313,6 +341,10 @@ jobs: # Set proper permissions chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/* + chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop" + chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png" + chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" + chmod 755 "build-deb/DEBIAN/postinst" # Create DEB file if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index dc391d8..18afbe9 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -24,6 +24,8 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + LinuxDesktopEntryInstaller.EnsureInstalled(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Avoid duplicate validations from both Avalonia and the CommunityToolkit. diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index adfd7c3..4b9b39c 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -251,7 +251,9 @@ "desktop.page_index_format": "Desktop {0}", "launcher.title": "App Launcher", "launcher.subtitle": "Apps and folders from Windows Start Menu", + "launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries", "launcher.empty": "No Start Menu entries found.", + "launcher.empty_linux": "No Linux desktop entries were found.", "launcher.empty_folder": "This folder is empty.", "launcher.folder_items_format": "{0} apps", "launcher.context.hide_icon": "Hide Icon", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index d3f546b..8ea4e0c 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -251,7 +251,9 @@ "desktop.page_index_format": "桌面 {0}", "launcher.title": "应用启动台", "launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹", + "launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用", "launcher.empty": "未找到开始菜单条目。", + "launcher.empty_linux": "未找到 Linux .desktop 应用条目。", "launcher.empty_folder": "此文件夹为空。", "launcher.folder_items_format": "{0} 个应用", "launcher.context.hide_icon": "隐藏图标", diff --git a/LanMountainDesktop/Models/StartMenuAppEntry.cs b/LanMountainDesktop/Models/StartMenuAppEntry.cs index d3d99eb..931cdc2 100644 --- a/LanMountainDesktop/Models/StartMenuAppEntry.cs +++ b/LanMountainDesktop/Models/StartMenuAppEntry.cs @@ -1,4 +1,6 @@ -namespace LanMountainDesktop.Models; +using System.Collections.Generic; + +namespace LanMountainDesktop.Models; public sealed class StartMenuAppEntry { @@ -9,4 +11,10 @@ public sealed class StartMenuAppEntry public required string RelativePath { get; init; } public byte[]? IconPngBytes { get; init; } + + public string? LaunchExecutable { get; init; } + + public IReadOnlyList LaunchArguments { get; init; } = []; + + public string? WorkingDirectory { get; init; } } diff --git a/LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs b/LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs new file mode 100644 index 0000000..c752ada --- /dev/null +++ b/LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs @@ -0,0 +1,192 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace LanMountainDesktop.Services; + +internal static class LinuxDesktopEntryInstaller +{ + private const string DesktopFileName = "LanMountainDesktop.desktop"; + private const string IconFileName = "lanmountaindesktop.png"; + private const string IconName = "lanmountaindesktop"; + + public static void EnsureInstalled() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + try + { + var executablePath = ResolveExecutablePath(); + if (string.IsNullOrWhiteSpace(executablePath)) + { + return; + } + + var dataHome = ResolveDataHome(); + if (string.IsNullOrWhiteSpace(dataHome)) + { + return; + } + + var applicationsDir = Path.Combine(dataHome, "applications"); + var iconDir = Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps"); + + Directory.CreateDirectory(applicationsDir); + Directory.CreateDirectory(iconDir); + + var desktopTargetPath = Path.Combine(applicationsDir, DesktopFileName); + var iconTargetPath = Path.Combine(iconDir, IconFileName); + + TryCopyBundledIcon(iconTargetPath); + + var desktopEntryContent = BuildDesktopEntryContent(executablePath); + WriteFileIfChanged(desktopTargetPath, desktopEntryContent); + + TryRunCommand("chmod", "+x", executablePath); + TryRunCommand("chmod", "+x", desktopTargetPath); + TryRunCommand("update-desktop-database", applicationsDir); + TryRunCommand("gtk-update-icon-cache", Path.Combine(dataHome, "icons", "hicolor")); + } + catch + { + // Keep startup resilient if desktop integration fails. + } + } + + private static string ResolveExecutablePath() + { + var processPath = Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(processPath)) + { + return processPath; + } + + var commandLineArgs = Environment.GetCommandLineArgs(); + if (commandLineArgs.Length > 0 && !string.IsNullOrWhiteSpace(commandLineArgs[0])) + { + return commandLineArgs[0]; + } + + return string.Empty; + } + + private static string ResolveDataHome() + { + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (!string.IsNullOrWhiteSpace(dataHome)) + { + return dataHome.Trim(); + } + + var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(homePath)) + { + return string.Empty; + } + + return Path.Combine(homePath, ".local", "share"); + } + + private static void TryCopyBundledIcon(string iconTargetPath) + { + foreach (var candidatePath in EnumerateIconSourceCandidates()) + { + try + { + if (!File.Exists(candidatePath)) + { + continue; + } + + File.Copy(candidatePath, iconTargetPath, overwrite: true); + return; + } + catch + { + // Ignore failures and continue trying fallbacks. + } + } + } + + private static string[] EnumerateIconSourceCandidates() + { + var baseDirectory = AppContext.BaseDirectory; + return + [ + Path.Combine(baseDirectory, "share", "icons", "hicolor", "256x256", "apps", IconFileName), + Path.Combine(baseDirectory, IconFileName) + ]; + } + + private static string BuildDesktopEntryContent(string executablePath) + { + var escapedExecutablePath = executablePath.Replace("\"", "\\\"", StringComparison.Ordinal); + return + "[Desktop Entry]\n" + + "Type=Application\n" + + "Version=1.0\n" + + "Name=LanMountainDesktop\n" + + "Comment=LanMountainDesktop desktop shell\n" + + $"Exec=\"{escapedExecutablePath}\" %U\n" + + $"Icon={IconName}\n" + + "Terminal=false\n" + + "Categories=Utility;Education;\n" + + "StartupWMClass=LanMountainDesktop\n"; + } + + private static void WriteFileIfChanged(string filePath, string content) + { + try + { + if (File.Exists(filePath)) + { + var existing = File.ReadAllText(filePath); + if (string.Equals(existing, content, StringComparison.Ordinal)) + { + return; + } + } + } + catch + { + // Fall through to attempt writing the content. + } + + File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private static void TryRunCommand(string fileName, params string[] arguments) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process is null) + { + return; + } + + _ = process.WaitForExit(2_500); + } + catch + { + // Ignore missing command or update failures. + } + } +} diff --git a/LanMountainDesktop/Services/LinuxDesktopEntryService.cs b/LanMountainDesktop/Services/LinuxDesktopEntryService.cs new file mode 100644 index 0000000..1a3db16 --- /dev/null +++ b/LanMountainDesktop/Services/LinuxDesktopEntryService.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using LanMountainDesktop.Models; + +namespace LanMountainDesktop.Services; + +public sealed class LinuxDesktopEntryService +{ + private static readonly Regex FieldCodeRegex = + new(@"%[fFuUdDnNickvm]", RegexOptions.Compiled); + + public StartMenuFolderNode Load() + { + var root = new StartMenuFolderNode("All Apps", string.Empty); + if (!OperatingSystem.IsLinux()) + { + return root; + } + + var seenDesktopIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var applicationsRoot in EnumerateApplicationsRoots()) + { + foreach (var desktopFilePath in EnumerateDesktopFilesSafe(applicationsRoot)) + { + if (!TryParseDesktopEntry(desktopFilePath, applicationsRoot, out var appEntry)) + { + continue; + } + + if (seenDesktopIds.Add(appEntry.RelativePath)) + { + root.Apps.Add(appEntry); + } + } + } + + root.Apps.Sort((left, right) => + string.Compare(left.DisplayName, right.DisplayName, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase)); + return root; + } + + private static IEnumerable EnumerateApplicationsRoots() + { + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory)) + { + dataHome = Path.Combine(homeDirectory, ".local", "share"); + } + + var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share") + .Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var candidates = new List(); + if (!string.IsNullOrWhiteSpace(dataHome)) + { + candidates.Add(Path.Combine(dataHome, "applications")); + } + + foreach (var dataDir in dataDirs) + { + candidates.Add(Path.Combine(dataDir, "applications")); + } + + if (!string.IsNullOrWhiteSpace(homeDirectory)) + { + candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "applications")); + } + + candidates.Add("/var/lib/flatpak/exports/share/applications"); + candidates.Add("/var/lib/snapd/desktop/applications"); + + return candidates + .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable EnumerateDesktopFilesSafe(string applicationsRoot) + { + try + { + return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories); + } + catch + { + return Array.Empty(); + } + } + + private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry) + { + appEntry = null!; + + Dictionary fields; + try + { + fields = ReadDesktopEntryFields(desktopFilePath); + } + catch + { + return false; + } + + if (!fields.TryGetValue("Type", out var entryType) || + !string.Equals(entryType, "Application", StringComparison.OrdinalIgnoreCase) || + GetBooleanField(fields, "NoDisplay") || + GetBooleanField(fields, "Hidden")) + { + return false; + } + + var displayName = GetPreferredName(fields); + if (string.IsNullOrWhiteSpace(displayName)) + { + return false; + } + + if (!fields.TryGetValue("Exec", out var execValue) || + !TryParseExec(execValue, out var launchExecutable, out var launchArguments)) + { + return false; + } + + if (fields.TryGetValue("TryExec", out var tryExecValue) && + !string.IsNullOrWhiteSpace(tryExecValue) && + !CommandExists(tryExecValue)) + { + return false; + } + + var desktopFileId = BuildDesktopFileId(desktopFilePath, applicationsRoot); + var iconValue = fields.TryGetValue("Icon", out var iconFieldValue) + ? iconFieldValue + : string.Empty; + var workingDirectory = Path.IsPathRooted(launchExecutable) + ? Path.GetDirectoryName(launchExecutable) + : null; + + appEntry = new StartMenuAppEntry + { + DisplayName = displayName.Trim(), + FilePath = desktopFilePath, + RelativePath = desktopFileId, + IconPngBytes = LinuxIconService.TryGetIconPngBytes(iconValue, Path.GetDirectoryName(desktopFilePath)), + LaunchExecutable = launchExecutable, + LaunchArguments = launchArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory + }; + return true; + } + + private static Dictionary ReadDesktopEntryFields(string desktopFilePath) + { + var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + var inDesktopEntrySection = false; + foreach (var rawLine in File.ReadLines(desktopFilePath)) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) + { + continue; + } + + if (line.StartsWith('[') && line.EndsWith(']')) + { + inDesktopEntrySection = string.Equals(line, "[Desktop Entry]", StringComparison.OrdinalIgnoreCase); + continue; + } + + if (!inDesktopEntrySection) + { + continue; + } + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0 || separatorIndex >= line.Length - 1) + { + continue; + } + + var key = line[..separatorIndex].Trim(); + var value = line[(separatorIndex + 1)..].Trim(); + fields[key] = value; + } + + return fields; + } + + private static bool GetBooleanField(IReadOnlyDictionary fields, string key) + { + return fields.TryGetValue(key, out var value) && + bool.TryParse(value, out var result) && + result; + } + + private static string GetPreferredName(IReadOnlyDictionary fields) + { + if (TryGetLocalizedField(fields, "Name", out var localizedName)) + { + return localizedName; + } + + return fields.TryGetValue("Name", out var fallbackName) + ? fallbackName + : string.Empty; + } + + private static bool TryGetLocalizedField(IReadOnlyDictionary fields, string baseKey, out string value) + { + value = string.Empty; + var uiCulture = CultureInfo.CurrentUICulture; + var candidates = new[] + { + $"{baseKey}[{uiCulture.Name}]", + $"{baseKey}[{uiCulture.TwoLetterISOLanguageName}]" + }; + + foreach (var key in candidates) + { + if (fields.TryGetValue(key, out var localizedValue) && + !string.IsNullOrWhiteSpace(localizedValue)) + { + value = localizedValue; + return true; + } + } + + return false; + } + + private static string BuildDesktopFileId(string desktopFilePath, string applicationsRoot) + { + var relativePath = Path.GetRelativePath(applicationsRoot, desktopFilePath) + .Replace(Path.DirectorySeparatorChar, '-') + .Replace(Path.AltDirectorySeparatorChar, '-'); + + return relativePath.Trim(); + } + + private static bool TryParseExec(string execValue, out string launchExecutable, out List launchArguments) + { + launchExecutable = string.Empty; + launchArguments = []; + + var tokens = TokenizeExec(execValue); + if (tokens.Count == 0) + { + return false; + } + + var cleanedTokens = new List(tokens.Count); + foreach (var token in tokens) + { + if (string.IsNullOrWhiteSpace(token)) + { + continue; + } + + var normalizedToken = token.Replace("%%", "%", StringComparison.Ordinal); + if (normalizedToken.Length == 2 && normalizedToken[0] == '%') + { + continue; + } + + normalizedToken = FieldCodeRegex.Replace(normalizedToken, string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedToken)) + { + continue; + } + + cleanedTokens.Add(normalizedToken); + } + + if (cleanedTokens.Count == 0) + { + return false; + } + + launchExecutable = cleanedTokens[0]; + launchArguments = cleanedTokens.Skip(1).ToList(); + return true; + } + + private static List TokenizeExec(string execValue) + { + var tokens = new List(); + var current = new StringBuilder(); + var inQuotes = false; + char quoteChar = '\0'; + + foreach (var c in execValue) + { + if ((c == '"' || c == '\'') && + (!inQuotes || quoteChar == c)) + { + if (inQuotes) + { + inQuotes = false; + quoteChar = '\0'; + } + else + { + inQuotes = true; + quoteChar = c; + } + + continue; + } + + if (char.IsWhiteSpace(c) && !inQuotes) + { + if (current.Length > 0) + { + tokens.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(c); + } + + if (current.Length > 0) + { + tokens.Add(current.ToString()); + } + + return tokens; + } + + private static bool CommandExists(string command) + { + var trimmedCommand = command.Trim(); + if (string.IsNullOrWhiteSpace(trimmedCommand)) + { + return false; + } + + if (Path.IsPathRooted(trimmedCommand)) + { + return File.Exists(trimmedCommand); + } + + var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var pathEntry in pathEntries) + { + try + { + var candidate = Path.Combine(pathEntry, trimmedCommand); + if (File.Exists(candidate)) + { + return true; + } + } + catch + { + // Ignore malformed PATH entries. + } + } + + return false; + } +} diff --git a/LanMountainDesktop/Services/LinuxIconService.cs b/LanMountainDesktop/Services/LinuxIconService.cs new file mode 100644 index 0000000..294fd9d --- /dev/null +++ b/LanMountainDesktop/Services/LinuxIconService.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace LanMountainDesktop.Services; + +internal static class LinuxIconService +{ + private static readonly string[] SupportedRasterExtensions = + [ + ".png", + ".ico" + ]; + + private static readonly Regex SizeDirectoryRegex = + new(@"(?\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly ConcurrentDictionary IconPathCache = new(StringComparer.OrdinalIgnoreCase); + + public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null) + { + if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey)) + { + return null; + } + + foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory)) + { + if (TryReadIconBytes(candidatePath, out var bytes)) + { + return bytes; + } + } + + return null; + } + + private static IEnumerable ResolveIconCandidates(string iconKey, string? desktopFileDirectory) + { + if (Path.HasExtension(iconKey)) + { + var directPath = ExpandHome(iconKey); + if (Path.IsPathRooted(directPath)) + { + yield return directPath; + } + else if (!string.IsNullOrWhiteSpace(desktopFileDirectory)) + { + yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath)); + } + + yield break; + } + + var resolvedThemePath = ResolveThemedIconPath(iconKey); + if (!string.IsNullOrWhiteSpace(resolvedThemePath)) + { + yield return resolvedThemePath; + } + } + + private static string? ResolveThemedIconPath(string iconName) + { + return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key)); + } + + private static string? FindBestMatchingIconPath(string iconName) + { + var candidates = new List<(string Path, int Score)>(); + foreach (var iconRoot in EnumerateIconRoots()) + { + foreach (var extension in SupportedRasterExtensions) + { + foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension)) + { + candidates.Add((candidatePath, ScoreIconPath(candidatePath))); + } + } + } + + return candidates + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Path.Length) + .Select(candidate => candidate.Path) + .FirstOrDefault(); + } + + private static IEnumerable EnumerateIconRoots() + { + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory)) + { + dataHome = Path.Combine(homeDirectory, ".local", "share"); + } + + var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share") + .Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var candidates = new List(); + if (!string.IsNullOrWhiteSpace(dataHome)) + { + candidates.Add(Path.Combine(dataHome, "icons")); + candidates.Add(Path.Combine(dataHome, "pixmaps")); + } + + foreach (var dataDir in dataDirs) + { + candidates.Add(Path.Combine(dataDir, "icons")); + candidates.Add(Path.Combine(dataDir, "pixmaps")); + } + + if (!string.IsNullOrWhiteSpace(homeDirectory)) + { + candidates.Add(Path.Combine(homeDirectory, ".icons")); + candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons")); + } + + candidates.Add("/var/lib/flatpak/exports/share/icons"); + candidates.Add("/var/lib/snapd/desktop/icons"); + + return candidates + .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static IEnumerable EnumerateFilesSafe(string rootPath, string fileName) + { + try + { + return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories); + } + catch + { + return Array.Empty(); + } + } + + private static bool TryReadIconBytes(string filePath, out byte[] bytes) + { + bytes = []; + try + { + var extension = Path.GetExtension(filePath); + if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) || + !File.Exists(filePath)) + { + return false; + } + + bytes = File.ReadAllBytes(filePath); + return bytes.Length > 0; + } + catch + { + return false; + } + } + + private static int ScoreIconPath(string filePath) + { + var score = 0; + var extension = Path.GetExtension(filePath); + if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase)) + { + score += 4_000; + } + else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase)) + { + score += 2_000; + } + + if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + { + score += 8_000; + } + + if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + { + score += 1_000; + } + + var match = SizeDirectoryRegex.Match(filePath); + if (match.Success && + int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size)) + { + score += Math.Min(size, 512); + } + + return score; + } + + private static string ExpandHome(string path) + { + if (!path.StartsWith("~", StringComparison.Ordinal)) + { + return path; + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(homeDirectory)) + { + return path; + } + + return path.Length == 1 + ? homeDirectory + : Path.Combine(homeDirectory, path[2..]); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs index 843e3aa..1f2dedc 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopPaging.cs @@ -38,6 +38,7 @@ public partial class MainWindow Bitmap? IconBitmap); private readonly WindowsStartMenuService _windowsStartMenuService = new(); + private readonly LinuxDesktopEntryService _linuxDesktopEntryService = new(); private readonly Dictionary _launcherIconCache = new(StringComparer.OrdinalIgnoreCase); private readonly Stack _launcherFolderStack = []; private readonly HashSet _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase); @@ -116,7 +117,9 @@ public partial class MainWindow { var loadResult = await Task.Run(() => { - var loadedRoot = _windowsStartMenuService.Load(); + var loadedRoot = OperatingSystem.IsLinux() + ? _linuxDesktopEntryService.Load() + : _windowsStartMenuService.Load(); var folderIconBytes = OperatingSystem.IsWindows() ? WindowsIconService.TryGetSystemFolderIconPngBytes() : null; @@ -771,7 +774,7 @@ public partial class MainWindow if (LauncherRootTilePanel.Children.Count == 0) { LauncherRootTilePanel.Children.Add(CreateLauncherHintTile( - L("launcher.empty", "No Start Menu entries found."), + GetLauncherEmptyText(), string.Empty)); } @@ -1440,10 +1443,40 @@ public partial class MainWindow return new string(letters).ToUpperInvariant(); } + private string GetLauncherEmptyText() + { + return OperatingSystem.IsLinux() + ? L("launcher.empty_linux", "No Linux desktop entries were found.") + : L("launcher.empty", "No Start Menu entries found."); + } + private static void LaunchStartMenuEntry(StartMenuAppEntry app) { try { + if (OperatingSystem.IsLinux() && + !string.IsNullOrWhiteSpace(app.LaunchExecutable)) + { + var linuxStartInfo = new ProcessStartInfo + { + FileName = app.LaunchExecutable, + UseShellExecute = false + }; + + if (!string.IsNullOrWhiteSpace(app.WorkingDirectory)) + { + linuxStartInfo.WorkingDirectory = app.WorkingDirectory; + } + + foreach (var argument in app.LaunchArguments) + { + linuxStartInfo.ArgumentList.Add(argument); + } + + Process.Start(linuxStartInfo); + return; + } + var startInfo = new ProcessStartInfo { FileName = app.FilePath, diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index bcffdf8..903de0f 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -97,9 +97,13 @@ public partial class MainWindow "Swipe to pick a category, tap to open, then drag a widget onto the desktop."); LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher"); - LauncherSubtitleTextBlock.Text = L( - "launcher.subtitle", - "Displays all apps and folders based on the Windows Start menu structure."); + LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux() + ? L( + "launcher.subtitle_linux", + "Displays installed apps discovered from Linux desktop entries.") + : L( + "launcher.subtitle", + "Displays all apps and folders based on the Windows Start menu structure."); ToolTip.SetTip(LauncherFolderBackButton, L("common.back", "Back")); ToolTip.SetTip(LauncherFolderCloseButton, L("common.close", "Close")); diff --git a/LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop b/LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop new file mode 100644 index 0000000..2be03cc --- /dev/null +++ b/LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=LanMountainDesktop +Comment=LanMountainDesktop desktop shell +Exec=@@EXEC@@ %U +Icon=@@ICON@@ +Terminal=false +Categories=Utility;Education; +StartupWMClass=LanMountainDesktop diff --git a/LanMountainDesktop/packaging/linux/install.sh b/LanMountainDesktop/packaging/linux/install.sh new file mode 100644 index 0000000..60ff10e --- /dev/null +++ b/LanMountainDesktop/packaging/linux/install.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +APP_BIN="$SCRIPT_DIR/LanMountainDesktop" +DESKTOP_TEMPLATE="$SCRIPT_DIR/share/applications/LanMountainDesktop.desktop" +ICON_SOURCE="$SCRIPT_DIR/share/icons/hicolor/256x256/apps/lanmountaindesktop.png" + +APPLICATIONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" +DESKTOP_TARGET="$APPLICATIONS_DIR/LanMountainDesktop.desktop" +ICON_TARGET="$ICONS_DIR/lanmountaindesktop.png" + +mkdir -p "$APPLICATIONS_DIR" "$ICONS_DIR" + +cp "$ICON_SOURCE" "$ICON_TARGET" +sed \ + -e "s|@@EXEC@@|$APP_BIN|g" \ + -e "s|@@ICON@@|lanmountaindesktop|g" \ + "$DESKTOP_TEMPLATE" > "$DESKTOP_TARGET" + +chmod +x "$APP_BIN" "$DESKTOP_TARGET" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true +fi + +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" >/dev/null 2>&1 || true +fi + +printf '%s\n' "Installed desktop entry: $DESKTOP_TARGET" +printf '%s\n' "Installed icon: $ICON_TARGET" diff --git a/LanMountainDesktop/packaging/linux/lanmountaindesktop.png b/LanMountainDesktop/packaging/linux/lanmountaindesktop.png new file mode 100644 index 0000000000000000000000000000000000000000..c3d110be5c000488e8fd90f663feb933e4026f98 GIT binary patch literal 13510 zcmahw^;eY7*SoO5(%m5-0@5Yjtstd{N-ZJ+O6StMOCu>DARP)y3P{H;9nw-#3rk8a z-TU%+|AFrh&zWb=oO|!gy)$=iO|+4L4mk-62><{ff2^x%0ssK-et`fm;ay|lQS$D; zo~Mb9I-p{hZR@TBvR8Ym1_1m_AicIBxa$+U>Avy=04O^D`+&W!C2s)$Df7pgYR{oo zyFoJ)S`Y+W7G5_;2nN7C))HC%8T9@ z?JG&xTKLpd3WIw{y{cwCDCr|3aZlq-eqOn{Bkb7{3j;Vm;u`l?BfI z0C>Oc2vsreQHZ>%f`;wD z*}JK+Mp7_s?Yx{6>i!yJ)*j(If{VMQg7WAIjUS0pfqMyY8pZ z>X#}6P5P@r+BC^P2s{U}1wF{7ME&Hj+;4@mwJJP+2> z$PD3(eZvgJkKLd?uydn|m8flSd!$Rb=5p|URUz(rAAbF%&wfHGic^W5!ks^un-K8> zpViwwB)>iqTmRKF(0S!Vim5A~3N{E~OMtWGRssvjzO(c-M)<0d%;nmgGpduFc7KSn zp8295@Pw>Okm@#w3JzFEUN>Y@>wiVAC&M4*>B_>vPRu=aF-gG?QGft_4HBQTApx6Na`|M+w zOBmB{o!MP{pl-TBFix$$j34Yp#OlCk8N?^$&k%m%cGQ>?%*%2lI$D)Go0m^2V>k@A zAW1-jz^aTJdO{S`s&G|Sx_|)8??(|s)3bR%JTaoUvu(5z+8pv6pOGS=5+S3io7Qpe zTfJWXNXSJ~APNpf2f3)g5iNpUa2;9VL6x{QdGB9v>dK{7U`u>Jt4rqkvA*dW0Lj@j4 zn3U;6OSUCFiHyj=I-cuv9E8LZHqZ#U6w@1!d7^32v*=+a1}R1@)i71DikJI)3eeN= zr<*sFz>uhjGhU`i*>GANlKoD!gq!bn?BogCXZJ}}PA18io}~=(Z7x7Sev&1~L(k>8 zE!~TFIz+&7(#baoo3yp{?KnEHHt6*Dp;Vq`rK|pMrHb!dMLQGu4$!FQ+&|fB*40@? zk>TX?7k>&RnWgkUd~Z0J2xa^2-+5g8Y-6qao)OfG!|xL=GbT4E;j;#b){id^vw!TA zmt<#p2v+E}pn;WS5=3Y$|vZNb@c$8vqN(M*rtpbGh1cb=QL5LGb zD7uK6FJV-#*1Q7en7FYcH^DYp6^7+*sYlyG_hcTnYdmBBO#_}vI5TJPce@I89yMx% zM{qZv{YW198?QpyqmpriXX(+FS0~<>(5ehV5}P~m$uf|KsR%xny=FDdyeFub-DP7-mJn&DFh(SQ zlVYam_T{gk$!G!rsvf=Z{tNb^v_-lO?ZnA;w z+-_O}UI@P04+&xXnQDlXQtZ0G#d=#MTgy62xd|do!QZN5RD}r?9b%F+jreIH}B08 z-+_YY)q`uaAyUB?)WesazeS1s!`67yKDip_sP4-bYQ)o-5e}TAzTHkcUPl(1+}eje zp&=O@TFN_`6&TC3MZTnlM-#?{Y`1^|XFL$i+yc7%LVV0FJN8s?=f9kIE5!Po(5j6f zK7Pywh05&e{W7{BFw*q9zHD|{+Bhv$3ib1s5=qQ?;2i|X`Ti#oY7)LL+d|XrRQf-N z9p#=_DC_^DoGc5u(`R7_`^4T766*E-eu1X%^Wy{fMjud1r^di6`DFU$#*$5`P7nzC9cIbSLWa6QT+gIrih5yv{Joy zMBC!O-@^b>e=B;G7=)pQc)9KPZ5y`dO8YBUq6d|nl^SVGr13bWFi&gS#V3W$h9HQA z>I2i(_D(S?U?&u-JyX(ANig2G2dYH4i%PQZ>oTpD#p}<2G8M;Tc)Mq7rE1)!+`35!?AcH?Nm3piv=Fr9SuON+#D*v&2vAJi9Nxl zw_8`T6@OS*bFK6>b6&X>1*X^NQJDP$Zn5`O8-~XeRXUjnZEs=H`l71Rj7n5gMaL1( zT>tKycSN6{<^I+pp+2U4n=%9s2wxSETLB};w5wh7=n=o}|JwzxVwRFlU(09oXGSq} zp3~9+q6MT#=mH)!+%OGZT5IM{brdZD&Sl9qERMMY%$eH*E)OlT*H!~wOHlBCOZCO( z-!gq1i%6@Y4YrDDDxQ z>xFQTz5OuJ|Eu=ROz8B#&ntTfrXKR+=Vea86`W%VQCxZz%!_J_!O9#tKv#>3e~oxx zWHuAwZwbi@)?$LrurDX}5K589a>0*WkL(gguCv2AJOq!=PAdW+2q$ln^#kG zko^N?W<>6~%O{Vi8Z#4d@97b2X>W#N?LFVrz5Z8I*4KO!vnxgt4=*H<-dpWqVYy|r z*-_XxkfBD%tY!TZzdS=6k}cZFgrT2FZbGA5g(O{bt{Y4!B1Dwq*+{@o55|HO2JTfif9^PqCSx z2K*@`Xk^5_a(f@H9?(!kg)wlMZzNP&&@ssn%LrTkXr8WP3^rnT%EY@9acLc2{o`A- z`l4c3J`zGipsEN5ALki0yv3v5)Z8DryAlbQ&6L9*U;Xb{L6p<`;FjToL>Jde5auCgAe-NTfGiu$-t79!q!aR~z>EB3mP)9pOR}ahKKf zQ~!yRQ}Jxnx3~UA<*6T8naD|2R<-;@tG@a^st-C6sNXLnvtG#4x7Dv80X6?BEc`H7 zA-wuJPoRx9{Egjpxsg$Q=w#LM2Ep+=`+T79+^hDA&%KxT2?k>X32Q!MkBg_na+&wy zJn^w-gci8c{<1V9y%rBI*@Qr;R8C^sMsRo+rg$uz@!lxqp~0+H*@(;PxVg?!|nGNRleS zh@%yf)>kgnr5$o?R0?8V(v|)9{_HG2P!xpWxVU&RN6h|6HjHUOM(T>yt<5WyNcX``Xhg zC~VI5y-0Px??s6#UG=}c_Ogm3XNI4-JC(h;#!yleAF?@hj-p?tTopnrHa_k}0)AJc z7yhgkiCH#Z%R{Rkkc7^u6_Pxp=&I`0(*U5mSL?AP1Cb}(i9?URRGg_n31U^Kbf}p$ z?Xg3eOJ4|c=u6~`0DnJMeAgiu&;GdGHJt1a*2`19{iUITl$w9lz{r43nw;74#)jR0 ztkFRuB&H-&_P`hlxJvIceCh{$|0Z&flQ|@y0T=5=Ybx7xq*inj_4#%(u3J|Q5sgm( zb9#9oEkm^%eR|v*B|o0c@pp8ZCg?-vhXK+&z{{XU-nWZtTwU(YXEss|3BiY-jM#nY zF_mQe0L4eMf15vZSDEQ6!F9yw&H+yO(k-x)*7iYg?d=UUL4iW9UDte0w@vFj;WIxv ztvttNffzs}ev2Ttl_y>WF7>TXRUDqt33r}9u53C=-#+Q0!THj!Ms{7aDGxDF$d5O{ zP$~ysFgA2oFg}-UfhqYh@Mf^{ul{&4R_7(G1q*1w`25{>p6Jic)TQJ}-pXpY`I67y z_fFc3GU=A;pPZQ8z83EbsH;q{W%NQ^=wsttHc?d+HW+@Tq-OsGiaM z0v}%9y%gV!GqFtJX$684hTy*NEh;O3KfzL0b;!sd@3 zM)88wfJWTAWV}LzT-{a9p-q1>ltJjy7d@`1n#4>2ytsC;i{50*%CxsuX5;6sdX3A& z`prxsib(&JZ$fiec_UXdhJWHeg1s?I&NAuaEfCdNJyUpoQqn`xjFt+X33*Qfi~i8q z3G1}doRtU-muKpU1E9G)ju2~;%BD~DAcLokYC$8jrhgXyG>|FrpA*0Rl%}U`XC+h7 z)v(n4vfWtSx0|v|l%sFup?cIgSIG#U-aj+5#T*ZWOI2dB04%kiIr_o-xqY)ll&sVB zvhuDV*UN7#&!##E;{-rju$8#eX@Gccf`6Pw7RqdyfGxq^yH?=^o}nVVnfsZ0G#0nS z^wHUs?Y8Ln+7u?7#?OTgL>hAcdoAM;aFE)+qx!)V43KfC6)Iw7aC^l|F&*F@N{*wZ& z=o35qkKu+us;+xGk8Q5!+s6Q%^6cKfOJ@rHSu;xT?~SkfZLbIec5WU5#J&Y#V^KH3 zivhccVPlqqNw0rU`OcRs0y>AG3l1MuqAEhl&!0(VZm$d@XJP<+5cz5w$)1TD zYUV$2^BE0MTwfXjc`8D@;V1oxJ1v2Ponw@R72ekLQ0gs3Iz!ukjBjQb%B@BqEBSRw z(8U*hDZ?!kaa~Uu;h@7Z6E}Y34S#l!jpV;8Jx)>kxc8(@2U){jqv;6tZi*)WAAc5% zLj&$ow7rEeUtZueSp+eJIQh_-hs-wGKTD~_Ux0qx1=S$Wf9dh$6pi1q_RYB!N|fLS z#okn^0r}+YjpHB~uE{SAgTmDVlC0_^oJx5sS0Bryk@#Y<35yB&IrU^G?M+2Wn_a0) zglv$4Hl_gX7pg0dBd(^DR;MM!V(`BNEJX*|y6W7|2TmYo9d^oMzK;mjv7j$sQ^9r{ z1Pz>}+;74mgkGd0R$yfRIW8fO&8PE9;UeTbNNLe}TGqAv(jr)yoPXG^yOwd!DLv+T zfqIO$mTTae^n2}6kDlfm({+6z7Se@~Y|2}tBvwSNQ)bS8n%;y%rK;@lDcX69tukw+ z>jOz#Cbh>nbm-N70IEH7egoNj3VropUqtAWN;~jy-!PPN1qU_jl#uG6EEM6xcpv?_ zy&f>YDqu*4j{*-^*1HvGFUDs`!7h=EfP9kJy@nK-kpW*>8+I0w`rCgk7aMX+eYelj z))z1haKb*DkL1Hv3{$fid9*!EtWXgaG7VcSzYy!0LnLIrZs?ICN!NXJ+QBSQ!EgWn z9RBlHyQxvz!Nhy@I#x}nc5YGQi)naeXZ$+PaF|Y7bpKmU0)T!V#5KMzQQ7MTN9yxa z2ZKtIg{LLBX*YjO`!Kis{CKLjYx~wjomkV%| z7)hj-J#Z#>u=18y#{2Z~#W9O`l+#2FXz}?X=?wYS^d#xa=wo*0;+rP*>Ju#m0ghB< z`slV_JhbvjjJi&g@0V2LBwq;HN=(eR(74#kq3CtbR7fvI;}rEz6pB1mws)#1D#`Ec z*fjA8?F4#_DZ24N13>}n)OGof#-Z$$23My8?j#NOXl`CU=Jn#@3lKq9$4r)<`F(gH zy>K)9)u||!_1-+JwwEi9#I;#2e}Vip_VpF<)LWm#l>v!&T>_S_-*55%Y9#;dDiV}E z#l*^!d=mLlp(B#3#X@Pi^#^(p(_?sg9O+F3?@V-Au(%yc3LvTO(@;P2V=#E_SxDZO z`NP|?(Vvceduj*rEBwp#t6M6|_K$irm9(xqeG%V=$v?rCS?7eGaUgq>)}fXjSP{AB zTl+9D9l=vGw^C&pmFW7rpv4d;GD7x}<<;>gxO2$eLd+&F`=GFD-D>-u9C! zCN5PnXEobk7yXA!%1nC^;wrvJT5I=R0_pv}Bv{qK?F@a*b=-=xvriNf7w7cGD@iMJ zns~~lO#p|OkQ`}J3T<_7@|znSDWo*J;ftRpvw&xgirH@FOt=$W*Q}oFOpNX4?R%d# zH)ww5nEf!+X6J!^k75m^-I~O%{52mn;C27{RG}o}xs(;pCv(k08f!<2JYX=h)2Oi; zGeYRQ`iqNfU5qdHxHb8;Qfx?Me0cuY7t3dTjmk#c`l8aio}1nH&y4*_c9*rSczrdArnSCkMpPu8JmC0LsM>2V3ufCX#WKUs}(OKi#`>i(L26wNEE|!p9 zP^E^Y_3^(*_*`U3)D|UiRdg3ed;Xw`HTv4-ae?fS=0;-{M2bv7hhQF5`UV;(ulhB-eoRvhR1eTF2Jy z{HOua26cGUgvD7-prTc?L7YjwbxhnRF(lz(bMbG|hrGW8I>?Rhp5O6xuKIJ_?O^ zIxmhGi}^h;e$4Ru)oK!p^F9i`tB`YjXPVO!2BXrz5@>%$o zb<#!0FG%6YQ{Q=JHW-W>JG@s?+9|6xm*4;DEbykpZueEm{T`**C%zA6>(?4D4D?9d z)bX-Nl-ZJIJU;#0!M&BYVo7~Oq;$#i{3X#$ymmJ z_&jAe7TP^R_e4UCSDIDu4#VC!et93a;cM5S%|SO#&@CNTd(*^IF7MWu!K{B!hl$bn z{(yvZ9xt#w5$TZ}aV^7i%Ph7hGQsZTpmeqSrt(g#7h$YvCk?H21Q9{toatp;!IaNZ z1USp~gj{-V_OQ9YY4e;y+fPY_TliNWt25usC+%WVS)QK(V;My^gWTihj#iFeTSbIF z*EkA=FFLWh6}Y^-Xx;J;kQVVPjo0&wO}VmyjO6H2y6I1c|6FK1$CZ%FjHt=RKl$dV zZN^+!S=|!TN?LN(N=)0E#x-+gQadQz))^k{139Ir%WutMOj>+$!~*_9Cwz)mLZYzs zl*z10f#!va?`I|4{()ulBVmidnz>H-7eB_K|318bQ|tzDBvy{BUr!0Xtu*oL6f)OX z-C%e#wjZ^h$)E7pmykaJGaK(k4WJ;{eGq!GN!Rc|R$wS~)z!$#yej(zTUk{_TNQ=N zZoclz#xsMZ$e*_sR_iGDV_jC#>4nK15i=o+24f9ABDrEWb@P45D*}j#^6--Jxd^-O z(-$$;wG&qAAGOIxOIN9Aw&dG*vgHpFsJ@lU{wbH8$y2DYQ3AoJO$Yrb(Q|zV1^q{8 zb^G2bkVgjz6d3v(DuSIiJ7ixqbu0HD_$Aam@)UbGe9cQs4fDNtmh!k>1P|Gy!>1NZ z=Q!b9aLVEJcJ8NSsqCM{ORGxY2yIf82MN;hyS`W)=HjPjvQ77RYo=7Lo)VC6`HA3} z<8`pex9zLM4rl+Lo$n019pMC>D_y@gix>q3m+@Chnu@ zWkLyt{$vEhC+C}*32~*fS92M8E{bn{m!iMMJRgg?BDKVwPZb`ii!VaH!doWx&(14K z?D}6nNOP1+vS1kbAjSV@k|>FH0THGg?zP)-Y|2K<1E|cbsK=)~h-KslY=*NQ%(jE{ z$f;7QX?b38VWQQavs8dn46Llk4-*ZRvoS=$ynTH&O@s#JbVSa_bv=&z4)+1! z!h4xt1b^@}`b+&k9{yv8r)b8+JP%EiHQ9{fwFcL=gK`W$RM=wuLP)v(n4!>o?8;C$ zk_a{;LIR6d;}d*Lf`m6)mKfHuQp0Q@_`n7L@k(oeF);^e5YrYVateD2_*-LRDM0I{ zus6pVRJ^?uj-5RAi2ZqR!3`Mut(KS|c}2J_$X=We{tp2HgjN~k1~C5$S>_Dv?U@T* zK)a&JsA0rk!N`MG&op3wigO$hE!6US3JYJ@o_ytqdv-oQ&V|f{Tt6G+2W02%;LWE? zfS8oMX<^ldc*^koE%aI(hM4|l659@TsSN=xo!nR0m26Y{k0*i3vlRcB6n}k6d5m0s zb`O}IXNQzQ|JawyF}8i)bTkM%5aD%W7HeHGt`x&SOh=3E*z`Wgh(~8C3 z*)#3_RR1bxSb?P=?=_u@Q$4qumPD~0SXNVSS}K!LLIjOKkfT4kfGJmk)4^8yb7e4+ ztmmMnhpI0*?&Dl^=E!T)NUL*`K$orHz`g1(QRT>`PT&3)Xzm5Hi`z$!-_5ZM+<;D4 z3^;2o+vlH`xf}sRn58%T#a;HqCaX}6QWdlDV&(cl7RSCAv}IMHZ;^v8Ey! zNE#EcDmL2FLRe5TWW%Wrf-s>!J@puCl5eOb;#6BxrnvSUB&Nu`kb?qe=cC9{_U-0X zn$iz=-W8nTn3XGQiGryW$%N+CxY%B#-{1?0%2Bk4|B|QBcMt+xIuU%caSn=?C<{u= zi3xPH4JW|2R86VNBRQY@QG{)wl?MQSGe*UO;giy*eD^+#X6=H@RC zLHa}{EFrNwu>`o5o zj2*dtt^4$0=8E<|C_yn5O7sXk-(A7kt3yzy0k;?^nEYWSCfp$1=hR;mriU>0Wq5Ia zx*$7?9Oy9m>x@qmpbhIVK|Apr#B&i>C~l=*gUN?iq0eOa4LNCfIP=mtvwyiMqbdG$ z{yxk8T8X_Qb6?fw0qpGiQR34(8iSEzo8J!GWENC`GbgK~VwS%|(fOae%vjAb;(Y_n z{heDMsa=U^lTyQ|Va;%YDeRCUx+D>2>H=a7(A(^ zTN!UB%Uck1&7s}yT;MyJ-lXYz_Mb3-e>6Ftv#-_}SSBI0}1VxpIzk65Aru~Y=W8T#lJPu+qOG$|XQTZf5G$u6p; ziES%1DR0?NQN)5g2QZ*?F4fuSjxl{8V;u*&;|*v7i0&{@=k`{jMdE90Jj9=HXEFi= z=>BRRhrg8jnZWV;BKCIJX{OKSO%^Afn0gf=lc5Zwhz!@DDW}ANG5e#h08x9lqv_SM zWi<2t6g|_oz_3pGJ;)z= ztU9J3uv-v1wkrPX7fw zqZEX;24;DNxf7AiiPx_AchDWTB0JGct5{pA;sm*DmSXsOh5#e^Ax-Zqm1IB*@;u9z z2L=ke`|3Z(gZPmbfbn6%<>uF49sm=NHgNe|9*{$o-1I^dDnZep5)G%Wh zp`DyVS#0IKRH#w|jmycy?4z-o7w4L2w>(Wi$KT}=8#NmG5Q#%KnU@ur>Dn5EKNLkX zn6oZV#GaQj>-%PL;fXz2CE$bzwaG;CSTQ!;R+h!8gnBZ#Z{?>O7}Qp;j1BI^bDXmF z4SH%EO8*Qbx+adjp~C!T{6oF{rA~lWSfp<~$FiFF3Mgx_x|* zS2w?oVcNFGvn3f2t+2g$ss@6jKs2hx<+Q=_jKm^m-Zr+&A9XQS!lXp0;@#vz_9Q^Q zIp{MQvKsbE`%c*!2ey`=bmIsnX1`iNqHc#lvb;;PgHry=ASb@*)J^L-T;Vj^~!59*a&Hn8DLv%o>(3bjy!gMSC_!w#ubdzNe~hx+B$9;))htd6if zG15F&Zfj?Vy>7ibaD*ivszhH7^>kapT`Bv+tO9AnFPIq|el`C>ENg%uWatyK@H<7! zbJ@d{F`6LLdA`LhITUUIe$UJeumvpWG<|vJLuLE8QdHu;e^M#E-W;0HQ!8(3!5R)m zrla~4bj2=!M$`OitcSqk+ba)GBSU@4DsT_w9dy_iw*f}(%I(|!#MsS#yiql%ar^0IU@cB^+o zcv_`8`1XBhGe`^cp7d@KU=~B0xvh-8unM3?A(T%Mt_0ojx#XsXy}r{9vY~?r&NPsp zgs{A)|KV#vjG;YzEEd5bW#iA~u#E{!xGwpoK|Ijn(}YKn^8j^T($C zzy8z%P<4Oow8c}DD8U#NkRLwM;>(tQuG6;pT>5XaDDZP}O~3%? zSK~L)X^<#VD`LfK!8A#b6R)>U==CHgw^8dIJZ%ab1IN2314 zaJaZ#J>*0Ulot!BoK-R`2OoT9UARx^9Al`~X8_0Y{TbzD$Bz@g> zNAm*lRTw>lN16YUz_ynts%!4NI}+sf*CaU>T|VeVqJD4YNPhLOTHCu53*=lNzU?75 zme4B_j0C;8dCM7#pA{ObEXISu^0va4xD#~h?K^n{6Z|T?srf$x`ShN-+S$pEYPO(5jbVxM%x2 z|3`Uh*icmy0nDaLNtW~AN|9U+JNs5b%VE_;DkP_mfnCS?_f7g& zKc!OMQ{%5|SQ^I5J6p=CVq5{ma|Tu53mj=lfksA!ulk*N2xsl`f`L^D#F<-d?y6!k zhJzR~g6(v+-w#2TgyD`q4(DrQYuZB~_7TP0^4Ze-p2a4>o2)BmLZ;v6ahhWPJ zefvsih4AZSIWV&L&Z|ScrUFQFo-)1jucFsLglP0&+{$NO_Jbdb7QDf+M;vl7;Z`YR z9HhALEacy2bg6-3ww{w8GjXJz`f0y01kcI!-3vt?@UD-yd<2lC{o({@N+>zhew%(H zi#8+(iTiEVNtnOq{uDgzIF)`S|Y2e8ewRQORVmy13H z-H^_GJ9&Mv$&Gq70F-Fe9$6V0LJ+y#82#Ujcma=za*&8e-ep~LIcF)|>DuKMnfj)`8euWR1ln}#ut!SNBs_C{?q7CAAb!q|y z@x&~qJ^`rMNQ^)v8#q+MLy@6t#XuLv2F76A zt3RD-SOto~J%X)8Pi{$p?9K-SXiZ6s`z8{aL~oC0cV~LiI!*{eoy&;<@I~v!FL$+a zF6U_loP-f{`p(I;Q9FLtgj)|5h=J!1S^9k;ReD%O@}2-5s2G_)rJG1_JN=>zdDg4* z1&;G8BBTcP_SwOi7SsxK`!dMt_KjH##mIZjr>k+{%HsioZQCXXG*8c+6Rn0=A2^l^ zeJ}<@<+K^aJ(`n5@z8ZmLWG}^#7D$7=o`Y{^#7DSW3MV+Z&K*DlkKrxtvOe?&E7AB zAL00g*!(qvZlN{lR@r?m+mplZO#v~OQC?sAFwXkyHE6vKL!!~kp)yk-KO$K(uDGI$ zNxEJ|6NbevAbrALrajdJpvt0idh%lprNN0e>EmIzmz%T9B?dsI6%BrCWK>D{P&U7cg&&WKoG=^h2H=J*!$xHB5-1-k=8Nso z0iy_&V47b%@!FDPvBaHTE%$|b1H`JS!aEvFK zj+P%?8u(fKUm;Cube)Zy0%xqs-BzcJQGEmKH1UfyIJ#fOp#G7#G~>=wz}GO%a2a+O z0Vzo*Kp6@~Ny5d~${l)ZoOD2tm*`ba+s%x4lCu6QLVr}T48Kvnn89^p_GvL9oIzwp zh#ybs=HlF&*xtGDPEF7d^gGBK6A01$`~7i9U&OJ|qkktVDVL2+>aYK_^o)xbE5ePP zU0B|~YM9b(yp89d*)K?Z@UP0q zOvKf)=}G43mMPGH%I9p251c#1H!gQ^X0g_3x#UcUI0zf95fv0Vp+kA4vZk>}D(Ag2Pb zK|eBPV^FM7>jsCftqUCA=05qcyYuFn9@vWDnaLrh`HXRYiIgM|buxOaZX>r@*lL;P z5qdgn{}F3OXBm{=6eN=|?A|}xB?kB1yunNDQn*+x`B}vl$bq?S<+Yu69h_Pk2o+%p zhoz34hs*pElG#L1vBmSb4%mr}M>pvhzPt$>^tn#H3lVGBfL}X=BVt0gt9Yj0CI{i4 z`0(l%d^*+oD!vY=9Ax04iVv5oEnj;vrI)ek1oduK)mRNjO47s^D~859%^<|*;3ddG zWV32xkA1M_9EFxx(y3)@OC5keWE`j8`J)7@4 z@7_(|;s+I(?0NM$XigaY)Ioi12DjX|?oUb;Epw(wk3UlqzZHP9aJb#4?s0Q&#gE87 zp<%G4l1262RnAZdTt7|}C$Vc&JtzFz|Em8OlE4&_3SWdZ)$(ADXr5s@t$Vg>UH`gN zJ%|8Dq6G7p4rr2PRA)Ub%!|&KLhIbJvax@ETkXj(3)QlzO~QW?&vD_AZ8{*nE4L31 zfLdm1S(T8H;8aziPfxkNz5$qH2oN2De`{pXAyS+CZG ra2+Db?neaw`{51<57p2icFWkxwIV&&`