mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
0.7.2
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -71,4 +71,5 @@
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!**
|
||||
|
||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
629
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
629
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
@@ -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<TelemetryEvent> _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<AppSettingsSnapshot>(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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["was_visible"] = wasVisible,
|
||||
["window_state"] = windowState
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsWindowOpened(string source, string? currentPageId)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_window_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["current_page_id"] = currentPageId
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsWindowClosed(string source, string? currentPageId)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_window_closed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["current_page_id"] = currentPageId
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_navigation",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["page_id"] = pageId,
|
||||
["drawer_title"] = drawerTitle
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_drawer_closed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["page_id"] = pageId,
|
||||
["drawer_title"] = drawerTitle
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_placed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateAfter: DescribePlacement(placement),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentMoved(
|
||||
DesktopComponentPlacementSnapshot before,
|
||||
DesktopComponentPlacementSnapshot after,
|
||||
string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_moved",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(before),
|
||||
stateAfter: DescribePlacement(after),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_deleted",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(before),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_editor_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = "app_first_launch",
|
||||
["distinct_id"] = installId,
|
||||
["timestamp"] = timestamp.ToString("o"),
|
||||
["properties"] = new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["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<string, object?>? payload = null,
|
||||
IReadOnlyDictionary<string, object?>? stateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? 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<string, object?>(),
|
||||
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<TelemetryEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<TelemetryEvent>();
|
||||
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<string, object?>
|
||||
{
|
||||
["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<string, object?> CreatePageState(string? pageId)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["page_id"] = pageId
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> DescribePlacement(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["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
|
||||
};
|
||||
}
|
||||
}
|
||||
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
@@ -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<AppSettingsSnapshot>(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<string>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
@@ -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<string, object?> Payload,
|
||||
IReadOnlyDictionary<string, object?>? StateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? StateAfter = null)
|
||||
{
|
||||
public Dictionary<string, object?> ToPostHogProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, object?>(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<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
|
||||
{
|
||||
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
177
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
177
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
@@ -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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(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<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var changedKeys = new List<string>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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<Models.AppSettingsSnapshot>(SettingsScope.App);
|
||||
snapshot.DeviceId = newDeviceId;
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
|
||||
|
||||
DeviceId = newDeviceId;
|
||||
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,15 +38,15 @@
|
||||
Margin="0,16,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding DeviceIdHeader}"
|
||||
<TextBlock Text="{Binding TelemetryIdHeader}"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="{Binding DeviceIdDescription}"
|
||||
<TextBlock Text="{Binding TelemetryIdDescription}"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
Margin="0,4,0,8" />
|
||||
<TextBox x:Name="DeviceIdTextBox"
|
||||
Text="{Binding DeviceId}"
|
||||
<TextBox x:Name="TelemetryIdTextBox"
|
||||
Text="{Binding TelemetryId}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
@@ -54,8 +54,8 @@
|
||||
IsTabStop="False" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="{Binding RefreshDeviceIdText}"
|
||||
Command="{Binding RefreshDeviceIdCommand}"
|
||||
Content="{Binding RefreshTelemetryIdText}"
|
||||
Command="{Binding RefreshTelemetryIdCommand}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="16,0,0,0"
|
||||
Classes="accent-button" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user