1
0
Fork 0

feat(tools): add first-run external tool installer wizard and catalog

첫 실행 시 외부 도구 설치 마법사를 띄우고 7종(FFmpeg·LibreOffice·Pandoc·ImageMagick·H2Orestart·agy·Codex)을 기본 전체 선택 상태로 순차 설치한다.
- Core: ExternalToolCatalog(정적 카탈로그, winget ID 실검증), ExternalToolInstaller(winget 무인 실행·재감지 판정, IExternalCommandRunner 시드로 테스트 가능)
- Core: ExternalToolDetector에 winget FFmpeg 스캔 + Pandoc/ImageMagick 감지 추가
- App: ToolSetupWindow + ToolSetupViewModel, 첫 실행 플래그(setup.tools.done), 설정 창 '외부 도구 설치/검증' 재진입 버튼
- Tests: ExternalToolInstallerTests 5건
- 기존 미커밋 작업(광고 Ads 지원: AdBannerControl/AdLargeCardControl/AdService, AI 백엔드 agy·switchboard·codex 채팅 클라이언트, 관련 테스트) 포함
This commit is contained in:
Yun Chan 2026-09-07 21:16:49 +09:00
parent f00f6567f4
commit 6a41c4a0dc
37 changed files with 2545 additions and 69 deletions

View file

@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Everything2Everything.Core.Ads;
public class AdService : IAdService
{
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(5) };
private readonly List<AdItem> _ads = new();
private readonly object _lock = new();
private int _bannerIndex = 0;
private int _largeCardIndex = 0;
public AdService()
{
InitializeDefaultAds();
}
private void InitializeDefaultAds()
{
lock (_lock)
{
_ads.Clear();
// Default House Banner Ads
_ads.Add(new AdItem
{
Id = "house-banner-sponsor-1",
Title = "초고속 무손실 미디어 솔루션",
Description = "Everything2Everything 공식 후원 및 제휴 파트너십을 확인하세요.",
BadgeText = "스폰서",
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png",
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
CtaText = "자세히 보기",
Type = AdType.Banner,
IsActive = true,
DisplayOrder = 1
});
_ads.Add(new AdItem
{
Id = "house-banner-tip-2",
Title = "대용량 일괄 변환 최적화",
Description = "FFmpeg 및 LibRaw 가속 엔진으로 수천 장의 이미지를 순식간에 처리합니다.",
BadgeText = "AD",
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-video.png",
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing#features",
CtaText = "가이드 보기",
Type = AdType.Banner,
IsActive = true,
DisplayOrder = 2
});
// Default Large Card Ads
_ads.Add(new AdItem
{
Id = "house-large-card-1",
Title = "비즈니스 미디어 변환 엔진",
Description = "개인 및 기업을 위한 고성능 무손실 일괄 변환. 워터마크 없이 100% 무료로 자유롭게 활용하세요.",
BadgeText = "스폰서 추천",
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/illus-done.png",
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
CtaText = "파트너십 문의",
Type = AdType.LargeCard,
IsActive = true,
DisplayOrder = 1
});
_ads.Add(new AdItem
{
Id = "house-large-card-2",
Title = "스마트 AI 요약 & OCR 가속",
Description = "로컬 LLM과 Tesseract OCR을 결합하여 스캔 문서와 PDF를 즉시 검색 가능한 텍스트로 변환합니다.",
BadgeText = "AD",
ImageUrl = "pack://application:,,,/Everything2Everything;component/Assets/glyph-ai.png",
TargetUrl = "https://github.com/yunchan8804/Everything2Everthing",
CtaText = "기능 살펴보기",
Type = AdType.LargeCard,
IsActive = true,
DisplayOrder = 2
});
}
}
public IReadOnlyList<AdItem> GetBannerAds()
{
lock (_lock)
{
return _ads.Where(a => a.Type == AdType.Banner && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
}
}
public IReadOnlyList<AdItem> GetLargeCardAds()
{
lock (_lock)
{
return _ads.Where(a => a.Type == AdType.LargeCard && a.IsActive).OrderBy(a => a.DisplayOrder).ToList();
}
}
public AdItem? GetNextBannerAd()
{
var banners = GetBannerAds();
if (banners.Count == 0) return null;
lock (_lock)
{
var ad = banners[_bannerIndex % banners.Count];
_bannerIndex = (_bannerIndex + 1) % banners.Count;
return ad;
}
}
public AdItem? GetNextLargeCardAd()
{
var cards = GetLargeCardAds();
if (cards.Count == 0) return null;
lock (_lock)
{
var ad = cards[_largeCardIndex % cards.Count];
_largeCardIndex = (_largeCardIndex + 1) % cards.Count;
return ad;
}
}
public int LoadAdsFromJson(string json)
{
if (string.IsNullOrWhiteSpace(json)) return 0;
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var items = JsonSerializer.Deserialize<List<AdItem>>(json, options);
if (items == null || items.Count == 0) return 0;
lock (_lock)
{
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.Id))
{
item.Id = Guid.NewGuid().ToString("N");
}
var existingIdx = _ads.FindIndex(a => a.Id == item.Id);
if (existingIdx >= 0)
{
_ads[existingIdx] = item;
}
else
{
_ads.Add(item);
}
}
}
return items.Count;
}
catch
{
return 0;
}
}
public async Task<int> RefreshAdsFromFeedAsync(string feedUrl, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(feedUrl) || !Uri.TryCreate(feedUrl, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return 0;
}
try
{
var response = await HttpClient.GetStringAsync(uri, cancellationToken).ConfigureAwait(false);
return LoadAdsFromJson(response);
}
catch
{
return 0;
}
}
public bool OpenAdUrl(AdItem ad)
{
if (ad == null || string.IsNullOrWhiteSpace(ad.TargetUrl))
{
return false;
}
if (!Uri.TryCreate(ad.TargetUrl, UriKind.Absolute, out var uri))
{
return false;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return false;
}
try
{
var psi = new ProcessStartInfo
{
FileName = uri.AbsoluteUri,
UseShellExecute = true
};
Process.Start(psi);
return true;
}
catch
{
return false;
}
}
}