diff --git a/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs b/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
index 6d2e2c3..d379527 100644
--- a/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
+++ b/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
@@ -5,16 +5,20 @@ namespace LanMountainDesktop.DesktopHost;
public static class DesktopBootstrap
{
- public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup)
+ public static void InitializeStartupServices(
+ Action initializeTelemetryIdentity,
+ Action initializeCrashTelemetry,
+ Action initializeUsageTelemetry,
+ Action scheduleStartupCleanup)
{
- ArgumentNullException.ThrowIfNull(initializeDeviceId);
- ArgumentNullException.ThrowIfNull(initializeCrashReporting);
- ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
+ ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
+ ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
+ ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
- initializeDeviceId();
- initializeCrashReporting();
- initializeUserBehaviorAnalytics();
+ initializeTelemetryIdentity();
+ initializeCrashTelemetry();
+ initializeUsageTelemetry();
scheduleStartupCleanup();
}
diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml
index f4e0fa1..2f801bc 100644
--- a/LanMountainDesktop/App.axaml
+++ b/LanMountainDesktop/App.axaml
@@ -71,4 +71,5 @@
+
diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index 0a8076f..729d424 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -57,7 +57,12 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
- private TrayIcons? _trayIcons;
+ private TrayIcon? _trayIcon;
+ private NativeMenuItem? _trayShowDesktopMenuItem;
+ private NativeMenuItem? _traySettingsMenuItem;
+ private NativeMenuItem? _trayComponentLibraryMenuItem;
+ private NativeMenuItem? _trayRestartMenuItem;
+ private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private bool _mainWindowClosed;
@@ -65,7 +70,6 @@ public partial class App : Application
private DesktopShellHost? _desktopShellHost;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
- internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
@@ -244,18 +248,43 @@ public partial class App : Application
{
try
{
- DisposeTrayIcon();
-
- var trayIcon = new TrayIcon
+ if (_trayIcon is null)
{
- Icon = _appLogoService.CreateTrayIcon(),
- ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
- Menu = BuildTrayMenu(),
- IsVisible = true
- };
+ _trayShowDesktopMenuItem = new NativeMenuItem();
+ _trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
- _trayIcons = [trayIcon];
- TrayIcon.SetIcons(this, _trayIcons);
+ _traySettingsMenuItem = new NativeMenuItem();
+ _traySettingsMenuItem.Click += OnTraySettingsClick;
+
+ _trayComponentLibraryMenuItem = new NativeMenuItem();
+ _trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
+
+ _trayRestartMenuItem = new NativeMenuItem();
+ _trayRestartMenuItem.Click += OnTrayRestartClick;
+
+ _trayExitMenuItem = new NativeMenuItem();
+ _trayExitMenuItem.Click += OnTrayExitClick;
+
+ var trayMenu = new NativeMenu();
+ trayMenu.Items.Add(_trayShowDesktopMenuItem);
+ trayMenu.Items.Add(_traySettingsMenuItem);
+ trayMenu.Items.Add(_trayComponentLibraryMenuItem);
+ trayMenu.Items.Add(new NativeMenuItemSeparator());
+ trayMenu.Items.Add(_trayRestartMenuItem);
+ trayMenu.Items.Add(new NativeMenuItemSeparator());
+ trayMenu.Items.Add(_trayExitMenuItem);
+
+ _trayIcon = new TrayIcon
+ {
+ Icon = _appLogoService.CreateTrayIcon(),
+ Menu = trayMenu,
+ IsVisible = true
+ };
+
+ TrayIcon.SetIcons(this, [_trayIcon]);
+ }
+
+ RefreshTrayIconContent();
}
catch (Exception ex)
{
@@ -263,51 +292,58 @@ public partial class App : Application
}
}
- private NativeMenu BuildTrayMenu()
+ private void RefreshTrayIconContent()
{
- var menu = new NativeMenu();
+ if (_trayIcon is not null)
+ {
+ _trayIcon.IsVisible = true;
+ if (!OperatingSystem.IsLinux())
+ {
+ _trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
+ }
+ }
- var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
- showDesktopItem.Click += OnTrayShowDesktopClick;
- menu.Items.Add(showDesktopItem);
+ if (_trayShowDesktopMenuItem is not null)
+ {
+ _trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
+ }
- var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
- settingsItem.Click += OnTraySettingsClick;
- menu.Items.Add(settingsItem);
+ if (_traySettingsMenuItem is not null)
+ {
+ _traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
+ }
- var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
- componentLibraryItem.Click += OnTrayComponentLibraryClick;
- menu.Items.Add(componentLibraryItem);
+ if (_trayComponentLibraryMenuItem is not null)
+ {
+ _trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
+ }
- menu.Items.Add(new NativeMenuItemSeparator());
+ if (_trayRestartMenuItem is not null)
+ {
+ _trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
+ }
- var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
- restartItem.Click += OnTrayRestartClick;
- menu.Items.Add(restartItem);
-
- menu.Items.Add(new NativeMenuItemSeparator());
-
- var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
- exitItem.Click += OnTrayExitClick;
- menu.Items.Add(exitItem);
-
- return menu;
+ if (_trayExitMenuItem is not null)
+ {
+ _trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
+ }
}
private void DisposeTrayIcon()
{
- if (_trayIcons is null)
+ if (_trayIcon is null)
{
return;
}
- TrayIcon.SetIcons(this, null);
- foreach (var trayIcon in _trayIcons)
+ try
{
- trayIcon.Dispose();
+ _trayIcon.IsVisible = false;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
}
-
- _trayIcons = null;
}
private void EnsureSettingsWindowService()
@@ -520,10 +556,7 @@ public partial class App : Application
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
- if (_trayIcons is not null)
- {
- InitializeTrayIcon();
- }
+ RefreshTrayIconContent();
}
}, DispatcherPriority.Background);
}
@@ -591,13 +624,13 @@ public partial class App : Application
try
{
- var (analytics, crashReport) = App.AnalyticsServices;
- analytics?.SendShutdownEvent();
- crashReport?.SendShutdownEvent();
+ TelemetryServices.Usage?.Shutdown(
+ _shutdownIntent == ShutdownIntent.RestartRequested,
+ "App.PerformExitCleanup");
}
catch (Exception ex)
{
- AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
+ AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex);
}
try
@@ -631,6 +664,27 @@ public partial class App : Application
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
+
+ try
+ {
+ TelemetryServices.Crash?.CaptureShutdown(
+ _shutdownIntent == ShutdownIntent.RestartRequested,
+ "App.PerformExitCleanup");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex);
+ }
+
+ try
+ {
+ TelemetryServices.Crash?.Dispose();
+ TelemetryServices.Usage?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex);
+ }
}
private MainWindow CreateAndAssignMainWindow(
diff --git a/LanMountainDesktop/Assets/Documents/Privacy.md b/LanMountainDesktop/Assets/Documents/Privacy.md
index 8ebf5b8..a49e00b 100644
--- a/LanMountainDesktop/Assets/Documents/Privacy.md
+++ b/LanMountainDesktop/Assets/Documents/Privacy.md
@@ -1,326 +1,54 @@
-# LanMountainDesktop 隐私政策
+# 隐私与遥测说明
-**最后更新日期:2026年3月17日**
+LanMountainDesktop 提供两类可选遥测能力:
----
+- 崩溃数据上传
+- 行为数据分析
-## 引言
+这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。
-欢迎使用 LanMountainDesktop!我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
+## 默认行为
-**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
+当“崩溃数据上传”和“行为数据分析”都关闭时:
----
+- 仅首次启动会发送一次 `app_first_launch` 事件
+- 该事件只用于统计用户量
+- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定
+- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息
-## 1. 数据收集范围
+## 崩溃数据上传
-### 1.1 我们收集的数据
+当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。
-当您启用匿名数据收集功能时,我们会收集以下数据:
+上报内容可能包括:
-#### 匿名崩溃数据
-- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
-- **设备信息**:操作系统版本、设备型号、架构(x64/x86)
-- **应用版本**:当前使用的应用版本号
-- **设备标识符**:匿名生成的唯一设备ID(不包含个人信息)
+- 异常堆栈和错误上下文
+- 应用版本与运行环境
+- 操作系统信息
+- 设备基础信息
+- 最近的日志尾部内容
-#### 匿名使用数据
-- **应用启动和关闭事件**:记录应用何时启动和关闭
-- **功能使用统计**:哪些功能被使用、使用频率
-- **设置变更**:用户更改了哪些设置(不包含具体设置值)
-- **界面交互**:点击了哪些按钮、访问了哪些页面
-- **设备信息**:操作系统、应用版本、设备类型
+应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。
-### 1.2 始终收集的基础数据
+## 行为数据分析
-**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
+当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。
-- ✅ **应用启动事件**:用于统计日活跃用户
-- ✅ **设备标识符**:用于区分不同用户(不包含个人信息)
-- ✅ **应用版本**:用于统计版本分布
+上报内容可能包括:
-**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
+- 应用启动和退出时间
+- 会话开始与结束时间
+- 设置页打开、关闭和导航
+- 抽屉打开和关闭
+- 桌面组件的放置、移动、缩放、删除和编辑入口
-### 1.3 我们不收集的数据
+这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。
-我们**明确承诺不收集**以下数据:
+## 身份与隐私控制
-- ❌ 个人身份信息(姓名、邮箱、电话等)
-- ❌ 真实姓名或用户名
-- ❌ 地理位置信息(精确位置)
-- ❌ 文件内容或文档数据
-- ❌ 密码或凭据信息
-- ❌ 网络浏览历史
-- ❌ 联系人信息
-- ❌ 照片、视频或音频文件
+应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。
----
+- 刷新 telemetry ID 只会影响后续详细遥测
+- 关闭开关后,不会继续发送对应类别的详细遥测
+- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报
-## 2. 数据收集目的
-
-我们收集数据的目的如下:
-
-### 2.1 基础数据用途(始终收集)
-- **统计用户数量**:了解应用的用户规模
-- **统计日活跃用户**:了解应用的活跃程度
-- **版本分布统计**:了解用户使用的版本情况
-
-### 2.2 崩溃数据用途
-- **提高应用稳定性**:识别和修复崩溃问题
-- **优化性能**:分析性能瓶颈
-- **改进用户体验**:了解应用在不同设备上的表现
-
-### 2.3 使用数据用途
-- **功能优化**:了解哪些功能最受欢迎,优先改进
-- **用户体验改进**:优化界面设计和交互流程
-- **统计分析**:了解用户规模和使用趋势
-- **产品决策**:基于数据做出产品发展方向决策
-
----
-
-## 3. 数据存储和处理
-
-### 3.1 数据存储位置
-
-我们使用以下第三方服务存储和处理数据:
-
-#### Sentry(崩溃报告)
-- **用途**:崩溃数据收集和分析
-- **位置**:美国
-- **官网**:https://sentry.io
-- **隐私政策**:https://sentry.io/privacy/
-
-#### PostHog(使用分析)
-- **用途**:用户行为分析和统计
-- **位置**:美国
-- **官网**:https://posthog.com
-- **隐私政策**:https://posthog.com/privacy
-
-### 3.2 数据保留期限
-
-- **崩溃数据**:保留90天后自动删除
-- **使用数据**:保留12个月后自动删除
-- **设备标识符**:永久保留(用于统计日活用户)
-
-### 3.3 数据安全措施
-
-我们采取以下安全措施保护您的数据:
-
-- ✅ 数据传输使用HTTPS加密
-- ✅ 数据存储使用加密技术
-- ✅ 访问权限严格控制
-- ✅ 定期安全审计
-
----
-
-## 4. 数据共享
-
-### 4.1 我们不会出售您的数据
-
-我们**明确承诺**:
-- ❌ 不会出售您的个人数据
-- ❌ 不会将您的数据用于广告目的
-- ❌ 不会与第三方共享可识别个人的数据
-
-### 4.2 必要的共享
-
-我们仅在以下情况下共享数据:
-
-- **服务提供商**:与Sentry和PostHog共享数据以提供服务
-- **法律要求**:在法律要求或政府机构合法要求时
-
----
-
-## 5. 您的权利
-
-### 5.1 选择权
-
-您完全控制详细数据收集:
-
-- **匿名崩溃数据**:可在设置中开启或关闭
-- **匿名使用数据**:可在设置中开启或关闭
-- **基础数据**:始终收集(用于统计用户数量)
-
-**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。
-
-### 5.2 数据访问权
-
-您可以:
-- 查看我们收集的数据类型
-- 了解数据的使用目的
-- 了解数据的存储位置
-
-### 5.3 数据删除权
-
-您可以:
-- 随时关闭详细数据收集功能
-- 删除本地存储的设备标识符
-- 联系我们删除已收集的数据
-
----
-
-## 6. 设备标识符
-
-### 6.1 什么是设备标识符?
-
-设备标识符是一个随机生成的唯一字符串,用于:
-- 统计日活用户数量
-- 区分不同设备
-- 分析用户使用趋势
-
-### 6.2 设备标识符的特点
-
-- **匿名性**:不包含任何个人信息
-- **随机性**:通过SHA256算法生成
-- **唯一性**:每个设备有唯一标识符
-- **持久性**:即使刷新设备ID,仍能关联到同一用户
-- **可重置**:您可以在设置中刷新设备标识符
-
-### 6.3 设备标识符刷新
-
-当您刷新设备标识符时:
-- 生成新的设备ID
-- **持久用户ID保持不变**,确保仍能关联到同一用户
-- 统计数据不会丢失
-
-### 6.4 设备标识符示例
-
-```
-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
-```
-
----
-
-## 7. 儿童隐私保护
-
-本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据,请联系我们,我们将立即删除相关数据。
-
----
-
-## 8. 国际数据传输
-
-由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保:
-
-- 数据传输符合相关法律法规
-- 服务提供商遵守GDPR等隐私法规
-- 采取适当的安全措施保护数据
-
----
-
-## 9. 隐私政策更新
-
-我们可能会不时更新本隐私政策。更新时,我们将:
-
-- 在本页面更新"最后更新日期"
-- 在应用内通知您重大变更
-- 继续使用应用即表示您同意更新后的政策
-
----
-
-## 10. 联系我们
-
-如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:
-
-- **GitHub Issues**:https://github.com/wwiinnddyy/LanMountainDesktop/issues
-- **电子邮件**:[您的邮箱地址]
-
----
-
-## 11. 法律依据
-
-### 11.1 GDPR合规
-
-如果您位于欧洲经济区(EEA),我们的数据处理基于:
-
-- **同意**:您明确同意数据收集
-- **合法利益**:改进应用性能和用户体验
-
-### 11.2 CCPA合规
-
-如果您是加州居民,您有权:
-
-- 知道我们收集了哪些数据
-- 要求删除您的数据
-- 选择退出数据销售(我们不销售数据)
-
----
-
-## 12. 第三方链接
-
-本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。
-
----
-
-## 13. 数据收集示例
-
-### 13.1 崩溃报告示例
-
-```json
-{
- "event_id": "abc123",
- "timestamp": "2024-01-01T12:00:00Z",
- "device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "app_version": "1.0.0",
- "os_name": "Windows",
- "os_version": "10.0.19041",
- "error_message": "NullReferenceException",
- "stack_trace": "..."
-}
-```
-
-### 13.2 使用数据示例
-
-```json
-{
- "event": "app_online",
- "timestamp": "2024-01-01T12:00:00Z",
- "distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "properties": {
- "app_version": "1.0.0",
- "os_name": "Windows",
- "event_type": "app_start",
- "analytics_enabled": true
- }
-}
-```
-
-### 13.3 基础数据示例(始终收集)
-
-```json
-{
- "event": "$pageview",
- "timestamp": "2024-01-01T12:00:00Z",
- "distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "properties": {
- "$current_url": "app://main",
- "$title": "LanMountainDesktop"
- }
-}
-```
-
----
-
-## 14. 您的同意
-
-使用本应用即表示您:
-
-- ✅ 已阅读并理解本隐私政策
-- ✅ 同意我们按照本政策收集和使用数据
-- ✅ 了解您可以随时撤回同意(详细数据收集)
-- ✅ 了解基础数据将始终收集以统计用户数量
-
----
-
-## 15. 免责声明
-
-本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责:
-
-- 第三方服务的隐私政策
-- 您自行分享的数据
-- 不可抗力导致的数据泄露
-
----
-
-**感谢您信任阑山桌面LanMountainDesktop!**
-
-我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index e691e59..315970e 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -71,9 +71,11 @@ public sealed class AppSettingsSnapshot
public bool UploadAnonymousUsageData { get; set; }
- public string? DeviceId { get; set; }
+ public string? TelemetryInstallId { get; set; }
- public string? PersistentUserId { get; set; }
+ public string? TelemetryId { get; set; }
+
+ public bool HasReportedTelemetryBaseline { get; set; }
public string UpdateChannel { get; set; } = "stable";
diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs
index a6d31d9..4cdb8e1 100644
--- a/LanMountainDesktop/Program.cs
+++ b/LanMountainDesktop/Program.cs
@@ -8,7 +8,6 @@ using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
-using Sentry;
namespace LanMountainDesktop;
@@ -21,11 +20,6 @@ sealed class Program
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
- DesktopBootstrap.InitializeStartupServices(
- InitializeDeviceId,
- InitializeCrashReporting,
- InitializeUserBehaviorAnalytics,
- ScheduleWhiteboardNoteStartupCleanup);
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
@@ -44,6 +38,12 @@ sealed class Program
return;
}
+ DesktopBootstrap.InitializeStartupServices(
+ InitializeTelemetryIdentity,
+ InitializeCrashTelemetry,
+ InitializeUsageTelemetry,
+ ScheduleWhiteboardNoteStartupCleanup);
+
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
@@ -53,7 +53,6 @@ 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.");
}
@@ -185,204 +184,90 @@ sealed class Program
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
+ var exception = eventArgs.ExceptionObject as Exception
+ ?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception.");
+
AppLogger.Critical(
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
- eventArgs.ExceptionObject as Exception);
+ exception);
- if (eventArgs.IsTerminating)
+ try
{
- SentrySdk.Flush(TimeSpan.FromSeconds(5));
+ TelemetryServices.Crash?.CaptureUnhandledException(
+ exception,
+ "AppDomain.UnhandledException",
+ eventArgs.IsTerminating);
+ }
+ catch (Exception telemetryException)
+ {
+ AppLogger.Warn("UnhandledException", "Failed to forward unhandled exception to crash telemetry.", telemetryException);
}
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
+
+ try
+ {
+ TelemetryServices.Crash?.CaptureTaskException(
+ eventArgs.Exception,
+ "TaskScheduler.UnobservedTaskException");
+ }
+ catch (Exception telemetryException)
+ {
+ AppLogger.Warn("TaskScheduler", "Failed to forward task exception to crash telemetry.", telemetryException);
+ }
+
eventArgs.SetObserved();
};
}
- private static void InitializeDeviceId()
+ private static void InitializeTelemetryIdentity()
{
try
{
- DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
- AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
+ TelemetryIdentityService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
+ AppLogger.Info(
+ "Startup",
+ $"Telemetry identity initialized. InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
}
catch (Exception ex)
{
- AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
+ AppLogger.Warn("Startup", "Failed to initialize telemetry identity service.", 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()
+ private static void InitializeCrashTelemetry()
{
try
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
- _crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
- _crashReportService.RefreshEnabledState();
+ var crashTelemetry = new SentryCrashTelemetryService(settingsFacade);
+ TelemetryServices.Crash = crashTelemetry;
+ crashTelemetry.Initialize();
+ AppLogger.Info("Startup", $"Crash telemetry initialized. Enabled={crashTelemetry.IsEnabled}.");
}
catch (Exception ex)
{
- AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
+ AppLogger.Warn("Startup", "Failed to initialize crash telemetry service.", ex);
}
}
- private static void InitializeUserBehaviorAnalytics()
+ private static void InitializeUsageTelemetry()
{
try
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
- _userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
- _userBehaviorAnalyticsService.Initialize();
+ var usageTelemetry = new PostHogUsageTelemetryService(settingsFacade);
+ TelemetryServices.Usage = usageTelemetry;
+ usageTelemetry.Initialize();
+ AppLogger.Info("Startup", $"Usage telemetry initialized. Enabled={usageTelemetry.IsUsageEnabled}.");
}
catch (Exception ex)
{
- AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
+ AppLogger.Warn("Startup", "Failed to initialize usage telemetry 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
deleted file mode 100644
index b26ed15..0000000
--- a/LanMountainDesktop/Services/CrashReportService.cs
+++ /dev/null
@@ -1,1011 +0,0 @@
-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 string? _persistentUserId; // 持久化的用户ID,用于关联设备
- 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;
- }
- }
-
- // 持久化的用户ID,用于跨设备关联用户
- public string PersistentUserId
- {
- get
- {
- if (_persistentUserId is null)
- {
- throw new InvalidOperationException("PersistentUserId not initialized");
- }
- return _persistentUserId;
- }
- }
-
- private void EnsureDeviceId()
- {
- if (_isInitialized)
- {
- return;
- }
-
- _isInitialized = true;
-
- try
- {
- var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
-
- // 初始化或生成持久化用户ID(只生成一次,永不改变)
- if (string.IsNullOrEmpty(snapshot.PersistentUserId))
- {
- snapshot.PersistentUserId = GeneratePersistentUserId();
- AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}");
- }
- _persistentUserId = snapshot.PersistentUserId;
-
- // 初始化或生成设备ID(可以刷新)
- if (string.IsNullOrEmpty(snapshot.DeviceId))
- {
- snapshot.DeviceId = GenerateDeviceId();
- _settingsFacade.Settings.SaveSnapshot(
- SettingsScope.App,
- snapshot,
- changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]);
- _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();
- _persistentUserId = GeneratePersistentUserId();
- 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();
- }
-
- private static string GeneratePersistentUserId()
- {
- // 生成一个永久性的用户ID,基于机器名和用户名的哈希
- var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop";
- using var sha = System.Security.Cryptography.SHA256.Create();
- var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo));
- 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));
-
- // 发送PostHog标准的$pageview事件用于统计日活(始终发送,不受开关影响)
- CaptureEvent("$pageview", new Dictionary
- {
- { "$current_url", "app://main" },
- { "$title", "LanMountainDesktop" }
- });
-
- // 发送应用启动事件(始终发送,用于统计用户数量)
- CaptureEvent("app_online", new Dictionary
- {
- { "event_type", "app_start" },
- { "analytics_enabled", _isEnabled }
- });
-
- 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
- {
- // 基础事件($pageview, app_online, app_shutdown等)始终发送,用于统计用户数量
- bool isBasicEvent = eventName.StartsWith("$") ||
- eventName == "app_online" ||
- eventName == "app_shutdown" ||
- eventName == "$identify";
-
- // 非基础事件只有在启用时才发送
- if (!isBasicEvent && !_isEnabled)
- {
- return;
- }
-
- var eventData = new UserBehaviorEvent
- {
- Event = eventName,
- DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID
- 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
- {
- { "$app_version", GetAppVersion() },
- { "$os", GetOsName() },
- { "$os_version", GetOsVersion() },
- { "$device_type", GetDeviceModel() },
- { "$device_id", _deviceIdService.DeviceId } // 当前设备ID
- };
-
- // PostHog正确的$identify格式
- // 使用PersistentUserId作为distinct_id,确保设备ID刷新后仍能关联到同一用户
- var requestBody = new Dictionary
- {
- { "api_key", PostHogApiKey },
- { "event", "$identify" },
- { "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID
- { "timestamp", DateTimeOffset.UtcNow.ToString("o") },
- { "properties", new Dictionary
- {
- { "$set", userProperties },
- { "$set_once", new Dictionary
- {
- { "first_app_open", DateTimeOffset.UtcNow.ToString("o") }
- }
- }
- }
- }
- };
-
- 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();
-
- // 显式开始会话跟踪
- SentrySdk.StartSession();
-
- 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)
- {
- // 结束Sentry会话
- SentrySdk.EndSession();
- SentrySdk.Flush(TimeSpan.FromSeconds(3));
- AppLogger.Info("CrashReport", $"Shutdown event 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/PostHogUsageTelemetryService.cs b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
new file mode 100644
index 0000000..d24d70b
--- /dev/null
+++ b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
@@ -0,0 +1,629 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class PostHogUsageTelemetryService : IDisposable
+{
+ private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
+ private const string PostHogHost = "https://us.i.posthog.com/capture/";
+
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly ISettingsService _settingsService;
+ private readonly HttpClient _httpClient = new()
+ {
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+ private readonly Queue _eventQueue = new();
+ private readonly object _queueLock = new();
+
+ private Timer? _flushTimer;
+ private bool _isInitialized;
+ private bool _isUsageEnabled;
+ private bool _sessionActive;
+ private string _sessionId = string.Empty;
+ private DateTimeOffset _sessionStartUtc;
+ private long _sequence;
+ private readonly string _launchId = Guid.NewGuid().ToString("N");
+
+ public PostHogUsageTelemetryService(ISettingsFacadeService settingsFacade)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _settingsService = settingsFacade.Settings;
+ _settingsService.Changed += OnSettingsChanged;
+ }
+
+ public bool IsUsageEnabled => _isUsageEnabled;
+
+ public void Initialize()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _isInitialized = true;
+
+ EnsureBaselineEventSent();
+ RefreshEnabledState(forceSessionStart: true);
+
+ _flushTimer = new Timer(
+ _ => FlushEvents(),
+ null,
+ TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(30));
+
+ AppLogger.Info(
+ "PostHogUsage",
+ $"Usage telemetry initialized. Enabled={_isUsageEnabled}; InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
+ }
+
+ public void RefreshEnabledState(bool forceSessionStart = false)
+ {
+ try
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ var enabled = snapshot.UploadAnonymousUsageData;
+
+ if (_isUsageEnabled == enabled && !forceSessionStart)
+ {
+ return;
+ }
+
+ var previous = _isUsageEnabled;
+ _isUsageEnabled = enabled;
+ AppLogger.Info("PostHogUsage", $"Usage analytics enabled state changed from '{previous}' to '{_isUsageEnabled}'.");
+
+ if (_isUsageEnabled)
+ {
+ StartSession("usage_enabled");
+ return;
+ }
+
+ ClearQueuedEvents();
+ StopSessionWithoutSending();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
+ _isUsageEnabled = false;
+ ClearQueuedEvents();
+ StopSessionWithoutSending();
+ }
+ }
+
+ public void TrackMainWindowOpened(string source, bool isVisible, string windowState)
+ {
+ CaptureEvent(
+ "main_window_opened",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["is_visible"] = isVisible,
+ ["window_state"] = windowState
+ },
+ forceFlush: true);
+ }
+
+ public void TrackMainWindowClosed(string source, bool wasVisible, string windowState)
+ {
+ CaptureEvent(
+ "main_window_closed",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["was_visible"] = wasVisible,
+ ["window_state"] = windowState
+ },
+ forceFlush: true);
+ }
+
+ public void TrackSettingsWindowOpened(string source, string? currentPageId)
+ {
+ CaptureEvent(
+ "settings_window_opened",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["current_page_id"] = currentPageId
+ },
+ forceFlush: true);
+ }
+
+ public void TrackSettingsWindowClosed(string source, string? currentPageId)
+ {
+ CaptureEvent(
+ "settings_window_closed",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["current_page_id"] = currentPageId
+ },
+ forceFlush: true);
+ }
+
+ public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source)
+ {
+ CaptureEvent(
+ "settings_navigation",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["from_page_id"] = fromPageId,
+ ["to_page_id"] = toPageId
+ },
+ stateBefore: CreatePageState(fromPageId),
+ stateAfter: CreatePageState(toPageId));
+ }
+
+ public void TrackSettingsDrawerOpened(string? pageId, string? drawerTitle)
+ {
+ CaptureEvent(
+ "settings_drawer_opened",
+ new Dictionary
+ {
+ ["page_id"] = pageId,
+ ["drawer_title"] = drawerTitle
+ },
+ forceFlush: true);
+ }
+
+ public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle)
+ {
+ CaptureEvent(
+ "settings_drawer_closed",
+ new Dictionary
+ {
+ ["page_id"] = pageId,
+ ["drawer_title"] = drawerTitle
+ },
+ forceFlush: true);
+ }
+
+ public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source)
+ {
+ CaptureEvent(
+ "desktop_component_placed",
+ new Dictionary
+ {
+ ["source"] = source
+ },
+ stateAfter: DescribePlacement(placement),
+ forceFlush: true);
+ }
+
+ public void TrackDesktopComponentMoved(
+ DesktopComponentPlacementSnapshot before,
+ DesktopComponentPlacementSnapshot after,
+ string source)
+ {
+ CaptureEvent(
+ "desktop_component_moved",
+ new Dictionary
+ {
+ ["source"] = source
+ },
+ stateBefore: DescribePlacement(before),
+ stateAfter: DescribePlacement(after),
+ forceFlush: true);
+ }
+
+ public void TrackDesktopComponentResized(
+ DesktopComponentPlacementSnapshot before,
+ DesktopComponentPlacementSnapshot after,
+ string source)
+ {
+ CaptureEvent(
+ "desktop_component_resized",
+ new Dictionary
+ {
+ ["source"] = source
+ },
+ stateBefore: DescribePlacement(before),
+ stateAfter: DescribePlacement(after),
+ forceFlush: true);
+ }
+
+ public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source)
+ {
+ CaptureEvent(
+ "desktop_component_deleted",
+ new Dictionary
+ {
+ ["source"] = source
+ },
+ stateBefore: DescribePlacement(before),
+ forceFlush: true);
+ }
+
+ public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source)
+ {
+ CaptureEvent(
+ "desktop_component_editor_opened",
+ new Dictionary
+ {
+ ["source"] = source
+ },
+ stateBefore: DescribePlacement(placement),
+ forceFlush: true);
+ }
+
+ public void TrackSessionStarted(string source)
+ {
+ StartSession(source);
+ }
+
+ public void TrackSessionEnded(string source)
+ {
+ EndSession(source);
+ }
+
+ public void Shutdown(bool isRestart, string source)
+ {
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ if (_isUsageEnabled && _sessionActive)
+ {
+ EndSession(source, isRestart);
+ }
+
+ FlushEvents();
+ AppLogger.Info(
+ "PostHogUsage",
+ $"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _flushTimer?.Dispose();
+ _settingsService.Changed -= OnSettingsChanged;
+ Shutdown(isRestart: false, source: "Dispose");
+ FlushEvents();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
+ }
+ finally
+ {
+ _httpClient.Dispose();
+ }
+ }
+
+ private void EnsureBaselineEventSent()
+ {
+ try
+ {
+ var identity = TelemetryIdentityService.Instance;
+ if (identity.HasReportedBaseline)
+ {
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ if (SendBaselineEventToPostHog(identity.InstallId, now))
+ {
+ identity.MarkBaselineReported();
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
+ }
+ }
+
+ private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
+ {
+ try
+ {
+ var requestBody = new Dictionary
+ {
+ ["api_key"] = PostHogApiKey,
+ ["event"] = "app_first_launch",
+ ["distinct_id"] = installId,
+ ["timestamp"] = timestamp.ToString("o"),
+ ["properties"] = new Dictionary
+ {
+ ["launch_time_utc"] = timestamp.ToString("o")
+ }
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ using var content = new ByteArrayContent(bytes);
+ content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+
+ var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
+ var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLogger.Warn(
+ "PostHogUsage",
+ $"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
+ return false;
+ }
+
+ AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
+ return false;
+ }
+ }
+
+ private void StartSession(string source)
+ {
+ if (!_isInitialized || !_isUsageEnabled)
+ {
+ return;
+ }
+
+ if (_sessionActive)
+ {
+ return;
+ }
+
+ _sessionActive = true;
+ _sessionId = Guid.NewGuid().ToString("N");
+ _sessionStartUtc = DateTimeOffset.UtcNow;
+ _sequence = 0;
+
+ CaptureEvent(
+ "app_session_start",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["launch_id"] = _launchId,
+ ["session_start_utc"] = _sessionStartUtc.ToString("o"),
+ ["local_hour"] = _sessionStartUtc.ToLocalTime().Hour,
+ ["day_part"] = TelemetryEnvironmentInfo.GetLocalDayPart(_sessionStartUtc),
+ ["timezone"] = TimeZoneInfo.Local.Id,
+ ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
+ ["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
+ ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
+ ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
+ ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture()
+ },
+ forceFlush: true);
+
+ AppLogger.Info("PostHogUsage", $"Session started. SessionId={_sessionId}; Source='{source}'.");
+ }
+
+ private void EndSession(string source, bool isRestart = false)
+ {
+ if (!_isInitialized || !_sessionActive)
+ {
+ return;
+ }
+
+ var endUtc = DateTimeOffset.UtcNow;
+ var durationMs = Math.Max(0, (long)(endUtc - _sessionStartUtc).TotalMilliseconds);
+
+ CaptureEvent(
+ "app_session_end",
+ new Dictionary
+ {
+ ["source"] = source,
+ ["launch_id"] = _launchId,
+ ["session_start_utc"] = _sessionStartUtc.ToString("o"),
+ ["session_end_utc"] = endUtc.ToString("o"),
+ ["duration_ms"] = durationMs,
+ ["is_restart"] = isRestart
+ },
+ forceFlush: true);
+
+ _sessionActive = false;
+ _sessionId = string.Empty;
+ _sessionStartUtc = default;
+ _sequence = 0;
+ AppLogger.Info("PostHogUsage", $"Session ended. Source='{source}'; DurationMs={durationMs}; Restart={isRestart}.");
+ }
+
+ private void StopSessionWithoutSending()
+ {
+ _sessionActive = false;
+ _sessionId = string.Empty;
+ _sessionStartUtc = default;
+ _sequence = 0;
+ }
+
+ private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
+ {
+ _ = sender;
+
+ if (e.Scope != SettingsScope.App ||
+ e.ChangedKeys is null ||
+ !e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousUsageData), StringComparer.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ AppLogger.Info("PostHogUsage", "Usage analytics settings changed. Refreshing enabled state.");
+ RefreshEnabledState();
+ }
+
+ private void CaptureEvent(
+ string eventName,
+ IReadOnlyDictionary? payload = null,
+ IReadOnlyDictionary? stateBefore = null,
+ IReadOnlyDictionary? stateAfter = null,
+ bool forceFlush = false)
+ {
+ if (!_isInitialized || !_isUsageEnabled || !_sessionActive)
+ {
+ return;
+ }
+
+ var eventData = new TelemetryEvent(
+ eventName,
+ TelemetryIdentityService.Instance.TelemetryId,
+ TelemetryIdentityService.Instance.InstallId,
+ TelemetryIdentityService.Instance.TelemetryId,
+ _sessionId,
+ Interlocked.Increment(ref _sequence),
+ DateTimeOffset.UtcNow,
+ payload ?? new Dictionary(),
+ stateBefore,
+ stateAfter);
+
+ lock (_queueLock)
+ {
+ _eventQueue.Enqueue(eventData);
+ }
+
+ if (forceFlush)
+ {
+ FlushEvents();
+ return;
+ }
+
+ var shouldFlush = false;
+ lock (_queueLock)
+ {
+ shouldFlush = _eventQueue.Count >= 20;
+ }
+
+ if (shouldFlush)
+ {
+ FlushEvents();
+ }
+ }
+
+ 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
+ {
+ foreach (var telemetryEvent in eventsToSend)
+ {
+ if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
+ {
+ throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
+
+ lock (_queueLock)
+ {
+ foreach (var evt in eventsToSend)
+ {
+ if (_eventQueue.Count >= 100)
+ {
+ break;
+ }
+
+ _eventQueue.Enqueue(evt);
+ }
+ }
+ }
+ }
+
+ private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
+ {
+ try
+ {
+ var requestBody = new Dictionary
+ {
+ ["api_key"] = PostHogApiKey,
+ ["event"] = telemetryEvent.EventName,
+ ["distinct_id"] = telemetryEvent.DistinctId,
+ ["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
+ ["properties"] = telemetryEvent.ToPostHogProperties()
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ using var content = new ByteArrayContent(bytes);
+ content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+
+ var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
+ var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ AppLogger.Warn(
+ "PostHogUsage",
+ $"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
+ return false;
+ }
+
+ if (flushImmediately)
+ {
+ AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
+ return false;
+ }
+ }
+
+ private void ClearQueuedEvents()
+ {
+ lock (_queueLock)
+ {
+ _eventQueue.Clear();
+ }
+ }
+
+ private static IReadOnlyDictionary CreatePageState(string? pageId)
+ {
+ return new Dictionary
+ {
+ ["page_id"] = pageId
+ };
+ }
+
+ private static IReadOnlyDictionary DescribePlacement(DesktopComponentPlacementSnapshot placement)
+ {
+ return new Dictionary
+ {
+ ["placement_id"] = placement.PlacementId,
+ ["component_id"] = placement.ComponentId,
+ ["page_index"] = placement.PageIndex,
+ ["row"] = placement.Row,
+ ["column"] = placement.Column,
+ ["width_cells"] = placement.WidthCells,
+ ["height_cells"] = placement.HeightCells
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/SentryCrashTelemetryService.cs b/LanMountainDesktop/Services/SentryCrashTelemetryService.cs
new file mode 100644
index 0000000..9aac45e
--- /dev/null
+++ b/LanMountainDesktop/Services/SentryCrashTelemetryService.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using Sentry;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class SentryCrashTelemetryService : IDisposable
+{
+ private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
+ private const string AutoIpAddress = "{{auto}}";
+
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly ISettingsService _settingsService;
+ private readonly object _syncRoot = new();
+
+ private IDisposable? _sentryHandle;
+ private bool _isInitialized;
+ private bool _isEnabled;
+ private bool _disposed;
+
+ public SentryCrashTelemetryService(ISettingsFacadeService settingsFacade)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _settingsService = settingsFacade.Settings;
+ _settingsService.Changed += OnSettingsChanged;
+ }
+
+ public bool IsEnabled
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _isInitialized && _isEnabled && SentrySdk.IsEnabled;
+ }
+ }
+ }
+
+ public void Initialize()
+ {
+ lock (_syncRoot)
+ {
+ EnsureNotDisposed();
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ _isInitialized = true;
+ }
+
+ RefreshEnabledState(force: true);
+ }
+
+ public void RefreshEnabledState(bool force = false)
+ {
+ bool shouldEnable;
+ lock (_syncRoot)
+ {
+ EnsureNotDisposed();
+ if (!_isInitialized)
+ {
+ return;
+ }
+
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ shouldEnable = snapshot.UploadAnonymousCrashData;
+
+ if (!force && _isEnabled == shouldEnable)
+ {
+ return;
+ }
+ }
+
+ if (shouldEnable)
+ {
+ EnableSentry();
+ return;
+ }
+
+ DisableSentry();
+ }
+
+ public void CaptureUnhandledException(Exception exception, string source, bool isTerminating)
+ {
+ if (exception is null)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ if (!CanCapture())
+ {
+ return;
+ }
+ }
+
+ var eventId = SentrySdk.CaptureException(exception, scope =>
+ {
+ ApplyCommonScope(scope, source, "unhandled_exception", includeLogTail: true);
+ scope.Level = isTerminating ? SentryLevel.Fatal : SentryLevel.Error;
+ scope.SetTag("exception_source", source);
+ scope.SetTag("is_terminating", isTerminating.ToString());
+ });
+
+ AppLogger.Info("SentryCrash", $"Captured unhandled exception from '{source}'. EventId={eventId}.");
+
+ if (isTerminating)
+ {
+ EndCrashSession();
+ SentrySdk.Flush(TimeSpan.FromSeconds(5));
+ }
+ }
+
+ public void CaptureTaskException(Exception exception, string source)
+ {
+ if (exception is null)
+ {
+ return;
+ }
+
+ lock (_syncRoot)
+ {
+ if (!CanCapture())
+ {
+ return;
+ }
+ }
+
+ var eventId = SentrySdk.CaptureException(exception, scope =>
+ {
+ ApplyCommonScope(scope, source, "task_exception", includeLogTail: true);
+ scope.Level = SentryLevel.Error;
+ scope.SetTag("exception_source", source);
+ });
+
+ AppLogger.Info("SentryCrash", $"Captured task exception from '{source}'. EventId={eventId}.");
+ SentrySdk.Flush(TimeSpan.FromSeconds(2));
+ }
+
+ public void CaptureShutdown(bool isRestart, string source)
+ {
+ lock (_syncRoot)
+ {
+ if (!CanCapture())
+ {
+ return;
+ }
+ }
+
+ var eventId = SentrySdk.CaptureMessage("application_shutdown", scope =>
+ {
+ ApplyCommonScope(scope, source, "shutdown", includeLogTail: true);
+ scope.Level = SentryLevel.Info;
+ scope.SetTag("shutdown_intent", isRestart ? "restart" : "exit");
+ scope.SetExtra("shutdown_intent", isRestart ? "restart" : "exit");
+ }, SentryLevel.Info);
+
+ AppLogger.Info(
+ "SentryCrash",
+ $"Captured application shutdown. Source='{source}'; Restart={isRestart}; EventId={eventId}.");
+
+ EndCrashSession();
+ SentrySdk.Flush(TimeSpan.FromSeconds(5));
+ }
+
+ public void Dispose()
+ {
+ lock (_syncRoot)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ }
+
+ try
+ {
+ _settingsService.Changed -= OnSettingsChanged;
+ DisableSentry();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("SentryCrash", "Failed to dispose crash telemetry service.", ex);
+ }
+ }
+
+ private void EnableSentry()
+ {
+ lock (_syncRoot)
+ {
+ if (_isEnabled && _sentryHandle is not null && SentrySdk.IsEnabled)
+ {
+ return;
+ }
+ }
+
+ var handle = SentrySdk.Init(options =>
+ {
+ options.Dsn = SentryDsn;
+ options.AutoSessionTracking = true;
+ options.AttachStacktrace = true;
+ options.SendDefaultPii = true;
+ options.MaxBreadcrumbs = 100;
+ options.Release = TelemetryEnvironmentInfo.GetAppVersion();
+ options.Environment = TelemetryEnvironmentInfo.GetEnvironment();
+ options.DisableAppDomainUnhandledExceptionCapture();
+ options.DisableUnobservedTaskExceptionCapture();
+ });
+
+ lock (_syncRoot)
+ {
+ if (_disposed)
+ {
+ handle.Dispose();
+ return;
+ }
+
+ _sentryHandle?.Dispose();
+ _sentryHandle = handle;
+ _isEnabled = true;
+ }
+
+ SentrySdk.ConfigureScope(scope => ApplyCommonScope(scope, "startup", "startup", includeLogTail: false));
+ AppLogger.Info("SentryCrash", "Crash telemetry enabled.");
+ }
+
+ private void DisableSentry()
+ {
+ IDisposable? handle;
+ lock (_syncRoot)
+ {
+ if (!_isEnabled && _sentryHandle is null)
+ {
+ return;
+ }
+
+ _isEnabled = false;
+ handle = _sentryHandle;
+ _sentryHandle = null;
+ }
+
+ try
+ {
+ EndCrashSession();
+ SentrySdk.Flush(TimeSpan.FromSeconds(3));
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("SentryCrash", "Failed to flush Sentry while disabling crash telemetry.", ex);
+ }
+ finally
+ {
+ handle?.Dispose();
+ }
+
+ AppLogger.Info("SentryCrash", "Crash telemetry disabled.");
+ }
+
+ private void EndCrashSession()
+ {
+ try
+ {
+ if (SentrySdk.IsEnabled)
+ {
+ SentrySdk.EndSession(SessionEndStatus.Exited);
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("SentryCrash", "Failed to end Sentry session.", ex);
+ }
+ }
+
+ private bool CanCapture()
+ {
+ return !_disposed && _isInitialized && _isEnabled && SentrySdk.IsEnabled;
+ }
+
+ private void ApplyCommonScope(Scope scope, string source, string eventType, bool includeLogTail)
+ {
+ var installId = TelemetryIdentityService.Instance.InstallId;
+ var telemetryId = TelemetryIdentityService.Instance.TelemetryId;
+
+ scope.User = new SentryUser
+ {
+ Id = telemetryId,
+ IpAddress = AutoIpAddress
+ };
+
+ scope.SetTag("telemetry_channel", "sentry");
+ scope.SetTag("event_type", eventType);
+ scope.SetTag("source", source);
+ scope.SetTag("install_id", installId);
+ scope.SetTag("telemetry_id", telemetryId);
+ scope.SetTag("app_version", TelemetryEnvironmentInfo.GetAppVersion());
+ scope.SetTag("environment", TelemetryEnvironmentInfo.GetEnvironment());
+ scope.SetTag("os_name", TelemetryEnvironmentInfo.GetOsName());
+ scope.SetTag("os_version", TelemetryEnvironmentInfo.GetOsVersion());
+ scope.SetTag("os_build", TelemetryEnvironmentInfo.GetOsBuild());
+ scope.SetTag("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
+ scope.SetTag("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
+ scope.SetTag("processor_count", TelemetryEnvironmentInfo.GetProcessorCount().ToString());
+ scope.SetTag("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB().ToString());
+ scope.SetTag("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
+ scope.SetTag("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
+ scope.SetTag("language", TelemetryEnvironmentInfo.GetSystemLanguage());
+ scope.SetExtra("install_id", installId);
+ scope.SetExtra("telemetry_id", telemetryId);
+ scope.SetExtra("app_version", TelemetryEnvironmentInfo.GetAppVersion());
+ scope.SetExtra("environment", TelemetryEnvironmentInfo.GetEnvironment());
+ scope.SetExtra("os_name", TelemetryEnvironmentInfo.GetOsName());
+ scope.SetExtra("os_version", TelemetryEnvironmentInfo.GetOsVersion());
+ scope.SetExtra("os_build", TelemetryEnvironmentInfo.GetOsBuild());
+ scope.SetExtra("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
+ scope.SetExtra("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
+ scope.SetExtra("processor_count", TelemetryEnvironmentInfo.GetProcessorCount());
+ scope.SetExtra("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB());
+ scope.SetExtra("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
+ scope.SetExtra("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
+ scope.SetExtra("language", TelemetryEnvironmentInfo.GetSystemLanguage());
+ scope.SetExtra("log_file_path", AppLogger.LogFilePath);
+
+ if (includeLogTail)
+ {
+ var logTail = ReadLogTail(maxLines: 200, maxCharacters: 32_768);
+ if (!string.IsNullOrWhiteSpace(logTail))
+ {
+ scope.SetExtra("log_tail", logTail);
+ scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
+ var attachment = new Attachment(
+ AttachmentType.Default,
+ new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
+ "log-tail.txt",
+ "text/plain");
+ scope.AddAttachment(attachment);
+ }
+ }
+ }
+
+ private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
+ {
+ _ = sender;
+
+ if (e.Scope != SettingsScope.App ||
+ e.ChangedKeys is null ||
+ !e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousCrashData), StringComparer.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ AppLogger.Info("SentryCrash", "Crash telemetry setting changed. Refreshing enabled state.");
+ RefreshEnabledState();
+ }
+
+ private static string ReadLogTail(int maxLines, int maxCharacters)
+ {
+ try
+ {
+ var logFilePath = AppLogger.LogFilePath;
+ if (string.IsNullOrWhiteSpace(logFilePath) || !File.Exists(logFilePath))
+ {
+ return string.Empty;
+ }
+
+ var lines = new Queue(Math.Min(maxLines, 256));
+ using var reader = File.OpenText(logFilePath);
+ string? line;
+ while ((line = reader.ReadLine()) is not null)
+ {
+ if (lines.Count >= maxLines)
+ {
+ lines.Dequeue();
+ }
+
+ lines.Enqueue(line);
+ }
+
+ var tail = string.Join(Environment.NewLine, lines);
+ if (tail.Length <= maxCharacters)
+ {
+ return tail;
+ }
+
+ return tail[^maxCharacters..];
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("SentryCrash", "Failed to read log tail for crash telemetry.", ex);
+ return string.Empty;
+ }
+ }
+
+ private void EnsureNotDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(SentryCrashTelemetryService));
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index 0105f7c..1f8142d 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -609,17 +609,32 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
public void Save(PrivacySettingsState state)
{
var snapshot = _settingsService.Load();
- snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
- snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
- AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
+ var changedKeys = new List();
+
+ if (snapshot.UploadAnonymousCrashData != state.UploadAnonymousCrashData)
+ {
+ snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
+ changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousCrashData));
+ }
+
+ if (snapshot.UploadAnonymousUsageData != state.UploadAnonymousUsageData)
+ {
+ snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
+ changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousUsageData));
+ }
+
+ if (changedKeys.Count == 0)
+ {
+ return;
+ }
+
+ AppLogger.Info(
+ "PrivacySettings",
+ $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
- changedKeys:
- [
- nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
- nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
- ]);
+ changedKeys: changedKeys);
}
}
diff --git a/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs b/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
new file mode 100644
index 0000000..05c8506
--- /dev/null
+++ b/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Globalization;
+using System.Runtime.InteropServices;
+
+namespace LanMountainDesktop.Services;
+
+internal static class TelemetryEnvironmentInfo
+{
+ public static string GetAppVersion()
+ {
+ var assembly = typeof(TelemetryEnvironmentInfo).Assembly;
+ var version = assembly.GetName().Version;
+ return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+
+ public static string GetEnvironment()
+ {
+#if DEBUG
+ return "development";
+#else
+ return "production";
+#endif
+ }
+
+ public 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";
+ }
+
+ public static string GetOsVersion()
+ {
+ try
+ {
+ return Environment.OSVersion.VersionString ?? "Unknown";
+ }
+ catch
+ {
+ return "Unknown";
+ }
+ }
+
+ public static string GetOsBuild()
+ {
+ try
+ {
+ return Environment.OSVersion.Version.Build.ToString(CultureInfo.InvariantCulture);
+ }
+ catch
+ {
+ return "Unknown";
+ }
+ }
+
+ public static string GetDeviceModel()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return "Windows PC";
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return "Linux PC";
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return "Mac";
+ }
+
+ return "Unknown";
+ }
+
+ public static string GetDeviceArchitecture()
+ {
+ return RuntimeInformation.OSArchitecture.ToString();
+ }
+
+ public static string GetSystemLanguage()
+ {
+ try
+ {
+ return CultureInfo.CurrentUICulture.Name ?? "en-US";
+ }
+ catch
+ {
+ return "en-US";
+ }
+ }
+
+ public static int GetProcessorCount()
+ {
+ return Environment.ProcessorCount;
+ }
+
+ public static long GetTotalMemoryMB()
+ {
+ try
+ {
+ return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ public static string GetRuntimeVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ public static string GetClrVersion()
+ {
+ return Environment.Version.ToString();
+ }
+
+ public static string GetLocalDayPart(DateTimeOffset timestamp)
+ {
+ var hour = timestamp.ToLocalTime().Hour;
+ return hour switch
+ {
+ < 6 => "late_night",
+ < 12 => "morning",
+ < 18 => "afternoon",
+ _ => "evening"
+ };
+ }
+}
diff --git a/LanMountainDesktop/Services/TelemetryEvent.cs b/LanMountainDesktop/Services/TelemetryEvent.cs
new file mode 100644
index 0000000..cad7fa5
--- /dev/null
+++ b/LanMountainDesktop/Services/TelemetryEvent.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LanMountainDesktop.Services;
+
+internal sealed record TelemetryEvent(
+ string EventName,
+ string DistinctId,
+ string InstallId,
+ string TelemetryId,
+ string SessionId,
+ long Sequence,
+ DateTimeOffset Timestamp,
+ IReadOnlyDictionary Payload,
+ IReadOnlyDictionary? StateBefore = null,
+ IReadOnlyDictionary? StateAfter = null)
+{
+ public Dictionary ToPostHogProperties()
+ {
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["install_id"] = InstallId,
+ ["telemetry_id"] = TelemetryId,
+ ["session_id"] = SessionId,
+ ["sequence"] = Sequence,
+ ["timestamp_utc"] = Timestamp.ToString("o"),
+ ["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
+ ["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
+ ["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
+ ["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
+ ["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
+ ["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
+ ["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
+ ["payload"] = Copy(Payload)
+ };
+
+ if (StateBefore is not null && StateBefore.Count > 0)
+ {
+ properties["state_before"] = Copy(StateBefore);
+ }
+
+ if (StateAfter is not null && StateAfter.Count > 0)
+ {
+ properties["state_after"] = Copy(StateAfter);
+ }
+
+ return properties;
+ }
+
+ private static Dictionary Copy(IReadOnlyDictionary source)
+ {
+ return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/LanMountainDesktop/Services/TelemetryIdentityService.cs b/LanMountainDesktop/Services/TelemetryIdentityService.cs
new file mode 100644
index 0000000..0c4ec5f
--- /dev/null
+++ b/LanMountainDesktop/Services/TelemetryIdentityService.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Collections.Generic;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+
+namespace LanMountainDesktop.Services;
+
+public sealed class TelemetryIdentityService
+{
+ private static TelemetryIdentityService? _instance;
+
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly object _syncRoot = new();
+
+ private string _installId = string.Empty;
+ private string _telemetryId = string.Empty;
+ private bool _hasReportedBaseline;
+
+ public static TelemetryIdentityService Instance =>
+ _instance ?? throw new InvalidOperationException("TelemetryIdentityService not initialized.");
+
+ private TelemetryIdentityService(ISettingsFacadeService settingsFacade)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ }
+
+ public static void Initialize(ISettingsFacadeService settingsFacade)
+ {
+ if (_instance is not null)
+ {
+ return;
+ }
+
+ var instance = new TelemetryIdentityService(settingsFacade);
+ instance.LoadOrCreateIdentity();
+ _instance = instance;
+ TelemetryServices.Identity = instance;
+
+ AppLogger.Info(
+ "TelemetryIdentity",
+ $"Initialized. InstallId={instance.InstallId}; TelemetryId={instance.TelemetryId}; BaselineReported={instance.HasReportedBaseline}.");
+ }
+
+ public string InstallId
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ EnsureInitialized();
+ return _installId;
+ }
+ }
+ }
+
+ public string TelemetryId
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ EnsureInitialized();
+ return _telemetryId;
+ }
+ }
+ }
+
+ public bool HasReportedBaseline
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ EnsureInitialized();
+ return _hasReportedBaseline;
+ }
+ }
+ }
+
+ public string RefreshTelemetryId()
+ {
+ lock (_syncRoot)
+ {
+ EnsureInitialized();
+
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ snapshot.TelemetryId = GenerateId();
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.TelemetryId)]);
+
+ _telemetryId = snapshot.TelemetryId ?? GenerateId();
+ AppLogger.Info("TelemetryIdentity", $"Telemetry id refreshed. TelemetryId={_telemetryId}");
+ return _telemetryId;
+ }
+ }
+
+ public bool MarkBaselineReported()
+ {
+ lock (_syncRoot)
+ {
+ EnsureInitialized();
+
+ if (_hasReportedBaseline)
+ {
+ return false;
+ }
+
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ if (snapshot.HasReportedTelemetryBaseline)
+ {
+ _hasReportedBaseline = true;
+ return false;
+ }
+
+ snapshot.HasReportedTelemetryBaseline = true;
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys: [nameof(AppSettingsSnapshot.HasReportedTelemetryBaseline)]);
+
+ _hasReportedBaseline = true;
+ AppLogger.Info("TelemetryIdentity", "Marked baseline telemetry as reported.");
+ return true;
+ }
+ }
+
+ private void LoadOrCreateIdentity()
+ {
+ lock (_syncRoot)
+ {
+ var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App);
+ var changedKeys = new List();
+
+ if (string.IsNullOrWhiteSpace(snapshot.TelemetryInstallId))
+ {
+ snapshot.TelemetryInstallId = GenerateId();
+ changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryInstallId));
+ }
+
+ if (string.IsNullOrWhiteSpace(snapshot.TelemetryId))
+ {
+ snapshot.TelemetryId = GenerateId();
+ changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryId));
+ }
+
+ _installId = snapshot.TelemetryInstallId ?? GenerateId();
+ _telemetryId = snapshot.TelemetryId ?? GenerateId();
+ _hasReportedBaseline = snapshot.HasReportedTelemetryBaseline;
+
+ if (changedKeys.Count > 0)
+ {
+ _settingsFacade.Settings.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys: changedKeys);
+ }
+ }
+ }
+
+ private void EnsureInitialized()
+ {
+ if (!string.IsNullOrWhiteSpace(_installId) && !string.IsNullOrWhiteSpace(_telemetryId))
+ {
+ return;
+ }
+
+ LoadOrCreateIdentity();
+ }
+
+ private static string GenerateId()
+ {
+ return Guid.NewGuid().ToString("N");
+ }
+}
diff --git a/LanMountainDesktop/Services/TelemetryServices.cs b/LanMountainDesktop/Services/TelemetryServices.cs
new file mode 100644
index 0000000..4bb86e2
--- /dev/null
+++ b/LanMountainDesktop/Services/TelemetryServices.cs
@@ -0,0 +1,10 @@
+namespace LanMountainDesktop.Services;
+
+public static class TelemetryServices
+{
+ public static TelemetryIdentityService? Identity { get; set; }
+
+ public static PostHogUsageTelemetryService? Usage { get; set; }
+
+ public static SentryCrashTelemetryService? Crash { get; set; }
+}
diff --git a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
index 039d501..a22348b 100644
--- a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
+++ b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs
@@ -2,9 +2,9 @@ using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
-using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.ViewModels;
@@ -19,7 +19,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
- _settingsFacade = settingsFacade;
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
@@ -35,7 +35,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private bool _uploadAnonymousUsageData;
[ObservableProperty]
- private string _deviceId = string.Empty;
+ private string _telemetryId = string.Empty;
[ObservableProperty]
private string _privacyHeader = string.Empty;
@@ -53,13 +53,13 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private string _usageUploadDescription = string.Empty;
[ObservableProperty]
- private string _deviceIdHeader = string.Empty;
+ private string _telemetryIdHeader = string.Empty;
[ObservableProperty]
- private string _deviceIdDescription = string.Empty;
+ private string _telemetryIdDescription = string.Empty;
[ObservableProperty]
- private string _refreshDeviceIdText = string.Empty;
+ private string _refreshTelemetryIdText = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
@@ -72,32 +72,27 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
var state = _settingsFacade.Privacy.Get();
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
- DeviceId = DeviceIdService.Instance.DeviceId;
+ TelemetryId = TelemetryServices.Identity?.TelemetryId ?? string.Empty;
}
[RelayCommand]
- private void RefreshDeviceId()
+ private void RefreshTelemetryId()
{
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 identity = TelemetryServices.Identity;
+ if (identity is null)
+ {
+ AppLogger.Warn("PrivacySettings", "Telemetry identity service is unavailable.");
+ return;
+ }
- 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}");
+ TelemetryId = identity.RefreshTelemetryId();
+ AppLogger.Info("PrivacySettings", $"Telemetry ID refreshed: {TelemetryId}");
}
catch (Exception ex)
{
- AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
+ AppLogger.Warn("PrivacySettings", "Failed to refresh telemetry ID.", ex);
}
}
@@ -132,12 +127,18 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
{
PrivacyHeader = L("settings.privacy.title", "Privacy");
CrashUploadHeader = L("settings.privacy.crash_upload_title", "Anonymous crash data uploads");
- 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");
+ CrashUploadDescription = L(
+ "settings.privacy.crash_upload_description",
+ "Send crash reports to help us improve stability.");
+ UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage analytics");
+ UsageUploadDescription = L(
+ "settings.privacy.usage_upload_description",
+ "Send usage events to help us understand feature usage and session flow.");
+ TelemetryIdHeader = L("settings.privacy.telemetry_id_title", "Telemetry ID");
+ TelemetryIdDescription = L(
+ "settings.privacy.telemetry_id_description",
+ "A refreshable anonymous identifier used for detailed telemetry sessions.");
+ RefreshTelemetryIdText = L("settings.privacy.refresh_telemetry_id", "Refresh");
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
@@ -147,10 +148,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
{
try
{
- // 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
-
- // 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)
diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
index 6847ab3..d81b238 100644
--- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
+++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
@@ -634,6 +634,20 @@ public partial class MainWindow
}
}
+ private static DesktopComponentPlacementSnapshot ClonePlacementSnapshot(DesktopComponentPlacementSnapshot placement)
+ {
+ return new DesktopComponentPlacementSnapshot
+ {
+ PlacementId = placement.PlacementId,
+ PageIndex = placement.PageIndex,
+ ComponentId = placement.ComponentId,
+ Row = placement.Row,
+ Column = placement.Column,
+ WidthCells = placement.WidthCells,
+ HeightCells = placement.HeightCells
+ };
+ }
+
private void OnSettingsWindowStateChanged(object? sender, EventArgs e)
{
_ = sender;
@@ -875,6 +889,8 @@ public partial class MainWindow
return;
}
+ var before = ClonePlacementSnapshot(placement);
+
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
{
_componentEditorWindowService.Close();
@@ -896,6 +912,7 @@ public partial class MainWindow
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
PersistSettings();
+ TelemetryServices.Usage?.TrackDesktopComponentDeleted(before, "component.delete");
}
private void OpenSelectedComponentEditor()
@@ -912,6 +929,8 @@ public partial class MainWindow
ComponentId: placement.ComponentId,
PlacementId: placement.PlacementId,
RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId)));
+
+ TelemetryServices.Usage?.TrackDesktopComponentEditorOpened(ClonePlacementSnapshot(placement), "component.edit");
}
private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement)
@@ -1220,6 +1239,7 @@ public partial class MainWindow
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext();
PersistSettings();
+ TelemetryServices.Usage?.TrackDesktopComponentPlaced(ClonePlacementSnapshot(placement), "component.create");
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
}
@@ -2350,6 +2370,8 @@ public partial class MainWindow
return false;
}
+ var before = ClonePlacementSnapshot(placement);
+
var widthCells = Math.Max(1, _desktopComponentResize.CurrentWidthCells);
var heightCells = Math.Max(1, _desktopComponentResize.CurrentHeightCells);
var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells;
@@ -2360,6 +2382,7 @@ public partial class MainWindow
if (changed)
{
PersistSettings();
+ TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
}
return true;
@@ -2569,6 +2592,8 @@ public partial class MainWindow
return false;
}
+ var before = ClonePlacementSnapshot(placement);
+
placement.Row = Math.Max(0, row);
placement.Column = Math.Max(0, column);
@@ -2578,6 +2603,7 @@ public partial class MainWindow
_desktopComponentDrag.SourceHost.Opacity = 1;
ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen);
PersistSettings();
+ TelemetryServices.Usage?.TrackDesktopComponentMoved(before, ClonePlacementSnapshot(placement), "component.move");
return true;
}
diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs
index 5fd1af3..e176156 100644
--- a/LanMountainDesktop/Views/MainWindow.axaml.cs
+++ b/LanMountainDesktop/Views/MainWindow.axaml.cs
@@ -289,6 +289,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
ApplyNightModeState(_isNightMode, refreshPalettes: true);
ApplyLocalization();
+ TelemetryServices.Usage?.TrackMainWindowOpened(
+ "MainWindow.OnOpened",
+ IsVisible,
+ WindowState.ToString());
DesktopHost.SizeChanged += OnDesktopHostSizeChanged;
RebuildDesktopGrid();
LoadLauncherEntriesAsync();
@@ -303,6 +307,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
protected override void OnClosed(EventArgs e)
{
+ var wasVisible = IsVisible;
+ var windowState = WindowState.ToString();
+
PersistSettings();
_componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null)
@@ -329,6 +336,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
{
settingsWindowService.StateChanged -= OnSettingsWindowStateChanged;
}
+ TelemetryServices.Usage?.TrackMainWindowClosed(
+ "MainWindow.OnClosed",
+ wasVisible,
+ windowState);
base.OnClosed(e);
}
diff --git a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
index 42cc330..2b6218a 100644
--- a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
+++ b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml
@@ -38,15 +38,15 @@
Margin="0,16,0,0">
-
-
-
diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs
index 2687b7a..d719601 100644
--- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs
+++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs
@@ -104,16 +104,26 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
return;
}
+ var wasOpen = ViewModel.IsDrawerOpen;
+ var previousTitle = ViewModel.DrawerTitle;
DrawerContentHost.Content = content;
ViewModel.DrawerTitle = title ?? ViewModel.DrawerFallbackTitle;
ViewModel.IsDrawerOpen = true;
SyncTitleText();
UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh();
+ if (!wasOpen || !string.Equals(previousTitle, ViewModel.DrawerTitle, StringComparison.Ordinal))
+ {
+ TelemetryServices.Usage?.TrackSettingsDrawerOpened(ViewModel.CurrentPageId, ViewModel.DrawerTitle);
+ }
}
public void CloseDrawer()
{
+ var wasOpen = ViewModel.IsDrawerOpen || DrawerContentHost?.Content is not null;
+ var currentPageId = ViewModel.CurrentPageId;
+ var drawerTitle = ViewModel.DrawerTitle;
+
if (DrawerContentHost is not null)
{
DrawerContentHost.Content = null;
@@ -124,6 +134,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
SyncTitleText();
UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh();
+ if (wasOpen)
+ {
+ TelemetryServices.Usage?.TrackSettingsDrawerClosed(currentPageId, drawerTitle);
+ }
}
public void RequestRestart(string? reason = null)
@@ -199,6 +213,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
private void NavigateTo(string? pageId)
{
+ var previousPageId = ViewModel.CurrentPageId;
var descriptor = ResolveDescriptor(pageId);
if (descriptor is null)
{
@@ -226,6 +241,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
SyncTitleText();
UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh();
+ if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase))
+ {
+ TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, "navigation");
+ }
}
private SettingsPageDescriptor? ResolveDescriptor(string? pageId)
@@ -367,6 +386,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
UpdateChromeMetrics();
UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh();
+ TelemetryServices.Usage?.TrackSettingsWindowOpened("SettingsWindow.OnOpened", ViewModel.CurrentPageId);
}
private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -461,6 +481,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
}
Opened -= OnOpened;
SizeChanged -= OnWindowSizeChanged;
+ TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId);
}
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs
index b266c2d..1be2753 100644
--- a/LanMountainDesktop/plugins/PluginRuntimeService.cs
+++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs
@@ -41,7 +41,7 @@ public sealed class PluginRuntimeService : IDisposable
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
{
- PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
+ PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
_sharedContractManager = new PluginSharedContractManager(
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
_packageManager = new PluginRuntimePackageManager(this);