This commit is contained in:
lincube
2026-03-21 16:16:02 +08:00
parent 2a1c09ae39
commit 46a8df5900
20 changed files with 1744 additions and 1585 deletions

View File

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

View File

@@ -71,4 +71,5 @@
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Application.Styles>
</Application>

View File

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

View File

@@ -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**
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。

View File

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

View File

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

View 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
};
}
}

View 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));
}
}
}

View File

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

View 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"
};
}
}

View 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);
}
}

View 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");
}
}

View 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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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