mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0662565dca | ||
|
|
12a2f6729b | ||
|
|
5d2449fa8f | ||
|
|
00339f0ed0 |
@@ -44,4 +44,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
public const string DesktopFileManager = "DesktopFileManager";
|
||||
}
|
||||
|
||||
@@ -400,6 +400,16 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"文件管理",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -1075,7 +1075,9 @@
|
||||
"zhijiaohub.settings.source": "Image Source",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||
"zhijiaohub.settings.rinlit": "Rin's Gallery",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto Memes",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community, Rin's Gallery contains content from Rin's community, Jiangtokoto Memes contains rich meme resources.",
|
||||
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||
|
||||
@@ -1069,7 +1069,9 @@
|
||||
"zhijiaohub.settings.source": "图片源",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||
"zhijiaohub.settings.rinlit": "Rin's 图库",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto 表情包",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。",
|
||||
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||
|
||||
@@ -125,6 +125,7 @@ public static class ZhiJiaoHubSources
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
public const string RinLit = "rinlit";
|
||||
public const string Jiangtokoto = "jiangtokoto";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
@@ -132,6 +133,7 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
"rinlit" => RinLit,
|
||||
"jiangtokoto" => Jiangtokoto,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
@@ -142,6 +144,7 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
Sectl => "SECTL 图库",
|
||||
RinLit => "Rin's 图库",
|
||||
Jiangtokoto => "Jiangtokoto 表情包",
|
||||
_ => "ClassIsland 图库"
|
||||
};
|
||||
}
|
||||
@@ -154,8 +157,13 @@ public sealed class ZhiJiaoHubSourceConfig
|
||||
public string Repo { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public bool UseJsonIndex { get; init; } = false;
|
||||
public string? JsonIndexPath { get; init; } = null;
|
||||
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
|
||||
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
|
||||
public string? JsonIndexUrl => JsonIndexPath != null
|
||||
? $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{JsonIndexPath}"
|
||||
: null;
|
||||
|
||||
public static ZhiJiaoHubSourceConfig GetConfig(string source)
|
||||
{
|
||||
@@ -172,8 +180,17 @@ public sealed class ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "RinLit-233-shiroko",
|
||||
Repo = "Rin-sHub",
|
||||
Path = "assets/images",
|
||||
DisplayName = "Rin's 图库"
|
||||
Path = "updates/images",
|
||||
DisplayName = "Rin's 图库",
|
||||
UseJsonIndex = true,
|
||||
JsonIndexPath = "updates/images.json"
|
||||
},
|
||||
ZhiJiaoHubSources.Jiangtokoto => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "unDefFtr",
|
||||
Repo = "jiangtokoto-images",
|
||||
Path = "images",
|
||||
DisplayName = "Jiangtokoto 表情包"
|
||||
},
|
||||
_ => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
|
||||
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum FileSystemItemType
|
||||
{
|
||||
Drive,
|
||||
Directory,
|
||||
File
|
||||
}
|
||||
|
||||
public sealed class FileSystemItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public FileSystemItemType ItemType { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
public string? Extension { get; init; }
|
||||
|
||||
public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive;
|
||||
|
||||
public static FileSystemItem FromDriveInfo(DriveInfo drive)
|
||||
{
|
||||
string name;
|
||||
long? size = null;
|
||||
|
||||
try
|
||||
{
|
||||
var volumeLabel = drive.VolumeLabel;
|
||||
name = string.IsNullOrWhiteSpace(volumeLabel)
|
||||
? $"{drive.Name.TrimEnd('\\', '/')}"
|
||||
: $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = $"{drive.Name.TrimEnd('\\', '/')}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var totalSize = drive.TotalSize;
|
||||
size = totalSize > 0 ? totalSize : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
size = null;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = name,
|
||||
FullPath = drive.Name,
|
||||
ItemType = FileSystemItemType.Drive,
|
||||
Size = size,
|
||||
LastModified = null,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = directory.Name,
|
||||
FullPath = directory.FullName,
|
||||
ItemType = FileSystemItemType.Directory,
|
||||
Size = null,
|
||||
LastModified = directory.LastWriteTime,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromFileInfo(FileInfo file)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = file.Name,
|
||||
FullPath = file.FullName,
|
||||
ItemType = FileSystemItemType.File,
|
||||
Size = file.Length,
|
||||
LastModified = file.LastWriteTime,
|
||||
Extension = file.Extension
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 隐藏所有底层小窗口
|
||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
||||
// 这样可以保持组件的运行状态(动画、输入等)
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
|
||||
@@ -1,214 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal static class LinuxIconService
|
||||
{
|
||||
private static readonly string[] SupportedRasterExtensions =
|
||||
[
|
||||
".png",
|
||||
".ico"
|
||||
];
|
||||
private static readonly string[] IconThemePaths = {
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/icons"),
|
||||
"/var/lib/snapd/desktop/icons"
|
||||
};
|
||||
|
||||
private static readonly Regex SizeDirectoryRegex =
|
||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] IconSizes = { "512x512", "256x256", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "16x16" };
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly string[] FolderIconNames = { "folder", "inode-directory", "folder-default" };
|
||||
private static readonly string[] DriveIconNames = { "drive-harddisk", "drive-removable-media", "media-removable" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
|
||||
try
|
||||
{
|
||||
if (TryReadIconBytes(candidatePath, out var bytes))
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
return TryGetThemeIcon(iconName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string iconName, string? searchDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Path.IsPathRooted(iconName) && File.Exists(iconName))
|
||||
{
|
||||
return bytes;
|
||||
if (iconName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return File.ReadAllBytes(iconName);
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var pngBytes = TryGetThemeIcon(iconName);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchDirectory))
|
||||
{
|
||||
var localIconPath = Path.Combine(searchDirectory, "icons", iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
|
||||
localIconPath = Path.Combine(searchDirectory, iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
if (Path.HasExtension(iconKey))
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var directPath = ExpandHome(iconKey);
|
||||
if (Path.IsPathRooted(directPath))
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
yield return directPath;
|
||||
return iconBytes;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedThemePath = ResolveThemedIconPath(iconKey);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
yield return resolvedThemePath;
|
||||
".txt" => "text-x-generic",
|
||||
".md" => "text-x-markdown",
|
||||
".pdf" => "application-pdf",
|
||||
".doc" or ".docx" => "application-msword",
|
||||
".xls" or ".xlsx" => "application-vnd.ms-excel",
|
||||
".ppt" or ".pptx" => "application-vnd.ms-powerpoint",
|
||||
".zip" or ".rar" or ".7z" or ".tar" or ".gz" => "application-x-archive",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" => "audio-x-generic",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => "video-x-generic",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".svg" => "image-x-generic",
|
||||
".cs" => "text-x-csharp",
|
||||
".js" or ".ts" => "text-x-javascript",
|
||||
".py" => "text-x-python",
|
||||
".java" => "text-x-java",
|
||||
".cpp" or ".c" or ".h" => "text-x-c++",
|
||||
".json" => "application-json",
|
||||
".xml" => "text-xml",
|
||||
".html" or ".htm" => "text-html",
|
||||
".css" => "text-css",
|
||||
".sh" or ".bash" => "text-x-script",
|
||||
".exe" or ".msi" => "application-x-executable",
|
||||
".deb" or ".rpm" => "application-x-package",
|
||||
".iso" or ".img" => "application-x-cd-image",
|
||||
_ => "text-x-generic"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryGetThemeIcon(string iconName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 themePath in IconThemePaths)
|
||||
{
|
||||
foreach (var extension in SupportedRasterExtensions)
|
||||
if (!Directory.Exists(themePath))
|
||||
{
|
||||
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconBytes = TryFindIconInTheme(themePath, iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconFromGtkTheme(iconName);
|
||||
}
|
||||
|
||||
private static byte[]? TryFindIconInTheme(string themePath, string iconName)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
var iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
return false;
|
||||
var iconPath = Path.Combine(themePath, "hicolor", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
bytes = File.ReadAllBytes(filePath);
|
||||
return bytes.Length > 0;
|
||||
var directPath = Path.Combine(themePath, $"{iconName}.png");
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return File.ReadAllBytes(directPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreIconPath(string filePath)
|
||||
private static byte[]? TryGetIconFromGtkTheme(string iconName)
|
||||
{
|
||||
var score = 0;
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
score += 4_000;
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gtk3-icon-browser",
|
||||
Arguments = $"--icon={iconName}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
|
||||
catch
|
||||
{
|
||||
score += 2_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
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..]);
|
||||
}
|
||||
}
|
||||
|
||||
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static class MacIconService
|
||||
{
|
||||
private const int IconSize = 256;
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_sharedWorkspace();
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_iconForFile(IntPtr workspace, IntPtr filePath);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSImage_initWithContentsOfFile(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern IntPtr CGImageDestinationCreateWithURL(IntPtr url, IntPtr type, uint count, IntPtr options);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern void CGImageDestinationAddImage(IntPtr dest, IntPtr image, IntPtr properties);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern bool CGImageDestinationFinalize(IntPtr dest);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSString_stringWithUTF8String(string str);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSURL_fileURLWithPath(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern void CFRelease(IntPtr handle);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSTemporaryDirectory();
|
||||
|
||||
private static readonly string[] SystemFolderPaths =
|
||||
{
|
||||
"/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources",
|
||||
"/System/Library/Extensions",
|
||||
"/System/Library/PrivateFrameworks"
|
||||
};
|
||||
|
||||
private static readonly string[] FolderIconNames = { "GenericFolderIcon.icns", "SidebarDownloadsFolder.icns", "SidebarDocumentsFolder.icns" };
|
||||
private static readonly string[] DriveIconNames = { "GenericHardDiskIcon.icns", "ExternalDiskIcon.icns", "RemovableDiskIcon.icns" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return TryGetIconUsingNSWorkspace(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return TryGetIconForExtension(extension);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/System/Library/CoreServices");
|
||||
}
|
||||
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/");
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingNSWorkspace(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
var script = $@"
|
||||
tell application ""System Events""
|
||||
set theIcon to icon of file ""{filePath}""
|
||||
end tell
|
||||
";
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
Arguments = $"-e 'tell application \"Finder\" to get icon of file \"{filePath}\"'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return TryGetIconUsingSips(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingSips(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png -z {IconSize} {IconSize} \"{filePath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconForExtension(string extension)
|
||||
{
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "TextEdit.icns",
|
||||
".md" => "TextEdit.icns",
|
||||
".pdf" => "Preview.icns",
|
||||
".doc" or ".docx" => "Microsoft Word.icns",
|
||||
".xls" or ".xlsx" => "Microsoft Excel.icns",
|
||||
".ppt" or ".pptx" => "Microsoft PowerPoint.icns",
|
||||
".zip" or ".rar" or ".7z" => "Archive Utility.icns",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" => "Music.icns",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" => "QuickTime Player.icns",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" => "Preview.icns",
|
||||
".cs" => "Visual Studio.icns",
|
||||
".js" or ".ts" => "Visual Studio Code.icns",
|
||||
".py" => "IDLE.icns",
|
||||
".json" => "TextEdit.icns",
|
||||
".xml" => "TextEdit.icns",
|
||||
".html" or ".htm" => "Safari.icns",
|
||||
".css" => "TextEdit.icns",
|
||||
".sh" => "Terminal.icns",
|
||||
".app" => "AppIcon.icns",
|
||||
".dmg" => "DiskImage.icns",
|
||||
_ => "GenericDocumentIcon.icns"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryConvertIcnsToPng(string icnsPath)
|
||||
{
|
||||
if (!File.Exists(icnsPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png \"{icnsPath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3246,16 +3246,27 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
|
||||
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
List<ZhiJiaoHubImageItem> images;
|
||||
|
||||
// 如果使用JSON索引模式(Rin's Hub)
|
||||
if (config.UseJsonIndex && !string.IsNullOrEmpty(config.JsonIndexUrl))
|
||||
{
|
||||
images = await FetchImagesFromJsonIndex(config, mirrorSource, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 标准模式(ClassIsland/SECTL)
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
@@ -3380,6 +3391,85 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return images;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从JSON索引文件获取图片列表(Rin's Hub专用)
|
||||
/// </summary>
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromJsonIndex(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
// 下载JSON索引文件
|
||||
var jsonUrl = config.JsonIndexUrl!;
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
jsonUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + jsonUrl;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, jsonUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var jsonText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(jsonText);
|
||||
var root = document.RootElement;
|
||||
|
||||
// 解析 hub_items 数组
|
||||
if (!root.TryGetProperty("hub_items", out var hubItems) || hubItems.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException($"JSON索引文件格式无效:缺少 hub_items 数组");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in hubItems.EnumerateArray())
|
||||
{
|
||||
// 获取图片路径
|
||||
if (!item.TryGetProperty("image", out var imageProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePath = imageProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取标题(用于显示名称)
|
||||
string title = string.Empty;
|
||||
if (item.TryGetProperty("title", out var titleProp))
|
||||
{
|
||||
title = titleProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// 如果没有标题,使用文件名
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = Path.GetFileNameWithoutExtension(imagePath);
|
||||
}
|
||||
|
||||
// 构建完整的图片URL
|
||||
// imagePath 格式如: "Discord/姐姐好香.png"
|
||||
// 需要拼接为: https://raw.githubusercontent.com/.../updates/images/Discord/姐姐好香.png
|
||||
// 并对路径中的每个部分进行URL编码
|
||||
var pathParts = imagePath.Split('/');
|
||||
var encodedPath = string.Join("/", pathParts.Select(part => Uri.EscapeDataString(part)));
|
||||
var imageUrl = $"https://raw.githubusercontent.com/{config.Owner}/{config.Repo}/main/{config.Path}/{encodedPath}";
|
||||
|
||||
// 应用镜像加速
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(title, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
|
||||
@@ -93,6 +93,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
||||
private const int HTTRANSPARENT = -1;
|
||||
private const int HTCLIENT = 1;
|
||||
|
||||
@@ -105,6 +106,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
||||
private static readonly object _staticLock = new();
|
||||
|
||||
// 【修复问题1】静态持有委托引用,防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃
|
||||
private static WndProcDelegate? _wndProcDelegate;
|
||||
|
||||
// 【修复问题2】记录每个窗口的 DPI 缩放比例
|
||||
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
|
||||
|
||||
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
|
||||
private static readonly Dictionary<IntPtr, long> _lastSendToBottomTime = new();
|
||||
private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度
|
||||
|
||||
// 【新增】定时器定期强制置底
|
||||
private static System.Timers.Timer? _keepBottomTimer;
|
||||
private static readonly object _timerLock = new();
|
||||
|
||||
public bool IsBottomMostSupported => true;
|
||||
|
||||
public void SetupBottomMost(Window window)
|
||||
@@ -130,6 +145,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_bottomMostWindows[handle] = true;
|
||||
_interactiveRegions[handle] = [];
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放
|
||||
}
|
||||
|
||||
// 注入消息钩子
|
||||
@@ -138,6 +154,9 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 初始置底
|
||||
SendToBottomInternal(handle);
|
||||
|
||||
// 【新增】启动定时器定期强制置底
|
||||
StartKeepBottomTimer();
|
||||
|
||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||
};
|
||||
|
||||
@@ -152,6 +171,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_originalWndProcs.Remove(handle);
|
||||
_interactiveRegions.Remove(handle);
|
||||
_windowScreenOrigins.Remove(handle);
|
||||
_windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -174,21 +194,113 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】启动定时器定期强制置底所有窗口
|
||||
/// </summary>
|
||||
private static void StartKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_keepBottomTimer != null) return;
|
||||
|
||||
_keepBottomTimer = new System.Timers.Timer(200); // 每 200ms 检查一次
|
||||
_keepBottomTimer.Elapsed += (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
foreach (var kvp in _bottomMostWindows)
|
||||
{
|
||||
if (kvp.Value) // 如果标记为置底
|
||||
{
|
||||
SendToBottomInternal(kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略定时器错误
|
||||
}
|
||||
};
|
||||
_keepBottomTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】停止定时器
|
||||
/// </summary>
|
||||
private static void StopKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
_keepBottomTimer?.Stop();
|
||||
_keepBottomTimer?.Dispose();
|
||||
_keepBottomTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAsDesktopChild(IntPtr handle)
|
||||
{
|
||||
// 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件
|
||||
|
||||
// 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层)
|
||||
var workerW = IntPtr.Zero;
|
||||
var hDefView = IntPtr.Zero;
|
||||
|
||||
// 枚举所有顶层窗口
|
||||
var windowHandles = new ArrayList();
|
||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
// 查找 WorkerW 窗口(Wallpaper Engine 创建)
|
||||
var className = GetWindowClassName(h);
|
||||
if (className == "WorkerW")
|
||||
{
|
||||
// 在 WorkerW 下查找 SHELLDLL_DefView
|
||||
var defView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (defView != IntPtr.Zero)
|
||||
{
|
||||
workerW = h;
|
||||
hDefView = defView;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了 WorkerW 层,使用它作为父窗口
|
||||
if (workerW != IntPtr.Zero && hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to WorkerW layer (Wallpaper Engine detected)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题4】获取窗口类名
|
||||
/// </summary>
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
{
|
||||
handles.Add(handle);
|
||||
@@ -203,13 +315,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
lock (_staticLock)
|
||||
{
|
||||
_originalWndProcs[handle] = originalWndProc;
|
||||
|
||||
// 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收
|
||||
_wndProcDelegate ??= SubclassWndProc;
|
||||
}
|
||||
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate));
|
||||
}
|
||||
|
||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// 【新增】处理应用激活消息 - 当其他应用激活时立即置底
|
||||
if (msg == WM_ACTIVATEAPP)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 立即置底,不进行频率限制
|
||||
SendToBottomInternal(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||
if (msg == WM_WINDOWPOSCHANGING)
|
||||
{
|
||||
@@ -217,7 +345,19 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 【修复问题5】优化 Z 轴竞争 - 限制置底操作频率
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (_lastSendToBottomTime.TryGetValue(hWnd, out var lastTime))
|
||||
{
|
||||
if (now - lastTime < MinSendToBottomIntervalMs)
|
||||
{
|
||||
// 跳过过于频繁的置底操作
|
||||
goto CallOriginal;
|
||||
}
|
||||
}
|
||||
|
||||
SendToBottomInternal(hWnd);
|
||||
_lastSendToBottomTime[hWnd] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,11 +373,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
||||
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
||||
if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0
|
||||
|
||||
// 将屏幕物理像素坐标转为窗口相对坐标
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
var point = new Point(clientX, clientY);
|
||||
|
||||
// 【修复问题2】将物理像素坐标转换为逻辑 DIP 坐标
|
||||
// _interactiveRegions 存储的是 Avalonia UI 的逻辑 DIP 坐标
|
||||
var logicalX = clientX / dpiScale;
|
||||
var logicalY = clientY / dpiScale;
|
||||
var point = new Point(logicalX, logicalY);
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
@@ -255,6 +404,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
|
||||
// 调用原始窗口过程
|
||||
CallOriginal:
|
||||
IntPtr originalWndProc;
|
||||
lock (_staticLock)
|
||||
{
|
||||
@@ -277,6 +427,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_interactiveRegions[handle] = regions;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +442,31 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题2】更新指定窗口的 DPI 缩放比例
|
||||
/// </summary>
|
||||
private static void UpdateWindowDpiScale(IntPtr handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取窗口所在的显示器 DPI
|
||||
var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out var _) == 0)
|
||||
{
|
||||
// DPI 缩放比例 = 当前 DPI / 96 (标准 DPI)
|
||||
_windowDpiScales[handle] = dpiX / 96.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果获取失败,使用默认缩放 1.0
|
||||
_windowDpiScales[handle] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
@@ -328,6 +504,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
// 【修复问题2】DPI 相关的 P/Invoke 声明
|
||||
private const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
private const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
||||
|
||||
[DllImport("shcore.dll")]
|
||||
private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
// 【修复问题4】获取窗口类名的 P/Invoke
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern int GetClassName(IntPtr hWnd, char[] lpClassName, int nMaxCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -696,18 +696,23 @@ internal static class WindowsIconService
|
||||
try
|
||||
{
|
||||
using var source = Image.FromHbitmap(bitmapHandle);
|
||||
using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
||||
var width = source.Width;
|
||||
var height = source.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.CompositingMode = CompositingMode.SourceCopy;
|
||||
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);
|
||||
graphics.DrawImage(source, 0, 0, width, height);
|
||||
}
|
||||
|
||||
FixBitmapAlpha(bitmap);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
@@ -718,6 +723,47 @@ internal static class WindowsIconService
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixBitmapAlpha(Bitmap bitmap)
|
||||
{
|
||||
var width = bitmap.Width;
|
||||
var height = bitmap.Height;
|
||||
var rect = new Rectangle(0, 0, width, height);
|
||||
var data = bitmap.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Math.Abs(data.Stride) * height;
|
||||
var buffer = new byte[bytes];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, bytes);
|
||||
|
||||
for (var i = 0; i < bytes; i += 4)
|
||||
{
|
||||
var b = buffer[i];
|
||||
var g = buffer[i + 1];
|
||||
var r = buffer[i + 2];
|
||||
var a = buffer[i + 3];
|
||||
|
||||
if (a == 0 && (r != 0 || g != 0 || b != 0))
|
||||
{
|
||||
a = (byte)Math.Max(r, Math.Max(g, b));
|
||||
buffer[i + 3] = a;
|
||||
}
|
||||
else if (a > 0 && a < 255)
|
||||
{
|
||||
buffer[i] = (byte)(b * 255 / a);
|
||||
buffer[i + 1] = (byte)(g * 255 / a);
|
||||
buffer[i + 2] = (byte)(r * 255 / a);
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.Copy(buffer, 0, data.Scan0, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||
{
|
||||
shouldUninitialize = false;
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
<ComboBoxItem x:Name="RinLitItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="rinlit" />
|
||||
<ComboBoxItem x:Name="JiangtokotoItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="jiangtokoto" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
|
||||
@@ -30,10 +30,11 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
RinLitItem.Content = L("zhijiaohub.settings.rinlit", "Rin's 图库");
|
||||
JiangtokotoItem.Content = L("zhijiaohub.settings.jiangtokoto", "Jiangtokoto 表情包");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容。");
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
@@ -67,6 +68,7 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
ZhiJiaoHubSources.RinLit => RinLitItem,
|
||||
ZhiJiaoHubSources.Jiangtokoto => JiangtokotoItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
|
||||
@@ -475,7 +475,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"component.zhijiao_hub",
|
||||
() => new ZhiJiaoHubWidget())
|
||||
() => new ZhiJiaoHubWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"component.file_manager",
|
||||
() => new FileManagerWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
@@ -0,0 +1,138 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.FileManagerWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- 导航栏 -->
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<!-- 返回按钮 -->
|
||||
<Button x:Name="BackButton"
|
||||
Grid.Column="0"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnBackButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowLeft"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 主页/盘符按钮 -->
|
||||
<Button x:Name="HomeButton"
|
||||
Grid.Column="1"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnHomeButtonClick">
|
||||
<fi:SymbolIcon Symbol="Home"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 路径显示 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="10,0"
|
||||
VerticalAlignment="Center"
|
||||
Height="32">
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding $self.Text}"
|
||||
Text="此电脑" />
|
||||
</Border>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="3"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Margin="0,10"
|
||||
Background="{DynamicResource AdaptiveDividerBrush}" />
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="FileItemsControl">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StackPanel x:Name="EmptyStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="FolderOpen"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
<TextBlock x:Name="EmptyStateTextBlock"
|
||||
Text="文件夹为空"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<StackPanel x:Name="ErrorStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ErrorCircle"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
<TextBlock x:Name="ErrorStateTextBlock"
|
||||
Text="无法访问此文件夹"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
@@ -0,0 +1,819 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class FileManagerWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IDesktopPageVisibilityAwareComponentWidget,
|
||||
IComponentPlacementContextAware,
|
||||
IDisposable
|
||||
{
|
||||
private readonly List<string> _navigationHistory = new();
|
||||
private int _currentHistoryIndex = -1;
|
||||
private string _currentPath = string.Empty;
|
||||
private string _componentId = BuiltInComponentIds.DesktopFileManager;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isAttached;
|
||||
private bool _isDisposed;
|
||||
|
||||
private const double TapMovementThreshold = 10;
|
||||
private const long TapTimeThresholdMs = 500;
|
||||
|
||||
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||
|
||||
private record PointerGestureState(
|
||||
Point StartPosition,
|
||||
long StartTime,
|
||||
FileSystemItem Item,
|
||||
Border Border
|
||||
);
|
||||
|
||||
public FileManagerWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.25, 10, 20),
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 16));
|
||||
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActivePage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
|
||||
if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopFileManager
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = true;
|
||||
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = false;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void ApplyLayoutMetrics()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
||||
|
||||
var buttonSize = Math.Clamp(32 * scale, 28, 40);
|
||||
var iconSize = Math.Clamp(14 * scale, 12, 18);
|
||||
var pathFontSize = Math.Clamp(13 * scale, 11, 16);
|
||||
|
||||
BackButton.Width = buttonSize;
|
||||
BackButton.Height = buttonSize;
|
||||
BackButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
HomeButton.Width = buttonSize;
|
||||
HomeButton.Height = buttonSize;
|
||||
HomeButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
RefreshButton.Width = buttonSize;
|
||||
RefreshButton.Height = buttonSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
PathTextBlock.FontSize = pathFontSize;
|
||||
|
||||
if (BackButton.Content is SymbolIcon backIcon)
|
||||
{
|
||||
backIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (HomeButton.Content is SymbolIcon homeIcon)
|
||||
{
|
||||
homeIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (RefreshButton.Content is SymbolIcon refreshIcon)
|
||||
{
|
||||
refreshIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
||||
}
|
||||
|
||||
private void OnBackButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_currentHistoryIndex > 0)
|
||||
{
|
||||
_currentHistoryIndex--;
|
||||
var path = _navigationHistory[_currentHistoryIndex];
|
||||
LoadDirectory(path, addToHistory: false);
|
||||
}
|
||||
else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0)
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHomeButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
|
||||
private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border || border.DataContext is not FileSystemItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(border);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp, item, border);
|
||||
|
||||
e.Pointer.Capture(border);
|
||||
}
|
||||
|
||||
private void OnItemPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
if (distance > TapMovementThreshold)
|
||||
{
|
||||
_gestureStates.Remove(pointerId);
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||
|
||||
if (distance <= TapMovementThreshold && elapsed <= TapTimeThresholdMs)
|
||||
{
|
||||
if (state.Item.IsDirectory)
|
||||
{
|
||||
LoadDirectory(state.Item.FullPath, addToHistory: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenFile(state.Item.FullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToDrives()
|
||||
{
|
||||
_navigationHistory.Clear();
|
||||
_currentHistoryIndex = -1;
|
||||
_currentPath = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var drives = new List<FileSystemItem>();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = FileSystemItem.FromDriveInfo(drive);
|
||||
drives.Add(item);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "主目录",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
var linuxMountPoints = new[] { "/mnt", "/media", "/run/media" };
|
||||
foreach (var mount in linuxMountPoints)
|
||||
{
|
||||
if (Directory.Exists(mount))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = mount,
|
||||
FullPath = mount,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "用户",
|
||||
FullPath = "/Users",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "应用程序",
|
||||
FullPath = "/Applications",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "个人",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
if (Directory.Exists("/Volumes"))
|
||||
{
|
||||
foreach (var volume in Directory.GetDirectories("/Volumes"))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = Path.GetFileName(volume),
|
||||
FullPath = volume,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderFileItems(drives);
|
||||
PathTextBlock.Text = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
|
||||
UpdateEmptyState(drives.Count == 0, "没有可用的位置");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex);
|
||||
ShowError("无法加载位置列表");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDirectory(string path, bool addToHistory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
NavigateToDrives();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
|
||||
if (!directoryInfo.Exists)
|
||||
{
|
||||
ShowError("文件夹不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
var items = new List<FileSystemItem>();
|
||||
|
||||
// 添加子文件夹
|
||||
try
|
||||
{
|
||||
var directories = directoryInfo.GetDirectories()
|
||||
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(FileSystemItem.FromDirectoryInfo);
|
||||
items.AddRange(directories);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件夹
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
try
|
||||
{
|
||||
var files = directoryInfo.GetFiles()
|
||||
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(FileSystemItem.FromFileInfo);
|
||||
items.AddRange(files);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件
|
||||
}
|
||||
|
||||
RenderFileItems(items);
|
||||
_currentPath = path;
|
||||
PathTextBlock.Text = FormatPathForDisplay(path);
|
||||
|
||||
if (addToHistory)
|
||||
{
|
||||
// 移除当前位置之后的历史记录
|
||||
if (_currentHistoryIndex < _navigationHistory.Count - 1)
|
||||
{
|
||||
_navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1);
|
||||
}
|
||||
|
||||
_navigationHistory.Add(path);
|
||||
_currentHistoryIndex = _navigationHistory.Count - 1;
|
||||
}
|
||||
|
||||
UpdateEmptyState(items.Count == 0, "文件夹为空");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
ShowError("没有权限访问此文件夹");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex);
|
||||
ShowError("无法加载文件夹内容");
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderFileItems(List<FileSystemItem> items)
|
||||
{
|
||||
FileItemsControl.ItemsSource = null;
|
||||
FileItemsControl.Items.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var itemControl = CreateFileItemControl(item);
|
||||
FileItemsControl.Items.Add(itemControl);
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreateFileItemControl(FileSystemItem item)
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var itemWidth = Math.Clamp(72 * scale, 64, 96);
|
||||
var itemHeight = Math.Clamp(80 * scale, 72, 108);
|
||||
var iconSize = Math.Clamp(32 * scale, 24, 40);
|
||||
var fontSize = Math.Clamp(11 * scale, 10, 14);
|
||||
|
||||
var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Width = itemWidth,
|
||||
Height = itemHeight,
|
||||
Margin = new Thickness(4),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Colors.Transparent),
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
DataContext = item
|
||||
};
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
|
||||
var iconImage = CreateSystemIconImage(item, iconSize);
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = item.Name,
|
||||
FontSize = fontSize,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = textBrush
|
||||
};
|
||||
|
||||
if (iconImage is not null)
|
||||
{
|
||||
grid.Children.Add(iconImage);
|
||||
Grid.SetRow(iconImage, 0);
|
||||
}
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
Grid.SetRow(textBlock, 1);
|
||||
|
||||
border.Child = grid;
|
||||
|
||||
ToolTip.SetTip(border, item.Name);
|
||||
|
||||
border.PointerPressed += OnItemPointerPressed;
|
||||
border.PointerMoved += OnItemPointerMoved;
|
||||
border.PointerReleased += OnItemPointerReleased;
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private Control? CreateSystemIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => GetDriveIconBytes(item.FullPath),
|
||||
FileSystemItemType.Directory => WindowsIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => WindowsIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => LinuxIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => LinuxIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => LinuxIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => MacIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => MacIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => MacIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
var bitmap = new Bitmap(stream);
|
||||
return new Image
|
||||
{
|
||||
Source = bitmap,
|
||||
Width = iconSize,
|
||||
Height = iconSize,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Stretch = Stretch.Uniform
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return CreateFallbackIconImage(item, iconSize);
|
||||
}
|
||||
|
||||
private static byte[]? GetDriveIconBytes(string drivePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(drivePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(drivePath))
|
||||
{
|
||||
return WindowsIconService.TryGetIconPngBytes(drivePath);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Control CreateFallbackIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
var symbol = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive,
|
||||
FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder,
|
||||
_ => FluentIcons.Common.Symbol.Document
|
||||
};
|
||||
|
||||
var iconBrush = item.ItemType == FileSystemItemType.File
|
||||
? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray)
|
||||
: this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
|
||||
|
||||
return new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshCurrentDirectory()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadDirectory(_currentPath, addToHistory: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEmptyState(bool isEmpty, string message)
|
||||
{
|
||||
EmptyStatePanel.IsVisible = isEmpty;
|
||||
EmptyStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = !isEmpty;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorStatePanel.IsVisible = true;
|
||||
ErrorStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = false;
|
||||
EmptyStatePanel.IsVisible = false;
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(filePath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatPathForDisplay(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
}
|
||||
|
||||
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var driveInfo = new DriveInfo(path.Substring(0, 1));
|
||||
if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel))
|
||||
{
|
||||
return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (path == "/")
|
||||
{
|
||||
return "根目录";
|
||||
}
|
||||
|
||||
if (path == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
{
|
||||
return "主目录";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
if (path == "/Applications")
|
||||
{
|
||||
return "应用程序";
|
||||
}
|
||||
|
||||
if (path == "/Users")
|
||||
{
|
||||
return "用户";
|
||||
}
|
||||
|
||||
if (path.StartsWith("/Volumes/"))
|
||||
{
|
||||
return Path.GetFileName(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 3)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return $"{parts[0]}{separator}...{separator}{parts[^2]}{separator}{parts[^1]}";
|
||||
}
|
||||
}
|
||||
@@ -258,18 +258,43 @@ public partial class TransparentOverlayWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
// 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失
|
||||
var control = TryGetExistingControl(placement.PlacementId);
|
||||
if (control is null)
|
||||
{
|
||||
// 如果没有现有实例,才创建新的
|
||||
control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
}
|
||||
|
||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题3】尝试从现有的小窗口中获取组件控件实例
|
||||
/// </summary>
|
||||
private Control? TryGetExistingControl(string placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
||||
// 通过反射或公共 API 获取现有窗口中的控件
|
||||
// 这里需要 FusedDesktopManagerService 提供获取控件的方法
|
||||
// 暂时返回 null,后续需要扩展接口
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除组件
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user