Compare commits

...

5 Commits

Author SHA1 Message Date
lincube
594a62132f 0.6.6
滑动优化
2026-03-18 20:09:00 +08:00
lincube
15e589aedd 0.6.5
流畅性优化测试
2026-03-17 18:36:10 +08:00
lincube
ac4617f5cf 0.6.4 2026-03-17 14:57:41 +08:00
lincube
0645598753 0.6.3.1
最近文件查看优化,课程表组件优化,插件安装优化。
2026-03-17 12:30:30 +08:00
lincube
dadd132b4f 0.6.3
优化了文本框焦点,优化了更新体验,优化了遥测,披露了收集的数据。
2026-03-17 01:01:48 +08:00
32 changed files with 1332 additions and 162 deletions

View File

@@ -67,6 +67,15 @@ public partial class App : Application
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle => internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle; (Current as App)?._hostApplicationLifecycle;
// 隐私政策查看事件
public static event Action? CurrentPrivacyPolicyViewRequested;
// 触发隐私政策查看事件的方法
public static void RaisePrivacyPolicyViewRequested()
{
CurrentPrivacyPolicyViewRequested?.Invoke();
}
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public ISettingsFacadeService SettingsFacade => _settingsFacade; public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle; public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
@@ -501,6 +510,8 @@ public partial class App : Application
if (languageChanged) if (languageChanged)
{ {
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings(); ApplyCurrentCultureFromSettings();
if (_trayIcons is not null) if (_trayIcons is not null)
{ {

View File

@@ -0,0 +1,326 @@
# LanMountainDesktop 隐私政策
**最后更新日期2026年3月17日**
---
## 引言
欢迎使用 LanMountainDesktop我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
---
## 1. 数据收集范围
### 1.1 我们收集的数据
当您启用匿名数据收集功能时,我们会收集以下数据:
#### 匿名崩溃数据
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
- **设备信息**操作系统版本、设备型号、架构x64/x86
- **应用版本**:当前使用的应用版本号
- **设备标识符**匿名生成的唯一设备ID不包含个人信息
#### 匿名使用数据
- **应用启动和关闭事件**:记录应用何时启动和关闭
- **功能使用统计**:哪些功能被使用、使用频率
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
- **界面交互**:点击了哪些按钮、访问了哪些页面
- **设备信息**:操作系统、应用版本、设备类型
### 1.2 始终收集的基础数据
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
-**应用启动事件**:用于统计日活跃用户
-**设备标识符**:用于区分不同用户(不包含个人信息)
-**应用版本**:用于统计版本分布
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
### 1.3 我们不收集的数据
我们**明确承诺不收集**以下数据:
- ❌ 个人身份信息(姓名、邮箱、电话等)
- ❌ 真实姓名或用户名
- ❌ 地理位置信息(精确位置)
- ❌ 文件内容或文档数据
- ❌ 密码或凭据信息
- ❌ 网络浏览历史
- ❌ 联系人信息
- ❌ 照片、视频或音频文件
---
## 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

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -21,6 +21,9 @@
<ItemGroup> <ItemGroup>
<Folder Include="Models\" /> <Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Localization\**" />
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
<EmbeddedResource Include="Localization\*.json" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" /> <None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" /> <None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>

View File

@@ -99,6 +99,11 @@
"settings.privacy.crash_upload_description": "Help us improve application stability.", "settings.privacy.crash_upload_description": "Help us improve application stability.",
"settings.privacy.usage_upload_title": "Anonymous usage data uploads", "settings.privacy.usage_upload_title": "Anonymous usage data uploads",
"settings.privacy.usage_upload_description": "Help us improve application features.", "settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.privacy.device_id_title": "Device ID",
"settings.privacy.device_id_description": "Unique identifier for this device. Click refresh to regenerate.",
"settings.privacy.refresh_device_id": "Refresh",
"settings.privacy.policy_hint_prefix": "For more details, please ",
"settings.privacy.view_policy": "view our privacy policy",
"settings.weather.title": "Weather", "settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.", "settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source", "settings.weather.location_source_header": "Location Source",
@@ -554,6 +559,7 @@
"component_category.info": "Info", "component_category.info": "Info",
"component_category.calculator": "Calculator", "component_category.calculator": "Calculator",
"component_category.study": "Study", "component_category.study": "Study",
"component_category.file": "File",
"component.date": "Calendar", "component.date": "Calendar",
"component.month_calendar": "Month Calendar", "component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar", "component.lunar_calendar": "Lunar Calendar",
@@ -887,5 +893,7 @@
"placement.tile": "Tile", "placement.tile": "Tile",
"single_instance.notice.title": "App already running", "single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.", "single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK" "single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
} }

View File

@@ -98,6 +98,11 @@
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。", "settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
"settings.privacy.usage_upload_title": "匿名上传使用数据", "settings.privacy.usage_upload_title": "匿名上传使用数据",
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。", "settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.privacy.device_id_title": "设备标识符",
"settings.privacy.device_id_description": "此设备的唯一标识符。点击刷新以重新生成。",
"settings.privacy.refresh_device_id": "刷新",
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气", "settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。", "settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源", "settings.weather.location_source_header": "位置来源",
@@ -552,6 +557,7 @@
"component_category.info": "信息推荐", "component_category.info": "信息推荐",
"component_category.calculator": "计算器", "component_category.calculator": "计算器",
"component_category.study": "自习", "component_category.study": "自习",
"component_category.file": "文件",
"component.date": "日历", "component.date": "日历",
"component.month_calendar": "月历", "component.month_calendar": "月历",
"component.lunar_calendar": "农历", "component.lunar_calendar": "农历",
@@ -885,5 +891,7 @@
"placement.tile": "平铺", "placement.tile": "平铺",
"single_instance.notice.title": "应用已经运行", "single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。", "single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定" "single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启"
} }

View File

@@ -62,8 +62,6 @@ public sealed class AppSettingsSnapshot
public string AppRenderMode { get; set; } = "Default"; public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; } public bool IncludePrereleaseUpdates { get; set; }
public bool UploadAnonymousCrashData { get; set; } public bool UploadAnonymousCrashData { get; set; }
@@ -72,6 +70,8 @@ public sealed class AppSettingsSnapshot
public string? DeviceId { get; set; } public string? DeviceId { get; set; }
public string? PersistentUserId { get; set; }
public string UpdateChannel { get; set; } = "stable"; public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm"; public string UpdateMode { get; set; } = "download_then_confirm";

View File

@@ -15,6 +15,7 @@ public sealed class DeviceIdService
{ {
private static DeviceIdService? _instance; private static DeviceIdService? _instance;
private string? _deviceId; private string? _deviceId;
private string? _persistentUserId; // 持久化的用户ID用于关联设备
private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitialized; private bool _isInitialized;
@@ -43,6 +44,19 @@ public sealed class DeviceIdService
} }
} }
// 持久化的用户ID用于跨设备关联用户
public string PersistentUserId
{
get
{
if (_persistentUserId is null)
{
throw new InvalidOperationException("PersistentUserId not initialized");
}
return _persistentUserId;
}
}
private void EnsureDeviceId() private void EnsureDeviceId()
{ {
if (_isInitialized) if (_isInitialized)
@@ -56,13 +70,22 @@ public sealed class DeviceIdService
{ {
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// 初始化或生成持久化用户ID只生成一次永不改变
if (string.IsNullOrEmpty(snapshot.PersistentUserId))
{
snapshot.PersistentUserId = GeneratePersistentUserId();
AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}");
}
_persistentUserId = snapshot.PersistentUserId;
// 初始化或生成设备ID可以刷新
if (string.IsNullOrEmpty(snapshot.DeviceId)) if (string.IsNullOrEmpty(snapshot.DeviceId))
{ {
snapshot.DeviceId = GenerateDeviceId(); snapshot.DeviceId = GenerateDeviceId();
_settingsFacade.Settings.SaveSnapshot( _settingsFacade.Settings.SaveSnapshot(
SettingsScope.App, SettingsScope.App,
snapshot, snapshot,
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]); changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]);
_deviceId = snapshot.DeviceId; _deviceId = snapshot.DeviceId;
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}"); AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
} }
@@ -75,6 +98,7 @@ public sealed class DeviceIdService
catch (Exception ex) catch (Exception ex)
{ {
_deviceId = GenerateDeviceId(); _deviceId = GenerateDeviceId();
_persistentUserId = GeneratePersistentUserId();
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex); AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
} }
} }
@@ -87,6 +111,15 @@ public sealed class DeviceIdService
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo)); var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
return Convert.ToHexString(hash)[..32].ToLower(); return Convert.ToHexString(hash)[..32].ToLower();
} }
private static string GeneratePersistentUserId()
{
// 生成一个永久性的用户ID基于机器名和用户名的哈希
var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
} }
public sealed class UserBehaviorAnalyticsService : IDisposable public sealed class UserBehaviorAnalyticsService : IDisposable
@@ -140,9 +173,18 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)); TimeSpan.FromSeconds(30));
// 发送PostHog标准的$pageview事件用于统计日活始终发送不受开关影响
CaptureEvent("$pageview", new Dictionary<string, object>
{
{ "$current_url", "app://main" },
{ "$title", "LanMountainDesktop" }
});
// 发送应用启动事件(始终发送,用于统计用户数量)
CaptureEvent("app_online", new Dictionary<string, object> CaptureEvent("app_online", new Dictionary<string, object>
{ {
{ "event_type", "app_start" } { "event_type", "app_start" },
{ "analytics_enabled", _isEnabled }
}); });
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}"); AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
@@ -382,10 +424,22 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
try try
{ {
// 基础事件($pageview, app_online, app_shutdown等始终发送用于统计用户数量
bool isBasicEvent = eventName.StartsWith("$") ||
eventName == "app_online" ||
eventName == "app_shutdown" ||
eventName == "$identify";
// 非基础事件只有在启用时才发送
if (!isBasicEvent && !_isEnabled)
{
return;
}
var eventData = new UserBehaviorEvent var eventData = new UserBehaviorEvent
{ {
Event = eventName, Event = eventName,
DistinctId = _deviceIdService.DeviceId, DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID
Timestamp = DateTimeOffset.UtcNow, Timestamp = DateTimeOffset.UtcNow,
Properties = properties ?? new Dictionary<string, object>(), Properties = properties ?? new Dictionary<string, object>(),
IncludeDetailedData = _isEnabled IncludeDetailedData = _isEnabled
@@ -542,21 +596,29 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
{ {
var userProperties = new Dictionary<string, object> var userProperties = new Dictionary<string, object>
{ {
{ "$device_id", distinctId },
{ "$app_version", GetAppVersion() }, { "$app_version", GetAppVersion() },
{ "$os", GetOsName() }, { "$os", GetOsName() },
{ "$os_version", GetOsVersion() } { "$os_version", GetOsVersion() },
{ "$device_type", GetDeviceModel() },
{ "$device_id", _deviceIdService.DeviceId } // 当前设备ID
}; };
// PostHog正确的$identify格式
// 使用PersistentUserId作为distinct_id确保设备ID刷新后仍能关联到同一用户
var requestBody = new Dictionary<string, object> var requestBody = new Dictionary<string, object>
{ {
{ "api_key", PostHogApiKey }, { "api_key", PostHogApiKey },
{ "event", "$identify" }, { "event", "$identify" },
{ "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") }, { "timestamp", DateTimeOffset.UtcNow.ToString("o") },
{ "properties", new Dictionary<string, object> { "properties", new Dictionary<string, object>
{ {
{ "distinct_id", distinctId }, { "$set", userProperties },
{ "$set", userProperties } { "$set_once", new Dictionary<string, object>
{
{ "first_app_open", DateTimeOffset.UtcNow.ToString("o") }
}
}
} }
} }
}; };
@@ -796,6 +858,9 @@ public sealed class CrashReportService
}); });
ConfigureCrashReportingScope(); ConfigureCrashReportingScope();
// 显式开始会话跟踪
SentrySdk.StartSession();
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}"); AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
@@ -849,7 +914,10 @@ public sealed class CrashReportService
{ {
if (_isEnabled && _isInitialized) if (_isEnabled && _isInitialized)
{ {
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}"); // 结束Sentry会话
SentrySdk.EndSession();
SentrySdk.Flush(TimeSpan.FromSeconds(3));
AppLogger.Info("CrashReport", $"Shutdown event sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
return; return;
} }

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Reflection;
using System.Text.Json; using System.Text.Json;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -16,6 +17,23 @@ public sealed class LocalizationService
private readonly Dictionary<string, Dictionary<string, string>> _cache = private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 清除指定语言代码的缓存,强制下次重新加载。
/// 在语言切换时调用此方法以确保加载最新的语言文件。
/// </summary>
public void ClearCache(string? languageCode = null)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
_cache.Clear();
}
else
{
var normalizedCode = NormalizeLanguageCode(languageCode);
_cache.Remove(normalizedCode);
}
}
public string NormalizeLanguageCode(string? languageCode) public string NormalizeLanguageCode(string? languageCode)
{ {
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase) return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
@@ -42,14 +60,17 @@ public sealed class LocalizationService
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try try
{ {
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json"); var json = TryLoadFromFileSystem(languageCode);
if (File.Exists(filePath)) if (string.IsNullOrEmpty(json))
{
json = TryLoadFromEmbeddedResource(languageCode);
}
if (!string.IsNullOrEmpty(json))
{ {
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF'); json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions); var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null) if (data is not null && data.Count > 0)
{ {
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase); result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
} }
@@ -60,7 +81,48 @@ public sealed class LocalizationService
// Keep empty table for resilience. // Keep empty table for resilience.
} }
_cache[languageCode] = result; // 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
if (result.Count > 0)
{
_cache[languageCode] = result;
}
return result; return result;
} }
private string? TryLoadFromFileSystem(string languageCode)
{
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
return File.ReadAllText(filePath);
}
}
catch
{
// Continue to next method
}
return null;
}
private string? TryLoadFromEmbeddedResource(string languageCode)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
catch
{
// Continue to next method
}
return null;
}
} }

View File

@@ -7,6 +7,7 @@ using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using Microsoft.Win32;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
@@ -35,66 +36,19 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20) public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
{ {
var documents = new List<OfficeRecentDocument>(); var documents = new List<OfficeRecentDocument>();
var recentPaths = GetRecentFolders();
foreach (var recentPath in recentPaths) // 方法1: 从注册表读取Office最近文档最可靠
{ TryGetFromRegistry(documents);
if (!Directory.Exists(recentPath))
{
continue;
}
try // 方法2: 从Recent文件夹读取快捷方式备用
{ TryGetFromRecentFolders(documents);
var files = Directory.GetFiles(recentPath, "*.lnk");
foreach (var lnkPath in files)
{
var targetPath = GetShortcutTarget(lnkPath);
if (string.IsNullOrEmpty(targetPath))
{
continue;
}
var extension = Path.GetExtension(targetPath).ToLowerInvariant(); // 方法3: 从Windows Jump List读取如果可用
if (!IsOfficeFile(extension)) TryGetFromJumpList(documents);
{
continue;
}
if (!System.IO.File.Exists(targetPath))
{
continue;
}
try
{
var fileInfo = new FileInfo(targetPath);
var doc = new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(targetPath),
FilePath = targetPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension)
};
if (!documents.Any(d => d.FilePath == targetPath))
{
documents.Add(doc);
}
}
catch
{
}
}
}
catch
{
}
}
return documents return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(d => d.LastModifiedTime).First())
.OrderByDescending(d => d.LastModifiedTime) .OrderByDescending(d => d.LastModifiedTime)
.Take(maxCount) .Take(maxCount)
.ToList(); .ToList();
@@ -116,6 +70,231 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
} }
} }
#pragma warning disable CA1416 // 平台兼容性警告
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
{
try
{
// Word最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Word\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Word\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Word\Reading Locations");
// Excel最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Excel\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\Excel\Reading Locations");
// PowerPoint最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\PowerPoint\Reading Locations");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\15.0\PowerPoint\Reading Locations");
// 通用Office最近文档
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Word");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office Excel");
TryGetFromOfficeRegistry(documents, @"Software\Microsoft\Office\16.0\Common\Open Find\Microsoft Office PowerPoint");
}
catch
{
// 忽略注册表访问错误
}
}
private void TryGetFromOfficeRegistry(List<OfficeRecentDocument> documents, string registryPath)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null) return;
foreach (var subKeyName in key.GetSubKeyNames())
{
try
{
using var subKey = key.OpenSubKey(subKeyName);
if (subKey == null) continue;
var filePath = subKey.GetValue("Path") as string;
if (string.IsNullOrEmpty(filePath)) continue;
AddDocumentIfExists(documents, filePath);
}
catch
{
// 忽略单个子键访问错误
}
}
}
catch
{
// 忽略注册表访问错误
}
}
#pragma warning restore CA1416 // 平台兼容性警告
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{
var recentPaths = GetRecentFolders();
foreach (var recentPath in recentPaths)
{
if (!Directory.Exists(recentPath))
{
continue;
}
try
{
var files = Directory.GetFiles(recentPath, "*.lnk");
foreach (var lnkPath in files)
{
var targetPath = GetShortcutTarget(lnkPath);
if (string.IsNullOrEmpty(targetPath))
{
continue;
}
AddDocumentIfExists(documents, targetPath);
}
}
catch
{
// 忽略文件夹访问错误
}
}
}
private void TryGetFromJumpList(List<OfficeRecentDocument> documents)
{
try
{
// Windows Jump List存储在以下位置
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var jumpListPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft", "Windows", "Recent", "AutomaticDestinations");
if (!Directory.Exists(jumpListPath)) return;
// Office应用的Jump List文件
var officeJumpListFiles = new[]
{
"a7bd7a3f3d5a4c74.automaticDestinations-ms", // Word
"9b524fe3be704a4d.automaticDestinations-ms", // Excel
"d0063c4c7de64e5e.automaticDestinations-ms" // PowerPoint
};
foreach (var jumpFile in officeJumpListFiles)
{
var fullPath = Path.Combine(jumpListPath, jumpFile);
if (File.Exists(fullPath))
{
TryParseJumpListFile(fullPath, documents);
}
}
}
catch
{
// Jump List解析失败忽略
}
}
private void TryParseJumpListFile(string jumpListPath, List<OfficeRecentDocument> documents)
{
try
{
// Jump List文件是二进制格式这里使用简化的方法
// 读取文件并尝试提取文件路径
var bytes = File.ReadAllBytes(jumpListPath);
var text = Encoding.Unicode.GetString(bytes);
// 查找可能的文件路径(简化实现)
var possiblePaths = ExtractPossiblePaths(text);
foreach (var path in possiblePaths)
{
AddDocumentIfExists(documents, path);
}
}
catch
{
// Jump List解析失败忽略
}
}
private IEnumerable<string> ExtractPossiblePaths(string text)
{
var paths = new List<string>();
// 查找常见的文件路径模式
var patterns = new[]
{
@"[A-Z]:\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)",
@"\\\\[^\\]+\\[^\x00-\x1F""<>|]*\.(docx?|xlsx?|pptx?|rtf|csv)"
};
foreach (var pattern in patterns)
{
try
{
var matches = System.Text.RegularExpressions.Regex.Matches(text, pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match match in matches)
{
var path = match.Value.Trim('\0', ' ', '"');
if (!string.IsNullOrEmpty(path))
{
paths.Add(path);
}
}
}
catch
{
// 忽略正则表达式错误
}
}
return paths.Distinct(StringComparer.OrdinalIgnoreCase);
}
private void AddDocumentIfExists(List<OfficeRecentDocument> documents, string filePath)
{
try
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
if (!IsOfficeFile(extension))
{
return;
}
if (!File.Exists(filePath))
{
return;
}
var fileInfo = new FileInfo(filePath);
var doc = new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(filePath),
FilePath = filePath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension)
};
if (!documents.Any(d => string.Equals(d.FilePath, filePath, StringComparison.OrdinalIgnoreCase)))
{
documents.Add(doc);
}
}
catch
{
// 忽略单个文件处理错误
}
}
private static List<string> GetRecentFolders() private static List<string> GetRecentFolders()
{ {
var folders = new List<string>(); var folders = new List<string>();
@@ -125,6 +304,12 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent")); folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent")); folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
// 添加Office 365路径
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"));
folders.Add(Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent"));
return folders; return folders;
} }

View File

@@ -47,7 +47,6 @@ public sealed record PrivacySettingsState(
bool UploadAnonymousCrashData, bool UploadAnonymousCrashData,
bool UploadAnonymousUsageData); bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState( public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates, bool IncludePrereleaseUpdates,
string UpdateChannel, string UpdateChannel,
string UpdateMode, string UpdateMode,

View File

@@ -628,7 +628,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateChannel, snapshot.UpdateChannel,
snapshot.IncludePrereleaseUpdates); snapshot.IncludePrereleaseUpdates);
return new UpdateSettingsState( return new UpdateSettingsState(
snapshot.AutoCheckUpdates,
string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase), string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase),
normalizedChannel, normalizedChannel,
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode), UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
@@ -646,7 +645,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
var normalizedChannel = UpdateSettingsValues.NormalizeChannel( var normalizedChannel = UpdateSettingsValues.NormalizeChannel(
state.UpdateChannel, state.UpdateChannel,
state.IncludePrereleaseUpdates); state.IncludePrereleaseUpdates);
snapshot.AutoCheckUpdates = state.AutoCheckUpdates;
snapshot.IncludePrereleaseUpdates = string.Equals( snapshot.IncludePrereleaseUpdates = string.Equals(
normalizedChannel, normalizedChannel,
UpdateSettingsValues.ChannelPreview, UpdateSettingsValues.ChannelPreview,
@@ -672,7 +670,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot, snapshot,
changedKeys: changedKeys:
[ [
nameof(AppSettingsSnapshot.AutoCheckUpdates), nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates), nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel), nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode), nameof(AppSettingsSnapshot.UpdateMode),

View File

@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (languageChanged) if (languageChanged)
{ {
var regionState = _settingsFacade.Region.Get(); var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode); _viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild(); _pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId); _window.ReloadPages(_viewModel.CurrentPageId);

View File

@@ -131,13 +131,10 @@ public sealed class UpdateWorkflowService
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var state = _settingsFacade.Update.Get(); var state = _settingsFacade.Update.Get();
if (!state.AutoCheckUpdates)
{
return;
}
try try
{ {
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken); var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null) if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{ {
@@ -145,12 +142,14 @@ public sealed class UpdateWorkflowService
} }
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode); var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
// For "Silent Download" and "Silent Install" modes, automatically download the update
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{ {
return; await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
} }
// For "Manual" mode, just check but don't download
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{ {
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();
RefreshItemStates(); RefreshItemStates();
// 设置更明显的状态消息
var pluginName = result.PluginName ?? item.Name;
StatusMessage = string.Format( StatusMessage = string.Format(
CultureInfo.CurrentCulture, CultureInfo.CurrentCulture,
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."), L("market.status.install_success_restart_format", "Plugin '{0}' installed successfully! Please restart the application to activate it."),
result.PluginName ?? item.Name); pluginName);
RestartRequested?.Invoke(RestartRequiredMessage);
// 触发重启提醒
RestartRequested?.Invoke(string.Format(
CultureInfo.CurrentCulture,
L("market.dialog.restart_message_format", "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"),
pluginName));
return; return;
} }

View File

@@ -0,0 +1,92 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ViewModels;
public sealed partial class PrivacyPolicyViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string _loadingText = string.Empty;
[ObservableProperty]
private string _errorText = string.Empty;
[ObservableProperty]
private string _markdownContent = string.Empty;
[ObservableProperty]
private bool _isLoading = true;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _hasContent;
public PrivacyPolicyViewModel()
{
_languageCode = "zh-CN";
RefreshLocalizedText();
LoadPrivacyPolicy();
}
private void RefreshLocalizedText()
{
Title = L("settings.privacy.policy_title", "Privacy Policy");
Description = L("settings.privacy.policy_description", "Learn how we collect, use, and protect your data.");
LoadingText = L("settings.privacy.policy_loading", "Loading privacy policy...");
}
private async void LoadPrivacyPolicy()
{
try
{
IsLoading = true;
HasError = false;
HasContent = false;
// 从嵌入资源加载隐私政策Markdown文件
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "LanMountainDesktop.Assets.Documents.Privacy.md";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new FileNotFoundException($"Privacy policy resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
var markdown = await reader.ReadToEndAsync();
MarkdownContent = markdown;
IsLoading = false;
HasContent = true;
AppLogger.Info("PrivacyPolicy", "Privacy policy loaded successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacyPolicy", "Failed to load privacy policy.", ex);
IsLoading = false;
HasError = true;
ErrorText = L("settings.privacy.policy_error", "Failed to load privacy policy. Please try again later.");
}
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}

View File

@@ -15,6 +15,8 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private readonly string _languageCode; private readonly string _languageCode;
private bool _isInitializing; private bool _isInitializing;
public event Action? ViewPrivacyPolicyRequested;
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade) public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{ {
_settingsFacade = settingsFacade; _settingsFacade = settingsFacade;
@@ -59,6 +61,12 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _refreshDeviceIdText = string.Empty; private string _refreshDeviceIdText = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
[ObservableProperty]
private string _privacyPolicyHintPrefix = string.Empty;
public void Load() public void Load()
{ {
var state = _settingsFacade.Privacy.Get(); var state = _settingsFacade.Privacy.Get();
@@ -130,6 +138,25 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID"); 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."); DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh"); RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
[RelayCommand]
private void ViewPrivacyPolicy()
{
try
{
// 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
// 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to view privacy policy.", ex);
}
} }
private string L(string key, string fallback) private string L(string key, string fallback)

View File

@@ -268,12 +268,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
partial void OnSelectedLanguageChanged(SelectionOption value) partial void OnSelectedLanguageChanged(SelectionOption value)
{ {
RefreshPreview();
if (_isInitializing || value is null) if (_isInitializing || value is null)
{ {
return; return;
} }
// 更新语言代码并刷新UI文本
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
RefreshLocalizedText();
RefreshPreview();
// 保存设置
_settingsFacade.Region.Save(new RegionSettingsState( _settingsFacade.Region.Save(new RegionSettingsState(
value.Value, value.Value,
NormalizeTimeZoneId(SelectedTimeZone?.Id))); NormalizeTimeZoneId(SelectedTimeZone?.Id)));
@@ -1329,9 +1334,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
LoadStateFromSettings(); LoadStateFromSettings();
} }
[ObservableProperty]
private bool _autoCheckUpdates;
[ObservableProperty] [ObservableProperty]
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable; private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
@@ -1380,9 +1382,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _preferencesDescription = string.Empty; private string _preferencesDescription = string.Empty;
[ObservableProperty]
private string _autoCheckUpdatesLabel = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _updateChannelLabel = string.Empty; private string _updateChannelLabel = string.Empty;
@@ -1520,16 +1519,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private bool IsBusy => IsCheckingForUpdates || IsDownloading; private bool IsBusy => IsCheckingForUpdates || IsDownloading;
partial void OnAutoCheckUpdatesChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
}
partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value) partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value)
{ {
if (value is not null && if (value is not null &&
@@ -1729,7 +1718,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
var current = _settingsFacade.Update.Get(); var current = _settingsFacade.Update.Get();
_settingsFacade.Update.Save(current with _settingsFacade.Update.Save(current with
{ {
AutoCheckUpdates = AutoCheckUpdates,
IncludePrereleaseUpdates = string.Equals( IncludePrereleaseUpdates = string.Equals(
SelectedUpdateChannelValue, SelectedUpdateChannelValue,
UpdateSettingsValues.ChannelPreview, UpdateSettingsValues.ChannelPreview,
@@ -1841,7 +1829,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information."); StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information.");
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences"); PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed."); PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
AutoCheckUpdatesLabel = L("settings.update.auto_check_toggle", "Automatically check for updates on startup");
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel"); UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
UpdateSourceLabel = L("settings.update.source_label", "Download Source"); UpdateSourceLabel = L("settings.update.source_label", "Download Source");
UpdateModeLabel = L("settings.update.mode_label", "Update Mode"); UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
@@ -1870,7 +1857,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{ {
var update = _settingsFacade.Update.Get(); var update = _settingsFacade.Update.Get();
_isInitializing = true; _isInitializing = true;
AutoCheckUpdates = update.AutoCheckUpdates;
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates); SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource); SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode); SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);

View File

@@ -43,7 +43,7 @@
<Grid Grid.Row="1"> <Grid Grid.Row="1">
<ScrollViewer x:Name="ContentScrollViewer" <ScrollViewer x:Name="ContentScrollViewer"
HorizontalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled"> VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel" /> <StackPanel x:Name="CourseListPanel" />
</ScrollViewer> </ScrollViewer>

View File

@@ -198,12 +198,32 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return; return;
} }
if (courseIndex < CourseListPanel.Children.Count) // 确保在UI线程执行
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
if (courseIndex >= CourseListPanel.Children.Count)
{
return;
}
var targetChild = CourseListPanel.Children[courseIndex]; var targetChild = CourseListPanel.Children[courseIndex];
if (targetChild == null || !targetChild.IsArrangeValid)
{
return;
}
var bounds = targetChild.Bounds; var bounds = targetChild.Bounds;
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y); var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
} var contentHeight = CourseListPanel.Bounds.Height;
// 计算滚动位置,使当前课程居中显示
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
// 确保不超出边界
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
ContentScrollViewer.Offset = new Vector(0, targetOffset);
}, Avalonia.Threading.DispatcherPriority.Loaded);
} }
public void RefreshFromSettings() public void RefreshFromSettings()
@@ -298,6 +318,15 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var currentIndex = FindCurrentCourseIndex(); var currentIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = currentIndex; _lastCurrentCourseIndex = currentIndex;
HideStatus(); HideStatus();
// 初始化时自动跳转到当前课程
if (currentIndex >= 0)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ScrollToCurrentCourse(currentIndex);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
} }
RenderScheduleItems(); RenderScheduleItems();
@@ -484,10 +513,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
: CreateBrush("#FF4D5A"); : CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2"); var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var visibleItems = _courseItems.Take(maxVisibleItems).ToList(); for (var i = 0; i < _courseItems.Count; i++)
for (var i = 0; i < visibleItems.Count; i++)
{ {
var item = visibleItems[i]; var item = _courseItems[i];
var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush; var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
var bullet = new Border var bullet = new Border

View File

@@ -11,7 +11,7 @@
<Border x:Name="RootBorder" <Border x:Name="RootBorder"
CornerRadius="34" CornerRadius="34"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" Background="#2D5A8E"
ClipToBounds="True" ClipToBounds="True"
BorderThickness="0" BorderThickness="0"
Padding="0"> Padding="0">
@@ -23,15 +23,15 @@
VerticalAlignment="Top" VerticalAlignment="Top"
Margin="0,-40,-40,0" Margin="0,-40,-40,0"
CornerRadius="70" CornerRadius="70"
Background="{DynamicResource SystemAccentColorLight2Brush}" Background="#4A90D9"
Opacity="0.2" Opacity="0.3"
IsHitTestVisible="False" /> IsHitTestVisible="False" />
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14"> <Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<TextBlock x:Name="HeaderTextBlock" <TextBlock x:Name="HeaderTextBlock"
Text="最近文档" Text="最近文档"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Foreground="#D8FFFFFF"
FontSize="18" FontSize="18"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@@ -48,7 +48,7 @@
PointerPressed="OnRefreshPointerPressed"> PointerPressed="OnRefreshPointerPressed">
<fi:SymbolIcon Symbol="ArrowSync" <fi:SymbolIcon Symbol="ArrowSync"
FontSize="14" FontSize="14"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" /> Foreground="#B8FFFFFF" />
</Button> </Button>
</Grid> </Grid>
@@ -68,14 +68,14 @@
Width="130" Width="130"
Height="90" Height="90"
CornerRadius="10" CornerRadius="10"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" Background="#3AFFFFFF"
Padding="10" Padding="10"
Cursor="Hand" Cursor="Hand"
PointerPressed="OnDocumentCardPointerPressed"> PointerPressed="OnDocumentCardPointerPressed">
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0"
Text="{Binding FileName}" Text="{Binding FileName}"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Foreground="#D8FFFFFF"
FontSize="12" FontSize="12"
FontWeight="Medium" FontWeight="Medium"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
@@ -84,7 +84,7 @@
VerticalAlignment="Top" /> VerticalAlignment="Top" />
<TextBlock Grid.Row="2" <TextBlock Grid.Row="2"
Text="{Binding TimeAgo}" Text="{Binding TimeAgo}"
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}" Foreground="#9AFFFFFF"
FontSize="10" FontSize="10"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
MaxLines="1" /> MaxLines="1" />
@@ -99,7 +99,7 @@
<TextBlock x:Name="StatusTextBlock" <TextBlock x:Name="StatusTextBlock"
IsVisible="False" IsVisible="False"
Text="暂无最近文档" Text="暂无最近文档"
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}" Foreground="#9AFFFFFF"
FontSize="14" FontSize="14"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" /> VerticalAlignment="Center" />

View File

@@ -964,6 +964,7 @@ public partial class MainWindow
DisposeComponentIfNeeded(host); DisposeComponentIfNeeded(host);
contentHost.Child = component; contentHost.Child = component;
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen); ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext(); UpdateDesktopPageAwareComponentContext();
if (_selectedDesktopComponentHost == host) if (_selectedDesktopComponentHost == host)
{ {
@@ -1102,6 +1103,7 @@ public partial class MainWindow
ClearTimeZoneServiceBindings(pageGrid.Children.OfType<Control>().ToList()); ClearTimeZoneServiceBindings(pageGrid.Children.OfType<Control>().ToList());
pageGrid.Children.Clear(); pageGrid.Children.Clear();
InvalidateDesktopPageAwareComponentContextCache();
var maxColumns = pageGrid.ColumnDefinitions.Count; var maxColumns = pageGrid.ColumnDefinitions.Count;
var maxRows = pageGrid.RowDefinitions.Count; var maxRows = pageGrid.RowDefinitions.Count;
@@ -1204,6 +1206,7 @@ public partial class MainWindow
pageGrid.Children.Add(host); pageGrid.Children.Add(host);
_desktopComponentPlacements.Add(placement); _desktopComponentPlacements.Add(placement);
InvalidateDesktopPageAwareComponentContextCache();
UpdateDesktopPageAwareComponentContext(); UpdateDesktopPageAwareComponentContext();
PersistSettings(); PersistSettings();
@@ -1577,14 +1580,86 @@ public partial class MainWindow
} }
} }
private void InvalidateDesktopPageAwareComponentContextCache()
{
_desktopPageContextInitialized = false;
_desktopPageContextActiveMask = 0;
}
private int BuildDesktopPageAwareComponentActiveMask()
{
if (_isSettingsOpen)
{
return 0;
}
var activeMask = 0;
if (_desktopSurfacePageWidth > 1 &&
_desktopPagesHostTransform is not null &&
(_isDesktopSwipeActive ||
_desktopPageContextSettlingSourceIndex is not null ||
_desktopPageContextSettlingTargetIndex is not null))
{
var viewportLeft = -_desktopPagesHostTransform.X;
var viewportRight = viewportLeft + _desktopSurfacePageWidth;
for (var pageIndex = 0; pageIndex < _desktopPageCount; pageIndex++)
{
var pageLeft = pageIndex * _desktopSurfacePageWidth;
var pageRight = pageLeft + _desktopSurfacePageWidth;
if (pageRight > viewportLeft + 0.5d && pageLeft < viewportRight - 0.5d)
{
activeMask |= 1 << pageIndex;
}
}
}
if (_currentDesktopSurfaceIndex >= 0 && _currentDesktopSurfaceIndex < _desktopPageCount)
{
activeMask |= 1 << _currentDesktopSurfaceIndex;
}
if (_desktopPageContextSettlingSourceIndex is int sourceIndex &&
sourceIndex >= 0 &&
sourceIndex < _desktopPageCount)
{
activeMask |= 1 << sourceIndex;
}
if (_desktopPageContextSettlingTargetIndex is int targetIndex &&
targetIndex >= 0 &&
targetIndex < _desktopPageCount)
{
activeMask |= 1 << targetIndex;
}
return activeMask;
}
private void UpdateDesktopPageAwareComponentContext() private void UpdateDesktopPageAwareComponentContext()
{ {
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen; var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
var activeMask = BuildDesktopPageAwareComponentActiveMask();
var pageUpdateMask = !_desktopPageContextInitialized || isEditMode != _desktopPageContextEditMode
? _desktopPageComponentGrids.Keys.Aggregate(0, (mask, pageIndex) => mask | (1 << pageIndex))
: activeMask ^ _desktopPageContextActiveMask;
if (_desktopPageContextInitialized &&
pageUpdateMask == 0 &&
isEditMode == _desktopPageContextEditMode &&
activeMask == _desktopPageContextActiveMask)
{
return;
}
foreach (var pair in _desktopPageComponentGrids) foreach (var pair in _desktopPageComponentGrids)
{ {
var isOnActivePage = pair.Key == activeDesktopPageIndex; var pageBit = 1 << pair.Key;
if ((pageUpdateMask & pageBit) == 0)
{
continue;
}
var isOnActivePage = (activeMask & pageBit) != 0;
foreach (var host in pair.Value.Children.OfType<Border>()) foreach (var host in pair.Value.Children.OfType<Border>())
{ {
if (!host.Classes.Contains(DesktopComponentHostClass)) if (!host.Classes.Contains(DesktopComponentHostClass))
@@ -1598,6 +1673,10 @@ public partial class MainWindow
} }
} }
} }
_desktopPageContextInitialized = true;
_desktopPageContextEditMode = isEditMode;
_desktopPageContextActiveMask = activeMask;
} }
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode) private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
@@ -2702,6 +2781,11 @@ public partial class MainWindow
return Symbol.Apps; return Symbol.Apps;
} }
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Folder;
}
return Symbol.Apps; return Symbol.Apps;
} }
@@ -2747,6 +2831,11 @@ public partial class MainWindow
return L("component_category.study", "Study"); return L("component_category.study", "Study");
} }
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.file", "File");
}
return categoryId; return categoryId;
} }

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@@ -16,6 +17,7 @@ using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -54,6 +56,8 @@ public partial class MainWindow
private int _currentDesktopSurfaceIndex; private int _currentDesktopSurfaceIndex;
private double _desktopSurfacePageWidth; private double _desktopSurfacePageWidth;
private TranslateTransform? _desktopPagesHostTransform; private TranslateTransform? _desktopPagesHostTransform;
private Transitions? _desktopPagesHostSnapTransitions;
private bool _desktopPagesHostTransitionsSuspended;
private bool _isDesktopSwipeActive; private bool _isDesktopSwipeActive;
private bool _isDesktopSwipeDirectionLocked; private bool _isDesktopSwipeDirectionLocked;
private Point _desktopSwipeStartPoint; private Point _desktopSwipeStartPoint;
@@ -62,6 +66,12 @@ public partial class MainWindow
private long _desktopSwipeLastTimestamp; private long _desktopSwipeLastTimestamp;
private double _desktopSwipeVelocityX; private double _desktopSwipeVelocityX;
private double _desktopSwipeBaseOffset; private double _desktopSwipeBaseOffset;
private bool _desktopPageContextInitialized;
private bool _desktopPageContextEditMode;
private int _desktopPageContextActiveMask;
private int? _desktopPageContextSettlingSourceIndex;
private int? _desktopPageContextSettlingTargetIndex;
private int _desktopPageContextSettleRevision;
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount); private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
@@ -164,6 +174,15 @@ public partial class MainWindow
DesktopPagesHost.RenderTransform = _desktopPagesHostTransform; DesktopPagesHost.RenderTransform = _desktopPagesHostTransform;
} }
if (_desktopPagesHostTransitionsSuspended)
{
_desktopPagesHostTransform.Transitions = null;
}
else
{
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
}
var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0; var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0;
var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1; var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1;
var pageWidth = Math.Max(1, gridMetrics.GridWidthPx); var pageWidth = Math.Max(1, gridMetrics.GridWidthPx);
@@ -200,6 +219,7 @@ public partial class MainWindow
DesktopPagesContainer.Width = pageWidth * _desktopPageCount; DesktopPagesContainer.Width = pageWidth * _desktopPageCount;
DesktopPagesContainer.Height = pageHeight; DesktopPagesContainer.Height = pageHeight;
_desktopPageComponentGrids.Clear(); _desktopPageComponentGrids.Clear();
InvalidateDesktopPageAwareComponentContextCache();
for (var index = 0; index < _desktopPageCount; index++) for (var index = 0; index < _desktopPageCount; index++)
{ {
DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel))); DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
@@ -354,6 +374,88 @@ public partial class MainWindow
UpdateDesktopPageAwareComponentContext(); UpdateDesktopPageAwareComponentContext();
} }
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
{
if (_desktopPagesHostTransform is null)
{
return;
}
if (enabled)
{
if (!_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostTransform.Transitions = _desktopPagesHostSnapTransitions;
_desktopPagesHostTransitionsSuspended = false;
return;
}
if (_desktopPagesHostTransitionsSuspended)
{
return;
}
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
_desktopPagesHostTransform.Transitions = null;
_desktopPagesHostTransitionsSuspended = true;
}
private void ClearDesktopPageContextSettle(bool refreshContext)
{
_desktopPageContextSettleRevision++;
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
if (refreshContext)
{
UpdateDesktopPageAwareComponentContext();
}
}
private void BeginDesktopPageContextSettle(int previousIndex, int targetIndex)
{
var sourceIndex = previousIndex >= 0 && previousIndex < _desktopPageCount
? previousIndex
: (int?)null;
var destinationIndex = targetIndex >= 0 && targetIndex < _desktopPageCount
? targetIndex
: (int?)null;
if (sourceIndex == destinationIndex && destinationIndex is not null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
if (sourceIndex is null && destinationIndex is null)
{
ClearDesktopPageContextSettle(refreshContext: false);
return;
}
_desktopPageContextSettleRevision++;
var settleRevision = _desktopPageContextSettleRevision;
_desktopPageContextSettlingSourceIndex = sourceIndex;
_desktopPageContextSettlingTargetIndex = destinationIndex;
DispatcherTimer.RunOnce(
() =>
{
if (settleRevision != _desktopPageContextSettleRevision)
{
return;
}
_desktopPageContextSettlingSourceIndex = null;
_desktopPageContextSettlingTargetIndex = null;
UpdateDesktopPageAwareComponentContext();
},
FluttermotionToken.Page + TimeSpan.FromMilliseconds(36));
}
private void MoveSurfaceBy(int delta) private void MoveSurfaceBy(int delta)
{ {
if (delta == 0) if (delta == 0)
@@ -373,9 +475,11 @@ public partial class MainWindow
return; return;
} }
var previousIndex = _currentDesktopSurfaceIndex;
_currentDesktopSurfaceIndex = target; _currentDesktopSurfaceIndex = target;
BeginDesktopPageContextSettle(previousIndex, target);
ApplyDesktopSurfaceOffset(); ApplyDesktopSurfaceOffset();
PersistSettings(); SchedulePersistSettings(delayMs: Math.Max(280, (int)FluttermotionToken.Page.TotalMilliseconds + 80));
} }
private bool CanSwipeDesktopSurface() private bool CanSwipeDesktopSurface()
@@ -426,6 +530,7 @@ public partial class MainWindow
return; return;
} }
ClearDesktopPageContextSettle(refreshContext: false);
_isDesktopSwipeActive = true; _isDesktopSwipeActive = true;
_isDesktopSwipeDirectionLocked = false; _isDesktopSwipeDirectionLocked = false;
_desktopSwipeStartPoint = pointerInViewport; _desktopSwipeStartPoint = pointerInViewport;
@@ -603,6 +708,7 @@ public partial class MainWindow
} }
_isDesktopSwipeDirectionLocked = true; _isDesktopSwipeDirectionLocked = true;
SetDesktopPagesHostSnapAnimationEnabled(enabled: false);
if (e.Pointer.Captured != DesktopPagesViewport) if (e.Pointer.Captured != DesktopPagesViewport)
{ {
e.Pointer.Capture(DesktopPagesViewport); e.Pointer.Capture(DesktopPagesViewport);
@@ -621,6 +727,7 @@ public partial class MainWindow
} }
_desktopPagesHostTransform.X = tentative; _desktopPagesHostTransform.X = tentative;
UpdateDesktopPageAwareComponentContext();
e.Handled = true; e.Handled = true;
} }
@@ -656,6 +763,7 @@ public partial class MainWindow
_desktopSwipeLastTimestamp = 0; _desktopSwipeLastTimestamp = 0;
if (wasDirectionLocked) if (wasDirectionLocked)
{ {
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
ApplyDesktopSurfaceOffset(); ApplyDesktopSurfaceOffset();
} }
} }
@@ -682,6 +790,8 @@ public partial class MainWindow
return false; return false;
} }
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X; var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y; var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
var absDeltaX = Math.Abs(deltaX); var absDeltaX = Math.Abs(deltaX);

View File

@@ -32,6 +32,11 @@ public partial class MainWindow
{ {
_ = sender; _ = sender;
if (_suppressOwnSettingsReloadCount > 0)
{
return;
}
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 }) if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
{ {
var changedKeys = e.ChangedKeys.ToArray(); var changedKeys = e.ChangedKeys.ToArray();
@@ -382,6 +387,7 @@ public partial class MainWindow
private void PersistSettings() private void PersistSettings()
{ {
_persistSettingsRevision++;
if (_suppressSettingsPersistence) if (_suppressSettingsPersistence)
{ {
return; return;
@@ -389,6 +395,8 @@ public partial class MainWindow
try try
{ {
// Saving our own state should not trigger a full external reload cycle.
_suppressOwnSettingsReloadCount++;
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot()); _settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot()); _componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot()); _settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
@@ -397,11 +405,29 @@ public partial class MainWindow
{ {
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex); AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
} }
finally
{
if (_suppressOwnSettingsReloadCount > 0)
{
_suppressOwnSettingsReloadCount--;
}
}
} }
private void SchedulePersistSettings(int delayMs = 200) private void SchedulePersistSettings(int delayMs = 200)
{ {
DispatcherTimer.RunOnce(PersistSettings, TimeSpan.FromMilliseconds(Math.Max(0, delayMs))); var revision = ++_persistSettingsRevision;
DispatcherTimer.RunOnce(
() =>
{
if (revision != _persistSettingsRevision)
{
return;
}
PersistSettings();
},
TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
} }
internal void ReloadFromPersistedSettings() internal void ReloadFromPersistedSettings()
@@ -473,6 +499,7 @@ public partial class MainWindow
var latestWeatherState = _weatherSettingsService.Get(); var latestWeatherState = _weatherSettingsService.Get();
var latestUpdateState = _updateSettingsService.Get(); var latestUpdateState = _updateSettingsService.Get();
var latestThemeState = _themeSettingsService.Get(); var latestThemeState = _themeSettingsService.Get();
var latestPrivacyState = _settingsFacade.Privacy.Get();
return new AppSettingsSnapshot return new AppSettingsSnapshot
{ {
GridShortSideCells = _targetShortSideCells, GridShortSideCells = _targetShortSideCells,
@@ -504,7 +531,8 @@ public partial class MainWindow
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests, WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
AutoStartWithWindows = _autoStartWithWindows, AutoStartWithWindows = _autoStartWithWindows,
AppRenderMode = _selectedAppRenderMode, AppRenderMode = _selectedAppRenderMode,
AutoCheckUpdates = latestUpdateState.AutoCheckUpdates, UploadAnonymousCrashData = latestPrivacyState.UploadAnonymousCrashData,
UploadAnonymousUsageData = latestPrivacyState.UploadAnonymousUsageData,
IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates, IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates,
UpdateChannel = latestUpdateState.UpdateChannel, UpdateChannel = latestUpdateState.UpdateChannel,
UpdateMode = latestUpdateState.UpdateMode, UpdateMode = latestUpdateState.UpdateMode,

View File

@@ -145,7 +145,9 @@
<TranslateTransform> <TranslateTransform>
<TranslateTransform.Transitions> <TranslateTransform.Transitions>
<Transitions> <Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" /> <DoubleTransition Property="X"
Duration="{StaticResource FluttermotionToken.Duration.Page}"
Easing="0.22,1,0.36,1" />
</Transitions> </Transitions>
</TranslateTransform.Transitions> </TranslateTransform.Transitions>
</TranslateTransform> </TranslateTransform>

View File

@@ -153,6 +153,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private bool _isWeatherPreviewInProgress; private bool _isWeatherPreviewInProgress;
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond; private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _externalSettingsReloadPending; private bool _externalSettingsReloadPending;
private int _persistSettingsRevision;
private int _suppressOwnSettingsReloadCount;
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap; private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
public MainWindow() public MainWindow()

View File

@@ -16,8 +16,11 @@
<ui:SettingsExpander.Footer> <ui:SettingsExpander.Footer>
<Grid ColumnDefinitions="*,Auto" <Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12"> ColumnSpacing="12">
<TextBox Text="{Binding SearchText}" <TextBox x:Name="SearchTextBox"
Watermark="{Binding SearchPlaceholder}" /> Text="{Binding SearchText}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1" <Button Grid.Column="1"
Command="{Binding RefreshCommand}" Command="{Binding RefreshCommand}"
Content="{Binding RefreshButtonText}" /> Content="{Binding RefreshButtonText}" />

View File

@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
xmlns:helpers="using:LanMountainDesktop.Helpers"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacyPolicyDrawer"
x:DataType="vm:PrivacyPolicyViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container"
Margin="0,0,0,8">
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border Classes="settings-section-card-icon-host"
Width="48"
Height="48">
<Viewbox Stretch="Uniform">
<fi:SymbolIcon Symbol="Document" />
</Viewbox>
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Classes="settings-card-header"
Margin="0"
Text="{Binding Title}" />
<TextBlock Classes="settings-item-description"
Text="{Binding Description}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border Classes="settings-section-card">
<StackPanel Spacing="12">
<TextBlock Classes="settings-item-description"
IsVisible="{Binding IsLoading}"
Text="{Binding LoadingText}" />
<TextBlock Classes="settings-item-description"
IsVisible="{Binding HasError}"
Text="{Binding ErrorText}"
TextWrapping="Wrap" />
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
Markdown="{Binding MarkdownContent}"
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,17 @@
using Avalonia.Controls;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PrivacyPolicyDrawer : UserControl
{
public PrivacyPolicyDrawer()
{
InitializeComponent();
}
public PrivacyPolicyDrawer(PrivacyPolicyViewModel viewModel) : this()
{
DataContext = viewModel;
}
}

View File

@@ -45,10 +45,13 @@
FontSize="12" FontSize="12"
Opacity="0.7" Opacity="0.7"
Margin="0,4,0,8" /> Margin="0,4,0,8" />
<TextBox Text="{Binding DeviceId}" <TextBox x:Name="DeviceIdTextBox"
Text="{Binding DeviceId}"
IsReadOnly="True" IsReadOnly="True"
FontFamily="Consolas" FontFamily="Consolas"
FontSize="12" /> FontSize="12"
Focusable="False"
IsTabStop="False" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" <Button Grid.Column="1"
Content="{Binding RefreshDeviceIdText}" Content="{Binding RefreshDeviceIdText}"
@@ -58,6 +61,28 @@
Classes="accent-button" /> Classes="accent-button" />
</Grid> </Grid>
</Border> </Border>
<StackPanel Orientation="Horizontal"
Margin="0,16,0,0"
Spacing="4">
<TextBlock Text="{Binding PrivacyPolicyHintPrefix}"
FontSize="13"
VerticalAlignment="Center" />
<Button Content="{Binding ViewPrivacyPolicyText}"
Command="{Binding ViewPrivacyPolicyCommand}"
Background="Transparent"
BorderThickness="0"
Padding="0"
FontSize="13"
Foreground="{DynamicResource SystemAccentColor}"
Cursor="Hand">
<Button.Styles>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="TextBlock.Foreground" Value="{DynamicResource SystemAccentColorDark1}" />
</Style>
</Button.Styles>
</Button>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View File

@@ -22,9 +22,17 @@ public partial class PrivacySettingsPage : SettingsPageBase
public PrivacySettingsPage(PrivacySettingsPageViewModel viewModel) public PrivacySettingsPage(PrivacySettingsPageViewModel viewModel)
{ {
ViewModel = viewModel; ViewModel = viewModel;
ViewModel.ViewPrivacyPolicyRequested += OnViewPrivacyPolicyRequested;
DataContext = ViewModel; DataContext = ViewModel;
InitializeComponent(); InitializeComponent();
} }
public PrivacySettingsPageViewModel ViewModel { get; } public PrivacySettingsPageViewModel ViewModel { get; }
private void OnViewPrivacyPolicyRequested()
{
var privacyPolicyViewModel = new PrivacyPolicyViewModel();
var drawer = new PrivacyPolicyDrawer(privacyPolicyViewModel);
OpenDrawer(drawer, privacyPolicyViewModel.Title);
}
} }

View File

@@ -21,12 +21,17 @@
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />
<Setter Property="Opacity" Value="0.68" /> <Setter Property="Opacity" Value="0.68" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
</Style> </Style>
<Style Selector="TextBlock.update-kv-value"> <Style Selector="TextBlock.update-kv-value">
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="MaxWidth" Value="200" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style> </Style>
</UserControl.Styles> </UserControl.Styles>
@@ -64,10 +69,12 @@
Content="{Binding CheckForUpdatesButtonText}" /> Content="{Binding CheckForUpdatesButtonText}" />
</Grid> </Grid>
<Grid ColumnDefinitions="*,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14" RowDefinitions="Auto,Auto"
RowSpacing="12"> ColumnSpacing="20"
<StackPanel Grid.Column="0" RowSpacing="16">
<StackPanel Grid.Row="0"
Grid.Column="0"
Spacing="4"> Spacing="4">
<TextBlock Classes="update-kv-label" <TextBlock Classes="update-kv-label"
Text="{Binding CurrentVersionLabel}" /> Text="{Binding CurrentVersionLabel}" />
@@ -75,7 +82,8 @@
Text="{Binding CurrentVersionText}" /> Text="{Binding CurrentVersionText}" />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" <StackPanel Grid.Row="0"
Grid.Column="1"
Spacing="4" Spacing="4"
IsVisible="{Binding IsLatestVersionVisible}"> IsVisible="{Binding IsLatestVersionVisible}">
<TextBlock Classes="update-kv-label" <TextBlock Classes="update-kv-label"
@@ -105,18 +113,27 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Spacing="8"> <StackPanel Spacing="12"
HorizontalAlignment="Left">
<TextBlock Classes="settings-item-description" <TextBlock Classes="settings-item-description"
Text="{Binding UpdateStatus}" /> Text="{Binding UpdateStatus}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
MaxWidth="500" />
<ProgressBar Minimum="0" <ProgressBar Minimum="0"
Maximum="100" Maximum="100"
Value="{Binding DownloadProgressValue}" Value="{Binding DownloadProgressValue}"
IsVisible="{Binding IsDownloadProgressVisible}" /> IsVisible="{Binding IsDownloadProgressVisible}"
HorizontalAlignment="Stretch"
Margin="0,4,0,4" />
<TextBlock Classes="settings-item-description" <TextBlock Classes="settings-item-description"
IsVisible="{Binding IsDownloadProgressVisible}" IsVisible="{Binding IsDownloadProgressVisible}"
Text="{Binding DownloadProgressText}" /> Text="{Binding DownloadProgressText}"
TextWrapping="Wrap"
HorizontalAlignment="Left"
Margin="0,4,0,0" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
@@ -210,15 +227,7 @@
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AutoCheckUpdatesLabel}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ClockAlarm" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding AutoCheckUpdates}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View File

@@ -96,8 +96,11 @@
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="*,Auto" <Grid ColumnDefinitions="*,Auto"
ColumnSpacing="12"> ColumnSpacing="12">
<TextBox Text="{Binding SearchKeyword}" <TextBox x:Name="SearchKeywordTextBox"
Watermark="{Binding SearchPlaceholder}" /> Text="{Binding SearchKeyword}"
Watermark="{Binding SearchPlaceholder}"
Focusable="True"
IsTabStop="True" />
<Button Grid.Column="1" <Button Grid.Column="1"
Command="{Binding SearchCommand}" Command="{Binding SearchCommand}"
Content="{Binding SearchButtonText}" /> Content="{Binding SearchButtonText}" />
@@ -178,12 +181,18 @@
</Grid> </Grid>
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
<ui:SettingsExpanderItem> <ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationKey}" <TextBox x:Name="LocationKeyTextBox"
Watermark="{Binding LocationKeyPlaceholder}" /> Text="{Binding LocationKey}"
Watermark="{Binding LocationKeyPlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
<ui:SettingsExpanderItem> <ui:SettingsExpanderItem>
<TextBox Text="{Binding LocationName}" <TextBox x:Name="LocationNameTextBox"
Watermark="{Binding LocationNamePlaceholder}" /> Text="{Binding LocationName}"
Watermark="{Binding LocationNamePlaceholder}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpanderItem> </ui:SettingsExpanderItem>
</ui:SettingsExpander> </ui:SettingsExpander>
@@ -230,11 +239,14 @@
<fi:SymbolIconSource Symbol="Warning" /> <fi:SymbolIconSource Symbol="Warning" />
</ui:SettingsExpander.IconSource> </ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer> <ui:SettingsExpander.Footer>
<TextBox Width="360" <TextBox x:Name="ExcludedAlertsTextBox"
Width="360"
MinHeight="120" MinHeight="120"
AcceptsReturn="True" AcceptsReturn="True"
TextWrapping="Wrap" TextWrapping="Wrap"
Text="{Binding ExcludedAlerts}" /> Text="{Binding ExcludedAlerts}"
Focusable="True"
IsTabStop="True" />
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>