应用遥测,插件市场
This commit is contained in:
lincube
2026-03-16 09:50:48 +08:00
parent 557b79e8c0
commit 6c9f6be1b1
13 changed files with 1321 additions and 105 deletions

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Version>3.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

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

View File

@@ -27,8 +27,7 @@
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
@@ -56,6 +55,8 @@
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
<PackageReference Include="PostHog" Version="2.4.0" />
<PackageReference Include="Sentry" Version="4.0.0" />
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; or '$(RuntimeIdentifier)' == 'win-x86'" />
@@ -69,17 +70,13 @@
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<ItemGroup>
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

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

View File

@@ -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<App>()
@@ -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
}
}

View File

@@ -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<AppSettingsSnapshot>(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<UserBehaviorEvent> _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<string, object>
{
{ "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<string, object>
{
{ "component", componentName },
{ "action", action ?? "click" }
});
}
public void TrackComponentDrag(string componentId, string action)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("component_drag", new Dictionary<string, object>
{
{ "component_id", componentId },
{ "action", action }
});
}
public void TrackComponentDrop(string componentId, string targetPosition)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("component_drop", new Dictionary<string, object>
{
{ "component_id", componentId },
{ "target_position", targetPosition }
});
}
public void TrackSettingsOpen(string settingsPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_open", new Dictionary<string, object>
{
{ "page", settingsPage }
});
}
public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_change", new Dictionary<string, object>
{
{ "page", settingsPage },
{ "key", settingKey },
{ "old_value", oldValue ?? "" },
{ "new_value", newValue }
});
}
public void TrackSettingsClose(string settingsPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_close", new Dictionary<string, object>
{
{ "page", settingsPage }
});
}
public void TrackUpdateAction(string action, string? version = null)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
var props = new Dictionary<string, object>
{
{ "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<string, object>
{
{ "action", action }
});
}
public void TrackNavigation(string fromPage, string toPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("navigation", new Dictionary<string, object>
{
{ "from", fromPage },
{ "to", toPage }
});
}
public void SendCrashEvent()
{
if (!_isInitialized)
{
return;
}
try
{
var properties = new Dictionary<string, object>
{
{ "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<string, object>
{
{ "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<AppSettingsSnapshot>(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<string, object>());
}
}
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
_isEnabled = false;
}
}
public void CaptureEvent(string eventName, Dictionary<string, object>? properties = null)
{
if (!_isInitialized)
{
return;
}
try
{
var eventData = new UserBehaviorEvent
{
Event = eventName,
DistinctId = _deviceIdService.DeviceId,
Timestamp = DateTimeOffset.UtcNow,
Properties = properties ?? new Dictionary<string, object>(),
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<string, object>
{
{ "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<string, object>
{
{ "feature_name", featureName },
{ "action", action }
});
}
private void FlushEvents()
{
List<UserBehaviorEvent> eventsToSend;
lock (_queueLock)
{
if (_eventQueue.Count == 0)
{
return;
}
eventsToSend = new List<UserBehaviorEvent>();
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<UserBehaviorEvent> 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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "$device_id", distinctId },
{ "$app_version", GetAppVersion() },
{ "$os", GetOsName() },
{ "$os_version", GetOsVersion() }
};
var requestBody = new Dictionary<string, object>
{
{ "api_key", PostHogApiKey },
{ "event", "$identify" },
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
{ "properties", new Dictionary<string, object>
{
{ "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<string, object> GetEventProperties(UserBehaviorEvent e)
{
var props = new Dictionary<string, object>
{
{ "$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<string, object> Properties { get; set; } = new();
public bool IncludeDetailedData { get; set; }
}
}
public static class DictionaryExtensions
{
public static Dictionary<string, object> Merge(this Dictionary<string, object> first, Dictionary<string, object> second)
{
var result = new Dictionary<string, object>(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<AppSettingsSnapshot>(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
}
}

View File

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

View File

@@ -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<Models.AppSettingsSnapshot>(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)

View File

@@ -31,6 +31,33 @@
<ToggleSwitch IsChecked="{Binding UploadAnonymousUsageData}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8"
Padding="16"
Margin="0,16,0,0">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding DeviceIdHeader}"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="{Binding DeviceIdDescription}"
FontSize="12"
Opacity="0.7"
Margin="0,4,0,8" />
<TextBox Text="{Binding DeviceId}"
IsReadOnly="True"
FontFamily="Consolas"
FontSize="12" />
</StackPanel>
<Button Grid.Column="1"
Content="{Binding RefreshDeviceIdText}"
Command="{Binding RefreshDeviceIdCommand}"
VerticalAlignment="Center"
Margin="16,0,0,0"
Classes="accent-button" />
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -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/<id>/<version>/`, 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

View File

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