diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..899fe66 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,43 @@ +name: Build + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Restore + Build solution + shell: pwsh + run: | + dotnet build EverythingToJpeg.slnx -c Release --nologo + + - name: Build MSIX + shell: pwsh + run: | + ./packaging/GenerateAssets.ps1 + ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 + + - name: Upload MSIX artifact + uses: actions/upload-artifact@v4 + with: + name: EverythingToJpeg-x64-msix-${{ github.sha }} + path: packaging/dist/EverythingToJpeg-x64.msix + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3066cb1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: '릴리즈 태그 (예: v0.1.0)' + required: true + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Generate placeholder logos + shell: pwsh + run: ./packaging/GenerateAssets.ps1 + + - name: Decode PFX (if secret provided) + id: pfx + shell: pwsh + env: + PFX_BASE64: ${{ secrets.PFX_BASE64 }} + run: | + if ($env:PFX_BASE64) { + $bytes = [Convert]::FromBase64String($env:PFX_BASE64) + $path = Join-Path $env:RUNNER_TEMP 'cert.pfx' + [IO.File]::WriteAllBytes($path, $bytes) + "pfx_path=$path" >> $env:GITHUB_OUTPUT + Write-Host '✅ PFX 디코딩 완료 — 서명 모드로 빌드' + } else { + Write-Host 'ℹ PFX_BASE64 secret 없음 — unsigned 모드로 빌드' + } + + - name: Build MSIX (signed if PFX, else unsigned) + shell: pwsh + env: + PFX_PASSWORD: ${{ secrets.PFX_PASSWORD }} + run: | + $pfx = '${{ steps.pfx.outputs.pfx_path }}' + if ($pfx) { + $sec = ConvertTo-SecureString -String $env:PFX_PASSWORD -AsPlainText -Force + ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 -Sign -PfxPath $pfx -PfxPassword $sec + } else { + ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 + } + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: EverythingToJpeg-x64-msix + path: packaging/dist/EverythingToJpeg-x64.msix + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: packaging/dist/EverythingToJpeg-x64.msix + generate_release_notes: true + body: | + ## EverythingToJpeg + + 모든 파일을 우클릭 한 번으로 JPEG 로 변환합니다. + + ### 설치 방법 + 1. 아래 `EverythingToJpeg-x64.msix` 다운로드. + 2. 관리자 PowerShell: + ```powershell + # 자체 서명 인증서를 신뢰 저장소에 등록 (PFX 별도 보유 필요) + Import-PfxCertificate -CertStoreLocation Cert:\LocalMachine\TrustedPeople ` + -FilePath .\EverythingToJpeg-DevCert.pfx ` + -Password (ConvertTo-SecureString 'EverythingToJpegDev' -AsPlainText -Force) + + # MSIX 사이드로드 + Add-AppxPackage -Path .\EverythingToJpeg-x64.msix -ForceApplicationShutdown + ``` + 3. PNG/HEIC/PDF 등 파일 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…" + + > 이 패키지는 자체 서명입니다. 첫 PC 1회만 인증서 등록이 필요합니다. + + ### 변경사항 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5221139 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# build outputs +bin/ +obj/ +publish/ +publish-self/ + +# IDE +.vs/ +.vscode/ +*.user +*.suo + +# OS +Thumbs.db +.DS_Store + +# logs +*.log + +# rider +.idea/ + +# Phase 2 packaging artifacts +*.pfx +*.msix +*.msixbundle +*.appx +*.appxbundle +packaging/Layout/ +packaging/dist/ + +# C++ project intermediate +src/EverythingToJpeg.Shell/x64/ +src/EverythingToJpeg.Shell/Win32/ +src/EverythingToJpeg.Shell/Debug/ +src/EverythingToJpeg.Shell/Release/ +src/EverythingToJpeg.Shell/.vs/ +src/EverythingToJpeg.Shell/Everythi*/ +*.tlog +*.obj +*.pch +*.iobj +*.ipdb +*.recipe +*.lastbuildstate + +# .NET artifacts dir from BuildMsix.ps1 +artifacts/ + +# NuGet packages restore + nuget.exe cache +packages/ +tools/ diff --git a/EverythingToJpeg.slnx b/EverythingToJpeg.slnx new file mode 100644 index 0000000..b50fab5 --- /dev/null +++ b/EverythingToJpeg.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c9d5f0 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# EverythingToJpeg + +Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BMP · TIFF · WebP · AVIF · HEIC · RAW · PSD · PDF · DOCX 를 지원합니다. + +- **빠른 변환** — 다이얼로그 없이 원본 폴더의 `<원본명>_jpeg/` 하위에 즉시 저장 +- **변환…** — 옵션 다이얼로그(품질, 출력 위치, 이름 충돌, 크기 제한, PDF DPI) +- 진행 상황 + 썸네일 + 드래그 & 드롭 (메인 창) + +## 빠른 시작 + +### 1) 빌드 + +```powershell +# 솔루션 빌드 +dotnet build EverythingToJpeg.slnx -c Release + +# 단일 폴더 publish (framework-dependent, .NET 9 Desktop Runtime 필요) +dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` + -c Release -r win-x64 --self-contained false -o publish + +# .NET 런타임 동봉 (단일 사용자 배포가 편함) +dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` + -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=false -o publish-self +``` + +산출물: `publish\EverythingToJpeg.exe` + +### 2) 컨텍스트 메뉴 등록 + +`EverythingToJpeg.exe`를 한 번 실행 → "컨텍스트 메뉴 등록" 클릭. 또는 CLI: + +```powershell +.\EverythingToJpeg.exe register +.\EverythingToJpeg.exe unregister +``` + +> Windows 11 메인 우클릭 메뉴가 아니라 "추가 옵션 표시(Shift+우클릭)" 메뉴에 노출됩니다. 메인 메뉴 노출은 Phase 2에서 IExplorerCommand + MSIX 로 추가 예정. + +### 3) 사용 + +- 파일 우클릭 → "추가 옵션 표시" → **JPEG로 빠른 변환** 또는 **JPEG로 변환…** +- 또는 메인 창에 파일/폴더를 끌어다 놓기 + +## 지원 현황 + +| 형식 | 상태 | 참고 | +|---|---|---| +| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x | +| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 | +| PDF | ✅ 준비됨 | PDFtoImage(PDFium) | +| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 | +| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 | +| HWP · HWPX | ⚙ 외부 도구 필요 | LibreOffice + [H2Orestart](https://github.com/ebandal/H2Orestart) 확장 | + +## 기술 스택 + +- **.NET 9 + WPF**, Windows 10.0.19041.0+ +- UI: **WPF-UI 4.3** (Win11 Fluent 2 — Mica 백드롭, Segoe UI Variable 타입 램프) +- 변환 엔진: Magick.NET, PDFtoImage, PhotoSauce.MagicScaler + Libheif + +## 로드맵 + +| 단계 | 상태 | 내용 | +|---|---|---| +| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환, Fluent UI | +| Phase 2 | ✅ | C++ IExplorerCommand DLL + MSIX Sparse Package | +| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 | +| Phase 4 | ✅ | **자체 서명 MSIX 자동화** — `packaging/BuildAndSign.ps1` 한 방으로 인증서 생성 + 서명 + 패키지 | +| Phase 5 | ✅ | GitHub 리모트 + Actions 릴리즈 워크플로 | +| Phase 6 | ✅ | Past Results 영구 저장 (`%LocalAppData%\EverythingToJpeg\history.jsonl`) | +| Phase 7 | ✅ | 단축키 (Ctrl+O 추가, Ctrl+Enter 변환, Esc 닫기, F5 새로고침), 코드 정리 | +| Phase 8 | ✅ | 시작 시 Provider 가용성 자동 체크, 사이드바에 외부 도구 필요 안내 | + +## 키보드 단축키 + +| 키 | 동작 | +|---|---| +| Ctrl+O | 파일 추가 | +| Ctrl+Enter | Process Queue (변환 시작) | +| Esc | 창 닫기 | +| F5 | 통계 새로고침 | +| Active Queue 행 클릭 | 우측 Preview에 즉시 표시 | +| Past Results 행 클릭 | 원본 파일이 있으면 Preview 표시 | + +## 두 가지 사용 방식 + +### A) Portable EXE — 가장 가벼움 (Phase 1) +- `dotnet publish` 산출물 그대로 사용 +- 우클릭 → **추가 옵션 표시** → "JPEG로 빠른 변환" / "JPEG로 변환…" +- 인증서·서명 불필요 + +### B) MSIX 패키지 — Win11 메인 메뉴 노출 (Phase 2) +- `packaging/BuildMsix.ps1` 로 MSIX 빌드 +- 자체 서명 인증서를 `LocalMachine\TrustedPeople`에 임포트 후 사이드로드 +- 우클릭 → 바로 메인 메뉴에 항목 노출 +- 자세한 절차는 [packaging/README.md](packaging/README.md) + +## 프로젝트 구조 + +``` +everythingToJpeg/ +├── EverythingToJpeg.slnx +└── src/ + ├── EverythingToJpeg.Core/ — 변환 엔진, Provider 추상화 + │ ├── Providers/ — IConverterProvider + Capability 메타데이터 + │ ├── Converters/ — Magick / Heic / Pdf / Docx / Html / Hwpx + │ ├── ConversionEngine.cs + │ └── EverythingToJpegBootstrap.cs + └── EverythingToJpeg.App/ — WPF + CLI 통합 진입점 + ├── App.xaml(.cs) — CLI 라우터 + ├── Cli/CliRouter.cs — verb: quick / dialog / register / diagnose + ├── Shell/ContextMenuRegistrar.cs — HKCU 레지스트리 등록 + └── Views/ — Fluent UI 화면 +``` + +## Provider 전략 (확장 포인트) + +새 형식을 지원하려면 `IConverterProvider`를 구현하고 `EverythingToJpegBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability`에 다음을 명시하세요: + +- `Status` — `Available` / `Preview` / `RequiresExternal` / `ComingSoon` / `Disabled` +- `Extensions` — 자동 라우팅 + 컨텍스트 메뉴 등록 키 +- `ExternalDependencies` — UI에 자동 노출되는 외부 도구 +- `RoadmapNote` — 사용자에게 보여줄 향후 계획 + +`ComingSoon` 상태는 메인 창의 "지원 형식" 섹션에 자동 노출되지만 컨텍스트 메뉴 등록에서는 자동 제외됩니다. + +## 라이선스 + +MIT (예정). diff --git a/packaging/Assets/Square150x150Logo.png b/packaging/Assets/Square150x150Logo.png new file mode 100644 index 0000000..bd402c6 Binary files /dev/null and b/packaging/Assets/Square150x150Logo.png differ diff --git a/packaging/Assets/Square44x44Logo.png b/packaging/Assets/Square44x44Logo.png new file mode 100644 index 0000000..997ce99 Binary files /dev/null and b/packaging/Assets/Square44x44Logo.png differ diff --git a/packaging/Assets/StoreLogo.png b/packaging/Assets/StoreLogo.png new file mode 100644 index 0000000..a052a96 Binary files /dev/null and b/packaging/Assets/StoreLogo.png differ diff --git a/packaging/Assets/Wide310x150Logo.png b/packaging/Assets/Wide310x150Logo.png new file mode 100644 index 0000000..10bdd46 Binary files /dev/null and b/packaging/Assets/Wide310x150Logo.png differ diff --git a/packaging/BuildAndSign.ps1 b/packaging/BuildAndSign.ps1 new file mode 100644 index 0000000..2440418 --- /dev/null +++ b/packaging/BuildAndSign.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 5.1 +# 한방에 빌드+자체서명: 인증서 자동 생성 → MSIX 빌드 → 서명까지 일관 처리. +# 산출: +# - packaging/dist/EverythingToJpeg-x64.msix (서명됨) +# - packaging/EverythingToJpeg-DevCert.pfx (5대 PC 신뢰 등록용) + +[CmdletBinding()] +param( + [string]$Subject = 'CN=EverythingToJpegDev', + [string]$Password = 'EverythingToJpegDev', + [string]$Configuration = 'Release', + [string]$Platform = 'x64' +) + +$ErrorActionPreference = 'Stop' +$packagingDir = $PSScriptRoot +$pfxPath = Join-Path $packagingDir 'EverythingToJpeg-DevCert.pfx' +$securePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force + +# ---- 1) 인증서 ---- +$existing = Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue | + Where-Object { $_.Subject -eq $Subject } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + +if (-not $existing) { + Write-Host "[1/3] 자체 서명 인증서 생성: $Subject" + $existing = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $Subject ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(5) ` + -FriendlyName 'EverythingToJpeg Dev' +} +else { + Write-Host "[1/3] 기존 인증서 재사용 (Thumbprint $($existing.Thumbprint))" +} + +if (-not (Test-Path $pfxPath)) { + Export-PfxCertificate -Cert $existing -FilePath $pfxPath -Password $securePassword | Out-Null + Write-Host " PFX 내보냄: $pfxPath" +} + +# ---- 2) MSIX 빌드 + 서명 ---- +Write-Host "[2/3] MSIX 빌드 + 서명" +& (Join-Path $packagingDir 'BuildMsix.ps1') ` + -Configuration $Configuration ` + -Platform $Platform ` + -Sign ` + -CertThumbprint $existing.Thumbprint + +# ---- 3) 안내 ---- +Write-Host '' +Write-Host '[3/3] 완료. 다음 단계:' +Write-Host ' 1. PFX 파일을 5대 PC 각각에 복사:' +Write-Host " $pfxPath" +Write-Host ' 2. 각 PC에서 관리자 PowerShell:' +Write-Host ' cd packaging' +Write-Host " .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\dist\EverythingToJpeg-x64.msix" +Write-Host ' PFX 비밀번호:' $Password +Write-Host '' +Write-Host ' 3. 우클릭 → JPEG로 빠른 변환 / JPEG로 변환… 이 메인 메뉴에 노출됨.' diff --git a/packaging/BuildMsix.ps1 b/packaging/BuildMsix.ps1 new file mode 100644 index 0000000..14dd699 --- /dev/null +++ b/packaging/BuildMsix.ps1 @@ -0,0 +1,155 @@ +#Requires -Version 5.1 +# MSIX 빌드 파이프라인. +# 1) .NET App publish (framework-dependent) +# 2) C++ Shell DLL 빌드 +# 3) 패키지 Layout 디렉토리 구성 +# 4) makeappx pack +# 5) (선택) signtool sign + +[CmdletBinding()] +param( + [string]$Configuration = 'Release', + [string]$Platform = 'x64', + [switch]$Sign, + [string]$PfxPath, + [securestring]$PfxPassword, + [string]$CertThumbprint, + [switch]$SelfContained +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$packagingDir = $PSScriptRoot +$layoutDir = Join-Path $packagingDir 'Layout' +$distDir = Join-Path $packagingDir 'dist' +$manifestPath = Join-Path $packagingDir 'Package.appxmanifest' +$assetsSrc = Join-Path $packagingDir 'Assets' + +$appProj = Join-Path $repoRoot 'src\EverythingToJpeg.App\EverythingToJpeg.App.csproj' +$shellProj = Join-Path $repoRoot 'src\EverythingToJpeg.Shell\EverythingToJpeg.Shell.vcxproj' + +function Find-WindowsSdkTool { + param([string]$ToolName) + $sdkRoots = @( + "${env:ProgramFiles(x86)}\Windows Kits\10\bin", + "$env:ProgramFiles\Windows Kits\10\bin" + ) | Where-Object { Test-Path $_ } + foreach ($root in $sdkRoots) { + $versions = Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object Name -Descending + foreach ($v in $versions) { + $candidate = Join-Path $v.FullName "x64\$ToolName" + if (Test-Path $candidate) { return [string]$candidate } + } + } + return $null +} + +$makeappx = Find-WindowsSdkTool 'makeappx.exe' +if (-not $makeappx) { throw 'makeappx.exe를 찾지 못했습니다. Windows 10 SDK가 필요합니다.' } +Write-Host "makeappx: $makeappx" + +if ($Sign) { + $signtool = Find-WindowsSdkTool 'signtool.exe' + if (-not $signtool) { throw 'signtool.exe를 찾지 못했습니다.' } + Write-Host "signtool: $signtool" +} + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { throw 'vswhere.exe를 찾지 못했습니다.' } +$msbuild = (& $vswhere -latest -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1) +if (-not $msbuild) { throw 'MSBuild를 찾지 못했습니다.' } +Write-Host "msbuild: $msbuild" + +# ---- 1) .NET App publish ---- +Write-Host '' +Write-Host '[1/5] .NET App publish' +$publishOut = Join-Path $repoRoot ('artifacts\publish\app-' + $Platform.ToLower()) +if (Test-Path $publishOut) { Remove-Item $publishOut -Recurse -Force } +$rid = if ($Platform -eq 'ARM64') { 'win-arm64' } else { 'win-x64' } +$selfFlag = if ($SelfContained) { 'true' } else { 'false' } +& dotnet publish $appProj -c $Configuration -r $rid --self-contained $selfFlag -o $publishOut | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'dotnet publish 실패' } + +# ---- 2) C++ Shell DLL ---- +Write-Host '' +Write-Host '[2/5] C++ Shell DLL 빌드' + +# nuget.exe 자동 다운로드 (없으면) +$nuget = Get-Command nuget.exe -ErrorAction SilentlyContinue +if (-not $nuget) { + $nugetExe = Join-Path $repoRoot 'tools\nuget.exe' + if (-not (Test-Path $nugetExe)) { + New-Item -ItemType Directory -Path (Split-Path $nugetExe) -Force | Out-Null + Write-Host ' nuget.exe 다운로드 중…' + Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe + } + $nugetCmd = $nugetExe +} else { + $nugetCmd = $nuget.Source +} + +$packagesDir = Join-Path $repoRoot 'packages' +& $nugetCmd restore (Join-Path $repoRoot 'src\EverythingToJpeg.Shell\packages.config') -PackagesDirectory $packagesDir | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'NuGet restore 실패' } + +& $msbuild $shellProj /p:Configuration=$Configuration /p:Platform=$Platform /m /v:minimal | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'Shell build 실패' } +$shellDll = Join-Path $repoRoot ("src\EverythingToJpeg.Shell\$Platform\$Configuration\EverythingToJpeg.Shell.dll") +if (-not (Test-Path $shellDll)) { throw "Shell DLL 산출물 없음: $shellDll" } + +# ---- 3) Layout 디렉토리 ---- +Write-Host '' +Write-Host '[3/5] Layout 디렉토리 구성' +if (Test-Path $layoutDir) { Remove-Item $layoutDir -Recurse -Force } +New-Item -ItemType Directory -Path $layoutDir | Out-Null + +Copy-Item -Path (Join-Path $publishOut '*') -Destination $layoutDir -Recurse -Force +Copy-Item -Path $shellDll -Destination $layoutDir -Force + +$layoutAssets = Join-Path $layoutDir 'Assets' +New-Item -ItemType Directory -Path $layoutAssets -Force | Out-Null +Copy-Item -Path (Join-Path $assetsSrc '*') -Destination $layoutAssets -Force + +Copy-Item -Path $manifestPath -Destination (Join-Path $layoutDir 'AppxManifest.xml') -Force + +# ---- 4) makeappx pack ---- +Write-Host '' +Write-Host '[4/5] makeappx pack' +if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } +$msixPath = Join-Path $distDir ("EverythingToJpeg-$($Platform.ToLower()).msix") +if (Test-Path $msixPath) { Remove-Item $msixPath -Force } +& $makeappx pack /d $layoutDir /p $msixPath /o | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' } +Write-Host "✅ MSIX 산출: $msixPath" + +# ---- 5) (선택) sign ---- +if ($Sign) { + Write-Host '' + Write-Host '[5/5] signtool sign' + if ($CertThumbprint) { + & $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host + } elseif ($PfxPath) { + if (-not $PfxPassword) { + $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호' + } + $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 + } finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } else { + throw '서명을 하려면 -CertThumbprint 또는 -PfxPath 가 필요합니다.' + } + if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' } + Write-Host '✅ 서명 완료' +} else { + Write-Host '' + Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.' +} + +Write-Host '' +Write-Host "최종 산출물: $msixPath" diff --git a/packaging/CreateDevCert.ps1 b/packaging/CreateDevCert.ps1 new file mode 100644 index 0000000..9889cc2 --- /dev/null +++ b/packaging/CreateDevCert.ps1 @@ -0,0 +1,44 @@ +#Requires -Version 5.1 +# 자체 서명 코드 사이닝 인증서 생성 + PFX export. +# Subject가 Package.appxmanifest 의 와 정확히 일치해야 한다. + +[CmdletBinding()] +param( + [string]$Subject = 'CN=EverythingToJpegDev', + [string]$OutputPfx = (Join-Path $PSScriptRoot 'EverythingToJpeg-DevCert.pfx'), + [securestring]$Password +) + +$ErrorActionPreference = 'Stop' + +if (-not $Password) { + Write-Host '인증서 PFX 보호용 비밀번호를 입력하세요. (5대 PC에 설치할 때 필요합니다)' + $Password = Read-Host -AsSecureString -Prompt '비밀번호' +} + +Write-Host "Creating self-signed code-signing certificate: $Subject" +$cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $Subject ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(5) ` + -FriendlyName 'EverythingToJpeg Dev' + +Write-Host "Thumbprint: $($cert.Thumbprint)" +Write-Host "Exporting PFX: $OutputPfx" +Export-PfxCertificate -Cert $cert -FilePath $OutputPfx -Password $Password | Out-Null + +Write-Host '' +Write-Host '--- 다음 단계 ---' +Write-Host " 1. 이 PFX 파일을 5대 PC 각각에 복사" +Write-Host " 2. 각 PC에서 관리자 PowerShell로:" +Write-Host ' Import-PfxCertificate -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" -FilePath <경로>.pfx -Password (Read-Host -AsSecureString)' +Write-Host ' 3. MSIX 빌드 시 BuildMsix.ps1 -CertThumbprint ' + $cert.Thumbprint +Write-Host '' +Write-Host "PFX는 비밀이므로 절대 git에 커밋하지 마세요. (.gitignore에 *.pfx 추가됨)" diff --git a/packaging/GenerateAssets.ps1 b/packaging/GenerateAssets.ps1 new file mode 100644 index 0000000..6813a8f --- /dev/null +++ b/packaging/GenerateAssets.ps1 @@ -0,0 +1,56 @@ +#Requires -Version 5.1 +# packaging/Assets/ 의 placeholder 아이콘들을 생성한다. +# 추후 진짜 로고로 교체. + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$assetsDir = Join-Path $PSScriptRoot 'Assets' +if (-not (Test-Path $assetsDir)) { New-Item -ItemType Directory -Path $assetsDir | Out-Null } + +function New-LogoPng { + param( + [int]$Width, + [int]$Height, + [string]$Path, + [string]$Label = '' + ) + $bmp = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit + + $rect = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height) + $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( + $rect, + [System.Drawing.Color]::FromArgb(0xFF, 0x3B, 0x82, 0xF6), + [System.Drawing.Color]::FromArgb(0xFF, 0x1E, 0x40, 0xAF), + [System.Drawing.Drawing2D.LinearGradientMode]::Diagonal) + $g.FillRectangle($brush, $rect) + + if ($Label) { + $fontSize = [Math]::Max(8, [Math]::Min($Width, $Height) / 5) + $font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold) + $textBrush = [System.Drawing.Brushes]::White + $sf = New-Object System.Drawing.StringFormat + $sf.Alignment = [System.Drawing.StringAlignment]::Center + $sf.LineAlignment = [System.Drawing.StringAlignment]::Center + $rectF = New-Object System.Drawing.RectangleF(0, 0, [float]$Width, [float]$Height) + $g.DrawString($Label, $font, $textBrush, $rectF, $sf) + $font.Dispose() + $sf.Dispose() + } + + $g.Dispose() + $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + $brush.Dispose() + Write-Host " [+] $Path ($Width x $Height)" +} + +Write-Host 'Generating placeholder logos…' +New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2J' +New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2J' +New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2J' +New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'EverythingToJpeg' +Write-Host 'Done.' diff --git a/packaging/Install-EverythingToJpeg.ps1 b/packaging/Install-EverythingToJpeg.ps1 new file mode 100644 index 0000000..df2e633 --- /dev/null +++ b/packaging/Install-EverythingToJpeg.ps1 @@ -0,0 +1,42 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +# 5대 PC에서 MSIX 사이드로드 설치 — 1회 셋업 스크립트. +# 사용법: +# PowerShell (관리자) > .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg.msix + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$PfxPath, + [Parameter(Mandatory)] [string]$MsixPath, + [securestring]$PfxPassword, + [string]$Password = 'EverythingToJpegDev' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $PfxPath" } +if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" } + +if (-not $PfxPassword) { + $PfxPassword = ConvertTo-SecureString -String $Password -AsPlainText -Force +} + +Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…' +$importResult = Import-PfxCertificate ` + -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' ` + -FilePath $PfxPath ` + -Password $PfxPassword +Write-Host " Thumbprint: $($importResult.Thumbprint)" + +Write-Host '[2/3] 인증서를 LocalMachine\Root에도 임포트 (체인 신뢰)…' +Import-PfxCertificate ` + -CertStoreLocation 'Cert:\LocalMachine\Root' ` + -FilePath $PfxPath ` + -Password $PfxPassword | Out-Null + +Write-Host '[3/3] MSIX 패키지 설치…' +Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown + +Write-Host '' +Write-Host '✅ 설치 완료. Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…" 항목이 보일 겁니다.' +Write-Host ' (탐색기 재시작이 필요할 수 있음: 작업 관리자 → "Windows 탐색기" 다시 시작)' diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest new file mode 100644 index 0000000..f79706a --- /dev/null +++ b/packaging/Package.appxmanifest @@ -0,0 +1,202 @@ + + + + + + + EverythingToJpeg + YunChan + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..92b9e26 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,87 @@ +# Phase 2 — MSIX 패키징 + +Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…"을 띄우는 정공법. + +## 구성 + +``` +packaging/ +├── Package.appxmanifest — IExplorerCommand 등록 (com:Class + desktop4:FileExplorerContextMenus) +├── Assets/ — 앱 아이콘 (placeholder, GenerateAssets.ps1로 생성) +├── GenerateAssets.ps1 — placeholder PNG 일괄 생성 +├── CreateDevCert.ps1 — 자체 서명 코드사이닝 인증서 생성 + PFX export +├── BuildMsix.ps1 — .NET publish + C++ DLL 빌드 + makeappx + (선택) signtool +└── Install-EverythingToJpeg.ps1 — 5대 PC 1회 설치 스크립트 +``` + +C++ Shell DLL은 `src/EverythingToJpeg.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다. + +## 1회: 자체 서명 인증서 만들기 + +```powershell +cd packaging +.\CreateDevCert.ps1 +# Subject 기본값: CN=EverythingToJpegDev (Package.appxmanifest의 Publisher와 일치) +# 비밀번호 입력 → EverythingToJpeg-DevCert.pfx 생성 +``` + +출력된 Thumbprint를 `BuildMsix.ps1 -CertThumbprint <값>` 으로 사용하거나, PFX 파일을 5대 PC에 복사해서 설치 시 사용합니다. + +## 빌드 + +### 미서명 (Phase 1 그대로 사용 가능, 메인 메뉴 노출은 안 됨) +```powershell +.\BuildMsix.ps1 +# 산출: packaging/dist/EverythingToJpeg-x64.msix +``` + +### 서명 +```powershell +# 방법 1: PFX 사용 +.\BuildMsix.ps1 -Sign -PfxPath .\EverythingToJpeg-DevCert.pfx + +# 방법 2: 인증서 저장소의 Thumbprint +.\BuildMsix.ps1 -Sign -CertThumbprint AABBCCDD... +``` + +## 5대 PC 설치 (관리자 PowerShell) + +```powershell +.\Install-EverythingToJpeg.ps1 ` + -PfxPath .\EverythingToJpeg-DevCert.pfx ` + -MsixPath .\EverythingToJpeg-x64.msix +``` + +스크립트가 자동으로: +1. PFX를 `LocalMachine\TrustedPeople` 에 임포트 +2. PFX를 `LocalMachine\Root` 에도 임포트 (체인 신뢰) +3. `Add-AppxPackage` 로 MSIX 사이드로드 + +설치 후 PNG/JPG/HEIC/PDF/DOCX 등을 우클릭하면 **메인 메뉴에 직접** "JPEG로 빠른 변환" / "JPEG로 변환…"이 보입니다. + +## 미서명 사이드로드 (Phase 2 임시 사용) + +자체 서명 만들기조차 귀찮을 때: +```powershell +# 개발자 모드 켜기: 설정 → 개인 정보 및 보안 → 개발자용 → 켜기 +Add-AppxPackage -AllowUnsigned -Path .\EverythingToJpeg-x64.msix +``` +> Win11 24H2부터 `-AllowUnsigned` 지원. 이전 버전은 자체 서명 권장. + +## CI/CD + +`.github/workflows/release.yml` — 태그 푸시(`v1.0.0` 등) 시 자동: +1. .NET / MSBuild 셋업 +2. `BuildMsix.ps1` 실행 (미서명) +3. GitHub Release 생성 + MSIX 첨부 + +서명까지 자동화하려면 GitHub Secrets에 `PFX_BASE64`, `PFX_PASSWORD`를 등록하고 워크플로에 단계 추가 (별도 보안 검토 후). + +## 트러블슈팅 + +| 증상 | 원인 / 해결 | +|---|---| +| `Add-AppxPackage`: "신뢰할 수 없는 인증서" | PFX를 `LocalMachine\TrustedPeople`에 임포트했는지 확인 (Install 스크립트 자동 수행) | +| 메뉴가 안 뜸 | 탐색기 재시작: 작업관리자 → "Windows 탐색기" 다시 시작 | +| Publisher 불일치 오류 | `Package.appxmanifest`의 `Publisher=` 와 인증서 `Subject` 가 정확히 일치해야 함 | +| `App identity required` | MSIX 패키지로 설치된 경우에만 IExplorerCommand 작동. portable EXE는 Phase 1 레지스트리 방식 사용 | diff --git a/src/EverythingToJpeg.App/App.xaml b/src/EverythingToJpeg.App/App.xaml new file mode 100644 index 0000000..7516c02 --- /dev/null +++ b/src/EverythingToJpeg.App/App.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + 4 + 8 + 16 + 24 + 32 + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs new file mode 100644 index 0000000..6ed2c32 --- /dev/null +++ b/src/EverythingToJpeg.App/App.xaml.cs @@ -0,0 +1,155 @@ +using System.Windows; +using EverythingToJpeg.App.Cli; +using EverythingToJpeg.App.Views; +using EverythingToJpeg.Core; + +namespace EverythingToJpeg.App; + +public partial class App : Application +{ + public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault(); + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + WireGlobalExceptionLogging(); + + var parsed = CliRouter.Parse(e.Args); + + switch (parsed.Mode) + { + case CliRouter.Mode.Help: + ConsoleHelper.WriteLine(CliRouter.HelpText()); + Environment.Exit(0); + return; + + case CliRouter.Mode.Register: + Environment.Exit(CliRouter.RunRegister(register: true)); + return; + + case CliRouter.Mode.Unregister: + Environment.Exit(CliRouter.RunRegister(register: false)); + return; + + case CliRouter.Mode.Diagnose: + ShowDiagnoseWindow(); + return; + + case CliRouter.Mode.Quick: + if (parsed.Files.Count == 0) + { + MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); + Shutdown(1); + return; + } + await RunQuickAsync(parsed.Files); + return; + + case CliRouter.Mode.Dialog: + ShowConvertDialog(parsed.Files); + return; + + case CliRouter.Mode.ShowMain: + default: + ShowMainWindow(); + return; + } + } + + private void ShowMainWindow() + { + var window = new MainWindow(); + MainWindow = window; + window.Show(); + } + + private void ShowConvertDialog(IReadOnlyList files) + { + var window = new Views.MainWindow(files); + MainWindow = window; + window.Show(); + } + + private void ShowDiagnoseWindow() + { + var window = new DiagnoseWindow(Engine); + MainWindow = window; + window.Show(); + } + + private async Task RunQuickAsync(IReadOnlyList files) + { + var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log"); + var log = new System.Text.StringBuilder(); + log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)"); + foreach (var f in files) log.AppendLine($" src: {f}"); + + var progress = new QuickProgressWindow(files.Count); + progress.Show(); + + try + { + var options = ConvertOptions.Quick(); + var reporter = new Progress(p => progress.Report(p)); + var results = await Engine.ConvertManyAsync(files, options, reporter); + + foreach (var r in results) + { + log.AppendLine($" [{r.Status}] {Path.GetFileName(r.SourcePath)} → {r.OutputPaths.Count} output(s)"); + if (r.Message is { Length: > 0 }) log.AppendLine($" msg: {r.Message}"); + if (r.Error is not null) log.AppendLine($" err: {r.Error}"); + foreach (var o in r.OutputPaths) log.AppendLine($" out: {o}"); + } + + progress.Finish(results); + } + catch (Exception ex) + { + log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}"); + log.AppendLine(ex.ToString()); + try { progress.Close(); } catch { } + MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + try { File.WriteAllText(logPath, log.ToString()); } catch { } + } + } + + private static void WireGlobalExceptionLogging() + { + var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log"); + + void Append(string source, Exception? ex) + { + try + { + File.AppendAllText(path, + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n\n"); + } + catch { } + } + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Append("AppDomain.UnhandledException", e.ExceptionObject as Exception); + + Current.DispatcherUnhandledException += (_, e) => + { + Append("Application.DispatcherUnhandledException", e.Exception); + MessageBox.Show( + "예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path, + "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + e.Handled = true; + }; + + System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, e) => + { + Append("TaskScheduler.UnobservedTaskException", e.Exception); + e.SetObserved(); + }; + } +} diff --git a/src/EverythingToJpeg.App/AssemblyInfo.cs b/src/EverythingToJpeg.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/EverythingToJpeg.App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/EverythingToJpeg.App/Cli/CliRouter.cs b/src/EverythingToJpeg.App/Cli/CliRouter.cs new file mode 100644 index 0000000..613be16 --- /dev/null +++ b/src/EverythingToJpeg.App/Cli/CliRouter.cs @@ -0,0 +1,120 @@ +using EverythingToJpeg.App.Shell; +using EverythingToJpeg.Core; + +namespace EverythingToJpeg.App.Cli; + +internal static class CliRouter +{ + public enum Mode + { + ShowMain, + Quick, + Dialog, + Register, + Unregister, + Diagnose, + Help, + } + + public sealed record ParsedArgs(Mode Mode, IReadOnlyList Files); + + public static ParsedArgs Parse(string[] args) + { + if (args is null || args.Length == 0) + return new ParsedArgs(Mode.ShowMain, Array.Empty()); + + var verb = args[0].Trim().ToLowerInvariant(); + var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList(); + + return verb switch + { + "quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)), + "dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)), + "register" => new ParsedArgs(Mode.Register, Array.Empty()), + "unregister" => new ParsedArgs(Mode.Unregister, Array.Empty()), + "diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty()), + "help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty()), + _ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)), + _ => new ParsedArgs(Mode.ShowMain, Array.Empty()), + }; + } + + private static IReadOnlyList ExpandFiles(IEnumerable raw) + { + var list = new List(); + foreach (var arg in raw) + { + if (string.IsNullOrWhiteSpace(arg)) continue; + try + { + if (File.Exists(arg)) { list.Add(Path.GetFullPath(arg)); continue; } + if (Directory.Exists(arg)) + { + foreach (var f in Directory.EnumerateFiles(arg, "*", SearchOption.TopDirectoryOnly)) + list.Add(Path.GetFullPath(f)); + } + } + catch { } + } + return list; + } + + public static string HelpText() + { + var engine = EverythingToJpegBootstrap.CreateDefault(); + var supported = string.Join(", ", + engine.Providers.Implemented.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e)); + var coming = string.Join(", ", + engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e)); + + return $""" + EverythingToJpeg — 모든 것을 JPEG로 + + 사용: + EverythingToJpeg.exe quick <파일들...> 빠른 변환 (다이얼로그 없이 즉시) + EverythingToJpeg.exe dialog <파일들...> 상세 옵션 다이얼로그 표시 + EverythingToJpeg.exe register 컨텍스트 메뉴 등록 (현재 사용자) + EverythingToJpeg.exe unregister 컨텍스트 메뉴 해제 + EverythingToJpeg.exe diagnose 지원 형식·외부 도구 진단 + EverythingToJpeg.exe 메인 창 표시 + + 지원 (지금): {supported} + 지원 예정 : {coming} + """; + } + + public static int RunRegister(bool register) + { + var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log"); + var log = new System.Text.StringBuilder(); + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] RunRegister start, register={register}"); + try + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Creating engine..."); + var engine = EverythingToJpegBootstrap.CreateDefault(); + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Engine OK. Implemented providers: {string.Join(",", engine.Providers.Implemented.Select(p => p.Capability.Id))}"); + + if (register) + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Calling Register..."); + ContextMenuRegistrar.Register(engine); + } + else + { + ContextMenuRegistrar.Unregister(engine); + } + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] OK"); + File.WriteAllText(logPath, log.ToString()); + Console.Out.WriteLine($"등록 완료. 로그: {logPath}"); + return 0; + } + catch (Exception ex) + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] EXCEPTION {ex.GetType().Name}: {ex.Message}"); + log.AppendLine(ex.ToString()); + try { File.WriteAllText(logPath, log.ToString()); } catch { } + Console.Error.WriteLine(ex.Message); + return 1; + } + } +} diff --git a/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs new file mode 100644 index 0000000..345f187 --- /dev/null +++ b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; + +namespace EverythingToJpeg.App.Cli; + +internal static class ConsoleHelper +{ + private static bool _attached; + + public static void WriteLine(string text) + { + EnsureAttached(); + Console.Out.WriteLine(text); + Console.Out.Flush(); + } + + private static void EnsureAttached() + { + if (_attached) return; + _attached = AttachConsole(-1); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); +} diff --git a/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj new file mode 100644 index 0000000..b68e912 --- /dev/null +++ b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + enable + enable + true + EverythingToJpeg + EverythingToJpeg.App + app.manifest + 10.0.17763.0 + 10.0.17763.0 + $(NoWarn);NU1901;NU1902;NU1903;NU1904 + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/GlobalUsings.cs b/src/EverythingToJpeg.App/GlobalUsings.cs new file mode 100644 index 0000000..8ba9936 --- /dev/null +++ b/src/EverythingToJpeg.App/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using MessageBox = System.Windows.MessageBox; +global using MessageBoxButton = System.Windows.MessageBoxButton; +global using MessageBoxImage = System.Windows.MessageBoxImage; +global using MessageBoxResult = System.Windows.MessageBoxResult; +global using TextBlock = System.Windows.Controls.TextBlock; diff --git a/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs new file mode 100644 index 0000000..d752263 --- /dev/null +++ b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs @@ -0,0 +1,96 @@ +using EverythingToJpeg.Core; +using EverythingToJpeg.Core.Providers; +using Microsoft.Win32; + +namespace EverythingToJpeg.App.Shell; + +internal static class ContextMenuRegistrar +{ + private const string QuickVerb = "EverythingToJpeg.Quick"; + private const string DialogVerb = "EverythingToJpeg.Dialog"; + + private const string QuickLabel = "JPEG로 빠른 변환"; + private const string DialogLabel = "JPEG로 변환…"; + + public static void Register(ConversionEngine engine) + { + var exe = GetAppExecutablePath(); + var icon = exe + ",0"; + + foreach (var ext in CollectExtensions(engine)) + { + WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\""); + WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\""); + } + + NotifyShell(); + } + + public static void Unregister(ConversionEngine engine) + { + foreach (var ext in CollectExtensions(engine)) + { + DeleteVerb(ext, QuickVerb); + DeleteVerb(ext, DialogVerb); + } + NotifyShell(); + } + + private static IEnumerable CollectExtensions(ConversionEngine engine) + { + return engine.Providers.Implemented + .Where(p => p.Capability.CanRegisterContextMenu) + .SelectMany(p => p.Capability.Extensions) + .Select(e => e.StartsWith('.') ? e : "." + e) + .Select(e => e.ToLowerInvariant()) + .Distinct(); + } + + private static void WriteVerb(string ext, string verb, string label, string icon, string command) + { + var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}"; + using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true) + ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}"); + + verbKey.SetValue(null, label, RegistryValueKind.String); + verbKey.SetValue("Icon", icon, RegistryValueKind.String); + verbKey.SetValue("MUIVerb", label, RegistryValueKind.String); + + using var commandKey = verbKey.CreateSubKey("command", writable: true) + ?? throw new InvalidOperationException("command 하위 키 생성 실패"); + commandKey.SetValue(null, command, RegistryValueKind.String); + } + + private static void DeleteVerb(string ext, string verb) + { + var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell"; + try + { + using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true); + parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false); + } + catch + { + } + } + + private static string GetAppExecutablePath() + { + var exe = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe; + return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar + "EverythingToJpeg.exe"; + } + + private static void NotifyShell() + { + try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); } + catch { } + } + + private static class NativeMethods + { + [System.Runtime.InteropServices.DllImport("shell32.dll")] + public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + } +} diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml new file mode 100644 index 0000000..d5b95ec --- /dev/null +++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs new file mode 100644 index 0000000..7e6e2e6 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs @@ -0,0 +1,118 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using EverythingToJpeg.Core; +using EverythingToJpeg.Core.Providers; +using Wpf.Ui.Controls; + +namespace EverythingToJpeg.App.Views; + +public partial class DiagnoseWindow : FluentWindow +{ + private readonly ConversionEngine _engine; + + public DiagnoseWindow(ConversionEngine engine) + { + _engine = engine; + InitializeComponent(); + Loaded += async (_, _) => await PopulateAsync(); + } + + private async Task PopulateAsync() + { + ItemsPanel.Children.Clear(); + + ItemsPanel.Children.Add(BuildSection("환경", new[] + { + ("OS", Environment.OSVersion.VersionString), + (".NET", Environment.Version.ToString()), + ("실행 경로", Environment.ProcessPath ?? AppContext.BaseDirectory), + })); + + foreach (var provider in _engine.Providers.All) + { + var availability = await provider.CheckAvailabilityAsync(); + ItemsPanel.Children.Add(BuildProviderCard(provider, availability)); + } + } + + private static UIElement BuildSection(string title, IEnumerable<(string Key, string Value)> items) + { + var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) }; + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = title, + FontSize = 14, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(0, 0, 0, 8), + }); + foreach (var (k, v) in items) + { + var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + var keyText = new TextBlock { Text = k, FontSize = 12, Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush") }; + var valueText = new TextBlock { Text = v, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetColumn(valueText, 1); + row.Children.Add(keyText); + row.Children.Add(valueText); + stack.Children.Add(row); + } + card.Content = stack; + return card; + } + + private UIElement BuildProviderCard(IConverterProvider provider, ProviderAvailability availability) + { + var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) }; + var stack = new StackPanel(); + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(new TextBlock + { + Text = provider.Capability.DisplayName, + FontSize = 14, + FontWeight = FontWeights.SemiBold, + }); + var (badgeText, brushKey) = (provider.Capability.Status, availability.IsReady) switch + { + (ProviderStatus.ComingSoon, _) => ("개발 중", "BadgeMutedBrush"), + (ProviderStatus.Disabled, _) => ("비활성", "BadgeMutedBrush"), + (_, true) => ("준비됨", "BadgeReadyBrush"), + _ => ("점검 필요", "BadgeWarnBrush"), + }; + header.Children.Add(new Border + { + Background = (Brush)Application.Current.FindResource(brushKey), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(8, 2, 8, 2), + Margin = new Thickness(8, 0, 0, 0), + Child = new TextBlock { Text = badgeText, FontSize = 11, Foreground = Brushes.White }, + }); + stack.Children.Add(header); + + stack.Children.Add(new TextBlock + { + Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}", + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"), + Margin = new Thickness(0, 4, 0, 0), + }); + if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason)) + { + stack.Children.Add(new TextBlock + { + Text = availability.Reason, + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }); + } + card.Content = stack; + return card; + } + + private async void OnRefreshClick(object sender, RoutedEventArgs e) => await PopulateAsync(); + private void OnCloseClick(object sender, RoutedEventArgs e) => Close(); +} diff --git a/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml new file mode 100644 index 0000000..ef512f6 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inter, Segoe UI Variable Text, Segoe UI + JetBrains Mono, Cascadia Mono, Consolas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/EverythingToJpeg.App/Views/MainWindow.xaml new file mode 100644 index 0000000..ed650c5 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml @@ -0,0 +1,671 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FormatShift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +