diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml
new file mode 100644
index 0000000..40afd11
--- /dev/null
+++ b/.codex/environments/environment.toml
@@ -0,0 +1,16 @@
+# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
+version = 1
+name = "LanMountainDesktop"
+
+[setup]
+script = ""
+
+[[actions]]
+name = "运行"
+icon = "run"
+command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
+
+[[actions]]
+name = "构建"
+icon = "tool"
+command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
diff --git a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
index da890d9..d44230f 100644
--- a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
+++ b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
@@ -4,6 +4,7 @@
enable
enable
false
+ 1.0.0
diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
index ab7a099..825c60f 100644
--- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
+++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
+ public const string DesktopRemovableStorage = "DesktopRemovableStorage";
}
diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
index 6c62cab..52c9c02 100644
--- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
+++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
+ new DesktopComponentDefinition(
+ BuiltInComponentIds.DesktopRemovableStorage,
+ "Removable Storage",
+ "Storage",
+ "File",
+ MinWidthCells: 2,
+ MinHeightCells: 2,
+ AllowStatusBarPlacement: false,
+ AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.Date,
"Calendar",
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index c369c61..06e054d 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -55,6 +55,10 @@
+
+
+
+
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index 8a4f8b9..be74c03 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -86,6 +86,8 @@
"settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
+ "settings.status_bar.clock_transparent_background_label": "Transparent background",
+ "settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
"settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact",
@@ -588,6 +590,7 @@
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.office_recent_documents": "Recent Documents",
+ "component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_session_control": "Study Session Control",
@@ -789,6 +792,20 @@
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
+ "removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
+ "removable_storage.settings.behavior_title": "Behavior",
+ "removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
+ "removable_storage.action.open": "Open",
+ "removable_storage.action.eject": "Eject",
+ "removable_storage.widget.default_name": "Removable Drive",
+ "removable_storage.widget.empty_title": "No device inserted",
+ "removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
+ "removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
+ "removable_storage.widget.ready": "Ready to open or eject.",
+ "removable_storage.widget.ejecting": "Ejecting drive...",
+ "removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
+ "removable_storage.widget.open_failed": "Failed to open this drive.",
+ "removable_storage.widget.refresh_failed": "Drive list refresh failed.",
"study.session_control.action.start": "Start Study Session",
"study.session_control.action.stop": "Stop Study Session",
"study.session_control.idle_hint": "Tap the right button to start",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index 085c7c1..77a5c12 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -85,6 +85,8 @@
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
+ "settings.status_bar.clock_transparent_background_label": "透明背景",
+ "settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
"settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑",
@@ -782,6 +784,21 @@
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
+ "component.removable_storage": "可移动存储",
+ "removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
+ "removable_storage.settings.behavior_title": "行为",
+ "removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
+ "removable_storage.action.open": "打开",
+ "removable_storage.action.eject": "弹出",
+ "removable_storage.widget.default_name": "可移动磁盘",
+ "removable_storage.widget.empty_title": "未插入设备",
+ "removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
+ "removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
+ "removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
+ "removable_storage.widget.ejecting": "正在弹出设备...",
+ "removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
+ "removable_storage.widget.open_failed": "打开该设备失败。",
+ "removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index 3880637..f28c8f9 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -101,6 +101,8 @@ public sealed class AppSettingsSnapshot
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
+ public bool StatusBarClockTransparentBackground { get; set; }
+
public string StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12;
diff --git a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
index a4eb18f..212a539 100644
--- a/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
+++ b/LanMountainDesktop/Services/DesktopComponentEditorRegistryFactory.cs
@@ -72,6 +72,9 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
+ [BuiltInComponentIds.DesktopRemovableStorage] = new(
+ BuiltInComponentIds.DesktopRemovableStorage,
+ context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
diff --git a/LanMountainDesktop/Services/OfficeRecentDocumentsService.cs b/LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
index 5a5394b..8900485 100644
--- a/LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
+++ b/LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
@@ -1,13 +1,18 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
using System.Text;
-using System.Text.Json;
-using LanMountainDesktop.Services.Settings;
+using System.Text.RegularExpressions;
+using System.Threading;
using Microsoft.Win32;
+using MudTools.OfficeInterop;
+using MudTools.OfficeInterop.Excel;
+using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services;
@@ -25,31 +30,49 @@ public sealed class OfficeRecentDocument
public DateTime LastModifiedTime { get; set; }
public long FileSizeBytes { get; set; }
public string IconGlyph { get; set; } = string.Empty;
+ internal DateTime? RecentAccessTime { get; set; }
+ internal int SourcePriority { get; set; }
+ internal int SourceOrder { get; set; }
}
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
+ private const string LogCategory = "OfficeRecentDocs";
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
+ private static readonly Regex OfficeFilePathRegex = new(
+ @"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex OfficeMruTimestampRegex = new(
+ @"\[T(?[0-9A-F]+)\]",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List GetRecentDocuments(int maxCount = 20)
{
var documents = new List();
- // 方法1: 从注册表读取Office最近文档(最可靠)
+ if (!OperatingSystem.IsWindows())
+ {
+ return documents;
+ }
+
TryGetFromRegistry(documents);
-
- // 方法2: 从Recent文件夹读取快捷方式(备用)
TryGetFromRecentFolders(documents);
+ TryGetFromJumpLists(documents);
- // 方法3: 从Windows Jump List读取(如果可用)
- TryGetFromJumpList(documents);
+ if (documents.Count < maxCount)
+ {
+ TryGetFromMudToolsInterop(documents);
+ }
return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
- .Select(g => g.OrderByDescending(d => d.LastModifiedTime).First())
- .OrderByDescending(d => d.LastModifiedTime)
+ .Select(MergeDocuments)
+ .OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
+ .ThenBy(d => d.SourcePriority)
+ .ThenBy(d => d.SourceOrder)
+ .ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount)
.ToList();
}
@@ -63,261 +86,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
FileName = filePath,
UseShellExecute = true
};
+
Process.Start(startInfo);
}
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
+ }
+ }
+
+ private static OfficeRecentDocument MergeDocuments(IGrouping group)
+ {
+ var preferred = group
+ .OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
+ .ThenBy(d => d.SourcePriority)
+ .ThenBy(d => d.SourceOrder)
+ .ThenByDescending(d => d.LastModifiedTime)
+ .First();
+
+ return new OfficeRecentDocument
+ {
+ FileName = preferred.FileName,
+ FilePath = preferred.FilePath,
+ Extension = preferred.Extension,
+ LastModifiedTime = group.Max(d => d.LastModifiedTime),
+ FileSizeBytes = preferred.FileSizeBytes,
+ IconGlyph = preferred.IconGlyph,
+ RecentAccessTime = group
+ .Where(d => d.RecentAccessTime.HasValue)
+ .Select(d => d.RecentAccessTime)
+ .Max(),
+ SourcePriority = preferred.SourcePriority,
+ SourceOrder = preferred.SourceOrder
+ };
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void TryGetFromMudToolsInterop(List documents)
+ {
+ try
+ {
+ RunOnStaThread(() =>
+ {
+ var sourceOrder = 0;
+ TryGetFromWordInterop(documents, ref sourceOrder);
+ TryGetFromExcelInterop(documents, ref sourceOrder);
+ });
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void TryGetFromWordInterop(List documents, ref int sourceOrder)
+ {
+ if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
+ {
+ return;
+ }
+
+ object? application = null;
+
+ try
+ {
+ application = WordFactory.Connection(comObject!);
+
+ if (createdNew)
+ {
+ TrySetProperty(comObject, "Visible", false);
+ TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
+ }
+
+ AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
+ }
+ finally
+ {
+ CleanupOfficeApplication(application, comObject, createdNew);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void TryGetFromExcelInterop(List documents, ref int sourceOrder)
+ {
+ if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
+ {
+ return;
+ }
+
+ object? application = null;
+
+ try
+ {
+ application = ExcelFactory.Connection(comObject!);
+
+ if (createdNew)
+ {
+ TrySetProperty(comObject, "Visible", false);
+ TrySetProperty(application, "DisplayAlerts", false);
+ }
+
+ AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
+ }
+ finally
+ {
+ CleanupOfficeApplication(application, comObject, createdNew);
+ }
+ }
+
+ private void AddInteropRecentFiles(
+ List documents,
+ object? recentFiles,
+ int sourcePriority,
+ ref int sourceOrder)
+ {
+ if (recentFiles == null)
+ {
+ return;
+ }
+
+ var count = GetIntProperty(recentFiles, "Count");
+ var itemProperty = recentFiles.GetType().GetProperty("Item");
+ if (count <= 0 || itemProperty == null)
+ {
+ return;
+ }
+
+ for (var index = 1; index <= count; index++)
+ {
+ try
+ {
+ var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
+ var filePath = GetStringProperty(recentFile, "Path");
+ AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
+ }
+ catch
+ {
+ // Ignore a single malformed MRU entry and keep processing the rest.
+ }
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
+ {
+ comObject = null;
+ createdNew = false;
+
+ var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
+ if (applicationType == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ comObject = Activator.CreateInstance(applicationType);
+ createdNew = comObject != null;
+ return comObject != null;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
+ {
+ try
+ {
+ if (createdNew && application != null)
+ {
+ InvokeParameterlessMethod(application, "Quit");
+ }
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ if (application is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ catch
+ {
+ }
+
+ ReleaseComObject(application);
+ if (!ReferenceEquals(application, comObject))
+ {
+ ReleaseComObject(comObject);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static void ReleaseComObject(object? instance)
+ {
+ if (instance == null || !Marshal.IsComObject(instance))
+ {
+ return;
+ }
+
+ try
+ {
+ Marshal.FinalReleaseComObject(instance);
+ }
catch
{
}
}
-#pragma warning disable CA1416 // 平台兼容性警告
+ [SupportedOSPlatform("windows")]
+ private static void RunOnStaThread(Action action)
+ {
+ Exception? exception = null;
+ using var finished = new ManualResetEventSlim();
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ action();
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+ finally
+ {
+ finished.Set();
+ }
+ });
+
+ thread.IsBackground = true;
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ finished.Wait();
+
+ if (exception != null)
+ {
+ throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
private void TryGetFromRegistry(List documents)
{
try
{
- // Word最近文档
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Word\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Word\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Word\Reading Locations");
-
- // Excel最近文档
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Excel\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Excel\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Excel\Reading Locations");
-
- // PowerPoint最近文档
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\PowerPoint\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\PowerPoint\Reading Locations");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\PowerPoint\Reading Locations");
-
- // 通用Office最近文档
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Word");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Excel");
- TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office PowerPoint");
- }
- catch
- {
- // 忽略注册表访问错误
- }
- }
-
- private void TryGetFromOfficeRegistry(List documents, string registryPath)
- {
- try
- {
- using var key = Registry.CurrentUser.OpenSubKey(registryPath);
- if (key == null) return;
-
- foreach (var subKeyName in key.GetSubKeyNames())
+ using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
+ if (officeRoot == null)
{
- try
- {
- using var subKey = key.OpenSubKey(subKeyName);
- if (subKey == null) continue;
+ return;
+ }
- var filePath = subKey.GetValue("Path") as string;
- if (string.IsNullOrEmpty(filePath)) continue;
+ var versions = officeRoot
+ .GetSubKeyNames()
+ .Where(IsOfficeVersionKey)
+ .OrderByDescending(ParseVersionKey)
+ .ToList();
- AddDocumentIfExists(documents, filePath);
- }
- catch
- {
- // 忽略单个子键访问错误
- }
+ var sourceOrder = 0;
+ foreach (var version in versions)
+ {
+ TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
+ TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
+ TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
}
}
- catch
+ catch (Exception ex)
{
- // 忽略注册表访问错误
+ AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void TryGetFromRegistryApp(List documents, string version, string appName, ref int sourceOrder)
+ {
+ TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
+
+ using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
+ if (userMruRoot == null)
+ {
+ return;
+ }
+
+ foreach (var identityKey in userMruRoot.GetSubKeyNames())
+ {
+ TryGetFromRegistryMruKey(
+ documents,
+ $@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
+ ref sourceOrder);
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private void TryGetFromRegistryMruKey(List documents, string registryPath, ref int sourceOrder)
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(registryPath);
+ if (key == null)
+ {
+ return;
+ }
+
+ var entries = key
+ .GetValueNames()
+ .Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
+ .Select(name => new
+ {
+ Name = name,
+ Order = ParseMruItemOrder(name),
+ Value = key.GetValue(name) as string
+ })
+ .Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
+ .OrderBy(entry => entry.Order);
+
+ foreach (var entry in entries)
+ {
+ var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
+ AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
}
}
-#pragma warning restore CA1416 // 平台兼容性警告
private void TryGetFromRecentFolders(List documents)
{
- var recentPaths = GetRecentFolders();
-
- foreach (var recentPath in recentPaths)
+ try
{
- if (!Directory.Exists(recentPath))
- {
- continue;
- }
+ var linkFiles = GetRecentFolders()
+ .Where(Directory.Exists)
+ .SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
+ .Select(path => new FileInfo(path))
+ .OrderByDescending(info => info.LastWriteTimeUtc)
+ .ToList();
- try
+ var sourceOrder = 0;
+ foreach (var linkFile in linkFiles)
{
- var files = Directory.GetFiles(recentPath, "*.lnk");
- foreach (var lnkPath in files)
- {
- var targetPath = GetShortcutTarget(lnkPath);
- if (string.IsNullOrEmpty(targetPath))
- {
- continue;
- }
-
- AddDocumentIfExists(documents, targetPath);
- }
- }
- catch
- {
- // 忽略文件夹访问错误
+ var targetPath = GetShortcutTarget(linkFile.FullName);
+ AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
}
}
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
+ }
}
- private void TryGetFromJumpList(List documents)
+ private void TryGetFromJumpLists(List documents)
{
try
{
- // Windows Jump List存储在以下位置
- var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
- var jumpListPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "Microsoft", "Windows", "Recent", "AutomaticDestinations");
+ var jumpListFiles = GetJumpListFolders()
+ .Where(Directory.Exists)
+ .SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
+ .Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
+ .Select(path => new FileInfo(path))
+ .OrderByDescending(info => info.LastWriteTimeUtc)
+ .ToList();
- if (!Directory.Exists(jumpListPath)) return;
-
- // Office应用的Jump List文件
- var officeJumpListFiles = new[]
+ var sourceOrder = 0;
+ foreach (var jumpListFile in jumpListFiles)
{
- "a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
- "9b524fe3be704a4d.automaticDestinations-ms", // Excel
- "d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
- };
+ TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
+ }
+ }
- foreach (var jumpFile in officeJumpListFiles)
+ private void TryParseJumpListFile(FileInfo jumpListFile, List documents, ref int sourceOrder)
+ {
+ try
+ {
+ var bytes = File.ReadAllBytes(jumpListFile.FullName);
+ foreach (var filePath in ExtractPossiblePaths(bytes))
{
- var fullPath = Path.Combine(jumpListPath, jumpFile);
- if (File.Exists(fullPath))
- {
- TryParseJumpListFile(fullPath, documents);
- }
+ AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
}
}
catch
{
- // Jump List解析失败,忽略
+ // Ignore a single Jump List file and keep scanning the rest.
}
}
- private void TryParseJumpListFile(string jumpListPath, List documents)
+ private static IEnumerable ExtractPossiblePaths(byte[] bytes)
{
- try
- {
- // Jump List文件是二进制格式,这里使用简化的方法
- // 读取文件并尝试提取文件路径
- var bytes = File.ReadAllBytes(jumpListPath);
- var text = Encoding.Unicode.GetString(bytes);
+ var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
- // 查找可能的文件路径(简化实现)
- var possiblePaths = ExtractPossiblePaths(text);
- foreach (var path in possiblePaths)
+ foreach (var text in new[]
+ {
+ Encoding.Unicode.GetString(bytes),
+ Encoding.Latin1.GetString(bytes)
+ })
+ {
+ foreach (Match match in OfficeFilePathRegex.Matches(text))
{
- AddDocumentIfExists(documents, path);
- }
- }
- catch
- {
- // Jump List解析失败,忽略
- }
- }
-
- private IEnumerable ExtractPossiblePaths(string text)
- {
- var paths = new List();
-
- // 查找常见的文件路径模式
- var patterns = new[]
- {
- @"[A-Z]:\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)",
- @"\\\\[^\\]+\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)"
- };
-
- foreach (var pattern in patterns)
- {
- try
- {
- var matches = System.Text.RegularExpressions.Regex.Matches(text, pattern,
- System.Text.RegularExpressions.RegexOptions.IgnoreCase);
-
- foreach (System.Text.RegularExpressions.Match match in matches)
+ var normalizedPath = NormalizeFilePath(match.Value);
+ if (!string.IsNullOrWhiteSpace(normalizedPath))
{
- var path = match.Value.Trim('\0', ' ', '"');
- if (!string.IsNullOrEmpty(path))
- {
- paths.Add(path);
- }
+ paths.Add(normalizedPath);
}
}
- catch
- {
- // 忽略正则表达式错误
- }
}
- return paths.Distinct(StringComparer.OrdinalIgnoreCase);
+ return paths;
}
- private void AddDocumentIfExists(List documents, string filePath)
+ private void AddDocumentIfExists(
+ List documents,
+ string? filePath,
+ int sourcePriority,
+ int sourceOrder,
+ DateTime? recentAccessTime)
{
try
{
- var extension = Path.GetExtension(filePath).ToLowerInvariant();
- if (!IsOfficeFile(extension))
+ var normalizedPath = NormalizeFilePath(filePath);
+ if (string.IsNullOrWhiteSpace(normalizedPath))
{
return;
}
- if (!File.Exists(filePath))
+ var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
+ if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{
return;
}
- var fileInfo = new FileInfo(filePath);
- var doc = new OfficeRecentDocument
+ var fileInfo = new FileInfo(normalizedPath);
+ documents.Add(new OfficeRecentDocument
{
- FileName = Path.GetFileNameWithoutExtension(filePath),
- FilePath = filePath,
+ FileName = Path.GetFileNameWithoutExtension(normalizedPath),
+ FilePath = normalizedPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
- IconGlyph = GetIconGlyph(extension)
- };
-
- if (!documents.Any(d => string.Equals(d.FilePath, filePath, StringComparison.OrdinalIgnoreCase)))
- {
- documents.Add(doc);
- }
+ IconGlyph = GetIconGlyph(extension),
+ RecentAccessTime = recentAccessTime,
+ SourcePriority = sourcePriority,
+ SourceOrder = sourceOrder
+ });
}
catch
{
- // 忽略单个文件处理错误
+ // Ignore a single file and keep processing the rest of the MRU list.
}
}
- private static List GetRecentFolders()
+ private static IEnumerable GetRecentFolders()
{
- var folders = new List();
-
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
- folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
- folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
- folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
-
- // 添加Office 365路径
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"));
- folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"));
- folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent"));
- return folders;
+ return new[]
+ {
+ Path.Combine(appData, "Microsoft", "Windows", "Recent"),
+ Path.Combine(appData, "Microsoft", "Word", "Recent"),
+ Path.Combine(appData, "Microsoft", "Excel", "Recent"),
+ Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
+ Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
+ Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
+ Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
+ }.Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static IEnumerable GetJumpListFolders()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+
+ return new[]
+ {
+ Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
+ Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
+ Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
+ Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
+ }.Distinct(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static bool IsOfficeVersionKey(string keyName)
+ {
+ return Version.TryParse(keyName, out _);
+ }
+
+ private static Version ParseVersionKey(string keyName)
+ {
+ return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
+ }
+
+ private static int ParseMruItemOrder(string valueName)
+ {
+ var numberText = valueName["Item ".Length..];
+ return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
+ ? number
+ : int.MaxValue;
+ }
+
+ private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
+ {
+ var filePath = ExtractOfficeFilePath(rawValue);
+ DateTime? recentAccessTime = null;
+
+ var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
+ if (timestampMatch.Success &&
+ long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
+ fileTime > 0)
+ {
+ try
+ {
+ recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
+ }
+ catch
+ {
+ recentAccessTime = null;
+ }
+ }
+
+ return (filePath, recentAccessTime);
+ }
+
+ private static string? ExtractOfficeFilePath(string rawValue)
+ {
+ if (string.IsNullOrWhiteSpace(rawValue))
+ {
+ return null;
+ }
+
+ var markerIndex = rawValue.LastIndexOf('*');
+ var candidate = markerIndex >= 0
+ ? rawValue[(markerIndex + 1)..]
+ : rawValue;
+
+ var normalizedCandidate = NormalizeFilePath(candidate);
+ if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
+ {
+ return normalizedCandidate;
+ }
+
+ var match = OfficeFilePathRegex.Match(rawValue);
+ return match.Success ? NormalizeFilePath(match.Value) : null;
+ }
+
+ private static string? NormalizeFilePath(string? rawPath)
+ {
+ if (string.IsNullOrWhiteSpace(rawPath))
+ {
+ return null;
+ }
+
+ var candidate = rawPath.Trim('\0', ' ', '"');
+ candidate = Environment.ExpandEnvironmentVariables(candidate);
+
+ if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
+ {
+ candidate = uri.LocalPath;
+ }
+
+ candidate = candidate.Replace('/', '\\');
+ return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
}
private static bool IsOfficeFile(string extension)
{
- return OfficeExtensions.Contains(extension) ||
- ExcelExtensions.Contains(extension) ||
- PowerPointExtensions.Contains(extension);
+ return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
+ ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
+ PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
private static string GetIconGlyph(string extension)
@@ -335,4 +684,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
return ShortcutHelper.GetShortcutTarget(lnkPath);
}
+
+ private static object? GetPropertyValue(object? instance, string propertyName)
+ {
+ return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
+ }
+
+ private static string? GetStringProperty(object? instance, string propertyName)
+ {
+ return GetPropertyValue(instance, propertyName) as string;
+ }
+
+ private static int GetIntProperty(object instance, string propertyName)
+ {
+ var value = GetPropertyValue(instance, propertyName);
+ return value switch
+ {
+ int intValue => intValue,
+ short shortValue => shortValue,
+ long longValue => (int)longValue,
+ _ => 0
+ };
+ }
+
+ private static void TrySetProperty(object? instance, string propertyName, object value)
+ {
+ var property = instance?.GetType().GetProperty(propertyName);
+ if (property?.CanWrite == true)
+ {
+ property.SetValue(instance, value);
+ }
+ }
+
+ private static void InvokeParameterlessMethod(object instance, string methodName)
+ {
+ instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
+ }
}
diff --git a/LanMountainDesktop/Services/RemovableStorageService.cs b/LanMountainDesktop/Services/RemovableStorageService.cs
new file mode 100644
index 0000000..726e5f3
--- /dev/null
+++ b/LanMountainDesktop/Services/RemovableStorageService.cs
@@ -0,0 +1,310 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+namespace LanMountainDesktop.Services;
+
+public sealed record RemovableStorageDrive(
+ string RootPath,
+ string DriveLetter,
+ string? VolumeLabel);
+
+public interface IRemovableStorageService
+{
+ IReadOnlyList GetConnectedDrives();
+
+ bool OpenDrive(string rootPath);
+
+ bool EjectDrive(string rootPath);
+}
+
+public sealed class RemovableStorageService : IRemovableStorageService
+{
+ public IReadOnlyList GetConnectedDrives()
+ {
+ var drives = new List();
+
+ foreach (var drive in DriveInfo.GetDrives())
+ {
+ try
+ {
+ if (drive.DriveType != DriveType.Removable || !drive.IsReady)
+ {
+ continue;
+ }
+
+ var rootPath = NormalizeRootPath(drive.Name);
+ if (string.IsNullOrWhiteSpace(rootPath))
+ {
+ continue;
+ }
+
+ var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
+ ? null
+ : drive.VolumeLabel.Trim();
+
+ drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
+ }
+ }
+
+ return drives
+ .OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ public bool OpenDrive(string rootPath)
+ {
+ var normalizedRootPath = NormalizeRootPath(rootPath);
+ if (string.IsNullOrWhiteSpace(normalizedRootPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = normalizedRootPath,
+ UseShellExecute = true
+ });
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
+ return false;
+ }
+ }
+
+ public bool EjectDrive(string rootPath)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ var normalizedRootPath = NormalizeRootPath(rootPath);
+ if (string.IsNullOrWhiteSpace(normalizedRootPath))
+ {
+ return false;
+ }
+
+ object? shellApplication = null;
+ object? computerFolder = null;
+ object? driveItem = null;
+
+ try
+ {
+ var shellType = Type.GetTypeFromProgID("Shell.Application");
+ if (shellType is null)
+ {
+ return false;
+ }
+
+ shellApplication = Activator.CreateInstance(shellType);
+ if (shellApplication is null)
+ {
+ return false;
+ }
+
+ computerFolder = shellType.InvokeMember(
+ "NameSpace",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: shellApplication,
+ args: [17]);
+ if (computerFolder is null)
+ {
+ return false;
+ }
+
+ var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ driveItem = computerFolder.GetType().InvokeMember(
+ "ParseName",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: computerFolder,
+ args: [driveToken]);
+ if (driveItem is null)
+ {
+ return false;
+ }
+
+ if (TryInvokeVerb(driveItem, "Eject"))
+ {
+ return true;
+ }
+
+ return TryInvokeLocalizedEjectVerb(driveItem);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
+ return false;
+ }
+ finally
+ {
+ ReleaseComObject(driveItem);
+ ReleaseComObject(computerFolder);
+ ReleaseComObject(shellApplication);
+ }
+ }
+
+ private static bool TryInvokeLocalizedEjectVerb(object driveItem)
+ {
+ object? verbs = null;
+
+ try
+ {
+ verbs = driveItem.GetType().InvokeMember(
+ "Verbs",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: driveItem,
+ args: null);
+ if (verbs is null)
+ {
+ return false;
+ }
+
+ var verbsType = verbs.GetType();
+ var countObject = verbsType.InvokeMember(
+ "Count",
+ BindingFlags.GetProperty,
+ binder: null,
+ target: verbs,
+ args: null);
+ var count = countObject is null
+ ? 0
+ : Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
+
+ for (var index = 0; index < count; index++)
+ {
+ object? verb = null;
+
+ try
+ {
+ verb = verbsType.InvokeMember(
+ "Item",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: verbs,
+ args: [index]);
+ if (verb is null)
+ {
+ continue;
+ }
+
+ var verbNameObject = verb.GetType().InvokeMember(
+ "Name",
+ BindingFlags.GetProperty,
+ binder: null,
+ target: verb,
+ args: null);
+ var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
+ if (!IsEjectVerbName(verbName))
+ {
+ continue;
+ }
+
+ verb.GetType().InvokeMember(
+ "DoIt",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: verb,
+ args: null);
+ return true;
+ }
+ finally
+ {
+ ReleaseComObject(verb);
+ }
+ }
+
+ return false;
+ }
+ finally
+ {
+ ReleaseComObject(verbs);
+ }
+ }
+
+ private static bool TryInvokeVerb(object driveItem, string verbName)
+ {
+ try
+ {
+ driveItem.GetType().InvokeMember(
+ "InvokeVerb",
+ BindingFlags.InvokeMethod,
+ binder: null,
+ target: driveItem,
+ args: [verbName]);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsEjectVerbName(string? verbName)
+ {
+ if (string.IsNullOrWhiteSpace(verbName))
+ {
+ return false;
+ }
+
+ var normalized = string.Concat(
+ verbName
+ .Where(character => !char.IsWhiteSpace(character) && character != '&'))
+ .Trim();
+
+ return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
+ normalized.Contains("弹出", StringComparison.Ordinal) ||
+ normalized.Contains("安全删除", StringComparison.Ordinal) ||
+ normalized.Contains("卸载", StringComparison.Ordinal);
+ }
+
+ private static string NormalizeRootPath(string? rootPath)
+ {
+ if (string.IsNullOrWhiteSpace(rootPath))
+ {
+ return string.Empty;
+ }
+
+ var trimmed = rootPath.Trim();
+ if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
+ }
+
+ if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
+ {
+ return trimmed + Path.DirectorySeparatorChar;
+ }
+
+ var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
+ var resolvedRoot = Path.GetPathRoot(normalized);
+ return string.IsNullOrWhiteSpace(resolvedRoot)
+ ? normalized
+ : resolvedRoot;
+ }
+
+ private static void ReleaseComObject(object? value)
+ {
+ if (value is not null && Marshal.IsComObject(value))
+ {
+ Marshal.FinalReleaseComObject(value);
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
index 1dabba4..7a82e8f 100644
--- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
@@ -29,6 +29,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
+ bool ClockTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index beca663..6ef48d2 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -361,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
+ snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -373,6 +374,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
+ snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot(
@@ -385,6 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
+ nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);
diff --git a/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml b/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml
index 3fcbaf8..43e9588 100644
--- a/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml
+++ b/LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml
@@ -1,5 +1,6 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
@@ -74,7 +75,21 @@
+
+
+
+