Compare commits

...

5 Commits

Author SHA1 Message Date
63b924e72a Поправлен ридми 2026-06-04 22:54:24 +03:00
Roman Pytkov
4cd09b7b77 Рефакторинг 2026-06-04 22:14:56 +03:00
Roman Pytkov
f9da101c4a WPF реализация 2026-06-04 22:02:15 +03:00
Roman Pytkov
85e9092d14 Исправлено предупреждение 2026-06-04 21:55:09 +03:00
Roman Pytkov
a827bd608b Убраны попытки применить переменные окружения к сессии 2026-06-04 21:48:07 +03:00
20 changed files with 423 additions and 255 deletions

11
.vscode/launch.json vendored
View File

@@ -12,6 +12,17 @@
"console": "integratedTerminal", "console": "integratedTerminal",
"stopAtEntry": false "stopAtEntry": false
}, },
{
"name": "WPF (Debug)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-wpf",
"program": "${workspaceFolder}/src/Sms.TaskTwo.Wpf/bin/Debug/net8.0-windows/Sms.TaskTwo.Wpf.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Sms.TaskTwo.Wpf/bin/Debug/net8.0-windows",
"console": "integratedTerminal",
"stopAtEntry": false
},
{ {
"name": "Attach", "name": "Attach",
"type": "coreclr", "type": "coreclr",

27
.vscode/tasks.json vendored
View File

@@ -41,6 +41,33 @@
}, },
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
}, },
{
"label": "build-wpf",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"group": "build",
"problemMatcher": "$msCompile"
},
{
"label": "run-wpf",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj",
"--no-build"
],
"group": "none",
"problemMatcher": "$msCompile",
"dependsOn": "build-wpf"
},
{ {
"label": "run-avalonia", "label": "run-avalonia",
"command": "dotnet", "command": "dotnet",

View File

@@ -1,6 +1,6 @@
# SMS Task Two — редактор переменных среды # SMS Task Two — редактор переменных среды
Десктопное приложение на **Avalonia** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в переносимые проекты для последующего порта на **WPF**. Десктопное приложение на **Avalonia** и **WPF** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в общие проекты.
## Solution ## Solution
@@ -15,7 +15,8 @@
| `Sms.Environment.Linux` | `~/.config/environment.d/99-sms-task-two.conf` | | `Sms.Environment.Linux` | `~/.config/environment.d/99-sms-task-two.conf` |
| `Sms.TaskTwo.Core` | Конфигурация, сервис, логирование | | `Sms.TaskTwo.Core` | Конфигурация, сервис, логирование |
| `Sms.TaskTwo.ViewModels` | MVVM (`CommunityToolkit.Mvvm`) | | `Sms.TaskTwo.ViewModels` | MVVM (`CommunityToolkit.Mvvm`) |
| `Sms.TaskTwo.Avalonia` | UI-хост | | `Sms.TaskTwo.Avalonia` | UI-хост (Avalonia) |
| `Sms.TaskTwo.Wpf` | UI-хост (WPF) |
## Сборка и запуск ## Сборка и запуск
@@ -42,7 +43,7 @@ dotnet run --project src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj
{"SMS_MEAL_SERVER_URL":"URL сервера","SMS_MEAL_API_KEY":"ключ API"} {"SMS_MEAL_SERVER_URL":"URL сервера","SMS_MEAL_API_KEY":"ключ API"}
``` ```
Переменная записывается тем же механизмом, что и остальные, и доступна другим процессам после применения окружения ОС. Переменная записывается тем же механизмом, что и остальные, и доступна другим процессам после перезапуска сессии или приложений.
## Логирование ## Логирование
@@ -72,19 +73,7 @@ dotnet run --project src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj
### Windows ### Windows
- Чтение/запись: реестр `HKEY_CURRENT_USER\Environment`. - Чтение/запись: реестр `HKEY_CURRENT_USER\Environment`.
- **Применить к сессии** (`ReloadEnvironment`): обновляет env текущего процесса и рассылает `WM_SETTINGCHANGE` для других GUI-приложений.
### Linux ### Linux
- Запись в `~/.config/environment.d/` (systemd `KEY=value`). - Запись в `~/.config/environment.d/` (systemd `KEY=value`); переменные подхватываются после перезапуска login-сессии.
- **Применить к сессии** не поддерживается: переменные подхватываются после перезапуска login-сессии.
## Предположения (ТЗ)
1. Колонка «Поле» — только имена из `appsettings.json`, без добавления новых строк вручную.
2. Значения по умолчанию показываются в UI, в ОС записываются при первом изменении пользователем.
3. Pixel-perfect вёрстка не требуется; элементы стилизованы по макету (заголовок, DataGrid, кнопки «−» / «×»).
## Порт на WPF
Создать `Sms.TaskTwo.Wpf`, подключить `Sms.TaskTwo.Core`, `Sms.TaskTwo.ViewModels`, зарегистрировать `IEnvironmentVariableStore` так же, как в `App.axaml.cs` Avalonia-проекта.

View File

@@ -4,6 +4,7 @@
<Project Path="src/Sms.Environment.Windows/Sms.Environment.Windows.csproj" /> <Project Path="src/Sms.Environment.Windows/Sms.Environment.Windows.csproj" />
<Project Path="src/Sms.Environment/Sms.Environment.csproj" /> <Project Path="src/Sms.Environment/Sms.Environment.csproj" />
<Project Path="src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj" /> <Project Path="src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj" />
<Project Path="src/Sms.TaskTwo.Wpf/Sms.TaskTwo.Wpf.csproj" />
<Project Path="src/Sms.TaskTwo.Core/Sms.TaskTwo.Core.csproj" /> <Project Path="src/Sms.TaskTwo.Core/Sms.TaskTwo.Core.csproj" />
<Project Path="src/Sms.TaskTwo.ViewModels/Sms.TaskTwo.ViewModels.csproj" /> <Project Path="src/Sms.TaskTwo.ViewModels/Sms.TaskTwo.ViewModels.csproj" />
</Folder> </Folder>

View File

@@ -52,7 +52,6 @@ public sealed class LinuxEnvironmentVariableStore : IEnvironmentVariableStore
var managed = LoadManagedFile(); var managed = LoadManagedFile();
managed[name] = value; managed[name] = value;
WriteManagedFileAtomic(managed); WriteManagedFileAtomic(managed);
System.Environment.SetEnvironmentVariable(name, value);
} }
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{ {
@@ -115,8 +114,6 @@ public sealed class LinuxEnvironmentVariableStore : IEnvironmentVariableStore
WriteConfFileAtomic(file, variables); WriteConfFileAtomic(file, variables);
} }
System.Environment.SetEnvironmentVariable(name, null);
} }
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{ {
@@ -126,15 +123,6 @@ public sealed class LinuxEnvironmentVariableStore : IEnvironmentVariableStore
} }
} }
public EnvironmentReloadResult ReloadEnvironment() =>
new()
{
Success = false,
Message =
"Применение переменных к сессии в Linux не поддерживается. " +
"Значения сохраняются в ~/.config/environment.d/ и подхватываются после перезапуска login-сессии.",
};
private Dictionary<string, string> LoadManagedFile() private Dictionary<string, string> LoadManagedFile()
{ {
if (!File.Exists(_managedFilePath)) if (!File.Exists(_managedFilePath))

View File

@@ -1,13 +1,12 @@
using System.Runtime.InteropServices; using System.Runtime.Versioning;
using Microsoft.Win32; using Microsoft.Win32;
using Sms.Environment; using Sms.Environment;
namespace Sms.Environment.Windows; namespace Sms.Environment.Windows;
[SupportedOSPlatform("windows")]
public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
{ {
private const int HWND_BROADCAST = 0xffff;
private const int WM_SETTINGCHANGE = 0x001A;
private const string EnvironmentKeyPath = "Environment"; private const string EnvironmentKeyPath = "Environment";
public string? Get(string name) => public string? Get(string name) =>
@@ -25,8 +24,6 @@ public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
using var key = Registry.CurrentUser.OpenSubKey(EnvironmentKeyPath, writable: true) using var key = Registry.CurrentUser.OpenSubKey(EnvironmentKeyPath, writable: true)
?? Registry.CurrentUser.CreateSubKey(EnvironmentKeyPath, writable: true); ?? Registry.CurrentUser.CreateSubKey(EnvironmentKeyPath, writable: true);
key.SetValue(name, value, RegistryValueKind.ExpandString); key.SetValue(name, value, RegistryValueKind.ExpandString);
System.Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
BroadcastEnvironmentChange();
} }
public bool Exists(string name) => Get(name) is not null; public bool Exists(string name) => Get(name) is not null;
@@ -79,27 +76,6 @@ public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
{ {
using var key = Registry.CurrentUser.OpenSubKey(EnvironmentKeyPath, writable: true); using var key = Registry.CurrentUser.OpenSubKey(EnvironmentKeyPath, writable: true);
key?.DeleteValue(name, throwOnMissingValue: false); key?.DeleteValue(name, throwOnMissingValue: false);
System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process);
BroadcastEnvironmentChange();
}
public EnvironmentReloadResult ReloadEnvironment()
{
var variables = GetUserPersistedEnvironment();
foreach (var pair in variables)
{
System.Environment.SetEnvironmentVariable(pair.Key, pair.Value, EnvironmentVariableTarget.Process);
}
BroadcastEnvironmentChange();
return new EnvironmentReloadResult
{
Success = true,
Message = variables.Count == 0
? "Пользовательских переменных для применения нет."
: $"Применено {variables.Count} переменных к текущему процессу и отправлено WM_SETTINGCHANGE для других приложений.",
};
} }
private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source) private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source)
@@ -112,36 +88,4 @@ public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
return result; return result;
} }
private static void BroadcastEnvironmentChange()
{
try
{
_ = NativeMethods.SendMessageTimeout(
HWND_BROADCAST,
WM_SETTINGCHANGE,
IntPtr.Zero,
"Environment",
0,
1000,
out _);
}
catch
{
// Non-critical: new processes still see updated registry values.
}
}
private static class NativeMethods
{
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr SendMessageTimeout(
int hWnd,
int msg,
IntPtr wParam,
string lParam,
int fuFlags,
int uTimeout,
out IntPtr lpdwResult);
}
} }

View File

@@ -1,8 +0,0 @@
namespace Sms.Environment;
public sealed class EnvironmentReloadResult
{
public required bool Success { get; init; }
public required string Message { get; init; }
}

View File

@@ -19,6 +19,4 @@ public interface IEnvironmentVariableStore
bool IsPersistedInUserStore(string name); bool IsPersistedInUserStore(string name);
void RemoveFromUserStore(string name); void RemoveFromUserStore(string name);
EnvironmentReloadResult ReloadEnvironment();
} }

View File

@@ -22,7 +22,7 @@
<Grid RowDefinitions="Auto,Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto,*">
<Grid Grid.Row="0" <Grid Grid.Row="0"
Margin="12,12,12,0" Margin="12,12,12,0"
ColumnDefinitions="*,Auto,Auto" ColumnDefinitions="*,Auto"
ColumnSpacing="8"> ColumnSpacing="8">
<CheckBox Content="Отображать все переменные" <CheckBox Content="Отображать все переменные"
IsChecked="{Binding ShowAllVariables}" IsChecked="{Binding ShowAllVariables}"
@@ -31,10 +31,6 @@
Content="Обновить" Content="Обновить"
Command="{Binding RefreshCommand}" Command="{Binding RefreshCommand}"
MinWidth="100" /> MinWidth="100" />
<Button Grid.Column="2"
Content="Применить к сессии"
Command="{Binding ReloadEnvironmentCommand}"
MinWidth="140" />
</Grid> </Grid>
<Grid Grid.Row="1" <Grid Grid.Row="1"
@@ -61,11 +57,7 @@
Spacing="2"> Spacing="2">
<TextBlock Foreground="#C62828" <TextBlock Foreground="#C62828"
Text="{Binding AddVariableError}" Text="{Binding AddVariableError}"
IsVisible="{Binding AddVariableError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding HasAddVariableError}" />
<TextBlock Foreground="#2E7D32"
Text="{Binding ReloadEnvironmentMessage}"
TextWrapping="Wrap"
IsVisible="{Binding ReloadEnvironmentMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel> </StackPanel>
<Border Grid.Row="3" <Border Grid.Row="3"

View File

@@ -1,3 +1,4 @@
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Sms.TaskTwo.ViewModels; using Sms.TaskTwo.ViewModels;
@@ -5,8 +6,6 @@ namespace Sms.TaskTwo.Avalonia.Views;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private readonly Dictionary<EnvironmentVariableRowViewModel, DataGridRow> _gridRows = new();
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -24,71 +23,25 @@ public partial class MainWindow : Window
return; return;
} }
if (_gridRows.TryGetValue(row, out var previousRow) && previousRow != e.Row)
{
previousRow.DataContextChanged -= OnRowDataContextChanged;
}
_gridRows[row] = e.Row;
ApplyRowClasses(e.Row, row); ApplyRowClasses(e.Row, row);
row.RowAppearanceChanged -= OnRowAppearanceChanged; PropertyChangedEventHandler? handler = null;
row.RowAppearanceChanged += OnRowAppearanceChanged; handler = (_, args) =>
e.Row.DataContextChanged -= OnRowDataContextChanged;
e.Row.DataContextChanged += OnRowDataContextChanged;
}
private void OnRowDataContextChanged(object? sender, EventArgs e)
{
if (sender is not DataGridRow gridRow)
{ {
return; if (args.PropertyName is nameof(EnvironmentVariableRowViewModel.UseUserStore))
} {
ApplyRowClasses(e.Row, row);
}
};
var row = _gridRows.FirstOrDefault(pair => pair.Value == gridRow).Key; row.PropertyChanged += handler;
if (row is null) e.Row.DetachedFromVisualTree += (_, _) => row.PropertyChanged -= handler;
{
return;
}
row.RowAppearanceChanged -= OnRowAppearanceChanged;
_gridRows.Remove(row);
gridRow.DataContextChanged -= OnRowDataContextChanged;
}
private void OnRowAppearanceChanged(object? sender, EventArgs e)
{
if (sender is not EnvironmentVariableRowViewModel row)
{
return;
}
if (_gridRows.TryGetValue(row, out var gridRow))
{
ApplyRowClasses(gridRow, row);
}
} }
private static void ApplyRowClasses(DataGridRow gridRow, EnvironmentVariableRowViewModel row) private static void ApplyRowClasses(DataGridRow gridRow, EnvironmentVariableRowViewModel row)
{ {
gridRow.Classes.Remove("appSettings"); gridRow.Classes.Set("appSettings", row.IsFromAppSettings);
gridRow.Classes.Remove("custom"); gridRow.Classes.Set("custom", row.IsCustom);
gridRow.Classes.Remove("userStore"); gridRow.Classes.Set("userStore", row.UseUserStore);
if (row.IsFromAppSettings)
{
gridRow.Classes.Add("appSettings");
}
if (row.IsCustom)
{
gridRow.Classes.Add("custom");
}
if (row.UseUserStore)
{
gridRow.Classes.Add("userStore");
}
} }
} }

View File

@@ -36,12 +36,11 @@ public sealed class EnvironmentVariablesService
_options = options.Value; _options = options.Value;
_log = log; _log = log;
_configuredNames = new HashSet<string>(_options.Names, StringComparer.Ordinal); _configuredNames = new HashSet<string>(_options.Names, StringComparer.Ordinal);
ReloadMetadata();
} }
public void EnsureConfiguredVariablesExist() public void EnsureConfiguredVariablesExist()
{ {
ReloadMetadata();
foreach (var name in _options.Names) foreach (var name in _options.Names)
{ {
if (_store.IsPersistedInUserStore(name)) if (_store.IsPersistedInUserStore(name))
@@ -51,8 +50,7 @@ public sealed class EnvironmentVariablesService
var value = ResolveRequiredValue(name); var value = ResolveRequiredValue(name);
_store.Set(name, value); _store.Set(name, value);
_log.WriteLine( LogInfo($"Created configured variable: {name}={value}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created configured variable: {name}={value}");
} }
} }
@@ -97,7 +95,6 @@ public sealed class EnvironmentVariablesService
public bool TryAddCustomVariable(string name, string value, out string? errorMessage) public bool TryAddCustomVariable(string name, string value, out string? errorMessage)
{ {
ReloadMetadata();
name = name.Trim(); name = name.Trim();
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -127,38 +124,25 @@ public sealed class EnvironmentVariablesService
_customNames.Add(name); _customNames.Add(name);
PersistCustomNames(); PersistCustomNames();
_store.Set(name, value); _store.Set(name, value);
_log.WriteLine( LogInfo($"Created custom variable: {name}={value}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created custom variable: {name}={value}");
errorMessage = null; errorMessage = null;
return true; return true;
} }
public EnvironmentVariableRow GetRowSnapshot(string name) public EnvironmentVariableRow GetRowSnapshot(string name) =>
{ CreateRow(name, ResolveRequiredValue(name));
ReloadMetadata();
return CreateRow(name, ResolveRequiredValue(name));
}
public string GetDisplayValue(string name) => ResolveRequiredValue(name); public string GetRequiredValue(string name) => ResolveRequiredValue(name);
public string? GetProcessValue(string name) => public string? GetProcessValue(string name) =>
_store.GetProcessEnvironment().TryGetValue(name, out var value) ? value : null; _store.GetProcessEnvironment().TryGetValue(name, out var value) ? value : null;
public EnvironmentReloadResult ReloadEnvironment()
{
var result = _store.ReloadEnvironment();
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Reload environment: success={result.Success}; {result.Message}");
return result;
}
public void SaveValue(string name, string value) public void SaveValue(string name, string value)
{ {
var previous = _store.GetUserPersistedValue(name); var previous = _store.GetUserPersistedValue(name);
_store.Set(name, value); _store.Set(name, value);
_log.WriteLine( LogInfo($"Changed value: {name}={value} (previous: {previous ?? "<none>"})");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed value: {name}={value} (previous: {previous ?? "<none>"})");
} }
public void SaveComment(string name, string comment) public void SaveComment(string name, string comment)
@@ -166,22 +150,18 @@ public sealed class EnvironmentVariablesService
var previous = _comments.GetValueOrDefault(name, string.Empty); var previous = _comments.GetValueOrDefault(name, string.Empty);
_comments[name] = comment; _comments[name] = comment;
PersistComments(); PersistComments();
_log.WriteLine( LogInfo($"Changed comment: {name}={comment} (previous: {(string.IsNullOrEmpty(previous) ? "<none>" : previous)})");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed comment: {name}={comment} (previous: {(string.IsNullOrEmpty(previous) ? "<none>" : previous)})");
} }
public void RemoveVariable(string name) public void RemoveVariable(string name)
{ {
ReloadMetadata();
if (IsCustom(name)) if (IsCustom(name))
{ {
_customNames.Remove(name); _customNames.Remove(name);
PersistCustomNames(); PersistCustomNames();
_comments.Remove(name); _comments.Remove(name);
PersistComments(); PersistComments();
_log.WriteLine( LogInfo($"Removed custom variable from list: {name}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Removed custom variable from list: {name}");
} }
DeleteFromUserStore(name); DeleteFromUserStore(name);
@@ -196,8 +176,7 @@ public sealed class EnvironmentVariablesService
var previous = _store.GetUserPersistedValue(name); var previous = _store.GetUserPersistedValue(name);
_store.RemoveFromUserStore(name); _store.RemoveFromUserStore(name);
_log.WriteLine( LogInfo($"Removed from user store: {name} (previous: {previous ?? "<none>"})");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Removed from user store: {name} (previous: {previous ?? "<none>"})");
} }
public bool IsCustom(string name) => _customNames.Contains(name, StringComparer.Ordinal); public bool IsCustom(string name) => _customNames.Contains(name, StringComparer.Ordinal);
@@ -235,12 +214,7 @@ public sealed class EnvironmentVariablesService
if (_configuredNames.Contains(name) || _customNames.Contains(name)) if (_configuredNames.Contains(name) || _customNames.Contains(name))
{ {
if (_options.Defaults.TryGetValue(name, out var defaultValue)) return _options.Defaults.TryGetValue(name, out var defaultValue) ? defaultValue : string.Empty;
{
return defaultValue;
}
return string.Empty;
} }
return string.Empty; return string.Empty;
@@ -260,8 +234,7 @@ public sealed class EnvironmentVariablesService
} }
catch (JsonException ex) catch (JsonException ex)
{ {
_log.WriteLine( LogWarn($"Failed to parse custom variables {_options.CustomVariablesVariableName}: {ex.Message}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] Failed to parse custom variables {_options.CustomVariablesVariableName}: {ex.Message}");
return []; return [];
} }
} }
@@ -287,8 +260,7 @@ public sealed class EnvironmentVariablesService
} }
catch (JsonException ex) catch (JsonException ex)
{ {
_log.WriteLine( LogWarn($"Failed to parse comments variable {_options.CommentsVariableName}: {ex.Message}");
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] Failed to parse comments variable {_options.CommentsVariableName}: {ex.Message}");
return new Dictionary<string, string>(StringComparer.Ordinal); return new Dictionary<string, string>(StringComparer.Ordinal);
} }
} }
@@ -298,4 +270,10 @@ public sealed class EnvironmentVariablesService
var json = JsonSerializer.Serialize(_comments, JsonOptions); var json = JsonSerializer.Serialize(_comments, JsonOptions);
_store.Set(_options.CommentsVariableName, json); _store.Set(_options.CommentsVariableName, json);
} }
private void LogInfo(string message) =>
_log.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] {message}");
private void LogWarn(string message) =>
_log.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] {message}");
} }

View File

@@ -36,6 +36,7 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
private bool _useUserStore; private bool _useUserStore;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ActualValueDisplay))]
private string _actualValue = string.Empty; private string _actualValue = string.Empty;
public string ActualValueDisplay => public string ActualValueDisplay =>
@@ -49,8 +50,6 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
partial void OnUseUserStoreChanged(bool value) partial void OnUseUserStoreChanged(bool value)
{ {
RowAppearanceChanged?.Invoke(this, EventArgs.Empty);
if (_isLoading) if (_isLoading)
{ {
return; return;
@@ -58,9 +57,7 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
if (IsFromAppSettings && !value) if (IsFromAppSettings && !value)
{ {
BeginLoad(); RunWithoutSave(() => UseUserStore = true);
UseUserStore = true;
EndLoad();
return; return;
} }
@@ -79,14 +76,10 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
} }
_service.DeleteFromUserStore(Field); _service.DeleteFromUserStore(Field);
BeginLoad(); RunWithoutSave(() => RequiredValue = _service.GetRequiredValue(Field));
RequiredValue = _service.GetDisplayValue(Field);
EndLoad();
RefreshActualValue(_service.GetProcessValue(Field)); RefreshActualValue(_service.GetProcessValue(Field));
} }
partial void OnActualValueChanged(string value) => OnPropertyChanged(nameof(ActualValueDisplay));
partial void OnRequiredValueChanged(string value) partial void OnRequiredValueChanged(string value)
{ {
if (_isLoading || !UseUserStore) if (_isLoading || !UseUserStore)
@@ -98,22 +91,22 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
RefreshActualValue(_service.GetProcessValue(Field)); RefreshActualValue(_service.GetProcessValue(Field));
} }
public event EventHandler? RowAppearanceChanged;
public void ApplySnapshot(EnvironmentVariableRow row, bool suppressSave = false) public void ApplySnapshot(EnvironmentVariableRow row, bool suppressSave = false)
{ {
if (suppressSave) if (suppressSave)
{ {
BeginLoad(); RunWithoutSave(ApplyValues);
}
else
{
ApplyValues();
} }
RequiredValue = row.Value; void ApplyValues()
Comment = row.Comment;
UseUserStore = row.IsFromAppSettings || row.IsPersistedInUserStore;
if (suppressSave)
{ {
EndLoad(); RequiredValue = row.Value;
Comment = row.Comment;
UseUserStore = row.IsFromAppSettings || row.IsPersistedInUserStore;
} }
} }
@@ -122,10 +115,6 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
ActualValue = processValue ?? string.Empty; ActualValue = processValue ?? string.Empty;
} }
public void BeginLoad() => _isLoading = true;
public void EndLoad() => _isLoading = false;
partial void OnCommentChanged(string value) partial void OnCommentChanged(string value)
{ {
if (_isLoading) if (_isLoading)
@@ -135,4 +124,17 @@ public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
_service.SaveComment(Field, value); _service.SaveComment(Field, value);
} }
private void RunWithoutSave(Action action)
{
_isLoading = true;
try
{
action();
}
finally
{
_isLoading = false;
}
}
} }

View File

@@ -30,29 +30,17 @@ public sealed partial class MainWindowViewModel : ObservableObject
private string _newVariableValue = string.Empty; private string _newVariableValue = string.Empty;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAddVariableError))]
private string? _addVariableError; private string? _addVariableError;
[ObservableProperty] public bool HasAddVariableError => !string.IsNullOrEmpty(AddVariableError);
private string? _reloadEnvironmentMessage;
partial void OnShowAllVariablesChanged(bool value) => SyncRowsFromService(); partial void OnShowAllVariablesChanged(bool value) => SyncRowsFromService();
partial void OnNewVariableNameChanged(string value) => AddVariableCommand.NotifyCanExecuteChanged(); partial void OnNewVariableNameChanged(string value) => AddVariableCommand.NotifyCanExecuteChanged();
[RelayCommand] [RelayCommand]
private void Refresh() private void Refresh() => SyncRowsFromService();
{
SyncRowsFromService();
RefreshProcessStates();
}
[RelayCommand]
private void ReloadEnvironment()
{
var result = _service.ReloadEnvironment();
ReloadEnvironmentMessage = result.Message;
RefreshProcessStates();
}
[RelayCommand(CanExecute = nameof(CanAddVariable))] [RelayCommand(CanExecute = nameof(CanAddVariable))]
private void AddVariable() private void AddVariable()
@@ -74,18 +62,18 @@ public sealed partial class MainWindowViewModel : ObservableObject
private EnvironmentVariableRowViewModel CreateRowViewModel(EnvironmentVariableRow row) private EnvironmentVariableRowViewModel CreateRowViewModel(EnvironmentVariableRow row)
{ {
EnvironmentVariableRowViewModel? viewModel = null; var field = row.Field;
viewModel = new EnvironmentVariableRowViewModel( return new EnvironmentVariableRowViewModel(
row, row,
_service, _service,
onCustomRemoved: () => onCustomRemoved: () =>
{ {
if (viewModel is not null) var existing = Rows.FirstOrDefault(r => r.Field == field);
if (existing is not null)
{ {
Rows.Remove(viewModel); Rows.Remove(existing);
} }
}); });
return viewModel;
} }
private int FindInsertIndexForCustomVariable() private int FindInsertIndexForCustomVariable()
@@ -143,11 +131,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
} }
} }
RefreshProcessStates();
}
private void RefreshProcessStates()
{
foreach (var row in Rows) foreach (var row in Rows)
{ {
row.RefreshActualValue(_service.GetProcessValue(row.Field)); row.RefreshActualValue(_service.GetProcessValue(row.Field));

View File

@@ -0,0 +1,70 @@
<Application x:Class="Sms.TaskTwo.Wpf.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Startup="OnStartup"
Exit="OnExit">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
<Style x:Key="RoundedGridStyle" TargetType="DataGrid">
<Setter Property="RowHeight" Value="32" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="GridLinesVisibility" Value="All" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CanUserReorderColumns" Value="False" />
<Setter Property="CanUserResizeColumns" Value="True" />
<Setter Property="CanUserSortColumns" Value="False" />
<Setter Property="AutoGenerateColumns" Value="False" />
</Style>
<Style x:Key="VariableRowStyle" TargetType="DataGridRow">
<Setter Property="Background" Value="White" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsFromAppSettings}" Value="True">
<Setter Property="Background" Value="#E3F2FD" />
</DataTrigger>
<DataTrigger Binding="{Binding IsCustom}" Value="True">
<Setter Property="Background" Value="#FFF8E1" />
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsFromAppSettings}" Value="True" />
<Condition Binding="{Binding UseUserStore}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter Property="Background" Value="#C8E6C9" />
</MultiDataTrigger>
<DataTrigger Binding="{Binding UseUserStore}" Value="True">
<Setter Property="BorderBrush" Value="#2E7D32" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="WatermarkTextBlockStyle" TargetType="TextBlock">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Margin" Value="6,0,0,0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="#9E9E9E" />
<Setter Property="Visibility" Value="Collapsed" />
</Style>
<Style x:Key="NameWatermarkStyle" TargetType="TextBlock" BasedOn="{StaticResource WatermarkTextBlockStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding NewVariableName}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ValueWatermarkStyle" TargetType="TextBlock" BasedOn="{StaticResource WatermarkTextBlockStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding NewVariableValue}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Padding" Value="8,4" />
</Style>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,54 @@
using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Sms.Environment;
using Sms.Environment.Linux;
using Sms.Environment.Windows;
using Sms.TaskTwo.Core.DependencyInjection;
using Sms.TaskTwo.Core.Logging;
using Sms.TaskTwo.ViewModels;
using Sms.TaskTwo.Wpf.Views;
namespace Sms.TaskTwo.Wpf;
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
private void OnStartup(object sender, StartupEventArgs e)
{
Services = ConfigureServices();
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>();
MainWindow = new MainWindow(mainViewModel);
MainWindow.Show();
}
private void OnExit(object sender, ExitEventArgs e)
{
if (Services.GetService<ConsoleLog>() is IDisposable log)
{
log.Dispose();
}
}
private static IServiceProvider ConfigureServices()
{
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton<IEnvironmentVariableStore>(CreateEnvironmentStore);
services.AddTaskTwoCore(configuration);
services.AddSingleton<MainWindowViewModel>();
return services.BuildServiceProvider();
}
private static IEnvironmentVariableStore CreateEnvironmentStore(IServiceProvider _) =>
OperatingSystem.IsWindows()
? new WindowsEnvironmentVariableStore()
: new LinuxEnvironmentVariableStore();
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.Environment.Linux\Sms.Environment.Linux.csproj" />
<ProjectReference Include="..\Sms.Environment.Windows\Sms.Environment.Windows.csproj" />
<ProjectReference Include="..\Sms.TaskTwo.Core\Sms.TaskTwo.Core.csproj" />
<ProjectReference Include="..\Sms.TaskTwo.ViewModels\Sms.TaskTwo.ViewModels.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,119 @@
<Window x:Class="Sms.TaskTwo.Wpf.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Sms.TaskTwo.ViewModels;assembly=Sms.TaskTwo.ViewModels"
Title="Тестовое WPF-приложение для SmartMealService"
Width="960"
Height="600"
MinWidth="760"
MinHeight="480"
Background="#F5F5F5">
<Border CornerRadius="12"
Background="White"
BorderBrush="#C8C8C8"
BorderThickness="1"
Margin="12"
ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0"
Margin="12,12,12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<CheckBox Content="Отображать все переменные"
IsChecked="{Binding ShowAllVariables}"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Обновить"
Command="{Binding RefreshCommand}"
MinWidth="100"
Margin="8,0,0,0" />
</Grid>
<Grid Grid.Row="1"
Margin="12,8,12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="Новая:"
VerticalAlignment="Center" />
<Grid Grid.Column="1" Margin="8,0,0,0">
<TextBox Text="{Binding NewVariableName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Имя переменной" Style="{StaticResource NameWatermarkStyle}" />
</Grid>
<Grid Grid.Column="2" Margin="8,0,0,0">
<TextBox Text="{Binding NewVariableValue, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Значение" Style="{StaticResource ValueWatermarkStyle}" />
</Grid>
<Button Grid.Column="3"
Content="Добавить"
Command="{Binding AddVariableCommand}"
MinWidth="100"
Margin="8,0,0,0" />
</Grid>
<TextBlock Grid.Row="2"
Margin="12,4,12,0"
Foreground="#C62828"
Text="{Binding AddVariableError}"
Visibility="{Binding HasAddVariableError, Converter={StaticResource BooleanToVisibility}}" />
<Border Grid.Row="3"
Margin="12,4,12,12"
CornerRadius="8"
ClipToBounds="True"
Background="White"
BorderBrush="#B0B0B0"
BorderThickness="1">
<DataGrid x:Name="VariablesGrid"
Style="{StaticResource RoundedGridStyle}"
RowStyle="{StaticResource VariableRowStyle}"
ItemsSource="{Binding Rows}">
<DataGrid.Columns>
<DataGridTemplateColumn Header=""
Width="44">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type vm:EnvironmentVariableRowViewModel}">
<CheckBox IsChecked="{Binding UseUserStore, Mode=TwoWay}"
IsEnabled="{Binding CanChangeUserStore}"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Поле"
Binding="{Binding Field}"
IsReadOnly="True"
Width="210"
MinWidth="150" />
<DataGridTextColumn Header="Требуемое значение"
Binding="{Binding RequiredValue, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Width="2*"
MinWidth="140" />
<DataGridTextColumn Header="Актуальное значение"
Binding="{Binding ActualValueDisplay}"
IsReadOnly="True"
Width="2*"
MinWidth="140" />
<DataGridTextColumn Header="Комментарий"
Binding="{Binding Comment, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Width="240"
MinWidth="180" />
</DataGrid.Columns>
</DataGrid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,13 @@
using System.Windows;
using Sms.TaskTwo.ViewModels;
namespace Sms.TaskTwo.Wpf.Views;
public partial class MainWindow : Window
{
public MainWindow(MainWindowViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Sms.TaskTwo.Wpf.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,16 @@
{
"EnvironmentVariables": {
"Names": [
"SMS_MEAL_SERVER_URL",
"SMS_MEAL_API_KEY"
],
"CommentsVariableName": "SMS_TASK_TWO_COMMENTS",
"CustomVariablesVariableName": "SMS_TASK_TWO_CUSTOM_VARS",
"Defaults": {
"SMS_MEAL_SERVER_URL": "https://localhost"
}
},
"Logging": {
"LogDirectory": "logs"
}
}