From 6c9f6be1b1db62fa934436a0cb2d684cc332fef5 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 16 Mar 2026 09:50:48 +0800 Subject: [PATCH] 0.6.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应用遥测,插件市场 --- LanAirApp/README.md | 34 +- .../IPluginContext.cs | 2 +- .../LanMountainDesktop.PluginSdk.csproj | 2 +- LanMountainDesktop/App.axaml.cs | 13 + LanMountainDesktop/LanMountainDesktop.csproj | 13 +- .../Models/AppSettingsSnapshot.cs | 2 + LanMountainDesktop/Program.cs | 198 +++- .../Services/CrashReportService.cs | 943 ++++++++++++++++++ .../Settings/SettingsDomainServices.cs | 1 + .../PrivacySettingsPageViewModel.cs | 46 + .../SettingsPages/PrivacySettingsPage.axaml | 27 + LanMountainDesktop/plugins/README.md | 77 +- README.md | 68 +- 13 files changed, 1321 insertions(+), 105 deletions(-) create mode 100644 LanMountainDesktop/Services/CrashReportService.cs 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 @@ + + + + + + + + +