Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/TableCloth.App/Components/IAppUserInterface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public interface IAppUserInterface
{
AboutWindow CreateAboutWindow();
OptionsWindow CreateOptionsWindow(string? initialTabKey = null);
PowerSchemeGuideWindow CreatePowerSchemeGuideWindow();
CatalogPage CreateCatalogPage(string searchKeyword);
CatalogPageViewModel CreateCatalogPageViewModel(string searchKeyword);
QuickStartPage CreateQuickStartPage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public InputPasswordWindow CreateInputPasswordWindow()
public DisclaimerWindow CreateDisclaimerWindow()
=> SetOwnerIfAvailable(serviceProvider.GetRequiredService<DisclaimerWindow>());

public PowerSchemeGuideWindow CreatePowerSchemeGuideWindow()
=> SetOwnerIfAvailable(serviceProvider.GetRequiredService<PowerSchemeGuideWindow>());

public SplashScreen CreateSplashScreen()
=> SetOwnerIfAvailable(serviceProvider.GetRequiredService<SplashScreen>());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace TableCloth.Components.Implementations;

public sealed class SandboxLauncher(
IAppMessageBox appMessageBox,
IAppUserInterface appUserInterface,
IApplicationService applicationService,
ISharedLocations sharedLocations,
ISandboxBuilder sandboxBuilder,
ISandboxCleanupManager sandboxCleanupManager,
Expand Down Expand Up @@ -49,6 +51,12 @@ public async Task RunSandboxAsync(TableClothConfiguration config, CancellationTo
return;
}

// CPU 최대 성능이 제한(스로틀)되어 있으면, 시스템 메시지 박스 대신 전용 안내 창을 띄워
// 클래식(powercfg.cpl)/모던(전원 및 절전) 전원 설정 중 하나를 골라 열 수 있게 안내한다.
// 백그라운드에서 호출될 수 있으므로 UI 스레드로 마샬링해 모달로 표시한다(런치는 계속 진행).
if (Helpers.IsSandboxCpuLikelyThrottled() == true)
applicationService.DispatchInvoke(() => appUserInterface.CreatePowerSchemeGuideWindow().ShowDialog(), []);
Comment on lines +54 to +58
Comment on lines +54 to +58

if (config.CertPair != null)
{
var now = DateTime.Now;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public static IHostApplicationBuilder UseTableCloth(this IHostApplicationBuilder
.AddWindow<InputPasswordWindow, InputPasswordWindowViewModel>()
.AddWindow<AboutWindow, AboutWindowViewModel>()
.AddWindow<OptionsWindow, OptionsWindowViewModel>()
.AddWindow<PowerSchemeGuideWindow, PowerSchemeGuideWindowViewModel>()
.AddWindow<CertSelectWindow, CertSelectWindowViewModel>()
.AddWindow<MainWindow, MainWindowViewModel>()
.AddPage<CatalogPage, CatalogPageViewModel>(addPageAsSingleton: true)
Expand Down
99 changes: 99 additions & 0 deletions src/TableCloth.App/Dialogs/PowerSchemeGuideWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<Window x:Class="TableCloth.Dialogs.PowerSchemeGuideWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:TableCloth.ViewModels"
xmlns:res="clr-namespace:TableCloth.Resources;assembly=TableCloth.Core"
mc:Ignorable="d"
Style="{DynamicResource MainWindowStyle}"
WindowStyle="SingleBorderWindow" WindowStartupLocation="CenterOwner" ResizeMode="NoResize" ShowInTaskbar="False"
Title="{x:Static res:UIStringResources.PowerSchemeGuide_Title}" Width="520" SizeToContent="Height"
TextOptions.TextFormattingMode="Display" TextOptions.TextRenderingMode="ClearType"
d:DataContext="{d:DesignInstance Type=vm:PowerSchemeGuideWindowViewModelForDesigner, IsDesignTimeCreatable=True}">
<Window.Resources>
<!-- 옵션 카드 버튼: 테마 기본 Button 템플릿은 ContentPresenter의 HorizontalAlignment를 Center로
하드코딩해 HorizontalContentAlignment를 무시한다. 내부 텍스트를 왼쪽 정렬하기 위해
ContentPresenter를 Stretch로 두는 로컬 템플릿을 적용한다(테마 색은 DynamicResource로 유지). -->
<Style x:Key="OptionCardButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource ControlDefaultBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource ControlDefaultBorderBrush}" />
<Setter Property="Foreground" Value="{DynamicResource ControlDefaultForeground}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
SnapsToDevicePixels="True">
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource ControlPrimaryMouseOverBackground}" />
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource ControlPrimaryMouseOverBorderBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource ControlPrimarySelectedBackground}" />
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource ControlPrimarySelectedBorderBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>

<TextBlock Grid.Row="0" Margin="0 0 0 8" FontSize="16" FontWeight="SemiBold" TextWrapping="Wrap"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_Heading}" />

<TextBlock Grid.Row="1" Margin="0 0 0 14" TextWrapping="Wrap"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_Body}" />

<!-- 클래식 제어판(powercfg.cpl). 열기만 하고 이 안내 창은 그대로 유지된다. -->
<Button Grid.Row="2" Margin="0 0 0 8" Padding="14 10" Style="{StaticResource OptionCardButtonStyle}"
Command="{Binding OpenClassicPowerOptionsCommand}">
<StackPanel>
<TextBlock FontWeight="SemiBold" TextWrapping="Wrap" TextAlignment="Left"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_OpenClassicButton}" />
<TextBlock Margin="0 3 0 0" FontSize="11" TextWrapping="Wrap" TextAlignment="Left"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_OpenClassicDescription}" />
</StackPanel>
</Button>

<!-- 모던 설정(전원 및 절전). 열기만 하고 이 안내 창은 그대로 유지된다. -->
<Button Grid.Row="3" Margin="0 0 0 16" Padding="14 10" Style="{StaticResource OptionCardButtonStyle}"
Command="{Binding OpenModernPowerSettingsCommand}">
<StackPanel>
<TextBlock FontWeight="SemiBold" TextWrapping="Wrap" TextAlignment="Left"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_OpenModernButton}" />
<TextBlock Margin="0 3 0 0" FontSize="11" TextWrapping="Wrap" TextAlignment="Left"
Foreground="{DynamicResource ControlSecondaryForeground}"
Text="{x:Static res:UIStringResources.PowerSchemeGuide_OpenModernDescription}" />
</StackPanel>
</Button>

<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Padding="16 6" Content="{x:Static res:UIStringResources.PowerSchemeGuide_CloseButton}"
Command="{Binding DismissCommand}" IsCancel="True" IsDefault="True" />
</StackPanel>
</Grid>
</Window>
22 changes: 22 additions & 0 deletions src/TableCloth.App/Dialogs/PowerSchemeGuideWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Windows;
using TableCloth.ViewModels;

namespace TableCloth.Dialogs;

public partial class PowerSchemeGuideWindow : Window
{
public PowerSchemeGuideWindow(
PowerSchemeGuideWindowViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
viewModel.CloseRequested += ViewModel_CloseRequested;
}

public PowerSchemeGuideWindowViewModel ViewModel
=> (PowerSchemeGuideWindowViewModel)DataContext;

private void ViewModel_CloseRequested(object? sender, EventArgs e)
=> Close();
}
66 changes: 66 additions & 0 deletions src/TableCloth.App/ViewModels/PowerSchemeGuideWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace TableCloth.ViewModels;

[Obsolete("This class is reserved for design-time usage.", false)]
public partial class PowerSchemeGuideWindowViewModelForDesigner : PowerSchemeGuideWindowViewModel { }

/// <summary>
/// CPU 최대 성능(최대 프로세서 상태)이 제한되어 Windows Sandbox가 느려질 수 있음을 안내하고,
/// 사용자가 클래식 제어판(<c>powercfg.cpl</c>) 또는 모던 설정(전원 및 절전) 중 하나를 골라 열 수 있게 하는
/// 안내 창의 뷰모델. 전원 옵션을 여는 커맨드는 창을 닫지 않고 그대로 유지하며(사용자가 확인 후 직접 닫음),
/// '나중에'만 창을 닫는다. 여는 데 실패해도 무해하다(베스트에포트).
/// </summary>
public partial class PowerSchemeGuideWindowViewModel : ObservableObject
{
protected PowerSchemeGuideWindowViewModel() { }

[ActivatorUtilitiesConstructor]
public PowerSchemeGuideWindowViewModel(
TaskFactory taskFactory)
{
_taskFactory = taskFactory;
}

private readonly TaskFactory _taskFactory = default!;

/// <summary>안내 창을 닫아 달라는 요청. 코드 비하인드가 구독해 <c>Close()</c>를 호출한다.</summary>
public event EventHandler? CloseRequested;

private async Task RequestCloseAsync()
=> await _taskFactory.StartNew(() => CloseRequested?.Invoke(this, EventArgs.Empty)).ConfigureAwait(false);

/// <summary>클래식 제어판의 전원 옵션(powercfg.cpl)을 연다. 안내 창은 닫지 않고 그대로 둔다.</summary>
[RelayCommand]
private void OpenClassicPowerOptions()
// control.exe 로 .cpl 을 여는 것이 셸 연결에 의존하지 않아 안정적이다.
=> TryStart(new ProcessStartInfo("control.exe", "powercfg.cpl") { UseShellExecute = true });

/// <summary>모던 Windows 설정의 전원 페이지(전원 및 절전)를 연다. 안내 창은 닫지 않고 그대로 둔다.</summary>
[RelayCommand]
private void OpenModernPowerSettings()
=> TryStart(new ProcessStartInfo("ms-settings:powersleep") { UseShellExecute = true });

/// <summary>아무 것도 열지 않고 안내 창을 닫는다(나중에).</summary>
[RelayCommand]
private async Task Dismiss()
=> await RequestCloseAsync();

private static void TryStart(ProcessStartInfo startInfo)
{
try
{
Process.Start(startInfo);
}
catch
{
// 외부 설정 페이지 열기는 보조 동작이므로 실패해도 조용히 무시한다.
}
}
}
135 changes: 135 additions & 0 deletions src/TableCloth.Core/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;

#nullable enable
Expand All @@ -11,6 +12,140 @@ namespace TableCloth
{
public static class Helpers
{
// 전원 관리 옵션 "이름(GUID)"이 아니라, 활성 옵션의 실제 CPU 성능 상한(최대 프로세서 상태)을 읽어
// 판단한다. 이름 기반 판별은 복제/OEM 커스텀 옵션을 오탐하고, AC의 '균형 조정'처럼 실제로는
// 100%로 동작하는 경우도 잘못 경고했다. 여기서는 현재 전원 소스(AC/DC)의 최대 프로세서 상태를
// 그대로 읽어, CPU가 실제로 제한(스로틀)될 때만(=100% 미만) Windows Sandbox 성능 저하로 판정한다.

/// <summary>Windows 프로세서 전원 관리 하위 그룹 GUID(GUID_PROCESSOR_SETTINGS_SUBGROUP).</summary>
private static readonly Guid ProcessorSettingsSubgroupGuid =
new Guid("54533251-82be-4824-96c1-47b60b740d00");

/// <summary>"최대 프로세서 상태" 설정 GUID(GUID_PROCESSOR_THROTTLE_MAXIMUM). 값은 0~100(%).</summary>
private static readonly Guid ProcessorThrottleMaximumSettingGuid =
new Guid("bc5038f7-23e0-4960-96da-33abaf5935ec");

private const uint ERROR_SUCCESS = 0u;

/// <summary>제한이 없는(=최고 성능) 최대 프로세서 상태 값(%).</summary>
private const int FullProcessorStatePercent = 100;

[DllImport("powrprof.dll", SetLastError = true)]
private static extern uint PowerGetActiveScheme(IntPtr userRootPowerKey, out IntPtr activePolicyGuid);

[DllImport("powrprof.dll")]
private static extern uint PowerReadACValueIndex(
IntPtr rootPowerKey, in Guid schemeGuid, in Guid subGroupOfPowerSettingsGuid,
in Guid powerSettingGuid, out uint acValueIndex);

[DllImport("powrprof.dll")]
private static extern uint PowerReadDCValueIndex(
IntPtr rootPowerKey, in Guid schemeGuid, in Guid subGroupOfPowerSettingsGuid,
in Guid powerSettingGuid, out uint dcValueIndex);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr hMem);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetSystemPowerStatus(out SYSTEM_POWER_STATUS lpSystemPowerStatus);

[StructLayout(LayoutKind.Sequential)]
private struct SYSTEM_POWER_STATUS
{
public byte ACLineStatus; // 0 = 배터리(오프라인), 1 = AC(온라인), 255 = 알 수 없음
public byte BatteryFlag;
public byte BatteryLifePercent;
public byte SystemStatusFlag;
public int BatteryLifeTime;
public int BatteryFullLifeTime;
}

/// <summary>
/// 최대 프로세서 상태 값(%)이 "제한됨(스로틀)"에 해당하는지 여부를 반환하는 순수 함수.
/// 100% 미만이면 CPU 성능이 상한으로 제한된 것으로 본다.
/// </summary>
public static bool IsProcessorStateThrottled(int maxProcessorStatePercent)
=> maxProcessorStatePercent < FullProcessorStatePercent;

/// <summary>
/// 호스트에서 현재 활성화된 전원 관리 옵션의 GUID를 <c>PowerGetActiveScheme</c>로 읽는다.
/// 읽기에 실패하면 <see langword="null"/>을 반환한다.
/// </summary>
public static Guid? GetActivePowerSchemeGuid()
{
var activePolicyGuidPtr = IntPtr.Zero;

try
{
if (PowerGetActiveScheme(IntPtr.Zero, out activePolicyGuidPtr) != ERROR_SUCCESS)
return null;

if (activePolicyGuidPtr == IntPtr.Zero)
return null;

return Marshal.PtrToStructure<Guid>(activePolicyGuidPtr);
}
catch
{
return null;
}
finally
{
if (activePolicyGuidPtr != IntPtr.Zero)
LocalFree(activePolicyGuidPtr);
}
}

/// <summary>현재 배터리(DC)로 실행 중인지 여부. 판별 불가/AC면 <see langword="false"/>.</summary>
private static bool IsRunningOnBattery()
{
if (!GetSystemPowerStatus(out var status))
return false;

// 0 = 배터리. 1(AC)/255(알 수 없음)은 AC로 간주해 AC 설정을 읽는다(보수적).
return status.ACLineStatus == 0;
}
Comment on lines +101 to +108

/// <summary>
/// 현재 전원 소스(AC 또는 배터리)에 적용되는 활성 옵션의 "최대 프로세서 상태"(%)를 읽는다.
/// 읽기에 실패하면 <see langword="null"/>을 반환한다.
/// </summary>
public static int? GetActiveMaxProcessorStatePercent()
{
var activeSchemeGuid = GetActivePowerSchemeGuid();
if (!activeSchemeGuid.HasValue)
return null;

var schemeGuid = activeSchemeGuid.Value;

var result = IsRunningOnBattery()
? PowerReadDCValueIndex(IntPtr.Zero, in schemeGuid, in ProcessorSettingsSubgroupGuid,
in ProcessorThrottleMaximumSettingGuid, out var value)
: PowerReadACValueIndex(IntPtr.Zero, in schemeGuid, in ProcessorSettingsSubgroupGuid,
in ProcessorThrottleMaximumSettingGuid, out value);

if (result != ERROR_SUCCESS)
return null;

return (int)value;
}
Comment on lines +114 to +132

/// <summary>
/// 호스트의 CPU 최대 성능이 제한(스로틀)되어 Windows Sandbox가 느려질 가능성이 높은지 여부를 반환한다.
/// 현재 전원 소스의 최대 프로세서 상태가 100% 미만이면 <see langword="true"/>. 판별에 실패하면
/// (예: 지원되지 않는 환경) <see langword="null"/>을 반환해, 확신할 때만 경고하도록 한다.
/// </summary>
public static bool? IsSandboxCpuLikelyThrottled()
{
var maxProcessorStatePercent = GetActiveMaxProcessorStatePercent();

if (!maxProcessorStatePercent.HasValue)
return null;

return IsProcessorStateThrottled(maxProcessorStatePercent.Value);
}

public static bool IsDevelopmentBuild =>
#if DEBUG
true
Expand Down
Loading
Loading