Linux相关版本适配
This commit is contained in:
lincube
2026-03-07 00:58:52 +08:00
parent 1f509959a9
commit 0d14675cc0
14 changed files with 940 additions and 6 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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": "隐藏图标",

View File

@@ -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<string> LaunchArguments { get; init; } = [];
public string? WorkingDirectory { get; init; }
}

View File

@@ -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.
}
}
}

View File

@@ -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<string>(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<string> 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<string>();
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<string> EnumerateDesktopFilesSafe(string applicationsRoot)
{
try
{
return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry)
{
appEntry = null!;
Dictionary<string, string> 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<string, string> ReadDesktopEntryFields(string desktopFilePath)
{
var fields = new Dictionary<string, string>(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<string, string> fields, string key)
{
return fields.TryGetValue(key, out var value) &&
bool.TryParse(value, out var result) &&
result;
}
private static string GetPreferredName(IReadOnlyDictionary<string, string> 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<string, string> 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<string> launchArguments)
{
launchExecutable = string.Empty;
launchArguments = [];
var tokens = TokenizeExec(execValue);
if (tokens.Count == 0)
{
return false;
}
var cleanedTokens = new List<string>(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<string> TokenizeExec(string execValue)
{
var tokens = new List<string>();
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;
}
}

View File

@@ -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(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly ConcurrentDictionary<string, string?> 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<string> 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<string> 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<string>();
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<string> EnumerateFilesSafe(string rootPath, string fileName)
{
try
{
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
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..]);
}
}

View File

@@ -38,6 +38,7 @@ public partial class MainWindow
Bitmap? IconBitmap);
private readonly WindowsStartMenuService _windowsStartMenuService = new();
private readonly LinuxDesktopEntryService _linuxDesktopEntryService = new();
private readonly Dictionary<string, Bitmap> _launcherIconCache = new(StringComparer.OrdinalIgnoreCase);
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
private readonly HashSet<string> _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,

View File

@@ -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"));

View File

@@ -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

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -141,6 +141,33 @@ function Create-PackageArchive {
return $archivePath
}
function Add-LinuxDesktopAssets {
param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
[Parameter(Mandatory = $true)][string]$RepoRoot
)
$resourcesRoot = Join-Path $RepoRoot "packaging/linux"
$desktopTemplate = Join-Path $resourcesRoot "LanMountainDesktop.desktop"
$iconSource = Join-Path $resourcesRoot "lanmountaindesktop.png"
$installScriptSource = Join-Path $resourcesRoot "install.sh"
foreach ($requiredPath in @($desktopTemplate, $iconSource, $installScriptSource)) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Linux packaging resource is missing: $requiredPath"
}
}
$applicationsDir = Join-Path $PublishedDirectory "share/applications"
$iconsDir = Join-Path $PublishedDirectory "share/icons/hicolor/256x256/apps"
[System.IO.Directory]::CreateDirectory($applicationsDir) | Out-Null
[System.IO.Directory]::CreateDirectory($iconsDir) | Out-Null
Copy-Item -LiteralPath $desktopTemplate -Destination (Join-Path $applicationsDir "LanMountainDesktop.desktop") -Force
Copy-Item -LiteralPath $iconSource -Destination (Join-Path $iconsDir "lanmountaindesktop.png") -Force
Copy-Item -LiteralPath $installScriptSource -Destination (Join-Path $PublishedDirectory "install.sh") -Force
}
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
@@ -184,6 +211,10 @@ if ($LASTEXITCODE -ne 0) {
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
}
if (-not $KeepSymbols) {
Get-ChildItem -Path $PublishDir -Recurse -File -Filter "*.pdb" | ForEach-Object {
[System.IO.File]::Delete($_.FullName)