Files
better-genshin-impact/BetterGenshinImpact/View/Windows/ScriptRepoWindow.xaml.cs
ShadowLemoon f7976b0bbd feat: 根据文件夹名字和内容重合度区分仓库;启动时自动更新仓库和订阅 (#2767)
* feat: 实现启动时自动更新已订阅脚本及多仓库分离存储

- ScriptConfig: 新增 AutoUpdateSubscribedScripts 配置项
- ScriptRepoUpdater: 动态 CenterRepoPath, 按仓库URL分离存储
  - 内容重合度检测(Jaccard系数)判断仓库异同
  - URL→文件夹名持久化映射(repo_folder_mapping.json)
  - repo_updated.json 存放于各自仓库文件夹内
  - AutoUpdateSubscribedScripts 启动时自动更新订阅脚本
  - 静默同步仓库、渠道URL解析、检出更新脚本
- RepoWebBridge: 使用动态路径, 辅助方法改为 internal
- MainWindowViewModel: 启动时调用自动更新

* feat: 基于内容重合度的导入zip仓库

* perf: 合并默认仓库url映射

* perf: 清理兼容字段

* perf: 添加线程锁以避免并发调用

* fix: 缓存FolderMapping、修复重合度异常返回值、目录扫描异常隔离、移除未使用变量

* perf: 优化更新流程

* perf: 内存缓存添加锁

* fix: 修复更新状态逻辑,确保克隆失败时不标记为已更新

* perf: 文件夹映射先写磁盘再写缓存

* refactor: 简化生成唯一文件夹名称的方法,移除不必要的参数

* fix: ResetRepo加写锁并清理URL映射条目

* perf: 优化重合度算法

* docs: 更新注释

* fix: 仅重置实际更新成功的脚本的 hasUpdate 标记

* feat: 手动一键更新按钮

* feat: 订阅路径迁移至独立文件存储并简化更新逻辑

- 订阅数据从 config.json 迁移到 User/subscriptions/{repo}.json 独立文件
- 添加 ReaderWriterLockSlim 保护订阅文件并发读写
- 使用 System.Text.Json + ConfigService.JsonOptions 序列化
- 新增 RepoWebBridge.GetSubscribedScriptPaths() 桥接方法
- 启动时自动从旧 config.json 迁移订阅数据到独立文件
- 合并手动/自动更新为 UpdateAllSubscribedScriptsCore 共用核心
- 移除 hasUpdate 检查,直接全量更新所有订阅脚本
- 移除冗余 logPrefix 参数

* refactor: 简化启动时自动更新调用

- 移除 Task.Run + try-catch 包装,异常处理已内置于方法中
- 直接使用 fire-and-forget 异步调用

* fix: 订阅目录命名改为 PascalCase (Subscriptions)

* refactor: 移除死代码和冗余中间层方法

* fix: ReadSubscriptionFile 异常时记录日志避免订阅数据静默丢失

* fix: 进度条改为Indeterminate模式、异常日志补全、订阅去重、迁移批量写入、锁注释

* refactor: 提取 ReadFolderMappingFromDisk 消除映射方法嵌套 try

* fix: 补全静态方法异常日志、WriteSubscriptionFile异常保护、ManualUpdate注释

* fix: ManualUpdateSubscribedScripts 加 try-catch 兜底并提示用户重置仓库

* fix: Dialog打开时检测后台自动更新状态,自动禁用按钮并显示进度提示

- ScriptRepoUpdater 新增 IsAutoUpdating 标志和 AutoUpdateStateChanged 事件
- ScriptRepoWindow 订阅事件,自动更新期间显示进度条、禁用所有操作按钮并 Toast 提示
- 更新仓库/重置仓库按钮也加上 IsEnabled 绑定 IsUpdating

* fix: 将自动更新调用包裹在 Task.Run 中避免 UI 线程阻塞

AutoUpdateSubscribedScripts 的 await 后续会被 WPF SynchronizationContext
调度回 UI 线程,导致大量 Git checkout 和文件 IO 操作阻塞界面。
用 Task.Run 确保整个流程在线程池执行。

* fix: 进度条分离 IsProgressIndeterminate 属性,按操作类型正确切换确定/不确定模式

* refactor: 消除 pathing 展开重复逻辑、用布尔字段替换字符串比较追踪状态来源、补全锁注释、统一日志方式

* fix: ExpandTopLevelPaths 泛化展开所有 PathMapper 顶层 key 防止误删用户目录,迁移移入锁内

* fix: 命令行启动配置组/一条龙前先等待自动更新订阅脚本完成

* feat: 添加命令行启动时是否先自动更新选项

* fix: 修复按钮位置
2026-02-20 15:09:17 +08:00

599 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using BetterGenshinImpact.Core.Config;
using BetterGenshinImpact.Core.Script;
using BetterGenshinImpact.GameTask;
using BetterGenshinImpact.Helpers;
using BetterGenshinImpact.Helpers.Ui;
using BetterGenshinImpact.Helpers.Win32;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Meziantou.Framework.Win32;
using Microsoft.Win32;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Data;
using System.Globalization;
using System.Windows.Navigation;
using Wpf.Ui.Violeta.Controls;
namespace BetterGenshinImpact.View.Windows;
[ObservableObject]
public partial class ScriptRepoWindow
{
// 更新渠道类
public class RepoChannel
{
public string Name { get; set; }
public string Url { get; set; }
public RepoChannel(string name, string url)
{
Name = name;
Url = url;
}
}
// 渠道列表
private ObservableCollection<RepoChannel> _repoChannels;
public ObservableCollection<RepoChannel> RepoChannels => _repoChannels;
// 选中的渠道
[ObservableProperty] private RepoChannel? _selectedRepoChannel;
// 控制仓库地址是否只读
[ObservableProperty] private bool _isRepoUrlReadOnly = true;
// 添加进度相关的可观察属性
[ObservableProperty] private bool _isUpdating;
[ObservableProperty] private bool _isProgressIndeterminate;
[ObservableProperty] private int _updateProgressValue;
[ObservableProperty] private string _updateProgressText = "准备更新,请耐心等待...";
[ObservableProperty] private ScriptConfig _config = TaskContext.Instance().Config.ScriptConfig;
// Git 凭据相关属性
private const string GitCredentialAppName = "BetterGenshinImpact.GitCredentials";
[ObservableProperty] private string _gitUsername = "";
[ObservableProperty] private string _gitToken = "";
// 在线更新相关属性
[ObservableProperty] private string _onlineDownloadUrl = "";
// 获取当前仓库URL用于界面显示
public string CurrentRepoUrl
{
get
{
if (SelectedRepoChannel == null)
{
return "";
}
return SelectedRepoChannel.Name == "自定义" ? Config.CustomRepoUrl : SelectedRepoChannel.Url;
}
}
public ScriptRepoWindow()
{
InitializeRepoChannels();
LoadCredentialsFromManager();
InitializeComponent();
DataContext = this;
Config.PropertyChanged += OnConfigPropertyChanged;
PropertyChanged += OnPropertyChanged;
ScriptRepoUpdater.Instance.AutoUpdateStateChanged += OnAutoUpdateStateChanged;
// 设置 PasswordBox 的初始值
Loaded += (s, e) =>
{
GitTokenPasswordBox.Password = GitToken;
SyncAutoUpdateState();
};
SourceInitialized += (s, e) =>
{
// 应用系统背景
WindowHelper.TryApplySystemBackdrop(this);
// 设置仓库地址的只读状态
IsRepoUrlReadOnly = SelectedRepoChannel == null || SelectedRepoChannel.Name != "自定义";
};
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
//OnSelectedRepoChannelChanged
if (e.PropertyName == nameof(SelectedRepoChannel))
{
OnSelectedRepoChannelChanged();
}
// 监听IsUpdating变化以调整窗口高度
else if (e.PropertyName == nameof(IsUpdating))
{
OnIsUpdatingChanged();
}
// 监听 GitUsername 和 GitToken 变化,保存到凭据管理器
else if (e.PropertyName == nameof(GitUsername) || e.PropertyName == nameof(GitToken))
{
SaveCredentialsToManager();
}
}
/// <summary>
/// 从 Windows 凭据管理器加载 Git 凭据
/// </summary>
private void LoadCredentialsFromManager()
{
var credential = CredentialManagerHelper.ReadCredential(GitCredentialAppName);
GitUsername = credential?.UserName ?? "";
GitToken = credential?.Password ?? "";
}
/// <summary>
/// 保存 Git 凭据到 Windows 凭据管理器
/// </summary>
private void SaveCredentialsToManager()
{
CredentialManagerHelper.SaveCredential(
GitCredentialAppName,
GitUsername,
GitToken,
"Git credentials for BetterGenshinImpact script repository",
CredentialPersistence.LocalMachine);
}
~ScriptRepoWindow()
{
Config.PropertyChanged -= OnConfigPropertyChanged;
PropertyChanged -= OnPropertyChanged;
ScriptRepoUpdater.Instance.AutoUpdateStateChanged -= OnAutoUpdateStateChanged;
}
/// <summary>
/// 后台自动更新状态变化回调(可能在非 UI 线程触发)
/// </summary>
private void OnAutoUpdateStateChanged(object? sender, EventArgs e)
{
Dispatcher.BeginInvoke(SyncAutoUpdateState);
}
/// <summary>
/// 同步后台自动更新状态到 Dialog 的进度条和按钮
/// </summary>
private void SyncAutoUpdateState()
{
if (ScriptRepoUpdater.Instance.IsAutoUpdating)
{
IsUpdating = true;
IsProgressIndeterminate = true;
UpdateProgressText = "后台正在自动更新订阅脚本...";
Toast.Information("后台正在自动更新订阅脚本,请等待完成");
}
else
{
IsUpdating = false;
IsProgressIndeterminate = false;
}
}
private void OnConfigPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// 监听CustomRepoUrl变化通知界面更新显示
if (e.PropertyName == nameof(ScriptConfig.CustomRepoUrl))
{
OnPropertyChanged(nameof(CurrentRepoUrl));
}
}
private void OnIsUpdatingChanged()
{
// 当IsUpdating状态变化时强制重新计算窗口大小
Dispatcher.BeginInvoke(() =>
{
InvalidateMeasure();
UpdateLayout();
}, System.Windows.Threading.DispatcherPriority.Loaded);
}
private void InitializeRepoChannels()
{
_repoChannels = new ObservableCollection<RepoChannel>(
ScriptRepoUpdater.RepoChannels.Select(kv => new RepoChannel(kv.Key, kv.Value))
);
_repoChannels.Add(new RepoChannel("自定义", "https://example.com/custom-repo"));
// 根据配置中保存的渠道名称恢复选择
if (string.IsNullOrEmpty(Config.SelectedChannelName))
{
// 默认选中第一个渠道
SelectedRepoChannel = _repoChannels[0];
Config.SelectedChannelName = SelectedRepoChannel.Name;
}
else
{
// 根据保存的渠道名称找到对应的渠道
var savedChannel = _repoChannels.FirstOrDefault(c => c.Name == Config.SelectedChannelName);
SelectedRepoChannel = savedChannel ?? _repoChannels[0];
// 如果找不到保存的渠道,更新配置为默认渠道
if (savedChannel == null)
{
Config.SelectedChannelName = _repoChannels[0].Name;
}
}
}
private void OnSelectedRepoChannelChanged()
{
if (SelectedRepoChannel is null)
{
return;
}
// 保存选择的渠道名称
Config.SelectedChannelName = SelectedRepoChannel.Name;
// 更新仓库地址只读状态
IsRepoUrlReadOnly = SelectedRepoChannel.Name != "自定义";
// 通知界面更新CurrentRepoUrl
OnPropertyChanged(nameof(CurrentRepoUrl));
}
[RelayCommand]
private async Task UpdateRepo()
{
if (SelectedRepoChannel is null)
{
Toast.Warning("请选择一个脚本仓库更新渠道。");
return;
}
// 获取当前仓库URL
string repoUrl = CurrentRepoUrl;
// 验证URL
if (string.IsNullOrWhiteSpace(repoUrl))
{
Toast.Warning("请输入自定义仓库URL。");
return;
}
if (repoUrl == "https://example.com/custom-repo")
{
Toast.Warning("请修改默认的自定义URL为有效的仓库地址。");
return;
}
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out _))
{
Toast.Warning("请输入有效的URL地址。");
return;
}
try
{
// 显示更新中提示
Toast.Information("正在更新脚本仓库,请耐心等待...");
// 设置进度显示
IsUpdating = true;
IsProgressIndeterminate = true;
UpdateProgressValue = 0;
UpdateProgressText = "准备更新,请耐心等待...";
// 执行更新Task.Run 避免 SynchronizationContext 将后续 IO 调度回 UI 线程)
var (_, updated) = await Task.Run(() => ScriptRepoUpdater.Instance.UpdateCenterRepoByGit(repoUrl,
(path, steps, totalSteps) =>
{
// 收到实际进度后切换为确定模式
IsProgressIndeterminate = false;
// 更新进度显示WPF 绑定引擎会自动将跨线程 PropertyChanged 调度到 UI 线程)
double progressPercentage = totalSteps > 0 ? Math.Min(100, (double)steps / totalSteps * 100) : 0;
UpdateProgressValue = (int)progressPercentage;
UpdateProgressText = $"{path}";
}));
// 更新结果提示
if (updated)
{
Toast.Success("脚本仓库更新成功,有新内容");
}
else
{
Toast.Success("脚本仓库已是最新");
}
}
catch (Exception ex)
{
await ThemedMessageBox.ErrorAsync($"更新失败,可尝试重置仓库后重新更新。失败原因:{ex.Message}");
}
finally
{
// 隐藏进度条
IsUpdating = false;
IsProgressIndeterminate = false;
}
}
[RelayCommand]
private async Task OpenLocalScriptRepo()
{
// 检查是否需要提示用户更新仓库
var shouldContinue = await CheckAndPromptRepoUpdate();
if (shouldContinue)
{
TaskContext.Instance().Config.ScriptConfig.ScriptRepoHintDotVisible = false;
ScriptRepoUpdater.Instance.OpenLocalRepoInWebView();
Close();
}
}
/// <summary>
/// 检查仓库更新时间并提示用户
/// </summary>
/// <returns>是否继续打开仓库true: 继续打开, false: 取消操作)</returns>
private async Task<bool> CheckAndPromptRepoUpdate()
{
TimeSpan timeSinceUpdate;
try
{
// 检查仓库文件夹是否存在
if (!Directory.Exists(ScriptRepoUpdater.CenterRepoPath))
{
return true;
}
// 查找 repo.json 文件
var repoJsonPath = Directory.GetFiles(ScriptRepoUpdater.CenterRepoPath, "repo.json", SearchOption.AllDirectories).FirstOrDefault();
if (repoJsonPath == null || !File.Exists(repoJsonPath))
{
return true;
}
// 获取 repo.json 文件的最后修改时间
var repoJsonFile = new FileInfo(repoJsonPath);
DateTime lastUpdateTime = repoJsonFile.LastWriteTime;
// 检查是否超过 30 天
timeSinceUpdate = DateTime.Now - lastUpdateTime;
if (timeSinceUpdate.TotalDays <= 30)
{
return true;
}
}
catch
{
// 出现异常时,继续打开仓库
return true;
}
// 提示用户更新
var dialog = new RepoUpdateDialog((int)timeSinceUpdate.TotalDays);
var result = await dialog.ShowDialogAsync();
if (result == Wpf.Ui.Controls.MessageBoxResult.Primary)
{
// 用户选择"立即更新"
await UpdateRepo();
return false;
}
else if (result == Wpf.Ui.Controls.MessageBoxResult.Secondary)
{
// 用户选择"直接打开"
return true;
}
else
{
// 用户关闭对话框(点击 X 或按 ESC
return false;
}
}
[RelayCommand]
private async Task ResetRepo()
{
if (IsUpdating)
{
Toast.Warning("请等待当前更新完成后再进行重置操作。");
return;
}
// 添加确认对话框
var result = await ThemedMessageBox.ShowAsync(
"确定要重置脚本仓库吗?无法正常更新时候可以使用本功能,重置后请重新更新脚本仓库。",
"确认重置",
MessageBoxButton.YesNo,
ThemedMessageBox.MessageBoxIcon.Warning);
if (result == MessageBoxResult.Yes)
{
try
{
await ScriptRepoUpdater.Instance.ResetCurrentRepoAsync();
Toast.Success("脚本仓库已重置,请重新更新脚本仓库。");
}
catch (Exception ex)
{
Toast.Error($"重置失败: {ex.Message}");
}
}
}
/*
[RelayCommand]
private async Task DownloadOnlineRepo()
{
if (string.IsNullOrWhiteSpace(OnlineDownloadUrl))
{
Toast.Warning("请输入有效的下载地址。");
return;
}
if (IsUpdating)
{
Toast.Warning("请等待当前操作完成后再进行下载。");
return;
}
try
{
IsUpdating = true;
UpdateProgressValue = 0;
UpdateProgressText = "正在下载脚本仓库...";
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(10);
// 下载文件
var response = await httpClient.GetAsync(OnlineDownloadUrl);
response.EnsureSuccessStatusCode();
var tempZipPath = Path.Combine(Path.GetTempPath(), $"script_repo_{DateTime.Now:yyyyMMddHHmmss}.zip");
await using (var fileStream = File.Create(tempZipPath))
{
await response.Content.CopyToAsync(fileStream);
}
UpdateProgressText = "正在解压并导入...";
UpdateProgressValue = 50;
// 导入下载的zip文件
await ImportZipFile(tempZipPath);
// 清理临时文件
if (File.Exists(tempZipPath))
{
File.Delete(tempZipPath);
}
Toast.Success("在线脚本仓库下载并导入成功!");
}
catch (Exception ex)
{
Toast.Error($"下载失败: {ex.Message}");
}
finally
{
IsUpdating = false;
}
}*/
[RelayCommand]
private async Task ImportLocalScriptsRepoZip()
{
if (IsUpdating)
{
Toast.Warning("请等待当前操作完成后再进行导入。");
return;
}
try
{
var openFileDialog = new OpenFileDialog
{
Title = "选择脚本仓库压缩包",
Filter = "压缩包文件 (*.zip)|*.zip",
Multiselect = false
};
if (openFileDialog.ShowDialog() == true)
{
IsUpdating = true;
IsProgressIndeterminate = false; // 导入有实际百分比进度
UpdateProgressValue = 0;
UpdateProgressText = "正在导入脚本仓库,请耐心等待...";
await ScriptRepoUpdater.Instance.ImportLocalRepoZip(openFileDialog.FileName, (progress, text) =>
{
Dispatcher.Invoke(() =>
{
UpdateProgressValue = progress;
UpdateProgressText = text;
});
});
Toast.Success("脚本仓库导入成功!");
}
}
catch (Exception ex)
{
Toast.Error($"导入失败: {ex.Message}");
}
finally
{
IsUpdating = false;
}
}
[RelayCommand]
private async Task UpdateSubscribedScripts()
{
if (IsUpdating)
{
Toast.Warning("请等待当前操作完成后再进行更新。");
return;
}
try
{
IsUpdating = true;
IsProgressIndeterminate = true;
UpdateProgressValue = 0;
UpdateProgressText = "正在更新订阅脚本...";
// Task.Run 避免 SynchronizationContext 将 checkout/IO 调度回 UI 线程
await Task.Run(() => ScriptRepoUpdater.Instance.ManualUpdateSubscribedScripts());
}
catch (Exception ex)
{
Toast.Error($"更新订阅脚本失败: {ex.Message}");
}
finally
{
IsUpdating = false;
IsProgressIndeterminate = false;
}
}
/// <summary>
/// 处理超链接点击事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = e.Uri.AbsoluteUri,
UseShellExecute = true
});
}
catch (Exception ex)
{
ThemedMessageBox.Warning($"无法打开链接: {ex.Message}", "错误");
}
e.Handled = true;
}
/// <summary>
/// 处理 PasswordBox 的密码变化事件
/// </summary>
private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
{
if (sender is System.Windows.Controls.PasswordBox passwordBox)
{
// 更新 GitToken 属性,触发自动保存到凭据管理器
GitToken = passwordBox.Password;
}
}
}