diff --git a/LanAirApp/README.md b/LanAirApp/README.md
index 3f9de4b..b79d19c 100644
--- a/LanAirApp/README.md
+++ b/LanAirApp/README.md
@@ -1,21 +1,33 @@
-# LanAirApp
+# LanAirApp (Mirror)
## 中文
-`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
+这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
-### 目录说明
+### 这份镜像的角色
-- `docs/`:插件开发与打包文档。
-- `samples/`:示例插件与参考项目。
-- `standards/`:插件清单和目录结构约定。
-- `tools/`:插件打包与辅助工具。
+- 提供本地工作区里的 `airappmarket` 索引副本
+- 提供插件文档、工具和样例镜像,便于和宿主一起联调
+- 不承担宿主运行时职责
-### 与宿主的关系
+### 权威来源
-- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
-- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
+- 插件市场与开发文档:独立 `LanAirApp` 仓库
+- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
+- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
## English
-`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
+This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
+
+### Role of this mirror
+
+- keep a local copy of the `airappmarket` index for workspace integration
+- keep mirrored docs, tools, and sample templates for local development
+- avoid duplicating host runtime responsibilities
+
+### Sources of truth
+
+- Plugin market and developer docs: standalone `LanAirApp`
+- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
+- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
diff --git a/LanMountainDesktop.PluginSdk/IPluginContext.cs b/LanMountainDesktop.PluginSdk/IPluginContext.cs
index dc37ece..9b4c980 100644
--- a/LanMountainDesktop.PluginSdk/IPluginContext.cs
+++ b/LanMountainDesktop.PluginSdk/IPluginContext.cs
@@ -1,6 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
-[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
+[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
public interface IPluginContext : IPluginRuntimeContext
{
}
diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
index 4fa6673..c7dcfce 100644
--- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
+++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
@@ -4,7 +4,7 @@
net10.0
enable
enable
- 2.0.0
+ 3.0.0
diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index 908d895..03082ac 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -63,6 +63,7 @@ public partial class App : Application
private bool _uiUnhandledExceptionHooked;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
+ internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
@@ -569,6 +570,18 @@ public partial class App : Application
_exitCleanupCompleted = true;
_settingsFacade.Settings.Changed -= OnSettingsChanged;
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
+
+ try
+ {
+ var (analytics, crashReport) = App.AnalyticsServices;
+ analytics?.SendShutdownEvent();
+ crashReport?.SendShutdownEvent();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
+ }
+
try
{
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index 791fdb7..2a5f164 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -27,8 +27,7 @@
-
+
@@ -56,6 +55,8 @@
+
+
@@ -69,17 +70,13 @@
-
+
-
+
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index ad65e3e..5be34b1 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -70,6 +70,8 @@ public sealed class AppSettingsSnapshot
public bool UploadAnonymousUsageData { get; set; }
+ public string? DeviceId { get; set; }
+
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs
index a47d423..350927d 100644
--- a/LanMountainDesktop/Program.cs
+++ b/LanMountainDesktop/Program.cs
@@ -7,6 +7,7 @@ using Avalonia.WebView.Desktop;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
+using Sentry;
namespace LanMountainDesktop;
@@ -14,14 +15,14 @@ sealed class Program
{
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
- // Initialization code. Don't use any Avalonia, third-party APIs or any
- // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
- // yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
+ InitializeDeviceId();
+ InitializeCrashReporting();
+ InitializeUserBehaviorAnalytics();
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
@@ -49,6 +50,7 @@ sealed class Program
StartupRenderMode = renderMode;
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
+ App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -63,7 +65,6 @@ sealed class Program
}
}
- // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
{
var builder = AppBuilder.Configure()
@@ -151,7 +152,6 @@ sealed class Program
}
catch (ArgumentException)
{
- // The previous process already exited before we started waiting.
}
catch (Exception ex)
{
@@ -167,6 +167,11 @@ sealed class Program
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
+
+ if (eventArgs.IsTerminating)
+ {
+ SentrySdk.Flush(TimeSpan.FromSeconds(5));
+ }
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
@@ -175,4 +180,187 @@ sealed class Program
eventArgs.SetObserved();
};
}
+
+ private static void InitializeDeviceId()
+ {
+ try
+ {
+ DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
+ AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
+ }
+ }
+
+ private static void InitializeSentryForAnalytics()
+ {
+ try
+ {
+ var deviceId = DeviceIdService.Instance.DeviceId;
+
+ SentrySdk.Init(options =>
+ {
+ options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
+ options.AutoSessionTracking = true;
+ options.Release = GetAppVersion();
+ options.Environment = GetEnvironment();
+ });
+
+ SentrySdk.ConfigureScope(scope =>
+ {
+ scope.User = new SentryUser
+ {
+ Id = deviceId
+ };
+
+ scope.SetTag("data_type", "analytics");
+ scope.SetTag("device_id", deviceId);
+ scope.SetTag("app_version", GetAppVersion());
+ scope.SetTag("os_name", GetOsName());
+ scope.SetTag("os_version", GetOsVersion());
+ scope.SetTag("os_build", GetOsBuild());
+ scope.SetTag("device_model", GetDeviceModel());
+ scope.SetTag("device_arch", GetDeviceArchitecture());
+ scope.SetTag("processor_count", GetProcessorCount().ToString());
+ scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
+ scope.SetTag("runtime_version", GetRuntimeVersion());
+ scope.SetTag("language", GetSystemLanguage());
+ scope.SetTag("clr_version", GetClrVersion());
+ scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
+ });
+
+ SentrySdk.CaptureMessage("user_active");
+
+ AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
+ }
+ }
+
+ private static string GetAppVersion()
+ {
+ var version = typeof(Program).Assembly.GetName().Version;
+ return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+
+ private static string GetOsName()
+ {
+ if (OperatingSystem.IsWindows()) return "Windows";
+ if (OperatingSystem.IsLinux()) return "Linux";
+ if (OperatingSystem.IsMacOS()) return "macOS";
+ return "Unknown";
+ }
+
+ private static string GetOsVersion()
+ {
+ try { return Environment.OSVersion.VersionString ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetOsBuild()
+ {
+ try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetDeviceName()
+ {
+ try { return Environment.MachineName ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetDeviceModel()
+ {
+ if (OperatingSystem.IsWindows()) return "Windows PC";
+ if (OperatingSystem.IsLinux()) return "Linux PC";
+ if (OperatingSystem.IsMacOS()) return "Mac";
+ return "Unknown";
+ }
+
+ private static string GetDeviceArchitecture()
+ {
+ return Environment.Is64BitOperatingSystem ? "x64" : "x86";
+ }
+
+ private static int GetProcessorCount()
+ {
+ return Environment.ProcessorCount;
+ }
+
+ private static long GetTotalMemoryMB()
+ {
+ try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
+ catch { return 0; }
+ }
+
+ private static string GetRuntimeVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ private static string GetSystemLanguage()
+ {
+ try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
+ catch { return "en-US"; }
+ }
+
+ private static string GetClrVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ private static CrashReportService? _crashReportService;
+ private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
+
+ private static void InitializeCrashReporting()
+ {
+ try
+ {
+ var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ _crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
+ _crashReportService.RefreshEnabledState();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
+ }
+ }
+
+ private static void InitializeUserBehaviorAnalytics()
+ {
+ try
+ {
+ var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ _userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
+ _userBehaviorAnalyticsService.Initialize();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
+ }
+ }
+
+ private static string GetReleaseVersion()
+ {
+ var assembly = typeof(Program).Assembly;
+ var version = assembly.GetName().Version;
+ if (version is null)
+ {
+ return "1.0.0";
+ }
+ return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
+ }
+
+ private static string GetEnvironment()
+ {
+#if DEBUG
+ return "development";
+#else
+ return "production";
+#endif
+ }
}
diff --git a/LanMountainDesktop/Services/CrashReportService.cs b/LanMountainDesktop/Services/CrashReportService.cs
new file mode 100644
index 0000000..83a2880
--- /dev/null
+++ b/LanMountainDesktop/Services/CrashReportService.cs
@@ -0,0 +1,943 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using Sentry;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class DeviceIdService
+{
+ private static DeviceIdService? _instance;
+ private string? _deviceId;
+ private readonly ISettingsFacadeService _settingsFacade;
+ private bool _isInitialized;
+
+ public static DeviceIdService Instance => _instance ?? throw new InvalidOperationException("DeviceIdService not initialized");
+
+ public DeviceIdService(ISettingsFacadeService settingsFacade)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ }
+
+ public static void Initialize(ISettingsFacadeService settingsFacade)
+ {
+ _instance = new DeviceIdService(settingsFacade);
+ _instance.EnsureDeviceId();
+ }
+
+ public string DeviceId
+ {
+ get
+ {
+ if (_deviceId is null)
+ {
+ throw new InvalidOperationException("DeviceId not initialized");
+ }
+ return _deviceId;
+ }
+ }
+
+ private void EnsureDeviceId()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _isInitialized = true;
+
+ try
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+
+ if (string.IsNullOrEmpty(snapshot.DeviceId))
+ {
+ snapshot.DeviceId = GenerateDeviceId();
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
+ _deviceId = snapshot.DeviceId;
+ AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
+ }
+ else
+ {
+ _deviceId = snapshot.DeviceId;
+ AppLogger.Info("DeviceId", $"Loaded existing device ID: {_deviceId}");
+ }
+ }
+ catch (Exception ex)
+ {
+ _deviceId = GenerateDeviceId();
+ AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
+ }
+ }
+
+ private static string GenerateDeviceId()
+ {
+ var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{timestamp}";
+ using var sha = System.Security.Cryptography.SHA256.Create();
+ var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
+ return Convert.ToHexString(hash)[..32].ToLower();
+ }
+}
+
+public sealed class UserBehaviorAnalyticsService : IDisposable
+{
+ private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
+ private const string PostHogHost = "https://us.i.posthog.com/capture/";
+
+ private bool _isEnabled;
+ private bool _isInitialized;
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly DeviceIdService _deviceIdService;
+ private readonly Queue _eventQueue = new();
+ private readonly object _queueLock = new();
+ private System.Threading.Timer? _flushTimer;
+ private readonly PluginSdk.ISettingsService _settingsService;
+
+ public UserBehaviorAnalyticsService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _settingsService = settingsFacade.Settings;
+ _deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
+ _settingsService.Changed += OnSettingsChanged;
+ }
+
+ private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
+ {
+ if (e.Scope == PluginSdk.SettingsScope.App &&
+ e.ChangedKeys is not null &&
+ (e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
+ {
+ AppLogger.Info("UserBehaviorAnalytics", "Settings changed, refreshing enabled state.");
+ RefreshEnabledState();
+ }
+ }
+
+ public void Initialize()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _isInitialized = true;
+ RefreshEnabledState();
+
+ try
+ {
+ _flushTimer = new System.Threading.Timer(
+ _ => FlushEvents(),
+ null,
+ TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(30));
+
+ CaptureEvent("app_online", new Dictionary
+ {
+ { "event_type", "app_start" }
+ });
+
+ AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to initialize analytics.", ex);
+ }
+ }
+
+ public void TrackClick(string componentName, string? action = null)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("ui_click", new Dictionary
+ {
+ { "component", componentName },
+ { "action", action ?? "click" }
+ });
+ }
+
+ public void TrackComponentDrag(string componentId, string action)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("component_drag", new Dictionary
+ {
+ { "component_id", componentId },
+ { "action", action }
+ });
+ }
+
+ public void TrackComponentDrop(string componentId, string targetPosition)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("component_drop", new Dictionary
+ {
+ { "component_id", componentId },
+ { "target_position", targetPosition }
+ });
+ }
+
+ public void TrackSettingsOpen(string settingsPage)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("settings_open", new Dictionary
+ {
+ { "page", settingsPage }
+ });
+ }
+
+ public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("settings_change", new Dictionary
+ {
+ { "page", settingsPage },
+ { "key", settingKey },
+ { "old_value", oldValue ?? "" },
+ { "new_value", newValue }
+ });
+ }
+
+ public void TrackSettingsClose(string settingsPage)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("settings_close", new Dictionary
+ {
+ { "page", settingsPage }
+ });
+ }
+
+ public void TrackUpdateAction(string action, string? version = null)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ var props = new Dictionary
+ {
+ { "action", action }
+ };
+
+ if (version is not null)
+ {
+ props["version"] = version;
+ }
+
+ CaptureEvent("update_action", props);
+ }
+
+ public void TrackRestartAction(string action)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("restart_action", new Dictionary
+ {
+ { "action", action }
+ });
+ }
+
+ public void TrackNavigation(string fromPage, string toPage)
+ {
+ if (!_isEnabled || !_isInitialized)
+ {
+ return;
+ }
+
+ CaptureEvent("navigation", new Dictionary
+ {
+ { "from", fromPage },
+ { "to", toPage }
+ });
+ }
+
+ public void SendCrashEvent()
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ var properties = new Dictionary
+ {
+ { "app_version", GetAppVersion() },
+ { "event_time", DateTimeOffset.UtcNow.ToString("o") },
+ { "event_type", "app_crash" }
+ };
+
+ CaptureEvent("app_crash", properties);
+ FlushEvents();
+
+ AppLogger.Info("UserBehaviorAnalytics", $"Crash event sent. DeviceId={_deviceIdService.DeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to send crash event.", ex);
+ }
+ }
+
+ public void SendShutdownEvent()
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ var properties = new Dictionary
+ {
+ { "app_version", GetAppVersion() },
+ { "event_time", DateTimeOffset.UtcNow.ToString("o") },
+ { "event_type", "app_shutdown" }
+ };
+
+ if (_isEnabled)
+ {
+ properties["os_name"] = GetOsName();
+ properties["os_version"] = GetOsVersion();
+ properties["device_name"] = GetDeviceName();
+ properties["device_model"] = GetDeviceModel();
+ properties["device_arch"] = GetDeviceArchitecture();
+ properties["language"] = GetSystemLanguage();
+ }
+
+ CaptureEvent("app_shutdown", properties);
+ FlushEvents();
+
+ AppLogger.Info("UserBehaviorAnalytics", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to send shutdown event.", ex);
+ }
+ }
+
+ public void RefreshEnabledState()
+ {
+ try
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ var newEnabled = snapshot.UploadAnonymousUsageData;
+
+ if (_isEnabled != newEnabled)
+ {
+ _isEnabled = newEnabled;
+ AppLogger.Info("UserBehaviorAnalytics", $"User behavior analytics enabled state changed to '{_isEnabled}'.");
+
+ if (_isEnabled && _isInitialized)
+ {
+ CaptureEvent("analytics_enabled", new Dictionary());
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
+ _isEnabled = false;
+ }
+ }
+
+ public void CaptureEvent(string eventName, Dictionary? properties = null)
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ var eventData = new UserBehaviorEvent
+ {
+ Event = eventName,
+ DistinctId = _deviceIdService.DeviceId,
+ Timestamp = DateTimeOffset.UtcNow,
+ Properties = properties ?? new Dictionary(),
+ IncludeDetailedData = _isEnabled
+ };
+
+ lock (_queueLock)
+ {
+ _eventQueue.Enqueue(eventData);
+
+ if (_eventQueue.Count >= 20)
+ {
+ FlushEvents();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", $"Failed to capture event '{eventName}'.", ex);
+ }
+ }
+
+ public void CapturePageView(string pageName, string? sourcePage = null)
+ {
+ var properties = new Dictionary
+ {
+ { "page_name", pageName }
+ };
+
+ if (!string.IsNullOrEmpty(sourcePage))
+ {
+ properties["source_page"] = sourcePage;
+ }
+
+ CaptureEvent("page_view", properties);
+ }
+
+ public void CaptureFeatureUsage(string featureName, string action)
+ {
+ CaptureEvent("feature_usage", new Dictionary
+ {
+ { "feature_name", featureName },
+ { "action", action }
+ });
+ }
+
+ private void FlushEvents()
+ {
+ List eventsToSend;
+
+ lock (_queueLock)
+ {
+ if (_eventQueue.Count == 0)
+ {
+ return;
+ }
+
+ eventsToSend = new List();
+ while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
+ {
+ eventsToSend.Add(_eventQueue.Dequeue());
+ }
+ }
+
+ try
+ {
+ SendEventsToPostHog(eventsToSend);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog.", ex);
+
+ lock (_queueLock)
+ {
+ foreach (var evt in eventsToSend)
+ {
+ if (_eventQueue.Count < 100)
+ {
+ _eventQueue.Enqueue(evt);
+ }
+ }
+ }
+ }
+ }
+
+ private void SendEventsToPostHog(List events)
+ {
+ try
+ {
+ using var client = new System.Net.Http.HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+
+ var firstEvent = events.FirstOrDefault();
+ if (firstEvent is not null)
+ {
+ SendIdentifyToPostHog(client, firstEvent.DistinctId);
+ }
+
+ foreach (var e in events)
+ {
+ var properties = new Dictionary
+ {
+ { "distinct_id", e.DistinctId }
+ };
+
+ if (e.IncludeDetailedData)
+ {
+ properties["$os"] = GetOsName();
+ properties["$os_version"] = GetOsVersion();
+ properties["$app_version"] = GetAppVersion();
+ properties["$device_id"] = e.DistinctId;
+ }
+
+ foreach (var kvp in e.Properties)
+ {
+ properties[kvp.Key] = kvp.Value;
+ }
+
+ var requestBody = new Dictionary
+ {
+ { "api_key", PostHogApiKey },
+ { "event", e.Event },
+ { "timestamp", e.Timestamp.ToString("o") },
+ { "properties", properties }
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ var content = new System.Net.Http.ByteArrayContent(bytes);
+ content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+
+ var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
+ var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", $"PostHog API error for event '{e.Event}': {response.StatusCode} - {responseBody}");
+ }
+ }
+
+ AppLogger.Info("UserBehaviorAnalytics", $"Successfully sent {events.Count} events to PostHog.");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog API.", ex);
+ }
+ }
+
+ private void SendIdentifyToPostHog(System.Net.Http.HttpClient client, string distinctId)
+ {
+ try
+ {
+ var userProperties = new Dictionary
+ {
+ { "$device_id", distinctId },
+ { "$app_version", GetAppVersion() },
+ { "$os", GetOsName() },
+ { "$os_version", GetOsVersion() }
+ };
+
+ var requestBody = new Dictionary
+ {
+ { "api_key", PostHogApiKey },
+ { "event", "$identify" },
+ { "timestamp", DateTimeOffset.UtcNow.ToString("o") },
+ { "properties", new Dictionary
+ {
+ { "distinct_id", distinctId },
+ { "$set", userProperties }
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ var content = new System.Net.Http.ByteArrayContent(bytes);
+ content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+
+ var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
+ var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+
+ AppLogger.Info("UserBehaviorAnalytics", $"PostHog identify response: {response.StatusCode}");
+
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", $"PostHog identify failed: {response.StatusCode} - {responseBody}");
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Failed to send identify to PostHog.", ex);
+ }
+ }
+
+ private static Dictionary GetEventProperties(UserBehaviorEvent e)
+ {
+ var props = new Dictionary
+ {
+ { "$os", GetOsName() },
+ { "$os_version", GetOsVersion() },
+ { "$app_version", GetAppVersion() },
+ { "$device_id", e.DistinctId }
+ };
+
+ foreach (var kvp in e.Properties)
+ {
+ props[kvp.Key] = kvp.Value;
+ }
+
+ return props;
+ }
+
+ public bool IsEnabled => _isEnabled;
+
+ public string DeviceId => _deviceIdService.DeviceId;
+
+ private static string GetAppVersion()
+ {
+ var assembly = typeof(UserBehaviorAnalyticsService).Assembly;
+ var version = assembly.GetName().Version;
+ return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+
+ private static string GetOsName()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
+ return "Unknown";
+ }
+
+ private static string GetOsVersion()
+ {
+ try { return Environment.OSVersion.VersionString ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetDeviceName()
+ {
+ try { return Environment.MachineName ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetDeviceModel()
+ {
+ var osDesc = RuntimeInformation.OSDescription;
+ if (osDesc.Contains("Windows")) return "Windows PC";
+ if (osDesc.Contains("Linux")) return "Linux PC";
+ if (osDesc.Contains("Darwin")) return "Mac";
+ return osDesc;
+ }
+
+ private static string GetDeviceArchitecture()
+ {
+ return RuntimeInformation.OSArchitecture.ToString();
+ }
+
+ private static string GetSystemLanguage()
+ {
+ try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
+ catch { return "en-US"; }
+ }
+
+ private static string GetOsBuild()
+ {
+ try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static int GetProcessorCount()
+ {
+ return Environment.ProcessorCount;
+ }
+
+ private static long GetTotalMemoryMB()
+ {
+ try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
+ catch { return 0; }
+ }
+
+ private static string GetRuntimeVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ private static string GetClrVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ private static string GetDotNetVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _flushTimer?.Dispose();
+ FlushEvents();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("UserBehaviorAnalytics", "Error disposing analytics service.", ex);
+ }
+ }
+
+ private class UserBehaviorEvent
+ {
+ public string Event { get; set; } = string.Empty;
+ public string DistinctId { get; set; } = string.Empty;
+ public DateTimeOffset Timestamp { get; set; }
+ public Dictionary Properties { get; set; } = new();
+ public bool IncludeDetailedData { get; set; }
+ }
+}
+
+public static class DictionaryExtensions
+{
+ public static Dictionary Merge(this Dictionary first, Dictionary second)
+ {
+ var result = new Dictionary(first);
+ foreach (var kvp in second)
+ {
+ result[kvp.Key] = kvp.Value;
+ }
+ return result;
+ }
+}
+
+public sealed class CrashReportService
+{
+ private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
+
+ private bool _isInitialized;
+ private bool _isEnabled;
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly DeviceIdService _deviceIdService;
+ private readonly PluginSdk.ISettingsService _settingsService;
+
+ public CrashReportService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _settingsService = settingsFacade.Settings;
+ _deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
+ _settingsService.Changed += OnSettingsChanged;
+ }
+
+ private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
+ {
+ if (e.Scope == PluginSdk.SettingsScope.App &&
+ e.ChangedKeys is not null &&
+ (e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
+ {
+ AppLogger.Info("CrashReport", "Settings changed, refreshing enabled state.");
+ RefreshEnabledState();
+ }
+ }
+
+ public void RefreshEnabledState()
+ {
+ try
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ var newEnabled = snapshot.UploadAnonymousCrashData;
+
+ if (_isEnabled != newEnabled)
+ {
+ _isEnabled = newEnabled;
+ AppLogger.Info("CrashReport", $"Crash reporting enabled state changed to '{_isEnabled}'.");
+
+ if (_isEnabled && !_isInitialized)
+ {
+ InitializeSentry();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("CrashReport", "Failed to refresh crash reporting enabled state.", ex);
+ _isEnabled = false;
+ }
+ }
+
+ private void InitializeSentry()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _isInitialized = true;
+
+ try
+ {
+ SentrySdk.Init(options =>
+ {
+ options.Dsn = SentryDsn;
+ options.AutoSessionTracking = true;
+ options.AttachStacktrace = true;
+ options.MaxBreadcrumbs = 100;
+ options.Release = GetAppVersion();
+ options.Environment = GetEnvironment();
+ });
+
+ ConfigureCrashReportingScope();
+
+ AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
+
+#if DEBUG
+ SentrySdk.CaptureMessage($"Crash reporting enabled - Debug mode test. DeviceId={_deviceIdService.DeviceId}");
+#endif
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("CrashReport", "Failed to initialize Sentry crash reporting.", ex);
+ _isInitialized = false;
+ }
+ }
+
+ private void ConfigureCrashReportingScope()
+ {
+ try
+ {
+ SentrySdk.ConfigureScope(scope =>
+ {
+ scope.User = new SentryUser
+ {
+ Id = _deviceIdService.DeviceId
+ };
+
+ scope.SetTag("data_type", "crash_report");
+ scope.SetTag("device_id", _deviceIdService.DeviceId);
+ scope.SetTag("device_name", GetDeviceName());
+ scope.SetTag("device_model", GetDeviceModel());
+ scope.SetTag("device_arch", GetDeviceArchitecture());
+ scope.SetTag("os_name", GetOsName());
+ scope.SetTag("os_version", GetOsVersion());
+ scope.SetTag("language", GetSystemLanguage());
+ });
+
+ AppLogger.Info("CrashReport", $"Crash reporting scope configured. DeviceId={_deviceIdService.DeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("CrashReport", "Failed to configure crash reporting scope.", ex);
+ }
+ }
+
+ public bool IsEnabled => _isEnabled;
+
+ public string DeviceId => _deviceIdService.DeviceId;
+
+ public void SendShutdownEvent()
+ {
+ try
+ {
+ if (_isEnabled && _isInitialized)
+ {
+ AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
+ return;
+ }
+
+ if (!_isInitialized)
+ {
+ SentrySdk.Init(options =>
+ {
+ options.Dsn = SentryDsn;
+ options.AutoSessionTracking = false;
+ options.Release = GetAppVersion();
+ options.Environment = GetEnvironment();
+ });
+ }
+
+ SentrySdk.ConfigureScope(scope =>
+ {
+ scope.User = new SentryUser
+ {
+ Id = _deviceIdService.DeviceId
+ };
+ scope.SetTag("data_type", "shutdown");
+ scope.SetTag("device_id", _deviceIdService.DeviceId);
+ scope.SetTag("app_version", GetAppVersion());
+ });
+
+ SentrySdk.CaptureMessage($"app_shutdown - DeviceId={_deviceIdService.DeviceId}");
+ SentrySdk.Flush(TimeSpan.FromSeconds(3));
+
+ AppLogger.Info("CrashReport", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("CrashReport", "Failed to send shutdown event.", ex);
+ }
+ }
+
+ private static string GetDeviceName()
+ {
+ try { return Environment.MachineName ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetDeviceModel()
+ {
+ var osDesc = RuntimeInformation.OSDescription;
+ if (osDesc.Contains("Windows")) return "Windows PC";
+ if (osDesc.Contains("Linux")) return "Linux PC";
+ if (osDesc.Contains("Darwin")) return "Mac";
+ return osDesc;
+ }
+
+ private static string GetDeviceArchitecture()
+ {
+ return RuntimeInformation.OSArchitecture.ToString();
+ }
+
+ private static string GetOsName()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
+ return "Unknown";
+ }
+
+ private static string GetOsVersion()
+ {
+ try { return Environment.OSVersion.VersionString ?? "Unknown"; }
+ catch { return "Unknown"; }
+ }
+
+ private static string GetSystemLanguage()
+ {
+ try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
+ catch { return "en-US"; }
+ }
+
+ private static string GetAppVersion()
+ {
+ var version = typeof(CrashReportService).Assembly.GetName().Version;
+ return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+
+ private static string GetEnvironment()
+ {
+#if DEBUG
+ return "development";
+#else
+ return "production";
+#endif
+ }
+}
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index 11ff815..464d25a 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -609,6 +609,7 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
var snapshot = _settingsService.Load();
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
+ AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
diff --git a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
index 66435fd..29391ef 100644
--- a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
+++ b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
@@ -1,6 +1,10 @@
+using System;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.ViewModels;
@@ -28,6 +32,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _uploadAnonymousUsageData;
+ [ObservableProperty]
+ private string _deviceId = string.Empty;
+
[ObservableProperty]
private string _privacyHeader = string.Empty;
@@ -43,11 +50,47 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _usageUploadDescription = string.Empty;
+ [ObservableProperty]
+ private string _deviceIdHeader = string.Empty;
+
+ [ObservableProperty]
+ private string _deviceIdDescription = string.Empty;
+
+ [ObservableProperty]
+ private string _refreshDeviceIdText = string.Empty;
+
public void Load()
{
var state = _settingsFacade.Privacy.Get();
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
+ DeviceId = DeviceIdService.Instance.DeviceId;
+ }
+
+ [RelayCommand]
+ private void RefreshDeviceId()
+ {
+ try
+ {
+ var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
+ using var sha = System.Security.Cryptography.SHA256.Create();
+ var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
+ var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
+
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ snapshot.DeviceId = newDeviceId;
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
+
+ DeviceId = newDeviceId;
+ AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
+ }
}
partial void OnUploadAnonymousCrashDataChanged(bool value)
@@ -84,6 +127,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
+ DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
+ DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
+ RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
}
private string L(string key, string fallback)
diff --git a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
index db9614a..2f6478e 100644
--- a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
+++ b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
@@ -31,6 +31,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/plugins/README.md b/LanMountainDesktop/plugins/README.md
index 0f1481a..72f6eb9 100644
--- a/LanMountainDesktop/plugins/README.md
+++ b/LanMountainDesktop/plugins/README.md
@@ -1,38 +1,30 @@
-# 宿主侧插件运行时
+# 宿主侧插件运行时 / Host Plugin Runtime
## 中文
-本目录保存阑山桌面宿主程序中的插件运行时实现。
+本目录保存阑山桌面宿主侧插件运行时实现。
### 主要职责
-- 发现已安装插件
-- 安装和替换 `.laapp` 插件包
-- 加载插件程序集
-- 接入插件贡献的设置页和桌面组件
-- 在宿主设置界面中展示插件与市场信息
+- 发现、安装和替换 `.laapp` 插件包
+- 加载插件程序集和共享契约
+- 接入插件设置页、桌面组件与市场界面
+- 为 `3.0.0` API 基线插件构建插件作用域的 `IServiceCollection` / `ServiceProvider`
+- 在激活前解析共享契约缓存,并暴露显式插件导出
-### 市场安装优先级
+### 与 LanAirApp 的分工
-1. 宿主先连接 `LanAirApp/airappmarket/index.json`。
-2. 当条目同时提供 `releaseTag` 和 `releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
-3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`。
-4. 插件介绍始终读取仓库根目录 `README.md`。
-5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
+- `LanAirApp` 负责官方市场索引、开发文档、校验工具和镜像样例
+- 本目录负责宿主运行时发现、安装、加载和界面接入
+- 权威示例插件是独立仓库 `LanMountainDesktop.SamplePlugin`,`LanAirApp` 中的样例目录只是镜像模板
-### 核心文件
+### 市场安装顺序
-- `PluginLoader.cs`
-- `PluginLoadContext.cs`
-- `PluginRuntimeService.cs`
-- `PluginCatalogEntry.cs`
-- `PluginMarketIndexService.cs`
-- `PluginMarketInstallService.cs`
-
-### 与 `LanAirApp` 的分工
-
-- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
-- 宿主目录负责运行时发现、安装、加载和界面接入。
+1. 宿主读取官方 `LanAirApp/airappmarket/index.json`
+2. 若条目同时包含 `releaseTag` 与 `releaseAssetName`,优先解析 GitHub Release 资产
+3. 若 Release 解析失败,则回退到仓库根目录 `.laapp`
+4. 插件详情始终读取插件仓库根目录 `README.md`
+5. 市场安装为暂存安装,重启后生效
## English
@@ -40,25 +32,22 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
### Responsibilities
-- discover installed plugins
-- install and replace `.laapp` packages
-- load plugin assemblies
-- integrate plugin settings pages and desktop components
-- expose market and plugin management in the host UI
-- build a plugin-scoped `IServiceCollection`/`ServiceProvider` for API `2.0.0` plugins
-- resolve shared contract assemblies into a version-isolated cache before plugin activation
-- expose explicit cross-plugin exports through `IPluginExportRegistry`
+- discover, install, and replace `.laapp` packages
+- load plugin assemblies and shared contracts
+- integrate plugin settings pages, desktop components, and market UI
+- build a plugin-scoped `IServiceCollection` / `ServiceProvider` for API `3.0.0` plugins
+- resolve shared contract caches before activation and expose explicit plugin exports
+
+### Relationship with LanAirApp
+
+- `LanAirApp` owns the official market index, developer docs, validation tools, and mirrored sample templates
+- this directory owns host-side discovery, installation, loading, and UI integration
+- the authoritative sample plugin lives in the standalone `LanMountainDesktop.SamplePlugin` repository; the `LanAirApp` sample directory is only a mirror/template copy
### Market install order
-1. The host reads `LanAirApp/airappmarket/index.json`.
-2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
-3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
-4. Plugin details always come from the repository root `README.md`.
-5. Market installs are staged and take effect after restart.
-
-### Dependency model
-
-- Plugin-private managed and native NuGet dependencies remain plugin-local and are resolved through `AssemblyDependencyResolver`.
-- Shared contract assemblies are downloaded from the official market index, cached under `LocalAppData/LanMountainDesktop/SharedContracts///`, and loaded into the default context so host and plugins share the same contract types.
-- Different contract versions are isolated on disk. If two active plugins request incompatible versions of the same shared assembly name in one process, the host fails the later activation with a clear error instead of loading an ambiguous contract.
+1. The host reads the official `LanAirApp/airappmarket/index.json`
+2. If an entry contains both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset
+3. If Release resolution fails, the host falls back to the repository-root `.laapp`
+4. Plugin details always come from the plugin repository root `README.md`
+5. Market installs are staged and take effect after restart
diff --git a/README.md b/README.md
index 9ba3fa3..eb7619f 100644
--- a/README.md
+++ b/README.md
@@ -1,49 +1,47 @@
-# 阑山桌面(LanMountainDesktop)
+# 阑山桌面 / LanMountainDesktop
## 中文
-阑山桌面是一个基于 Avalonia 的桌面壳层项目。它不是单纯的启动器,而是一个可编排、可扩展、可长期演进的桌面信息空间。
+`LanMontainDesktop` 是阑山桌面的宿主应用权威仓库,负责应用本体、宿主侧插件运行时,以及宿主侧 `PluginSdk` API 基线。
-### 核心目标
+### 本仓库负责什么
-- 通过网格化布局管理桌面组件。
-- 提供状态栏、任务栏和多页桌面的统一外壳。
-- 通过主题、玻璃效果和动效塑造统一体验。
-- 通过组件系统和插件系统持续扩展能力。
+- `LanMountainDesktop/`:桌面宿主应用
+- `LanMountainDesktop.PluginSdk/`:宿主侧插件 API 真源
+- `LanMountainDesktop/plugins/`:插件发现、安装、加载、市场接入
+- `LanMountainDesktop.Tests/`:宿主与插件运行时测试
+- `LanAirApp/`:仅用于联调的镜像副本,权威版本仍以独立 `LanAirApp` 仓库为准
-### 当前工程结构
+### 生态边界
-- `LanMountainDesktop/`:桌面主程序。
-- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端。
-- `LanMountainDesktop/ComponentSystem/`:组件定义与注册系统。
-- `LanMountainDesktop/plugins/`:宿主侧插件加载、安装和设置集成。
-- `docs/`:视觉与设计规范。
-- `LanAirApp/`:插件开发资料镜像,权威版本以独立 `LanAirApp` 仓库为准。
+- 应用本体:`LanMontainDesktop`
+- 插件市场与开发资料:独立 `LanAirApp`
+- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
-### 生态关系
+### 当前插件 API 基线
-- 宿主程序只连接 `LanAirApp` 仓库中的官方市场索引。
-- 官方市场索引返回插件列表以及各插件项目根目录链接。
-- 插件项目根目录提供 `.laapp` 安装包和 `README.md`。
-
-### 当前状态
-
-- Windows 是当前主要目标平台。
-- 已提供组件系统、插件系统、主题系统和设置系统。
-- 中文为主语言,英文为附加扩展语言。
-- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`,SDK 版本由根目录 `global.json` 锁定。
-
-### 运行说明
-
-运行方法见 [run.md](./run.md)。
+- 宿主插件 API 基线:`3.0.0`
+- `SampleClock` 共享契约:`2.0.0`
## English
-LanMountainDesktop is an Avalonia-based desktop shell. It is designed as a composable and extensible desktop environment rather than a simple launcher.
+`LanMontainDesktop` is the authoritative host repository for LanMountainDesktop. It owns the desktop application, the host-side plugin runtime, and the host-side `PluginSdk` API baseline.
-### Main goals
+### What this repository owns
-- manage desktop widgets with a grid-based layout
-- provide a unified shell with status bar, taskbar, and multi-page desktop support
-- build a consistent experience through themes, glass effects, and motion
-- extend capabilities through the component and plugin systems
+- `LanMountainDesktop/`: the desktop host application
+- `LanMountainDesktop.PluginSdk/`: the canonical host-side plugin API
+- `LanMountainDesktop/plugins/`: plugin discovery, installation, loading, and market integration
+- `LanMountainDesktop.Tests/`: host and plugin runtime tests
+- `LanAirApp/`: a mirror kept for local workspace integration only; the standalone `LanAirApp` repository remains the source of truth
+
+### Ecosystem boundaries
+
+- Application host: `LanMontainDesktop`
+- Plugin market and developer-facing materials: standalone `LanAirApp`
+- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
+
+### Current plugin API baseline
+
+- Host plugin API baseline: `3.0.0`
+- `SampleClock` shared contract: `2.0.0`