From bc154a0f4091ba99915e56dddf3d9e739d592a67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:30:07 +0000 Subject: [PATCH 01/43] Initial plan From 8c7f1e2b8fd757cf53e5177ca0a57f378617a111 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:41:29 +0000 Subject: [PATCH 02/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E3=81=AE=E5=9F=BA=E6=9C=AC?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Freeesia/WindowTranslator/sessions/5fefad82-801f-4158-ad6f-9d7500bf50ed Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .../Modules/PluginStore/NuGetPluginService.cs | 393 ++++++++++++++++++ .../Modules/PluginStore/PluginStoreView.xaml | 252 +++++++++++ .../PluginStore/PluginStoreView.xaml.cs | 80 ++++ .../PluginStore/PluginStoreViewModel.cs | 264 ++++++++++++ .../Modules/Settings/AllSettingsDialog.xaml | 4 + .../Modules/Settings/AllSettingsViewModel.cs | 5 + WindowTranslator/Program.cs | 6 + .../Properties/Resources.Designer.cs | 311 +++++++++----- WindowTranslator/Properties/Resources.ar.resx | 51 +++ WindowTranslator/Properties/Resources.cs.resx | 51 +++ WindowTranslator/Properties/Resources.de.resx | 51 +++ WindowTranslator/Properties/Resources.en.resx | 51 +++ WindowTranslator/Properties/Resources.es.resx | 51 +++ WindowTranslator/Properties/Resources.fa.resx | 51 +++ .../Properties/Resources.fil.resx | 51 +++ WindowTranslator/Properties/Resources.fr.resx | 51 +++ WindowTranslator/Properties/Resources.hi.resx | 51 +++ WindowTranslator/Properties/Resources.id.resx | 51 +++ WindowTranslator/Properties/Resources.ko.resx | 51 +++ WindowTranslator/Properties/Resources.ms.resx | 51 +++ WindowTranslator/Properties/Resources.pl.resx | 51 +++ .../Properties/Resources.pt-BR.resx | 51 +++ WindowTranslator/Properties/Resources.resx | 51 +++ WindowTranslator/Properties/Resources.ru.resx | 51 +++ WindowTranslator/Properties/Resources.th.resx | 51 +++ WindowTranslator/Properties/Resources.tr.resx | 51 +++ WindowTranslator/Properties/Resources.vi.resx | 51 +++ .../Properties/Resources.zh-CN.resx | 51 +++ .../Properties/Resources.zh-TW.resx | 51 +++ 29 files changed, 2273 insertions(+), 113 deletions(-) create mode 100644 WindowTranslator/Modules/PluginStore/NuGetPluginService.cs create mode 100644 WindowTranslator/Modules/PluginStore/PluginStoreView.xaml create mode 100644 WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs create mode 100644 WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs new file mode 100644 index 00000000..3f0c81bb --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -0,0 +1,393 @@ +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGet V3 REST APIを使用してプラグインパッケージの検索・インストール・管理を行うサービスです。 +/// +public sealed class NuGetPluginService : IDisposable +{ + private const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; + private const string PluginTag = "windowtranslator-plugin"; + private const string NuGetFlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; + + private static readonly string UserPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); + private static readonly string ManifestPath = Path.Combine(UserPluginsDir, "nuget-manifest.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient httpClient; + private readonly ILogger logger; + private string? searchUrl; + + public NuGetPluginService(ILogger logger) + { + this.httpClient = new HttpClient(); + this.logger = logger; + } + + /// + /// NuGetでWindowTranslatorプラグインを検索します。 + /// + public async Task> SearchPackagesAsync(CancellationToken cancellationToken = default) + { + if (this.searchUrl is null) + { + this.searchUrl = await GetSearchUrlAsync(cancellationToken).ConfigureAwait(false); + } + + var url = $"{this.searchUrl}?q=tags:{PluginTag}&take=100&semVerLevel=2.0.0&prerelease=false"; + this.logger.LogDebug("NuGet検索URL: {Url}", url); + + var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("NuGet検索結果のデシリアライズに失敗しました。"); + + this.logger.LogInformation("NuGet検索完了: {Count}件のパッケージが見つかりました。", result.Data?.Length ?? 0); + + return result.Data?.Select(d => new NuGetPackageInfo( + Id: d.PackageId ?? string.Empty, + Version: d.Version ?? string.Empty, + Title: d.Title ?? d.PackageId ?? string.Empty, + Description: d.Description ?? string.Empty, + Authors: string.Join(", ", d.Authors ?? []), + ProjectUrl: d.ProjectUrl, + LicenseUrl: d.LicenseUrl + )).ToArray() ?? []; + } + + /// + /// 指定したNuGetパッケージをインストールします。 + /// + public async Task InstallPackageAsync(string packageId, string version, IProgress? progress = null, CancellationToken cancellationToken = default) + { + var packageIdLower = packageId.ToLowerInvariant(); + var versionLower = version.ToLowerInvariant(); + var nupkgUrl = $"{NuGetFlatContainerBase}/{packageIdLower}/{versionLower}/{packageIdLower}.{versionLower}.nupkg"; + + this.logger.LogInformation("パッケージをダウンロード中: {PackageId} {Version}", packageId, version); + + // 一時ディレクトリにダウンロード + var tempDir = Path.Combine(Path.GetTempPath(), "WindowTranslatorPlugins", packageId); + Directory.CreateDirectory(tempDir); + var tempNupkgPath = Path.Combine(tempDir, $"{packageIdLower}.{versionLower}.nupkg"); + + try + { + await DownloadFileAsync(nupkgUrl, tempNupkgPath, progress, cancellationToken).ConfigureAwait(false); + + // ターゲットディレクトリを準備 + var targetDir = Path.Combine(UserPluginsDir, packageId); + // 古いファイルをバックアップして削除する前に一時フォルダへ移動 + var backupDir = $"{targetDir}.backup"; + if (Directory.Exists(targetDir)) + { + if (Directory.Exists(backupDir)) + Directory.Delete(backupDir, recursive: true); + Directory.Move(targetDir, backupDir); + } + + Directory.CreateDirectory(targetDir); + + try + { + // nupkgを展開して必要なDLLをコピー + ExtractPluginDlls(tempNupkgPath, targetDir); + this.logger.LogInformation("パッケージの展開完了: {PackageId} -> {TargetDir}", packageId, targetDir); + } + catch + { + // 失敗したら元に戻す + Directory.Delete(targetDir, recursive: true); + if (Directory.Exists(backupDir)) + Directory.Move(backupDir, targetDir); + throw; + } + + // バックアップを削除 + if (Directory.Exists(backupDir)) + Directory.Delete(backupDir, recursive: true); + + // マニフェストを更新 + await UpdateManifestAsync(packageId, version, cancellationToken).ConfigureAwait(false); + } + finally + { + // 一時ファイルを削除 + try { File.Delete(tempNupkgPath); } catch { /* ignore */ } + } + } + + /// + /// 指定したパッケージをアンインストールします。(次回起動時に適用) + /// + public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) + { + this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + + var targetDir = Path.Combine(UserPluginsDir, packageId); + // 実行中のDLLがロックされている可能性があるため、削除マーカーを置く + var pendingDeleteMarker = Path.Combine(UserPluginsDir, $"{packageId}.pending-delete"); + await File.WriteAllTextAsync(pendingDeleteMarker, packageId, cancellationToken).ConfigureAwait(false); + + // マニフェストから削除 + await RemoveFromManifestAsync(packageId, cancellationToken).ConfigureAwait(false); + + this.logger.LogInformation("パッケージ {PackageId} をアンインストールキューに追加しました。再起動後に完全に削除されます。", packageId); + } + + /// + /// アプリ起動時にペンディング削除マーカーを処理します。 + /// + public void ProcessPendingDeletions() + { + if (!Directory.Exists(UserPluginsDir)) + return; + + foreach (var markerFile in Directory.GetFiles(UserPluginsDir, "*.pending-delete")) + { + try + { + var packageId = File.ReadAllText(markerFile); + var targetDir = Path.Combine(UserPluginsDir, packageId); + if (Directory.Exists(targetDir)) + { + Directory.Delete(targetDir, recursive: true); + this.logger.LogInformation("ペンディング削除を処理: {PackageId}", packageId); + } + File.Delete(markerFile); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "ペンディング削除の処理に失敗: {MarkerFile}", markerFile); + } + } + } + + /// + /// インストール済みのパッケージ一覧を取得します。 + /// + public async Task> GetInstalledPackagesAsync(CancellationToken cancellationToken = default) + { + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + return manifest.Packages; + } + + private async Task GetSearchUrlAsync(CancellationToken cancellationToken) + { + var response = await this.httpClient.GetAsync(NuGetServiceIndexUrl, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var index = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("NuGetサービスインデックスのデシリアライズに失敗しました。"); + + var searchEntry = index.Resources?.FirstOrDefault(r => r.Type == "SearchQueryService/3.5.0") + ?? index.Resources?.FirstOrDefault(r => r.Type?.StartsWith("SearchQueryService", StringComparison.Ordinal) == true) + ?? throw new InvalidOperationException("NuGet検索サービスURLが見つかりませんでした。"); + + return searchEntry.Id ?? throw new InvalidOperationException("NuGet検索サービスURLが空です。"); + } + + private async Task DownloadFileAsync(string url, string destPath, IProgress? progress, CancellationToken cancellationToken) + { + using var response = await this.httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? -1; + var downloadedBytes = 0L; + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var fileStream = File.Create(destPath); + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + downloadedBytes += bytesRead; + if (totalBytes > 0) + { + progress?.Report((double)downloadedBytes / totalBytes); + } + } + } + + private static void ExtractPluginDlls(string nupkgPath, string targetDir) + { + using var archive = ZipFile.OpenRead(nupkgPath); + + // 最適なTFMのlib/エントリを探す + var libEntries = archive.Entries + .Where(e => e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name) + && e.Name != "_._") + .ToList(); + + if (!libEntries.Any()) + { + throw new InvalidOperationException("パッケージにlib/フォルダが見つかりませんでした。"); + } + + // TFMを選択(net10.0-windows > net10.0 > net9.0-windows > net9.0 > ... の優先順位) + var tfmGroups = libEntries + .GroupBy(e => e.FullName.Split('/')[1]) + .ToList(); + + var selectedTfm = SelectBestTfm([.. tfmGroups.Select(g => g.Key)]); + if (selectedTfm is null) + { + throw new InvalidOperationException("互換性のあるターゲットフレームワークが見つかりませんでした。"); + } + + var selectedEntries = tfmGroups.First(g => g.Key == selectedTfm); + + foreach (var entry in selectedEntries) + { + var destPath = Path.Combine(targetDir, entry.Name); + entry.ExtractToFile(destPath, overwrite: true); + } + } + + private static string? SelectBestTfm(string[] tfms) + { + // TFMの優先度リスト(.NET 10から降順、Windows版を優先) + var orderedPrefixes = new[] + { + "net10.0-windows", + "net10.0", + "net9.0-windows", + "net9.0", + "net8.0-windows", + "net8.0", + "net7.0-windows", + "net7.0", + "net6.0-windows", + "net6.0", + "netstandard2.1", + "netstandard2.0", + }; + + foreach (var prefix in orderedPrefixes) + { + // 完全一致または前方一致(例: net10.0-windows10.0.20348.0) + var match = tfms.OrderByDescending(t => t).FirstOrDefault(t => + t.Equals(prefix, StringComparison.OrdinalIgnoreCase) + || t.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase) + || t.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase)); + if (match is not null) + return match; + } + + return tfms.FirstOrDefault(); + } + + private async Task UpdateManifestAsync(string packageId, string version, CancellationToken cancellationToken) + { + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var packages = manifest.Packages.ToList(); + var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + var newEntry = new InstalledPackageInfo(packageId, version, DateTime.UtcNow); + if (existing >= 0) + packages[existing] = newEntry; + else + packages.Add(newEntry); + + await SaveManifestAsync(new InstalledManifest([.. packages]), cancellationToken).ConfigureAwait(false); + } + + private async Task RemoveFromManifestAsync(string packageId, CancellationToken cancellationToken) + { + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var packages = manifest.Packages.Where(p => !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)).ToList(); + await SaveManifestAsync(new InstalledManifest([.. packages]), cancellationToken).ConfigureAwait(false); + } + + private async Task LoadManifestAsync(CancellationToken cancellationToken) + { + try + { + if (File.Exists(ManifestPath)) + { + using var fs = File.OpenRead(ManifestPath); + var manifest = await JsonSerializer.DeserializeAsync(fs, JsonOptions, cancellationToken).ConfigureAwait(false); + return manifest ?? new InstalledManifest([]); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "プラグインマニフェストの読み込みに失敗しました。"); + } + return new InstalledManifest([]); + } + + private static async Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) + { + Directory.CreateDirectory(UserPluginsDir); + using var fs = File.Create(ManifestPath); + await JsonSerializer.SerializeAsync(fs, manifest, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + this.httpClient.Dispose(); + } +} + +/// NuGetパッケージ情報 +public record NuGetPackageInfo( + string Id, + string Version, + string Title, + string Description, + string Authors, + string? ProjectUrl, + string? LicenseUrl +); + +/// インストール済みパッケージ情報 +public record InstalledPackageInfo( + string Id, + string Version, + DateTime InstalledAt +); + +/// インストール済みパッケージのマニフェスト +public record InstalledManifest(List Packages); + +// NuGet V3 API レスポンス型 +internal record NuGetServiceIndex( + [property: JsonPropertyName("resources")] NuGetServiceResource[]? Resources +); + +internal record NuGetServiceResource( + [property: JsonPropertyName("@id")] string? Id, + [property: JsonPropertyName("@type")] string? Type +); + +internal record NuGetSearchResponse( + [property: JsonPropertyName("totalHits")] int TotalHits, + [property: JsonPropertyName("data")] NuGetSearchData[]? Data +); + +internal record NuGetSearchData( + [property: JsonPropertyName("id")] string? PackageId, + [property: JsonPropertyName("version")] string? Version, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("authors")] string[]? Authors, + [property: JsonPropertyName("projectUrl")] string? ProjectUrl, + [property: JsonPropertyName("licenseUrl")] string? LicenseUrl +); diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml new file mode 100644 index 00000000..4b26ed4b --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs new file mode 100644 index 00000000..956ef27b --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// PluginStoreView.xaml の相互作用ロジック +/// +public partial class PluginStoreView +{ + private bool loaded; + + public PluginStoreView() + { + InitializeComponent(); + this.IsVisibleChanged += OnIsVisibleChanged; + } + + private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (this.loaded || !this.IsVisible) + return; + this.loaded = true; + if (this.DataContext is PluginStoreViewModel vm) + { + _ = vm.LoadCommand.ExecuteAsync(null); + } + } +} + +/// +/// null でない場合に true を返すコンバーター +/// +[ValueConversion(typeof(object), typeof(bool))] +public sealed class NotNullToBoolConverter : IValueConverter +{ + public static NotNullToBoolConverter Default { get; } = new(); + + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + => value is not null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// +/// null でない場合に Visible を返すコンバーター +/// +[ValueConversion(typeof(object), typeof(Visibility))] +public sealed class NotNullToVisibilityConverter : IValueConverter +{ + public static NotNullToVisibilityConverter Default { get; } = new(); + + public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) + => value is not null ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// +/// bool を反転するコンバーター(Visibility対応) +/// +[ValueConversion(typeof(bool), typeof(object))] +public sealed class InverseBoolConverter : IValueConverter +{ + public static InverseBoolConverter Default { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var inverseBool = value is bool b && !b; + if (parameter is string p && p == "Visibility") + return inverseBool ? Visibility.Visible : Visibility.Collapsed; + return inverseBool; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && !b; +} diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs new file mode 100644 index 00000000..527e005a --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -0,0 +1,264 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using WindowTranslator.Properties; +using Wpf.Ui; +using Wpf.Ui.Extensions; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// プラグインストアのViewModel +/// +public partial class PluginStoreViewModel : ObservableObject +{ + private readonly NuGetPluginService nugetService; + private readonly ILogger logger; + private readonly IContentDialogService dialogService; + private readonly ISnackbarService snackbarService; + + [ObservableProperty] + private bool isLoading; + + [ObservableProperty] + private string? errorMessage; + + [ObservableProperty] + private PluginPackageViewModel? selectedPackage; + + public ObservableCollection Packages { get; } = []; + + public PluginStoreViewModel( + NuGetPluginService nugetService, + ILogger logger, + IContentDialogService dialogService, + ISnackbarService snackbarService) + { + this.nugetService = nugetService; + this.logger = logger; + this.dialogService = dialogService; + this.snackbarService = snackbarService; + } + + /// + /// プラグイン一覧を読み込みます。 + /// + [RelayCommand] + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (this.IsLoading) + return; + + this.IsLoading = true; + this.ErrorMessage = null; + + try + { + var installed = await this.nugetService.GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(true); + var installedDict = installed.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase); + + var packages = await this.nugetService.SearchPackagesAsync(cancellationToken).ConfigureAwait(true); + this.logger.LogInformation("NuGetから{Count}件のプラグインパッケージを取得しました。", packages.Count); + + this.Packages.Clear(); + foreach (var pkg in packages) + { + installedDict.TryGetValue(pkg.Id, out var installedInfo); + var isInstalled = installedInfo is not null; + var installedVersion = installedInfo?.Version; + var isUpdateAvailable = isInstalled + && installedVersion is not null + && IsNewerVersion(pkg.Version, installedVersion); + + this.Packages.Add(new PluginPackageViewModel(pkg, isInstalled, installedVersion, isUpdateAvailable)); + } + + // インストール済みだがNuGetに見つからないパッケージも表示 + foreach (var inst in installed) + { + if (!this.Packages.Any(p => p.Id.Equals(inst.Id, StringComparison.OrdinalIgnoreCase))) + { + this.Packages.Add(new PluginPackageViewModel( + new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), + isInstalled: true, + installedVersion: inst.Version, + isUpdateAvailable: false)); + } + } + } + catch (OperationCanceledException) + { + // キャンセルは正常 + } + catch (Exception ex) + { + this.logger.LogError(ex, "NuGet検索に失敗しました。"); + this.ErrorMessage = Resources.NuGetSearchFailed; + } + finally + { + this.IsLoading = false; + } + } + + /// + /// プラグインをインストールまたは更新します。 + /// + [RelayCommand] + public async Task InstallAsync(PluginPackageViewModel package) + { + package.IsInstalling = true; + try + { + this.logger.LogInformation("プラグインのインストール開始: {PackageId} {Version}", package.Id, package.LatestVersion); + var progress = new Progress(v => package.InstallProgress = v); + await this.nugetService.InstallPackageAsync(package.Id, package.LatestVersion, progress).ConfigureAwait(true); + + package.IsInstalled = true; + package.InstalledVersion = package.LatestVersion; + package.IsUpdateAvailable = false; + package.InstallProgress = 0; + + this.logger.LogInformation("プラグインのインストール完了: {PackageId}", package.Id); + + // 再起動が必要な旨を表示 + await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = Resources.PluginInstallSuccess, + Content = Resources.RestartRequired, + CloseButtonText = Resources.Close, + }).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + // キャンセルは正常 + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインのインストールに失敗しました: {PackageId}", package.Id); + await this.dialogService.ShowAlertAsync( + Resources.PluginInstallFailed, + ex.Message, + Resources.Close).ConfigureAwait(true); + } + finally + { + package.IsInstalling = false; + } + } + + /// + /// プラグインをアンインストールします。 + /// + [RelayCommand] + public async Task UninstallAsync(PluginPackageViewModel package) + { + var result = await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = Resources.Uninstall, + Content = string.Format(Resources.UninstallConfirm, package.Title), + PrimaryButtonText = Resources.Uninstall, + CloseButtonText = Resources.Cancel, + }).ConfigureAwait(true); + + if (result != Wpf.Ui.Controls.ContentDialogResult.Primary) + return; + + try + { + await this.nugetService.UninstallPackageAsync(package.Id).ConfigureAwait(true); + package.IsInstalled = false; + package.InstalledVersion = null; + package.IsUpdateAvailable = false; + + await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = Resources.Uninstall, + Content = Resources.RestartRequired, + CloseButtonText = Resources.Close, + }).ConfigureAwait(true); + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインのアンインストールに失敗しました: {PackageId}", package.Id); + await this.dialogService.ShowAlertAsync( + Resources.Uninstall, + ex.Message, + Resources.Close).ConfigureAwait(true); + } + } + + private static bool IsNewerVersion(string latestVersion, string installedVersion) + { + try + { + return Version.Parse(latestVersion) > Version.Parse(installedVersion); + } + catch + { + return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; + } + } +} + +/// +/// プラグインパッケージの表示モデル +/// +public partial class PluginPackageViewModel : ObservableObject +{ + public string Id { get; } + public string Title { get; } + public string Description { get; } + public string Authors { get; } + public string LatestVersion { get; } + public string? ProjectUrl { get; } + public string? LicenseUrl { get; } + + [ObservableProperty] + private bool isInstalled; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + private string? installedVersion; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + private bool isUpdateAvailable; + + [ObservableProperty] + private bool isInstalling; + + [ObservableProperty] + private double installProgress; + + public string StatusText + { + get + { + if (this.IsUpdateAvailable && this.InstalledVersion is not null) + return string.Format(Properties.Resources.UpdateAvailableVersion, this.InstalledVersion, this.LatestVersion); + if (this.IsInstalled && this.InstalledVersion is not null) + return string.Format(Properties.Resources.InstalledVersion, this.InstalledVersion); + return string.Empty; + } + } + + public PluginPackageViewModel( + NuGetPackageInfo info, + bool isInstalled, + string? installedVersion, + bool isUpdateAvailable) + { + this.Id = info.Id; + this.Title = info.Title; + this.Description = info.Description; + this.Authors = info.Authors; + this.LatestVersion = info.Version; + this.ProjectUrl = info.ProjectUrl; + this.LicenseUrl = info.LicenseUrl; + this.isInstalled = isInstalled; + this.installedVersion = installedVersion; + this.isUpdateAvailable = isUpdateAvailable; + } +} diff --git a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml index 890a733b..8c2eb942 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml +++ b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml @@ -6,6 +6,7 @@ xmlns:data="clr-namespace:WindowTranslator.Data" xmlns:local="clr-namespace:WindowTranslator.Modules.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:pluginStore="clr-namespace:WindowTranslator.Modules.PluginStore" xmlns:properties="clr-namespace:WindowTranslator.Properties" xmlns:pt="http://propertytools.org/wpf" xmlns:root="clr-namespace:WindowTranslator" @@ -399,6 +400,9 @@ + + + diff --git a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs index 46d0641b..b7ecd468 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs +++ b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs @@ -19,6 +19,7 @@ using WindowTranslator.ComponentModel; using WindowTranslator.Extensions; using WindowTranslator.Modules.Main; +using WindowTranslator.Modules.PluginStore; using WindowTranslator.Properties; using WindowTranslator.Stores; using Wpf.Ui; @@ -103,6 +104,8 @@ sealed partial class AllSettingsViewModel : ObservableObject, IDisposable public bool IsVisibleReviewButton => this.reviewRequestService.CanOpenReview; + public PluginStoreViewModel PluginStore { get; } + public AllSettingsViewModel( [Inject] PluginProvider provider, [Inject] IOptionsSnapshot options, @@ -116,6 +119,7 @@ public AllSettingsViewModel( [Inject] IEnumerable validators, [Inject] IMainWindowModule mainWindowModule, [Inject] ILogger logger, + [Inject] PluginStoreViewModel pluginStoreViewModel, string target, bool? applyMode = null) { @@ -158,6 +162,7 @@ public AllSettingsViewModel( this.logger = logger; this.target = target; this.rootConfig = config as IConfigurationRoot; + this.PluginStore = pluginStoreViewModel; this.updateChecker.UpdateAvailable += UpdateChecker_UpdateAvailable; SetUpUpdateInfo(); this.isStartup = GetIsStartup(); diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index f2df1337..7296b8be 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -26,6 +26,7 @@ using WindowTranslator.Modules.ErrorReport; using WindowTranslator.Modules.LogView; using WindowTranslator.Modules.Main; +using WindowTranslator.Modules.PluginStore; using WindowTranslator.Modules.Settings; using WindowTranslator.Modules.Startup; using WindowTranslator.Modules.Validate; @@ -161,6 +162,8 @@ builder.Services.AddPresentation(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddTransient(); builder.Services.Configure(builder.Configuration, op => op.ErrorOnUnknownConfiguration = false); builder.Services.Configure(builder.Configuration.GetSection(nameof(UserSettings.Common))); builder.Services.AddTransient(typeof(IConfigureNamedOptions<>), typeof(ConfigurePluginParam<>)); @@ -185,6 +188,9 @@ e.Window.Activate(); }; +// 起動時にペンディング削除を処理する +app.Services.GetRequiredService().ProcessPendingDeletions(); + if (SentrySdk.IsEnabled) { app.Logger.LogInformation("Sentry is enabled."); diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 37d4cd4d..6ff2bf59 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -28,7 +28,7 @@ namespace WindowTranslator.Properties; /// -/// [JCYꂽȂǂ邽߂́AɌ^w肳ꂽ\[X NXłB +/// ローカライズされた文字列などを検索するための、厳密に型指定されたリソース クラスです。 /// // This class was auto-generated by a text template. // To add or remove a member, edit your .ResX file. @@ -45,15 +45,15 @@ internal Resources() { } /// - /// ̃NXŎgpĂLbVꂽ ResourceManager CX^XԂ܂B + /// このクラスで使用されているキャッシュされた ResourceManager インスタンスを返します。 /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Resources.ResourceManager ResourceManager => resourceMan ??= new CustomResourceManager("WindowTranslator.Properties.Resources", Assembly.GetExecutingAssembly()); /// - /// ׂĂɂ‚āÃ݂Xbh CurrentUICulture vpeBI[o[Ch܂ - /// ݂̃Xbh CurrentUICulture vpeBI[o[Ch܂B + /// すべてについて、現在のスレッドの CurrentUICulture プロパティをオーバーライドします + /// 現在のスレッドの CurrentUICulture プロパティをオーバーライドします。 /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Globalization.CultureInfo Culture @@ -63,542 +63,627 @@ internal Resources() { } /// - /// "̃Avɂ‚" ɗގĂ郍[JCYꂽ܂B + /// "このアプリについて" に類似しているローカライズされた文字列を検索します。 /// public static string About => ResourceManager.GetString("About", resourceCulture) ?? string.Empty; /// - /// "A" ɗގĂ郍[JCYꂽ܂B + /// "連絡先" に類似しているローカライズされた文字列を検索します。 /// public static string Address => ResourceManager.GetString("Address", resourceCulture) ?? string.Empty; /// - /// "Av" ɗގĂ郍[JCYꂽ܂B + /// "アプリ情報" に類似しているローカライズされた文字列を検索します。 /// public static string Application => ResourceManager.GetString("Application", resourceCulture) ?? string.Empty; /// - /// "Kp" ɗގĂ郍[JCYꂽ܂B + /// "適用" に類似しているローカライズされた文字列を検索します。 /// public static string Apply => ResourceManager.GetString("Apply", resourceCulture) ?? string.Empty; /// - /// "A^b`" ɗގĂ郍[JCYꂽ܂B + /// "アタッチ" に類似しているローカライズされた文字列を検索します。 /// public static string Attach => ResourceManager.GetString("Attach", resourceCulture) ?? string.Empty; /// - /// "A^b`" ɗގĂ郍[JCYꂽ܂B + /// "アタッチ中" に類似しているローカライズされた文字列を検索します。 /// public static string Attaching => ResourceManager.GetString("Attaching", resourceCulture) ?? string.Empty; /// - /// "XN[" ɗގĂ郍[JCYꂽ܂B + /// "自動スクロール" に類似しているローカライズされた文字列を検索します。 /// public static string AutoScroll => ResourceManager.GetString("AutoScroll", resourceCulture) ?? string.Empty; /// - /// "N" ɗގĂ郍[JCYꂽ܂B + /// "自動起動" に類似しているローカライズされた文字列を検索します。 /// public static string AutoStart => ResourceManager.GetString("AutoStart", resourceCulture) ?? string.Empty; /// - /// "PCNɎN" ɗގĂ郍[JCYꂽ܂B + /// "PC起動時に自動起動" に類似しているローカライズされた文字列を検索します。 /// public static string AutoStartWithPC => ResourceManager.GetString("AutoStartWithPC", resourceCulture) ?? string.Empty; /// - /// "|Ώ" ɗގĂ郍[JCYꂽ܂B + /// "自動翻訳対象" に類似しているローカライズされた文字列を検索します。 /// public static string AutoTargets => ResourceManager.GetString("AutoTargets", resourceCulture) ?? string.Empty; /// - /// "rh" ɗގĂ郍[JCYꂽ܂B + /// "ビルド日時" に類似しているローカライズされた文字列を検索します。 /// public static string BuildDate => ResourceManager.GetString("BuildDate", resourceCulture) ?? string.Empty; /// - /// "LbVW[" ɗގĂ郍[JCYꂽ܂B + /// "キャッシュモジュール" に類似しているローカライズされた文字列を検索します。 /// public static string CacheModule => ResourceManager.GetString("CacheModule", resourceCulture) ?? string.Empty; /// - /// "LbVݒ" ɗގĂ郍[JCYꂽ܂B + /// "キャッシュ設定" に類似しているローカライズされた文字列を検索します。 /// public static string CacheParam => ResourceManager.GetString("CacheParam", resourceCulture) ?? string.Empty; /// - /// "LZ" ɗގĂ郍[JCYꂽ܂B + /// "キャンセル" に類似しているローカライズされた文字列を検索します。 /// public static string Cancel => ResourceManager.GetString("Cancel", resourceCulture) ?? string.Empty; /// - /// "Lv`[EBhE" ɗގĂ郍[JCYꂽ܂B + /// "キャプチャーウィンドウ" に類似しているローカライズされた文字列を検索します。 /// public static string Capture => ResourceManager.GetString("Capture", resourceCulture) ?? string.Empty; /// - /// "Vo[W̃`FbN" ɗގĂ郍[JCYꂽ܂B + /// "新しいバージョンのチェック" に類似しているローカライズされた文字列を検索します。 /// public static string CheckNewVersion => ResourceManager.GetString("CheckNewVersion", resourceCulture) ?? string.Empty; /// - /// "XVe̊mF" ɗގĂ郍[JCYꂽ܂B + /// "更新内容の確認" に類似しているローカライズされた文字列を検索します。 /// public static string CheckUpdateNotes => ResourceManager.GetString("CheckUpdateNotes", resourceCulture) ?? string.Empty; /// - /// "NA" ɗގĂ郍[JCYꂽ܂B + /// "クリア" に類似しているローカライズされた文字列を検索します。 /// public static string Clear => ResourceManager.GetString("Clear", resourceCulture) ?? string.Empty; /// - /// "‚" ɗގĂ郍[JCYꂽ܂B + /// "閉じる" に類似しているローカライズされた文字列を検索します。 /// public static string Close => ResourceManager.GetString("Close", resourceCulture) ?? string.Empty; /// - /// "mF" ɗގĂ郍[JCYꂽ܂B + /// "確認" に類似しているローカライズされた文字列を検索します。 /// public static string Confirm => ResourceManager.GetString("Confirm", resourceCulture) ?? string.Empty; /// - /// "Rs[܂" ɗގĂ郍[JCYꂽ܂B + /// "コピーしました" に類似しているローカライズされた文字列を検索します。 /// public static string Copied => ResourceManager.GetString("Copied", resourceCulture) ?? string.Empty; /// - /// "Rs[" ɗގĂ郍[JCYꂽ܂B + /// "情報をコピー" に類似しているローカライズされた文字列を検索します。 /// public static string Copy => ResourceManager.GetString("Copy", resourceCulture) ?? string.Empty; /// - /// "ftHgݒ" ɗގĂ郍[JCYꂽ܂B + /// "デフォルト設定" に類似しているローカライズされた文字列を検索します。 /// public static string DefaultSetting => ResourceManager.GetString("DefaultSetting", resourceCulture) ?? string.Empty; /// - /// "f^b`" ɗގĂ郍[JCYꂽ܂B + /// "デタッチ" に類似しているローカライズされた文字列を検索します。 /// public static string Detach => ResourceManager.GetString("Detach", resourceCulture) ?? string.Empty; /// - /// "Zp" ɗގĂ郍[JCYꂽ܂B + /// "技術情報" に類似しているローカライズされた文字列を検索します。 /// public static string Develop => ResourceManager.GetString("Develop", resourceCulture) ?? string.Empty; /// - /// "J" ɗގĂ郍[JCYꂽ܂B + /// "開発者" に類似しているローカライズされた文字列を検索します。 /// public static string DevelopedBy => ResourceManager.GetString("DevelopedBy", resourceCulture) ?? string.Empty; /// - /// "ACR\" ɗގĂ郍[JCYꂽ܂B + /// "処理中アイコンを表示する" に類似しているローカライズされた文字列を検索します。 /// public static string DisplayBusy => ResourceManager.GetString("DisplayBusy", resourceCulture) ?? string.Empty; /// - /// "\@" ɗގĂ郍[JCYꂽ܂B + /// "表示方法" に類似しているローカライズされた文字列を検索します。 /// public static string DisplayMethod => ResourceManager.GetString("DisplayMethod", resourceCulture) ?? string.Empty; /// - /// "I" ɗގĂ郍[JCYꂽ܂B + /// "終了" に類似しているローカライズされた文字列を検索します。 /// public static string Exit => ResourceManager.GetString("Exit", resourceCulture) ?? string.Empty; /// - /// "GNX|[g" ɗގĂ郍[JCYꂽ܂B + /// "エクスポート" に類似しているローカライズされた文字列を検索します。 /// public static string Export => ResourceManager.GetString("Export", resourceCulture) ?? string.Empty; /// - /// "ÕGNX|[g" ɗގĂ郍[JCYꂽ܂B + /// "ログのエクスポート" に類似しているローカライズされた文字列を検索します。 /// public static string ExportLogs => ResourceManager.GetString("ExportLogs", resourceCulture) ?? string.Empty; /// - /// "GNX|[gs" ɗގĂ郍[JCYꂽ܂B + /// "エクスポート失敗" に類似しているローカライズされた文字列を検索します。 /// public static string ExportLogsFailed => ResourceManager.GetString("ExportLogsFailed", resourceCulture) ?? string.Empty; /// - /// "eLXgt@C" ɗގĂ郍[JCYꂽ܂B + /// "テキストファイル" に類似しているローカライズされた文字列を検索します。 /// public static string ExportLogsFilterText => ResourceManager.GetString("ExportLogsFilterText", resourceCulture) ?? string.Empty; /// - /// "GNX|[g" ɗގĂ郍[JCYꂽ܂B + /// "エクスポート完了" に類似しているローカライズされた文字列を検索します。 /// public static string ExportLogsSuccess => ResourceManager.GetString("ExportLogsSuccess", resourceCulture) ?? string.Empty; /// - /// "O`{0}`ɃGNX|[g܂B" ɗގĂ郍[JCYꂽ܂B + /// "ログを`{0}`にエクスポートしました。" に類似しているローカライズされた文字列を検索します。 /// public static string ExportLogsSuccessDetail => ResourceManager.GetString("ExportLogsSuccessDetail", resourceCulture) ?? string.Empty; /// - /// "ݒ̓KpɎs܂B" ɗގĂ郍[JCYꂽ܂B + /// "設定の適用に失敗しました。" に類似しているローカライズされた文字列を検索します。 /// public static string FaildApplySettings => ResourceManager.GetString("FaildApplySettings", resourceCulture) ?? string.Empty; /// - /// "OCRɎs܂" ɗގĂ郍[JCYꂽ܂B + /// "OCRに失敗しました" に類似しているローカライズされた文字列を検索します。 /// public static string FaildOcr => ResourceManager.GetString("FaildOcr", resourceCulture) ?? string.Empty; /// - /// "EBhE̖ߍ݂Ɏs܂B" ɗގĂ郍[JCYꂽ܂B + /// "ウィンドウの埋め込みに失敗しました。" に類似しているローカライズされた文字列を検索します。 /// public static string FaildOverlay => ResourceManager.GetString("FaildOverlay", resourceCulture) ?? string.Empty; /// - /// "|Ɏs܂" ɗގĂ郍[JCYꂽ܂B + /// "翻訳に失敗しました" に類似しているローカライズされた文字列を検索します。 /// public static string FaildTranslate => ResourceManager.GetString("FaildTranslate", resourceCulture) ?? string.Empty; /// - /// "߂eLXg臒l" ɗގĂ郍[JCYꂽ܂B + /// "近いテキストの閾値" に類似しているローカライズされた文字列を検索します。 /// public static string FuzzyMatchThreshold => ResourceManager.GetString("FuzzyMatchThreshold", resourceCulture) ?? string.Empty; /// - /// "Sʐݒ" ɗގĂ郍[JCYꂽ܂B + /// "全般設定" に類似しているローカライズされた文字列を検索します。 /// public static string GeneralSettings => ResourceManager.GetString("GeneralSettings", resourceCulture) ?? string.Empty; /// - /// "Abvf[g܂: {0}" ɗގĂ郍[JCYꂽ܂B + /// "アップデートがあります: {0}" に類似しているローカライズされた文字列を検索します。 /// public static string HasUpdate => ResourceManager.GetString("HasUpdate", resourceCulture) ?? string.Empty; /// - /// "ĂԂ" ɗގĂ郍[JCYꂽ܂B + /// "押している間だけ" に類似しているローカライズされた文字列を検索します。 /// public static string Hold => ResourceManager.GetString("Hold", resourceCulture) ?? string.Empty; /// - /// "LbV" ɗގĂ郍[JCYꂽ܂B + /// "メモリ内キャッシュ" に類似しているローカライズされた文字列を検索します。 /// public static string InMemoryCache => ResourceManager.GetString("InMemoryCache", resourceCulture) ?? string.Empty; /// - /// "Vo[W: {0} ̃CXg[" ɗގĂ郍[JCYꂽ܂B + /// "インストール" に類似しているローカライズされた文字列を検索します。 + /// + public static string Install => ResourceManager.GetString("Install", resourceCulture) ?? string.Empty; + + /// + /// "インストール済み" に類似しているローカライズされた文字列を検索します。 + /// + public static string Installed => ResourceManager.GetString("Installed", resourceCulture) ?? string.Empty; + + /// + /// "インストール済み: {0}" に類似しているローカライズされた文字列を検索します。 + /// + public static string InstalledVersion => ResourceManager.GetString("InstalledVersion", resourceCulture) ?? string.Empty; + + /// + /// "インストール済みバージョン" に類似しているローカライズされた文字列を検索します。 + /// + public static string InstalledVersionLabel => ResourceManager.GetString("InstalledVersionLabel", resourceCulture) ?? string.Empty; + + /// + /// "新しいバージョン: {0} のインストール" に類似しているローカライズされた文字列を検索します。 /// public static string InstallNewVersion => ResourceManager.GetString("InstallNewVersion", resourceCulture) ?? string.Empty; /// - /// "{0}: ݒ茟؃G[" ɗގĂ郍[JCYꂽ܂B + /// "{0}: 設定検証エラー" に類似しているローカライズされた文字列を検索します。 /// public static string InvalidSettings => ResourceManager.GetString("InvalidSettings", resourceCulture) ?? string.Empty; /// - /// ":tired-face: **̂܂܎sĂ삵Ȃ”\ł**&#13;&a..." ɗގĂ郍[JCYꂽ܂B + /// ":tired-face: **そのまま実行しても動作しない可能性が高いです**&#10;**..." に類似しているローカライズされた文字列を検索します。 /// public static string InvalidSettingsContent => ResourceManager.GetString("InvalidSettingsContent", resourceCulture) ?? string.Empty; /// - /// "x|ΏۂɑIvZXNƂɎIɖ|󂷂" ɗގĂ郍[JCYꂽ܂B + /// "一度翻訳対象に選択したプロセスが起動したときに自動的に翻訳する" に類似しているローカライズされた文字列を検索します。 /// public static string IsEnableAutoTarget => ResourceManager.GetString("IsEnableAutoTarget", resourceCulture) ?? string.Empty; /// - /// "I[o[C\Lv`[”\ɂ" ɗގĂ郍[JCYꂽ܂B + /// "オーバーレイ表示をキャプチャー可能にする" に類似しているローカライズされた文字列を検索します。 /// public static string IsEnableCaptureOverlay => ResourceManager.GetString("IsEnableCaptureOverlay", resourceCulture) ?? string.Empty; /// - /// "ŐVo[WpłB" ɗގĂ郍[JCYꂽ܂B + /// "最新バージョンをご利用中です。" に類似しているローカライズされた文字列を検索します。 /// public static string IsLatest => ResourceManager.GetString("IsLatest", resourceCulture) ?? string.Empty; /// - /// "I[oCLɂ^C~Ôݖ|󂷂" ɗގĂ郍[JCYꂽ܂B + /// "オーバレイを有効にしたタイミングのみ翻訳する" に類似しているローカライズされた文字列を検索します。 /// public static string IsOneShotMode => ResourceManager.GetString("IsOneShotMode", resourceCulture) ?? string.Empty; /// - /// "}EX|C^[ʒũeLXĝ݃I[oC|\" ɗގĂ郍[JCYꂽ܂B + /// "マウスポインター位置のテキストのみオーバレイ翻訳を表示する" に類似しているローカライズされた文字列を検索します。 /// public static string IsOverlayPointSwap => ResourceManager.GetString("IsOverlayPointSwap", resourceCulture) ?? string.Empty; /// - /// "ݒ" ɗގĂ郍[JCYꂽ܂B + /// "言語設定" に類似しているローカライズされた文字列を検索します。 /// public static string Language => ResourceManager.GetString("Language", resourceCulture) ?? string.Empty; /// - /// "CZX" ɗގĂ郍[JCYꂽ܂B + /// "最新バージョン" に類似しているローカライズされた文字列を検索します。 + /// + public static string LatestVersion => ResourceManager.GetString("LatestVersion", resourceCulture) ?? string.Empty; + + /// + /// "ライセンス" に類似しているローカライズされた文字列を検索します。 /// public static string License => ResourceManager.GetString("License", resourceCulture) ?? string.Empty; /// - /// "[Jt@CLbV" ɗގĂ郍[JCYꂽ܂B + /// "ライセンス情報" に類似しているローカライズされた文字列を検索します。 + /// + public static string LicenseUrl => ResourceManager.GetString("LicenseUrl", resourceCulture) ?? string.Empty; + + /// + /// "ローカルファイルキャッシュ" に類似しているローカライズされた文字列を検索します。 /// public static string LocalCache => ResourceManager.GetString("LocalCache", resourceCulture) ?? string.Empty; /// - /// "O" ɗގĂ郍[JCYꂽ܂B + /// "ログ" に類似しているローカライズされた文字列を検索します。 /// public static string Log => ResourceManager.GetString("Log", resourceCulture) ?? string.Empty; /// - /// "̑" ɗގĂ郍[JCYꂽ܂B + /// "その他" に類似しているローカライズされた文字列を検索します。 /// public static string Misc => ResourceManager.GetString("Misc", resourceCulture) ?? string.Empty; /// - /// "łWindowTranslatorNł" ɗގĂ郍[JCYꂽ܂B + /// "すでにWindowTranslatorが起動中です" に類似しているローカライズされた文字列を検索します。 /// public static string MutexError => ResourceManager.GetString("MutexError", resourceCulture) ?? string.Empty; /// - /// "Vo[W: {0} [X܂" ɗގĂ郍[JCYꂽ܂B + /// "新しいバージョン: {0} がリリースされました" に類似しているローカライズされた文字列を検索します。 /// public static string NewVersionAvailable => ResourceManager.GetString("NewVersionAvailable", resourceCulture) ?? string.Empty; /// - /// "LbVȂ" ɗގĂ郍[JCYꂽ܂B + /// "キャッシュしない" に類似しているローカライズされた文字列を検索します。 /// public static string NoCache => ResourceManager.GetString("NoCache", resourceCulture) ?? string.Empty; /// - /// "|󂵂Ȃ" ɗގĂ郍[JCYꂽ܂B + /// "翻訳しない" に類似しているローカライズされた文字列を検索します。 /// public static string NoTranslateModule => ResourceManager.GetString("NoTranslateModule", resourceCulture) ?? string.Empty; /// - /// "{0}OCR@\g܂BΏۂ̌@\CXg[Ă" ɗގĂ郍[JCYꂽ܂B + /// "NuGetからのプラグイン一覧の取得に失敗しました。ネットワーク接続を確認してください。" に類似しているローカライズされた文字列を検索します。 + /// + public static string NuGetSearchFailed => ResourceManager.GetString("NuGetSearchFailed", resourceCulture) ?? string.Empty; + + /// + /// "{0}のOCR機能が使えません。対象の言語機能をインストールしてください" に類似しているローカライズされた文字列を検索します。 /// public static string OcrLanguageNotAvailable => ResourceManager.GetString("OcrLanguageNotAvailable", resourceCulture) ?? string.Empty; /// - /// "FW[" ɗގĂ郍[JCYꂽ܂B + /// "認識モジュール" に類似しているローカライズされた文字列を検索します。 /// public static string OcrModule => ResourceManager.GetString("OcrModule", resourceCulture) ?? string.Empty; /// - /// "OK" ɗގĂ郍[JCYꂽ܂B + /// "OK" に類似しているローカライズされた文字列を検索します。 /// public static string OK => ResourceManager.GetString("OK", resourceCulture) ?? string.Empty; /// - /// "ڍ׏̊mF" ɗގĂ郍[JCYꂽ܂B + /// "詳細情報の確認" に類似しているローカライズされた文字列を検索します。 /// public static string OpenChangelogCommand => ResourceManager.GetString("OpenChangelogCommand", resourceCulture) ?? string.Empty; /// - /// "T[hp[eB[CZX̊mF" ɗގĂ郍[JCYꂽ܂B + /// "サードパーティーライセンスの確認" に類似しているローカライズされた文字列を検索します。 /// public static string OpenThirdPartyLicensesCommand => ResourceManager.GetString("OpenThirdPartyLicensesCommand", resourceCulture) ?? string.Empty; /// - /// "I[oC" ɗގĂ郍[JCYꂽ܂B + /// "オーバレイ" に類似しているローカライズされた文字列を検索します。 /// public static string Overlay => ResourceManager.GetString("Overlay", resourceCulture) ?? string.Empty; /// - /// "I[o[Cwi̕sx" ɗގĂ郍[JCYꂽ܂B + /// "オーバーレイ背景の不透明度" に類似しているローカライズされた文字列を検索します。 /// public static string OverlayOpacity => ResourceManager.GetString("OverlayOpacity", resourceCulture) ?? string.Empty; /// - /// "I[o[C؂ւ" ɗގĂ郍[JCYꂽ܂B + /// "オーバーレイ切り替え" に類似しているローカライズされた文字列を検索します。 /// public static string OverlayShortcut => ResourceManager.GetString("OverlayShortcut", resourceCulture) ?? string.Empty; /// - /// "I[o[C\̐؂ւ" ɗގĂ郍[JCYꂽ܂B + /// "オーバーレイ表示の切り替え" に類似しているローカライズされた文字列を検索します。 /// public static string OverlaySwitch => ResourceManager.GetString("OverlaySwitch", resourceCulture) ?? string.Empty; /// - /// "vOCݒ" ɗގĂ郍[JCYꂽ܂B + /// "プラグイン設定" に類似しているローカライズされた文字列を検索します。 /// public static string Plugin => ResourceManager.GetString("Plugin", resourceCulture) ?? string.Empty; /// - /// "Jy[W" ɗގĂ郍[JCYꂽ܂B + /// "インストール失敗" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginInstallFailed => ResourceManager.GetString("PluginInstallFailed", resourceCulture) ?? string.Empty; + + /// + /// "インストール完了" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; + + /// + /// "プラグインストア" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; + + /// + /// "プロジェクトページ" に類似しているローカライズされた文字列を検索します。 + /// + public static string ProjectUrl => ResourceManager.GetString("ProjectUrl", resourceCulture) ?? string.Empty; + + /// + /// "公開ページ" に類似しているローカライズされた文字列を検索します。 /// public static string PublishPage => ResourceManager.GetString("PublishPage", resourceCulture) ?? string.Empty; /// - /// "{0}Nɓo^܂B" ɗގĂ郍[JCYꂽ܂B + /// "{0}を自動起動に登録しました。" に類似しているローカライズされた文字列を検索します。 /// public static string RegisterAutoStart => ResourceManager.GetString("RegisterAutoStart", resourceCulture) ?? string.Empty; /// - /// "" ɗގĂ郍[JCYꂽ܂B + /// "プラグインの変更を適用するには、WindowTranslatorを再起動してください。" に類似しているローカライズされた文字列を検索します。 + /// + public static string RestartRequired => ResourceManager.GetString("RestartRequired", resourceCulture) ?? string.Empty; + + /// + /// "後で" に類似しているローカライズされた文字列を検索します。 /// public static string ReviewLater => ResourceManager.GetString("ReviewLater", resourceCulture) ?? string.Empty; /// - /// "xƕ\Ȃ" ɗގĂ郍[JCYꂽ܂B + /// "二度と表示しない" に類似しているローカライズされた文字列を検索します。 /// public static string ReviewNeverShowAgain => ResourceManager.GetString("ReviewNeverShowAgain", resourceCulture) ?? string.Empty; /// - /// "r[̂肢" ɗގĂ郍[JCYꂽ܂B + /// "レビューのお願い" に類似しているローカライズされた文字列を検索します。 /// public static string ReviewRequest => ResourceManager.GetString("ReviewRequest", resourceCulture) ?? string.Empty; /// - /// "WindowTranslatorp肪Ƃ܂BMicrosoft Store..." ɗގĂ郍[JCYꂽ܂B + /// "WindowTranslatorをご利用いただきありがとうございます。Microsoft Store..." に類似しているローカライズされた文字列を検索します。 /// public static string ReviewRequestMessage => ResourceManager.GetString("ReviewRequestMessage", resourceCulture) ?? string.Empty; /// - /// "̂܂܎s" ɗގĂ郍[JCYꂽ܂B + /// "そのまま実行" に類似しているローカライズされた文字列を検索します。 /// public static string RunAsIs => ResourceManager.GetString("RunAsIs", resourceCulture) ?? string.Empty; /// - /// "|󌳌Ɩ|挾ꂪłBقȂ錾w肵ĂB" ɗގĂ郍[JCYꂽ܂B + /// "翻訳元言語と翻訳先言語が同一です。異なる言語を指定してください。" に類似しているローカライズされた文字列を検索します。 /// public static string SameSourceTargetLanguage => ResourceManager.GetString("SameSourceTargetLanguage", resourceCulture) ?? string.Empty; /// - /// "ۑĕ‚" ɗގĂ郍[JCYꂽ܂B + /// "保存して閉じる" に類似しているローカライズされた文字列を検索します。 /// public static string SaveAndClose => ResourceManager.GetString("SaveAndClose", resourceCulture) ?? string.Empty; /// - /// "WindowTranslatorȊÕEBhEIĂ" ɗގĂ郍[JCYꂽ܂B + /// "WindowTranslator以外のウィンドウを選択してください" に類似しているローカライズされた文字列を検索します。 /// public static string SelectOtherWindow => ResourceManager.GetString("SelectOtherWindow", resourceCulture) ?? string.Empty; /// - /// "G[|[gVXeɑM܂Bȉ̏񂪑M܂B&#13;&#1..." ɗގĂ郍[JCYꂽ܂B + /// "エラー情報をレポートシステムに送信します。以下の情報が送信されます。&#10;* アプリ情報..." に類似しているローカライズされた文字列を検索します。 /// public static string SendReportToolTip => ResourceManager.GetString("SendReportToolTip", resourceCulture) ?? string.Empty; /// - /// "𑗐M" ɗގĂ郍[JCYꂽ܂B + /// "情報を送信" に類似しているローカライズされた文字列を検索します。 /// public static string SendRerpot => ResourceManager.GetString("SendRerpot", resourceCulture) ?? string.Empty; /// - /// "M" ɗގĂ郍[JCYꂽ܂B + /// "送信完了" に類似しているローカライズされた文字列を検索します。 /// public static string Sent => ResourceManager.GetString("Sent", resourceCulture) ?? string.Empty; /// - /// ":tired-face: **̂܂ܕۑĂ삵܂B**&#13;&#10..." ɗގĂ郍[JCYꂽ܂B + /// ":tired-face: **このまま保存しても動作しません。**&#10;***&..." に類似しているローカライズされた文字列を検索します。 /// public static string SettingInvalidContent => ResourceManager.GetString("SettingInvalidContent", resourceCulture) ?? string.Empty; /// - /// "ݒ" ɗގĂ郍[JCYꂽ܂B + /// "設定" に類似しているローカライズされた文字列を検索します。 /// public static string Settings => ResourceManager.GetString("Settings", resourceCulture) ?? string.Empty; /// - /// "ݒ茟؃G[" ɗގĂ郍[JCYꂽ܂B + /// "設定検証エラー" に類似しているローカライズされた文字列を検索します。 /// public static string SettingsInvalid => ResourceManager.GetString("SettingsInvalid", resourceCulture) ?? string.Empty; /// - /// "S̐ݒ" ɗގĂ郍[JCYꂽ܂B + /// "全体設定" に類似しているローカライズされた文字列を検索します。 /// public static string SettingsViewModel => ResourceManager.GetString("SettingsViewModel", resourceCulture) ?? string.Empty; /// - /// "V[gJbg" ɗގĂ郍[JCYꂽ܂B + /// "ショートカット" に類似しているローカライズされた文字列を検索します。 /// public static string Shortcut => ResourceManager.GetString("Shortcut", resourceCulture) ?? string.Empty; /// - /// "|(F)" ɗގĂ郍[JCYꂽ܂B + /// "翻訳元(認識)言語" に類似しているローカライズされた文字列を検索します。 /// public static string Source => ResourceManager.GetString("Source", resourceCulture) ?? string.Empty; /// - /// "SteamŃQ[Mtg" ɗގĂ郍[JCYꂽ܂B + /// "Steamでゲームをギフト" に類似しているローカライズされた文字列を検索します。 /// public static string SteamWishlist => ResourceManager.GetString("SteamWishlist", resourceCulture) ?? string.Empty; /// - /// "M" ɗގĂ郍[JCYꂽ܂B + /// "送信" に類似しているローカライズされた文字列を検索します。 /// public static string Submit => ResourceManager.GetString("Submit", resourceCulture) ?? string.Empty; /// - /// "|(\)" ɗގĂ郍[JCYꂽ܂B + /// "翻訳先(表示)言語" に類似しているローカライズされた文字列を検索します。 /// public static string Target => ResourceManager.GetString("Target", resourceCulture) ?? string.Empty; /// - /// "|ΏۃvZX" ɗގĂ郍[JCYꂽ܂B + /// "翻訳対象プロセス" に類似しているローカライズされた文字列を検索します。 /// public static string TargetProcesses => ResourceManager.GetString("TargetProcesses", resourceCulture) ?? string.Empty; /// - /// "ΏۂƂ̐ݒ" ɗގĂ郍[JCYꂽ܂B + /// "対象ごとの設定" に類似しているローカライズされた文字列を検索します。 /// public static string TargetSpecificSettings => ResourceManager.GetString("TargetSpecificSettings", resourceCulture) ?? string.Empty; /// - /// "Av" ɗގĂ郍[JCYꂽ܂B + /// "アプリ名" に類似しているローカライズされた文字列を検索します。 /// public static string Title => ResourceManager.GetString("Title", resourceCulture) ?? string.Empty; /// - /// "ON/OFF؂ւ" ɗގĂ郍[JCYꂽ܂B + /// "押してON/OFFを切り替える" に類似しているローカライズされた文字列を検索します。 /// public static string Toggle => ResourceManager.GetString("Toggle", resourceCulture) ?? string.Empty; /// - /// "ݒ" ɗގĂ郍[JCYꂽ܂B + /// "言語設定" に類似しているローカライズされた文字列を検索します。 /// public static string TranslateLanguage => ResourceManager.GetString("TranslateLanguage", resourceCulture) ?? string.Empty; /// - /// "|󃂃W[" ɗގĂ郍[JCYꂽ܂B + /// "翻訳モジュール" に類似しているローカライズされた文字列を検索します。 /// public static string TranslateModule => ResourceManager.GetString("TranslateModule", resourceCulture) ?? string.Empty; /// - /// "sȃG[܂" ɗގĂ郍[JCYꂽ܂B + /// "不明なエラーが発生しました" に類似しているローカライズされた文字列を検索します。 /// public static string UnhundledErrorMessage => ResourceManager.GetString("UnhundledErrorMessage", resourceCulture) ?? string.Empty; /// - /// "IEBhEu{0}v̓vZXłȂ߁ALv`[o܂B&#13;..." ɗގĂ郍[JCYꂽ܂B + /// "アンインストール" に類似しているローカライズされた文字列を検索します。 + /// + public static string Uninstall => ResourceManager.GetString("Uninstall", resourceCulture) ?? string.Empty; + + /// + /// "{0} をアンインストールしますか?次回起動時に完全に削除されます。" に類似しているローカライズされた文字列を検索します。 + /// + public static string UninstallConfirm => ResourceManager.GetString("UninstallConfirm", resourceCulture) ?? string.Empty; + + /// + /// "選択したウィンドウ「{0}」はプロセスを特定できないため、キャプチャー出来ません。&#10;..." に類似しているローカライズされた文字列を検索します。 /// public static string UnknownWindow => ResourceManager.GetString("UnknownWindow", resourceCulture) ?? string.Empty; /// - /// "{0}̎N܂B" ɗގĂ郍[JCYꂽ܂B + /// "{0}の自動起動を解除しました。" に類似しているローカライズされた文字列を検索します。 /// public static string UnregisterAutoStart => ResourceManager.GetString("UnregisterAutoStart", resourceCulture) ?? string.Empty; /// - /// "ŐVo[WɍXV" ɗގĂ郍[JCYꂽ܂B + /// "更新" に類似しているローカライズされた文字列を検索します。 + /// + public static string Update => ResourceManager.GetString("Update", resourceCulture) ?? string.Empty; + + /// + /// "更新あり" に類似しているローカライズされた文字列を検索します。 + /// + public static string UpdateAvailable => ResourceManager.GetString("UpdateAvailable", resourceCulture) ?? string.Empty; + + /// + /// "インストール済み: {0} → 最新: {1}" に類似しているローカライズされた文字列を検索します。 + /// + public static string UpdateAvailableVersion => ResourceManager.GetString("UpdateAvailableVersion", resourceCulture) ?? string.Empty; + + /// + /// "最新バージョンに更新" に類似しているローカライズされた文字列を検索します。 /// public static string UpdateCommand => ResourceManager.GetString("UpdateCommand", resourceCulture) ?? string.Empty; /// - /// "XV" ɗގĂ郍[JCYꂽ܂B + /// "更新情報" に類似しているローカライズされた文字列を検索します。 /// public static string UpdateInfo => ResourceManager.GetString("UpdateInfo", resourceCulture) ?? string.Empty; /// - /// "o[W" ɗގĂ郍[JCYꂽ܂B + /// "バージョン" に類似しているローカライズされた文字列を検索します。 /// public static string Version => ResourceManager.GetString("Version", resourceCulture) ?? string.Empty; /// - /// "|󌋉ʕ\[h" ɗގĂ郍[JCYꂽ܂B + /// "翻訳結果表示モード" に類似しているローカライズされた文字列を検索します。 /// public static string ViewMode => ResourceManager.GetString("ViewMode", resourceCulture) ?? string.Empty; /// - /// "WindowsWF" ɗގĂ郍[JCYꂽ܂B + /// "Windows標準文字認識" に類似しているローカライズされた文字列を検索します。 /// public static string WindowsMediaOcr => ResourceManager.GetString("WindowsMediaOcr", resourceCulture) ?? string.Empty; /// - /// "r[" ɗގĂ郍[JCYꂽ܂B + /// "レビューする" に類似しているローカライズされた文字列を検索します。 /// public static string WriteReview => ResourceManager.GetString("WriteReview", resourceCulture) ?? string.Empty; } diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 63af0458..dba7cf5a 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -447,4 +447,55 @@ + + متجر المكونات الإضافية + + + تثبيت + + + مثبت + + + تحديث + + + إلغاء التثبيت + + + هل أنت متأكد من رغبتك في إلغاء تثبيت {0}؟ سيتم حذفه بالكامل عند التشغيل التالي. + + + يتوفر تحديث + + + مثبت: {0} → الأحدث: {1} + + + مثبت: {0} + + + الإصدار المثبت + + + أحدث إصدار + + + فشل استرداد قائمة المكونات الإضافية من NuGet. يرجى التحقق من اتصالك بالشبكة. + + + اكتمل التثبيت + + + فشل التثبيت + + + يرجى إعادة تشغيل WindowTranslator لتطبيق تغييرات المكون الإضافي. + + + صفحة المشروع + + + معلومات الترخيص + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 83b05eb1..69ba7493 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -343,4 +343,55 @@ Monitory nejsou podporovány. + + Obchod s pluginy + + + Nainstalovat + + + Nainstalováno + + + Aktualizovat + + + Odinstalovat + + + Opravdu chcete odinstalovat {0}? Bude zcela odstraněn při příštím spuštění. + + + Dostupná aktualizace + + + Nainstalováno: {0} → Nejnovější: {1} + + + Nainstalováno: {0} + + + Nainstalovaná verze + + + Nejnovější verze + + + Nepodařilo se načíst seznam pluginů z NuGet. Zkontrolujte připojení k síti. + + + Instalace dokončena + + + Instalace se nezdařila + + + Restartujte WindowTranslator, aby se změny pluginu projevily. + + + Stránka projektu + + + Informace o licenci + diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 00177fe2..0c653608 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -456,4 +456,55 @@ Monitore werden nicht unterstützt. + + Plugin-Store + + + Installieren + + + Installiert + + + Aktualisieren + + + Deinstallieren + + + Möchten Sie {0} wirklich deinstallieren? Es wird beim nächsten Start vollständig entfernt. + + + Update verfügbar + + + Installiert: {0} → Aktuell: {1} + + + Installiert: {0} + + + Installierte Version + + + Neueste Version + + + Fehler beim Abrufen der Plugin-Liste von NuGet. Bitte überprüfen Sie Ihre Netzwerkverbindung. + + + Installation abgeschlossen + + + Installation fehlgeschlagen + + + Bitte starten Sie WindowTranslator neu, um Plugin-Änderungen anzuwenden. + + + Projektseite + + + Lizenzinformationen + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 74c0acfa..7f9058d2 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -456,4 +456,55 @@ Monitors are not supported. + + Plugin Store + + + Install + + + Installed + + + Update + + + Uninstall + + + Are you sure you want to uninstall {0}? It will be fully removed on next startup. + + + Update available + + + Installed: {0} → Latest: {1} + + + Installed: {0} + + + Installed version + + + Latest version + + + Failed to retrieve plugin list from NuGet. Please check your network connection. + + + Installation complete + + + Installation failed + + + Please restart WindowTranslator to apply plugin changes. + + + Project page + + + License information + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index d22a3c3a..7521e7a6 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -447,4 +447,55 @@ + + Tienda de plugins + + + Instalar + + + Instalado + + + Actualizar + + + Desinstalar + + + ¿Está seguro de que desea desinstalar {0}? Se eliminará completamente en el próximo inicio. + + + Actualización disponible + + + Instalado: {0} → Último: {1} + + + Instalado: {0} + + + Versión instalada + + + Última versión + + + Error al recuperar la lista de plugins de NuGet. Compruebe su conexión de red. + + + Instalación completada + + + Error de instalación + + + Reinicie WindowTranslator para aplicar los cambios del plugin. + + + Página del proyecto + + + Información de licencia + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index ce535156..0b3838f9 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -447,4 +447,55 @@ + + فروشگاه افزونه + + + نصب + + + نصب شده + + + به‌روزرسانی + + + حذف + + + آیا مطمئن هستید که می‌خواهید {0} را حذف کنید؟ در راه‌اندازی بعدی به طور کامل حذف خواهد شد. + + + به‌روزرسانی موجود است + + + نصب شده: {0} → جدیدترین: {1} + + + نصب شده: {0} + + + نسخه نصب شده + + + جدیدترین نسخه + + + دریافت لیست افزونه از NuGet ناموفق بود. لطفاً اتصال شبکه خود را بررسی کنید. + + + نصب کامل شد + + + نصب ناموفق بود + + + لطفاً WindowTranslator را مجدداً راه‌اندازی کنید تا تغییرات افزونه اعمال شود. + + + صفحه پروژه + + + اطلاعات مجوز + diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 657dde0d..7ac38b90 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -456,4 +456,55 @@ Ang monitor ay hindi suportado. + + Plugin Store + + + I-install + + + Naka-install + + + I-update + + + I-uninstall + + + Sigurado ka bang gusto mong i-uninstall ang {0}? Ito ay ganap na matatanggal sa susunod na pagsisimula. + + + Available ang update + + + Naka-install: {0} → Pinakabago: {1} + + + Naka-install: {0} + + + Naka-install na bersyon + + + Pinakabagong bersyon + + + Nabigo sa pagkuha ng listahan ng plugin mula sa NuGet. Pakisuri ang iyong koneksyon sa network. + + + Natapos ang pag-install + + + Nabigo ang pag-install + + + Mangyaring i-restart ang WindowTranslator upang mailapat ang mga pagbabago sa plugin. + + + Pahina ng proyekto + + + Impormasyon ng lisensya + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index f4c7038c..258d72e0 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -447,4 +447,55 @@ + + Boutique de plugins + + + Installer + + + Installé + + + Mettre à jour + + + Désinstaller + + + Voulez-vous vraiment désinstaller {0} ? Il sera complètement supprimé au prochain démarrage. + + + Mise à jour disponible + + + Installé : {0} → Dernier : {1} + + + Installé : {0} + + + Version installée + + + Dernière version + + + Échec de la récupération de la liste des plugins depuis NuGet. Vérifiez votre connexion réseau. + + + Installation terminée + + + Échec de l'installation + + + Veuillez redémarrer WindowTranslator pour appliquer les modifications de plugin. + + + Page du projet + + + Informations de licence + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index e93cc645..ba7b6383 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -455,4 +455,55 @@ + + Plugin Store + + + Install + + + Installed + + + Update + + + Uninstall + + + Are you sure you want to uninstall {0}? It will be fully removed on next startup. + + + Update available + + + Installed: {0} → Latest: {1} + + + Installed: {0} + + + Installed version + + + Latest version + + + Failed to retrieve plugin list from NuGet. Please check your network connection. + + + Installation complete + + + Installation failed + + + Please restart WindowTranslator to apply plugin changes. + + + Project page + + + License information + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 494a9598..ca130e23 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -455,4 +455,55 @@ Monitor tidak didukung. + + Toko Plugin + + + Pasang + + + Terpasang + + + Perbarui + + + Hapus + + + Apakah Anda yakin ingin menghapus {0}? Ini akan dihapus sepenuhnya saat startup berikutnya. + + + Pembaruan tersedia + + + Terpasang: {0} → Terbaru: {1} + + + Terpasang: {0} + + + Versi terpasang + + + Versi terbaru + + + Gagal mengambil daftar plugin dari NuGet. Silakan periksa koneksi jaringan Anda. + + + Instalasi selesai + + + Instalasi gagal + + + Silakan restart WindowTranslator untuk menerapkan perubahan plugin. + + + Halaman proyek + + + Informasi lisensi + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 186b4ee9..a285463a 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -456,4 +456,55 @@ + + 플러그인 스토어 + + + 설치 + + + 설치됨 + + + 업데이트 + + + 제거 + + + {0}을(를) 제거하시겠습니까? 다음 시작 시 완전히 제거됩니다. + + + 업데이트 있음 + + + 설치됨: {0} → 최신: {1} + + + 설치됨: {0} + + + 설치된 버전 + + + 최신 버전 + + + NuGet에서 플러그인 목록을 가져오지 못했습니다. 네트워크 연결을 확인하세요. + + + 설치 완료 + + + 설치 실패 + + + 플러그인 변경 사항을 적용하려면 WindowTranslator를 다시 시작하세요. + + + 프로젝트 페이지 + + + 라이선스 정보 + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 6f2dbe69..34fc87e5 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -455,4 +455,55 @@ Monitor tidak disokong. + + Kedai Plugin + + + Pasang + + + Dipasang + + + Kemaskini + + + Nyahpasang + + + Adakah anda pasti mahu menyahpasang {0}? Ia akan dibuang sepenuhnya semasa permulaan seterusnya. + + + Kemaskini tersedia + + + Dipasang: {0} → Terkini: {1} + + + Dipasang: {0} + + + Versi yang dipasang + + + Versi terkini + + + Gagal mendapatkan senarai plugin dari NuGet. Sila semak sambungan rangkaian anda. + + + Pemasangan selesai + + + Pemasangan gagal + + + Sila mulakan semula WindowTranslator untuk menerapkan perubahan plugin. + + + Halaman projek + + + Maklumat lesen + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index 93400dac..45d1c620 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -456,4 +456,55 @@ Monitory nie są obsługiwane. + + Sklep wtyczek + + + Zainstaluj + + + Zainstalowano + + + Aktualizuj + + + Odinstaluj + + + Czy na pewno chcesz odinstalować {0}? Zostanie całkowicie usunięty przy następnym uruchomieniu. + + + Dostępna aktualizacja + + + Zainstalowano: {0} → Najnowszy: {1} + + + Zainstalowano: {0} + + + Zainstalowana wersja + + + Najnowsza wersja + + + Nie udało się pobrać listy wtyczek z NuGet. Sprawdź połączenie sieciowe. + + + Instalacja zakończona + + + Instalacja nie powiodła się + + + Uruchom ponownie WindowTranslator, aby zastosować zmiany wtyczki. + + + Strona projektu + + + Informacje o licencji + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 9ae483a0..abb41766 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -455,4 +455,55 @@ Monitor tidak didukung. + + Loja de Plugins + + + Instalar + + + Instalado + + + Atualizar + + + Desinstalar + + + Tem certeza que deseja desinstalar {0}? Ele será completamente removido na próxima inicialização. + + + Atualização disponível + + + Instalado: {0} → Mais recente: {1} + + + Instalado: {0} + + + Versão instalada + + + Versão mais recente + + + Falha ao recuperar lista de plugins do NuGet. Verifique sua conexão de rede. + + + Instalação concluída + + + Falha na instalação + + + Reinicie o WindowTranslator para aplicar as alterações de plugin. + + + Página do projeto + + + Informações de licença + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 5e6bfd5f..91b432e6 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -456,4 +456,55 @@ + + プラグインストア + + + インストール + + + インストール済み + + + 更新 + + + アンインストール + + + {0} をアンインストールしますか?次回起動時に完全に削除されます。 + + + 更新あり + + + インストール済み: {0} → 最新: {1} + + + インストール済み: {0} + + + インストール済みバージョン + + + 最新バージョン + + + NuGetからのプラグイン一覧の取得に失敗しました。ネットワーク接続を確認してください。 + + + インストール完了 + + + インストール失敗 + + + プラグインの変更を適用するには、WindowTranslatorを再起動してください。 + + + プロジェクトページ + + + ライセンス情報 + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index dd98967c..065397e9 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -447,4 +447,55 @@ + + Магазин плагинов + + + Установить + + + Установлен + + + Обновить + + + Удалить + + + Вы уверены, что хотите удалить {0}? Он будет полностью удалён при следующем запуске. + + + Доступно обновление + + + Установлен: {0} → Последний: {1} + + + Установлен: {0} + + + Установленная версия + + + Последняя версия + + + Не удалось получить список плагинов из NuGet. Проверьте подключение к сети. + + + Установка завершена + + + Ошибка установки + + + Перезапустите WindowTranslator для применения изменений плагина. + + + Страница проекта + + + Информация о лицензии + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index efb6f04e..7056a265 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -456,4 +456,55 @@ + + ร้านปลั๊กอิน + + + ติดตั้ง + + + ติดตั้งแล้ว + + + อัปเดต + + + ถอนการติดตั้ง + + + คุณแน่ใจหรือไม่ว่าต้องการถอนการติดตั้ง {0}? จะถูกลบออกอย่างสมบูรณ์เมื่อเริ่มต้นครั้งถัดไป + + + มีการอัปเดต + + + ติดตั้งแล้ว: {0} → ล่าสุด: {1} + + + ติดตั้งแล้ว: {0} + + + เวอร์ชันที่ติดตั้ง + + + เวอร์ชันล่าสุด + + + ไม่สามารถดึงรายการปลั๊กอินจาก NuGet ได้ โปรดตรวจสอบการเชื่อมต่อเครือข่าย + + + การติดตั้งเสร็จสมบูรณ์ + + + การติดตั้งล้มเหลว + + + กรุณาเริ่ม WindowTranslator ใหม่เพื่อนำการเปลี่ยนแปลงปลั๊กอินไปใช้ + + + หน้าโครงการ + + + ข้อมูลใบอนุญาต + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index cd9c45fa..b2ec77de 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -456,4 +456,55 @@ Monitör desteklenmiyor. + + Eklenti Mağazası + + + Yükle + + + Yüklü + + + Güncelle + + + Kaldır + + + {0} öğesini kaldırmak istediğinizden emin misiniz? Sonraki başlatmada tamamen silinecek. + + + Güncelleme mevcut + + + Yüklü: {0} → En son: {1} + + + Yüklü: {0} + + + Yüklü sürüm + + + En son sürüm + + + NuGet'ten eklenti listesi alınamadı. Lütfen ağ bağlantınızı kontrol edin. + + + Kurulum tamamlandı + + + Kurulum başarısız + + + Eklenti değişikliklerini uygulamak için lütfen WindowTranslator'ı yeniden başlatın. + + + Proje sayfası + + + Lisans bilgileri + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 81d73a70..19e07b60 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -456,4 +456,55 @@ Màn hình không được hỗ trợ. + + Cửa hàng Plugin + + + Cài đặt + + + Đã cài đặt + + + Cập nhật + + + Gỡ cài đặt + + + Bạn có chắc muốn gỡ cài đặt {0}? Nó sẽ được xóa hoàn toàn khi khởi động lại. + + + Có cập nhật + + + Đã cài: {0} → Mới nhất: {1} + + + Đã cài: {0} + + + Phiên bản đã cài + + + Phiên bản mới nhất + + + Không thể lấy danh sách plugin từ NuGet. Vui lòng kiểm tra kết nối mạng. + + + Cài đặt hoàn tất + + + Cài đặt thất bại + + + Vui lòng khởi động lại WindowTranslator để áp dụng thay đổi plugin. + + + Trang dự án + + + Thông tin giấy phép + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index feb1e119..0699b4d5 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -456,4 +456,55 @@ + + 插件商店 + + + 安装 + + + 已安装 + + + 更新 + + + 卸载 + + + 确定要卸载 {0} 吗?下次启动时将完全移除。 + + + 有更新 + + + 已安装: {0} → 最新: {1} + + + 已安装: {0} + + + 已安装版本 + + + 最新版本 + + + 无法从 NuGet 获取插件列表。请检查您的网络连接。 + + + 安装完成 + + + 安装失败 + + + 请重启 WindowTranslator 以应用插件更改。 + + + 项目页面 + + + 许可证信息 + \ No newline at end of file diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 784cc9d9..8f4708b8 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -456,4 +456,55 @@ + + 外掛程式商店 + + + 安裝 + + + 已安裝 + + + 更新 + + + 解除安裝 + + + 確定要解除安裝 {0} 嗎?下次啟動時將完全移除。 + + + 有更新 + + + 已安裝: {0} → 最新: {1} + + + 已安裝: {0} + + + 已安裝版本 + + + 最新版本 + + + 無法從 NuGet 取得外掛程式清單。請檢查您的網路連線。 + + + 安裝完成 + + + 安裝失敗 + + + 請重新啟動 WindowTranslator 以套用外掛程式變更。 + + + 專案頁面 + + + 授權資訊 + \ No newline at end of file From 1364f7aa872061a6a0cbc33b1e3f99a9d0db1c08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:46:49 +0000 Subject: [PATCH 03/43] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=E3=81=A8=E3=82=B5=E3=83=B3=E3=83=97=E3=83=AB?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/Freeesia/WindowTranslator/sessions/5fefad82-801f-4158-ad6f-9d7500bf50ed Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- Samples/README.md | 135 ++++++++++++++++++ .../SampleTranslateModule.cs | 39 +++++ .../WindowTranslator.Plugin.Sample.csproj | 35 +++++ .../Modules/PluginStore/NuGetPluginService.cs | 5 +- WindowTranslator/Properties/Resources.hi.resx | 34 ++--- 5 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 Samples/README.md create mode 100644 Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs create mode 100644 Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj diff --git a/Samples/README.md b/Samples/README.md new file mode 100644 index 00000000..0b0fe1b7 --- /dev/null +++ b/Samples/README.md @@ -0,0 +1,135 @@ +# WindowTranslator 外部プラグイン開発ガイド + +## 概要 + +WindowTranslator は外部プラグインによる機能拡張をサポートしています。 +NuGet パッケージとしてプラグインを公開することで、他のユーザーがアプリ内から簡単にインストールできます。 + +## クイックスタート + +### 1. プロジェクト作成 + +```bash +dotnet new classlib -n WindowTranslator.Plugin.YourPlugin +cd WindowTranslator.Plugin.YourPlugin +``` + +### 2. .csproj を設定 + +最小構成の `.csproj` 例: + +```xml + + + net10.0 + true + + + WindowTranslator.Plugin.YourPlugin + 1.0.0 + YourName + 説明文 + + windowtranslator-plugin + MIT + + + + + + + + + + + +``` + +> **重要**: `` に `windowtranslator-plugin` を含めることで、 +> WindowTranslator アプリ内のプラグインストアに表示されます。 + +### 3. プラグインを実装 + +対象のインターフェースを実装します: + +| インターフェース | 用途 | +|---|---| +| `ITranslateModule` | テキスト翻訳 | +| `IOcrModule` | 画像からテキスト認識 | +| `ICaptureModule` | ウィンドウキャプチャ | +| `IFilterModule` | 翻訳前後のテキスト加工 | +| `IColorModule` | 色変換 | +| `ICacheModule` | 翻訳結果キャッシュ | + +```csharp +using System.ComponentModel; +using WindowTranslator.Modules; + +[DisplayName("MyPlugin 翻訳")] +public class MyTranslateModule : ITranslateModule +{ + public async IAsyncEnumerable TranslateAsync( + IAsyncEnumerable texts, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var text in texts.WithCancellation(cancellationToken)) + { + yield return await MyTranslateApiAsync(text, cancellationToken); + } + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} +``` + +### 4. パッケージをビルドして NuGet に公開 + +```bash +dotnet pack -c Release -o ./nupkg +dotnet nuget push ./nupkg/WindowTranslator.Plugin.YourPlugin.1.0.0.nupkg \ + --api-key YOUR_API_KEY \ + --source https://api.nuget.org/v3/index.json +``` + +## プラグイン設定パラメータ + +プラグインに設定画面を追加するには `IPluginParam` を実装します: + +```csharp +using PropertyTools.DataAnnotations; +using WindowTranslator; + +public class MyPluginParam : IPluginParam +{ + [Category("API設定")] + [DisplayName("APIキー")] + public string ApiKey { get; set; } = string.Empty; + + [Category("翻訳設定")] + [DisplayName("翻訳元言語")] + public string SourceLanguage { get; set; } = "ja"; +} +``` + +## デフォルトモジュールの指定 + +プラグインをデフォルトとして使用させるには `[DefaultModule]` 属性を付与します: + +```csharp +[DefaultModule] +[DisplayName("My 翻訳")] +public class MyTranslateModule : ITranslateModule { ... } +``` + +## プラグインインストール先 + +インストールされたプラグインは以下のフォルダに配置されます: + +- Windows: `%USERPROFILE%\.wt\plugins\{PackageId}\` + +## 注意事項 + +- プラグインは .NET 10 以上をターゲットにしてください +- `true` を必ず設定してください +- ホスト側で既に提供されているパッケージは `ExcludeAssets="runtime"` を設定し、DLL を重複させないようにしてください +- プラグインに必要な独自の依存 DLL はすべて `lib/net10.0/` フォルダに含めてください diff --git a/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs b/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs new file mode 100644 index 00000000..59b81a4b --- /dev/null +++ b/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; +using WindowTranslator.Modules; + +namespace WindowTranslator.Plugin.Sample; + +/// +/// サンプル翻訳モジュールです。 +/// テキストをそのまま返す(翻訳しない)実装例です。 +/// +[DisplayName("サンプル翻訳")] +public class SampleTranslateModule : ITranslateModule +{ + private readonly ILogger logger; + + public SampleTranslateModule(ILogger logger) + { + this.logger = logger; + } + + /// + public async IAsyncEnumerable TranslateAsync( + IAsyncEnumerable texts, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var text in texts.WithCancellation(cancellationToken)) + { + this.logger.LogDebug("翻訳: {Text}", text); + // TODO: ここで実際の翻訳処理を実装してください + yield return $"[翻訳済み] {text}"; + } + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } +} diff --git a/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj b/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj new file mode 100644 index 00000000..7bb0e01b --- /dev/null +++ b/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj @@ -0,0 +1,35 @@ + + + + + net10.0 + + true + + + WindowTranslator.Plugin.Sample + 1.0.0 + YourName + WindowTranslator サンプルプラグイン + windowtranslator-plugin + https://github.com/yourname/windowtranslator-plugin-sample + MIT + false + + + + + + + + + false + runtime + + + + + + + + diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 3f0c81bb..5e01aa89 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -33,7 +33,10 @@ public sealed class NuGetPluginService : IDisposable public NuGetPluginService(ILogger logger) { - this.httpClient = new HttpClient(); + this.httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30), + }; this.logger = logger; } diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index ba7b6383..2eb684cc 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -456,54 +456,54 @@ - Plugin Store + प्लगइन स्टोर - Install + इंस्टॉल करें - Installed + इंस्टॉल है - Update + अपडेट करें - Uninstall + अनइंस्टॉल करें - Are you sure you want to uninstall {0}? It will be fully removed on next startup. + क्या आप {0} को अनइंस्टॉल करना चाहते हैं? यह अगली बार शुरू होने पर पूरी तरह हटा दिया जाएगा। - Update available + अपडेट उपलब्ध - Installed: {0} → Latest: {1} + इंस्टॉल: {0} → नवीनतम: {1} - Installed: {0} + इंस्टॉल: {0} - Installed version + इंस्टॉल किया गया संस्करण - Latest version + नवीनतम संस्करण - Failed to retrieve plugin list from NuGet. Please check your network connection. + NuGet से प्लगइन सूची प्राप्त करने में विफल। कृपया अपना नेटवर्क कनेक्शन जांचें। - Installation complete + इंस्टॉलेशन पूर्ण - Installation failed + इंस्टॉलेशन विफल - Please restart WindowTranslator to apply plugin changes. + प्लगइन परिवर्तन लागू करने के लिए कृपया WindowTranslator को पुनः आरंभ करें। - Project page + प्रोजेक्ट पेज - License information + लाइसेंस जानकारी \ No newline at end of file From fb90a494f08c4334ce84d40128fe9d97a7dd9587 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 15:13:15 +0000 Subject: [PATCH 04/43] Changes before error encountered Agent-Logs-Url: https://github.com/Freeesia/WindowTranslator/sessions/49085e31-7c44-4f11-82e0-fe40172b8447 Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- Plugins/Directory.Build.props | 9 ++ Plugins/Directory.Build.targets | 2 +- ...nslator.Plugin.DeepLTranslatePlugin.csproj | 4 + .../WindowTranslator.Plugin.FoMPlugin.csproj | 1 + ...anslator.Plugin.GitHubCopilotPlugin.csproj | 1 + ...dowTranslator.Plugin.GoogleAIPlugin.csproj | 1 + ...lator.Plugin.GoogleAppsSctiptPlugin.csproj | 3 + .../WindowTranslator.Plugin.LLMPlugin.csproj | 1 + ...WindowTranslator.Plugin.PLaMoPlugin.csproj | 1 + ...ranslator.Plugin.TesseractOCRPlugin.csproj | 1 + .../Modules/PluginStore/NuGetPluginCatalog.cs | 53 ++++++++ .../Modules/PluginStore/NuGetPluginService.cs | 127 ++++++++++++++++++ WindowTranslator/Program.cs | 18 ++- 13 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index ffcb5d5e..eaff4328 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -6,6 +6,15 @@ true + + + windowtranslator-plugin + https://github.com/Freeesia/WindowTranslator + Freeesia + + false + + diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 24869907..0721539f 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index 1cd83d92..8c02a33f 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -1,5 +1,9 @@  + + true + + diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index 16bac979..e0c9fe00 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -2,6 +2,7 @@ net10.0-windows10.0.20348.0 true + true diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index 90dd527f..e9ea1352 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -1,6 +1,7 @@  net10.0-windows10.0.20348.0 + true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index ecc7110a..e15c28c5 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -2,6 +2,7 @@ net10.0-windows10.0.20348.0 + true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index e09e0417..6177f6ba 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -1,4 +1,7 @@  + + true + diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index 33feedb9..a37c1d24 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -1,6 +1,7 @@  net10.0-windows10.0.20348.0 + true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index c1a0b6bd..bc77e53a 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -2,6 +2,7 @@ net10.0 + true diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index bcae45af..b0e61f0e 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -2,6 +2,7 @@ net10.0-windows10.0.20348.0 + true diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs new file mode 100644 index 00000000..fac75a44 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -0,0 +1,53 @@ +using System.IO; +using Weikio.PluginFramework.Catalogs; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGet経由でインストールされたプラグインを一時フォルダからロードするカタログです。 +/// ファイルロックを回避するため、読み込み前にプラグインフォルダを一時フォルダにコピーします。 +/// +public class NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) + : FolderPluginCatalog( + Path.Combine(Path.GetTempPath(), "WindowTranslator", "plugins"), + options) +{ + private static readonly string TempDir = + Path.Combine(Path.GetTempPath(), "WindowTranslator", "plugins"); + + /// + public override async Task Initialize() + { + // ロック解除のために一時フォルダを削除してからコピー + if (Directory.Exists(TempDir)) + { + Directory.Delete(TempDir, recursive: true); + } + Directory.CreateDirectory(TempDir); + + if (Directory.Exists(sourceDir)) + { + // プラグインのサブフォルダのみコピー(nuget-manifest.json等のファイルはスキップ) + foreach (var subDir in Directory.GetDirectories(sourceDir)) + { + var destSubDir = Path.Combine(TempDir, Path.GetFileName(subDir)); + CopyDirectory(subDir, destSubDir); + } + } + + await base.Initialize().ConfigureAwait(false); + } + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + } + foreach (var subDir in Directory.GetDirectories(source)) + { + CopyDirectory(subDir, Path.Combine(destination, Path.GetFileName(subDir))); + } + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 5e01aa89..552ab054 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -16,6 +16,40 @@ public sealed class NuGetPluginService : IDisposable private const string PluginTag = "windowtranslator-plugin"; private const string NuGetFlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; + /// + /// モジュール/パラメータクラス名からNuGetパッケージIDへのマッピング。 + /// アプリバンドルから除外されたプラグインの後方互換性自動インストールに使用します。 + /// + public static readonly IReadOnlyDictionary KnownClassToPackage = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // WindowTranslator.Plugin.FoMPlugin + ["FoMFilterModule"] = "WindowTranslator.Plugin.FoMPlugin", + ["FoMOptions"] = "WindowTranslator.Plugin.FoMPlugin", + // WindowTranslator.Plugin.PLaMoPlugin + ["PLaMoTranslator"] = "WindowTranslator.Plugin.PLaMoPlugin", + ["PLaMoOptions"] = "WindowTranslator.Plugin.PLaMoPlugin", + // WindowTranslator.Plugin.GitHubCopilotPlugin + ["GitHubCopilotTranslator"] = "WindowTranslator.Plugin.GitHubCopilotPlugin", + ["GitHubCopilotOptions"] = "WindowTranslator.Plugin.GitHubCopilotPlugin", + // WindowTranslator.Plugin.DeepLTranslatePlugin + ["DeepLTranslator"] = "WindowTranslator.Plugin.DeepLTranslatePlugin", + ["DeepLOptions"] = "WindowTranslator.Plugin.DeepLTranslatePlugin", + // WindowTranslator.Plugin.GoogleAIPlugin + ["GoogleAITranslator"] = "WindowTranslator.Plugin.GoogleAIPlugin", + ["GoogleAIOcr"] = "WindowTranslator.Plugin.GoogleAIPlugin", + ["GoogleAIOptions"] = "WindowTranslator.Plugin.GoogleAIPlugin", + // WindowTranslator.Plugin.GoogleAppsSctiptPlugin + ["GasTranslator"] = "WindowTranslator.Plugin.GoogleAppsSctiptPlugin", + ["GasOptions"] = "WindowTranslator.Plugin.GoogleAppsSctiptPlugin", + // WindowTranslator.Plugin.LLMPlugin + ["LLMTranslator"] = "WindowTranslator.Plugin.LLMPlugin", + ["LLMOcr"] = "WindowTranslator.Plugin.LLMPlugin", + ["LLMOptions"] = "WindowTranslator.Plugin.LLMPlugin", + // WindowTranslator.Plugin.TesseractOCRPlugin + ["TesseractOcr"] = "WindowTranslator.Plugin.TesseractOCRPlugin", + }; + private static readonly string UserPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); private static readonly string ManifestPath = Path.Combine(UserPluginsDir, "nuget-manifest.json"); @@ -40,6 +74,95 @@ public NuGetPluginService(ILogger logger) this.logger = logger; } + /// + /// 指定したパッケージの最新バージョンをインストールします。 + /// + public async Task InstallLatestPackageAsync(string packageId, IProgress? progress = null, CancellationToken cancellationToken = default) + { + var versionsUrl = $"{NuGetFlatContainerBase}/{packageId.ToLowerInvariant()}/index.json"; + var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var versions = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false); + var latestVersion = versions?.Versions?.LastOrDefault() + ?? throw new InvalidOperationException($"パッケージ {packageId} のバージョン一覧を取得できませんでした。"); + await InstallPackageAsync(packageId, latestVersion, progress, cancellationToken).ConfigureAwait(false); + } + + /// + /// 設定ファイルで参照されているがインストールされていないプラグインを自動インストールします。 + /// アプリバンドルから除外されたプラグインの後方互換性維持のために使用します。 + /// + public async Task AutoInstallFromSettingsAsync(string settingsPath, CancellationToken cancellationToken = default) + { + if (!File.Exists(settingsPath)) + { + return; + } + + try + { + using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath, cancellationToken).ConfigureAwait(false)); + var neededPackages = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (doc.RootElement.TryGetProperty("Targets", out var targets)) + { + foreach (var target in targets.EnumerateObject()) + { + // SelectedPlugins の値(モジュールクラス名)をチェック + if (target.Value.TryGetProperty("SelectedPlugins", out var selectedPlugins)) + { + foreach (var plugin in selectedPlugins.EnumerateObject()) + { + var className = plugin.Value.GetString(); + if (className is not null && KnownClassToPackage.TryGetValue(className, out var packageId)) + { + neededPackages.Add(packageId); + } + } + } + + // PluginParams のキー(パラメータクラス名)をチェック + if (target.Value.TryGetProperty("PluginParams", out var pluginParams)) + { + foreach (var param in pluginParams.EnumerateObject()) + { + if (KnownClassToPackage.TryGetValue(param.Name, out var packageId)) + { + neededPackages.Add(packageId); + } + } + } + } + } + + if (neededPackages.Count == 0) + { + return; + } + + var installed = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); + var installedIds = installed.Select(p => p.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var packageId in neededPackages.Where(id => !installedIds.Contains(id))) + { + this.logger.LogInformation("設定で参照されているプラグインを自動インストール: {PackageId}", packageId); + try + { + await InstallLatestPackageAsync(packageId, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "プラグイン {PackageId} の自動インストールに失敗しました。", packageId); + } + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "設定からのプラグイン自動インストール処理中にエラーが発生しました。"); + } + } + /// /// NuGetでWindowTranslatorプラグインを検索します。 /// @@ -394,3 +517,7 @@ internal record NuGetSearchData( [property: JsonPropertyName("projectUrl")] string? ProjectUrl, [property: JsonPropertyName("licenseUrl")] string? LicenseUrl ); + +internal record NuGetVersionListResponse( + [property: JsonPropertyName("versions")] string[]? Versions +); diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index 7296b8be..6c2bab4a 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -59,6 +59,14 @@ var exeDir = Path.GetDirectoryName(Environment.GetCommandLineArgs()[0])!; Directory.SetCurrentDirectory(exeDir); +// ペンディング削除処理と設定参照プラグインの自動インストール(カタログ初期化より前に実行する必要がある) +{ + using var earlyLoggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Warning)); + using var earlyNuGetService = new NuGetPluginService(earlyLoggerFactory.CreateLogger()); + earlyNuGetService.ProcessPendingDeletions(); + await earlyNuGetService.AutoInstallFromSettingsAsync(PathUtility.UserSettings); +} + var builder = KamishibaiApplication.CreateBuilder(); builder.Host.ConfigureLogging((c, l) => @@ -122,10 +130,9 @@ } var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); -if (Directory.Exists(userPluginsDir)) -{ - pluginFolderCatalog.AddCatalog(new FolderPluginCatalog(userPluginsDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); -} +pluginFolderCatalog.AddCatalog(new NuGetPluginCatalog( + userPluginsDir, + new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); builder.Services.AddPluginCatalog(pluginFolderCatalog); builder.Configuration @@ -188,9 +195,6 @@ e.Window.Activate(); }; -// 起動時にペンディング削除を処理する -app.Services.GetRequiredService().ProcessPendingDeletions(); - if (SentrySdk.IsEnabled) { app.Logger.LogInformation("Sentry is enabled."); From 37f3588e93124ff41650e858931d4ef7e45d8ca6 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 26 Jul 2026 15:10:12 +0900 Subject: [PATCH 05/43] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E9=85=8D=E5=B8=83=E5=8B=95=E4=BD=9C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/Directory.Build.props | 4 +- Plugins/Directory.Build.targets | 4 +- .../SampleTranslateModule.cs | 39 ------------------- .../WindowTranslator.Plugin.Sample.csproj | 35 ----------------- .../Modules/PluginStore/NuGetPluginCatalog.cs | 8 +++- Samples/README.md => docs/plugin.md | 25 +++--------- 6 files changed, 16 insertions(+), 99 deletions(-) delete mode 100644 Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs delete mode 100644 Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj rename Samples/README.md => docs/plugin.md (86%) diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index eaff4328..2b796eb3 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -11,7 +11,7 @@ windowtranslator-plugin https://github.com/Freeesia/WindowTranslator Freeesia - + false @@ -31,4 +31,4 @@ runtime - \ No newline at end of file + diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 0721539f..efca3a7f 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -2,7 +2,7 @@ - + - \ No newline at end of file + diff --git a/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs b/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs deleted file mode 100644 index 59b81a4b..00000000 --- a/Samples/WindowTranslator.Plugin.Sample/SampleTranslateModule.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using Microsoft.Extensions.Logging; -using WindowTranslator.Modules; - -namespace WindowTranslator.Plugin.Sample; - -/// -/// サンプル翻訳モジュールです。 -/// テキストをそのまま返す(翻訳しない)実装例です。 -/// -[DisplayName("サンプル翻訳")] -public class SampleTranslateModule : ITranslateModule -{ - private readonly ILogger logger; - - public SampleTranslateModule(ILogger logger) - { - this.logger = logger; - } - - /// - public async IAsyncEnumerable TranslateAsync( - IAsyncEnumerable texts, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var text in texts.WithCancellation(cancellationToken)) - { - this.logger.LogDebug("翻訳: {Text}", text); - // TODO: ここで実際の翻訳処理を実装してください - yield return $"[翻訳済み] {text}"; - } - } - - public ValueTask DisposeAsync() - { - return ValueTask.CompletedTask; - } -} diff --git a/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj b/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj deleted file mode 100644 index 7bb0e01b..00000000 --- a/Samples/WindowTranslator.Plugin.Sample/WindowTranslator.Plugin.Sample.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - - net10.0 - - true - - - WindowTranslator.Plugin.Sample - 1.0.0 - YourName - WindowTranslator サンプルプラグイン - windowtranslator-plugin - https://github.com/yourname/windowtranslator-plugin-sample - MIT - false - - - - - - - - - false - runtime - - - - - - - - diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 20e4f98f..f961c801 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -59,7 +59,13 @@ private static void CopyDirectory(string source, string destination) Directory.CreateDirectory(destination); foreach (var file in Directory.GetFiles(source)) { - File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + var destinationFile = Path.Combine(destination, Path.GetFileName(file)); + if (File.Exists(destinationFile)) + { + continue; + } + + File.Copy(file, destinationFile); } foreach (var subDir in Directory.GetDirectories(source)) { diff --git a/Samples/README.md b/docs/plugin.md similarity index 86% rename from Samples/README.md rename to docs/plugin.md index 0b0fe1b7..cc9f31c6 100644 --- a/Samples/README.md +++ b/docs/plugin.md @@ -61,26 +61,11 @@ cd WindowTranslator.Plugin.YourPlugin | `IColorModule` | 色変換 | | `ICacheModule` | 翻訳結果キャッシュ | -```csharp -using System.ComponentModel; -using WindowTranslator.Modules; - -[DisplayName("MyPlugin 翻訳")] -public class MyTranslateModule : ITranslateModule -{ - public async IAsyncEnumerable TranslateAsync( - IAsyncEnumerable texts, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var text in texts.WithCancellation(cancellationToken)) - { - yield return await MyTranslateApiAsync(text, cancellationToken); - } - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} -``` +実際の実装例は +[DeepLTranslator.cs](../Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/DeepLTranslator.cs) +と +[WindowTranslator.Plugin.DeepLTranslatePlugin.csproj](../Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj) +を参照してください。 ### 4. パッケージをビルドして NuGet に公開 From 61ee76d8beed0beaca1ed638ac6191da0771fda5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 28 Jul 2026 21:50:16 +0900 Subject: [PATCH 06/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E9=85=8D=E5=B8=83=E3=81=A8=E4=BE=9D=E5=AD=98=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-package.yml | 15 + Directory.Packages.props | 3 +- Plugins/Directory.Build.props | 3 +- Plugins/Directory.Build.targets | 23 + .../NuGetPluginServiceTests.cs | 521 +++++++++++++++ .../PluginStore/NuGetPackageInstaller.cs | 594 ++++++++++++++++++ .../Modules/PluginStore/NuGetPluginCatalog.cs | 72 ++- .../Modules/PluginStore/NuGetPluginService.cs | 489 +++++++++----- .../PluginStore/PluginStoreViewModel.cs | 12 +- WindowTranslator/Properties/AssemblyInfo.cs | 3 + WindowTranslator/WindowTranslator.csproj | 1 + docs/plugin.md | 16 +- 12 files changed, 1556 insertions(+), 196 deletions(-) create mode 100644 WindowTranslator.Tests/NuGetPluginServiceTests.cs create mode 100644 WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs create mode 100644 WindowTranslator/Properties/AssemblyInfo.cs diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index 2f73507f..32314746 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -25,10 +25,25 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 + - uses: Jimver/cuda-toolkit@v0.2.30 + with: + cuda: '12.9.0' - run: | dotnet pack WindowTranslator.Abstractions -c Release -o pack ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + Get-ChildItem Plugins\WindowTranslator.Plugin.*\*.csproj | + Where-Object { $_.Directory.Name -notlike '*.Tests' } | + ForEach-Object { + dotnet pack $_.FullName -c Release -o pack ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + } dotnet nuget push pack\*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d409406..512bce90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,7 @@ + @@ -59,4 +60,4 @@ - \ No newline at end of file + diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index 2b796eb3..3295561e 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -8,9 +8,10 @@ - windowtranslator-plugin https://github.com/Freeesia/WindowTranslator Freeesia + + false false diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index efca3a7f..ed78c408 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -2,6 +2,29 @@ + + $(PackageTags);windowtranslator-plugin + $(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage + + + + + + <_PluginWinX64RuntimeAsset Include="$(TargetDir)runtimes\win-x64\**\*" /> + <_PluginWinRuntimeAsset Include="$(TargetDir)runtimes\win\**\*" /> + <_PluginAnyRuntimeAsset Include="$(TargetDir)runtimes\any\**\*" /> + + + + + + diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs new file mode 100644 index 00000000..c87f2313 --- /dev/null +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -0,0 +1,521 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using WindowTranslator.Modules.PluginStore; + +namespace WindowTranslator.Tests; + +public sealed class NuGetPluginServiceTests +{ + [Fact] + public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [ + new("Dependency.Package", "[1.0.0, 2.0.0)"), + new("Host.Provided", "[1.0.0]", Exclude: "Runtime"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + ["lib/net10.0/fr/Root.Plugin.resources.dll"] = "fr"u8.ToArray(), + ["runtimes/win-x64/native/root-native.dll"] = "native"u8.ToArray(), + ["lib/net10.0/runtimes/win-x64/native/custom-native.dll"] = "custom"u8.ToArray(), + })); + handler.AddPackage( + "Dependency.Package", + "1.0.0", + CreatePackage( + "Dependency.Package", + "1.0.0", + [new("Transitive.Package", "[2.0.0]")], + new Dictionary + { + ["lib/net8.0/Dependency.Package.dll"] = "dependency"u8.ToArray(), + })); + handler.AddPackage( + "Transitive.Package", + "2.0.0", + CreatePackage( + "Transitive.Package", + "2.0.0", + [], + new Dictionary + { + ["lib/netstandard2.0/Transitive.Package.dll"] = "transitive"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + var pluginDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Assert.Equal("root", await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Root.Plugin.dll"))); + Assert.Equal( + "fr", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "fr", "Root.Plugin.resources.dll"))); + Assert.Equal( + "dependency", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Dependency.Package.dll"))); + Assert.Equal( + "transitive", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Transitive.Package.dll"))); + Assert.Equal( + "native", + await File.ReadAllTextAsync(Path.Combine( + pluginDirectory, + "runtimes", + "win-x64", + "native", + "root-native.dll"))); + Assert.Equal( + "custom", + await File.ReadAllTextAsync(Path.Combine( + pluginDirectory, + "runtimes", + "win-x64", + "native", + "custom-native.dll"))); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.Contains("host.provided", StringComparison.OrdinalIgnoreCase)); + + var installed = await service.GetInstalledPackagesAsync(); + var package = Assert.Single(installed); + Assert.Equal("Root.Plugin", package.Id); + Assert.Equal("1.0.0", package.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ManifestWriteFailureRestoresThePreviousPluginDirectory() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); + await using (var manifestLock = new FileStream( + manifestPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + var exception = await Record.ExceptionAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + Assert.True( + exception is IOException or UnauthorizedAccessException, + $"Unexpected exception: {exception}"); + } + + Assert.Equal( + "version-one", + await File.ReadAllTextAsync( + Path.Combine(testDirectory, "Root.Plugin", "Root.Plugin.dll"))); + var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + Assert.Equal("1.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ReinstallAfterUninstallRemovesThePendingDeletionMarker() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + await service.UninstallPackageAsync("Root.Plugin"); + + var markerPath = Path.Combine(testDirectory, "Root.Plugin.pending-delete"); + Assert.True(File.Exists(markerPath)); + + await service.InstallPackageAsync("Root.Plugin", "2.0.0"); + Assert.False(File.Exists(markerPath)); + + service.ProcessPendingDeletions(); + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + Assert.Equal("2.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task DependencyWithIncompatibleLibStillInstallsCompatibleNativeAssets() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [new("Native.Dependency", "[1.0.0]")], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + handler.AddPackage( + "Native.Dependency", + "1.0.0", + CreatePackage( + "Native.Dependency", + "1.0.0", + [], + new Dictionary + { + ["lib/net48/LegacyOnly.dll"] = "legacy"u8.ToArray(), + ["runtimes/win-x64/native/compatible.dll"] = "native"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + Assert.False(File.Exists( + Path.Combine(testDirectory, "Root.Plugin", "LegacyOnly.dll"))); + Assert.Equal( + "native", + await File.ReadAllTextAsync(Path.Combine( + testDirectory, + "Root.Plugin", + "runtimes", + "win-x64", + "native", + "compatible.dll"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InvalidManifestDoesNotReplaceExistingPluginDirectory() + { + var testDirectory = CreateTestDirectory(); + try + { + var pluginDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Directory.CreateDirectory(pluginDirectory); + await File.WriteAllTextAsync( + Path.Combine(pluginDirectory, "Root.Plugin.dll"), + "existing"); + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + "{ invalid"); + + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "replacement"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + + Assert.Equal( + "existing", + await File.ReadAllTextAsync( + Path.Combine(pluginDirectory, "Root.Plugin.dll"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public void CatalogCopyIncludesLegacyRootFilesAndSkipsManagementState() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + File.WriteAllText(Path.Combine(sourceDirectory, "Legacy.Plugin.dll"), "legacy"); + File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json"), "{}"); + File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json.tmp-test"), "{}"); + File.WriteAllText(Path.Combine(sourceDirectory, "Root.Plugin.pending-delete"), "Root.Plugin"); + File.WriteAllText( + Path.Combine(sourceDirectory, "Root.Plugin.pending-delete.tmp-test"), + "Root.Plugin"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin")); + File.WriteAllText( + Path.Combine(sourceDirectory, "Root.Plugin", "Root.Plugin.dll"), + "plugin"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.backup-test")); + File.WriteAllText( + Path.Combine(sourceDirectory, "Root.Plugin.backup-test", "old.dll"), + "old"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, ".Root.Plugin.installing-test")); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Root.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"), + "existing"); + + NuGetPluginCatalog.CopyPluginFiles(sourceDirectory, destinationDirectory); + + Assert.True(File.Exists(Path.Combine(destinationDirectory, "Legacy.Plugin.dll"))); + Assert.Equal( + "existing", + File.ReadAllText( + Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"))); + Assert.False(File.Exists(Path.Combine(destinationDirectory, "nuget-manifest.json"))); + Assert.False(File.Exists( + Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); + Assert.False(File.Exists(Path.Combine(destinationDirectory, "Root.Plugin.pending-delete"))); + Assert.False(File.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.pending-delete.tmp-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, ".Root.Plugin.installing-test"))); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + + [Fact] + public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() + { + Assert.Equal( + "net10.0-windows10.0.20348.0", + NuGetPackageInstaller.SelectBestTfm( + ["net10.0", "net10.0-windows10.0.20348.0", "netstandard2.0"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net48"])); + } + + private static NuGetPluginService CreateService(HttpClient client, string pluginDirectory) + => new( + NullLogger.Instance, + client, + pluginDirectory); + + private static byte[] CreatePackage( + string id, + string version, + IReadOnlyCollection dependencies, + IReadOnlyDictionary entries) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var dependencyElements = dependencies.Select(dependency => + { + var element = new XElement( + "dependency", + new XAttribute("id", dependency.Id), + new XAttribute("version", dependency.Version)); + if (dependency.Exclude is not null) + { + element.Add(new XAttribute("exclude", dependency.Exclude)); + } + return element; + }); + var nuspec = new XDocument( + new XElement( + "package", + new XElement( + "metadata", + new XElement("id", id), + new XElement("version", version), + new XElement("authors", "WindowTranslator.Tests"), + new XElement("description", "Test package"), + new XElement( + "dependencies", + new XElement( + "group", + new XAttribute("targetFramework", "net10.0"), + dependencyElements))))); + var nuspecEntry = archive.CreateEntry($"{id}.nuspec"); + using (var nuspecStream = nuspecEntry.Open()) + { + nuspec.Save(nuspecStream); + } + + foreach (var (path, content) in entries) + { + var entry = archive.CreateEntry(path); + using var entryStream = entry.Open(); + entryStream.Write(content); + } + } + + return stream.ToArray(); + } + + private static string CreateTestDirectory() + { + var path = Path.Combine( + Path.GetTempPath(), + "WindowTranslator.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteTestDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // テスト用ディレクトリの後始末はテスト結果へ影響させない + } + } + + private sealed record TestDependency(string Id, string Version, string? Exclude = null); + + private sealed class InMemoryNuGetHandler : HttpMessageHandler + { + private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); + + public List RequestedPaths { get; } = []; + + public void AddPackage(string id, string version, byte[] package) + => this.packages[(id.ToLowerInvariant(), version.ToLowerInvariant())] = package; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + this.RequestedPaths.Add(path); + var segments = path.Trim('/').Split('/'); + if (segments.Length == 3 + && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase) + && segments[2].Equals("index.json", StringComparison.OrdinalIgnoreCase)) + { + var id = segments[1].ToLowerInvariant(); + var versions = this.packages.Keys + .Where(key => key.Id == id) + .Select(key => key.Version) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) + .ToArray(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonSerializer.Serialize(new { versions }), + Encoding.UTF8, + "application/json"), + }); + } + + if (segments.Length == 4 + && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase)) + { + var key = (segments[1].ToLowerInvariant(), segments[2].ToLowerInvariant()); + if (this.packages.TryGetValue(key, out var package)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(package), + }); + } + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs new file mode 100644 index 00000000..51822ef2 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -0,0 +1,594 @@ +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGetパッケージとそのランタイム依存関係を、プラグインフォルダへ展開します。 +/// +internal sealed class NuGetPackageInstaller(HttpClient httpClient, ILogger logger) +{ + private const string FlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; + + private static readonly string[] CompatibleFrameworks = + [ + "net10.0-windows", + "net10.0", + "net9.0-windows", + "net9.0", + "net8.0-windows", + "net8.0", + "net7.0-windows", + "net7.0", + "net6.0-windows", + "net6.0", + "net5.0-windows", + "net5.0", + "netcoreapp3.1", + "netstandard2.1", + "netstandard2.0", + ]; + + private static readonly string[] CompatibleRuntimeIdentifiers = ["win-x64", "win", "any"]; + + private readonly HttpClient httpClient = httpClient; + private readonly ILogger logger = logger; + + public async Task InstallAsync( + string packageId, + string version, + string destinationDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + ValidatePackageId(packageId); + var requestedVersion = NuGetVersion.Parse(version); + var workDirectory = Path.Combine( + Path.GetTempPath(), + "WindowTranslatorPlugins", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDirectory); + + try + { + var artifacts = await ResolvePackageGraphAsync( + packageId, + requestedVersion, + workDirectory, + progress, + cancellationToken).ConfigureAwait(false); + + Directory.CreateDirectory(destinationDirectory); + foreach (var artifact in artifacts.OrderByDescending(a => + a.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))) + { + ExtractPackageAssets( + artifact.PackagePath, + destinationDirectory, + requirePluginAssembly: artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + } + } + finally + { + TryDeleteDirectory(workDirectory); + } + } + + internal static string? SelectBestTfm(IEnumerable frameworks) + { + var candidates = frameworks + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(original => (Original: original, Normalized: NormalizeFramework(original))) + .ToArray(); + + foreach (var compatibleFramework in CompatibleFrameworks) + { + var match = candidates + .OrderByDescending(c => c.Normalized, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(c => compatibleFramework.EndsWith("-windows", StringComparison.Ordinal) + ? c.Normalized.StartsWith(compatibleFramework, StringComparison.OrdinalIgnoreCase) + : c.Normalized.Equals(compatibleFramework, StringComparison.OrdinalIgnoreCase)); + if (match.Original is not null) + { + return match.Original; + } + } + + return null; + } + + private async Task> ResolvePackageGraphAsync( + string rootPackageId, + NuGetVersion rootVersion, + string workDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + var constraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var selectedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var artifacts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var queue = new Queue(); + var queued = new HashSet(StringComparer.OrdinalIgnoreCase); + + AddConstraint( + rootPackageId, + "$root", + new VersionRange( + rootVersion, + includeMinVersion: true, + maxVersion: rootVersion, + includeMaxVersion: true)); + + while (queue.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var currentId = queue.Dequeue(); + queued.Remove(currentId); + + if (!constraints.TryGetValue(currentId, out var currentConstraints) + || currentConstraints.Count == 0) + { + if (selectedVersions.Remove(currentId)) + { + artifacts.Remove(currentId); + RemoveConstraintsFrom(currentId); + } + continue; + } + + var ranges = currentConstraints.Select(c => c.Range).ToArray(); + var resolvedVersion = currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) + ? rootVersion + : await ResolveDependencyVersionAsync(currentId, ranges, cancellationToken).ConfigureAwait(false); + + if (!ranges.All(r => r.Satisfies(resolvedVersion))) + { + throw new InvalidOperationException( + $"パッケージ {currentId} の依存バージョンを解決できませんでした: {string.Join(", ", ranges)}"); + } + + if (selectedVersions.TryGetValue(currentId, out var existingVersion) + && existingVersion == resolvedVersion) + { + continue; + } + + if (selectedVersions.ContainsKey(currentId)) + { + RemoveConstraintsFrom(currentId); + } + selectedVersions[currentId] = resolvedVersion; + var packagePath = Path.Combine( + workDirectory, + $"{currentId.ToLowerInvariant()}.{resolvedVersion.ToNormalizedString().ToLowerInvariant()}.nupkg"); + await DownloadPackageAsync( + currentId, + resolvedVersion, + packagePath, + currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? progress : null, + cancellationToken).ConfigureAwait(false); + + artifacts[currentId] = new PackageArtifact(currentId, resolvedVersion, packagePath); + foreach (var dependency in ReadRuntimeDependencies(packagePath)) + { + AddConstraint(dependency.Id, currentId, dependency.VersionRange); + } + } + + return artifacts.Values.ToArray(); + + void AddConstraint(string id, string source, VersionRange range) + { + ValidatePackageId(id); + if (!constraints.TryGetValue(id, out var packageConstraints)) + { + packageConstraints = []; + constraints[id] = packageConstraints; + } + + if (packageConstraints.Any(c => + c.Source.Equals(source, StringComparison.OrdinalIgnoreCase) + && c.Range.ToString().Equals(range.ToString(), StringComparison.OrdinalIgnoreCase))) + { + return; + } + + packageConstraints.Add(new DependencyConstraint(source, range)); + Enqueue(id); + } + + void RemoveConstraintsFrom(string source) + { + foreach (var (id, packageConstraints) in constraints) + { + if (packageConstraints.RemoveAll(c => + c.Source.Equals(source, StringComparison.OrdinalIgnoreCase)) > 0) + { + Enqueue(id); + } + } + } + + void Enqueue(string id) + { + if (queued.Add(id)) + { + queue.Enqueue(id); + } + } + } + + private async Task ResolveDependencyVersionAsync( + string packageId, + IReadOnlyCollection ranges, + CancellationToken cancellationToken) + { + var versionsUrl = $"{FlatContainerBase}/{packageId.ToLowerInvariant()}/index.json"; + using var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var versionList = await JsonSerializer.DeserializeAsync( + content, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var compatibleVersions = versionList?.Versions? + .Select(NuGetVersion.Parse) + .Where(v => ranges.All(r => r.Satisfies(v))) + .OrderBy(v => v) + .ToArray() ?? []; + return compatibleVersions.FirstOrDefault(v => !v.IsPrerelease) + ?? compatibleVersions.FirstOrDefault() + ?? throw new InvalidOperationException( + $"パッケージ {packageId} の依存条件を満たすバージョンがありません: {string.Join(", ", ranges)}"); + } + + private async Task DownloadPackageAsync( + string packageId, + NuGetVersion version, + string destinationPath, + IProgress? progress, + CancellationToken cancellationToken) + { + var packageIdLower = packageId.ToLowerInvariant(); + var versionLower = version.ToNormalizedString().ToLowerInvariant(); + var url = $"{FlatContainerBase}/{packageIdLower}/{versionLower}/{packageIdLower}.{versionLower}.nupkg"; + this.logger.LogInformation("NuGetパッケージをダウンロード中: {PackageId} {Version}", packageId, version); + + using var response = await this.httpClient.GetAsync( + url, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? -1; + var downloadedBytes = 0L; + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var destination = File.Create(destinationPath); + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + downloadedBytes += bytesRead; + if (totalBytes > 0) + { + progress?.Report((double)downloadedBytes / totalBytes); + } + } + } + + private static List ReadRuntimeDependencies(string packagePath) + { + using var archive = ZipFile.OpenRead(packagePath); + var nuspecEntry = archive.Entries.FirstOrDefault(e => + e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException("パッケージにnuspecが見つかりませんでした。"); + + using var stream = nuspecEntry.Open(); + var document = XDocument.Load(stream); + var metadata = document.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "metadata") + ?? throw new InvalidOperationException("nuspecのmetadataが見つかりませんでした。"); + var dependencies = metadata.Elements().FirstOrDefault(e => e.Name.LocalName == "dependencies"); + if (dependencies is null) + { + return []; + } + + var result = new List(); + result.AddRange(ParseDependencyElements( + dependencies.Elements().Where(e => e.Name.LocalName == "dependency"))); + + var groups = dependencies.Elements() + .Where(e => e.Name.LocalName == "group") + .Select(e => ( + Element: e, + Framework: e.Attribute("targetFramework")?.Value)) + .ToArray(); + if (groups.Length == 0) + { + return result; + } + + var frameworkGroups = groups.Where(g => + !string.IsNullOrWhiteSpace(g.Framework) + && !g.Framework.Equals("any", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (frameworkGroups.Length > 0) + { + var selectedFramework = SelectBestTfm(frameworkGroups.Select(g => g.Framework!)); + if (selectedFramework is not null) + { + result.AddRange(ParseDependencyElements( + frameworkGroups.First(g => string.Equals( + g.Framework, + selectedFramework, + StringComparison.OrdinalIgnoreCase)).Element.Elements())); + return result; + } + } + + var fallbackGroup = groups.FirstOrDefault(g => + string.IsNullOrWhiteSpace(g.Framework) + || g.Framework.Equals("any", StringComparison.OrdinalIgnoreCase)); + if (fallbackGroup.Element is not null) + { + result.AddRange(ParseDependencyElements(fallbackGroup.Element.Elements())); + return result; + } + + throw new InvalidOperationException("互換性のある依存関係グループが見つかりませんでした。"); + } + + private static IEnumerable ParseDependencyElements(IEnumerable elements) + { + foreach (var element in elements.Where(e => e.Name.LocalName == "dependency")) + { + var id = element.Attribute("id")?.Value; + if (string.IsNullOrWhiteSpace(id) || !IncludesRuntimeAssets(element)) + { + continue; + } + + var versionText = element.Attribute("version")?.Value; + yield return new PackageDependency( + id, + string.IsNullOrWhiteSpace(versionText) ? VersionRange.All : VersionRange.Parse(versionText)); + } + } + + private static bool IncludesRuntimeAssets(XElement dependency) + { + var excluded = SplitAssets(dependency.Attribute("exclude")?.Value); + if (excluded.Contains("all") || excluded.Contains("runtime")) + { + return false; + } + + var included = SplitAssets(dependency.Attribute("include")?.Value); + return included.Count == 0 || included.Contains("all") || included.Contains("runtime"); + } + + private static HashSet SplitAssets(string? assets) + => string.IsNullOrWhiteSpace(assets) + ? new(StringComparer.OrdinalIgnoreCase) + : assets.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static void ExtractPackageAssets( + string packagePath, + string destinationDirectory, + bool requirePluginAssembly) + { + using var archive = ZipFile.OpenRead(packagePath); + var libEntries = archive.Entries + .Where(e => e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name) + && e.Name != "_._") + .ToArray(); + var libGroups = libEntries + .Where(e => e.FullName.Split('/').Length >= 3) + .GroupBy(e => e.FullName.Split('/')[1]) + .ToArray(); + var hasPluginAssembly = false; + + if (libGroups.Length > 0) + { + var selectedFramework = SelectBestTfm(libGroups.Select(g => g.Key)); + if (selectedFramework is not null) + { + var selectedEntries = libGroups.First(g => g.Key.Equals( + selectedFramework, + StringComparison.OrdinalIgnoreCase)).ToArray(); + hasPluginAssembly = selectedEntries.Any(e => + e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); + + ExtractEntries( + selectedEntries, + $"lib/{selectedFramework}/", + destinationDirectory); + } + } + + hasPluginAssembly |= ExtractRuntimeAssets(archive, destinationDirectory); + if (requirePluginAssembly && !hasPluginAssembly) + { + throw new InvalidOperationException("プラグインパッケージに互換性のあるアセンブリが見つかりませんでした。"); + } + } + + private static bool ExtractRuntimeAssets(ZipArchive archive, string destinationDirectory) + { + var hasManagedAssembly = false; + var runtimeLibIdentifier = CompatibleRuntimeIdentifiers.FirstOrDefault(rid => + archive.Entries.Any(e => e.FullName.StartsWith( + $"runtimes/{rid}/lib/", + StringComparison.OrdinalIgnoreCase))); + var runtimeLibPrefix = $"runtimes/{runtimeLibIdentifier}/lib/"; + var runtimeLibGroups = archive.Entries + .Where(e => runtimeLibIdentifier is not null + && e.FullName.StartsWith(runtimeLibPrefix, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name)) + .Where(e => e.FullName.Split('/').Length >= 5) + .GroupBy(e => e.FullName.Split('/')[3]) + .ToArray(); + if (runtimeLibGroups.Length > 0) + { + var selectedFramework = SelectBestTfm(runtimeLibGroups.Select(g => g.Key)); + if (selectedFramework is not null) + { + var selectedEntries = runtimeLibGroups.First(g => g.Key.Equals( + selectedFramework, + StringComparison.OrdinalIgnoreCase)).ToArray(); + hasManagedAssembly = selectedEntries.Any(e => + e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); + ExtractEntries( + selectedEntries, + string.Empty, + destinationDirectory); + } + } + + var nativeRuntimeIdentifier = CompatibleRuntimeIdentifiers.FirstOrDefault(rid => + archive.Entries.Any(e => e.FullName.StartsWith( + $"runtimes/{rid}/native/", + StringComparison.OrdinalIgnoreCase))); + if (nativeRuntimeIdentifier is null) + { + return hasManagedAssembly; + } + + var nativePrefix = $"runtimes/{nativeRuntimeIdentifier}/native/"; + ExtractEntries( + archive.Entries.Where(e => e.FullName.StartsWith(nativePrefix, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name)), + string.Empty, + destinationDirectory); + return hasManagedAssembly; + } + + private static void ExtractEntries( + IEnumerable entries, + string prefix, + string destinationDirectory) + { + foreach (var entry in entries) + { + var relativePath = entry.FullName[prefix.Length..]; + if (string.IsNullOrWhiteSpace(relativePath)) + { + continue; + } + + var destinationPath = GetSafeDestinationPath(destinationDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + if (File.Exists(destinationPath)) + { + using var existing = File.OpenRead(destinationPath); + using var incoming = entry.Open(); + if (!StreamsEqual(existing, incoming)) + { + throw new InvalidOperationException( + $"依存パッケージ間でファイルが競合しています: {relativePath}"); + } + continue; + } + + entry.ExtractToFile(destinationPath); + } + } + + private static string GetSafeDestinationPath(string destinationDirectory, string relativePath) + { + var root = Path.GetFullPath(destinationDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var destination = Path.GetFullPath(Path.Combine( + root, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"不正なパッケージエントリです: {relativePath}"); + } + + return destination; + } + + private static bool StreamsEqual(Stream left, Stream right) + { + if (left.Length != right.Length) + { + return false; + } + + var leftBuffer = new byte[81920]; + var rightBuffer = new byte[81920]; + int leftRead; + while ((leftRead = left.Read(leftBuffer, 0, leftBuffer.Length)) > 0) + { + var rightRead = right.Read(rightBuffer, 0, rightBuffer.Length); + if (leftRead != rightRead + || !leftBuffer.AsSpan(0, leftRead).SequenceEqual(rightBuffer.AsSpan(0, rightRead))) + { + return false; + } + } + + return right.ReadByte() == -1; + } + + private static string NormalizeFramework(string framework) + { + var normalized = framework.Replace(" ", string.Empty, StringComparison.Ordinal); + const string netCoreAppPrefix = ".NETCoreApp,Version=v"; + const string netStandardPrefix = ".NETStandard,Version=v"; + if (normalized.StartsWith(netCoreAppPrefix, StringComparison.OrdinalIgnoreCase)) + { + return $"net{normalized[netCoreAppPrefix.Length..]}"; + } + if (normalized.StartsWith(netStandardPrefix, StringComparison.OrdinalIgnoreCase)) + { + return $"netstandard{normalized[netStandardPrefix.Length..]}"; + } + return normalized; + } + + private static void ValidatePackageId(string packageId) + { + if (string.IsNullOrWhiteSpace(packageId) + || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || packageId.Contains(Path.DirectorySeparatorChar) + || packageId.Contains(Path.AltDirectorySeparatorChar)) + { + throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + } + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch + { + // 一時ディレクトリの後始末失敗はインストール結果へ影響させない + } + } + + private sealed record PackageArtifact(string Id, NuGetVersion Version, string PackagePath); + + private sealed record PackageDependency(string Id, VersionRange VersionRange); + + private sealed record DependencyConstraint(string Source, VersionRange Range); + + private sealed record VersionIndex( + [property: JsonPropertyName("versions")] string[]? Versions); +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index f961c801..9126a1c6 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -10,16 +10,23 @@ namespace WindowTranslator.Modules.PluginStore; /// public sealed class NuGetPluginCatalog : IPluginCatalog { - private static readonly string TempDir = + private static readonly string DefaultTempDir = Path.Combine(Path.GetTempPath(), "WindowTranslator", "plugins"); private readonly string sourceDir; + private readonly string tempDir; private readonly FolderPluginCatalog innerCatalog; public NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) + : this(sourceDir, DefaultTempDir, options) + { + } + + internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalogOptions options) { this.sourceDir = sourceDir; - this.innerCatalog = new FolderPluginCatalog(TempDir, options); + this.tempDir = tempDir; + this.innerCatalog = new FolderPluginCatalog(tempDir, options); } /// @@ -29,20 +36,15 @@ public NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) public async Task Initialize() { // ロック解除のために一時フォルダを削除してからコピー - if (Directory.Exists(TempDir)) + if (Directory.Exists(this.tempDir)) { - Directory.Delete(TempDir, recursive: true); + Directory.Delete(this.tempDir, recursive: true); } - Directory.CreateDirectory(TempDir); + Directory.CreateDirectory(this.tempDir); if (Directory.Exists(this.sourceDir)) { - // プラグインのサブフォルダのみコピー(nuget-manifest.json等のファイルはスキップ) - foreach (var subDir in Directory.GetDirectories(this.sourceDir)) - { - var destSubDir = Path.Combine(TempDir, Path.GetFileName(subDir)); - CopyDirectory(subDir, destSubDir); - } + CopyPluginFiles(this.sourceDir, this.tempDir); } await this.innerCatalog.Initialize().ConfigureAwait(false); @@ -54,18 +56,58 @@ public async Task Initialize() /// public Plugin Get(string name, Version version) => this.innerCatalog.Get(name, version); - private static void CopyDirectory(string source, string destination) + internal static void CopyPluginFiles(string source, string destination) { Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) { - var destinationFile = Path.Combine(destination, Path.GetFileName(file)); - if (File.Exists(destinationFile)) + var fileName = Path.GetFileName(file); + if (IsManagementFile(fileName)) { continue; } - File.Copy(file, destinationFile); + var destinationFile = Path.Combine(destination, fileName); + if (!File.Exists(destinationFile)) + { + File.Copy(file, destinationFile); + } + } + + foreach (var subDir in Directory.GetDirectories(source)) + { + var directoryName = Path.GetFileName(subDir); + if (IsWorkingDirectory(directoryName)) + { + continue; + } + + CopyDirectory(subDir, Path.Combine(destination, directoryName)); + } + } + + private static bool IsWorkingDirectory(string directoryName) + => directoryName.EndsWith(".backup", StringComparison.OrdinalIgnoreCase) + || directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase) + || directoryName.Contains(".installing-", StringComparison.OrdinalIgnoreCase); + + private static bool IsManagementFile(string fileName) + => fileName.Equals("nuget-manifest.json", StringComparison.OrdinalIgnoreCase) + || fileName.StartsWith("nuget-manifest.json.tmp-", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".pending-delete", StringComparison.OrdinalIgnoreCase) + || fileName.Contains(".pending-delete.tmp-", StringComparison.OrdinalIgnoreCase); + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) + { + var destinationFile = Path.Combine(destination, Path.GetFileName(file)); + if (!File.Exists(destinationFile)) + { + File.Copy(file, destinationFile); + } } foreach (var subDir in Directory.GetDirectories(source)) { diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 552ab054..80044091 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -1,9 +1,9 @@ using System.IO; -using System.IO.Compression; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; +using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; @@ -50,9 +50,6 @@ public sealed class NuGetPluginService : IDisposable ["TesseractOcr"] = "WindowTranslator.Plugin.TesseractOCRPlugin", }; - private static readonly string UserPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); - private static readonly string ManifestPath = Path.Combine(UserPluginsDir, "nuget-manifest.json"); - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -63,15 +60,32 @@ public sealed class NuGetPluginService : IDisposable private readonly HttpClient httpClient; private readonly ILogger logger; + private readonly string userPluginsDir; + private readonly string manifestPath; + private readonly bool ownsHttpClient; + private readonly SemaphoreSlim operationLock = new(1, 1); private string? searchUrl; public NuGetPluginService(ILogger logger) + : this( + logger, + new HttpClient { Timeout = TimeSpan.FromSeconds(30) }, + Path.Combine(PathUtility.UserDir, "plugins"), + ownsHttpClient: true) + { + } + + internal NuGetPluginService( + ILogger logger, + HttpClient httpClient, + string userPluginsDir, + bool ownsHttpClient = false) { - this.httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(30), - }; this.logger = logger; + this.httpClient = httpClient; + this.userPluginsDir = Path.GetFullPath(userPluginsDir); + this.manifestPath = Path.Combine(this.userPluginsDir, "nuget-manifest.json"); + this.ownsHttpClient = ownsHttpClient; } /// @@ -80,13 +94,22 @@ public NuGetPluginService(ILogger logger) public async Task InstallLatestPackageAsync(string packageId, IProgress? progress = null, CancellationToken cancellationToken = default) { var versionsUrl = $"{NuGetFlatContainerBase}/{packageId.ToLowerInvariant()}/index.json"; - var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); + using var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var versions = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false); - var latestVersion = versions?.Versions?.LastOrDefault() + var parsedVersions = versions?.Versions? + .Select(NuGetVersion.Parse) + .OrderByDescending(v => v) + .ToArray() ?? []; + var latestVersion = parsedVersions.FirstOrDefault(v => !v.IsPrerelease) + ?? parsedVersions.FirstOrDefault() ?? throw new InvalidOperationException($"パッケージ {packageId} のバージョン一覧を取得できませんでした。"); - await InstallPackageAsync(packageId, latestVersion, progress, cancellationToken).ConfigureAwait(false); + await InstallPackageAsync( + packageId, + latestVersion.ToNormalizedString(), + progress, + cancellationToken).ConfigureAwait(false); } /// @@ -151,12 +174,36 @@ public async Task AutoInstallFromSettingsAsync(string settingsPath, Cancellation { await InstallLatestPackageAsync(packageId, cancellationToken: cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (HttpRequestException ex) + { + this.logger.LogWarning( + ex, + "NuGetへ接続できないため、プラグインの自動インストールを中断します: {PackageId}", + packageId); + break; + } + catch (TaskCanceledException ex) + { + this.logger.LogWarning( + ex, + "NuGet接続がタイムアウトしたため、プラグインの自動インストールを中断します: {PackageId}", + packageId); + break; + } catch (Exception ex) { this.logger.LogWarning(ex, "プラグイン {PackageId} の自動インストールに失敗しました。", packageId); } } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { this.logger.LogWarning(ex, "設定からのプラグイン自動インストール処理中にエラーが発生しました。"); @@ -176,10 +223,10 @@ public async Task> SearchPackagesAsync(Cancellat var url = $"{this.searchUrl}?q=tags:{PluginTag}&take=100&semVerLevel=2.0.0&prerelease=false"; this.logger.LogDebug("NuGet検索URL: {Url}", url); - var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("NuGet検索結果のデシリアライズに失敗しました。"); @@ -201,60 +248,100 @@ public async Task> SearchPackagesAsync(Cancellat /// public async Task InstallPackageAsync(string packageId, string version, IProgress? progress = null, CancellationToken cancellationToken = default) { - var packageIdLower = packageId.ToLowerInvariant(); - var versionLower = version.ToLowerInvariant(); - var nupkgUrl = $"{NuGetFlatContainerBase}/{packageIdLower}/{versionLower}/{packageIdLower}.{versionLower}.nupkg"; - - this.logger.LogInformation("パッケージをダウンロード中: {PackageId} {Version}", packageId, version); - - // 一時ディレクトリにダウンロード - var tempDir = Path.Combine(Path.GetTempPath(), "WindowTranslatorPlugins", packageId); - Directory.CreateDirectory(tempDir); - var tempNupkgPath = Path.Combine(tempDir, $"{packageIdLower}.{versionLower}.nupkg"); - + var operationId = Guid.NewGuid().ToString("N"); + var targetDir = GetPackageDirectory(packageId); + var stagingDir = Path.Combine(this.userPluginsDir, $".{packageId}.installing-{operationId}"); + var backupDir = $"{targetDir}.backup-{operationId}"; + var pendingDeleteMarker = GetPendingDeleteMarker(packageId); + var markerWasPresent = false; + string? markerContent = null; + var targetMoved = false; + var stagingMoved = false; + await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await DownloadFileAsync(nupkgUrl, tempNupkgPath, progress, cancellationToken).ConfigureAwait(false); + markerWasPresent = File.Exists(pendingDeleteMarker); + markerContent = markerWasPresent + ? await File.ReadAllTextAsync(pendingDeleteMarker, cancellationToken).ConfigureAwait(false) + : null; + Directory.CreateDirectory(this.userPluginsDir); + var installer = new NuGetPackageInstaller(this.httpClient, this.logger); + await installer.InstallAsync( + packageId, + version, + stagingDir, + progress, + cancellationToken).ConfigureAwait(false); + + var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = AddOrUpdatePackage(currentManifest, packageId, version); + + if (markerWasPresent) + { + File.Delete(pendingDeleteMarker); + } - // ターゲットディレクトリを準備 - var targetDir = Path.Combine(UserPluginsDir, packageId); - // 古いファイルをバックアップして削除する前に一時フォルダへ移動 - var backupDir = $"{targetDir}.backup"; if (Directory.Exists(targetDir)) { - if (Directory.Exists(backupDir)) - Directory.Delete(backupDir, recursive: true); Directory.Move(targetDir, backupDir); + targetMoved = true; } - Directory.CreateDirectory(targetDir); + Directory.Move(stagingDir, targetDir); + stagingMoved = true; + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); try { - // nupkgを展開して必要なDLLをコピー - ExtractPluginDlls(tempNupkgPath, targetDir); - this.logger.LogInformation("パッケージの展開完了: {PackageId} -> {TargetDir}", packageId, targetDir); + if (Directory.Exists(backupDir)) + { + Directory.Delete(backupDir, recursive: true); + } } - catch + catch (Exception ex) { - // 失敗したら元に戻す - Directory.Delete(targetDir, recursive: true); - if (Directory.Exists(backupDir)) - Directory.Move(backupDir, targetDir); - throw; + this.logger.LogWarning(ex, "プラグインバックアップの削除に失敗しました: {BackupDir}", backupDir); } - // バックアップを削除 - if (Directory.Exists(backupDir)) - Directory.Delete(backupDir, recursive: true); - - // マニフェストを更新 - await UpdateManifestAsync(packageId, version, cancellationToken).ConfigureAwait(false); + this.logger.LogInformation( + "パッケージのインストール完了: {PackageId} {Version} -> {TargetDir}", + packageId, + version, + targetDir); + } + catch + { + try + { + if (stagingMoved && Directory.Exists(targetDir)) + { + Directory.Move(targetDir, stagingDir); + } + if (targetMoved && Directory.Exists(backupDir)) + { + Directory.Move(backupDir, targetDir); + } + if (markerWasPresent && !File.Exists(pendingDeleteMarker)) + { + await File.WriteAllTextAsync( + pendingDeleteMarker, + markerContent ?? packageId, + CancellationToken.None).ConfigureAwait(false); + } + } + catch (Exception rollbackException) + { + this.logger.LogError( + rollbackException, + "プラグイン {PackageId} のインストール失敗後の復旧に失敗しました。", + packageId); + } + throw; } finally { - // 一時ファイルを削除 - try { File.Delete(tempNupkgPath); } catch { /* ignore */ } + TryDeleteDirectory(stagingDir); + this.operationLock.Release(); } } @@ -263,17 +350,43 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres /// public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) { - this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + _ = GetPackageDirectory(packageId); + Directory.CreateDirectory(this.userPluginsDir); - var targetDir = Path.Combine(UserPluginsDir, packageId); - // 実行中のDLLがロックされている可能性があるため、削除マーカーを置く - var pendingDeleteMarker = Path.Combine(UserPluginsDir, $"{packageId}.pending-delete"); - await File.WriteAllTextAsync(pendingDeleteMarker, packageId, cancellationToken).ConfigureAwait(false); + var pendingDeleteMarker = GetPendingDeleteMarker(packageId); + var markerAlreadyExisted = File.Exists(pendingDeleteMarker); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = RemovePackage(manifest, packageId); - // マニフェストから削除 - await RemoveFromManifestAsync(packageId, cancellationToken).ConfigureAwait(false); + try + { + await WriteTextAtomicallyAsync( + pendingDeleteMarker, + packageId, + cancellationToken).ConfigureAwait(false); + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + } + catch + { + if (!markerAlreadyExisted) + { + TryDeleteFile(pendingDeleteMarker); + } + throw; + } - this.logger.LogInformation("パッケージ {PackageId} をアンインストールキューに追加しました。再起動後に完全に削除されます。", packageId); + this.logger.LogInformation( + "パッケージ {PackageId} をアンインストールキューに追加しました。再起動後に完全に削除されます。", + packageId); + } + finally + { + this.operationLock.Release(); + } } /// @@ -281,27 +394,43 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc /// public void ProcessPendingDeletions() { - if (!Directory.Exists(UserPluginsDir)) - return; - - foreach (var markerFile in Directory.GetFiles(UserPluginsDir, "*.pending-delete")) + this.operationLock.Wait(); + try { - try + if (!Directory.Exists(this.userPluginsDir)) { - var packageId = File.ReadAllText(markerFile); - var targetDir = Path.Combine(UserPluginsDir, packageId); - if (Directory.Exists(targetDir)) - { - Directory.Delete(targetDir, recursive: true); - this.logger.LogInformation("ペンディング削除を処理: {PackageId}", packageId); - } - File.Delete(markerFile); + return; } - catch (Exception ex) + + foreach (var markerFile in Directory.GetFiles(this.userPluginsDir, "*.pending-delete")) { - this.logger.LogWarning(ex, "ペンディング削除の処理に失敗: {MarkerFile}", markerFile); + try + { + var packageId = File.ReadAllText(markerFile); + var markerPackageId = Path.GetFileName(markerFile)[..^".pending-delete".Length]; + if (!packageId.Equals(markerPackageId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("削除マーカーのパッケージIDがファイル名と一致しません。"); + } + + var targetDir = GetPackageDirectory(packageId); + if (Directory.Exists(targetDir)) + { + Directory.Delete(targetDir, recursive: true); + this.logger.LogInformation("ペンディング削除を処理: {PackageId}", packageId); + } + File.Delete(markerFile); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "ペンディング削除の処理に失敗: {MarkerFile}", markerFile); + } } } + finally + { + this.operationLock.Release(); + } } /// @@ -315,9 +444,9 @@ public async Task> GetInstalledPackagesAsync private async Task GetSearchUrlAsync(CancellationToken cancellationToken) { - var response = await this.httpClient.GetAsync(NuGetServiceIndexUrl, cancellationToken).ConfigureAwait(false); + using var response = await this.httpClient.GetAsync(NuGetServiceIndexUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var index = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("NuGetサービスインデックスのデシリアライズに失敗しました。"); @@ -328,147 +457,165 @@ private async Task GetSearchUrlAsync(CancellationToken cancellationToken return searchEntry.Id ?? throw new InvalidOperationException("NuGet検索サービスURLが空です。"); } - private async Task DownloadFileAsync(string url, string destPath, IProgress? progress, CancellationToken cancellationToken) + private static InstalledManifest AddOrUpdatePackage( + InstalledManifest manifest, + string packageId, + string version) { - using var response = await this.httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1; - var downloadedBytes = 0L; - - using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var fileStream = File.Create(destPath); - var buffer = new byte[81920]; - int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + var packages = manifest.Packages.ToList(); + var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + var newEntry = new InstalledPackageInfo(packageId, version, DateTime.UtcNow); + if (existing >= 0) { - await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); - downloadedBytes += bytesRead; - if (totalBytes > 0) - { - progress?.Report((double)downloadedBytes / totalBytes); - } + packages[existing] = newEntry; + } + else + { + packages.Add(newEntry); } - } - private static void ExtractPluginDlls(string nupkgPath, string targetDir) - { - using var archive = ZipFile.OpenRead(nupkgPath); + return new InstalledManifest([.. packages]); + } - // 最適なTFMのlib/エントリを探す - var libEntries = archive.Entries - .Where(e => e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrEmpty(e.Name) - && e.Name != "_._") - .ToList(); + private static InstalledManifest RemovePackage(InstalledManifest manifest, string packageId) + => new([.. manifest.Packages.Where(p => + !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); - if (!libEntries.Any()) + private async Task LoadManifestAsync(CancellationToken cancellationToken) + { + if (!File.Exists(this.manifestPath)) { - throw new InvalidOperationException("パッケージにlib/フォルダが見つかりませんでした。"); + return new InstalledManifest([]); } - // TFMを選択(net10.0-windows > net10.0 > net9.0-windows > net9.0 > ... の優先順位) - var tfmGroups = libEntries - .GroupBy(e => e.FullName.Split('/')[1]) - .ToList(); - - var selectedTfm = SelectBestTfm([.. tfmGroups.Select(g => g.Key)]); - if (selectedTfm is null) + try { - throw new InvalidOperationException("互換性のあるターゲットフレームワークが見つかりませんでした。"); + await using var fs = File.OpenRead(this.manifestPath); + return await JsonSerializer.DeserializeAsync( + fs, + JsonOptions, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException("プラグインマニフェストが空です。"); } - - var selectedEntries = tfmGroups.First(g => g.Key == selectedTfm); - - foreach (var entry in selectedEntries) + catch (Exception ex) when (ex is not OperationCanceledException) { - var destPath = Path.Combine(targetDir, entry.Name); - entry.ExtractToFile(destPath, overwrite: true); + this.logger.LogWarning(ex, "プラグインマニフェストの読み込みに失敗しました。"); + throw new InvalidOperationException("プラグインマニフェストを読み込めませんでした。", ex); } } - private static string? SelectBestTfm(string[] tfms) + private async Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) { - // TFMの優先度リスト(.NET 10から降順、Windows版を優先) - var orderedPrefixes = new[] + Directory.CreateDirectory(this.userPluginsDir); + var temporaryPath = $"{this.manifestPath}.tmp-{Guid.NewGuid():N}"; + try { - "net10.0-windows", - "net10.0", - "net9.0-windows", - "net9.0", - "net8.0-windows", - "net8.0", - "net7.0-windows", - "net7.0", - "net6.0-windows", - "net6.0", - "netstandard2.1", - "netstandard2.0", - }; + await using (var fs = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true)) + { + await JsonSerializer.SerializeAsync(fs, manifest, JsonOptions, cancellationToken).ConfigureAwait(false); + await fs.FlushAsync(cancellationToken).ConfigureAwait(false); + } - foreach (var prefix in orderedPrefixes) + ReplaceFile(temporaryPath, this.manifestPath); + } + finally { - // 完全一致または前方一致(例: net10.0-windows10.0.20348.0) - var match = tfms.OrderByDescending(t => t).FirstOrDefault(t => - t.Equals(prefix, StringComparison.OrdinalIgnoreCase) - || t.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase) - || t.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase)); - if (match is not null) - return match; + TryDeleteFile(temporaryPath); } + } - return tfms.FirstOrDefault(); + private static async Task WriteTextAtomicallyAsync( + string destinationPath, + string content, + CancellationToken cancellationToken) + { + var temporaryPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}"; + try + { + await File.WriteAllTextAsync(temporaryPath, content, cancellationToken).ConfigureAwait(false); + ReplaceFile(temporaryPath, destinationPath); + } + finally + { + TryDeleteFile(temporaryPath); + } } - private async Task UpdateManifestAsync(string packageId, string version, CancellationToken cancellationToken) + private static void ReplaceFile(string sourcePath, string destinationPath) + => File.Move(sourcePath, destinationPath, overwrite: true); + + private string GetPackageDirectory(string packageId) { - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var packages = manifest.Packages.ToList(); - var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); - var newEntry = new InstalledPackageInfo(packageId, version, DateTime.UtcNow); - if (existing >= 0) - packages[existing] = newEntry; - else - packages.Add(newEntry); + if (string.IsNullOrWhiteSpace(packageId) + || packageId is "." or ".." + || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || packageId.Contains(Path.DirectorySeparatorChar) + || packageId.Contains(Path.AltDirectorySeparatorChar)) + { + throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + } + + var root = this.userPluginsDir + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var packageDirectory = Path.GetFullPath(Path.Combine(root, packageId)); + if (!packageDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + } - await SaveManifestAsync(new InstalledManifest([.. packages]), cancellationToken).ConfigureAwait(false); + return packageDirectory; } - private async Task RemoveFromManifestAsync(string packageId, CancellationToken cancellationToken) + private string GetPendingDeleteMarker(string packageId) { - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var packages = manifest.Packages.Where(p => !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)).ToList(); - await SaveManifestAsync(new InstalledManifest([.. packages]), cancellationToken).ConfigureAwait(false); + _ = GetPackageDirectory(packageId); + return Path.Combine(this.userPluginsDir, $"{packageId}.pending-delete"); } - private async Task LoadManifestAsync(CancellationToken cancellationToken) + private static void TryDeleteDirectory(string directory) { try { - if (File.Exists(ManifestPath)) + if (Directory.Exists(directory)) { - using var fs = File.OpenRead(ManifestPath); - var manifest = await JsonSerializer.DeserializeAsync(fs, JsonOptions, cancellationToken).ConfigureAwait(false); - return manifest ?? new InstalledManifest([]); + Directory.Delete(directory, recursive: true); } } - catch (Exception ex) + catch { - this.logger.LogWarning(ex, "プラグインマニフェストの読み込みに失敗しました。"); + // 後始末の失敗は元の処理結果へ影響させない } - return new InstalledManifest([]); } - private static async Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) + private static void TryDeleteFile(string path) { - Directory.CreateDirectory(UserPluginsDir); - using var fs = File.Create(ManifestPath); - await JsonSerializer.SerializeAsync(fs, manifest, JsonOptions, cancellationToken).ConfigureAwait(false); + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // 後始末の失敗は元の処理結果へ影響させない + } } public void Dispose() { - this.httpClient.Dispose(); + if (this.ownsHttpClient) + { + this.httpClient.Dispose(); + } + this.operationLock.Dispose(); } } diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 527e005a..8059f580 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; +using NuGet.Versioning; using WindowTranslator.Properties; using Wpf.Ui; using Wpf.Ui.Extensions; @@ -191,14 +192,13 @@ await this.dialogService.ShowAlertAsync( private static bool IsNewerVersion(string latestVersion, string installedVersion) { - try - { - return Version.Parse(latestVersion) > Version.Parse(installedVersion); - } - catch + if (NuGetVersion.TryParse(latestVersion, out var latest) + && NuGetVersion.TryParse(installedVersion, out var installed)) { - return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; + return latest > installed; } + + return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; } } diff --git a/WindowTranslator/Properties/AssemblyInfo.cs b/WindowTranslator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..cbbe402c --- /dev/null +++ b/WindowTranslator/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WindowTranslator.Tests")] diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index c34e6197..d1caa9a4 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -51,6 +51,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/docs/plugin.md b/docs/plugin.md index cc9f31c6..bcbad419 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -30,7 +30,7 @@ cd WindowTranslator.Plugin.YourPlugin YourName 説明文 - windowtranslator-plugin + $(PackageTags);windowtranslator-plugin MIT @@ -112,9 +112,21 @@ public class MyTranslateModule : ITranslateModule { ... } - Windows: `%USERPROFILE%\.wt\plugins\{PackageId}\` +## アプリからインストールする + +1. WindowTranslator の設定を開きます。 +2. 「プラグインストア」タブを選択します。 +3. 利用するプラグインの「インストール」を選択します。 +4. インストール完了後に WindowTranslator を再起動します。 + +NuGetパッケージで宣言されたランタイム依存関係も再帰的に取得されます。 +同じ依存パッケージに両立しないバージョン条件がある場合は、既存の +プラグイン配置を変更せずにインストールを中止します。 + ## 注意事項 - プラグインは .NET 10 以上をターゲットにしてください - `true` を必ず設定してください - ホスト側で既に提供されているパッケージは `ExcludeAssets="runtime"` を設定し、DLL を重複させないようにしてください -- プラグインに必要な独自の依存 DLL はすべて `lib/net10.0/` フォルダに含めてください +- 通常のランタイム依存は `PackageReference` として宣言してください +- パッケージ固有の追加ファイルは、実行時に必要な相対ディレクトリを保って `lib/net10.0/` に含めてください From 5fa132bfe396c09a959c5690f3a2eb8fb23e46a2 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sat, 1 Aug 2026 02:56:25 +0900 Subject: [PATCH 07/43] Address NuGet plugin lifecycle and packaging feedback --- .github/workflows/dotnet-desktop.yml | 22 +- .github/workflows/dotnet-package.yml | 42 ++- ColorThief/ColorThief/ColorThief.csproj | 4 + Plugins/Directory.Build.props | 6 +- Plugins/Directory.Build.targets | 12 +- ...tor.Plugin.BergamotTranslatorPlugin.csproj | 2 + ...wTranslator.Plugin.ColorThiefPlugin.csproj | 2 + ...nslator.Plugin.DeepLTranslatePlugin.csproj | 2 + ...WindowTranslator.Plugin.DummyPlugin.csproj | 5 + .../WindowTranslator.Plugin.FoMPlugin.csproj | 2 + ...anslator.Plugin.GitHubCopilotPlugin.csproj | 2 + ...dowTranslator.Plugin.GoogleAIPlugin.csproj | 2 + ...lator.Plugin.GoogleAppsSctiptPlugin.csproj | 2 + .../WindowTranslator.Plugin.LLMPlugin.csproj | 2 + ...indowTranslator.Plugin.OneOcrPlugin.csproj | 2 + ...WindowTranslator.Plugin.PLaMoPlugin.csproj | 6 +- ...ranslator.Plugin.TesseractOCRPlugin.csproj | 2 + .../NuGetPluginServiceTests.cs | 355 ++++++++++++++++-- .../UserSettingsConfigurationTests.cs | 69 ++++ .../PluginStore/NuGetPackageInstaller.cs | 68 +++- .../Modules/PluginStore/NuGetPluginCatalog.cs | 152 ++++++-- .../Modules/PluginStore/NuGetPluginService.cs | 351 +++++------------ .../PluginStore/PluginStoreViewModel.cs | 24 +- WindowTranslator/Program.cs | 89 ++++- .../Properties/Resources.Designer.cs | 258 ++++++------- WindowTranslator/Properties/Resources.ar.resx | 2 +- WindowTranslator/Properties/Resources.cs.resx | 2 +- WindowTranslator/Properties/Resources.de.resx | 2 +- WindowTranslator/Properties/Resources.en.resx | 2 +- WindowTranslator/Properties/Resources.es.resx | 2 +- WindowTranslator/Properties/Resources.fa.resx | 2 +- .../Properties/Resources.fil.resx | 2 +- WindowTranslator/Properties/Resources.fr.resx | 2 +- WindowTranslator/Properties/Resources.hi.resx | 4 +- WindowTranslator/Properties/Resources.hu.resx | 2 +- WindowTranslator/Properties/Resources.id.resx | 2 +- WindowTranslator/Properties/Resources.ko.resx | 2 +- WindowTranslator/Properties/Resources.ms.resx | 2 +- WindowTranslator/Properties/Resources.pl.resx | 2 +- .../Properties/Resources.pt-BR.resx | 2 +- WindowTranslator/Properties/Resources.resx | 2 +- WindowTranslator/Properties/Resources.ru.resx | 2 +- WindowTranslator/Properties/Resources.th.resx | 2 +- WindowTranslator/Properties/Resources.tr.resx | 2 +- WindowTranslator/Properties/Resources.vi.resx | 2 +- .../Properties/Resources.zh-CN.resx | 2 +- .../Properties/Resources.zh-TW.resx | 2 +- docs/plugin.md | 19 +- 48 files changed, 1016 insertions(+), 532 deletions(-) create mode 100644 WindowTranslator.Tests/UserSettingsConfigurationTests.cs diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 234fe11c..95f68e4c 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -125,9 +125,6 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 - - uses: Jimver/cuda-toolkit@v0.2.30 - with: - cuda: '12.9.0' - run: | dotnet publish WindowTranslator -c Release -o publish --sc ${{ matrix.self }} ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` @@ -137,14 +134,27 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - $folders = Get-ChildItem -Path "Plugins" -Directory | Where-Object { $_.Name -notmatch "Dummy" -and $_.Name -notmatch "Tests?" } - for ($i = 0; $i -lt $folders.Count; $i++) { - dotnet publish $folders[$i].FullName -c Release -o "publish\plugins\$($folders[$i].Name)" ` + $projects = Get-ChildItem -Path "Plugins\WindowTranslator.Plugin.*\*.csproj" | + Where-Object { $_.Directory.Name -notmatch "Dummy" -and $_.Directory.Name -notmatch "Tests?" } + foreach ($project in $projects) { + $propertyOutput = dotnet msbuild $project.FullName -nologo -getProperty:ExcludeFromAppBundle + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $excludeFromAppBundle = ($propertyOutput | Select-Object -Last 1).Trim() + if ($excludeFromAppBundle -eq "true") { + Write-Host "Skip app bundle: $($project.Name)" + continue + } + dotnet publish $project.FullName -c Release -o "publish\plugins\$($project.Directory.Name)" ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } } - uses: actions/download-artifact@v8 if: ${{ needs.docs.result == 'success' }} diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index 32314746..685cafa9 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -34,16 +34,36 @@ jobs: -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - Get-ChildItem Plugins\WindowTranslator.Plugin.*\*.csproj | - Where-Object { $_.Directory.Name -notlike '*.Tests' } | - ForEach-Object { - dotnet pack $_.FullName -c Release -o pack ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + dotnet pack ColorThief\ColorThief -c Release -o pack ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $projects = Get-ChildItem Plugins\WindowTranslator.Plugin.*\*.csproj | + Where-Object { $_.Directory.Name -notlike '*.Tests' } + foreach ($project in $projects) { + $propertyOutput = dotnet msbuild $project.FullName -nologo -getProperty:IsPackable + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $isPackable = ($propertyOutput | Select-Object -Last 1).Trim() + if ($isPackable -ne "true") { + Write-Host "Skip NuGet package: $($project.Name)" + continue + } + dotnet pack $project.FullName -c Release -o pack ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } + } dotnet nuget push pack\*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/ColorThief/ColorThief/ColorThief.csproj b/ColorThief/ColorThief/ColorThief.csproj index 556393bf..bf9a9540 100644 --- a/ColorThief/ColorThief/ColorThief.csproj +++ b/ColorThief/ColorThief/ColorThief.csproj @@ -2,6 +2,10 @@ net10.0-windows10.0.20348.0 + WindowTranslator.ColorThief + WindowTranslator ColorThief Support Library + Color extraction support library for the WindowTranslator ColorThief plugin. + $(PackageTags);ColorThief enable enable StudioFreesia.ColorThief diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index 3295561e..e50f2937 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -8,10 +8,8 @@ - https://github.com/Freeesia/WindowTranslator - Freeesia - - false + false false diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index ed78c408..f880e7be 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -2,16 +2,20 @@ - + $(PackageTags);windowtranslator-plugin - $(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage + $(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage - + <_PluginWinX64RuntimeAsset Include="$(TargetDir)runtimes\win-x64\**\*" /> <_PluginWinRuntimeAsset Include="$(TargetDir)runtimes\win\**\*" /> diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj index b13dc3e6..a82c827c 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj @@ -2,6 +2,8 @@ net10.0 + WindowTranslator Bergamot Translator Plugin + Offline neural machine translation for WindowTranslator using Bergamot. enable enable true diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj index 4fe79cd6..3d9529ea 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj @@ -1,6 +1,8 @@ net10.0-windows10.0.20348.0 + WindowTranslator ColorThief Plugin + Detects readable foreground and background colors for translated text. diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index 8c02a33f..96f41f02 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -1,6 +1,8 @@  + WindowTranslator DeepL Translator Plugin + Translation for WindowTranslator using the DeepL API. true diff --git a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj index 61718a1f..7fb550e8 100644 --- a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj @@ -1,3 +1,8 @@ + + + false + + diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index e0c9fe00..f4b22776 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -1,6 +1,8 @@  net10.0-windows10.0.20348.0 + WindowTranslator Fields of Mistria Filter Plugin + Context-aware translation filtering for Fields of Mistria. true true diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index e9ea1352..428fe634 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -1,6 +1,8 @@  net10.0-windows10.0.20348.0 + WindowTranslator GitHub Copilot Translator Plugin + Translation for WindowTranslator using GitHub Copilot. true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index e15c28c5..beac88eb 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -2,6 +2,8 @@ net10.0-windows10.0.20348.0 + WindowTranslator Google AI Plugin + Translation, OCR, and text correction for WindowTranslator using Google AI. true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index 6177f6ba..5eb0def3 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -1,5 +1,7 @@  + WindowTranslator Google Apps Script Translator Plugin + Translation for WindowTranslator through Google Apps Script. true diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index a37c1d24..77b4ff35 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -1,6 +1,8 @@  net10.0-windows10.0.20348.0 + WindowTranslator LLM Plugin + Translation, OCR, and text correction through OpenAI-compatible language models. true diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj index e6bb20f4..87c87e52 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj @@ -1,6 +1,8 @@  net10.0-windows10.0.20348.0 + WindowTranslator OneOCR Plugin + OCR for WindowTranslator using the Windows OneOCR engine. true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index bc77e53a..41a9cb76 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -2,11 +2,15 @@ net10.0 + WindowTranslator PLaMo Translator Plugin + Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. true + false + true - + diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index b0e61f0e..75de048b 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -2,6 +2,8 @@ net10.0-windows10.0.20348.0 + WindowTranslator Tesseract OCR Plugin + OCR for WindowTranslator using the Tesseract engine. true diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index c87f2313..d1eade8e 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -5,6 +5,9 @@ using System.Text.Json; using System.Xml.Linq; using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Versioning; +using Weikio.PluginFramework.Catalogs; +using WindowTranslator.Modules; using WindowTranslator.Modules.PluginStore; namespace WindowTranslator.Tests; @@ -167,7 +170,7 @@ await File.ReadAllTextAsync( } [Fact] - public async Task ReinstallAfterUninstallRemovesThePendingDeletionMarker() + public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstall() { var testDirectory = CreateTestDirectory(); try @@ -201,13 +204,10 @@ public async Task ReinstallAfterUninstallRemovesThePendingDeletionMarker() await service.InstallPackageAsync("Root.Plugin", "1.0.0"); await service.UninstallPackageAsync("Root.Plugin"); - var markerPath = Path.Combine(testDirectory, "Root.Plugin.pending-delete"); - Assert.True(File.Exists(markerPath)); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(await service.GetInstalledPackagesAsync()); await service.InstallPackageAsync("Root.Plugin", "2.0.0"); - Assert.False(File.Exists(markerPath)); - - service.ProcessPendingDeletions(); Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); var installed = Assert.Single(await service.GetInstalledPackagesAsync()); Assert.Equal("2.0.0", installed.Version); @@ -218,6 +218,56 @@ public async Task ReinstallAfterUninstallRemovesThePendingDeletionMarker() } } + [Fact] + public async Task UninstallRestoresManagedFilesWhenManifestUpdateFails() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); + using (var manifestLock = new FileStream( + manifestPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + var exception = await Record.ExceptionAsync( + () => service.UninstallPackageAsync("Root.Plugin")); + Assert.True( + exception is IOException or UnauthorizedAccessException, + $"Unexpected exception: {exception}"); + } + + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(Directory.GetDirectories( + testDirectory, + "Root.Plugin.uninstalling-*")); + var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + Assert.Equal("1.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task DependencyWithIncompatibleLibStillInstallsCompatibleNativeAssets() { @@ -318,7 +368,136 @@ await File.ReadAllTextAsync( } [Fact] - public void CatalogCopyIncludesLegacyRootFilesAndSkipsManagementState() + public async Task ExistingManifestLoadsInstalledPackages() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new + { + Packages = new[] + { + new + { + Id = "Legacy.Plugin", + Version = "1.0.0", + }, + }, + })); + + using var handler = new InMemoryNuGetHandler(); + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + + Assert.Equal("Legacy.Plugin", installed.Id); + Assert.Equal("1.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallRejectsPackageRequiringNewerHostAbstractions() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [ + new( + "WindowTranslator.Abstractions", + "[2.0.0, 3.0.0)", + Exclude: "Runtime"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }); + + var exception = await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + + Assert.Contains("WindowTranslator.Abstractions", exception.Message); + Assert.Contains("[2.0.0, 3.0.0)", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(await service.GetInstalledPackagesAsync()); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallAcceptsCompatibleHostAbstractionsWithoutDownloadingIt() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [ + new( + "WindowTranslator.Abstractions", + "[1.0.0, 2.0.0)"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.Contains( + "windowtranslator.abstractions", + StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() { var sourceDirectory = CreateTestDirectory(); var destinationDirectory = CreateTestDirectory(); @@ -327,41 +506,114 @@ public void CatalogCopyIncludesLegacyRootFilesAndSkipsManagementState() File.WriteAllText(Path.Combine(sourceDirectory, "Legacy.Plugin.dll"), "legacy"); File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json"), "{}"); File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json.tmp-test"), "{}"); - File.WriteAllText(Path.Combine(sourceDirectory, "Root.Plugin.pending-delete"), "Root.Plugin"); - File.WriteAllText( - Path.Combine(sourceDirectory, "Root.Plugin.pending-delete.tmp-test"), - "Root.Plugin"); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin")); - File.WriteAllText( - Path.Combine(sourceDirectory, "Root.Plugin", "Root.Plugin.dll"), - "plugin"); + var sourcePluginPath = + Path.Combine(sourceDirectory, "Root.Plugin", "Root.Plugin.dll"); + File.WriteAllText(sourcePluginPath, "plugin-new"); + var unchangedSourcePath = + Path.Combine(sourceDirectory, "Root.Plugin", "Unchanged.dll"); + File.WriteAllText(unchangedSourcePath, "unchanged"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Empty.Plugin")); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.backup-test")); File.WriteAllText( Path.Combine(sourceDirectory, "Root.Plugin.backup-test", "old.dll"), "old"); Directory.CreateDirectory(Path.Combine(sourceDirectory, ".Root.Plugin.installing-test")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.uninstalling-test")); Directory.CreateDirectory(Path.Combine(destinationDirectory, "Root.Plugin")); + var destinationPluginPath = + Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"); + File.WriteAllText(destinationPluginPath, "plugin-old"); + var unchangedDestinationPath = + Path.Combine(destinationDirectory, "Root.Plugin", "Unchanged.dll"); + File.WriteAllText(unchangedDestinationPath, "unchanged"); + var unchangedTimestamp = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(unchangedSourcePath, unchangedTimestamp); + File.SetLastWriteTimeUtc(unchangedDestinationPath, unchangedTimestamp); + File.SetCreationTimeUtc(unchangedSourcePath, unchangedTimestamp); + File.SetCreationTimeUtc(unchangedDestinationPath, unchangedTimestamp); + File.SetLastWriteTimeUtc(sourcePluginPath, unchangedTimestamp); + File.SetLastWriteTimeUtc(destinationPluginPath, unchangedTimestamp); + File.SetCreationTimeUtc( + sourcePluginPath, + unchangedTimestamp.AddMinutes(2)); + File.SetCreationTimeUtc( + destinationPluginPath, + unchangedTimestamp.AddMinutes(-2)); File.WriteAllText( - Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"), - "existing"); + Path.Combine(destinationDirectory, "Root.Plugin", "Removed.dll"), + "stale"); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Removed.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Removed.Plugin", "Removed.Plugin.dll"), + "stale"); + File.WriteAllText( + Path.Combine(destinationDirectory, "nuget-manifest.json"), + "{}"); - NuGetPluginCatalog.CopyPluginFiles(sourceDirectory, destinationDirectory); + using var unchangedFileLock = new FileStream( + unchangedDestinationPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory); + using var synchronizedFileLock = new FileStream( + destinationPluginPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory); Assert.True(File.Exists(Path.Combine(destinationDirectory, "Legacy.Plugin.dll"))); Assert.Equal( - "existing", - File.ReadAllText( - Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"))); + "plugin-new", + File.ReadAllText(destinationPluginPath)); + Assert.Equal("unchanged", File.ReadAllText(unchangedDestinationPath)); + Assert.False(File.Exists( + Path.Combine(destinationDirectory, "Root.Plugin", "Removed.dll"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Removed.Plugin"))); + Assert.True(Directory.Exists( + Path.Combine(destinationDirectory, "Empty.Plugin"))); Assert.False(File.Exists(Path.Combine(destinationDirectory, "nuget-manifest.json"))); Assert.False(File.Exists( Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); - Assert.False(File.Exists(Path.Combine(destinationDirectory, "Root.Plugin.pending-delete"))); - Assert.False(File.Exists( - Path.Combine(destinationDirectory, "Root.Plugin.pending-delete.tmp-test"))); Assert.False(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); Assert.False(Directory.Exists( Path.Combine(destinationDirectory, ".Root.Plugin.installing-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.uninstalling-test"))); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + + [Fact] + public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + Directory.Delete(sourceDirectory); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Removed.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Removed.Plugin", "Removed.Plugin.dll"), + "stale"); + + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory); + + Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory)); } finally { @@ -370,6 +622,48 @@ public void CatalogCopyIncludesLegacyRootFilesAndSkipsManagementState() } } + [Fact] + public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() + { + var sourceDirectory = CreateTestDirectory(); + var tempDirectory = CreateTestDirectory(); + try + { + var packageDirectory = Path.Combine(sourceDirectory, "Catalog.Probe"); + Directory.CreateDirectory(packageDirectory); + var testAssemblyPath = typeof(NuGetPluginServiceTests).Assembly.Location; + File.Copy( + testAssemblyPath, + Path.Combine(packageDirectory, Path.GetFileName(testAssemblyPath))); + + var options = new FolderPluginCatalogOptions(); + options.TypeFinderOptions.TypeFinderCriterias.Clear(); + options.TypeFinderOptions.TypeFinderCriterias.Add(new() + { + Query = static (_, type) => + type.Name == nameof(CatalogProbeTranslateModule), + }); + options.PluginLoadContextOptions.AdditionalRuntimePaths = + [AppContext.BaseDirectory]; + var catalog = new NuGetPluginCatalog( + sourceDirectory, + tempDirectory, + options); + + await catalog.Initialize(); + + Assert.True(catalog.IsInitialized); + Assert.Contains( + catalog.GetPlugins(), + plugin => plugin.Type.Name == nameof(CatalogProbeTranslateModule)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(tempDirectory); + } + } + [Fact] public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() { @@ -380,11 +674,15 @@ public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net48"])); } - private static NuGetPluginService CreateService(HttpClient client, string pluginDirectory) + private static NuGetPluginService CreateService( + HttpClient client, + string pluginDirectory, + IReadOnlyDictionary? hostPackageVersions = null) => new( NullLogger.Instance, client, - pluginDirectory); + pluginDirectory, + hostPackageVersions: hostPackageVersions); private static byte[] CreatePackage( string id, @@ -519,3 +817,10 @@ protected override Task SendAsync( } } } + +public sealed class CatalogProbeTranslateModule : ITranslateModule +{ + public ValueTask TranslateAsync(TextInfo[] srcTexts) + => ValueTask.FromResult( + Enumerable.Repeat(string.Empty, srcTexts.Length).ToArray()); +} diff --git a/WindowTranslator.Tests/UserSettingsConfigurationTests.cs b/WindowTranslator.Tests/UserSettingsConfigurationTests.cs new file mode 100644 index 00000000..c3ae70c3 --- /dev/null +++ b/WindowTranslator.Tests/UserSettingsConfigurationTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using WindowTranslator.Modules; +using WindowTranslator.Stores; + +namespace WindowTranslator.Tests; + +public sealed class UserSettingsConfigurationTests +{ + [Fact] + public void UserSettingsIgnoresPluginParametersThatHaveNoLoadedType() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Common:ViewMode"] = nameof(ViewMode.Capture), + ["Targets::Font"] = "Default Font", + ["Targets:game:Font"] = "Test Font", + ["Targets:game:SelectedPlugins:ITranslateModule"] = "MissingTranslator", + ["Targets:game:PluginParams:MissingOptions:ApiKey"] = "secret", + }) + .Build(); + var settings = new UserSettings(); + + new global::ConfigureUserSettings(configuration).Configure(settings); + + Assert.Equal(ViewMode.Capture, settings.Common.ViewMode); + Assert.Equal("Default Font", settings.Targets[string.Empty].Font); + var target = settings.Targets["game"]; + Assert.Equal("Test Font", target.Font); + Assert.Equal( + "MissingTranslator", + target.SelectedPlugins[nameof(ITranslateModule)]); + Assert.Empty(target.PluginParams); + } + + [Fact] + public void InvalidLoadedPluginParameterIsIgnoredWithoutChangingDefaults() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Targets:game:PluginParams:InvalidPluginParam:RetryCount"] = + "not-an-integer", + }) + .Build(); + var options = new InvalidPluginParam(); + var configure = new global::ConfigurePluginParam( + configuration, + new TestProcessInfoStore("game"), + NullLogger>.Instance); + + configure.Configure(options); + + Assert.Equal(7, options.RetryCount); + } + + public sealed class InvalidPluginParam : IPluginParam + { + public int RetryCount { get; set; } = 7; + } + + private sealed class TestProcessInfoStore(string name) : IProcessInfoStore + { + public IntPtr MainWindowHandle => IntPtr.Zero; + + public string Name { get; } = name; + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 51822ef2..208ff456 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -12,7 +12,10 @@ namespace WindowTranslator.Modules.PluginStore; /// /// NuGetパッケージとそのランタイム依存関係を、プラグインフォルダへ展開します。 /// -internal sealed class NuGetPackageInstaller(HttpClient httpClient, ILogger logger) +internal sealed class NuGetPackageInstaller( + HttpClient httpClient, + ILogger logger, + IReadOnlyDictionary? hostPackageVersions = null) { private const string FlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; @@ -39,6 +42,8 @@ internal sealed class NuGetPackageInstaller(HttpClient httpClient, ILogger logge private readonly HttpClient httpClient = httpClient; private readonly ILogger logger = logger; + private readonly IReadOnlyDictionary hostPackageVersions = + hostPackageVersions ?? new Dictionary(StringComparer.OrdinalIgnoreCase); public async Task InstallAsync( string packageId, @@ -173,10 +178,20 @@ await DownloadPackageAsync( packagePath, currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? progress : null, cancellationToken).ConfigureAwait(false); + ValidateHostPackageDependencies( + packagePath, + currentId, + resolvedVersion, + this.hostPackageVersions); artifacts[currentId] = new PackageArtifact(currentId, resolvedVersion, packagePath); foreach (var dependency in ReadRuntimeDependencies(packagePath)) { + if (this.hostPackageVersions.ContainsKey(dependency.Id)) + { + continue; + } + AddConstraint(dependency.Id, currentId, dependency.VersionRange); } } @@ -284,6 +299,14 @@ private async Task DownloadPackageAsync( } private static List ReadRuntimeDependencies(string packagePath) + => ReadDependencies(packagePath, runtimeOnly: true); + + private static List ReadPackageDependencies(string packagePath) + => ReadDependencies(packagePath, runtimeOnly: false); + + private static List ReadDependencies( + string packagePath, + bool runtimeOnly) { using var archive = ZipFile.OpenRead(packagePath); var nuspecEntry = archive.Entries.FirstOrDefault(e => @@ -302,7 +325,8 @@ private static List ReadRuntimeDependencies(string packagePat var result = new List(); result.AddRange(ParseDependencyElements( - dependencies.Elements().Where(e => e.Name.LocalName == "dependency"))); + dependencies.Elements().Where(e => e.Name.LocalName == "dependency"), + runtimeOnly)); var groups = dependencies.Elements() .Where(e => e.Name.LocalName == "group") @@ -327,7 +351,8 @@ private static List ReadRuntimeDependencies(string packagePat frameworkGroups.First(g => string.Equals( g.Framework, selectedFramework, - StringComparison.OrdinalIgnoreCase)).Element.Elements())); + StringComparison.OrdinalIgnoreCase)).Element.Elements(), + runtimeOnly)); return result; } } @@ -337,19 +362,24 @@ private static List ReadRuntimeDependencies(string packagePat || g.Framework.Equals("any", StringComparison.OrdinalIgnoreCase)); if (fallbackGroup.Element is not null) { - result.AddRange(ParseDependencyElements(fallbackGroup.Element.Elements())); + result.AddRange(ParseDependencyElements( + fallbackGroup.Element.Elements(), + runtimeOnly)); return result; } throw new InvalidOperationException("互換性のある依存関係グループが見つかりませんでした。"); } - private static IEnumerable ParseDependencyElements(IEnumerable elements) + private static IEnumerable ParseDependencyElements( + IEnumerable elements, + bool runtimeOnly) { foreach (var element in elements.Where(e => e.Name.LocalName == "dependency")) { var id = element.Attribute("id")?.Value; - if (string.IsNullOrWhiteSpace(id) || !IncludesRuntimeAssets(element)) + if (string.IsNullOrWhiteSpace(id) + || runtimeOnly && !IncludesRuntimeAssets(element)) { continue; } @@ -361,6 +391,32 @@ private static IEnumerable ParseDependencyElements(IEnumerabl } } + private static void ValidateHostPackageDependencies( + string packagePath, + string packageId, + NuGetVersion packageVersion, + IReadOnlyDictionary hostPackageVersions) + { + if (hostPackageVersions.Count == 0) + { + return; + } + + foreach (var dependency in ReadPackageDependencies(packagePath)) + { + if (!hostPackageVersions.TryGetValue(dependency.Id, out var hostVersion) + || dependency.VersionRange.Satisfies(hostVersion)) + { + continue; + } + + throw new InvalidOperationException( + $"プラグイン {packageId} {packageVersion} は " + + $"{dependency.Id} {dependency.VersionRange} を必要としますが、" + + $"実行中のWindowTranslatorが提供するバージョンは {hostVersion} です。"); + } + } + private static bool IncludesRuntimeAssets(XElement dependency) { var excluded = SplitAssets(dependency.Attribute("exclude")?.Value); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 9126a1c6..b1d82876 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -35,17 +35,7 @@ internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalo /// public async Task Initialize() { - // ロック解除のために一時フォルダを削除してからコピー - if (Directory.Exists(this.tempDir)) - { - Directory.Delete(this.tempDir, recursive: true); - } - Directory.CreateDirectory(this.tempDir); - - if (Directory.Exists(this.sourceDir)) - { - CopyPluginFiles(this.sourceDir, this.tempDir); - } + SynchronizePluginFiles(this.sourceDir, this.tempDir); await this.innerCatalog.Initialize().ConfigureAwait(false); } @@ -56,62 +46,154 @@ public async Task Initialize() /// public Plugin Get(string name, Version version) => this.innerCatalog.Get(name, version); - internal static void CopyPluginFiles(string source, string destination) + internal static void SynchronizePluginFiles(string source, string destination) { Directory.CreateDirectory(destination); - foreach (var file in Directory.GetFiles(source)) + var sourceFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sourceDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(source)) + { + CollectSourceEntries( + source, + source, + isRoot: true, + sourceFiles, + sourceDirectories); + } + + foreach (var relativeDirectory in sourceDirectories.OrderBy(GetPathDepth)) { - var fileName = Path.GetFileName(file); - if (IsManagementFile(fileName)) + var destinationDirectory = Path.Combine(destination, relativeDirectory); + if (File.Exists(destinationDirectory)) + { + File.Delete(destinationDirectory); + } + + Directory.CreateDirectory(destinationDirectory); + } + + foreach (var (relativePath, sourceFile) in sourceFiles) + { + var destinationFile = Path.Combine(destination, relativePath); + if (FilesMatch(sourceFile, destinationFile)) { continue; } - var destinationFile = Path.Combine(destination, fileName); - if (!File.Exists(destinationFile)) + if (Directory.Exists(destinationFile)) { - File.Copy(file, destinationFile); + Directory.Delete(destinationFile, recursive: true); } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + CopyFile(sourceFile, destinationFile); } - foreach (var subDir in Directory.GetDirectories(source)) + foreach (var destinationFile in Directory.EnumerateFiles( + destination, + "*", + SearchOption.AllDirectories)) { - var directoryName = Path.GetFileName(subDir); - if (IsWorkingDirectory(directoryName)) + var relativePath = Path.GetRelativePath(destination, destinationFile); + if (!sourceFiles.ContainsKey(relativePath)) { - continue; + File.Delete(destinationFile); } + } - CopyDirectory(subDir, Path.Combine(destination, directoryName)); + foreach (var destinationDirectory in Directory + .EnumerateDirectories(destination, "*", SearchOption.AllDirectories) + .OrderByDescending(GetPathDepth)) + { + var relativePath = Path.GetRelativePath(destination, destinationDirectory); + if (!sourceDirectories.Contains(relativePath)) + { + Directory.Delete(destinationDirectory, recursive: true); + } } } private static bool IsWorkingDirectory(string directoryName) => directoryName.EndsWith(".backup", StringComparison.OrdinalIgnoreCase) || directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase) + || directoryName.Contains(".uninstalling-", StringComparison.OrdinalIgnoreCase) || directoryName.Contains(".installing-", StringComparison.OrdinalIgnoreCase); private static bool IsManagementFile(string fileName) => fileName.Equals("nuget-manifest.json", StringComparison.OrdinalIgnoreCase) - || fileName.StartsWith("nuget-manifest.json.tmp-", StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".pending-delete", StringComparison.OrdinalIgnoreCase) - || fileName.Contains(".pending-delete.tmp-", StringComparison.OrdinalIgnoreCase); - - private static void CopyDirectory(string source, string destination) + || fileName.StartsWith("nuget-manifest.json.tmp-", StringComparison.OrdinalIgnoreCase); + + private static void CollectSourceEntries( + string sourceRoot, + string currentDirectory, + bool isRoot, + Dictionary sourceFiles, + HashSet sourceDirectories) { - Directory.CreateDirectory(destination); - foreach (var file in Directory.GetFiles(source)) + foreach (var file in Directory.EnumerateFiles(currentDirectory)) { - var destinationFile = Path.Combine(destination, Path.GetFileName(file)); - if (!File.Exists(destinationFile)) + if (isRoot && IsManagementFile(Path.GetFileName(file))) { - File.Copy(file, destinationFile); + continue; } + + sourceFiles[Path.GetRelativePath(sourceRoot, file)] = file; + } + + foreach (var subDirectory in Directory.EnumerateDirectories(currentDirectory)) + { + if (isRoot && IsWorkingDirectory(Path.GetFileName(subDirectory))) + { + continue; + } + + var relativePath = Path.GetRelativePath(sourceRoot, subDirectory); + sourceDirectories.Add(relativePath); + CollectSourceEntries( + sourceRoot, + subDirectory, + isRoot: false, + sourceFiles, + sourceDirectories); + } + } + + private static bool FilesMatch(string source, string destination) + { + if (!File.Exists(destination)) + { + return false; } - foreach (var subDir in Directory.GetDirectories(source)) + + var sourceInfo = new FileInfo(source); + var destinationInfo = new FileInfo(destination); + return sourceInfo.Length == destinationInfo.Length + && sourceInfo.LastWriteTimeUtc == destinationInfo.LastWriteTimeUtc + && sourceInfo.CreationTimeUtc == destinationInfo.CreationTimeUtc; + } + + private static void CopyFile(string source, string destination) + { + var temporaryPath = $"{destination}.sync-{Guid.NewGuid():N}"; + try { - CopyDirectory(subDir, Path.Combine(destination, Path.GetFileName(subDir))); + File.Copy(source, temporaryPath); + File.SetCreationTimeUtc(temporaryPath, File.GetCreationTimeUtc(source)); + File.SetLastWriteTimeUtc(temporaryPath, File.GetLastWriteTimeUtc(source)); + File.Move(temporaryPath, destination, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } } } + + private static int GetPathDepth(string path) + => path.Count(character => + character == Path.DirectorySeparatorChar + || character == Path.AltDirectorySeparatorChar); } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 80044091..1d8b08ba 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -1,5 +1,6 @@ using System.IO; using System.Net.Http; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -14,41 +15,6 @@ public sealed class NuGetPluginService : IDisposable { private const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; private const string PluginTag = "windowtranslator-plugin"; - private const string NuGetFlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; - - /// - /// モジュール/パラメータクラス名からNuGetパッケージIDへのマッピング。 - /// アプリバンドルから除外されたプラグインの後方互換性自動インストールに使用します。 - /// - public static readonly IReadOnlyDictionary KnownClassToPackage = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - // WindowTranslator.Plugin.FoMPlugin - ["FoMFilterModule"] = "WindowTranslator.Plugin.FoMPlugin", - ["FoMOptions"] = "WindowTranslator.Plugin.FoMPlugin", - // WindowTranslator.Plugin.PLaMoPlugin - ["PLaMoTranslator"] = "WindowTranslator.Plugin.PLaMoPlugin", - ["PLaMoOptions"] = "WindowTranslator.Plugin.PLaMoPlugin", - // WindowTranslator.Plugin.GitHubCopilotPlugin - ["GitHubCopilotTranslator"] = "WindowTranslator.Plugin.GitHubCopilotPlugin", - ["GitHubCopilotOptions"] = "WindowTranslator.Plugin.GitHubCopilotPlugin", - // WindowTranslator.Plugin.DeepLTranslatePlugin - ["DeepLTranslator"] = "WindowTranslator.Plugin.DeepLTranslatePlugin", - ["DeepLOptions"] = "WindowTranslator.Plugin.DeepLTranslatePlugin", - // WindowTranslator.Plugin.GoogleAIPlugin - ["GoogleAITranslator"] = "WindowTranslator.Plugin.GoogleAIPlugin", - ["GoogleAIOcr"] = "WindowTranslator.Plugin.GoogleAIPlugin", - ["GoogleAIOptions"] = "WindowTranslator.Plugin.GoogleAIPlugin", - // WindowTranslator.Plugin.GoogleAppsSctiptPlugin - ["GasTranslator"] = "WindowTranslator.Plugin.GoogleAppsSctiptPlugin", - ["GasOptions"] = "WindowTranslator.Plugin.GoogleAppsSctiptPlugin", - // WindowTranslator.Plugin.LLMPlugin - ["LLMTranslator"] = "WindowTranslator.Plugin.LLMPlugin", - ["LLMOcr"] = "WindowTranslator.Plugin.LLMPlugin", - ["LLMOptions"] = "WindowTranslator.Plugin.LLMPlugin", - // WindowTranslator.Plugin.TesseractOCRPlugin - ["TesseractOcr"] = "WindowTranslator.Plugin.TesseractOCRPlugin", - }; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -63,6 +29,7 @@ public sealed class NuGetPluginService : IDisposable private readonly string userPluginsDir; private readonly string manifestPath; private readonly bool ownsHttpClient; + private readonly IReadOnlyDictionary hostPackageVersions; private readonly SemaphoreSlim operationLock = new(1, 1); private string? searchUrl; @@ -79,135 +46,15 @@ internal NuGetPluginService( ILogger logger, HttpClient httpClient, string userPluginsDir, - bool ownsHttpClient = false) + bool ownsHttpClient = false, + IReadOnlyDictionary? hostPackageVersions = null) { this.logger = logger; this.httpClient = httpClient; this.userPluginsDir = Path.GetFullPath(userPluginsDir); this.manifestPath = Path.Combine(this.userPluginsDir, "nuget-manifest.json"); this.ownsHttpClient = ownsHttpClient; - } - - /// - /// 指定したパッケージの最新バージョンをインストールします。 - /// - public async Task InstallLatestPackageAsync(string packageId, IProgress? progress = null, CancellationToken cancellationToken = default) - { - var versionsUrl = $"{NuGetFlatContainerBase}/{packageId.ToLowerInvariant()}/index.json"; - using var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var versions = await JsonSerializer.DeserializeAsync(content, JsonOptions, cancellationToken).ConfigureAwait(false); - var parsedVersions = versions?.Versions? - .Select(NuGetVersion.Parse) - .OrderByDescending(v => v) - .ToArray() ?? []; - var latestVersion = parsedVersions.FirstOrDefault(v => !v.IsPrerelease) - ?? parsedVersions.FirstOrDefault() - ?? throw new InvalidOperationException($"パッケージ {packageId} のバージョン一覧を取得できませんでした。"); - await InstallPackageAsync( - packageId, - latestVersion.ToNormalizedString(), - progress, - cancellationToken).ConfigureAwait(false); - } - - /// - /// 設定ファイルで参照されているがインストールされていないプラグインを自動インストールします。 - /// アプリバンドルから除外されたプラグインの後方互換性維持のために使用します。 - /// - public async Task AutoInstallFromSettingsAsync(string settingsPath, CancellationToken cancellationToken = default) - { - if (!File.Exists(settingsPath)) - { - return; - } - - try - { - using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath, cancellationToken).ConfigureAwait(false)); - var neededPackages = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (doc.RootElement.TryGetProperty("Targets", out var targets)) - { - foreach (var target in targets.EnumerateObject()) - { - // SelectedPlugins の値(モジュールクラス名)をチェック - if (target.Value.TryGetProperty("SelectedPlugins", out var selectedPlugins)) - { - foreach (var plugin in selectedPlugins.EnumerateObject()) - { - var className = plugin.Value.GetString(); - if (className is not null && KnownClassToPackage.TryGetValue(className, out var packageId)) - { - neededPackages.Add(packageId); - } - } - } - - // PluginParams のキー(パラメータクラス名)をチェック - if (target.Value.TryGetProperty("PluginParams", out var pluginParams)) - { - foreach (var param in pluginParams.EnumerateObject()) - { - if (KnownClassToPackage.TryGetValue(param.Name, out var packageId)) - { - neededPackages.Add(packageId); - } - } - } - } - } - - if (neededPackages.Count == 0) - { - return; - } - - var installed = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); - var installedIds = installed.Select(p => p.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var packageId in neededPackages.Where(id => !installedIds.Contains(id))) - { - this.logger.LogInformation("設定で参照されているプラグインを自動インストール: {PackageId}", packageId); - try - { - await InstallLatestPackageAsync(packageId, cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (HttpRequestException ex) - { - this.logger.LogWarning( - ex, - "NuGetへ接続できないため、プラグインの自動インストールを中断します: {PackageId}", - packageId); - break; - } - catch (TaskCanceledException ex) - { - this.logger.LogWarning( - ex, - "NuGet接続がタイムアウトしたため、プラグインの自動インストールを中断します: {PackageId}", - packageId); - break; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "プラグイン {PackageId} の自動インストールに失敗しました。", packageId); - } - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - this.logger.LogWarning(ex, "設定からのプラグイン自動インストール処理中にエラーが発生しました。"); - } + this.hostPackageVersions = hostPackageVersions ?? CreateHostPackageVersions(); } /// @@ -252,20 +99,16 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres var targetDir = GetPackageDirectory(packageId); var stagingDir = Path.Combine(this.userPluginsDir, $".{packageId}.installing-{operationId}"); var backupDir = $"{targetDir}.backup-{operationId}"; - var pendingDeleteMarker = GetPendingDeleteMarker(packageId); - var markerWasPresent = false; - string? markerContent = null; var targetMoved = false; var stagingMoved = false; await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - markerWasPresent = File.Exists(pendingDeleteMarker); - markerContent = markerWasPresent - ? await File.ReadAllTextAsync(pendingDeleteMarker, cancellationToken).ConfigureAwait(false) - : null; Directory.CreateDirectory(this.userPluginsDir); - var installer = new NuGetPackageInstaller(this.httpClient, this.logger); + var installer = new NuGetPackageInstaller( + this.httpClient, + this.logger, + this.hostPackageVersions); await installer.InstallAsync( packageId, version, @@ -276,11 +119,6 @@ await installer.InstallAsync( var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); var updatedManifest = AddOrUpdatePackage(currentManifest, packageId, version); - if (markerWasPresent) - { - File.Delete(pendingDeleteMarker); - } - if (Directory.Exists(targetDir)) { Directory.Move(targetDir, backupDir); @@ -321,13 +159,6 @@ await installer.InstallAsync( { Directory.Move(backupDir, targetDir); } - if (markerWasPresent && !File.Exists(pendingDeleteMarker)) - { - await File.WriteAllTextAsync( - pendingDeleteMarker, - markerContent ?? packageId, - CancellationToken.None).ConfigureAwait(false); - } } catch (Exception rollbackException) { @@ -346,86 +177,73 @@ await File.WriteAllTextAsync( } /// - /// 指定したパッケージをアンインストールします。(次回起動時に適用) + /// 指定したパッケージを管理フォルダから削除します。 + /// 実行中のプラグインは一時フォルダから読み込まれているため、反映には再起動が必要です。 /// public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) { + var operationId = Guid.NewGuid().ToString("N"); + var targetDir = GetPackageDirectory(packageId); + var uninstallingDir = $"{targetDir}.uninstalling-{operationId}"; + var targetMoved = false; await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); - _ = GetPackageDirectory(packageId); Directory.CreateDirectory(this.userPluginsDir); - var pendingDeleteMarker = GetPendingDeleteMarker(packageId); - var markerAlreadyExisted = File.Exists(pendingDeleteMarker); var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); var updatedManifest = RemovePackage(manifest, packageId); + if (Directory.Exists(targetDir)) + { + Directory.Move(targetDir, uninstallingDir); + targetMoved = true; + } + try { - await WriteTextAtomicallyAsync( - pendingDeleteMarker, - packageId, - cancellationToken).ConfigureAwait(false); await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); } catch { - if (!markerAlreadyExisted) + try { - TryDeleteFile(pendingDeleteMarker); + if (targetMoved + && Directory.Exists(uninstallingDir) + && !Directory.Exists(targetDir)) + { + Directory.Move(uninstallingDir, targetDir); + } + } + catch (Exception rollbackException) + { + this.logger.LogError( + rollbackException, + "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", + packageId); } throw; } - this.logger.LogInformation( - "パッケージ {PackageId} をアンインストールキューに追加しました。再起動後に完全に削除されます。", - packageId); - } - finally - { - this.operationLock.Release(); - } - } - - /// - /// アプリ起動時にペンディング削除マーカーを処理します。 - /// - public void ProcessPendingDeletions() - { - this.operationLock.Wait(); - try - { - if (!Directory.Exists(this.userPluginsDir)) - { - return; - } - - foreach (var markerFile in Directory.GetFiles(this.userPluginsDir, "*.pending-delete")) + try { - try - { - var packageId = File.ReadAllText(markerFile); - var markerPackageId = Path.GetFileName(markerFile)[..^".pending-delete".Length]; - if (!packageId.Equals(markerPackageId, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("削除マーカーのパッケージIDがファイル名と一致しません。"); - } - - var targetDir = GetPackageDirectory(packageId); - if (Directory.Exists(targetDir)) - { - Directory.Delete(targetDir, recursive: true); - this.logger.LogInformation("ペンディング削除を処理: {PackageId}", packageId); - } - File.Delete(markerFile); - } - catch (Exception ex) + if (Directory.Exists(uninstallingDir)) { - this.logger.LogWarning(ex, "ペンディング削除の処理に失敗: {MarkerFile}", markerFile); + Directory.Delete(uninstallingDir, recursive: true); } } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "アンインストール済みプラグインフォルダの削除に失敗しました: {Directory}", + uninstallingDir); + } + + this.logger.LogInformation( + "パッケージ {PackageId} を管理フォルダからアンインストールしました。再起動後に反映されます。", + packageId); } finally { @@ -464,7 +282,7 @@ private static InstalledManifest AddOrUpdatePackage( { var packages = manifest.Packages.ToList(); var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); - var newEntry = new InstalledPackageInfo(packageId, version, DateTime.UtcNow); + var newEntry = new InstalledPackageInfo(packageId, version); if (existing >= 0) { packages[existing] = newEntry; @@ -477,6 +295,34 @@ private static InstalledManifest AddOrUpdatePackage( return new InstalledManifest([.. packages]); } + private static Dictionary CreateHostPackageVersions() + { + var abstractionsAssembly = typeof(UserSettings).Assembly; + var informationalVersion = abstractionsAssembly + .GetCustomAttribute()? + .InformationalVersion; + if (!string.IsNullOrWhiteSpace(informationalVersion) + && NuGetVersion.TryParse(informationalVersion, out var packageVersion)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = packageVersion, + }; + } + + var assemblyVersion = abstractionsAssembly.GetName().Version + ?? throw new InvalidOperationException( + "WindowTranslator.Abstractions のバージョンを取得できませんでした。"); + var fallbackVersion = new NuGetVersion( + assemblyVersion.Major, + assemblyVersion.Minor, + Math.Max(assemblyVersion.Build, 0)); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = fallbackVersion, + }; + } + private static InstalledManifest RemovePackage(InstalledManifest manifest, string packageId) => new([.. manifest.Packages.Where(p => !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); @@ -491,11 +337,18 @@ private async Task LoadManifestAsync(CancellationToken cancel try { await using var fs = File.OpenRead(this.manifestPath); - return await JsonSerializer.DeserializeAsync( + var manifest = await JsonSerializer.DeserializeAsync( fs, JsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidDataException("プラグインマニフェストが空です。"); + if (manifest.Packages is null) + { + throw new InvalidDataException( + "プラグインマニフェストにインストール済みパッケージ一覧がありません。"); + } + + return manifest; } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -530,23 +383,6 @@ private async Task SaveManifestAsync(InstalledManifest manifest, CancellationTok } } - private static async Task WriteTextAtomicallyAsync( - string destinationPath, - string content, - CancellationToken cancellationToken) - { - var temporaryPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}"; - try - { - await File.WriteAllTextAsync(temporaryPath, content, cancellationToken).ConfigureAwait(false); - ReplaceFile(temporaryPath, destinationPath); - } - finally - { - TryDeleteFile(temporaryPath); - } - } - private static void ReplaceFile(string sourcePath, string destinationPath) => File.Move(sourcePath, destinationPath, overwrite: true); @@ -573,12 +409,6 @@ private string GetPackageDirectory(string packageId) return packageDirectory; } - private string GetPendingDeleteMarker(string packageId) - { - _ = GetPackageDirectory(packageId); - return Path.Combine(this.userPluginsDir, $"{packageId}.pending-delete"); - } - private static void TryDeleteDirectory(string directory) { try @@ -633,11 +463,10 @@ public record NuGetPackageInfo( /// インストール済みパッケージ情報 public record InstalledPackageInfo( string Id, - string Version, - DateTime InstalledAt + string Version ); -/// インストール済みパッケージのマニフェスト +/// NuGetプラグインの管理マニフェスト public record InstalledManifest(List Packages); // NuGet V3 API レスポンス型 @@ -664,7 +493,3 @@ internal record NuGetSearchData( [property: JsonPropertyName("projectUrl")] string? ProjectUrl, [property: JsonPropertyName("licenseUrl")] string? LicenseUrl ); - -internal record NuGetVersionListResponse( - [property: JsonPropertyName("versions")] string[]? Versions -); diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 8059f580..649d9f2c 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -17,7 +17,6 @@ public partial class PluginStoreViewModel : ObservableObject private readonly NuGetPluginService nugetService; private readonly ILogger logger; private readonly IContentDialogService dialogService; - private readonly ISnackbarService snackbarService; [ObservableProperty] private bool isLoading; @@ -33,13 +32,11 @@ public partial class PluginStoreViewModel : ObservableObject public PluginStoreViewModel( NuGetPluginService nugetService, ILogger logger, - IContentDialogService dialogService, - ISnackbarService snackbarService) + IContentDialogService dialogService) { this.nugetService = nugetService; this.logger = logger; this.dialogService = dialogService; - this.snackbarService = snackbarService; } /// @@ -88,7 +85,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) } } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // キャンセルは正常 } @@ -107,14 +104,20 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) /// プラグインをインストールまたは更新します。 /// [RelayCommand] - public async Task InstallAsync(PluginPackageViewModel package) + public async Task InstallAsync( + PluginPackageViewModel package, + CancellationToken cancellationToken = default) { package.IsInstalling = true; try { this.logger.LogInformation("プラグインのインストール開始: {PackageId} {Version}", package.Id, package.LatestVersion); var progress = new Progress(v => package.InstallProgress = v); - await this.nugetService.InstallPackageAsync(package.Id, package.LatestVersion, progress).ConfigureAwait(true); + await this.nugetService.InstallPackageAsync( + package.Id, + package.LatestVersion, + progress, + cancellationToken).ConfigureAwait(true); package.IsInstalled = true; package.InstalledVersion = package.LatestVersion; @@ -129,9 +132,9 @@ await this.dialogService.ShowSimpleDialogAsync(new() Title = Resources.PluginInstallSuccess, Content = Resources.RestartRequired, CloseButtonText = Resources.Close, - }).ConfigureAwait(true); + }, cancellationToken).ConfigureAwait(true); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // キャンセルは正常 } @@ -141,7 +144,8 @@ await this.dialogService.ShowSimpleDialogAsync(new() await this.dialogService.ShowAlertAsync( Resources.PluginInstallFailed, ex.Message, - Resources.Close).ConfigureAwait(true); + Resources.Close, + cancellationToken).ConfigureAwait(true); } finally { diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index 59274802..db70867c 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -54,14 +54,6 @@ var exeDir = Path.GetDirectoryName(Environment.GetCommandLineArgs()[0])!; Directory.SetCurrentDirectory(exeDir); -// ペンディング削除処理と設定参照プラグインの自動インストール(カタログ初期化より前に実行する必要がある) -{ - using var earlyLoggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Warning)); - using var earlyNuGetService = new NuGetPluginService(earlyLoggerFactory.CreateLogger()); - earlyNuGetService.ProcessPendingDeletions(); - await earlyNuGetService.AutoInstallFromSettingsAsync(PathUtility.UserSettings); -} - var builder = KamishibaiApplication.CreateBuilder(); builder.Host.ConfigureLogging((c, l) => @@ -168,7 +160,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); -builder.Services.Configure(builder.Configuration, op => op.ErrorOnUnknownConfiguration = false); +builder.Services.AddTransient, ConfigureUserSettings>(); builder.Services.Configure(builder.Configuration.GetSection(nameof(UserSettings.Common))); builder.Services.AddTransient(typeof(IConfigureNamedOptions<>), typeof(ConfigurePluginParam<>)); builder.Services.AddTransient(typeof(IConfigureOptions<>), typeof(ConfigurePluginParam<>)); @@ -236,11 +228,23 @@ static string GetPluginName(PluginNameOptions options, Type type) } } -class ConfigurePluginParam(IConfiguration configuration, IProcessInfoStore store) : IConfigureNamedOptions +class ConfigureUserSettings(IConfiguration configuration) : IConfigureOptions +{ + private readonly IConfiguration configuration = configuration; + + public void Configure(UserSettings options) + => PluginParameterIgnoringConfigurationBinder.Bind(this.configuration, options); +} + +class ConfigurePluginParam( + IConfiguration configuration, + IProcessInfoStore store, + ILogger> logger) : IConfigureNamedOptions where TOptions : class, IPluginParam { private readonly IConfiguration configuration = configuration.GetSection(nameof(UserSettings.Targets)); private readonly IProcessInfoStore store = store; + private readonly ILogger> logger = logger; public void Configure(TOptions options) { @@ -249,7 +253,7 @@ public void Configure(TOptions options) { section = this.configuration.GetSection(Options.DefaultName); } - GetTargetSection(section, typeof(TOptions).Name).Bind(options); + this.BindParameter(section, options); } public void Configure(string? name, TOptions options) @@ -260,7 +264,46 @@ public void Configure(string? name, TOptions options) { section = this.configuration.GetSection(Options.DefaultName); } - GetTargetSection(section, typeof(TOptions).Name).Bind(options); + this.BindParameter(section, options); + } + + private void BindParameter(IConfigurationSection targetSection, TOptions options) + { + var parameterSection = GetTargetSection(targetSection, typeof(TOptions).Name); + if (!parameterSection.Exists()) + { + return; + } + + try + { + var configured = parameterSection.Get(); + if (configured is null) + { + return; + } + + foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.CanRead + && property.CanWrite + && property.GetIndexParameters().Length == 0) + { + property.SetValue(options, property.GetValue(configured)); + } + } + } + catch (Exception ex) when (ex is InvalidOperationException + or FormatException + or NotSupportedException + or MissingMethodException + or ArgumentException + or TargetInvocationException) + { + this.logger.LogWarning( + "プラグインパラメータ {ParameterType} を読み込めないため無視します。", + typeof(TOptions).Name); + } } private static IConfigurationSection GetTargetSection(IConfigurationSection section, string name) @@ -288,7 +331,7 @@ public void Configure(TargetSettings options) { section = this.configuration.GetSection(Options.DefaultName); } - section.Bind(options); + PluginParameterIgnoringConfigurationBinder.Bind(section, options); } public void Configure(string? name, TargetSettings options) @@ -299,7 +342,25 @@ public void Configure(string? name, TargetSettings options) { section = this.configuration.GetSection(Options.DefaultName); } - section.Bind(options); + PluginParameterIgnoringConfigurationBinder.Bind(section, options); + } +} + +static class PluginParameterIgnoringConfigurationBinder +{ + public static void Bind(IConfiguration configuration, object options) + { + var values = configuration + .AsEnumerable(makePathsRelative: true) + .Where(value => !value.Key + .Split(ConfigurationPath.KeyDelimiter, StringSplitOptions.None) + .Contains( + nameof(TargetSettings.PluginParams), + StringComparer.OrdinalIgnoreCase)); + var filteredConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + filteredConfiguration.Bind(options); } } diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 9e1518db..dd5417ea 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -28,7 +28,7 @@ namespace WindowTranslator.Properties; /// -/// ローカライズされた文字列などを検索するための、厳密に型指定されたリソース クラスです。 +/// [JCYꂽȂǂ邽߂́AɌ^w肳ꂽ\[X NXłB /// // This class was auto-generated by a text template. // To add or remove a member, edit your .ResX file. @@ -45,15 +45,15 @@ internal Resources() { } /// - /// このクラスで使用されているキャッシュされた ResourceManager インスタンスを返します。 + /// ̃NXŎgpĂLbVꂽ ResourceManager CX^XԂ܂B /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Resources.ResourceManager ResourceManager => resourceMan ??= new CustomResourceManager("WindowTranslator.Properties.Resources", Assembly.GetExecutingAssembly()); /// - /// すべてについて、現在のスレッドの CurrentUICulture プロパティをオーバーライドします - /// 現在のスレッドの CurrentUICulture プロパティをオーバーライドします。 + /// ׂĂɂ‚āÃ݂Xbh CurrentUICulture vpeBI[o[Ch܂ + /// ݂̃Xbh CurrentUICulture vpeBI[o[Ch܂B /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] public static global::System.Globalization.CultureInfo Culture @@ -63,622 +63,622 @@ internal Resources() { } /// - /// "このアプリについて" に類似しているローカライズされた文字列を検索します。 + /// "̃Avɂ‚" ɗގĂ郍[JCYꂽ܂B /// public static string About => ResourceManager.GetString("About", resourceCulture) ?? string.Empty; /// - /// "連絡先" に類似しているローカライズされた文字列を検索します。 + /// "A" ɗގĂ郍[JCYꂽ܂B /// public static string Address => ResourceManager.GetString("Address", resourceCulture) ?? string.Empty; /// - /// "アプリ情報" に類似しているローカライズされた文字列を検索します。 + /// "Av" ɗގĂ郍[JCYꂽ܂B /// public static string Application => ResourceManager.GetString("Application", resourceCulture) ?? string.Empty; /// - /// "適用" に類似しているローカライズされた文字列を検索します。 + /// "Kp" ɗގĂ郍[JCYꂽ܂B /// public static string Apply => ResourceManager.GetString("Apply", resourceCulture) ?? string.Empty; /// - /// "アタッチ" に類似しているローカライズされた文字列を検索します。 + /// "A^b`" ɗގĂ郍[JCYꂽ܂B /// public static string Attach => ResourceManager.GetString("Attach", resourceCulture) ?? string.Empty; /// - /// "アタッチ中" に類似しているローカライズされた文字列を検索します。 + /// "A^b`" ɗގĂ郍[JCYꂽ܂B /// public static string Attaching => ResourceManager.GetString("Attaching", resourceCulture) ?? string.Empty; /// - /// "自動スクロール" に類似しているローカライズされた文字列を検索します。 + /// "XN[" ɗގĂ郍[JCYꂽ܂B /// public static string AutoScroll => ResourceManager.GetString("AutoScroll", resourceCulture) ?? string.Empty; /// - /// "自動起動" に類似しているローカライズされた文字列を検索します。 + /// "N" ɗގĂ郍[JCYꂽ܂B /// public static string AutoStart => ResourceManager.GetString("AutoStart", resourceCulture) ?? string.Empty; /// - /// "PC起動時に自動起動" に類似しているローカライズされた文字列を検索します。 + /// "PCNɎN" ɗގĂ郍[JCYꂽ܂B /// public static string AutoStartWithPC => ResourceManager.GetString("AutoStartWithPC", resourceCulture) ?? string.Empty; /// - /// "自動翻訳対象" に類似しているローカライズされた文字列を検索します。 + /// "|Ώ" ɗގĂ郍[JCYꂽ܂B /// public static string AutoTargets => ResourceManager.GetString("AutoTargets", resourceCulture) ?? string.Empty; /// - /// "ビルド日時" に類似しているローカライズされた文字列を検索します。 + /// "rh" ɗގĂ郍[JCYꂽ܂B /// public static string BuildDate => ResourceManager.GetString("BuildDate", resourceCulture) ?? string.Empty; /// - /// "キャッシュモジュール" に類似しているローカライズされた文字列を検索します。 + /// "LbVW[" ɗގĂ郍[JCYꂽ܂B /// public static string CacheModule => ResourceManager.GetString("CacheModule", resourceCulture) ?? string.Empty; /// - /// "キャッシュ設定" に類似しているローカライズされた文字列を検索します。 + /// "LbVݒ" ɗގĂ郍[JCYꂽ܂B /// public static string CacheParam => ResourceManager.GetString("CacheParam", resourceCulture) ?? string.Empty; /// - /// "キャンセル" に類似しているローカライズされた文字列を検索します。 + /// "LZ" ɗގĂ郍[JCYꂽ܂B /// public static string Cancel => ResourceManager.GetString("Cancel", resourceCulture) ?? string.Empty; /// - /// "キャプチャーウィンドウ" に類似しているローカライズされた文字列を検索します。 + /// "Lv`[EBhE" ɗގĂ郍[JCYꂽ܂B /// public static string Capture => ResourceManager.GetString("Capture", resourceCulture) ?? string.Empty; /// - /// "新しいバージョンのチェック" に類似しているローカライズされた文字列を検索します。 + /// "Vo[W̃`FbN" ɗގĂ郍[JCYꂽ܂B /// public static string CheckNewVersion => ResourceManager.GetString("CheckNewVersion", resourceCulture) ?? string.Empty; /// - /// "更新内容の確認" に類似しているローカライズされた文字列を検索します。 + /// "XVe̊mF" ɗގĂ郍[JCYꂽ܂B /// public static string CheckUpdateNotes => ResourceManager.GetString("CheckUpdateNotes", resourceCulture) ?? string.Empty; /// - /// "クリア" に類似しているローカライズされた文字列を検索します。 + /// "NA" ɗގĂ郍[JCYꂽ܂B /// public static string Clear => ResourceManager.GetString("Clear", resourceCulture) ?? string.Empty; /// - /// "閉じる" に類似しているローカライズされた文字列を検索します。 + /// "‚" ɗގĂ郍[JCYꂽ܂B /// public static string Close => ResourceManager.GetString("Close", resourceCulture) ?? string.Empty; /// - /// "確認" に類似しているローカライズされた文字列を検索します。 + /// "mF" ɗގĂ郍[JCYꂽ܂B /// public static string Confirm => ResourceManager.GetString("Confirm", resourceCulture) ?? string.Empty; /// - /// "コピーしました" に類似しているローカライズされた文字列を検索します。 + /// "Rs[܂" ɗގĂ郍[JCYꂽ܂B /// public static string Copied => ResourceManager.GetString("Copied", resourceCulture) ?? string.Empty; /// - /// "情報をコピー" に類似しているローカライズされた文字列を検索します。 + /// "Rs[" ɗގĂ郍[JCYꂽ܂B /// public static string Copy => ResourceManager.GetString("Copy", resourceCulture) ?? string.Empty; /// - /// "デフォルト設定" に類似しているローカライズされた文字列を検索します。 + /// "ftHgݒ" ɗގĂ郍[JCYꂽ܂B /// public static string DefaultSetting => ResourceManager.GetString("DefaultSetting", resourceCulture) ?? string.Empty; /// - /// "デタッチ" に類似しているローカライズされた文字列を検索します。 + /// "f^b`" ɗގĂ郍[JCYꂽ܂B /// public static string Detach => ResourceManager.GetString("Detach", resourceCulture) ?? string.Empty; /// - /// "技術情報" に類似しているローカライズされた文字列を検索します。 + /// "Zp" ɗގĂ郍[JCYꂽ܂B /// public static string Develop => ResourceManager.GetString("Develop", resourceCulture) ?? string.Empty; /// - /// "開発者" に類似しているローカライズされた文字列を検索します。 + /// "J" ɗގĂ郍[JCYꂽ܂B /// public static string DevelopedBy => ResourceManager.GetString("DevelopedBy", resourceCulture) ?? string.Empty; /// - /// "処理中アイコンを表示する" に類似しているローカライズされた文字列を検索します。 + /// "ACR\" ɗގĂ郍[JCYꂽ܂B /// public static string DisplayBusy => ResourceManager.GetString("DisplayBusy", resourceCulture) ?? string.Empty; /// - /// "表示方法" に類似しているローカライズされた文字列を検索します。 + /// "\@" ɗގĂ郍[JCYꂽ܂B /// public static string DisplayMethod => ResourceManager.GetString("DisplayMethod", resourceCulture) ?? string.Empty; /// - /// "終了" に類似しているローカライズされた文字列を検索します。 + /// "I" ɗގĂ郍[JCYꂽ܂B /// public static string Exit => ResourceManager.GetString("Exit", resourceCulture) ?? string.Empty; /// - /// "エクスポート" に類似しているローカライズされた文字列を検索します。 + /// "GNX|[g" ɗގĂ郍[JCYꂽ܂B /// public static string Export => ResourceManager.GetString("Export", resourceCulture) ?? string.Empty; /// - /// "ログのエクスポート" に類似しているローカライズされた文字列を検索します。 + /// "ÕGNX|[g" ɗގĂ郍[JCYꂽ܂B /// public static string ExportLogs => ResourceManager.GetString("ExportLogs", resourceCulture) ?? string.Empty; /// - /// "エクスポート失敗" に類似しているローカライズされた文字列を検索します。 + /// "GNX|[gs" ɗގĂ郍[JCYꂽ܂B /// public static string ExportLogsFailed => ResourceManager.GetString("ExportLogsFailed", resourceCulture) ?? string.Empty; /// - /// "テキストファイル" に類似しているローカライズされた文字列を検索します。 + /// "eLXgt@C" ɗގĂ郍[JCYꂽ܂B /// public static string ExportLogsFilterText => ResourceManager.GetString("ExportLogsFilterText", resourceCulture) ?? string.Empty; /// - /// "エクスポート完了" に類似しているローカライズされた文字列を検索します。 + /// "GNX|[g" ɗގĂ郍[JCYꂽ܂B /// public static string ExportLogsSuccess => ResourceManager.GetString("ExportLogsSuccess", resourceCulture) ?? string.Empty; /// - /// "ログを`{0}`にエクスポートしました。" に類似しているローカライズされた文字列を検索します。 + /// "O`{0}`ɃGNX|[g܂B" ɗގĂ郍[JCYꂽ܂B /// public static string ExportLogsSuccessDetail => ResourceManager.GetString("ExportLogsSuccessDetail", resourceCulture) ?? string.Empty; /// - /// "設定の適用に失敗しました。" に類似しているローカライズされた文字列を検索します。 + /// "ݒ̓KpɎs܂B" ɗގĂ郍[JCYꂽ܂B /// public static string FaildApplySettings => ResourceManager.GetString("FaildApplySettings", resourceCulture) ?? string.Empty; /// - /// "OCRに失敗しました" に類似しているローカライズされた文字列を検索します。 + /// "OCRɎs܂" ɗގĂ郍[JCYꂽ܂B /// public static string FaildOcr => ResourceManager.GetString("FaildOcr", resourceCulture) ?? string.Empty; /// - /// "ウィンドウの埋め込みに失敗しました。" に類似しているローカライズされた文字列を検索します。 + /// "EBhE̖ߍ݂Ɏs܂B" ɗގĂ郍[JCYꂽ܂B /// public static string FaildOverlay => ResourceManager.GetString("FaildOverlay", resourceCulture) ?? string.Empty; /// - /// "翻訳に失敗しました" に類似しているローカライズされた文字列を検索します。 + /// "|Ɏs܂" ɗގĂ郍[JCYꂽ܂B /// public static string FaildTranslate => ResourceManager.GetString("FaildTranslate", resourceCulture) ?? string.Empty; /// - /// "近いテキストの閾値" に類似しているローカライズされた文字列を検索します。 + /// "߂eLXg臒l" ɗގĂ郍[JCYꂽ܂B /// public static string FuzzyMatchThreshold => ResourceManager.GetString("FuzzyMatchThreshold", resourceCulture) ?? string.Empty; /// - /// "全般設定" に類似しているローカライズされた文字列を検索します。 + /// "Sʐݒ" ɗގĂ郍[JCYꂽ܂B /// public static string GeneralSettings => ResourceManager.GetString("GeneralSettings", resourceCulture) ?? string.Empty; /// - /// "アップデートがあります: {0}" に類似しているローカライズされた文字列を検索します。 + /// "Abvf[g܂: {0}" ɗގĂ郍[JCYꂽ܂B /// public static string HasUpdate => ResourceManager.GetString("HasUpdate", resourceCulture) ?? string.Empty; /// - /// "押している間だけ" に類似しているローカライズされた文字列を検索します。 + /// "ĂԂ" ɗގĂ郍[JCYꂽ܂B /// public static string Hold => ResourceManager.GetString("Hold", resourceCulture) ?? string.Empty; /// - /// "メモリ内キャッシュ" に類似しているローカライズされた文字列を検索します。 + /// "LbV" ɗގĂ郍[JCYꂽ܂B /// public static string InMemoryCache => ResourceManager.GetString("InMemoryCache", resourceCulture) ?? string.Empty; /// - /// "インストール" に類似しているローカライズされた文字列を検索します。 + /// "CXg[" ɗގĂ郍[JCYꂽ܂B /// public static string Install => ResourceManager.GetString("Install", resourceCulture) ?? string.Empty; /// - /// "インストール済み" に類似しているローカライズされた文字列を検索します。 + /// "CXg[ς" ɗގĂ郍[JCYꂽ܂B /// public static string Installed => ResourceManager.GetString("Installed", resourceCulture) ?? string.Empty; /// - /// "インストール済み: {0}" に類似しているローカライズされた文字列を検索します。 + /// "CXg[ς: {0}" ɗގĂ郍[JCYꂽ܂B /// public static string InstalledVersion => ResourceManager.GetString("InstalledVersion", resourceCulture) ?? string.Empty; /// - /// "インストール済みバージョン" に類似しているローカライズされた文字列を検索します。 + /// "CXg[ς݃o[W" ɗގĂ郍[JCYꂽ܂B /// public static string InstalledVersionLabel => ResourceManager.GetString("InstalledVersionLabel", resourceCulture) ?? string.Empty; /// - /// "新しいバージョン: {0} のインストール" に類似しているローカライズされた文字列を検索します。 + /// "Vo[W: {0} ̃CXg[" ɗގĂ郍[JCYꂽ܂B /// public static string InstallNewVersion => ResourceManager.GetString("InstallNewVersion", resourceCulture) ?? string.Empty; /// - /// "{0}: 設定検証エラー" に類似しているローカライズされた文字列を検索します。 + /// "{0}: ݒ茟؃G[" ɗގĂ郍[JCYꂽ܂B /// public static string InvalidSettings => ResourceManager.GetString("InvalidSettings", resourceCulture) ?? string.Empty; /// - /// ":tired-face: **そのまま実行しても動作しない可能性が高いです**&#10;**..." に類似しているローカライズされた文字列を検索します。 + /// ":tired-face: **̂܂܎sĂ삵Ȃ”\ł**&#13;&a..." ɗގĂ郍[JCYꂽ܂B /// public static string InvalidSettingsContent => ResourceManager.GetString("InvalidSettingsContent", resourceCulture) ?? string.Empty; /// - /// "一度翻訳対象に選択したプロセスが起動したときに自動的に翻訳する" に類似しているローカライズされた文字列を検索します。 + /// "x|ΏۂɑIvZXNƂɎIɖ|󂷂" ɗގĂ郍[JCYꂽ܂B /// public static string IsEnableAutoTarget => ResourceManager.GetString("IsEnableAutoTarget", resourceCulture) ?? string.Empty; /// - /// "オーバーレイ表示をキャプチャー可能にする" に類似しているローカライズされた文字列を検索します。 + /// "I[o[C\Lv`[”\ɂ" ɗގĂ郍[JCYꂽ܂B /// public static string IsEnableCaptureOverlay => ResourceManager.GetString("IsEnableCaptureOverlay", resourceCulture) ?? string.Empty; /// - /// "最新バージョンをご利用中です。" に類似しているローカライズされた文字列を検索します。 + /// "ŐVo[WpłB" ɗގĂ郍[JCYꂽ܂B /// public static string IsLatest => ResourceManager.GetString("IsLatest", resourceCulture) ?? string.Empty; /// - /// "マウスポインター位置のテキストのみオーバレイ翻訳を表示する" に類似しているローカライズされた文字列を検索します。 + /// "}EX|C^[ʒũeLXĝ݃I[oC|\" ɗގĂ郍[JCYꂽ܂B /// public static string IsOverlayPointSwap => ResourceManager.GetString("IsOverlayPointSwap", resourceCulture) ?? string.Empty; /// - /// "言語設定" に類似しているローカライズされた文字列を検索します。 + /// "ݒ" ɗގĂ郍[JCYꂽ܂B /// public static string Language => ResourceManager.GetString("Language", resourceCulture) ?? string.Empty; /// - /// "最新バージョン" に類似しているローカライズされた文字列を検索します。 + /// "ŐVo[W" ɗގĂ郍[JCYꂽ܂B /// public static string LatestVersion => ResourceManager.GetString("LatestVersion", resourceCulture) ?? string.Empty; /// - /// "ライセンス" に類似しているローカライズされた文字列を検索します。 + /// "CZX" ɗގĂ郍[JCYꂽ܂B /// public static string License => ResourceManager.GetString("License", resourceCulture) ?? string.Empty; /// - /// "ライセンス情報" に類似しているローカライズされた文字列を検索します。 + /// "CZX" ɗގĂ郍[JCYꂽ܂B /// public static string LicenseUrl => ResourceManager.GetString("LicenseUrl", resourceCulture) ?? string.Empty; /// - /// "ローカルファイルキャッシュ" に類似しているローカライズされた文字列を検索します。 + /// "[Jt@CLbV" ɗގĂ郍[JCYꂽ܂B /// public static string LocalCache => ResourceManager.GetString("LocalCache", resourceCulture) ?? string.Empty; /// - /// "ログ" に類似しているローカライズされた文字列を検索します。 + /// "O" ɗގĂ郍[JCYꂽ܂B /// public static string Log => ResourceManager.GetString("Log", resourceCulture) ?? string.Empty; /// - /// "その他" に類似しているローカライズされた文字列を検索します。 + /// "̑" ɗގĂ郍[JCYꂽ܂B /// public static string Misc => ResourceManager.GetString("Misc", resourceCulture) ?? string.Empty; /// - /// "すでにWindowTranslatorが起動中です" に類似しているローカライズされた文字列を検索します。 + /// "łWindowTranslatorNł" ɗގĂ郍[JCYꂽ܂B /// public static string MutexError => ResourceManager.GetString("MutexError", resourceCulture) ?? string.Empty; /// - /// "新しいバージョン: {0} がリリースされました" に類似しているローカライズされた文字列を検索します。 + /// "Vo[W: {0} [X܂" ɗގĂ郍[JCYꂽ܂B /// public static string NewVersionAvailable => ResourceManager.GetString("NewVersionAvailable", resourceCulture) ?? string.Empty; /// - /// "キャッシュしない" に類似しているローカライズされた文字列を検索します。 + /// "LbVȂ" ɗގĂ郍[JCYꂽ܂B /// public static string NoCache => ResourceManager.GetString("NoCache", resourceCulture) ?? string.Empty; /// - /// "翻訳しない" に類似しているローカライズされた文字列を検索します。 + /// "|󂵂Ȃ" ɗގĂ郍[JCYꂽ܂B /// public static string NoTranslateModule => ResourceManager.GetString("NoTranslateModule", resourceCulture) ?? string.Empty; /// - /// "NuGetからのプラグイン一覧の取得に失敗しました。ネットワーク接続を確認してください。" に類似しているローカライズされた文字列を検索します。 + /// "NuGet̃vOCꗗ̎擾Ɏs܂Blbg[NڑmFĂB" ɗގĂ郍[JCYꂽ܂B /// public static string NuGetSearchFailed => ResourceManager.GetString("NuGetSearchFailed", resourceCulture) ?? string.Empty; /// - /// "{0}のOCR機能が使えません。対象の言語機能をインストールしてください" に類似しているローカライズされた文字列を検索します。 + /// "{0}OCR@\g܂BΏۂ̌@\CXg[Ă" ɗގĂ郍[JCYꂽ܂B /// public static string OcrLanguageNotAvailable => ResourceManager.GetString("OcrLanguageNotAvailable", resourceCulture) ?? string.Empty; /// - /// "認識モジュール" に類似しているローカライズされた文字列を検索します。 + /// "FW[" ɗގĂ郍[JCYꂽ܂B /// public static string OcrModule => ResourceManager.GetString("OcrModule", resourceCulture) ?? string.Empty; /// - /// "OK" に類似しているローカライズされた文字列を検索します。 + /// "OK" ɗގĂ郍[JCYꂽ܂B /// public static string OK => ResourceManager.GetString("OK", resourceCulture) ?? string.Empty; /// - /// "詳細情報の確認" に類似しているローカライズされた文字列を検索します。 + /// "ڍ׏̊mF" ɗގĂ郍[JCYꂽ܂B /// public static string OpenChangelogCommand => ResourceManager.GetString("OpenChangelogCommand", resourceCulture) ?? string.Empty; /// - /// "サードパーティーライセンスの確認" に類似しているローカライズされた文字列を検索します。 + /// "T[hp[eB[CZX̊mF" ɗގĂ郍[JCYꂽ܂B /// public static string OpenThirdPartyLicensesCommand => ResourceManager.GetString("OpenThirdPartyLicensesCommand", resourceCulture) ?? string.Empty; /// - /// "オーバレイ" に類似しているローカライズされた文字列を検索します。 + /// "I[oC" ɗގĂ郍[JCYꂽ܂B /// public static string Overlay => ResourceManager.GetString("Overlay", resourceCulture) ?? string.Empty; /// - /// "オーバーレイ背景の不透明度" に類似しているローカライズされた文字列を検索します。 + /// "I[o[Cwi̕sx" ɗގĂ郍[JCYꂽ܂B /// public static string OverlayOpacity => ResourceManager.GetString("OverlayOpacity", resourceCulture) ?? string.Empty; /// - /// "オーバーレイ切り替え" に類似しているローカライズされた文字列を検索します。 + /// "I[o[C؂ւ" ɗގĂ郍[JCYꂽ܂B /// public static string OverlayShortcut => ResourceManager.GetString("OverlayShortcut", resourceCulture) ?? string.Empty; /// - /// "オーバーレイ表示の切り替え" に類似しているローカライズされた文字列を検索します。 + /// "I[o[C\̐؂ւ" ɗގĂ郍[JCYꂽ܂B /// public static string OverlaySwitch => ResourceManager.GetString("OverlaySwitch", resourceCulture) ?? string.Empty; /// - /// "プラグイン設定" に類似しているローカライズされた文字列を検索します。 + /// "vOCݒ" ɗގĂ郍[JCYꂽ܂B /// public static string Plugin => ResourceManager.GetString("Plugin", resourceCulture) ?? string.Empty; /// - /// "インストール失敗" に類似しているローカライズされた文字列を検索します。 + /// "CXg[s" ɗގĂ郍[JCYꂽ܂B /// public static string PluginInstallFailed => ResourceManager.GetString("PluginInstallFailed", resourceCulture) ?? string.Empty; /// - /// "インストール完了" に類似しているローカライズされた文字列を検索します。 + /// "CXg[" ɗގĂ郍[JCYꂽ܂B /// public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; /// - /// "プラグインストア" に類似しているローカライズされた文字列を検索します。 + /// "vOCXgA" ɗގĂ郍[JCYꂽ܂B /// public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; /// - /// "プロジェクトページ" に類似しているローカライズされた文字列を検索します。 + /// "vWFNgy[W" ɗގĂ郍[JCYꂽ܂B /// public static string ProjectUrl => ResourceManager.GetString("ProjectUrl", resourceCulture) ?? string.Empty; /// - /// "公開ページ" に類似しているローカライズされた文字列を検索します。 + /// "Jy[W" ɗގĂ郍[JCYꂽ܂B /// public static string PublishPage => ResourceManager.GetString("PublishPage", resourceCulture) ?? string.Empty; /// - /// "{0}を自動起動に登録しました。" に類似しているローカライズされた文字列を検索します。 + /// "{0}Nɓo^܂B" ɗގĂ郍[JCYꂽ܂B /// public static string RegisterAutoStart => ResourceManager.GetString("RegisterAutoStart", resourceCulture) ?? string.Empty; /// - /// "プラグインの変更を適用するには、WindowTranslatorを再起動してください。" に類似しているローカライズされた文字列を検索します。 + /// "vOC̕ύXKpɂ́AWindowTranslatorċNĂB" ɗގĂ郍[JCYꂽ܂B /// public static string RestartRequired => ResourceManager.GetString("RestartRequired", resourceCulture) ?? string.Empty; /// - /// "後で" に類似しているローカライズされた文字列を検索します。 + /// "" ɗގĂ郍[JCYꂽ܂B /// public static string ReviewLater => ResourceManager.GetString("ReviewLater", resourceCulture) ?? string.Empty; /// - /// "二度と表示しない" に類似しているローカライズされた文字列を検索します。 + /// "xƕ\Ȃ" ɗގĂ郍[JCYꂽ܂B /// public static string ReviewNeverShowAgain => ResourceManager.GetString("ReviewNeverShowAgain", resourceCulture) ?? string.Empty; /// - /// "レビューのお願い" に類似しているローカライズされた文字列を検索します。 + /// "r[̂肢" ɗގĂ郍[JCYꂽ܂B /// public static string ReviewRequest => ResourceManager.GetString("ReviewRequest", resourceCulture) ?? string.Empty; /// - /// "WindowTranslatorをご利用いただきありがとうございます。Microsoft Store..." に類似しているローカライズされた文字列を検索します。 + /// "WindowTranslatorp肪Ƃ܂BMicrosoft Store..." ɗގĂ郍[JCYꂽ܂B /// public static string ReviewRequestMessage => ResourceManager.GetString("ReviewRequestMessage", resourceCulture) ?? string.Empty; /// - /// "そのまま実行" に類似しているローカライズされた文字列を検索します。 + /// "̂܂܎s" ɗގĂ郍[JCYꂽ܂B /// public static string RunAsIs => ResourceManager.GetString("RunAsIs", resourceCulture) ?? string.Empty; /// - /// "翻訳元言語と翻訳先言語が同一です。異なる言語を指定してください。" に類似しているローカライズされた文字列を検索します。 + /// "|󌳌Ɩ|挾ꂪłBقȂ錾w肵ĂB" ɗގĂ郍[JCYꂽ܂B /// public static string SameSourceTargetLanguage => ResourceManager.GetString("SameSourceTargetLanguage", resourceCulture) ?? string.Empty; /// - /// "保存して閉じる" に類似しているローカライズされた文字列を検索します。 + /// "ۑĕ‚" ɗގĂ郍[JCYꂽ܂B /// public static string SaveAndClose => ResourceManager.GetString("SaveAndClose", resourceCulture) ?? string.Empty; /// - /// "WindowTranslator以外のウィンドウを選択してください" に類似しているローカライズされた文字列を検索します。 + /// "WindowTranslatorȊÕEBhEIĂ" ɗގĂ郍[JCYꂽ܂B /// public static string SelectOtherWindow => ResourceManager.GetString("SelectOtherWindow", resourceCulture) ?? string.Empty; /// - /// "エラー情報をレポートシステムに送信します。以下の情報が送信されます。&#10;* アプリ情報..." に類似しているローカライズされた文字列を検索します。 + /// "G[|[gVXeɑM܂Bȉ̏񂪑M܂B&#13;&#1..." ɗގĂ郍[JCYꂽ܂B /// public static string SendReportToolTip => ResourceManager.GetString("SendReportToolTip", resourceCulture) ?? string.Empty; /// - /// "情報を送信" に類似しているローカライズされた文字列を検索します。 + /// "𑗐M" ɗގĂ郍[JCYꂽ܂B /// public static string SendRerpot => ResourceManager.GetString("SendRerpot", resourceCulture) ?? string.Empty; /// - /// "送信完了" に類似しているローカライズされた文字列を検索します。 + /// "M" ɗގĂ郍[JCYꂽ܂B /// public static string Sent => ResourceManager.GetString("Sent", resourceCulture) ?? string.Empty; /// - /// ":tired-face: **このまま保存しても動作しません。**&#10;***&..." に類似しているローカライズされた文字列を検索します。 + /// ":tired-face: **̂܂ܕۑĂ삵܂B**&#13;&#10..." ɗގĂ郍[JCYꂽ܂B /// public static string SettingInvalidContent => ResourceManager.GetString("SettingInvalidContent", resourceCulture) ?? string.Empty; /// - /// "設定" に類似しているローカライズされた文字列を検索します。 + /// "ݒ" ɗގĂ郍[JCYꂽ܂B /// public static string Settings => ResourceManager.GetString("Settings", resourceCulture) ?? string.Empty; /// - /// "設定検証エラー" に類似しているローカライズされた文字列を検索します。 + /// "ݒ茟؃G[" ɗގĂ郍[JCYꂽ܂B /// public static string SettingsInvalid => ResourceManager.GetString("SettingsInvalid", resourceCulture) ?? string.Empty; /// - /// "全体設定" に類似しているローカライズされた文字列を検索します。 + /// "S̐ݒ" ɗގĂ郍[JCYꂽ܂B /// public static string SettingsViewModel => ResourceManager.GetString("SettingsViewModel", resourceCulture) ?? string.Empty; /// - /// "ショートカット" に類似しているローカライズされた文字列を検索します。 + /// "V[gJbg" ɗގĂ郍[JCYꂽ܂B /// public static string Shortcut => ResourceManager.GetString("Shortcut", resourceCulture) ?? string.Empty; /// - /// "翻訳元(認識)言語" に類似しているローカライズされた文字列を検索します。 + /// "|(F)" ɗގĂ郍[JCYꂽ܂B /// public static string Source => ResourceManager.GetString("Source", resourceCulture) ?? string.Empty; /// - /// "Steamでゲームをギフト" に類似しているローカライズされた文字列を検索します。 + /// "SteamŃQ[Mtg" ɗގĂ郍[JCYꂽ܂B /// public static string SteamWishlist => ResourceManager.GetString("SteamWishlist", resourceCulture) ?? string.Empty; /// - /// "送信" に類似しているローカライズされた文字列を検索します。 + /// "M" ɗގĂ郍[JCYꂽ܂B /// public static string Submit => ResourceManager.GetString("Submit", resourceCulture) ?? string.Empty; /// - /// "翻訳先(表示)言語" に類似しているローカライズされた文字列を検索します。 + /// "|(\)" ɗގĂ郍[JCYꂽ܂B /// public static string Target => ResourceManager.GetString("Target", resourceCulture) ?? string.Empty; /// - /// "翻訳対象プロセス" に類似しているローカライズされた文字列を検索します。 + /// "|ΏۃvZX" ɗގĂ郍[JCYꂽ܂B /// public static string TargetProcesses => ResourceManager.GetString("TargetProcesses", resourceCulture) ?? string.Empty; /// - /// "対象ごとの設定" に類似しているローカライズされた文字列を検索します。 + /// "ΏۂƂ̐ݒ" ɗގĂ郍[JCYꂽ܂B /// public static string TargetSpecificSettings => ResourceManager.GetString("TargetSpecificSettings", resourceCulture) ?? string.Empty; /// - /// "アプリ名" に類似しているローカライズされた文字列を検索します。 + /// "Av" ɗގĂ郍[JCYꂽ܂B /// public static string Title => ResourceManager.GetString("Title", resourceCulture) ?? string.Empty; /// - /// "押してON/OFFを切り替える" に類似しているローカライズされた文字列を検索します。 + /// "ON/OFF؂ւ" ɗގĂ郍[JCYꂽ܂B /// public static string Toggle => ResourceManager.GetString("Toggle", resourceCulture) ?? string.Empty; /// - /// "言語設定" に類似しているローカライズされた文字列を検索します。 + /// "ݒ" ɗގĂ郍[JCYꂽ܂B /// public static string TranslateLanguage => ResourceManager.GetString("TranslateLanguage", resourceCulture) ?? string.Empty; /// - /// "翻訳モジュール" に類似しているローカライズされた文字列を検索します。 + /// "|󃂃W[" ɗގĂ郍[JCYꂽ܂B /// public static string TranslateModule => ResourceManager.GetString("TranslateModule", resourceCulture) ?? string.Empty; /// - /// "不明なエラーが発生しました" に類似しているローカライズされた文字列を検索します。 + /// "sȃG[܂" ɗގĂ郍[JCYꂽ܂B /// public static string UnhundledErrorMessage => ResourceManager.GetString("UnhundledErrorMessage", resourceCulture) ?? string.Empty; /// - /// "アンインストール" に類似しているローカライズされた文字列を検索します。 + /// "ACXg[" ɗގĂ郍[JCYꂽ܂B /// public static string Uninstall => ResourceManager.GetString("Uninstall", resourceCulture) ?? string.Empty; /// - /// "{0} をアンインストールしますか?次回起動時に完全に削除されます。" に類似しているローカライズされた文字列を検索します。 + /// "{0} ACXg[܂H" ɗގĂ郍[JCYꂽ܂B /// public static string UninstallConfirm => ResourceManager.GetString("UninstallConfirm", resourceCulture) ?? string.Empty; /// - /// "選択したウィンドウ「{0}」はプロセスを特定できないため、キャプチャー出来ません。&#10;..." に類似しているローカライズされた文字列を検索します。 + /// "IEBhEu{0}v̓vZXłȂ߁ALv`[o܂B&#13;..." ɗގĂ郍[JCYꂽ܂B /// public static string UnknownWindow => ResourceManager.GetString("UnknownWindow", resourceCulture) ?? string.Empty; /// - /// "{0}の自動起動を解除しました。" に類似しているローカライズされた文字列を検索します。 + /// "{0}̎N܂B" ɗގĂ郍[JCYꂽ܂B /// public static string UnregisterAutoStart => ResourceManager.GetString("UnregisterAutoStart", resourceCulture) ?? string.Empty; /// - /// "更新" に類似しているローカライズされた文字列を検索します。 + /// "XV" ɗގĂ郍[JCYꂽ܂B /// public static string Update => ResourceManager.GetString("Update", resourceCulture) ?? string.Empty; /// - /// "更新あり" に類似しているローカライズされた文字列を検索します。 + /// "XV" ɗގĂ郍[JCYꂽ܂B /// public static string UpdateAvailable => ResourceManager.GetString("UpdateAvailable", resourceCulture) ?? string.Empty; /// - /// "インストール済み: {0} → 最新: {1}" に類似しているローカライズされた文字列を検索します。 + /// "CXg[ς: {0} ŐV: {1}" ɗގĂ郍[JCYꂽ܂B /// public static string UpdateAvailableVersion => ResourceManager.GetString("UpdateAvailableVersion", resourceCulture) ?? string.Empty; /// - /// "最新バージョンに更新" に類似しているローカライズされた文字列を検索します。 + /// "ŐVo[WɍXV" ɗގĂ郍[JCYꂽ܂B /// public static string UpdateCommand => ResourceManager.GetString("UpdateCommand", resourceCulture) ?? string.Empty; /// - /// "更新情報" に類似しているローカライズされた文字列を検索します。 + /// "XV" ɗގĂ郍[JCYꂽ܂B /// public static string UpdateInfo => ResourceManager.GetString("UpdateInfo", resourceCulture) ?? string.Empty; /// - /// "バージョン" に類似しているローカライズされた文字列を検索します。 + /// "o[W" ɗގĂ郍[JCYꂽ܂B /// public static string Version => ResourceManager.GetString("Version", resourceCulture) ?? string.Empty; /// - /// "翻訳結果表示モード" に類似しているローカライズされた文字列を検索します。 + /// "|󌋉ʕ\[h" ɗގĂ郍[JCYꂽ܂B /// public static string ViewMode => ResourceManager.GetString("ViewMode", resourceCulture) ?? string.Empty; /// - /// "Windows標準文字認識" に類似しているローカライズされた文字列を検索します。 + /// "WindowsWF" ɗގĂ郍[JCYꂽ܂B /// public static string WindowsMediaOcr => ResourceManager.GetString("WindowsMediaOcr", resourceCulture) ?? string.Empty; /// - /// "レビューする" に類似しているローカライズされた文字列を検索します。 + /// "r[" ɗގĂ郍[JCYꂽ܂B /// public static string WriteReview => ResourceManager.GetString("WriteReview", resourceCulture) ?? string.Empty; } diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index b2737646..1d0e33af 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -466,7 +466,7 @@ إلغاء التثبيت - هل أنت متأكد من رغبتك في إلغاء تثبيت {0}؟ سيتم حذفه بالكامل عند التشغيل التالي. + هل أنت متأكد من رغبتك في إلغاء تثبيت {0}؟ يتوفر تحديث diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index a488938a..7438924c 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -356,7 +356,7 @@ Monitory nejsou podporovány. Odinstalovat - Opravdu chcete odinstalovat {0}? Bude zcela odstraněn při příštím spuštění. + Opravdu chcete odinstalovat {0}? Dostupná aktualizace diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 9df7938a..dbdf0186 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -475,7 +475,7 @@ Monitore werden nicht unterstützt. Deinstallieren - Möchten Sie {0} wirklich deinstallieren? Es wird beim nächsten Start vollständig entfernt. + Möchten Sie {0} wirklich deinstallieren? Update verfügbar diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index db123fe1..6d0acccf 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -475,7 +475,7 @@ Monitors are not supported. Uninstall - Are you sure you want to uninstall {0}? It will be fully removed on next startup. + Are you sure you want to uninstall {0}? Update available diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index 3c443c0e..bf6977f4 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -466,7 +466,7 @@ Desinstalar - ¿Está seguro de que desea desinstalar {0}? Se eliminará completamente en el próximo inicio. + ¿Está seguro de que desea desinstalar {0}? Actualización disponible diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 1f9e5f5e..a5bacfea 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -460,7 +460,7 @@ حذف - آیا مطمئن هستید که می‌خواهید {0} را حذف کنید؟ در راه‌اندازی بعدی به طور کامل حذف خواهد شد. + آیا مطمئن هستید که می‌خواهید {0} را حذف کنید؟ به‌روزرسانی موجود است diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 97d5ae89..44c7b38d 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -475,7 +475,7 @@ Ang monitor ay hindi suportado. I-uninstall - Sigurado ka bang gusto mong i-uninstall ang {0}? Ito ay ganap na matatanggal sa susunod na pagsisimula. + Sigurado ka bang gusto mong i-uninstall ang {0}? Available ang update diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 7192e2a7..2e988292 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -466,7 +466,7 @@ Désinstaller - Voulez-vous vraiment désinstaller {0} ? Il sera complètement supprimé au prochain démarrage. + Voulez-vous vraiment désinstaller {0} ? Mise à jour disponible diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 90938454..0d475cbd 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -468,7 +468,7 @@ अनइंस्टॉल करें - क्या आप {0} को अनइंस्टॉल करना चाहते हैं? यह अगली बार शुरू होने पर पूरी तरह हटा दिया जाएगा। + क्या आप {0} को अनइंस्टॉल करना चाहते हैं? अपडेट उपलब्ध @@ -503,4 +503,4 @@ लाइसेंस जानकारी - \ No newline at end of file + diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 3d443905..3d6eb384 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -356,7 +356,7 @@ A monitorok nem támogatottak. Eltávolítás - Biztosan eltávolítja a(z) {0} bővítményt? A következő indításkor teljesen törlődik. + Biztosan eltávolítja a(z) {0} bővítményt? Frissítés érhető el diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 831b9951..1e5935b6 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -474,7 +474,7 @@ Monitor tidak didukung. Hapus - Apakah Anda yakin ingin menghapus {0}? Ini akan dihapus sepenuhnya saat startup berikutnya. + Apakah Anda yakin ingin menghapus {0}? Pembaruan tersedia diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index c8975d58..6843817f 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -475,7 +475,7 @@ 제거 - {0}을(를) 제거하시겠습니까? 다음 시작 시 완전히 제거됩니다. + {0}을(를) 제거하시겠습니까? 업데이트 있음 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 4b22e3f2..35fcf87a 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -474,7 +474,7 @@ Monitor tidak disokong. Nyahpasang - Adakah anda pasti mahu menyahpasang {0}? Ia akan dibuang sepenuhnya semasa permulaan seterusnya. + Adakah anda pasti mahu menyahpasang {0}? Kemaskini tersedia diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index 163c956e..a7f2a347 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -475,7 +475,7 @@ Monitory nie są obsługiwane. Odinstaluj - Czy na pewno chcesz odinstalować {0}? Zostanie całkowicie usunięty przy następnym uruchomieniu. + Czy na pewno chcesz odinstalować {0}? Dostępna aktualizacja diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 264d0454..57e5427a 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -474,7 +474,7 @@ Monitor tidak didukung. Desinstalar - Tem certeza que deseja desinstalar {0}? Ele será completamente removido na próxima inicialização. + Tem certeza que deseja desinstalar {0}? Atualização disponível diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 5f447758..098a8db5 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -475,7 +475,7 @@ アンインストール - {0} をアンインストールしますか?次回起動時に完全に削除されます。 + {0} をアンインストールしますか? 更新あり diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 58f21d91..e6c2f57b 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -466,7 +466,7 @@ Удалить - Вы уверены, что хотите удалить {0}? Он будет полностью удалён при следующем запуске. + Вы уверены, что хотите удалить {0}? Доступно обновление diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 14c7279d..32b18f69 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -475,7 +475,7 @@ ถอนการติดตั้ง - คุณแน่ใจหรือไม่ว่าต้องการถอนการติดตั้ง {0}? จะถูกลบออกอย่างสมบูรณ์เมื่อเริ่มต้นครั้งถัดไป + คุณแน่ใจหรือไม่ว่าต้องการถอนการติดตั้ง {0}? มีการอัปเดต diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index da9222d8..14d89982 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -475,7 +475,7 @@ Monitör desteklenmiyor. Kaldır - {0} öğesini kaldırmak istediğinizden emin misiniz? Sonraki başlatmada tamamen silinecek. + {0} öğesini kaldırmak istediğinizden emin misiniz? Güncelleme mevcut diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index ff57858d..1e8d917e 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -475,7 +475,7 @@ Màn hình không được hỗ trợ. Gỡ cài đặt - Bạn có chắc muốn gỡ cài đặt {0}? Nó sẽ được xóa hoàn toàn khi khởi động lại. + Bạn có chắc muốn gỡ cài đặt {0}? Có cập nhật diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 2958ffa4..9302d7c2 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -475,7 +475,7 @@ 卸载 - 确定要卸载 {0} 吗?下次启动时将完全移除。 + 确定要卸载 {0} 吗? 有更新 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 0e3cf99a..f6e61784 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -475,7 +475,7 @@ 解除安裝 - 確定要解除安裝 {0} 嗎?下次啟動時將完全移除。 + 確定要解除安裝 {0} 嗎? 有更新 diff --git a/docs/plugin.md b/docs/plugin.md index bcbad419..a5644d0a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -26,9 +26,10 @@ cd WindowTranslator.Plugin.YourPlugin WindowTranslator.Plugin.YourPlugin + WindowTranslator Your Plugin 1.0.0 YourName - 説明文 + プラグインストアに表示する具体的な説明文 $(PackageTags);windowtranslator-plugin MIT @@ -47,6 +48,14 @@ cd WindowTranslator.Plugin.YourPlugin > **重要**: `` に `windowtranslator-plugin` を含めることで、 > WindowTranslator アプリ内のプラグインストアに表示されます。 +> +> ``、`<Description>`、`<Authors>`、プロジェクトURL、ライセンス情報は +> プラグインストアの一覧・詳細に表示されます。利用者が機能と提供元を判断できる +> 内容を設定してください。 +> +> `WindowTranslator.Abstractions` の依存バージョン範囲は、インストール先の +> WindowTranslator との互換性判定に使用されます。サポートする最も古い +> `WindowTranslator.Abstractions` のバージョンを指定してください。 ### 3. プラグインを実装 @@ -123,10 +132,18 @@ NuGetパッケージで宣言されたランタイム依存関係も再帰的に 同じ依存パッケージに両立しないバージョン条件がある場合は、既存の プラグイン配置を変更せずにインストールを中止します。 +保存済みのモジュール選択やプラグイン設定パラメータだけを根拠に、パッケージが +自動インストールされることはありません。インストールはプラグインストアで +利用者が明示的に実行した場合だけ行われます。 + +アンインストールすると管理フォルダのパッケージは直ちに削除されます。 +実行中に読み込まれたプラグインを停止するには、WindowTranslator の再起動が必要です。 + ## 注意事項 - プラグインは .NET 10 以上をターゲットにしてください - `<EnableDynamicLoading>true</EnableDynamicLoading>` を必ず設定してください - ホスト側で既に提供されているパッケージは `ExcludeAssets="runtime"` を設定し、DLL を重複させないようにしてください - 通常のランタイム依存は `PackageReference` として宣言してください +- `ProjectReference` は通常、参照先プロジェクトへの NuGet 依存としてパッケージ化されます。参照先をNuGetへ公開しない場合は、`PrivateAssets="all"` を設定したうえで必要なDLLをプラグインパッケージへ同梱し、参照先が必要とする `PackageReference` もプラグイン側で宣言してください - パッケージ固有の追加ファイルは、実行時に必要な相対ディレクトリを保って `lib/net10.0/` に含めてください From a5de0609e1ed55d5097a74a935e0b091406725a5 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sat, 1 Aug 2026 12:46:49 +0900 Subject: [PATCH 08/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E3=83=95=E3=83=AC=E3=83=BC=E3=83=A0=E3=83=AF?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E5=88=A4=E5=AE=9A=E3=81=A8=E4=BE=9D=E5=AD=98?= =?UTF-8?q?=E3=82=A2=E3=82=BB=E3=83=B3=E3=83=96=E3=83=AA=E8=A7=A3=E6=B1=BA?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + .../NuGetPluginServiceTests.cs | 70 ++++++++-- .../PluginStore/NuGetPackageInstaller.cs | 90 ++++++------ .../Modules/PluginStore/NuGetPluginCatalog.cs | 131 +++++++++++++++++- WindowTranslator/WindowTranslator.csproj | 1 + 5 files changed, 233 insertions(+), 60 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 04ff4b36..bf4b2b97 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,7 @@ <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.298" /> <PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" /> <PackageVersion Include="Moq" Version="4.20.72" /> + <PackageVersion Include="NuGet.Frameworks" Version="7.6.0" /> <PackageVersion Include="NuGet.Versioning" Version="7.6.0" /> <PackageVersion Include="Octokit" Version="14.0.0" /> <PackageVersion Include="Panlingo.LanguageIdentification.FastText" Version="0.6.2" /> diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index d1eade8e..a6891928 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -1,12 +1,15 @@ using System.IO.Compression; using System.Net; using System.Net.Http; +using System.Runtime.InteropServices; +using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Xml.Linq; using Microsoft.Extensions.Logging.Abstractions; using NuGet.Versioning; using Weikio.PluginFramework.Catalogs; +using Weikio.PluginFramework.Context; using WindowTranslator.Modules; using WindowTranslator.Modules.PluginStore; @@ -14,6 +17,8 @@ namespace WindowTranslator.Tests; public sealed class NuGetPluginServiceTests { + private static readonly string RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier; + [Fact] public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories() { @@ -35,8 +40,8 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories { ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), ["lib/net10.0/fr/Root.Plugin.resources.dll"] = "fr"u8.ToArray(), - ["runtimes/win-x64/native/root-native.dll"] = "native"u8.ToArray(), - ["lib/net10.0/runtimes/win-x64/native/custom-native.dll"] = "custom"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/native/root-native.dll"] = "native"u8.ToArray(), + [$"lib/net10.0/runtimes/{RuntimeIdentifier}/native/custom-native.dll"] = "custom"u8.ToArray(), })); handler.AddPackage( "Dependency.Package", @@ -82,7 +87,7 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories await File.ReadAllTextAsync(Path.Combine( pluginDirectory, "runtimes", - "win-x64", + RuntimeIdentifier, "native", "root-native.dll"))); Assert.Equal( @@ -90,7 +95,7 @@ await File.ReadAllTextAsync(Path.Combine( await File.ReadAllTextAsync(Path.Combine( pluginDirectory, "runtimes", - "win-x64", + RuntimeIdentifier, "native", "custom-native.dll"))); Assert.DoesNotContain( @@ -296,7 +301,7 @@ public async Task DependencyWithIncompatibleLibStillInstallsCompatibleNativeAsse new Dictionary<string, byte[]> { ["lib/net48/LegacyOnly.dll"] = "legacy"u8.ToArray(), - ["runtimes/win-x64/native/compatible.dll"] = "native"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/native/compatible.dll"] = "native"u8.ToArray(), })); using var client = new HttpClient(handler); @@ -312,7 +317,7 @@ await File.ReadAllTextAsync(Path.Combine( testDirectory, "Root.Plugin", "runtimes", - "win-x64", + RuntimeIdentifier, "native", "compatible.dll"))); } @@ -635,6 +640,20 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() File.Copy( testAssemblyPath, Path.Combine(packageDirectory, Path.GetFileName(testAssemblyPath))); + var runtimeDirectory = Path.Combine( + packageDirectory, + "runtimes", + "win", + "lib", + "net10.0"); + Directory.CreateDirectory(runtimeDirectory); + File.Copy( + typeof(NuGetVersion).Assembly.Location, + Path.Combine(runtimeDirectory, "NuGet.Versioning.dll")); + Assert.Empty(Directory.EnumerateFiles( + packageDirectory, + "*.deps.json", + SearchOption.AllDirectories)); var options = new FolderPluginCatalogOptions(); options.TypeFinderOptions.TypeFinderCriterias.Clear(); @@ -643,8 +662,19 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() Query = static (_, type) => type.Name == nameof(CatalogProbeTranslateModule), }); - options.PluginLoadContextOptions.AdditionalRuntimePaths = - [AppContext.BaseDirectory]; + options.PluginLoadContextOptions.UseHostApplicationAssemblies = + UseHostApplicationAssembliesEnum.Selected; + options.PluginLoadContextOptions.HostApplicationAssemblies = + AssemblyLoadContext.Default.Assemblies + .Where(assembly => !assembly.IsDynamic + && assembly != typeof(NuGetPluginServiceTests).Assembly + && !string.Equals( + assembly.GetName().Name, + typeof(NuGetVersion).Assembly.GetName().Name, + StringComparison.OrdinalIgnoreCase)) + .Select(assembly => assembly.GetName()) + .ToList(); + options.PluginLoadContextOptions.AdditionalRuntimePaths = []; var catalog = new NuGetPluginCatalog( sourceDirectory, tempDirectory, @@ -653,9 +683,13 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() await catalog.Initialize(); Assert.True(catalog.IsInitialized); - Assert.Contains( + var plugin = Assert.Single( catalog.GetPlugins(), plugin => plugin.Type.Name == nameof(CatalogProbeTranslateModule)); + var module = Assert.IsAssignableFrom<ITranslateModule>( + Activator.CreateInstance(plugin.Type)); + var translated = await module.TranslateAsync([new("source", null)]); + Assert.Equal("1.2.3", Assert.Single(translated)); } finally { @@ -671,6 +705,20 @@ public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() "net10.0-windows10.0.20348.0", NuGetPackageInstaller.SelectBestTfm( ["net10.0", "net10.0-windows10.0.20348.0", "netstandard2.0"])); + Assert.Equal( + "net10.0-windows10.0.19041.0", + NuGetPackageInstaller.SelectBestTfm( + [ + "net10.0", + "net10.0-windows10.0.19041.0", + "net10.0-windows10.0.22621.0", + ])); + Assert.Equal( + ".NETCoreApp,Version=v10.0", + NuGetPackageInstaller.SelectBestTfm([".NETCoreApp,Version=v10.0"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm( + ["net10.0-windows10.0.22621.0"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net11.0-windows"])); Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net48"])); } @@ -822,5 +870,7 @@ public sealed class CatalogProbeTranslateModule : ITranslateModule { public ValueTask<string[]> TranslateAsync(TextInfo[] srcTexts) => ValueTask.FromResult( - Enumerable.Repeat(string.Empty, srcTexts.Length).ToArray()); + Enumerable.Repeat( + new NuGetVersion(1, 2, 3).ToNormalizedString(), + srcTexts.Length).ToArray()); } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 208ff456..726f6315 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -1,10 +1,14 @@ using System.IO; using System.IO.Compression; using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Linq; using Microsoft.Extensions.Logging; +using NuGet.Frameworks; using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; @@ -19,26 +23,11 @@ internal sealed class NuGetPackageInstaller( { private const string FlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; - private static readonly string[] CompatibleFrameworks = - [ - "net10.0-windows", - "net10.0", - "net9.0-windows", - "net9.0", - "net8.0-windows", - "net8.0", - "net7.0-windows", - "net7.0", - "net6.0-windows", - "net6.0", - "net5.0-windows", - "net5.0", - "netcoreapp3.1", - "netstandard2.1", - "netstandard2.0", - ]; - - private static readonly string[] CompatibleRuntimeIdentifiers = ["win-x64", "win", "any"]; + private static readonly NuGetFramework HostFramework = GetHostFramework(); + private static readonly FrameworkReducer FrameworkReducer = new(); + + private static readonly string[] CompatibleRuntimeIdentifiers = + [RuntimeInformation.RuntimeIdentifier, "win", "any"]; private readonly HttpClient httpClient = httpClient; private readonly ILogger logger = logger; @@ -89,23 +78,44 @@ public async Task InstallAsync( { var candidates = frameworks .Distinct(StringComparer.OrdinalIgnoreCase) - .Select(original => (Original: original, Normalized: NormalizeFramework(original))) + .Select(original => (Original: original, Framework: ParseFramework(original))) + .Where(candidate => candidate.Framework is not null) .ToArray(); - foreach (var compatibleFramework in CompatibleFrameworks) + var nearest = FrameworkReducer.GetNearest( + HostFramework, + candidates.Select(candidate => candidate.Framework!)); + if (nearest is null) { - var match = candidates - .OrderByDescending(c => c.Normalized, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(c => compatibleFramework.EndsWith("-windows", StringComparison.Ordinal) - ? c.Normalized.StartsWith(compatibleFramework, StringComparison.OrdinalIgnoreCase) - : c.Normalized.Equals(compatibleFramework, StringComparison.OrdinalIgnoreCase)); - if (match.Original is not null) - { - return match.Original; - } + return null; } - return null; + return candidates.First(candidate => NuGetFrameworkFullComparer.Instance.Equals( + candidate.Framework, + nearest)).Original; + } + + private static NuGetFramework GetHostFramework() + { + var assembly = typeof(NuGetPackageInstaller).Assembly; + var frameworkName = assembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName + ?? throw new InvalidOperationException("WindowTranslator のターゲットフレームワークを取得できませんでした。"); + var framework = NuGetFramework.Parse(frameworkName); + var platformName = assembly.GetCustomAttribute<TargetPlatformAttribute>()?.PlatformName; + return string.IsNullOrWhiteSpace(platformName) + ? framework + : NuGetFramework.ParseFolder($"{framework.GetShortFolderName()}-{platformName}"); + } + + private static NuGetFramework? ParseFramework(string framework) + { + if (string.IsNullOrWhiteSpace(framework)) + { + return null; + } + + var parsed = NuGetFramework.Parse(framework); + return parsed.IsUnsupported ? null : parsed; } private async Task<IReadOnlyCollection<PackageArtifact>> ResolvePackageGraphAsync( @@ -597,22 +607,6 @@ private static bool StreamsEqual(Stream left, Stream right) return right.ReadByte() == -1; } - private static string NormalizeFramework(string framework) - { - var normalized = framework.Replace(" ", string.Empty, StringComparison.Ordinal); - const string netCoreAppPrefix = ".NETCoreApp,Version=v"; - const string netStandardPrefix = ".NETStandard,Version=v"; - if (normalized.StartsWith(netCoreAppPrefix, StringComparison.OrdinalIgnoreCase)) - { - return $"net{normalized[netCoreAppPrefix.Length..]}"; - } - if (normalized.StartsWith(netStandardPrefix, StringComparison.OrdinalIgnoreCase)) - { - return $"netstandard{normalized[netStandardPrefix.Length..]}"; - } - return normalized; - } - private static void ValidatePackageId(string packageId) { if (string.IsNullOrWhiteSpace(packageId) diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index b1d82876..e25a450b 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -1,6 +1,8 @@ using System.IO; +using System.Reflection.PortableExecutable; using Weikio.PluginFramework.Abstractions; using Weikio.PluginFramework.Catalogs; +using Weikio.PluginFramework.Context; namespace WindowTranslator.Modules.PluginStore; @@ -15,7 +17,8 @@ public sealed class NuGetPluginCatalog : IPluginCatalog private readonly string sourceDir; private readonly string tempDir; - private readonly FolderPluginCatalog innerCatalog; + private readonly FolderPluginCatalogOptions options; + private CompositePluginCatalog innerCatalog = new(); public NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) : this(sourceDir, DefaultTempDir, options) @@ -26,7 +29,7 @@ internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalo { this.sourceDir = sourceDir; this.tempDir = tempDir; - this.innerCatalog = new FolderPluginCatalog(tempDir, options); + this.options = options; } /// <inheritdoc/> @@ -37,6 +40,7 @@ public async Task Initialize() { SynchronizePluginFiles(this.sourceDir, this.tempDir); + this.innerCatalog = CreateCatalog(this.tempDir, this.options); await this.innerCatalog.Initialize().ConfigureAwait(false); } @@ -46,6 +50,127 @@ public async Task Initialize() /// <inheritdoc/> public Plugin Get(string name, Version version) => this.innerCatalog.Get(name, version); + private static CompositePluginCatalog CreateCatalog( + string directory, + FolderPluginCatalogOptions baseOptions) + { + var catalogs = new List<IPluginCatalog> + { + new FolderPluginCatalog( + directory, + CreateCatalogOptions( + baseOptions, + directory, + SearchOption.TopDirectoryOnly, + includeSubfolders: false)), + }; + + foreach (var packageDirectory in Directory + .EnumerateDirectories(directory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + catalogs.Add(new FolderPluginCatalog( + packageDirectory, + CreateCatalogOptions( + baseOptions, + packageDirectory, + SearchOption.AllDirectories, + includeSubfolders: true))); + } + + return new CompositePluginCatalog([.. catalogs]); + } + + private static FolderPluginCatalogOptions CreateCatalogOptions( + FolderPluginCatalogOptions baseOptions, + string pluginDirectory, + SearchOption searchOption, + bool includeSubfolders) + { + var baseLoadOptions = baseOptions.PluginLoadContextOptions; + var files = Directory + .EnumerateFiles(pluginDirectory, "*", searchOption) + .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + .Select(path => new PluginFile( + path, + Path.GetRelativePath(pluginDirectory, path), + IsManagedAssembly(path))) + .OrderBy(file => GetRuntimeAssetPriority(file.RelativePath)) + .ThenBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var runtimeHints = new List<RuntimeAssemblyHint>( + baseLoadOptions.RuntimeAssemblyHints ?? []); + var hintKeys = runtimeHints + .Select(hint => GetHintKey(hint.FileName, hint.IsNative)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in files.OrderByDescending(file => file.IsManaged)) + { + var isNative = !file.IsManaged; + if (hintKeys.Add(GetHintKey(Path.GetFileName(file.Path), isNative))) + { + runtimeHints.Add(new RuntimeAssemblyHint( + Path.GetFileName(file.Path), + file.Path, + isNative)); + } + } + + var additionalRuntimePaths = new List<string>( + baseLoadOptions.AdditionalRuntimePaths ?? []); + foreach (var path in files + .Where(file => file.IsManaged) + .Select(file => Path.GetDirectoryName(file.Path)!) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!additionalRuntimePaths.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + additionalRuntimePaths.Add(path); + } + } + + return new FolderPluginCatalogOptions + { + IncludeSubfolders = includeSubfolders, + SearchPatterns = [.. baseOptions.SearchPatterns], + TypeFinderOptions = baseOptions.TypeFinderOptions, + PluginNameOptions = baseOptions.PluginNameOptions, + PluginLoadContextOptions = new PluginLoadContextOptions + { + UseHostApplicationAssemblies = baseLoadOptions.UseHostApplicationAssemblies, + HostApplicationAssemblies = [.. baseLoadOptions.HostApplicationAssemblies], + LoggerFactory = baseLoadOptions.LoggerFactory, + AdditionalRuntimePaths = additionalRuntimePaths, + RuntimeAssemblyHints = runtimeHints, + }, + }; + } + + private static bool IsManagedAssembly(string path) + { + try + { + using var stream = File.OpenRead(path); + using var reader = new PEReader(stream); + return reader.HasMetadata; + } + catch (BadImageFormatException) + { + return false; + } + } + + private static int GetRuntimeAssetPriority(string relativePath) + => relativePath.StartsWith( + $"runtimes{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? 0 + : 1; + + private static string GetHintKey(string fileName, bool isNative) + => $"{(isNative ? 'N' : 'M')}:{fileName}"; + internal static void SynchronizePluginFiles(string source, string destination) { Directory.CreateDirectory(destination); @@ -196,4 +321,6 @@ private static int GetPathDepth(string path) => path.Count(character => character == Path.DirectorySeparatorChar || character == Path.AltDirectorySeparatorChar); + + private sealed record PluginFile(string Path, string RelativePath, bool IsManaged); } diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index e07e91be..f97b6cea 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -51,6 +51,7 @@ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" /> + <PackageReference Include="NuGet.Frameworks" /> <PackageReference Include="NuGet.Versioning" /> <PackageReference Include="Octokit" /> <PackageReference Include="PropertyTools.Wpf" /> From 9041b6b834bf772561b8fa4dca128c9cb62b0ce3 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 2 Aug 2026 02:34:19 +0900 Subject: [PATCH 09/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E3=81=A8?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CatalogProbeResources.ar.resx | 18 +++ .../CatalogProbeResources.fr.resx | 18 +++ .../CatalogProbeResources.resx | 18 +++ .../NuGetPluginServiceTests.cs | 126 ++++++++++++++++ .../Modules/PluginStore/NuGetPluginCatalog.cs | 138 +++++++++++++++++- .../PluginStore/PluginStoreViewModel.cs | 10 ++ docs/plugin.md | 2 +- 7 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 WindowTranslator.Tests/CatalogProbeResources.ar.resx create mode 100644 WindowTranslator.Tests/CatalogProbeResources.fr.resx create mode 100644 WindowTranslator.Tests/CatalogProbeResources.resx diff --git a/WindowTranslator.Tests/CatalogProbeResources.ar.resx b/WindowTranslator.Tests/CatalogProbeResources.ar.resx new file mode 100644 index 00000000..cec4452a --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.ar.resx @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Greeting" xml:space="preserve"> + <value>العربية</value> + </data> +</root> diff --git a/WindowTranslator.Tests/CatalogProbeResources.fr.resx b/WindowTranslator.Tests/CatalogProbeResources.fr.resx new file mode 100644 index 00000000..d96e511b --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.fr.resx @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Greeting" xml:space="preserve"> + <value>français</value> + </data> +</root> diff --git a/WindowTranslator.Tests/CatalogProbeResources.resx b/WindowTranslator.Tests/CatalogProbeResources.resx new file mode 100644 index 00000000..6bf0819a --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.resx @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Greeting" xml:space="preserve"> + <value>日本語</value> + </data> +</root> diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index a6891928..2eabbf91 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -1,6 +1,8 @@ +using System.Globalization; using System.IO.Compression; using System.Net; using System.Net.Http; +using System.Resources; using System.Runtime.InteropServices; using System.Runtime.Loader; using System.Text; @@ -407,6 +409,42 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task PluginStoreKeepsInstalledPackagesVisibleWhenNuGetSearchFails() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), + Encoding.UTF8); + using var handler = new InMemoryNuGetHandler(); + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + var viewModel = new PluginStoreViewModel( + service, + NullLogger<PluginStoreViewModel>.Instance, + dialogService: null!); + + await viewModel.LoadAsync(); + + var package = Assert.Single(viewModel.Packages); + Assert.Equal("Installed.Plugin", package.Id); + Assert.Equal("1.2.3", package.InstalledVersion); + Assert.True(package.IsInstalled); + Assert.NotNull(viewModel.ErrorMessage); + Assert.Contains( + handler.RequestedPaths, + path => path.Equals("/v3/index.json", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task InstallRejectsPackageRequiringNewerHostAbstractions() { @@ -698,6 +736,81 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() } } + [Fact] + public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() + { + var sourceDirectory = CreateTestDirectory(); + var tempDirectory = CreateTestDirectory(); + var originalCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR"); + var packageDirectory = Path.Combine(sourceDirectory, "Catalog.Probe"); + Directory.CreateDirectory(packageDirectory); + var testAssemblyPath = typeof(NuGetPluginServiceTests).Assembly.Location; + File.Copy( + testAssemblyPath, + Path.Combine(packageDirectory, Path.GetFileName(testAssemblyPath))); + foreach (var cultureName in new[] { "ar", "fr" }) + { + var cultureDirectory = Path.Combine(packageDirectory, cultureName); + Directory.CreateDirectory(cultureDirectory); + File.Copy( + Path.Combine( + Path.GetDirectoryName(testAssemblyPath)!, + cultureName, + "WindowTranslator.Tests.resources.dll"), + Path.Combine(cultureDirectory, "WindowTranslator.Tests.resources.dll")); + } + + var options = new FolderPluginCatalogOptions(); + options.TypeFinderOptions.TypeFinderCriterias.Clear(); + options.TypeFinderOptions.TypeFinderCriterias.Add(new() + { + Query = static (_, type) => + type.Name == nameof(CatalogProbeLocalizedTranslateModule), + }); + options.PluginNameOptions.PluginNameGenerator = static (_, type) => + new ResourceManager( + "WindowTranslator.Tests.CatalogProbeResources", + type.Assembly).GetString("Greeting", CultureInfo.CurrentUICulture) + ?? type.Name; + options.PluginLoadContextOptions.UseHostApplicationAssemblies = + UseHostApplicationAssembliesEnum.Selected; + options.PluginLoadContextOptions.HostApplicationAssemblies = + AssemblyLoadContext.Default.Assemblies + .Where(assembly => !assembly.IsDynamic + && assembly != typeof(NuGetPluginServiceTests).Assembly) + .Select(assembly => assembly.GetName()) + .ToList(); + options.PluginLoadContextOptions.AdditionalRuntimePaths = []; + var catalog = new NuGetPluginCatalog( + sourceDirectory, + tempDirectory, + options); + + await catalog.Initialize(); + + var plugin = Assert.Single( + catalog.GetPlugins(), + plugin => plugin.Type.Name == nameof(CatalogProbeLocalizedTranslateModule)); + Assert.Equal("français", plugin.Name); + Assert.NotSame( + AssemblyLoadContext.Default, + AssemblyLoadContext.GetLoadContext(plugin.Type.Assembly)); + var module = Assert.IsAssignableFrom<ITranslateModule>( + Activator.CreateInstance(plugin.Type)); + var translated = await module.TranslateAsync([new("source", null)]); + Assert.Equal("français", Assert.Single(translated)); + } + finally + { + CultureInfo.CurrentUICulture = originalCulture; + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(tempDirectory); + } + } + [Fact] public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() { @@ -874,3 +987,16 @@ public ValueTask<string[]> TranslateAsync(TextInfo[] srcTexts) new NuGetVersion(1, 2, 3).ToNormalizedString(), srcTexts.Length).ToArray()); } + +public sealed class CatalogProbeLocalizedTranslateModule : ITranslateModule +{ + private static readonly ResourceManager Resources = new( + "WindowTranslator.Tests.CatalogProbeResources", + typeof(CatalogProbeLocalizedTranslateModule).Assembly); + + public ValueTask<string[]> TranslateAsync(TextInfo[] srcTexts) + => ValueTask.FromResult( + Enumerable.Repeat( + Resources.GetString("Greeting", CultureInfo.CurrentUICulture) ?? string.Empty, + srcTexts.Length).ToArray()); +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index e25a450b..7a7e7dff 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -1,5 +1,7 @@ using System.IO; +using System.Reflection; using System.Reflection.PortableExecutable; +using System.Runtime.Loader; using Weikio.PluginFramework.Abstractions; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; @@ -92,20 +94,29 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( .EnumerateFiles(pluginDirectory, "*", searchOption) .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) - .Select(path => new PluginFile( - path, - Path.GetRelativePath(pluginDirectory, path), - IsManagedAssembly(path))) + .Select(path => CreatePluginFile(pluginDirectory, path)) .OrderBy(file => GetRuntimeAssetPriority(file.RelativePath)) .ThenBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase) .ToArray(); + var satelliteAssemblies = files + .Where(file => file.IsSatelliteAssembly) + .GroupBy(file => GetSatelliteKey( + file.AssemblyName!.Name!, + file.AssemblyName.CultureName!)) + .ToDictionary( + group => group.Key, + group => group.First(), + StringComparer.OrdinalIgnoreCase); + var runtimeHints = new List<RuntimeAssemblyHint>( baseLoadOptions.RuntimeAssemblyHints ?? []); var hintKeys = runtimeHints .Select(hint => GetHintKey(hint.FileName, hint.IsNative)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var file in files.OrderByDescending(file => file.IsManaged)) + foreach (var file in files + .Where(file => !file.IsSatelliteAssembly) + .OrderByDescending(file => file.IsManaged)) { var isNative = !file.IsManaged; if (hintKeys.Add(GetHintKey(Path.GetFileName(file.Path), isNative))) @@ -120,7 +131,7 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( var additionalRuntimePaths = new List<string>( baseLoadOptions.AdditionalRuntimePaths ?? []); foreach (var path in files - .Where(file => file.IsManaged) + .Where(file => file.IsManaged && !file.IsSatelliteAssembly) .Select(file => Path.GetDirectoryName(file.Path)!) .Distinct(StringComparer.OrdinalIgnoreCase)) { @@ -135,7 +146,9 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( IncludeSubfolders = includeSubfolders, SearchPatterns = [.. baseOptions.SearchPatterns], TypeFinderOptions = baseOptions.TypeFinderOptions, - PluginNameOptions = baseOptions.PluginNameOptions, + PluginNameOptions = CreatePluginNameOptions( + baseOptions.PluginNameOptions, + satelliteAssemblies), PluginLoadContextOptions = new PluginLoadContextOptions { UseHostApplicationAssemblies = baseLoadOptions.UseHostApplicationAssemblies, @@ -147,6 +160,104 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( }; } + private static PluginFile CreatePluginFile(string pluginDirectory, string path) + { + var isManaged = IsManagedAssembly(path); + return new PluginFile( + path, + Path.GetRelativePath(pluginDirectory, path), + isManaged, + isManaged ? TryGetAssemblyName(path) : null); + } + + private static PluginNameOptions CreatePluginNameOptions( + PluginNameOptions baseOptions, + IReadOnlyDictionary<string, PluginFile> satelliteAssemblies) + { + if (satelliteAssemblies.Count == 0) + { + return baseOptions; + } + + var configuredContexts = new HashSet<AssemblyLoadContext>(); + var contextLock = new object(); + + void EnsureSatelliteResolver(Type type) + { + var context = AssemblyLoadContext.GetLoadContext(type.Assembly); + if (context is null || context == AssemblyLoadContext.Default) + { + return; + } + + lock (contextLock) + { + if (configuredContexts.Add(context)) + { + context.Resolving += ResolveSatelliteAssembly; + } + } + } + + Assembly? ResolveSatelliteAssembly( + AssemblyLoadContext context, + AssemblyName requestedAssembly) + { + if (string.IsNullOrWhiteSpace(requestedAssembly.Name) + || string.IsNullOrWhiteSpace(requestedAssembly.CultureName) + || !satelliteAssemblies.TryGetValue( + GetSatelliteKey(requestedAssembly.Name, requestedAssembly.CultureName), + out var satelliteAssembly) + || requestedAssembly.Version is not null + && satelliteAssembly.AssemblyName!.Version != requestedAssembly.Version) + { + return null; + } + + return context.LoadFromAssemblyPath(satelliteAssembly.Path); + } + + return new PluginNameOptions + { + PluginNameGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginNameGenerator(baseOptions, type); + }, + PluginVersionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginVersionGenerator(baseOptions, type); + }, + PluginDescriptionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginDescriptionGenerator(baseOptions, type); + }, + PluginProductVersionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginProductVersionGenerator(baseOptions, type); + }, + }; + } + + private static AssemblyName? TryGetAssemblyName(string path) + { + try + { + return AssemblyName.GetAssemblyName(path); + } + catch (BadImageFormatException) + { + return null; + } + catch (FileLoadException) + { + return null; + } + } + private static bool IsManagedAssembly(string path) { try @@ -171,6 +282,9 @@ private static int GetRuntimeAssetPriority(string relativePath) private static string GetHintKey(string fileName, bool isNative) => $"{(isNative ? 'N' : 'M')}:{fileName}"; + private static string GetSatelliteKey(string assemblyName, string cultureName) + => $"{cultureName}:{assemblyName}"; + internal static void SynchronizePluginFiles(string source, string destination) { Directory.CreateDirectory(destination); @@ -322,5 +436,13 @@ private static int GetPathDepth(string path) character == Path.DirectorySeparatorChar || character == Path.AltDirectorySeparatorChar); - private sealed record PluginFile(string Path, string RelativePath, bool IsManaged); + private sealed record PluginFile( + string Path, + string RelativePath, + bool IsManaged, + AssemblyName? AssemblyName) + { + public bool IsSatelliteAssembly + => !string.IsNullOrWhiteSpace(this.AssemblyName?.CultureName); + } } diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 649d9f2c..81830e2f 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -56,6 +56,16 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) var installed = await this.nugetService.GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(true); var installedDict = installed.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase); + this.Packages.Clear(); + foreach (var inst in installed) + { + this.Packages.Add(new PluginPackageViewModel( + new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), + isInstalled: true, + installedVersion: inst.Version, + isUpdateAvailable: false)); + } + var packages = await this.nugetService.SearchPackagesAsync(cancellationToken).ConfigureAwait(true); this.logger.LogInformation("NuGetから{Count}件のプラグインパッケージを取得しました。", packages.Count); diff --git a/docs/plugin.md b/docs/plugin.md index a5644d0a..932ad583 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -141,7 +141,7 @@ NuGetパッケージで宣言されたランタイム依存関係も再帰的に ## 注意事項 -- プラグインは .NET 10 以上をターゲットにしてください +- プラグインは WindowTranslator と同じ `net10.0` をターゲットにしてください。`net11.0` など、ホストより新しいTFMは読み込めません - `<EnableDynamicLoading>true</EnableDynamicLoading>` を必ず設定してください - ホスト側で既に提供されているパッケージは `ExcludeAssets="runtime"` を設定し、DLL を重複させないようにしてください - 通常のランタイム依存は `PackageReference` として宣言してください From 51cd4ea24c835d70241414ba6abd820d297b605a Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 2 Aug 2026 03:30:49 +0900 Subject: [PATCH 10/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E3=83=97=E3=83=AC=E3=83=AA=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E5=B0=8E=E5=85=A5=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 134 ++++++++++++++++++ .../Modules/PluginStore/NuGetPluginService.cs | 19 ++- .../Modules/PluginStore/PluginStoreView.xaml | 17 ++- .../PluginStore/PluginStoreViewModel.cs | 110 ++++++++++---- .../Modules/Settings/AllSettingsDialog.xaml | 8 +- .../Properties/Resources.Designer.cs | 7 +- WindowTranslator/Properties/Resources.ar.resx | 5 +- WindowTranslator/Properties/Resources.cs.resx | 5 +- WindowTranslator/Properties/Resources.de.resx | 5 +- WindowTranslator/Properties/Resources.en.resx | 5 +- WindowTranslator/Properties/Resources.es.resx | 5 +- WindowTranslator/Properties/Resources.fa.resx | 5 +- .../Properties/Resources.fil.resx | 5 +- WindowTranslator/Properties/Resources.fr.resx | 5 +- WindowTranslator/Properties/Resources.hi.resx | 5 +- WindowTranslator/Properties/Resources.hu.resx | 5 +- WindowTranslator/Properties/Resources.id.resx | 5 +- WindowTranslator/Properties/Resources.ko.resx | 5 +- WindowTranslator/Properties/Resources.ms.resx | 5 +- WindowTranslator/Properties/Resources.pl.resx | 5 +- .../Properties/Resources.pt-BR.resx | 5 +- WindowTranslator/Properties/Resources.resx | 5 +- WindowTranslator/Properties/Resources.ru.resx | 5 +- WindowTranslator/Properties/Resources.th.resx | 5 +- WindowTranslator/Properties/Resources.tr.resx | 5 +- WindowTranslator/Properties/Resources.vi.resx | 5 +- .../Properties/Resources.zh-CN.resx | 5 +- .../Properties/Resources.zh-TW.resx | 5 +- docs/plugin.md | 3 +- 29 files changed, 343 insertions(+), 65 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 2eabbf91..91148cda 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -445,6 +445,102 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), } } + [Fact] + public async Task SearchReturnsReleaseAndPrereleaseVersions() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler + { + SearchResponseJson = """ + { + "totalHits": 1, + "data": [ + { + "id": "Test.Plugin", + "version": "1.1.0-beta.2", + "title": "Test Plugin", + "description": "Test description", + "authors": ["WindowTranslator.Tests"], + "versions": [ + { "version": "1.0.0" }, + { "version": "1.1.0-beta.1" }, + { "version": "1.1.0-beta.2" } + ] + } + ] + } + """, + }; + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + var package = Assert.Single(await service.SearchPackagesAsync()); + + Assert.Equal("Test.Plugin", package.Id); + Assert.Equal("1.1.0-beta.2", package.Version); + Assert.Equal( + ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], + package.Versions); + Assert.Contains( + handler.RequestedUris, + uri => uri.Contains("prerelease=true", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public void PackageVersionSelectionRequiresOptInForPrerelease() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "1.1.0-beta.2", + "Test Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"]), + isInstalled: true, + installedVersion: "1.0.0"); + + Assert.Equal("1.0.0", package.LatestVersion); + Assert.False(package.UsePrerelease); + Assert.False(package.IsUpdateAvailable); + + package.UsePrerelease = true; + + Assert.Equal("1.1.0-beta.2", package.LatestVersion); + Assert.True(package.IsUpdateAvailable); + Assert.True(package.CanInstall); + + var prereleaseOnlyPackage = new PluginPackageViewModel( + new NuGetPackageInfo( + "Preview.Plugin", + "2.0.0-preview.1", + "Preview Plugin", + string.Empty, + string.Empty, + null, + null, + ["2.0.0-preview.1"]), + isInstalled: false, + installedVersion: null); + + Assert.Null(prereleaseOnlyPackage.LatestVersion); + Assert.False(prereleaseOnlyPackage.CanInstall); + + prereleaseOnlyPackage.UsePrerelease = true; + + Assert.Equal("2.0.0-preview.1", prereleaseOnlyPackage.LatestVersion); + Assert.True(prereleaseOnlyPackage.CanInstall); + } + [Fact] public async Task InstallRejectsPackageRequiringNewerHostAbstractions() { @@ -931,6 +1027,10 @@ private sealed class InMemoryNuGetHandler : HttpMessageHandler public List<string> RequestedPaths { get; } = []; + public List<string> RequestedUris { get; } = []; + + public string? SearchResponseJson { get; init; } + public void AddPackage(string id, string version, byte[] package) => this.packages[(id.ToLowerInvariant(), version.ToLowerInvariant())] = package; @@ -940,6 +1040,40 @@ protected override Task<HttpResponseMessage> SendAsync( { var path = request.RequestUri!.AbsolutePath; this.RequestedPaths.Add(path); + this.RequestedUris.Add(request.RequestUri.PathAndQuery); + + if (path.Equals("/v3/index.json", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "resources": [ + { + "@id": "https://nuget.test/query", + "@type": "SearchQueryService/3.5.0" + } + ] + } + """, + Encoding.UTF8, + "application/json"), + }); + } + + if (path.Equals("/query", StringComparison.OrdinalIgnoreCase) + && this.SearchResponseJson is not null) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + this.SearchResponseJson, + Encoding.UTF8, + "application/json"), + }); + } + var segments = path.Trim('/').Split('/'); if (segments.Length == 3 && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase) diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 1d8b08ba..b5b84126 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -67,7 +67,7 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat this.searchUrl = await GetSearchUrlAsync(cancellationToken).ConfigureAwait(false); } - var url = $"{this.searchUrl}?q=tags:{PluginTag}&take=100&semVerLevel=2.0.0&prerelease=false"; + var url = $"{this.searchUrl}?q=tags:{PluginTag}&take=100&semVerLevel=2.0.0&prerelease=true"; this.logger.LogDebug("NuGet検索URL: {Url}", url); using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); @@ -86,7 +86,12 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat Description: d.Description ?? string.Empty, Authors: string.Join(", ", d.Authors ?? []), ProjectUrl: d.ProjectUrl, - LicenseUrl: d.LicenseUrl + LicenseUrl: d.LicenseUrl, + Versions: d.Versions? + .Select(version => version.Version) + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Select(version => version!) + .ToArray() )).ToArray() ?? []; } @@ -457,7 +462,8 @@ public record NuGetPackageInfo( string Description, string Authors, string? ProjectUrl, - string? LicenseUrl + string? LicenseUrl, + IReadOnlyList<string>? Versions = null ); /// <summary>インストール済みパッケージ情報</summary> @@ -491,5 +497,10 @@ internal record NuGetSearchData( [property: JsonPropertyName("description")] string? Description, [property: JsonPropertyName("authors")] string[]? Authors, [property: JsonPropertyName("projectUrl")] string? ProjectUrl, - [property: JsonPropertyName("licenseUrl")] string? LicenseUrl + [property: JsonPropertyName("licenseUrl")] string? LicenseUrl, + [property: JsonPropertyName("versions")] NuGetSearchVersion[]? Versions +); + +internal record NuGetSearchVersion( + [property: JsonPropertyName("version")] string? Version ); diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 4b26ed4b..e86de86d 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -69,6 +69,7 @@ <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <!-- タイトルと状態 --> @@ -100,10 +101,20 @@ Text="{Binding StatusText}" TextTrimming="CharacterEllipsis" /> + <CheckBox + Grid.Row="2" + Grid.Column="0" + Margin="0,2,0,0" + Content="{x:Static properties:Resources.Prerelease}" + IsEnabled="{Binding IsInstalling, Converter={x:Static local:InverseBoolConverter.Default}}" + IsChecked="{Binding UsePrerelease, Mode=TwoWay}" + ToolTip="{Binding PrereleaseVersion}" + Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> + <!-- インストールボタン --> <StackPanel Grid.Row="0" - Grid.RowSpan="2" + Grid.RowSpan="3" Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal"> @@ -122,7 +133,7 @@ CommandParameter="{Binding}" Content="{x:Static properties:Resources.Install}" Icon="{ui:SymbolIcon ArrowDownload24}" - IsEnabled="{Binding IsInstalling, Converter={x:Static local:InverseBoolConverter.Default}}" + IsEnabled="{Binding CanInstall}" Style="{StaticResource InstallButtonStyle}" Visibility="{Binding IsInstalled, Converter={x:Static local:InverseBoolConverter.Default}, ConverterParameter=Visibility}" /> @@ -132,7 +143,7 @@ CommandParameter="{Binding}" Content="{x:Static properties:Resources.Update}" Icon="{ui:SymbolIcon ArrowSync24}" - IsEnabled="{Binding IsInstalling, Converter={x:Static local:InverseBoolConverter.Default}}" + IsEnabled="{Binding CanInstall}" Style="{StaticResource InstallButtonStyle}" Visibility="{Binding IsUpdateAvailable, Converter={StaticResource b2vConv}}" /> diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 81830e2f..f1ca6f1a 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -62,8 +62,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) this.Packages.Add(new PluginPackageViewModel( new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), isInstalled: true, - installedVersion: inst.Version, - isUpdateAvailable: false)); + installedVersion: inst.Version)); } var packages = await this.nugetService.SearchPackagesAsync(cancellationToken).ConfigureAwait(true); @@ -75,11 +74,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) installedDict.TryGetValue(pkg.Id, out var installedInfo); var isInstalled = installedInfo is not null; var installedVersion = installedInfo?.Version; - var isUpdateAvailable = isInstalled - && installedVersion is not null - && IsNewerVersion(pkg.Version, installedVersion); - - this.Packages.Add(new PluginPackageViewModel(pkg, isInstalled, installedVersion, isUpdateAvailable)); + this.Packages.Add(new PluginPackageViewModel(pkg, isInstalled, installedVersion)); } // インストール済みだがNuGetに見つからないパッケージも表示 @@ -90,8 +85,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) this.Packages.Add(new PluginPackageViewModel( new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), isInstalled: true, - installedVersion: inst.Version, - isUpdateAvailable: false)); + installedVersion: inst.Version)); } } } @@ -118,20 +112,25 @@ public async Task InstallAsync( PluginPackageViewModel package, CancellationToken cancellationToken = default) { + var version = package.LatestVersion; + if (string.IsNullOrWhiteSpace(version)) + { + return; + } + package.IsInstalling = true; try { - this.logger.LogInformation("プラグインのインストール開始: {PackageId} {Version}", package.Id, package.LatestVersion); + this.logger.LogInformation("プラグインのインストール開始: {PackageId} {Version}", package.Id, version); var progress = new Progress<double>(v => package.InstallProgress = v); await this.nugetService.InstallPackageAsync( package.Id, - package.LatestVersion, + version, progress, cancellationToken).ConfigureAwait(true); package.IsInstalled = true; - package.InstalledVersion = package.LatestVersion; - package.IsUpdateAvailable = false; + package.InstalledVersion = version; package.InstallProgress = 0; this.logger.LogInformation("プラグインのインストール完了: {PackageId}", package.Id); @@ -185,7 +184,6 @@ public async Task UninstallAsync(PluginPackageViewModel package) await this.nugetService.UninstallPackageAsync(package.Id).ConfigureAwait(true); package.IsInstalled = false; package.InstalledVersion = null; - package.IsUpdateAvailable = false; await this.dialogService.ShowSimpleDialogAsync(new() { @@ -204,16 +202,6 @@ await this.dialogService.ShowAlertAsync( } } - private static bool IsNewerVersion(string latestVersion, string installedVersion) - { - if (NuGetVersion.TryParse(latestVersion, out var latest) - && NuGetVersion.TryParse(installedVersion, out var installed)) - { - return latest > installed; - } - - return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; - } } /// <summary> @@ -225,7 +213,13 @@ public partial class PluginPackageViewModel : ObservableObject public string Title { get; } public string Description { get; } public string Authors { get; } - public string LatestVersion { get; } + public string? ReleaseVersion { get; } + public string? PrereleaseVersion { get; } + public string? LatestVersion => this.UsePrerelease + ? this.PrereleaseVersion + : this.ReleaseVersion; + public bool HasPrereleaseVersion => this.PrereleaseVersion is not null; + public bool CanInstall => !this.IsInstalling && this.LatestVersion is not null; public string? ProjectUrl { get; } public string? LicenseUrl { get; } @@ -241,16 +235,25 @@ public partial class PluginPackageViewModel : ObservableObject private bool isUpdateAvailable; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanInstall))] private bool isInstalling; [ObservableProperty] private double installProgress; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LatestVersion))] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(StatusText))] + private bool usePrerelease; + public string StatusText { get { - if (this.IsUpdateAvailable && this.InstalledVersion is not null) + if (this.IsUpdateAvailable + && this.InstalledVersion is not null + && this.LatestVersion is not null) return string.Format(Properties.Resources.UpdateAvailableVersion, this.InstalledVersion, this.LatestVersion); if (this.IsInstalled && this.InstalledVersion is not null) return string.Format(Properties.Resources.InstalledVersion, this.InstalledVersion); @@ -261,18 +264,65 @@ public string StatusText public PluginPackageViewModel( NuGetPackageInfo info, bool isInstalled, - string? installedVersion, - bool isUpdateAvailable) + string? installedVersion) { + var versions = new[] { info.Version } + .Concat(info.Versions ?? []) + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(version => (Text: version, Parsed: ParseVersion(version))) + .Where(version => version.Parsed is not null) + .ToArray(); + this.Id = info.Id; this.Title = info.Title; this.Description = info.Description; this.Authors = info.Authors; - this.LatestVersion = info.Version; + this.ReleaseVersion = versions + .Where(version => !version.Parsed!.IsPrerelease) + .OrderByDescending(version => version.Parsed) + .Select(version => version.Text) + .FirstOrDefault(); + this.PrereleaseVersion = versions + .Where(version => version.Parsed!.IsPrerelease) + .OrderByDescending(version => version.Parsed) + .Select(version => version.Text) + .FirstOrDefault(); this.ProjectUrl = info.ProjectUrl; this.LicenseUrl = info.LicenseUrl; this.isInstalled = isInstalled; this.installedVersion = installedVersion; - this.isUpdateAvailable = isUpdateAvailable; + this.usePrerelease = this.PrereleaseVersion is not null + && NuGetVersion.TryParse(installedVersion, out var installed) + && installed.IsPrerelease; + RefreshUpdateAvailable(); + } + + partial void OnIsInstalledChanged(bool value) => RefreshUpdateAvailable(); + + partial void OnInstalledVersionChanged(string? value) => RefreshUpdateAvailable(); + + partial void OnUsePrereleaseChanged(bool value) => RefreshUpdateAvailable(); + + private void RefreshUpdateAvailable() + { + this.IsUpdateAvailable = this.IsInstalled + && this.InstalledVersion is not null + && this.LatestVersion is not null + && IsNewerVersion(this.LatestVersion, this.InstalledVersion); + } + + private static NuGetVersion? ParseVersion(string version) + => NuGetVersion.TryParse(version, out var parsed) ? parsed : null; + + private static bool IsNewerVersion(string latestVersion, string installedVersion) + { + if (NuGetVersion.TryParse(latestVersion, out var latest) + && NuGetVersion.TryParse(installedVersion, out var installed)) + { + return latest > installed; + } + + return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; } } diff --git a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml index 8c2eb942..00d0b35c 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml +++ b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml @@ -375,6 +375,9 @@ </pt:PropertyGrid> </Grid> </TabItem> + <TabItem Header="{x:Static properties:Resources.PluginStore}"> + <pluginStore:PluginStoreView DataContext="{Binding PluginStore}" /> + </TabItem> <TabItem Header="{x:Static properties:Resources.About}" Visibility="{Binding IsVisibleAbout, Converter={StaticResource b2vConv}}"> <DockPanel> <ui:Button @@ -400,11 +403,8 @@ </pt:PropertyGrid> </DockPanel> </TabItem> - <TabItem Header="{x:Static properties:Resources.PluginStore}"> - <pluginStore:PluginStoreView DataContext="{Binding PluginStore}" /> - </TabItem> </TabControl> </DockPanel> <ContentPresenter x:Name="RootContentDialog" /> </Grid> -</ui:FluentWindow> \ No newline at end of file +</ui:FluentWindow> diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 30f17809..b4e15bb9 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -458,10 +458,15 @@ internal Resources() { public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; /// <summary> - /// "プラグインストア" に類似しているローカライズされた文字列を検索します。 + /// "プラグイン" に類似しているローカライズされた文字列を検索します。 /// </summary> public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; + /// <summary> + /// "プレリリース" に類似しているローカライズされた文字列を検索します。 + /// </summary> + public static string Prerelease => ResourceManager.GetString("Prerelease", resourceCulture) ?? string.Empty; + /// <summary> /// "プロジェクトページ" に類似しているローカライズされた文字列を検索します。 /// </summary> diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 1d0e33af..1de815fd 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -451,7 +451,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>متجر المكونات الإضافية</value> + <value>المكونات الإضافية</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>إصدار أولي</value> </data> <data name="Install" xml:space="preserve"> <value>تثبيت</value> diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 7438924c..cd8b7a45 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -341,7 +341,10 @@ Monitory nejsou podporovány. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Obchod s pluginy</value> + <value>Pluginy</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Předběžná verze</value> </data> <data name="Install" xml:space="preserve"> <value>Nainstalovat</value> diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index dbdf0186..410b25d5 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -460,7 +460,10 @@ Monitore werden nicht unterstützt. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Plugin-Store</value> + <value>Plugins</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Vorabversion</value> </data> <data name="Install" xml:space="preserve"> <value>Installieren</value> diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 6d0acccf..23eed21d 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -460,7 +460,10 @@ Monitors are not supported. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Plugin Store</value> + <value>Plugins</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Prerelease</value> </data> <data name="Install" xml:space="preserve"> <value>Install</value> diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index bf6977f4..1b36ad36 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -451,7 +451,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Tienda de plugins</value> + <value>Plugins</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Versión preliminar</value> </data> <data name="Install" xml:space="preserve"> <value>Instalar</value> diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index a5bacfea..0be9545a 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -445,7 +445,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>فروشگاه افزونه</value> + <value>افزونه‌ها</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>پیش‌انتشار</value> </data> <data name="Install" xml:space="preserve"> <value>نصب</value> diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 44c7b38d..5d860d57 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -460,7 +460,10 @@ Ang monitor ay hindi suportado. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Plugin Store</value> + <value>Mga Plugin</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Prerelease</value> </data> <data name="Install" xml:space="preserve"> <value>I-install</value> diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 2e988292..aac8ccbb 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -451,7 +451,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Boutique de plugins</value> + <value>Plugins</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Préversion</value> </data> <data name="Install" xml:space="preserve"> <value>Installer</value> diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 0d475cbd..469aa170 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -453,7 +453,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>प्लगइन स्टोर</value> + <value>प्लगइन</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>प्रीरिलीज़</value> </data> <data name="Install" xml:space="preserve"> <value>इंस्टॉल करें</value> diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 3d6eb384..dcc6720c 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -341,7 +341,10 @@ A monitorok nem támogatottak. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Bővítményáruház</value> + <value>Bővítmények</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Előzetes kiadás</value> </data> <data name="Install" xml:space="preserve"> <value>Telepítés</value> diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 1e5935b6..aa7140fc 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -459,7 +459,10 @@ Monitor tidak didukung.</value> </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Toko Plugin</value> + <value>Plugin</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Prarilis</value> </data> <data name="Install" xml:space="preserve"> <value>Pasang</value> diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 6843817f..817dc5d9 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -460,7 +460,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>플러그인 스토어</value> + <value>플러그인</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>시험판</value> </data> <data name="Install" xml:space="preserve"> <value>설치</value> diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 35fcf87a..468b4d13 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -459,7 +459,10 @@ Monitor tidak disokong.</value> </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Kedai Plugin</value> + <value>Plugin</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Prakeluaran</value> </data> <data name="Install" xml:space="preserve"> <value>Pasang</value> diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index a7f2a347..f667ae1c 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -460,7 +460,10 @@ Monitory nie są obsługiwane. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Sklep wtyczek</value> + <value>Wtyczki</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Wersja wstępna</value> </data> <data name="Install" xml:space="preserve"> <value>Zainstaluj</value> diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 57e5427a..2fbacce7 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -459,7 +459,10 @@ Monitor tidak didukung.</value> </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Loja de Plugins</value> + <value>Plugins</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Pré-lançamento</value> </data> <data name="Install" xml:space="preserve"> <value>Instalar</value> diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 098a8db5..e9e8b24f 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -460,7 +460,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>プラグインストア</value> + <value>プラグイン</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>プレリリース</value> </data> <data name="Install" xml:space="preserve"> <value>インストール</value> diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index e6c2f57b..6fddcfb1 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -451,7 +451,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Магазин плагинов</value> + <value>Плагины</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Предварительная версия</value> </data> <data name="Install" xml:space="preserve"> <value>Установить</value> diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 32b18f69..17bebcad 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -460,7 +460,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>ร้านปลั๊กอิน</value> + <value>ปลั๊กอิน</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>รุ่นก่อนเผยแพร่</value> </data> <data name="Install" xml:space="preserve"> <value>ติดตั้ง</value> diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 14d89982..3a192391 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -460,7 +460,10 @@ Monitör desteklenmiyor. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Eklenti Mağazası</value> + <value>Eklentiler</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Ön sürüm</value> </data> <data name="Install" xml:space="preserve"> <value>Yükle</value> diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 1e8d917e..878151c4 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -460,7 +460,10 @@ Màn hình không được hỗ trợ. </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>Cửa hàng Plugin</value> + <value>Plugin</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>Bản phát hành trước</value> </data> <data name="Install" xml:space="preserve"> <value>Cài đặt</value> diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 9302d7c2..04b14e05 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -460,7 +460,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>插件商店</value> + <value>插件</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>预发行版</value> </data> <data name="Install" xml:space="preserve"> <value>安装</value> diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index f6e61784..da6427bd 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -460,7 +460,10 @@ </value> </data> <data name="PluginStore" xml:space="preserve"> - <value>外掛程式商店</value> + <value>外掛程式</value> + </data> + <data name="Prerelease" xml:space="preserve"> + <value>預發行版本</value> </data> <data name="Install" xml:space="preserve"> <value>安裝</value> diff --git a/docs/plugin.md b/docs/plugin.md index 932ad583..942a4f6f 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -124,8 +124,9 @@ public class MyTranslateModule : ITranslateModule { ... } ## アプリからインストールする 1. WindowTranslator の設定を開きます。 -2. 「プラグインストア」タブを選択します。 +2. 「プラグイン」タブを選択します。 3. 利用するプラグインの「インストール」を選択します。 + プレリリース版を利用する場合は、そのプラグインの「プレリリース」にチェックを入れます。 4. インストール完了後に WindowTranslator を再起動します。 NuGetパッケージで宣言されたランタイム依存関係も再帰的に取得されます。 From 9e597f2bae79de38de675919813a5e19b12c48c5 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 2 Aug 2026 03:52:53 +0900 Subject: [PATCH 11/43] =?UTF-8?q?NuGet=E3=83=91=E3=83=83=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E7=89=88=E7=95=AA=E5=8F=B7=E3=82=92=E3=82=BF?= =?UTF-8?q?=E3=82=B0=E3=81=8B=E3=82=89=E5=8F=96=E5=BE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-package.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index 685cafa9..d8c7082d 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -25,12 +25,21 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 + - id: package-version + shell: pwsh + run: | + $tag = '${{ github.ref_name }}' + if ($tag -notmatch '^v(?<version>[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$') { + Write-Error "NuGet package tag must be v-prefixed SemVer: $tag" + exit 1 + } + "version=$($Matches.version)" >> $env:GITHUB_OUTPUT - uses: Jimver/cuda-toolkit@v0.2.30 with: cuda: '12.9.0' - run: | dotnet pack WindowTranslator.Abstractions -c Release -o pack ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:Version=${{ steps.package-version.outputs.version }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} @@ -38,7 +47,7 @@ jobs: exit $LASTEXITCODE } dotnet pack ColorThief\ColorThief -c Release -o pack ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:Version=${{ steps.package-version.outputs.version }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} @@ -58,7 +67,7 @@ jobs: continue } dotnet pack $project.FullName -c Release -o pack ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:Version=${{ steps.package-version.outputs.version }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} From d7dc5f44ac07c8327a9454772203b193172f9a4c Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Mon, 3 Aug 2026 09:34:30 +0900 Subject: [PATCH 12/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E3=81=AENuGet=E9=80=A3?= =?UTF-8?q?=E6=90=BA=E3=82=92=E3=83=A9=E3=82=A4=E3=83=96=E3=83=A9=E3=83=AA?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 2 + Plugins/Directory.Build.targets | 12 + .../README.md | 22 + .../README.md | 17 + .../README.md | 22 + .../README.md | 25 + .../README.md | 24 + .../README.md | 27 + .../README.md | 21 + .../README.md | 28 + .../README.md | 25 +- .../README.md | 25 + .../README.md | 22 + .../NuGetPluginServiceTests.cs | 520 +++++++++++++++--- .../PluginStore/NuGetPackageInstaller.cs | 173 +++--- .../Modules/PluginStore/NuGetPluginService.cs | 207 ++++--- .../NuGetProtocolPluginMetadataSource.cs | 122 ++++ .../Modules/PluginStore/PluginStoreView.xaml | 184 ++++--- .../PluginStore/PluginStoreViewModel.cs | 112 +++- WindowTranslator/WindowTranslator.csproj | 2 + 20 files changed, 1268 insertions(+), 324 deletions(-) create mode 100644 Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.FoMPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.LLMPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md create mode 100644 WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bf4b2b97..26c77095 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,6 +35,8 @@ <PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" /> <PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="NuGet.Frameworks" Version="7.6.0" /> + <PackageVersion Include="NuGet.Packaging" Version="7.6.0" /> + <PackageVersion Include="NuGet.Protocol" Version="7.6.0" /> <PackageVersion Include="NuGet.Versioning" Version="7.6.0" /> <PackageVersion Include="Octokit" Version="14.0.0" /> <PackageVersion Include="Panlingo.LanguageIdentification.FastText" Version="0.6.2" /> diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index f880e7be..ae80e3ff 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -4,10 +4,22 @@ <PropertyGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'"> <PackageTags>$(PackageTags);windowtranslator-plugin</PackageTags> + <PackageReadmeFile Condition="Exists('$(MSBuildProjectDirectory)\README.md')">README.md</PackageReadmeFile> + <TargetsForTfmSpecificContentInPackage + Condition="Exists('$(MSBuildProjectDirectory)\README.md')">$(TargetsForTfmSpecificContentInPackage);AddPluginReadmeToPackage</TargetsForTfmSpecificContentInPackage> <TargetsForTfmSpecificBuildOutput Condition="'$(IncludePluginRuntimeAssetsInPackage)' == 'true'">$(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage</TargetsForTfmSpecificBuildOutput> </PropertyGroup> + <!-- IncludeContentInPack=false のプラグインでも README を常にパッケージへ含める。 --> + <Target Name="AddPluginReadmeToPackage"> + <ItemGroup> + <TfmSpecificPackageFile Include="$(MSBuildProjectDirectory)\README.md"> + <PackagePath>README.md</PackagePath> + </TfmSpecificPackageFile> + </ItemGroup> + </Target> + <!-- PackageReference の build/buildTransitive ターゲットによって出力されるネイティブ資産は、 依存パッケージの標準 runtimes/ 配下に存在しない場合がある。 diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md new file mode 100644 index 00000000..13c70698 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md @@ -0,0 +1,22 @@ +# WindowTranslator Bergamot Translator Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、Bergamotによるニューラル機械翻訳をローカル実行するプラグインです。 + +## 機能 + +- 翻訳テキストを外部サービスへ送信せず、オフラインで翻訳 +- 対応する言語ペアのモデルを初回利用時に自動取得 +- 直接変換できない言語ペアでは、利用可能な場合に英語を経由して翻訳 + +## 必要条件 + +- 対応するBergamot翻訳モデルが提供されている言語ペア +- モデルを初めて取得するときのインターネット接続 + +モデルの取得後はオフラインで利用できます。モデルが提供されていない言語ペアでは、このモジュールを選択できません。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md new file mode 100644 index 00000000..d8bcbf79 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md @@ -0,0 +1,17 @@ +# WindowTranslator ColorThief Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、キャプチャ画像から翻訳テキストに適した前景色と背景色を推定するカラープラグインです。 + +## 機能 + +- OCR領域周辺の代表色から背景色を推定 +- 背景との明度差を考慮して読みやすい文字色を選択 +- 回転したテキスト領域にも対応 + +外部APIや追加設定は必要ありません。インストール後、対象設定のカラーモジュールで「近似カラー」を選択してください。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md new file mode 100644 index 00000000..9dc70687 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md @@ -0,0 +1,22 @@ +# WindowTranslator DeepL Translator Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でDeepL APIを利用する翻訳プラグインです。 + +## 機能 + +- DeepL APIによる翻訳 +- WindowTranslatorから渡された文脈を翻訳リクエストへ反映 +- CSV用語集による表記の統一 +- 設定画面からAPI利用量を確認 + +## 設定 + +- DeepL APIの認証キーが必要です。 +- 用語集を利用する場合は、ヘッダーなしの`原文,訳文`形式のCSVファイルを指定します。 +- 利用可能な言語、料金、上限はDeepL APIの契約内容に従います。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md new file mode 100644 index 00000000..fd4d848b --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md @@ -0,0 +1,25 @@ +# WindowTranslator Fields of Mistria Filter Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) の翻訳をゲーム「Fields of Mistria」向けに補助するフィルタープラグインです。 + +## 機能 + +- 実行中の`FieldsOfMistria.exe`を検出した場合だけ有効化 +- ゲームの`localization.json`を参照してOCR結果を補正 +- キャラクター、シーン、会話の情報を翻訳コンテキストとして追加 +- キャラクター名やアイテム名を選択中の翻訳モジュールへ用語集として登録 + +## 設定 + +- OCR補正の有効化 +- 公式日本語テキストの利用 +- プレイヤー名と農場名 +- ゲームデータに存在しないテキストの除外 + +このプラグイン自体は翻訳サービスを提供しません。WindowTranslatorで別途、翻訳モジュールを選択してください。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md new file mode 100644 index 00000000..15474f86 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md @@ -0,0 +1,24 @@ +# WindowTranslator GitHub Copilot Translator Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でGitHub Copilotを利用する翻訳プラグインです。 + +## 機能 + +- 選択したCopilotモデルによる文脈を考慮した翻訳 +- 翻訳方針を追加できるカスタムコンテキスト +- CSV用語集による表記の統一 +- WindowTranslatorが提供する会話やゲーム固有のコンテキストを反映 + +## 必要条件と設定 + +- GitHub Copilotを利用できるGitHubアカウントと認証 +- 利用するモデル名 +- 必要に応じて翻訳コンテキストと、ヘッダーなしの`原文,訳文`形式のCSV用語集 + +利用可能なモデル、料金、上限はGitHub Copilotの契約内容に従います。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md new file mode 100644 index 00000000..3ed1b4c4 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md @@ -0,0 +1,27 @@ +# WindowTranslator Google AI Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でGoogle AI(Gemini)を利用する多機能プラグインです。 + +## 機能 + +- Geminiによる文脈を考慮した翻訳 +- 画像を直接送信するAI OCR +- OCRテキストまたは元画像を使った認識結果の補正 +- カスタム翻訳コンテキスト、補正サンプル、CSV用語集 + +AI OCRとOCR補正は実験的な機能です。 + +## 必要条件と設定 + +- Google AI APIキー +- 利用するGeminiモデル。必要に応じてプレビュー版モデル名も指定できます。 +- OCR補正を使う場合は、補正方法と待機動作を選択します。 +- 用語集はヘッダーなしの`原文,訳文`形式のCSVファイルです。 + +画像やテキストは設定したGoogle AIサービスへ送信されます。利用可能なモデル、料金、上限はサービスの契約内容に従います。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md new file mode 100644 index 00000000..517b75c8 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md @@ -0,0 +1,21 @@ +# WindowTranslator Google Apps Script Translator Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) からGoogle Apps Scriptを呼び出して翻訳するプラグインです。 + +## 機能 + +- 複数のテキストをGoogle Apps Script経由で翻訳 +- 組み込みの公開スクリプト、または任意のApps Scriptデプロイを利用可能 +- WindowTranslatorの翻訳元言語と翻訳先言語をスクリプトへ自動送信 + +## 必要条件と設定 + +- 組み込みスクリプトを利用する場合はGoogleアカウントによる認証が必要です。 +- 独自スクリプトを利用する場合は、Google Apps Script APIから実行可能なデプロイIDを設定します。 +- インターネット接続が必要です。利用量や実行上限はGoogle側の制限に従います。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md new file mode 100644 index 00000000..3502e059 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md @@ -0,0 +1,28 @@ +# WindowTranslator LLM Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でOpenAI互換APIを利用する多機能プラグインです。 + +## 機能 + +- 大規模言語モデルによる文脈を考慮した翻訳 +- 画像対応モデルを利用したAI OCR +- OCRテキストまたは元画像を使った認識結果の補正 +- OpenAI APIと互換エンドポイントの両方に対応 +- カスタム翻訳コンテキスト、補正サンプル、CSV用語集 + +AI OCRとOCR補正は実験的な機能です。 + +## 必要条件と設定 + +- 利用するモデル名 +- サービスが要求するAPIキー +- OpenAI以外を利用する場合はOpenAI互換APIのエンドポイント +- 用語集を利用する場合は、ヘッダーなしの`原文,訳文`形式のCSVファイル + +画像やテキストは設定したAPIへ送信されます。料金、上限、データの扱いは利用するサービスの契約内容に従います。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md index f7676e21..7975b2b0 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md @@ -1,5 +1,26 @@ -# OneOcr +# WindowTranslator OneOCR Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、WindowsのSnipping Toolに含まれるOneOCRエンジンを利用するOCRプラグインです。 + +## 機能 + +- WindowsのローカルOCRエンジンによる高速な文字認識 +- OCR領域の結合、傾き、拡大率、明るさ、コントラストを考慮した後処理 +- 認識結果から翻訳先言語のテキストを除外 + +## 必要条件 + +- OneOCRを含む対応バージョンのWindows Snipping Tool +- 初回設定時に、Snipping Toolから必要なOneOCRコンポーネントをWindowTranslatorの共有データ領域へコピーできること + +対応するSnipping Toolが見つからない場合は、WindowTranslatorからMicrosoft Storeを開いて更新できます。OneOCR本体とモデルは、このNuGetパッケージには含まれません。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 ## 参考 -https://github.com/ksasao/SnippingToolOcrSharp \ No newline at end of file +- [SnippingToolOcrSharp](https://github.com/ksasao/SnippingToolOcrSharp) diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md new file mode 100644 index 00000000..20cbada5 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md @@ -0,0 +1,25 @@ +# WindowTranslator PLaMo Translator Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、日本語に強いPLaMo 2 Translateモデルをローカル実行する翻訳プラグインです。 + +## 機能 + +- LLamaSharpを使用したローカル翻訳 +- 翻訳テキストを外部サービスへ送信せずに処理 +- 初回利用時に量子化済みPLaMo翻訳モデルを自動取得 +- コンテキスト長と使用するVRAM量を設定可能 + +## 必要条件 + +- 64ビット版Windows +- 十分な空きストレージとメモリ +- CUDAに対応するNVIDIA GPUとドライバーを推奨 +- モデルを初めて取得するときのインターネット接続 + +モデル取得後の翻訳はローカルで実行されます。用語集と追加コンテキストには対応していません。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md new file mode 100644 index 00000000..8002e1f6 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md @@ -0,0 +1,22 @@ +# WindowTranslator Tesseract OCR Plugin + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、オープンソースのTesseract OCRエンジンを利用するプラグインです。 + +## 機能 + +- Tesseractによる多言語OCR +- 翻訳元言語に対応する`traineddata`を初回利用時に自動取得 +- OCR領域の結合、拡大率、明るさ、コントラストを考慮した後処理 + +## 必要条件 + +- Microsoft Visual C++ 2015以降のx64ランタイム +- 言語データを初めて取得するときのインターネット接続 + +必要なVisual C++ランタイムがない場合は、WindowTranslatorからインストールできます。言語データは`tesseract-ocr/tessdata_best`から取得されます。 + +## インストール + +WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 + +インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 91148cda..729e0570 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using System.IO.Compression; using System.Net; @@ -9,6 +10,9 @@ using System.Text.Json; using System.Xml.Linq; using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Frameworks; +using NuGet.Packaging; +using NuGet.Packaging.Core; using NuGet.Versioning; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; @@ -422,7 +426,14 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), Encoding.UTF8); using var handler = new InMemoryNuGetHandler(); using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + var metadataSource = new InMemoryNuGetMetadataSource + { + SearchException = new HttpRequestException("NuGet search failed."), + }; + using var service = CreateService( + client, + testDirectory, + metadataSource: metadataSource); var viewModel = new PluginStoreViewModel( service, NullLogger<PluginStoreViewModel>.Instance, @@ -435,9 +446,7 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), Assert.Equal("1.2.3", package.InstalledVersion); Assert.True(package.IsInstalled); Assert.NotNull(viewModel.ErrorMessage); - Assert.Contains( - handler.RequestedPaths, - path => path.Equals("/v3/index.json", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); } finally { @@ -451,30 +460,30 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() var testDirectory = CreateTestDirectory(); try { - using var handler = new InMemoryNuGetHandler + using var handler = new InMemoryNuGetHandler(); + var metadataSource = new InMemoryNuGetMetadataSource { - SearchResponseJson = """ - { - "totalHits": 1, - "data": [ - { - "id": "Test.Plugin", - "version": "1.1.0-beta.2", - "title": "Test Plugin", - "description": "Test description", - "authors": ["WindowTranslator.Tests"], - "versions": [ - { "version": "1.0.0" }, - { "version": "1.1.0-beta.1" }, - { "version": "1.1.0-beta.2" } - ] - } - ] - } - """, + SearchResults = + [ + new NuGetPluginSearchMetadata( + "Test.Plugin", + "Test Plugin", + "Test description", + "WindowTranslator.Tests", + null, + null), + ], }; + metadataSource.AddVersions( + "Test.Plugin", + CreatePluginVersionMetadata("1.0.0"), + CreatePluginVersionMetadata("1.1.0-beta.1"), + CreatePluginVersionMetadata("1.1.0-beta.2")); using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService( + client, + testDirectory, + metadataSource: metadataSource); var package = Assert.Single(await service.SearchPackagesAsync()); @@ -483,9 +492,67 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() Assert.Equal( ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], package.Versions); - Assert.Contains( - handler.RequestedUris, - uri => uri.Contains("prerelease=true", StringComparison.OrdinalIgnoreCase)); + Assert.Equal([true], metadataSource.RequestedPrereleaseOptions); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SearchKeepsOnlyVersionsWithCompatibleDirectAbstractionsDependency() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + var metadataSource = new InMemoryNuGetMetadataSource + { + SearchResults = + [ + new NuGetPluginSearchMetadata( + "Compatible.Plugin", + null, + null, + null, + null, + null), + new NuGetPluginSearchMetadata( + "Missing.Dependency.Plugin", + null, + null, + null, + null, + null), + ], + }; + metadataSource.AddVersions( + "Compatible.Plugin", + CreatePluginVersionMetadata("1.0.0", "[1.0.0, 2.0.0)"), + CreatePluginVersionMetadata("2.0.0", "[2.0.0, 3.0.0)")); + metadataSource.AddVersions( + "Missing.Dependency.Plugin", + CreatePluginVersionMetadata( + "1.0.0", + includeAbstractionsDependency: false)); + + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }, + metadataSource); + + var package = Assert.Single(await service.SearchPackagesAsync()); + + Assert.Equal("Compatible.Plugin", package.Id); + Assert.Equal("1.0.0", package.Version); + Assert.Equal(["1.0.0"], package.Versions); + Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); } finally { @@ -635,6 +702,165 @@ public async Task InstallAcceptsCompatibleHostAbstractionsWithoutDownloadingIt() } } + [Fact] + public async Task InstallRejectsPackageWithoutDirectAbstractionsDependency() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary<string, byte[]> + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + }, + includeAbstractionsDependency: false)); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + var exception = await Assert.ThrowsAsync<InvalidOperationException>( + () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); + + Assert.Contains("WindowTranslator.Abstractions", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(await service.GetInstalledPackagesAsync()); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallRejectsPackageWithoutPluginTag() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary<string, byte[]> + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + }, + includePluginTag: false)); + + using var client = new HttpClient(handler); + using var service = CreateService(client, testDirectory); + + var exception = await Assert.ThrowsAsync<InvalidOperationException>( + () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); + + Assert.Contains("プラグインタグ", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(await service.GetInstalledPackagesAsync()); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Readme.Plugin", + "1.0.0", + CreatePackage( + "Readme.Plugin", + "1.0.0", + [], + new Dictionary<string, byte[]> + { + ["lib/net10.0/Readme.Plugin.dll"] = "release"u8.ToArray(), + ["README.md"] = "# Release README"u8.ToArray(), + })); + handler.AddPackage( + "Readme.Plugin", + "2.0.0-preview.1", + CreatePackage( + "Readme.Plugin", + "2.0.0-preview.1", + [], + new Dictionary<string, byte[]> + { + ["lib/net10.0/Readme.Plugin.dll"] = "preview"u8.ToArray(), + ["README.md"] = "# Preview README"u8.ToArray(), + })); + + var metadataSource = new InMemoryNuGetMetadataSource(); + metadataSource.AddReadmeUrl( + "Readme.Plugin", + "1.0.0", + "https://nuget.test/readme/readme.plugin/1.0.0"); + metadataSource.AddReadmeUrl( + "Readme.Plugin", + "2.0.0-preview.1", + "https://nuget.test/readme/readme.plugin/2.0.0-preview.1"); + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + metadataSource: metadataSource); + var viewModel = new PluginStoreViewModel( + service, + NullLogger<PluginStoreViewModel>.Instance, + dialogService: null!); + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Readme.Plugin", + "2.0.0-preview.1", + "README Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0", "2.0.0-preview.1"]), + isInstalled: false, + installedVersion: null); + + viewModel.SelectedPackage = package; + await WaitForReadmeAsync(package, "# Release README"); + + package.UsePrerelease = true; + await WaitForReadmeAsync(package, "# Preview README"); + + Assert.Contains( + handler.RequestedPaths, + path => path.Equals("/readme/readme.plugin/1.0.0", StringComparison.OrdinalIgnoreCase)); + Assert.Contains( + handler.RequestedPaths, + path => path.Equals( + "/readme/readme.plugin/2.0.0-preview.1", + StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() { @@ -934,23 +1160,95 @@ public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() private static NuGetPluginService CreateService( HttpClient client, string pluginDirectory, - IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null) + IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, + INuGetPluginMetadataSource? metadataSource = null) => new( NullLogger<NuGetPluginService>.Instance, client, pluginDirectory, - hostPackageVersions: hostPackageVersions); + hostPackageVersions: hostPackageVersions, + metadataSource: metadataSource ?? new InMemoryNuGetMetadataSource()); + + private static NuGetPluginVersionMetadata CreatePluginVersionMetadata( + string version, + string? abstractionsRange = null, + bool includeAbstractionsDependency = true) + => new( + NuGetVersion.Parse(version), + IsListed: true, + [ + new PackageDependencyGroup( + NuGetFramework.ParseFolder("net10.0"), + includeAbstractionsDependency + ? [ + new PackageDependency( + NuGetPluginService.AbstractionsPackageId, + abstractionsRange is null + ? VersionRange.All + : VersionRange.Parse(abstractionsRange)), + ] + : []), + ]); + + private static async Task WaitForReadmeAsync( + PluginPackageViewModel package, + string expectedReadme) + { + if (package.ReadmeMarkdown == expectedReadme) + { + return; + } + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + PropertyChangedEventHandler? handler = null; + handler = (_, e) => + { + if (e.PropertyName == nameof(PluginPackageViewModel.ReadmeMarkdown) + && package.ReadmeMarkdown == expectedReadme) + { + completion.TrySetResult(); + } + }; + package.PropertyChanged += handler; + try + { + if (package.ReadmeMarkdown == expectedReadme) + { + return; + } + await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + package.PropertyChanged -= handler; + } + } private static byte[] CreatePackage( string id, string version, IReadOnlyCollection<TestDependency> dependencies, - IReadOnlyDictionary<string, byte[]> entries) + IReadOnlyDictionary<string, byte[]> entries, + bool includePluginTag = true, + bool includeAbstractionsDependency = true) { + var packageDependencies = dependencies.ToList(); + if (includeAbstractionsDependency + && !packageDependencies.Any(dependency => dependency.Id.Equals( + "WindowTranslator.Abstractions", + StringComparison.OrdinalIgnoreCase))) + { + packageDependencies.Add(new( + "WindowTranslator.Abstractions", + "(, )", + Exclude: "Runtime")); + } + using var stream = new MemoryStream(); using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) { - var dependencyElements = dependencies.Select(dependency => + var dependencyElements = packageDependencies.Select(dependency => { var element = new XElement( "dependency", @@ -962,21 +1260,27 @@ private static byte[] CreatePackage( } return element; }); - var nuspec = new XDocument( + var metadata = new XElement( + "metadata", + new XElement("id", id), + new XElement("version", version), + new XElement("authors", "WindowTranslator.Tests"), + new XElement("description", "Test package")); + if (includePluginTag) + { + metadata.Add(new XElement("tags", "windowtranslator-plugin")); + } + if (entries.ContainsKey("README.md")) + { + metadata.Add(new XElement("readme", "README.md")); + } + metadata.Add(new XElement( + "dependencies", new XElement( - "package", - new XElement( - "metadata", - new XElement("id", id), - new XElement("version", version), - new XElement("authors", "WindowTranslator.Tests"), - new XElement("description", "Test package"), - new XElement( - "dependencies", - new XElement( - "group", - new XAttribute("targetFramework", "net10.0"), - dependencyElements))))); + "group", + new XAttribute("targetFramework", "net10.0"), + dependencyElements))); + var nuspec = new XDocument(new XElement("package", metadata)); var nuspecEntry = archive.CreateEntry($"{id}.nuspec"); using (var nuspecStream = nuspecEntry.Open()) { @@ -1021,16 +1325,71 @@ private static void DeleteTestDirectory(string path) private sealed record TestDependency(string Id, string Version, string? Exclude = null); + private sealed class InMemoryNuGetMetadataSource : INuGetPluginMetadataSource + { + private readonly Dictionary<string, IReadOnlyList<NuGetPluginVersionMetadata>> versions = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, string> readmeUrls = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList<NuGetPluginSearchMetadata> SearchResults { get; init; } = []; + + public Exception? SearchException { get; init; } + + public List<string> RequestedTags { get; } = []; + + public List<bool> RequestedPrereleaseOptions { get; } = []; + + public void AddVersions(string packageId, params NuGetPluginVersionMetadata[] packageVersions) + => this.versions[packageId] = packageVersions; + + public void AddReadmeUrl(string packageId, string version, string url) + => this.readmeUrls[GetReadmeKey(packageId, NuGetVersion.Parse(version))] = url; + + public Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( + string tag, + bool includePrerelease, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.RequestedTags.Add(tag); + this.RequestedPrereleaseOptions.Add(includePrerelease); + return this.SearchException is null + ? Task.FromResult(this.SearchResults) + : Task.FromException<IReadOnlyList<NuGetPluginSearchMetadata>>(this.SearchException); + } + + public Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( + string packageId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult( + this.versions.TryGetValue(packageId, out var packageVersions) + ? packageVersions + : (IReadOnlyList<NuGetPluginVersionMetadata>)[]); + } + + public Task<string?> GetReadmeUrlAsync( + string packageId, + NuGetVersion version, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + this.readmeUrls.TryGetValue(GetReadmeKey(packageId, version), out var readmeUrl); + return Task.FromResult(readmeUrl); + } + + private static string GetReadmeKey(string packageId, NuGetVersion version) + => $"{packageId}\n{version.ToNormalizedString()}"; + } + private sealed class InMemoryNuGetHandler : HttpMessageHandler { private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); public List<string> RequestedPaths { get; } = []; - public List<string> RequestedUris { get; } = []; - - public string? SearchResponseJson { get; init; } - public void AddPackage(string id, string version, byte[] package) => this.packages[(id.ToLowerInvariant(), version.ToLowerInvariant())] = package; @@ -1040,41 +1399,14 @@ protected override Task<HttpResponseMessage> SendAsync( { var path = request.RequestUri!.AbsolutePath; this.RequestedPaths.Add(path); - this.RequestedUris.Add(request.RequestUri.PathAndQuery); - - if (path.Equals("/v3/index.json", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - """ - { - "resources": [ - { - "@id": "https://nuget.test/query", - "@type": "SearchQueryService/3.5.0" - } - ] - } - """, - Encoding.UTF8, - "application/json"), - }); - } - if (path.Equals("/query", StringComparison.OrdinalIgnoreCase) - && this.SearchResponseJson is not null) + var segments = path.Trim('/').Split('/'); + if (segments.Length == 3 + && segments[0].Equals("readme", StringComparison.OrdinalIgnoreCase)) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - this.SearchResponseJson, - Encoding.UTF8, - "application/json"), - }); + return Task.FromResult(CreateReadmeResponse(segments[1], segments[2])); } - var segments = path.Trim('/').Split('/'); if (segments.Length == 3 && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase) && segments[2].Equals("index.json", StringComparison.OrdinalIgnoreCase)) @@ -1110,6 +1442,32 @@ protected override Task<HttpResponseMessage> SendAsync( return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } + + private HttpResponseMessage CreateReadmeResponse(string packageId, string version) + { + if (!this.packages.TryGetValue( + (packageId.ToLowerInvariant(), version.ToLowerInvariant()), + out var package)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + using var stream = new MemoryStream(package); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var readmeEntry = archive.Entries.FirstOrDefault(entry => + entry.FullName.Equals("README.md", StringComparison.OrdinalIgnoreCase)); + if (readmeEntry is null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + using var reader = new StreamReader(readmeEntry.Open(), Encoding.UTF8); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(reader.ReadToEnd(), Encoding.UTF8, "text/markdown"), + }; + } + } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 726f6315..3eda3e27 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -6,9 +6,10 @@ using System.Runtime.Versioning; using System.Text.Json; using System.Text.Json.Serialization; -using System.Xml.Linq; using Microsoft.Extensions.Logging; using NuGet.Frameworks; +using NuGet.Packaging; +using NuGet.Packaging.Core; using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; @@ -95,6 +96,26 @@ public async Task InstallAsync( nearest)).Original; } + internal static PackageDependencyGroup? SelectBestDependencyGroup( + IEnumerable<PackageDependencyGroup> dependencyGroups) + { + var groups = dependencyGroups.ToArray(); + var frameworkGroups = groups + .Where(group => !group.TargetFramework.IsAny && !group.TargetFramework.IsUnsupported) + .ToArray(); + var nearest = FrameworkReducer.GetNearest( + HostFramework, + frameworkGroups.Select(group => group.TargetFramework)); + if (nearest is not null) + { + return frameworkGroups.First(group => NuGetFrameworkFullComparer.Instance.Equals( + group.TargetFramework, + nearest)); + } + + return groups.FirstOrDefault(group => group.TargetFramework.IsAny); + } + private static NuGetFramework GetHostFramework() { var assembly = typeof(NuGetPackageInstaller).Assembly; @@ -192,7 +213,8 @@ await DownloadPackageAsync( packagePath, currentId, resolvedVersion, - this.hostPackageVersions); + this.hostPackageVersions, + requirePluginPackage: currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase)); artifacts[currentId] = new PackageArtifact(currentId, resolvedVersion, packagePath); foreach (var dependency in ReadRuntimeDependencies(packagePath)) @@ -318,101 +340,64 @@ private static List<PackageDependency> ReadDependencies( string packagePath, bool runtimeOnly) { - using var archive = ZipFile.OpenRead(packagePath); - var nuspecEntry = archive.Entries.FirstOrDefault(e => - e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException("パッケージにnuspecが見つかりませんでした。"); - - using var stream = nuspecEntry.Open(); - var document = XDocument.Load(stream); - var metadata = document.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "metadata") - ?? throw new InvalidOperationException("nuspecのmetadataが見つかりませんでした。"); - var dependencies = metadata.Elements().FirstOrDefault(e => e.Name.LocalName == "dependencies"); - if (dependencies is null) + using var packageStream = File.OpenRead(packagePath); + using var packageReader = new PackageArchiveReader(packageStream); + var groups = packageReader.NuspecReader.GetDependencyGroups().ToArray(); + var selectedGroup = SelectBestDependencyGroup(groups); + if (selectedGroup is null && groups.Length > 0) { - return []; + throw new InvalidOperationException("互換性のある依存関係グループが見つかりませんでした。"); } - var result = new List<PackageDependency>(); - result.AddRange(ParseDependencyElements( - dependencies.Elements().Where(e => e.Name.LocalName == "dependency"), - runtimeOnly)); - - var groups = dependencies.Elements() - .Where(e => e.Name.LocalName == "group") - .Select(e => ( - Element: e, - Framework: e.Attribute("targetFramework")?.Value)) - .ToArray(); - if (groups.Length == 0) + var dependencies = new List<PackageDependency>(); + var anyGroup = groups.FirstOrDefault(group => group.TargetFramework.IsAny); + if (anyGroup is not null && !ReferenceEquals(anyGroup, selectedGroup)) { - return result; + dependencies.AddRange(anyGroup.Packages); } - - var frameworkGroups = groups.Where(g => - !string.IsNullOrWhiteSpace(g.Framework) - && !g.Framework.Equals("any", StringComparison.OrdinalIgnoreCase)).ToArray(); - if (frameworkGroups.Length > 0) + if (selectedGroup is not null) { - var selectedFramework = SelectBestTfm(frameworkGroups.Select(g => g.Framework!)); - if (selectedFramework is not null) - { - result.AddRange(ParseDependencyElements( - frameworkGroups.First(g => string.Equals( - g.Framework, - selectedFramework, - StringComparison.OrdinalIgnoreCase)).Element.Elements(), - runtimeOnly)); - return result; - } + dependencies.AddRange(selectedGroup.Packages); } - var fallbackGroup = groups.FirstOrDefault(g => - string.IsNullOrWhiteSpace(g.Framework) - || g.Framework.Equals("any", StringComparison.OrdinalIgnoreCase)); - if (fallbackGroup.Element is not null) - { - result.AddRange(ParseDependencyElements( - fallbackGroup.Element.Elements(), - runtimeOnly)); - return result; - } - - throw new InvalidOperationException("互換性のある依存関係グループが見つかりませんでした。"); - } - - private static IEnumerable<PackageDependency> ParseDependencyElements( - IEnumerable<XElement> elements, - bool runtimeOnly) - { - foreach (var element in elements.Where(e => e.Name.LocalName == "dependency")) - { - var id = element.Attribute("id")?.Value; - if (string.IsNullOrWhiteSpace(id) - || runtimeOnly && !IncludesRuntimeAssets(element)) - { - continue; - } - - var versionText = element.Attribute("version")?.Value; - yield return new PackageDependency( - id, - string.IsNullOrWhiteSpace(versionText) ? VersionRange.All : VersionRange.Parse(versionText)); - } + return dependencies + .Where(dependency => !runtimeOnly || IncludesRuntimeAssets(dependency)) + .ToList(); } private static void ValidateHostPackageDependencies( string packagePath, string packageId, NuGetVersion packageVersion, - IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions) + IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions, + bool requirePluginPackage) { - if (hostPackageVersions.Count == 0) + var dependencies = ReadPackageDependencies(packagePath); + if (requirePluginPackage) { - return; + if (!HasPackageTag(packagePath, NuGetPluginService.PluginTag)) + { + throw new InvalidOperationException( + $"パッケージ {packageId} {packageVersion} はWindowTranslatorプラグインタグを持っていません。"); + } + + if (!hostPackageVersions.ContainsKey(NuGetPluginService.AbstractionsPackageId)) + { + throw new InvalidOperationException( + $"実行中の{NuGetPluginService.AbstractionsPackageId}のバージョンを確認できません。"); + } + + if (!dependencies.Any(dependency => dependency.Id.Equals( + NuGetPluginService.AbstractionsPackageId, + StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"パッケージ {packageId} {packageVersion} は" + + $"{NuGetPluginService.AbstractionsPackageId}へ直接依存していません。"); + } } - foreach (var dependency in ReadPackageDependencies(packagePath)) + foreach (var dependency in dependencies) { if (!hostPackageVersions.TryGetValue(dependency.Id, out var hostVersion) || dependency.VersionRange.Satisfies(hostVersion)) @@ -427,24 +412,30 @@ private static void ValidateHostPackageDependencies( } } - private static bool IncludesRuntimeAssets(XElement dependency) + private static bool HasPackageTag(string packagePath, string requiredTag) { - var excluded = SplitAssets(dependency.Attribute("exclude")?.Value); - if (excluded.Contains("all") || excluded.Contains("runtime")) + using var packageStream = File.OpenRead(packagePath); + using var packageReader = new PackageArchiveReader(packageStream); + var tags = packageReader.NuspecReader.GetTags(); + return tags?.Split( + [' ', '\t', '\r', '\n', ';', ','], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains(requiredTag, StringComparer.OrdinalIgnoreCase) == true; + } + + private static bool IncludesRuntimeAssets(PackageDependency dependency) + { + if (dependency.Exclude.Contains("all", StringComparer.OrdinalIgnoreCase) + || dependency.Exclude.Contains("runtime", StringComparer.OrdinalIgnoreCase)) { return false; } - var included = SplitAssets(dependency.Attribute("include")?.Value); - return included.Count == 0 || included.Contains("all") || included.Contains("runtime"); + return dependency.Include.Count == 0 + || dependency.Include.Contains("all", StringComparer.OrdinalIgnoreCase) + || dependency.Include.Contains("runtime", StringComparer.OrdinalIgnoreCase); } - private static HashSet<string> SplitAssets(string? assets) - => string.IsNullOrWhiteSpace(assets) - ? new(StringComparer.OrdinalIgnoreCase) - : assets.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - private static void ExtractPackageAssets( string packagePath, string destinationDirectory, @@ -635,8 +626,6 @@ private static void TryDeleteDirectory(string directory) private sealed record PackageArtifact(string Id, NuGetVersion Version, string PackagePath); - private sealed record PackageDependency(string Id, VersionRange VersionRange); - private sealed record DependencyConstraint(string Source, VersionRange Range); private sealed record VersionIndex( diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index b5b84126..346f3087 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -1,9 +1,11 @@ using System.IO; +using System.Net; using System.Net.Http; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; +using NuGet.Packaging; using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; @@ -14,7 +16,9 @@ namespace WindowTranslator.Modules.PluginStore; public sealed class NuGetPluginService : IDisposable { private const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; - private const string PluginTag = "windowtranslator-plugin"; + internal const string PluginTag = "windowtranslator-plugin"; + internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; + private const int MaxConcurrentMetadataRequests = 8; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -30,13 +34,19 @@ public sealed class NuGetPluginService : IDisposable private readonly string manifestPath; private readonly bool ownsHttpClient; private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions; + private readonly INuGetPluginMetadataSource metadataSource; private readonly SemaphoreSlim operationLock = new(1, 1); - private string? searchUrl; public NuGetPluginService(ILogger<NuGetPluginService> logger) : this( logger, - new HttpClient { Timeout = TimeSpan.FromSeconds(30) }, + new HttpClient(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + }) + { + Timeout = TimeSpan.FromSeconds(30), + }, Path.Combine(PathUtility.UserDir, "plugins"), ownsHttpClient: true) { @@ -47,7 +57,8 @@ internal NuGetPluginService( HttpClient httpClient, string userPluginsDir, bool ownsHttpClient = false, - IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null) + IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, + INuGetPluginMetadataSource? metadataSource = null) { this.logger = logger; this.httpClient = httpClient; @@ -55,6 +66,8 @@ internal NuGetPluginService( this.manifestPath = Path.Combine(this.userPluginsDir, "nuget-manifest.json"); this.ownsHttpClient = ownsHttpClient; this.hostPackageVersions = hostPackageVersions ?? CreateHostPackageVersions(); + this.metadataSource = metadataSource + ?? new NuGetProtocolPluginMetadataSource(NuGetServiceIndexUrl); } /// <summary> @@ -62,37 +75,79 @@ internal NuGetPluginService( /// </summary> public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(CancellationToken cancellationToken = default) { - if (this.searchUrl is null) + var searchResults = await this.metadataSource + .SearchAsync(PluginTag, includePrerelease: true, cancellationToken) + .ConfigureAwait(false); + this.logger.LogInformation("NuGetタグ検索完了: {Count}件の候補が見つかりました。", searchResults.Count); + + using var requestGate = new SemaphoreSlim(MaxConcurrentMetadataRequests); + var packageTasks = searchResults.Select(async data => + { + await requestGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await CreateCompatiblePackageInfoAsync( + data, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "NuGetパッケージのプラグイン互換性を確認できなかったため除外します: {PackageId}", + data.Id); + return null; + } + finally + { + requestGate.Release(); + } + }); + var packages = await Task.WhenAll(packageTasks).ConfigureAwait(false); + var compatiblePackages = packages.Where(package => package is not null).Select(package => package!).ToArray(); + + this.logger.LogInformation( + "NuGet互換性確認完了: {Count}件のWindowTranslatorプラグインが見つかりました。", + compatiblePackages.Length); + return compatiblePackages; + } + + /// <summary> + /// 指定したパッケージバージョンのREADMEを取得します。 + /// </summary> + public async Task<string?> GetPackageReadmeAsync( + string packageId, + string version, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(packageId)) { - this.searchUrl = await GetSearchUrlAsync(cancellationToken).ConfigureAwait(false); + throw new ArgumentException("NuGetパッケージIDが空です。", nameof(packageId)); + } + if (!NuGetVersion.TryParse(version, out var packageVersion)) + { + throw new ArgumentException($"不正なNuGetパッケージバージョンです: {version}", nameof(version)); } - var url = $"{this.searchUrl}?q=tags:{PluginTag}&take=100&semVerLevel=2.0.0&prerelease=true"; - this.logger.LogDebug("NuGet検索URL: {Url}", url); + var readmeUrl = await this.metadataSource + .GetReadmeUrlAsync(packageId, packageVersion, cancellationToken) + .ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(readmeUrl)) + { + return null; + } + using var response = await this.httpClient.GetAsync(readmeUrl, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } - using var response = await this.httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - - await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync<NuGetSearchResponse>(content, JsonOptions, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("NuGet検索結果のデシリアライズに失敗しました。"); - - this.logger.LogInformation("NuGet検索完了: {Count}件のパッケージが見つかりました。", result.Data?.Length ?? 0); - - return result.Data?.Select(d => new NuGetPackageInfo( - Id: d.PackageId ?? string.Empty, - Version: d.Version ?? string.Empty, - Title: d.Title ?? d.PackageId ?? string.Empty, - Description: d.Description ?? string.Empty, - Authors: string.Join(", ", d.Authors ?? []), - ProjectUrl: d.ProjectUrl, - LicenseUrl: d.LicenseUrl, - Versions: d.Versions? - .Select(version => version.Version) - .Where(version => !string.IsNullOrWhiteSpace(version)) - .Select(version => version!) - .ToArray() - )).ToArray() ?? []; + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } /// <summary> @@ -265,19 +320,57 @@ public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync return manifest.Packages; } - private async Task<string> GetSearchUrlAsync(CancellationToken cancellationToken) + private async Task<NuGetPackageInfo?> CreateCompatiblePackageInfoAsync( + NuGetPluginSearchMetadata data, + CancellationToken cancellationToken) { - using var response = await this.httpClient.GetAsync(NuGetServiceIndexUrl, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var index = await JsonSerializer.DeserializeAsync<NuGetServiceIndex>(content, JsonOptions, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("NuGetサービスインデックスのデシリアライズに失敗しました。"); + var versions = await this.metadataSource + .GetPackageVersionsAsync(data.Id, cancellationToken) + .ConfigureAwait(false); + var compatibleVersions = versions + .Where(version => version.IsListed + && HasCompatibleAbstractionsDependency(version.DependencyGroups)) + .OrderBy(version => version.Version) + .ToArray(); + if (compatibleVersions.Length == 0) + { + this.logger.LogDebug( + "WindowTranslator.Abstractionsへの互換依存がないため除外します: {PackageId}", + data.Id); + return null; + } - var searchEntry = index.Resources?.FirstOrDefault(r => r.Type == "SearchQueryService/3.5.0") - ?? index.Resources?.FirstOrDefault(r => r.Type?.StartsWith("SearchQueryService", StringComparison.Ordinal) == true) - ?? throw new InvalidOperationException("NuGet検索サービスURLが見つかりませんでした。"); + var latestVersion = compatibleVersions[^1].Version.ToNormalizedString(); + return new NuGetPackageInfo( + Id: data.Id, + Version: latestVersion, + Title: data.Title ?? data.Id, + Description: data.Description ?? string.Empty, + Authors: data.Authors ?? string.Empty, + ProjectUrl: data.ProjectUrl, + LicenseUrl: data.LicenseUrl, + Versions: compatibleVersions + .Select(version => version.Version.ToNormalizedString()) + .ToArray()); + } + + private bool HasCompatibleAbstractionsDependency( + IReadOnlyList<PackageDependencyGroup> dependencyGroups) + { + if (!this.hostPackageVersions.TryGetValue(AbstractionsPackageId, out var hostVersion)) + { + return false; + } - return searchEntry.Id ?? throw new InvalidOperationException("NuGet検索サービスURLが空です。"); + var dependencyGroup = NuGetPackageInstaller.SelectBestDependencyGroup(dependencyGroups); + var dependency = dependencyGroup?.Packages.FirstOrDefault(item => + item.Id.Equals(AbstractionsPackageId, StringComparison.OrdinalIgnoreCase)); + if (dependency is null) + { + return false; + } + + return dependency.VersionRange?.Satisfies(hostVersion) is not false; } private static InstalledManifest AddOrUpdatePackage( @@ -311,7 +404,7 @@ private static Dictionary<string, NuGetVersion> CreateHostPackageVersions() { return new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) { - ["WindowTranslator.Abstractions"] = packageVersion, + [AbstractionsPackageId] = packageVersion, }; } @@ -324,7 +417,7 @@ private static Dictionary<string, NuGetVersion> CreateHostPackageVersions() Math.Max(assemblyVersion.Build, 0)); return new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) { - ["WindowTranslator.Abstractions"] = fallbackVersion, + [AbstractionsPackageId] = fallbackVersion, }; } @@ -474,33 +567,3 @@ string Version /// <summary>NuGetプラグインの管理マニフェスト</summary> public record InstalledManifest(List<InstalledPackageInfo> Packages); - -// NuGet V3 API レスポンス型 -internal record NuGetServiceIndex( - [property: JsonPropertyName("resources")] NuGetServiceResource[]? Resources -); - -internal record NuGetServiceResource( - [property: JsonPropertyName("@id")] string? Id, - [property: JsonPropertyName("@type")] string? Type -); - -internal record NuGetSearchResponse( - [property: JsonPropertyName("totalHits")] int TotalHits, - [property: JsonPropertyName("data")] NuGetSearchData[]? Data -); - -internal record NuGetSearchData( - [property: JsonPropertyName("id")] string? PackageId, - [property: JsonPropertyName("version")] string? Version, - [property: JsonPropertyName("title")] string? Title, - [property: JsonPropertyName("description")] string? Description, - [property: JsonPropertyName("authors")] string[]? Authors, - [property: JsonPropertyName("projectUrl")] string? ProjectUrl, - [property: JsonPropertyName("licenseUrl")] string? LicenseUrl, - [property: JsonPropertyName("versions")] NuGetSearchVersion[]? Versions -); - -internal record NuGetSearchVersion( - [property: JsonPropertyName("version")] string? Version -); diff --git a/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs b/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs new file mode 100644 index 00000000..8ddd583e --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs @@ -0,0 +1,122 @@ +using NuGet.Common; +using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +internal interface INuGetPluginMetadataSource +{ + Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( + string tag, + bool includePrerelease, + CancellationToken cancellationToken); + + Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( + string packageId, + CancellationToken cancellationToken); + + Task<string?> GetReadmeUrlAsync( + string packageId, + NuGetVersion version, + CancellationToken cancellationToken); +} + +internal sealed class NuGetProtocolPluginMetadataSource(string serviceIndexUrl) : INuGetPluginMetadataSource +{ + private const int SearchResultLimit = 100; + + private readonly SourceRepository repository = Repository.Factory.GetCoreV3(serviceIndexUrl); + + public async Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( + string tag, + bool includePrerelease, + CancellationToken cancellationToken) + { + var searchResource = await this.repository + .GetResourceAsync<PackageSearchResource>(cancellationToken) + .ConfigureAwait(false); + var searchFilter = new SearchFilter(includePrerelease) + { + IncludeDelisted = false, + }; + var results = await searchResource.SearchAsync( + $"tags:{tag}", + searchFilter, + skip: 0, + take: SearchResultLimit, + NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + + return results + .Where(metadata => !string.IsNullOrWhiteSpace(metadata.Identity?.Id)) + .Select(metadata => new NuGetPluginSearchMetadata( + metadata.Identity.Id, + metadata.Title, + metadata.Description, + metadata.Authors, + metadata.ProjectUrl?.AbsoluteUri, + metadata.LicenseUrl?.AbsoluteUri)) + .ToArray(); + } + + public async Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( + string packageId, + CancellationToken cancellationToken) + { + var metadataResource = await this.repository + .GetResourceAsync<PackageMetadataResource>(cancellationToken) + .ConfigureAwait(false); + using var cacheContext = new SourceCacheContext(); + var versions = await metadataResource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + cacheContext, + NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + + return versions + .Where(metadata => metadata.Identity?.Version is not null) + .Select(metadata => new NuGetPluginVersionMetadata( + metadata.Identity.Version, + metadata.IsListed, + metadata.DependencySets?.ToArray() ?? [])) + .ToArray(); + } + + public async Task<string?> GetReadmeUrlAsync( + string packageId, + NuGetVersion version, + CancellationToken cancellationToken) + { + var metadataResource = await this.repository + .GetResourceAsync<PackageMetadataResource>(cancellationToken) + .ConfigureAwait(false); + using var cacheContext = new SourceCacheContext(); + var metadata = await metadataResource.GetMetadataAsync( + new PackageIdentity(packageId, version), + cacheContext, + NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + + return string.IsNullOrWhiteSpace(metadata?.ReadmeFileUrl) + ? null + : metadata.ReadmeFileUrl; + } +} + +internal sealed record NuGetPluginSearchMetadata( + string Id, + string? Title, + string? Description, + string? Authors, + string? ProjectUrl, + string? LicenseUrl); + +internal sealed record NuGetPluginVersionMetadata( + NuGetVersion Version, + bool IsListed, + IReadOnlyList<PackageDependencyGroup> DependencyGroups); diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index e86de86d..8e50a79e 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -2,8 +2,11 @@ x:Class="WindowTranslator.Modules.PluginStore.PluginStoreView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ctrl="clr-namespace:WindowTranslator.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:WindowTranslator.Modules.PluginStore" + xmlns:md="https://github.com/whistyun/MdXaml" + xmlns:mdp="clr-namespace:MdXaml.Plugins;assembly=MdXaml.Plugins" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:properties="clr-namespace:WindowTranslator.Properties" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" @@ -171,93 +174,124 @@ ResizeBehavior="PreviousAndNext" /> <!-- パッケージ詳細 --> - <ScrollViewer + <Grid Grid.Column="2" Margin="8" - VerticalScrollBarVisibility="Auto"> - <StackPanel DataContext="{Binding SelectedPackage}"> - <StackPanel.Style> - <Style TargetType="StackPanel"> - <Style.Triggers> - <DataTrigger Binding="{Binding DataContext.SelectedPackage, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="{x:Null}"> - <Setter Property="Visibility" Value="Collapsed" /> - </DataTrigger> - </Style.Triggers> - </Style> - </StackPanel.Style> + DataContext="{Binding SelectedPackage}"> + <Grid.Style> + <Style TargetType="Grid"> + <Style.Triggers> + <DataTrigger Binding="{Binding DataContext.SelectedPackage, RelativeSource={RelativeSource AncestorType=UserControl}}" Value="{x:Null}"> + <Setter Property="Visibility" Value="Collapsed" /> + </DataTrigger> + </Style.Triggers> + </Style> + </Grid.Style> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> - <ui:TextBlock - Margin="0,0,0,4" - FontSize="16" - FontWeight="SemiBold" - Text="{Binding Title}" - TextWrapping="Wrap" /> + <ScrollViewer + Grid.Row="0" + MaxHeight="300" + VerticalScrollBarVisibility="Auto"> + <StackPanel> + <ui:TextBlock + Margin="0,0,0,4" + FontSize="16" + FontWeight="SemiBold" + Text="{Binding Title}" + TextWrapping="Wrap" /> - <ui:TextBlock - Margin="0,0,0,8" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text="{Binding Authors}" - TextWrapping="Wrap" /> + <ui:TextBlock + Margin="0,0,0,8" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Text="{Binding Authors}" + TextWrapping="Wrap" /> - <Separator Margin="0,0,0,8" /> + <Separator Margin="0,0,0,8" /> - <ui:TextBlock - Margin="0,0,0,8" - Text="{Binding Description}" - TextWrapping="Wrap" /> + <ui:TextBlock + Margin="0,0,0,8" + Text="{Binding Description}" + TextWrapping="Wrap" /> - <!-- バージョン情報 --> - <Grid Margin="0,4"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - </Grid.ColumnDefinitions> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> + <!-- バージョン情報 --> + <Grid Margin="0,4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> - <Label - Grid.Row="0" - Grid.Column="0" - Content="{x:Static properties:Resources.LatestVersion}" - Padding="0,4,8,4" /> - <ui:TextBlock - Grid.Row="0" - Grid.Column="1" - VerticalAlignment="Center" - Text="{Binding LatestVersion}" /> + <Label + Grid.Row="0" + Grid.Column="0" + Content="{x:Static properties:Resources.LatestVersion}" + Padding="0,4,8,4" /> + <ui:TextBlock + Grid.Row="0" + Grid.Column="1" + VerticalAlignment="Center" + Text="{Binding LatestVersion}" /> - <Label - Grid.Row="1" - Grid.Column="0" - Content="{x:Static properties:Resources.InstalledVersionLabel}" - Padding="0,4,8,4" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - <ui:TextBlock - Grid.Row="1" - Grid.Column="1" - VerticalAlignment="Center" - Text="{Binding InstalledVersion}" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - </Grid> + <Label + Grid.Row="1" + Grid.Column="0" + Content="{x:Static properties:Resources.InstalledVersionLabel}" + Padding="0,4,8,4" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + <ui:TextBlock + Grid.Row="1" + Grid.Column="1" + VerticalAlignment="Center" + Text="{Binding InstalledVersion}" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + </Grid> + + <Separator Margin="0,4,0,8" /> + + <!-- リンク --> + <ui:HyperlinkButton + Content="{x:Static properties:Resources.ProjectUrl}" + Icon="{ui:SymbolIcon Globe24}" + NavigateUri="{Binding ProjectUrl}" + Visibility="{Binding ProjectUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> - <Separator Margin="0,4,0,8" /> + <ui:HyperlinkButton + Content="{x:Static properties:Resources.LicenseUrl}" + Icon="{ui:SymbolIcon Document24}" + NavigateUri="{Binding LicenseUrl}" + Visibility="{Binding LicenseUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> + </StackPanel> + </ScrollViewer> - <!-- リンク --> - <ui:HyperlinkButton - Content="{x:Static properties:Resources.ProjectUrl}" - Icon="{ui:SymbolIcon Globe24}" - NavigateUri="{Binding ProjectUrl}" - Visibility="{Binding ProjectUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> + <ProgressBar + Grid.Row="1" + Height="4" + Margin="0,4" + IsIndeterminate="True" + Visibility="{Binding IsReadmeLoading, Converter={StaticResource b2vConv}}" /> - <ui:HyperlinkButton - Content="{x:Static properties:Resources.LicenseUrl}" - Icon="{ui:SymbolIcon Document24}" - NavigateUri="{Binding LicenseUrl}" - Visibility="{Binding LicenseUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> - </StackPanel> - </ScrollViewer> + <md:MarkdownScrollViewer + Grid.Row="2" + Margin="0,8,0,0" + ClickAction="OpenBrowser" + Markdown="{Binding ReadmeMarkdown}" + MarkdownStyle="{StaticResource mdStyle}" + Visibility="{Binding HasReadme, Converter={StaticResource b2vConv}}"> + <md:MarkdownScrollViewer.Plugins> + <mdp:MdXamlPlugins> + <ctrl:CustomPluginSetup /> + </mdp:MdXamlPlugins> + </md:MarkdownScrollViewer.Plugins> + </md:MarkdownScrollViewer> + </Grid> </Grid> </Grid> </UserControl> diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index f1ca6f1a..fb9da678 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; @@ -17,6 +18,8 @@ public partial class PluginStoreViewModel : ObservableObject private readonly NuGetPluginService nugetService; private readonly ILogger<PluginStoreViewModel> logger; private readonly IContentDialogService dialogService; + private CancellationTokenSource? readmeLoadCancellation; + private PluginPackageViewModel? selectedPackage; [ObservableProperty] private bool isLoading; @@ -24,8 +27,34 @@ public partial class PluginStoreViewModel : ObservableObject [ObservableProperty] private string? errorMessage; - [ObservableProperty] - private PluginPackageViewModel? selectedPackage; + public PluginPackageViewModel? SelectedPackage + { + get => this.selectedPackage; + set + { + var previous = this.selectedPackage; + if (!SetProperty(ref this.selectedPackage, value)) + { + return; + } + + if (previous is not null) + { + previous.PropertyChanged -= OnSelectedPackagePropertyChanged; + previous.IsReadmeLoading = false; + } + if (value is not null) + { + value.PropertyChanged += OnSelectedPackagePropertyChanged; + StartReadmeLoad(value); + } + else + { + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + } + } + } public ObservableCollection<PluginPackageViewModel> Packages { get; } = []; @@ -202,6 +231,76 @@ await this.dialogService.ShowAlertAsync( } } + private void OnSelectedPackagePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is PluginPackageViewModel package + && ReferenceEquals(package, this.SelectedPackage) + && e.PropertyName == nameof(PluginPackageViewModel.LatestVersion)) + { + StartReadmeLoad(package); + } + } + + private void StartReadmeLoad(PluginPackageViewModel package) + { + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + package.ReadmeMarkdown = null; + + var version = package.LatestVersion; + if (string.IsNullOrWhiteSpace(version)) + { + package.IsReadmeLoading = false; + return; + } + + var cancellationSource = new CancellationTokenSource(); + this.readmeLoadCancellation = cancellationSource; + package.IsReadmeLoading = true; + _ = LoadPackageReadmeAsync(package, version, cancellationSource); + } + + private async Task LoadPackageReadmeAsync( + PluginPackageViewModel package, + string version, + CancellationTokenSource cancellationSource) + { + try + { + var readme = await this.nugetService.GetPackageReadmeAsync( + package.Id, + version, + cancellationSource.Token).ConfigureAwait(true); + if (!cancellationSource.IsCancellationRequested + && ReferenceEquals(package, this.SelectedPackage) + && string.Equals(version, package.LatestVersion, StringComparison.OrdinalIgnoreCase)) + { + package.ReadmeMarkdown = readme; + } + } + catch (OperationCanceledException) when (cancellationSource.IsCancellationRequested) + { + // 選択変更によるキャンセルは正常 + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "プラグインREADMEの取得に失敗しました: {PackageId} {Version}", + package.Id, + version); + } + finally + { + if (ReferenceEquals(this.readmeLoadCancellation, cancellationSource)) + { + package.IsReadmeLoading = false; + this.readmeLoadCancellation = null; + } + cancellationSource.Dispose(); + } + } + } /// <summary> @@ -241,12 +340,21 @@ public partial class PluginPackageViewModel : ObservableObject [ObservableProperty] private double installProgress; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasReadme))] + private string? readmeMarkdown; + + [ObservableProperty] + private bool isReadmeLoading; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(LatestVersion))] [NotifyPropertyChangedFor(nameof(CanInstall))] [NotifyPropertyChangedFor(nameof(StatusText))] private bool usePrerelease; + public bool HasReadme => !string.IsNullOrWhiteSpace(this.ReadmeMarkdown); + public string StatusText { get diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index f97b6cea..dd54bcec 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -52,6 +52,8 @@ </PackageReference> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" /> <PackageReference Include="NuGet.Frameworks" /> + <PackageReference Include="NuGet.Packaging" /> + <PackageReference Include="NuGet.Protocol" /> <PackageReference Include="NuGet.Versioning" /> <PackageReference Include="Octokit" /> <PackageReference Include="PropertyTools.Wpf" /> From e6168a307992b172b8f270b03b825330778040f9 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Mon, 3 Aug 2026 22:52:43 +0900 Subject: [PATCH 13/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E6=83=85=E5=A0=B1=E3=81=AE=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=B0=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=81=A8=E5=86=8D=E8=B5=B7=E5=8B=95=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 223 ++++++++++++++++- WindowTranslator/ApplicationRestart.cs | 82 ++++++ .../Modules/PluginStore/NuGetPluginCatalog.cs | 78 +++++- .../Modules/PluginStore/NuGetPluginService.cs | 227 +++++++++++++++-- .../Modules/PluginStore/PluginStoreView.xaml | 2 +- .../PluginStore/PluginStoreViewModel.cs | 236 ++++++++++++++---- .../Modules/Settings/AllSettingsViewModel.cs | 1 + WindowTranslator/Program.cs | 6 +- .../Properties/Resources.Designer.cs | 10 + WindowTranslator/Properties/Resources.en.resx | 6 + WindowTranslator/Properties/Resources.resx | 6 + docs/plugin.md | 7 +- 12 files changed, 810 insertions(+), 74 deletions(-) create mode 100644 WindowTranslator/ApplicationRestart.cs diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 729e0570..9744f4ad 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -9,6 +9,7 @@ using System.Text; using System.Text.Json; using System.Xml.Linq; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using NuGet.Frameworks; using NuGet.Packaging; @@ -73,7 +74,7 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories })); using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(client, testDirectory, hostMajorVersion: 7); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); @@ -112,6 +113,8 @@ await File.ReadAllTextAsync(Path.Combine( var package = Assert.Single(installed); Assert.Equal("Root.Plugin", package.Id); Assert.Equal("1.0.0", package.Version); + Assert.Equal(7, package.HostMajorVersion); + Assert.True(package.IsCompatible); } finally { @@ -413,6 +416,126 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task ExistingManifestRecordsCurrentHostMajorVersionOnFirstRead() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo("Legacy.Plugin", "1.0.0")]))); + using var handler = new InMemoryNuGetHandler(); + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + hostMajorVersion: 7); + + var package = Assert.Single(await service.GetInstalledPackagesAsync()); + + Assert.Equal(7, package.HostMajorVersion); + Assert.True(package.IsCompatible); + using var document = JsonDocument.Parse(await File.ReadAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"))); + Assert.Equal( + 7, + document.RootElement + .GetProperty("Packages")[0] + .GetProperty("HostMajorVersion") + .GetInt32()); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstalledPackageFromAnotherHostMajorVersionIsMarkedIncompatible() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo("Old.Plugin", "1.0.0", HostMajorVersion: 6)]))); + using var handler = new InMemoryNuGetHandler(); + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + hostMajorVersion: 7); + + var package = Assert.Single(await service.GetInstalledPackagesAsync()); + + Assert.False(package.IsCompatible); + Assert.Equal(6, package.HostMajorVersion); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSettings() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + var metadataSource = new InMemoryNuGetMetadataSource + { + SearchResults = + [ + new NuGetPluginSearchMetadata( + "Background.Plugin", + "Background Plugin", + null, + null, + null, + null), + ], + }; + metadataSource.AddVersions( + "Background.Plugin", + CreatePluginVersionMetadata("1.0.0")); + using var client = new HttpClient(handler); + using var service = CreateService( + client, + testDirectory, + metadataSource: metadataSource); + var updated = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + service.PackageInformationUpdated += (_, _) => updated.TrySetResult(); + + Assert.IsAssignableFrom<IHostedService>(service); + await service.StartAsync(CancellationToken.None); + try + { + await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + Assert.True(service.PackageSnapshot.IsInitialized); + Assert.Null(service.PackageSnapshot.Error); + Assert.Equal( + "Background.Plugin", + Assert.Single(service.PackageSnapshot.Packages).Id); + Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task PluginStoreKeepsInstalledPackagesVisibleWhenNuGetSearchFails() { @@ -608,6 +731,49 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() Assert.True(prereleaseOnlyPackage.CanInstall); } + [Fact] + public void IncompatibleInstalledPackageCanReinstallACompatibleVersion() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "1.0.0", + "Test Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0"]), + isInstalled: true, + installedVersion: "1.0.0", + isCompatible: false, + hasCompatiblePackageVersion: true); + + Assert.False(package.IsUpdateAvailable); + Assert.True(package.RequiresReinstall); + Assert.True(package.CanUpdate); + Assert.Equal(WindowTranslator.Properties.Resources.PluginIncompatible, package.StatusText); + + package.IsCompatible = true; + + Assert.False(package.RequiresReinstall); + Assert.False(package.CanUpdate); + } + + [Fact] + public void RestartArgumentsAreNotForwardedToTheRestartedApplication() + { + var arguments = ApplicationRestart.RemoveRestartArguments( + [ + "--IgnoreUpdate", + ApplicationRestart.RestartProcessIdArgument, + "1234", + "--SuppressMode", + ]); + + Assert.Equal(["--IgnoreUpdate", "--SuppressMode"], arguments); + } + [Fact] public async Task InstallRejectsPackageRequiringNewerHostAbstractions() { @@ -987,6 +1153,55 @@ public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() } } + [Fact] + public void CatalogSynchronizationExcludesPackagesFromAnotherHostMajorVersion() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + var compatibleDirectory = Path.Combine(sourceDirectory, "Compatible.Plugin"); + var incompatibleDirectory = Path.Combine(sourceDirectory, "Incompatible.Plugin"); + Directory.CreateDirectory(compatibleDirectory); + Directory.CreateDirectory(incompatibleDirectory); + File.WriteAllText(Path.Combine(compatibleDirectory, "Compatible.Plugin.dll"), "compatible"); + File.WriteAllText(Path.Combine(incompatibleDirectory, "Incompatible.Plugin.dll"), "incompatible"); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Incompatible.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Incompatible.Plugin", "Incompatible.Plugin.dll"), + "stale"); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [ + new InstalledPackageInfo("Compatible.Plugin", "1.0.0", HostMajorVersion: 7), + new InstalledPackageInfo("Incompatible.Plugin", "1.0.0", HostMajorVersion: 6), + ]))); + + var incompatiblePackages = NuGetPluginCatalog.GetIncompatiblePackageIds( + sourceDirectory, + hostMajorVersion: 7); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory, + incompatiblePackages); + + Assert.True(File.Exists(Path.Combine( + destinationDirectory, + "Compatible.Plugin", + "Compatible.Plugin.dll"))); + Assert.False(Directory.Exists(Path.Combine( + destinationDirectory, + "Incompatible.Plugin"))); + Assert.Equal(["Incompatible.Plugin"], incompatiblePackages); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + [Fact] public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() { @@ -1161,13 +1376,15 @@ private static NuGetPluginService CreateService( HttpClient client, string pluginDirectory, IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, - INuGetPluginMetadataSource? metadataSource = null) + INuGetPluginMetadataSource? metadataSource = null, + int? hostMajorVersion = null) => new( NullLogger<NuGetPluginService>.Instance, client, pluginDirectory, hostPackageVersions: hostPackageVersions, - metadataSource: metadataSource ?? new InMemoryNuGetMetadataSource()); + metadataSource: metadataSource ?? new InMemoryNuGetMetadataSource(), + hostMajorVersion: hostMajorVersion); private static NuGetPluginVersionMetadata CreatePluginVersionMetadata( string version, diff --git a/WindowTranslator/ApplicationRestart.cs b/WindowTranslator/ApplicationRestart.cs new file mode 100644 index 00000000..5024ad09 --- /dev/null +++ b/WindowTranslator/ApplicationRestart.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using System.Globalization; +using System.Windows; + +namespace WindowTranslator; + +internal static class ApplicationRestart +{ + internal const string RestartProcessIdArgument = "--windowtranslator-restart-pid"; + private static readonly TimeSpan PreviousProcessWaitTimeout = TimeSpan.FromSeconds(30); + + public static void Restart() + { + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("実行ファイルのパスを取得できませんでした。"); + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(RestartProcessIdArgument); + startInfo.ArgumentList.Add(Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + foreach (var argument in RemoveRestartArguments(Environment.GetCommandLineArgs().Skip(1))) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new InvalidOperationException("WindowTranslatorを再起動できませんでした。"); + Application.Current.Shutdown(); + } + + public static string[] WaitForPreviousProcess(IEnumerable<string> arguments) + { + var (remainingArguments, processId) = ParseRestartArguments(arguments); + + if (processId is not null && processId != Environment.ProcessId) + { + try + { + using var process = Process.GetProcessById(processId.Value); + _ = process.WaitForExit(PreviousProcessWaitTimeout); + } + catch (ArgumentException) + { + // 再起動元のプロセスはすでに終了している + } + } + + return remainingArguments; + } + + internal static string[] RemoveRestartArguments(IEnumerable<string> arguments) + => ParseRestartArguments(arguments).Arguments; + + private static (string[] Arguments, int? ProcessId) ParseRestartArguments( + IEnumerable<string> arguments) + { + var argumentArray = arguments.ToArray(); + var remainingArguments = new List<string>(); + int? processId = null; + for (var index = 0; index < argumentArray.Length; index++) + { + var argument = argumentArray[index]; + if (argument.Equals(RestartProcessIdArgument, StringComparison.OrdinalIgnoreCase) + && index + 1 < argumentArray.Length + && int.TryParse( + argumentArray[index + 1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var parsedProcessId)) + { + processId = parsedProcessId; + index++; + continue; + } + + remainingArguments.Add(argument); + } + + return ([.. remainingArguments], processId); + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 7a7e7dff..34636209 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Reflection.PortableExecutable; using System.Runtime.Loader; +using System.Text.Json; using Weikio.PluginFramework.Abstractions; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; @@ -19,18 +20,37 @@ public sealed class NuGetPluginCatalog : IPluginCatalog private readonly string sourceDir; private readonly string tempDir; + private readonly int hostMajorVersion; private readonly FolderPluginCatalogOptions options; private CompositePluginCatalog innerCatalog = new(); public NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) - : this(sourceDir, DefaultTempDir, options) + : this(sourceDir, DefaultTempDir, AppInfo.Instance.Version.Major, options) + { + } + + public NuGetPluginCatalog( + string sourceDir, + int hostMajorVersion, + FolderPluginCatalogOptions options) + : this(sourceDir, DefaultTempDir, hostMajorVersion, options) { } internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalogOptions options) + : this(sourceDir, tempDir, AppInfo.Instance.Version.Major, options) + { + } + + internal NuGetPluginCatalog( + string sourceDir, + string tempDir, + int hostMajorVersion, + FolderPluginCatalogOptions options) { this.sourceDir = sourceDir; this.tempDir = tempDir; + this.hostMajorVersion = hostMajorVersion; this.options = options; } @@ -40,7 +60,10 @@ internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalo /// <inheritdoc/> public async Task Initialize() { - SynchronizePluginFiles(this.sourceDir, this.tempDir); + var incompatiblePackages = GetIncompatiblePackageIds( + this.sourceDir, + this.hostMajorVersion); + SynchronizePluginFiles(this.sourceDir, this.tempDir, incompatiblePackages); this.innerCatalog = CreateCatalog(this.tempDir, this.options); await this.innerCatalog.Initialize().ConfigureAwait(false); @@ -285,7 +308,10 @@ private static string GetHintKey(string fileName, bool isNative) private static string GetSatelliteKey(string assemblyName, string cultureName) => $"{cultureName}:{assemblyName}"; - internal static void SynchronizePluginFiles(string source, string destination) + internal static void SynchronizePluginFiles( + string source, + string destination, + IReadOnlySet<string>? excludedRootDirectories = null) { Directory.CreateDirectory(destination); @@ -298,7 +324,8 @@ internal static void SynchronizePluginFiles(string source, string destination) source, isRoot: true, sourceFiles, - sourceDirectories); + sourceDirectories, + excludedRootDirectories); } foreach (var relativeDirectory in sourceDirectories.OrderBy(GetPathDepth)) @@ -363,12 +390,45 @@ private static bool IsManagementFile(string fileName) => fileName.Equals("nuget-manifest.json", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("nuget-manifest.json.tmp-", StringComparison.OrdinalIgnoreCase); + internal static IReadOnlySet<string> GetIncompatiblePackageIds( + string sourceDirectory, + int hostMajorVersion) + { + var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); + if (!File.Exists(manifestPath)) + { + return new HashSet<string>(StringComparer.OrdinalIgnoreCase); + } + + try + { + using var stream = File.OpenRead(manifestPath); + var manifest = JsonSerializer.Deserialize<InstalledManifest>( + stream, + NuGetPluginService.ManifestJsonOptions); + return manifest?.Packages + .Where(package => package.HostMajorVersion is not null + && package.HostMajorVersion != hostMajorVersion) + .Select(package => package.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException) + { + // 壊れたマニフェストはNuGetPluginService側で報告する。ここでは既存動作を維持する。 + return new HashSet<string>(StringComparer.OrdinalIgnoreCase); + } + } + private static void CollectSourceEntries( string sourceRoot, string currentDirectory, bool isRoot, Dictionary<string, string> sourceFiles, - HashSet<string> sourceDirectories) + HashSet<string> sourceDirectories, + IReadOnlySet<string>? excludedRootDirectories) { foreach (var file in Directory.EnumerateFiles(currentDirectory)) { @@ -382,7 +442,10 @@ private static void CollectSourceEntries( foreach (var subDirectory in Directory.EnumerateDirectories(currentDirectory)) { - if (isRoot && IsWorkingDirectory(Path.GetFileName(subDirectory))) + var directoryName = Path.GetFileName(subDirectory); + if (isRoot + && (IsWorkingDirectory(directoryName) + || excludedRootDirectories?.Contains(directoryName) is true)) { continue; } @@ -394,7 +457,8 @@ private static void CollectSourceEntries( subDirectory, isRoot: false, sourceFiles, - sourceDirectories); + sourceDirectories, + excludedRootDirectories); } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 346f3087..c3aa7de6 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NuGet.Packaging; using NuGet.Versioning; @@ -13,14 +14,15 @@ namespace WindowTranslator.Modules.PluginStore; /// <summary> /// NuGet V3 REST APIを使用してプラグインパッケージの検索・インストール・管理を行うサービスです。 /// </summary> -public sealed class NuGetPluginService : IDisposable +public sealed class NuGetPluginService : BackgroundService { private const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; internal const string PluginTag = "windowtranslator-plugin"; internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; private const int MaxConcurrentMetadataRequests = 8; + private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); - private static readonly JsonSerializerOptions JsonOptions = new() + internal static readonly JsonSerializerOptions ManifestJsonOptions = new() { PropertyNameCaseInsensitive = true, AllowTrailingCommas = true, @@ -35,9 +37,16 @@ public sealed class NuGetPluginService : IDisposable private readonly bool ownsHttpClient; private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions; private readonly INuGetPluginMetadataSource metadataSource; + private readonly App? app; + private readonly int hostMajorVersion; private readonly SemaphoreSlim operationLock = new(1, 1); + private readonly SemaphoreSlim refreshLock = new(1, 1); + private readonly object snapshotLock = new(); + private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; + private long installedPackagesGeneration; + private int disposeState; - public NuGetPluginService(ILogger<NuGetPluginService> logger) + public NuGetPluginService(ILogger<NuGetPluginService> logger, App app) : this( logger, new HttpClient(new HttpClientHandler @@ -48,7 +57,8 @@ public NuGetPluginService(ILogger<NuGetPluginService> logger) Timeout = TimeSpan.FromSeconds(30), }, Path.Combine(PathUtility.UserDir, "plugins"), - ownsHttpClient: true) + ownsHttpClient: true, + app: app) { } @@ -58,7 +68,9 @@ internal NuGetPluginService( string userPluginsDir, bool ownsHttpClient = false, IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, - INuGetPluginMetadataSource? metadataSource = null) + INuGetPluginMetadataSource? metadataSource = null, + App? app = null, + int? hostMajorVersion = null) { this.logger = logger; this.httpClient = httpClient; @@ -68,6 +80,85 @@ internal NuGetPluginService( this.hostPackageVersions = hostPackageVersions ?? CreateHostPackageVersions(); this.metadataSource = metadataSource ?? new NuGetProtocolPluginMetadataSource(NuGetServiceIndexUrl); + this.app = app; + this.hostMajorVersion = hostMajorVersion ?? AppInfo.Instance.Version.Major; + } + + internal event EventHandler? PackageInformationUpdated; + + internal PluginStoreSnapshot PackageSnapshot + { + get + { + lock (this.snapshotLock) + { + return this.packageSnapshot; + } + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (this.app is not null) + { + await this.app.WaitForStartupAsync().ConfigureAwait(false); + } + + while (!stoppingToken.IsCancellationRequested) + { + await RefreshPackageInformationAsync(stoppingToken).ConfigureAwait(false); + await Task.Delay(PackageInformationRefreshInterval, stoppingToken).ConfigureAwait(false); + } + } + + internal async Task RefreshPackageInformationAsync(CancellationToken cancellationToken = default) + { + await this.refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var previousSnapshot = this.PackageSnapshot; + var packages = previousSnapshot.Packages; + Exception? error = null; + try + { + packages = await SearchPackagesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "NuGetからプラグイン情報を更新できませんでした。"); + error = ex; + } + + var installedGenerationBefore = Volatile.Read(ref this.installedPackagesGeneration); + IReadOnlyList<InstalledPackageInfo> installedPackages; + try + { + installedPackages = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "インストール済みプラグイン情報を更新できませんでした。"); + installedPackages = this.PackageSnapshot.InstalledPackages; + error = ex; + } + + var installedGenerationAfter = Volatile.Read(ref this.installedPackagesGeneration); + if (installedGenerationBefore != installedGenerationAfter) + { + installedPackages = this.PackageSnapshot.InstalledPackages; + } + SetPackageSnapshot( + new( + IsInitialized: true, + InstalledPackages: installedPackages, + Packages: packages, + Error: error), + installedGenerationAfter); + } + finally + { + this.refreshLock.Release(); + } } /// <summary> @@ -188,6 +279,7 @@ await installer.InstallAsync( Directory.Move(stagingDir, targetDir); stagingMoved = true; await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + UpdateInstalledPackages(updatedManifest.Packages); try { @@ -264,6 +356,7 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc try { await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + UpdateInstalledPackages(updatedManifest.Packages); } catch { @@ -316,8 +409,28 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc /// </summary> public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync(CancellationToken cancellationToken = default) { - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - return manifest.Packages; + await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var migratedPackages = manifest.Packages + .Select(package => package.HostMajorVersion is null + ? package with { HostMajorVersion = this.hostMajorVersion } + : package) + .ToList(); + if (migratedPackages.Where((package, index) => + package != manifest.Packages[index]).Any()) + { + manifest = new InstalledManifest(migratedPackages); + await SaveManifestAsync(manifest, cancellationToken).ConfigureAwait(false); + } + + return GetCompatibilityAwarePackages(manifest.Packages); + } + finally + { + this.operationLock.Release(); + } } private async Task<NuGetPackageInfo?> CreateCompatiblePackageInfoAsync( @@ -373,14 +486,17 @@ private bool HasCompatibleAbstractionsDependency( return dependency.VersionRange?.Satisfies(hostVersion) is not false; } - private static InstalledManifest AddOrUpdatePackage( + private InstalledManifest AddOrUpdatePackage( InstalledManifest manifest, string packageId, string version) { var packages = manifest.Packages.ToList(); var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); - var newEntry = new InstalledPackageInfo(packageId, version); + var newEntry = new InstalledPackageInfo( + packageId, + version, + this.hostMajorVersion); if (existing >= 0) { packages[existing] = newEntry; @@ -421,6 +537,65 @@ private static Dictionary<string, NuGetVersion> CreateHostPackageVersions() }; } + private InstalledPackageInfo[] GetCompatibilityAwarePackages( + IEnumerable<InstalledPackageInfo> packages) + => packages + .Select(package => package with + { + IsCompatible = package.HostMajorVersion is null + || package.HostMajorVersion == this.hostMajorVersion, + }) + .ToArray(); + + private void UpdateInstalledPackages(IEnumerable<InstalledPackageInfo> packages) + { + lock (this.snapshotLock) + { + this.installedPackagesGeneration++; + this.packageSnapshot = this.packageSnapshot with + { + InstalledPackages = GetCompatibilityAwarePackages(packages), + }; + } + NotifyPackageInformationUpdated(); + } + + private void SetPackageSnapshot( + PluginStoreSnapshot snapshot, + long? expectedInstalledPackagesGeneration = null) + { + lock (this.snapshotLock) + { + if (expectedInstalledPackagesGeneration is not null + && expectedInstalledPackagesGeneration != this.installedPackagesGeneration) + { + snapshot = snapshot with + { + InstalledPackages = this.packageSnapshot.InstalledPackages, + }; + } + this.packageSnapshot = snapshot; + } + + NotifyPackageInformationUpdated(); + } + + private void NotifyPackageInformationUpdated() + { + foreach (EventHandler handler in this.PackageInformationUpdated?.GetInvocationList() + .Cast<EventHandler>() ?? []) + { + try + { + handler(this, EventArgs.Empty); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "プラグイン情報更新イベントの通知に失敗しました。"); + } + } + } + private static InstalledManifest RemovePackage(InstalledManifest manifest, string packageId) => new([.. manifest.Packages.Where(p => !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); @@ -437,7 +612,7 @@ private async Task<InstalledManifest> LoadManifestAsync(CancellationToken cancel await using var fs = File.OpenRead(this.manifestPath); var manifest = await JsonSerializer.DeserializeAsync<InstalledManifest>( fs, - JsonOptions, + ManifestJsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidDataException("プラグインマニフェストが空です。"); if (manifest.Packages is null) @@ -469,7 +644,11 @@ private async Task SaveManifestAsync(InstalledManifest manifest, CancellationTok bufferSize: 4096, useAsync: true)) { - await JsonSerializer.SerializeAsync(fs, manifest, JsonOptions, cancellationToken).ConfigureAwait(false); + await JsonSerializer.SerializeAsync( + fs, + manifest, + ManifestJsonOptions, + cancellationToken).ConfigureAwait(false); await fs.FlushAsync(cancellationToken).ConfigureAwait(false); } @@ -537,13 +716,20 @@ private static void TryDeleteFile(string path) } } - public void Dispose() + public override void Dispose() { + if (Interlocked.Exchange(ref this.disposeState, 1) != 0) + { + return; + } + + base.Dispose(); if (this.ownsHttpClient) { this.httpClient.Dispose(); } this.operationLock.Dispose(); + this.refreshLock.Dispose(); } } @@ -562,8 +748,21 @@ public record NuGetPackageInfo( /// <summary>インストール済みパッケージ情報</summary> public record InstalledPackageInfo( string Id, - string Version -); + string Version, + int? HostMajorVersion = null) +{ + [JsonIgnore] + public bool IsCompatible { get; init; } = true; +} /// <summary>NuGetプラグインの管理マニフェスト</summary> public record InstalledManifest(List<InstalledPackageInfo> Packages); + +internal sealed record PluginStoreSnapshot( + bool IsInitialized, + IReadOnlyList<InstalledPackageInfo> InstalledPackages, + IReadOnlyList<NuGetPackageInfo> Packages, + Exception? Error) +{ + public static PluginStoreSnapshot Empty { get; } = new(false, [], [], null); +} diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 8e50a79e..4f9e77fb 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -148,7 +148,7 @@ Icon="{ui:SymbolIcon ArrowSync24}" IsEnabled="{Binding CanInstall}" Style="{StaticResource InstallButtonStyle}" - Visibility="{Binding IsUpdateAvailable, Converter={StaticResource b2vConv}}" /> + Visibility="{Binding CanUpdate, Converter={StaticResource b2vConv}}" /> <!-- アンインストールボタン(インストール済み) --> <ui:Button diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index fb9da678..2adcbbfa 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using NuGet.Versioning; +using System.Windows; using WindowTranslator.Properties; using Wpf.Ui; using Wpf.Ui.Extensions; @@ -13,13 +14,16 @@ namespace WindowTranslator.Modules.PluginStore; /// <summary> /// プラグインストアのViewModel /// </summary> -public partial class PluginStoreViewModel : ObservableObject +public partial class PluginStoreViewModel : ObservableObject, IDisposable { private readonly NuGetPluginService nugetService; private readonly ILogger<PluginStoreViewModel> logger; private readonly IContentDialogService dialogService; private CancellationTokenSource? readmeLoadCancellation; private PluginPackageViewModel? selectedPackage; + private PluginStoreSnapshot? pendingSnapshot; + private PluginStoreSnapshot? appliedSnapshot; + private bool disposed; [ObservableProperty] private bool isLoading; @@ -66,6 +70,7 @@ public PluginStoreViewModel( this.nugetService = nugetService; this.logger = logger; this.dialogService = dialogService; + this.nugetService.PackageInformationUpdated += OnPackageInformationUpdated; } /// <summary> @@ -82,41 +87,14 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) try { - var installed = await this.nugetService.GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(true); - var installedDict = installed.ToDictionary(p => p.Id, StringComparer.OrdinalIgnoreCase); - - this.Packages.Clear(); - foreach (var inst in installed) - { - this.Packages.Add(new PluginPackageViewModel( - new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), - isInstalled: true, - installedVersion: inst.Version)); - } - - var packages = await this.nugetService.SearchPackagesAsync(cancellationToken).ConfigureAwait(true); - this.logger.LogInformation("NuGetから{Count}件のプラグインパッケージを取得しました。", packages.Count); - - this.Packages.Clear(); - foreach (var pkg in packages) + if (!this.nugetService.PackageSnapshot.IsInitialized) { - installedDict.TryGetValue(pkg.Id, out var installedInfo); - var isInstalled = installedInfo is not null; - var installedVersion = installedInfo?.Version; - this.Packages.Add(new PluginPackageViewModel(pkg, isInstalled, installedVersion)); + await this.nugetService + .RefreshPackageInformationAsync(cancellationToken) + .ConfigureAwait(true); } - // インストール済みだがNuGetに見つからないパッケージも表示 - foreach (var inst in installed) - { - if (!this.Packages.Any(p => p.Id.Equals(inst.Id, StringComparison.OrdinalIgnoreCase))) - { - this.Packages.Add(new PluginPackageViewModel( - new NuGetPackageInfo(inst.Id, inst.Version, inst.Id, string.Empty, string.Empty, null, null), - isInstalled: true, - installedVersion: inst.Version)); - } - } + ApplyPackageSnapshot(this.nugetService.PackageSnapshot); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -133,6 +111,114 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) } } + private void OnPackageInformationUpdated(object? sender, EventArgs e) + { + if (this.disposed) + { + return; + } + + var snapshot = this.nugetService.PackageSnapshot; + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess()) + { + ApplyPackageSnapshot(snapshot); + } + else + { + _ = dispatcher.BeginInvoke(() => ApplyPackageSnapshot(snapshot)); + } + } + + private void ApplyPackageSnapshot(PluginStoreSnapshot snapshot) + { + if (this.disposed) + { + return; + } + if (ReferenceEquals(this.appliedSnapshot, snapshot)) + { + return; + } + if (this.Packages.Any(package => package.IsInstalling)) + { + this.pendingSnapshot = snapshot; + return; + } + + this.pendingSnapshot = null; + this.appliedSnapshot = snapshot; + var selectedPackageId = this.SelectedPackage?.Id; + var prereleaseSelections = this.Packages.ToDictionary( + package => package.Id, + package => package.UsePrerelease, + StringComparer.OrdinalIgnoreCase); + var installedPackages = snapshot.InstalledPackages.ToDictionary( + package => package.Id, + StringComparer.OrdinalIgnoreCase); + + this.Packages.Clear(); + foreach (var packageInfo in snapshot.Packages) + { + installedPackages.TryGetValue(packageInfo.Id, out var installedPackage); + var package = new PluginPackageViewModel( + packageInfo, + isInstalled: installedPackage is not null, + installedVersion: installedPackage?.Version, + isCompatible: installedPackage?.IsCompatible ?? true, + hasCompatiblePackageVersion: true); + if (package.HasPrereleaseVersion + && prereleaseSelections.TryGetValue(package.Id, out var usePrerelease)) + { + package.UsePrerelease = usePrerelease; + } + this.Packages.Add(package); + } + + foreach (var installedPackage in snapshot.InstalledPackages) + { + if (this.Packages.Any(package => package.Id.Equals( + installedPackage.Id, + StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + this.Packages.Add(new PluginPackageViewModel( + new NuGetPackageInfo( + installedPackage.Id, + installedPackage.Version, + installedPackage.Id, + string.Empty, + string.Empty, + null, + null), + isInstalled: true, + installedVersion: installedPackage.Version, + isCompatible: installedPackage.IsCompatible, + hasCompatiblePackageVersion: false)); + } + + this.SelectedPackage = selectedPackageId is null + ? null + : this.Packages.FirstOrDefault(package => package.Id.Equals( + selectedPackageId, + StringComparison.OrdinalIgnoreCase)); + this.ErrorMessage = snapshot.Error is null ? null : Resources.NuGetSearchFailed; + this.logger.LogInformation( + "バックグラウンド更新から{Count}件のプラグインパッケージを反映しました。", + snapshot.Packages.Count); + } + + private void ApplyPendingSnapshot() + { + if (this.pendingSnapshot is { } snapshot + && !this.Packages.Any(package => package.IsInstalling)) + { + ApplyPackageSnapshot(snapshot); + } + } + /// <summary> /// プラグインをインストールまたは更新します。 /// </summary> @@ -160,17 +246,14 @@ await this.nugetService.InstallPackageAsync( package.IsInstalled = true; package.InstalledVersion = version; + package.IsCompatible = true; package.InstallProgress = 0; this.logger.LogInformation("プラグインのインストール完了: {PackageId}", package.Id); - // 再起動が必要な旨を表示 - await this.dialogService.ShowSimpleDialogAsync(new() - { - Title = Resources.PluginInstallSuccess, - Content = Resources.RestartRequired, - CloseButtonText = Resources.Close, - }, cancellationToken).ConfigureAwait(true); + await ShowRestartDialogAsync( + Resources.PluginInstallSuccess, + cancellationToken).ConfigureAwait(true); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -188,6 +271,7 @@ await this.dialogService.ShowAlertAsync( finally { package.IsInstalling = false; + ApplyPendingSnapshot(); } } @@ -208,18 +292,15 @@ public async Task UninstallAsync(PluginPackageViewModel package) if (result != Wpf.Ui.Controls.ContentDialogResult.Primary) return; + package.IsInstalling = true; try { await this.nugetService.UninstallPackageAsync(package.Id).ConfigureAwait(true); package.IsInstalled = false; package.InstalledVersion = null; + package.IsCompatible = true; - await this.dialogService.ShowSimpleDialogAsync(new() - { - Title = Resources.Uninstall, - Content = Resources.RestartRequired, - CloseButtonText = Resources.Close, - }).ConfigureAwait(true); + await ShowRestartDialogAsync(Resources.Uninstall).ConfigureAwait(true); } catch (Exception ex) { @@ -229,6 +310,28 @@ await this.dialogService.ShowAlertAsync( ex.Message, Resources.Close).ConfigureAwait(true); } + finally + { + package.IsInstalling = false; + ApplyPendingSnapshot(); + } + } + + private async Task ShowRestartDialogAsync( + string title, + CancellationToken cancellationToken = default) + { + var result = await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = title, + Content = Resources.RestartRequired, + PrimaryButtonText = Resources.RestartNow, + CloseButtonText = Resources.Close, + }, cancellationToken).ConfigureAwait(true); + if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) + { + ApplicationRestart.Restart(); + } } private void OnSelectedPackagePropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -301,6 +404,20 @@ private async Task LoadPackageReadmeAsync( } } + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.nugetService.PackageInformationUpdated -= OnPackageInformationUpdated; + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + GC.SuppressFinalize(this); + } + } /// <summary> @@ -319,10 +436,18 @@ public partial class PluginPackageViewModel : ObservableObject : this.ReleaseVersion; public bool HasPrereleaseVersion => this.PrereleaseVersion is not null; public bool CanInstall => !this.IsInstalling && this.LatestVersion is not null; + public bool RequiresReinstall => this.IsInstalled + && !this.IsCompatible + && this.hasCompatiblePackageVersion + && this.LatestVersion is not null; + public bool CanUpdate => this.IsUpdateAvailable || this.RequiresReinstall; public string? ProjectUrl { get; } public string? LicenseUrl { get; } + private readonly bool hasCompatiblePackageVersion; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool isInstalled; [ObservableProperty] @@ -331,8 +456,15 @@ public partial class PluginPackageViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusText))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool isUpdateAvailable; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] + private bool isCompatible; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanInstall))] private bool isInstalling; @@ -351,6 +483,8 @@ public partial class PluginPackageViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(LatestVersion))] [NotifyPropertyChangedFor(nameof(CanInstall))] [NotifyPropertyChangedFor(nameof(StatusText))] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool usePrerelease; public bool HasReadme => !string.IsNullOrWhiteSpace(this.ReadmeMarkdown); @@ -359,6 +493,8 @@ public string StatusText { get { + if (this.IsInstalled && !this.IsCompatible) + return Resources.PluginIncompatible; if (this.IsUpdateAvailable && this.InstalledVersion is not null && this.LatestVersion is not null) @@ -372,7 +508,9 @@ public string StatusText public PluginPackageViewModel( NuGetPackageInfo info, bool isInstalled, - string? installedVersion) + string? installedVersion, + bool isCompatible = true, + bool hasCompatiblePackageVersion = true) { var versions = new[] { info.Version } .Concat(info.Versions ?? []) @@ -398,8 +536,10 @@ public PluginPackageViewModel( .FirstOrDefault(); this.ProjectUrl = info.ProjectUrl; this.LicenseUrl = info.LicenseUrl; + this.hasCompatiblePackageVersion = hasCompatiblePackageVersion; this.isInstalled = isInstalled; this.installedVersion = installedVersion; + this.isCompatible = isCompatible; this.usePrerelease = this.PrereleaseVersion is not null && NuGetVersion.TryParse(installedVersion, out var installed) && installed.IsPrerelease; @@ -412,6 +552,8 @@ public PluginPackageViewModel( partial void OnUsePrereleaseChanged(bool value) => RefreshUpdateAvailable(); + partial void OnIsCompatibleChanged(bool value) => RefreshUpdateAvailable(); + private void RefreshUpdateAvailable() { this.IsUpdateAvailable = this.IsInstalled diff --git a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs index 5e15a387..f0e21189 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs +++ b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs @@ -338,6 +338,7 @@ private DisposeAction EnterBusy() public void Dispose() { this.updateChecker.UpdateAvailable -= UpdateChecker_UpdateAvailable; + this.PluginStore.Dispose(); } } diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index db70867c..be8d99ef 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -38,6 +38,8 @@ //Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo("it"); //Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo("it"); +args = ApplicationRestart.WaitForPreviousProcess(args); + #if NO_MUTEX var createdNew = true; #else @@ -119,6 +121,7 @@ var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); pluginFolderCatalog.AddCatalog(new NuGetPluginCatalog( userPluginsDir, + AppInfo.Instance.Version.Major, new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); builder.Services.AddPluginCatalog(pluginFolderCatalog); @@ -158,7 +161,8 @@ builder.Services.AddPresentation<ValidateDialog, ValidateViewModel>(); builder.Services.AddSingleton<IContentDialogService, ContentDialogService>(); builder.Services.AddSingleton<ISnackbarService, SnackbarService>(); -builder.Services.AddSingleton<NuGetPluginService>(); +builder.Services.AddSingleton<NuGetPluginService>() + .AddHostedService(sp => sp.GetRequiredService<NuGetPluginService>()); builder.Services.AddTransient<PluginStoreViewModel>(); builder.Services.AddTransient<IConfigureOptions<UserSettings>, ConfigureUserSettings>(); builder.Services.Configure<CommonSettings>(builder.Configuration.GetSection(nameof(UserSettings.Common))); diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index b4e15bb9..a526e1fb 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -457,6 +457,11 @@ internal Resources() { /// </summary> public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; + /// <summary> + /// "現在のWindowTranslatorメジャーバージョンとは互換性がありません。" に類似しているローカライズされた文字列を検索します。 + /// </summary> + public static string PluginIncompatible => ResourceManager.GetString("PluginIncompatible", resourceCulture) ?? string.Empty; + /// <summary> /// "プラグイン" に類似しているローカライズされた文字列を検索します。 /// </summary> @@ -482,6 +487,11 @@ internal Resources() { /// </summary> public static string RegisterAutoStart => ResourceManager.GetString("RegisterAutoStart", resourceCulture) ?? string.Empty; + /// <summary> + /// "今すぐ再起動" に類似しているローカライズされた文字列を検索します。 + /// </summary> + public static string RestartNow => ResourceManager.GetString("RestartNow", resourceCulture) ?? string.Empty; + /// <summary> /// "プラグインの変更を適用するには、WindowTranslatorを再起動してください。" に類似しているローカライズされた文字列を検索します。 /// </summary> diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 23eed21d..21dfeaaa 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -504,6 +504,12 @@ Monitors are not supported. <data name="PluginInstallFailed" xml:space="preserve"> <value>Installation failed</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>This plugin is incompatible with the current WindowTranslator major version.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Restart now</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Please restart WindowTranslator to apply plugin changes.</value> </data> diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index e9e8b24f..0774e624 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -504,6 +504,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>インストール失敗</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>現在のWindowTranslatorメジャーバージョンとは互換性がありません。</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>今すぐ再起動</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>プラグインの変更を適用するには、WindowTranslatorを再起動してください。</value> </data> diff --git a/docs/plugin.md b/docs/plugin.md index 942a4f6f..7b59fcf3 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -127,7 +127,7 @@ public class MyTranslateModule : ITranslateModule { ... } 2. 「プラグイン」タブを選択します。 3. 利用するプラグインの「インストール」を選択します。 プレリリース版を利用する場合は、そのプラグインの「プレリリース」にチェックを入れます。 -4. インストール完了後に WindowTranslator を再起動します。 +4. インストール完了後のダイアログから WindowTranslator を再起動します。 NuGetパッケージで宣言されたランタイム依存関係も再帰的に取得されます。 同じ依存パッケージに両立しないバージョン条件がある場合は、既存の @@ -137,6 +137,11 @@ NuGetパッケージで宣言されたランタイム依存関係も再帰的に 自動インストールされることはありません。インストールはプラグインストアで 利用者が明示的に実行した場合だけ行われます。 +プラグイン情報は WindowTranslator の起動後にバックグラウンドで更新されます。 +プラグインをインストールした WindowTranslator と現在のメジャーバージョンが異なる場合、 +そのプラグインは互換性がないものとして起動時のロード対象から除外されます。 +互換バージョンを再インストールすると、次回起動から再び利用できます。 + アンインストールすると管理フォルダのパッケージは直ちに削除されます。 実行中に読み込まれたプラグインを停止するには、WindowTranslator の再起動が必要です。 From 38a25ccdb115228c8ec498908a947623301f6fd6 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Tue, 4 Aug 2026 09:46:50 +0900 Subject: [PATCH 14/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E9=80=9A=E4=BF=A1=E3=82=92SDK=E3=81=B8=E7=B5=B1?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 1 + .../NuGetPluginServiceTests.cs | 516 +++++++++++------- .../PluginStore/NuGetPackageInstaller.cs | 83 ++- .../Modules/PluginStore/NuGetPluginCatalog.cs | 5 +- .../Modules/PluginStore/NuGetPluginService.cs | 381 ++++++------- .../NuGetProtocolPluginMetadataSource.cs | 122 ----- .../PluginStore/PluginCompatibility.cs | 21 + WindowTranslator/Program.cs | 20 +- WindowTranslator/WindowTranslator.csproj | 3 +- 9 files changed, 582 insertions(+), 570 deletions(-) delete mode 100644 WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs create mode 100644 WindowTranslator/Modules/PluginStore/PluginCompatibility.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 26c77095..91bbe10e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ <PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.302" /> <PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" /> diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 9744f4ad..5f1664bf 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -11,9 +11,12 @@ using System.Xml.Linq; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Configuration; using NuGet.Frameworks; using NuGet.Packaging; using NuGet.Packaging.Core; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; using NuGet.Versioning; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; @@ -73,8 +76,7 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories ["lib/netstandard2.0/Transitive.Package.dll"] = "transitive"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory, hostMajorVersion: 7); + using var service = CreateService(handler, testDirectory, hostMajorVersion: 7); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); @@ -152,8 +154,7 @@ public async Task ManifestWriteFailureRestoresThePreviousPluginDirectory() ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); @@ -213,8 +214,7 @@ public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstal ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); await service.UninstallPackageAsync("Root.Plugin"); @@ -251,8 +251,7 @@ public async Task UninstallRestoresManagedFilesWhenManifestUpdateFails() ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); @@ -313,8 +312,7 @@ public async Task DependencyWithIncompatibleLibStillInstallsCompatibleNativeAsse [$"runtimes/{RuntimeIdentifier}/native/compatible.dll"] = "native"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); await service.InstallPackageAsync("Root.Plugin", "1.0.0"); @@ -364,8 +362,7 @@ await File.WriteAllTextAsync( ["lib/net10.0/Root.Plugin.dll"] = "replacement"u8.ToArray(), })); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); await Assert.ThrowsAsync<InvalidOperationException>( () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); @@ -402,8 +399,7 @@ await File.WriteAllTextAsync( })); using var handler = new InMemoryNuGetHandler(); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); var installed = Assert.Single(await service.GetInstalledPackagesAsync()); @@ -427,9 +423,8 @@ await File.WriteAllTextAsync( JsonSerializer.Serialize(new InstalledManifest( [new InstalledPackageInfo("Legacy.Plugin", "1.0.0")]))); using var handler = new InMemoryNuGetHandler(); - using var client = new HttpClient(handler); using var service = CreateService( - client, + handler, testDirectory, hostMajorVersion: 7); @@ -453,7 +448,7 @@ await File.WriteAllTextAsync( } [Fact] - public async Task InstalledPackageFromAnotherHostMajorVersionIsMarkedIncompatible() + public async Task InstalledPackageCompatibilityFollowsValidationSetting() { var testDirectory = CreateTestDirectory(); try @@ -463,15 +458,14 @@ await File.WriteAllTextAsync( JsonSerializer.Serialize(new InstalledManifest( [new InstalledPackageInfo("Old.Plugin", "1.0.0", HostMajorVersion: 6)]))); using var handler = new InMemoryNuGetHandler(); - using var client = new HttpClient(handler); using var service = CreateService( - client, + handler, testDirectory, hostMajorVersion: 7); var package = Assert.Single(await service.GetInstalledPackagesAsync()); - Assert.False(package.IsCompatible); + Assert.Equal(PluginCompatibility.ValidationDisabled, package.IsCompatible); Assert.Equal(6, package.HostMajorVersion); } finally @@ -487,27 +481,22 @@ public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSetti try { using var handler = new InMemoryNuGetHandler(); - var metadataSource = new InMemoryNuGetMetadataSource - { - SearchResults = + handler.SearchResults = [ - new NuGetPluginSearchMetadata( + CreatePackageSearchMetadata( "Background.Plugin", "Background Plugin", null, null, null, null), - ], - }; - metadataSource.AddVersions( + ]; + handler.AddMetadataVersions( "Background.Plugin", CreatePluginVersionMetadata("1.0.0")); - using var client = new HttpClient(handler); using var service = CreateService( - client, - testDirectory, - metadataSource: metadataSource); + handler, + testDirectory); var updated = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); service.PackageInformationUpdated += (_, _) => updated.TrySetResult(); @@ -528,7 +517,7 @@ public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSetti Assert.Equal( "Background.Plugin", Assert.Single(service.PackageSnapshot.Packages).Id); - Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); } finally { @@ -548,15 +537,10 @@ await File.WriteAllTextAsync( [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), Encoding.UTF8); using var handler = new InMemoryNuGetHandler(); - using var client = new HttpClient(handler); - var metadataSource = new InMemoryNuGetMetadataSource - { - SearchException = new HttpRequestException("NuGet search failed."), - }; + handler.SearchException = new HttpRequestException("NuGet search failed."); using var service = CreateService( - client, - testDirectory, - metadataSource: metadataSource); + handler, + testDirectory); var viewModel = new PluginStoreViewModel( service, NullLogger<PluginStoreViewModel>.Instance, @@ -569,7 +553,7 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), Assert.Equal("1.2.3", package.InstalledVersion); Assert.True(package.IsInstalled); Assert.NotNull(viewModel.ErrorMessage); - Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); } finally { @@ -584,29 +568,24 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() try { using var handler = new InMemoryNuGetHandler(); - var metadataSource = new InMemoryNuGetMetadataSource - { - SearchResults = + handler.SearchResults = [ - new NuGetPluginSearchMetadata( + CreatePackageSearchMetadata( "Test.Plugin", "Test Plugin", "Test description", "WindowTranslator.Tests", null, null), - ], - }; - metadataSource.AddVersions( + ]; + handler.AddMetadataVersions( "Test.Plugin", CreatePluginVersionMetadata("1.0.0"), CreatePluginVersionMetadata("1.1.0-beta.1"), CreatePluginVersionMetadata("1.1.0-beta.2")); - using var client = new HttpClient(handler); using var service = CreateService( - client, - testDirectory, - metadataSource: metadataSource); + handler, + testDirectory); var package = Assert.Single(await service.SearchPackagesAsync()); @@ -615,7 +594,7 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() Assert.Equal( ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], package.Versions); - Assert.Equal([true], metadataSource.RequestedPrereleaseOptions); + Assert.Equal([true], handler.RequestedPrereleaseOptions); } finally { @@ -630,52 +609,51 @@ public async Task SearchKeepsOnlyVersionsWithCompatibleDirectAbstractionsDepende try { using var handler = new InMemoryNuGetHandler(); - var metadataSource = new InMemoryNuGetMetadataSource - { - SearchResults = + handler.SearchResults = [ - new NuGetPluginSearchMetadata( + CreatePackageSearchMetadata( "Compatible.Plugin", null, null, null, null, null), - new NuGetPluginSearchMetadata( + CreatePackageSearchMetadata( "Missing.Dependency.Plugin", null, null, null, null, null), - ], - }; - metadataSource.AddVersions( + ]; + handler.AddMetadataVersions( "Compatible.Plugin", CreatePluginVersionMetadata("1.0.0", "[1.0.0, 2.0.0)"), CreatePluginVersionMetadata("2.0.0", "[2.0.0, 3.0.0)")); - metadataSource.AddVersions( + handler.AddMetadataVersions( "Missing.Dependency.Plugin", CreatePluginVersionMetadata( "1.0.0", includeAbstractionsDependency: false)); - using var client = new HttpClient(handler); using var service = CreateService( - client, + handler, testDirectory, new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) { ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), - }, - metadataSource); + }); var package = Assert.Single(await service.SearchPackagesAsync()); Assert.Equal("Compatible.Plugin", package.Id); - Assert.Equal("1.0.0", package.Version); - Assert.Equal(["1.0.0"], package.Versions); - Assert.Equal(["windowtranslator-plugin"], metadataSource.RequestedTags); + Assert.Equal( + PluginCompatibility.ValidationDisabled ? "2.0.0" : "1.0.0", + package.Version); + Assert.Equal( + PluginCompatibility.ValidationDisabled ? ["1.0.0", "2.0.0"] : ["1.0.0"], + package.Versions); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); } finally { @@ -775,7 +753,7 @@ public void RestartArgumentsAreNotForwardedToTheRestartedApplication() } [Fact] - public async Task InstallRejectsPackageRequiringNewerHostAbstractions() + public async Task InstallCompatibilityFollowsValidationSetting() { var testDirectory = CreateTestDirectory(); try @@ -798,22 +776,28 @@ public async Task InstallRejectsPackageRequiringNewerHostAbstractions() ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), })); - using var client = new HttpClient(handler); using var service = CreateService( - client, + handler, testDirectory, new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) { ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), }); - var exception = await Assert.ThrowsAsync<InvalidOperationException>( - () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); - - Assert.Contains("WindowTranslator.Abstractions", exception.Message); - Assert.Contains("[2.0.0, 3.0.0)", exception.Message); - Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(await service.GetInstalledPackagesAsync()); + if (PluginCompatibility.ValidationDisabled) + { + await service.InstallPackageAsync("Root.Plugin", "2.0.0"); + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + } + else + { + var exception = await Assert.ThrowsAsync<InvalidOperationException>( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + Assert.Contains("WindowTranslator.Abstractions", exception.Message); + Assert.Contains("[2.0.0, 3.0.0)", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(await service.GetInstalledPackagesAsync()); + } } finally { @@ -844,9 +828,8 @@ public async Task InstallAcceptsCompatibleHostAbstractionsWithoutDownloadingIt() ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), })); - using var client = new HttpClient(handler); using var service = CreateService( - client, + handler, testDirectory, new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase) { @@ -888,8 +871,7 @@ public async Task InstallRejectsPackageWithoutDirectAbstractionsDependency() }, includeAbstractionsDependency: false)); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); var exception = await Assert.ThrowsAsync<InvalidOperationException>( () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); @@ -924,8 +906,7 @@ public async Task InstallRejectsPackageWithoutPluginTag() }, includePluginTag: false)); - using var client = new HttpClient(handler); - using var service = CreateService(client, testDirectory); + using var service = CreateService(handler, testDirectory); var exception = await Assert.ThrowsAsync<InvalidOperationException>( () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); @@ -972,20 +953,17 @@ public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() ["README.md"] = "# Preview README"u8.ToArray(), })); - var metadataSource = new InMemoryNuGetMetadataSource(); - metadataSource.AddReadmeUrl( + handler.AddReadmeUrl( "Readme.Plugin", "1.0.0", "https://nuget.test/readme/readme.plugin/1.0.0"); - metadataSource.AddReadmeUrl( + handler.AddReadmeUrl( "Readme.Plugin", "2.0.0-preview.1", "https://nuget.test/readme/readme.plugin/2.0.0-preview.1"); - using var client = new HttpClient(handler); using var service = CreateService( - client, - testDirectory, - metadataSource: metadataSource); + handler, + testDirectory); var viewModel = new PluginStoreViewModel( service, NullLogger<PluginStoreViewModel>.Instance, @@ -1154,7 +1132,7 @@ public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() } [Fact] - public void CatalogSynchronizationExcludesPackagesFromAnotherHostMajorVersion() + public void CatalogSynchronizationFollowsCompatibilityValidationSetting() { var sourceDirectory = CreateTestDirectory(); var destinationDirectory = CreateTestDirectory(); @@ -1190,10 +1168,12 @@ public void CatalogSynchronizationExcludesPackagesFromAnotherHostMajorVersion() destinationDirectory, "Compatible.Plugin", "Compatible.Plugin.dll"))); - Assert.False(Directory.Exists(Path.Combine( - destinationDirectory, - "Incompatible.Plugin"))); - Assert.Equal(["Incompatible.Plugin"], incompatiblePackages); + Assert.Equal( + PluginCompatibility.ValidationDisabled, + Directory.Exists(Path.Combine(destinationDirectory, "Incompatible.Plugin"))); + Assert.Equal( + PluginCompatibility.ValidationDisabled ? [] : ["Incompatible.Plugin"], + incompatiblePackages); } finally { @@ -1373,20 +1353,19 @@ public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() } private static NuGetPluginService CreateService( - HttpClient client, + InMemoryNuGetHandler handler, string pluginDirectory, IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, - INuGetPluginMetadataSource? metadataSource = null, int? hostMajorVersion = null) => new( NullLogger<NuGetPluginService>.Instance, - client, + new InMemoryHttpClientFactory(handler), + handler.CreateRepository(), pluginDirectory, - hostPackageVersions: hostPackageVersions, - metadataSource: metadataSource ?? new InMemoryNuGetMetadataSource(), - hostMajorVersion: hostMajorVersion); + hostPackageVersions ?? NuGetPluginService.CreateHostPackageVersions(), + hostMajorVersion ?? AppInfo.Instance.Version.Major); - private static NuGetPluginVersionMetadata CreatePluginVersionMetadata( + private static TestPackageVersion CreatePluginVersionMetadata( string version, string? abstractionsRange = null, bool includeAbstractionsDependency = true) @@ -1407,6 +1386,32 @@ abstractionsRange is null : []), ]); + private static IPackageSearchMetadata CreatePackageSearchMetadata( + string packageId, + string? title, + string? description, + string? authors, + string? projectUrl, + string? licenseUrl, + NuGetVersion? version = null, + IEnumerable<PackageDependencyGroup>? dependencySets = null, + bool isListed = true, + string? readmeFileUrl = null) + => new TestPackageSearchMetadata + { + Identity = new PackageIdentity( + packageId, + version ?? NuGetVersion.Parse("0.0.0")), + Title = title!, + Description = description!, + Authors = authors!, + ProjectUrl = projectUrl is null ? null! : new Uri(projectUrl), + LicenseUrl = licenseUrl is null ? null! : new Uri(licenseUrl), + DependencySets = dependencySets ?? [], + IsListed = isListed, + ReadmeFileUrl = readmeFileUrl!, + }; + private static async Task WaitForReadmeAsync( PluginPackageViewModel package, string expectedReadme) @@ -1542,73 +1547,90 @@ private static void DeleteTestDirectory(string path) private sealed record TestDependency(string Id, string Version, string? Exclude = null); - private sealed class InMemoryNuGetMetadataSource : INuGetPluginMetadataSource + private sealed record TestPackageVersion( + NuGetVersion Version, + bool IsListed, + IReadOnlyList<PackageDependencyGroup> DependencyGroups); + + private sealed class TestPackageSearchMetadata : IPackageSearchMetadata + { + public string Authors { get; init; } = null!; + public IEnumerable<PackageDependencyGroup> DependencySets { get; init; } = []; + public string Description { get; init; } = null!; + public long? DownloadCount { get; init; } + public Uri IconUrl { get; init; } = null!; + public PackageIdentity Identity { get; init; } = null!; + public Uri LicenseUrl { get; init; } = null!; + public Uri ProjectUrl { get; init; } = null!; + public Uri ReadmeUrl { get; init; } = null!; + public string ReadmeFileUrl { get; init; } = null!; + public Uri ReportAbuseUrl { get; init; } = null!; + public Uri PackageDetailsUrl { get; init; } = null!; + public DateTimeOffset? Published { get; init; } + public IReadOnlyList<string> OwnersList { get; init; } = []; + public string Owners { get; init; } = null!; + public bool RequireLicenseAcceptance { get; init; } + public string Summary { get; init; } = null!; + public string Tags { get; init; } = null!; + public string Title { get; init; } = null!; + public bool IsListed { get; init; } + public bool PrefixReserved { get; init; } + public LicenseMetadata LicenseMetadata { get; init; } = null!; + public IEnumerable<PackageVulnerabilityMetadata> Vulnerabilities { get; init; } = []; + + public Task<PackageDeprecationMetadata?> GetDeprecationMetadataAsync() + => Task.FromResult<PackageDeprecationMetadata?>(null); + + public Task<IEnumerable<VersionInfo>> GetVersionsAsync() + => Task.FromResult<IEnumerable<VersionInfo>>([]); + } + + private sealed class InMemoryHttpClientFactory(InMemoryNuGetHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private sealed class InMemoryNuGetHandler : HttpMessageHandler { - private readonly Dictionary<string, IReadOnlyList<NuGetPluginVersionMetadata>> versions = + private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); + private readonly Dictionary<string, IReadOnlyList<TestPackageVersion>> metadataVersions = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> readmeUrls = new(StringComparer.OrdinalIgnoreCase); - public IReadOnlyList<NuGetPluginSearchMetadata> SearchResults { get; init; } = []; + public IReadOnlyList<IPackageSearchMetadata> SearchResults { get; set; } = []; - public Exception? SearchException { get; init; } + public Exception? SearchException { get; set; } - public List<string> RequestedTags { get; } = []; + public List<string> RequestedSearchTerms { get; } = []; public List<bool> RequestedPrereleaseOptions { get; } = []; - public void AddVersions(string packageId, params NuGetPluginVersionMetadata[] packageVersions) - => this.versions[packageId] = packageVersions; + public List<string> RequestedPaths { get; } = []; - public void AddReadmeUrl(string packageId, string version, string url) - => this.readmeUrls[GetReadmeKey(packageId, NuGetVersion.Parse(version))] = url; + public void AddPackage(string id, string version, byte[] package) + => this.packages[(NormalizeId(id), NormalizeVersion(version))] = package; - public Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( - string tag, - bool includePrerelease, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - this.RequestedTags.Add(tag); - this.RequestedPrereleaseOptions.Add(includePrerelease); - return this.SearchException is null - ? Task.FromResult(this.SearchResults) - : Task.FromException<IReadOnlyList<NuGetPluginSearchMetadata>>(this.SearchException); - } + public void AddMetadataVersions(string packageId, params TestPackageVersion[] packageVersions) + => this.metadataVersions[packageId] = packageVersions; - public Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( - string packageId, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult( - this.versions.TryGetValue(packageId, out var packageVersions) - ? packageVersions - : (IReadOnlyList<NuGetPluginVersionMetadata>)[]); - } + public void AddReadmeUrl(string packageId, string version, string url) + => this.readmeUrls[GetReadmeKey(packageId, NuGetVersion.Parse(version))] = url; - public Task<string?> GetReadmeUrlAsync( - string packageId, - NuGetVersion version, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - this.readmeUrls.TryGetValue(GetReadmeKey(packageId, version), out var readmeUrl); - return Task.FromResult(readmeUrl); - } + public SourceRepository CreateRepository() + => new( + new PackageSource("https://nuget.test/v3/index.json"), + [ + new InMemoryResourceProvider<PackageSearchResource>( + new InMemoryPackageSearchResource(this)), + new InMemoryResourceProvider<PackageMetadataResource>( + new InMemoryPackageMetadataResource(this)), + new InMemoryResourceProvider<FindPackageByIdResource>( + new InMemoryFindPackageByIdResource(this)), + ]); private static string GetReadmeKey(string packageId, NuGetVersion version) => $"{packageId}\n{version.ToNormalizedString()}"; - } - - private sealed class InMemoryNuGetHandler : HttpMessageHandler - { - private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); - - public List<string> RequestedPaths { get; } = []; - - public void AddPackage(string id, string version, byte[] package) - => this.packages[(id.ToLowerInvariant(), version.ToLowerInvariant())] = package; protected override Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, @@ -1624,46 +1646,13 @@ protected override Task<HttpResponseMessage> SendAsync( return Task.FromResult(CreateReadmeResponse(segments[1], segments[2])); } - if (segments.Length == 3 - && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase) - && segments[2].Equals("index.json", StringComparison.OrdinalIgnoreCase)) - { - var id = segments[1].ToLowerInvariant(); - var versions = this.packages.Keys - .Where(key => key.Id == id) - .Select(key => key.Version) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) - .ToArray(); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - JsonSerializer.Serialize(new { versions }), - Encoding.UTF8, - "application/json"), - }); - } - - if (segments.Length == 4 - && segments[0].Equals("v3-flatcontainer", StringComparison.OrdinalIgnoreCase)) - { - var key = (segments[1].ToLowerInvariant(), segments[2].ToLowerInvariant()); - if (this.packages.TryGetValue(key, out var package)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(package), - }); - } - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } private HttpResponseMessage CreateReadmeResponse(string packageId, string version) { if (!this.packages.TryGetValue( - (packageId.ToLowerInvariant(), version.ToLowerInvariant()), + (NormalizeId(packageId), NormalizeVersion(version)), out var package)) { return new HttpResponseMessage(HttpStatusCode.NotFound); @@ -1685,6 +1674,157 @@ private HttpResponseMessage CreateReadmeResponse(string packageId, string versio }; } + private static string NormalizeId(string packageId) => packageId.ToLowerInvariant(); + + private static string NormalizeVersion(string version) + => NuGetVersion.Parse(version).ToNormalizedString().ToLowerInvariant(); + + private sealed class InMemoryPackageSearchResource(InMemoryNuGetHandler source) + : PackageSearchResource + { + public override Task<IEnumerable<IPackageSearchMetadata>> SearchAsync( + string searchTerm, + SearchFilter filters, + int skip, + int take, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + source.RequestedSearchTerms.Add(searchTerm); + source.RequestedPrereleaseOptions.Add(filters.IncludePrerelease); + return source.SearchException is null + ? Task.FromResult(source.SearchResults.Skip(skip).Take(take).AsEnumerable()) + : Task.FromException<IEnumerable<IPackageSearchMetadata>>(source.SearchException); + } + } + + private sealed class InMemoryPackageMetadataResource(InMemoryNuGetHandler source) + : PackageMetadataResource + { + public override Task<IEnumerable<IPackageSearchMetadata>> GetMetadataAsync( + string packageId, + bool includePrerelease, + bool includeUnlisted, + SourceCacheContext sourceCacheContext, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var versions = source.metadataVersions.TryGetValue(packageId, out var packageVersions) + ? packageVersions + : []; + var metadata = versions + .Where(version => includePrerelease || !version.Version.IsPrerelease) + .Where(version => includeUnlisted || version.IsListed) + .Select(version => CreatePackageSearchMetadata( + packageId, + title: null, + description: null, + authors: null, + projectUrl: null, + licenseUrl: null, + version.Version, + version.DependencyGroups, + version.IsListed)); + return Task.FromResult(metadata); + } + + public override Task<IPackageSearchMetadata> GetMetadataAsync( + PackageIdentity identity, + SourceCacheContext sourceCacheContext, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + source.readmeUrls.TryGetValue(GetReadmeKey(identity.Id, identity.Version), out var readmeUrl); + return Task.FromResult(CreatePackageSearchMetadata( + identity.Id, + title: null, + description: null, + authors: null, + projectUrl: null, + licenseUrl: null, + identity.Version, + readmeFileUrl: readmeUrl)); + } + } + + private sealed class InMemoryFindPackageByIdResource(InMemoryNuGetHandler source) + : FindPackageByIdResource + { + public override Task<IEnumerable<NuGetVersion>> GetAllVersionsAsync( + string id, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var normalizedId = NormalizeId(id); + source.RequestedPaths.Add($"/v3-flatcontainer/{normalizedId}/index.json"); + return Task.FromResult(source.packages.Keys + .Where(key => key.Id == normalizedId) + .Select(key => NuGetVersion.Parse(key.Version)) + .OrderBy(version => version) + .AsEnumerable()); + } + + public override async Task<bool> CopyNupkgToStreamAsync( + string id, + NuGetVersion version, + Stream destination, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var normalizedId = NormalizeId(id); + var normalizedVersion = NormalizeVersion(version.ToNormalizedString()); + source.RequestedPaths.Add( + $"/v3-flatcontainer/{normalizedId}/{normalizedVersion}/{normalizedId}.{normalizedVersion}.nupkg"); + if (!source.packages.TryGetValue((normalizedId, normalizedVersion), out var package)) + { + return false; + } + + await destination.WriteAsync(package, token); + return true; + } + + public override Task<FindPackageByIdDependencyInfo> GetDependencyInfoAsync( + string id, + NuGetVersion version, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => throw new NotSupportedException(); + + public override Task<IPackageDownloader> GetPackageDownloaderAsync( + PackageIdentity packageIdentity, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => throw new NotSupportedException(); + + public override Task<bool> DoesPackageExistAsync( + string id, + NuGetVersion version, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => Task.FromResult(source.packages.ContainsKey( + (NormalizeId(id), NormalizeVersion(version.ToNormalizedString())))); + } + } + + private sealed class InMemoryResourceProvider<TResource>(TResource resource) + : ResourceProvider(typeof(TResource)) + where TResource : class, INuGetResource + { + public override Task<Tuple<bool, INuGetResource?>> TryCreate( + SourceRepository source, + CancellationToken token) + => Task.FromResult(Tuple.Create<bool, INuGetResource?>(true, resource)); } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 3eda3e27..0b1865c5 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -1,15 +1,13 @@ using System.IO; using System.IO.Compression; -using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; using System.Runtime.Versioning; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using NuGet.Frameworks; using NuGet.Packaging; using NuGet.Packaging.Core; +using NuGet.Protocol.Core.Types; using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; @@ -18,22 +16,19 @@ namespace WindowTranslator.Modules.PluginStore; /// NuGetパッケージとそのランタイム依存関係を、プラグインフォルダへ展開します。 /// </summary> internal sealed class NuGetPackageInstaller( - HttpClient httpClient, + FindPackageByIdResource packageResource, ILogger logger, - IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null) + IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions) { - private const string FlatContainerBase = "https://api.nuget.org/v3-flatcontainer"; - private static readonly NuGetFramework HostFramework = GetHostFramework(); private static readonly FrameworkReducer FrameworkReducer = new(); private static readonly string[] CompatibleRuntimeIdentifiers = [RuntimeInformation.RuntimeIdentifier, "win", "any"]; - private readonly HttpClient httpClient = httpClient; + private readonly FindPackageByIdResource packageResource = packageResource; private readonly ILogger logger = logger; - private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions = - hostPackageVersions ?? new Dictionary<string, NuGetVersion>(StringComparer.OrdinalIgnoreCase); + private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions = hostPackageVersions; public async Task InstallAsync( string packageId, @@ -151,6 +146,7 @@ private async Task<IReadOnlyCollection<PackageArtifact>> ResolvePackageGraphAsyn var artifacts = new Dictionary<string, PackageArtifact>(StringComparer.OrdinalIgnoreCase); var queue = new Queue<string>(); var queued = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + using var cacheContext = new SourceCacheContext(); AddConstraint( rootPackageId, @@ -181,7 +177,11 @@ private async Task<IReadOnlyCollection<PackageArtifact>> ResolvePackageGraphAsyn var ranges = currentConstraints.Select(c => c.Range).ToArray(); var resolvedVersion = currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? rootVersion - : await ResolveDependencyVersionAsync(currentId, ranges, cancellationToken).ConfigureAwait(false); + : await ResolveDependencyVersionAsync( + currentId, + ranges, + cacheContext, + cancellationToken).ConfigureAwait(false); if (!ranges.All(r => r.Satisfies(resolvedVersion))) { @@ -208,6 +208,7 @@ await DownloadPackageAsync( resolvedVersion, packagePath, currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? progress : null, + cacheContext, cancellationToken).ConfigureAwait(false); ValidateHostPackageDependencies( packagePath, @@ -274,21 +275,18 @@ void Enqueue(string id) private async Task<NuGetVersion> ResolveDependencyVersionAsync( string packageId, IReadOnlyCollection<VersionRange> ranges, + SourceCacheContext cacheContext, CancellationToken cancellationToken) { - var versionsUrl = $"{FlatContainerBase}/{packageId.ToLowerInvariant()}/index.json"; - using var response = await this.httpClient.GetAsync(versionsUrl, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - await using var content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var versionList = await JsonSerializer.DeserializeAsync<VersionIndex>( - content, - cancellationToken: cancellationToken).ConfigureAwait(false); - - var compatibleVersions = versionList?.Versions? - .Select(NuGetVersion.Parse) + var versions = await this.packageResource.GetAllVersionsAsync( + packageId, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + var compatibleVersions = versions .Where(v => ranges.All(r => r.Satisfies(v))) .OrderBy(v => v) - .ToArray() ?? []; + .ToArray(); return compatibleVersions.FirstOrDefault(v => !v.IsPrerelease) ?? compatibleVersions.FirstOrDefault() ?? throw new InvalidOperationException( @@ -300,34 +298,24 @@ private async Task DownloadPackageAsync( NuGetVersion version, string destinationPath, IProgress<double>? progress, + SourceCacheContext cacheContext, CancellationToken cancellationToken) { - var packageIdLower = packageId.ToLowerInvariant(); - var versionLower = version.ToNormalizedString().ToLowerInvariant(); - var url = $"{FlatContainerBase}/{packageIdLower}/{versionLower}/{packageIdLower}.{versionLower}.nupkg"; this.logger.LogInformation("NuGetパッケージをダウンロード中: {PackageId} {Version}", packageId, version); - - using var response = await this.httpClient.GetAsync( - url, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1; - var downloadedBytes = 0L; - await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + progress?.Report(0); await using var destination = File.Create(destinationPath); - var buffer = new byte[81920]; - int bytesRead; - while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + var copied = await this.packageResource.CopyNupkgToStreamAsync( + packageId, + version, + destination, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + if (!copied) { - await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); - downloadedBytes += bytesRead; - if (totalBytes > 0) - { - progress?.Report((double)downloadedBytes / totalBytes); - } + throw new InvalidOperationException($"NuGetパッケージを取得できませんでした: {packageId} {version}"); } + progress?.Report(1); } private static List<PackageDependency> ReadRuntimeDependencies(string packagePath) @@ -381,7 +369,8 @@ private static void ValidateHostPackageDependencies( $"パッケージ {packageId} {packageVersion} はWindowTranslatorプラグインタグを持っていません。"); } - if (!hostPackageVersions.ContainsKey(NuGetPluginService.AbstractionsPackageId)) + if (!PluginCompatibility.ValidationDisabled + && !hostPackageVersions.ContainsKey(NuGetPluginService.AbstractionsPackageId)) { throw new InvalidOperationException( $"実行中の{NuGetPluginService.AbstractionsPackageId}のバージョンを確認できません。"); @@ -400,7 +389,7 @@ private static void ValidateHostPackageDependencies( foreach (var dependency in dependencies) { if (!hostPackageVersions.TryGetValue(dependency.Id, out var hostVersion) - || dependency.VersionRange.Satisfies(hostVersion)) + || PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion)) { continue; } @@ -628,6 +617,4 @@ private sealed record PackageArtifact(string Id, NuGetVersion Version, string Pa private sealed record DependencyConstraint(string Source, VersionRange Range); - private sealed record VersionIndex( - [property: JsonPropertyName("versions")] string[]? Versions); } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 34636209..caad75ae 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -407,8 +407,9 @@ internal static IReadOnlySet<string> GetIncompatiblePackageIds( stream, NuGetPluginService.ManifestJsonOptions); return manifest?.Packages - .Where(package => package.HostMajorVersion is not null - && package.HostMajorVersion != hostMajorVersion) + .Where(package => !PluginCompatibility.IsHostMajorCompatible( + package.HostMajorVersion, + hostMajorVersion)) .Select(package => package.Id) .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index c3aa7de6..8e3bd218 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -6,19 +6,24 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Threading; using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.Protocol.Core.Types; using NuGet.Versioning; namespace WindowTranslator.Modules.PluginStore; /// <summary> -/// NuGet V3 REST APIを使用してプラグインパッケージの検索・インストール・管理を行うサービスです。 +/// NuGetクライアントSDKを使用してプラグインパッケージの検索・インストール・管理を行うサービスです。 /// </summary> public sealed class NuGetPluginService : BackgroundService { - private const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; + internal const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; + internal const string HttpClientName = "NuGetPluginReadme"; internal const string PluginTag = "windowtranslator-plugin"; internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; + private const int SearchResultLimit = 100; private const int MaxConcurrentMetadataRequests = 8; private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); @@ -30,58 +35,34 @@ public sealed class NuGetPluginService : BackgroundService DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - private readonly HttpClient httpClient; + private readonly IHttpClientFactory httpClientFactory; + private readonly SourceRepository repository; private readonly ILogger<NuGetPluginService> logger; private readonly string userPluginsDir; private readonly string manifestPath; - private readonly bool ownsHttpClient; private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions; - private readonly INuGetPluginMetadataSource metadataSource; - private readonly App? app; private readonly int hostMajorVersion; - private readonly SemaphoreSlim operationLock = new(1, 1); - private readonly SemaphoreSlim refreshLock = new(1, 1); + private readonly AsyncSemaphore operationLock = new(1); + private readonly AsyncSemaphore refreshLock = new(1); private readonly object snapshotLock = new(); private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; private long installedPackagesGeneration; - private int disposeState; - - public NuGetPluginService(ILogger<NuGetPluginService> logger, App app) - : this( - logger, - new HttpClient(new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.All, - }) - { - Timeout = TimeSpan.FromSeconds(30), - }, - Path.Combine(PathUtility.UserDir, "plugins"), - ownsHttpClient: true, - app: app) - { - } internal NuGetPluginService( ILogger<NuGetPluginService> logger, - HttpClient httpClient, + IHttpClientFactory httpClientFactory, + SourceRepository repository, string userPluginsDir, - bool ownsHttpClient = false, - IReadOnlyDictionary<string, NuGetVersion>? hostPackageVersions = null, - INuGetPluginMetadataSource? metadataSource = null, - App? app = null, - int? hostMajorVersion = null) + IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions, + int hostMajorVersion) { this.logger = logger; - this.httpClient = httpClient; + this.httpClientFactory = httpClientFactory; + this.repository = repository; this.userPluginsDir = Path.GetFullPath(userPluginsDir); this.manifestPath = Path.Combine(this.userPluginsDir, "nuget-manifest.json"); - this.ownsHttpClient = ownsHttpClient; - this.hostPackageVersions = hostPackageVersions ?? CreateHostPackageVersions(); - this.metadataSource = metadataSource - ?? new NuGetProtocolPluginMetadataSource(NuGetServiceIndexUrl); - this.app = app; - this.hostMajorVersion = hostMajorVersion ?? AppInfo.Instance.Version.Major; + this.hostPackageVersions = hostPackageVersions; + this.hostMajorVersion = hostMajorVersion; } internal event EventHandler? PackageInformationUpdated; @@ -99,11 +80,6 @@ internal PluginStoreSnapshot PackageSnapshot protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - if (this.app is not null) - { - await this.app.WaitForStartupAsync().ConfigureAwait(false); - } - while (!stoppingToken.IsCancellationRequested) { await RefreshPackageInformationAsync(stoppingToken).ConfigureAwait(false); @@ -113,52 +89,45 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) internal async Task RefreshPackageInformationAsync(CancellationToken cancellationToken = default) { - await this.refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); + using var refresh = await this.refreshLock.EnterAsync(cancellationToken); + var previousSnapshot = this.PackageSnapshot; + var packages = previousSnapshot.Packages; + Exception? error = null; try { - var previousSnapshot = this.PackageSnapshot; - var packages = previousSnapshot.Packages; - Exception? error = null; - try - { - packages = await SearchPackagesAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogWarning(ex, "NuGetからプラグイン情報を更新できませんでした。"); - error = ex; - } - - var installedGenerationBefore = Volatile.Read(ref this.installedPackagesGeneration); - IReadOnlyList<InstalledPackageInfo> installedPackages; - try - { - installedPackages = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - this.logger.LogWarning(ex, "インストール済みプラグイン情報を更新できませんでした。"); - installedPackages = this.PackageSnapshot.InstalledPackages; - error = ex; - } + packages = await SearchPackagesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "NuGetからプラグイン情報を更新できませんでした。"); + error = ex; + } - var installedGenerationAfter = Volatile.Read(ref this.installedPackagesGeneration); - if (installedGenerationBefore != installedGenerationAfter) - { - installedPackages = this.PackageSnapshot.InstalledPackages; - } - SetPackageSnapshot( - new( - IsInitialized: true, - InstalledPackages: installedPackages, - Packages: packages, - Error: error), - installedGenerationAfter); + var installedGenerationBefore = Volatile.Read(ref this.installedPackagesGeneration); + IReadOnlyList<InstalledPackageInfo> installedPackages; + try + { + installedPackages = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); } - finally + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "インストール済みプラグイン情報を更新できませんでした。"); + installedPackages = this.PackageSnapshot.InstalledPackages; + error = ex; + } + + var installedGenerationAfter = Volatile.Read(ref this.installedPackagesGeneration); + if (installedGenerationBefore != installedGenerationAfter) { - this.refreshLock.Release(); + installedPackages = this.PackageSnapshot.InstalledPackages; } + SetPackageSnapshot( + new( + IsInitialized: true, + InstalledPackages: installedPackages, + Packages: packages, + Error: error), + installedGenerationAfter); } /// <summary> @@ -166,19 +135,32 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio /// </summary> public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(CancellationToken cancellationToken = default) { - var searchResults = await this.metadataSource - .SearchAsync(PluginTag, includePrerelease: true, cancellationToken) + var searchResource = await this.repository + .GetResourceAsync<PackageSearchResource>(cancellationToken) .ConfigureAwait(false); - this.logger.LogInformation("NuGetタグ検索完了: {Count}件の候補が見つかりました。", searchResults.Count); + var metadataResource = await this.repository + .GetResourceAsync<PackageMetadataResource>(cancellationToken) + .ConfigureAwait(false); + var searchResults = (await searchResource.SearchAsync( + $"tags:{PluginTag}", + new(includePrerelease: true) { IncludeDelisted = false }, + skip: 0, + take: SearchResultLimit, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false)) + .Where(metadata => !string.IsNullOrWhiteSpace(metadata.Identity?.Id)) + .ToArray(); + this.logger.LogInformation("NuGetタグ検索完了: {Count}件の候補が見つかりました。", searchResults.Length); - using var requestGate = new SemaphoreSlim(MaxConcurrentMetadataRequests); + var requestGate = new AsyncSemaphore(MaxConcurrentMetadataRequests); var packageTasks = searchResults.Select(async data => { - await requestGate.WaitAsync(cancellationToken).ConfigureAwait(false); + using var request = await requestGate.EnterAsync(cancellationToken); try { return await CreateCompatiblePackageInfoAsync( data, + metadataResource, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -190,13 +172,9 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat this.logger.LogWarning( ex, "NuGetパッケージのプラグイン互換性を確認できなかったため除外します: {PackageId}", - data.Id); + data.Identity.Id); return null; } - finally - { - requestGate.Release(); - } }); var packages = await Task.WhenAll(packageTasks).ConfigureAwait(false); var compatiblePackages = packages.Where(package => package is not null).Select(package => package!).ToArray(); @@ -224,14 +202,23 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat throw new ArgumentException($"不正なNuGetパッケージバージョンです: {version}", nameof(version)); } - var readmeUrl = await this.metadataSource - .GetReadmeUrlAsync(packageId, packageVersion, cancellationToken) + var metadataResource = await this.repository + .GetResourceAsync<PackageMetadataResource>(cancellationToken) .ConfigureAwait(false); + using var cacheContext = new SourceCacheContext(); + var metadata = await metadataResource.GetMetadataAsync( + new PackageIdentity(packageId, packageVersion), + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + var readmeUrl = metadata?.ReadmeFileUrl; if (string.IsNullOrWhiteSpace(readmeUrl)) { return null; } - using var response = await this.httpClient.GetAsync(readmeUrl, cancellationToken).ConfigureAwait(false); + + using var httpClient = this.httpClientFactory.CreateClient(HttpClientName); + using var response = await httpClient.GetAsync(readmeUrl, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.NotFound) { return null; @@ -252,12 +239,15 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres var backupDir = $"{targetDir}.backup-{operationId}"; var targetMoved = false; var stagingMoved = false; - await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + using var operation = await this.operationLock.EnterAsync(cancellationToken); try { Directory.CreateDirectory(this.userPluginsDir); + var packageResource = await this.repository + .GetResourceAsync<FindPackageByIdResource>(cancellationToken) + .ConfigureAwait(false); var installer = new NuGetPackageInstaller( - this.httpClient, + packageResource, this.logger, this.hostPackageVersions); await installer.InstallAsync( @@ -324,7 +314,6 @@ await installer.InstallAsync( finally { TryDeleteDirectory(stagingDir); - this.operationLock.Release(); } } @@ -338,70 +327,63 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc var targetDir = GetPackageDirectory(packageId); var uninstallingDir = $"{targetDir}.uninstalling-{operationId}"; var targetMoved = false; - await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); - Directory.CreateDirectory(this.userPluginsDir); + using var operation = await this.operationLock.EnterAsync(cancellationToken); + this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + Directory.CreateDirectory(this.userPluginsDir); - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var updatedManifest = RemovePackage(manifest, packageId); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = RemovePackage(manifest, packageId); - if (Directory.Exists(targetDir)) - { - Directory.Move(targetDir, uninstallingDir); - targetMoved = true; - } + if (Directory.Exists(targetDir)) + { + Directory.Move(targetDir, uninstallingDir); + targetMoved = true; + } + try + { + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + UpdateInstalledPackages(updatedManifest.Packages); + } + catch + { try { - await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); - UpdateInstalledPackages(updatedManifest.Packages); - } - catch - { - try - { - if (targetMoved - && Directory.Exists(uninstallingDir) - && !Directory.Exists(targetDir)) - { - Directory.Move(uninstallingDir, targetDir); - } - } - catch (Exception rollbackException) + if (targetMoved + && Directory.Exists(uninstallingDir) + && !Directory.Exists(targetDir)) { - this.logger.LogError( - rollbackException, - "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", - packageId); + Directory.Move(uninstallingDir, targetDir); } - throw; } - - try + catch (Exception rollbackException) { - if (Directory.Exists(uninstallingDir)) - { - Directory.Delete(uninstallingDir, recursive: true); - } + this.logger.LogError( + rollbackException, + "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", + packageId); } - catch (Exception ex) + throw; + } + + try + { + if (Directory.Exists(uninstallingDir)) { - this.logger.LogWarning( - ex, - "アンインストール済みプラグインフォルダの削除に失敗しました: {Directory}", - uninstallingDir); + Directory.Delete(uninstallingDir, recursive: true); } - - this.logger.LogInformation( - "パッケージ {PackageId} を管理フォルダからアンインストールしました。再起動後に反映されます。", - packageId); } - finally + catch (Exception ex) { - this.operationLock.Release(); + this.logger.LogWarning( + ex, + "アンインストール済みプラグインフォルダの削除に失敗しました: {Directory}", + uninstallingDir); } + + this.logger.LogInformation( + "パッケージ {PackageId} を管理フォルダからアンインストールしました。再起動後に反映されます。", + packageId); } /// <summary> @@ -409,73 +391,69 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc /// </summary> public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync(CancellationToken cancellationToken = default) { - await this.operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using var operation = await this.operationLock.EnterAsync(cancellationToken); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var migratedPackages = manifest.Packages + .Select(package => package.HostMajorVersion is null + ? package with { HostMajorVersion = this.hostMajorVersion } + : package) + .ToList(); + if (migratedPackages.Where((package, index) => + package != manifest.Packages[index]).Any()) { - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var migratedPackages = manifest.Packages - .Select(package => package.HostMajorVersion is null - ? package with { HostMajorVersion = this.hostMajorVersion } - : package) - .ToList(); - if (migratedPackages.Where((package, index) => - package != manifest.Packages[index]).Any()) - { - manifest = new InstalledManifest(migratedPackages); - await SaveManifestAsync(manifest, cancellationToken).ConfigureAwait(false); - } - - return GetCompatibilityAwarePackages(manifest.Packages); - } - finally - { - this.operationLock.Release(); + manifest = new InstalledManifest(migratedPackages); + await SaveManifestAsync(manifest, cancellationToken).ConfigureAwait(false); } + + return GetCompatibilityAwarePackages(manifest.Packages); } private async Task<NuGetPackageInfo?> CreateCompatiblePackageInfoAsync( - NuGetPluginSearchMetadata data, + IPackageSearchMetadata data, + PackageMetadataResource metadataResource, CancellationToken cancellationToken) { - var versions = await this.metadataSource - .GetPackageVersionsAsync(data.Id, cancellationToken) - .ConfigureAwait(false); + var packageId = data.Identity.Id; + using var cacheContext = new SourceCacheContext(); + var versions = await metadataResource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); var compatibleVersions = versions - .Where(version => version.IsListed - && HasCompatibleAbstractionsDependency(version.DependencyGroups)) - .OrderBy(version => version.Version) + .Where(version => version.Identity?.Version is not null + && version.IsListed + && HasCompatibleAbstractionsDependency(version.DependencySets)) + .OrderBy(version => version.Identity.Version) .ToArray(); if (compatibleVersions.Length == 0) { this.logger.LogDebug( "WindowTranslator.Abstractionsへの互換依存がないため除外します: {PackageId}", - data.Id); + packageId); return null; } - var latestVersion = compatibleVersions[^1].Version.ToNormalizedString(); + var latestVersion = compatibleVersions[^1].Identity.Version.ToNormalizedString(); return new NuGetPackageInfo( - Id: data.Id, + Id: packageId, Version: latestVersion, - Title: data.Title ?? data.Id, + Title: data.Title ?? packageId, Description: data.Description ?? string.Empty, Authors: data.Authors ?? string.Empty, - ProjectUrl: data.ProjectUrl, - LicenseUrl: data.LicenseUrl, + ProjectUrl: data.ProjectUrl?.AbsoluteUri, + LicenseUrl: data.LicenseUrl?.AbsoluteUri, Versions: compatibleVersions - .Select(version => version.Version.ToNormalizedString()) + .Select(version => version.Identity.Version.ToNormalizedString()) .ToArray()); } private bool HasCompatibleAbstractionsDependency( - IReadOnlyList<PackageDependencyGroup> dependencyGroups) + IEnumerable<PackageDependencyGroup>? dependencyGroups) { - if (!this.hostPackageVersions.TryGetValue(AbstractionsPackageId, out var hostVersion)) - { - return false; - } - - var dependencyGroup = NuGetPackageInstaller.SelectBestDependencyGroup(dependencyGroups); + var dependencyGroup = NuGetPackageInstaller.SelectBestDependencyGroup(dependencyGroups ?? []); var dependency = dependencyGroup?.Packages.FirstOrDefault(item => item.Id.Equals(AbstractionsPackageId, StringComparison.OrdinalIgnoreCase)); if (dependency is null) @@ -483,7 +461,8 @@ private bool HasCompatibleAbstractionsDependency( return false; } - return dependency.VersionRange?.Satisfies(hostVersion) is not false; + this.hostPackageVersions.TryGetValue(AbstractionsPackageId, out var hostVersion); + return PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion); } private InstalledManifest AddOrUpdatePackage( @@ -509,7 +488,7 @@ private InstalledManifest AddOrUpdatePackage( return new InstalledManifest([.. packages]); } - private static Dictionary<string, NuGetVersion> CreateHostPackageVersions() + internal static IReadOnlyDictionary<string, NuGetVersion> CreateHostPackageVersions() { var abstractionsAssembly = typeof(UserSettings).Assembly; var informationalVersion = abstractionsAssembly @@ -542,8 +521,9 @@ private InstalledPackageInfo[] GetCompatibilityAwarePackages( => packages .Select(package => package with { - IsCompatible = package.HostMajorVersion is null - || package.HostMajorVersion == this.hostMajorVersion, + IsCompatible = PluginCompatibility.IsHostMajorCompatible( + package.HostMajorVersion, + this.hostMajorVersion), }) .ToArray(); @@ -716,21 +696,6 @@ private static void TryDeleteFile(string path) } } - public override void Dispose() - { - if (Interlocked.Exchange(ref this.disposeState, 1) != 0) - { - return; - } - - base.Dispose(); - if (this.ownsHttpClient) - { - this.httpClient.Dispose(); - } - this.operationLock.Dispose(); - this.refreshLock.Dispose(); - } } /// <summary>NuGetパッケージ情報</summary> diff --git a/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs b/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs deleted file mode 100644 index 8ddd583e..00000000 --- a/WindowTranslator/Modules/PluginStore/NuGetProtocolPluginMetadataSource.cs +++ /dev/null @@ -1,122 +0,0 @@ -using NuGet.Common; -using NuGet.Packaging; -using NuGet.Packaging.Core; -using NuGet.Protocol; -using NuGet.Protocol.Core.Types; -using NuGet.Versioning; - -namespace WindowTranslator.Modules.PluginStore; - -internal interface INuGetPluginMetadataSource -{ - Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( - string tag, - bool includePrerelease, - CancellationToken cancellationToken); - - Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( - string packageId, - CancellationToken cancellationToken); - - Task<string?> GetReadmeUrlAsync( - string packageId, - NuGetVersion version, - CancellationToken cancellationToken); -} - -internal sealed class NuGetProtocolPluginMetadataSource(string serviceIndexUrl) : INuGetPluginMetadataSource -{ - private const int SearchResultLimit = 100; - - private readonly SourceRepository repository = Repository.Factory.GetCoreV3(serviceIndexUrl); - - public async Task<IReadOnlyList<NuGetPluginSearchMetadata>> SearchAsync( - string tag, - bool includePrerelease, - CancellationToken cancellationToken) - { - var searchResource = await this.repository - .GetResourceAsync<PackageSearchResource>(cancellationToken) - .ConfigureAwait(false); - var searchFilter = new SearchFilter(includePrerelease) - { - IncludeDelisted = false, - }; - var results = await searchResource.SearchAsync( - $"tags:{tag}", - searchFilter, - skip: 0, - take: SearchResultLimit, - NullLogger.Instance, - cancellationToken).ConfigureAwait(false); - - return results - .Where(metadata => !string.IsNullOrWhiteSpace(metadata.Identity?.Id)) - .Select(metadata => new NuGetPluginSearchMetadata( - metadata.Identity.Id, - metadata.Title, - metadata.Description, - metadata.Authors, - metadata.ProjectUrl?.AbsoluteUri, - metadata.LicenseUrl?.AbsoluteUri)) - .ToArray(); - } - - public async Task<IReadOnlyList<NuGetPluginVersionMetadata>> GetPackageVersionsAsync( - string packageId, - CancellationToken cancellationToken) - { - var metadataResource = await this.repository - .GetResourceAsync<PackageMetadataResource>(cancellationToken) - .ConfigureAwait(false); - using var cacheContext = new SourceCacheContext(); - var versions = await metadataResource.GetMetadataAsync( - packageId, - includePrerelease: true, - includeUnlisted: false, - cacheContext, - NullLogger.Instance, - cancellationToken).ConfigureAwait(false); - - return versions - .Where(metadata => metadata.Identity?.Version is not null) - .Select(metadata => new NuGetPluginVersionMetadata( - metadata.Identity.Version, - metadata.IsListed, - metadata.DependencySets?.ToArray() ?? [])) - .ToArray(); - } - - public async Task<string?> GetReadmeUrlAsync( - string packageId, - NuGetVersion version, - CancellationToken cancellationToken) - { - var metadataResource = await this.repository - .GetResourceAsync<PackageMetadataResource>(cancellationToken) - .ConfigureAwait(false); - using var cacheContext = new SourceCacheContext(); - var metadata = await metadataResource.GetMetadataAsync( - new PackageIdentity(packageId, version), - cacheContext, - NullLogger.Instance, - cancellationToken).ConfigureAwait(false); - - return string.IsNullOrWhiteSpace(metadata?.ReadmeFileUrl) - ? null - : metadata.ReadmeFileUrl; - } -} - -internal sealed record NuGetPluginSearchMetadata( - string Id, - string? Title, - string? Description, - string? Authors, - string? ProjectUrl, - string? LicenseUrl); - -internal sealed record NuGetPluginVersionMetadata( - NuGetVersion Version, - bool IsListed, - IReadOnlyList<PackageDependencyGroup> DependencyGroups); diff --git a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs new file mode 100644 index 00000000..f813c3a8 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs @@ -0,0 +1,21 @@ +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +internal static class PluginCompatibility +{ +#if DISABLE_PLUGIN_COMPATIBILITY_VALIDATION + internal static bool ValidationDisabled => true; +#else + internal static bool ValidationDisabled => false; +#endif + + internal static bool IsVersionCompatible(VersionRange? requiredVersion, NuGetVersion? hostVersion) + => ValidationDisabled + || (hostVersion is not null && requiredVersion?.Satisfies(hostVersion) is not false); + + internal static bool IsHostMajorCompatible(int? installedHostMajorVersion, int hostMajorVersion) + => ValidationDisabled + || installedHostMajorVersion is null + || installedHostMajorVersion == hostMajorVersion; +} diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index be8d99ef..4d80eb6c 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Net; +using System.Net.Http; using System.Reflection; using System.Windows; using System.Windows.Markup; @@ -12,6 +14,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; using Octokit; using Sentry.Extensions.Logging; using Weikio.PluginFramework.Abstractions; @@ -161,7 +165,21 @@ builder.Services.AddPresentation<ValidateDialog, ValidateViewModel>(); builder.Services.AddSingleton<IContentDialogService, ContentDialogService>(); builder.Services.AddSingleton<ISnackbarService, SnackbarService>(); -builder.Services.AddSingleton<NuGetPluginService>() +builder.Services.AddHttpClient(NuGetPluginService.HttpClientName, client => + client.Timeout = TimeSpan.FromSeconds(30)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + }); +builder.Services.AddSingleton<SourceRepository>(_ => + NuGet.Protocol.Core.Types.Repository.Factory.GetCoreV3(NuGetPluginService.NuGetServiceIndexUrl)); +builder.Services.AddSingleton(sp => new NuGetPluginService( + sp.GetRequiredService<ILogger<NuGetPluginService>>(), + sp.GetRequiredService<IHttpClientFactory>(), + sp.GetRequiredService<SourceRepository>(), + userPluginsDir, + NuGetPluginService.CreateHostPackageVersions(), + AppInfo.Instance.Version.Major)) .AddHostedService(sp => sp.GetRequiredService<NuGetPluginService>()); builder.Services.AddTransient<PluginStoreViewModel>(); builder.Services.AddTransient<IConfigureOptions<UserSettings>, ConfigureUserSettings>(); diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index dd54bcec..7cff9ac0 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -21,7 +21,7 @@ <PublishReferencesDocumentationFiles>false</PublishReferencesDocumentationFiles> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> - <DefineConstants>$(DefineConstants);NO_MUTEX</DefineConstants> + <DefineConstants>$(DefineConstants);NO_MUTEX;DISABLE_PLUGIN_COMPATIBILITY_VALIDATION</DefineConstants> </PropertyGroup> <!-- Configure Sentry --> <PropertyGroup Condition="'$(SENTRY_AUTH_TOKEN)' != ''"> @@ -44,6 +44,7 @@ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> + <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" /> <PackageReference Include="Microsoft.Windows.CsWin32"> From f44c7380b2901e0ef3b2ea1b70875272dace0aa2 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Wed, 5 Aug 2026 00:21:55 +0900 Subject: [PATCH 15/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E9=87=8D=E8=A4=87=E3=81=A8=E6=A4=9C=E7=B4=A2=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 177 ++++++++++++++++++ .../Modules/PluginStore/NuGetPluginCatalog.cs | 5 - .../Modules/PluginStore/NuGetPluginService.cs | 51 ++++- .../PluginStore/PrioritizedPluginCatalog.cs | 41 ++++ WindowTranslator/Program.cs | 16 +- WindowTranslator/Properties/Resources.ar.resx | 6 + WindowTranslator/Properties/Resources.cs.resx | 6 + WindowTranslator/Properties/Resources.de.resx | 6 + WindowTranslator/Properties/Resources.es.resx | 6 + WindowTranslator/Properties/Resources.fa.resx | 6 + .../Properties/Resources.fil.resx | 6 + WindowTranslator/Properties/Resources.fr.resx | 6 + WindowTranslator/Properties/Resources.hi.resx | 6 + WindowTranslator/Properties/Resources.hu.resx | 6 + WindowTranslator/Properties/Resources.id.resx | 6 + WindowTranslator/Properties/Resources.ko.resx | 6 + WindowTranslator/Properties/Resources.ms.resx | 6 + WindowTranslator/Properties/Resources.pl.resx | 6 + .../Properties/Resources.pt-BR.resx | 6 + WindowTranslator/Properties/Resources.ru.resx | 6 + WindowTranslator/Properties/Resources.th.resx | 6 + WindowTranslator/Properties/Resources.tr.resx | 6 + WindowTranslator/Properties/Resources.vi.resx | 6 + .../Properties/Resources.zh-CN.resx | 6 + .../Properties/Resources.zh-TW.resx | 6 + 25 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 5f1664bf..e1a99f9b 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -18,6 +18,7 @@ using NuGet.Protocol; using NuGet.Protocol.Core.Types; using NuGet.Versioning; +using Weikio.PluginFramework.Abstractions; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; using WindowTranslator.Modules; @@ -561,6 +562,90 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), } } + [Fact] + public async Task RefreshReportsMetadataFailuresAndKeepsSuccessfulPackages() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Available.Plugin", + null, + null, + null, + null, + null), + CreatePackageSearchMetadata( + "Unavailable.Plugin", + null, + null, + null, + null, + null), + ]; + handler.AddMetadataVersions( + "Available.Plugin", + CreatePluginVersionMetadata("1.0.0")); + handler.AddMetadataException( + "Unavailable.Plugin", + new HttpRequestException("Metadata request failed.")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.Equal( + "Available.Plugin", + Assert.Single(service.PackageSnapshot.Packages).Id); + var error = Assert.IsType<AggregateException>(service.PackageSnapshot.Error); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains( + "Unavailable.Plugin", + StringComparison.Ordinal)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task RefreshReportsAnErrorWhenEveryMetadataRequestFails() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Unavailable.Plugin", + null, + null, + null, + null, + null), + ]; + handler.AddMetadataException( + "Unavailable.Plugin", + new HttpRequestException("Metadata request failed.")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.True(service.PackageSnapshot.IsInitialized); + Assert.Empty(service.PackageSnapshot.Packages); + Assert.NotNull(service.PackageSnapshot.Error); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task SearchReturnsReleaseAndPrereleaseVersions() { @@ -1182,6 +1267,32 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() } } + [Fact] + public async Task PrioritizedCatalogUsesNuGetPluginAndRemovesBundledDuplicate() + { + var nugetCatalog = new TestPluginCatalog( + typeof(NuGetPluginTypes.ReplacedPlugin), + typeof(NuGetPluginTypes.NuGetOnlyPlugin)); + var bundledCatalog = new TestPluginCatalog( + typeof(BundledPluginTypes.ReplacedPlugin), + typeof(BundledPluginTypes.BundledOnlyPlugin)); + var catalog = new PrioritizedPluginCatalog(nugetCatalog, bundledCatalog); + + await catalog.Initialize(); + + Assert.True(catalog.IsInitialized); + Assert.Equal( + [ + typeof(NuGetPluginTypes.ReplacedPlugin), + typeof(NuGetPluginTypes.NuGetOnlyPlugin), + typeof(BundledPluginTypes.BundledOnlyPlugin), + ], + catalog.GetPlugins().Select(plugin => plugin.Type)); + Assert.Equal( + typeof(NuGetPluginTypes.ReplacedPlugin), + catalog.Get(nameof(NuGetPluginTypes.ReplacedPlugin), new Version(1, 0)).Type); + } + [Fact] public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() { @@ -1585,6 +1696,63 @@ public Task<IEnumerable<VersionInfo>> GetVersionsAsync() => Task.FromResult<IEnumerable<VersionInfo>>([]); } + private sealed class TestPluginCatalog : IPluginCatalog + { + private readonly List<Plugin> plugins; + + public TestPluginCatalog(params Type[] pluginTypes) + { + this.plugins = pluginTypes + .Select(type => new Plugin( + type.Assembly, + type, + type.Name, + new Version(1, 0), + this, + string.Empty, + string.Empty, + string.Empty, + [])) + .ToList(); + } + + public bool IsInitialized { get; private set; } + + public Task Initialize() + { + this.IsInitialized = true; + return Task.CompletedTask; + } + + public List<Plugin> GetPlugins() => [.. this.plugins]; + + public Plugin Get(string name, Version version) + => this.plugins.FirstOrDefault(plugin => + plugin.Name == name && plugin.Version == version)!; + } + + private static class NuGetPluginTypes + { + public sealed class ReplacedPlugin + { + } + + public sealed class NuGetOnlyPlugin + { + } + } + + private static class BundledPluginTypes + { + public sealed class ReplacedPlugin + { + } + + public sealed class BundledOnlyPlugin + { + } + } + private sealed class InMemoryHttpClientFactory(InMemoryNuGetHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); @@ -1595,6 +1763,8 @@ private sealed class InMemoryNuGetHandler : HttpMessageHandler private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); private readonly Dictionary<string, IReadOnlyList<TestPackageVersion>> metadataVersions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, Exception> metadataExceptions = + new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> readmeUrls = new(StringComparer.OrdinalIgnoreCase); @@ -1614,6 +1784,9 @@ public void AddPackage(string id, string version, byte[] package) public void AddMetadataVersions(string packageId, params TestPackageVersion[] packageVersions) => this.metadataVersions[packageId] = packageVersions; + public void AddMetadataException(string packageId, Exception exception) + => this.metadataExceptions[packageId] = exception; + public void AddReadmeUrl(string packageId, string version, string url) => this.readmeUrls[GetReadmeKey(packageId, NuGetVersion.Parse(version))] = url; @@ -1711,6 +1884,10 @@ public override Task<IEnumerable<IPackageSearchMetadata>> GetMetadataAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + if (source.metadataExceptions.TryGetValue(packageId, out var exception)) + { + return Task.FromException<IEnumerable<IPackageSearchMetadata>>(exception); + } var versions = source.metadataVersions.TryGetValue(packageId, out var packageVersions) ? packageVersions : []; diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index caad75ae..46fcd8cb 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -24,11 +24,6 @@ public sealed class NuGetPluginCatalog : IPluginCatalog private readonly FolderPluginCatalogOptions options; private CompositePluginCatalog innerCatalog = new(); - public NuGetPluginCatalog(string sourceDir, FolderPluginCatalogOptions options) - : this(sourceDir, DefaultTempDir, AppInfo.Instance.Version.Major, options) - { - } - public NuGetPluginCatalog( string sourceDir, int hostMajorVersion, diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 8e3bd218..c15b7b52 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -95,7 +95,9 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio Exception? error = null; try { - packages = await SearchPackagesAsync(cancellationToken).ConfigureAwait(false); + var searchResult = await SearchPackagesCoreAsync(cancellationToken).ConfigureAwait(false); + packages = searchResult.Packages; + error = searchResult.Error; } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -134,6 +136,18 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio /// NuGetでWindowTranslatorプラグインを検索します。 /// </summary> public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(CancellationToken cancellationToken = default) + { + var result = await SearchPackagesCoreAsync(cancellationToken).ConfigureAwait(false); + if (result.Error is not null) + { + throw result.Error; + } + + return result.Packages; + } + + private async Task<PackageSearchResult> SearchPackagesCoreAsync( + CancellationToken cancellationToken) { var searchResource = await this.repository .GetResourceAsync<PackageSearchResource>(cancellationToken) @@ -158,10 +172,11 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat using var request = await requestGate.EnterAsync(cancellationToken); try { - return await CreateCompatiblePackageInfoAsync( + var package = await CreateCompatiblePackageInfoAsync( data, metadataResource, cancellationToken).ConfigureAwait(false); + return new PackageMetadataResult(package, null); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -169,20 +184,35 @@ public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(Cancellat } catch (Exception ex) { + var packageError = new InvalidOperationException( + $"NuGetパッケージ {data.Identity.Id} のメタデータを取得できませんでした。", + ex); this.logger.LogWarning( - ex, + packageError, "NuGetパッケージのプラグイン互換性を確認できなかったため除外します: {PackageId}", data.Identity.Id); - return null; + return new PackageMetadataResult(null, packageError); } }); - var packages = await Task.WhenAll(packageTasks).ConfigureAwait(false); - var compatiblePackages = packages.Where(package => package is not null).Select(package => package!).ToArray(); + var results = await Task.WhenAll(packageTasks).ConfigureAwait(false); + var compatiblePackages = results + .Where(result => result.Package is not null) + .Select(result => result.Package!) + .ToArray(); + var errors = results + .Where(result => result.Error is not null) + .Select(result => result.Error!) + .ToArray(); + var error = errors.Length == 0 + ? null + : new AggregateException( + "一部のNuGetパッケージ情報を取得できませんでした。", + errors); this.logger.LogInformation( "NuGet互換性確認完了: {Count}件のWindowTranslatorプラグインが見つかりました。", compatiblePackages.Length); - return compatiblePackages; + return new(compatiblePackages, error); } /// <summary> @@ -696,6 +726,13 @@ private static void TryDeleteFile(string path) } } + private sealed record PackageMetadataResult( + NuGetPackageInfo? Package, + Exception? Error); + + private sealed record PackageSearchResult( + IReadOnlyList<NuGetPackageInfo> Packages, + Exception? Error); } /// <summary>NuGetパッケージ情報</summary> diff --git a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs new file mode 100644 index 00000000..ac228137 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs @@ -0,0 +1,41 @@ +using Weikio.PluginFramework.Abstractions; + +namespace WindowTranslator.Modules.PluginStore; + +/// <summary> +/// 優先カタログのプラグインを先に返し、同じ型名のフォールバックプラグインを除外します。 +/// </summary> +internal sealed class PrioritizedPluginCatalog( + IPluginCatalog preferredCatalog, + IPluginCatalog fallbackCatalog) : IPluginCatalog +{ + private readonly IPluginCatalog preferredCatalog = preferredCatalog; + private readonly IPluginCatalog fallbackCatalog = fallbackCatalog; + + /// <inheritdoc/> + public bool IsInitialized + => this.preferredCatalog.IsInitialized && this.fallbackCatalog.IsInitialized; + + /// <inheritdoc/> + public async Task Initialize() + { + await this.preferredCatalog.Initialize().ConfigureAwait(false); + await this.fallbackCatalog.Initialize().ConfigureAwait(false); + } + + /// <inheritdoc/> + public List<Plugin> GetPlugins() + { + var typeNames = new HashSet<string>(StringComparer.Ordinal); + return this.preferredCatalog + .GetPlugins() + .Concat(this.fallbackCatalog.GetPlugins()) + .Where(plugin => typeNames.Add(plugin.Type.Name)) + .ToList(); + } + + /// <inheritdoc/> + public Plugin Get(string name, Version version) + => this.GetPlugins().FirstOrDefault(plugin => + plugin.Name == name && plugin.Version == version)!; +} diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index 4d80eb6c..03bd1c92 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -115,19 +115,19 @@ .AddPluginType<ITargetSettingsValidator>() .AddPluginType<IPluginParam>(); -var pluginFolderCatalog = new CompositePluginCatalog(); +var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); +IPluginCatalog pluginFolderCatalog = new NuGetPluginCatalog( + userPluginsDir, + AppInfo.Instance.Version.Major, + new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } }); var appPluginDir = @".\plugins"; if (Directory.Exists(appPluginDir)) { - pluginFolderCatalog.AddCatalog(new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); + pluginFolderCatalog = new PrioritizedPluginCatalog( + pluginFolderCatalog, + new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); } -var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); -pluginFolderCatalog.AddCatalog(new NuGetPluginCatalog( - userPluginsDir, - AppInfo.Instance.Version.Major, - new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); - builder.Services.AddPluginCatalog(pluginFolderCatalog); builder.Configuration .AddCommandLine(args) diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 1de815fd..979d39c9 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -495,6 +495,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>فشل التثبيت</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>هذا المكون الإضافي غير متوافق مع الإصدار الرئيسي الحالي من WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>إعادة التشغيل الآن</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>يرجى إعادة تشغيل WindowTranslator لتطبيق تغييرات المكون الإضافي.</value> </data> diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index cd8b7a45..2b2280c0 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -385,6 +385,12 @@ Monitory nejsou podporovány. <data name="PluginInstallFailed" xml:space="preserve"> <value>Instalace se nezdařila</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Tento plugin není kompatibilní s aktuální hlavní verzí aplikace WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Restartovat nyní</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Restartujte WindowTranslator, aby se změny pluginu projevily.</value> </data> diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 410b25d5..6381cbf3 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -504,6 +504,12 @@ Monitore werden nicht unterstützt. <data name="PluginInstallFailed" xml:space="preserve"> <value>Installation fehlgeschlagen</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Dieses Plugin ist mit der aktuellen Hauptversion von WindowTranslator nicht kompatibel.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Jetzt neu starten</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Bitte starten Sie WindowTranslator neu, um Plugin-Änderungen anzuwenden.</value> </data> diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index 1b36ad36..35b9debe 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -495,6 +495,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>Error de instalación</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Este plugin no es compatible con la versión principal actual de WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Reiniciar ahora</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Reinicie WindowTranslator para aplicar los cambios del plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 0be9545a..006c1798 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -489,6 +489,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>نصب ناموفق بود</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>این افزونه با نسخه اصلی فعلی WindowTranslator سازگار نیست.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>اکنون راه‌اندازی مجدد شود</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>لطفاً WindowTranslator را مجدداً راه‌اندازی کنید تا تغییرات افزونه اعمال شود.</value> </data> diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 5d860d57..a39ad460 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -504,6 +504,12 @@ Ang monitor ay hindi suportado. <data name="PluginInstallFailed" xml:space="preserve"> <value>Nabigo ang pag-install</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Hindi tugma ang plugin na ito sa kasalukuyang pangunahing bersyon ng WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>I-restart ngayon</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Mangyaring i-restart ang WindowTranslator upang mailapat ang mga pagbabago sa plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index aac8ccbb..773a85ae 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -495,6 +495,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>Échec de l'installation</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Ce plugin n’est pas compatible avec la version majeure actuelle de WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Redémarrer maintenant</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Veuillez redémarrer WindowTranslator pour appliquer les modifications de plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 469aa170..04c59816 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -497,6 +497,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>इंस्टॉलेशन विफल</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>यह प्लगइन WindowTranslator के वर्तमान प्रमुख संस्करण के साथ संगत नहीं है।</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>अभी पुनः आरंभ करें</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>प्लगइन परिवर्तन लागू करने के लिए कृपया WindowTranslator को पुनः आरंभ करें।</value> </data> diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index dcc6720c..353f559e 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -385,6 +385,12 @@ A monitorok nem támogatottak. <data name="PluginInstallFailed" xml:space="preserve"> <value>A telepítés sikertelen</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Ez a bővítmény nem kompatibilis a WindowTranslator jelenlegi főverziójával.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Újraindítás most</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>A bővítménymódosítások alkalmazásához indítsa újra a WindowTranslator alkalmazást.</value> </data> diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index aa7140fc..3543c1e7 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -503,6 +503,12 @@ Monitor tidak didukung.</value> <data name="PluginInstallFailed" xml:space="preserve"> <value>Instalasi gagal</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Plugin ini tidak kompatibel dengan versi mayor WindowTranslator saat ini.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Mulai ulang sekarang</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Silakan restart WindowTranslator untuk menerapkan perubahan plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 817dc5d9..d1825877 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -504,6 +504,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>설치 실패</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>이 플러그인은 현재 WindowTranslator 주 버전과 호환되지 않습니다.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>지금 다시 시작</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>플러그인 변경 사항을 적용하려면 WindowTranslator를 다시 시작하세요.</value> </data> diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 468b4d13..7e53277f 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -503,6 +503,12 @@ Monitor tidak disokong.</value> <data name="PluginInstallFailed" xml:space="preserve"> <value>Pemasangan gagal</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Pemalam ini tidak serasi dengan versi utama WindowTranslator semasa.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Mulakan semula sekarang</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Sila mulakan semula WindowTranslator untuk menerapkan perubahan plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index f667ae1c..f9bbba9a 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -504,6 +504,12 @@ Monitory nie są obsługiwane. <data name="PluginInstallFailed" xml:space="preserve"> <value>Instalacja nie powiodła się</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Ta wtyczka nie jest zgodna z bieżącą główną wersją WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Uruchom ponownie teraz</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Uruchom ponownie WindowTranslator, aby zastosować zmiany wtyczki.</value> </data> diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 2fbacce7..4c0f7abc 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -503,6 +503,12 @@ Monitor tidak didukung.</value> <data name="PluginInstallFailed" xml:space="preserve"> <value>Falha na instalação</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Este plugin não é compatível com a versão principal atual do WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Reiniciar agora</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Reinicie o WindowTranslator para aplicar as alterações de plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 6fddcfb1..e9edbc8d 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -495,6 +495,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>Ошибка установки</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Этот плагин несовместим с текущей основной версией WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Перезапустить сейчас</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Перезапустите WindowTranslator для применения изменений плагина.</value> </data> diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 17bebcad..823db23f 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -504,6 +504,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>การติดตั้งล้มเหลว</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>ปลั๊กอินนี้ไม่เข้ากันกับ WindowTranslator เวอร์ชันหลักปัจจุบัน</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>เริ่มใหม่ตอนนี้</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>กรุณาเริ่ม WindowTranslator ใหม่เพื่อนำการเปลี่ยนแปลงปลั๊กอินไปใช้</value> </data> diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 3a192391..54e3a7e6 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -504,6 +504,12 @@ Monitör desteklenmiyor. <data name="PluginInstallFailed" xml:space="preserve"> <value>Kurulum başarısız</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Bu eklenti, WindowTranslator'ın mevcut ana sürümüyle uyumlu değil.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Şimdi yeniden başlat</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Eklenti değişikliklerini uygulamak için lütfen WindowTranslator'ı yeniden başlatın.</value> </data> diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 878151c4..3c74da9e 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -504,6 +504,12 @@ Màn hình không được hỗ trợ. <data name="PluginInstallFailed" xml:space="preserve"> <value>Cài đặt thất bại</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>Plugin này không tương thích với phiên bản chính hiện tại của WindowTranslator.</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>Khởi động lại ngay</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>Vui lòng khởi động lại WindowTranslator để áp dụng thay đổi plugin.</value> </data> diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 04b14e05..672a4951 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -504,6 +504,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>安装失败</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>此插件与当前 WindowTranslator 主版本不兼容。</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>立即重启</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>请重启 WindowTranslator 以应用插件更改。</value> </data> diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index da6427bd..93a76e22 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -504,6 +504,12 @@ <data name="PluginInstallFailed" xml:space="preserve"> <value>安裝失敗</value> </data> + <data name="PluginIncompatible" xml:space="preserve"> + <value>此外掛程式與目前的 WindowTranslator 主要版本不相容。</value> + </data> + <data name="RestartNow" xml:space="preserve"> + <value>立即重新啟動</value> + </data> <data name="RestartRequired" xml:space="preserve"> <value>請重新啟動 WindowTranslator 以套用外掛程式變更。</value> </data> From 5cdcc954a7a7b25b276e86fec064779beb0fb752 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Wed, 5 Aug 2026 02:03:05 +0900 Subject: [PATCH 16/43] =?UTF-8?q?NuGet=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E7=AE=A1=E7=90=86=E9=A0=98=E5=9F=9F=E3=81=A8=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E5=87=A6=E7=90=86=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-package.yml | 3 +- .../NuGetPluginServiceTests.cs | 92 ++++++++++++------- .../Modules/PluginStore/NuGetPluginCatalog.cs | 9 +- .../Modules/PluginStore/NuGetPluginService.cs | 25 ++--- .../PluginStore/PrioritizedPluginCatalog.cs | 40 ++++++-- WindowTranslator/Program.cs | 18 +++- docs/plugin.md | 5 +- 7 files changed, 131 insertions(+), 61 deletions(-) diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index d8c7082d..b2ad6bf6 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -70,7 +70,8 @@ jobs: -p:Version=${{ steps.package-version.outputs.version }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` + -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index e1a99f9b..b39baf52 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -3,6 +3,8 @@ using System.IO.Compression; using System.Net; using System.Net.Http; +using System.Reflection; +using System.Reflection.Emit; using System.Resources; using System.Runtime.InteropServices; using System.Runtime.Loader; @@ -271,7 +273,7 @@ public async Task UninstallRestoresManagedFilesWhenManifestUpdateFails() Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); Assert.Empty(Directory.GetDirectories( - testDirectory, + Path.Combine(testDirectory, NuGetPluginService.OperationsDirectoryName), "Root.Plugin.uninstalling-*")); var installed = Assert.Single(await service.GetInstalledPackagesAsync()); Assert.Equal("1.0.0", installed.Version); @@ -1108,11 +1110,16 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() Path.Combine(sourceDirectory, "Root.Plugin", "Unchanged.dll"); File.WriteAllText(unchangedSourcePath, "unchanged"); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Empty.Plugin")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, ".operations")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, ".operations", "backup-test")); + File.WriteAllText( + Path.Combine(sourceDirectory, ".operations", "backup-test", "old.dll"), + "old"); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.backup-test")); File.WriteAllText( Path.Combine(sourceDirectory, "Root.Plugin.backup-test", "old.dll"), "old"); - Directory.CreateDirectory(Path.Combine(sourceDirectory, ".Root.Plugin.installing-test")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.installing-test")); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.uninstalling-test")); Directory.CreateDirectory(Path.Combine(destinationDirectory, "Root.Plugin")); var destinationPluginPath = @@ -1177,10 +1184,12 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() Assert.False(File.Exists( Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, ".operations"))); + Assert.True(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); - Assert.False(Directory.Exists( - Path.Combine(destinationDirectory, ".Root.Plugin.installing-test"))); - Assert.False(Directory.Exists( + Assert.True(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.installing-test"))); + Assert.True(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.uninstalling-test"))); } finally @@ -1268,14 +1277,37 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() } [Fact] - public async Task PrioritizedCatalogUsesNuGetPluginAndRemovesBundledDuplicate() + public async Task PrioritizedCatalogReplacesFallbackAssemblyAndKeepsSameTypeNameFromOtherAssembly() { + var replacedAssemblyName = $"Replaced.Plugin.{Guid.NewGuid():N}"; + var nugetTypes = CreatePluginTypes( + replacedAssemblyName, + "NuGet.ReplacedPlugin", + "NuGet.NuGetOnlyPlugin"); + var replacedBundledTypes = CreatePluginTypes( + replacedAssemblyName, + "Bundled.ReplacedPlugin", + "Bundled.AlsoReplacedPlugin"); + var duplicateNugetTypes = CreatePluginTypes( + replacedAssemblyName, + "DuplicateNuGet.ReplacedPlugin", + "DuplicateNuGet.AlsoReplacedPlugin"); + var bundledOnlyType = CreatePluginTypes( + $"Bundled.Plugin.{Guid.NewGuid():N}", + "Bundled.BundledOnlyPlugin")[0]; + var sameTypeNameFromOtherAssembly = CreatePluginTypes( + $"Other.Plugin.{Guid.NewGuid():N}", + "Other.ReplacedPlugin")[0]; var nugetCatalog = new TestPluginCatalog( - typeof(NuGetPluginTypes.ReplacedPlugin), - typeof(NuGetPluginTypes.NuGetOnlyPlugin)); + nugetTypes[0], + nugetTypes[1], + duplicateNugetTypes[0], + duplicateNugetTypes[1]); var bundledCatalog = new TestPluginCatalog( - typeof(BundledPluginTypes.ReplacedPlugin), - typeof(BundledPluginTypes.BundledOnlyPlugin)); + replacedBundledTypes[0], + replacedBundledTypes[1], + bundledOnlyType, + sameTypeNameFromOtherAssembly); var catalog = new PrioritizedPluginCatalog(nugetCatalog, bundledCatalog); await catalog.Initialize(); @@ -1283,14 +1315,15 @@ public async Task PrioritizedCatalogUsesNuGetPluginAndRemovesBundledDuplicate() Assert.True(catalog.IsInitialized); Assert.Equal( [ - typeof(NuGetPluginTypes.ReplacedPlugin), - typeof(NuGetPluginTypes.NuGetOnlyPlugin), - typeof(BundledPluginTypes.BundledOnlyPlugin), + nugetTypes[0], + nugetTypes[1], + bundledOnlyType, + sameTypeNameFromOtherAssembly, ], catalog.GetPlugins().Select(plugin => plugin.Type)); Assert.Equal( - typeof(NuGetPluginTypes.ReplacedPlugin), - catalog.Get(nameof(NuGetPluginTypes.ReplacedPlugin), new Version(1, 0)).Type); + nugetTypes[0], + catalog.Get("ReplacedPlugin", new Version(1, 0)).Type); } [Fact] @@ -1731,26 +1764,17 @@ public Plugin Get(string name, Version version) plugin.Name == name && plugin.Version == version)!; } - private static class NuGetPluginTypes + private static Type[] CreatePluginTypes(string assemblyName, params string[] typeNames) { - public sealed class ReplacedPlugin - { - } - - public sealed class NuGetOnlyPlugin - { - } - } - - private static class BundledPluginTypes - { - public sealed class ReplacedPlugin - { - } - - public sealed class BundledOnlyPlugin - { - } + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName(assemblyName), + AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(assemblyName); + return typeNames + .Select(typeName => module + .DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class) + .CreateType()!) + .ToArray(); } private sealed class InMemoryHttpClientFactory(InMemoryNuGetHandler handler) : IHttpClientFactory diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 46fcd8cb..80880d89 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -16,7 +16,7 @@ namespace WindowTranslator.Modules.PluginStore; public sealed class NuGetPluginCatalog : IPluginCatalog { private static readonly string DefaultTempDir = - Path.Combine(Path.GetTempPath(), "WindowTranslator", "plugins"); + Path.Combine(Path.GetTempPath(), "WindowTranslator", "nuget-plugins"); private readonly string sourceDir; private readonly string tempDir; @@ -376,10 +376,9 @@ internal static void SynchronizePluginFiles( } private static bool IsWorkingDirectory(string directoryName) - => directoryName.EndsWith(".backup", StringComparison.OrdinalIgnoreCase) - || directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase) - || directoryName.Contains(".uninstalling-", StringComparison.OrdinalIgnoreCase) - || directoryName.Contains(".installing-", StringComparison.OrdinalIgnoreCase); + => directoryName.Equals( + NuGetPluginService.OperationsDirectoryName, + StringComparison.OrdinalIgnoreCase); private static bool IsManagementFile(string fileName) => fileName.Equals("nuget-manifest.json", StringComparison.OrdinalIgnoreCase) diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index c15b7b52..2147b588 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -23,6 +23,7 @@ public sealed class NuGetPluginService : BackgroundService internal const string HttpClientName = "NuGetPluginReadme"; internal const string PluginTag = "windowtranslator-plugin"; internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; + internal const string OperationsDirectoryName = ".operations"; private const int SearchResultLimit = 100; private const int MaxConcurrentMetadataRequests = 8; private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); @@ -38,7 +39,8 @@ public sealed class NuGetPluginService : BackgroundService private readonly IHttpClientFactory httpClientFactory; private readonly SourceRepository repository; private readonly ILogger<NuGetPluginService> logger; - private readonly string userPluginsDir; + private readonly string nugetPluginsDir; + private readonly string operationsDir; private readonly string manifestPath; private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions; private readonly int hostMajorVersion; @@ -52,15 +54,16 @@ internal NuGetPluginService( ILogger<NuGetPluginService> logger, IHttpClientFactory httpClientFactory, SourceRepository repository, - string userPluginsDir, + string nugetPluginsDir, IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions, int hostMajorVersion) { this.logger = logger; this.httpClientFactory = httpClientFactory; this.repository = repository; - this.userPluginsDir = Path.GetFullPath(userPluginsDir); - this.manifestPath = Path.Combine(this.userPluginsDir, "nuget-manifest.json"); + this.nugetPluginsDir = Path.GetFullPath(nugetPluginsDir); + this.operationsDir = Path.Combine(this.nugetPluginsDir, OperationsDirectoryName); + this.manifestPath = Path.Combine(this.nugetPluginsDir, "nuget-manifest.json"); this.hostPackageVersions = hostPackageVersions; this.hostMajorVersion = hostMajorVersion; } @@ -265,14 +268,14 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres { var operationId = Guid.NewGuid().ToString("N"); var targetDir = GetPackageDirectory(packageId); - var stagingDir = Path.Combine(this.userPluginsDir, $".{packageId}.installing-{operationId}"); - var backupDir = $"{targetDir}.backup-{operationId}"; + var stagingDir = Path.Combine(this.operationsDir, $"{packageId}.installing-{operationId}"); + var backupDir = Path.Combine(this.operationsDir, $"{packageId}.backup-{operationId}"); var targetMoved = false; var stagingMoved = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); try { - Directory.CreateDirectory(this.userPluginsDir); + Directory.CreateDirectory(this.operationsDir); var packageResource = await this.repository .GetResourceAsync<FindPackageByIdResource>(cancellationToken) .ConfigureAwait(false); @@ -355,11 +358,11 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc { var operationId = Guid.NewGuid().ToString("N"); var targetDir = GetPackageDirectory(packageId); - var uninstallingDir = $"{targetDir}.uninstalling-{operationId}"; + var uninstallingDir = Path.Combine(this.operationsDir, $"{packageId}.uninstalling-{operationId}"); var targetMoved = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); - Directory.CreateDirectory(this.userPluginsDir); + Directory.CreateDirectory(this.operationsDir); var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); var updatedManifest = RemovePackage(manifest, packageId); @@ -642,7 +645,7 @@ private async Task<InstalledManifest> LoadManifestAsync(CancellationToken cancel private async Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) { - Directory.CreateDirectory(this.userPluginsDir); + Directory.CreateDirectory(this.nugetPluginsDir); var temporaryPath = $"{this.manifestPath}.tmp-{Guid.NewGuid():N}"; try { @@ -684,7 +687,7 @@ private string GetPackageDirectory(string packageId) throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); } - var root = this.userPluginsDir + var root = this.nugetPluginsDir .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var packageDirectory = Path.GetFullPath(Path.Combine(root, packageId)); diff --git a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs index ac228137..30d8714f 100644 --- a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs @@ -1,9 +1,10 @@ +using System.Reflection; using Weikio.PluginFramework.Abstractions; namespace WindowTranslator.Modules.PluginStore; /// <summary> -/// 優先カタログのプラグインを先に返し、同じ型名のフォールバックプラグインを除外します。 +/// 優先カタログのプラグインを先に返し、同じアセンブリ名のフォールバックプラグインを除外します。 /// </summary> internal sealed class PrioritizedPluginCatalog( IPluginCatalog preferredCatalog, @@ -26,11 +27,38 @@ public async Task Initialize() /// <inheritdoc/> public List<Plugin> GetPlugins() { - var typeNames = new HashSet<string>(StringComparer.Ordinal); - return this.preferredCatalog - .GetPlugins() - .Concat(this.fallbackCatalog.GetPlugins()) - .Where(plugin => typeNames.Add(plugin.Type.Name)) + var selectedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + return SelectPluginsByAssembly( + this.preferredCatalog.GetPlugins(), + selectedAssemblyNames) + .Concat(SelectPluginsByAssembly( + this.fallbackCatalog.GetPlugins(), + selectedAssemblyNames)) + .ToList(); + } + + private static List<Plugin> SelectPluginsByAssembly( + List<Plugin> plugins, + HashSet<string> selectedAssemblyNames) + { + var selectedAssemblies = new HashSet<Assembly>(ReferenceEqualityComparer.Instance); + foreach (var plugin in plugins) + { + var assembly = plugin.Type.Assembly; + if (selectedAssemblies.Contains(assembly)) + { + continue; + } + + var assemblyName = assembly.GetName().Name; + if (assemblyName is null || selectedAssemblyNames.Add(assemblyName)) + { + selectedAssemblies.Add(assembly); + } + } + + return plugins + .Where(plugin => selectedAssemblies.Contains(plugin.Type.Assembly)) .ToList(); } diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index 03bd1c92..addd1d80 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -116,16 +116,28 @@ .AddPluginType<IPluginParam>(); var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); +var nugetPluginsDir = Path.Combine(PathUtility.UserDir, "nuget-plugins"); IPluginCatalog pluginFolderCatalog = new NuGetPluginCatalog( - userPluginsDir, + nugetPluginsDir, AppInfo.Instance.Version.Major, new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } }); +var fallbackPluginCatalogs = new List<IPluginCatalog>(); var appPluginDir = @".\plugins"; if (Directory.Exists(appPluginDir)) +{ + fallbackPluginCatalogs.Add( + new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); +} +if (Directory.Exists(userPluginsDir)) +{ + fallbackPluginCatalogs.Add( + new FolderPluginCatalog(userPluginsDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); +} +if (fallbackPluginCatalogs.Count > 0) { pluginFolderCatalog = new PrioritizedPluginCatalog( pluginFolderCatalog, - new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); + new CompositePluginCatalog([.. fallbackPluginCatalogs])); } builder.Services.AddPluginCatalog(pluginFolderCatalog); @@ -177,7 +189,7 @@ sp.GetRequiredService<ILogger<NuGetPluginService>>(), sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<SourceRepository>(), - userPluginsDir, + nugetPluginsDir, NuGetPluginService.CreateHostPackageVersions(), AppInfo.Instance.Version.Major)) .AddHostedService(sp => sp.GetRequiredService<NuGetPluginService>()); diff --git a/docs/plugin.md b/docs/plugin.md index 7b59fcf3..8f695c9b 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -119,7 +119,10 @@ public class MyTranslateModule : ITranslateModule { ... } インストールされたプラグインは以下のフォルダに配置されます: -- Windows: `%USERPROFILE%\.wt\plugins\{PackageId}\` +- NuGetからインストール: `%USERPROFILE%\.wt\nuget-plugins\{PackageId}\` +- ユーザーが手動で配置: `%USERPROFILE%\.wt\plugins\` + +NuGetのインストール・更新・アンインストールでは、手動配置用フォルダの内容を変更しません。 ## アプリからインストールする From f0d1cd176db387fffaa4bb8785b7303b80642c75 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Thu, 6 Aug 2026 00:05:02 +0900 Subject: [PATCH 17/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E6=93=8D=E4=BD=9C=E3=81=AE=E4=B8=AD=E6=96=AD=E5=BE=A9?= =?UTF-8?q?=E6=97=A7=E3=81=A8=E3=83=9E=E3=83=8B=E3=83=95=E3=82=A7=E3=82=B9?= =?UTF-8?q?=E3=83=88=E6=A4=9C=E8=A8=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 162 ++++++++-- .../Modules/PluginStore/NuGetPluginCatalog.cs | 6 +- .../PluginStore/NuGetPluginOperation.cs | 285 ++++++++++++++++++ .../Modules/PluginStore/NuGetPluginService.cs | 258 +++++++--------- .../PluginStore/PluginCompatibility.cs | 3 +- 5 files changed, 529 insertions(+), 185 deletions(-) create mode 100644 WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index b39baf52..05e77327 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -382,32 +382,27 @@ await File.ReadAllTextAsync( } [Fact] - public async Task ExistingManifestLoadsInstalledPackages() + public async Task ManifestLoadsInstalledPackages() { var testDirectory = CreateTestDirectory(); try { await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), - JsonSerializer.Serialize(new - { - Packages = new[] - { - new - { - Id = "Legacy.Plugin", - Version = "1.0.0", - }, - }, - })); + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo("Root.Plugin", "1.0.0", HostMajorVersion: 7)]))); using var handler = new InMemoryNuGetHandler(); - using var service = CreateService(handler, testDirectory); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); var installed = Assert.Single(await service.GetInstalledPackagesAsync()); - Assert.Equal("Legacy.Plugin", installed.Id); + Assert.Equal("Root.Plugin", installed.Id); Assert.Equal("1.0.0", installed.Version); + Assert.Equal(7, installed.HostMajorVersion); } finally { @@ -416,33 +411,32 @@ await File.WriteAllTextAsync( } [Fact] - public async Task ExistingManifestRecordsCurrentHostMajorVersionOnFirstRead() + public async Task ManifestWithoutHostMajorVersionIsRejected() { var testDirectory = CreateTestDirectory(); try { await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), - JsonSerializer.Serialize(new InstalledManifest( - [new InstalledPackageInfo("Legacy.Plugin", "1.0.0")]))); + JsonSerializer.Serialize(new + { + Packages = new[] + { + new + { + Id = "Legacy.Plugin", + Version = "1.0.0", + }, + }, + })); using var handler = new InMemoryNuGetHandler(); using var service = CreateService( handler, testDirectory, hostMajorVersion: 7); - var package = Assert.Single(await service.GetInstalledPackagesAsync()); - - Assert.Equal(7, package.HostMajorVersion); - Assert.True(package.IsCompatible); - using var document = JsonDocument.Parse(await File.ReadAllTextAsync( - Path.Combine(testDirectory, "nuget-manifest.json"))); - Assert.Equal( - 7, - document.RootElement - .GetProperty("Packages")[0] - .GetProperty("HostMajorVersion") - .GetInt32()); + await Assert.ThrowsAsync<InvalidOperationException>( + () => service.GetInstalledPackagesAsync()); } finally { @@ -537,13 +531,14 @@ public async Task PluginStoreKeepsInstalledPackagesVisibleWhenNuGetSearchFails() await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new InstalledManifest( - [new InstalledPackageInfo("Installed.Plugin", "1.2.3")])), + [new InstalledPackageInfo("Installed.Plugin", "1.2.3", HostMajorVersion: 7)])), Encoding.UTF8); using var handler = new InMemoryNuGetHandler(); handler.SearchException = new HttpRequestException("NuGet search failed."); using var service = CreateService( handler, - testDirectory); + testDirectory, + hostMajorVersion: 7); var viewModel = new PluginStoreViewModel( service, NullLogger<PluginStoreViewModel>.Instance, @@ -1225,6 +1220,111 @@ public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() } } + [Fact] + public async Task InterruptedInstallIsRolledBackBeforeItIsTreatedAsCompleted() + { + var sourceDirectory = CreateTestDirectory(); + try + { + const string packageId = "Root.Plugin"; + var originalManifest = new InstalledManifest( + [new InstalledPackageInfo(packageId, "1.0.0", HostMajorVersion: 1)]); + var updatedManifest = new InstalledManifest( + [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); + await NuGetPluginOperation.SaveManifestAsync( + manifestPath, + updatedManifest, + CancellationToken.None); + + var operationPaths = NuGetPluginOperation.CreatePaths(sourceDirectory, packageId); + Directory.CreateDirectory(operationPaths.BackupPath); + File.WriteAllText(Path.Combine(operationPaths.BackupPath, "plugin.txt"), "old"); + var targetDirectory = NuGetPluginOperation.GetPackageDirectory(sourceDirectory, packageId); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "new"); + await NuGetPluginOperation.WriteJournalAsync( + operationPaths, + new( + operationPaths.OperationId, + packageId, + NuGetPluginOperationKind.Install, + ManifestExisted: true, + originalManifest), + CancellationToken.None); + + var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( + sourceDirectory); + + Assert.Empty(unresolved); + Assert.Equal("old", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + var restoredManifest = JsonSerializer.Deserialize<InstalledManifest>( + File.ReadAllText(manifestPath), + NuGetPluginService.ManifestJsonOptions); + Assert.Equal("1.0.0", Assert.Single(restoredManifest!.Packages).Version); + Assert.False(File.Exists(operationPaths.JournalPath)); + Assert.False(Directory.Exists(operationPaths.StagingPath)); + Assert.False(Directory.Exists(operationPaths.BackupPath)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + } + } + + [Fact] + public async Task CompletedInstallKeepsNewFilesAndOnlyCleansOperationData() + { + var sourceDirectory = CreateTestDirectory(); + try + { + const string packageId = "Root.Plugin"; + var originalManifest = new InstalledManifest( + [new InstalledPackageInfo(packageId, "1.0.0", HostMajorVersion: 1)]); + var updatedManifest = new InstalledManifest( + [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); + await NuGetPluginOperation.SaveManifestAsync( + manifestPath, + updatedManifest, + CancellationToken.None); + + var operationPaths = NuGetPluginOperation.CreatePaths(sourceDirectory, packageId); + Directory.CreateDirectory(operationPaths.BackupPath); + File.WriteAllText(Path.Combine(operationPaths.BackupPath, "plugin.txt"), "old"); + var targetDirectory = NuGetPluginOperation.GetPackageDirectory(sourceDirectory, packageId); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "new"); + await NuGetPluginOperation.WriteJournalAsync( + operationPaths, + new( + operationPaths.OperationId, + packageId, + NuGetPluginOperationKind.Install, + ManifestExisted: true, + originalManifest), + CancellationToken.None); + NuGetPluginOperation.MarkCommitted(operationPaths); + + var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( + sourceDirectory); + + Assert.Empty(unresolved); + Assert.Equal("new", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + var retainedManifest = JsonSerializer.Deserialize<InstalledManifest>( + File.ReadAllText(manifestPath), + NuGetPluginService.ManifestJsonOptions); + Assert.Equal("2.0.0", Assert.Single(retainedManifest!.Packages).Version); + Assert.False(File.Exists(operationPaths.JournalPath)); + Assert.False(File.Exists(operationPaths.CommittedPath)); + Assert.False(Directory.Exists(operationPaths.BackupPath)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + } + } + [Fact] public void CatalogSynchronizationFollowsCompatibilityValidationSetting() { diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 80880d89..6d709781 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -55,9 +55,13 @@ internal NuGetPluginCatalog( /// <inheritdoc/> public async Task Initialize() { + var unresolvedOperations = await NuGetPluginOperation + .RecoverInterruptedOperationsAsync(this.sourceDir) + .ConfigureAwait(false); var incompatiblePackages = GetIncompatiblePackageIds( this.sourceDir, - this.hostMajorVersion); + this.hostMajorVersion).ToHashSet(StringComparer.OrdinalIgnoreCase); + incompatiblePackages.UnionWith(unresolvedOperations); SynchronizePluginFiles(this.sourceDir, this.tempDir, incompatiblePackages); this.innerCatalog = CreateCatalog(this.tempDir, this.options); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs new file mode 100644 index 00000000..4292c752 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs @@ -0,0 +1,285 @@ +using System.Diagnostics; +using System.IO; +using System.Text.Json; + +namespace WindowTranslator.Modules.PluginStore; + +internal enum NuGetPluginOperationKind +{ + Install, + Uninstall, +} + +internal sealed record NuGetPluginOperationState( + string OperationId, + string PackageId, + NuGetPluginOperationKind Kind, + bool ManifestExisted, + InstalledManifest OriginalManifest); + +internal sealed record NuGetPluginOperationPaths( + string OperationId, + string PackageId, + string JournalPath, + string CommittedPath, + string StagingPath, + string BackupPath, + string UninstallingPath); + +internal static class NuGetPluginOperation +{ + private const string JournalSuffix = ".operation.json"; + private const string CommittedSuffix = ".committed"; + + internal static NuGetPluginOperationPaths CreatePaths(string nugetPluginsDir, string packageId) + => GetPaths(nugetPluginsDir, packageId, Guid.NewGuid().ToString("N")); + + internal static string GetPackageDirectory(string nugetPluginsDir, string packageId) + { + if (string.IsNullOrWhiteSpace(packageId) + || packageId is "." or ".." + || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || packageId.Contains(Path.DirectorySeparatorChar) + || packageId.Contains(Path.AltDirectorySeparatorChar)) + { + throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + } + + var root = Path.GetFullPath(nugetPluginsDir) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var packageDirectory = Path.GetFullPath(Path.Combine(root, packageId)); + if (!packageDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + } + + return packageDirectory; + } + + internal static Task WriteJournalAsync( + NuGetPluginOperationPaths paths, + NuGetPluginOperationState state, + CancellationToken cancellationToken) + { + if (!paths.OperationId.Equals(state.OperationId, StringComparison.Ordinal) + || !paths.PackageId.Equals(state.PackageId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("NuGetプラグイン操作とジャーナルの対象が一致しません。"); + } + + return SaveJsonAsync(paths.JournalPath, state, cancellationToken); + } + + internal static void MarkCommitted(NuGetPluginOperationPaths paths) + { + using var stream = new FileStream( + paths.CommittedPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1, + FileOptions.WriteThrough); + stream.Flush(flushToDisk: true); + } + + internal static async Task<IReadOnlySet<string>> RecoverInterruptedOperationsAsync( + string nugetPluginsDir, + CancellationToken cancellationToken = default) + { + var operationsDir = Path.Combine( + Path.GetFullPath(nugetPluginsDir), + NuGetPluginService.OperationsDirectoryName); + if (!Directory.Exists(operationsDir)) + { + return new HashSet<string>(StringComparer.OrdinalIgnoreCase); + } + + var unresolvedPackageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var journalPath in Directory.EnumerateFiles( + operationsDir, + $"*{JournalSuffix}", + SearchOption.TopDirectoryOnly)) + { + cancellationToken.ThrowIfCancellationRequested(); + NuGetPluginOperationState? state = null; + try + { + await using (var stream = File.OpenRead(journalPath)) + { + state = await JsonSerializer.DeserializeAsync<NuGetPluginOperationState>( + stream, + NuGetPluginService.ManifestJsonOptions, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException("NuGetプラグイン操作ジャーナルが空です。"); + } + var paths = GetPaths(nugetPluginsDir, state.PackageId, state.OperationId); + if (!paths.JournalPath.Equals(journalPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("NuGetプラグイン操作ジャーナルのIDが一致しません。"); + } + + if (File.Exists(paths.CommittedPath)) + { + CleanupCommitted(paths); + continue; + } + + await RollbackAsync( + nugetPluginsDir, + state, + paths, + cancellationToken).ConfigureAwait(false); + CleanupRolledBack(paths); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (!string.IsNullOrWhiteSpace(state?.PackageId)) + { + unresolvedPackageIds.Add(state.PackageId); + } + Trace.TraceWarning( + "NuGetプラグイン操作の復旧に失敗しました: {0} ({1})", + journalPath, + ex); + } + } + + return unresolvedPackageIds; + } + + internal static async Task RollbackAsync( + string nugetPluginsDir, + NuGetPluginOperationState state, + NuGetPluginOperationPaths paths, + CancellationToken cancellationToken) + { + var targetDir = GetPackageDirectory(nugetPluginsDir, state.PackageId); + switch (state.Kind) + { + case NuGetPluginOperationKind.Install: + if (!Directory.Exists(paths.StagingPath) && Directory.Exists(targetDir)) + { + Directory.Move(targetDir, paths.StagingPath); + } + if (Directory.Exists(paths.BackupPath)) + { + Directory.Move(paths.BackupPath, targetDir); + } + break; + case NuGetPluginOperationKind.Uninstall: + if (Directory.Exists(paths.UninstallingPath) && !Directory.Exists(targetDir)) + { + Directory.Move(paths.UninstallingPath, targetDir); + } + break; + default: + throw new InvalidDataException($"不明なNuGetプラグイン操作です: {state.Kind}"); + } + + var manifestPath = Path.Combine(Path.GetFullPath(nugetPluginsDir), "nuget-manifest.json"); + if (state.ManifestExisted) + { + await SaveManifestAsync( + manifestPath, + state.OriginalManifest, + cancellationToken).ConfigureAwait(false); + } + else if (File.Exists(manifestPath)) + { + File.Delete(manifestPath); + } + } + + internal static void CleanupCommitted(NuGetPluginOperationPaths paths) + { + DeleteDirectoryIfExists(paths.StagingPath); + DeleteDirectoryIfExists(paths.BackupPath); + DeleteDirectoryIfExists(paths.UninstallingPath); + File.Delete(paths.JournalPath); + File.Delete(paths.CommittedPath); + } + + internal static void CleanupRolledBack(NuGetPluginOperationPaths paths) + { + File.Delete(paths.JournalPath); + DeleteDirectoryIfExists(paths.StagingPath); + DeleteDirectoryIfExists(paths.BackupPath); + DeleteDirectoryIfExists(paths.UninstallingPath); + File.Delete(paths.CommittedPath); + } + + internal static Task SaveManifestAsync( + string manifestPath, + InstalledManifest manifest, + CancellationToken cancellationToken) + => SaveJsonAsync(manifestPath, manifest, cancellationToken); + + private static NuGetPluginOperationPaths GetPaths( + string nugetPluginsDir, + string packageId, + string operationId) + { + if (!Guid.TryParseExact(operationId, "N", out _)) + { + throw new InvalidOperationException($"不正なNuGetプラグイン操作IDです: {operationId}"); + } + _ = GetPackageDirectory(nugetPluginsDir, packageId); + + var operationsDir = Path.Combine( + Path.GetFullPath(nugetPluginsDir), + NuGetPluginService.OperationsDirectoryName); + return new( + operationId, + packageId, + Path.Combine(operationsDir, $"{operationId}{JournalSuffix}"), + Path.Combine(operationsDir, $"{operationId}{CommittedSuffix}"), + Path.Combine(operationsDir, $"{packageId}.installing-{operationId}"), + Path.Combine(operationsDir, $"{packageId}.backup-{operationId}"), + Path.Combine(operationsDir, $"{packageId}.uninstalling-{operationId}")); + } + + private static async Task SaveJsonAsync<T>( + string destinationPath, + T value, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + var temporaryPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}"; + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true)) + { + await JsonSerializer.SerializeAsync( + stream, + value, + NuGetPluginService.ManifestJsonOptions, + cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(temporaryPath, destinationPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 2147b588..218841e4 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -40,7 +40,6 @@ public sealed class NuGetPluginService : BackgroundService private readonly SourceRepository repository; private readonly ILogger<NuGetPluginService> logger; private readonly string nugetPluginsDir; - private readonly string operationsDir; private readonly string manifestPath; private readonly IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions; private readonly int hostMajorVersion; @@ -62,7 +61,6 @@ internal NuGetPluginService( this.httpClientFactory = httpClientFactory; this.repository = repository; this.nugetPluginsDir = Path.GetFullPath(nugetPluginsDir); - this.operationsDir = Path.Combine(this.nugetPluginsDir, OperationsDirectoryName); this.manifestPath = Path.Combine(this.nugetPluginsDir, "nuget-manifest.json"); this.hostPackageVersions = hostPackageVersions; this.hostMajorVersion = hostMajorVersion; @@ -266,16 +264,14 @@ private async Task<PackageSearchResult> SearchPackagesCoreAsync( /// </summary> public async Task InstallPackageAsync(string packageId, string version, IProgress<double>? progress = null, CancellationToken cancellationToken = default) { - var operationId = Guid.NewGuid().ToString("N"); + var operationPaths = NuGetPluginOperation.CreatePaths(this.nugetPluginsDir, packageId); var targetDir = GetPackageDirectory(packageId); - var stagingDir = Path.Combine(this.operationsDir, $"{packageId}.installing-{operationId}"); - var backupDir = Path.Combine(this.operationsDir, $"{packageId}.backup-{operationId}"); - var targetMoved = false; - var stagingMoved = false; + NuGetPluginOperationState? operationState = null; + var journalWritten = false; + var committed = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); try { - Directory.CreateDirectory(this.operationsDir); var packageResource = await this.repository .GetResourceAsync<FindPackageByIdResource>(cancellationToken) .ConfigureAwait(false); @@ -286,34 +282,48 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres await installer.InstallAsync( packageId, version, - stagingDir, + operationPaths.StagingPath, progress, cancellationToken).ConfigureAwait(false); + var manifestExisted = File.Exists(this.manifestPath); var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); var updatedManifest = AddOrUpdatePackage(currentManifest, packageId, version); + operationState = new( + operationPaths.OperationId, + packageId, + NuGetPluginOperationKind.Install, + manifestExisted, + currentManifest); + await NuGetPluginOperation.WriteJournalAsync( + operationPaths, + operationState, + cancellationToken).ConfigureAwait(false); + journalWritten = true; if (Directory.Exists(targetDir)) { - Directory.Move(targetDir, backupDir); - targetMoved = true; + Directory.Move(targetDir, operationPaths.BackupPath); } - Directory.Move(stagingDir, targetDir); - stagingMoved = true; + Directory.Move(operationPaths.StagingPath, targetDir); await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + NuGetPluginOperation.MarkCommitted(operationPaths); + committed = true; UpdateInstalledPackages(updatedManifest.Packages); try { - if (Directory.Exists(backupDir)) - { - Directory.Delete(backupDir, recursive: true); - } + NuGetPluginOperation.CleanupCommitted(operationPaths); + journalWritten = false; } catch (Exception ex) { - this.logger.LogWarning(ex, "プラグインバックアップの削除に失敗しました: {BackupDir}", backupDir); + this.logger.LogWarning( + ex, + "完了したプラグイン操作の後片付けに失敗しました: {PackageId} {OperationId}", + packageId, + operationPaths.OperationId); } this.logger.LogInformation( @@ -324,29 +334,34 @@ await installer.InstallAsync( } catch { - try + if (journalWritten && !committed && operationState is not null) { - if (stagingMoved && Directory.Exists(targetDir)) + try { - Directory.Move(targetDir, stagingDir); + await NuGetPluginOperation.RollbackAsync( + this.nugetPluginsDir, + operationState, + operationPaths, + CancellationToken.None).ConfigureAwait(false); + NuGetPluginOperation.CleanupRolledBack(operationPaths); + journalWritten = false; } - if (targetMoved && Directory.Exists(backupDir)) + catch (Exception rollbackException) { - Directory.Move(backupDir, targetDir); + this.logger.LogError( + rollbackException, + "プラグイン {PackageId} のインストール失敗後の復旧に失敗しました。", + packageId); } } - catch (Exception rollbackException) - { - this.logger.LogError( - rollbackException, - "プラグイン {PackageId} のインストール失敗後の復旧に失敗しました。", - packageId); - } throw; } finally { - TryDeleteDirectory(stagingDir); + if (!journalWritten) + { + TryDeleteDirectory(operationPaths.StagingPath); + } } } @@ -356,62 +371,77 @@ await installer.InstallAsync( /// </summary> public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) { - var operationId = Guid.NewGuid().ToString("N"); + var operationPaths = NuGetPluginOperation.CreatePaths(this.nugetPluginsDir, packageId); var targetDir = GetPackageDirectory(packageId); - var uninstallingDir = Path.Combine(this.operationsDir, $"{packageId}.uninstalling-{operationId}"); - var targetMoved = false; + NuGetPluginOperationState? operationState = null; + var journalWritten = false; + var committed = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); - Directory.CreateDirectory(this.operationsDir); - - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var updatedManifest = RemovePackage(manifest, packageId); - - if (Directory.Exists(targetDir)) - { - Directory.Move(targetDir, uninstallingDir); - targetMoved = true; - } try { + var manifestExisted = File.Exists(this.manifestPath); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = RemovePackage(manifest, packageId); + operationState = new( + operationPaths.OperationId, + packageId, + NuGetPluginOperationKind.Uninstall, + manifestExisted, + manifest); + await NuGetPluginOperation.WriteJournalAsync( + operationPaths, + operationState, + cancellationToken).ConfigureAwait(false); + journalWritten = true; + + if (Directory.Exists(targetDir)) + { + Directory.Move(targetDir, operationPaths.UninstallingPath); + } + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + NuGetPluginOperation.MarkCommitted(operationPaths); + committed = true; UpdateInstalledPackages(updatedManifest.Packages); - } - catch - { + try { - if (targetMoved - && Directory.Exists(uninstallingDir) - && !Directory.Exists(targetDir)) - { - Directory.Move(uninstallingDir, targetDir); - } + NuGetPluginOperation.CleanupCommitted(operationPaths); + journalWritten = false; } - catch (Exception rollbackException) + catch (Exception ex) { - this.logger.LogError( - rollbackException, - "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", - packageId); + this.logger.LogWarning( + ex, + "完了したプラグイン操作の後片付けに失敗しました: {PackageId} {OperationId}", + packageId, + operationPaths.OperationId); } - throw; } - - try + catch { - if (Directory.Exists(uninstallingDir)) + if (journalWritten && !committed && operationState is not null) { - Directory.Delete(uninstallingDir, recursive: true); + try + { + await NuGetPluginOperation.RollbackAsync( + this.nugetPluginsDir, + operationState, + operationPaths, + CancellationToken.None).ConfigureAwait(false); + NuGetPluginOperation.CleanupRolledBack(operationPaths); + } + catch (Exception rollbackException) + { + this.logger.LogError( + rollbackException, + "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", + packageId); + } } - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "アンインストール済みプラグインフォルダの削除に失敗しました: {Directory}", - uninstallingDir); + throw; } this.logger.LogInformation( @@ -426,18 +456,6 @@ public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync { using var operation = await this.operationLock.EnterAsync(cancellationToken); var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var migratedPackages = manifest.Packages - .Select(package => package.HostMajorVersion is null - ? package with { HostMajorVersion = this.hostMajorVersion } - : package) - .ToList(); - if (migratedPackages.Where((package, index) => - package != manifest.Packages[index]).Any()) - { - manifest = new InstalledManifest(migratedPackages); - await SaveManifestAsync(manifest, cancellationToken).ConfigureAwait(false); - } - return GetCompatibilityAwarePackages(manifest.Packages); } @@ -643,61 +661,14 @@ private async Task<InstalledManifest> LoadManifestAsync(CancellationToken cancel } } - private async Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) - { - Directory.CreateDirectory(this.nugetPluginsDir); - var temporaryPath = $"{this.manifestPath}.tmp-{Guid.NewGuid():N}"; - try - { - await using (var fs = new FileStream( - temporaryPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 4096, - useAsync: true)) - { - await JsonSerializer.SerializeAsync( - fs, - manifest, - ManifestJsonOptions, - cancellationToken).ConfigureAwait(false); - await fs.FlushAsync(cancellationToken).ConfigureAwait(false); - } - - ReplaceFile(temporaryPath, this.manifestPath); - } - finally - { - TryDeleteFile(temporaryPath); - } - } - - private static void ReplaceFile(string sourcePath, string destinationPath) - => File.Move(sourcePath, destinationPath, overwrite: true); + private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) + => NuGetPluginOperation.SaveManifestAsync( + this.manifestPath, + manifest, + cancellationToken); private string GetPackageDirectory(string packageId) - { - if (string.IsNullOrWhiteSpace(packageId) - || packageId is "." or ".." - || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 - || packageId.Contains(Path.DirectorySeparatorChar) - || packageId.Contains(Path.AltDirectorySeparatorChar)) - { - throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); - } - - var root = this.nugetPluginsDir - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - var packageDirectory = Path.GetFullPath(Path.Combine(root, packageId)); - if (!packageDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); - } - - return packageDirectory; - } + => NuGetPluginOperation.GetPackageDirectory(this.nugetPluginsDir, packageId); private static void TryDeleteDirectory(string directory) { @@ -714,21 +685,6 @@ private static void TryDeleteDirectory(string directory) } } - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch - { - // 後始末の失敗は元の処理結果へ影響させない - } - } - private sealed record PackageMetadataResult( NuGetPackageInfo? Package, Exception? Error); @@ -754,7 +710,7 @@ public record NuGetPackageInfo( public record InstalledPackageInfo( string Id, string Version, - int? HostMajorVersion = null) + [property: JsonRequired] int HostMajorVersion) { [JsonIgnore] public bool IsCompatible { get; init; } = true; diff --git a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs index f813c3a8..cb1f72dc 100644 --- a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs +++ b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs @@ -14,8 +14,7 @@ internal static bool IsVersionCompatible(VersionRange? requiredVersion, NuGetVer => ValidationDisabled || (hostVersion is not null && requiredVersion?.Satisfies(hostVersion) is not false); - internal static bool IsHostMajorCompatible(int? installedHostMajorVersion, int hostMajorVersion) + internal static bool IsHostMajorCompatible(int installedHostMajorVersion, int hostMajorVersion) => ValidationDisabled - || installedHostMajorVersion is null || installedHostMajorVersion == hostMajorVersion; } From 5d5ed74cc8703b63aa15834ae83fed54204ff4e1 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 9 Aug 2026 14:55:59 +0900 Subject: [PATCH 18/43] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=82=92=E5=89=8A=E9=99=A4=E3=81=97=E3=81=A6=E6=A7=8B?= =?UTF-8?q?=E6=88=90=E3=82=92=E6=95=B4=E7=90=86=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/Directory.Build.targets | 13 +- .../NuGetPluginServiceTests.cs | 182 +++++----- .../PluginStore/NuGetPackageInstaller.cs | 67 ++-- .../Modules/PluginStore/NuGetPluginCatalog.cs | 146 ++------ .../PluginStore/NuGetPluginOperation.cs | 320 ++++++++--------- .../Modules/PluginStore/NuGetPluginService.cs | 334 ++++-------------- .../Modules/PluginStore/PluginStoreView.xaml | 194 +++++----- .../PluginStore/PluginStoreView.xaml.cs | 68 ---- .../PluginStore/PluginStoreViewModel.cs | 67 +--- .../PluginStore/PrioritizedPluginCatalog.cs | 26 +- 10 files changed, 476 insertions(+), 941 deletions(-) diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index ae80e3ff..7dc871b9 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -5,20 +5,13 @@ <PropertyGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'"> <PackageTags>$(PackageTags);windowtranslator-plugin</PackageTags> <PackageReadmeFile Condition="Exists('$(MSBuildProjectDirectory)\README.md')">README.md</PackageReadmeFile> - <TargetsForTfmSpecificContentInPackage - Condition="Exists('$(MSBuildProjectDirectory)\README.md')">$(TargetsForTfmSpecificContentInPackage);AddPluginReadmeToPackage</TargetsForTfmSpecificContentInPackage> <TargetsForTfmSpecificBuildOutput Condition="'$(IncludePluginRuntimeAssetsInPackage)' == 'true'">$(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage</TargetsForTfmSpecificBuildOutput> </PropertyGroup> - <!-- IncludeContentInPack=false のプラグインでも README を常にパッケージへ含める。 --> - <Target Name="AddPluginReadmeToPackage"> - <ItemGroup> - <TfmSpecificPackageFile Include="$(MSBuildProjectDirectory)\README.md"> - <PackagePath>README.md</PackagePath> - </TfmSpecificPackageFile> - </ItemGroup> - </Target> + <ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\README.md')"> + <None Update="$(MSBuildProjectDirectory)\README.md" Pack="true" PackagePath="\" /> + </ItemGroup> <!-- PackageReference の build/buildTransitive ターゲットによって出力されるネイティブ資産は、 diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 05e77327..9723a908 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -114,7 +114,7 @@ await File.ReadAllTextAsync(Path.Combine( handler.RequestedPaths, path => path.Contains("host.provided", StringComparison.OrdinalIgnoreCase)); - var installed = await service.GetInstalledPackagesAsync(); + var installed = service.PackageSnapshot.InstalledPackages; var package = Assert.Single(installed); Assert.Equal("Root.Plugin", package.Id); Assert.Equal("1.0.0", package.Version); @@ -178,7 +178,7 @@ public async Task ManifestWriteFailureRestoresThePreviousPluginDirectory() "version-one", await File.ReadAllTextAsync( Path.Combine(testDirectory, "Root.Plugin", "Root.Plugin.dll"))); - var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); Assert.Equal("1.0.0", installed.Version); } finally @@ -222,11 +222,11 @@ public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstal await service.UninstallPackageAsync("Root.Plugin"); Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(await service.GetInstalledPackagesAsync()); + Assert.Empty(service.PackageSnapshot.InstalledPackages); await service.InstallPackageAsync("Root.Plugin", "2.0.0"); Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); Assert.Equal("2.0.0", installed.Version); } finally @@ -236,7 +236,7 @@ public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstal } [Fact] - public async Task UninstallRestoresManagedFilesWhenManifestUpdateFails() + public async Task UninstallKeepsManagedFilesWhenManifestUpdateFails() { var testDirectory = CreateTestDirectory(); try @@ -272,10 +272,7 @@ public async Task UninstallRestoresManagedFilesWhenManifestUpdateFails() } Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(Directory.GetDirectories( - Path.Combine(testDirectory, NuGetPluginService.OperationsDirectoryName), - "Root.Plugin.uninstalling-*")); - var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); Assert.Equal("1.0.0", installed.Version); } finally @@ -398,7 +395,8 @@ await File.WriteAllTextAsync( testDirectory, hostMajorVersion: 7); - var installed = Assert.Single(await service.GetInstalledPackagesAsync()); + await service.RefreshPackageInformationAsync(); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); Assert.Equal("Root.Plugin", installed.Id); Assert.Equal("1.0.0", installed.Version); @@ -416,6 +414,7 @@ public async Task ManifestWithoutHostMajorVersionIsRejected() var testDirectory = CreateTestDirectory(); try { + Directory.CreateDirectory(Path.Combine(testDirectory, "Legacy.Plugin")); await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new @@ -435,8 +434,12 @@ await File.WriteAllTextAsync( testDirectory, hostMajorVersion: 7); - await Assert.ThrowsAsync<InvalidOperationException>( - () => service.GetInstalledPackagesAsync()); + await service.RefreshPackageInformationAsync(); + Assert.IsType<InvalidOperationException>(service.PackageSnapshot.Error); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + Assert.Empty(NuGetPluginCatalog.GetLoadablePackageIds( + testDirectory, + hostMajorVersion: 7)); } finally { @@ -460,7 +463,8 @@ await File.WriteAllTextAsync( testDirectory, hostMajorVersion: 7); - var package = Assert.Single(await service.GetInstalledPackagesAsync()); + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.InstalledPackages); Assert.Equal(PluginCompatibility.ValidationDisabled, package.IsCompatible); Assert.Equal(6, package.HostMajorVersion); @@ -509,7 +513,6 @@ public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSetti await service.StopAsync(CancellationToken.None); } - Assert.True(service.PackageSnapshot.IsInitialized); Assert.Null(service.PackageSnapshot.Error); Assert.Equal( "Background.Plugin", @@ -544,7 +547,7 @@ [new InstalledPackageInfo("Installed.Plugin", "1.2.3", HostMajorVersion: 7)])), NullLogger<PluginStoreViewModel>.Instance, dialogService: null!); - await viewModel.LoadAsync(); + await service.RefreshPackageInformationAsync(); var package = Assert.Single(viewModel.Packages); Assert.Equal("Installed.Plugin", package.Id); @@ -633,7 +636,6 @@ public async Task RefreshReportsAnErrorWhenEveryMetadataRequestFails() await service.RefreshPackageInformationAsync(); - Assert.True(service.PackageSnapshot.IsInitialized); Assert.Empty(service.PackageSnapshot.Packages); Assert.NotNull(service.PackageSnapshot.Error); } @@ -669,10 +671,10 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() handler, testDirectory); - var package = Assert.Single(await service.SearchPackagesAsync()); + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.Packages); Assert.Equal("Test.Plugin", package.Id); - Assert.Equal("1.1.0-beta.2", package.Version); Assert.Equal( ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], package.Versions); @@ -726,12 +728,10 @@ public async Task SearchKeepsOnlyVersionsWithCompatibleDirectAbstractionsDepende ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), }); - var package = Assert.Single(await service.SearchPackagesAsync()); + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.Packages); Assert.Equal("Compatible.Plugin", package.Id); - Assert.Equal( - PluginCompatibility.ValidationDisabled ? "2.0.0" : "1.0.0", - package.Version); Assert.Equal( PluginCompatibility.ValidationDisabled ? ["1.0.0", "2.0.0"] : ["1.0.0"], package.Versions); @@ -749,7 +749,6 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() var package = new PluginPackageViewModel( new NuGetPackageInfo( "Test.Plugin", - "1.1.0-beta.2", "Test Plugin", string.Empty, string.Empty, @@ -772,7 +771,6 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() var prereleaseOnlyPackage = new PluginPackageViewModel( new NuGetPackageInfo( "Preview.Plugin", - "2.0.0-preview.1", "Preview Plugin", string.Empty, string.Empty, @@ -797,7 +795,6 @@ public void IncompatibleInstalledPackageCanReinstallACompatibleVersion() var package = new PluginPackageViewModel( new NuGetPackageInfo( "Test.Plugin", - "1.0.0", "Test Plugin", string.Empty, string.Empty, @@ -878,7 +875,7 @@ public async Task InstallCompatibilityFollowsValidationSetting() Assert.Contains("WindowTranslator.Abstractions", exception.Message); Assert.Contains("[2.0.0, 3.0.0)", exception.Message); Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(await service.GetInstalledPackagesAsync()); + Assert.Empty(service.PackageSnapshot.InstalledPackages); } } finally @@ -960,7 +957,7 @@ public async Task InstallRejectsPackageWithoutDirectAbstractionsDependency() Assert.Contains("WindowTranslator.Abstractions", exception.Message); Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(await service.GetInstalledPackagesAsync()); + Assert.Empty(service.PackageSnapshot.InstalledPackages); } finally { @@ -974,6 +971,13 @@ public async Task InstallRejectsPackageWithoutPluginTag() var testDirectory = CreateTestDirectory(); try { + var targetDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "old"); + await NuGetPluginOperation.SaveManifestAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + new([new("Root.Plugin", "0.9.0", HostMajorVersion: 1)]), + CancellationToken.None); using var handler = new InMemoryNuGetHandler(); handler.AddPackage( "Root.Plugin", @@ -989,13 +993,14 @@ public async Task InstallRejectsPackageWithoutPluginTag() includePluginTag: false)); using var service = CreateService(handler, testDirectory); + await service.RefreshPackageInformationAsync(); var exception = await Assert.ThrowsAsync<InvalidOperationException>( () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); Assert.Contains("プラグインタグ", exception.Message); - Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); - Assert.Empty(await service.GetInstalledPackagesAsync()); + Assert.Equal("old", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + Assert.Equal("0.9.0", Assert.Single(service.PackageSnapshot.InstalledPackages).Version); } finally { @@ -1053,7 +1058,6 @@ public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() var package = new PluginPackageViewModel( new NuGetPackageInfo( "Readme.Plugin", - "2.0.0-preview.1", "README Plugin", string.Empty, string.Empty, @@ -1152,9 +1156,13 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() FileMode.Open, FileAccess.Read, FileShare.Read); + var includedPackages = new HashSet<string>( + ["Root.Plugin", "Empty.Plugin"], + StringComparer.OrdinalIgnoreCase); NuGetPluginCatalog.SynchronizePluginFiles( sourceDirectory, - destinationDirectory); + destinationDirectory, + includedPackages); using var synchronizedFileLock = new FileStream( destinationPluginPath, FileMode.Open, @@ -1162,9 +1170,10 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() FileShare.Read); NuGetPluginCatalog.SynchronizePluginFiles( sourceDirectory, - destinationDirectory); + destinationDirectory, + includedPackages); - Assert.True(File.Exists(Path.Combine(destinationDirectory, "Legacy.Plugin.dll"))); + Assert.False(File.Exists(Path.Combine(destinationDirectory, "Legacy.Plugin.dll"))); Assert.Equal( "plugin-new", File.ReadAllText(destinationPluginPath)); @@ -1180,11 +1189,11 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); Assert.False(Directory.Exists( Path.Combine(destinationDirectory, ".operations"))); - Assert.True(Directory.Exists( + Assert.False(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); - Assert.True(Directory.Exists( + Assert.False(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.installing-test"))); - Assert.True(Directory.Exists( + Assert.False(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.uninstalling-test"))); } finally @@ -1209,7 +1218,8 @@ public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() NuGetPluginCatalog.SynchronizePluginFiles( sourceDirectory, - destinationDirectory); + destinationDirectory, + new HashSet<string>(StringComparer.OrdinalIgnoreCase)); Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory)); } @@ -1234,37 +1244,35 @@ public async Task InterruptedInstallIsRolledBackBeforeItIsTreatedAsCompleted() var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); await NuGetPluginOperation.SaveManifestAsync( manifestPath, - updatedManifest, + originalManifest, CancellationToken.None); - var operationPaths = NuGetPluginOperation.CreatePaths(sourceDirectory, packageId); - Directory.CreateDirectory(operationPaths.BackupPath); - File.WriteAllText(Path.Combine(operationPaths.BackupPath, "plugin.txt"), "old"); - var targetDirectory = NuGetPluginOperation.GetPackageDirectory(sourceDirectory, packageId); - Directory.CreateDirectory(targetDirectory); - File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "new"); - await NuGetPluginOperation.WriteJournalAsync( - operationPaths, - new( - operationPaths.OperationId, - packageId, - NuGetPluginOperationKind.Install, - ManifestExisted: true, - originalManifest), + var operation = await NuGetPluginOperation.BeginAsync( + sourceDirectory, + packageId, + originalManifest, + CancellationToken.None); + Directory.CreateDirectory(operation.BackupPath); + File.WriteAllText(Path.Combine(operation.BackupPath, "plugin.txt"), "old"); + Directory.Move(operation.WorkingPath, operation.TargetPath); + File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); + await NuGetPluginOperation.SaveManifestAsync( + manifestPath, + updatedManifest, CancellationToken.None); var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( sourceDirectory); Assert.Empty(unresolved); - Assert.Equal("old", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + Assert.Equal("old", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); var restoredManifest = JsonSerializer.Deserialize<InstalledManifest>( File.ReadAllText(manifestPath), NuGetPluginService.ManifestJsonOptions); Assert.Equal("1.0.0", Assert.Single(restoredManifest!.Packages).Version); - Assert.False(File.Exists(operationPaths.JournalPath)); - Assert.False(Directory.Exists(operationPaths.StagingPath)); - Assert.False(Directory.Exists(operationPaths.BackupPath)); + Assert.False(File.Exists(operation.PendingPath)); + Assert.False(Directory.Exists(operation.WorkingPath)); + Assert.False(Directory.Exists(operation.BackupPath)); } finally { @@ -1286,38 +1294,36 @@ public async Task CompletedInstallKeepsNewFilesAndOnlyCleansOperationData() var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); await NuGetPluginOperation.SaveManifestAsync( manifestPath, - updatedManifest, + originalManifest, CancellationToken.None); - var operationPaths = NuGetPluginOperation.CreatePaths(sourceDirectory, packageId); - Directory.CreateDirectory(operationPaths.BackupPath); - File.WriteAllText(Path.Combine(operationPaths.BackupPath, "plugin.txt"), "old"); - var targetDirectory = NuGetPluginOperation.GetPackageDirectory(sourceDirectory, packageId); - Directory.CreateDirectory(targetDirectory); - File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "new"); - await NuGetPluginOperation.WriteJournalAsync( - operationPaths, - new( - operationPaths.OperationId, - packageId, - NuGetPluginOperationKind.Install, - ManifestExisted: true, - originalManifest), + var operation = await NuGetPluginOperation.BeginAsync( + sourceDirectory, + packageId, + originalManifest, + CancellationToken.None); + Directory.CreateDirectory(operation.BackupPath); + File.WriteAllText(Path.Combine(operation.BackupPath, "plugin.txt"), "old"); + Directory.Move(operation.WorkingPath, operation.TargetPath); + File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); + await NuGetPluginOperation.SaveManifestAsync( + manifestPath, + updatedManifest, CancellationToken.None); - NuGetPluginOperation.MarkCommitted(operationPaths); + operation.Commit(); var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( sourceDirectory); Assert.Empty(unresolved); - Assert.Equal("new", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + Assert.Equal("new", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); var retainedManifest = JsonSerializer.Deserialize<InstalledManifest>( File.ReadAllText(manifestPath), NuGetPluginService.ManifestJsonOptions); Assert.Equal("2.0.0", Assert.Single(retainedManifest!.Packages).Version); - Assert.False(File.Exists(operationPaths.JournalPath)); - Assert.False(File.Exists(operationPaths.CommittedPath)); - Assert.False(Directory.Exists(operationPaths.BackupPath)); + Assert.False(File.Exists(operation.PendingPath)); + Assert.False(File.Exists(operation.CommittedPath)); + Assert.False(Directory.Exists(operation.BackupPath)); } finally { @@ -1334,10 +1340,13 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() { var compatibleDirectory = Path.Combine(sourceDirectory, "Compatible.Plugin"); var incompatibleDirectory = Path.Combine(sourceDirectory, "Incompatible.Plugin"); + var orphanDirectory = Path.Combine(sourceDirectory, "Orphan.Plugin"); Directory.CreateDirectory(compatibleDirectory); Directory.CreateDirectory(incompatibleDirectory); + Directory.CreateDirectory(orphanDirectory); File.WriteAllText(Path.Combine(compatibleDirectory, "Compatible.Plugin.dll"), "compatible"); File.WriteAllText(Path.Combine(incompatibleDirectory, "Incompatible.Plugin.dll"), "incompatible"); + File.WriteAllText(Path.Combine(orphanDirectory, "Orphan.Plugin.dll"), "orphan"); Directory.CreateDirectory(Path.Combine(destinationDirectory, "Incompatible.Plugin")); File.WriteAllText( Path.Combine(destinationDirectory, "Incompatible.Plugin", "Incompatible.Plugin.dll"), @@ -1350,13 +1359,13 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() new InstalledPackageInfo("Incompatible.Plugin", "1.0.0", HostMajorVersion: 6), ]))); - var incompatiblePackages = NuGetPluginCatalog.GetIncompatiblePackageIds( + var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( sourceDirectory, hostMajorVersion: 7); NuGetPluginCatalog.SynchronizePluginFiles( sourceDirectory, destinationDirectory, - incompatiblePackages); + loadablePackages); Assert.True(File.Exists(Path.Combine( destinationDirectory, @@ -1365,9 +1374,12 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() Assert.Equal( PluginCompatibility.ValidationDisabled, Directory.Exists(Path.Combine(destinationDirectory, "Incompatible.Plugin"))); + Assert.False(Directory.Exists(Path.Combine(destinationDirectory, "Orphan.Plugin"))); Assert.Equal( - PluginCompatibility.ValidationDisabled ? [] : ["Incompatible.Plugin"], - incompatiblePackages); + PluginCompatibility.ValidationDisabled + ? ["Compatible.Plugin", "Incompatible.Plugin"] + : ["Compatible.Plugin"], + loadablePackages.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)); } finally { @@ -1453,6 +1465,10 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() packageDirectory, "*.deps.json", SearchOption.AllDirectories)); + await NuGetPluginOperation.SaveManifestAsync( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + new([new("Catalog.Probe", "1.0.0", HostMajorVersion: 1)]), + CancellationToken.None); var options = new FolderPluginCatalogOptions(); options.TypeFinderOptions.TypeFinderCriterias.Clear(); @@ -1477,6 +1493,7 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() var catalog = new NuGetPluginCatalog( sourceDirectory, tempDirectory, + hostMajorVersion: 1, options); await catalog.Initialize(); @@ -1523,6 +1540,10 @@ public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() "WindowTranslator.Tests.resources.dll"), Path.Combine(cultureDirectory, "WindowTranslator.Tests.resources.dll")); } + await NuGetPluginOperation.SaveManifestAsync( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + new([new("Catalog.Probe", "1.0.0", HostMajorVersion: 1)]), + CancellationToken.None); var options = new FolderPluginCatalogOptions(); options.TypeFinderOptions.TypeFinderCriterias.Clear(); @@ -1548,6 +1569,7 @@ public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() var catalog = new NuGetPluginCatalog( sourceDirectory, tempDirectory, + hostMajorVersion: 1, options); await catalog.Initialize(); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 0b1865c5..e90bead4 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -37,7 +37,7 @@ public async Task InstallAsync( IProgress<double>? progress, CancellationToken cancellationToken) { - ValidatePackageId(packageId); + PackageIdValidator.ValidatePackageId(packageId); var requestedVersion = NuGetVersion.Parse(version); var workDirectory = Path.Combine( Path.GetTempPath(), @@ -210,15 +210,16 @@ await DownloadPackageAsync( currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? progress : null, cacheContext, cancellationToken).ConfigureAwait(false); + var metadata = ReadPackageMetadata(packagePath); ValidateHostPackageDependencies( - packagePath, + metadata, currentId, resolvedVersion, this.hostPackageVersions, requirePluginPackage: currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase)); - artifacts[currentId] = new PackageArtifact(currentId, resolvedVersion, packagePath); - foreach (var dependency in ReadRuntimeDependencies(packagePath)) + artifacts[currentId] = new PackageArtifact(currentId, packagePath); + foreach (var dependency in metadata.Dependencies.Where(IncludesRuntimeAssets)) { if (this.hostPackageVersions.ContainsKey(dependency.Id)) { @@ -233,7 +234,7 @@ await DownloadPackageAsync( void AddConstraint(string id, string source, VersionRange range) { - ValidatePackageId(id); + PackageIdValidator.ValidatePackageId(id); if (!constraints.TryGetValue(id, out var packageConstraints)) { packageConstraints = []; @@ -318,15 +319,7 @@ private async Task DownloadPackageAsync( progress?.Report(1); } - private static List<PackageDependency> ReadRuntimeDependencies(string packagePath) - => ReadDependencies(packagePath, runtimeOnly: true); - - private static List<PackageDependency> ReadPackageDependencies(string packagePath) - => ReadDependencies(packagePath, runtimeOnly: false); - - private static List<PackageDependency> ReadDependencies( - string packagePath, - bool runtimeOnly) + private static PackageMetadata ReadPackageMetadata(string packagePath) { using var packageStream = File.OpenRead(packagePath); using var packageReader = new PackageArchiveReader(packageStream); @@ -348,22 +341,24 @@ private static List<PackageDependency> ReadDependencies( dependencies.AddRange(selectedGroup.Packages); } - return dependencies - .Where(dependency => !runtimeOnly || IncludesRuntimeAssets(dependency)) - .ToList(); + var tags = packageReader.NuspecReader.GetTags(); + var hasPluginTag = tags?.Split( + [' ', '\t', '\r', '\n', ';', ','], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains(NuGetPluginService.PluginTag, StringComparer.OrdinalIgnoreCase) == true; + return new(dependencies, hasPluginTag); } private static void ValidateHostPackageDependencies( - string packagePath, + PackageMetadata metadata, string packageId, NuGetVersion packageVersion, IReadOnlyDictionary<string, NuGetVersion> hostPackageVersions, bool requirePluginPackage) { - var dependencies = ReadPackageDependencies(packagePath); if (requirePluginPackage) { - if (!HasPackageTag(packagePath, NuGetPluginService.PluginTag)) + if (!metadata.HasPluginTag) { throw new InvalidOperationException( $"パッケージ {packageId} {packageVersion} はWindowTranslatorプラグインタグを持っていません。"); @@ -376,7 +371,7 @@ private static void ValidateHostPackageDependencies( $"実行中の{NuGetPluginService.AbstractionsPackageId}のバージョンを確認できません。"); } - if (!dependencies.Any(dependency => dependency.Id.Equals( + if (!metadata.Dependencies.Any(dependency => dependency.Id.Equals( NuGetPluginService.AbstractionsPackageId, StringComparison.OrdinalIgnoreCase))) { @@ -386,7 +381,7 @@ private static void ValidateHostPackageDependencies( } } - foreach (var dependency in dependencies) + foreach (var dependency in metadata.Dependencies) { if (!hostPackageVersions.TryGetValue(dependency.Id, out var hostVersion) || PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion)) @@ -401,17 +396,6 @@ private static void ValidateHostPackageDependencies( } } - private static bool HasPackageTag(string packagePath, string requiredTag) - { - using var packageStream = File.OpenRead(packagePath); - using var packageReader = new PackageArchiveReader(packageStream); - var tags = packageReader.NuspecReader.GetTags(); - return tags?.Split( - [' ', '\t', '\r', '\n', ';', ','], - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Contains(requiredTag, StringComparer.OrdinalIgnoreCase) == true; - } - private static bool IncludesRuntimeAssets(PackageDependency dependency) { if (dependency.Exclude.Contains("all", StringComparer.OrdinalIgnoreCase) @@ -587,17 +571,6 @@ private static bool StreamsEqual(Stream left, Stream right) return right.ReadByte() == -1; } - private static void ValidatePackageId(string packageId) - { - if (string.IsNullOrWhiteSpace(packageId) - || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 - || packageId.Contains(Path.DirectorySeparatorChar) - || packageId.Contains(Path.AltDirectorySeparatorChar)) - { - throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); - } - } - private static void TryDeleteDirectory(string directory) { try @@ -613,8 +586,12 @@ private static void TryDeleteDirectory(string directory) } } - private sealed record PackageArtifact(string Id, NuGetVersion Version, string PackagePath); + private sealed record PackageArtifact(string Id, string PackagePath); private sealed record DependencyConstraint(string Source, VersionRange Range); + private sealed record PackageMetadata( + IReadOnlyList<PackageDependency> Dependencies, + bool HasPluginTag); + } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 6d709781..161bdc77 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -1,6 +1,5 @@ using System.IO; using System.Reflection; -using System.Reflection.PortableExecutable; using System.Runtime.Loader; using System.Text.Json; using Weikio.PluginFramework.Abstractions; @@ -32,11 +31,6 @@ public NuGetPluginCatalog( { } - internal NuGetPluginCatalog(string sourceDir, string tempDir, FolderPluginCatalogOptions options) - : this(sourceDir, tempDir, AppInfo.Instance.Version.Major, options) - { - } - internal NuGetPluginCatalog( string sourceDir, string tempDir, @@ -58,11 +52,11 @@ public async Task Initialize() var unresolvedOperations = await NuGetPluginOperation .RecoverInterruptedOperationsAsync(this.sourceDir) .ConfigureAwait(false); - var incompatiblePackages = GetIncompatiblePackageIds( + var loadablePackages = GetLoadablePackageIds( this.sourceDir, - this.hostMajorVersion).ToHashSet(StringComparer.OrdinalIgnoreCase); - incompatiblePackages.UnionWith(unresolvedOperations); - SynchronizePluginFiles(this.sourceDir, this.tempDir, incompatiblePackages); + this.hostMajorVersion); + loadablePackages.ExceptWith(unresolvedOperations); + SynchronizePluginFiles(this.sourceDir, this.tempDir, loadablePackages); this.innerCatalog = CreateCatalog(this.tempDir, this.options); await this.innerCatalog.Initialize().ConfigureAwait(false); @@ -78,42 +72,21 @@ private static CompositePluginCatalog CreateCatalog( string directory, FolderPluginCatalogOptions baseOptions) { - var catalogs = new List<IPluginCatalog> - { - new FolderPluginCatalog( - directory, - CreateCatalogOptions( - baseOptions, - directory, - SearchOption.TopDirectoryOnly, - includeSubfolders: false)), - }; - - foreach (var packageDirectory in Directory - .EnumerateDirectories(directory) - .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) - { - catalogs.Add(new FolderPluginCatalog( + return new CompositePluginCatalog([.. Directory + .EnumerateDirectories(directory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(packageDirectory => new FolderPluginCatalog( packageDirectory, - CreateCatalogOptions( - baseOptions, - packageDirectory, - SearchOption.AllDirectories, - includeSubfolders: true))); - } - - return new CompositePluginCatalog([.. catalogs]); + CreateCatalogOptions(baseOptions, packageDirectory))) ]); } private static FolderPluginCatalogOptions CreateCatalogOptions( FolderPluginCatalogOptions baseOptions, - string pluginDirectory, - SearchOption searchOption, - bool includeSubfolders) + string pluginDirectory) { var baseLoadOptions = baseOptions.PluginLoadContextOptions; var files = Directory - .EnumerateFiles(pluginDirectory, "*", searchOption) + .EnumerateFiles(pluginDirectory, "*", SearchOption.AllDirectories) .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) .Select(path => CreatePluginFile(pluginDirectory, path)) @@ -165,7 +138,7 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( return new FolderPluginCatalogOptions { - IncludeSubfolders = includeSubfolders, + IncludeSubfolders = true, SearchPatterns = [.. baseOptions.SearchPatterns], TypeFinderOptions = baseOptions.TypeFinderOptions, PluginNameOptions = CreatePluginNameOptions( @@ -184,12 +157,12 @@ private static FolderPluginCatalogOptions CreateCatalogOptions( private static PluginFile CreatePluginFile(string pluginDirectory, string path) { - var isManaged = IsManagedAssembly(path); + var assemblyName = TryGetAssemblyName(path); return new PluginFile( path, Path.GetRelativePath(pluginDirectory, path), - isManaged, - isManaged ? TryGetAssemblyName(path) : null); + assemblyName is not null, + assemblyName); } private static PluginNameOptions CreatePluginNameOptions( @@ -280,20 +253,6 @@ void EnsureSatelliteResolver(Type type) } } - private static bool IsManagedAssembly(string path) - { - try - { - using var stream = File.OpenRead(path); - using var reader = new PEReader(stream); - return reader.HasMetadata; - } - catch (BadImageFormatException) - { - return false; - } - } - private static int GetRuntimeAssetPriority(string relativePath) => relativePath.StartsWith( $"runtimes{Path.DirectorySeparatorChar}", @@ -310,7 +269,7 @@ private static string GetSatelliteKey(string assemblyName, string cultureName) internal static void SynchronizePluginFiles( string source, string destination, - IReadOnlySet<string>? excludedRootDirectories = null) + IReadOnlySet<string> includedRootDirectories) { Directory.CreateDirectory(destination); @@ -318,13 +277,16 @@ internal static void SynchronizePluginFiles( var sourceDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase); if (Directory.Exists(source)) { - CollectSourceEntries( - source, - source, - isRoot: true, - sourceFiles, - sourceDirectories, - excludedRootDirectories); + foreach (var packageDirectory in Directory.EnumerateDirectories(source) + .Where(path => includedRootDirectories.Contains(Path.GetFileName(path)))) + { + sourceDirectories.Add(Path.GetRelativePath(source, packageDirectory)); + CollectSourceEntries( + source, + packageDirectory, + sourceFiles, + sourceDirectories); + } } foreach (var relativeDirectory in sourceDirectories.OrderBy(GetPathDepth)) @@ -379,44 +341,31 @@ internal static void SynchronizePluginFiles( } } - private static bool IsWorkingDirectory(string directoryName) - => directoryName.Equals( - NuGetPluginService.OperationsDirectoryName, - StringComparison.OrdinalIgnoreCase); - - private static bool IsManagementFile(string fileName) - => fileName.Equals("nuget-manifest.json", StringComparison.OrdinalIgnoreCase) - || fileName.StartsWith("nuget-manifest.json.tmp-", StringComparison.OrdinalIgnoreCase); - - internal static IReadOnlySet<string> GetIncompatiblePackageIds( + internal static HashSet<string> GetLoadablePackageIds( string sourceDirectory, int hostMajorVersion) { - var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); - if (!File.Exists(manifestPath)) - { - return new HashSet<string>(StringComparer.OrdinalIgnoreCase); - } - try { - using var stream = File.OpenRead(manifestPath); - var manifest = JsonSerializer.Deserialize<InstalledManifest>( + using var stream = File.OpenRead(Path.Combine( + sourceDirectory, + "nuget-manifest.json")); + var packages = JsonSerializer.Deserialize<InstalledManifest>( stream, - NuGetPluginService.ManifestJsonOptions); - return manifest?.Packages - .Where(package => !PluginCompatibility.IsHostMajorCompatible( + NuGetPluginService.ManifestJsonOptions)?.Packages + ?? throw new InvalidDataException("プラグインmanifestにパッケージ一覧がありません。"); + return packages + .Where(package => PluginCompatibility.IsHostMajorCompatible( package.HostMajorVersion, hostMajorVersion)) .Select(package => package.Id) - .ToHashSet(StringComparer.OrdinalIgnoreCase) - ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase); + .ToHashSet(StringComparer.OrdinalIgnoreCase); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or InvalidDataException or JsonException) { - // 壊れたマニフェストはNuGetPluginService側で報告する。ここでは既存動作を維持する。 return new HashSet<string>(StringComparer.OrdinalIgnoreCase); } } @@ -424,40 +373,23 @@ or UnauthorizedAccessException private static void CollectSourceEntries( string sourceRoot, string currentDirectory, - bool isRoot, Dictionary<string, string> sourceFiles, - HashSet<string> sourceDirectories, - IReadOnlySet<string>? excludedRootDirectories) + HashSet<string> sourceDirectories) { foreach (var file in Directory.EnumerateFiles(currentDirectory)) { - if (isRoot && IsManagementFile(Path.GetFileName(file))) - { - continue; - } - sourceFiles[Path.GetRelativePath(sourceRoot, file)] = file; } foreach (var subDirectory in Directory.EnumerateDirectories(currentDirectory)) { - var directoryName = Path.GetFileName(subDirectory); - if (isRoot - && (IsWorkingDirectory(directoryName) - || excludedRootDirectories?.Contains(directoryName) is true)) - { - continue; - } - var relativePath = Path.GetRelativePath(sourceRoot, subDirectory); sourceDirectories.Add(relativePath); CollectSourceEntries( sourceRoot, subDirectory, - isRoot: false, sourceFiles, - sourceDirectories, - excludedRootDirectories); + sourceDirectories); } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs index 4292c752..d75abcac 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs @@ -1,136 +1,168 @@ using System.Diagnostics; using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; +using NuGet.Packaging; namespace WindowTranslator.Modules.PluginStore; -internal enum NuGetPluginOperationKind +internal sealed record NuGetPluginOperationState( + [property: JsonRequired] string PackageId, + InstalledManifest? OriginalManifest); + +internal sealed class NuGetPluginOperation : IAsyncDisposable { - Install, - Uninstall, -} + internal const string OperationsDirectoryName = ".operations"; + private const string PendingFileName = "pending.json"; + private const string CommittedFileName = "committed.json"; + + private readonly string rootDirectory; + private readonly string operationDirectory; + private readonly NuGetPluginOperationState state; + private bool committed; + + private NuGetPluginOperation( + string rootDirectory, + string operationDirectory, + NuGetPluginOperationState state) + { + this.rootDirectory = Path.GetFullPath(rootDirectory); + this.operationDirectory = Path.GetFullPath(operationDirectory); + this.state = state; + _ = this.TargetPath; + } -internal sealed record NuGetPluginOperationState( - string OperationId, - string PackageId, - NuGetPluginOperationKind Kind, - bool ManifestExisted, - InstalledManifest OriginalManifest); + internal string TargetPath => GetPackageDirectory(this.rootDirectory, this.state.PackageId); -internal sealed record NuGetPluginOperationPaths( - string OperationId, - string PackageId, - string JournalPath, - string CommittedPath, - string StagingPath, - string BackupPath, - string UninstallingPath); + internal string WorkingPath => Path.Combine(this.operationDirectory, "working"); -internal static class NuGetPluginOperation -{ - private const string JournalSuffix = ".operation.json"; - private const string CommittedSuffix = ".committed"; + internal string BackupPath => Path.Combine(this.operationDirectory, "backup"); - internal static NuGetPluginOperationPaths CreatePaths(string nugetPluginsDir, string packageId) - => GetPaths(nugetPluginsDir, packageId, Guid.NewGuid().ToString("N")); + internal string PendingPath => Path.Combine(this.operationDirectory, PendingFileName); - internal static string GetPackageDirectory(string nugetPluginsDir, string packageId) + internal string CommittedPath => Path.Combine(this.operationDirectory, CommittedFileName); + + internal static async Task<NuGetPluginOperation> BeginAsync( + string rootDirectory, + string packageId, + InstalledManifest? originalManifest, + CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(packageId) - || packageId is "." or ".." - || packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 - || packageId.Contains(Path.DirectorySeparatorChar) - || packageId.Contains(Path.AltDirectorySeparatorChar)) + var operationDirectory = Path.Combine( + Path.GetFullPath(rootDirectory), + OperationsDirectoryName, + Guid.NewGuid().ToString("N")); + var operation = new NuGetPluginOperation( + rootDirectory, + operationDirectory, + new(packageId, originalManifest)); + Directory.CreateDirectory(operationDirectory); + Directory.CreateDirectory(operation.WorkingPath); + + try { - throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + await SaveJsonAsync( + operation.PendingPath, + operation.state, + cancellationToken).ConfigureAwait(false); + return operation; } - - var root = Path.GetFullPath(nugetPluginsDir) - .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - + Path.DirectorySeparatorChar; - var packageDirectory = Path.GetFullPath(Path.Combine(root, packageId)); - if (!packageDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + catch { - throw new InvalidOperationException($"不正なNuGetパッケージIDです: {packageId}"); + DeleteDirectoryIfExists(operationDirectory); + throw; } + } - return packageDirectory; + internal static string GetPackageDirectory(string rootDirectory, string packageId) + { + PackageIdValidator.ValidatePackageId(packageId); + return Path.Combine(Path.GetFullPath(rootDirectory), packageId); } - internal static Task WriteJournalAsync( - NuGetPluginOperationPaths paths, - NuGetPluginOperationState state, + internal static Task SaveManifestAsync( + string manifestPath, + InstalledManifest manifest, CancellationToken cancellationToken) - { - if (!paths.OperationId.Equals(state.OperationId, StringComparison.Ordinal) - || !paths.PackageId.Equals(state.PackageId, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException("NuGetプラグイン操作とジャーナルの対象が一致しません。"); - } + => SaveJsonAsync(manifestPath, manifest, cancellationToken); - return SaveJsonAsync(paths.JournalPath, state, cancellationToken); + internal void Commit() + { + File.Move(this.PendingPath, this.CommittedPath); + this.committed = true; } - internal static void MarkCommitted(NuGetPluginOperationPaths paths) + public async ValueTask DisposeAsync() { - using var stream = new FileStream( - paths.CommittedPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 1, - FileOptions.WriteThrough); - stream.Flush(flushToDisk: true); + try + { + if (this.committed) + { + CleanupCommitted(); + } + else + { + await RollbackAsync(CancellationToken.None).ConfigureAwait(false); + CleanupRolledBack(); + } + } + catch (Exception ex) + { + Trace.TraceWarning( + "NuGetプラグイン操作の後処理に失敗しました: {0} {1} ({2})", + this.state.PackageId, + Path.GetFileName(this.operationDirectory), + ex); + } } internal static async Task<IReadOnlySet<string>> RecoverInterruptedOperationsAsync( - string nugetPluginsDir, + string rootDirectory, CancellationToken cancellationToken = default) { - var operationsDir = Path.Combine( - Path.GetFullPath(nugetPluginsDir), - NuGetPluginService.OperationsDirectoryName); - if (!Directory.Exists(operationsDir)) + var operationsDirectory = Path.Combine( + Path.GetFullPath(rootDirectory), + OperationsDirectoryName); + if (!Directory.Exists(operationsDirectory)) { return new HashSet<string>(StringComparer.OrdinalIgnoreCase); } var unresolvedPackageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - foreach (var journalPath in Directory.EnumerateFiles( - operationsDir, - $"*{JournalSuffix}", - SearchOption.TopDirectoryOnly)) + foreach (var operationDirectory in Directory.EnumerateDirectories(operationsDirectory)) { cancellationToken.ThrowIfCancellationRequested(); NuGetPluginOperationState? state = null; try { - await using (var stream = File.OpenRead(journalPath)) + var committedPath = Path.Combine(operationDirectory, CommittedFileName); + var isCommitted = File.Exists(committedPath); + var statePath = isCommitted + ? committedPath + : Path.Combine(operationDirectory, PendingFileName); + if (!File.Exists(statePath)) { - state = await JsonSerializer.DeserializeAsync<NuGetPluginOperationState>( - stream, - NuGetPluginService.ManifestJsonOptions, - cancellationToken).ConfigureAwait(false) - ?? throw new InvalidDataException("NuGetプラグイン操作ジャーナルが空です。"); + DeleteDirectoryIfExists(operationDirectory); + continue; } - var paths = GetPaths(nugetPluginsDir, state.PackageId, state.OperationId); - if (!paths.JournalPath.Equals(journalPath, StringComparison.OrdinalIgnoreCase)) + + state = JsonSerializer.Deserialize<NuGetPluginOperationState>( + await File.ReadAllTextAsync(statePath, cancellationToken).ConfigureAwait(false), + NuGetPluginService.ManifestJsonOptions) + ?? throw new InvalidDataException("NuGetプラグイン操作情報が空です。"); + var operation = new NuGetPluginOperation(rootDirectory, operationDirectory, state) { - throw new InvalidDataException("NuGetプラグイン操作ジャーナルのIDが一致しません。"); + committed = isCommitted, + }; + if (isCommitted) + { + operation.CleanupCommitted(); } - - if (File.Exists(paths.CommittedPath)) + else { - CleanupCommitted(paths); - continue; + await operation.RollbackAsync(cancellationToken).ConfigureAwait(false); + operation.CleanupRolledBack(); } - - await RollbackAsync( - nugetPluginsDir, - state, - paths, - cancellationToken).ConfigureAwait(false); - CleanupRolledBack(paths); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -140,7 +172,7 @@ await RollbackAsync( } Trace.TraceWarning( "NuGetプラグイン操作の復旧に失敗しました: {0} ({1})", - journalPath, + operationDirectory, ex); } } @@ -148,41 +180,23 @@ await RollbackAsync( return unresolvedPackageIds; } - internal static async Task RollbackAsync( - string nugetPluginsDir, - NuGetPluginOperationState state, - NuGetPluginOperationPaths paths, - CancellationToken cancellationToken) + private async Task RollbackAsync(CancellationToken cancellationToken) { - var targetDir = GetPackageDirectory(nugetPluginsDir, state.PackageId); - switch (state.Kind) + if (!Directory.Exists(this.WorkingPath) && Directory.Exists(this.TargetPath)) { - case NuGetPluginOperationKind.Install: - if (!Directory.Exists(paths.StagingPath) && Directory.Exists(targetDir)) - { - Directory.Move(targetDir, paths.StagingPath); - } - if (Directory.Exists(paths.BackupPath)) - { - Directory.Move(paths.BackupPath, targetDir); - } - break; - case NuGetPluginOperationKind.Uninstall: - if (Directory.Exists(paths.UninstallingPath) && !Directory.Exists(targetDir)) - { - Directory.Move(paths.UninstallingPath, targetDir); - } - break; - default: - throw new InvalidDataException($"不明なNuGetプラグイン操作です: {state.Kind}"); + Directory.Move(this.TargetPath, this.WorkingPath); + } + if (Directory.Exists(this.BackupPath)) + { + Directory.Move(this.BackupPath, this.TargetPath); } - var manifestPath = Path.Combine(Path.GetFullPath(nugetPluginsDir), "nuget-manifest.json"); - if (state.ManifestExisted) + var manifestPath = Path.Combine(this.rootDirectory, "nuget-manifest.json"); + if (this.state.OriginalManifest is { } originalManifest) { await SaveManifestAsync( manifestPath, - state.OriginalManifest, + originalManifest, cancellationToken).ConfigureAwait(false); } else if (File.Exists(manifestPath)) @@ -191,52 +205,14 @@ await SaveManifestAsync( } } - internal static void CleanupCommitted(NuGetPluginOperationPaths paths) - { - DeleteDirectoryIfExists(paths.StagingPath); - DeleteDirectoryIfExists(paths.BackupPath); - DeleteDirectoryIfExists(paths.UninstallingPath); - File.Delete(paths.JournalPath); - File.Delete(paths.CommittedPath); - } - - internal static void CleanupRolledBack(NuGetPluginOperationPaths paths) - { - File.Delete(paths.JournalPath); - DeleteDirectoryIfExists(paths.StagingPath); - DeleteDirectoryIfExists(paths.BackupPath); - DeleteDirectoryIfExists(paths.UninstallingPath); - File.Delete(paths.CommittedPath); - } - - internal static Task SaveManifestAsync( - string manifestPath, - InstalledManifest manifest, - CancellationToken cancellationToken) - => SaveJsonAsync(manifestPath, manifest, cancellationToken); + private void CleanupCommitted() + => DeleteDirectoryIfExists(this.operationDirectory); - private static NuGetPluginOperationPaths GetPaths( - string nugetPluginsDir, - string packageId, - string operationId) + private void CleanupRolledBack() { - if (!Guid.TryParseExact(operationId, "N", out _)) - { - throw new InvalidOperationException($"不正なNuGetプラグイン操作IDです: {operationId}"); - } - _ = GetPackageDirectory(nugetPluginsDir, packageId); - - var operationsDir = Path.Combine( - Path.GetFullPath(nugetPluginsDir), - NuGetPluginService.OperationsDirectoryName); - return new( - operationId, - packageId, - Path.Combine(operationsDir, $"{operationId}{JournalSuffix}"), - Path.Combine(operationsDir, $"{operationId}{CommittedSuffix}"), - Path.Combine(operationsDir, $"{packageId}.installing-{operationId}"), - Path.Combine(operationsDir, $"{packageId}.backup-{operationId}"), - Path.Combine(operationsDir, $"{packageId}.uninstalling-{operationId}")); + // 復旧後に同じ操作を再実行しないよう、作業データより先に状態を消す。 + File.Delete(this.PendingPath); + CleanupCommitted(); } private static async Task SaveJsonAsync<T>( @@ -248,30 +224,16 @@ private static async Task SaveJsonAsync<T>( var temporaryPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}"; try { - await using (var stream = new FileStream( + var json = JsonSerializer.Serialize(value, NuGetPluginService.ManifestJsonOptions); + await File.WriteAllTextAsync( temporaryPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 4096, - useAsync: true)) - { - await JsonSerializer.SerializeAsync( - stream, - value, - NuGetPluginService.ManifestJsonOptions, - cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - + json, + cancellationToken).ConfigureAwait(false); File.Move(temporaryPath, destinationPath, overwrite: true); } finally { - if (File.Exists(temporaryPath)) - { - File.Delete(temporaryPath); - } + File.Delete(temporaryPath); } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 218841e4..e61225a6 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -23,7 +23,6 @@ public sealed class NuGetPluginService : BackgroundService internal const string HttpClientName = "NuGetPluginReadme"; internal const string PluginTag = "windowtranslator-plugin"; internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; - internal const string OperationsDirectoryName = ".operations"; private const int SearchResultLimit = 100; private const int MaxConcurrentMetadataRequests = 8; private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); @@ -47,7 +46,6 @@ public sealed class NuGetPluginService : BackgroundService private readonly AsyncSemaphore refreshLock = new(1); private readonly object snapshotLock = new(); private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; - private long installedPackagesGeneration; internal NuGetPluginService( ILogger<NuGetPluginService> logger, @@ -106,11 +104,12 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio error = ex; } - var installedGenerationBefore = Volatile.Read(ref this.installedPackagesGeneration); + using var operation = await this.operationLock.EnterAsync(cancellationToken); IReadOnlyList<InstalledPackageInfo> installedPackages; try { - installedPackages = await GetInstalledPackagesAsync(cancellationToken).ConfigureAwait(false); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + installedPackages = GetCompatibilityAwarePackages(manifest.Packages); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -119,35 +118,13 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio error = ex; } - var installedGenerationAfter = Volatile.Read(ref this.installedPackagesGeneration); - if (installedGenerationBefore != installedGenerationAfter) - { - installedPackages = this.PackageSnapshot.InstalledPackages; - } - SetPackageSnapshot( - new( - IsInitialized: true, - InstalledPackages: installedPackages, - Packages: packages, - Error: error), - installedGenerationAfter); - } - - /// <summary> - /// NuGetでWindowTranslatorプラグインを検索します。 - /// </summary> - public async Task<IReadOnlyList<NuGetPackageInfo>> SearchPackagesAsync(CancellationToken cancellationToken = default) - { - var result = await SearchPackagesCoreAsync(cancellationToken).ConfigureAwait(false); - if (result.Error is not null) - { - throw result.Error; - } - - return result.Packages; + SetPackageSnapshot(new( + InstalledPackages: installedPackages, + Packages: packages, + Error: error)); } - private async Task<PackageSearchResult> SearchPackagesCoreAsync( + private async Task<(IReadOnlyList<NuGetPackageInfo> Packages, Exception? Error)> SearchPackagesCoreAsync( CancellationToken cancellationToken) { var searchResource = await this.repository @@ -177,7 +154,7 @@ private async Task<PackageSearchResult> SearchPackagesCoreAsync( data, metadataResource, cancellationToken).ConfigureAwait(false); - return new PackageMetadataResult(package, null); + return (Package: package, Error: (Exception?)null); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -192,7 +169,7 @@ private async Task<PackageSearchResult> SearchPackagesCoreAsync( packageError, "NuGetパッケージのプラグイン互換性を確認できなかったため除外します: {PackageId}", data.Identity.Id); - return new PackageMetadataResult(null, packageError); + return (Package: (NuGetPackageInfo?)null, Error: packageError); } }); var results = await Task.WhenAll(packageTasks).ConfigureAwait(false); @@ -264,105 +241,50 @@ private async Task<PackageSearchResult> SearchPackagesCoreAsync( /// </summary> public async Task InstallPackageAsync(string packageId, string version, IProgress<double>? progress = null, CancellationToken cancellationToken = default) { - var operationPaths = NuGetPluginOperation.CreatePaths(this.nugetPluginsDir, packageId); - var targetDir = GetPackageDirectory(packageId); - NuGetPluginOperationState? operationState = null; - var journalWritten = false; - var committed = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); - try - { - var packageResource = await this.repository - .GetResourceAsync<FindPackageByIdResource>(cancellationToken) - .ConfigureAwait(false); - var installer = new NuGetPackageInstaller( - packageResource, - this.logger, - this.hostPackageVersions); - await installer.InstallAsync( - packageId, - version, - operationPaths.StagingPath, - progress, - cancellationToken).ConfigureAwait(false); - - var manifestExisted = File.Exists(this.manifestPath); - var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var updatedManifest = AddOrUpdatePackage(currentManifest, packageId, version); - operationState = new( - operationPaths.OperationId, - packageId, - NuGetPluginOperationKind.Install, - manifestExisted, - currentManifest); - await NuGetPluginOperation.WriteJournalAsync( - operationPaths, - operationState, - cancellationToken).ConfigureAwait(false); - journalWritten = true; - - if (Directory.Exists(targetDir)) - { - Directory.Move(targetDir, operationPaths.BackupPath); - } - - Directory.Move(operationPaths.StagingPath, targetDir); - await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); - NuGetPluginOperation.MarkCommitted(operationPaths); - committed = true; - UpdateInstalledPackages(updatedManifest.Packages); + var manifestExisted = File.Exists(this.manifestPath); + var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + await using var pluginOperation = await NuGetPluginOperation.BeginAsync( + this.nugetPluginsDir, + packageId, + manifestExisted ? currentManifest : null, + cancellationToken).ConfigureAwait(false); - try - { - NuGetPluginOperation.CleanupCommitted(operationPaths); - journalWritten = false; - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "完了したプラグイン操作の後片付けに失敗しました: {PackageId} {OperationId}", - packageId, - operationPaths.OperationId); - } + var packageResource = await this.repository + .GetResourceAsync<FindPackageByIdResource>(cancellationToken) + .ConfigureAwait(false); + var installer = new NuGetPackageInstaller( + packageResource, + this.logger, + this.hostPackageVersions); + await installer.InstallAsync( + packageId, + version, + pluginOperation.WorkingPath, + progress, + cancellationToken).ConfigureAwait(false); - this.logger.LogInformation( - "パッケージのインストール完了: {PackageId} {Version} -> {TargetDir}", - packageId, - version, - targetDir); - } - catch + if (Directory.Exists(pluginOperation.TargetPath)) { - if (journalWritten && !committed && operationState is not null) - { - try - { - await NuGetPluginOperation.RollbackAsync( - this.nugetPluginsDir, - operationState, - operationPaths, - CancellationToken.None).ConfigureAwait(false); - NuGetPluginOperation.CleanupRolledBack(operationPaths); - journalWritten = false; - } - catch (Exception rollbackException) - { - this.logger.LogError( - rollbackException, - "プラグイン {PackageId} のインストール失敗後の復旧に失敗しました。", - packageId); - } - } - throw; - } - finally - { - if (!journalWritten) - { - TryDeleteDirectory(operationPaths.StagingPath); - } + Directory.Move(pluginOperation.TargetPath, pluginOperation.BackupPath); } + Directory.Move(pluginOperation.WorkingPath, pluginOperation.TargetPath); + + var updatedManifest = new InstalledManifest( + [ + .. currentManifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)), + new(packageId, version, this.hostMajorVersion), + ]); + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + pluginOperation.Commit(); + UpdateInstalledPackages(updatedManifest.Packages); + + this.logger.LogInformation( + "パッケージのインストール完了: {PackageId} {Version} -> {TargetDir}", + packageId, + version, + pluginOperation.TargetPath); } /// <summary> @@ -371,77 +293,18 @@ await NuGetPluginOperation.RollbackAsync( /// </summary> public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) { - var operationPaths = NuGetPluginOperation.CreatePaths(this.nugetPluginsDir, packageId); - var targetDir = GetPackageDirectory(packageId); - NuGetPluginOperationState? operationState = null; - var journalWritten = false; - var committed = false; using var operation = await this.operationLock.EnterAsync(cancellationToken); this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = new InstalledManifest([.. manifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + UpdateInstalledPackages(updatedManifest.Packages); - try - { - var manifestExisted = File.Exists(this.manifestPath); - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var updatedManifest = RemovePackage(manifest, packageId); - operationState = new( - operationPaths.OperationId, - packageId, - NuGetPluginOperationKind.Uninstall, - manifestExisted, - manifest); - await NuGetPluginOperation.WriteJournalAsync( - operationPaths, - operationState, - cancellationToken).ConfigureAwait(false); - journalWritten = true; - - if (Directory.Exists(targetDir)) - { - Directory.Move(targetDir, operationPaths.UninstallingPath); - } - - await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); - NuGetPluginOperation.MarkCommitted(operationPaths); - committed = true; - UpdateInstalledPackages(updatedManifest.Packages); - - try - { - NuGetPluginOperation.CleanupCommitted(operationPaths); - journalWritten = false; - } - catch (Exception ex) - { - this.logger.LogWarning( - ex, - "完了したプラグイン操作の後片付けに失敗しました: {PackageId} {OperationId}", - packageId, - operationPaths.OperationId); - } - } - catch + var targetPath = NuGetPluginOperation.GetPackageDirectory(this.nugetPluginsDir, packageId); + if (Directory.Exists(targetPath)) { - if (journalWritten && !committed && operationState is not null) - { - try - { - await NuGetPluginOperation.RollbackAsync( - this.nugetPluginsDir, - operationState, - operationPaths, - CancellationToken.None).ConfigureAwait(false); - NuGetPluginOperation.CleanupRolledBack(operationPaths); - } - catch (Exception rollbackException) - { - this.logger.LogError( - rollbackException, - "プラグイン {PackageId} のアンインストール失敗後の復旧に失敗しました。", - packageId); - } - } - throw; + Directory.Delete(targetPath, recursive: true); } this.logger.LogInformation( @@ -449,16 +312,6 @@ await NuGetPluginOperation.RollbackAsync( packageId); } - /// <summary> - /// インストール済みのパッケージ一覧を取得します。 - /// </summary> - public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync(CancellationToken cancellationToken = default) - { - using var operation = await this.operationLock.EnterAsync(cancellationToken); - var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - return GetCompatibilityAwarePackages(manifest.Packages); - } - private async Task<NuGetPackageInfo?> CreateCompatiblePackageInfoAsync( IPackageSearchMetadata data, PackageMetadataResource metadataResource, @@ -487,10 +340,8 @@ public async Task<IReadOnlyList<InstalledPackageInfo>> GetInstalledPackagesAsync return null; } - var latestVersion = compatibleVersions[^1].Identity.Version.ToNormalizedString(); return new NuGetPackageInfo( Id: packageId, - Version: latestVersion, Title: data.Title ?? packageId, Description: data.Description ?? string.Empty, Authors: data.Authors ?? string.Empty, @@ -516,29 +367,6 @@ private bool HasCompatibleAbstractionsDependency( return PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion); } - private InstalledManifest AddOrUpdatePackage( - InstalledManifest manifest, - string packageId, - string version) - { - var packages = manifest.Packages.ToList(); - var existing = packages.FindIndex(p => p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); - var newEntry = new InstalledPackageInfo( - packageId, - version, - this.hostMajorVersion); - if (existing >= 0) - { - packages[existing] = newEntry; - } - else - { - packages.Add(newEntry); - } - - return new InstalledManifest([.. packages]); - } - internal static IReadOnlyDictionary<string, NuGetVersion> CreateHostPackageVersions() { var abstractionsAssembly = typeof(UserSettings).Assembly; @@ -582,7 +410,6 @@ private void UpdateInstalledPackages(IEnumerable<InstalledPackageInfo> packages) { lock (this.snapshotLock) { - this.installedPackagesGeneration++; this.packageSnapshot = this.packageSnapshot with { InstalledPackages = GetCompatibilityAwarePackages(packages), @@ -591,20 +418,10 @@ private void UpdateInstalledPackages(IEnumerable<InstalledPackageInfo> packages) NotifyPackageInformationUpdated(); } - private void SetPackageSnapshot( - PluginStoreSnapshot snapshot, - long? expectedInstalledPackagesGeneration = null) + private void SetPackageSnapshot(PluginStoreSnapshot snapshot) { lock (this.snapshotLock) { - if (expectedInstalledPackagesGeneration is not null - && expectedInstalledPackagesGeneration != this.installedPackagesGeneration) - { - snapshot = snapshot with - { - InstalledPackages = this.packageSnapshot.InstalledPackages, - }; - } this.packageSnapshot = snapshot; } @@ -627,10 +444,6 @@ private void NotifyPackageInformationUpdated() } } - private static InstalledManifest RemovePackage(InstalledManifest manifest, string packageId) - => new([.. manifest.Packages.Where(p => - !p.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); - private async Task<InstalledManifest> LoadManifestAsync(CancellationToken cancellationToken) { if (!File.Exists(this.manifestPath)) @@ -667,43 +480,17 @@ private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken can manifest, cancellationToken); - private string GetPackageDirectory(string packageId) - => NuGetPluginOperation.GetPackageDirectory(this.nugetPluginsDir, packageId); - - private static void TryDeleteDirectory(string directory) - { - try - { - if (Directory.Exists(directory)) - { - Directory.Delete(directory, recursive: true); - } - } - catch - { - // 後始末の失敗は元の処理結果へ影響させない - } - } - - private sealed record PackageMetadataResult( - NuGetPackageInfo? Package, - Exception? Error); - - private sealed record PackageSearchResult( - IReadOnlyList<NuGetPackageInfo> Packages, - Exception? Error); } /// <summary>NuGetパッケージ情報</summary> public record NuGetPackageInfo( string Id, - string Version, string Title, string Description, string Authors, string? ProjectUrl, string? LicenseUrl, - IReadOnlyList<string>? Versions = null + IReadOnlyList<string> Versions ); /// <summary>インストール済みパッケージ情報</summary> @@ -720,10 +507,9 @@ public record InstalledPackageInfo( public record InstalledManifest(List<InstalledPackageInfo> Packages); internal sealed record PluginStoreSnapshot( - bool IsInitialized, IReadOnlyList<InstalledPackageInfo> InstalledPackages, IReadOnlyList<NuGetPackageInfo> Packages, Exception? Error) { - public static PluginStoreSnapshot Empty { get; } = new(false, [], [], null); + public static PluginStoreSnapshot Empty { get; } = new([], [], null); } diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 4f9e77fb..aa509c85 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -4,17 +4,21 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ctrl="clr-namespace:WindowTranslator.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:data="clr-namespace:WindowTranslator.Data" xmlns:local="clr-namespace:WindowTranslator.Modules.PluginStore" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:md="https://github.com/whistyun/MdXaml" xmlns:mdp="clr-namespace:MdXaml.Plugins;assembly=MdXaml.Plugins" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:properties="clr-namespace:WindowTranslator.Properties" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" d:DataContext="{d:DesignInstance {x:Type local:PluginStoreViewModel}}" mc:Ignorable="d"> <UserControl.Resources> <BooleanToVisibilityConverter x:Key="b2vConv" /> - <Style x:Key="InstallButtonStyle" BasedOn="{StaticResource {x:Type ui:Button}}" TargetType="ui:Button"> + <Style + x:Key="InstallButtonStyle" + BasedOn="{StaticResource {x:Type ui:Button}}" + TargetType="ui:Button"> <Setter Property="Margin" Value="4,2" /> <Setter Property="MinWidth" Value="90" /> </Style> @@ -31,17 +35,10 @@ Grid.Row="0" Margin="4" IsClosable="False" - IsOpen="{Binding ErrorMessage, Converter={x:Static local:NotNullToBoolConverter.Default}}" + IsOpen="{Binding HasError}" Message="{Binding ErrorMessage}" Severity="Error" /> - <!-- ローディング中 --> - <ProgressBar - Grid.Row="0" - Height="4" - IsIndeterminate="True" - Visibility="{Binding IsLoading, Converter={StaticResource b2vConv}}" /> - <!-- パッケージ一覧と詳細 --> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> @@ -55,8 +52,8 @@ Grid.Column="0" Margin="4" ItemsSource="{Binding Packages}" - SelectedItem="{Binding SelectedPackage}" - ScrollViewer.HorizontalScrollBarVisibility="Hidden"> + ScrollViewer.HorizontalScrollBarVisibility="Hidden" + SelectedItem="{Binding SelectedPackage}"> <ui:ListView.ItemContainerStyle> <Style BasedOn="{StaticResource {x:Type ui:ListViewItem}}" TargetType="ui:ListViewItem"> <Setter Property="HorizontalContentAlignment" Value="Stretch" /> @@ -64,103 +61,34 @@ </ui:ListView.ItemContainerStyle> <ui:ListView.ItemTemplate> <DataTemplate DataType="{x:Type local:PluginPackageViewModel}"> - <Grid Margin="4,2"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - + <StackPanel Orientation="Vertical"> <!-- タイトルと状態 --> - <StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal"> + <!-- バージョン情報 --> + <DockPanel> + <ui:InfoBadge + Margin="4" + CornerRadius="20" + DockPanel.Dock="Right" + Icon="{ui:SymbolIcon Checkmark16}" + Severity="Success" + Style="{DynamicResource IconInfoBadgeStyle}" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + <ui:InfoBadge + Margin="4" + CornerRadius="20" + DockPanel.Dock="Right" + Icon="{ui:SymbolIcon Alert16}" + Severity="Caution" + Style="{DynamicResource IconInfoBadgeStyle}" + Visibility="{Binding IsUpdateAvailable, Converter={StaticResource b2vConv}}" /> <ui:TextBlock VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Title}" TextTrimming="CharacterEllipsis" /> - <ui:Badge - Margin="8,0,0,0" - VerticalAlignment="Center" - Appearance="Success" - Content="{x:Static properties:Resources.Installed}" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - <ui:Badge - Margin="8,0,0,0" - VerticalAlignment="Center" - Appearance="Caution" - Content="{x:Static properties:Resources.UpdateAvailable}" - Visibility="{Binding IsUpdateAvailable, Converter={StaticResource b2vConv}}" /> - </StackPanel> - - <!-- バージョン情報 --> - <ui:TextBlock - Grid.Row="1" - Grid.Column="0" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text="{Binding StatusText}" - TextTrimming="CharacterEllipsis" /> - - <CheckBox - Grid.Row="2" - Grid.Column="0" - Margin="0,2,0,0" - Content="{x:Static properties:Resources.Prerelease}" - IsEnabled="{Binding IsInstalling, Converter={x:Static local:InverseBoolConverter.Default}}" - IsChecked="{Binding UsePrerelease, Mode=TwoWay}" - ToolTip="{Binding PrereleaseVersion}" - Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> - - <!-- インストールボタン --> - <StackPanel - Grid.Row="0" - Grid.RowSpan="3" - Grid.Column="1" - VerticalAlignment="Center" - Orientation="Horizontal"> - <!-- インストールプログレス --> - <ProgressBar - Width="60" - Height="4" - Margin="4,0" - VerticalAlignment="Center" - Value="{Binding InstallProgress}" - Visibility="{Binding IsInstalling, Converter={StaticResource b2vConv}}" /> - - <!-- インストールボタン(未インストール) --> - <ui:Button - Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" - CommandParameter="{Binding}" - Content="{x:Static properties:Resources.Install}" - Icon="{ui:SymbolIcon ArrowDownload24}" - IsEnabled="{Binding CanInstall}" - Style="{StaticResource InstallButtonStyle}" - Visibility="{Binding IsInstalled, Converter={x:Static local:InverseBoolConverter.Default}, ConverterParameter=Visibility}" /> - - <!-- 更新ボタン(更新あり) --> - <ui:Button - Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" - CommandParameter="{Binding}" - Content="{x:Static properties:Resources.Update}" - Icon="{ui:SymbolIcon ArrowSync24}" - IsEnabled="{Binding CanInstall}" - Style="{StaticResource InstallButtonStyle}" - Visibility="{Binding CanUpdate, Converter={StaticResource b2vConv}}" /> - - <!-- アンインストールボタン(インストール済み) --> - <ui:Button - Command="{Binding DataContext.UninstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" - CommandParameter="{Binding}" - Content="{x:Static properties:Resources.Uninstall}" - Icon="{ui:SymbolIcon Delete24}" - IsEnabled="{Binding IsInstalling, Converter={x:Static local:InverseBoolConverter.Default}}" - Style="{StaticResource InstallButtonStyle}" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - </StackPanel> - </Grid> + </DockPanel> + <ui:TextBlock Text="{Binding Description}" TextWrapping="Wrap" /> + </StackPanel> </DataTemplate> </ui:ListView.ItemTemplate> </ui:ListView> @@ -232,8 +160,8 @@ <Label Grid.Row="0" Grid.Column="0" - Content="{x:Static properties:Resources.LatestVersion}" - Padding="0,4,8,4" /> + Padding="0,4,8,4" + Content="{x:Static properties:Resources.LatestVersion}" /> <ui:TextBlock Grid.Row="0" Grid.Column="1" @@ -243,8 +171,8 @@ <Label Grid.Row="1" Grid.Column="0" - Content="{x:Static properties:Resources.InstalledVersionLabel}" Padding="0,4,8,4" + Content="{x:Static properties:Resources.InstalledVersionLabel}" Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> <ui:TextBlock Grid.Row="1" @@ -253,6 +181,56 @@ Text="{Binding InstalledVersion}" Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> </Grid> + <DockPanel> + <CheckBox + Margin="0,2,0,0" + Content="{x:Static properties:Resources.Prerelease}" + IsChecked="{Binding UsePrerelease, Mode=TwoWay}" + IsEnabled="{Binding IsInstalling, Converter={x:Static data:InverseBoolConverter.Default}}" + ToolTip="{Binding PrereleaseVersion}" + Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> + <!-- インストールプログレス --> + <ui:ProgressRing + Height="20" + Margin="4,0" + VerticalAlignment="Center" + DockPanel.Dock="Right" + Progress="{Binding InstallProgress}" + Visibility="{Binding IsInstalling, Converter={StaticResource b2vConv}}" /> + + <!-- インストールボタン(未インストール) --> + <ui:Button + Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" + CommandParameter="{Binding}" + Content="{x:Static properties:Resources.Install}" + DockPanel.Dock="Right" + Icon="{ui:SymbolIcon ArrowDownload24}" + IsEnabled="{Binding CanInstall}" + Style="{StaticResource InstallButtonStyle}" + Visibility="{Binding IsNotInstalled, Converter={StaticResource b2vConv}}" /> + + <!-- 更新ボタン(更新あり) --> + <ui:Button + Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" + CommandParameter="{Binding}" + Content="{x:Static properties:Resources.Update}" + DockPanel.Dock="Right" + Icon="{ui:SymbolIcon ArrowSync24}" + IsEnabled="{Binding CanInstall}" + Style="{StaticResource InstallButtonStyle}" + Visibility="{Binding CanUpdate, Converter={StaticResource b2vConv}}" /> + + <!-- アンインストールボタン(インストール済み) --> + <ui:Button + Command="{Binding DataContext.UninstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" + CommandParameter="{Binding}" + Content="{x:Static properties:Resources.Uninstall}" + DockPanel.Dock="Right" + Icon="{ui:SymbolIcon Delete24}" + IsEnabled="{Binding IsInstalling, Converter={x:Static data:InverseBoolConverter.Default}}" + Style="{StaticResource InstallButtonStyle}" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + </DockPanel> <Separator Margin="0,4,0,8" /> @@ -261,13 +239,13 @@ Content="{x:Static properties:Resources.ProjectUrl}" Icon="{ui:SymbolIcon Globe24}" NavigateUri="{Binding ProjectUrl}" - Visibility="{Binding ProjectUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> + Visibility="{Binding HasProjectUrl, Converter={StaticResource b2vConv}}" /> <ui:HyperlinkButton Content="{x:Static properties:Resources.LicenseUrl}" Icon="{ui:SymbolIcon Document24}" NavigateUri="{Binding LicenseUrl}" - Visibility="{Binding LicenseUrl, Converter={x:Static local:NotNullToVisibilityConverter.Default}}" /> + Visibility="{Binding HasLicenseUrl, Converter={StaticResource b2vConv}}" /> </StackPanel> </ScrollViewer> diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs index 956ef27b..78bce56d 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs @@ -1,7 +1,3 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Data; - namespace WindowTranslator.Modules.PluginStore; /// <summary> @@ -9,72 +5,8 @@ namespace WindowTranslator.Modules.PluginStore; /// </summary> public partial class PluginStoreView { - private bool loaded; - public PluginStoreView() { InitializeComponent(); - this.IsVisibleChanged += OnIsVisibleChanged; - } - - private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) - { - if (this.loaded || !this.IsVisible) - return; - this.loaded = true; - if (this.DataContext is PluginStoreViewModel vm) - { - _ = vm.LoadCommand.ExecuteAsync(null); - } - } -} - -/// <summary> -/// null でない場合に true を返すコンバーター -/// </summary> -[ValueConversion(typeof(object), typeof(bool))] -public sealed class NotNullToBoolConverter : IValueConverter -{ - public static NotNullToBoolConverter Default { get; } = new(); - - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - => value is not null; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// <summary> -/// null でない場合に Visible を返すコンバーター -/// </summary> -[ValueConversion(typeof(object), typeof(Visibility))] -public sealed class NotNullToVisibilityConverter : IValueConverter -{ - public static NotNullToVisibilityConverter Default { get; } = new(); - - public object Convert(object? value, Type targetType, object parameter, CultureInfo culture) - => value is not null ? Visibility.Visible : Visibility.Collapsed; - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} - -/// <summary> -/// bool を反転するコンバーター(Visibility対応) -/// </summary> -[ValueConversion(typeof(bool), typeof(object))] -public sealed class InverseBoolConverter : IValueConverter -{ - public static InverseBoolConverter Default { get; } = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - var inverseBool = value is bool b && !b; - if (parameter is string p && p == "Visibility") - return inverseBool ? Visibility.Visible : Visibility.Collapsed; - return inverseBool; } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - => value is bool b && !b; } diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 2adcbbfa..8b39cf78 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -26,11 +26,11 @@ public partial class PluginStoreViewModel : ObservableObject, IDisposable private bool disposed; [ObservableProperty] - private bool isLoading; - - [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] private string? errorMessage; + public bool HasError => this.ErrorMessage is not null; + public PluginPackageViewModel? SelectedPackage { get => this.selectedPackage; @@ -71,44 +71,7 @@ public PluginStoreViewModel( this.logger = logger; this.dialogService = dialogService; this.nugetService.PackageInformationUpdated += OnPackageInformationUpdated; - } - - /// <summary> - /// プラグイン一覧を読み込みます。 - /// </summary> - [RelayCommand] - public async Task LoadAsync(CancellationToken cancellationToken = default) - { - if (this.IsLoading) - return; - - this.IsLoading = true; - this.ErrorMessage = null; - - try - { - if (!this.nugetService.PackageSnapshot.IsInitialized) - { - await this.nugetService - .RefreshPackageInformationAsync(cancellationToken) - .ConfigureAwait(true); - } - - ApplyPackageSnapshot(this.nugetService.PackageSnapshot); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // キャンセルは正常 - } - catch (Exception ex) - { - this.logger.LogError(ex, "NuGet検索に失敗しました。"); - this.ErrorMessage = Resources.NuGetSearchFailed; - } - finally - { - this.IsLoading = false; - } + ApplyPackageSnapshot(this.nugetService.PackageSnapshot); } private void OnPackageInformationUpdated(object? sender, EventArgs e) @@ -186,13 +149,13 @@ private void ApplyPackageSnapshot(PluginStoreSnapshot snapshot) this.Packages.Add(new PluginPackageViewModel( new NuGetPackageInfo( - installedPackage.Id, - installedPackage.Version, - installedPackage.Id, - string.Empty, - string.Empty, - null, - null), + Id: installedPackage.Id, + Title: installedPackage.Id, + Description: string.Empty, + Authors: string.Empty, + ProjectUrl: null, + LicenseUrl: null, + Versions: []), isInstalled: true, installedVersion: installedPackage.Version, isCompatible: installedPackage.IsCompatible, @@ -443,13 +406,18 @@ public partial class PluginPackageViewModel : ObservableObject public bool CanUpdate => this.IsUpdateAvailable || this.RequiresReinstall; public string? ProjectUrl { get; } public string? LicenseUrl { get; } + public bool HasProjectUrl => this.ProjectUrl is not null; + public bool HasLicenseUrl => this.LicenseUrl is not null; private readonly bool hasCompatiblePackageVersion; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsNotInstalled))] [NotifyPropertyChangedFor(nameof(RequiresReinstall))] [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool isInstalled; + public bool IsNotInstalled => !this.IsInstalled; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusText))] private string? installedVersion; @@ -512,8 +480,7 @@ public PluginPackageViewModel( bool isCompatible = true, bool hasCompatiblePackageVersion = true) { - var versions = new[] { info.Version } - .Concat(info.Versions ?? []) + var versions = info.Versions .Where(version => !string.IsNullOrWhiteSpace(version)) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(version => (Text: version, Parsed: ParseVersion(version))) diff --git a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs index 30d8714f..3fc20909 100644 --- a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs @@ -27,29 +27,15 @@ public async Task Initialize() /// <inheritdoc/> public List<Plugin> GetPlugins() { - var selectedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - return SelectPluginsByAssembly( - this.preferredCatalog.GetPlugins(), - selectedAssemblyNames) - .Concat(SelectPluginsByAssembly( - this.fallbackCatalog.GetPlugins(), - selectedAssemblyNames)) + var plugins = this.preferredCatalog.GetPlugins() + .Concat(this.fallbackCatalog.GetPlugins()) .ToList(); - } - - private static List<Plugin> SelectPluginsByAssembly( - List<Plugin> plugins, - HashSet<string> selectedAssemblyNames) - { + var selectedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var selectedAssemblies = new HashSet<Assembly>(ReferenceEqualityComparer.Instance); - foreach (var plugin in plugins) + foreach (var assembly in plugins + .Select(plugin => plugin.Type.Assembly) + .Distinct<Assembly>(ReferenceEqualityComparer.Instance)) { - var assembly = plugin.Type.Assembly; - if (selectedAssemblies.Contains(assembly)) - { - continue; - } - var assemblyName = assembly.GetName().Name; if (assemblyName is null || selectedAssemblyNames.Add(assemblyName)) { From ba9e31c8bcb9e222e88aecad4dbacdb231cc6dae Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 9 Aug 2026 16:33:30 +0900 Subject: [PATCH 19/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E7=94=BB=E9=9D=A2=E3=81=AE?= =?UTF-8?q?UI=E3=83=AC=E3=82=A4=E3=82=A2=E3=82=A6=E3=83=88=E3=82=92?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=83=BB=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit パッケージ一覧と詳細パネルのカラム順序を見直し、一覧のスクロールバー非表示化やアイテムのマージン・テキスト折り返し設定を最適化しました。詳細パネルはGridベースでボタンやバージョン情報、プログレスリング等の配置を整理し、リンクボタンも横並びに統一。全体的に視認性と一貫性を高めるリファクタリングを実施しています。 --- .../Modules/PluginStore/PluginStoreView.xaml | 118 ++++++++++-------- 1 file changed, 67 insertions(+), 51 deletions(-) diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index aa509c85..46102c04 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -42,9 +42,9 @@ <!-- パッケージ一覧と詳細 --> <Grid Grid.Row="1"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" MinWidth="200" /> - <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="300" MinWidth="200" /> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" MinWidth="200" /> </Grid.ColumnDefinitions> <!-- パッケージ一覧 --> @@ -52,7 +52,7 @@ Grid.Column="0" Margin="4" ItemsSource="{Binding Packages}" - ScrollViewer.HorizontalScrollBarVisibility="Hidden" + ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectedItem="{Binding SelectedPackage}"> <ui:ListView.ItemContainerStyle> <Style BasedOn="{StaticResource {x:Type ui:ListViewItem}}" TargetType="ui:ListViewItem"> @@ -61,10 +61,10 @@ </ui:ListView.ItemContainerStyle> <ui:ListView.ItemTemplate> <DataTemplate DataType="{x:Type local:PluginPackageViewModel}"> - <StackPanel Orientation="Vertical"> + <StackPanel Margin="4"> <!-- タイトルと状態 --> <!-- バージョン情報 --> - <DockPanel> + <DockPanel LastChildFill="True"> <ui:InfoBadge Margin="4" CornerRadius="20" @@ -85,9 +85,16 @@ VerticalAlignment="Center" FontWeight="SemiBold" Text="{Binding Title}" - TextTrimming="CharacterEllipsis" /> + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> </DockPanel> - <ui:TextBlock Text="{Binding Description}" TextWrapping="Wrap" /> + <ui:TextBlock + Grid.Row="1" + HorizontalAlignment="Stretch" + VerticalAlignment="Top" + FontSize="12" + Text="{Binding Description}" + TextWrapping="Wrap" /> </StackPanel> </DataTemplate> </ui:ListView.ItemTemplate> @@ -146,60 +153,50 @@ Text="{Binding Description}" TextWrapping="Wrap" /> + <DockPanel HorizontalAlignment="Stretch"> + <CheckBox + Margin="0,2,0,0" + Content="{x:Static properties:Resources.Prerelease}" + IsChecked="{Binding UsePrerelease, Mode=TwoWay}" + IsEnabled="{Binding IsInstalling, Converter={x:Static data:InverseBoolConverter.Default}}" + ToolTip="{Binding PrereleaseVersion}" + Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> + <!-- インストールプログレス --> + <ui:ProgressRing + Height="20" + Margin="4,0" + VerticalAlignment="Center" + DockPanel.Dock="Right" + Progress="{Binding InstallProgress}" + Visibility="{Binding IsInstalling, Converter={StaticResource b2vConv}}" /> + + </DockPanel> <!-- バージョン情報 --> <Grid Margin="0,4"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <Label Grid.Row="0" Grid.Column="0" - Padding="0,4,8,4" Content="{x:Static properties:Resources.LatestVersion}" /> <ui:TextBlock Grid.Row="0" Grid.Column="1" VerticalAlignment="Center" Text="{Binding LatestVersion}" /> - - <Label - Grid.Row="1" - Grid.Column="0" - Padding="0,4,8,4" - Content="{x:Static properties:Resources.InstalledVersionLabel}" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - <ui:TextBlock - Grid.Row="1" - Grid.Column="1" - VerticalAlignment="Center" - Text="{Binding InstalledVersion}" - Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - </Grid> - <DockPanel> - <CheckBox - Margin="0,2,0,0" - Content="{x:Static properties:Resources.Prerelease}" - IsChecked="{Binding UsePrerelease, Mode=TwoWay}" - IsEnabled="{Binding IsInstalling, Converter={x:Static data:InverseBoolConverter.Default}}" - ToolTip="{Binding PrereleaseVersion}" - Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> - <!-- インストールプログレス --> - <ui:ProgressRing - Height="20" - Margin="4,0" - VerticalAlignment="Center" - DockPanel.Dock="Right" - Progress="{Binding InstallProgress}" - Visibility="{Binding IsInstalling, Converter={StaticResource b2vConv}}" /> - <!-- インストールボタン(未インストール) --> <ui:Button + Grid.Row="0" + Grid.Column="2" Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" Content="{x:Static properties:Resources.Install}" @@ -211,6 +208,8 @@ <!-- 更新ボタン(更新あり) --> <ui:Button + Grid.Row="0" + Grid.Column="2" Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" Content="{x:Static properties:Resources.Update}" @@ -220,8 +219,22 @@ Style="{StaticResource InstallButtonStyle}" Visibility="{Binding CanUpdate, Converter={StaticResource b2vConv}}" /> + <Label + Grid.Row="1" + Grid.Column="0" + Content="{x:Static properties:Resources.InstalledVersionLabel}" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + <ui:TextBlock + Grid.Row="1" + Grid.Column="1" + VerticalAlignment="Center" + Text="{Binding InstalledVersion}" + Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> + <!-- アンインストールボタン(インストール済み) --> <ui:Button + Grid.Row="1" + Grid.Column="2" Command="{Binding DataContext.UninstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" Content="{x:Static properties:Resources.Uninstall}" @@ -230,22 +243,25 @@ IsEnabled="{Binding IsInstalling, Converter={x:Static data:InverseBoolConverter.Default}}" Style="{StaticResource InstallButtonStyle}" Visibility="{Binding IsInstalled, Converter={StaticResource b2vConv}}" /> - </DockPanel> - <Separator Margin="0,4,0,8" /> + </Grid> + + <Separator Margin="0,4,0,4" /> <!-- リンク --> - <ui:HyperlinkButton - Content="{x:Static properties:Resources.ProjectUrl}" - Icon="{ui:SymbolIcon Globe24}" - NavigateUri="{Binding ProjectUrl}" - Visibility="{Binding HasProjectUrl, Converter={StaticResource b2vConv}}" /> + <StackPanel HorizontalAlignment="Right" Orientation="Horizontal"> + <ui:HyperlinkButton + Content="{x:Static properties:Resources.ProjectUrl}" + Icon="{ui:SymbolIcon Globe24}" + NavigateUri="{Binding ProjectUrl}" + Visibility="{Binding HasProjectUrl, Converter={StaticResource b2vConv}}" /> - <ui:HyperlinkButton - Content="{x:Static properties:Resources.LicenseUrl}" - Icon="{ui:SymbolIcon Document24}" - NavigateUri="{Binding LicenseUrl}" - Visibility="{Binding HasLicenseUrl, Converter={StaticResource b2vConv}}" /> + <ui:HyperlinkButton + Content="{x:Static properties:Resources.LicenseUrl}" + Icon="{ui:SymbolIcon Document24}" + NavigateUri="{Binding LicenseUrl}" + Visibility="{Binding HasLicenseUrl, Converter={StaticResource b2vConv}}" /> + </StackPanel> </StackPanel> </ScrollViewer> From 97372a2c08f550c80a2b1d4fb4a68dc75fb9e124 Mon Sep 17 00:00:00 2001 From: Freesia <freesia@studiofreesia.com> Date: Sun, 9 Aug 2026 20:05:38 +0900 Subject: [PATCH 20/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2UI=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E3=83=BB=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3/=E5=85=8D?= =?UTF-8?q?=E8=B2=AC=E4=BA=8B=E9=A0=85=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit プラグインストアUIを刷新し、各プラグインにアイコン(plugin-icon.png)やNuGetパッケージのアイコンURLを表示するよう変更しました。パッケージ一覧・詳細に公式バッジとアイコンを追加。ストア下部に多言語対応の免責事項を表示し、「今後表示しない」設定(UserSettings: HidePluginStoreDisclaimer)を追加。各プラグインプロジェクトのTitleを簡潔化。NuGetPackageInfoへIconUrlプロパティ追加、NuGetPluginService・テストも対応。plugin-icon.pngのビルド設定修正、多言語リソース(PluginStoreDisclaimer)を全言語分追加。 --- Plugins/Directory.Build.targets | 5 + ...tor.Plugin.BergamotTranslatorPlugin.csproj | 2 +- ...wTranslator.Plugin.ColorThiefPlugin.csproj | 2 +- ...nslator.Plugin.DeepLTranslatePlugin.csproj | 2 +- .../WindowTranslator.Plugin.FoMPlugin.csproj | 2 +- ...anslator.Plugin.GitHubCopilotPlugin.csproj | 2 +- ...dowTranslator.Plugin.GoogleAIPlugin.csproj | 2 +- ...lator.Plugin.GoogleAppsSctiptPlugin.csproj | 2 +- .../WindowTranslator.Plugin.LLMPlugin.csproj | 2 +- ...indowTranslator.Plugin.OneOcrPlugin.csproj | 2 +- ...WindowTranslator.Plugin.PLaMoPlugin.csproj | 2 +- ...ranslator.Plugin.TesseractOCRPlugin.csproj | 2 +- Plugins/plugin-icon.png | Bin 0 -> 75130 bytes WindowTranslator.Abstractions/UserSettings.cs | 5 + .../NuGetPluginServiceTests.cs | 29 ++- .../UserSettingsConfigurationTests.cs | 2 + .../Modules/PluginStore/NuGetPluginService.cs | 6 +- .../Modules/PluginStore/PluginStoreView.xaml | 237 +++++++++++++----- .../PluginStore/PluginStoreViewModel.cs | 9 + .../Modules/Settings/AllSettingsViewModel.cs | 2 + .../Properties/Resources.Designer.cs | 5 + WindowTranslator/Properties/Resources.ar.resx | 3 + WindowTranslator/Properties/Resources.cs.resx | 3 + WindowTranslator/Properties/Resources.de.resx | 3 + WindowTranslator/Properties/Resources.en.resx | 3 + WindowTranslator/Properties/Resources.es.resx | 3 + WindowTranslator/Properties/Resources.fa.resx | 3 + .../Properties/Resources.fil.resx | 3 + WindowTranslator/Properties/Resources.fr.resx | 3 + WindowTranslator/Properties/Resources.hi.resx | 3 + WindowTranslator/Properties/Resources.hu.resx | 3 + WindowTranslator/Properties/Resources.id.resx | 3 + WindowTranslator/Properties/Resources.ko.resx | 3 + WindowTranslator/Properties/Resources.ms.resx | 3 + WindowTranslator/Properties/Resources.pl.resx | 3 + .../Properties/Resources.pt-BR.resx | 3 + WindowTranslator/Properties/Resources.resx | 3 + WindowTranslator/Properties/Resources.ru.resx | 3 + WindowTranslator/Properties/Resources.th.resx | 3 + WindowTranslator/Properties/Resources.tr.resx | 3 + WindowTranslator/Properties/Resources.vi.resx | 3 + .../Properties/Resources.zh-CN.resx | 3 + .../Properties/Resources.zh-TW.resx | 3 + WindowTranslator/WindowTranslator.csproj | 1 + 44 files changed, 313 insertions(+), 76 deletions(-) create mode 100644 Plugins/plugin-icon.png diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 7dc871b9..7371bc28 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -5,6 +5,7 @@ <PropertyGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'"> <PackageTags>$(PackageTags);windowtranslator-plugin</PackageTags> <PackageReadmeFile Condition="Exists('$(MSBuildProjectDirectory)\README.md')">README.md</PackageReadmeFile> + <PackageIcon>plugin-icon.png</PackageIcon> <TargetsForTfmSpecificBuildOutput Condition="'$(IncludePluginRuntimeAssetsInPackage)' == 'true'">$(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage</TargetsForTfmSpecificBuildOutput> </PropertyGroup> @@ -13,6 +14,10 @@ <None Update="$(MSBuildProjectDirectory)\README.md" Pack="true" PackagePath="\" /> </ItemGroup> + <ItemGroup Condition="'$(IsTestProject)' != 'true' AND '$(IsPackable)' != 'false'"> + <None Include="$(MSBuildThisFileDirectory)plugin-icon.png" Pack="true" PackagePath="\" /> + </ItemGroup> + <!-- PackageReference の build/buildTransitive ターゲットによって出力されるネイティブ資産は、 依存パッケージの標準 runtimes/ 配下に存在しない場合がある。 diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj index a82c827c..379a3053 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <TargetFramework>net10.0</TargetFramework> - <Title>WindowTranslator Bergamot Translator Plugin + Bergamot Translator Plugin Offline neural machine translation for WindowTranslator using Bergamot. enable enable diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj index 3d9529ea..9515f260 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj @@ -1,7 +1,7 @@ net10.0-windows10.0.20348.0 - WindowTranslator ColorThief Plugin + ColorThief Plugin Detects readable foreground and background colors for translated text. diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index 96f41f02..ecff13ec 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -1,7 +1,7 @@  - WindowTranslator DeepL Translator Plugin + DeepL Translator Plugin Translation for WindowTranslator using the DeepL API. true diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index 2846ed4d..c7979cc5 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -1,7 +1,7 @@  net10.0-windows10.0.20348.0 - WindowTranslator Fields of Mistria Filter Plugin + Fields of Mistria Filter Plugin Context-aware translation filtering for Fields of Mistria. true true diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index 428fe634..71a02d16 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -1,7 +1,7 @@  net10.0-windows10.0.20348.0 - WindowTranslator GitHub Copilot Translator Plugin + GitHub Copilot Translator Plugin Translation for WindowTranslator using GitHub Copilot. true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index beac88eb..ffbfadf7 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -2,7 +2,7 @@ net10.0-windows10.0.20348.0 - WindowTranslator Google AI Plugin + Google AI Plugin Translation, OCR, and text correction for WindowTranslator using Google AI. true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index 5eb0def3..a7f4954b 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -1,6 +1,6 @@  - WindowTranslator Google Apps Script Translator Plugin + Google Apps Script Translator Plugin Translation for WindowTranslator through Google Apps Script. true diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index 77b4ff35..6bac6e23 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -1,7 +1,7 @@  net10.0-windows10.0.20348.0 - WindowTranslator LLM Plugin + LLM Plugin Translation, OCR, and text correction through OpenAI-compatible language models. true diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj index 87c87e52..97ac11cd 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj @@ -1,7 +1,7 @@  net10.0-windows10.0.20348.0 - WindowTranslator OneOCR Plugin + OneOCR Plugin OCR for WindowTranslator using the Windows OneOCR engine. true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index d3d74bfe..d5790d8a 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -2,7 +2,7 @@ net10.0 - WindowTranslator PLaMo Translator Plugin + PLaMo Translator Plugin Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. true false diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index 75de048b..ba85c1f4 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -2,7 +2,7 @@ net10.0-windows10.0.20348.0 - WindowTranslator Tesseract OCR Plugin + Tesseract OCR Plugin OCR for WindowTranslator using the Tesseract engine. true diff --git a/Plugins/plugin-icon.png b/Plugins/plugin-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..27a93452f9a2f433d50100a5f8090cdbce4d7067 GIT binary patch literal 75130 zcmWh!Wmp_t51m~WcXui7P~4>y*U}c3h2rimixn^K-Xf*AJ1q7V*J8!p-L)Uzk34ts zXD0VfCOJ9DL~5wXW1^9v0RVuhsPJAB06?#oAOHpFbun=HY4Lx}Ra5>QP&r1i|EfSN zrB$T?pe7Fe$prCLM|D!rbp-&d-v0~ex8qMU0Qgs=_+I*xmr?R_SeE6SJ7dfkwaaeS zJ(9x&jfKFXqRM)oqTO$-bm?NJ4YQ5;)ITaVy3hq8LC)TPK|u+AGoq^M^9G+oSctV=DvQp?GPYYy!z{8cbU6BpW2~1 zc-y44aeG^OltaB3e0w|Ksmoqow87F-e|u#Z*k~!#ICFAJ8e{Kmk2A-b^`>gQNiC$a z%*1{XIb(OV;&EsAYWwc{M|KF}myT8Wsub2CpTx`AptWZAKCZ=rVh%721pdK1l0&2861E3;md|I+(6 z%lWTfMt9H8B1C`5x*0wF$EANWIxZIQbuWmH7W>^dJaM;}J(HC1Gf%uWY;StQxxh`s z(0&uGbyo`IfnG-CzR@}PPZyVszN}QnKDnDL7X`P^i&wE>A-i!>M~f6j*B$-V+fZ|; zv$v42nEzKtauXYg4vI_BQYv=C3iE^^v_Riwoo9WCLl^1$lEcO`j%9cMKd1Vc961Al zltux+i+s3BMu*=hs|8|7mej~PMzv>2aD7&rc}$&^ucJs?)w68oWV0i2a1l}ALVH%Y zujUrxQsg>vjNYu7et&yRI`)({>bv`!Ns&E1p)mEEmmQ~_eGt+8Nf3fd ztmVyPevxSOt|N=}eKB6)v57L>hWwJkbkO~$09q}iyU}2;@+X(vB8*L6u2oi*)K96J zFTxlVk{pYMkHl7w5(8qx0*}jVbBi||PS2-GBJlm`D;>QGtn#sEZ?!M_MrAGyx70R+ z#(O>AQ?F^$no@*!%ciov(o@{ZfdpBfJCpz!D&bm%xcO5P1Foqb%@VA)=ak_gkX>W+>0DQ_=&S0lxa&Kf7q={i z;lx4?Z64A=m+oS9+()g)5*OCiQbsCNr9HQNdL)E*I9$I?Ze1h}>V4W1ce6d?)Qk8> zvvU!Bj`qF}fyF({x~E6oU9Eyn0{wFvj}Z5Wt(cO6K!Lf9$Hr^M%#=M}YZd*igi;xk zXqVq;t|WTW*m&I>FdCuGb%!y5*}mI-1tj zL2^6Bidd?v7UO!oH>R3$UT5RE1a65P=HEaVOUrf?6&Ew^yt`lote?|yRqOYF+!#gxl?Z|C-(l2vU6 zr!7m!SA;25$1!e-K3n>K8vc0KVbo!H=g=>YIQyGha&64q#{bvl4ISuW&-PI4+Dix} zJXg-}%G8h(r8iJZikgwxqdWL?l{lSsxp6;np)7J0R^k6J`^CS~RflBn9bEg&Sl~E^ z+V_kklGMd1NTQ)szU$^#!m$0jdRoQ7Gy`i|X8G5vy{XUQt&m#HEzQ&`D&;uNk2R-v zj?Yx53U?Pz7xZZh*JQX)_Rbe|Zh5_%XPy6IswgK2pI&Hks}2Tlaa5~9Q+j81 z%X||VRqMLaLN86tyS*Fv(#*XEm3*rLjHicr6ijaS&Nhtio&`9 zMY-Q!+A5K?{<&1Kd0%)9otEdSXtUcz8y}cx{Fx~-C_!lbBYV;^rghzW{mXjXUH8AN z%fzV?6UF;*wc5f5yvf#07v+1?<=E;vHNy}4BqjX&vU|Zr1%HB+Km1qLmOWP2wfJM) zp32L!h6d09_BKNRIKeI#`9zBC<5i=(xXFmK>98>s55m0-{6%ijKt8OWA+eHH;*lxmJh<1RBP1>aLV< zIZ`@l1JC~*@1fAN(+l8+zaI1L!xx!lRlQ)q96s6FiOYsA*Y)#UdS&zoL` z9&y#O6q>a>9&-1?lyv_!Sm{=(i~Mk(BVch=&ifNIEAs(4Ax@6OQA6^){YAfb(rw0v zGm929&W>cY9|Ty1o9>ZsheePI(qk_Ah;7 z9UYIR+i1vTS5Ry2p7d7MjWMIcdgo5X?8$Qfv%ej#o#0_cVJ=z?_?V$5a-^Fao?4Vr z^H1(?Soz=Spw>1!^R!At5Cst!#*0D($6^3o2@4(@=Eto}{!rF>--;7nNF2ptjq36l z$8PV}H#Y<`Br=3-HhZw?%c48$MIOUpe!JxZ&Nlh>495_6TG3(1esCbu{mAM4C8i-^ zbXA5#*(pj@OqXBPy?-C&YMKe+TSe0R@`m<~qx#&7wx2)T{afuAP^8!aA?&w_Fq8iG zz{dW+jYcZN>T-GQDtkKEPiWkqh;9N#^U493Icu0T>W$?-CEL&Ma2qG9A|X^}okg^X ze-=*YXP9U2z`~;m#T1QUmibMO*^RBVwR|Y>d2#%AJ`UC_Jt&d2xOf9=K~(EZ=63jWQA-z0WpI7z=*|BoTJ*N` zV;83_RuzQj>kNqGGW+?{EdA(C(Y&3W-*LrQHHfvZ(6AT^=X%NO9qI|vN=9;jG}>q# ziH;5=Uyc4uZhHC#0|&m%wmwJB^B*|`poUi>?19;*h#Rl>-ZcW6^ej>Y0d?R+msyxC z$W-g$;<JHWyMkuD-1Y+c5ECR^y9rnS_0j;69d25oYRF^ih*`nC1lfKNqR zNUK!^ZNK$pjA5n5ZJw8rJ-Q7k4qqy+v)LV9Eb%060n#A`Al2rlwEjnGb-=qw@gptS zkycy_%PgTY{TB;fqIH609&e9Pk-hAFBf>bn0(1gvaNFr0Psb0HA*)kn z=km=Ke+7rTTeV307Vu)~>5q!81bXUt6wNKHdjwU0vNdK1C;|~Cj9LMqF$At!;=KqQ z%x2LVBj9?Isqc9E8xNa}qZ(TKn-G5@x*0{bm>Lu^+&Y!rwlip>)0Br1IN}CmP@Up6 zZ{sW@M}K7fWp>usE=MLa$uWAoN_rLxDOa%#V@c`juC0_$tJr%zKrBB8Bot`)7%E%= zXa!UN&)YrC2B5t2u(}Sh_aXj?w<1b6a#ej6kaG0qwT0)_RFwv-c7GJm&{eNt+`7!K4~;k=J`%*MOOr! zWx-pfOJTh`tEQkURrz%qn(f+ZPmur1k@Jw%2utSj&&3kz$4Ddy zz(j)r-36(P>1}-gPQXwS90&{<8qQ^p48rgtkm8}n)m=Bc?875ndkbag=PL@!I6^Z7 zaSLy3W&LVUvuI0_{U)l5(9nJ<=f)JwO|mANE-fYNx1ekF1EZ87l{0+kKWWfFxk4&+ z=V=OsRsu^FjIAqgxSB<+)<#!0x9H@%`h1D9_SODnmNZ}kIvV{L+z%dr(702ZrypkK z>H4HFE0sXwVgHsHk-*5YAo(@`RZ0c05UDzSz^P~R12K9_cFvq0dPUzJx>@B;`{u5K zoj9~ySW84G*pz*>r;?`}x3h=yPP1CD!6WDW&qYJT5LgQ^=RkcCD(MEIHlc<;%?GwP zo<7Y#M*{Q6P)I!6GLLDXK6?6yBSL%#+37=-kIk_t9U77eQlOWKYcpOWv&e6gosMEl z7BI5{EozUCCoQ1d1~dK&R<<;W(8U2iO+8sAx-!gOC;}WA5(!eY|FLWBU^#I}s)an# z@Z2SVvwDI6t(zkBwb#T5xFMSz?5Fy_`qV=u16$xyoE8^Cc|rP29Z%&HYI-+5k?H~7 zmea5HUB17fCPB%JqQ5)d=W<}u-WaNKvvkS{N1DA$5nC`osG(_Rhgw&s#1GB2ak>lA zJjXg7aKe$C_dSYAHK2+YD)q0=0l` zH*?6I>;qlJK8x`C0nf{Sr_gaj&DZInrv#}AU}`;o#4d!libw%Ow(EuL8-i(u84h%) zY;`nmBIkPSgJGvtr_<(a+-Tfx=m36@6tIDk%nA4b)Ry3Q#{)NCy~J95;>iPnroW`w zEi;%ChY%u!j3kSx6tG+Ip3TaRFNG2BDU!jrxrdcO^wWHG;J0Ui`ecvA8oz!(jf#s% z;Sph9gN{fAc>x}@(KmIB6I)iVNu|}yJUunl&i@ToJe6EfC_wau)#(H*^)?jHB!=@O z@NRL$Gt*|}mBM?#8vSmA70Y;Q>QbJv+VD&gk^~bL9D5T?yBiL+q7i&6oRH#=@aH^* znQLZ0PzJ_dlzhM%U`?|GT9F!4L-2HY+aU_*%7khGGN616D-buv01-g+@)H|?to;BU ziSR-1(~GP7R}~Xvk&^+NHKsK}SPh@WvJw&Gf@x0K>t3A7o)rJ5nO6P|C{b_TPCR*C z7ro3p_o^6Z8;vg?ae7Q~!W3nZR8D5vr32+m+szGjrzTW&aV`}^l)jNm6)>$d zL=6zg1km%r9YxWbpI6_bdOT;1{@Zbag0y_qhx3^~a;T3RU%d5d?GSUN$N&<*+ZqZ9 zB?g;diG2lCpwnPU;U0q1v4PW_i;AFiqR9V#-?9gi-j!~9f(PJ(sTw&Bh@Fpb^#=Rl zA8=v&tgFOp3QIuX@0sa*iK?M06AN#|fblGldjDn0VODQ+?U%#}r*gkgU`o6r z)vs_+R=6_xU$%t-FQ_~ik$a6(s)l615`1Aw!vIT>X|-5}AO0(d^X-lS zM+KX;Bq)Y3t^;N`|6zZbAxwdgKY5lwFo<0l9MTYlR~*K6>q0GxAQ&RBQOdYkAKdU_ zgbMo3D1L$px(dS(aO-7NxUcb(W7v5@?(TPI`GB0uWb`Of1F#6xj`6ZH}~th;jmR z+w=^UkW!EiLdZXRnnYV<%eQ_d(Vjg7}iflOe$^Yp+rMjJ?{hYIw?6VGg@Tp-2icL7T{}79r{8-c2sI#XnQE~ghn9)f ztnOI+Q$5E6m7fA~Ik3@sb$<0Ew3#>6$gw{c2$C`j0=N$D+T+vWX}10D~B+3g<>WD>>|Wn-+*UAY!f1 zd*kaM+b7N9xFplZ0drw-uESTmvGpiPW`zk zCKB(|2LBv-gC5eY{~?;K$I({$*bu!9YjtX!qdui8Ew;mW?&H#z?~42EZmPr6!@8U2 z4oaMKhhN*JJ>d~3*MZwMcHCev4m!>wmQ-$4+@;$;mHDgHY-U) zLKs!yynDq5%D6OWm{N`stLj(|vkD{q(zbDbtCh;J`eGchrRs6(40b}N!G*8O{vCpF zYk&t*)3)h~KIlJw-!@?&dfm&AexiPQqd)B$rFfsOUdrWyYut?{JQ?Y z8a}w1Gt&O&hgJ2{Hrd2p@cEoAql!)7JL^Q^>n|dol5EIMn@$tHiAd-KSTraJMf7H$ z=>;5C_u@CwkWYCq07u|a2vih&ZkjI>eHC_$&C8|B^nI*SKC>LuPUuBZgpSQhJK!|iefK5U z=VVyv7d?AGcII|&oN;g6F)?i*8TwDT*kZT=6Ty@KI&cZ}fJxY8ftgQhMain5TL7Qp{o$z|lAqvQS5&aC5`r}>gviT$!;LW2X`ASnC*8d;bhfqHa! z8OVKaa^sTk*G1hpEiNnpKv}c5y^PPgF^+Z!Vs20P8p5lK?l;bOz30jVW+$<6d%Nw3 ztcS#*PwPAiscxhwVqU50v_yY%k8$wQY-pL7;AfrDoHC7}PmgbvOg>kjFZ2u9OZ0a(Loc4KTE)KrW`SbbK{2I)66h7|AaM- zH54Wu8!5ql4}jo3)GU7FWebJ<^>O?#g8eGeCd{K)-_OIHp$cCF_#^ zPT^Wxv!eEK04umwHBNxTav6E>kueHW{;0UZJ{DqHs=OZ?xAf)vGR_Y4@n&;P9Qql;?$OAN0^c+et1-^7AkOP6o&d_gw3&9J^x50XI@k9#}>*Afx_x1!~XMC zVqZf1aIseFc=+J$Gt3D}lQt<(XqO=LT7)$P{w|dC1e1ZzT-IJ4*W3^*hNJr(%OWD- z$ul_mNG_V&Q`P`uNb||?qIhm))<8F$YsKRlaqCR)!ZO7s1^2W= zI_*u

Ty+xz*e6gy$E|=rm1sEPjrdQ&jFm>QRC{(al`OGux&4wR9R72$|3y3hYRx z;E8RxECm_1iZVb|@zl>I;0)OtI1Cx7O%WH zIQ0vHHqr&?k$}Vqy6Aoc2?>X2ja#w_R4sN9nzVBhFAhTuHN&HNI zuF%ZiT(Kg_Vo)LO7`%;nL3Ti)OFV7UrT5xloJMZ(d)_{dzu8kQEx zTAESw(= zOhcc4qv8*vr|$dycFsX0B5pI8ScWaFGHK;U6T*USd*z2)x53+#t!A>}l?K!6)pi$z zJ=kkZcoTlj_}@}Q;a1!KncD^uw_3m&

@^G?;L-&u8eN>vt!;{GdPX2~yVQNYG)q z$*ErJjgyZ$(v{H^jRzEx!-Zds7p?z#oXwtu;!7E$LKBz})_-y{z`~F~P+{hgLIK48 z9Fe3?j4yc`Am7KSip#`3lRACIAFo)xMc77mU9xx0N(3m-Veo&H!95NzJfkJ5iu`xX zk)eer`O)S4NzU6vxR?iba`)s6opy9vcZZ7|U)gffhmjj9PlE#+g_wbV`oC5Id-x8DE-oR`Zr7Ozo_LvEZzYU)TC+uAKj}i$e7+a`EA}BEcg0MuoYG znG_&KgQlHs;y}VbBL_B!47UZ{AcCMI6R7ZAP~f?5X_*955-gbG>r+fjWn!fA}Y0|Jf$)U0X241A9XHWE+PG=Txc@6b740`D;&fj!iP$K)Q3Mk*q zy_r-+r&Kf>IkgheSNNgAdtfYeV9h>-?>x{nTN|Dd-<#B)sc;xq{T3T04{~B97}Fdv z1j-S)!FN+0)}T;;fB-GklMMvTzWYvC>&p%qmOmGK2=ekc{}mstW#arHk8{e>kM>)R zU6Oj5u6VxkSyOI+OIYrnE2>(6E-y;8ZJD8wHb-US!Q?~MeI~^>iFMkqnmunw#7$B71oM$v_c>6Z-b7Gv(vc=jxr@GOBH>uOTwXU9SL5x#WY@Su$VvMKQm&0~kpV~p+}P5<&p>@x-*#~ItfKh8v#uhAJ>1}=qY zP)gcOYbiVQEOqR0(#KeTCZ7A*_RP4>LM@zIIp0V5B!7C=VARfc&W$F?k+IaSR#b=d zi8foH(9>QPkONa$Y;pbOde3!9&jX5Zlx76`n=_5QB$uw0qC@3F`ct7+pb0W^kkons zM5t%a)A4ZHUNWbM`o%8F!Ph6s!QUp)!Q16kURQscsLp>$)((a^n=XB)-4@l)mI;<0uIQH^l34zcuR400d`*=Uk(tYPM75my z`VAj0I(x|PiI9vDQ_Wgu1a|}!&Eipu?kx5@+q0hjR@D!63#OGHOoTh^PaR8Uy*B*> zWCg|FDhi56w!i7(y+TMHd^Mu6q$tMWagcBM*S6QWsdbCZ{lc?;Zszx_ zyzK9y0RpmR)LF&1EAv0fW>H!QA^Ajr2jl`%x|$e<3lE3^!;xv!q$G&pik~B>w_fBW zNv2AzK^Gf6;Aqf_BGjmeHk2kBQIB0gx=_*}|I4O~&l$1!gCw@weu&`hUJs#oD-=)>tKgjzFL#_EQv5E25noBts>e{d-MyttYs; z>!aR}>p3D(cf=)Xjd-P{B&SWiLRwJ<$QvLBHKGBKK&xi*KuqnIno`>Ar2#sEQ^(e&O@bGdIgNr*M%AM9*M;`A_i2!|@(VD8G`W+PA5F z{2zQSrZH9yQAVQNYNtBly;?|3M_6zi`0fErc zi?u7rCuF$PrndG7VzV=f#thOdEJgT47mHdJ93GA*46;U~Gf&qtdo%VYuC6Fl)!nTu zT9r2~8Ld}?K0FLm)VZy=c$FafS!9ylwG*ln!GKrRFFhIL(4w6rwI!^;IMl-dSUyLf z(=q>Rw$D_fqAr@`o#qn{YaemWwXhIjujBooCVcu-USXiKMgB-`9?|vtcd@mLTX3Z> zG;^#)+7HR0(Z;q90@n{7WCp%I0e(p&NPVU>`(uMmN&xw`)A(va( zv#TIYNLT+mr=Ii@??!?H?lt3xlXp#BQj+3A`Hm~AKW-h4CWYsQ&o)Kpb(x6(g#G|F z=d(poKoY^Y4?cdK(Q~dfuSg^H+F*QkW&jSckVxlq12Yup4G>yzyJrMKwd*$)$V$Vp z@Xw1JLj0+}$={oU=N%w4Wgu@EoF+gDKn?{acHeguX!u<7s4nx_SY|V-=&i7%`#paLH@ebp44l)vctG?>fJU-Ul$ zX@Cf%X~F7aS{Sqwoz8|k@Sa5Krs2uIUtU=seI3kIy)}%0Jw?vIn+f9Or1yXW>cC~9 z)097B5&>gi+E-=%e;6vh)%kNgvQZ zDY6QQZ@x`G3b^eBryx}RD2Hu8{)mH80Gb6bj2ftV`X(lc(&y<+^S3j~0zSx?yrlVpyN!njlkN(5h6f-s6*lntUVpDjE8? z(#DGj`gv0POi*RO!7F{qz2+Z=KY;?xvU{^EfDJ_3avCg@U@t8dS>C8j?hVk7x<5?m z>4&l2krS3r{k$SVgz2n0Srp6Tm;8R6c46GNx9$IU!r|gjL740?RYD}*bftQ>Tdi4k z0?uQ*3R0$?L%RDjute?8jSYd3ej zs-bqb-kkRL@l(&sl@u$~{x&+Dn{-+K?+Q#wWtg8(>YaJasTl|?TO|gkMehpYzGnm; z^)JWh(CY`5!++mk?Qxy%O}EUAP8PB4?ByJaifKz9Fo_;On0HK?W>XJGr7E{fD%vA; zeJ6h%B(H4QbE){9v?1$~d=LUP-&a8rTal!Z_5)~v(m6NNL{VN_@M|(7=Uv8zalrhQ zfJU;3R0K=#+6RCE21LWwl$ihiqFTN@;%kzy+O|QgAT+3~1M4#&_X@seBc~xF&&W@^ zjh&=k`de_BcDq1PF<5H7QGzNVpq@YZPY}v>r#rdrXnBtD0142J`m|R4t+UoU^&2Ji zkHJV`GA!Qij70Q_OMfo5@xUaU!;5#`-{MBAlbjYpKDq^|t)`+07A3f?XO1IsQ^SLR zKpmjw4GId3aBN6`MY6KWV7aKS>U08;t&hNe_d(ca?p1CdusTlVMhN#N@|IQ#Y@0mx z^E}3oC)&ZHfT2C^MA9$m8V}?pV4^5TEf!#yyP9YDkgF-~) zv9H~>Yi%5<#@A>lL_yOC(O;tss%Eph0h!@R(XP12Aly$y;igr&K((vy~f& zZnD9Tbqkgv#iW3cZn4-{wdn-9FK)-h!XlRUCC0}>tuY374!!O? z`X)5(^8IOVHl+HK-@I-QeI%-CW7eyZEY3*Yw7rSk_yC!CVd~@t`1(LF!PIQG*Mfk=Mv&@KwN?<>vnu{T#jfI)hWu zj2k7%3%_)9r{3qD@HN0~>gu(Q5eG>>^sB&w0W9_i#Om68h*>pA$Z9_od#~=+Z6Ch= zR)m+QpJrRa2=iV%6dsrET2>P zUh3W_isI=|DgT2G|LPJU>1t8Xb8m^f5F-Y#`)t4n8{g-qL6I7Rz$;(5w>v~U&>lB8 zTNhh^&F3qL@z#;hGFPOP*pu(7G@1;l3s#1&3}D)r{%k?C*4X6C;m#hm!|6nq#6%Hg z6~+)iAmcEH|&+ANQ#uQ z_$HtG8CkQ%WwjM^0?Umd8PfCA`ACVH0ZWKqyc z7F2vby;<*mOK;fXA(d9~wMmr1mwiSAMgyxxt4)DM*%PwO9ngPrN}EISV!?hohqdTtz2ZwsIV6xT0&k8pWM`d2Q4LE+*%3(KaL!XP{xNZgHSee4-Nx!|*zWl!Eu>a* z?ec@kIgim{F&uKl*VnBA{8mVfJO}{%#Jm=5qzvPOty@X7mpXkD$FiwP21~KB9KtR# z!GZ)`?TuZ^cnU{Y@@sZ+|H(7%XLR6S6=b~?wHx4w4O;Ea^+dlR8rng6znz6GMQ> zU59Md*X8TjHf8qv0*>Vjlo((F5pD!nfacwx`j`m_Sj6iv(ZtqWS%^(=os zSA|*PuZE;r1flJRiy2ChKvMP$tVg`g#tQki0Ezs`eZYg0mr9N!;zRBrMz5@hFu(f0 z`ZD!h!&UQP1gxWU9HQ$?X$K-{%z#7v-3Je+w|Dy4?x?VU@nVEsOIB&{MW=lb=Qb4^ z4#L)grZrufS2!xvJ+IRNlD`WMMRZc}KpZOrwV*?x6NLWr>%sHV>hW_;cL5_s* z*d^$1aUdsII@`7b(0POXY3dNu>S%Vw$kE=}9Y;IoNF7E6dZU1^7+3xq7!VaxP`bk8HJRX%M2V+YD_FApg0MPY& zBZ#$ga=5XMB&vo5W;(<6)|=ZpYj$YTEb->N4g-p&e1}nItNd;ckIrmC+*7`CmeJAl zCnkzOUG;28HpMpC=`|91{rUbIN9V8ngz2S`NYZkAnujOPPKsqfS>-K8Ck7ngSgm_1 zB85qVGk`yTJb#-N%0@7Zc-3uXPefPa-`C?tt0SIF&YkAXal-Imq979x)NAjI)-Ved z#-^q9bz!hc1?kY&Z6!*=e?6B}#K$gMPECCh;4r#bImkYf8=fs*@~GlDG|e^;TPgV# zH{ErTzLNl$l|uqLMm>!%%+U2Q?g&97sPtv;BH#p_T3>N+kh7sWM{c{kygdG%rj-ru z8)AgsY3+!($!4VHcoIZ9gv*>L&cUz6sC3}PGggxG!b9=)f2|y%RmiK1|F2|bAaLZ6 zpNKF{DXd}HH0CBen0o?POb)|Oz57E`^IJM~DeLQC#|f9bE{x?+tZ{MgwHb-)23zRm zdnIs0t=xhxvFY^iYXkcj#%X}M3X$Wm`#CZ7Yi+E!d7LHUKbofYuoO5DL-e=Go-rS^ zF!Z~Zxffqqs_Ix2$v-7{hv= z_lxQpwb3d3eT(T`^T&I*_3EUa_k*-)qwqHxG;gWuNiasmiY_>|8fCgnp#R_j%WW$T}rg$U9s`?_!=tbx18FEBToV^T0nsEbR&}~GY4gfa)Uri1)P!rlE6R(^ z-gOd_r)_!%1Ot{(({CVs`|wv@p=|*if1ylMt;HL%`tYP{?M~IsW>$2SJv-FCliEOn z_t8XS$Tfy6+kKhB-}+0X(X14$CjNL=DypQQ z=`VJGDDfRO)&yi){L51)HjwM6@$Hzz+n z|658GWCV0E{_G(9Y(-%F{I83dDfO?by^CFLR?;&upa~$m1c0L~Gl z&L6M3Pu;Cg3lz16j2v}FwuXj|*DU*~8F6$N_}d^a!~UY_opo*peawcpa0r*b>;uV_ z^$-{9Pe_eB{+1&-SszA&Ynr=v56aEYq>L{DpFSnYMdMlNoK?Of_3RLmwNz7|sP_mH z8$TK$kRq;o!!hp1p=_%^%#jf39>&f8kYNl3O;B)2%KarT_O^-50JN_5>axN+sbS*? zPSgMq0>>27329-kbkc&pz63=;-ME36;JTaD0}o1eeUIO_>WY*)KUo8m-U#yOplUI% z8?1 zA*<+37364=+Ou(%$p6NbOS84sEV@TQ78TwI;YF8vCG%&&Yj#pNQqJ582gqn7TYo6a z$wDq9uhH2tGV?PQ0zv?^v$@;aj8y4_HZ(M<^gq+nhGe4ra<@-zF_pR;(;Bwb7QECI zp1q0@DIaWouELu<_dnix>{p$hOoVp|%4?S=W_(#7V81koN$*i^K%W5@hi}D~GGYT(x3dKt6!tJZ1QVT z4s7d?-{h48YkD+%oEQKZeznBL+!sG=b(_546m_;25{f8B-t9O?(8+*=R2a=K&m%klvuxrq@t(DN#L0}1THTZ zRLc*4_|#D{(~rD};VDb-Y&{;W6Y!NXh>;aD04Nuu5EYjmKPLSl*{<43v#ru)K~0sP zHVOVzdt`UHuHSaG6htcG;*zPBom-`}`{TQE1J*Jod}*0x^3azq72x6Kd0GX|eac=@>?X@r%z-QbxnJ55 zqMTJj`Wy6rTy%`;g^OL6`ej@fnMZ@pi1$$T-sP!i>Mt)jEBf>z{fagoGYZYYe-EoP zAe_6ZfHs-D*0$eSaep-m+>kRN)y>TGXA`zEt)}L2IYVctI-VLA=2{9$am&Mop>i+m z9Trh#gz<12tPd;q@`7&Lt!$mxkzT>+?V5&35T z{KpP483IVZcAu)Pkl9E}J7kA0TQ3)di|6E{nbm$mL;)&7ZmsM#OTV^3IDn}zod>CGI}R#txgdQncjv1tylM> z*6Q$%ONB+@tq8oCDWbSA`0D)I;?Pj!Fn~eIlGIy z=Hoz7ok*!t;Sx7LUJI3JiZ?&|EihM}l0Z3u2sa=3Ry9~91(!dT{pv(jHZG;+M@>gr zUr5~gOUBKEp2PcYV7qeN2i-9sk%p`ZJc9ev62a8vvjoMT9N2XLU_8MI?10w3AoyMV z`{IQ{59=2WeE7k6EG#s}SgjsZkz$Bgp@Y=Q+*QpZxjPAd>@4T@g3JH5u-f_~^vvdg zqGw@CG}Kvw3%h=PyA|^8oo&Zcg7AvxSzjdTfm?XHEjHZuh&el&x4k3}6vYwLlN_rg zn>{~8?s|55x6c=w{Ry2pNb>b>JeFrlmQ@dXZ_X8IlCui-sS%a5YT`9lU&cKd^4+~B z^Tsts7JCru2NGr^NAynF+1CkAXvr~3p#;qSk!U|jh~f_lj51$umml==9TKUr~S4!JFdlo|P%sA7L#?3iSx6v3Og%lo-xH=1T(equu7 z2nuRaq7;A95ZFLxualXG$ZPlYlr3>x&L~Wczy1r#R)6wKOqnL?eqV+ZrWOD?-V|Z^ zQ(;v3^FV^t1r0q!j&VO+U2VYmQ#3MT!6~j12wgNDh&hyxr^>8&m&Y+Tu{(&P3?K?Tqwn?Z4 z-g|S*LLMnwe&L06o|UU3dczJRi?Xd{z1Xj>GegaGvT>cE&w!tBH5wPHQDL|g@JPIu zccLHm; zTwBTssfm~`xeL!|Att3dY=?(PcNTvP@+X;SgltX+jiNSVO2DKs5PZs3=M)*_fZ98x ztWcwN8-*sxSo`Y#T4ed-*x1~VTo{8}!`Mkg6uMW}#dm5jJm8SCd1ZWqiS*4;nh7Q5 zR=^4Gyu6>Y8ts*|%m#YD`X^cwcRHhwwn)irro-SW$ zFgnSjx6bf~pvYV%fImWIIDbq(9_DY^M`GQ_)v8m?SLur~{CykH9&BrbW3XoNkVP>< zCW^SeD0464^gaAT1QjL)kqzY#E{$wm84NLI$5q}bwruMW&QASGE$-;z0~J=2nZBpl z@8#+Yoc8=bQFS~uSyK)9>kqzo+DEu_n<2|ypt&C`ZVCA-Wxc=IP7acPQfaq1*>zF0 z@F9BXq;X`Rri*RQ$?yaz1*5I~?gYE6q5!<08C0D-bU+ISOmfzjYCAhfdXDvC_8sHaq0*c1};eqAA-lSaV4cbz)5iw*P~Z|_3JAbBKjg9#fC}iI6O{uIzK$?zs0bq zP#5?ZhOh)|wkFeO8t;d$7@M72*elPtH|_c53&HiD zvJKL={qGX2?9+iexA{ciqH*m6p_!ZxX~yqgCQ!Q}a)J( za)OC@9*(T=uzD_+Y;x)@(2D;>8rWaH`WD7A+!q;xX`1SBm4Cf(9V=jcW{BsP`! zDcudDQ(8ia(WxNaynFxJb^h6PowHr%`#jHm-=BwEw)OxcRYB~EU<9-#>MDtQbw$^Y z>O5(y*C@=vR<=5E9EW^4Hror&Wo5-shICks1;G_P>@a9K>dzk2taT!LL%Q01 z$R25AIpzCZ!vgTJZ9?g75}%A`po&s!>j;qq-eEyh>Z#yCqB3pOM~ zFNz)Nz}&W5*y?L{3f}QMj`Cr?_xL0(Z}fA4uW1V3@auXU6@uRgk?FV5TkEITwC04E zP{P<%d5GKa!51=cUt9*ku zb`Fr}=I|amEMQ6sFcA&I;E9li%hj6DE~`1|w$5=54?LO{OF*)I-8$EuxbEOdRFQ||H4@2tFxWRzyrC{)1wpSN)td10K2-_o6q-FOe> z@6Q%05+Ie|E7e@u$~E_p%mazaz0bRbs8FhYf5(%`jS~J$IE7|K7x#wHV(WsT8^d7% zUjfPE+9TpaKA$$nH1*4Doo_Q+xF8z0H|7q_hv(@wSl5$53(#AHnxovl>yB%sL*){c z(jlJsii^_)A%HcK3)9eq>U*C|#p@8d`!F#UIuKpdV-<8S~ zqn}L^Jl>mTUEjL1%8=0o9@6fdBjp4>oWyC!NUWkdW_qQ9EK-U_wGJmtpuqk&XF6O+ zzISQXby*Jn$^McCYkJlcAuM?&P4`Xt(-otmtzl#W!<4syLr4w7jGT<}mQdJ6vU(VYmF9Q`Bjv}8BgW?;QNR%v;W|2eb;^Cam1~-j1ruIXLW9V z_kGf=q=%P4>*8zV7C~u%W8zy1@VIe)(K~^0jm@Ushuu!{OODj6=fU&RYA)?T1HQF% zly2yo{+YS$GczLBu@?C``pzSBfom$2@#@zy1kj~kndpg$M}PYQJg4}tdA_*3Cw88m z7TeH3buK>x>bcT>fJd;k1p}l8@1MYI)g?%(^jA-vRH>p6Cp1`XR%22xIXtNUpN}L* z*XWE|kp4R9%ewFd_4&FVvFpCzIk*2bI|4s^OpG7mM1e1K+9$Ocvfp9S!&(Cv^bt{9 z8-h?xxX;^-3E^|<0SC3>uwTapUMp6UdewCN3LhKx&$6x8SLU_QEI2#>BOX5i>>ScF zVOCP`B|z$jIttveh_Bp5bAQuj{8!OZgWrY5=tp#F)8WLqdnFI?L;W9I$8uq|ZXuFv z*$jEB+8T+*98utDW@O4gj5VHIbUD# z@N8oy9$nSQUU|$^oAb`foOJr1kpH&Xo_lg~Z)+z-gCRdS6(3M7R?cB2-TzoSNy-&p?wa&~5PlEs#JnvH1q!wvz`J zHB6w&fXf>+l#xGWDbQa3a+Tid$H=2klL^ZbhndEVHDvC$kv}o!yP~>r5xv$H zJ82@lqWk#coCvI?`yl~BG6LHfY)#ZqZNHc00etbfCABt|+X~e>q@r&@mE1wms{(Lw zoV-wuk#!Y_fE5NazR}mHs34=vgB>O>JLRqZX~fjgX7q0|8~b^UVGT_W3X?@epPZGH zm4-ZlWp_Ftj8Un@wsxx{obqS8H0PI9b<_S5XTDfb0ez0_d%3N_ij+*E_w_7%nX+>6 z^#D0#@sA$zWs%AR=ZT2U$5uHFmu?EKTo14HH)V*&OYb?s15BY-#IRgM7CsbPqdiIb z`+ePNj4mW(I!)%PQZJy2rkV{+@DPpzA~>#WNncq}V+Il7WJ$|&2ryne#k(4bQLzcL z7&?@m^|t%X_r!P(h8$+mN5At9xw{z!<&}jySXr*nU1B^oTDidLmlCk+fzZ_Sp#Ics zkwGyf7l;M=qx+<=NY!pb7D0F~tey?H2?`KnphmyoKt!y%>cOuZ;_SMpD*uMGhLJuM z&U}!n7Id>KM&5uG_&mPq@lBHu!7-C~;jv;P%sPvom@wYg?F4d8Tk}ZIafG1E>MB`Z?tb8cIhSX9_GoB26xOX_ctFOSN(H z-L<*eb7O~`U8t+B+W8T2yAEg1%9=|)p`9@T*BPs#!z=k*DgK|4t|aY-Jp-x+pCyI- zz^kPMpQh|7gfIn>*#49{FtS8FhS%8;zXKH89Jt+;An)mPwMYLwStV~5Q#zO@U{m=R z^ruDs>N=0bYv?mMc|UW`o##7R41ZAKSj_WWRlCI}+7~_lo?IOTZabgsgj&mA{mH=& zC`?HFGxz`|j2AoKG1~@e*^s`c1s_|*fCm2462FW3BeFkeIc@(k`gJ=MOvX`!8lZtv zBmUqf5uy|Ex+9@~58vRsI!B0tD%p!Xq|BDlj$j3v!G3-=0x0>ow)>5? z1y9$w&@tCEx!l0O%=Tkf>BoA+1Lnd81s%=)1Nz)T^g57j1pkc?ZPU+1R4B|Tyyxh# zl{qK(ikd+-LF`tY=!@fALdq7fr6RBc9?D0|ktGpgKy`o>yPgIhxPRzCg)k&wc;LTB zik%qiXT!-0#VcCEWJo_mO!6S;A0yV)nKfGl`B=0du&2e-z> ze0wDW`p*d0uzzob-tAlU#yOQed&lHG@=IV8_cpnUGJP@I?Sxh2C!al+z412>&A=j{ zxePP^E#FO~EC@TpSG}4ueh(_RWNUi!K9)rOpahC|1BA5#D>DQ^szfG8F$yNcx~XBO zpt(pWE@*C~0}o^%sl<=#NZCgmMrLkbs+~}G2@vs1+~d$NVn{1_H5k;JQck3~nar>L zbavZfZ#KVBGSF6?yhBFSlBeI}dj*Ly9kfs4paTjxVqq8FefbD3kjn7u=s%2TGRxo36(%>!Xgb(nF8CcO z>T%NiZiYS+yGP!50si{oZ0GSnyw#Fl;K%m%HhSvt+IGg9D`GH1jxkE{I;52H3BQ39 zSmIAJ!=UtbozCMD?yZ>P-3&yABmt~rY{LwlsIQQkw>h8RWc^S-!VvHhN%8mN ztpjc`1c4h%8do3jZMIMTiF&MB#lsAk>AVtU5yj@VX#JfCD)fk=pGQ0ytBi7Rhv%mm zc^M_1#1#_Qeh5V|M;rvCawARdH%9oqRzWK%iqH`d5m9Q8m+w9el_r z_#}=EDIhvt^RZgtOTw49Hs|44Z(N%UNy5;;-^kl^0Ywf`} z1B;B~t3Zxi@IEb+6mhv&7=8;s+=0KZ-n*ziFDgilI&K%WRs!<(x{mpBrXY z!~*uPQheVT$kQWgg(RqnPHknccx$Jv73VcOfMfE0h1g8u$YLky>QO1n!rSAH75d7? z@4{`ZWpP%sS9!^e9w58`L7-G|i5lx)630eSuvRB>9J1#a{}NL_fiNIkY!v>aAb3te ztzXwn0WHS&L1GaX!Z_kC@Z1Hh1+BVfjcmmk|1o;HgZ}(P#`Tb-E$EOWAz?VCRs3Ri zuduNG!E+ebugakzj#N49 zMw$@4Ul(_AZ0Lw$ksfkL0>eIBVyA=;oV)Rh`g?{eMq&u^ICJE3;b-#JGv^ z^>*jJx&6A?=XALX(Qk#ofl;G(`tEOn$~joRkb-#w0q#nm7vBsXYTvf$wo6k1$Nwfi zG^YA5Ep4){VV@`fRQA}59dS_vmL->#&^_94bbBVs!4n0pn z$=lfoO7y^b(jARjm4Fb)e#6BR>Q<rX^J!SAU(biMogK6|M&m)iZQ zb1agl`Q+3X$MB>W0@Xpwix2?^&`3}MaAO69pE(SfF^>PK5ty)fco^UGbI#i(Ht+2! zr)HoDjt#hAaSZB?u?D*iL-RlCj!cd?O8$AuNlU*x3#)fc5l8JjS{fAaCBpBB#=<)& z2|(dpVrwr|kFKk@{4{_aV^Zo8)+aYU-C?3~ObHE&$IrJXyM=L&;2j3(ZmKxIa<@4P~* zGIV>JNm=oWNpswWn!?|f{hc(N^|bF!4U~Sa#WEYvSVoO7z#C@Wo62ct+Oee42;VG7 ziaMr3;Rs0#eYw4@?W(`yj>{~|sGtNCXFVE!7Hq`0cdm_O1^is4dcCixIdi7r*3`Y01d{HQDv%Fsg}o_VtQf1x5u4O@>MHTAo0mW%Lsm2H;L;fGKNFr(v9Kg*|3z(PII2* zjvwv>6>VX^$i}eh7mgzJQ~RShD{y6D9VOmm#G`e#Yx9rXTOMAh83$InaN5Ix{D*lB zTBBiTt5PUJ#2Lpkf9_SrSCFFIeFQlwWzt=QO8BMvrMaT!W2Qw~1_(kBL5bCD(D(g1 zuqrHvu|EX&YEps&*vHokUGC9@>aV8GRRD1wO)c(k;quJi6H}9un;?XvkpH(J z>y95RE_^0#ycZI%FTcUIS$7xV*stZKaZ8Cld2>~rZtUmY zA%)Xc{unG3@NAS`jvRlA7vyRr@>y!&Yv~A4N-KA$XGJhK;y<2Y)mkabEk96~yG`AZ4Oti(MRLT`(Ev*aZ2w*iomWT!o9 zs;7~xOClY&P4d%p>UdKE4;+43J4BhEniw*IYy%m*lH-Eq>?BF0SSNMpUh?+xXPwC9 zy$fSORHa?s(Q{+xbu?)Lh#`kjs2G>#b2c;Ds85%`0vMATf;IE-AXF8Y#sgsG1!wR_ zbv^b&yh9oA^@T1!dbE$bImfRB&8pjaY?;8d^z+oEu?VbhE-zcF;08Nu782jQ3fHD@|_GlP>e%= zj6J}hCZq&gKs{uNR7nKHj~mqf7He<<_LUN)L2x8;g;cJ}xZaV%0xK?-LQtR1-fh!1 z-dCu0pI16j>V;<`GyfIa&R|QYz~Za&R*6soGtI^~`I%5Q4uC)h=psb#vF5G*yDyoI zjBzXz3~FQA9Qq8n_lAZJ+ywDpp@Ki>5iTmgyXBwHgr5OL_+OH2B8z_TEt=Cm9|YW} z-*oyl>dmH=~2+n+#o#zqwY@5-0xu}zp7567!Eq!r10h0m7(Re!_sQa%DL(+$Cu?0hS$Ifa#vAlr zm~MGTmQL59IgKGBzJ`nBx_s1>vO^)Ndc{YWIXqULq@&(ClKuYsk%uKE*UTHD&hHM%3d`T~0B z{ryG@oBfdxZv@dLrvM@bFIj8%D?j#d@m0QSRBIvs$^`pdqX7eim>DOcP5@2E39@p~=seqVD`E`I__f>P8nd)+SV}cagY=Y{XN*0_1oq2z!7Rj*HM%sIWi9 z&NkIWGfFoanlFR|4-wA35@y|C z0~D5Z=JSN&|Hsy@YplP9&_(l0t-C&&Nfv(rfj2DWAjvw5ppOS{G5Ul5W)C*K--!i0 z(w#R2wFoTUP-idN#|^gLd5B@A*TJ`gf8?c6bYI!zK2cnr1Z=w&&1$nxYi%YUP+eH( z;djoQCLz-^61Ua=f~~+wy)YRPyDhLQz1fByAyk@|sAiHoyJwZ)|HYw%iBvG|4e;S) zHOP=5yy-+ibyn_z(BRBlLHa*nfZ#H}y0_FwIF4wXV_R187Wc*toGA+LnJVR0KIiqEc~Unu9`4uT?&U+ z38in+?m*$?XEgJ*V{xh2nOqjnFfbD`z9%rLxW7GB5vbM-%;olWYne6FhI>wNA{t$G zhJyDhQd@6~BZbbL6e9b-nXkd~iQ>&+=UAi($2@pI6y*C#uMVI4{?))jg zqs!&{r0p7#JKh>V=!BMs^ghIej{P((3jBloFw8dcWbx{^_)7=Ll zyyq7(*T9X;B(L=%XB0a7k8;vp3umoGLiE<#)iCAzp*Mj$;My|0+SCvX|pt zm=1$@5OWmMHsGU3C^m!6`C)-Zh&oW2?fVO`zMN@Ch5c+m)?|&wfk07v<{<3Y%xVQw zaVG(Dm3qUt;Tq2g=DlJ;0-O*8b-`HEXH9Q~%rlaA@lHJv0NwT$58XD)9)4@mKs^aP zS}nUSq`9;>?L5Pq7?}g7{6#uDli%r*dSo=&d5Rehv8DkOph%(-2T^8lTow?LDH>GG$K*%W=SI z(}&TypcwLjJ3Cj$x2R{0F0AiKyodNwMKfQr4hKT@Q~u6BlA&UgJk;_WfoUuif{$;g ze(6@9w(nkIN>*YIZIR^Q^lCg7*nbD)5Ma+OAKCKP#U@)&oSdQe0>RvPH3Tn*>CqAq z4c)L(*%yz4K}X-=X6D(UIl7PAKhlkNNWQ@I$UX%m1Q=#!q~`?pKPw#Y7&_7y85>Y^ zPyEBZx;?P$QE4XYJ+jNl_}u*5J?O$^OzmP(WioB0OQdqp=kZU`65?>DYtZ2!tm3y3 zK!Wr46?ohnEp)&zSy)nuThEVA))$NewzH>Ag8Hyln8f`Mt$OhvKgp3(vk_pR32J_z zr`eX7>+0@eAEzvL{9Nier_)M(U=E9aUEzyhp_O6DAO0G2u5$CtNG*?kUGAN(^uVRs zE}B6!D@IavqE}F+{Lk|YBT@q_+Avd~^N1Hgf&KyqBuMN|7de3!VkQ1T`h10^YQI<- z#VLru>H-bVM{YFF)74{LtJiIDFC4&0?f(Bpf05+{8fUgQ7pm15+yD8@G;^g41LnB4 zMbA&M>{b#BjAWiCm2N@dXv?K=X--;9CI6Jo+NZV3&#&V499!{EV%F)1H%~hcnLlvrAQ`^y zv1P6Ncx=GfI%zo?SbKxb9pu*8>U$r8Fg}M)?sARsVCnT9 zT;bpFg5c&3wJpKai?&l;8y^jzspVx67q#V!*>AaRBl7tk#y{iCrh@*F>7=~mG5ZTf z0QG<2u3*e9P;bbl2P%uHUg>Ck|4l!%nh7d^dsTTKq)0ZNi&-nf;12-9xDMw`A8;Ro z?Uso3j@p{7#ZgZN)*)>C^}e?l-aHxkbel(bPzS2V-7$Nc$8?&7K^0uV>eOe_%<>%g zNE819{sSO4UF^#D#pVo~V=Prf=-wnPmZ6%#4Vse!9wKnBq}WJ{JjVnA!c%Fr%in;G zhw5uls+08g)%rSLyq~68hyi%fp=t+JV)!pSKpRV1Nva?|LnV|F3H<~b{@jMW<*E(he$GJ z#x409M{^1sXx)g(08uo_mq}q%Lh*^mPB>g-f**VZ=0N&F*StsT%U)X%)BY*$rK&7qdmX2g$kP+Ew z+|hqf)cgdDz(qp<%??&vGw^r}Ryh~RT;}WVc`PXUhM3WS8;S)@OkPWRZQE98AO6#q znibL|c(0E8#S2!KwJG>s`}NqLK!DVrrZvbew@(a#1 zf%AU$F*QC78`;>F_PH58D$3;3mxhX;w_EQEzpJAlG4ii~f*>IRj3#{J9o8IP5QM-= zebP)xCGJ2zWhc2e2KNWLYGjUE`m!vHEmacl!LEaA3X^TyOgpjQm4jSo`Xi* zN7#J7(Vt)z$;Mg`Qz!Gv8#_weBJ>Em;bEL+GikEV={|CfLTnFRT`uLx{Dp?bpY&MI zKs>dZduzC&(W|h_kZ~(Dl!r*tAA)ejc%U6o2Mp)Lg~UD=E>w>h8`c%Tb_1htT3l6; z67pE}vG10K#o?0W#4j(;3$KR+nmjb3VJPpx{le+K*dIUh*hCh_ zuM<^g+TAMa(^U6En=Ug5Z~JvO8TRbP;&L=)#wtp?gUQTTnhTpr$jSbouq;*<{g7WHA>I;<8Y30D;nl zKC1M zn{-+Ue5J<&`~hQQHgq_bi7-|T@o9@d-SWy5{#LWgf+g0r%VyBx;W$6?3o#e|!0;si z9tYfHeIRB|*jZ&`w%&WQSm`K24XI8atm(elLN)Vl``SH;uo;{u{o056f>l@bnVVvv zG$o?ANuZR5btsev$jdHD<>}k`b#3F5-xN|@?uh?2qbA)mo+~FbX0>ye1bk7QPZHjY z2Lf(ne`2K*KrJlrC||)Cj}4|IOvL@Mp{)JKF9J8Px=!OZ(m^2$cxk!HNg)uNb-odj zgLkI|)IU>1;3Yo^!coj96(uYX5fK3a=C>9QhrMADM*&FEi3TC2(DGO-V_W3njzI_O zc6*2C9KtE`Q%uDdkFc@O8l&rJ+`c?b8P|W7gytQ8yYD(a3-`i(RziV=j>GZ^v$SPP zpmF^9nS|pRX2zML?dC9KOEGpzZec$Dy2>S(^n&DzMem!ydZM&U$X9?>FGDfzauQ9R~)fz|q;6Qd76^q{+hsmVAzSPn396exHkOkR| zg}7a{!8d(g(em1B1E5M3NI6527<r|C`)Q*6c)Bxz$CC&GZlDk_YX-Gq#PSP8$HiGS=|5=@XPR8UV- z3r>BC5>~uvBa`p&4s~@#@sm``-x$HB-Itkz=dEl|Tq435i7$X-adn1bUEPV13yA#D z5e-{aL>uwM0nRhv7iQfXd}vedu;;>L#!GlOxgh=&FAEubx=WT9+|3+#=r4bD zw5bqy#%kD#Q^SHH0aF~p8}L>v*TxbIXdBH+z%@&RpncgkScfmQ&1lh74P)h(>&CvW z#$kPr?N)5^`D6QXp^M|e6mof@FfPM9vzV;SK^{xpqwrQDBH8?J3~#&yPbN6(rZ%69 z1l)K~_Yg@s@LP3H80A7>!TQqlmLBawB3W6d$KAK27Q8$3hUHh2f2ae6_Hj7;lS%KZ5IOe#s{gtdaQ3t-Tusz_ZvP{UJNPJ-4bFO_h#L#Og+MS z!sRDP>?hmqUSaK8KX>>LCT*$5Mw`m~07-HpcI?{M&$F|dUj6Mk|68YeGQ6b5w_(FZ z#=hrA>GD_X1Q>KEHoNo@f4Kuj&Sp|jc5Tg&bPn+-(RmwOkC-V7DMidb>fiKr)aAPV z`9?rSB_^!<3Ai%wFc(4kh_iyhw+oN0f|IZ9TOB?3}IWRe)=;07*5S5plg2&`z1f zd~@amHh6FCD|COCq#ex6*uMMukwOoRG4pE)9kFJMGMe#QA?@Rs?qZ#>o^jnb|HRP_ zgZ864b=n%cau3O*xU2NRxAz^tWrq8czK*n>Rv=8Gg=_@~QQKe?mKG+(3&Fe!c_J`} zwI)F(!jxLcl*t_z7HLF_`$ImVO$~r=bv9@lu&~89f-R@HcYWRIl}wPzqB167HTnf% z%~|NwAMWr;3nLDPyC^`F&o25!0PX&yOc|ZYBiifo3Q|l=vj% z!2Fc+!O*&8W7Qa@pI-@akla??8Qtr0)HUIIb*OFJ>|;2hezH7mT`f}2n3YWc{_q^E zfB(%P@hEPsbHl?9Nzl^*-us8a2HSo@v~%Ux0xUA_j`4lR zgPnu#yfOM_kGRiXqlB7)JDuc}gU4KY2-k z@!o!4Pn3d-DzK2fHTXpAKK>KY%92GLWklzt15l%xsnNZ|EhmF5S7H;C=+FD}4PT9A zzdU4N$|)9desJhV3$SrQW%%tC4k85w{7cn{p9A{$RZzR+vu^MO>*xz~gE^>`zW?W5 z{d;oJ3(sc{OHKaN6ERW*9`FRp?6t5o`)QNBsJ3GKg@n}ju%7#PFE%OUoN^RD+bKEM-pl`6?bAIK^wr~4WoLJ++LoQWN6NDc zgBq#F4^}cT<&&8#!2&uxBEg?@xB#r9z{@W;|KgGp3mlQo>ee3o@~+NhG$kXt*#-It zmflc zWW_1mSl^M;Oh4KXm;D(qx7ML1d1LS_&?4@jfKP^9?5ANt$D%?SOQs?Hm*Mak|0YV9 zrA#Fy8r*iz{Rg~#J_n~TZaiOOLtVSNHT6UD+X5W(2N@He|I<+MhF-fi`i~!8 zG`Mg%dzu}fa?1@K>A(!GS_P!2(NGrhd~WdIYdwCv6r87-pX|(1sNe89T8No2)HbV; zw@Gy#gIvpzjSFh&s zTDxE8zs5Iw7a7=sf|kV&tfidDIwi9%x6c1KqPaH7eOSLc@OEQ)Mt!;;r#=RW^zs1a z@3INe+6n@2p;;g(t?tH;ZCn)qtEG|RD4^)DaYBqaYzK@cc)eGY`Wb|Gq|%REgyhX9Gcd-19J8sgQ1Rrbg7ifK^nk6!UUq(5EF1Cx4wE zQ@>|P!#z@k&_ijSqAtLzJ>^d!d&WEg@CzUuX#WDiK0PpKAQHL?v0_6U;G;0!RR3}F za2C>FN<_IH5!%AY&BJS)y4R+{zjnJ->tH9vs9CfS!U}hX_JR3&D4r`%ar3^%g1>8S z(ESWqURhDFzDbSV$aL4Ge{hX;WLaAB)GIl~_H*m&3^~7U@U*wkhh@Nx zT_RY*<2?M{=PP)={TOundy*F=3+OE$8X!001Y!1|{SRQ^qIbW4clJ6sS(o8G{X>-M zud`SKXqL7e9ZVb0fzT2P-%ax7&1w`fX#oN`p%RJsYS>5>+>>3831p>-zKY6VNO+yb zO|>3lxcv14cD2Ru`H#sb7oa}JVho^xr-|T^9u?Vct;_7N%%Vt@v@-t@lbR}qb%$cv zW11ajPTz7r<;J59CE1+no7FPQS;rU0MkeRJJwY--F$pJEC#F0Fbd^F%W@nPJz_qdh zh+vB;X!EbMjKd|Y?><4DmQz0?Adhb@rZM*sw+aO&e8W-!h{xIT1fakP@x=dyJZ?nm z6uBl7sApZ+rH%2zldpWtNd-y?!#NbswqqGQr_upb2DzPv6PkPWr?9UhyZ$k2Zd0c& zTM-0MYFk9N9d~a2Ro9+#4(ttG^butqnC$IUDZCDTm^!%Y@|g}_yZZzmH;7Icp}(s&KIsXlS+a3`+wgq?lP4lzBee+qlyY4bgM0k;~R&h zsdXjV9jeS#Cusd*Abon>FfzG>lNDw9hax=m1$q|x7=H0xHxp?uWp}HlW^M2%TmF>? z7Q!L>@QD(Kw@|ObYP-?&n z*gdg-?-=WKkc}c=nl2>v0VO4BwrDV@=uyMwwWC*l=>b)EW}pBiZ%B0wXzwRt8yqnC z7ANC&sCh#YG(l6=Kppbw+E-Qx|N8|O!<+!8S;5Aoj=7DyE7gj#YP=jKNAvCZwxq=9 z1od@1K&!ii}Tx5P?hVVCD>!T9h6;&lT7b+=!K_ca1*WJ!uQ#& z3{@o6WV-lzjcDB5VS-zWo0mIG4Gsj)tN&}GW3K+|m$|q*`T+EsQvDo9su5+2v&)On zhWr}Ufh|%~h=Stu6-Vxk2yfcka#~Zk<+z)x$3!cGnr&l?`mN3NlmHF)keEUJY&RLp zvBwijYQ1I&_y5)`!!^`Q=chkQE(&`*+X#J8SndBJS|lE4L+(0~wanKs(x$K2X9If9 z<23J@JSsKPjcfn&BsTm!OBEhmKpPafQ>D>--)p~A3mP4e6OGSa)?U#g;xXX}%WVkd zd<;JP+8GX5y-3<8Z1fbe0r$CMsBWGTN&Fk${R!XtRe~ftA~;?ocpChh0L>g*Pm;a& z{6N-CWfoTcW}%wGSl-1js@z0e?c>3lD6=w$h^0Z<)DLkdb!Ey|2lq3@8y|;PD#SsU zUj&%%r-3pqy$G+i?mR(+T&EkS>MH8oeP{pLz3X< zz&>Q%IHZm^DFbI%4q(B9h&uWQcB+A}N+fJp#2CBhX$x1C{rMiIc{CD#Bx_DkmQeq;xG08+%`MIWBUQZFnb*&X8FV2zQCW#Kf zTF4YE*PWyFpptZ*|5yL1zvd<0_l&8den{oh0HB?Nf*O{D;KzSP51S#i!X=K$L;cCG zUA3PRf&-dP8`#rBox5S*`6#0xMIcN#j{0H2Mb&rm9a%WsfPaAyfp^G~`#z5mrg(JA z8W;#iUg6kR_4*CNOi)Oc{H+A{zkwRvHhf9>e8@sJR1_YI z`-v8cX4T=qe+ka8rU+#Ty$kxypC_mAtXMbk_!pw8%AS5dTGqxQxOmIbQnW1ed^9)v zOKNeEPxiqC_0<1Unv=eKSNnN&Ck9GbIP3dIKZa@>u{{MVa>V~;PAVZc1xM$ZTJjR( z12*L67`ICS$e7lV{K!d|*1*Bs9DhRmh$}sg8sh#(hGBlbV_EQDd^#($O=xi)xFu{U zn*Hd#4s3}6qJU^Sb{GCn8bnS6y1^|1|5ibXiO+U@S4Ed#_|UAz0DizQ1p2|iNQQL8 zT53g)By_;a49k|hT!_WEUjH=+#LzzjgU==(^TBt*Y&UnR+LU+CdOwLGKL+AdcKBK3 zv;MN|P8gJNtg`!eGjakBTv<|R+mIPV*50IB8f*JF^tz2<*B!kg^Xj!^Nt{lg0uBi6 zqQZ9Hqd6pqP{xSX2NXJ}1}7LjS}y*y016i z*Gg7Bk#P%1lRr&*#RL6%)snWp^|dvSF4+HN*8^#BoyBh&&37x(GA=VRY_{>HYP3*4>bJP*rckjlcnLav>k&m?lHL z=5aGEcDp1{?f77Apl~O1^8N4NLb7Nq0pD=q9o&Q{=I~})FA~5 z4&dk+nX7__zuxEIVx|ylCI~4bk13v}AUT zAU`67Do&{3?lRCIlz?Y5)`~JEIU_ivELO;AcLH9K`Io4<=h-GB+_NPA^i9S> z4sH??CcaJ!VBf^S$YWLY5wI(GTpskqn4+X}$r+M#bwuZO+mOZfSvb2*CHxQcZQ0J` zb;oSs9&3JKonrpyJ+|!6)*nYEPd|=go34?gz=2cj73s&3iE1@f=fh>gj>uAE5#{{Ksi1AkMh%pS}#<>HCtq+*YRSz>36vUw+|%Li~lTeXnIr ztz4nyyyb^l@R3xI?d7|k^p7Rmr{CG)N`syT{abA2vDq>8vf$1l!Tm>q?pbQO!424P zalQoKfTp32{^T}oTDTd42n3dSejo=2;Aca^P=0jR_mbTj^^ZkAqgVqw6EA@;$gweT zdGqhKu(t<~lQgFk6^AizlW2@V@~L`oU8#j~gP&NSn@aE^a}B8j`s#*Xym5GDsfz%U zX&jquKO(b#EX}A+j21Gs+?(am?$Q-K~np zxaXMUHi}t6`MX}pi?dbnBjP3xZKNQakB&q{pny6@aT=HIi#}|K_;1b*u9-E+t-xG z2OB8v!f~%wxeein)?=CzD_Tu?E-E|9;QZY707XLzW}V59cg|XX)Ps>`z<|sVQt3N( z!*GX|5{|?#Urcnvh|$iWO{oz$i+AOL0Qk#-ZgRVQR^yU8OxRZX)@intP(^;ez}Zu4 zKD%^ZOZib&Uu|t%jF3l7#vvEB<$7Q+rS% z$~ZL4I`F-@<)|8W+v6a(9E~-_Kc6_m0>w5Y;&sNG1->Ev2p9WPk?E5o$v49;Dzw3t zK^+u`KSVh#Ks^UVJ#K$K7QDJt$sKF1#v$-Zip%p%$`**E)-$r@P{augQdE33sct6p z0N|iEHcjFI8#XFQDFM0o#%111)q=)#&6?y#J$de(x{JRWd{3AbBiA`qe2RItc8ax(FENPo;8Hrx4)cHP{eF@wG#m;de^>$l#+TKe<~qKajZoZU}rRzw*d z$>HxvXckfY23mv&ark)ni@Ja9_oCX z*x4YSW?iTbF!|sEaZ$DfFb&gWN4@Tys$4e&>pZUAY+&4Of^IY=eEhYnPkDK3WXM^cYMzEsQGX?hJZOd26!Fs5i7Q*CGQO``r1iy)6S8E zB;njJ&PewnE-myzJUVQvi@aBTd&}iok0Swd-awmgL)iUoy8RSxwrYrTFy5NogR0!L z8`U44qQSy>k#P?d56Gr(@9tGB>|B#|v<1hv{xXeAOHF~`AHQ`A70kS9dnmcpWld&wf9uSTj#(~IFDOqFVb=6+(#==TE_U=AY^%S)qs-E9JeO-T+5Aot zLlIl~I(tvQmRtMg3vMM#NJceq;i@P5AU=vF%s^fcCj)2tPM@Nvu!ZC>7IGmIh&5YB zFwQ8sljxVVUNe3dMD#eUXwDn>ZisY?ZbQzZ(6Wl7sS)p~?BXG5>;m`Tz+GqYYsr(H z#W%?1;c9Llp*0m6&p6Yhzzdvsf*W6LKvExqc!AOaw!s9T|8Oz>;7x@9!tGV;5=`{V zzG)(YmzCwpHFXkERJQLr#E)Biu&u~Jy7^_`tsv27b|8VT%f9awFxm{sk1TCW(ZM%< zZYjGnDBojA*2TU^SCQ%T_`~gtVf#y)b}wA@o*qbL4=?)evJ3xkLbewSn=@ghbshPg zufao&Xc&sgt$rmO{hW4on0tdGkZ+j>(V`2RY2Nx7@r=eCWRSNZXLpm)|9{53d#>gD zXP$J(uf8(}Q<(E*zs>m3x$h&9$!?xYE4gV;!|nQY+0qb+LtE^6U6-jrPRXIaCJevs zo7qUJ)#t#lJcP0d2?UI`N)ieKuofM%2TqjlDAx>~t^XxZhUk*Lr+ne}LtT)UFSmL8 zC70iVNwkq`{!;ZumrFKXpw04}|2N>nr*#u<1PMipX{a&H*x$n1BC%iFuodBH@A%qS z5e+xU_*ZM;O(6|+qZKV+!vcDFy4L9?0fhq@io-Qv9{;UBE(dfyNyw0&1l=%)vBG%p z_B>N!$Krwu2LnzA6>#qqZbShKIA~7oJt}gu+~na$+LHJ0E5NUu{0aWY(OHHy`L=QR z*%;lTbc3Lh5+V&s$54@$QYq=q!RV5f?iNWwL2{GsRFE7gDWf~y=l_1#acrMBjt6#g zU)S}!&hst^JPhGIXwMOFWF9rDPWn;oTsC-Jq-Ai+J3`{+J2iN@<;@kO0VuIn!t$sB z1<3)pT+Uu;A+U>6^MCA(mlGGVdw;P2SqvlifDB(wNeqwQ@%38ka%9yFs`&8eKGDGV z+kG|2d%&PO`|}kR$oSswuDI%UewpKmz)ZXdlIgKg<1g6Ub4hR_>14D_@MnO;qh87| z$3FayaNMW;EG0`RyO0npul64USPpQJI%7v%IoQD|VXXVblYg4|JY)g-00s&w@r@Z| zEpZ-Z&DiOql#BY1c~O4*-SFtY+Btfw2?MJDecbCnEG+I3HG1aIN#E$*N z84ybqeET0ofbXg9zw=GBB5DFz_$sjD;vd}x?grD<#UO$Z>dyu+jB5)yJ$pbrj@GA$ zDd`*E+I+-mxJI(@?B$j809AYPOCq*7iHg6T1$omgziaERtZID3jj~*rI?J#YiVcoD zlOu99XJbNd*$D{1IE?`@>sy%FAy5)xw5|eDCPSXiBmL+JepmK0|1nJpd4EjnOFt+URi@s2Y%REZ=jmA}g_C1d zgPiHCM4ss_Ij@^-)fPBcip01=qg)Pas}~Oa_7G(>43BHN2m0Gr#x&v8c5Hw&^BXvR z7-2*in)XqX3fLzhjOce?)Jac>7?_m#^oKwWQhjqbR9Vc5lQDrU^?4vJs}+teH;x4{U=)OKAZ`LWsG+^HA>6KM z_a8byMv1;T+&ns?`T=+lcPKM#VO<7tTfeA2t^|pdFTW;E!fGoxbK+8b)gGAdmZ#Mg zdDbl_FNLkZv;5P6FK%TDGNL;qH;?#-)qSc7o)63t0GE*6b|N`r@Scd)4m^cHRz+58 zW4s0?>onr7hG6lKYbA^k3uAI-k|?ovSP4;T9$BRO8+PhCkjn><8^zt79bK<% zx;Z;x{^#YcvNBO0L4Y4bvNF^kh{ z$1;ZQ$YuEh+KwC#avy4Hx^3Mx=JAS~_*v@xcv54KDe4OEUa8vTia;f|>W=YT(21Azl8v9osyTN@BfmlqtwARstlbKtHkVAZ-4d@+1_=%b7JEZq0EK9?;kIdO@3djWISn~JQC#K^Pb)DFcg53kZ4 z#!#UjX6bgP$D6*FZrAS+7k_aO3xsEOc7MMrP1Y;?pmgjYA5(+<75sRRT=R&h9gB}A zp7qN(0^2X~?s|+=zOBtRZy=BgayE^mW%*?s=qw@F?ZOw7mG)rp(}U>2pG;HYY-6=n z&kRt=Vtl;6nP zKMxFb3|3tEdTO{5C3U&?k}^!H*-bH32Guz7*K`M;mD9`@+73NiZLOt$-7oR*^Ix9e z(d2n-vRSgfU)@LOkCO(7v`R524PQe+*T|x26s)49lCe0pHTIPZ=hcCm`DNGJnrPj( zqUNc?&H2w$t3EkmzB*JnFIpi{>;P4z^1}%=tHx%FDOFF=xn~Vji!0KNdht=agb~J# z=8!*|qcI)w)8g@jawAe=6bu9!kEMb-C6;2UTXKm6*&w+7{tmS;=T6Boow}Q^4+EMU z?&kS3Oqpk>`uDqqx!{kgISmMxli19^h6p#FHKM1%`W9@Vx$dMy82y`pF?Wh|NdM9t=c|9QgIR+G_9JC z7vL~`+aS>w~@US_fg>{!pe9^#vEIa}$-2$RbKp1e2Z79n}`?qkr0l*|aXj zMZRs~$|mRQYsH(3QM^VDxsP_{Db(K`qbaokIxR=$Eh!2fvGVd4gB3VyPH&33CekNNfS+w$4`0hepD`-2i&wp3 zahaye57E}@sto_3_baAkx;HSlxf3HkSiy2`a*c?^jE@fb`sx=%Z#Hh)+WI_mboClz z`lO1Woj&irexAN(RS0zDAU{B^)>@x_`P^g4nkAoFxpi>k#dg*_U`a9Xg&&1Og)_^s zLcd--+x&DN7UX$8)wb^GT;Fv4W#hD1pRsnWF2c?gwzPf`8y_g6;RU*Rs0hks^pGC0u@z8bL?Hy26BR~t;St~kJbklasuoOQ5b zGm5>sRO1y$5ttxPxp$WQZQbQWgcBhs-Trl!J>O9=O%?x{Sie=5` zRH2l+s_~EZeO=D>nX|OH{?rp4(42yHj9rF3fs3k>x%NSuFu&ym8N|VY4Az$h%bxp? zTxH!jv6|5;TMKRr=MI=Ejl(=VkM(@Xe2#^b3bc(K^e=6Q_LCgEBuHiJuhn8{<>;qc z@-=B3)Y<*y67F}913~P>LJ)^b5b4kBP`l~i6jLP}Gg5+wyrVP?$D45hf_bPPR=wV< zUQ+xCy{B^9Km69IPG7s{zxt)wUy87@={XNc;N~Odk9!SQq&way^i)mPZ~XPv+2#DJ z{&}8Xv7KyK)5L`^)1F9?lwr#o0s7M7=i{`?7yr(qkL0Y_)Hll`lZ`qnz4_V*v%h1s z_F9>zdUCI<V1rr%+FhSd{D2OjchS>qGyLgk>e#%P-h+L5)4J9LEXI+2sK&`y4xBGdfyx+ zSfeW)sRaIz`w}L8sS{|dI@64>AJzE>wU*Sw%)O+E#7=CcKTVT>O$DlK!uZ|GoHDYK znjcbu_VG6|;{PF+je_VPa)hf_d*0&8%seo69Kv^GG}jDKT{&w<%ok}M_UmI-4|m@Y z)iN8{kJ!~G_PCn0eVkn$bdfWB{Dx+~s76u5QPFUn0h8>VvaZO?))ak59bVTq8^N<4 zH&8X0iqh^W_WpKIBe)~ooVpU}^^*T^`~aIdB;=```Z8Bv~fMG340*J!$Ur+bNi3rQa684yIJwvAxduSAyZBY9GBP}P^VPFd~- zKZ-7@7v!bQGCY_XuIw?2=d0@{V*Z{xJ(Ny|6}CjXbCd~p@5U_A-i+kQ zy{h>Yk}Ei2ZD9c z?W7Q+zYn)kat*xCtKTBspVXj7Tn&n_(iMhVE!?fkPfapQHMtDAJnXT32>rwTUBshI zlVCDZaWXEX6iGxT9g)eum-BcR$D+2?ONcKM7#odRjG zfHq6K<-hxfSeB#n)t~y8M1JP48SE;M$OR_h*KYx=T!^U`oVr4{xj2;1dz&Dgo!INlF`xX-8P_1jk*;U4_jc-=7* z%^#VmxXrsuYQrGL{6Uc(0;U!oDsnjMetu|>tE>R4uXhhM`*JG?eg zE$=Ysea=|HbX$|B9e=)B4vSvGF^8T>E2 zqtfW_Wl~l2IWl$?{My@~IA;%kCXo876EyKgxTJXf-GoG>Mqd>SE?&uBdDT<(7rp=4 z|I!_2&vwn8OikUi5?{AEMe`yn`*2V2gcU58M7 zIXW2K&F?rOAIc`Qe`T-Vj~3q+iMuc<>o~~n#Q6Q)RAY}-ay?N!DMavKIorZQEJAmjr4aMaN4ME zo^IcDfD5PUWs{Q%Bpbuk{Y}D|mQqk&CulY{nK!Z?UI`w%re?|%Xw-8f&Icj!ffVQt z`kp6tcZ25Nkt-Rnn&b$+{$*>B`i7bB*u3d=YWQ#Ugw$p46tC)t2Fj|CO@#rU7hy?} zv~??jij(7g3zt&uNe7_3*n3u3GdF|=``bHOI$$K~#JP1kx?wgD?s3W6NiFAw-v8M-3FMrh5mNMJl=jIQF})`rF{EM7|WV7|}< zID6*a94|qVfC>gI6x=2PWYb{kN&(li(oJ5)8me8dm2-AKYu|kwGgczp|EXKXxV>QH z7~yN8mV-yPw1)DI`V5b}_Z5|?t;TljHbKB$^1c2$1O#jBZ&KP=vZKT9W-A$r}>Q3EeK1XZielIIYyWp7!urRb9Xf~vN za(q05*&XZI-Ebv_Ow5PjRNltvLKXIA8{8Ui9^Ye}Fu$+Cga2mxn{CS7YAxj$seCE^ zaiOmVAY-kzwyar~+AxRfWLWX0Eyac`?pBv+wd@ySM0R(rY=DALY`LepmB$>stHTMl zeuZpLx9g83w7BG~%zwW0&!?i{o?C1yzP^)0jc{(&XmVJ40k3PQl{a7qVaDqqVzixF zoCZ3NuYudmyBGwReetsow<8`N2(+hsW|H^%HuZZR^5Lh}cR6-}yay?tw$!Q^L3Ba!W2_FVdYWtFMgetn zd9SBoYjXc%+0@JZhGN;>QorJs{kj%?g^8VJ7Uyi0EB(deX-%Pfw7$@g36&=Q&zF}b zMtN~pHOe%V@hyM8s2i2UtjxB+pT_FD|0NFeVj~`>DIYrsDtPrQ?#g>cI(+-ll=1k( zfdD?0R7b9)qwd{inc`4kMSX8mB0kis(uo3I%|i9@`y=WvO;W_hXhvjnE(0aM>chwR zEljWp>4)Q^$DjB=VRUSL-1-if5%yMDB&*ti_Usf6Z`Et6cjN8zA&GCMy-5Qat4y(% z=4=+-bP8n!Y&WeA*P&qlCr#WA;O?AebUX?fa>zyrPS)nD<1PBhM=Rx)WgNoBV%|p z-!IAmicJN>9fmkD)#37xxTE9EN7U+dYq+IK+rp~}x!d3W;#k->z2Wzz`T1l`pNtHO zo<JeH(w94K8nA+>1Z)%pVLL->Pn7usZJpS9g(&W{=R@OEBwyGjOQK z{r6ov2m6ayT-866Yfnj@b+zvmrj0*&S90avMl47_`Vlx_4$CotAilA8U#S7C?Dy3I zOd~(jmC8)%av7FdpYv%_*MCm|-BgFpI)4+a_Gb4gsL)aZB29MR?U6I^h&QtJsqb{G zRAy%<-8RPW)wvC2i2Go=nOpHO(@OVdX4FjF_*(dJy73tO7E5=BsklkHG7t;rx+A@H z9P5n>lb&wR?^dc;y{j}uhy}bO?E>Adhi!xvIFV-2Ei~>nm90Al5eVVnVxahkUS2=98Zq4qSL?RZ`+eji&NFtF}j*D<8YwA&@r|Fr6;0 zuEPZ%7&V#|j@ORkrH-oCCddEn3YkGW<_X*_*pF@D=6h)nadoYfmG!m&7S9Wbn{C_2 zQmK@0KX5C1&*<#mWmpicM`c_Swb8C~(VVzZ`#btg9(H=v+(nL7QLmtDZh22SW>Uh? zyR)hr&kV<7!IjlBsUP9lZ+B30r*%f4Yxr(inV&Ayytc#dSn!2?=wWO_=5rnyVo`h8{L6^O2!m=n$ch?0Q<_~28EfP`jl}(Ir-`- zNR?#^90;AxQJ6kyWoUN$-J~cHaBpm_oO;7kj4^*?;X?RgmSv|X;C#&T;*#ZGk-+@T zyNhgwxCup`$YE1e@XwB|)k?YorkS~}L)cZ3{H2`zyo^j;(h@R*7AoxA@Q#5AWWDA&Q5y5aLG&n-ot%=Jz=>lb-sYXB5hTO z^<^nQ8dLZf-_k`w)h+h=M<(>jFhy_0~y@aTPkHEH$c zwE@h#zQ5KK!Q!5CQW|TU#>8!tLVD~_h@V5!8Ndoc)?fs-BsH;Fgy@c#t28H>Mu^IB zukjCl#XAe|9JF7zWSOnxf?wqAY$U4dRBa!OX#?_URqOCS3dJDs`HyEgIht3PKl@d;2J z6EW~(E)9#k%8`&(CZx6R9=v zUG{>`h*YoAxaia&{w7%;M(LT|4TM4^AY7^8kUF=?wc3x=b66`d0rJT=9ignhjnc)~ zWIUB-MJ5|x>~Kxrqpf994{$dW@&hBq6*8Br^M7F^&<9e#6lap2zaTs)&_P^G;Wvx; zSVoaCG8XSgiW{b}j7yQtX607`<1@`LvMYL`_3uW9OGb#zI%T!p*fYa|!zHED#txlEiEL zWgH|GMKd%Sg+K3)p?>2B5Zgig?5E=S+v^uM-?QSp)jd0qKyhRC-EnI6?U4`K?&TSn z%1}MuvNFUwrsq2eEsykH>86`GSd^WR!nsY2%#nTf z_jC(VZ@Jxbq%$4nYBTsvMYU2pqCgMr%3~qxPs$&L>=p9lDTvH=Q}0>OVSk`lBNIw) zON=vGHTd|s^B67l8UH*YN-_Zrhea$DNZtayyVPj~SbLqeVX3`PgXFx>AfnGK|;?I~^%?&=8V9zB`De4%oij7ei{d zt(fyVQ&=Me7N*=p7HpY$at^a6-jBHWY&#x$`YTKqWW9Ftc^~$lWt!#`4sxf)^AA&S zl@#;}zM|vXC;=`=Ai$h(5B&^?tHCvc37SFCPb~n!yF9^9KK&?*$D6CjD<1DWH0<}{ zAc4H2fVEsYZCwv*mTolm$ij~f0yt)_Ei(fC`hhQKMgSSiNrrEc25H5vGBskXLOk+} zpLOPXTS4H)bEbZqWh3MJp<8+)5R6$K-qb+TSQ({Z5*Oa%>TjEV3Ot^sT6D%Bbw57f zl%r3$8SY!MScNBQNTvPPh&7#uzfQ}Xg6qh4$^y%%bG$(3(bzSH9`BM&A> z$HrEYL>SegzrR*LQ+ib&MfZlU(x%Ny7k9z_d;v9vHCz!L$ct6(NOCMbK5-0xySXZo zjF}O0_uDu2N44R9t(Ee48>Iw?j}FgkADw&`YW(c<_BqXOz4x<0(YUM=;VjZs%}_^N z2qTBXJ!T1Zm=l{35R7-Wox*)rXyNPSKRz#`ZIegZl$Q1$wzeO)PwjWc@7qQ$_^#Y- z-RsPyY(2OgU9MXv+RJ^s$S^98=S;dd^8D8wv+_l{cyOV?xT(q+dLSpW` z?U*=JVfW!ae$J}tHCt9LK#5awfTPmLC0OcNDD}NU2=aO@Z2fQUo4I^W19NKONXSCV$>5G^^r+HdUz4Nqd8=ugUU%}!$hqU+(!1Maos!~G`Z(F}To9jF z6bWj_4wHo4e!4gxJT}n~ORA3G1Gqr0xq>G+pWTTjmy5#>&?j6hSgQd(KwpH8PWA$u za+(T@Wf)P$(~V8^g8f6nf#r|8q2Ae& zZo6Q~@Xj|Jo|RF=q1H~I0_@Hyw1zwb^5|dAC6ZG zvaQIgh;x~x<@Ku=;h-^i5FZGAM7E)^+@BX^m0(q4l{ZCX+`4V93$Egfo`Q3zy%rq> zOw*-I0nM#g!<_Zc;*n}tyrC!FnFI0-4BfDeCys=foSBN;Ohj*TU}m*wssfWKlXzV+ zw!mL@`Fh=;Saimz3}YHv!2Guvf8_(L(k}~B!qQ^a2h+>+3+GQ6UpE7n7EkidftpLZ zXL zOFBVjJ?OiAqs|05L)C;Z>P5pAN=u)friVmxVno|9yV33i?+ZZ8??=n5r!w$Ma;9UwcJF7I?vPcd7oZU0@-cJ+E@$$^%PXO+X~GXkBY2DG|LoQLMA3DzWIa@f zKflP~RcVONpBbHq!OlUxqX7Y&+J-9v
FGn(E!S!5qNc4P24e-n`fI-+aiG{q-Or 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 public bool IsEnableAutoTarget { get; set; } + + ///

+ /// プラグインストアの免責事項を非表示にするか + /// + public bool HidePluginStoreDisclaimer { get; set; } } /// diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 9723a908..0b9c93a1 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -660,7 +660,8 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() "Test description", "WindowTranslator.Tests", null, - null), + null, + iconUrl: "https://nuget.test/icons/test-plugin.png"), ]; handler.AddMetadataVersions( "Test.Plugin", @@ -678,6 +679,7 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() Assert.Equal( ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], package.Versions); + Assert.Equal("https://nuget.test/icons/test-plugin.png", package.IconUrl); Assert.Equal([true], handler.RequestedPrereleaseOptions); } finally @@ -789,6 +791,27 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() Assert.True(prereleaseOnlyPackage.CanInstall); } + [Fact] + public void PackagePresentationUsesMetadataAndMarksFreesiaAsOfficial() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "Test Plugin", + "Description", + "Other; Freesia", + null, + null, + ["1.0.0"], + "https://nuget.test/icons/test-plugin.png"), + isInstalled: false, + installedVersion: null); + + Assert.Equal("Test Plugin", package.Title); + Assert.True(package.IsOfficial); + Assert.Equal("https://nuget.test/icons/test-plugin.png", package.IconUrl); + } + [Fact] public void IncompatibleInstalledPackageCanReinstallACompatibleVersion() { @@ -1662,7 +1685,8 @@ private static IPackageSearchMetadata CreatePackageSearchMetadata( NuGetVersion? version = null, IEnumerable? dependencySets = null, bool isListed = true, - string? readmeFileUrl = null) + string? readmeFileUrl = null, + string? iconUrl = null) => new TestPackageSearchMetadata { Identity = new PackageIdentity( @@ -1676,6 +1700,7 @@ private static IPackageSearchMetadata CreatePackageSearchMetadata( DependencySets = dependencySets ?? [], IsListed = isListed, ReadmeFileUrl = readmeFileUrl!, + IconUrl = iconUrl is null ? null! : new Uri(iconUrl), }; private static async Task WaitForReadmeAsync( diff --git a/WindowTranslator.Tests/UserSettingsConfigurationTests.cs b/WindowTranslator.Tests/UserSettingsConfigurationTests.cs index c3ae70c3..b599fe26 100644 --- a/WindowTranslator.Tests/UserSettingsConfigurationTests.cs +++ b/WindowTranslator.Tests/UserSettingsConfigurationTests.cs @@ -14,6 +14,7 @@ public void UserSettingsIgnoresPluginParametersThatHaveNoLoadedType() .AddInMemoryCollection(new Dictionary { ["Common:ViewMode"] = nameof(ViewMode.Capture), + ["Common:HidePluginStoreDisclaimer"] = "true", ["Targets::Font"] = "Default Font", ["Targets:game:Font"] = "Test Font", ["Targets:game:SelectedPlugins:ITranslateModule"] = "MissingTranslator", @@ -25,6 +26,7 @@ public void UserSettingsIgnoresPluginParametersThatHaveNoLoadedType() new global::ConfigureUserSettings(configuration).Configure(settings); Assert.Equal(ViewMode.Capture, settings.Common.ViewMode); + Assert.True(settings.Common.HidePluginStoreDisclaimer); Assert.Equal("Default Font", settings.Targets[string.Empty].Font); var target = settings.Targets["game"]; Assert.Equal("Test Font", target.Font); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index e61225a6..e9e81ea7 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -349,7 +349,8 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc LicenseUrl: data.LicenseUrl?.AbsoluteUri, Versions: compatibleVersions .Select(version => version.Identity.Version.ToNormalizedString()) - .ToArray()); + .ToArray(), + IconUrl: data.IconUrl?.AbsoluteUri); } private bool HasCompatibleAbstractionsDependency( @@ -490,7 +491,8 @@ public record NuGetPackageInfo( string Authors, string? ProjectUrl, string? LicenseUrl, - IReadOnlyList Versions + IReadOnlyList Versions, + string? IconUrl = null ); /// インストール済みパッケージ情報 diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 46102c04..9dc5d7e1 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -48,57 +48,136 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + Grid.Row="1" + Grid.Column="1" + HorizontalAlignment="Stretch" + VerticalAlignment="Top" + FontSize="12" + Text="{Binding Description}" + TextWrapping="Wrap" /> + + + + + + + + + + + + + + + - + + + + + - + + + + + + + + + + + + + + + diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 8b39cf78..b7505faa 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -29,6 +29,9 @@ public partial class PluginStoreViewModel : ObservableObject, IDisposable [NotifyPropertyChangedFor(nameof(HasError))] private string? errorMessage; + [ObservableProperty] + private bool hideDisclaimer; + public bool HasError => this.ErrorMessage is not null; public PluginPackageViewModel? SelectedPackage @@ -392,6 +395,8 @@ public partial class PluginPackageViewModel : ObservableObject public string Title { get; } public string Description { get; } public string Authors { get; } + public string? IconUrl { get; } + public bool IsOfficial { get; } public string? ReleaseVersion { get; } public string? PrereleaseVersion { get; } public string? LatestVersion => this.UsePrerelease @@ -491,6 +496,10 @@ public PluginPackageViewModel( this.Title = info.Title; this.Description = info.Description; this.Authors = info.Authors; + this.IconUrl = info.IconUrl; + this.IsOfficial = info.Authors + .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains("Freesia", StringComparer.OrdinalIgnoreCase); this.ReleaseVersion = versions .Where(version => !version.Parsed!.IsPrerelease) .OrderByDescending(version => version.Parsed) diff --git a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs index f0e21189..905a2e94 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs +++ b/WindowTranslator/Modules/Settings/AllSettingsViewModel.cs @@ -163,6 +163,7 @@ public AllSettingsViewModel( this.target = target; this.rootConfig = config as IConfigurationRoot; this.PluginStore = pluginStoreViewModel; + this.PluginStore.HideDisclaimer = common.HidePluginStoreDisclaimer; this.updateChecker.UpdateAvailable += UpdateChecker_UpdateAvailable; SetUpUpdateInfo(); this.isStartup = GetIsStartup(); @@ -248,6 +249,7 @@ public async Task SaveAsync(object window) OverlaySwitch = this.OverlaySwitch, IsOverlayPointSwap = this.IsOverlayPointSwap, IsEnableCaptureOverlay = this.IsEnableCaptureOverlay, + HidePluginStoreDisclaimer = this.PluginStore.HideDisclaimer, }, Targets = this.Targets.ToDictionary(t => t.Name, t => new TargetSettings() { diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index a526e1fb..ef69741b 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -467,6 +467,11 @@ internal Resources() { /// public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; + /// + /// プラグインストアの免責事項に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginStoreDisclaimer => ResourceManager.GetString("PluginStoreDisclaimer", resourceCulture) ?? string.Empty; + /// /// "プレリリース" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 979d39c9..13b68e80 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -510,4 +510,7 @@ معلومات الترخيص + + المكونات الإضافية حزم تابعة لجهات خارجية موزعة عبر NuGet. يمنح مالك كل حزمة ترخيصها، ولا يضمن WindowTranslator محتواها أو سلوكها أو أمانها. ثبّت فقط الحزم التي تثق بها. + diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 2b2280c0..8bb0dc71 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -400,4 +400,7 @@ Monitory nejsou podporovány. Informace o licenci + + Pluginy jsou balíčky třetích stran distribuované prostřednictvím NuGet. Licenci ke každému balíčku uděluje jeho vlastník; WindowTranslator nezaručuje jeho obsah, chování ani bezpečnost. Instalujte pouze balíčky, kterým důvěřujete. + diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 6381cbf3..64717862 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -519,4 +519,7 @@ Monitore werden nicht unterstützt. Lizenzinformationen + + Plugins sind Drittanbieterpakete, die über NuGet verteilt werden. Die Lizenz jedes Pakets wird vom jeweiligen Eigentümer gewährt; WindowTranslator übernimmt keine Gewähr für Inhalt, Verhalten oder Sicherheit. Installieren Sie nur vertrauenswürdige Pakete. + diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 21dfeaaa..f6daca1b 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -519,4 +519,7 @@ Monitors are not supported. License information + + Plugins are third-party packages distributed through NuGet. Each package is licensed by its owner; WindowTranslator does not guarantee its content, behavior, or safety. Install only packages you trust. + diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index 35b9debe..1bc9d1c4 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -510,4 +510,7 @@ Información de licencia + + Los complementos son paquetes de terceros distribuidos mediante NuGet. La licencia de cada paquete la concede su propietario; WindowTranslator no garantiza su contenido, funcionamiento ni seguridad. Instala solo paquetes de confianza. + diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 006c1798..6d22385d 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -504,4 +504,7 @@ اطلاعات مجوز + + افزونه‌ها بسته‌های شخص ثالثی هستند که از طریق NuGet توزیع می‌شوند. مجوز هر بسته را مالک آن ارائه می‌کند و WindowTranslator محتوا، عملکرد یا امنیت آن را تضمین نمی‌کند. فقط بسته‌های مورد اعتماد را نصب کنید. + diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index a39ad460..cbb5cc6c 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -519,4 +519,7 @@ Ang monitor ay hindi suportado. Impormasyon ng lisensya + + Ang mga plugin ay mga third-party package na ipinapamahagi sa NuGet. Ang lisensya ng bawat package ay ibinibigay ng may-ari nito; hindi ginagarantiya ng WindowTranslator ang nilalaman, paggana, o kaligtasan nito. Mag-install lamang ng mga package na pinagkakatiwalaan mo. + diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 773a85ae..8d441767 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -510,4 +510,7 @@ Informations de licence + + Les plugins sont des paquets tiers distribués via NuGet. La licence de chaque paquet est accordée par son propriétaire ; WindowTranslator ne garantit ni son contenu, ni son fonctionnement, ni sa sécurité. N’installez que des paquets de confiance. + diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 04c59816..55799abc 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -512,4 +512,7 @@ लाइसेंस जानकारी + + प्लगइन NuGet के माध्यम से वितरित तृतीय-पक्ष पैकेज हैं। प्रत्येक पैकेज का लाइसेंस उसके स्वामी द्वारा दिया जाता है; WindowTranslator उसकी सामग्री, व्यवहार या सुरक्षा की गारंटी नहीं देता। केवल विश्वसनीय पैकेज इंस्टॉल करें। + diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 353f559e..0142823e 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -400,4 +400,7 @@ A monitorok nem támogatottak. Licencinformációk + + A beépülő modulok a NuGeten keresztül terjesztett, harmadik féltől származó csomagok. Az egyes csomagok licencét a tulajdonosuk biztosítja; a WindowTranslator nem garantálja azok tartalmát, működését vagy biztonságát. Csak megbízható csomagokat telepítsen. + diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 3543c1e7..04cca7d9 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -518,4 +518,7 @@ Monitor tidak didukung. Informasi lisensi + + Plugin adalah paket pihak ketiga yang didistribusikan melalui NuGet. Lisensi setiap paket diberikan oleh pemiliknya; WindowTranslator tidak menjamin konten, perilaku, atau keamanannya. Instal hanya paket yang Anda percayai. + diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index d1825877..1c2f29ba 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -519,4 +519,7 @@ 라이선스 정보 + + 플러그인은 NuGet을 통해 배포되는 타사 패키지입니다. 각 패키지의 라이선스는 해당 소유자가 제공하며, WindowTranslator는 콘텐츠, 동작 또는 안전성을 보장하지 않습니다. 신뢰할 수 있는 패키지만 설치하세요. + diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 7e53277f..06bc2ea4 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -518,4 +518,7 @@ Monitor tidak disokong. Maklumat lesen + + Pemalam ialah pakej pihak ketiga yang diedarkan melalui NuGet. Lesen setiap pakej diberikan oleh pemiliknya; WindowTranslator tidak menjamin kandungan, tingkah laku atau keselamatannya. Pasang hanya pakej yang anda percayai. + diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index f9bbba9a..9c7d3748 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -519,4 +519,7 @@ Monitory nie są obsługiwane. Informacje o licencji + + Wtyczki są pakietami innych firm rozpowszechnianymi przez NuGet. Licencję każdego pakietu zapewnia jego właściciel; WindowTranslator nie gwarantuje jego zawartości, działania ani bezpieczeństwa. Instaluj tylko zaufane pakiety. + diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 4c0f7abc..a1b611df 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -518,4 +518,7 @@ Monitor tidak didukung. Informações de licença + + Os plugins são pacotes de terceiros distribuídos pelo NuGet. A licença de cada pacote é concedida pelo proprietário; o WindowTranslator não garante seu conteúdo, funcionamento ou segurança. Instale apenas pacotes confiáveis. + diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 0774e624..95805c42 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -519,4 +519,7 @@ ライセンス情報 + + プラグインはNuGet上の第三者パッケージです。各パッケージのライセンスは所有者から付与され、WindowTranslatorは内容・動作・安全性を保証しません。信頼できるパッケージだけをインストールしてください。 + diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index e9edbc8d..712655de 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -510,4 +510,7 @@ Информация о лицензии + + Плагины — это сторонние пакеты, распространяемые через NuGet. Лицензию на каждый пакет предоставляет его владелец; WindowTranslator не гарантирует его содержимое, работу или безопасность. Устанавливайте только пакеты, которым доверяете. + diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 823db23f..321a7f85 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -519,4 +519,7 @@ ข้อมูลใบอนุญาต + + ปลั๊กอินเป็นแพ็กเกจของบุคคลที่สามที่เผยแพร่ผ่าน NuGet เจ้าของแพ็กเกจเป็นผู้ให้สิทธิ์การใช้งาน และ WindowTranslator ไม่รับประกันเนื้อหา การทำงาน หรือความปลอดภัย โปรดติดตั้งเฉพาะแพ็กเกจที่คุณเชื่อถือ + diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 54e3a7e6..f8ee3b16 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -519,4 +519,7 @@ Monitör desteklenmiyor. Lisans bilgileri + + Eklentiler, NuGet üzerinden dağıtılan üçüncü taraf paketlerdir. Her paketin lisansı sahibi tarafından sağlanır; WindowTranslator içeriğini, davranışını veya güvenliğini garanti etmez. Yalnızca güvendiğiniz paketleri yükleyin. + diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 3c74da9e..4a20d8f9 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -519,4 +519,7 @@ Màn hình không được hỗ trợ. Thông tin giấy phép + + Plugin là các gói của bên thứ ba được phân phối qua NuGet. Giấy phép của mỗi gói do chủ sở hữu cung cấp; WindowTranslator không đảm bảo nội dung, hoạt động hoặc độ an toàn của gói. Chỉ cài đặt các gói bạn tin cậy. + diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 672a4951..9247f132 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -519,4 +519,7 @@ 许可证信息 + + 插件是通过 NuGet 分发的第三方包。每个包的许可证由其所有者授予;WindowTranslator 不保证其内容、行为或安全性。请仅安装你信任的包。 + diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 93a76e22..57795173 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -519,4 +519,7 @@ 授權資訊 + + 外掛程式是透過 NuGet 發佈的第三方套件。每個套件的授權由其擁有者授予;WindowTranslator 不保證其內容、行為或安全性。請只安裝您信任的套件。 + diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index 7cff9ac0..22d40b62 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -33,6 +33,7 @@ + From 60bc741ca99da19ce1c956efd890dc7d140a79e9 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 9 Aug 2026 20:16:59 +0900 Subject: [PATCH 21/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E7=8A=B6=E6=85=8B=E3=83=90=E3=83=83=E3=82=B8=E3=82=92?= =?UTF-8?q?=E3=82=A2=E3=82=A4=E3=82=B3=E3=83=B3=E3=81=AB=E9=87=8D=E3=81=AD?= =?UTF-8?q?=E3=81=A6=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/PluginStore/PluginStoreView.xaml | 54 ++++++++++++------- .../Properties/Resources.Designer.cs | 5 ++ WindowTranslator/Properties/Resources.ar.resx | 3 ++ WindowTranslator/Properties/Resources.cs.resx | 3 ++ WindowTranslator/Properties/Resources.de.resx | 3 ++ WindowTranslator/Properties/Resources.en.resx | 3 ++ WindowTranslator/Properties/Resources.es.resx | 3 ++ WindowTranslator/Properties/Resources.fa.resx | 3 ++ .../Properties/Resources.fil.resx | 3 ++ WindowTranslator/Properties/Resources.fr.resx | 3 ++ WindowTranslator/Properties/Resources.hi.resx | 3 ++ WindowTranslator/Properties/Resources.hu.resx | 3 ++ WindowTranslator/Properties/Resources.id.resx | 3 ++ WindowTranslator/Properties/Resources.ko.resx | 3 ++ WindowTranslator/Properties/Resources.ms.resx | 3 ++ WindowTranslator/Properties/Resources.pl.resx | 3 ++ .../Properties/Resources.pt-BR.resx | 3 ++ WindowTranslator/Properties/Resources.resx | 3 ++ WindowTranslator/Properties/Resources.ru.resx | 3 ++ WindowTranslator/Properties/Resources.th.resx | 3 ++ WindowTranslator/Properties/Resources.tr.resx | 3 ++ WindowTranslator/Properties/Resources.vi.resx | 3 ++ .../Properties/Resources.zh-CN.resx | 3 ++ .../Properties/Resources.zh-TW.resx | 3 ++ 24 files changed, 107 insertions(+), 18 deletions(-) diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 9dc5d7e1..3e9f4025 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -95,27 +95,45 @@ RenderOptions.BitmapScalingMode="HighQuality" Source="{Binding IconUrl}" Stretch="UniformToFill" /> + + + + + + - - @@ -134,7 +152,7 @@ Filled="True" Foreground="{ui:ThemeResource AccentTextFillColorSecondaryBrush}" Symbol="CheckmarkCircle24" - ToolTip="{Binding Authors}" + ToolTip="{x:Static properties:Resources.PluginOfficial}" Visibility="{Binding IsOfficial, Converter={StaticResource b2vConv}}" /> @@ -246,7 +264,7 @@ Filled="True" Foreground="{ui:ThemeResource AccentTextFillColorSecondaryBrush}" Symbol="CheckmarkCircle24" - ToolTip="{Binding Authors}" + ToolTip="{x:Static properties:Resources.PluginOfficial}" Visibility="{Binding IsOfficial, Converter={StaticResource b2vConv}}" /> public static string PluginStoreDisclaimer => ResourceManager.GetString("PluginStoreDisclaimer", resourceCulture) ?? string.Empty; + /// + /// "公式プラグイン" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginOfficial => ResourceManager.GetString("PluginOfficial", resourceCulture) ?? string.Empty; + /// /// "プレリリース" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 13b68e80..253acc01 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -513,4 +513,7 @@ المكونات الإضافية حزم تابعة لجهات خارجية موزعة عبر NuGet. يمنح مالك كل حزمة ترخيصها، ولا يضمن WindowTranslator محتواها أو سلوكها أو أمانها. ثبّت فقط الحزم التي تثق بها. + + مكوّن إضافي رسمي + diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 8bb0dc71..3772ad36 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -403,4 +403,7 @@ Monitory nejsou podporovány. Pluginy jsou balíčky třetích stran distribuované prostřednictvím NuGet. Licenci ke každému balíčku uděluje jeho vlastník; WindowTranslator nezaručuje jeho obsah, chování ani bezpečnost. Instalujte pouze balíčky, kterým důvěřujete. + + Oficiální plugin + diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 64717862..f20114c6 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -522,4 +522,7 @@ Monitore werden nicht unterstützt. Plugins sind Drittanbieterpakete, die über NuGet verteilt werden. Die Lizenz jedes Pakets wird vom jeweiligen Eigentümer gewährt; WindowTranslator übernimmt keine Gewähr für Inhalt, Verhalten oder Sicherheit. Installieren Sie nur vertrauenswürdige Pakete. + + Offizielles Plugin + diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index f6daca1b..39320722 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -522,4 +522,7 @@ Monitors are not supported. Plugins are third-party packages distributed through NuGet. Each package is licensed by its owner; WindowTranslator does not guarantee its content, behavior, or safety. Install only packages you trust. + + Official plugin + diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index 1bc9d1c4..7911842d 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -513,4 +513,7 @@ Los complementos son paquetes de terceros distribuidos mediante NuGet. La licencia de cada paquete la concede su propietario; WindowTranslator no garantiza su contenido, funcionamiento ni seguridad. Instala solo paquetes de confianza. + + Complemento oficial + diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 6d22385d..8201f8a5 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -507,4 +507,7 @@ افزونه‌ها بسته‌های شخص ثالثی هستند که از طریق NuGet توزیع می‌شوند. مجوز هر بسته را مالک آن ارائه می‌کند و WindowTranslator محتوا، عملکرد یا امنیت آن را تضمین نمی‌کند. فقط بسته‌های مورد اعتماد را نصب کنید. + + افزونه رسمی + diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index cbb5cc6c..70086f03 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -522,4 +522,7 @@ Ang monitor ay hindi suportado. Ang mga plugin ay mga third-party package na ipinapamahagi sa NuGet. Ang lisensya ng bawat package ay ibinibigay ng may-ari nito; hindi ginagarantiya ng WindowTranslator ang nilalaman, paggana, o kaligtasan nito. Mag-install lamang ng mga package na pinagkakatiwalaan mo. + + Opisyal na plugin + diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 8d441767..4e3b9f8b 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -513,4 +513,7 @@ Les plugins sont des paquets tiers distribués via NuGet. La licence de chaque paquet est accordée par son propriétaire ; WindowTranslator ne garantit ni son contenu, ni son fonctionnement, ni sa sécurité. N’installez que des paquets de confiance. + + Plugin officiel + diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index 55799abc..d4d8c0ea 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -515,4 +515,7 @@ प्लगइन NuGet के माध्यम से वितरित तृतीय-पक्ष पैकेज हैं। प्रत्येक पैकेज का लाइसेंस उसके स्वामी द्वारा दिया जाता है; WindowTranslator उसकी सामग्री, व्यवहार या सुरक्षा की गारंटी नहीं देता। केवल विश्वसनीय पैकेज इंस्टॉल करें। + + आधिकारिक प्लगइन + diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 0142823e..8f7416cf 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -403,4 +403,7 @@ A monitorok nem támogatottak. A beépülő modulok a NuGeten keresztül terjesztett, harmadik féltől származó csomagok. Az egyes csomagok licencét a tulajdonosuk biztosítja; a WindowTranslator nem garantálja azok tartalmát, működését vagy biztonságát. Csak megbízható csomagokat telepítsen. + + Hivatalos beépülő modul + diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 04cca7d9..a0cbeee6 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -521,4 +521,7 @@ Monitor tidak didukung. Plugin adalah paket pihak ketiga yang didistribusikan melalui NuGet. Lisensi setiap paket diberikan oleh pemiliknya; WindowTranslator tidak menjamin konten, perilaku, atau keamanannya. Instal hanya paket yang Anda percayai. + + Plugin resmi + diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 1c2f29ba..f9a75f5c 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -522,4 +522,7 @@ 플러그인은 NuGet을 통해 배포되는 타사 패키지입니다. 각 패키지의 라이선스는 해당 소유자가 제공하며, WindowTranslator는 콘텐츠, 동작 또는 안전성을 보장하지 않습니다. 신뢰할 수 있는 패키지만 설치하세요. + + 공식 플러그인 + diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 06bc2ea4..dafec169 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -521,4 +521,7 @@ Monitor tidak disokong. Pemalam ialah pakej pihak ketiga yang diedarkan melalui NuGet. Lesen setiap pakej diberikan oleh pemiliknya; WindowTranslator tidak menjamin kandungan, tingkah laku atau keselamatannya. Pasang hanya pakej yang anda percayai. + + Pemalam rasmi + diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index 9c7d3748..5ccda834 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -522,4 +522,7 @@ Monitory nie są obsługiwane. Wtyczki są pakietami innych firm rozpowszechnianymi przez NuGet. Licencję każdego pakietu zapewnia jego właściciel; WindowTranslator nie gwarantuje jego zawartości, działania ani bezpieczeństwa. Instaluj tylko zaufane pakiety. + + Oficjalna wtyczka + diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index a1b611df..ddb0d14d 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -521,4 +521,7 @@ Monitor tidak didukung. Os plugins são pacotes de terceiros distribuídos pelo NuGet. A licença de cada pacote é concedida pelo proprietário; o WindowTranslator não garante seu conteúdo, funcionamento ou segurança. Instale apenas pacotes confiáveis. + + Plugin oficial + diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 95805c42..f15c267a 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -522,4 +522,7 @@ プラグインはNuGet上の第三者パッケージです。各パッケージのライセンスは所有者から付与され、WindowTranslatorは内容・動作・安全性を保証しません。信頼できるパッケージだけをインストールしてください。 + + 公式プラグイン + diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 712655de..6bec581b 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -513,4 +513,7 @@ Плагины — это сторонние пакеты, распространяемые через NuGet. Лицензию на каждый пакет предоставляет его владелец; WindowTranslator не гарантирует его содержимое, работу или безопасность. Устанавливайте только пакеты, которым доверяете. + + Официальный плагин + diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 321a7f85..b1a3c09d 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -522,4 +522,7 @@ ปลั๊กอินเป็นแพ็กเกจของบุคคลที่สามที่เผยแพร่ผ่าน NuGet เจ้าของแพ็กเกจเป็นผู้ให้สิทธิ์การใช้งาน และ WindowTranslator ไม่รับประกันเนื้อหา การทำงาน หรือความปลอดภัย โปรดติดตั้งเฉพาะแพ็กเกจที่คุณเชื่อถือ + + ปลั๊กอินอย่างเป็นทางการ + diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index f8ee3b16..9451921f 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -522,4 +522,7 @@ Monitör desteklenmiyor. Eklentiler, NuGet üzerinden dağıtılan üçüncü taraf paketlerdir. Her paketin lisansı sahibi tarafından sağlanır; WindowTranslator içeriğini, davranışını veya güvenliğini garanti etmez. Yalnızca güvendiğiniz paketleri yükleyin. + + Resmî eklenti + diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 4a20d8f9..0b6c42aa 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -522,4 +522,7 @@ Màn hình không được hỗ trợ. Plugin là các gói của bên thứ ba được phân phối qua NuGet. Giấy phép của mỗi gói do chủ sở hữu cung cấp; WindowTranslator không đảm bảo nội dung, hoạt động hoặc độ an toàn của gói. Chỉ cài đặt các gói bạn tin cậy. + + Plugin chính thức + diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 9247f132..1a3e255a 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -522,4 +522,7 @@ 插件是通过 NuGet 分发的第三方包。每个包的许可证由其所有者授予;WindowTranslator 不保证其内容、行为或安全性。请仅安装你信任的包。 + + 官方插件 + diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 57795173..b02a632b 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -522,4 +522,7 @@ 外掛程式是透過 NuGet 發佈的第三方套件。每個套件的授權由其擁有者授予;WindowTranslator 不保證其內容、行為或安全性。請只安裝您信任的套件。 + + 官方外掛程式 + From b313bf29e1ec5537ceb98db5dd07669f23691789 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 9 Aug 2026 23:18:53 +0900 Subject: [PATCH 22/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E3=81=AE=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 45 ++++++++++++++++++- .../PluginStore/NuGetPluginOperation.cs | 5 ++- .../Modules/PluginStore/NuGetPluginService.cs | 15 ++++++- .../PluginStore/PluginStoreViewModel.cs | 20 --------- .../Properties/Resources.Designer.cs | 15 ------- WindowTranslator/Properties/Resources.ar.resx | 9 ---- WindowTranslator/Properties/Resources.cs.resx | 9 ---- WindowTranslator/Properties/Resources.de.resx | 9 ---- WindowTranslator/Properties/Resources.en.resx | 9 ---- WindowTranslator/Properties/Resources.es.resx | 9 ---- WindowTranslator/Properties/Resources.fa.resx | 9 ---- .../Properties/Resources.fil.resx | 9 ---- WindowTranslator/Properties/Resources.fr.resx | 9 ---- WindowTranslator/Properties/Resources.hi.resx | 9 ---- WindowTranslator/Properties/Resources.hu.resx | 9 ---- WindowTranslator/Properties/Resources.id.resx | 9 ---- WindowTranslator/Properties/Resources.ko.resx | 9 ---- WindowTranslator/Properties/Resources.ms.resx | 9 ---- WindowTranslator/Properties/Resources.pl.resx | 9 ---- .../Properties/Resources.pt-BR.resx | 9 ---- WindowTranslator/Properties/Resources.resx | 9 ---- WindowTranslator/Properties/Resources.ru.resx | 9 ---- WindowTranslator/Properties/Resources.th.resx | 9 ---- WindowTranslator/Properties/Resources.tr.resx | 9 ---- WindowTranslator/Properties/Resources.vi.resx | 9 ---- .../Properties/Resources.zh-CN.resx | 9 ---- .../Properties/Resources.zh-TW.resx | 9 ---- docs/plugin.md | 3 +- 28 files changed, 63 insertions(+), 238 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 0b9c93a1..64336489 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -832,7 +832,6 @@ public void IncompatibleInstalledPackageCanReinstallACompatibleVersion() Assert.False(package.IsUpdateAvailable); Assert.True(package.RequiresReinstall); Assert.True(package.CanUpdate); - Assert.Equal(WindowTranslator.Properties.Resources.PluginIncompatible, package.StatusText); package.IsCompatible = true; @@ -1354,6 +1353,50 @@ await NuGetPluginOperation.SaveManifestAsync( } } + [Fact] + public async Task CompletedInstallRemainsLoadableWhenOperationCleanupFails() + { + var sourceDirectory = CreateTestDirectory(); + try + { + const string packageId = "Root.Plugin"; + var manifest = new InstalledManifest( + [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); + var operation = await NuGetPluginOperation.BeginAsync( + sourceDirectory, + packageId, + originalManifest: null, + CancellationToken.None); + Directory.Move(operation.WorkingPath, operation.TargetPath); + File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); + await NuGetPluginOperation.SaveManifestAsync( + manifestPath, + manifest, + CancellationToken.None); + operation.Commit(); + + Directory.CreateDirectory(operation.BackupPath); + var lockedPath = Path.Combine(operation.BackupPath, "locked.txt"); + await File.WriteAllTextAsync(lockedPath, "locked"); + await using (File.Open(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( + sourceDirectory); + + Assert.Empty(unresolved); + Assert.Equal("new", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); + } + + Assert.Empty(await NuGetPluginOperation.RecoverInterruptedOperationsAsync(sourceDirectory)); + Assert.False(Directory.Exists(operation.BackupPath)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + } + } + [Fact] public void CatalogSynchronizationFollowsCompatibilityValidationSetting() { diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs index d75abcac..b8fdaaf4 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs @@ -133,10 +133,11 @@ internal static async Task> RecoverInterruptedOperationsAsy { cancellationToken.ThrowIfCancellationRequested(); NuGetPluginOperationState? state = null; + var isCommitted = false; try { var committedPath = Path.Combine(operationDirectory, CommittedFileName); - var isCommitted = File.Exists(committedPath); + isCommitted = File.Exists(committedPath); var statePath = isCommitted ? committedPath : Path.Combine(operationDirectory, PendingFileName); @@ -166,7 +167,7 @@ await File.ReadAllTextAsync(statePath, cancellationToken).ConfigureAwait(false), } catch (Exception ex) when (ex is not OperationCanceledException) { - if (!string.IsNullOrWhiteSpace(state?.PackageId)) + if (!isCommitted && !string.IsNullOrWhiteSpace(state?.PackageId)) { unresolvedPackageIds.Add(state.PackageId); } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index e9e81ea7..22c41019 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -46,6 +46,7 @@ public sealed class NuGetPluginService : BackgroundService private readonly AsyncSemaphore refreshLock = new(1); private readonly object snapshotLock = new(); private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; + private int disposed; internal NuGetPluginService( ILogger logger, @@ -144,7 +145,7 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio .ToArray(); this.logger.LogInformation("NuGetタグ検索完了: {Count}件の候補が見つかりました。", searchResults.Length); - var requestGate = new AsyncSemaphore(MaxConcurrentMetadataRequests); + using var requestGate = new AsyncSemaphore(MaxConcurrentMetadataRequests); var packageTasks = searchResults.Select(async data => { using var request = await requestGate.EnterAsync(cancellationToken); @@ -481,6 +482,18 @@ private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken can manifest, cancellationToken); + public override void Dispose() + { + if (Interlocked.Exchange(ref this.disposed, 1) != 0) + { + return; + } + + base.Dispose(); + this.operationLock.Dispose(); + this.refreshLock.Dispose(); + } + } /// NuGetパッケージ情報 diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index b7505faa..2a53d7a0 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -424,16 +424,13 @@ public partial class PluginPackageViewModel : ObservableObject public bool IsNotInstalled => !this.IsInstalled; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(StatusText))] private string? installedVersion; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(StatusText))] [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool isUpdateAvailable; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(StatusText))] [NotifyPropertyChangedFor(nameof(RequiresReinstall))] [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool isCompatible; @@ -455,29 +452,12 @@ public partial class PluginPackageViewModel : ObservableObject [ObservableProperty] [NotifyPropertyChangedFor(nameof(LatestVersion))] [NotifyPropertyChangedFor(nameof(CanInstall))] - [NotifyPropertyChangedFor(nameof(StatusText))] [NotifyPropertyChangedFor(nameof(RequiresReinstall))] [NotifyPropertyChangedFor(nameof(CanUpdate))] private bool usePrerelease; public bool HasReadme => !string.IsNullOrWhiteSpace(this.ReadmeMarkdown); - public string StatusText - { - get - { - if (this.IsInstalled && !this.IsCompatible) - return Resources.PluginIncompatible; - if (this.IsUpdateAvailable - && this.InstalledVersion is not null - && this.LatestVersion is not null) - return string.Format(Properties.Resources.UpdateAvailableVersion, this.InstalledVersion, this.LatestVersion); - if (this.IsInstalled && this.InstalledVersion is not null) - return string.Format(Properties.Resources.InstalledVersion, this.InstalledVersion); - return string.Empty; - } - } - public PluginPackageViewModel( NuGetPackageInfo info, bool isInstalled, diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index c2719e81..3c016b28 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -292,11 +292,6 @@ internal Resources() { ///
public static string Installed => ResourceManager.GetString("Installed", resourceCulture) ?? string.Empty; - /// - /// "インストール済み: {0}" に類似しているローカライズされた文字列を検索します。 - /// - public static string InstalledVersion => ResourceManager.GetString("InstalledVersion", resourceCulture) ?? string.Empty; - /// /// "インストール済みバージョン" に類似しているローカライズされた文字列を検索します。 /// @@ -457,11 +452,6 @@ internal Resources() { ///
public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; - /// - /// "現在のWindowTranslatorメジャーバージョンとは互換性がありません。" に類似しているローカライズされた文字列を検索します。 - /// - public static string PluginIncompatible => ResourceManager.GetString("PluginIncompatible", resourceCulture) ?? string.Empty; - /// /// "プラグイン" に類似しているローカライズされた文字列を検索します。 /// @@ -672,11 +662,6 @@ internal Resources() { /// public static string UpdateAvailable => ResourceManager.GetString("UpdateAvailable", resourceCulture) ?? string.Empty; - /// - /// "インストール済み: {0} → 最新: {1}" に類似しているローカライズされた文字列を検索します。 - /// - public static string UpdateAvailableVersion => ResourceManager.GetString("UpdateAvailableVersion", resourceCulture) ?? string.Empty; - /// /// "最新バージョンに更新" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 253acc01..e174d2d5 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -474,12 +474,6 @@ يتوفر تحديث - - مثبت: {0} → الأحدث: {1} - - - مثبت: {0} - الإصدار المثبت @@ -495,9 +489,6 @@ فشل التثبيت - - هذا المكون الإضافي غير متوافق مع الإصدار الرئيسي الحالي من WindowTranslator. - إعادة التشغيل الآن diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 3772ad36..92d6c6d8 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -364,12 +364,6 @@ Monitory nejsou podporovány. Dostupná aktualizace - - Nainstalováno: {0} → Nejnovější: {1} - - - Nainstalováno: {0} - Nainstalovaná verze @@ -385,9 +379,6 @@ Monitory nejsou podporovány. Instalace se nezdařila - - Tento plugin není kompatibilní s aktuální hlavní verzí aplikace WindowTranslator. - Restartovat nyní diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index f20114c6..be73f32c 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -483,12 +483,6 @@ Monitore werden nicht unterstützt. Update verfügbar - - Installiert: {0} → Aktuell: {1} - - - Installiert: {0} - Installierte Version @@ -504,9 +498,6 @@ Monitore werden nicht unterstützt. Installation fehlgeschlagen - - Dieses Plugin ist mit der aktuellen Hauptversion von WindowTranslator nicht kompatibel. - Jetzt neu starten diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 39320722..09b72eee 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -483,12 +483,6 @@ Monitors are not supported. Update available - - Installed: {0} → Latest: {1} - - - Installed: {0} - Installed version @@ -504,9 +498,6 @@ Monitors are not supported. Installation failed - - This plugin is incompatible with the current WindowTranslator major version. - Restart now diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index 7911842d..034bef2e 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -474,12 +474,6 @@ Actualización disponible - - Instalado: {0} → Último: {1} - - - Instalado: {0} - Versión instalada @@ -495,9 +489,6 @@ Error de instalación - - Este plugin no es compatible con la versión principal actual de WindowTranslator. - Reiniciar ahora diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index 8201f8a5..1e8c3988 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -468,12 +468,6 @@ به‌روزرسانی موجود است - - نصب شده: {0} → جدیدترین: {1} - - - نصب شده: {0} - نسخه نصب شده @@ -489,9 +483,6 @@ نصب ناموفق بود - - این افزونه با نسخه اصلی فعلی WindowTranslator سازگار نیست. - اکنون راه‌اندازی مجدد شود diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 70086f03..25f82960 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -483,12 +483,6 @@ Ang monitor ay hindi suportado. Available ang update - - Naka-install: {0} → Pinakabago: {1} - - - Naka-install: {0} - Naka-install na bersyon @@ -504,9 +498,6 @@ Ang monitor ay hindi suportado. Nabigo ang pag-install - - Hindi tugma ang plugin na ito sa kasalukuyang pangunahing bersyon ng WindowTranslator. - I-restart ngayon diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 4e3b9f8b..eca6b01d 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -474,12 +474,6 @@ Mise à jour disponible - - Installé : {0} → Dernier : {1} - - - Installé : {0} - Version installée @@ -495,9 +489,6 @@ Échec de l'installation - - Ce plugin n’est pas compatible avec la version majeure actuelle de WindowTranslator. - Redémarrer maintenant diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index d4d8c0ea..a42b8a23 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -476,12 +476,6 @@ अपडेट उपलब्ध - - इंस्टॉल: {0} → नवीनतम: {1} - - - इंस्टॉल: {0} - इंस्टॉल किया गया संस्करण @@ -497,9 +491,6 @@ इंस्टॉलेशन विफल - - यह प्लगइन WindowTranslator के वर्तमान प्रमुख संस्करण के साथ संगत नहीं है। - अभी पुनः आरंभ करें diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 8f7416cf..65c0a1db 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -364,12 +364,6 @@ A monitorok nem támogatottak. Frissítés érhető el - - Telepítve: {0} → Legújabb: {1} - - - Telepítve: {0} - Telepített verzió @@ -385,9 +379,6 @@ A monitorok nem támogatottak. A telepítés sikertelen - - Ez a bővítmény nem kompatibilis a WindowTranslator jelenlegi főverziójával. - Újraindítás most diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index a0cbeee6..100d6555 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -482,12 +482,6 @@ Monitor tidak didukung. Pembaruan tersedia - - Terpasang: {0} → Terbaru: {1} - - - Terpasang: {0} - Versi terpasang @@ -503,9 +497,6 @@ Monitor tidak didukung. Instalasi gagal - - Plugin ini tidak kompatibel dengan versi mayor WindowTranslator saat ini. - Mulai ulang sekarang diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index f9a75f5c..3164353b 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -483,12 +483,6 @@ 업데이트 있음 - - 설치됨: {0} → 최신: {1} - - - 설치됨: {0} - 설치된 버전 @@ -504,9 +498,6 @@ 설치 실패 - - 이 플러그인은 현재 WindowTranslator 주 버전과 호환되지 않습니다. - 지금 다시 시작 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index dafec169..363d5c56 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -482,12 +482,6 @@ Monitor tidak disokong. Kemaskini tersedia - - Dipasang: {0} → Terkini: {1} - - - Dipasang: {0} - Versi yang dipasang @@ -503,9 +497,6 @@ Monitor tidak disokong. Pemasangan gagal - - Pemalam ini tidak serasi dengan versi utama WindowTranslator semasa. - Mulakan semula sekarang diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index 5ccda834..728761f7 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -483,12 +483,6 @@ Monitory nie są obsługiwane. Dostępna aktualizacja - - Zainstalowano: {0} → Najnowszy: {1} - - - Zainstalowano: {0} - Zainstalowana wersja @@ -504,9 +498,6 @@ Monitory nie są obsługiwane. Instalacja nie powiodła się - - Ta wtyczka nie jest zgodna z bieżącą główną wersją WindowTranslator. - Uruchom ponownie teraz diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index ddb0d14d..3926b627 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -482,12 +482,6 @@ Monitor tidak didukung. Atualização disponível - - Instalado: {0} → Mais recente: {1} - - - Instalado: {0} - Versão instalada @@ -503,9 +497,6 @@ Monitor tidak didukung. Falha na instalação - - Este plugin não é compatível com a versão principal atual do WindowTranslator. - Reiniciar agora diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index f15c267a..37438d9c 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -483,12 +483,6 @@ 更新あり - - インストール済み: {0} → 最新: {1} - - - インストール済み: {0} - インストール済みバージョン @@ -504,9 +498,6 @@ インストール失敗 - - 現在のWindowTranslatorメジャーバージョンとは互換性がありません。 - 今すぐ再起動 diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 6bec581b..e7db10dd 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -474,12 +474,6 @@ Доступно обновление - - Установлен: {0} → Последний: {1} - - - Установлен: {0} - Установленная версия @@ -495,9 +489,6 @@ Ошибка установки - - Этот плагин несовместим с текущей основной версией WindowTranslator. - Перезапустить сейчас diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index b1a3c09d..67a1d993 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -483,12 +483,6 @@ มีการอัปเดต - - ติดตั้งแล้ว: {0} → ล่าสุด: {1} - - - ติดตั้งแล้ว: {0} - เวอร์ชันที่ติดตั้ง @@ -504,9 +498,6 @@ การติดตั้งล้มเหลว - - ปลั๊กอินนี้ไม่เข้ากันกับ WindowTranslator เวอร์ชันหลักปัจจุบัน - เริ่มใหม่ตอนนี้ diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 9451921f..78a67cff 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -483,12 +483,6 @@ Monitör desteklenmiyor. Güncelleme mevcut - - Yüklü: {0} → En son: {1} - - - Yüklü: {0} - Yüklü sürüm @@ -504,9 +498,6 @@ Monitör desteklenmiyor. Kurulum başarısız - - Bu eklenti, WindowTranslator'ın mevcut ana sürümüyle uyumlu değil. - Şimdi yeniden başlat diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 0b6c42aa..33b5a1b0 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -483,12 +483,6 @@ Màn hình không được hỗ trợ. Có cập nhật - - Đã cài: {0} → Mới nhất: {1} - - - Đã cài: {0} - Phiên bản đã cài @@ -504,9 +498,6 @@ Màn hình không được hỗ trợ. Cài đặt thất bại - - Plugin này không tương thích với phiên bản chính hiện tại của WindowTranslator. - Khởi động lại ngay diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 1a3e255a..759315e5 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -483,12 +483,6 @@ 有更新 - - 已安装: {0} → 最新: {1} - - - 已安装: {0} - 已安装版本 @@ -504,9 +498,6 @@ 安装失败 - - 此插件与当前 WindowTranslator 主版本不兼容。 - 立即重启 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index b02a632b..5317f659 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -483,12 +483,6 @@ 有更新 - - 已安裝: {0} → 最新: {1} - - - 已安裝: {0} - 已安裝版本 @@ -504,9 +498,6 @@ 安裝失敗 - - 此外掛程式與目前的 WindowTranslator 主要版本不相容。 - 立即重新啟動 diff --git a/docs/plugin.md b/docs/plugin.md index 8f695c9b..a3404e84 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -26,10 +26,11 @@ cd WindowTranslator.Plugin.YourPlugin WindowTranslator.Plugin.YourPlugin - WindowTranslator Your Plugin + Your Plugin 1.0.0 YourName プラグインストアに表示する具体的な説明文 + https://github.com/YourName/YourPlugin $(PackageTags);windowtranslator-plugin MIT From 2bdf8300fab9edf0bddbd23931df43c539dbbdd5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 10 Aug 2026 00:03:17 +0900 Subject: [PATCH 23/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E3=81=AE=E3=82=A2=E3=82=A4?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E8=A1=A8=E7=A4=BA=E3=83=AC=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=82=A6=E3=83=88=E3=82=92=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/PluginStore/PluginStoreView.xaml | 101 +++++++++--------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 3e9f4025..483e2357 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -68,7 +68,7 @@ - + @@ -78,59 +78,56 @@ - - - - - - - - - - - - + Margin="0,0,8,0"> + + + + + + + + + + + @@ -159,10 +156,12 @@ From 650b194712ff9b6e797c3b605e38870a7201bbdb Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 16:17:20 +0900 Subject: [PATCH 24/43] =?UTF-8?q?=E3=83=AC=E3=82=A4=E3=82=A2=E3=82=A6?= =?UTF-8?q?=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/PluginStore/PluginStoreView.xaml | 25 +++++++++++-------- .../Properties/Resources.Designer.cs | 22 +++++++++++----- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 483e2357..202e07b7 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -20,7 +20,6 @@ BasedOn="{StaticResource {x:Type ui:Button}}" TargetType="ui:Button"> - @@ -78,12 +77,10 @@ - + - + - + @@ -331,11 +334,11 @@ Grid.Column="2" Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" - Content="{x:Static properties:Resources.Install}" DockPanel.Dock="Right" Icon="{ui:SymbolIcon ArrowDownload24}" IsEnabled="{Binding CanInstall}" Style="{StaticResource InstallButtonStyle}" + ToolTip="{x:Static properties:Resources.Install}" Visibility="{Binding IsNotInstalled, Converter={StaticResource b2vConv}}" /> @@ -344,11 +347,11 @@ Grid.Column="2" Command="{Binding DataContext.InstallCommand, RelativeSource={RelativeSource AncestorType=UserControl}}" CommandParameter="{Binding}" - Content="{x:Static properties:Resources.Update}" DockPanel.Dock="Right" Icon="{ui:SymbolIcon ArrowSync24}" IsEnabled="{Binding CanInstall}" Style="{StaticResource InstallButtonStyle}" + ToolTip="{x:Static properties:Resources.Update}" Visibility="{Binding CanUpdate, Converter={StaticResource b2vConv}}" /> diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 3c016b28..b0c8adc7 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -367,6 +367,16 @@ internal Resources() { /// public static string Misc => ResourceManager.GetString("Misc", resourceCulture) ?? string.Empty; + /// + /// "マウスポインター判定の余白" に類似しているローカライズされた文字列を検索します。 + /// + public static string MousePointerHitTestPadding => ResourceManager.GetString("MousePointerHitTestPadding", resourceCulture) ?? string.Empty; + + /// + /// "マウスポインター周辺の判定領域に追加するピクセル数" に類似しているローカライズされた文字列を検索します。 + /// + public static string MousePointerHitTestPadding_Desc => ResourceManager.GetString("MousePointerHitTestPadding_Desc", resourceCulture) ?? string.Empty; + /// /// "すでにWindowTranslatorが起動中です" に類似しているローカライズされた文字列を検索します。 /// @@ -453,19 +463,19 @@ internal Resources() { public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; /// - /// "プラグイン" に類似しているローカライズされた文字列を検索します。 + /// "公式プラグイン" に類似しているローカライズされた文字列を検索します。 /// - public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; + public static string PluginOfficial => ResourceManager.GetString("PluginOfficial", resourceCulture) ?? string.Empty; /// - /// プラグインストアの免責事項に類似しているローカライズされた文字列を検索します。 + /// "プラグイン" に類似しているローカライズされた文字列を検索します。 /// - public static string PluginStoreDisclaimer => ResourceManager.GetString("PluginStoreDisclaimer", resourceCulture) ?? string.Empty; + public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; /// - /// "公式プラグイン" に類似しているローカライズされた文字列を検索します。 + /// "プラグインはNuGet上の第三者パッケージです。各パッケージのライセンスは所有者から付与され、Win..." に類似しているローカライズされた文字列を検索します。 /// - public static string PluginOfficial => ResourceManager.GetString("PluginOfficial", resourceCulture) ?? string.Empty; + public static string PluginStoreDisclaimer => ResourceManager.GetString("PluginStoreDisclaimer", resourceCulture) ?? string.Empty; /// /// "プレリリース" に類似しているローカライズされた文字列を検索します。 From 44770a76a9bddd6ca16bdf4fcc09e8d79a254fea Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 16:28:47 +0900 Subject: [PATCH 25/43] =?UTF-8?q?=E3=83=AC=E3=82=A4=E3=82=A2=E3=82=A6?= =?UTF-8?q?=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WindowTranslator/Modules/PluginStore/PluginStoreView.xaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml index 202e07b7..60a93c38 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -298,8 +298,10 @@ Visibility="{Binding HasPrereleaseVersion, Converter={StaticResource b2vConv}}" /> Date: Tue, 11 Aug 2026 16:33:10 +0900 Subject: [PATCH 26/43] =?UTF-8?q?PR=20#615=E3=81=AE=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 116 +++++++++++++++++- .../PluginStore/NuGetPackageInstaller.cs | 16 ++- .../Modules/PluginStore/NuGetPluginCatalog.cs | 24 +++- .../Modules/PluginStore/NuGetPluginService.cs | 17 ++- .../PluginStore/PluginCompatibility.cs | 17 +++ .../PluginStore/PluginStoreViewModel.cs | 18 ++- WindowTranslator/Program.cs | 4 +- docs/plugin.md | 2 + 8 files changed, 197 insertions(+), 17 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 64336489..c9514b68 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -439,7 +439,50 @@ await File.WriteAllTextAsync( Assert.Empty(service.PackageSnapshot.InstalledPackages); Assert.Empty(NuGetPluginCatalog.GetLoadablePackageIds( testDirectory, - hostMajorVersion: 7)); + hostMajorVersion: 7, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ManifestWithoutAbstractionsVersionRangeIsRejected() + { + var testDirectory = CreateTestDirectory(); + try + { + Directory.CreateDirectory(Path.Combine(testDirectory, "Legacy.Plugin")); + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new + { + Packages = new[] + { + new + { + Id = "Legacy.Plugin", + Version = "1.0.0", + HostMajorVersion = 7, + }, + }, + })); + using var handler = new InMemoryNuGetHandler(); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + + Assert.IsType(service.PackageSnapshot.Error); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + Assert.Empty(NuGetPluginCatalog.GetLoadablePackageIds( + testDirectory, + hostMajorVersion: 7, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"))); } finally { @@ -475,6 +518,51 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task InstalledPackageCompatibilityChecksAbstractionsVersionRange() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [ + new InstalledPackageInfo( + "Range.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "[2.0.0, 3.0.0)"), + ]))); + using var handler = new InMemoryNuGetHandler(); + var hostAbstractionsVersion = NuGetVersion.Parse("1.5.0"); + using var service = CreateService( + handler, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [NuGetPluginService.AbstractionsPackageId] = hostAbstractionsVersion, + }, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.InstalledPackages); + var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( + testDirectory, + hostMajorVersion: 7, + hostAbstractionsVersion); + + Assert.Equal(PluginCompatibility.ValidationDisabled, package.IsCompatible); + Assert.Equal( + PluginCompatibility.ValidationDisabled, + loadablePackages.Contains("Range.Plugin")); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSettings() { @@ -789,6 +877,23 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() Assert.Equal("2.0.0-preview.1", prereleaseOnlyPackage.LatestVersion); Assert.True(prereleaseOnlyPackage.CanInstall); + + var releaseNewerThanPrerelease = new PluginPackageViewModel( + new NuGetPackageInfo( + "Released.Plugin", + "Released Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.9.0-preview.1", "2.0.0"]), + isInstalled: false, + installedVersion: null) + { + UsePrerelease = true, + }; + + Assert.Equal("2.0.0", releaseNewerThanPrerelease.LatestVersion); } [Fact] @@ -940,6 +1045,10 @@ public async Task InstallAcceptsCompatibleHostAbstractionsWithoutDownloadingIt() await service.InstallPackageAsync("Root.Plugin", "1.0.0"); Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Equal( + "[1.0.0, 2.0.0)", + Assert.Single(service.PackageSnapshot.InstalledPackages) + .AbstractionsVersionRange); Assert.DoesNotContain( handler.RequestedPaths, path => path.Contains( @@ -1427,7 +1536,8 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( sourceDirectory, - hostMajorVersion: 7); + hostMajorVersion: 7, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0")); NuGetPluginCatalog.SynchronizePluginFiles( sourceDirectory, destinationDirectory, @@ -1560,6 +1670,7 @@ await NuGetPluginOperation.SaveManifestAsync( sourceDirectory, tempDirectory, hostMajorVersion: 1, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"), options); await catalog.Initialize(); @@ -1636,6 +1747,7 @@ await NuGetPluginOperation.SaveManifestAsync( sourceDirectory, tempDirectory, hostMajorVersion: 1, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"), options); await catalog.Initialize(); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index e90bead4..6379213b 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -30,7 +30,7 @@ internal sealed class NuGetPackageInstaller( private readonly ILogger logger = logger; private readonly IReadOnlyDictionary hostPackageVersions = hostPackageVersions; - public async Task InstallAsync( + public async Task InstallAsync( string packageId, string version, string destinationDirectory, @@ -63,6 +63,13 @@ public async Task InstallAsync( destinationDirectory, requirePluginAssembly: artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); } + + var rootPackage = artifacts.First(artifact => + artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + return rootPackage.Metadata.Dependencies.First(dependency => + dependency.Id.Equals( + NuGetPluginService.AbstractionsPackageId, + StringComparison.OrdinalIgnoreCase)).VersionRange; } finally { @@ -218,7 +225,7 @@ await DownloadPackageAsync( this.hostPackageVersions, requirePluginPackage: currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase)); - artifacts[currentId] = new PackageArtifact(currentId, packagePath); + artifacts[currentId] = new PackageArtifact(currentId, packagePath, metadata); foreach (var dependency in metadata.Dependencies.Where(IncludesRuntimeAssets)) { if (this.hostPackageVersions.ContainsKey(dependency.Id)) @@ -586,7 +593,10 @@ private static void TryDeleteDirectory(string directory) } } - private sealed record PackageArtifact(string Id, string PackagePath); + private sealed record PackageArtifact( + string Id, + string PackagePath, + PackageMetadata Metadata); private sealed record DependencyConstraint(string Source, VersionRange Range); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 161bdc77..365c125a 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Runtime.Loader; using System.Text.Json; +using NuGet.Versioning; using Weikio.PluginFramework.Abstractions; using Weikio.PluginFramework.Catalogs; using Weikio.PluginFramework.Context; @@ -20,14 +21,21 @@ public sealed class NuGetPluginCatalog : IPluginCatalog private readonly string sourceDir; private readonly string tempDir; private readonly int hostMajorVersion; + private readonly NuGetVersion hostAbstractionsVersion; private readonly FolderPluginCatalogOptions options; private CompositePluginCatalog innerCatalog = new(); public NuGetPluginCatalog( string sourceDir, int hostMajorVersion, + NuGetVersion hostAbstractionsVersion, FolderPluginCatalogOptions options) - : this(sourceDir, DefaultTempDir, hostMajorVersion, options) + : this( + sourceDir, + DefaultTempDir, + hostMajorVersion, + hostAbstractionsVersion, + options) { } @@ -35,11 +43,13 @@ internal NuGetPluginCatalog( string sourceDir, string tempDir, int hostMajorVersion, + NuGetVersion hostAbstractionsVersion, FolderPluginCatalogOptions options) { this.sourceDir = sourceDir; this.tempDir = tempDir; this.hostMajorVersion = hostMajorVersion; + this.hostAbstractionsVersion = hostAbstractionsVersion; this.options = options; } @@ -54,7 +64,8 @@ public async Task Initialize() .ConfigureAwait(false); var loadablePackages = GetLoadablePackageIds( this.sourceDir, - this.hostMajorVersion); + this.hostMajorVersion, + this.hostAbstractionsVersion); loadablePackages.ExceptWith(unresolvedOperations); SynchronizePluginFiles(this.sourceDir, this.tempDir, loadablePackages); @@ -343,7 +354,8 @@ internal static void SynchronizePluginFiles( internal static HashSet GetLoadablePackageIds( string sourceDirectory, - int hostMajorVersion) + int hostMajorVersion, + NuGetVersion hostAbstractionsVersion) { try { @@ -355,9 +367,11 @@ internal static HashSet GetLoadablePackageIds( NuGetPluginService.ManifestJsonOptions)?.Packages ?? throw new InvalidDataException("プラグインmanifestにパッケージ一覧がありません。"); return packages - .Where(package => PluginCompatibility.IsHostMajorCompatible( + .Where(package => PluginCompatibility.IsInstalledPackageCompatible( package.HostMajorVersion, - hostMajorVersion)) + hostMajorVersion, + package.AbstractionsVersionRange, + hostAbstractionsVersion)) .Select(package => package.Id) .ToHashSet(StringComparer.OrdinalIgnoreCase); } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 22c41019..09d1c1e0 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -258,7 +258,7 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres packageResource, this.logger, this.hostPackageVersions); - await installer.InstallAsync( + var abstractionsVersionRange = await installer.InstallAsync( packageId, version, pluginOperation.WorkingPath, @@ -275,7 +275,11 @@ await installer.InstallAsync( [ .. currentManifest.Packages.Where(package => !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)), - new(packageId, version, this.hostMajorVersion), + new( + packageId, + version, + this.hostMajorVersion, + abstractionsVersionRange.ToString()), ]); await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); pluginOperation.Commit(); @@ -402,9 +406,11 @@ private InstalledPackageInfo[] GetCompatibilityAwarePackages( => packages .Select(package => package with { - IsCompatible = PluginCompatibility.IsHostMajorCompatible( + IsCompatible = PluginCompatibility.IsInstalledPackageCompatible( package.HostMajorVersion, - this.hostMajorVersion), + this.hostMajorVersion, + package.AbstractionsVersionRange, + this.hostPackageVersions.GetValueOrDefault(AbstractionsPackageId)), }) .ToArray(); @@ -512,7 +518,8 @@ public record NuGetPackageInfo( public record InstalledPackageInfo( string Id, string Version, - [property: JsonRequired] int HostMajorVersion) + [property: JsonRequired] int HostMajorVersion, + [property: JsonRequired] string AbstractionsVersionRange = "(, )") { [JsonIgnore] public bool IsCompatible { get; init; } = true; diff --git a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs index cb1f72dc..084f75c6 100644 --- a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs +++ b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs @@ -17,4 +17,21 @@ internal static bool IsVersionCompatible(VersionRange? requiredVersion, NuGetVer internal static bool IsHostMajorCompatible(int installedHostMajorVersion, int hostMajorVersion) => ValidationDisabled || installedHostMajorVersion == hostMajorVersion; + + internal static bool IsInstalledPackageCompatible( + int installedHostMajorVersion, + int hostMajorVersion, + string? abstractionsVersionRange, + NuGetVersion? hostAbstractionsVersion) + { + if (ValidationDisabled) + { + return true; + } + + return IsHostMajorCompatible(installedHostMajorVersion, hostMajorVersion) + && !string.IsNullOrWhiteSpace(abstractionsVersionRange) + && VersionRange.TryParse(abstractionsVersionRange, out var requiredVersion) + && IsVersionCompatible(requiredVersion, hostAbstractionsVersion); + } } diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 2a53d7a0..b6d454f4 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -400,7 +400,7 @@ public partial class PluginPackageViewModel : ObservableObject public string? ReleaseVersion { get; } public string? PrereleaseVersion { get; } public string? LatestVersion => this.UsePrerelease - ? this.PrereleaseVersion + ? GetLatestVersion(this.ReleaseVersion, this.PrereleaseVersion) : this.ReleaseVersion; public bool HasPrereleaseVersion => this.PrereleaseVersion is not null; public bool CanInstall => !this.IsInstalling && this.LatestVersion is not null; @@ -521,6 +521,22 @@ private void RefreshUpdateAvailable() private static NuGetVersion? ParseVersion(string version) => NuGetVersion.TryParse(version, out var parsed) ? parsed : null; + private static string? GetLatestVersion(string? releaseVersion, string? prereleaseVersion) + { + if (releaseVersion is null) + { + return prereleaseVersion; + } + if (prereleaseVersion is null) + { + return releaseVersion; + } + + return IsNewerVersion(prereleaseVersion, releaseVersion) + ? prereleaseVersion + : releaseVersion; + } + private static bool IsNewerVersion(string latestVersion, string installedVersion) { if (NuGetVersion.TryParse(latestVersion, out var latest) diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index addd1d80..8b27fdc3 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -117,9 +117,11 @@ var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); var nugetPluginsDir = Path.Combine(PathUtility.UserDir, "nuget-plugins"); +var hostPackageVersions = NuGetPluginService.CreateHostPackageVersions(); IPluginCatalog pluginFolderCatalog = new NuGetPluginCatalog( nugetPluginsDir, AppInfo.Instance.Version.Major, + hostPackageVersions[NuGetPluginService.AbstractionsPackageId], new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } }); var fallbackPluginCatalogs = new List(); var appPluginDir = @".\plugins"; @@ -190,7 +192,7 @@ sp.GetRequiredService(), sp.GetRequiredService(), nugetPluginsDir, - NuGetPluginService.CreateHostPackageVersions(), + hostPackageVersions, AppInfo.Instance.Version.Major)) .AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddTransient(); diff --git a/docs/plugin.md b/docs/plugin.md index a3404e84..12afad3a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -144,6 +144,8 @@ NuGetパッケージで宣言されたランタイム依存関係も再帰的に プラグイン情報は WindowTranslator の起動後にバックグラウンドで更新されます。 プラグインをインストールした WindowTranslator と現在のメジャーバージョンが異なる場合、 そのプラグインは互換性がないものとして起動時のロード対象から除外されます。 +また、インストール時に記録された `WindowTranslator.Abstractions` の依存バージョン範囲を +現在のホストが満たさない場合も、起動時のロード対象から除外されます。 互換バージョンを再インストールすると、次回起動から再び利用できます。 アンインストールすると管理フォルダのパッケージは直ちに削除されます。 From c5d9e0ff3d4f7c390c43e0be6faaf2f57265dbb0 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 17:13:25 +0900 Subject: [PATCH 27/43] =?UTF-8?q?=E6=97=A7manifest=E4=BA=92=E6=8F=9B?= =?UTF-8?q?=E7=94=A8=E3=81=AE=E6=97=A2=E5=AE=9A=E5=80=A4=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 120 ++++++++++-------- .../Modules/PluginStore/NuGetPluginService.cs | 2 +- 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index c9514b68..cbce4c76 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -387,7 +387,11 @@ public async Task ManifestLoadsInstalledPackages() await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new InstalledManifest( - [new InstalledPackageInfo("Root.Plugin", "1.0.0", HostMajorVersion: 7)]))); + [new InstalledPackageInfo( + "Root.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "[1.0.0, 2.0.0)")]))); using var handler = new InMemoryNuGetHandler(); using var service = CreateService( @@ -448,48 +452,6 @@ await File.WriteAllTextAsync( } } - [Fact] - public async Task ManifestWithoutAbstractionsVersionRangeIsRejected() - { - var testDirectory = CreateTestDirectory(); - try - { - Directory.CreateDirectory(Path.Combine(testDirectory, "Legacy.Plugin")); - await File.WriteAllTextAsync( - Path.Combine(testDirectory, "nuget-manifest.json"), - JsonSerializer.Serialize(new - { - Packages = new[] - { - new - { - Id = "Legacy.Plugin", - Version = "1.0.0", - HostMajorVersion = 7, - }, - }, - })); - using var handler = new InMemoryNuGetHandler(); - using var service = CreateService( - handler, - testDirectory, - hostMajorVersion: 7); - - await service.RefreshPackageInformationAsync(); - - Assert.IsType(service.PackageSnapshot.Error); - Assert.Empty(service.PackageSnapshot.InstalledPackages); - Assert.Empty(NuGetPluginCatalog.GetLoadablePackageIds( - testDirectory, - hostMajorVersion: 7, - hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"))); - } - finally - { - DeleteTestDirectory(testDirectory); - } - } - [Fact] public async Task InstalledPackageCompatibilityFollowsValidationSetting() { @@ -499,7 +461,11 @@ public async Task InstalledPackageCompatibilityFollowsValidationSetting() await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new InstalledManifest( - [new InstalledPackageInfo("Old.Plugin", "1.0.0", HostMajorVersion: 6)]))); + [new InstalledPackageInfo( + "Old.Plugin", + "1.0.0", + HostMajorVersion: 6, + AbstractionsVersionRange: "[1.0.0, 2.0.0)")]))); using var handler = new InMemoryNuGetHandler(); using var service = CreateService( handler, @@ -622,7 +588,11 @@ public async Task PluginStoreKeepsInstalledPackagesVisibleWhenNuGetSearchFails() await File.WriteAllTextAsync( Path.Combine(testDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new InstalledManifest( - [new InstalledPackageInfo("Installed.Plugin", "1.2.3", HostMajorVersion: 7)])), + [new InstalledPackageInfo( + "Installed.Plugin", + "1.2.3", + HostMajorVersion: 7, + AbstractionsVersionRange: "(, )")])), Encoding.UTF8); using var handler = new InMemoryNuGetHandler(); handler.SearchException = new HttpRequestException("NuGet search failed."); @@ -1107,7 +1077,11 @@ public async Task InstallRejectsPackageWithoutPluginTag() File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "old"); await NuGetPluginOperation.SaveManifestAsync( Path.Combine(testDirectory, "nuget-manifest.json"), - new([new("Root.Plugin", "0.9.0", HostMajorVersion: 1)]), + new([new( + "Root.Plugin", + "0.9.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), CancellationToken.None); using var handler = new InMemoryNuGetHandler(); handler.AddPackage( @@ -1369,9 +1343,17 @@ public async Task InterruptedInstallIsRolledBackBeforeItIsTreatedAsCompleted() { const string packageId = "Root.Plugin"; var originalManifest = new InstalledManifest( - [new InstalledPackageInfo(packageId, "1.0.0", HostMajorVersion: 1)]); + [new InstalledPackageInfo( + packageId, + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]); var updatedManifest = new InstalledManifest( - [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + [new InstalledPackageInfo( + packageId, + "2.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]); var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); await NuGetPluginOperation.SaveManifestAsync( manifestPath, @@ -1419,9 +1401,17 @@ public async Task CompletedInstallKeepsNewFilesAndOnlyCleansOperationData() { const string packageId = "Root.Plugin"; var originalManifest = new InstalledManifest( - [new InstalledPackageInfo(packageId, "1.0.0", HostMajorVersion: 1)]); + [new InstalledPackageInfo( + packageId, + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]); var updatedManifest = new InstalledManifest( - [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + [new InstalledPackageInfo( + packageId, + "2.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]); var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); await NuGetPluginOperation.SaveManifestAsync( manifestPath, @@ -1470,7 +1460,11 @@ public async Task CompletedInstallRemainsLoadableWhenOperationCleanupFails() { const string packageId = "Root.Plugin"; var manifest = new InstalledManifest( - [new InstalledPackageInfo(packageId, "2.0.0", HostMajorVersion: 1)]); + [new InstalledPackageInfo( + packageId, + "2.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]); var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); var operation = await NuGetPluginOperation.BeginAsync( sourceDirectory, @@ -1530,8 +1524,16 @@ public void CatalogSynchronizationFollowsCompatibilityValidationSetting() Path.Combine(sourceDirectory, "nuget-manifest.json"), JsonSerializer.Serialize(new InstalledManifest( [ - new InstalledPackageInfo("Compatible.Plugin", "1.0.0", HostMajorVersion: 7), - new InstalledPackageInfo("Incompatible.Plugin", "1.0.0", HostMajorVersion: 6), + new InstalledPackageInfo( + "Compatible.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "(, )"), + new InstalledPackageInfo( + "Incompatible.Plugin", + "1.0.0", + HostMajorVersion: 6, + AbstractionsVersionRange: "(, )"), ]))); var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( @@ -1643,7 +1645,11 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() SearchOption.AllDirectories)); await NuGetPluginOperation.SaveManifestAsync( Path.Combine(sourceDirectory, "nuget-manifest.json"), - new([new("Catalog.Probe", "1.0.0", HostMajorVersion: 1)]), + new([new( + "Catalog.Probe", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), CancellationToken.None); var options = new FolderPluginCatalogOptions(); @@ -1719,7 +1725,11 @@ public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() } await NuGetPluginOperation.SaveManifestAsync( Path.Combine(sourceDirectory, "nuget-manifest.json"), - new([new("Catalog.Probe", "1.0.0", HostMajorVersion: 1)]), + new([new( + "Catalog.Probe", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), CancellationToken.None); var options = new FolderPluginCatalogOptions(); diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 09d1c1e0..ac0789dc 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -519,7 +519,7 @@ public record InstalledPackageInfo( string Id, string Version, [property: JsonRequired] int HostMajorVersion, - [property: JsonRequired] string AbstractionsVersionRange = "(, )") + [property: JsonRequired] string AbstractionsVersionRange) { [JsonIgnore] public bool IsCompatible { get; init; } = true; From 18b57629aaeeb00890e133f9f03c607058ca7a6c Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 19:23:34 +0900 Subject: [PATCH 28/43] =?UTF-8?q?NuGet=E6=89=80=E6=9C=89=E8=80=85=E3=81=A7?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=82=92=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 60 +++++++++++++++++-- .../Modules/PluginStore/NuGetPluginService.cs | 9 ++- .../PluginStore/PluginStoreViewModel.cs | 4 +- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index cbce4c76..48d3f49b 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -746,6 +746,55 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() } } + [Fact] + public async Task SearchUsesNuGetOwnersForOfficialPackageStatus() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Official.Plugin", + title: null, + description: null, + authors: "Other", + projectUrl: null, + licenseUrl: null, + owners: [NuGetPluginService.OfficialPackageOwner]), + CreatePackageSearchMetadata( + "Spoofed.Plugin", + title: null, + description: null, + authors: NuGetPluginService.OfficialPackageOwner, + projectUrl: null, + licenseUrl: null, + owners: ["Other"]), + ]; + handler.AddMetadataVersions( + "Official.Plugin", + CreatePluginVersionMetadata("1.0.0")); + handler.AddMetadataVersions( + "Spoofed.Plugin", + CreatePluginVersionMetadata("1.0.0")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.True(service.PackageSnapshot.Packages + .Single(package => package.Id == "Official.Plugin") + .IsOfficial); + Assert.False(service.PackageSnapshot.Packages + .Single(package => package.Id == "Spoofed.Plugin") + .IsOfficial); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task SearchKeepsOnlyVersionsWithCompatibleDirectAbstractionsDependency() { @@ -867,18 +916,19 @@ public void PackageVersionSelectionRequiresOptInForPrerelease() } [Fact] - public void PackagePresentationUsesMetadataAndMarksFreesiaAsOfficial() + public void PackagePresentationUsesOfficialFlagFromMetadata() { var package = new PluginPackageViewModel( new NuGetPackageInfo( "Test.Plugin", "Test Plugin", "Description", - "Other; Freesia", + "Other", null, null, ["1.0.0"], - "https://nuget.test/icons/test-plugin.png"), + "https://nuget.test/icons/test-plugin.png", + IsOfficial: true), isInstalled: false, installedVersion: null); @@ -1851,7 +1901,8 @@ private static IPackageSearchMetadata CreatePackageSearchMetadata( IEnumerable? dependencySets = null, bool isListed = true, string? readmeFileUrl = null, - string? iconUrl = null) + string? iconUrl = null, + IReadOnlyList? owners = null) => new TestPackageSearchMetadata { Identity = new PackageIdentity( @@ -1866,6 +1917,7 @@ private static IPackageSearchMetadata CreatePackageSearchMetadata( IsListed = isListed, ReadmeFileUrl = readmeFileUrl!, IconUrl = iconUrl is null ? null! : new Uri(iconUrl), + OwnersList = owners ?? [], }; private static async Task WaitForReadmeAsync( diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index ac0789dc..1f231b7e 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -23,6 +23,7 @@ public sealed class NuGetPluginService : BackgroundService internal const string HttpClientName = "NuGetPluginReadme"; internal const string PluginTag = "windowtranslator-plugin"; internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; + internal const string OfficialPackageOwner = "Freesia"; private const int SearchResultLimit = 100; private const int MaxConcurrentMetadataRequests = 8; private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); @@ -355,7 +356,10 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc Versions: compatibleVersions .Select(version => version.Identity.Version.ToNormalizedString()) .ToArray(), - IconUrl: data.IconUrl?.AbsoluteUri); + IconUrl: data.IconUrl?.AbsoluteUri, + IsOfficial: data.OwnersList.Contains( + OfficialPackageOwner, + StringComparer.OrdinalIgnoreCase)); } private bool HasCompatibleAbstractionsDependency( @@ -511,7 +515,8 @@ public record NuGetPackageInfo( string? ProjectUrl, string? LicenseUrl, IReadOnlyList Versions, - string? IconUrl = null + string? IconUrl = null, + bool IsOfficial = false ); /// インストール済みパッケージ情報 diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index b6d454f4..25d1ce77 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -477,9 +477,7 @@ public PluginPackageViewModel( this.Description = info.Description; this.Authors = info.Authors; this.IconUrl = info.IconUrl; - this.IsOfficial = info.Authors - .Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Contains("Freesia", StringComparer.OrdinalIgnoreCase); + this.IsOfficial = info.IsOfficial; this.ReleaseVersion = versions .Where(version => !version.Parsed!.IsPrerelease) .OrderByDescending(version => version.Parsed) From b6b3df1e308c998eba3e9178b8dc678fc9b46f2b Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 22:04:56 +0900 Subject: [PATCH 29/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E8=B5=B7=E5=8B=95=E6=99=82=E5=89=8A=E9=99=A4?= =?UTF-8?q?=E3=81=A8NuGet=E5=85=AC=E9=96=8B=E6=96=B9=E5=BC=8F=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-package.yml | 29 ++++++-- .../NuGetPluginServiceTests.cs | 58 +++++++++++++++- .../Modules/PluginStore/NuGetPluginCatalog.cs | 67 +++++++++++++++---- .../Modules/PluginStore/NuGetPluginService.cs | 12 +--- docs/plugin.md | 5 +- 5 files changed, 140 insertions(+), 31 deletions(-) diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index b2ad6bf6..de573cbd 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -1,12 +1,16 @@ name: .NET Core Package on: + pull_request: push: tags: [v*] jobs: build: runs-on: windows-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v7 with: @@ -28,12 +32,17 @@ jobs: - id: package-version shell: pwsh run: | - $tag = '${{ github.ref_name }}' - if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$') { - Write-Error "NuGet package tag must be v-prefixed SemVer: $tag" - exit 1 + if ('${{ github.event_name }}' -eq 'push') { + $tag = '${{ github.ref_name }}' + if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$') { + Write-Error "NuGet package tag must be v-prefixed SemVer: $tag" + exit 1 + } + "version=$($Matches.version)" >> $env:GITHUB_OUTPUT + } + else { + "version=0.0.0-pr.${{ github.run_number }}" >> $env:GITHUB_OUTPUT } - "version=$($Matches.version)" >> $env:GITHUB_OUTPUT - uses: Jimver/cuda-toolkit@v0.2.30 with: cuda: '12.9.0' @@ -76,4 +85,12 @@ jobs: exit $LASTEXITCODE } } - dotnet nuget push pack\*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + - name: NuGet login + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + id: nuget_login + uses: NuGet/login@v1 + with: + user: Freesia + - name: NuGet push + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: dotnet nuget push pack\*.nupkg --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 48d3f49b..54ae0b60 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -188,7 +188,7 @@ await File.ReadAllTextAsync( } [Fact] - public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstall() + public async Task UninstallDeletesManagedFilesAtNextStartupAndAllowsManualReinstall() { var testDirectory = CreateTestDirectory(); try @@ -221,9 +221,12 @@ public async Task UninstallRemovesManagedFilesImmediatelyAndAllowsManualReinstal await service.InstallPackageAsync("Root.Plugin", "1.0.0"); await service.UninstallPackageAsync("Root.Plugin"); - Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); Assert.Empty(service.PackageSnapshot.InstalledPackages); + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(testDirectory); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + await service.InstallPackageAsync("Root.Plugin", "2.0.0"); Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); @@ -1246,6 +1249,57 @@ public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() } } + [Fact] + public void StartupCleanupDeletesOnlyPackagesMissingFromAReadableManifest() + { + var sourceDirectory = CreateTestDirectory(); + try + { + var installedDirectory = Path.Combine(sourceDirectory, "Installed.Plugin"); + var removedDirectory = Path.Combine(sourceDirectory, "Removed.Plugin"); + var operationsDirectory = Path.Combine( + sourceDirectory, + NuGetPluginOperation.OperationsDirectoryName); + Directory.CreateDirectory(installedDirectory); + Directory.CreateDirectory(removedDirectory); + Directory.CreateDirectory(operationsDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize( + new InstalledManifest( + [ + new( + "Installed.Plugin", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )"), + ]), + NuGetPluginService.ManifestJsonOptions)); + + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(sourceDirectory); + + Assert.True(Directory.Exists(installedDirectory)); + Assert.False(Directory.Exists(removedDirectory)); + Assert.True(Directory.Exists(operationsDirectory)); + + var directoryKeptForInvalidManifest = Path.Combine( + sourceDirectory, + "Kept.For.Invalid.Manifest"); + Directory.CreateDirectory(directoryKeptForInvalidManifest); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + "{ invalid json"); + + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(sourceDirectory); + + Assert.True(Directory.Exists(directoryKeptForInvalidManifest)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + } + } + [Fact] public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() { diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 365c125a..4c37a1e5 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.Loader; @@ -62,6 +63,7 @@ public async Task Initialize() var unresolvedOperations = await NuGetPluginOperation .RecoverInterruptedOperationsAsync(this.sourceDir) .ConfigureAwait(false); + DeleteUninstalledPackageDirectories(this.sourceDir); var loadablePackages = GetLoadablePackageIds( this.sourceDir, this.hostMajorVersion, @@ -356,31 +358,72 @@ internal static HashSet GetLoadablePackageIds( string sourceDirectory, int hostMajorVersion, NuGetVersion hostAbstractionsVersion) + => (TryLoadManifest(sourceDirectory)?.Packages ?? []) + .Where(package => PluginCompatibility.IsInstalledPackageCompatible( + package.HostMajorVersion, + hostMajorVersion, + package.AbstractionsVersionRange, + hostAbstractionsVersion)) + .Select(package => package.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + internal static void DeleteUninstalledPackageDirectories(string sourceDirectory) + { + var manifest = TryLoadManifest(sourceDirectory); + if (manifest is null || !Directory.Exists(sourceDirectory)) + { + return; + } + + var installedPackageIds = manifest.Packages + .Select(package => package.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var packageDirectory in Directory.EnumerateDirectories(sourceDirectory)) + { + var directoryName = Path.GetFileName(packageDirectory); + if (directoryName.Equals( + NuGetPluginOperation.OperationsDirectoryName, + StringComparison.OrdinalIgnoreCase) + || installedPackageIds.Contains(directoryName)) + { + continue; + } + + try + { + Directory.Delete(packageDirectory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.TraceWarning( + "アンインストール済みNuGetプラグインの削除に失敗しました: {0} ({1})", + packageDirectory, + ex); + } + } + } + + private static InstalledManifest? TryLoadManifest(string sourceDirectory) { try { using var stream = File.OpenRead(Path.Combine( sourceDirectory, "nuget-manifest.json")); - var packages = JsonSerializer.Deserialize( + var manifest = JsonSerializer.Deserialize( stream, - NuGetPluginService.ManifestJsonOptions)?.Packages - ?? throw new InvalidDataException("プラグインmanifestにパッケージ一覧がありません。"); - return packages - .Where(package => PluginCompatibility.IsInstalledPackageCompatible( - package.HostMajorVersion, - hostMajorVersion, - package.AbstractionsVersionRange, - hostAbstractionsVersion)) - .Select(package => package.Id) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + NuGetPluginService.ManifestJsonOptions) + ?? throw new InvalidDataException("プラグインmanifestが空です。"); + return manifest.Packages is null + ? throw new InvalidDataException("プラグインmanifestにパッケージ一覧がありません。") + : manifest; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException) { - return new HashSet(StringComparer.OrdinalIgnoreCase); + return null; } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 1f231b7e..45db7eba 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -294,8 +294,8 @@ .. currentManifest.Packages.Where(package => } /// - /// 指定したパッケージを管理フォルダから削除します。 - /// 実行中のプラグインは一時フォルダから読み込まれているため、反映には再起動が必要です。 + /// 指定したパッケージをアンインストール対象としてmanifestから削除します。 + /// 管理フォルダの実体は次回起動時に削除されます。 /// public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) { @@ -307,14 +307,8 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); UpdateInstalledPackages(updatedManifest.Packages); - var targetPath = NuGetPluginOperation.GetPackageDirectory(this.nugetPluginsDir, packageId); - if (Directory.Exists(targetPath)) - { - Directory.Delete(targetPath, recursive: true); - } - this.logger.LogInformation( - "パッケージ {PackageId} を管理フォルダからアンインストールしました。再起動後に反映されます。", + "パッケージ {PackageId} をアンインストール対象として記録しました。管理フォルダは次回起動時に削除されます。", packageId); } diff --git a/docs/plugin.md b/docs/plugin.md index 12afad3a..7fccac4a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -148,8 +148,9 @@ NuGetパッケージで宣言されたランタイム依存関係も再帰的に 現在のホストが満たさない場合も、起動時のロード対象から除外されます。 互換バージョンを再インストールすると、次回起動から再び利用できます。 -アンインストールすると管理フォルダのパッケージは直ちに削除されます。 -実行中に読み込まれたプラグインを停止するには、WindowTranslator の再起動が必要です。 +アンインストールするとパッケージは読み込み対象から除外され、管理フォルダの実体は +次回の WindowTranslator 起動時に削除されます。実行中に読み込まれたプラグインを +停止するにも、WindowTranslator の再起動が必要です。 ## 注意事項 From eb941bb83bab40bb8eb1d0a918c508349b1d8a88 Mon Sep 17 00:00:00 2001 From: Freesia Date: Tue, 11 Aug 2026 23:16:07 +0900 Subject: [PATCH 30/43] =?UTF-8?q?PLaMo=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AENuGet=E3=83=91=E3=83=83=E3=82=B1=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E4=BD=9C=E6=88=90=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowTranslator.Plugin.PLaMoPlugin.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index d5790d8a..7c88752d 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -5,7 +5,6 @@ PLaMo Translator Plugin Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. true - false true x64 From 9299077d8d0320641e5620fe1dc67f60cfde2361 Mon Sep 17 00:00:00 2001 From: Freesia Date: Thu, 13 Aug 2026 16:43:26 +0900 Subject: [PATCH 31/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3README=E3=82=92=E3=82=A2=E3=83=97=E3=83=AA=E8=A8=80?= =?UTF-8?q?=E8=AA=9E=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../README.md | 243 +++++++++++++++++- .../README.md | 218 +++++++++++++++- .../README.md | 220 +++++++++++++++- .../README.md | 204 ++++++++++++++- .../README.md | 183 ++++++++++++- .../README.md | 205 ++++++++++++++- .../README.md | 200 +++++++++++++- .../README.md | 223 +++++++++++++++- .../README.md | 189 +++++++++++++- .../README.md | 217 +++++++++++++++- .../README.md | 185 ++++++++++++- .../LocalizedReadmeSelectorTests.cs | 137 ++++++++++ .../NuGetPluginServiceTests.cs | 49 ++++ .../PluginStore/LocalizedReadmeSelector.cs | 97 +++++++ .../Modules/PluginStore/NuGetPluginService.cs | 12 +- .../PluginStore/PluginStoreViewModel.cs | 2 + docs/plugin.md | 45 ++++ 17 files changed, 2571 insertions(+), 58 deletions(-) create mode 100644 WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs create mode 100644 WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md index 13c70698..ef3e498a 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md @@ -1,12 +1,15 @@ # WindowTranslator Bergamot Translator Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、Bergamotによるニューラル機械翻訳をローカル実行するプラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Bergamotによるニューラル機械翻訳をローカル実行するプラグインです。 ## 機能 - 翻訳テキストを外部サービスへ送信せず、オフラインで翻訳 - 対応する言語ペアのモデルを初回利用時に自動取得 - 直接変換できない言語ペアでは、利用可能な場合に英語を経由して翻訳 +- ネットワーク状況や翻訳回数の上限に影響されないローカル処理 ## 必要条件 @@ -15,8 +18,240 @@ モデルの取得後はオフラインで利用できます。モデルが提供されていない言語ペアでは、このモジュールを選択できません。 -## インストール +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that runs Bergamot neural machine translation locally. + +## Features + +- Translates offline without sending text to an external service +- Downloads the model for a supported language pair when it is first used +- Translates through English when a direct model is unavailable and a suitable route exists +- Runs locally without network instability or translation quotas + +## Requirements + +- A language pair for which a compatible Bergamot model is available +- An internet connection when downloading a model for the first time + +After a model has been downloaded, translation can run offline. The module cannot be selected for language pairs that have no available model. + +## ar + +وحدة ترجمة آلية تعمل دون اتصال. + +### المزايا +- **مجاني تماماً**: بدون أي رسوم +- **بدون حد للترجمة**: ترجم بقدر ما تريد +- **سريع**: المعالجة المحلية للترجمة السريعة +- **الخصوصية**: لا حاجة لاتصال بالإنترنت، البيانات لا تُرسل للخارج +- **الاستقرار**: لا يتأثر بالشبكة + +## cs + +Modul strojového překladu fungující offline. + +### Výhody +- **Zcela zdarma**: Bez jakýchkoli poplatků +- **Bez omezení překladu**: Můžete překládat kolikrát chcete +- **Rychlý**: Překlad je rychlý, protože se zpracovává lokálně +- **Soukromí**: Nevyžaduje připojení k internetu, data se neodesílají ven +- **Stabilita**: Není ovlivněn síťovými podmínkami + +## de + +Ein Maschinenübersetzungsmodul, das offline funktioniert. + +### Vorteile +- **Völlig kostenlos**: Keinerlei Kosten +- **Keine Übersetzungslimits**: Sie können beliebig oft übersetzen +- **Schnell**: Übersetzung ist schnell, da sie lokal verarbeitet wird +- **Datenschutz**: Keine Internetverbindung erforderlich, Daten werden nicht extern gesendet +- **Stabilität**: Nicht von Netzwerkbedingungen betroffen + +## es + +Un módulo de traducción automática que funciona sin conexión. + +### Ventajas +- **Completamente gratis**: Sin cargos +- **Sin límite de traducción**: Traduzca cuanto quiera +- **Rápido**: Procesamiento local para traducción rápida +- **Privacidad**: No se necesita conexión a Internet, los datos no se envían al exterior +- **Estabilidad**: No afectado por la red + +## fa + +ماژول ترجمه ماشینی که بدون اتصال به اینترنت کار می‌کند. + +### مزایا +- **کاملاً رایگان**: بدون هیچ هزینه‌ای +- **بدون محدودیت ترجمه**: هر مقدار که بخواهید ترجمه کنید +- **سریع**: پردازش محلی برای ترجمه سریع +- **حریم خصوصی**: نیاز به اتصال به اینترنت ندارد، داده‌ها به خارج ارسال نمی‌شوند +- **پایداری**: تحت تأثیر شبکه نیست + +## fil + +Isang modyul ng machine translation na gumagana offline. + +### Mga Bentahe +- **Lubos na Libre**: Walang anumang bayad +- **Walang Limitasyon sa Pagsasalin**: Maaari kang magsalin ng maraming beses hangga't gusto mo +- **Mabilis**: Ang pagsasalin ay mabilis dahil ito ay naproseso nang lokal +- **Privacy**: Walang kailangang koneksyon sa internet, ang data ay hindi ipinapadala sa labas +- **Katatagan**: Hindi apektado ng mga kondisyon ng network + +## fr + +Un module de traduction automatique qui fonctionne hors ligne. + +### Avantages +- **Complètement gratuit**: Aucun frais +- **Pas de limite de traduction**: Traduisez autant que vous voulez +- **Rapide**: Traitement local pour une traduction rapide +- **Confidentialité**: Pas de connexion Internet nécessaire, les données ne sont pas envoyées à l'extérieur +- **Stabilité**: Non affecté par le réseau + +## hi + +ऑफ़लाइन काम करने वाला मशीन अनुवाद मॉड्यूल। + +### फायदे +- **पूर्ण रूप से निःशुल्क**: बिल्कुल कोई शुल्क नहीं लगता +- **अनुवाद की कोई सीमा नहीं**: कितनी भी बार अनुवाद कर सकते हैं +- **तेज़**: स्थानीय प्रसंस्करण के कारण अनुवाद तेज़ है +- **गोपनीयता**: इंटरनेट कनेक्शन की आवश्यकता नहीं, डेटा बाहर नहीं भेजा जाता +- **स्थिरता**: नेटवर्क के प्रभाव से मुक्त + +## hu + +Offline gépi fordítási modul. + +### Előnyök +- **Teljesen ingyenes**: Semmiféle díj +- **Korlátlan fordítás**: Annyiszor fordíthat, amennyiszer szeretne +- **Gyors**: Helyileg feldolgozott, gyors fordítás +- **Adatvédelem**: Nem igényel internetkapcsolatot, adatokat nem küldi el +- **Stabilitás**: Nincs hatással a hálózati feltételek + +## id + +Modul terjemahan mesin yang berfungsi offline. + +### Keuntungan +- **Sepenuhnya Gratis**: Tidak ada biaya sama sekali +- **Tidak Ada Batas Terjemahan**: Anda dapat menerjemahkan sebanyak yang Anda inginkan +- **Cepat**: Terjemahan cepat karena diproses secara lokal +- **Privasi**: Tidak ada koneksi internet yang diperlukan, data tidak dikirim ke luar +- **Stabilitas**: Tidak terpengaruh oleh kondisi jaringan + +## ko + +오프라인에서 동작하는 기계 번역 모듈입니다. + +### 장점 +- **완전 무료**: 비용이 전혀 들지 않습니다 +- **번역 제한 없음**: 몇 번이라도 번역할 수 있습니다 +- **빠름**: 로컬에서 처리되므로 번역이 빠릅니다 +- **프라이버시**: 인터넷 연결이 불필요하며, 데이터가 외부로 전송되지 않습니다 +- **안정성**: 네트워크의 영향을 받지 않습니다 + +## ms + +Modul terjemahan mesin yang berfungsi di luar talian. + +### Kelebihan +- **Percuma Sepenuhnya**: Tiada caj sama sekali +- **Tiada Had Terjemahan**: Anda boleh menterjemah sebanyak yang anda mahu +- **Pantas**: Terjemahan pantas kerana diproses secara tempatan +- **Privasi**: Tiada sambungan internet diperlukan, data tidak dihantar ke luar +- **Kestabilan**: Tidak terjejas oleh keadaan rangkaian + +## pl + +Moduł tłumaczenia maszynowego działający offline. + +### Zalety +- **Całkowicie darmowe**: Bez żadnych opłat +- **Brak ograniczeń tłumaczenia**: Możesz tłumaczyć tyle razy, ile chcesz +- **Szybkie**: Tłumaczenie jest szybkie, ponieważ przetwarzane jest lokalnie +- **Prywatność**: Nie wymaga połączenia z internetem, dane nie są przesyłane na zewnątrz +- **Stabilność**: Nie zależy od warunków sieciowych + +## pt-BR + +Módulo de tradução automática que funciona offline. + +### Vantagens +- **Totalmente gratuito**: Sem custos +- **Sem limite de tradução**: Traduza quantas vezes quiser +- **Rápido**: Tradução rápida pois é processado localmente +- **Privacidade**: Não requer conexão à internet, dados não são enviados externamente +- **Estabilidade**: Não afetado pela rede + +## ru + +Модуль машинного перевода, работающий в автономном режиме. + +### Преимущества +- **Полностью бесплатно**: Никаких платежей +- **Без ограничений на перевод**: Вы можете переводить столько раз, сколько хотите +- **Быстро**: Перевод быстрый, так как обрабатывается локально +- **Конфиденциальность**: Не требуется подключение к интернету, данные не передаются наружу +- **Стабильность**: Не зависит от состояния сети + +## th + +โมดูลการแปลด้วยเครื่องที่ทำงานแบบออฟไลน์ + +### ข้อดี +- **ฟรีทั้งหมด**: ไม่มีค่าใช้จ่ายใดๆ +- **ไม่มีข้อจำกัดในการแปล**: คุณสามารถแปลได้มากเท่าที่ต้องการ +- **เร็ว**: การแปลรวดเร็วเนื่องจากประมวลผลในเครื่อง +- **ความเป็นส่วนตัว**: ไม่ต้องการการเชื่อมต่ออินเทอร์เน็ต ข้อมูลไม่ถูกส่งออกภายนอก +- **เสถียรภาพ**: ไม่ได้รับผลกระทบจากสภาพเครือข่าย + +## tr + +Çevrimdışı çalışan bir makine çevirisi modülü. + +### Avantajlar +- **Tamamen Ücretsiz**: Hiçbir ücret yok +- **Çeviri Sınırı Yok**: İstediğiniz kadar çeviri yapabilirsiniz +- **Hızlı**: Yerel olarak işlendiği için çeviri hızlıdır +- **Gizlilik**: İnternet bağlantısı gerekmez, veriler dışarıya gönderilmez +- **Kararlılık**: Ağ koşullarından etkilenmez + +## vi + +Mô-đun dịch máy hoạt động ngoại tuyến. + +### Ưu điểm +- **Hoàn toàn miễn phí**: Hoàn toàn không tốn phí +- **Không giới hạn dịch**: Có thể dịch không giới hạn lần +- **Nhanh**: Dịch nhanh vì được xử lý cục bộ +- **Quyền riêng tư**: Không cần kết nối internet, dữ liệu không được gửi ra bên ngoài +- **Ổn định**: Không bị ảnh hưởng bởi mạng + +## zh-CN + +离线工作的机器翻译模块。 + +### 优点 +- **完全免费**:完全不产生费用 +- **无翻译限制**:可以无限次翻译 +- **快速**:本地处理,翻译速度快 +- **隐私保护**:无需互联网连接,数据不会发送到外部 +- **稳定性**:不受网络影响 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +離線工作的機器翻譯模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **完全免費**:完全不產生費用 +- **無翻譯限制**:可以無限次翻譯 +- **快速**:本地處理,翻譯速度快 +- **隱私保護**:無需網際網路連線,資料不會傳送到外部 +- **穩定性**:不受網路影響 diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md index d8bcbf79..60bda2cf 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md @@ -1,6 +1,8 @@ # WindowTranslator ColorThief Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、キャプチャ画像から翻訳テキストに適した前景色と背景色を推定するカラープラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、キャプチャ画像から翻訳テキストに適した前景色と背景色を推定するカラープラグインです。 ## 機能 @@ -8,10 +10,216 @@ - 背景との明度差を考慮して読みやすい文字色を選択 - 回転したテキスト領域にも対応 -外部APIや追加設定は必要ありません。インストール後、対象設定のカラーモジュールで「近似カラー」を選択してください。 +外部APIや追加設定は必要ありません。対象設定のカラーモジュールで「近似カラー」を選択してください。 + +## en + +A color plugin for [WindowTranslator](https://github.com/Freeesia/WindowTranslator) that estimates readable foreground and background colors for translated text from the captured image. + +## Features + +- Estimates the background color from representative colors around the OCR region +- Selects a readable text color based on its brightness contrast with the background +- Supports rotated text regions + +No external API or additional configuration is required. Select **Approximate Color** as the color module in the target settings. + +## ar + +مكوّن ألوان إضافي لـ[WindowTranslator](https://github.com/Freeesia/WindowTranslator) يقدّر ألوان المقدمة والخلفية المناسبة للنص المترجم من الصورة الملتقطة. + +### الميزات + +- يقدّر لون الخلفية من الألوان المحيطة بمنطقة OCR +- يختار لون نص مقروء بناءً على فرق السطوع مع الخلفية +- يدعم مناطق النص المدورة + +## cs + +Barevný plugin pro [WindowTranslator](https://github.com/Freeesia/WindowTranslator), který z pořízeného snímku odhaduje vhodné barvy popředí a pozadí přeloženého textu. + +### Funkce + +- Odhaduje barvu pozadí z barev v okolí oblasti OCR +- Vybírá čitelnou barvu textu podle rozdílu jasu oproti pozadí +- Podporuje otočené oblasti textu + +## de + +Ein Farb-Plugin für [WindowTranslator](https://github.com/Freeesia/WindowTranslator), das aus dem aufgenommenen Bild geeignete Vorder- und Hintergrundfarben für übersetzten Text ermittelt. + +### Funktionen + +- Ermittelt die Hintergrundfarbe aus den Farben um den OCR-Bereich +- Wählt anhand des Helligkeitsunterschieds zum Hintergrund eine gut lesbare Textfarbe +- Unterstützt gedrehte Textbereiche + +## es + +Un complemento de color para [WindowTranslator](https://github.com/Freeesia/WindowTranslator) que estima colores de primer plano y de fondo adecuados para el texto traducido a partir de la imagen capturada. + +### Funciones + +- Estima el color de fondo a partir de los colores alrededor del área de OCR +- Selecciona un color de texto legible según la diferencia de brillo con el fondo +- Admite áreas de texto giradas + +## fa + +افزونهٔ رنگ برای [WindowTranslator](https://github.com/Freeesia/WindowTranslator) که از تصویر گرفته‌شده، رنگ‌های مناسب متن و پس‌زمینه را برای ترجمه برآورد می‌کند. + +### ویژگی‌ها + +- رنگ پس‌زمینه را از رنگ‌های اطراف ناحیهٔ OCR برآورد می‌کند +- بر اساس اختلاف روشنایی با پس‌زمینه، رنگ خوانایی برای متن انتخاب می‌کند +- از نواحی متن چرخیده پشتیبانی می‌کند + +## fil + +Isang color plugin para sa [WindowTranslator](https://github.com/Freeesia/WindowTranslator) na tumataya ng angkop na kulay ng teksto at background mula sa nakuhang larawan. + +### Mga tampok + +- Tinataya ang kulay ng background mula sa mga kulay sa paligid ng OCR area +- Pumipili ng nababasang kulay ng teksto batay sa diperensya ng liwanag sa background +- Sinusuportahan ang mga iniikot na text area + +## fr + +Un plugin de couleur pour [WindowTranslator](https://github.com/Freeesia/WindowTranslator) qui estime, à partir de l’image capturée, les couleurs de premier plan et d’arrière-plan adaptées au texte traduit. + +### Fonctionnalités + +- Estime la couleur d’arrière-plan à partir des couleurs autour de la zone OCR +- Sélectionne une couleur de texte lisible selon l’écart de luminosité avec l’arrière-plan +- Prend en charge les zones de texte pivotées + +## hi + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) के लिए एक रंग प्लगइन, जो कैप्चर की गई छवि से अनुवादित पाठ के लिए उपयुक्त अग्रभूमि और पृष्ठभूमि रंग का अनुमान लगाता है। + +### सुविधाएँ + +- OCR क्षेत्र के आसपास के रंगों से पृष्ठभूमि रंग का अनुमान लगाता है +- पृष्ठभूमि के साथ चमक के अंतर के आधार पर पढ़ने योग्य पाठ रंग चुनता है +- घुमाए गए पाठ क्षेत्रों का समर्थन करता है + +## hu + +Színbővítmény a [WindowTranslator](https://github.com/Freeesia/WindowTranslator) alkalmazáshoz, amely a rögzített képből megbecsüli a lefordított szöveg megfelelő előtér- és háttérszínét. + +### Funkciók + +- Az OCR-terület körüli színekből megbecsüli a háttérszínt +- A háttérhez viszonyított fényerőkülönbség alapján jól olvasható szövegszínt választ +- Támogatja az elforgatott szövegterületeket + +## id + +Plugin warna untuk [WindowTranslator](https://github.com/Freeesia/WindowTranslator) yang memperkirakan warna latar depan dan latar belakang yang sesuai untuk teks terjemahan dari gambar tangkapan. + +### Fitur + +- Memperkirakan warna latar belakang dari warna di sekitar area OCR +- Memilih warna teks yang mudah dibaca berdasarkan perbedaan kecerahan dengan latar belakang +- Mendukung area teks yang diputar + +## ko + +캡처 이미지에서 번역문에 적합한 전경색과 배경색을 추정하는 [WindowTranslator](https://github.com/Freeesia/WindowTranslator)용 색상 플러그인입니다. + +### 기능 + +- OCR 영역 주변의 색상으로 배경색을 추정 +- 배경과의 밝기 차이를 기준으로 읽기 쉬운 글자색을 선택 +- 회전된 텍스트 영역 지원 + +## ms + +Pemalam warna untuk [WindowTranslator](https://github.com/Freeesia/WindowTranslator) yang menganggarkan warna latar depan dan latar belakang yang sesuai untuk teks terjemahan daripada imej tangkapan. + +### Ciri + +- Menganggarkan warna latar belakang daripada warna di sekitar kawasan OCR +- Memilih warna teks yang mudah dibaca berdasarkan perbezaan kecerahan dengan latar belakang +- Menyokong kawasan teks yang diputar + +## pl + +Wtyczka kolorów dla [WindowTranslator](https://github.com/Freeesia/WindowTranslator), która na podstawie przechwyconego obrazu szacuje odpowiednie kolory tekstu i tła tłumaczenia. + +### Funkcje + +- Szacuje kolor tła na podstawie kolorów wokół obszaru OCR +- Wybiera czytelny kolor tekstu według różnicy jasności względem tła +- Obsługuje obrócone obszary tekstu + +## pt-BR + +Um plugin de cores para o [WindowTranslator](https://github.com/Freeesia/WindowTranslator) que estima cores adequadas de primeiro plano e de fundo para o texto traduzido a partir da imagem capturada. + +### Recursos + +- Estima a cor de fundo usando as cores ao redor da área de OCR +- Seleciona uma cor de texto legível com base na diferença de brilho em relação ao fundo +- Oferece suporte a áreas de texto giradas + +## ru + +Цветовой плагин для [WindowTranslator](https://github.com/Freeesia/WindowTranslator), который по захваченному изображению определяет подходящие цвета текста и фона для перевода. + +### Возможности + +- Определяет цвет фона по цветам вокруг области OCR +- Выбирает читаемый цвет текста с учётом разницы яркости относительно фона +- Поддерживает повёрнутые области текста + +## th + +ปลั๊กอินสีสำหรับ [WindowTranslator](https://github.com/Freeesia/WindowTranslator) ที่ประเมินสีข้อความและสีพื้นหลังที่เหมาะสมสำหรับข้อความแปลจากภาพที่จับไว้ + +### คุณสมบัติ + +- ประเมินสีพื้นหลังจากสีรอบบริเวณ OCR +- เลือกสีข้อความที่อ่านง่ายตามความแตกต่างของความสว่างกับพื้นหลัง +- รองรับบริเวณข้อความที่หมุน + +## tr + +Yakalanan görüntüden çevrilmiş metin için uygun ön plan ve arka plan renklerini tahmin eden bir [WindowTranslator](https://github.com/Freeesia/WindowTranslator) renk eklentisidir. + +### Özellikler + +- OCR alanının çevresindeki renklerden arka plan rengini tahmin eder +- Arka planla parlaklık farkına göre okunabilir bir metin rengi seçer +- Döndürülmüş metin alanlarını destekler + +## vi + +Plugin màu cho [WindowTranslator](https://github.com/Freeesia/WindowTranslator), ước tính màu chữ và màu nền phù hợp cho văn bản dịch từ ảnh đã chụp. + +### Tính năng + +- Ước tính màu nền từ các màu xung quanh vùng OCR +- Chọn màu chữ dễ đọc dựa trên độ chênh lệch sáng với nền +- Hỗ trợ vùng văn bản bị xoay + +## zh-CN + +一个用于 [WindowTranslator](https://github.com/Freeesia/WindowTranslator) 的颜色插件,可从捕获的图像中估算适合译文的前景色和背景色。 + +### 功能 + +- 根据 OCR 区域周围的颜色估算背景色 +- 根据与背景的亮度差选择易读的文字颜色 +- 支持旋转的文本区域 + +## zh-TW -## インストール +適用於 [WindowTranslator](https://github.com/Freeesia/WindowTranslator) 的色彩外掛程式,可從擷取的影像估算適合譯文的前景色與背景色。 -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +### 功能 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +- 根據 OCR 區域周圍的色彩估算背景色 +- 根據與背景的亮度差選擇易讀的文字色彩 +- 支援旋轉的文字區域 diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md index 9dc70687..920e8bf0 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator DeepL Translator Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でDeepL APIを利用する翻訳プラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でDeepL APIを利用する翻訳プラグインです。 ## 機能 -- DeepL APIによる翻訳 +- DeepL APIによる自然で高品質な翻訳 - WindowTranslatorから渡された文脈を翻訳リクエストへ反映 - CSV用語集による表記の統一 - 設定画面からAPI利用量を確認 @@ -15,8 +17,216 @@ - 用語集を利用する場合は、ヘッダーなしの`原文,訳文`形式のCSVファイルを指定します。 - 利用可能な言語、料金、上限はDeepL APIの契約内容に従います。 -## インストール +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the DeepL API. + +## Features + +- Natural, high-quality translation through the DeepL API +- Includes context supplied by WindowTranslator in translation requests +- Maintains consistent terminology with a CSV glossary +- Shows API usage from the settings screen + +## Configuration + +- A DeepL API authentication key is required. +- To use a glossary, specify a headerless CSV file in `source,target` format. +- Available languages, pricing, and usage limits depend on your DeepL API plan. + +## ar + +وحدة تستخدم خدمة ترجمة DeepL، المعروفة بالترجمات عالية الجودة. + +### المزايا +- **دقة عالية**: ترجمات طبيعية وعالية الجودة +- **طبقة مجانية كبيرة**: حتى 500,000 حرف مجاناً شهرياً (API مجاني) +- **سريع**: معالجة ترجمة سريعة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد + +## cs + +Překladový modul využívající překladatelskou službu DeepL. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů +- **Velká bezplatná kvóta**: Velkorysý bezplatný limit +- **Rychlost**: Rychlý překlad + +## de + +Ein Modul, das DeepLs Übersetzungsdienst verwendet, bekannt für hochwertige Übersetzungen. + +### Vorteile +- **Hohe Genauigkeit**: Liefert natürliche, hochwertige Übersetzungen +- **Großzügiges kostenloses Kontingent**: Bis zu 500.000 Zeichen pro Monat kostenlos (Free API) +- **Schnell**: Schnelle Übersetzungsverarbeitung +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten + +## es + +Un módulo que utiliza el servicio de traducción DeepL, conocido por traducciones de alta calidad. + +### Ventajas +- **Alta precisión**: Traducciones naturales y de alta calidad +- **Nivel gratuito sustancial**: Hasta 500,000 caracteres gratis por mes (API gratuita) +- **Rápido**: Procesamiento de traducción rápido +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios + +## fa + +ماژولی که از سرویس ترجمه DeepL، معروف به ترجمه‌های با کیفیت بالا، استفاده می‌کند. + +### مزایا +- **دقت بالا**: ترجمه‌های طبیعی و با کیفیت بالا +- **طبقه رایگان بزرگ**: تا 500,000 کاراکتر در ماه رایگان (API رایگان) +- **سریع**: پردازش ترجمه سریع +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها + +## fil + +Isang modyul na gumagamit ng serbisyo ng pagsasalin ng DeepL, kilala sa mataas na kalidad ng mga pagsasalin. + +### Mga Bentahe +- **Mataas na Katumpakan**: Nagbibigay ng natural at mataas na kalidad ng mga pagsasalin +- **Mapagbigay na Libreng Tier**: Hanggang 500,000 character bawat buwan nang libre (Free API) +- **Mabilis**: Mabilis na pagproseso ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary + +## fr + +Un module utilisant le service de traduction DeepL, connu pour des traductions de haute qualité. + +### Avantages +- **Haute précision**: Traductions naturelles et de haute qualité +- **Offre gratuite substantielle**: Jusqu'à 500 000 caractères gratuits par mois (API gratuite) +- **Rapide**: Traitement de traduction rapide +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires + +## hi + +उच्च गुणवत्ता अनुवाद के लिए प्रसिद्ध DeepL अनुवाद सेवा का उपयोग करने वाला मॉड्यूल। + +### फायदे +- **उच्च सटीकता**: प्राकृतिक, उच्च गुणवत्ता अनुवाद प्रदान करता है +- **उदार निःशुल्क सीमा**: मासिक 5 लाख वर्ण तक निःशुल्क उपयोग (निःशुल्क API) +- **तेज़**: तेज़ अनुवाद प्रसंस्करण +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं + +## hu + +A DeepL fordítási szolgáltatását használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások +- **Nagy ingyenes kvóta**: Nagyvonalú ingyenes limit +- **Sebesség**: Gyors fordítás + +## id + +Modul menggunakan layanan terjemahan DeepL, terkenal dengan terjemahan berkualitas tinggi. + +### Keuntungan +- **Akurasi Tinggi**: Menyediakan terjemahan alami berkualitas tinggi +- **Tingkat Gratis yang Murah Hati**: Hingga 500.000 karakter per bulan secara gratis (API Gratis) +- **Cepat**: Pemrosesan terjemahan cepat +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium + +## ko + +고품질 번역으로 알려진 DeepL의 번역 서비스를 사용하는 모듈입니다. + +### 장점 +- **높은 정확도**: 자연스럽고 고품질의 번역을 제공합니다 +- **넉넉한 무료 제공량**: 월 50만 자까지 무료로 사용할 수 있습니다 (무료 API) +- **빠름**: 번역 처리가 빠릅니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 + +## ms + +Modul menggunakan perkhidmatan terjemahan DeepL, terkenal dengan terjemahan berkualiti tinggi. + +### Kelebihan +- **Ketepatan Tinggi**: Menyediakan terjemahan semula jadi berkualiti tinggi +- **Peringkat Percuma yang Murah Hati**: Sehingga 500,000 aksara sebulan secara percuma (API Percuma) +- **Pantas**: Pemprosesan terjemahan pantas +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari + +## pl + +Moduł tłumaczeniowy wykorzystujący usługę tłumaczeniową DeepL. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń +- **Duży darmowy plan**: Hojny darmowy limit +- **Szybkość**: Szybkie tłumaczenie + +## pt-BR + +Módulo que utiliza o serviço de tradução DeepL, conhecido por traduções de alta qualidade. + +### Vantagens +- **Alta precisão**: Obtenha traduções naturais e de alta qualidade +- **Ampla cota gratuita**: Use gratuitamente até 500.000 caracteres por mês (API gratuita) +- **Rápido**: Processamento de tradução rápido +- **Suporte a glossário**: Use glossários para manter consistência na tradução + +## ru + +Модуль, использующий службу перевода DeepL, известную высококачественными переводами. + +### Преимущества +- **Высокая точность**: Обеспечивает естественные высококачественные переводы +- **Щедрый бесплатный тариф**: До 500 000 символов в месяц бесплатно (Free API) +- **Быстро**: Быстрая обработка перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев + +## th + +โมดูลที่ใช้บริการแปลของ DeepL ซึ่งเป็นที่รู้จักในด้านการแปลคุณภาพสูง + +### ข้อดี +- **ความแม่นยำสูง**: ให้การแปลที่เป็นธรรมชาติและมีคุณภาพสูง +- **แผนฟรีที่ใจกว้าง**: สูงสุด 500,000 ตัวอักษรต่อเดือนฟรี (Free API) +- **เร็ว**: การประมวลผลการแปลรวดเร็ว +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ + +## tr + +Yüksek kaliteli çevirilerle tanınan DeepL'in çeviri hizmetini kullanan bir modül. + +### Avantajlar +- **Yüksek Doğruluk**: Doğal, yüksek kaliteli çeviriler sağlar +- **Cömert Ücretsiz Katman**: Ayda 500.000 karaktere kadar ücretsiz (Free API) +- **Hızlı**: Hızlı çeviri işleme +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir + +## vi + +Mô-đun sử dụng dịch vụ dịch thuật DeepL được biết đến với chất lượng cao. + +### Ưu điểm +- **Độ chính xác cao**: Cung cấp bản dịch tự nhiên và chất lượng cao +- **Hạn mức miễn phí hào phóng**: Tối đa 500.000 ký tự mỗi tháng miễn phí (API miễn phí) +- **Nhanh**: Xử lý dịch nhanh +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ + +## zh-CN + +使用以高质量翻译闻名的 DeepL 翻译服务的模块。 + +### 优点 +- **高准确度**:提供自然、高质量的翻译 +- **充足的免费额度**:每月最多50万字符免费(免费 API) +- **快速**:翻译处理速度快 +- **术语表支持**:可以使用术语表保持翻译一致性 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +使用以高品質翻譯聞名的 DeepL 翻譯服務的模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **高準確度**:提供自然、高品質的翻譯 +- **充足的免費額度**:每月最多50萬字元免費(免費 API) +- **快速**:翻譯處理速度快 +- **術語表支援**:可以使用術語表保持翻譯一致性 diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md index fd4d848b..da2cbdf4 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md @@ -1,6 +1,8 @@ # WindowTranslator Fields of Mistria Filter Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) の翻訳をゲーム「Fields of Mistria」向けに補助するフィルタープラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)の翻訳をゲーム「Fields of Mistria」向けに補助するフィルタープラグインです。 ## 機能 @@ -18,8 +20,202 @@ このプラグイン自体は翻訳サービスを提供しません。WindowTranslatorで別途、翻訳モジュールを選択してください。 -## インストール +## en + +A filter plugin that adapts [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translations for the game **Fields of Mistria**. + +## Features + +- Activates only while `FieldsOfMistria.exe` is running +- Corrects OCR results using the game's `localization.json` +- Adds character, scene, and dialogue information to the translation context +- Registers character and item names as glossary entries with the selected translation module + +## Configuration + +- Enable or disable OCR correction +- Use the official Japanese text +- Set the player and farm names +- Exclude text that is not present in the game data + +This plugin does not provide a translation service by itself. Select a translation module separately in WindowTranslator. + +## ar + +مخصص لـ Fields of Mistria + +- استخدام التصحيح بناءً على موارد اللعبة +- استخدام الموارد اليابانية للعبة +- استبعاد النص غير المحدد +- اسم اللاعب / اسم المزرعة + +## cs + +Pouze pro Fields of Mistria + +- Použít opravy se zdroji obsaženými ve hře. +- Použít japonské zdroje obsažené ve hře +- Vyloučit nerozpoznaný text +- Jméno hráče / Název farmy + +## de + +Exklusiv bei Fields of Mistria + +- Verwendung der im Spiel enthaltenen ressourcenbasierten Korrekturen. +- Verwenden Sie die im Spiel enthaltenen japanischen Ressourcen. +- Nicht identifizierbaren Text ausschließen +- Spieler Name / Name des Betriebs + +## es + +Dedicado a Fields of Mistria + +- Usar corrección basada en recursos del juego +- Usar recursos japoneses del juego +- Excluir texto no identificable +- Nombre del jugador / Nombre de la granja + +## fa + +مخصوص Fields of Mistria + +- استفاده از تصحیح بر اساس منابع بازی +- استفاده از منابع ژاپنی بازی +- حذف متن مشخص‌نشده +- نام بازیکن / نام مزرعه + +## fil + +Eksklusibo para sa Fields of Mistria + +- Gamitin ang pagwawasto gamit ang mga mapagkukunang kasama sa laro +- Gamitin ang mga Japanese na mapagkukunang kasama sa laro +- Ibukod ang hindi matukoy na teksto +- Pangalan ng Manlalaro / Pangalan ng Farm + +## fr + +Dédié à Fields of Mistria + +- Utiliser la correction basée sur les ressources du jeu +- Utiliser les ressources japonaises du jeu +- Exclure le texte non identifiable +- Nom du joueur / Nom de la ferme + +## hi + +फील्ड्स ऑफ मिस्ट्रिया के लिए विशेष + +- गेम में शामिल संसाधनों के साथ क्षतिपूर्ति का उपयोग करें। +- गेम में शामिल जापानी संसाधनों का उपयोग करें +- अज्ञात टेक्स्ट बाहर करें +- खिलाड़ी का नाम / फार्म का नाम + +## hu + +Fields of Mistria kizárólagos + +- Javítás használata a játékban lévő erőforrásokkal +- A játékban lévő japán erőforrások használata +- Ismeretlen szöveg kizárása +- Játékos neve / Farm neve + +## id + +Eksklusif untuk Fields of Mistria + +- Gunakan kompensasi dengan sumber daya yang disertakan dalam game. +- Gunakan sumber daya Jepang yang disertakan dalam game +- Kecualikan teks yang tidak teridentifikasi +- Player Name / Nama Pertanian + +## ko + +Fields of Mistria 전용 + +- 게임에 포함된 리소스를 이용한 보정 활용하기 +- 게임에 포함된 일본어 리소스 이용하기 +- 특정할 수 없는 텍스트 제외 +- 플레이어 이름 / 농장명 + +## ms + +Eksklusif untuk Fields of Mistria + +- Gunakan pampasan dengan sumber yang disertakan dalam permainan. +- Gunakan sumber Jepun yang disertakan dalam permainan +- Kecualikan teks tidak dikenal pasti +- Player Name / Nama Ladang + +## pl + +Wyłącznie dla Fields of Mistria + +- Użyj rekompensaty z zasobami zawartymi w grze. +- Użyj japońskich zasobów zawartych w grze +- Wyklucz niezidentyfikowany tekst +- Nazwa gracza / Nazwa farmy + +## pt-BR + +Exclusivo para Fields of Mistria + +- Use compensação com recursos incluídos no jogo. +- Use recursos japoneses incluídos no jogo +- Excluir texto não identificado +- Player Name / Nome da Fazenda + +## ru + +Только для Fields of Mistria + +- Использовать исправление с помощью ресурсов, включенных в игру +- Использовать японские ресурсы, включенные в игру +- Исключить неопределенный текст +- Имя игрока / Название фермы + +## th + +สำหรับ Fields of Mistria เท่านั้น + +- ใช้การแก้ไขด้วยทรัพยากรที่รวมอยู่ในเกม +- ใช้ทรัพยากรภาษาญี่ปุ่นที่รวมอยู่ในเกม +- ยกเว้นข้อความที่ระบุไม่ได้ +- ชื่อผู้เล่น / ชื่อฟาร์ม + +## tr + +Yalnızca Fields of Mistria için + +- Oyunda bulunan kaynakları kullanarak düzeltme kullan +- Oyunda bulunan Japonca kaynakları kullan +- Belirtilemeyen metni hariç tut +- Oyuncu Adı / Çiftlik Adı + +## vi + +Fields of Mistria専用 + +- ゲームに含まれているリソースを利用した補正を利用する +- ゲームに含まれている日本語リソースを利用する +- 特定できないテキストを除外 +- プレイヤー名 / 農場名 + +## zh-CN + +迷雾之地》独有 + +- 使用游戏中包含的基于资源的修正。 +- 使用游戏中包含的日语资源。 +- 排除无法识别的文本。 +- 球员姓名 / 农场名称 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +專屬於 Fields of Mistria -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +- 使用遊戲中包含的資源型修正。 +- 使用遊戲中包含的日文資源。 +- 排除無法辨識的文字。 +- 玩家名稱 / 農場名稱 diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md index 15474f86..77713bfd 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md @@ -1,6 +1,8 @@ # WindowTranslator GitHub Copilot Translator Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でGitHub Copilotを利用する翻訳プラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でGitHub Copilotを利用する翻訳プラグインです。 ## 機能 @@ -17,8 +19,181 @@ 利用可能なモデル、料金、上限はGitHub Copilotの契約内容に従います。 -## インストール +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses GitHub Copilot. + +## Features + +- Context-aware translation with the selected Copilot model +- Custom context for adding translation instructions +- Consistent terminology through a CSV glossary +- Uses conversation and game-specific context supplied by WindowTranslator + +## Requirements and configuration + +- A GitHub account with access to GitHub Copilot and completed authentication +- The model name to use +- Optionally, translation context and a headerless CSV glossary in `source,target` format + +Available models, pricing, and usage limits depend on your GitHub Copilot plan. + +## ar + +ترجمة GitHub Copilot + +- معرف النموذج المتاح على GitHub Copilot (مثال: gpt-4o، claude-sonnet-4.5) +- معلومات السياق المستخدمة أثناء الترجمة +- مسار المسرد + +## cs + +Překlad GitHub Copilot + +- ID modelu dostupného na GitHub Copilot (např. gpt-4o, claude-sonnet-4.5) +- Kontextové informace použité při překladu +- Cesta ke glosáři + +## de + +GitHub Copilot-Übersetzung + +- ID des auf GitHub Copilot verfügbaren Modells (z. B. gpt-4o, claude-sonnet-4.5) +- Kontextinformationen für die Übersetzung +- Glossarpfad + +## es + +Traducción GitHub Copilot + +- ID del modelo disponible en GitHub Copilot (ej: gpt-4o, claude-sonnet-4.5) +- Información contextual utilizada durante la traducción +- Ruta del glosario + +## fa + +ترجمه GitHub Copilot + +- شناسه مدل موجود در GitHub Copilot (مثال: gpt-4o، claude-sonnet-4.5) +- اطلاعات زمینه‌ای که هنگام ترجمه استفاده می‌شود +- مسیر واژه‌نامه + +## fil + +Pagsasalin ng GitHub Copilot + +- ID ng modelong available sa GitHub Copilot (hal: gpt-4o, claude-sonnet-4.5) +- Impormasyon ng konteksto na ginagamit sa panahon ng pagsasalin +- Landas ng glossaryo + +## fr + +Traduction GitHub Copilot + +- ID du modèle disponible sur GitHub Copilot (ex: gpt-4o, claude-sonnet-4.5) +- Informations contextuelles utilisées lors de la traduction +- Chemin du glossaire + +## hi + +GitHub Copilot अनुवाद + +- GitHub Copilot पर उपलब्ध मॉडल की ID (उदा., gpt-4o, claude-sonnet-4.5) +- अनुवाद के दौरान उपयोग की जाने वाली संदर्भ जानकारी +- शब्दावली पथ + +## hu + +GitHub Copilot fordítás + +- A GitHub Copiloton elérhető modell azonosítója (pl. gpt-4o, claude-sonnet-4.5) +- Fordítás során használandó kontextuális információk +- Szószedet elérési útja + +## id + +Terjemahan GitHub Copilot + +- ID model yang tersedia di GitHub Copilot (contoh: gpt-4o, claude-sonnet-4.5) +- Informasi konteks yang digunakan saat penerjemahan +- Jalur glosarium + +## ko + +GitHub Copilot 번역 + +- GitHub Copilot에서 사용 가능한 모델 ID (예: gpt-4o, claude-sonnet-4.5) +- 번역 시 사용할 문맥 정보 +- 용어집 경로 + +## ms + +Terjemahan GitHub Copilot + +- ID model yang tersedia pada GitHub Copilot (contoh: gpt-4o, claude-sonnet-4.5) +- Maklumat konteks yang digunakan semasa terjemahan +- Laluan glosari + +## pl + +Tłumaczenie GitHub Copilot + +- ID modelu dostępnego w GitHub Copilot (np: gpt-4o, claude-sonnet-4.5) +- Informacje kontekstowe używane podczas tłumaczenia +- Ścieżka do słownika + +## pt-BR + +Tradução GitHub Copilot + +- ID do modelo disponível no GitHub Copilot (ex: gpt-4o, claude-sonnet-4.5) +- Informações de contexto usadas durante a tradução +- Caminho do glossário + +## ru + +Перевод GitHub Copilot + +- ID модели, доступной в GitHub Copilot (например: gpt-4o, claude-sonnet-4.5) +- Контекстная информация, используемая при переводе +- Путь к глоссарию + +## th + +การแปล GitHub Copilot + +- ID ของโมเดลที่มีใน GitHub Copilot (เช่น: gpt-4o, claude-sonnet-4.5) +- ข้อมูลบริบทที่ใช้ระหว่างการแปล +- เส้นทางอภิธานศัพท์ + +## tr + +GitHub Copilot Çevirisi + +- GitHub Copilot'ta mevcut modelin kimliği (örn: gpt-4o, claude-sonnet-4.5) +- Çeviri sırasında kullanılan bağlam bilgisi +- Sözlük yolu + +## vi + +Dịch thuật GitHub Copilot + +- ID mô hình có sẵn trên GitHub Copilot (ví dụ: gpt-4o, claude-sonnet-4.5) +- Thông tin ngữ cảnh sử dụng khi dịch thuật +- Đường dẫn thuật ngữ + +## zh-CN + +GitHub Copilot 翻译 + +- GitHub Copilot 上可用的模型 ID(例如:gpt-4o、claude-sonnet-4.5) +- 翻译时使用的上下文信息 +- 术语表路径 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +GitHub Copilot 翻譯 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +- GitHub Copilot 上可用的模型 ID(例如:gpt-4o、claude-sonnet-4.5) +- 翻譯時使用的上下文資訊 +- 術語表路徑 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md index 3ed1b4c4..e229b111 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator Google AI Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でGoogle AI(Gemini)を利用する多機能プラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でGoogle AI(Gemini)を利用する多機能プラグインです。 ## 機能 -- Geminiによる文脈を考慮した翻訳 +- Geminiによる文脈を考慮した高品質な翻訳 - 画像を直接送信するAI OCR - OCRテキストまたは元画像を使った認識結果の補正 - カスタム翻訳コンテキスト、補正サンプル、CSV用語集 @@ -20,8 +22,201 @@ AI OCRとOCR補正は実験的な機能です。 画像やテキストは設定したGoogle AIサービスへ送信されます。利用可能なモデル、料金、上限はサービスの契約内容に従います。 -## インストール +## en + +A multi-purpose [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that uses Google AI (Gemini). + +## Features + +- High-quality, context-aware translation with Gemini +- AI OCR that sends the source image directly to the model +- Correction of recognition results using OCR text or the original image +- Custom translation context, correction examples, and CSV glossaries + +AI OCR and OCR correction are experimental features. + +## Requirements and configuration + +- A Google AI API key +- The Gemini model to use; a preview model name can also be specified when necessary +- When using OCR correction, select the correction method and waiting behavior +- Glossaries use a headerless CSV file in `source,target` format + +Images and text are sent to the configured Google AI service. Available models, pricing, and usage limits depend on the service plan. + +## ar + +وحدة ترجمة تستخدم أحدث تقنيات الذكاء الاصطناعي من Google. + +### المزايا +- **أعلى دقة**: ترجمات عالية الجودة جداً تفهم السياق +- **المرونة**: تخصيص المطالبات لتعديل أسلوب الترجمة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد + +## cs + +Překladový modul využívající Gemini AI od Googlu. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů díky AI +- **Kontextový překlad**: Chápe kontext a překládá přirozeně + +## de + +Ein Übersetzungsmodul, das Googles neueste KI-Technologie nutzt. + +### Vorteile +- **Höchste Genauigkeit**: Fähig zu sehr hochwertiger Übersetzung mit Kontextverständnis +- **Flexibilität**: Kann Prompts anpassen, um Übersetzungsstil zu justieren +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten + +## es + +Un módulo de traducción que utiliza la última tecnología de IA de Google. + +### Ventajas +- **Mayor precisión**: Traducciones de muy alta calidad que entienden el contexto +- **Flexibilidad**: Personalice prompts para ajustar el estilo de traducción +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios + +## fa + +ماژول ترجمه‌ای که از جدیدترین فناوری هوش مصنوعی Google استفاده می‌کند. + +### مزایا +- **بالاترین دقت**: ترجمه‌های با کیفیت بسیار بالا که زمینه را درک می‌کنند +- **انعطاف‌پذیری**: سفارشی‌سازی دستورالعمل برای تنظیم سبک ترجمه +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها + +## fil + +Isang modyul ng pagsasalin na gumagamit ng pinakabagong teknolohiya ng AI ng Google. + +### Mga Bentahe +- **Pinakamataas na Katumpakan**: Kayang mag-translate ng napakataas na kalidad na may pag-unawa sa konteksto +- **Kakayahang umangkop**: Maaaring i-customize ang mga prompt upang ayusin ang estilo ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary + +## fr + +Un module de traduction utilisant la dernière technologie IA de Google. + +### Avantages +- **Plus haute précision**: Traductions de très haute qualité qui comprennent le contexte +- **Flexibilité**: Personnalisez les prompts pour ajuster le style de traduction +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires + +## hi + +Google की नवीनतम AI तकनीक का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **सर्वोच्च सटीकता**: संदर्भ को समझकर बहुत उच्च गुणवत्ता अनुवाद की क्षमता +- **लचीलापन**: अनुवाद शैली को समायोजित करने के लिए प्रॉम्प्ट को कस्टमाइज़ कर सकते हैं +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं + +## hu + +A Google Gemini AI-t használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások AI segítségével +- **Kontextuális fordítás**: Érti a kontextust és természetesen fordít + +## id + +Modul terjemahan yang memanfaatkan teknologi AI terbaru Google. + +### Keuntungan +- **Akurasi Tertinggi**: Mampu melakukan terjemahan berkualitas sangat tinggi dengan pemahaman kontekstual +- **Fleksibilitas**: Dapat menyesuaikan prompt untuk menyesuaikan gaya terjemahan +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium + +## ko + +Google의 최신 AI 기술을 활용한 번역 모듈입니다. + +### 장점 +- **최고 정확도**: 문맥을 이해한 매우 고품질의 번역이 가능합니다 +- **유연성**: 프롬프트를 커스터마이즈하여 번역 스타일을 조정할 수 있습니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 + +## ms + +Modul terjemahan yang memanfaatkan teknologi AI terkini Google. + +### Kelebihan +- **Ketepatan Tertinggi**: Mampu melakukan terjemahan berkualiti sangat tinggi dengan pemahaman kontekstual +- **Fleksibiliti**: Boleh menyesuaikan prompt untuk melaraskan gaya terjemahan +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari + +## pl + +Moduł tłumaczeniowy wykorzystujący Gemini AI od Google. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń dzięki AI +- **Kontekstowe tłumaczenie**: Rozumie kontekst i tłumaczy naturalnie + +## pt-BR + +Módulo de tradução que utiliza a mais recente tecnologia de IA do Google. + +### Vantagens +- **Máxima precisão**: Permite traduções de altíssima qualidade com compreensão de contexto +- **Flexibilidade**: Customize prompts para ajustar o estilo de tradução +- **Suporte a glossário**: Use glossários para manter consistência na tradução + +## ru + +Модуль перевода, использующий новейшие технологии искусственного интеллекта Google. + +### Преимущества +- **Наивысшая точность**: Способен на очень высококачественный перевод с пониманием контекста +- **Гибкость**: Можно настраивать подсказки для настройки стиля перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев + +## th + +โมดูลการแปลที่ใช้เทคโนโลยี AI ล่าสุดของ Google + +### ข้อดี +- **ความแม่นยำสูงสุด**: สามารถแปลคุณภาพสูงมากด้วยความเข้าใจบริบท +- **ความยืดหยุ่น**: สามารถปรับแต่ง prompt เพื่อปรับสไตล์การแปล +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ + +## tr + +Google'ın en son yapay zeka teknolojisinden yararlanan bir çeviri modülü. + +### Avantajlar +- **En Yüksek Doğruluk**: Bağlamsal anlayışla çok yüksek kaliteli çeviri yapabilir +- **Esneklik**: Çeviri stilini ayarlamak için istemler özelleştirebilir +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir + +## vi + +Mô-đun dịch thuật tận dụng công nghệ AI mới nhất của Google. + +### Ưu điểm +- **Độ chính xác cao nhất**: Có khả năng dịch chất lượng rất cao với hiểu biết ngữ cảnh +- **Linh hoạt**: Có thể tùy chỉnh prompt để điều chỉnh phong cách dịch +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ + +## zh-CN + +利用 Google 最新 AI 技术的翻译模块。 + +### 优点 +- **最高准确度**:能够理解上下文进行极高质量的翻译 +- **灵活性**:可以自定义提示来调整翻译风格 +- **术语表支持**:可以使用术语表保持翻译一致性 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +利用 Google 最新 AI 技術的翻譯模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **最高準確度**:能夠理解上下文進行極高品質的翻譯 +- **靈活性**:可以自訂提示來調整翻譯風格 +- **術語表支援**:可以使用術語表保持翻譯一致性 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md index 517b75c8..ff005259 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md @@ -1,6 +1,8 @@ # WindowTranslator Google Apps Script Translator Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) からGoogle Apps Scriptを呼び出して翻訳するプラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)からGoogle Apps Scriptを呼び出して翻訳するプラグインです。 ## 機能 @@ -14,8 +16,198 @@ - 独自スクリプトを利用する場合は、Google Apps Script APIから実行可能なデプロイIDを設定します。 - インターネット接続が必要です。利用量や実行上限はGoogle側の制限に従います。 -## インストール +## en + +A translation plugin that invokes Google Apps Script from [WindowTranslator](https://github.com/Freeesia/WindowTranslator). + +## Features + +- Translates multiple text entries through Google Apps Script +- Supports the built-in published script or a custom Apps Script deployment +- Automatically sends WindowTranslator's source and target languages to the script + +## Requirements and configuration + +- Using the built-in script requires authentication with a Google account. +- To use a custom script, specify a deployment ID that can be executed through the Google Apps Script API. +- An internet connection is required. Usage and execution limits are subject to Google's service limits. + +## ar + +وحدة ترجمة تستخدم خدمة ترجمة Google. + +### المزايا +- **مجاني تماماً**: يمكن استخدامه بدون مفتاح API +- **دعم متعدد اللغات**: يدعم العديد من أزواج اللغات +- **سهل**: لا حاجة لإعداد خاص + +## cs + +Překladový modul využívající překladatelskou službu Google. + +### Výhody +- **Zcela zdarma**: Lze používat bez klíče API +- **Vícejazyčná podpora**: Podporuje mnoho jazykových párů +- **Snadné**: Nevyžaduje speciální nastavení + +## de + +Ein Übersetzungsmodul, das Googles Übersetzungsdienst verwendet. + +### Vorteile +- **Völlig kostenlos**: Kann ohne API-Schlüssel verwendet werden +- **Mehrsprachige Unterstützung**: Unterstützt viele Sprachpaare +- **Einfach**: Keine spezielle Konfiguration erforderlich + +## es + +Un módulo de traducción que utiliza el servicio de traducción de Google. + +### Ventajas +- **Completamente gratis**: Se puede usar sin clave API +- **Soporte multilingüe**: Admite muchos pares de idiomas +- **Fácil**: No se requiere configuración especial + +## fa + +ماژول ترجمه‌ای که از سرویس ترجمه Google استفاده می‌کند. + +### مزایا +- **کاملاً رایگان**: می‌توان بدون کلید API استفاده کرد +- **پشتیبانی از چند زبان**: از جفت‌های زبانی زیادی پشتیبانی می‌کند +- **آسان**: نیاز به تنظیم خاصی ندارد + +## fil + +Isang modyul ng pagsasalin na gumagamit ng serbisyo ng pagsasalin ng Google. + +### Mga Bentahe +- **Lubos na Libre**: Maaaring gamitin nang walang API key +- **Suporta sa Maraming Wika**: Sumusuporta sa maraming language pair +- **Madali**: Walang kinakailangang special na configuration + +## fr + +Un module de traduction utilisant le service de traduction de Google. + +### Avantages +- **Complètement gratuit**: Peut être utilisé sans clé API +- **Support multilingue**: Prend en charge de nombreuses paires de langues +- **Facile**: Aucune configuration spéciale requise + +## hi + +Google की अनुवाद सेवा का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **पूर्ण रूप से निःशुल्क**: API कुंजी के बिना उपयोग कर सकते हैं +- **बहुभाषी सहायता**: कई भाषा जोड़ों का समर्थन करता है +- **सरल**: विशेष सेटअप की आवश्यकता नहीं + +## hu + +A Google fordítási szolgáltatását használó fordítási modul. + +### Előnyök +- **Teljesen ingyenes**: API kulcs nélkül is használható +- **Többnyelvű támogatás**: Sok nyelvpárt támogat +- **Egyszerű**: Nem igényel speciális beállítást + +## id + +Modul terjemahan menggunakan layanan terjemahan Google. + +### Keuntungan +- **Sepenuhnya Gratis**: Dapat digunakan tanpa kunci API +- **Dukungan Multibahasa**: Mendukung banyak pasangan bahasa +- **Mudah**: Tidak ada konfigurasi khusus yang diperlukan + +## ko + +Google의 번역 서비스를 사용하는 번역 모듈입니다. + +### 장점 +- **완전 무료**: API 키 없이 사용할 수 있습니다 +- **다국어 지원**: 많은 언어 쌍을 지원합니다 +- **간단함**: 특별한 구성이 필요 없습니다 + +## ms + +Modul terjemahan menggunakan perkhidmatan terjemahan Google. + +### Kelebihan +- **Percuma Sepenuhnya**: Boleh digunakan tanpa kunci API +- **Sokongan Berbilang Bahasa**: Menyokong banyak pasangan bahasa +- **Mudah**: Tiada konfigurasi khas diperlukan + +## pl + +Moduł tłumaczeniowy wykorzystujący usługę tłumaczeniową Google. + +### Zalety +- **Całkowicie darmowe**: Można używać bez klucza API +- **Obsługa wielojęzyczna**: Obsługuje wiele par językowych +- **Łatwość**: Nie wymaga specjalnej konfiguracji + +## pt-BR + +Módulo de tradução que utiliza o serviço de tradução do Google. + +### Vantagens +- **Totalmente gratuito**: Pode ser usado sem chave de API +- **Suporte multilíngue**: Suporta muitos pares de idiomas +- **Fácil**: Não requer configuração especial + +## ru + +Модуль перевода, использующий службу перевода Google. + +### Преимущества +- **Полностью бесплатно**: Можно использовать без API-ключа +- **Многоязычная поддержка**: Поддерживает множество языковых пар +- **Легко**: Не требуется специальная настройка + +## th + +โมดูลการแปลที่ใช้บริการแปลของ Google + +### ข้อดี +- **ฟรีทั้งหมด**: สามารถใช้ได้โดยไม่ต้องมี API key +- **รองรับหลายภาษา**: รองรับคู่ภาษามากมาย +- **ง่าย**: ไม่ต้องการการตั้งค่าพิเศษ + +## tr + +Google'ın çeviri hizmetini kullanan bir çeviri modülü. + +### Avantajlar +- **Tamamen Ücretsiz**: API anahtarı olmadan kullanılabilir +- **Çok Dilli Destek**: Birçok dil çiftini destekler +- **Kolay**: Özel yapılandırma gerektirmez + +## vi + +Mô-đun dịch thuật sử dụng dịch vụ dịch của Google. + +### Ưu điểm +- **Hoàn toàn miễn phí**: Có thể sử dụng mà không cần API key +- **Hỗ trợ đa ngôn ngữ**: Hỗ trợ nhiều cặp ngôn ngữ +- **Đơn giản**: Không cần cấu hình đặc biệt + +## zh-CN + +使用 Google 翻译服务的翻译模块。 + +### 优点 +- **完全免费**:无需 API 密钥即可使用 +- **多语言支持**:支持多种语言对 +- **简单**:无需特殊配置 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +使用 Google 翻譯服務的翻譯模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **完全免費**:無需 API 金鑰即可使用 +- **多語言支援**:支援多種語言對 +- **簡單**:無需特殊設定 diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md index 3502e059..24a94a0e 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator LLM Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) でOpenAI互換APIを利用する多機能プラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でOpenAI互換APIを利用する多機能プラグインです。 ## 機能 -- 大規模言語モデルによる文脈を考慮した翻訳 +- 大規模言語モデルによる文脈を考慮した高品質な翻訳 - 画像対応モデルを利用したAI OCR - OCRテキストまたは元画像を使った認識結果の補正 - OpenAI APIと互換エンドポイントの両方に対応 @@ -21,8 +23,219 @@ AI OCRとOCR補正は実験的な機能です。 画像やテキストは設定したAPIへ送信されます。料金、上限、データの扱いは利用するサービスの契約内容に従います。 -## インストール +## en + +A multi-purpose [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin for OpenAI-compatible APIs. + +## Features + +- High-quality, context-aware translation with large language models +- AI OCR using vision-capable models +- Correction of recognition results using OCR text or the original image +- Supports both the OpenAI API and compatible endpoints +- Custom translation context, correction examples, and CSV glossaries + +AI OCR and OCR correction are experimental features. + +## Requirements and configuration + +- The model name to use +- An API key when required by the service +- An OpenAI-compatible endpoint when using another provider +- A headerless CSV glossary in `source,target` format when terminology control is needed + +Images and text are sent to the configured API. Pricing, usage limits, and data handling depend on the selected service. + +## ar + +وحدة ترجمة تستخدم ChatGPT API أو LLM محلي. + +### المزايا +- **أعلى دقة**: ترجمات عالية الجودة بواسطة نماذج اللغة الكبيرة +- **المرونة**: تخصيص المطالبات لتعديل أسلوب الترجمة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد +- **دعم LLM محلي**: إمكانية استخدام خادم LLM الخاص بك + +## cs + +Překladový modul využívající ChatGPT od OpenAI. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů díky AI +- **Přirozený překlad**: Překlady znějí přirozeně + +## de + +Ein Übersetzungsmodul, das ChatGPT API oder lokales LLM verwendet. + +### Vorteile +- **Höchste Genauigkeit**: Hochwertige Übersetzung durch große Sprachmodelle +- **Flexibilität**: Kann Prompts anpassen, um Übersetzungsstil zu justieren +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten +- **Lokale LLM-Unterstützung**: Kann auch eigenen LLM-Server verwenden + +## es + +Un módulo de traducción que utiliza la API de ChatGPT o un LLM local. + +### Ventajas +- **Mayor precisión**: Traducciones de alta calidad por grandes modelos de lenguaje +- **Flexibilidad**: Personalice prompts para ajustar el estilo de traducción +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios +- **Soporte LLM local**: Posibilidad de usar su propio servidor LLM + +## fa + +ماژول ترجمه‌ای که از ChatGPT API یا LLM محلی استفاده می‌کند. + +### مزایا +- **بالاترین دقت**: ترجمه‌های با کیفیت بالا توسط مدل‌های زبانی بزرگ +- **انعطاف‌پذیری**: سفارشی‌سازی دستورالعمل برای تنظیم سبک ترجمه +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها +- **پشتیبانی از LLM محلی**: امکان استفاده از سرور LLM خودتان + +## fil + +Isang modyul ng pagsasalin na gumagamit ng ChatGPT API o local LLM. + +### Mga Bentahe +- **Pinakamataas na Katumpakan**: Mataas na kalidad ng pagsasalin ng malalaking modelo ng wika +- **Kakayahang umangkop**: Maaaring i-customize ang mga prompt upang ayusin ang estilo ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary +- **Suporta sa Local LLM**: Maaari ring gamitin ang sarili mong LLM server + +## fr + +Un module de traduction utilisant l'API ChatGPT ou un LLM local. + +### Avantages +- **Plus haute précision**: Traductions de haute qualité par grands modèles de langage +- **Flexibilité**: Personnalisez les prompts pour ajuster le style de traduction +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires +- **Support LLM local**: Possibilité d'utiliser votre propre serveur LLM + +## hi + +ChatGPT API या स्थानीय LLM का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **सर्वोच्च सटीकता**: बड़े भाषा मॉडल द्वारा उच्च गुणवत्ता अनुवाद +- **लचीलापन**: अनुवाद शैली को समायोजित करने के लिए प्रॉम्प्ट को कस्टमाइज़ कर सकते हैं +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं +- **स्थानीय LLM समर्थन**: अपना LLM सर्वर भी उपयोग कर सकते हैं + +## hu + +Az OpenAI ChatGPT-jét használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások AI segítségével +- **Természetes fordítás**: A fordítások természetesen hangzanak + +## id + +Modul terjemahan menggunakan API ChatGPT atau LLM lokal. + +### Keuntungan +- **Akurasi Tertinggi**: Terjemahan berkualitas tinggi oleh model bahasa besar +- **Fleksibilitas**: Dapat menyesuaikan prompt untuk menyesuaikan gaya terjemahan +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium +- **Dukungan LLM Lokal**: Juga dapat menggunakan server LLM Anda sendiri + +## ko + +ChatGPT API 또는 로컬 LLM을 사용하는 번역 모듈입니다. + +### 장점 +- **최고 정확도**: 대규모 언어 모델에 의한 고품질 번역 +- **유연성**: 프롬프트를 커스터마이즈하여 번역 스타일을 조정할 수 있습니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 +- **로컬 LLM 지원**: 자체 LLM 서버도 사용 가능 + +## ms + +Modul terjemahan menggunakan API ChatGPT atau LLM tempatan. + +### Kelebihan +- **Ketepatan Tertinggi**: Terjemahan berkualiti tinggi oleh model bahasa besar +- **Fleksibiliti**: Boleh menyesuaikan prompt untuk melaraskan gaya terjemahan +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari +- **Sokongan LLM Tempatan**: Juga boleh menggunakan pelayan LLM anda sendiri + +## pl + +Moduł tłumaczeniowy wykorzystujący ChatGPT od OpenAI. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń dzięki AI +- **Naturalne tłumaczenie**: Tłumaczenia brzmią naturalnie + +## pt-BR + +Módulo de tradução que utiliza ChatGPT API ou LLM local. + +### Vantagens +- **Máxima precisão**: Traduções de alta qualidade por modelos de linguagem em grande escala +- **Flexibilidade**: Customize prompts para ajustar o estilo de tradução +- **Suporte a glossário**: Use glossários para manter consistência na tradução +- **Suporte a LLM local**: Também pode usar seu próprio servidor LLM + +## ru + +Модуль перевода, использующий ChatGPT API или локальный LLM. + +### Преимущества +- **Наивысшая точность**: Высококачественный перевод большими языковыми моделями +- **Гибкость**: Можно настраивать подсказки для настройки стиля перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев +- **Поддержка локального LLM**: Также можно использовать собственный сервер LLM + +## th + +โมดูลการแปลที่ใช้ ChatGPT API หรือ LLM ในเครื่อง + +### ข้อดี +- **ความแม่นยำสูงสุด**: การแปลคุณภาพสูงโดยโมเดลภาษาขนาดใหญ่ +- **ความยืดหยุ่น**: สามารถปรับแต่ง prompt เพื่อปรับสไตล์การแปล +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ +- **รองรับ LLM ในเครื่อง**: สามารถใช้เซิร์ฟเวอร์ LLM ของคุณเองได้ + +## tr + +ChatGPT API veya yerel LLM kullanan bir çeviri modülü. + +### Avantajlar +- **En Yüksek Doğruluk**: Büyük dil modelleri tarafından yüksek kaliteli çeviri +- **Esneklik**: Çeviri stilini ayarlamak için istemler özelleştirebilir +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir +- **Yerel LLM Desteği**: Kendi LLM sunucunuzu da kullanabilirsiniz + +## vi + +Mô-đun dịch thuật sử dụng ChatGPT API hoặc LLM cục bộ. + +### Ưu điểm +- **Độ chính xác cao nhất**: Bản dịch chất lượng cao bằng các mô hình ngôn ngữ lớn +- **Linh hoạt**: Có thể tùy chỉnh prompt để điều chỉnh phong cách dịch +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ +- **Hỗ trợ LLM cục bộ**: Cũng có thể sử dụng máy chủ LLM của riêng bạn + +## zh-CN + +使用 ChatGPT API 或本地 LLM 的翻译模块。 + +### 优点 +- **最高准确度**:通过大型语言模型实现高质量翻译 +- **灵活性**:可以自定义提示来调整翻译风格 +- **术语表支持**:可以使用术语表保持翻译一致性 +- **本地 LLM 支持**:也可以使用自己的 LLM 服务器 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +使用 ChatGPT API 或本地 LLM 的翻譯模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **最高準確度**:透過大型語言模型實現高品質翻譯 +- **靈活性**:可以自訂提示來調整翻譯風格 +- **術語表支援**:可以使用術語表保持翻譯一致性 +- **本地 LLM 支援**:也可以使用自己的 LLM 伺服器 diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md index 7975b2b0..23af9fc9 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator OneOCR Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、WindowsのSnipping Toolに含まれるOneOCRエンジンを利用するOCRプラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、WindowsのSnipping Toolに含まれるOneOCRエンジンを利用するOCRプラグインです。 ## 機能 -- WindowsのローカルOCRエンジンによる高速な文字認識 +- WindowsのローカルOCRエンジンによる高速で高精度な文字認識 - OCR領域の結合、傾き、拡大率、明るさ、コントラストを考慮した後処理 - 認識結果から翻訳先言語のテキストを除外 @@ -15,12 +17,187 @@ 対応するSnipping Toolが見つからない場合は、WindowTranslatorからMicrosoft Storeを開いて更新できます。OneOCR本体とモデルは、このNuGetパッケージには含まれません。 -## インストール +## 参考 -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +- [SnippingToolOcrSharp](https://github.com/ksasao/SnippingToolOcrSharp) -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +## en -## 参考 +An OCR plugin for [WindowTranslator](https://github.com/Freeesia/WindowTranslator) that uses the OneOCR engine included with Windows Snipping Tool. + +## Features + +- Fast, high-accuracy text recognition with a local Windows OCR engine +- Post-processing that accounts for merged OCR regions, rotation, scale, brightness, and contrast +- Excludes text written in the translation target language from recognition results + +## Requirements + +- A supported version of Windows Snipping Tool that includes OneOCR +- Permission during initial setup to copy the required OneOCR components from Snipping Tool to WindowTranslator's shared data directory + +If a supported Snipping Tool cannot be found, WindowTranslator can open Microsoft Store so it can be updated. The OneOCR engine and models are not included in this NuGet package. + +## Reference - [SnippingToolOcrSharp](https://github.com/ksasao/SnippingToolOcrSharp) + +## ar + +وحدة OCR محلية مقدمة من Microsoft. + +### المزايا +- **دقة التعرف**: أعلى دقة في التعرف +- **سريع**: سرعة معالجة عالية جداً + +## cs + +Místní modul OCR dodaný společností Microsoft. + +### Výhody +- **Přesnost rozpoznávání**: Může se pochlubit nejvyšší přesností rozpoznávání +- **Rychlost**: Velmi vysoká rychlost zpracování + +## de + +Ein lokales OCR-Modul von Microsoft. + +### Vorteile +- **Erkennungsgenauigkeit**: Verfügt über die höchste Erkennungsgenauigkeit +- **Schnell**: Sehr hohe Verarbeitungsgeschwindigkeit + +## es + +Un módulo OCR local proporcionado por Microsoft. + +### Ventajas +- **Precisión de reconocimiento**: La más alta precisión de reconocimiento +- **Rápido**: Velocidad de procesamiento muy rápida + +## fa + +ماژول OCR محلی ارائه‌شده توسط Microsoft. + +### مزایا +- **دقت تشخیص**: بالاترین دقت تشخیص +- **سریع**: سرعت پردازش بسیار بالا + +## fil + +Isang lokal na modyul ng OCR na ibinigay ng Microsoft. + +### Mga Bentahe +- **Katumpakan ng Pagkilala**: Nag-aanyaya ng pinakamataas na katumpakan ng pagkilala +- **Mabilis**: Napakabilis ng bilis ng pagproseso + +## fr + +Un module OCR local fourni par Microsoft. + +### Avantages +- **Précision de reconnaissance**: La plus haute précision de reconnaissance +- **Rapide**: Vitesse de traitement très rapide + +## hi + +Microsoft द्वारा प्रदान किया गया एक स्थानीय OCR मॉड्यूल। + +### लाभ +- **पहचान सटीकता**: उच्चतम पहचान सटीकता का दावा करता है +- **तेज़**: बहुत तेज़ प्रोसेसिंग गति + +## hu + +A Microsoft által biztosított helyi OCR modul. + +### Előnyök +- **Felismerési pontosság**: A legjobb felismerési pontossággal rendelkezik +- **Sebesség**: Nagyon gyors feldolgozási sebesség + +## id + +Modul OCR lokal yang disediakan oleh Microsoft. + +### Keuntungan +- **Akurasi Pengenalan**: Memiliki akurasi pengenalan tertinggi +- **Cepat**: Kecepatan pemrosesan sangat cepat + +## ko + +Microsoft가 제공하는 로컬 OCR 모듈입니다. + +### 장점 +- **인식 정확도**: 가장 높은 인식 정확도를 자랑합니다 +- **빠름**: 처리 속도가 매우 빠릅니다 + +## ms + +Modul OCR tempatan yang disediakan oleh Microsoft. + +### Kelebihan +- **Ketepatan Pengecaman**: Mempunyai ketepatan pengecaman tertinggi +- **Pantas**: Kelajuan pemprosesan sangat pantas + +## pl + +Lokalny moduł OCR dostarczony przez Microsoft. + +### Zalety +- **Dokładność rozpoznawania**: Może pochwalić się najwyższą dokładnością rozpoznawania +- **Szybkość**: Bardzo szybka prędkość przetwarzania + +## pt-BR + +Módulo OCR local fornecido pela Microsoft. + +### Vantagens +- **Precisão de reconhecimento**: Possui a maior precisão de reconhecimento +- **Rápido**: Velocidade de processamento muito rápida + +## ru + +Локальный OCR-модуль, предоставляемый Microsoft. + +### Преимущества +- **Точность распознавания**: Обладает наивысшей точностью распознавания +- **Скорость**: Очень высокая скорость обработки + +## th + +โมดูล OCR ในเครื่องที่จัดทำโดย Microsoft + +### ข้อดี +- **ความแม่นยำในการรู้จำ**: มีความแม่นยำในการรู้จำสูงสุด +- **ความเร็ว**: ความเร็วในการประมวลผลสูงมาก + +## tr + +Microsoft tarafından sağlanan yerel bir OCR modülü. + +### Avantajlar +- **Tanıma Doğruluğu**: En yüksek tanıma doğruluğuna sahiptir +- **Hızlı**: Çok hızlı işleme hızı + +## vi + +Mô-đun OCR cục bộ do Microsoft cung cấp. + +### Ưu điểm +- **Độ chính xác nhận dạng**: Có độ chính xác nhận dạng cao nhất +- **Nhanh**: Tốc độ xử lý rất nhanh + +## zh-CN + +Microsoft 提供的本地 OCR 模块。 + +### 优点 +- **识别准确度**:拥有最高的识别准确度 +- **快速**:处理速度非常快 + +## zh-TW + +Microsoft 提供的本地 OCR 模組。 + +### 優點 +- **識別準確度**:擁有最高的識別準確度 +- **快速**:處理速度非常快 diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md index 20cbada5..f1dd595c 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator PLaMo Translator Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、日本語に強いPLaMo 2 Translateモデルをローカル実行する翻訳プラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、日本語に強いPLaMo 2 Translateモデルをローカル実行する翻訳プラグインです。 ## 機能 -- LLamaSharpを使用したローカル翻訳 +- LLamaSharpを使用した日本語に強いローカル翻訳 - 翻訳テキストを外部サービスへ送信せずに処理 - 初回利用時に量子化済みPLaMo翻訳モデルを自動取得 - コンテキスト長と使用するVRAM量を設定可能 @@ -18,8 +20,213 @@ モデル取得後の翻訳はローカルで実行されます。用語集と追加コンテキストには対応していません。 -## インストール +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that runs the Japanese-focused PLaMo 2 Translate model locally. + +## Features + +- Japanese-focused local translation through LLamaSharp +- Processes translated text without sending it to an external service +- Downloads a quantized PLaMo translation model when it is first used +- Configurable context length and VRAM usage + +## Requirements + +- 64-bit Windows +- Sufficient free storage and memory +- An NVIDIA GPU and driver with CUDA support are recommended +- An internet connection when downloading the model for the first time + +After the model has been downloaded, translation runs locally. Glossaries and additional translation context are not supported. + +## ar + +وحدة ترجمة تستخدم LLM محلي متخصص في اليابانية. + +### المزايا +- **متخصص في اليابانية**: محسن للترجمات اليابانية +- **مجاني تماماً**: نموذج مفتوح المصدر بدون رسوم +- **الخصوصية**: يعمل محلياً، البيانات لا تُرسل للخارج +- **غير متصل**: لا حاجة لاتصال بالإنترنت + +## cs + +Překlad PLaMo + +- Délka kontextu: Větší hodnoty umožňují překládat delší texty. +- Využití VRAM: Množství paměti GPU k použití. -1: pouze GPU. 0: pouze CPU. (Jednotka: GB) + +## de + +Ein Übersetzungsmodul, das lokales LLM spezialisiert für Japanisch verwendet. + +### Vorteile +- **Japanisch-spezialisiert**: Optimiert für japanische Übersetzung +- **Völlig kostenlos**: Open-Source-Modell ohne Gebühren +- **Datenschutz**: Läuft lokal, Daten werden nicht extern gesendet +- **Offline**: Keine Internetverbindung erforderlich + +## es + +Un módulo de traducción que utiliza un LLM local especializado para japonés. + +### Ventajas +- **Especializado en japonés**: Optimizado para traducciones al japonés +- **Completamente gratis**: Modelo de código abierto sin cargos +- **Privacidad**: Funciona localmente, los datos no se envían al exterior +- **Sin conexión**: No se necesita conexión a Internet + +## fa + +ماژول ترجمه‌ای که از LLM محلی تخصصی در زبان ژاپنی استفاده می‌کند. + +### مزایا +- **تخصصی در ژاپنی**: بهینه‌شده برای ترجمه‌های ژاپنی +- **کاملاً رایگان**: مدل متن‌باز بدون هزینه +- **حریم خصوصی**: به صورت محلی اجرا می‌شود، داده‌ها به خارج ارسال نمی‌شوند +- **آفلاین**: نیاز به اتصال به اینترنت ندارد + +## fil + +Isang modyul ng pagsasalin na gumagamit ng local LLM na dalubhasa para sa wikang Hapon. + +### Mga Bentahe +- **Dalubhasa sa Hapon**: Naka-optimize para sa pagsasalin ng Hapon +- **Lubos na Libre**: Open source model na walang bayad +- **Privacy**: Tumatakbo nang lokal, ang data ay hindi ipinapadala sa labas +- **Offline**: Walang kailangang koneksyon sa internet + +## fr + +Un module de traduction utilisant un LLM local spécialisé pour le japonais. + +### Avantages +- **Spécialisé japonais**: Optimisé pour les traductions japonaises +- **Complètement gratuit**: Modèle open source sans frais +- **Confidentialité**: Fonctionne localement, les données ne sont pas envoyées à l'extérieur +- **Hors ligne**: Pas de connexion Internet nécessaire + +## hi + +जापानी भाषा के लिए विशेष स्थानीय LLM का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **जापानी विशेषज्ञता**: जापानी अनुवाद के लिए अनुकूलित +- **पूर्ण रूप से निःशुल्क**: ओपन सोर्स मॉडल के साथ कोई शुल्क नहीं +- **गोपनीयता**: स्थानीय रूप से चलता है, डेटा बाहर नहीं भेजा जाता +- **ऑफ़लाइन**: इंटरनेट कनेक्शन की आवश्यकता नहीं + +## hu + +PLaMo fordítás + +- Kontextus hossza: A nagyobb értékek lehetővé teszik hosszabb szövegek fordítását. +- VRAM használat: A használandó GPU memória mennyisége. -1: Csak GPU. 0: Csak CPU. (Egység: GB) + +## id + +Modul terjemahan menggunakan LLM lokal khusus untuk Bahasa Jepang. + +### Keuntungan +- **Khusus Jepang**: Dioptimalkan untuk terjemahan Jepang +- **Sepenuhnya Gratis**: Model sumber terbuka tanpa biaya +- **Privasi**: Berjalan secara lokal, data tidak dikirim ke luar +- **Offline**: Tidak ada koneksi internet yang diperlukan + +## ko + +일본어에 특화된 로컬 LLM을 사용하는 번역 모듈입니다. + +### 장점 +- **일본어 특화**: 일본어 번역에 최적화되어 있습니다 +- **완전 무료**: 오픈 소스 모델로 비용이 발생하지 않습니다 +- **프라이버시**: 로컬에서 작동하므로 데이터가 외부로 전송되지 않습니다 +- **오프라인**: 인터넷 연결이 필요 없습니다 + +## ms + +Modul terjemahan menggunakan LLM tempatan khusus untuk Bahasa Jepun. + +### Kelebihan +- **Khusus Jepun**: Dioptimumkan untuk terjemahan Jepun +- **Percuma Sepenuhnya**: Model sumber terbuka tanpa caj +- **Privasi**: Berjalan secara tempatan, data tidak dihantar ke luar +- **Luar Talian**: Tiada sambungan internet diperlukan + +## pl + +Tłumaczenie PLaMo + +- Długość kontekstu: Większe wartości umożliwiają tłumaczenie dłuższych tekstów. +- Użycie VRAM: Ilość pamięci GPU do użycia. -1: tylko GPU. 0: tylko CPU. (Jednostka: GB) + +## pt-BR + +Módulo de tradução que utiliza LLM local especializado em japonês. + +### Vantagens +- **Especializado em japonês**: Otimizado para tradução de japonês +- **Totalmente gratuito**: Modelo de código aberto sem custos +- **Privacidade**: Como opera localmente, dados não são enviados externamente +- **Offline**: Não requer conexão à internet + +## ru + +Модуль перевода, использующий локальный LLM, специализированный для японского языка. + +### Преимущества +- **Специализация на японском**: Оптимизирован для перевода на японский язык +- **Полностью бесплатно**: Модель с открытым исходным кодом без платежей +- **Конфиденциальность**: Работает локально, данные не передаются наружу +- **Автономный**: Не требуется подключение к интернету + +## th + +โมดูลการแปลที่ใช้ LLM ในเครื่องที่เชี่ยวชาญสำหรับภาษาญี่ปุ่น + +### ข้อดี +- **เชี่ยวชาญภาษาญี่ปุ่น**: ปรับให้เหมาะสมสำหรับการแปลภาษาญี่ปุ่น +- **ฟรีทั้งหมด**: โมเดลโอเพนซอร์สไม่มีค่าใช้จ่าย +- **ความเป็นส่วนตัว**: ทำงานในเครื่อง ข้อมูลไม่ถูกส่งออกภายนอก +- **ออฟไลน์**: ไม่ต้องการการเชื่อมต่ออินเทอร์เน็ต + +## tr + +Japonca için özelleştirilmiş yerel LLM kullanan bir çeviri modülü. + +### Avantajlar +- **Japonca Uzmanlaşması**: Japonca çeviri için optimize edilmiş +- **Tamamen Ücretsiz**: Açık kaynak modeli, ücret yok +- **Gizlilik**: Yerel olarak çalışır, veriler dışarıya gönderilmez +- **Çevrimdışı**: İnternet bağlantısı gerekmez + +## vi + +Mô-đun dịch thuật sử dụng LLM cục bộ chuyên về tiếng Nhật. + +### Ưu điểm +- **Chuyên về tiếng Nhật**: Được tối ưu hóa cho dịch tiếng Nhật +- **Hoàn toàn miễn phí**: Mô hình nguồn mở không tốn phí +- **Quyền riêng tư**: Chạy cục bộ, dữ liệu không được gửi ra bên ngoài +- **Ngoại tuyến**: Không cần kết nối internet + +## zh-CN + +使用专门针对日语的本地 LLM 的翻译模块。 + +### 优点 +- **日语专用**:针对日语翻译进行了优化 +- **完全免费**:开源模型不产生费用 +- **隐私保护**:在本地运行,数据不会发送到外部 +- **离线**:无需互联网连接 + +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +使用專門針對日語的本地 LLM 的翻譯模組。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **日語專用**:針對日語翻譯進行了最佳化 +- **完全免費**:開源模型不產生費用 +- **隱私保護**:在本地執行,資料不會傳送到外部 +- **離線**:無需網際網路連線 diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md index 8002e1f6..0aa2e338 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md @@ -1,10 +1,12 @@ # WindowTranslator Tesseract OCR Plugin -[WindowTranslator](https://github.com/Freeesia/WindowTranslator) で、オープンソースのTesseract OCRエンジンを利用するプラグインです。 +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、オープンソースのTesseract OCRエンジンを利用するプラグインです。 ## 機能 -- Tesseractによる多言語OCR +- Tesseractによる100以上の言語に対応したOCR - 翻訳元言語に対応する`traineddata`を初回利用時に自動取得 - OCR領域の結合、拡大率、明るさ、コントラストを考慮した後処理 @@ -13,10 +15,181 @@ - Microsoft Visual C++ 2015以降のx64ランタイム - 言語データを初めて取得するときのインターネット接続 -必要なVisual C++ランタイムがない場合は、WindowTranslatorからインストールできます。言語データは`tesseract-ocr/tessdata_best`から取得されます。 +必要なVisual C++ランタイムがない場合は、WindowTranslatorから取得できます。言語データは`tesseract-ocr/tessdata_best`から取得されます。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that uses the open-source Tesseract OCR engine. + +## Features + +- Tesseract OCR with support for more than 100 languages +- Downloads the `traineddata` for the translation source language when it is first used +- Post-processing that accounts for merged OCR regions, scale, brightness, and contrast + +## Requirements + +- The x64 Microsoft Visual C++ 2015 or later runtime +- An internet connection when downloading language data for the first time + +WindowTranslator can obtain the required Visual C++ runtime if it is missing. Language data is downloaded from `tesseract-ocr/tessdata_best`. + +## ar + +محرك OCR مفتوح المصدر. + +### المزايا +- **دعم متعدد اللغات**: يدعم أكثر من 100 لغة +- **الاستقرار**: محرك موثوق به مع تاريخ طويل + +## cs + +OCR engine s otevřeným zdrojovým kódem. + +### Výhody +- **Vícejazyčná podpora**: Podporuje více než 100 jazyků +- **Stabilita**: Spolehlivý engine s dlouhou historií + +## de + +Eine Open-Source-OCR-Engine. + +### Vorteile +- **Mehrsprachige Unterstützung**: Unterstützt über 100 Sprachen +- **Stabilität**: Zuverlässige Engine mit langer Geschichte + +## es + +Un motor OCR de código abierto. + +### Ventajas +- **Soporte multilingüe**: Admite más de 100 idiomas +- **Estabilidad**: Un motor confiable con una larga historia + +## fa + +موتور OCR متن‌باز. + +### مزایا +- **پشتیبانی از چند زبان**: از بیش از 100 زبان پشتیبانی می‌کند +- **پایداری**: موتور قابل اعتماد با سابقه طولانی + +## fil + +Isang open-source na OCR engine. + +### Mga Bentahe +- **Suporta sa Maraming Wika**: Sumusuporta sa higit sa 100 wika +- **Katatagan**: Maaasahang engine na may mahabang kasaysayan + +## fr + +Un moteur OCR open source. + +### Avantages +- **Support multilingue**: Prend en charge plus de 100 langues +- **Stabilité**: Un moteur fiable avec une longue histoire + +## hi + +एक ओपन-सोर्स OCR इंजन। + +### लाभ +- **बहुभाषी समर्थन**: 100 से अधिक भाषाओं का समर्थन करता है +- **स्थिरता**: लंबे इतिहास वाला विश्वसनीय इंजन + +## hu + +Nyílt forráskódú OCR motor. + +### Előnyök +- **Többnyelvű támogatás**: Több mint 100 nyelvet támogat +- **Stabilitás**: Hosszú múltra visszatekintő megbízható motor + +## id + +Mesin OCR sumber terbuka. + +### Keuntungan +- **Dukungan Multibahasa**: Mendukung lebih dari 100 bahasa +- **Stabilitas**: Mesin yang dapat diandalkan dengan sejarah panjang + +## ko + +오픈 소스 OCR 엔진입니다. + +### 장점 +- **다국어 지원**: 100개 이상의 언어를 지원합니다 +- **안정성**: 오랜 역사를 가진 신뢰할 수 있는 엔진 + +## ms + +Enjin OCR sumber terbuka. + +### Kelebihan +- **Sokongan Berbilang Bahasa**: Menyokong lebih 100 bahasa +- **Kestabilan**: Enjin yang boleh dipercayai dengan sejarah panjang + +## pl + +Silnik OCR o otwartym kodzie źródłowym. + +### Zalety +- **Wsparcie wielojęzyczne**: Obsługuje ponad 100 języków +- **Stabilność**: Niezawodny silnik z długą historią + +## pt-BR + +Motor OCR de código aberto. + +### Vantagens +- **Suporte multilíngue**: Suporta mais de 100 idiomas +- **Estabilidade**: Motor confiável com longa história + +## ru + +OCR-движок с открытым исходным кодом. + +### Преимущества +- **Многоязычная поддержка**: Поддерживает более 100 языков +- **Стабильность**: Надежный движок с долгой историей + +## th + +เครื่องมือ OCR แบบโอเพนซอร์ส + +### ข้อดี +- **รองรับหลายภาษา**: รองรับมากกว่า 100 ภาษา +- **ความเสถียร**: เครื่องมือที่เชื่อถือได้พร้อมประวัติที่ยาวนาน + +## tr + +Açık kaynaklı bir OCR motoru. + +### Avantajlar +- **Çok Dilli Destek**: 100'den fazla dili destekler +- **Kararlılık**: Uzun geçmişe sahip güvenilir motor + +## vi + +Công cụ OCR nguồn mở. + +### Ưu điểm +- **Hỗ trợ đa ngôn ngữ**: Hỗ trợ hơn 100 ngôn ngữ +- **Ổn định**: Công cụ đáng tin cậy với lịch sử lâu dài + +## zh-CN + +开源 OCR 引擎。 + +### 优点 +- **多语言支持**:支持100多种语言 +- **稳定性**:历史悠久的可靠引擎 -## インストール +## zh-TW -WindowTranslatorの設定画面で3番目の「プラグイン」タブを開き、このプラグインをインストールしてください。プレリリース版を利用する場合は、このプラグインの「プレリリース」にチェックを入れます。 +開源 OCR 引擎。 -インストールまたは更新の反映にはWindowTranslatorの再起動が必要です。 +### 優點 +- **多語言支援**:支援100多種語言 +- **穩定性**:歷史悠久的可靠引擎 diff --git a/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs b/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs new file mode 100644 index 00000000..7cf8c5a4 --- /dev/null +++ b/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using WindowTranslator.Modules.PluginStore; + +namespace WindowTranslator.Tests; + +public class LocalizedReadmeSelectorTests +{ + [Theory] + [InlineData("pt-BR", "Português do Brasil")] + [InlineData("fr-CA", "Français")] + [InlineData("it-IT", "English")] + public void SelectUsesExactParentAndEnglishFallback( + string cultureName, + string expected) + { + const string markdown = """ + ## pt-BR + + Português do Brasil + + ## fr + + Français + + ## en + + English + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo(cultureName)); + + Assert.Equal(expected, result); + } + + [Fact] + public void SelectKeepsTheSharedPreambleAndOrdinaryHeadings() + { + const string markdown = """ + # Shared title + + Shared introduction. + + ## ja + + 日本語 + + ## 機能 + + - 機能A + + ## en + + English + + ## Features + + - Feature A + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("ja-JP")); + + Assert.Contains("# Shared title", result); + Assert.Contains("## 機能", result); + Assert.Contains("- 機能A", result); + Assert.DoesNotContain("## ja", result); + Assert.DoesNotContain("English", result); + } + + [Fact] + public void SelectFallsBackToTheFirstNonEmptyLanguageSection() + { + const string markdown = """ + ## ja + + 日本語 + + ## de + + Deutsch + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("fr-FR")); + + Assert.Equal("日本語", result); + } + + [Fact] + public void SelectReturnsTheOriginalMarkdownWithoutLanguageSections() + { + const string markdown = """ + # README + + ## Features + + - Feature A + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("en-US")); + + Assert.Same(markdown, result); + } + + [Fact] + public void SelectKeepsAnUppercaseAcronymHeadingInsideTheLanguageSection() + { + const string markdown = """ + ## ja + + 日本語 + + ## API + + APIの説明 + + ## en + + English + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("ja-JP")); + + Assert.Contains("## API", result); + Assert.Contains("APIの説明", result); + Assert.DoesNotContain("English", result); + } + +} diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 54ae0b60..7582ce46 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -1249,6 +1249,55 @@ public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() } } + [Fact] + public async Task PackageReadmeUsesTheRequestedUiCulture() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Localized.Plugin", + "1.0.0", + CreatePackage( + "Localized.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Localized.Plugin.dll"] = "plugin"u8.ToArray(), + ["README.md"] = """ + ## ja + + # 日本語 + + ## en + + # English + """u8.ToArray(), + })); + handler.AddReadmeUrl( + "Localized.Plugin", + "1.0.0", + "https://nuget.test/readme/localized.plugin/1.0.0"); + using var service = CreateService(handler, testDirectory); + + var readme = await service.GetPackageReadmeAsync( + "Localized.Plugin", + "1.0.0", + CultureInfo.GetCultureInfo("en-US")); + + Assert.Equal("# English", readme); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public void StartupCleanupDeletesOnlyPackagesMissingFromAReadableManifest() { diff --git a/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs b/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs new file mode 100644 index 00000000..0f565f6a --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace WindowTranslator.Modules.PluginStore; + +internal static partial class LocalizedReadmeSelector +{ + public static string Select(string markdown, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(markdown); + ArgumentNullException.ThrowIfNull(culture); + + var headings = LanguageHeadingRegex.Matches(markdown) + .Select(TryCreateHeading) + .OfType() + .ToArray(); + if (headings.Length == 0) + { + return markdown; + } + + var preamble = markdown[..headings[0].Start]; + var sections = headings + .Select((heading, index) => new ReadmeSection( + heading.CultureName, + markdown[heading.ContentStart..(index + 1 < headings.Length + ? headings[index + 1].Start + : markdown.Length)])) + .Where(section => !string.IsNullOrWhiteSpace(section.Content)) + .ToArray(); + if (sections.Length == 0) + { + return markdown; + } + + var selected = GetPreferredCultureNames(culture) + .Select(name => sections.FirstOrDefault(section => + section.CultureName.Equals(name, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(section => section is not null) + ?? sections[0]; + return Combine(preamble, selected.Content, markdown); + } + + private static IEnumerable GetPreferredCultureNames(CultureInfo culture) + { + for (var candidate = culture; !string.IsNullOrEmpty(candidate.Name); candidate = candidate.Parent) + { + yield return candidate.Name; + } + + yield return "en"; + } + + private static LanguageHeading? TryCreateHeading(Match match) + { + var value = match.Groups["culture"].Value; + try + { + var cultureName = CultureInfo.GetCultureInfo(value).Name; + return value.Equals(cultureName, StringComparison.Ordinal) + ? new(match.Index, match.Index + match.Length, cultureName) + : null; + } + catch (CultureNotFoundException) + { + return null; + } + } + + private static string Combine(string preamble, string content, string markdown) + { + var shared = preamble.TrimEnd('\r', '\n'); + var localized = content.Trim('\r', '\n'); + if (shared.Length == 0) + { + return localized; + } + if (localized.Length == 0) + { + return shared; + } + + var newLine = markdown.Contains("\r\n", StringComparison.Ordinal) + ? "\r\n" + : "\n"; + return $"{shared}{newLine}{newLine}{localized}"; + } + + [GeneratedRegex( + @"^##[ \t]+(?[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)(?:[ \t]+#+)?[ \t]*(?:\r?\n|$)", + RegexOptions.CultureInvariant | RegexOptions.Multiline)] + private static partial Regex LanguageHeadingRegex { get; } + + private sealed record LanguageHeading(int Start, int ContentStart, string CultureName); + + private sealed record ReadmeSection(string CultureName, string Content); +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index 45db7eba..a771f0c7 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO; using System.Net; using System.Net.Http; @@ -198,10 +199,7 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio /// /// 指定したパッケージバージョンのREADMEを取得します。 /// - public async Task GetPackageReadmeAsync( - string packageId, - string version, - CancellationToken cancellationToken = default) + public async Task GetPackageReadmeAsync(string packageId, string version, CultureInfo culture, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(packageId)) { @@ -211,6 +209,7 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio { throw new ArgumentException($"不正なNuGetパッケージバージョンです: {version}", nameof(version)); } + ArgumentNullException.ThrowIfNull(culture); var metadataResource = await this.repository .GetResourceAsync(cancellationToken) @@ -235,7 +234,10 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio } response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var markdown = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + return LocalizedReadmeSelector.Select(markdown, culture); } /// diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 25d1ce77..cc2e9d07 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; @@ -339,6 +340,7 @@ private async Task LoadPackageReadmeAsync( var readme = await this.nugetService.GetPackageReadmeAsync( package.Id, version, + CultureInfo.CurrentUICulture, cancellationSource.Token).ConfigureAwait(true); if (!cancellationSource.IsCancellationRequested && ReferenceEquals(package, this.SelectedPackage) diff --git a/docs/plugin.md b/docs/plugin.md index 7fccac4a..18c0c6e3 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -31,12 +31,15 @@ cd WindowTranslator.Plugin.YourPlugin YourName プラグインストアに表示する具体的な説明文 https://github.com/YourName/YourPlugin + README.md $(PackageTags);windowtranslator-plugin MIT + + @@ -58,6 +61,48 @@ cd WindowTranslator.Plugin.YourPlugin > WindowTranslator との互換性判定に使用されます。サポートする最も古い > `WindowTranslator.Abstractions` のバージョンを指定してください。 +### README の多言語化 + +プラグインストアは、README 内のカルチャー名だけで構成された第2レベル見出しを +言語セクションとして認識します。README は1ファイルのまま、必要な言語だけを +次のように記載してください。 + +```markdown +# Your Plugin + +## ja + +日本語の説明です。 + +## 機能 + +- 機能A + +## en + +English description. + +## Features + +- Feature A +``` + +アプリのUIカルチャーとの完全一致、親言語、英語、先頭セクションの順で表示する +言語を選択します。`## 機能`や`## Features`など、カルチャー名ではない見出しは +言語セクション内の通常の見出しとして扱われます。カルチャー名は`ja`、`pt-BR`、 +`zh-Hans`のように、.NETの正規表記と大文字・小文字まで一致させてください。 +正規形のカルチャー名を持つ第2レベル見出しはREADME全体で言語境界として扱うため、 +コード例など別の用途には使用しないでください。 + +リポジトリ内の公式プラグインは、既存のUI翻訳に合わせて次の22言語を1つの +READMEに収録しています。 + +`ja`、`en`、`ar`、`cs`、`de`、`es`、`fa`、`fil`、`fr`、`hi`、`hu`、`id`、 +`ko`、`ms`、`pl`、`pt-BR`、`ru`、`th`、`tr`、`vi`、`zh-CN`、`zh-TW` + +外部プラグインで全言語を用意する必要はありません。収録していないUI言語には +英語セクションが表示されます。 + ### 3. プラグインを実装 対象のインターフェースを実装します: From 727f966d26676de02bd3d509ffec3d8dba8af9d5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 03:13:01 +0900 Subject: [PATCH 32/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E9=85=8D=E5=B8=83=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=91=E3=83=86=E3=82=A3=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-desktop.yml | 31 +-- .github/workflows/dotnet-package.yml | 31 +-- Plugins/Directory.Build.props | 6 +- Plugins/Directory.Build.targets | 10 +- ...tor.Plugin.BergamotTranslatorPlugin.csproj | 2 + ...wTranslator.Plugin.ColorThiefPlugin.csproj | 2 + ...nslator.Plugin.DeepLTranslatePlugin.csproj | 2 +- ...WindowTranslator.Plugin.DummyPlugin.csproj | 2 +- .../WindowTranslator.Plugin.FoMPlugin.csproj | 2 +- ...anslator.Plugin.GitHubCopilotPlugin.csproj | 2 +- ...dowTranslator.Plugin.GoogleAIPlugin.csproj | 2 +- ...lator.Plugin.GoogleAppsSctiptPlugin.csproj | 2 +- .../WindowTranslator.Plugin.LLMPlugin.csproj | 2 +- ...indowTranslator.Plugin.OneOcrPlugin.csproj | 2 + ...WindowTranslator.Plugin.PLaMoPlugin.csproj | 2 +- ...ranslator.Plugin.TesseractOCRPlugin.csproj | 2 +- Plugins/WindowTranslator.Plugins.proj | 28 +++ .../NuGetPluginServiceTests.cs | 188 +------------- WindowTranslator.slnx | 1 + .../Modules/PluginStore/NuGetPluginCatalog.cs | 9 +- .../PluginStore/NuGetPluginOperation.cs | 232 ++---------------- .../Modules/PluginStore/NuGetPluginService.cs | 34 ++- 22 files changed, 132 insertions(+), 462 deletions(-) create mode 100644 Plugins/WindowTranslator.Plugins.proj diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 95f68e4c..e742efde 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -134,27 +134,16 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - $projects = Get-ChildItem -Path "Plugins\WindowTranslator.Plugin.*\*.csproj" | - Where-Object { $_.Directory.Name -notmatch "Dummy" -and $_.Directory.Name -notmatch "Tests?" } - foreach ($project in $projects) { - $propertyOutput = dotnet msbuild $project.FullName -nologo -getProperty:ExcludeFromAppBundle - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - $excludeFromAppBundle = ($propertyOutput | Select-Object -Last 1).Trim() - if ($excludeFromAppBundle -eq "true") { - Write-Host "Skip app bundle: $($project.Name)" - continue - } - dotnet publish $project.FullName -c Release -o "publish\plugins\$($project.Directory.Name)" ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` - -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PublishAppBundle ` + -p:Configuration=Release ` + -p:AppBundleOutputPath="$((Resolve-Path publish).Path)\plugins" ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` + -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } - uses: actions/download-artifact@v8 if: ${{ needs.docs.result == 'success' }} diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index de573cbd..aadba2c7 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -63,27 +63,16 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $projects = Get-ChildItem Plugins\WindowTranslator.Plugin.*\*.csproj | - Where-Object { $_.Directory.Name -notlike '*.Tests' } - foreach ($project in $projects) { - $propertyOutput = dotnet msbuild $project.FullName -nologo -getProperty:IsPackable - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - $isPackable = ($propertyOutput | Select-Object -Last 1).Trim() - if ($isPackable -ne "true") { - Write-Host "Skip NuGet package: $($project.Name)" - continue - } - dotnet pack $project.FullName -c Release -o pack ` - -p:Version=${{ steps.package-version.outputs.version }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` - -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PackPlugins ` + -p:Configuration=Release ` + -p:PackageOutputPath="$((Resolve-Path pack).Path)" ` + -p:Version=${{ steps.package-version.outputs.version }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` + -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE } - name: NuGet login if: ${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index e50f2937..ce5e1e56 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -6,12 +6,12 @@ true - + + false + false false - - false diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 7371bc28..314aa32c 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -1,4 +1,12 @@ + + $(PublishToNuGet) + $(IncludeInAppBundle) + $(AppBundleOutputPath)\$(MSBuildProjectName)\ + + @@ -39,7 +47,7 @@ - + diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj index 379a3053..8170af1c 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj @@ -4,6 +4,8 @@ net10.0 Bergamot Translator Plugin Offline neural machine translation for WindowTranslator using Bergamot. + true + true enable enable true diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj index 9515f260..9ae8011c 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj @@ -3,6 +3,8 @@ net10.0-windows10.0.20348.0 ColorThief Plugin Detects readable foreground and background colors for translated text. + true + true diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index ecff13ec..2fef9710 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -3,7 +3,7 @@ DeepL Translator Plugin Translation for WindowTranslator using the DeepL API. - true + true diff --git a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj index 7fb550e8..79f9d404 100644 --- a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj @@ -2,7 +2,7 @@ - false + false diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index c7979cc5..20a0a5ce 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -4,7 +4,7 @@ Fields of Mistria Filter Plugin Context-aware translation filtering for Fields of Mistria. true - true + true diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index 71a02d16..7a48c626 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -3,7 +3,7 @@ net10.0-windows10.0.20348.0 GitHub Copilot Translator Plugin Translation for WindowTranslator using GitHub Copilot. - true + true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index ffbfadf7..91850c72 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -4,7 +4,7 @@ net10.0-windows10.0.20348.0 Google AI Plugin Translation, OCR, and text correction for WindowTranslator using Google AI. - true + true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index a7f4954b..65e944fa 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -2,7 +2,7 @@ Google Apps Script Translator Plugin Translation for WindowTranslator through Google Apps Script. - true + true diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index 6bac6e23..c6bb66ee 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -3,7 +3,7 @@ net10.0-windows10.0.20348.0 LLM Plugin Translation, OCR, and text correction through OpenAI-compatible language models. - true + true diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj index 97ac11cd..d89bcd5b 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj @@ -4,6 +4,8 @@ OneOCR Plugin OCR for WindowTranslator using the Windows OneOCR engine. true + true + true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index 7c88752d..8ddea9c2 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -4,7 +4,7 @@ net10.0 PLaMo Translator Plugin Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. - true + true true x64 diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index ba85c1f4..142137d7 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -4,7 +4,7 @@ net10.0-windows10.0.20348.0 Tesseract OCR Plugin OCR for WindowTranslator using the Tesseract engine. - true + true diff --git a/Plugins/WindowTranslator.Plugins.proj b/Plugins/WindowTranslator.Plugins.proj new file mode 100644 index 00000000..a11ff9c3 --- /dev/null +++ b/Plugins/WindowTranslator.Plugins.proj @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index 7582ce46..cf5afba4 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -1128,7 +1128,7 @@ public async Task InstallRejectsPackageWithoutPluginTag() var targetDirectory = Path.Combine(testDirectory, "Root.Plugin"); Directory.CreateDirectory(targetDirectory); File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "old"); - await NuGetPluginOperation.SaveManifestAsync( + await NuGetPluginService.SaveManifestAsync( Path.Combine(testDirectory, "nuget-manifest.json"), new([new( "Root.Plugin", @@ -1299,19 +1299,19 @@ public async Task PackageReadmeUsesTheRequestedUiCulture() } [Fact] - public void StartupCleanupDeletesOnlyPackagesMissingFromAReadableManifest() + public void StartupCleanupDeletesDirectoriesMissingFromAReadableManifest() { var sourceDirectory = CreateTestDirectory(); try { var installedDirectory = Path.Combine(sourceDirectory, "Installed.Plugin"); var removedDirectory = Path.Combine(sourceDirectory, "Removed.Plugin"); - var operationsDirectory = Path.Combine( + var interruptedInstallDirectory = Path.Combine( sourceDirectory, - NuGetPluginOperation.OperationsDirectoryName); + "Installed.Plugin.installing-test"); Directory.CreateDirectory(installedDirectory); Directory.CreateDirectory(removedDirectory); - Directory.CreateDirectory(operationsDirectory); + Directory.CreateDirectory(interruptedInstallDirectory); File.WriteAllText( Path.Combine(sourceDirectory, "nuget-manifest.json"), JsonSerializer.Serialize( @@ -1329,7 +1329,7 @@ public void StartupCleanupDeletesOnlyPackagesMissingFromAReadableManifest() Assert.True(Directory.Exists(installedDirectory)); Assert.False(Directory.Exists(removedDirectory)); - Assert.True(Directory.Exists(operationsDirectory)); + Assert.False(Directory.Exists(interruptedInstallDirectory)); var directoryKeptForInvalidManifest = Path.Combine( sourceDirectory, @@ -1367,11 +1367,6 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() Path.Combine(sourceDirectory, "Root.Plugin", "Unchanged.dll"); File.WriteAllText(unchangedSourcePath, "unchanged"); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Empty.Plugin")); - Directory.CreateDirectory(Path.Combine(sourceDirectory, ".operations")); - Directory.CreateDirectory(Path.Combine(sourceDirectory, ".operations", "backup-test")); - File.WriteAllText( - Path.Combine(sourceDirectory, ".operations", "backup-test", "old.dll"), - "old"); Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.backup-test")); File.WriteAllText( Path.Combine(sourceDirectory, "Root.Plugin.backup-test", "old.dll"), @@ -1445,8 +1440,6 @@ public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() Assert.False(File.Exists(Path.Combine(destinationDirectory, "nuget-manifest.json"))); Assert.False(File.Exists( Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); - Assert.False(Directory.Exists( - Path.Combine(destinationDirectory, ".operations"))); Assert.False(Directory.Exists( Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); Assert.False(Directory.Exists( @@ -1488,171 +1481,6 @@ public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() } } - [Fact] - public async Task InterruptedInstallIsRolledBackBeforeItIsTreatedAsCompleted() - { - var sourceDirectory = CreateTestDirectory(); - try - { - const string packageId = "Root.Plugin"; - var originalManifest = new InstalledManifest( - [new InstalledPackageInfo( - packageId, - "1.0.0", - HostMajorVersion: 1, - AbstractionsVersionRange: "(, )")]); - var updatedManifest = new InstalledManifest( - [new InstalledPackageInfo( - packageId, - "2.0.0", - HostMajorVersion: 1, - AbstractionsVersionRange: "(, )")]); - var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); - await NuGetPluginOperation.SaveManifestAsync( - manifestPath, - originalManifest, - CancellationToken.None); - - var operation = await NuGetPluginOperation.BeginAsync( - sourceDirectory, - packageId, - originalManifest, - CancellationToken.None); - Directory.CreateDirectory(operation.BackupPath); - File.WriteAllText(Path.Combine(operation.BackupPath, "plugin.txt"), "old"); - Directory.Move(operation.WorkingPath, operation.TargetPath); - File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); - await NuGetPluginOperation.SaveManifestAsync( - manifestPath, - updatedManifest, - CancellationToken.None); - - var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( - sourceDirectory); - - Assert.Empty(unresolved); - Assert.Equal("old", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); - var restoredManifest = JsonSerializer.Deserialize( - File.ReadAllText(manifestPath), - NuGetPluginService.ManifestJsonOptions); - Assert.Equal("1.0.0", Assert.Single(restoredManifest!.Packages).Version); - Assert.False(File.Exists(operation.PendingPath)); - Assert.False(Directory.Exists(operation.WorkingPath)); - Assert.False(Directory.Exists(operation.BackupPath)); - } - finally - { - DeleteTestDirectory(sourceDirectory); - } - } - - [Fact] - public async Task CompletedInstallKeepsNewFilesAndOnlyCleansOperationData() - { - var sourceDirectory = CreateTestDirectory(); - try - { - const string packageId = "Root.Plugin"; - var originalManifest = new InstalledManifest( - [new InstalledPackageInfo( - packageId, - "1.0.0", - HostMajorVersion: 1, - AbstractionsVersionRange: "(, )")]); - var updatedManifest = new InstalledManifest( - [new InstalledPackageInfo( - packageId, - "2.0.0", - HostMajorVersion: 1, - AbstractionsVersionRange: "(, )")]); - var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); - await NuGetPluginOperation.SaveManifestAsync( - manifestPath, - originalManifest, - CancellationToken.None); - - var operation = await NuGetPluginOperation.BeginAsync( - sourceDirectory, - packageId, - originalManifest, - CancellationToken.None); - Directory.CreateDirectory(operation.BackupPath); - File.WriteAllText(Path.Combine(operation.BackupPath, "plugin.txt"), "old"); - Directory.Move(operation.WorkingPath, operation.TargetPath); - File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); - await NuGetPluginOperation.SaveManifestAsync( - manifestPath, - updatedManifest, - CancellationToken.None); - operation.Commit(); - - var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( - sourceDirectory); - - Assert.Empty(unresolved); - Assert.Equal("new", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); - var retainedManifest = JsonSerializer.Deserialize( - File.ReadAllText(manifestPath), - NuGetPluginService.ManifestJsonOptions); - Assert.Equal("2.0.0", Assert.Single(retainedManifest!.Packages).Version); - Assert.False(File.Exists(operation.PendingPath)); - Assert.False(File.Exists(operation.CommittedPath)); - Assert.False(Directory.Exists(operation.BackupPath)); - } - finally - { - DeleteTestDirectory(sourceDirectory); - } - } - - [Fact] - public async Task CompletedInstallRemainsLoadableWhenOperationCleanupFails() - { - var sourceDirectory = CreateTestDirectory(); - try - { - const string packageId = "Root.Plugin"; - var manifest = new InstalledManifest( - [new InstalledPackageInfo( - packageId, - "2.0.0", - HostMajorVersion: 1, - AbstractionsVersionRange: "(, )")]); - var manifestPath = Path.Combine(sourceDirectory, "nuget-manifest.json"); - var operation = await NuGetPluginOperation.BeginAsync( - sourceDirectory, - packageId, - originalManifest: null, - CancellationToken.None); - Directory.Move(operation.WorkingPath, operation.TargetPath); - File.WriteAllText(Path.Combine(operation.TargetPath, "plugin.txt"), "new"); - await NuGetPluginOperation.SaveManifestAsync( - manifestPath, - manifest, - CancellationToken.None); - operation.Commit(); - - Directory.CreateDirectory(operation.BackupPath); - var lockedPath = Path.Combine(operation.BackupPath, "locked.txt"); - await File.WriteAllTextAsync(lockedPath, "locked"); - await using (File.Open(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None)) - { - var unresolved = await NuGetPluginOperation.RecoverInterruptedOperationsAsync( - sourceDirectory); - - Assert.Empty(unresolved); - Assert.Equal("new", File.ReadAllText(Path.Combine(operation.TargetPath, "plugin.txt"))); - } - - Assert.Empty(await NuGetPluginOperation.RecoverInterruptedOperationsAsync(sourceDirectory)); - Assert.False(Directory.Exists(operation.BackupPath)); - } - finally - { - DeleteTestDirectory(sourceDirectory); - } - } - [Fact] public void CatalogSynchronizationFollowsCompatibilityValidationSetting() { @@ -1796,7 +1624,7 @@ public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() packageDirectory, "*.deps.json", SearchOption.AllDirectories)); - await NuGetPluginOperation.SaveManifestAsync( + await NuGetPluginService.SaveManifestAsync( Path.Combine(sourceDirectory, "nuget-manifest.json"), new([new( "Catalog.Probe", @@ -1876,7 +1704,7 @@ public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() "WindowTranslator.Tests.resources.dll"), Path.Combine(cultureDirectory, "WindowTranslator.Tests.resources.dll")); } - await NuGetPluginOperation.SaveManifestAsync( + await NuGetPluginService.SaveManifestAsync( Path.Combine(sourceDirectory, "nuget-manifest.json"), new([new( "Catalog.Probe", diff --git a/WindowTranslator.slnx b/WindowTranslator.slnx index 3447197f..4756c92a 100644 --- a/WindowTranslator.slnx +++ b/WindowTranslator.slnx @@ -25,6 +25,7 @@ + diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 4c37a1e5..240241dc 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -60,15 +60,11 @@ internal NuGetPluginCatalog( /// public async Task Initialize() { - var unresolvedOperations = await NuGetPluginOperation - .RecoverInterruptedOperationsAsync(this.sourceDir) - .ConfigureAwait(false); DeleteUninstalledPackageDirectories(this.sourceDir); var loadablePackages = GetLoadablePackageIds( this.sourceDir, this.hostMajorVersion, this.hostAbstractionsVersion); - loadablePackages.ExceptWith(unresolvedOperations); SynchronizePluginFiles(this.sourceDir, this.tempDir, loadablePackages); this.innerCatalog = CreateCatalog(this.tempDir, this.options); @@ -381,10 +377,7 @@ internal static void DeleteUninstalledPackageDirectories(string sourceDirectory) foreach (var packageDirectory in Directory.EnumerateDirectories(sourceDirectory)) { var directoryName = Path.GetFileName(packageDirectory); - if (directoryName.Equals( - NuGetPluginOperation.OperationsDirectoryName, - StringComparison.OrdinalIgnoreCase) - || installedPackageIds.Contains(directoryName)) + if (installedPackageIds.Contains(directoryName)) { continue; } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs index b8fdaaf4..2ab0936b 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs @@ -1,240 +1,54 @@ using System.Diagnostics; using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; using NuGet.Packaging; namespace WindowTranslator.Modules.PluginStore; -internal sealed record NuGetPluginOperationState( - [property: JsonRequired] string PackageId, - InstalledManifest? OriginalManifest); - -internal sealed class NuGetPluginOperation : IAsyncDisposable +internal sealed class NuGetPluginOperation : IDisposable { - internal const string OperationsDirectoryName = ".operations"; - private const string PendingFileName = "pending.json"; - private const string CommittedFileName = "committed.json"; - - private readonly string rootDirectory; - private readonly string operationDirectory; - private readonly NuGetPluginOperationState state; private bool committed; - private NuGetPluginOperation( - string rootDirectory, - string operationDirectory, - NuGetPluginOperationState state) + internal NuGetPluginOperation(string rootDirectory, string packageId) { - this.rootDirectory = Path.GetFullPath(rootDirectory); - this.operationDirectory = Path.GetFullPath(operationDirectory); - this.state = state; - _ = this.TargetPath; + PackageIdValidator.ValidatePackageId(packageId); + var rootPath = Path.GetFullPath(rootDirectory); + var operationId = Guid.NewGuid().ToString("N"); + this.TargetPath = Path.Combine(rootPath, packageId); + this.WorkingPath = Path.Combine(rootPath, $"{packageId}.installing-{operationId}"); + this.BackupPath = Path.Combine(rootPath, $"{packageId}.backup-{operationId}"); + Directory.CreateDirectory(this.WorkingPath); } - internal string TargetPath => GetPackageDirectory(this.rootDirectory, this.state.PackageId); - - internal string WorkingPath => Path.Combine(this.operationDirectory, "working"); - - internal string BackupPath => Path.Combine(this.operationDirectory, "backup"); - - internal string PendingPath => Path.Combine(this.operationDirectory, PendingFileName); - - internal string CommittedPath => Path.Combine(this.operationDirectory, CommittedFileName); - - internal static async Task BeginAsync( - string rootDirectory, - string packageId, - InstalledManifest? originalManifest, - CancellationToken cancellationToken) - { - var operationDirectory = Path.Combine( - Path.GetFullPath(rootDirectory), - OperationsDirectoryName, - Guid.NewGuid().ToString("N")); - var operation = new NuGetPluginOperation( - rootDirectory, - operationDirectory, - new(packageId, originalManifest)); - Directory.CreateDirectory(operationDirectory); - Directory.CreateDirectory(operation.WorkingPath); + internal string TargetPath { get; } - try - { - await SaveJsonAsync( - operation.PendingPath, - operation.state, - cancellationToken).ConfigureAwait(false); - return operation; - } - catch - { - DeleteDirectoryIfExists(operationDirectory); - throw; - } - } - - internal static string GetPackageDirectory(string rootDirectory, string packageId) - { - PackageIdValidator.ValidatePackageId(packageId); - return Path.Combine(Path.GetFullPath(rootDirectory), packageId); - } + internal string WorkingPath { get; } - internal static Task SaveManifestAsync( - string manifestPath, - InstalledManifest manifest, - CancellationToken cancellationToken) - => SaveJsonAsync(manifestPath, manifest, cancellationToken); + internal string BackupPath { get; } - internal void Commit() - { - File.Move(this.PendingPath, this.CommittedPath); - this.committed = true; - } + internal void Commit() => this.committed = true; - public async ValueTask DisposeAsync() + public void Dispose() { try { - if (this.committed) + if (!this.committed) { - CleanupCommitted(); - } - else - { - await RollbackAsync(CancellationToken.None).ConfigureAwait(false); - CleanupRolledBack(); - } - } - catch (Exception ex) - { - Trace.TraceWarning( - "NuGetプラグイン操作の後処理に失敗しました: {0} {1} ({2})", - this.state.PackageId, - Path.GetFileName(this.operationDirectory), - ex); - } - } - - internal static async Task> RecoverInterruptedOperationsAsync( - string rootDirectory, - CancellationToken cancellationToken = default) - { - var operationsDirectory = Path.Combine( - Path.GetFullPath(rootDirectory), - OperationsDirectoryName); - if (!Directory.Exists(operationsDirectory)) - { - return new HashSet(StringComparer.OrdinalIgnoreCase); - } - - var unresolvedPackageIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var operationDirectory in Directory.EnumerateDirectories(operationsDirectory)) - { - cancellationToken.ThrowIfCancellationRequested(); - NuGetPluginOperationState? state = null; - var isCommitted = false; - try - { - var committedPath = Path.Combine(operationDirectory, CommittedFileName); - isCommitted = File.Exists(committedPath); - var statePath = isCommitted - ? committedPath - : Path.Combine(operationDirectory, PendingFileName); - if (!File.Exists(statePath)) + if (!Directory.Exists(this.WorkingPath) && Directory.Exists(this.TargetPath)) { - DeleteDirectoryIfExists(operationDirectory); - continue; + Directory.Move(this.TargetPath, this.WorkingPath); } - - state = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(statePath, cancellationToken).ConfigureAwait(false), - NuGetPluginService.ManifestJsonOptions) - ?? throw new InvalidDataException("NuGetプラグイン操作情報が空です。"); - var operation = new NuGetPluginOperation(rootDirectory, operationDirectory, state) + if (Directory.Exists(this.BackupPath)) { - committed = isCommitted, - }; - if (isCommitted) - { - operation.CleanupCommitted(); - } - else - { - await operation.RollbackAsync(cancellationToken).ConfigureAwait(false); - operation.CleanupRolledBack(); + Directory.Move(this.BackupPath, this.TargetPath); } } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (!isCommitted && !string.IsNullOrWhiteSpace(state?.PackageId)) - { - unresolvedPackageIds.Add(state.PackageId); - } - Trace.TraceWarning( - "NuGetプラグイン操作の復旧に失敗しました: {0} ({1})", - operationDirectory, - ex); - } - } - - return unresolvedPackageIds; - } - private async Task RollbackAsync(CancellationToken cancellationToken) - { - if (!Directory.Exists(this.WorkingPath) && Directory.Exists(this.TargetPath)) - { - Directory.Move(this.TargetPath, this.WorkingPath); - } - if (Directory.Exists(this.BackupPath)) - { - Directory.Move(this.BackupPath, this.TargetPath); + DeleteDirectoryIfExists(this.WorkingPath); + DeleteDirectoryIfExists(this.BackupPath); } - - var manifestPath = Path.Combine(this.rootDirectory, "nuget-manifest.json"); - if (this.state.OriginalManifest is { } originalManifest) - { - await SaveManifestAsync( - manifestPath, - originalManifest, - cancellationToken).ConfigureAwait(false); - } - else if (File.Exists(manifestPath)) - { - File.Delete(manifestPath); - } - } - - private void CleanupCommitted() - => DeleteDirectoryIfExists(this.operationDirectory); - - private void CleanupRolledBack() - { - // 復旧後に同じ操作を再実行しないよう、作業データより先に状態を消す。 - File.Delete(this.PendingPath); - CleanupCommitted(); - } - - private static async Task SaveJsonAsync( - string destinationPath, - T value, - CancellationToken cancellationToken) - { - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - var temporaryPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}"; - try - { - var json = JsonSerializer.Serialize(value, NuGetPluginService.ManifestJsonOptions); - await File.WriteAllTextAsync( - temporaryPath, - json, - cancellationToken).ConfigureAwait(false); - File.Move(temporaryPath, destinationPath, overwrite: true); - } - finally + catch (Exception ex) { - File.Delete(temporaryPath); + Trace.TraceWarning("NuGet plugin operation cleanup failed: {0} ({1})", this.TargetPath, ex); } } diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index a771f0c7..ac0e1fb3 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -246,13 +246,8 @@ internal async Task RefreshPackageInformationAsync(CancellationToken cancellatio public async Task InstallPackageAsync(string packageId, string version, IProgress? progress = null, CancellationToken cancellationToken = default) { using var operation = await this.operationLock.EnterAsync(cancellationToken); - var manifestExisted = File.Exists(this.manifestPath); var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - await using var pluginOperation = await NuGetPluginOperation.BeginAsync( - this.nugetPluginsDir, - packageId, - manifestExisted ? currentManifest : null, - cancellationToken).ConfigureAwait(false); + using var pluginOperation = new NuGetPluginOperation(this.nugetPluginsDir, packageId); var packageResource = await this.repository .GetResourceAsync(cancellationToken) @@ -483,10 +478,29 @@ private async Task LoadManifestAsync(CancellationToken cancel } private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) - => NuGetPluginOperation.SaveManifestAsync( - this.manifestPath, - manifest, - cancellationToken); + => SaveManifestAsync(this.manifestPath, manifest, cancellationToken); + + internal static async Task SaveManifestAsync( + string manifestPath, + InstalledManifest manifest, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + var temporaryPath = $"{manifestPath}.tmp-{Guid.NewGuid():N}"; + try + { + var json = JsonSerializer.Serialize(manifest, ManifestJsonOptions); + await File.WriteAllTextAsync( + temporaryPath, + json, + cancellationToken).ConfigureAwait(false); + File.Move(temporaryPath, manifestPath, overwrite: true); + } + finally + { + File.Delete(temporaryPath); + } + } public override void Dispose() { From e550bbb16fa802d9c5a3299a688a8afff4ac1e04 Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 17:31:59 +0900 Subject: [PATCH 33/43] =?UTF-8?q?=E9=85=8D=E5=B8=83=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E6=A8=99=E6=BA=96=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-desktop.yml | 7 +--- .github/workflows/dotnet-package.yml | 39 ++---------------- ColorThief/ColorThief/ColorThief.csproj | 2 + Plugins/Directory.Build.props | 17 ++++---- Plugins/Directory.Build.targets | 25 ++--------- ...tor.Plugin.BergamotTranslatorPlugin.csproj | 4 +- ...wTranslator.Plugin.ColorThiefPlugin.csproj | 4 +- ...nslator.Plugin.DeepLTranslatePlugin.csproj | 3 +- ...WindowTranslator.Plugin.DummyPlugin.csproj | 3 +- .../WindowTranslator.Plugin.FoMPlugin.csproj | 3 +- ...anslator.Plugin.GitHubCopilotPlugin.csproj | 3 +- ...dowTranslator.Plugin.GoogleAIPlugin.csproj | 3 +- ...lator.Plugin.GoogleAppsSctiptPlugin.csproj | 3 +- .../WindowTranslator.Plugin.LLMPlugin.csproj | 3 +- ...indowTranslator.Plugin.OneOcrPlugin.csproj | 4 +- ...WindowTranslator.Plugin.PLaMoPlugin.csproj | 3 +- ...ranslator.Plugin.TesseractOCRPlugin.csproj | 3 +- Plugins/WindowTranslator.Plugins.proj | 28 ------------- .../WindowTranslator.Abstractions.csproj | 1 + WindowTranslator.Packages.proj | 30 ++++++++++++++ .../ColorThiefModuleTests.cs | 0 ...lator.Plugin.ColorThiefPlugin.Tests.csproj | 7 +++- .../images/text_000.jpg | Bin .../images/text_001.jpg | Bin .../images/text_002.jpg | Bin .../images/text_003.jpg | Bin .../images/text_004.jpg | Bin .../images/text_005.jpg | Bin .../images/text_006.jpg | Bin .../images/text_007.jpg | Bin .../images/text_008.jpg | Bin .../images/text_009.jpg | Bin .../images/text_010.jpg | Bin .../images/text_011.jpg | Bin .../images/text_012.jpg | Bin .../images/text_013.jpg | Bin .../images/text_014.jpg | Bin .../images/text_015.jpg | Bin .../images/text_016.jpg | Bin WindowTranslator.slnx | 4 +- 40 files changed, 82 insertions(+), 117 deletions(-) delete mode 100644 Plugins/WindowTranslator.Plugins.proj create mode 100644 WindowTranslator.Packages.proj rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/ColorThiefModuleTests.cs (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj (65%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_000.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_001.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_002.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_003.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_004.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_005.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_006.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_007.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_008.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_009.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_010.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_011.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_012.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_013.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_014.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_015.jpg (100%) rename {Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests => WindowTranslator.Plugin.ColorThiefPlugin.Tests}/images/text_016.jpg (100%) diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index e742efde..093f16fb 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -134,17 +134,14 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PublishAppBundle ` + dotnet msbuild WindowTranslator.Packages.proj -target:PublishAppBundle ` -p:Configuration=Release ` - -p:AppBundleOutputPath="$((Resolve-Path publish).Path)\plugins" ` + -p:AppBundleOutputPath="$PWD\publish\plugins" ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - uses: actions/download-artifact@v8 if: ${{ needs.docs.result == 'success' }} with: diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index aadba2c7..7c512ac6 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -29,51 +29,18 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 - - id: package-version - shell: pwsh - run: | - if ('${{ github.event_name }}' -eq 'push') { - $tag = '${{ github.ref_name }}' - if ($tag -notmatch '^v(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$') { - Write-Error "NuGet package tag must be v-prefixed SemVer: $tag" - exit 1 - } - "version=$($Matches.version)" >> $env:GITHUB_OUTPUT - } - else { - "version=0.0.0-pr.${{ github.run_number }}" >> $env:GITHUB_OUTPUT - } - uses: Jimver/cuda-toolkit@v0.2.30 with: cuda: '12.9.0' - run: | - dotnet pack WindowTranslator.Abstractions -c Release -o pack ` - -p:Version=${{ steps.package-version.outputs.version }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - dotnet pack ColorThief\ColorThief -c Release -o pack ` - -p:Version=${{ steps.package-version.outputs.version }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PackPlugins ` + dotnet msbuild WindowTranslator.Packages.proj -target:Pack ` -p:Configuration=Release ` - -p:PackageOutputPath="$((Resolve-Path pack).Path)" ` - -p:Version=${{ steps.package-version.outputs.version }} ` + -p:PackageOutputPath="$PWD\pack" ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - name: NuGet login if: ${{ startsWith(github.ref, 'refs/tags/v') }} id: nuget_login diff --git a/ColorThief/ColorThief/ColorThief.csproj b/ColorThief/ColorThief/ColorThief.csproj index bf9a9540..2c3c567c 100644 --- a/ColorThief/ColorThief/ColorThief.csproj +++ b/ColorThief/ColorThief/ColorThief.csproj @@ -6,6 +6,8 @@ WindowTranslator ColorThief Support Library Color extraction support library for the WindowTranslator ColorThief plugin. $(PackageTags);ColorThief + true + false enable enable StudioFreesia.ColorThief diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index ce5e1e56..81d8978b 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -4,15 +4,18 @@ net10.0 true + $(PackageTags);windowtranslator-plugin + README.md + plugin-icon.png - - - false - false - false - + + + + + + + diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 314aa32c..3244b5a0 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -1,38 +1,19 @@ - - $(PublishToNuGet) - $(IncludeInAppBundle) - $(AppBundleOutputPath)\$(MSBuildProjectName)\ - - - - $(PackageTags);windowtranslator-plugin - README.md - plugin-icon.png + $(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage - - - - - - - - <_PluginWinX64RuntimeAsset Include="$(TargetDir)runtimes\win-x64\**\*" /> @@ -47,7 +28,7 @@ - + diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj index 8170af1c..a9ee952e 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj @@ -4,8 +4,8 @@ net10.0 Bergamot Translator Plugin Offline neural machine translation for WindowTranslator using Bergamot. - true - true + true + true enable enable true diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj index 9ae8011c..3e49347c 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj @@ -3,8 +3,8 @@ net10.0-windows10.0.20348.0 ColorThief Plugin Detects readable foreground and background colors for translated text. - true - true + true + true diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index 2fef9710..2afc9efa 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -3,7 +3,8 @@ DeepL Translator Plugin Translation for WindowTranslator using the DeepL API. - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj index 79f9d404..e37e8a3a 100644 --- a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj @@ -2,7 +2,8 @@ - false + false + false diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index 20a0a5ce..79cd0353 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -4,7 +4,8 @@ Fields of Mistria Filter Plugin Context-aware translation filtering for Fields of Mistria. true - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index 7a48c626..db0736dd 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -3,7 +3,8 @@ net10.0-windows10.0.20348.0 GitHub Copilot Translator Plugin Translation for WindowTranslator using GitHub Copilot. - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index 91850c72..4a7f97ba 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -4,7 +4,8 @@ net10.0-windows10.0.20348.0 Google AI Plugin Translation, OCR, and text correction for WindowTranslator using Google AI. - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index 65e944fa..c317da57 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -2,7 +2,8 @@ Google Apps Script Translator Plugin Translation for WindowTranslator through Google Apps Script. - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index c6bb66ee..a426d83c 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -3,7 +3,8 @@ net10.0-windows10.0.20348.0 LLM Plugin Translation, OCR, and text correction through OpenAI-compatible language models. - true + true + false diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj index d89bcd5b..010991cf 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj @@ -4,8 +4,8 @@ OneOCR Plugin OCR for WindowTranslator using the Windows OneOCR engine. true - true - true + true + true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index 8ddea9c2..e95baa06 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -4,7 +4,8 @@ net10.0 PLaMo Translator Plugin Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. - true + true + false true x64 diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index 142137d7..89d62cfd 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -4,7 +4,8 @@ net10.0-windows10.0.20348.0 Tesseract OCR Plugin OCR for WindowTranslator using the Tesseract engine. - true + true + false diff --git a/Plugins/WindowTranslator.Plugins.proj b/Plugins/WindowTranslator.Plugins.proj deleted file mode 100644 index a11ff9c3..00000000 --- a/Plugins/WindowTranslator.Plugins.proj +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj b/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj index db4086ad..d168a920 100644 --- a/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj +++ b/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj @@ -4,6 +4,7 @@ net10.0;net10.0-windows10.0.20348.0 WindowTranslator true + false wt.png README.md true diff --git a/WindowTranslator.Packages.proj b/WindowTranslator.Packages.proj new file mode 100644 index 00000000..65798a99 --- /dev/null +++ b/WindowTranslator.Packages.proj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj similarity index 65% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj index f4f0a345..33cb0650 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj +++ b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj @@ -1,6 +1,9 @@  net10.0-windows10.0.20348.0 + true + false + false @@ -15,8 +18,8 @@ - - + + diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg diff --git a/WindowTranslator.slnx b/WindowTranslator.slnx index 4756c92a..67a29b21 100644 --- a/WindowTranslator.slnx +++ b/WindowTranslator.slnx @@ -10,6 +10,7 @@ + @@ -25,9 +26,7 @@ - - @@ -42,6 +41,7 @@ + From be312a0c1797fc43847e687f96dad45a5c9fd54b Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 17:37:03 +0900 Subject: [PATCH 34/43] =?UTF-8?q?=E9=9B=86=E7=B4=84=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AB=E3=82=88=E3=82=8B?= =?UTF-8?q?=E5=BE=A9=E5=85=83=E7=AB=B6=E5=90=88=E3=82=92=E5=9B=9E=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dotnet-desktop.yml | 2 +- .github/workflows/dotnet-package.yml | 2 +- .../WindowTranslator.Plugins.proj | 6 +++--- WindowTranslator.slnx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename WindowTranslator.Packages.proj => Plugins/WindowTranslator.Plugins.proj (73%) diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 093f16fb..24eaf959 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -134,7 +134,7 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - dotnet msbuild WindowTranslator.Packages.proj -target:PublishAppBundle ` + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PublishAppBundle ` -p:Configuration=Release ` -p:AppBundleOutputPath="$PWD\publish\plugins" ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index 7c512ac6..a0dacd41 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -33,7 +33,7 @@ jobs: with: cuda: '12.9.0' - run: | - dotnet msbuild WindowTranslator.Packages.proj -target:Pack ` + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:Pack ` -p:Configuration=Release ` -p:PackageOutputPath="$PWD\pack" ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` diff --git a/WindowTranslator.Packages.proj b/Plugins/WindowTranslator.Plugins.proj similarity index 73% rename from WindowTranslator.Packages.proj rename to Plugins/WindowTranslator.Plugins.proj index 65798a99..f6ac1589 100644 --- a/WindowTranslator.Packages.proj +++ b/Plugins/WindowTranslator.Plugins.proj @@ -1,8 +1,8 @@ - - - + + + diff --git a/WindowTranslator.slnx b/WindowTranslator.slnx index 67a29b21..2f4bab73 100644 --- a/WindowTranslator.slnx +++ b/WindowTranslator.slnx @@ -10,7 +10,6 @@ - @@ -26,6 +25,7 @@ + From cdaad3ee0465607da7e9e50470d58c486748e2b5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 17:46:33 +0900 Subject: [PATCH 35/43] =?UTF-8?q?=E9=85=8D=E5=B8=83=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=81=AE=E5=BE=A9=E5=85=83?= =?UTF-8?q?=E7=AB=B6=E5=90=88=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/WindowTranslator.Plugins.proj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Plugins/WindowTranslator.Plugins.proj b/Plugins/WindowTranslator.Plugins.proj index f6ac1589..62e6d85a 100644 --- a/Plugins/WindowTranslator.Plugins.proj +++ b/Plugins/WindowTranslator.Plugins.proj @@ -8,8 +8,7 @@ + Targets="Restore" /> From 03aee766cf5d43f40fafcb7c607f772da5bc9380 Mon Sep 17 00:00:00 2001 From: Freesia Date: Fri, 14 Aug 2026 20:41:32 +0900 Subject: [PATCH 36/43] =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=81=AE=E3=83=90?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=89=E5=87=A6=E7=90=86=E3=82=92=E5=85=83?= =?UTF-8?q?=E3=81=AE=E5=AE=9F=E8=A3=85=E3=81=AB=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserSettingsConfigurationTests.cs | 71 ---------------- WindowTranslator/Program.cs | 81 ++----------------- 2 files changed, 6 insertions(+), 146 deletions(-) delete mode 100644 WindowTranslator.Tests/UserSettingsConfigurationTests.cs diff --git a/WindowTranslator.Tests/UserSettingsConfigurationTests.cs b/WindowTranslator.Tests/UserSettingsConfigurationTests.cs deleted file mode 100644 index b599fe26..00000000 --- a/WindowTranslator.Tests/UserSettingsConfigurationTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using WindowTranslator.Modules; -using WindowTranslator.Stores; - -namespace WindowTranslator.Tests; - -public sealed class UserSettingsConfigurationTests -{ - [Fact] - public void UserSettingsIgnoresPluginParametersThatHaveNoLoadedType() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Common:ViewMode"] = nameof(ViewMode.Capture), - ["Common:HidePluginStoreDisclaimer"] = "true", - ["Targets::Font"] = "Default Font", - ["Targets:game:Font"] = "Test Font", - ["Targets:game:SelectedPlugins:ITranslateModule"] = "MissingTranslator", - ["Targets:game:PluginParams:MissingOptions:ApiKey"] = "secret", - }) - .Build(); - var settings = new UserSettings(); - - new global::ConfigureUserSettings(configuration).Configure(settings); - - Assert.Equal(ViewMode.Capture, settings.Common.ViewMode); - Assert.True(settings.Common.HidePluginStoreDisclaimer); - Assert.Equal("Default Font", settings.Targets[string.Empty].Font); - var target = settings.Targets["game"]; - Assert.Equal("Test Font", target.Font); - Assert.Equal( - "MissingTranslator", - target.SelectedPlugins[nameof(ITranslateModule)]); - Assert.Empty(target.PluginParams); - } - - [Fact] - public void InvalidLoadedPluginParameterIsIgnoredWithoutChangingDefaults() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Targets:game:PluginParams:InvalidPluginParam:RetryCount"] = - "not-an-integer", - }) - .Build(); - var options = new InvalidPluginParam(); - var configure = new global::ConfigurePluginParam( - configuration, - new TestProcessInfoStore("game"), - NullLogger>.Instance); - - configure.Configure(options); - - Assert.Equal(7, options.RetryCount); - } - - public sealed class InvalidPluginParam : IPluginParam - { - public int RetryCount { get; set; } = 7; - } - - private sealed class TestProcessInfoStore(string name) : IProcessInfoStore - { - public IntPtr MainWindowHandle => IntPtr.Zero; - - public string Name { get; } = name; - } -} diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index 8b27fdc3..5b3fcc32 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -196,7 +196,7 @@ AppInfo.Instance.Version.Major)) .AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddTransient(); -builder.Services.AddTransient, ConfigureUserSettings>(); +builder.Services.Configure(builder.Configuration, op => op.ErrorOnUnknownConfiguration = false); builder.Services.Configure(builder.Configuration.GetSection(nameof(UserSettings.Common))); builder.Services.AddTransient(typeof(IConfigureNamedOptions<>), typeof(ConfigurePluginParam<>)); builder.Services.AddTransient(typeof(IConfigureOptions<>), typeof(ConfigurePluginParam<>)); @@ -264,23 +264,11 @@ static string GetPluginName(PluginNameOptions options, Type type) } } -class ConfigureUserSettings(IConfiguration configuration) : IConfigureOptions -{ - private readonly IConfiguration configuration = configuration; - - public void Configure(UserSettings options) - => PluginParameterIgnoringConfigurationBinder.Bind(this.configuration, options); -} - -class ConfigurePluginParam( - IConfiguration configuration, - IProcessInfoStore store, - ILogger> logger) : IConfigureNamedOptions +class ConfigurePluginParam(IConfiguration configuration, IProcessInfoStore store) : IConfigureNamedOptions where TOptions : class, IPluginParam { private readonly IConfiguration configuration = configuration.GetSection(nameof(UserSettings.Targets)); private readonly IProcessInfoStore store = store; - private readonly ILogger> logger = logger; public void Configure(TOptions options) { @@ -289,7 +277,7 @@ public void Configure(TOptions options) { section = this.configuration.GetSection(Options.DefaultName); } - this.BindParameter(section, options); + GetTargetSection(section, typeof(TOptions).Name).Bind(options); } public void Configure(string? name, TOptions options) @@ -300,46 +288,7 @@ public void Configure(string? name, TOptions options) { section = this.configuration.GetSection(Options.DefaultName); } - this.BindParameter(section, options); - } - - private void BindParameter(IConfigurationSection targetSection, TOptions options) - { - var parameterSection = GetTargetSection(targetSection, typeof(TOptions).Name); - if (!parameterSection.Exists()) - { - return; - } - - try - { - var configured = parameterSection.Get(); - if (configured is null) - { - return; - } - - foreach (var property in typeof(TOptions).GetProperties(BindingFlags.Instance | BindingFlags.Public)) - { - if (property.CanRead - && property.CanWrite - && property.GetIndexParameters().Length == 0) - { - property.SetValue(options, property.GetValue(configured)); - } - } - } - catch (Exception ex) when (ex is InvalidOperationException - or FormatException - or NotSupportedException - or MissingMethodException - or ArgumentException - or TargetInvocationException) - { - this.logger.LogWarning( - "プラグインパラメータ {ParameterType} を読み込めないため無視します。", - typeof(TOptions).Name); - } + GetTargetSection(section, typeof(TOptions).Name).Bind(options); } private static IConfigurationSection GetTargetSection(IConfigurationSection section, string name) @@ -367,7 +316,7 @@ public void Configure(TargetSettings options) { section = this.configuration.GetSection(Options.DefaultName); } - PluginParameterIgnoringConfigurationBinder.Bind(section, options); + section.Bind(options); } public void Configure(string? name, TargetSettings options) @@ -378,25 +327,7 @@ public void Configure(string? name, TargetSettings options) { section = this.configuration.GetSection(Options.DefaultName); } - PluginParameterIgnoringConfigurationBinder.Bind(section, options); - } -} - -static class PluginParameterIgnoringConfigurationBinder -{ - public static void Bind(IConfiguration configuration, object options) - { - var values = configuration - .AsEnumerable(makePathsRelative: true) - .Where(value => !value.Key - .Split(ConfigurationPath.KeyDelimiter, StringSplitOptions.None) - .Contains( - nameof(TargetSettings.PluginParams), - StringComparer.OrdinalIgnoreCase)); - var filteredConfiguration = new ConfigurationBuilder() - .AddInMemoryCollection(values) - .Build(); - filteredConfiguration.Bind(options); + section.Bind(options); } } From fbf8c31470417752e15bd2e98f3ef40e73bf10f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:20:49 +0000 Subject: [PATCH 37/43] =?UTF-8?q?CUDA=E3=83=84=E3=83=BC=E3=83=AB=E3=82=AD?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=B9=E3=83=86=E3=83=83=E3=83=97=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4:=20PLamoPlugin=E3=81=AF=E5=90=8C=E6=A2=B1?= =?UTF-8?q?=E5=AF=BE=E8=B1=A1=E5=A4=96=E3=81=AE=E3=81=9F=E3=82=81=E4=B8=8D?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .github/workflows/dotnet-desktop.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index d78fa8d1..24eaf959 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -125,9 +125,6 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 - - uses: Jimver/cuda-toolkit@v0.2.36 - with: - cuda: '12.9.0' - run: | dotnet publish WindowTranslator -c Release -o publish --sc ${{ matrix.self }} ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` From ce46f8fb7d5d094cb7992445eb67d5c6f6608fca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:48:59 +0000 Subject: [PATCH 38/43] =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8=E7=AB=B6?= =?UTF-8?q?=E5=90=88=E3=81=AE=E8=AA=A4=E8=A7=A3=E6=B1=BA=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3:=20CommonSettings.IsEnableAutoTarget=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- WindowTranslator.Abstractions/UserSettings.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index e043ead0..bf6f20db 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -41,11 +41,6 @@ public class CommonSettings /// public bool IsOverlayPointSwap { get; set; } - /// - /// 自動的に翻訳を発動するか - /// - public bool IsEnableAutoTarget { get; set; } - /// /// プラグインストアの免責事項を非表示にするか /// From 699641b402c2fd0451c3bccd40b5288a876a1df3 Mon Sep 17 00:00:00 2001 From: Freesia Date: Thu, 20 Aug 2026 23:21:44 +0900 Subject: [PATCH 39/43] =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=83=88=E3=82=A2=E3=81=AE=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=BC=E3=83=AB=E5=BE=8C=E5=8B=95=E4=BD=9C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WindowTranslator.Abstractions/UserSettings.cs | 5 -- .../NuGetPluginServiceTests.cs | 56 +++++++++++- WindowTranslator/ApplicationRestart.cs | 3 +- .../PluginStore/NuGetPackageInstaller.cs | 13 ++- .../Modules/PluginStore/NuGetPluginService.cs | 87 ++++++++++++++++--- .../PluginStore/PluginStoreViewModel.cs | 32 ++++++- .../Modules/Settings/AllSettingsDialog.xaml | 5 ++ .../Modules/Settings/AllSettingsViewModel.cs | 2 - .../Properties/Resources.Designer.cs | 5 ++ WindowTranslator/Properties/Resources.ar.resx | 3 + WindowTranslator/Properties/Resources.cs.resx | 3 + WindowTranslator/Properties/Resources.de.resx | 3 + WindowTranslator/Properties/Resources.en.resx | 3 + WindowTranslator/Properties/Resources.es.resx | 3 + WindowTranslator/Properties/Resources.fa.resx | 3 + .../Properties/Resources.fil.resx | 3 + WindowTranslator/Properties/Resources.fr.resx | 3 + WindowTranslator/Properties/Resources.hi.resx | 3 + WindowTranslator/Properties/Resources.hu.resx | 3 + WindowTranslator/Properties/Resources.id.resx | 3 + WindowTranslator/Properties/Resources.ko.resx | 3 + WindowTranslator/Properties/Resources.ms.resx | 3 + WindowTranslator/Properties/Resources.pl.resx | 3 + .../Properties/Resources.pt-BR.resx | 3 + WindowTranslator/Properties/Resources.resx | 3 + WindowTranslator/Properties/Resources.ru.resx | 3 + WindowTranslator/Properties/Resources.th.resx | 3 + WindowTranslator/Properties/Resources.tr.resx | 3 + WindowTranslator/Properties/Resources.vi.resx | 3 + .../Properties/Resources.zh-CN.resx | 3 + .../Properties/Resources.zh-TW.resx | 3 + 31 files changed, 246 insertions(+), 28 deletions(-) diff --git a/WindowTranslator.Abstractions/UserSettings.cs b/WindowTranslator.Abstractions/UserSettings.cs index bf6f20db..ee018f1a 100644 --- a/WindowTranslator.Abstractions/UserSettings.cs +++ b/WindowTranslator.Abstractions/UserSettings.cs @@ -40,11 +40,6 @@ public class CommonSettings /// オーバレイのポインター挙動を逆にするか /// public bool IsOverlayPointSwap { get; set; } - - /// - /// プラグインストアの免責事項を非表示にするか - /// - public bool HidePluginStoreDisclaimer { get; set; } } /// diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index cf5afba4..d0cfe103 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -80,8 +80,9 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories })); using var service = CreateService(handler, testDirectory, hostMajorVersion: 7); + var progress = new RecordingProgress(); - await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + await service.InstallPackageAsync("Root.Plugin", "1.0.0", progress); var pluginDirectory = Path.Combine(testDirectory, "Root.Plugin"); Assert.Equal("root", await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Root.Plugin.dll"))); @@ -120,6 +121,11 @@ await File.ReadAllTextAsync(Path.Combine( Assert.Equal("1.0.0", package.Version); Assert.Equal(7, package.HostMajorVersion); Assert.True(package.IsCompatible); + Assert.Equal(0, progress.Values.First()); + Assert.Equal(100, progress.Values.Last()); + Assert.All(progress.Values, value => Assert.InRange(value, 0, 100)); + Assert.True(progress.Values.SequenceEqual(progress.Values.OrderBy(value => value))); + Assert.Contains(progress.Values, value => value is > 0 and < 100); } finally { @@ -749,6 +755,47 @@ public async Task SearchReturnsReleaseAndPrereleaseVersions() } } + [Fact] + public async Task DisclaimerPreferenceIsStoredInThePluginManifestAndPreservedByPluginOperations() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using (var service = CreateService(handler, testDirectory)) + { + await service.SetHideDisclaimerAsync(true); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + await service.UninstallPackageAsync("Root.Plugin"); + } + + using (var service = CreateService(handler, testDirectory)) + { + Assert.True(service.HideDisclaimer); + await service.SetHideDisclaimerAsync(false); + } + + using var reloadedService = CreateService(handler, testDirectory); + Assert.False(reloadedService.HideDisclaimer); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + [Fact] public async Task SearchUsesNuGetOwnersForOfficialPackageStatus() { @@ -1800,6 +1847,13 @@ private static NuGetPluginService CreateService( hostPackageVersions ?? NuGetPluginService.CreateHostPackageVersions(), hostMajorVersion ?? AppInfo.Instance.Version.Major); + private sealed class RecordingProgress : IProgress + { + public List Values { get; } = []; + + public void Report(double value) => this.Values.Add(value); + } + private static TestPackageVersion CreatePluginVersionMetadata( string version, string? abstractionsRange = null, diff --git a/WindowTranslator/ApplicationRestart.cs b/WindowTranslator/ApplicationRestart.cs index 5024ad09..1dcaf38f 100644 --- a/WindowTranslator/ApplicationRestart.cs +++ b/WindowTranslator/ApplicationRestart.cs @@ -7,7 +7,6 @@ namespace WindowTranslator; internal static class ApplicationRestart { internal const string RestartProcessIdArgument = "--windowtranslator-restart-pid"; - private static readonly TimeSpan PreviousProcessWaitTimeout = TimeSpan.FromSeconds(30); public static void Restart() { @@ -38,7 +37,7 @@ public static string[] WaitForPreviousProcess(IEnumerable arguments) try { using var process = Process.GetProcessById(processId.Value); - _ = process.WaitForExit(PreviousProcessWaitTimeout); + process.WaitForExit(); } catch (ArgumentException) { diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 6379213b..3dc1a068 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -44,6 +44,7 @@ public async Task InstallAsync( "WindowTranslatorPlugins", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDirectory); + progress?.Report(0); try { @@ -53,15 +54,19 @@ public async Task InstallAsync( workDirectory, progress, cancellationToken).ConfigureAwait(false); + progress?.Report(60); Directory.CreateDirectory(destinationDirectory); - foreach (var artifact in artifacts.OrderByDescending(a => - a.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))) + var orderedArtifacts = artifacts.OrderByDescending(a => + a.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)).ToArray(); + for (var index = 0; index < orderedArtifacts.Length; index++) { + var artifact = orderedArtifacts[index]; ExtractPackageAssets( artifact.PackagePath, destinationDirectory, requirePluginAssembly: artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + progress?.Report(60 + (30d * (index + 1) / orderedArtifacts.Length)); } var rootPackage = artifacts.First(artifact => @@ -310,7 +315,7 @@ private async Task DownloadPackageAsync( CancellationToken cancellationToken) { this.logger.LogInformation("NuGetパッケージをダウンロード中: {PackageId} {Version}", packageId, version); - progress?.Report(0); + progress?.Report(10); await using var destination = File.Create(destinationPath); var copied = await this.packageResource.CopyNupkgToStreamAsync( packageId, @@ -323,7 +328,7 @@ private async Task DownloadPackageAsync( { throw new InvalidOperationException($"NuGetパッケージを取得できませんでした: {packageId} {version}"); } - progress?.Report(1); + progress?.Report(50); } private static PackageMetadata ReadPackageMetadata(string packagePath) diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs index ac0e1fb3..ac252097 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -48,6 +48,8 @@ public sealed class NuGetPluginService : BackgroundService private readonly AsyncSemaphore refreshLock = new(1); private readonly object snapshotLock = new(); private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; + private bool hideDisclaimer; + private int restartRequired; private int disposed; internal NuGetPluginService( @@ -65,10 +67,15 @@ internal NuGetPluginService( this.manifestPath = Path.Combine(this.nugetPluginsDir, "nuget-manifest.json"); this.hostPackageVersions = hostPackageVersions; this.hostMajorVersion = hostMajorVersion; + this.hideDisclaimer = TryLoadHideDisclaimer(this.manifestPath, this.logger); } internal event EventHandler? PackageInformationUpdated; + internal bool HideDisclaimer => Volatile.Read(ref this.hideDisclaimer); + + internal bool IsRestartRequired => Volatile.Read(ref this.restartRequired) != 0; + internal PluginStoreSnapshot PackageSnapshot { get @@ -268,20 +275,27 @@ public async Task InstallPackageAsync(string packageId, string version, IProgres Directory.Move(pluginOperation.TargetPath, pluginOperation.BackupPath); } Directory.Move(pluginOperation.WorkingPath, pluginOperation.TargetPath); + progress?.Report(95); - var updatedManifest = new InstalledManifest( - [ - .. currentManifest.Packages.Where(package => - !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)), - new( - packageId, - version, - this.hostMajorVersion, - abstractionsVersionRange.ToString()), - ]); + var updatedManifest = currentManifest with + { + Packages = + [ + .. currentManifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)), + new( + packageId, + version, + this.hostMajorVersion, + abstractionsVersionRange.ToString()), + ], + HideDisclaimer = this.HideDisclaimer, + }; await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); pluginOperation.Commit(); UpdateInstalledPackages(updatedManifest.Packages); + Volatile.Write(ref this.restartRequired, 1); + progress?.Report(100); this.logger.LogInformation( "パッケージのインストール完了: {PackageId} {Version} -> {TargetDir}", @@ -299,16 +313,39 @@ public async Task UninstallPackageAsync(string packageId, CancellationToken canc using var operation = await this.operationLock.EnterAsync(cancellationToken); this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); - var updatedManifest = new InstalledManifest([.. manifest.Packages.Where(package => - !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))]); + var updatedManifest = manifest with + { + Packages = [.. manifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))], + HideDisclaimer = this.HideDisclaimer, + }; await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); UpdateInstalledPackages(updatedManifest.Packages); + Volatile.Write(ref this.restartRequired, 1); this.logger.LogInformation( "パッケージ {PackageId} をアンインストール対象として記録しました。管理フォルダは次回起動時に削除されます。", packageId); } + internal async Task SetHideDisclaimerAsync( + bool value, + CancellationToken cancellationToken = default) + { + Volatile.Write(ref this.hideDisclaimer, value); + using var operation = await this.operationLock.EnterAsync(cancellationToken); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var currentValue = this.HideDisclaimer; + if (manifest.HideDisclaimer == currentValue) + { + return; + } + + await SaveManifestAsync( + manifest with { HideDisclaimer = currentValue }, + cancellationToken).ConfigureAwait(false); + } + private async Task CreateCompatiblePackageInfoAsync( IPackageSearchMetadata data, PackageMetadataResource metadataResource, @@ -480,6 +517,28 @@ private async Task LoadManifestAsync(CancellationToken cancel private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) => SaveManifestAsync(this.manifestPath, manifest, cancellationToken); + private static bool TryLoadHideDisclaimer( + string manifestPath, + ILogger logger) + { + if (!File.Exists(manifestPath)) + { + return false; + } + + try + { + using var stream = File.OpenRead(manifestPath); + return JsonSerializer.Deserialize(stream, ManifestJsonOptions) + ?.HideDisclaimer ?? false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + logger.LogWarning(ex, "プラグインマニフェストから免責事項の表示設定を読み込めませんでした。"); + return false; + } + } + internal static async Task SaveManifestAsync( string manifestPath, InstalledManifest manifest, @@ -541,7 +600,9 @@ public record InstalledPackageInfo( } /// NuGetプラグインの管理マニフェスト -public record InstalledManifest(List Packages); +public record InstalledManifest( + List Packages, + bool HideDisclaimer = false); internal sealed record PluginStoreSnapshot( IReadOnlyList InstalledPackages, diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index cc2e9d07..5c69b669 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -33,6 +33,9 @@ public partial class PluginStoreViewModel : ObservableObject, IDisposable [ObservableProperty] private bool hideDisclaimer; + [ObservableProperty] + private bool requiresRestart; + public bool HasError => this.ErrorMessage is not null; public PluginPackageViewModel? SelectedPackage @@ -74,10 +77,27 @@ public PluginStoreViewModel( this.nugetService = nugetService; this.logger = logger; this.dialogService = dialogService; + this.hideDisclaimer = this.nugetService.HideDisclaimer; + this.requiresRestart = this.nugetService.IsRestartRequired; this.nugetService.PackageInformationUpdated += OnPackageInformationUpdated; ApplyPackageSnapshot(this.nugetService.PackageSnapshot); } + partial void OnHideDisclaimerChanged(bool value) + => _ = SaveHideDisclaimerAsync(value); + + private async Task SaveHideDisclaimerAsync(bool value) + { + try + { + await this.nugetService.SetHideDisclaimerAsync(value).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインストアの免責事項の表示設定を保存できませんでした。"); + } + } + private void OnPackageInformationUpdated(object? sender, EventArgs e) { if (this.disposed) @@ -200,6 +220,7 @@ public async Task InstallAsync( return; } + package.InstallProgress = 0; package.IsInstalling = true; try { @@ -214,7 +235,7 @@ await this.nugetService.InstallPackageAsync( package.IsInstalled = true; package.InstalledVersion = version; package.IsCompatible = true; - package.InstallProgress = 0; + this.RequiresRestart = this.nugetService.IsRestartRequired; this.logger.LogInformation("プラグインのインストール完了: {PackageId}", package.Id); @@ -266,6 +287,7 @@ public async Task UninstallAsync(PluginPackageViewModel package) package.IsInstalled = false; package.InstalledVersion = null; package.IsCompatible = true; + this.RequiresRestart = this.nugetService.IsRestartRequired; await ShowRestartDialogAsync(Resources.Uninstall).ConfigureAwait(true); } @@ -297,10 +319,18 @@ private async Task ShowRestartDialogAsync( }, cancellationToken).ConfigureAwait(true); if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) { + await SaveHideDisclaimerAsync(this.HideDisclaimer).ConfigureAwait(true); ApplicationRestart.Restart(); } } + [RelayCommand] + private async Task RestartAsync() + { + await SaveHideDisclaimerAsync(this.HideDisclaimer).ConfigureAwait(true); + ApplicationRestart.Restart(); + } + private void OnSelectedPackagePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (sender is PluginPackageViewModel package diff --git a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml index 8fdc62c8..26ecc0c0 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml +++ b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml @@ -204,6 +204,11 @@ + t.Name, t => new TargetSettings() { diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 9412f83f..19c708e9 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -502,6 +502,11 @@ internal Resources() { /// public static string RestartRequired => ResourceManager.GetString("RestartRequired", resourceCulture) ?? string.Empty; + /// + /// "プラグイン読み込み" に類似しているローカライズされた文字列を検索します。 + /// + public static string LoadPlugins => ResourceManager.GetString("LoadPlugins", resourceCulture) ?? string.Empty; + /// /// "後で" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 3289c809..b3fcbf59 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -492,6 +492,9 @@ يرجى إعادة تشغيل WindowTranslator لتطبيق تغييرات المكون الإضافي. + + تحميل المكونات الإضافية + صفحة المشروع diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index a2b5c537..3dfebb41 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -382,6 +382,9 @@ Monitory nejsou podporovány. Restartujte WindowTranslator, aby se změny pluginu projevily. + + Načíst pluginy + Stránka projektu diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index fc93ef57..e755ead8 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -501,6 +501,9 @@ Monitore werden nicht unterstützt. Bitte starten Sie WindowTranslator neu, um Plugin-Änderungen anzuwenden. + + Plugins laden + Projektseite diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 2e0b6e25..4155d462 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -501,6 +501,9 @@ Monitors are not supported. Please restart WindowTranslator to apply plugin changes. + + Load plugins + Project page diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index ffc6dac3..a0049401 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -492,6 +492,9 @@ Reinicie WindowTranslator para aplicar los cambios del plugin. + + Cargar plugins + Página del proyecto diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index a3e2b349..ecae44cc 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -486,6 +486,9 @@ لطفاً WindowTranslator را مجدداً راه‌اندازی کنید تا تغییرات افزونه اعمال شود. + + بارگذاری افزونه‌ها + صفحه پروژه diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index 5f30222c..c146ae50 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -501,6 +501,9 @@ Ang monitor ay hindi suportado. Mangyaring i-restart ang WindowTranslator upang mailapat ang mga pagbabago sa plugin. + + I-load ang mga plugin + Pahina ng proyekto diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index eb9ec3a0..69cc698b 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -492,6 +492,9 @@ Veuillez redémarrer WindowTranslator pour appliquer les modifications de plugin. + + Charger les plugins + Page du projet diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index d1b2c055..a5ac8e82 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -494,6 +494,9 @@ प्लगइन परिवर्तन लागू करने के लिए कृपया WindowTranslator को पुनः आरंभ करें। + + प्लगइन लोड करें + प्रोजेक्ट पेज diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 81daa280..f1976c1e 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -382,6 +382,9 @@ A monitorok nem támogatottak. A bővítménymódosítások alkalmazásához indítsa újra a WindowTranslator alkalmazást. + + Bővítmények betöltése + Projektoldal diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index b156d6ff..081b2da6 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -500,6 +500,9 @@ Monitor tidak didukung. Silakan restart WindowTranslator untuk menerapkan perubahan plugin. + + Muat plugin + Halaman proyek diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index a4fd69d5..e20a9a67 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -501,6 +501,9 @@ 플러그인 변경 사항을 적용하려면 WindowTranslator를 다시 시작하세요. + + 플러그인 불러오기 + 프로젝트 페이지 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 1c808bba..efcbf684 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -500,6 +500,9 @@ Monitor tidak disokong. Sila mulakan semula WindowTranslator untuk menerapkan perubahan plugin. + + Muatkan plugin + Halaman projek diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index cf439427..f1702230 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -501,6 +501,9 @@ Monitory nie są obsługiwane. Uruchom ponownie WindowTranslator, aby zastosować zmiany wtyczki. + + Wczytaj wtyczki + Strona projektu diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index c9b811c4..3b1cb83d 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -500,6 +500,9 @@ Monitor tidak didukung. Reinicie o WindowTranslator para aplicar as alterações de plugin. + + Carregar plugins + Página do projeto diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 6a2961c9..f5479496 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -501,6 +501,9 @@ プラグインの変更を適用するには、WindowTranslatorを再起動してください。 + + プラグイン読み込み + プロジェクトページ diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index be912d52..5f40bad8 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -492,6 +492,9 @@ Перезапустите WindowTranslator для применения изменений плагина. + + Загрузить плагины + Страница проекта diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index df777cf0..6c58964b 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -501,6 +501,9 @@ กรุณาเริ่ม WindowTranslator ใหม่เพื่อนำการเปลี่ยนแปลงปลั๊กอินไปใช้ + + โหลดปลั๊กอิน + หน้าโครงการ diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 6979bcb0..81bb3ffe 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -501,6 +501,9 @@ Monitör desteklenmiyor. Eklenti değişikliklerini uygulamak için lütfen WindowTranslator'ı yeniden başlatın. + + Eklentileri yükle + Proje sayfası diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index d5767ca9..3ed83e6f 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -501,6 +501,9 @@ Màn hình không được hỗ trợ. Vui lòng khởi động lại WindowTranslator để áp dụng thay đổi plugin. + + Tải plugin + Trang dự án diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index c9fdc0b9..abc5d153 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -501,6 +501,9 @@ 请重启 WindowTranslator 以应用插件更改。 + + 加载插件 + 项目页面 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 152d9978..63090dab 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -501,6 +501,9 @@ 請重新啟動 WindowTranslator 以套用外掛程式變更。 + + 載入外掛程式 + 專案頁面 From c47cbb0a489f91a56b7a59533d7dfcc9a21bbd61 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 23 Aug 2026 19:30:18 +0900 Subject: [PATCH 40/43] =?UTF-8?q?=E3=83=87=E3=83=90=E3=83=83=E3=82=B0?= =?UTF-8?q?=E6=99=82=E3=81=AE=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E3=81=A8=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E6=96=87=E8=A8=80=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/PluginStore/NuGetPluginCatalog.cs | 14 ++++++++++++++ .../Modules/PluginStore/PluginStoreViewModel.cs | 2 +- .../Modules/Settings/AllSettingsDialog.xaml | 2 +- WindowTranslator/Properties/Resources.Designer.cs | 4 ++-- WindowTranslator/Properties/Resources.ar.resx | 4 ++-- WindowTranslator/Properties/Resources.cs.resx | 4 ++-- WindowTranslator/Properties/Resources.de.resx | 4 ++-- WindowTranslator/Properties/Resources.en.resx | 4 ++-- WindowTranslator/Properties/Resources.es.resx | 4 ++-- WindowTranslator/Properties/Resources.fa.resx | 4 ++-- WindowTranslator/Properties/Resources.fil.resx | 4 ++-- WindowTranslator/Properties/Resources.fr.resx | 4 ++-- WindowTranslator/Properties/Resources.hi.resx | 4 ++-- WindowTranslator/Properties/Resources.hu.resx | 4 ++-- WindowTranslator/Properties/Resources.id.resx | 4 ++-- WindowTranslator/Properties/Resources.ko.resx | 4 ++-- WindowTranslator/Properties/Resources.ms.resx | 4 ++-- WindowTranslator/Properties/Resources.pl.resx | 4 ++-- WindowTranslator/Properties/Resources.pt-BR.resx | 4 ++-- WindowTranslator/Properties/Resources.resx | 4 ++-- WindowTranslator/Properties/Resources.ru.resx | 4 ++-- WindowTranslator/Properties/Resources.th.resx | 4 ++-- WindowTranslator/Properties/Resources.tr.resx | 4 ++-- WindowTranslator/Properties/Resources.vi.resx | 4 ++-- WindowTranslator/Properties/Resources.zh-CN.resx | 4 ++-- WindowTranslator/Properties/Resources.zh-TW.resx | 4 ++-- 26 files changed, 62 insertions(+), 48 deletions(-) diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs index 240241dc..ef31aa1e 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -19,6 +19,20 @@ public sealed class NuGetPluginCatalog : IPluginCatalog private static readonly string DefaultTempDir = Path.Combine(Path.GetTempPath(), "WindowTranslator", "nuget-plugins"); + static NuGetPluginCatalog() + { +#if DISABLE_PLUGIN_COMPATIBILITY_VALIDATION + var hostAbstractions = typeof(UserSettings).Assembly; + AssemblyLoadContext.Default.Resolving += (_, assemblyName) => + string.Equals( + assemblyName.Name, + hostAbstractions.GetName().Name, + StringComparison.OrdinalIgnoreCase) + ? hostAbstractions + : null; +#endif + } + private readonly string sourceDir; private readonly string tempDir; private readonly int hostMajorVersion; diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs index 5c69b669..50513d89 100644 --- a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -315,7 +315,7 @@ private async Task ShowRestartDialogAsync( Title = title, Content = Resources.RestartRequired, PrimaryButtonText = Resources.RestartNow, - CloseButtonText = Resources.Close, + CloseButtonText = Resources.ReviewLater, }, cancellationToken).ConfigureAwait(true); if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) { diff --git a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml index 26ecc0c0..70a9b153 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml +++ b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml @@ -206,7 +206,7 @@ ResourceManager.GetString("RestartRequired", resourceCulture) ?? string.Empty; /// - /// "プラグイン読み込み" に類似しているローカライズされた文字列を検索します。 + /// "再起動" に類似しているローカライズされた文字列を検索します。 /// - public static string LoadPlugins => ResourceManager.GetString("LoadPlugins", resourceCulture) ?? string.Empty; + public static string Restart => ResourceManager.GetString("Restart", resourceCulture) ?? string.Empty; /// /// "後で" に類似しているローカライズされた文字列を検索します。 diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index b3fcbf59..b042929a 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -492,8 +492,8 @@ يرجى إعادة تشغيل WindowTranslator لتطبيق تغييرات المكون الإضافي. - - تحميل المكونات الإضافية + + إعادة التشغيل صفحة المشروع diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 3dfebb41..9886e4b4 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -382,8 +382,8 @@ Monitory nejsou podporovány. Restartujte WindowTranslator, aby se změny pluginu projevily. - - Načíst pluginy + + Restartovat Stránka projektu diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index e755ead8..6e270c9c 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -501,8 +501,8 @@ Monitore werden nicht unterstützt. Bitte starten Sie WindowTranslator neu, um Plugin-Änderungen anzuwenden. - - Plugins laden + + Neu starten Projektseite diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index 4155d462..d3e76d5e 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -501,8 +501,8 @@ Monitors are not supported. Please restart WindowTranslator to apply plugin changes. - - Load plugins + + Restart Project page diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index a0049401..b85f99a1 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -492,8 +492,8 @@ Reinicie WindowTranslator para aplicar los cambios del plugin. - - Cargar plugins + + Reiniciar Página del proyecto diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index ecae44cc..13f059d9 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -486,8 +486,8 @@ لطفاً WindowTranslator را مجدداً راه‌اندازی کنید تا تغییرات افزونه اعمال شود. - - بارگذاری افزونه‌ها + + راه‌اندازی مجدد صفحه پروژه diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index c146ae50..8abb2184 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -501,8 +501,8 @@ Ang monitor ay hindi suportado. Mangyaring i-restart ang WindowTranslator upang mailapat ang mga pagbabago sa plugin. - - I-load ang mga plugin + + I-restart Pahina ng proyekto diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 69cc698b..d6b28f98 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -492,8 +492,8 @@ Veuillez redémarrer WindowTranslator pour appliquer les modifications de plugin. - - Charger les plugins + + Redémarrer Page du projet diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index a5ac8e82..c69e2f92 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -494,8 +494,8 @@ प्लगइन परिवर्तन लागू करने के लिए कृपया WindowTranslator को पुनः आरंभ करें। - - प्लगइन लोड करें + + पुनः आरंभ करें प्रोजेक्ट पेज diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index f1976c1e..f8d7d132 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -382,8 +382,8 @@ A monitorok nem támogatottak. A bővítménymódosítások alkalmazásához indítsa újra a WindowTranslator alkalmazást. - - Bővítmények betöltése + + Újraindítás Projektoldal diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 081b2da6..8189ca16 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -500,8 +500,8 @@ Monitor tidak didukung. Silakan restart WindowTranslator untuk menerapkan perubahan plugin. - - Muat plugin + + Mulai ulang Halaman proyek diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index e20a9a67..6bc74f4f 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -501,8 +501,8 @@ 플러그인 변경 사항을 적용하려면 WindowTranslator를 다시 시작하세요. - - 플러그인 불러오기 + + 다시 시작 프로젝트 페이지 diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index efcbf684..5bd75f0b 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -500,8 +500,8 @@ Monitor tidak disokong. Sila mulakan semula WindowTranslator untuk menerapkan perubahan plugin. - - Muatkan plugin + + Mulakan semula Halaman projek diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index f1702230..aa01d8e1 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -501,8 +501,8 @@ Monitory nie są obsługiwane. Uruchom ponownie WindowTranslator, aby zastosować zmiany wtyczki. - - Wczytaj wtyczki + + Uruchom ponownie Strona projektu diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 3b1cb83d..a97ba41c 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -500,8 +500,8 @@ Monitor tidak didukung. Reinicie o WindowTranslator para aplicar as alterações de plugin. - - Carregar plugins + + Reiniciar Página do projeto diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index f5479496..7d3e346f 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -501,8 +501,8 @@ プラグインの変更を適用するには、WindowTranslatorを再起動してください。 - - プラグイン読み込み + + 再起動 プロジェクトページ diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 5f40bad8..01b5b10c 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -492,8 +492,8 @@ Перезапустите WindowTranslator для применения изменений плагина. - - Загрузить плагины + + Перезапустить Страница проекта diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 6c58964b..09655236 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -501,8 +501,8 @@ กรุณาเริ่ม WindowTranslator ใหม่เพื่อนำการเปลี่ยนแปลงปลั๊กอินไปใช้ - - โหลดปลั๊กอิน + + เริ่มใหม่ หน้าโครงการ diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index 81bb3ffe..68f78557 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -501,8 +501,8 @@ Monitör desteklenmiyor. Eklenti değişikliklerini uygulamak için lütfen WindowTranslator'ı yeniden başlatın. - - Eklentileri yükle + + Yeniden başlat Proje sayfası diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 3ed83e6f..773f02c0 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -501,8 +501,8 @@ Màn hình không được hỗ trợ. Vui lòng khởi động lại WindowTranslator để áp dụng thay đổi plugin. - - Tải plugin + + Khởi động lại Trang dự án diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index abc5d153..f2d61645 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -501,8 +501,8 @@ 请重启 WindowTranslator 以应用插件更改。 - - 加载插件 + + 重启 项目页面 diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 63090dab..cf7700ec 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -501,8 +501,8 @@ 請重新啟動 WindowTranslator 以套用外掛程式變更。 - - 載入外掛程式 + + 重新啟動 專案頁面 From 6a88a8811cbfbc2112ca4f0e9dac1320f356b28a Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 23 Aug 2026 23:11:03 +0900 Subject: [PATCH 41/43] =?UTF-8?q?NuGet=E3=83=A9=E3=83=B3=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=83=A0=E8=B3=87=E7=94=A3=E3=81=AE=E5=B1=95=E9=96=8B=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NuGetPluginServiceTests.cs | 29 +++++++++++++------ .../PluginStore/NuGetPackageInstaller.cs | 17 ++++++----- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs index d0cfe103..c6a0374d 100644 --- a/WindowTranslator.Tests/NuGetPluginServiceTests.cs +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -33,7 +33,7 @@ public sealed class NuGetPluginServiceTests private static readonly string RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier; [Fact] - public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories() + public async Task InstallResolvesDependenciesUsingNuGetRuntimeAssetLayout() { var testDirectory = CreateTestDirectory(); try @@ -53,7 +53,7 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories { ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), ["lib/net10.0/fr/Root.Plugin.resources.dll"] = "fr"u8.ToArray(), - [$"runtimes/{RuntimeIdentifier}/native/root-native.dll"] = "native"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/native/subdirectory/root-native.dll"] = "native"u8.ToArray(), [$"lib/net10.0/runtimes/{RuntimeIdentifier}/native/custom-native.dll"] = "custom"u8.ToArray(), })); handler.AddPackage( @@ -65,7 +65,9 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories [new("Transitive.Package", "[2.0.0]")], new Dictionary { - ["lib/net8.0/Dependency.Package.dll"] = "dependency"u8.ToArray(), + ["lib/net8.0/Dependency.Package.dll"] = "fallback"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/lib/net8.0/Dependency.Package.dll"] = + "dependency"u8.ToArray(), })); handler.AddPackage( "Transitive.Package", @@ -92,17 +94,26 @@ public async Task InstallResolvesRuntimeDependenciesAndPreservesAssetDirectories Assert.Equal( "dependency", await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Dependency.Package.dll"))); + Assert.False(File.Exists(Path.Combine( + pluginDirectory, + "runtimes", + RuntimeIdentifier, + "lib", + "net8.0", + "Dependency.Package.dll"))); Assert.Equal( "transitive", await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Transitive.Package.dll"))); Assert.Equal( "native", - await File.ReadAllTextAsync(Path.Combine( - pluginDirectory, - "runtimes", - RuntimeIdentifier, - "native", - "root-native.dll"))); + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "root-native.dll"))); + Assert.False(File.Exists(Path.Combine( + pluginDirectory, + "runtimes", + RuntimeIdentifier, + "native", + "subdirectory", + "root-native.dll"))); Assert.Equal( "custom", await File.ReadAllTextAsync(Path.Combine( diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs index 3dc1a068..6d83d90b 100644 --- a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -436,9 +436,9 @@ private static void ExtractPackageAssets( .Where(e => e.FullName.Split('/').Length >= 3) .GroupBy(e => e.FullName.Split('/')[1]) .ToArray(); - var hasPluginAssembly = false; + var hasPluginAssembly = ExtractRuntimeAssets(archive, destinationDirectory); - if (libGroups.Length > 0) + if (!hasPluginAssembly && libGroups.Length > 0) { var selectedFramework = SelectBestTfm(libGroups.Select(g => g.Key)); if (selectedFramework is not null) @@ -456,7 +456,6 @@ private static void ExtractPackageAssets( } } - hasPluginAssembly |= ExtractRuntimeAssets(archive, destinationDirectory); if (requirePluginAssembly && !hasPluginAssembly) { throw new InvalidOperationException("プラグインパッケージに互換性のあるアセンブリが見つかりませんでした。"); @@ -490,7 +489,7 @@ private static bool ExtractRuntimeAssets(ZipArchive archive, string destinationD e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); ExtractEntries( selectedEntries, - string.Empty, + $"{runtimeLibPrefix}{selectedFramework}/", destinationDirectory); } } @@ -508,19 +507,21 @@ private static bool ExtractRuntimeAssets(ZipArchive archive, string destinationD ExtractEntries( archive.Entries.Where(e => e.FullName.StartsWith(nativePrefix, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(e.Name)), - string.Empty, - destinationDirectory); + nativePrefix, + destinationDirectory, + flatten: true); return hasManagedAssembly; } private static void ExtractEntries( IEnumerable entries, string prefix, - string destinationDirectory) + string destinationDirectory, + bool flatten = false) { foreach (var entry in entries) { - var relativePath = entry.FullName[prefix.Length..]; + var relativePath = flatten ? entry.Name : entry.FullName[prefix.Length..]; if (string.IsNullOrWhiteSpace(relativePath)) { continue; From e6039cfeab2ef3815519c2241fad7fb7664b0df5 Mon Sep 17 00:00:00 2001 From: Freesia Date: Mon, 24 Aug 2026 00:54:43 +0900 Subject: [PATCH 42/43] =?UTF-8?q?GitHub=20Copilot=E3=81=AECLI=E3=82=92?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3=E3=81=B8=E5=90=8C?= =?UTF-8?q?=E6=A2=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowTranslator.Plugin.GitHubCopilotPlugin.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index db0736dd..c27723be 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -5,6 +5,7 @@ Translation for WindowTranslator using GitHub Copilot. true false + true From cc9f96f72f89b21beb004a2983f45017f49a5751 Mon Sep 17 00:00:00 2001 From: Freesia Date: Thu, 27 Aug 2026 01:18:57 +0900 Subject: [PATCH 43/43] =?UTF-8?q?TesseractOCR=E3=81=AE=E3=83=8D=E3=82=A4?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=96DLL=E3=82=92=E5=90=8C=E6=A2=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/Directory.Build.targets | 6 ++++++ .../WindowTranslator.Plugin.TesseractOCRPlugin.csproj | 1 + 2 files changed, 7 insertions(+) diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 3244b5a0..6a5a07fd 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -19,12 +19,18 @@ <_PluginWinX64RuntimeAsset Include="$(TargetDir)runtimes\win-x64\**\*" /> <_PluginWinRuntimeAsset Include="$(TargetDir)runtimes\win\**\*" /> <_PluginAnyRuntimeAsset Include="$(TargetDir)runtimes\any\**\*" /> + <_PluginX64RuntimeAsset Include="$(TargetDir)x64\**\*" /> + <_PluginX86RuntimeAsset Include="$(TargetDir)x86\**\*" /> + + diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index 89d62cfd..2cc64f80 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -6,6 +6,7 @@ OCR for WindowTranslator using the Tesseract engine. true false + true