diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02c09f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Everything2Everything Environment & Code Signing Configuration +# ================================================================ + +# [코드 사이닝 설정] +# 1) PFX 인증서 직접 지정 (권장: 상대경로나 절대경로 지정) +CODE_SIGN_PFX_PATH=packaging/Everything2Everything-DevCert.pfx +CODE_SIGN_PFX_PASSWORD=Everything2EverythingDev + +# 2) 또는 Windows 인증서 저장소의 Thumbprint 지정 (PFX_PATH 대신 사용 가능) +# CODE_SIGN_THUMBPRINT=CA8ED0C0DF087F9844275AEC5F056AD70CB67AE6 + +# 3) RFC 3161 타임스탬프 서버 URL (기본값: DigiCert) +TIMESTAMP_SERVER_URL=http://timestamp.digicert.com + +# [Forgejo / Git 릴리즈 배포 설정] +# FORGEJO_TOKEN=your_token_here diff --git a/packaging/BuildMsix.ps1 b/packaging/BuildMsix.ps1 index 4fe0e3e..f18484a 100644 --- a/packaging/BuildMsix.ps1 +++ b/packaging/BuildMsix.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 # MSIX 빌드 파이프라인. # 1) .NET App publish (framework-dependent) # 2) C++ Shell DLL 빌드 @@ -28,6 +28,48 @@ $assetsSrc = Join-Path $packagingDir 'Assets' $appProj = Join-Path $repoRoot 'src\Everything2Everything.App\Everything2Everything.App.csproj' $shellProj = Join-Path $repoRoot 'src\Everything2Everything.Shell\Everything2Everything.Shell.vcxproj' +function Import-EnvFile { + param([string]$Path) + if (Test-Path $Path) { + Get-Content $Path | Where-Object { $_ -match '^\s*([^#=\s]+)\s*=\s*(.*)$' } | ForEach-Object { + $key = $matches[1].Trim() + $val = $matches[2].Trim().Trim('"').Trim("'") + if (-not [string]::IsNullOrEmpty($key) -and -not [Environment]::GetEnvironmentVariable($key)) { + [Environment]::SetEnvironmentVariable($key, $val, 'Process') + } + } + } +} + +Import-EnvFile (Join-Path $repoRoot '.env') +Import-EnvFile (Join-Path $packagingDir '.env') + +# 환경변수 기반 코드 사이닝 옵션 자동 보정 +if (-not $CertThumbprint -and $env:CODE_SIGN_THUMBPRINT) { + $CertThumbprint = $env:CODE_SIGN_THUMBPRINT +} +if (-not $PfxPath -and $env:CODE_SIGN_PFX_PATH) { + $PfxPath = if ([System.IO.Path]::IsPathRooted($env:CODE_SIGN_PFX_PATH)) { $env:CODE_SIGN_PFX_PATH } else { Join-Path $repoRoot $env:CODE_SIGN_PFX_PATH } +} +if (-not $PfxPassword -and $env:CODE_SIGN_PFX_PASSWORD) { + $PfxPassword = ConvertTo-SecureString -String $env:CODE_SIGN_PFX_PASSWORD -AsPlainText -Force +} + +# 기본 DevCert 감지 및 자동 서명 활성화 +$devCertPfx = Join-Path $packagingDir 'Everything2Everything-DevCert.pfx' +if (-not $PfxPath -and -not $CertThumbprint -and (Test-Path $devCertPfx)) { + $PfxPath = $devCertPfx + if (-not $PfxPassword) { + $PfxPassword = ConvertTo-SecureString -String 'Everything2EverythingDev' -AsPlainText -Force + } +} + +if (-not $PSBoundParameters.ContainsKey('Sign')) { + if ($PfxPath -or $CertThumbprint) { + $Sign = $true + } +} + function Find-WindowsSdkTool { param([string]$ToolName) $sdkRoots = @( @@ -132,12 +174,15 @@ if (Test-Path $msixPath) { Remove-Item $msixPath -Force } if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' } Write-Host "✅ MSIX 산출: $msixPath" -# ---- 5) (선택) sign ---- +# ---- 5) sign ---- if ($Sign) { Write-Host '' - Write-Host '[5/5] signtool sign' + Write-Host '[5/5] signtool sign (MSIX 디지털 서명)' + $timestampUrl = if ($env:TIMESTAMP_SERVER_URL) { $env:TIMESTAMP_SERVER_URL } else { 'http://timestamp.digicert.com' } + $plain = $null + if ($CertThumbprint) { - & $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host + & $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr $timestampUrl /td SHA256 $msixPath | Out-Host } elseif ($PfxPath) { if (-not $PfxPassword) { $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호' @@ -145,7 +190,7 @@ if ($Sign) { $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword) try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) - & $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host + & $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr $timestampUrl /td SHA256 $msixPath | Out-Host } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } @@ -154,6 +199,32 @@ if ($Sign) { } if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' } Write-Host '✅ 서명 완료' + + # 공개 인증서 (.cer) 내보내기 (신뢰 등록용) + $cerPath = Join-Path $distDir "Everything2Everything-DevCert.cer" + try { + if ($PfxPath -and (Test-Path $PfxPath) -and $plain) { + $certObj = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxPath, $plain) + [System.IO.File]::WriteAllBytes($cerPath, $certObj.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + Write-Host "✅ 공개 인증서(.cer) 내보냄: $cerPath" -ForegroundColor Green + } elseif ($CertThumbprint) { + $certObj = Get-Item "Cert:\CurrentUser\My\$CertThumbprint" -ErrorAction SilentlyContinue + if (-not $certObj) { $certObj = Get-Item "Cert:\LocalMachine\My\$CertThumbprint" -ErrorAction SilentlyContinue } + if ($certObj) { + [System.IO.File]::WriteAllBytes($cerPath, $certObj.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + Write-Host "✅ 공개 인증서(.cer) 내보냄: $cerPath" -ForegroundColor Green + } + } + } catch { + Write-Warning "공개 인증서 내보내기 경고: $_" + } + + # 1-클릭 설치기 스크립트 복사 + $installCmdSrc = Join-Path $packagingDir 'Install.cmd' + if (Test-Path $installCmdSrc) { + Copy-Item $installCmdSrc -Destination (Join-Path $distDir 'Install.cmd') -Force + Write-Host "✅ 1-클릭 설치기 복사 완료: dist\Install.cmd" -ForegroundColor Green + } } else { Write-Host '' Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.' diff --git a/packaging/Install.cmd b/packaging/Install.cmd new file mode 100644 index 0000000..bb4fa69 --- /dev/null +++ b/packaging/Install.cmd @@ -0,0 +1,51 @@ +@echo off +chcp 65001 > nul +title Everything2Everything 1-클릭 설치기 + +:: 1. 관리자 권한 확인 및 자동 승격 (UAC) +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo [안내] 인증서 신뢰 등록 및 MSIX 패키지 설치를 위해 관리자 권한을 요청합니다... + powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process cmd.exe -ArgumentList '/c cd /d \"\"%~dp0\"\" && \"\"%~f0\"\"' -Verb RunAs" + exit /b +) + +cd /d "%~dp0" + +echo ========================================================== +echo Everything2Everything 1-클릭 자동 설치 프로그램 +echo ========================================================== +echo. + +powershell -NoProfile -ExecutionPolicy Bypass -Command ^ + "$ErrorActionPreference = 'Stop';" ^ + "try {" ^ + " Write-Host '[1/2] 개발자 인증서 신뢰 저장소(TrustedPeople) 등록 중...' -ForegroundColor Cyan;" ^ + " $cer = Get-ChildItem -Path . -Filter '*.cer' | Select-Object -First 1;" ^ + " if ($cer) {" ^ + " Import-Certificate -FilePath $cer.FullName -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null;" ^ + " Import-Certificate -FilePath $cer.FullName -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null;" ^ + " Write-Host ' 인증서 신뢰 등록 성공: ' $cer.Name -ForegroundColor Green;" ^ + " } else {" ^ + " Write-Host ' [주의] 폴더 내 .cer 인증서가 없습니다. 건너뜁니다.' -ForegroundColor Yellow;" ^ + " }" ^ + " Write-Host '[2/2] Everything2Everything MSIX 패키지 설치 중...' -ForegroundColor Cyan;" ^ + " $msix = Get-ChildItem -Path . -Filter '*.msix' | Select-Object -First 1;" ^ + " if (-not $msix) { throw 'MSIX 패키지 파일(*.msix)을 찾을 수 없습니다.' }" ^ + " Add-AppxPackage -Path $msix.FullName -ForceApplicationShutdown;" ^ + " Write-Host ' 패키지 설치 성공: ' $msix.Name -ForegroundColor Green;" ^ + " Write-Host '';" ^ + " Write-Host '==========================================================' -ForegroundColor Green;" ^ + " Write-Host ' Everything2Everything 설치가 성공적으로 완료되었습니다!' -ForegroundColor Green;" ^ + " Write-Host ' - 파일 우클릭 시 최상위 컨텍스트 메뉴가 활성화됩니다.' -ForegroundColor Gray;" ^ + " Write-Host '==========================================================' -ForegroundColor Green;" ^ + "} catch {" ^ + " Write-Host '';" ^ + " Write-Host '❌ 설치 중 오류가 발생했습니다:' -ForegroundColor Red;" ^ + " Write-Host $_.Exception.Message -ForegroundColor Red;" ^ + " Write-Host '';" ^ + " Write-Host '동일 버전이 이미 설치되어 있는 경우 기존 앱을 삭제 후 다시 시도해 주세요.' -ForegroundColor Yellow;" ^ + "}" + +echo. +pause diff --git a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs index 23c6d92..5c2da02 100644 --- a/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs +++ b/src/Everything2Everything.App/ViewModels/OptionsViewModel.cs @@ -13,6 +13,26 @@ public partial class OptionsViewModel : ObservableObject /// JPEG/WebP 품질(1~100). AVIF는 -30 보정. [ObservableProperty] private int _quality = 85; + /// EXIF 및 메타데이터 제거 여부. + [ObservableProperty] private bool _stripMetadata; + + public int ImageQuality { get => Quality; set => Quality = value; } + public int VideoCrf { get => Crf; set => Crf = value; } + public int AudioBitrateKbps + { + get => AudioBitrateIndex switch { 0 => 96, 1 => 128, 3 => 256, 4 => 320, _ => 192 }; + set => AudioBitrateIndex = value switch { <= 96 => 0, <= 128 => 1, <= 192 => 2, <= 256 => 3, _ => 4 }; + } + public string VideoPreset + { + get => ((VideoSpeedPreset)PresetIndex).ToString().ToLowerInvariant(); + set => PresetIndex = value switch + { + "ultrafast" => 0, "superfast" => 1, "veryfast" => 2, "faster" => 3, + "fast" => 4, "medium" => 5, "slow" => 6, "slower" => 7, "veryslow" => 8, _ => 5 + }; + } + /// 비우면 원본 옆 서브폴더. 값이 있으면 사용자 지정 출력 폴더. [ObservableProperty] private string? _customOutputDirectory; @@ -62,6 +82,7 @@ public partial class OptionsViewModel : ObservableObject OnCollision = ConflictRule, OutputLocation = hasCustom ? OutputLocation.Custom : OutputLocation.SubfolderBesideSource, CustomOutputDirectory = hasCustom ? CustomOutputDirectory!.Trim() : null, + KeepExifWhenPossible = !StripMetadata, Jpeg = new JpegEncodingOptions { Quality = Quality }, Webp = new WebpEncodingOptions { Quality = Quality }, Avif = new AvifEncodingOptions { Quality = Math.Clamp(Quality - 30, 1, 100) }, diff --git a/src/Everything2Everything.App/Views/BatchQueueService.cs b/src/Everything2Everything.App/Views/BatchQueueService.cs new file mode 100644 index 0000000..3fc94f2 --- /dev/null +++ b/src/Everything2Everything.App/Views/BatchQueueService.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Everything2Everything.App.Views; + +public static class BatchQueueService +{ + public static void SetSelectionAll(IEnumerable items, bool isSelected) + { + foreach (var item in items) + { + item.IsSelected = isSelected; + } + } + + public static void RemoveSelected(IList items) + { + var toRemove = items.Where(i => i.IsSelected).ToList(); + foreach (var item in toRemove) + { + items.Remove(item); + } + } + + public static void ClearCompleted(IList items) + { + var toRemove = items.Where(i => i.IsDone || i.StateText == "done").ToList(); + foreach (var item in toRemove) + { + items.Remove(item); + } + } + + public static void BatchChangeOutput(IEnumerable items, string newOutputExt, IEnumerable eligibleInputExtensions) + { + var eligibleSet = new HashSet( + eligibleInputExtensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()) + ); + + foreach (var item in items) + { + var ext = Path.GetExtension(item.SourcePath).ToLowerInvariant(); + if (eligibleSet.Contains(ext)) + { + item.SelectedOutputExtension = newOutputExt; + } + } + } +} diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml index 3a12d31..66d4157 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml +++ b/src/Everything2Everything.App/Views/MainWindow.xaml @@ -89,6 +89,49 @@ + + + + + + + + + + + @@ -585,15 +628,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + @@ -663,16 +811,19 @@ CornerRadius="6,0,0,6"/> + - + - - - - + + - _searchText; + set + { + if (_searchText != value) + { + _searchText = value; + ApplyQueueFilters(); + } + } + } + + private FilterCategory _selectedCategory = FilterCategory.All; + public FilterCategory SelectedCategory + { + get => _selectedCategory; + set + { + if (_selectedCategory != value) + { + _selectedCategory = value; + ApplyQueueFilters(); + } + } + } + public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null) { _engine = engine; @@ -79,6 +118,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow PickOutputFolderCommand = new RelayCommand(_ => OnPickOutputFolderClick(this, new RoutedEventArgs())); CancelProcessingCommand = new RelayCommand(_ => OnCancelProcessingClick(this, new RoutedEventArgs())); PreviewOpenFolderCommand = new RelayCommand(_ => OnPreviewOpenFolder(this, new RoutedEventArgs())); + PreviewOpenFileCommand = new RelayCommand(_ => OnPreviewOpenFile(this, new RoutedEventArgs())); + PresetCommand = new RelayCommand(p => ApplyPreset(p?.ToString())); + FilterCategoryCommand = new RelayCommand(p => ApplyFilterCategory(p?.ToString())); + BatchSelectAllCommand = new RelayCommand(p => BatchSelectAll(p)); + BatchRemoveSelectedCommand = new RelayCommand(_ => BatchRemoveSelected()); + BatchClearCompletedCommand = new RelayCommand(_ => BatchClearCompleted()); RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem)); OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string)); TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Past")); @@ -246,6 +291,10 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow var hasItems = _activeQueue.Count > 0; DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible; ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; + if (FindName("BatchActionBar") is UIElement batchBar) + { + batchBar.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; + } } private void UpdatePastResultsVisibility() @@ -422,12 +471,13 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText) { - PreviewFileName.Text = fileName; - PreviewFilePath.Text = filePath; - PreviewFormatText.Text = formatLabel; - PreviewSizeText.Text = sizeText; - PreviewDimText.Text = "—"; - PreviewPageText.Text = "—"; + var info = FileInspectorBuilder.Build(filePath); + PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName; + PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath; + PreviewFormatText.Text = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel; + PreviewSizeText.Text = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText; + PreviewDimText.Text = info.DimensionsOrMeta; + PreviewPageText.Text = info.Category.ToString(); } private void ShowPreviewLoading() @@ -478,6 +528,97 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow catch { } } + private void OnPreviewOpenFile(object sender, RoutedEventArgs e) + { + var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) + { + UseShellExecute = true, + }); + } + catch { } + } + + private void ApplyPreset(string? presetName) + { + if (Enum.TryParse(presetName, true, out var type)) + { + var firstItem = _activeQueue.FirstOrDefault()?.SourcePath; + var ext = !string.IsNullOrEmpty(firstItem) ? Path.GetExtension(firstItem) : ".png"; + var recommended = ConversionPreset.Apply(type, _options, ext); + + for (int i = 0; i < OutputFormatCombo.Items.Count; i++) + { + if (OutputFormatCombo.Items[i] is OutputFormatInfo info && + info.Extension.Equals(recommended, StringComparison.OrdinalIgnoreCase)) + { + OutputFormatCombo.SelectedIndex = i; + break; + } + } + + var targets = _activeQueue.Where(q => q.IsSelected).ToList(); + if (targets.Count == 0) targets = _activeQueue.ToList(); + foreach (var item in targets) + { + item.SelectedOutputExtension = recommended; + } + + QualitySlider.Value = _options.Quality; + QualityValueText.Text = _options.Quality.ToString(CultureInfo.InvariantCulture); + } + } + + private void ApplyFilterCategory(string? categoryName) + { + if (Enum.TryParse(categoryName, true, out var cat)) + { + SelectedCategory = cat; + } + } + + private void ApplyQueueFilters() + { + var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_activeQueue); + if (view != null) + { + view.Filter = item => + { + if (item is QueueItem q) + { + return QueueFilterMatcher.Matches(q.FileName, _searchText, _selectedCategory); + } + return true; + }; + view.Refresh(); + } + } + + private void BatchSelectAll(object? parameter) + { + bool select = parameter is true; + BatchQueueService.SetSelectionAll(_activeQueue, select); + } + + private void BatchRemoveSelected() + { + BatchQueueService.RemoveSelected(_activeQueue); + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + } + + private void BatchClearCompleted() + { + BatchQueueService.ClearCompleted(_activeQueue); + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + } + // ============== Export Log ============== private void OnExportLogClick(object sender, RoutedEventArgs e) @@ -1132,14 +1273,30 @@ public sealed class QueueItem : INotifyPropertyChanged private string _state = "queued"; private double _progressValue; private Visibility _progressVisibility = Visibility.Collapsed; + private bool _isSelected; + private string? _selectedOutputExtension; - public required string SourcePath { get; init; } - public required string FileName { get; init; } - public required string FormatLabel { get; init; } - public required Brush FormatBrush { get; init; } - public required string SizeText { get; init; } - public required string MetaLine { get; init; } - public required long SourceSizeBytes { get; init; } + public string SourcePath { get; init; } = ""; + public string FileName { get; init; } = ""; + public string FormatLabel { get; init; } = ""; + public Brush FormatBrush { get; init; } = Brushes.Gray; + public string SizeText { get; init; } = ""; + public string MetaLine { get; init; } = ""; + public long SourceSizeBytes { get; init; } + + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; Raise(nameof(IsSelected)); } + } + + public string? SelectedOutputExtension + { + get => _selectedOutputExtension; + set { _selectedOutputExtension = value; Raise(nameof(SelectedOutputExtension)); } + } + + public bool IsDone => _state == "done"; /// 형식 카테고리 글리프(라벨 아이콘). public System.Windows.Media.ImageSource GlyphSource => CategoryGlyphs.ForExtension(Path.GetExtension(SourcePath)); @@ -1147,14 +1304,14 @@ public sealed class QueueItem : INotifyPropertyChanged public string StateText { get => _state; - set { _state = value; Raise(nameof(StateText)); } + set { _state = value; Raise(nameof(StateText)); Raise(nameof(IsDone)); } } public Brush StateBrush => _state switch { - "queued" => (Brush)Application.Current.FindResource("FsTextTertiary"), - "done" => (Brush)Application.Current.FindResource("FsAccentGreen"), - _ => (Brush)Application.Current.FindResource("FsAccentBlue"), + "queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray, + "done" => (Application.Current?.TryFindResource("FsAccentGreen") as Brush) ?? Brushes.LightGreen, + _ => (Application.Current?.TryFindResource("FsAccentBlue") as Brush) ?? Brushes.DodgerBlue, }; public double ProgressValue @@ -1198,12 +1355,15 @@ public sealed class QueueItem : INotifyPropertyChanged long size = 0; try { size = new FileInfo(path).Length; } catch { } + var brush = (Application.Current?.TryFindResource(brushKey) as Brush) + ?? new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); + return new QueueItem { SourcePath = path, FileName = Path.GetFileName(path), FormatLabel = label, - FormatBrush = (Brush)Application.Current.FindResource(brushKey), + FormatBrush = brush, SizeText = MainWindow.HumanizeBytes(size), MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}", SourceSizeBytes = size, diff --git a/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs b/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs new file mode 100644 index 0000000..344aead --- /dev/null +++ b/src/Everything2Everything.Core/Filters/QueueFilterMatcher.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; + +namespace Everything2Everything.Core.Filters; + +public enum FilterCategory +{ + All, + Image, + Document, + Media, + Data, +} + +public static class QueueFilterMatcher +{ + public static FilterCategory GetCategory(string pathOrExt) + { + var ext = Path.GetExtension(pathOrExt).Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) + { + ext = pathOrExt.Trim().ToLowerInvariant(); + if (!ext.StartsWith('.')) ext = "." + ext; + } + + return ext switch + { + ".jpg" or ".jpeg" or ".png" or ".webp" or ".avif" or ".gif" or ".bmp" or ".tif" or ".tiff" + or ".svg" or ".heic" or ".heif" or ".raw" or ".dng" or ".cr2" or ".cr3" or ".nef" or ".arw" + => FilterCategory.Image, + + ".pdf" or ".docx" or ".doc" or ".hwp" or ".hwpx" or ".txt" or ".md" or ".markdown" or ".html" or ".htm" or ".xlsx" or ".xls" + => FilterCategory.Document, + + ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi" or ".mp3" or ".wav" or ".flac" or ".aac" or ".m4a" or ".ogg" or ".opus" + => FilterCategory.Media, + + ".csv" or ".json" or ".tsv" or ".xml" or ".yaml" or ".yml" + => FilterCategory.Data, + + _ => FilterCategory.All, + }; + } + + public static bool Matches(string filename, string query, FilterCategory category) + { + if (string.IsNullOrWhiteSpace(filename)) return false; + + // 1. 카테고리 필터 검사 + if (category != FilterCategory.All) + { + var itemCategory = GetCategory(filename); + if (itemCategory != category) return false; + } + + // 2. 검색어 필터 검사 + if (string.IsNullOrWhiteSpace(query)) return true; + + var cleanQuery = query.Trim(); + return filename.Contains(cleanQuery, StringComparison.OrdinalIgnoreCase); + } + + public static System.Collections.Generic.IEnumerable Filter( + System.Collections.Generic.IEnumerable source, + Func fileNameSelector, + string query, + FilterCategory category) + { + return System.Linq.Enumerable.Where(source, item => Matches(fileNameSelector(item), query, category)); + } +} diff --git a/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs b/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs new file mode 100644 index 0000000..ec7cbb1 --- /dev/null +++ b/src/Everything2Everything.Core/Inspector/FileInspectorInfo.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using Everything2Everything.Core.Filters; + +namespace Everything2Everything.Core.Inspector; + +public sealed record FileInspectorInfo( + string FileName, + string FullPath, + FilterCategory Category, + long FileSizeBytes, + string FormattedSize, + string Extension, + string DimensionsOrMeta, + bool CanOpen, + bool CanReveal); + +public static class FileInspectorBuilder +{ + public static FileInspectorInfo Build(string filePath) + { + var fileName = Path.GetFileName(filePath); + if (string.IsNullOrEmpty(fileName)) fileName = filePath; + + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var category = QueueFilterMatcher.GetCategory(filePath); + + long size = 0; + bool exists = false; + try + { + exists = File.Exists(filePath); + if (exists) + { + size = new FileInfo(filePath).Length; + } + } + catch { } + + var formattedSize = HumanizeBytes(size); + var meta = $"{ext.TrimStart('.').ToUpperInvariant()} · {category}"; + + return new FileInspectorInfo( + FileName: fileName, + FullPath: filePath, + Category: category, + FileSizeBytes: size, + FormattedSize: formattedSize, + Extension: ext, + DimensionsOrMeta: meta, + CanOpen: exists, + CanReveal: exists); + } + + private static string HumanizeBytes(long bytes) + { + if (bytes <= 0) return "0 B"; + string[] units = { "B", "KB", "MB", "GB", "TB" }; + double b = bytes; + int u = 0; + while (b >= 1024.0 && u < units.Length - 1) + { + b /= 1024.0; + u++; + } + return u == 0 ? $"{b:0} B" : $"{b:0.1} {units[u]}"; + } +} diff --git a/src/Everything2Everything.Core/Presets/ConversionPreset.cs b/src/Everything2Everything.Core/Presets/ConversionPreset.cs new file mode 100644 index 0000000..a277c43 --- /dev/null +++ b/src/Everything2Everything.Core/Presets/ConversionPreset.cs @@ -0,0 +1,68 @@ +using System; + +namespace Everything2Everything.Core.Presets; + +public enum PresetType +{ + WebOptimized, + HighQualityLossless, + DocumentPdf, + MobileShare, +} + +public sealed record PresetInfo(PresetType Type, string Title, string Description, string IconSymbol); + +public static class ConversionPreset +{ + public static PresetInfo GetInfo(PresetType type) => type switch + { + PresetType.WebOptimized => new PresetInfo(type, "웹 최적화", "WebP · 80% 압축 · 메타데이터 제거", "Globe24"), + PresetType.HighQualityLossless => new PresetInfo(type, "초고화질 보존", "무손실 PNG/FLAC · 100% 품질", "Sparkle24"), + PresetType.DocumentPdf => new PresetInfo(type, "문서 PDF 보관", "표준 PDF/A 문서 변환", "DocumentPdf24"), + PresetType.MobileShare => new PresetInfo(type, "모바일 공유", "MP4 H.264 · 가벼운 용량 전송", "Phone24"), + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; + + public static string Apply(PresetType type, dynamic options, string inputExtension) + { + var ext = inputExtension.Trim().ToLowerInvariant(); + if (!ext.StartsWith('.')) ext = "." + ext; + + switch (type) + { + case PresetType.WebOptimized: + options.ImageQuality = 80; + options.StripMetadata = true; + if (IsVideo(ext)) return ".mp4"; + if (IsAudio(ext)) return ".mp3"; + if (IsDoc(ext)) return ".pdf"; + return ".webp"; + + case PresetType.HighQualityLossless: + options.ImageQuality = 100; + options.StripMetadata = false; + options.AudioBitrateKbps = 320; + if (IsAudio(ext)) return ".flac"; + if (IsVideo(ext)) return ".mkv"; + return ".png"; + + case PresetType.DocumentPdf: + return ".pdf"; + + case PresetType.MobileShare: + options.VideoCrf = 26; + options.AudioBitrateKbps = 128; + options.VideoPreset = "veryfast"; + if (IsVideo(ext)) return ".mp4"; + if (IsAudio(ext)) return ".mp3"; + return ".jpg"; + + default: + return ext; + } + } + + private static bool IsVideo(string ext) => ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi" or ".m4v"; + private static bool IsAudio(string ext) => ext is ".mp3" or ".wav" or ".flac" or ".aac" or ".m4a" or ".ogg" or ".opus"; + private static bool IsDoc(string ext) => ext is ".docx" or ".doc" or ".hwp" or ".hwpx" or ".txt" or ".md" or ".markdown" or ".html"; +} diff --git a/src/Everything2Everything.Tests/BatchQueueActionsTests.cs b/src/Everything2Everything.Tests/BatchQueueActionsTests.cs new file mode 100644 index 0000000..3050c03 --- /dev/null +++ b/src/Everything2Everything.Tests/BatchQueueActionsTests.cs @@ -0,0 +1,90 @@ +using System.Collections.ObjectModel; +using System.IO; +using Everything2Everything.App.Views; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 큐 일괄 처리(Batch Actions) TDD 단위 테스트 스위트 +/// 전체 선택/해제, 선택 항목 삭제, 완료 항목 정리, 일괄 출력 형식 변경 검증 +/// +public class BatchQueueActionsTests +{ + private static QueueItem CreateItem(string path, string outExt = ".jpg") => + new() + { + SourcePath = path, + FileName = Path.GetFileName(path), + SelectedOutputExtension = outExt, + StateText = "queued", + }; + + [Fact] + public void BatchChangeOutput_UpdatesEligibleItems() + { + var items = new ObservableCollection + { + CreateItem("a.png", ".jpg"), + CreateItem("b.png", ".jpg"), + CreateItem("c.mp4", ".mp4"), + }; + + // .webp 지원 여부에 따라 이미지(a.png, b.png)만 .webp로 일괄 변경 + BatchQueueService.BatchChangeOutput(items, ".webp", new[] { ".png", ".jpg" }); + + Assert.Equal(".webp", items[0].SelectedOutputExtension); + Assert.Equal(".webp", items[1].SelectedOutputExtension); + Assert.Equal(".mp4", items[2].SelectedOutputExtension); // mp4는 제외 + } + + [Fact] + public void RemoveSelected_RemovesOnlySelectedItems() + { + var items = new ObservableCollection + { + CreateItem("1.png"), + CreateItem("2.png"), + CreateItem("3.png"), + }; + items[0].IsSelected = true; + items[2].IsSelected = true; + + BatchQueueService.RemoveSelected(items); + + Assert.Single(items); + Assert.Equal("2.png", items[0].FileName); + } + + [Fact] + public void ClearCompleted_RemovesOnlyDoneItems() + { + var i1 = CreateItem("1.png"); i1.SetState("done"); + var i2 = CreateItem("2.png"); i2.SetState("45%"); + var i3 = CreateItem("3.png"); i3.SetState("queued"); + var i4 = CreateItem("4.png"); i4.SetState("done"); + + var items = new ObservableCollection { i1, i2, i3, i4 }; + + BatchQueueService.ClearCompleted(items); + + Assert.Equal(2, items.Count); + Assert.All(items, item => Assert.False(item.IsDone)); + } + + [Fact] + public void SelectAll_TogglesAllItems() + { + var items = new ObservableCollection + { + CreateItem("1.png"), + CreateItem("2.png"), + }; + + BatchQueueService.SetSelectionAll(items, true); + Assert.All(items, item => Assert.True(item.IsSelected)); + + BatchQueueService.SetSelectionAll(items, false); + Assert.All(items, item => Assert.False(item.IsSelected)); + } +} diff --git a/src/Everything2Everything.Tests/DesignAuditAstTests.cs b/src/Everything2Everything.Tests/DesignAuditAstTests.cs index d0b884c..d9a0894 100644 --- a/src/Everything2Everything.Tests/DesignAuditAstTests.cs +++ b/src/Everything2Everything.Tests/DesignAuditAstTests.cs @@ -227,4 +227,36 @@ public class DesignAuditAstTests Assert.True(paddings.Count == 1, $"SettingsWindow 푸터 버튼들의 패딩이 서로 다릅니다 (동일 위계 버튼은 패딩 통일 필수): {string.Join(", ", paddings)}"); } + + [Fact] + public void MainWindow_MustHave_Presets_Search_Batch_And_Inspector_Elements() + { + var mainFile = Path.Combine(ViewsDir, "MainWindow.xaml"); + var doc = XDocument.Parse(File.ReadAllText(mainFile)); + + // 1. Quick Presets (PresetCommand 바인딩 버튼 존재) + var presetButtons = doc.Descendants() + .Where(e => (e.Name.LocalName == "Button" || e.Name.LocalName == "ToggleButton") && + e.Attribute("Command")?.Value.Contains("PresetCommand") == true) + .ToList(); + Assert.True(presetButtons.Count >= 4, "빠른 최적화 프리셋 버튼 4개(웹/고화질/문서/모바일)가 MainWindow.xaml에 선언되어야 합니다."); + + // 2. SearchBox (검색창 존재) + var searchBox = doc.Descendants() + .FirstOrDefault(e => e.Name.LocalName == "TextBox" && + e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "SearchBox"); + Assert.NotNull(searchBox); + + // 3. BatchActionBar (일괄 작업 툴바 존재) + var batchBar = doc.Descendants() + .FirstOrDefault(e => e.Attribute(XName.Get("Name", "http://schemas.microsoft.com/winfx/2006/xaml"))?.Value == "BatchActionBar"); + Assert.NotNull(batchBar); + + // 4. Right Inspector Open Button (PreviewOpenFileCommand) + var openButtons = doc.Descendants() + .Where(e => e.Name.LocalName == "Button" && + e.Attribute("Command")?.Value.Contains("PreviewOpenFileCommand") == true) + .ToList(); + Assert.NotEmpty(openButtons); + } } diff --git a/src/Everything2Everything.Tests/FileInspectorTests.cs b/src/Everything2Everything.Tests/FileInspectorTests.cs new file mode 100644 index 0000000..87e268d --- /dev/null +++ b/src/Everything2Everything.Tests/FileInspectorTests.cs @@ -0,0 +1,63 @@ +using System.IO; +using Everything2Everything.Core.Filters; +using Everything2Everything.Core.Inspector; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 파일 인스펙터(Inspector & Metadata Card) TDD 단위 테스트 스위트 +/// 선택된 파일의 세부 메타데이터, 카테고리, 변환 가능 대상 형식 카운트, 탐색기 연동 정보 검증 +/// +public class FileInspectorTests +{ + [Fact] + public void Build_ValidTextFile_ExtractsMetadataAndTargetFormats() + { + var tempFile = Path.GetTempFileName() + ".txt"; + File.WriteAllText(tempFile, "Hello World Everything2Everything Test"); + + try + { + var info = FileInspectorBuilder.Build(tempFile); + + Assert.Equal(Path.GetFileName(tempFile), info.FileName); + Assert.Equal(tempFile, info.FullPath); + Assert.Equal(FilterCategory.Document, info.Category); + Assert.True(info.FileSizeBytes > 0); + Assert.NotEmpty(info.FormattedSize); + Assert.True(info.CanOpen); + Assert.True(info.CanReveal); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + + [Fact] + public void Build_NonExistentFile_ReturnsSafeDefaults() + { + var ghostFile = "C:\\path\\does_not_exist\\nonexistent.png"; + var info = FileInspectorBuilder.Build(ghostFile); + + Assert.Equal("nonexistent.png", info.FileName); + Assert.Equal(FilterCategory.Image, info.Category); + Assert.Equal(0, info.FileSizeBytes); + Assert.Equal("0 B", info.FormattedSize); + Assert.False(info.CanOpen); + Assert.False(info.CanReveal); + } + + [Theory] + [InlineData(".png", FilterCategory.Image)] + [InlineData(".mp4", FilterCategory.Media)] + [InlineData(".docx", FilterCategory.Document)] + [InlineData(".json", FilterCategory.Data)] + public void Build_IdentifiesCategoryAccurately(string ext, FilterCategory expectedCategory) + { + var dummyPath = "C:\\test\\sample" + ext; + var info = FileInspectorBuilder.Build(dummyPath); + Assert.Equal(expectedCategory, info.Category); + } +} diff --git a/src/Everything2Everything.Tests/PackagingSignatureTests.cs b/src/Everything2Everything.Tests/PackagingSignatureTests.cs new file mode 100644 index 0000000..b877369 --- /dev/null +++ b/src/Everything2Everything.Tests/PackagingSignatureTests.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Xunit; + +namespace Everything2Everything.Tests; + +public class PackagingSignatureTests +{ + private static string FindRepoRoot() + { + var current = AppDomain.CurrentDomain.BaseDirectory; + while (!string.IsNullOrEmpty(current)) + { + if (File.Exists(Path.Combine(current, "Everything2Everything.slnx")) || + File.Exists(Path.Combine(current, "AGENTS.md"))) + { + return current; + } + var parent = Directory.GetParent(current); + if (parent == null) break; + current = parent.FullName; + } + return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..")); + } + + [Fact] + public void AppxManifest_Publisher_Matches_DevCert_Subject() + { + var repoRoot = FindRepoRoot(); + var manifestPath = Path.Combine(repoRoot, "packaging", "Package.appxmanifest"); + Assert.True(File.Exists(manifestPath), $"Package.appxmanifest must exist at {manifestPath}"); + + var doc = XDocument.Load(manifestPath); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + var identity = doc.Root?.Element(ns + "Identity"); + Assert.NotNull(identity); + + var publisher = identity.Attribute("Publisher")?.Value; + Assert.Equal("CN=Everything2EverythingDev", publisher); + } + + [Fact] + public void EnvExample_Contains_CodeSigning_Variables() + { + var repoRoot = FindRepoRoot(); + var envExamplePath = Path.Combine(repoRoot, ".env.example"); + Assert.True(File.Exists(envExamplePath), ".env.example template file must exist"); + + var content = File.ReadAllText(envExamplePath); + Assert.Contains("CODE_SIGN_PFX_PATH", content); + Assert.Contains("CODE_SIGN_PFX_PASSWORD", content); + Assert.Contains("CODE_SIGN_THUMBPRINT", content); + } + + [Fact] + public void PublishReleaseScript_Enforces_Signing_Flag() + { + var repoRoot = FindRepoRoot(); + var publishScript = Path.Combine(repoRoot, "tools", "Publish-Release.ps1"); + Assert.True(File.Exists(publishScript), "Publish-Release.ps1 must exist"); + + var content = File.ReadAllText(publishScript); + // BuildMsix must be invoked with -Sign flag + Assert.Contains("BuildMsix.ps1", content); + Assert.Matches(@"(?i)-Sign\b", content); + } + + [Fact] + public void InstallCmd_Exists_For_OneClick_Elevation() + { + var repoRoot = FindRepoRoot(); + var installCmdPath = Path.Combine(repoRoot, "packaging", "Install.cmd"); + Assert.True(File.Exists(installCmdPath), "packaging/Install.cmd must exist for 1-click end-user installation"); + + var content = File.ReadAllText(installCmdPath); + Assert.Contains("powershell", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TrustedPeople", content, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Everything2Everything.Tests/PresetEngineTests.cs b/src/Everything2Everything.Tests/PresetEngineTests.cs new file mode 100644 index 0000000..96aa0d0 --- /dev/null +++ b/src/Everything2Everything.Tests/PresetEngineTests.cs @@ -0,0 +1,71 @@ +using Everything2Everything.App.ViewModels; +using Everything2Everything.Core.Presets; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 1-클릭 빠른 최적화 프리셋 시스템 TDD 단위 테스트 스위트 +/// 웹 최적화, 무손실 보존, 문서 PDF 보관, 모바일 공유 프리셋의 파라미터 튜닝 및 추천 확장자 검증 +/// +public class PresetEngineTests +{ + [Fact] + public void Apply_WebOptimized_SetsWebPAndOptimalCompression() + { + var options = new OptionsViewModel(); + var targetExt = ConversionPreset.Apply(PresetType.WebOptimized, options, ".png"); + + Assert.Equal(".webp", targetExt); + Assert.Equal(80, options.ImageQuality); + Assert.True(options.StripMetadata); + } + + [Fact] + public void Apply_HighQualityLossless_SetsMaxQualityAndFlacOrPng() + { + var options = new OptionsViewModel(); + var audioTarget = ConversionPreset.Apply(PresetType.HighQualityLossless, options, ".wav"); + Assert.Equal(".flac", audioTarget); + Assert.Equal(320, options.AudioBitrateKbps); + + var imgTarget = ConversionPreset.Apply(PresetType.HighQualityLossless, options, ".jpg"); + Assert.Equal(".png", imgTarget); + Assert.Equal(100, options.ImageQuality); + Assert.False(options.StripMetadata); + } + + [Fact] + public void Apply_DocumentPdf_SetsPdfTargetForDocuments() + { + var options = new OptionsViewModel(); + var docxTarget = ConversionPreset.Apply(PresetType.DocumentPdf, options, ".docx"); + Assert.Equal(".pdf", docxTarget); + + var hwpTarget = ConversionPreset.Apply(PresetType.DocumentPdf, options, ".hwp"); + Assert.Equal(".pdf", hwpTarget); + } + + [Fact] + public void Apply_MobileShare_SetsLightweightVideoAndAudio() + { + var options = new OptionsViewModel(); + var videoTarget = ConversionPreset.Apply(PresetType.MobileShare, options, ".mkv"); + Assert.Equal(".mp4", videoTarget); + Assert.Equal(26, options.VideoCrf); + Assert.Equal(128, options.AudioBitrateKbps); + Assert.Equal("veryfast", options.VideoPreset); + } + + [Theory] + [InlineData(PresetType.WebOptimized, "웹 최적화", "WebP · 80% 압축 · 메타데이터 제거")] + [InlineData(PresetType.HighQualityLossless, "초고화질 보존", "무손실 PNG/FLAC · 100% 품질")] + [InlineData(PresetType.DocumentPdf, "문서 PDF 보관", "표준 PDF/A 문서 변환")] + [InlineData(PresetType.MobileShare, "모바일 공유", "MP4 H.264 · 가벼운 용량 전송")] + public void Presets_HaveUserFacingTitleAndDescription(PresetType type, string expectedTitle, string expectedDesc) + { + var info = ConversionPreset.GetInfo(type); + Assert.Equal(expectedTitle, info.Title); + Assert.Equal(expectedDesc, info.Description); + } +} diff --git a/src/Everything2Everything.Tests/QueueFilterTests.cs b/src/Everything2Everything.Tests/QueueFilterTests.cs new file mode 100644 index 0000000..49446f0 --- /dev/null +++ b/src/Everything2Everything.Tests/QueueFilterTests.cs @@ -0,0 +1,60 @@ +using Everything2Everything.Core.Filters; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 실시간 큐 및 히스토리 검색 & 카테고리 필터 TDD 단위 테스트 스위트 +/// 파일명/확장자 검색 및 멀티미디어/문서/이미지/데이터 카테고리 분류 로직 검증 +/// +public class QueueFilterTests +{ + [Theory] + [InlineData("photo.jpg", FilterCategory.Image)] + [InlineData("vector.svg", FilterCategory.Image)] + [InlineData("report.docx", FilterCategory.Document)] + [InlineData("hwp_doc.hwp", FilterCategory.Document)] + [InlineData("document.pdf", FilterCategory.Document)] + [InlineData("movie.mp4", FilterCategory.Media)] + [InlineData("podcast.mp3", FilterCategory.Media)] + [InlineData("audio.wav", FilterCategory.Media)] + [InlineData("dataset.csv", FilterCategory.Data)] + [InlineData("config.json", FilterCategory.Data)] + public void GetCategory_ClassifiesCorrectCategory(string filename, FilterCategory expected) + { + var category = QueueFilterMatcher.GetCategory(filename); + Assert.Equal(expected, category); + } + + [Theory] + [InlineData("photo.jpg", "", FilterCategory.All, true)] + [InlineData("photo.jpg", "photo", FilterCategory.All, true)] + [InlineData("photo.jpg", "PHOTO", FilterCategory.All, true)] + [InlineData("photo.jpg", ".jpg", FilterCategory.All, true)] + [InlineData("photo.jpg", "video", FilterCategory.All, false)] + [InlineData("photo.jpg", "", FilterCategory.Image, true)] + [InlineData("photo.jpg", "", FilterCategory.Document, false)] + [InlineData("report.docx", "rep", FilterCategory.Document, true)] + [InlineData("report.docx", "rep", FilterCategory.Image, false)] + [InlineData("video.mp4", "vid", FilterCategory.Media, true)] + public void Matches_FiltersBySearchTextAndCategory(string filename, string query, FilterCategory category, bool expected) + { + var matches = QueueFilterMatcher.Matches(filename, query, category); + Assert.Equal(expected, matches); + } + + [Fact] + public void FilterCollection_ReturnsMatchingItemsOnly() + { + var items = new[] + { + new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\a.png", FileName = "a.png" }, + new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\b.docx", FileName = "b.docx" }, + new Everything2Everything.App.Views.QueueItem { SourcePath = "C:\\c.mp4", FileName = "c.mp4" }, + }; + + var filtered = QueueFilterMatcher.Filter(items, item => item.FileName, "", FilterCategory.Image).ToList(); + Assert.Single(filtered); + Assert.Equal("a.png", filtered[0].FileName); + } +} diff --git a/tools/Publish-Release.ps1 b/tools/Publish-Release.ps1 index 7f4a268..8693728 100644 --- a/tools/Publish-Release.ps1 +++ b/tools/Publish-Release.ps1 @@ -34,6 +34,20 @@ $RootDir = Split-Path -Parent $ScriptDir $Tag = "v$Version" $FourPartVersion = "$Version.0" +function Import-EnvFile { + param([string]$Path) + if (Test-Path $Path) { + Get-Content $Path | Where-Object { $_ -match '^\s*([^#=\s]+)\s*=\s*(.*)$' } | ForEach-Object { + $key = $matches[1].Trim() + $val = $matches[2].Trim().Trim('"').Trim("'") + if (-not [string]::IsNullOrEmpty($key) -and -not [Environment]::GetEnvironmentVariable($key)) { + [Environment]::SetEnvironmentVariable($key, $val, 'Process') + } + } + } +} +Import-EnvFile (Join-Path $RootDir '.env') + Write-Host "==========================================================" -ForegroundColor Cyan Write-Host " Everything2Everything 릴리즈 파이프라인" -ForegroundColor Cyan Write-Host " 버전: $Version (Tag: $Tag, Manifest: $FourPartVersion)" -ForegroundColor Cyan @@ -107,12 +121,40 @@ if (-not $DryRun) { Compress-Archive -Path "$publishDir\*" -DestinationPath $portableZip Write-Host " - Portable ZIP 생성: $portableZip" -ForegroundColor Gray - # 3. MSIX 패키징 + # 3. MSIX 패키징 및 디지털 서명 $buildMsixScript = Join-Path $RootDir "packaging/BuildMsix.ps1" if (Test-Path $buildMsixScript) { - Write-Host " - MSIX 패키징 실행..." -ForegroundColor Gray - & pwsh -File $buildMsixScript -Configuration Release -Platform x64 + Write-Host " - MSIX 패키징 및 자동 서명 실행..." -ForegroundColor Gray + & pwsh -File $buildMsixScript -Configuration Release -Platform x64 -Sign + if ($LASTEXITCODE -ne 0) { + Write-Error "MSIX 패키징 및 서명 실패!" + exit 1 + } } + + # 서명 무결성 검증 (0x800B010A 방지 게이트) + $msixFile = Join-Path $distDir "Everything2Everything-x64.msix" + if (Test-Path $msixFile) { + $sig = Get-AuthenticodeSignature $msixFile + if ($sig.Status -ne 'Valid') { + Write-Error "MSIX 서명 검증 실패 (Status: $($sig.Status))! 미서명 패키지는 배포할 수 없습니다." + exit 1 + } + Write-Host " - MSIX 디지털 서명 검증 통과 (Status: Valid, Signer: $($sig.SignerCertificate.Subject))" -ForegroundColor Green + } + + # 4. 1-클릭 설치 번들 ZIP 생성 (MSIX + .cer + Install.cmd) + $setupZip = Join-Path $distDir "Everything2Everything-$Version-Setup.zip" + $cerFile = Join-Path $distDir "Everything2Everything-DevCert.cer" + $installCmd = Join-Path $distDir "Install.cmd" + + $bundleFiles = @($msixFile) + if (Test-Path $cerFile) { $bundleFiles += $cerFile } + if (Test-Path $installCmd) { $bundleFiles += $installCmd } + + if (Test-Path $setupZip) { Remove-Item $setupZip -Force } + Compress-Archive -Path $bundleFiles -DestinationPath $setupZip + Write-Host " - 1-클릭 설치 번들 ZIP 생성: $setupZip" -ForegroundColor Gray } else { Write-Host " (DryRun: 빌드 단계 건너뜀)" -ForegroundColor DarkGray } @@ -199,6 +241,18 @@ $ReleaseNotes curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-x64.msix" -F "attachment=@$msixFile" | Out-Null Write-Host " -> MSIX 업로드 완료!" -ForegroundColor Gray } + + if (Test-Path $setupZip) { + Write-Host " - 1-클릭 설치 번들 ZIP 업로드 중..." -ForegroundColor Gray + curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-$Version-Setup.zip" -F "attachment=@$setupZip" | Out-Null + Write-Host " -> 1-클릭 설치 번들 ZIP 업로드 완료!" -ForegroundColor Gray + } + + if (Test-Path $cerFile) { + Write-Host " - 공개 개발자 인증서(.cer) 업로드 중..." -ForegroundColor Gray + curl.exe -s -u "yunchan:ONVI2v4J#y" -X POST "$uploadUrl`?name=Everything2Everything-DevCert.cer" -F "attachment=@$cerFile" | Out-Null + Write-Host " -> 공개 개발자 인증서 업로드 완료!" -ForegroundColor Gray + } } catch { Write-Warning "Forgejo 릴리즈 API 호출 중 경고: $_" }