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

@ -76,15 +76,27 @@ public partial class App : Application
private void ShowMainWindow()
{
var window = new MainWindow(Engine, Settings);
RunFirstRunSetupIfNeeded();
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
var window = new MainWindow(Engine, Settings, null, adService);
MainWindow = window;
window.Show();
window.Activate();
}
/// <summary>첫 실행이면 외부 도구 설치 마법사를 한 번 띄우고 완료 플래그를 남긴다(설정 창에서 재진입 가능).</summary>
private void RunFirstRunSetupIfNeeded()
{
if (Settings.Get("setup.tools.done") == "1") return;
var wizard = new ToolSetupWindow();
wizard.ShowDialog();
Settings.Set("setup.tools.done", "1");
}
private void ShowConvertDialog(IReadOnlyList<string> files)
{
var window = new Views.MainWindow(Engine, Settings, files);
var adService = _services.GetService<Everything2Everything.Core.Ads.IAdService>();
var window = new Views.MainWindow(Engine, Settings, files, adService);
MainWindow = window;
window.Show();
window.Activate();

View file

@ -0,0 +1,106 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Everything2Everything.Core.Ads;
namespace Everything2Everything.App.ViewModels;
public class AdViewModel : INotifyPropertyChanged
{
private readonly IAdService _adService;
private AdItem? _currentBannerAd;
private AdItem? _currentLargeAd;
private bool _isBannerVisible = true;
private bool _isLargeCardVisible = true;
public event PropertyChangedEventHandler? PropertyChanged;
public AdItem? CurrentBannerAd
{
get => _currentBannerAd;
set { _currentBannerAd = value; OnPropertyChanged(); }
}
public AdItem? CurrentLargeAd
{
get => _currentLargeAd;
set { _currentLargeAd = value; OnPropertyChanged(); }
}
public bool IsBannerVisible
{
get => _isBannerVisible;
set { _isBannerVisible = value; OnPropertyChanged(); }
}
public bool IsLargeCardVisible
{
get => _isLargeCardVisible;
set { _isLargeCardVisible = value; OnPropertyChanged(); }
}
public ICommand ClickBannerAdCommand { get; }
public ICommand ClickLargeAdCommand { get; }
public ICommand DismissBannerCommand { get; }
public ICommand DismissLargeCardCommand { get; }
public AdViewModel(IAdService adService)
{
_adService = adService ?? throw new ArgumentNullException(nameof(adService));
CurrentBannerAd = _adService.GetNextBannerAd();
CurrentLargeAd = _adService.GetNextLargeCardAd();
ClickBannerAdCommand = new AdCommand(_ =>
{
if (CurrentBannerAd != null)
_adService.OpenAdUrl(CurrentBannerAd);
});
ClickLargeAdCommand = new AdCommand(_ =>
{
if (CurrentLargeAd != null)
_adService.OpenAdUrl(CurrentLargeAd);
});
DismissBannerCommand = new AdCommand(_ => IsBannerVisible = false);
DismissLargeCardCommand = new AdCommand(_ => IsLargeCardVisible = false);
}
public void RotateBannerAd()
{
CurrentBannerAd = _adService.GetNextBannerAd();
}
public void RotateLargeCardAd()
{
CurrentLargeAd = _adService.GetNextLargeCardAd();
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private sealed class AdCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public AdCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged
{
add { }
remove { }
}
}
}

View file

@ -0,0 +1,184 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Media;
using Everything2Everything.Core.Converters;
namespace Everything2Everything.App.ViewModels;
public enum ToolInstallState
{
NotInstalled,
Installed,
Busy,
Failed,
}
/// <summary>설치 마법사 목록의 한 행. 카탈로그 정의 + 선택/상태/진행 표현.</summary>
public sealed class ToolInstallItem : INotifyPropertyChanged
{
public ExternalToolDefinition Definition { get; }
public ToolInstallItem(ExternalToolDefinition definition)
{
Definition = definition;
RefreshStatus();
}
public string Name => Definition.DisplayName;
public string Description => Definition.Description;
private bool _isSelected = true;
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
private ToolInstallState _state;
public ToolInstallState State
{
get => _state;
private set
{
_state = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsBusy));
OnPropertyChanged(nameof(StatusText));
OnPropertyChanged(nameof(StatusBrush));
}
}
public string StatusText => State switch
{
ToolInstallState.Installed => "설치됨",
ToolInstallState.Busy => "설치 중…",
ToolInstallState.Failed => "실패",
_ => "미설치",
};
public bool IsBusy => State == ToolInstallState.Busy;
public Brush StatusBrush => State switch
{
ToolInstallState.Installed => BrushOf("FsStatusSuccess"),
ToolInstallState.Busy => BrushOf("FsStatusInfo"),
ToolInstallState.Failed => BrushOf("FsStatusDanger"),
_ => BrushOf("FsStatusWarn"),
};
private string? _detail;
public string? Detail
{
get => _detail;
private set { _detail = value; OnPropertyChanged(); }
}
public void RefreshStatus()
=> State = Definition.IsInstalled() ? ToolInstallState.Installed : ToolInstallState.NotInstalled;
public void ApplyResult(bool success, string? detail)
{
Detail = detail;
State = success ? ToolInstallState.Installed : ToolInstallState.Failed;
}
public void BeginInstall() => State = ToolInstallState.Busy;
private static Brush BrushOf(string key)
=> (Application.Current?.TryFindResource(key) as Brush)
?? _fallbacks.GetValueOrDefault(key, _warn);
private static readonly Brush _info = Fixed(0x1565C0);
private static readonly Brush _success = Fixed(0x2E7D32);
private static readonly Brush _warn = Fixed(0xC77700);
private static readonly Brush _danger = Fixed(0xC62828);
private static readonly Dictionary<string, Brush> _fallbacks = new()
{
["FsStatusInfo"] = _info,
["FsStatusSuccess"] = _success,
["FsStatusWarn"] = _warn,
["FsStatusDanger"] = _danger,
};
private static Brush Fixed(uint rgb)
{
var c = (int)rgb;
return new SolidColorBrush(Color.FromRgb((byte)(c >> 16), (byte)(c >> 8), (byte)c));
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>설치 마법사의 루트 뷰모델: 목록 + 전체 선택 + 선택분 순차 설치.</summary>
public sealed class ToolSetupViewModel : INotifyPropertyChanged
{
private readonly ExternalToolInstaller _installer;
private bool _isInstalling;
private bool _selectAll = true;
public ObservableCollection<ToolInstallItem> Tools { get; } = new();
public ToolSetupViewModel(IEnumerable<ExternalToolDefinition>? definitions = null, ExternalToolInstaller? installer = null)
{
_installer = installer ?? new ExternalToolInstaller();
foreach (var def in definitions ?? ExternalToolCatalog.All)
Tools.Add(new ToolInstallItem(def));
}
public bool IsInstalling
{
get => _isInstalling;
private set { _isInstalling = value; OnPropertyChanged(); }
}
public bool SelectAll
{
get => _selectAll;
set
{
_selectAll = value;
foreach (var t in Tools) t.IsSelected = value;
OnPropertyChanged();
}
}
public void RefreshStatus()
{
foreach (var t in Tools) t.RefreshStatus();
}
public async Task InstallSelectedAsync(CancellationToken ct)
{
if (IsInstalling) return;
IsInstalling = true;
try
{
foreach (var item in Tools.Where(t => t.IsSelected).ToList())
await InstallOneAsync(item, ct).ConfigureAwait(true);
}
finally
{
IsInstalling = false;
}
}
public async Task InstallOneAsync(ToolInstallItem item, CancellationToken ct)
{
item.BeginInstall();
var result = await _installer.InstallAsync(item.Definition, ct).ConfigureAwait(true);
var detail = string.IsNullOrWhiteSpace(result.Output)
? result.Message
: Tail(result.Output, 600);
item.ApplyResult(result.Success, detail);
}
private static string Tail(string s, int max)
=> s.Length <= max ? s : "…" + s[^max..];
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View file

@ -0,0 +1,99 @@
<UserControl x:Class="Everything2Everything.App.Views.AdBannerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="56" d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</ResourceDictionary>
</UserControl.Resources>
<Border Visibility="{Binding IsBannerVisible, Converter={StaticResource BoolToVis}}"
Style="{StaticResource FsDoubleBezelShellStyle}"
Margin="0,4,0,0">
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="12,8">
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 1. [AD] / [스폰서] 배지 -->
<Border Grid.Column="0"
Margin="0,0,10,0"
Padding="6,2"
CornerRadius="4"
Background="#1A00E5FF"
BorderBrush="{StaticResource FsAccentCyan}"
BorderThickness="1"
VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentBannerAd.BadgeText, FallbackValue=AD}"
FontSize="10"
FontWeight="Bold"
Foreground="{StaticResource FsAccentCyan}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
<!-- 2. 광고 타이틀 및 설명 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="{Binding CurrentBannerAd.Title, FallbackValue=스폰서십 안내}"
Style="{StaticResource FsLabelStyle}"
Foreground="{StaticResource FsTextPrimary}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding CurrentBannerAd.Description, FallbackValue=Everything2Everything을 후원하고 최신 기능을 가장 먼저 만나보세요.}"
Style="{StaticResource FsCaptionStyle}"
Foreground="{StaticResource FsTextSecondary}"
Margin="0,2,0,0"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
</StackPanel>
<!-- 3. CTA 액션 버튼 -->
<Button Grid.Column="2"
Margin="0,0,8,0"
Padding="12,4"
Height="28"
VerticalAlignment="Center"
Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding ClickBannerAdCommand}"
ToolTip="광고 및 안내 링크 열기">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentBannerAd.CtaText, FallbackValue='자세히 보기'}"
FontSize="11"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<ui:SymbolIcon Symbol="Open24"
FontSize="11"
Margin="4,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- 4. 광고 닫기 버튼 -->
<Button Grid.Column="3"
Width="24" Height="24"
VerticalAlignment="Center"
Style="{StaticResource FsIconButtonStyle}"
Command="{Binding DismissBannerCommand}"
ToolTip="광고 닫기">
<ui:SymbolIcon Symbol="Dismiss24"
FontSize="11"
Foreground="{StaticResource FsTextTertiary}"
VerticalAlignment="Center"/>
</Button>
</Grid>
</Border>
</Border>
</UserControl>

View file

@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace Everything2Everything.App.Views;
public partial class AdBannerControl : UserControl
{
public AdBannerControl()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,117 @@
<UserControl x:Class="Everything2Everything.App.Views.AdLargeCardControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="220" d:DesignWidth="320">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</ResourceDictionary>
</UserControl.Resources>
<Border Visibility="{Binding IsLargeCardVisible, Converter={StaticResource BoolToVis}}"
Style="{StaticResource FsDoubleBezelShellStyle}"
Margin="0,16,0,0">
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16">
<StackPanel>
<!-- 1. 헤더: 스폰서 배지 + 닫기 버튼 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Border Padding="6,2"
CornerRadius="4"
Background="#1A3B82F6"
BorderBrush="{StaticResource FsAccentBlue}"
BorderThickness="1"
VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentLargeAd.BadgeText, FallbackValue='스폰서 추천'}"
FontSize="10"
FontWeight="Bold"
Foreground="{StaticResource FsAccentBlue}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
</StackPanel>
<Button Grid.Column="1"
Width="22" Height="22"
VerticalAlignment="Center"
Style="{StaticResource FsIconButtonStyle}"
Command="{Binding DismissLargeCardCommand}"
ToolTip="광고 닫기">
<ui:SymbolIcon Symbol="Dismiss24"
FontSize="10"
Foreground="{StaticResource FsTextTertiary}"
VerticalAlignment="Center"/>
</Button>
</Grid>
<!-- 2. 본문: 아이콘/이미지 + 타이틀 + 설명 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Width="36" Height="36"
CornerRadius="8"
Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="1"
Margin="0,0,12,0"
VerticalAlignment="Center">
<Image Width="20" Height="20"
VerticalAlignment="Center"
HorizontalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Everything2Everything;component/Assets/logo-mark.png"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentLargeAd.Title, FallbackValue='비즈니스 미디어 솔루션'}"
Style="{StaticResource FsLabelStyle}"
Foreground="{StaticResource FsTextPrimary}"
FontWeight="SemiBold"
FontSize="13"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding CurrentLargeAd.Description, FallbackValue='고성능 무손실 일괄 변환을 업무 환경에 적용하세요.'}"
Style="{StaticResource FsCaptionStyle}"
Foreground="{StaticResource FsTextSecondary}"
Margin="0,3,0,0"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- 3. 하단 전면 CTA 버튼 -->
<Button Style="{StaticResource FsIslandPrimaryButtonStyle}"
Height="34"
Command="{Binding ClickLargeAdCommand}"
ToolTip="자세히 보기">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding CurrentLargeAd.CtaText, FallbackValue='솔루션 살펴보기'}"
FontSize="12"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<ui:SymbolIcon Symbol="ArrowRight24"
FontSize="12"
Margin="6,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Border>
</UserControl>

View file

@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace Everything2Everything.App.Views;
public partial class AdLargeCardControl : UserControl
{
public AdLargeCardControl()
{
InitializeComponent();
}
}

View file

@ -3,6 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:Everything2Everything.App.Views"
Title="Everything2Everything"
Icon="pack://application:,,,/Everything2Everything;component/Assets/app-icon.png"
Width="1280" Height="960"
@ -327,6 +328,26 @@
</ComboBox>
</StackPanel>
<!-- E. AI 텍스트 변환 작업 선택기 (TXT/MD) -->
<StackPanel x:Name="AiQuickPanel" Margin="0,0,0,6" Visibility="Collapsed">
<TextBlock Text="AI 변환 작업" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,4"/>
<ComboBox x:Name="AiTaskQuickCombo"
AutomationProperties.AutomationId="AiTaskQuickCombo"
SelectedIndex="{Binding Options.AiTaskIndex, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay}"
SelectionChanged="OnAiTaskSelectionChanged">
<ComboBoxItem Content="핵심 요약 (Summarize)"/>
<ComboBoxItem Content="다국어 번역 (Translate)"/>
<ComboBoxItem Content="오탈자 및 문법 교정 (Proofread)"/>
</ComboBox>
<StackPanel x:Name="AiTargetLanguagePanel" Margin="0,8,0,0" Visibility="Collapsed">
<TextBlock Text="번역 대상 언어" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
<TextBox x:Name="AiTargetLanguageBox"
Style="{StaticResource FsPathInputStyle}"
Padding="10,6"
Text="{Binding Options.TargetLanguage, RelativeSource={RelativeSource AncestorType=Window}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
</StackPanel>
<!-- 5. 상세 인코딩 설정 (폴드아웃 Expander: 프리셋 직하단 위치) -->
<Expander x:Name="AdvancedOptionsExpander"
Margin="0,6,0,6"
@ -666,17 +687,18 @@
<Border Grid.Row="0" Grid.Column="0"
Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}"
BorderThickness="0,0,1,1" Padding="16,8">
BorderThickness="0,0,1,1" Padding="12,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="175"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*" MinWidth="110"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Search Box with Live Watermark & Clear Button -->
<Grid Grid.Column="0" Width="175" VerticalAlignment="Center">
<!-- Search Box with Live Watermark & Clear Button (Flex Width) -->
<Grid Grid.Column="0" Margin="0,0,8,0" VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Style="{StaticResource FsPathInputStyle}"
Padding="28,5,24,5" Text="{Binding SearchText, RelativeSource={RelativeSource AncestorType=Window}, UpdateSourceTrigger=PropertyChanged}"
ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center"/>
ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
<ui:SymbolIcon Symbol="Search24" FontSize="13" Foreground="{StaticResource FsTextTertiary}"
HorizontalAlignment="Left" VerticalAlignment="Center" Margin="8,0,0,0" IsHitTestVisible="False"/>
<TextBlock x:Name="SearchPlaceholderText"
@ -720,49 +742,26 @@
<ui:SymbolIcon Symbol="Dismiss24" FontSize="10" Foreground="{StaticResource FsTextTertiary}" VerticalAlignment="Center"/>
</Button>
</Grid>
<!-- Category Filter Chips & Inspector Toggle -->
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="All">
<TextBlock Text="전체" FontSize="11" VerticalAlignment="Center"/>
</Button>
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="Image">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="Image24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="이미지" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="Document">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="Document24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="문서" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="Media">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="Video24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="미디어" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Margin="0,0,3,0" Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding FilterCategoryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="Data">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="Database24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="데이터" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Border Width="1" Height="14" Background="{StaticResource FsBorderSubtle}" Margin="5,0"/>
<Button Padding="7,3" Style="{StaticResource FsSecondaryButtonStyle}"
<!-- 카테고리 필터 폴드아웃 드롭다운 -->
<ComboBox x:Name="CategoryFilterCombo" Grid.Column="1"
AutomationProperties.AutomationId="CategoryFilterCombo"
MinWidth="90" Height="28" Padding="10,4"
FontSize="11" VerticalAlignment="Center"
ToolTip="카테고리 필터 (전체 · 이미지 · 문서 · 미디어 · 데이터)"
SelectionChanged="OnCategoryFilterSelectionChanged">
<ComboBoxItem Tag="All" IsSelected="True" Content="전체"/>
<ComboBoxItem Tag="Image" Content="이미지"/>
<ComboBoxItem Tag="Document" Content="문서"/>
<ComboBoxItem Tag="Media" Content="미디어"/>
<ComboBoxItem Tag="Data" Content="데이터"/>
</ComboBox>
<!-- 구분선 & 미리보기 토글 버튼 -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="6,0,0,0">
<Border Width="1" Height="14" Background="{StaticResource FsBorderSubtle}" Margin="2,0,6,0"/>
<Button Padding="7,4" Height="28" Style="{StaticResource FsSecondaryButtonStyle}"
Command="{Binding ToggleInspectorCommand, RelativeSource={RelativeSource AncestorType=Window}}"
ToolTip="미리보기 패널 접기/펼치기">
ToolTip="미리보기 패널 접기/펼치기"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:SymbolIcon Symbol="PreviewLink24" FontSize="11" VerticalAlignment="Center" Margin="0,0,4,0"/>
<TextBlock Text="미리보기" FontSize="11" VerticalAlignment="Center"/>
@ -774,8 +773,13 @@
<!-- 좌측: Active Queue OR Past Results -->
<Grid Grid.Row="1" Grid.Column="0">
<!-- Active Queue view (default) -->
<Grid x:Name="ActiveQueueView" Visibility="Visible">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Active Queue view (default) -->
<Grid x:Name="ActiveQueueView" Grid.Row="0" Visibility="Visible">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@ -1012,7 +1016,7 @@
</Grid>
<!-- Past Results view -->
<Grid x:Name="PastResultsContainer" Visibility="Collapsed">
<Grid x:Name="PastResultsContainer" Grid.Row="0" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
@ -1229,6 +1233,9 @@
</Grid>
</Grid>
<!-- 가로 슬림 배너 광고 슬롯 -->
<local:AdBannerControl Grid.Row="1" Margin="12,4,12,8"
DataContext="{Binding AdVm, RelativeSource={RelativeSource AncestorType=Window}}"/>
</Grid>
<!-- /좌측 컬럼 끝 -->
@ -1468,6 +1475,10 @@
</StackPanel>
</Button>
</Grid>
<!-- 대형 카드 광고 슬롯 (스폰서십 및 솔루션 안내) -->
<local:AdLargeCardControl Margin="0,12,0,0"
DataContext="{Binding AdVm, RelativeSource={RelativeSource AncestorType=Window}}"/>
</StackPanel>
</Border>
</Grid>

View file

@ -12,6 +12,7 @@ using Everything2Everything.App.Shell;
using Everything2Everything.App.ViewModels;
using Everything2Everything.Core;
using Everything2Everything.Core.Filters;
using Everything2Everything.Core.Ads;
using Everything2Everything.Core.Inspector;
using Everything2Everything.Core.Presets;
using LossClass = Everything2Everything.Core.Providers.LossClass;
@ -134,15 +135,22 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
if (_selectedCategory != value)
{
_selectedCategory = value;
if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex != (int)value)
{
CategoryFilterCombo.SelectedIndex = (int)value;
}
ApplyQueueFilters();
}
}
}
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null)
public AdViewModel AdVm { get; }
public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList<string>? initialFiles = null, IAdService? adService = null)
{
_engine = engine;
_settings = settings;
AdVm = new AdViewModel(adService ?? new AdService());
AddFilesCommand = new RelayCommand(_ => PickAndAddFiles());
ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()),
@ -828,6 +836,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
}
}
private void OnCategoryFilterSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CategoryFilterCombo?.SelectedItem is ComboBoxItem item && item.Tag is string tag)
{
ApplyFilterCategory(tag);
}
else if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex >= 0)
{
SelectedCategory = (FilterCategory)CategoryFilterCombo.SelectedIndex;
}
}
private void ApplyFilterCategory(string? categoryName)
{
if (Enum.TryParse<FilterCategory>(categoryName, true, out var cat))
@ -1540,9 +1560,25 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC
if (PdfQuickPanel is not null)
PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed;
var isText = ext is ".txt" or ".md";
if (AiQuickPanel is not null)
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
if (AiTargetLanguagePanel is not null && AiTaskQuickCombo is not null)
AiTargetLanguagePanel.Visibility = (isText && AiTaskQuickCombo.SelectedIndex == 1)
? Visibility.Visible
: Visibility.Collapsed;
UpdateMediaPanelForFormat(extension);
}
private void OnAiTaskSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (AiTargetLanguagePanel is null || AiTaskQuickCombo is null) return;
AiTargetLanguagePanel.Visibility = AiTaskQuickCombo.SelectedIndex == 1
? Visibility.Visible
: Visibility.Collapsed;
}
/// <summary>출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴).</summary>
private void UpdateMediaPanelForFormat(string? extension)
{

View file

@ -74,6 +74,20 @@
</ComboBox>
</StackPanel>
<!-- AI 텍스트 처리 (txt/md) -->
<StackPanel x:Name="AiQuickPanel" Margin="0,18,0,0" Visibility="Collapsed">
<TextBlock Text="AI 작업 선택" Style="{StaticResource FsCaptionStyle}" Margin="0,0,0,4"/>
<ComboBox SelectedIndex="{Binding AiTaskIndex, Mode=TwoWay}">
<ComboBoxItem Content="핵심 요약 (Summarize)"/>
<ComboBoxItem Content="자연스러운 번역 (Translate)"/>
<ComboBoxItem Content="오탈자 및 문법 교정 (Proofread)"/>
</ComboBox>
<TextBox Margin="0,8,0,0" Style="{StaticResource FsPathInputStyle}"
Padding="10,6"
Text="{Binding TargetLanguage, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="번역 시 대상 언어 (예: 영어, 일본어, 중국어)"/>
</StackPanel>
<!-- 버튼 -->
<Grid Margin="0,22,0,0">
<Button HorizontalAlignment="Left" Content="자세히 옵션…"

View file

@ -34,10 +34,12 @@ public partial class QuickOptionsWindow : FluentWindow
var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi";
var isLossyAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg";
var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
var isText = ext is ".txt" or ".md";
VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed;
AudioQuickPanel.Visibility = (isVideo || isLossyAudio) ? Visibility.Visible : Visibility.Collapsed;
ImageQualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed;
AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed;
// 영상의 오디오 트랙임을 구분
if (isVideo) AudioPanelLabel.Text = "오디오 비트레이트";

View file

@ -39,12 +39,48 @@
<TextBlock Text="기본 백엔드" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
<ComboBox x:Name="BackendCombo" Margin="0,0,0,16">
<ComboBoxItem Content="자동 (API 키 우선, 없으면 Codex)"/>
<ComboBoxItem Content="자동 (API 키 우선 · Switchboard · Antigravity · Codex)"/>
<ComboBoxItem Content="Switchboard 게이트웨이 (http://127.0.0.1:8787 · 로컬/LAN)"/>
<ComboBoxItem Content="Antigravity CLI (agy · Gemini OAuth · 무료/구독)"/>
<ComboBoxItem Content="OpenAI (API 키)"/>
<ComboBoxItem Content="Anthropic (API 키)"/>
<ComboBoxItem Content="Codex CLI (ChatGPT 구독 OAuth)"/>
</ComboBox>
<!-- Switchboard Gateway -->
<TextBlock Text="Switchboard 게이트웨이 (로컬/LAN 멀티에이전트 오케스트레이션)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="SwitchboardEndpointBox" Grid.Column="0"
Style="{StaticResource FsPathInputStyle}"
TextChanged="OnSwitchboardEndpointChanged"/>
<Button x:Name="SwitchboardVerifyBtn" Grid.Column="1" Content="테스트"
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
Padding="12,6" Click="OnVerifySwitchboard"/>
<Button x:Name="SwitchboardStartBtn" Grid.Column="2" Content="게이트웨이 기동"
Style="{StaticResource FsSecondaryButtonStyle}" Margin="8,0,0,0"
Padding="12,6" Click="OnStartSwitchboardGateway"/>
</Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
<Ellipse x:Name="SwitchboardDot" Style="{StaticResource FsLossDotStyle}"
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock x:Name="SwitchboardStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</StackPanel>
<!-- Antigravity CLI (OAuth) -->
<TextBlock Text="Antigravity CLI (agy · Google OAuth · API 키 불필요)" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,16">
<Ellipse x:Name="AgyDot" Style="{StaticResource FsLossDotStyle}"
Fill="{StaticResource FsTextTertiary}" Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock x:Name="AgyStatus" Text="확인 중…" Style="{StaticResource FsCaptionStyle}" VerticalAlignment="Center"/>
<Button x:Name="AgyVerifyBtn" Content="테스트" Style="{StaticResource FsSecondaryButtonStyle}"
Margin="12,0,0,0" Padding="10,4" IsEnabled="False" Click="OnVerifyAgy" VerticalAlignment="Center"/>
</StackPanel>
<!-- OpenAI -->
<TextBlock Text="OpenAI API Key" Style="{StaticResource FsLabelStyle}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,4">
@ -111,6 +147,9 @@
<TextBlock Text="외부 도구" Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="15"/>
<TextBlock Text="설치하면 영상/오디오·한글/Word 변환이 자동 활성화됩니다."
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,12"/>
<Button Content="외부 도구 설치 / 검증" Style="{StaticResource FsSecondaryButtonStyle}"
Padding="14,6" HorizontalAlignment="Right" Margin="0,0,0,12"
Click="OnOpenToolInstaller"/>
<CheckBox x:Name="GpuToggle" Content="영상 변환 시 GPU 가속(NVENC) 시도 — 없으면 CPU 자동 전환"
Foreground="{StaticResource FsTextSecondary}" IsChecked="True" Margin="0,0,0,16"/>

View file

@ -26,12 +26,15 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
var backend = (_settings.Get("ai.backend") ?? "auto").ToLowerInvariant();
BackendCombo.SelectedIndex = backend switch
{
"openai" => 1,
"anthropic" => 2,
"codex" => 3,
"switchboard" or "gateway" => 1,
"agy" => 2,
"openai" => 3,
"anthropic" => 4,
"codex" => 5,
_ => 0,
};
ModelBox.Text = _settings.Get("ai.model") ?? string.Empty;
SwitchboardEndpointBox.Text = _settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787";
SetKeyStatus(OpenAiDot, OpenAiStatus, _settings.Contains("openai.apikey"), HasEnv("OPENAI_API_KEY"));
SetKeyStatus(AnthropicDot, AnthropicStatus, _settings.Contains("anthropic.apikey"), HasEnv("ANTHROPIC_API_KEY"));
@ -58,6 +61,9 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
private void OnAnthropicKeyChanged(object sender, RoutedEventArgs e)
=> AnthropicVerifyBtn.IsEnabled = AnthropicKeyBox.Password.StartsWith("sk-ant-", StringComparison.Ordinal);
private void OnSwitchboardEndpointChanged(object sender, TextChangedEventArgs e)
=> _ = CheckSwitchboardGatewayAsync();
private async void OnVerifyOpenAi(object sender, RoutedEventArgs e)
=> await VerifyAsync(OpenAiDot, OpenAiStatus, OpenAiVerifyBtn,
new OpenAiChatClient(OpenAiKeyBox.Password), ModelOr("gpt-4o-mini"));
@ -66,6 +72,63 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
=> await VerifyAsync(AnthropicDot, AnthropicStatus, AnthropicVerifyBtn,
new AnthropicChatClient(AnthropicKeyBox.Password), ModelOr("claude-3-5-sonnet-latest"));
private async void OnVerifySwitchboard(object sender, RoutedEventArgs e)
{
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox.Text)
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
: SwitchboardEndpointBox.Text.Trim();
await VerifyAsync(SwitchboardDot, SwitchboardStatus, SwitchboardVerifyBtn,
new SwitchboardChatClient(ep), ModelOr(string.Empty));
await CheckSwitchboardGatewayAsync();
}
private void OnStartSwitchboardGateway(object sender, RoutedEventArgs e)
{
try
{
var candidates = new[]
{
@"D:\workspace\Switchboard\start-gateway-lan.cmd",
@"D:\workspace\Switchboard\start-gateway-agy.cmd",
@"D:\workspace\Switchboard\start-gateway.cmd",
};
string? cmdPath = candidates.FirstOrDefault(System.IO.File.Exists);
if (cmdPath is null)
{
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
var relativeCandidates = new[]
{
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-lan.cmd")),
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway-agy.cmd")),
System.IO.Path.GetFullPath(System.IO.Path.Combine(baseDir, "..", "..", "..", "..", "..", "Switchboard", "start-gateway.cmd")),
};
cmdPath = relativeCandidates.FirstOrDefault(System.IO.File.Exists);
}
if (cmdPath is not null)
{
Process.Start(new ProcessStartInfo
{
FileName = cmdPath,
UseShellExecute = true,
WorkingDirectory = System.IO.Path.GetDirectoryName(cmdPath)!,
});
SwitchboardStatus.Text = "게이트웨이 기동 중… 잠시 후 테스트를 누르세요.";
}
else
{
SwitchboardStatus.Text = "런처 스크립트를 찾을 수 없습니다 (start-gateway-lan.cmd).";
}
}
catch (Exception ex)
{
SwitchboardStatus.Text = "기동 실패: " + Trunc(ex.Message);
}
}
private async void OnVerifyAgy(object sender, RoutedEventArgs e)
=> await VerifyAsync(AgyDot, AgyStatus, AgyVerifyBtn, new AgyChatClient(), ModelOr(string.Empty));
private async void OnVerifyCodex(object sender, RoutedEventArgs e)
=> await VerifyAsync(CodexDot, CodexStatus, CodexVerifyBtn, new CodexChatClient(), ModelOr(string.Empty));
@ -107,23 +170,59 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
LibreDot.Fill = Res(libre ? "FsStatusSuccess" : "FsStatusWarn");
LibreStatus.Text = libre ? "준비됨" : "미설치";
_ = CheckSwitchboardGatewayAsync();
var agy = ExternalToolDetector.IsAgyAvailable(out _);
AgyDot.Fill = Res(agy ? "FsStatusSuccess" : "FsTextTertiary");
AgyStatus.Text = agy ? "설치됨 — 키 없이 사용 가능" : "미설치 (winget install Google.AntigravityCLI)";
AgyVerifyBtn.IsEnabled = agy;
var codex = ExternalToolDetector.IsCodexAvailable();
CodexDot.Fill = Res(codex ? "FsStatusSuccess" : "FsTextTertiary");
CodexStatus.Text = codex ? "설치됨 — 키 없이 사용 가능" : "미설치 (npm i -g @openai/codex)";
CodexVerifyBtn.IsEnabled = codex;
}
private async Task CheckSwitchboardGatewayAsync()
{
if (SwitchboardDot is null || SwitchboardStatus is null) return;
var ep = string.IsNullOrWhiteSpace(SwitchboardEndpointBox?.Text)
? (_settings.Get("switchboard.endpoint") ?? "http://127.0.0.1:8787")
: SwitchboardEndpointBox.Text.Trim();
var (ok, profile, resolvedEp) = await ExternalToolDetector.CheckSwitchboardGatewayHealthAsync(ep);
if (ok)
{
SwitchboardDot.Fill = Res("FsStatusSuccess");
SwitchboardStatus.Text = string.IsNullOrWhiteSpace(profile)
? $"실행 중 ({resolvedEp})"
: $"실행 중 ({resolvedEp} · {profile})";
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = false;
}
else
{
SwitchboardDot.Fill = Res("FsTextTertiary");
SwitchboardStatus.Text = $"미실행 ({ep})";
if (SwitchboardStartBtn is not null) SwitchboardStartBtn.IsEnabled = true;
}
}
private void OnSave(object sender, RoutedEventArgs e)
{
var backend = BackendCombo.SelectedIndex switch
{
1 => "openai",
2 => "anthropic",
3 => "codex",
1 => "switchboard",
2 => "agy",
3 => "openai",
4 => "anthropic",
5 => "codex",
_ => "auto",
};
_settings.Set("ai.backend", backend);
var sbEndpoint = SwitchboardEndpointBox.Text?.Trim();
if (!string.IsNullOrEmpty(sbEndpoint))
_settings.Set("switchboard.endpoint", sbEndpoint);
var model = ModelBox.Text?.Trim();
if (string.IsNullOrEmpty(model)) _settings.Remove("ai.model");
else _settings.Set("ai.model", model);
@ -137,6 +236,13 @@ public partial class SettingsWindow : Wpf.Ui.Controls.FluentWindow
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnOpenToolInstaller(object sender, RoutedEventArgs e)
{
var win = new ToolSetupWindow { Owner = this };
win.ShowDialog();
RefreshToolStatus();
}
private void OnDownloadFfmpeg(object sender, RoutedEventArgs e)
=> OpenUrl("https://github.com/BtbN/FFmpeg-Builds/releases");

View file

@ -0,0 +1,88 @@
<ui:FluentWindow x:Class="Everything2Everything.App.Views.ToolSetupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="처음 설정 — 외부 도구 설치"
Width="640" Height="680"
WindowStartupLocation="CenterScreen"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
Background="{DynamicResource FsBgBase}">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ControlsDictionary/>
<ResourceDictionary Source="FormatShiftTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid Background="{StaticResource FsBgBase}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="처음 설정" ShowMaximize="False" ShowMinimize="False"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,8,24,16">
<StackPanel>
<TextBlock Text="필요한 외부 도구를 설치합니다."
Style="{StaticResource FsBodyStyle}" FontWeight="SemiBold" FontSize="16"/>
<TextBlock Text="변환에 필요한 외부 도구 목록입니다. 기본값은 모두 선택된 상태이며, 아래 버튼으로 한 번에 순차 설치합니다. 이미 설치된 도구는 설치 단계에서 확인만 됩니다. (H2Orestart는 라이선스상 자동 설치 대신 직접 추가가 필요합니다.)"
Style="{StaticResource FsCaptionStyle}" TextWrapping="Wrap" Margin="0,4,0,16"/>
<ItemsControl ItemsSource="{Binding Tools}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource FsDoubleBezelShellStyle}" Margin="0,0,0,10" HorizontalAlignment="Stretch">
<Border Style="{StaticResource FsDoubleBezelCoreStyle}" Padding="16,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" Margin="0,0,12,0">
<TextBlock Text="{Binding Name}" Style="{StaticResource FsBodyStyle}" FontWeight="Medium"/>
<TextBlock Text="{Binding Description}" Style="{StaticResource FsCaptionStyle}"
TextWrapping="Wrap" Margin="0,3,0,0"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Ellipse Width="9" Height="9" Style="{StaticResource FsLossDotStyle}"
Fill="{Binding StatusBrush}" VerticalAlignment="Center" Margin="0,0,7,0"/>
<TextBlock Text="{Binding StatusText}" Style="{StaticResource FsCaptionStyle}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2" Background="{StaticResource FsBgPanel}"
BorderBrush="{StaticResource FsBorderSubtle}" BorderThickness="0,1,0,0" Padding="24,14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox Grid.Column="0" IsChecked="{Binding SelectAll}" Content="전체 선택"
Foreground="{StaticResource FsTextSecondary}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="나중에" Style="{StaticResource FsSecondaryButtonStyle}" Padding="20,8" Click="OnLater"/>
<Button x:Name="InstallButton" Content="설치 시작"
Style="{StaticResource FsPrimaryButtonStyle}" Padding="20,8" Margin="8,0,0,0" Click="OnInstall"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</ui:FluentWindow>

View file

@ -0,0 +1,46 @@
using System.Threading;
using System.Windows;
using Everything2Everything.App.ViewModels;
namespace Everything2Everything.App.Views;
/// <summary>첫 실행 외부 도구 설치 마법사. 선택된(기본 전체) 도구를 순차 설치하고 상태를 갱신한다.</summary>
public partial class ToolSetupWindow : Wpf.Ui.Controls.FluentWindow
{
private readonly ToolSetupViewModel _vm;
private CancellationTokenSource? _cts;
public ToolSetupWindow()
{
InitializeComponent();
_vm = new ToolSetupViewModel();
DataContext = _vm;
}
private async void OnInstall(object sender, RoutedEventArgs e)
{
if (_vm.IsInstalling) return;
_cts = new CancellationTokenSource();
InstallButton.IsEnabled = false;
try
{
await _vm.InstallSelectedAsync(_cts.Token);
}
finally
{
InstallButton.IsEnabled = true;
}
}
private void OnLater(object sender, RoutedEventArgs e)
{
_cts?.Cancel();
Close();
}
protected override void OnClosed(EventArgs e)
{
_cts?.Cancel();
base.OnClosed(e);
}
}