Базовая работа на Linux
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
logs/
|
||||||
|
_mockup-page*.png
|
||||||
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Avalonia (Debug)",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build-avalonia",
|
||||||
|
"program": "${workspaceFolder}/src/Sms.TaskTwo.Avalonia/bin/Debug/net8.0/Sms.TaskTwo.Avalonia.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}/src/Sms.TaskTwo.Avalonia/bin/Debug/net8.0",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"stopAtEntry": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "attach"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
83
.vscode/tasks.json
vendored
Normal file
83
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "restore",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"restore",
|
||||||
|
"${workspaceFolder}/Sms.TaskTwo.slnx"
|
||||||
|
],
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build-solution",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Sms.TaskTwo.slnx",
|
||||||
|
"/property:GenerateFullPaths=true",
|
||||||
|
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": "$msCompile",
|
||||||
|
"dependsOn": "restore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build-avalonia",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj",
|
||||||
|
"/property:GenerateFullPaths=true",
|
||||||
|
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||||
|
],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run-avalonia",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj",
|
||||||
|
"--no-build"
|
||||||
|
],
|
||||||
|
"group": "none",
|
||||||
|
"problemMatcher": "$msCompile",
|
||||||
|
"dependsOn": "build-avalonia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run-avalonia (watch)",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"watch",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj"
|
||||||
|
],
|
||||||
|
"group": "none",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "clean",
|
||||||
|
"command": "dotnet",
|
||||||
|
"type": "process",
|
||||||
|
"args": [
|
||||||
|
"clean",
|
||||||
|
"${workspaceFolder}/Sms.TaskTwo.slnx"
|
||||||
|
],
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# SMS Task Two — редактор переменных среды
|
||||||
|
|
||||||
|
Десктопное приложение на **Avalonia** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в переносимые проекты для последующего порта на **WPF**.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Файл решения: [`Sms.TaskTwo.slnx`](Sms.TaskTwo.slnx) (формат XML solution).
|
||||||
|
|
||||||
|
## Структура проектов
|
||||||
|
|
||||||
|
| Проект | Назначение |
|
||||||
|
|--------|------------|
|
||||||
|
| `Sms.Environment` | Интерфейс `IEnvironmentVariableStore` |
|
||||||
|
| `Sms.Environment.Windows` | User env через реестр `HKCU\Environment` |
|
||||||
|
| `Sms.Environment.Linux` | `~/.config/environment.d/99-sms-task-two.conf` |
|
||||||
|
| `Sms.TaskTwo.Core` | Конфигурация, сервис, логирование |
|
||||||
|
| `Sms.TaskTwo.ViewModels` | MVVM (`CommunityToolkit.Mvvm`) |
|
||||||
|
| `Sms.TaskTwo.Avalonia` | UI-хост |
|
||||||
|
|
||||||
|
## Сборка и запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet restore Sms.TaskTwo.slnx
|
||||||
|
dotnet build Sms.TaskTwo.slnx
|
||||||
|
dotnet run --project src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
[`src/Sms.TaskTwo.Avalonia/appsettings.json`](src/Sms.TaskTwo.Avalonia/appsettings.json):
|
||||||
|
|
||||||
|
- `EnvironmentVariables:Names` — массив имён переменных (обязательно по ТЗ).
|
||||||
|
- `EnvironmentVariables:CommentsVariableName` — служебная переменная с JSON-комментариями (`имя → текст`).
|
||||||
|
- `EnvironmentVariables:Defaults` — значения по умолчанию, если переменная ещё не существует в ОС.
|
||||||
|
- `Logging:LogDirectory` — каталог логов (по умолчанию `logs`).
|
||||||
|
|
||||||
|
## Комментарии
|
||||||
|
|
||||||
|
Комментарии к полям хранятся в одной переменной среды (например `SMS_TASK_TWO_COMMENTS`) в формате JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"SMS_MEAL_SERVER_URL":"URL сервера","SMS_MEAL_API_KEY":"ключ API"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменная записывается тем же механизмом, что и остальные, и доступна другим процессам после применения окружения ОС.
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Класс `Sms.TaskTwo.Core.Logging.ConsoleLog` — дублирует записи в консоль и файл.
|
||||||
|
|
||||||
|
По умолчанию для GUI: `logs/test-sms-wpf-app-yyyyMMdd.log`. При вызове `ConsoleLog.Open()` без имени — `test-sms-console-app-yyyyMMdd_HHmmss.log`.
|
||||||
|
|
||||||
|
Пример строки:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-06-04 18:30:01 [INFO] Changed value: SMS_MEAL_SERVER_URL=https://example.com (previous: <none>)
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
- **Добавить** — создаёт пользовательскую переменную (имя + значение); список имён хранится в env-переменной `SMS_TASK_TWO_CUSTOM_VARS` (JSON-массив). Пользовательские строки подсвечиваются жёлтым, идут после переменных из конфига. **Удалить** у пользовательской переменной убирает её из списка и из user store.
|
||||||
|
- При старте переменные из `appsettings.json`, отсутствующие в пользовательском хранилище, **создаются автоматически** (со значением из `Defaults` или пустой строкой).
|
||||||
|
- Строки из конфига **всегда вверху** таблицы (в порядке из `Names`), остальные — ниже по алфавиту.
|
||||||
|
- Изменение одной строки обновляет только её ViewModel (без пересборки всей таблицы); при переключении «все переменные» строки добавляются/удаляются/переставляются инкрементально.
|
||||||
|
- **Отображать все переменные** — показывает все переменные процесса; значения можно записать в пользовательское хранилище (реестр / `environment.d`).
|
||||||
|
- Строки из `appsettings.json` подсвечиваются голубым (`#E3F2FD`).
|
||||||
|
- Переменные в пользовательском хранилище помечаются **USER** и зелёной полосой; кнопка **Удалить** снимает их из HKCU / `environment.d`.
|
||||||
|
- Совпадение обоих признаков — фон `#C8E6C9`.
|
||||||
|
|
||||||
|
## Платформы
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- Чтение/запись: `EnvironmentVariableTarget.User` (реестр `HKEY_CURRENT_USER\Environment`).
|
||||||
|
- После записи отправляется `WM_SETTINGCHANGE`, чтобы обновить env в уже запущенных GUI-приложениях.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
- Запись в `~/.config/environment.d/99-sms-task-two.conf` (формат systemd `KEY=value`, значения с пробелами в кавычках).
|
||||||
|
- Чтение: merge всех `*.conf` в `environment.d`, затем fallback на env текущего процесса.
|
||||||
|
- Для новых login-сессий может потребоваться перелогин или `systemctl --user import-environment` — ограничение systemd, не ошибка приложения.
|
||||||
|
|
||||||
|
## Предположения (ТЗ)
|
||||||
|
|
||||||
|
1. Колонка «Поле» — только имена из `appsettings.json`, без добавления новых строк вручную.
|
||||||
|
2. Значения по умолчанию показываются в UI, в ОС записываются при первом изменении пользователем.
|
||||||
|
3. Pixel-perfect вёрстка не требуется; элементы стилизованы по макету (заголовок, DataGrid, кнопки «−» / «×»).
|
||||||
|
|
||||||
|
## Порт на WPF
|
||||||
|
|
||||||
|
Создать `Sms.TaskTwo.Wpf`, подключить `Sms.TaskTwo.Core`, `Sms.TaskTwo.ViewModels`, зарегистрировать `IEnvironmentVariableStore` так же, как в `App.axaml.cs` Avalonia-проекта.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Sms.Environment.Linux;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
172
src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs
Normal file
172
src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using Sms.Environment;
|
||||||
|
|
||||||
|
namespace Sms.Environment.Linux;
|
||||||
|
|
||||||
|
public sealed class LinuxEnvironmentVariableStore : IEnvironmentVariableStore
|
||||||
|
{
|
||||||
|
private const string ManagedFileName = "99-sms-task-two.conf";
|
||||||
|
|
||||||
|
private readonly string _managedFilePath;
|
||||||
|
private readonly string _environmentDirectory;
|
||||||
|
|
||||||
|
public LinuxEnvironmentVariableStore()
|
||||||
|
: this(
|
||||||
|
Path.Combine(
|
||||||
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
|
||||||
|
".config",
|
||||||
|
"environment.d"),
|
||||||
|
ManagedFileName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LinuxEnvironmentVariableStore(string environmentDirectory, string managedFileName)
|
||||||
|
{
|
||||||
|
_environmentDirectory = environmentDirectory;
|
||||||
|
_managedFilePath = Path.Combine(environmentDirectory, managedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Get(string name)
|
||||||
|
{
|
||||||
|
var fromFiles = LoadMergedFromDirectory();
|
||||||
|
if (fromFiles.TryGetValue(name, out var fileValue))
|
||||||
|
{
|
||||||
|
return fileValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return System.Environment.GetEnvironmentVariable(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(string name, string value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_environmentDirectory);
|
||||||
|
var managed = LoadManagedFile();
|
||||||
|
managed[name] = value;
|
||||||
|
WriteManagedFileAtomic(managed);
|
||||||
|
System.Environment.SetEnvironmentVariable(name, value);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
|
||||||
|
{
|
||||||
|
throw new EnvironmentVariableStoreException(
|
||||||
|
$"Failed to write environment variable '{name}' to '{_managedFilePath}'.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string name) => Get(name) is not null;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names)
|
||||||
|
{
|
||||||
|
var merged = LoadMergedFromDirectory();
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (merged.TryGetValue(name, out var value))
|
||||||
|
{
|
||||||
|
result[name] = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var processValue = System.Environment.GetEnvironmentVariable(name);
|
||||||
|
if (processValue is not null)
|
||||||
|
{
|
||||||
|
result[name] = processValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetProcessEnvironment() =>
|
||||||
|
ToDictionary(System.Environment.GetEnvironmentVariables());
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetUserPersistedEnvironment() =>
|
||||||
|
LoadMergedFromDirectory();
|
||||||
|
|
||||||
|
public bool IsPersistedInUserStore(string name) =>
|
||||||
|
LoadMergedFromDirectory().ContainsKey(name);
|
||||||
|
|
||||||
|
public void RemoveFromUserStore(string name)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_environmentDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(_environmentDirectory, "*.conf"))
|
||||||
|
{
|
||||||
|
var variables = SystemdEnvironmentFileParser.Parse(File.ReadAllText(file));
|
||||||
|
if (!variables.Remove(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteConfFileAtomic(file, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.Environment.SetEnvironmentVariable(name, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
|
||||||
|
{
|
||||||
|
throw new EnvironmentVariableStoreException(
|
||||||
|
$"Failed to remove environment variable '{name}' from '{_environmentDirectory}'.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> LoadManagedFile()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_managedFilePath))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SystemdEnvironmentFileParser.Parse(File.ReadAllText(_managedFilePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> LoadMergedFromDirectory()
|
||||||
|
{
|
||||||
|
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
if (!Directory.Exists(_environmentDirectory))
|
||||||
|
{
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(_environmentDirectory, "*.conf").Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var parsed = SystemdEnvironmentFileParser.Parse(File.ReadAllText(file));
|
||||||
|
foreach (var pair in parsed)
|
||||||
|
{
|
||||||
|
merged[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteManagedFileAtomic(Dictionary<string, string> variables) =>
|
||||||
|
WriteConfFileAtomic(_managedFilePath, variables);
|
||||||
|
|
||||||
|
private static void WriteConfFileAtomic(string path, Dictionary<string, string> variables)
|
||||||
|
{
|
||||||
|
var content = SystemdEnvironmentFileParser.Serialize(variables);
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, content);
|
||||||
|
File.Move(tempPath, path, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (string key in source.Keys)
|
||||||
|
{
|
||||||
|
result[key] = source[key]?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
93
src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs
Normal file
93
src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Sms.Environment.Linux;
|
||||||
|
|
||||||
|
internal static class SystemdEnvironmentFileParser
|
||||||
|
{
|
||||||
|
public static Dictionary<string, string> Parse(string content)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rawLine in content.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separatorIndex = line.IndexOf('=');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separatorIndex].Trim();
|
||||||
|
var value = Unquote(line[(separatorIndex + 1)..].Trim());
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Serialize(IReadOnlyDictionary<string, string> variables)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
foreach (var pair in variables.OrderBy(static p => p.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
builder.Append(pair.Key);
|
||||||
|
builder.Append('=');
|
||||||
|
builder.Append(QuoteIfNeeded(pair.Value));
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteIfNeeded(string value)
|
||||||
|
{
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Any(static c => char.IsWhiteSpace(c) || c is '"' or '\\' or '$'))
|
||||||
|
{
|
||||||
|
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unquote(string value)
|
||||||
|
{
|
||||||
|
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||||
|
{
|
||||||
|
var inner = value[1..^1];
|
||||||
|
return Unescape(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unescape(string value)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
if (value[i] == '\\' && i + 1 < value.Length)
|
||||||
|
{
|
||||||
|
builder.Append(value[++i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(value[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Sms.Environment.Windows;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Sms.Environment;
|
||||||
|
|
||||||
|
namespace Sms.Environment.Windows;
|
||||||
|
|
||||||
|
public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
|
||||||
|
{
|
||||||
|
private const int HWND_BROADCAST = 0xffff;
|
||||||
|
private const int WM_SETTINGCHANGE = 0x001A;
|
||||||
|
|
||||||
|
public string? Get(string name) =>
|
||||||
|
System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User)
|
||||||
|
?? System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process);
|
||||||
|
|
||||||
|
public void Set(string name, string value)
|
||||||
|
{
|
||||||
|
System.Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.User);
|
||||||
|
System.Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
|
||||||
|
BroadcastEnvironmentChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string name) => Get(name) is not null;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
var value = Get(name);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
result[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetProcessEnvironment() =>
|
||||||
|
ToDictionary(System.Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process));
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetUserPersistedEnvironment() =>
|
||||||
|
ToDictionary(System.Environment.GetEnvironmentVariables(EnvironmentVariableTarget.User));
|
||||||
|
|
||||||
|
public bool IsPersistedInUserStore(string name) =>
|
||||||
|
System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User) is not null;
|
||||||
|
|
||||||
|
public void RemoveFromUserStore(string name)
|
||||||
|
{
|
||||||
|
System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.User);
|
||||||
|
System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process);
|
||||||
|
BroadcastEnvironmentChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (string key in source.Keys)
|
||||||
|
{
|
||||||
|
result[key] = source[key]?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Sms.Environment;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
14
src/Sms.Environment/EnvironmentVariableStoreException.cs
Normal file
14
src/Sms.Environment/EnvironmentVariableStoreException.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Sms.Environment;
|
||||||
|
|
||||||
|
public sealed class EnvironmentVariableStoreException : Exception
|
||||||
|
{
|
||||||
|
public EnvironmentVariableStoreException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnvironmentVariableStoreException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Sms.Environment/IEnvironmentVariableStore.cs
Normal file
20
src/Sms.Environment/IEnvironmentVariableStore.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Sms.Environment;
|
||||||
|
|
||||||
|
public interface IEnvironmentVariableStore
|
||||||
|
{
|
||||||
|
string? Get(string name);
|
||||||
|
|
||||||
|
void Set(string name, string value);
|
||||||
|
|
||||||
|
bool Exists(string name);
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names);
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, string> GetProcessEnvironment();
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, string> GetUserPersistedEnvironment();
|
||||||
|
|
||||||
|
bool IsPersistedInUserStore(string name);
|
||||||
|
|
||||||
|
void RemoveFromUserStore(string name);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="Sms.TaskTwo.Avalonia.App"
|
x:Class="Sms.TaskTwo.Avalonia.App"
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Light">
|
||||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
<Application.Styles>
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||||
<Application.Styles>
|
<FluentTheme />
|
||||||
<FluentTheme />
|
<Style Selector="Button.titleButton">
|
||||||
</Application.Styles>
|
<Setter Property="Width" Value="40" />
|
||||||
</Application>
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="FontSize" Value="18" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.titleButton:pointerover">
|
||||||
|
<Setter Property="Background" Value="#D8D8D8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.titleButton.close:pointerover">
|
||||||
|
<Setter Property="Background" Value="#E81123" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridRow.appSettings">
|
||||||
|
<Setter Property="Background" Value="#E3F2FD" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridRow.custom">
|
||||||
|
<Setter Property="Background" Value="#FFF8E1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridRow.userStore">
|
||||||
|
<Setter Property="BorderBrush" Value="#2E7D32" />
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,0,2" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGridRow.appSettings.userStore">
|
||||||
|
<Setter Property="Background" Value="#C8E6C9" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="DataGrid">
|
||||||
|
<Setter Property="RowHeight" Value="32" />
|
||||||
|
</Style>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Sms.Environment;
|
||||||
|
using Sms.Environment.Linux;
|
||||||
|
using Sms.Environment.Windows;
|
||||||
|
using Sms.TaskTwo.Avalonia.Views;
|
||||||
|
using Sms.TaskTwo.Core.DependencyInjection;
|
||||||
|
using Sms.TaskTwo.Core.Logging;
|
||||||
|
using Sms.TaskTwo.ViewModels;
|
||||||
|
|
||||||
namespace Sms.TaskTwo.Avalonia;
|
namespace Sms.TaskTwo.Avalonia;
|
||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
public static IServiceProvider Services { get; private set; } = null!;
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -13,11 +24,44 @@ public partial class App : Application
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
|
Services = ConfigureServices();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
desktop.MainWindow = new MainWindow();
|
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
|
desktop.MainWindow = new MainWindow(mainViewModel);
|
||||||
|
desktop.ShutdownRequested += OnShutdownRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
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"
|
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
|
||||||
x:Class="Sms.TaskTwo.Avalonia.MainWindow"
|
|
||||||
Title="Sms.TaskTwo.Avalonia">
|
|
||||||
Welcome to Avalonia!
|
|
||||||
</Window>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace Sms.TaskTwo.Avalonia;
|
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
|
||||||
{
|
|
||||||
public MainWindow()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Sms.TaskTwo.Avalonia;
|
namespace Sms.TaskTwo.Avalonia;
|
||||||
|
|
||||||
class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
|
||||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
|
||||||
// yet and stuff might break.
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
public static void Main(string[] args) =>
|
||||||
.StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
AppBuilder.Configure<App>()
|
||||||
=> AppBuilder.Configure<App>()
|
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -13,10 +14,25 @@
|
|||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
|
||||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</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>
|
</Project>
|
||||||
|
|||||||
117
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
Normal file
117
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:Sms.TaskTwo.ViewModels"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="Sms.TaskTwo.Avalonia.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Title="{x:Static core:AppResources.WindowTitle}"
|
||||||
|
xmlns:core="using:Sms.TaskTwo.Core"
|
||||||
|
Width="960"
|
||||||
|
Height="600"
|
||||||
|
MinWidth="760"
|
||||||
|
MinHeight="480"
|
||||||
|
Background="#F5F5F5"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaChromeHints="NoChrome"
|
||||||
|
SystemDecorations="None">
|
||||||
|
<Border CornerRadius="12"
|
||||||
|
Background="White"
|
||||||
|
BorderBrush="#C8C8C8"
|
||||||
|
BorderThickness="1"
|
||||||
|
Margin="8">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||||
|
<Grid Grid.Row="0"
|
||||||
|
ColumnDefinitions="*,Auto,Auto"
|
||||||
|
Background="#ECECEC"
|
||||||
|
Height="44">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static core:AppResources.WindowTitle}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="16,0,8,0"
|
||||||
|
FontSize="14" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="titleButton"
|
||||||
|
Content="−"
|
||||||
|
Click="OnMinimizeClick" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="titleButton close"
|
||||||
|
Content="×"
|
||||||
|
Click="OnCloseClick" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<CheckBox Grid.Row="1"
|
||||||
|
Margin="12,8,12,0"
|
||||||
|
Content="Отображать все переменные"
|
||||||
|
IsChecked="{Binding ShowAllVariables}" />
|
||||||
|
|
||||||
|
<Grid Grid.Row="2"
|
||||||
|
Margin="12,8,12,0"
|
||||||
|
ColumnDefinitions="Auto,2*,3*,Auto"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="Новая:"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Watermark="Имя переменной"
|
||||||
|
Text="{Binding NewVariableName}" />
|
||||||
|
<TextBox Grid.Column="2"
|
||||||
|
Watermark="Значение"
|
||||||
|
Text="{Binding NewVariableValue}" />
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Content="Добавить"
|
||||||
|
Command="{Binding AddVariableCommand}"
|
||||||
|
MinWidth="100" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3"
|
||||||
|
Margin="12,4,12,0"
|
||||||
|
Foreground="#C62828"
|
||||||
|
Text="{Binding AddVariableError}"
|
||||||
|
IsVisible="{Binding AddVariableError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<DataGrid x:Name="VariablesGrid"
|
||||||
|
Grid.Row="4"
|
||||||
|
Margin="12,4,12,12"
|
||||||
|
ItemsSource="{Binding Rows}"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
CanUserReorderColumns="False"
|
||||||
|
CanUserResizeColumns="True"
|
||||||
|
CanUserSortColumns="False"
|
||||||
|
GridLinesVisibility="All"
|
||||||
|
HeadersVisibility="Column"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="#B0B0B0"
|
||||||
|
LoadingRow="OnLoadingRow">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header=""
|
||||||
|
Binding="{Binding UserStoreBadge}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
Width="56" />
|
||||||
|
<DataGridTextColumn Header="Поле"
|
||||||
|
Binding="{Binding Field}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
Width="2*" />
|
||||||
|
<DataGridTextColumn Header="Значение"
|
||||||
|
Binding="{Binding Value, Mode=TwoWay}"
|
||||||
|
Width="3*" />
|
||||||
|
<DataGridTextColumn Header="Комментарий"
|
||||||
|
Binding="{Binding Comment, Mode=TwoWay}"
|
||||||
|
Width="3*" />
|
||||||
|
<DataGridTemplateColumn Header=""
|
||||||
|
Width="80">
|
||||||
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:EnvironmentVariableRowViewModel">
|
||||||
|
<Button Content="Удалить"
|
||||||
|
Command="{Binding DeleteFromUserStoreCommand}"
|
||||||
|
Padding="6,2"
|
||||||
|
FontSize="11" />
|
||||||
|
</DataTemplate>
|
||||||
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
|
</DataGridTemplateColumn>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
101
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml.cs
Normal file
101
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Sms.TaskTwo.ViewModels;
|
||||||
|
|
||||||
|
namespace Sms.TaskTwo.Avalonia.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly Dictionary<EnvironmentVariableRowViewModel, DataGridRow> _gridRows = new();
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MainWindow(object dataContext) : this()
|
||||||
|
{
|
||||||
|
DataContext = dataContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLoadingRow(object? sender, DataGridRowEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Row.DataContext is not EnvironmentVariableRowViewModel row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_gridRows.TryGetValue(row, out var previousRow) && previousRow != e.Row)
|
||||||
|
{
|
||||||
|
previousRow.DataContextChanged -= OnRowDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_gridRows[row] = e.Row;
|
||||||
|
ApplyRowClasses(e.Row, row);
|
||||||
|
|
||||||
|
row.RowAppearanceChanged -= OnRowAppearanceChanged;
|
||||||
|
row.RowAppearanceChanged += OnRowAppearanceChanged;
|
||||||
|
|
||||||
|
e.Row.DataContextChanged -= OnRowDataContextChanged;
|
||||||
|
e.Row.DataContextChanged += OnRowDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRowDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not DataGridRow gridRow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = _gridRows.FirstOrDefault(pair => pair.Value == gridRow).Key;
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
gridRow.Classes.Remove("appSettings");
|
||||||
|
gridRow.Classes.Remove("custom");
|
||||||
|
gridRow.Classes.Remove("userStore");
|
||||||
|
|
||||||
|
if (row.IsFromAppSettings)
|
||||||
|
{
|
||||||
|
gridRow.Classes.Add("appSettings");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.IsCustom)
|
||||||
|
{
|
||||||
|
gridRow.Classes.Add("custom");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.IsPersistedInUserStore)
|
||||||
|
{
|
||||||
|
gridRow.Classes.Add("userStore");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMinimizeClick(object? sender, RoutedEventArgs e) =>
|
||||||
|
WindowState = WindowState.Minimized;
|
||||||
|
|
||||||
|
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
|
||||||
|
Close();
|
||||||
|
}
|
||||||
16
src/Sms.TaskTwo.Avalonia/appsettings.json
Normal file
16
src/Sms.TaskTwo.Avalonia/appsettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/Sms.TaskTwo.Core/AppResources.cs
Normal file
6
src/Sms.TaskTwo.Core/AppResources.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Sms.TaskTwo.Core;
|
||||||
|
|
||||||
|
public static class AppResources
|
||||||
|
{
|
||||||
|
public const string WindowTitle = "Тестовое WPF-приложение для SmartMealService";
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Sms.TaskTwo.Core;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Sms.TaskTwo.Core.Configuration;
|
||||||
|
|
||||||
|
public sealed class EnvironmentVariablesOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "EnvironmentVariables";
|
||||||
|
|
||||||
|
public string[] Names { get; init; } = [];
|
||||||
|
|
||||||
|
public string CommentsVariableName { get; init; } = "SMS_TASK_TWO_COMMENTS";
|
||||||
|
|
||||||
|
public string CustomVariablesVariableName { get; init; } = "SMS_TASK_TWO_CUSTOM_VARS";
|
||||||
|
|
||||||
|
public Dictionary<string, string> Defaults { get; init; } = new(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
8
src/Sms.TaskTwo.Core/Configuration/LoggingOptions.cs
Normal file
8
src/Sms.TaskTwo.Core/Configuration/LoggingOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Sms.TaskTwo.Core.Configuration;
|
||||||
|
|
||||||
|
public sealed class LoggingOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Logging";
|
||||||
|
|
||||||
|
public string LogDirectory { get; init; } = "logs";
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Sms.TaskTwo.Core.Configuration;
|
||||||
|
using Sms.TaskTwo.Core.Logging;
|
||||||
|
using Sms.TaskTwo.Core.Services;
|
||||||
|
|
||||||
|
namespace Sms.TaskTwo.Core.DependencyInjection;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddTaskTwoCore(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<EnvironmentVariablesOptions>(
|
||||||
|
configuration.GetSection(EnvironmentVariablesOptions.SectionName));
|
||||||
|
services.Configure<LoggingOptions>(
|
||||||
|
configuration.GetSection(LoggingOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton(CreateConsoleLog);
|
||||||
|
services.AddSingleton<EnvironmentVariablesService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConsoleLog CreateConsoleLog(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LoggingOptions>>().Value;
|
||||||
|
Directory.CreateDirectory(options.LogDirectory);
|
||||||
|
var fileName = Path.Combine(
|
||||||
|
options.LogDirectory,
|
||||||
|
$"test-sms-wpf-app-{DateTime.Now:yyyyMMdd}.log");
|
||||||
|
return ConsoleLog.Open(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs
Normal file
57
src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
namespace Sms.TaskTwo.Core.Logging;
|
||||||
|
|
||||||
|
public sealed class ConsoleLog : IDisposable
|
||||||
|
{
|
||||||
|
private readonly StreamWriter _file;
|
||||||
|
|
||||||
|
private ConsoleLog(string filePath, DateTime startedAt)
|
||||||
|
{
|
||||||
|
FilePath = filePath;
|
||||||
|
StartedAt = startedAt;
|
||||||
|
_file = new StreamWriter(filePath, append: true) { AutoFlush = true };
|
||||||
|
_file.WriteLine($"Запуск: {startedAt:yyyy-MM-dd HH:mm:ss}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string FilePath { get; }
|
||||||
|
|
||||||
|
public DateTime StartedAt { get; }
|
||||||
|
|
||||||
|
public static ConsoleLog Open(string? fileName = null)
|
||||||
|
{
|
||||||
|
var startedAt = TruncateToSeconds(DateTime.Now);
|
||||||
|
fileName ??= $"test-sms-console-app-{startedAt:yyyyMMdd_HHmmss}.log";
|
||||||
|
return new ConsoleLog(Path.GetFullPath(fileName), startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime TruncateToSeconds(DateTime value) =>
|
||||||
|
new(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind);
|
||||||
|
|
||||||
|
public void Write(string? text)
|
||||||
|
{
|
||||||
|
Console.Write(text);
|
||||||
|
_file.Write(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WriteLine(string? text = null)
|
||||||
|
{
|
||||||
|
if (text is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
_file.WriteLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(text);
|
||||||
|
_file.WriteLine(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ReadLine(string prompt)
|
||||||
|
{
|
||||||
|
Write(prompt);
|
||||||
|
var line = Console.ReadLine();
|
||||||
|
_file.WriteLine(line ?? "");
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _file.Dispose();
|
||||||
|
}
|
||||||
16
src/Sms.TaskTwo.Core/Models/EnvironmentVariableRow.cs
Normal file
16
src/Sms.TaskTwo.Core/Models/EnvironmentVariableRow.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Sms.TaskTwo.Core.Models;
|
||||||
|
|
||||||
|
public sealed class EnvironmentVariableRow
|
||||||
|
{
|
||||||
|
public required string Field { get; init; }
|
||||||
|
|
||||||
|
public required string Value { get; init; }
|
||||||
|
|
||||||
|
public required string Comment { get; init; }
|
||||||
|
|
||||||
|
public bool IsFromAppSettings { get; init; }
|
||||||
|
|
||||||
|
public bool IsCustom { get; init; }
|
||||||
|
|
||||||
|
public bool IsPersistedInUserStore { get; init; }
|
||||||
|
}
|
||||||
286
src/Sms.TaskTwo.Core/Services/EnvironmentVariablesService.cs
Normal file
286
src/Sms.TaskTwo.Core/Services/EnvironmentVariablesService.cs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Sms.Environment;
|
||||||
|
using Sms.TaskTwo.Core.Configuration;
|
||||||
|
using Sms.TaskTwo.Core.Logging;
|
||||||
|
using Sms.TaskTwo.Core.Models;
|
||||||
|
|
||||||
|
namespace Sms.TaskTwo.Core.Services;
|
||||||
|
|
||||||
|
public sealed class EnvironmentVariablesService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Regex ValidNamePattern = new(
|
||||||
|
@"^[A-Za-z_][A-Za-z0-9_]*$",
|
||||||
|
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly IEnvironmentVariableStore _store;
|
||||||
|
private readonly EnvironmentVariablesOptions _options;
|
||||||
|
private readonly ConsoleLog _log;
|
||||||
|
private readonly HashSet<string> _configuredNames;
|
||||||
|
private List<string> _customNames = [];
|
||||||
|
private Dictionary<string, string> _comments = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public EnvironmentVariablesService(
|
||||||
|
IEnvironmentVariableStore store,
|
||||||
|
IOptions<EnvironmentVariablesOptions> options,
|
||||||
|
ConsoleLog log)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_options = options.Value;
|
||||||
|
_log = log;
|
||||||
|
_configuredNames = new HashSet<string>(_options.Names, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnsureConfiguredVariablesExist()
|
||||||
|
{
|
||||||
|
ReloadMetadata();
|
||||||
|
|
||||||
|
foreach (var name in _options.Names)
|
||||||
|
{
|
||||||
|
if (_store.IsPersistedInUserStore(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = ResolveValue(name);
|
||||||
|
_store.Set(name, value);
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created configured variable: {name}={value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<EnvironmentVariableRow> Load(bool showAllVariables)
|
||||||
|
{
|
||||||
|
ReloadMetadata();
|
||||||
|
var rows = new List<EnvironmentVariableRow>();
|
||||||
|
var knownNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var name in _options.Names)
|
||||||
|
{
|
||||||
|
rows.Add(CreateRow(name, ResolveValue(name)));
|
||||||
|
knownNames.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var name in _customNames)
|
||||||
|
{
|
||||||
|
if (knownNames.Add(name))
|
||||||
|
{
|
||||||
|
rows.Add(CreateRow(name, ResolveValue(name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showAllVariables)
|
||||||
|
{
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
var process = _store.GetProcessEnvironment();
|
||||||
|
foreach (var pair in process.OrderBy(static p => p.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (ShouldHideVariable(pair.Key) || knownNames.Contains(pair.Key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(CreateRow(pair.Key, pair.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAddCustomVariable(string name, string value, out string? errorMessage)
|
||||||
|
{
|
||||||
|
ReloadMetadata();
|
||||||
|
name = name.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
errorMessage = "Укажите имя переменной.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ValidNamePattern.IsMatch(name))
|
||||||
|
{
|
||||||
|
errorMessage = "Имя может содержать только латинские буквы, цифры и '_', и не может начинаться с цифры.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldHideVariable(name))
|
||||||
|
{
|
||||||
|
errorMessage = "Это имя зарезервировано приложением.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_configuredNames.Contains(name) || _customNames.Contains(name))
|
||||||
|
{
|
||||||
|
errorMessage = "Переменная с таким именем уже есть в списке.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_customNames.Add(name);
|
||||||
|
PersistCustomNames();
|
||||||
|
_store.Set(name, value);
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created custom variable: {name}={value}");
|
||||||
|
|
||||||
|
errorMessage = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnvironmentVariableRow GetRowSnapshot(string name)
|
||||||
|
{
|
||||||
|
ReloadMetadata();
|
||||||
|
return CreateRow(name, ResolveValue(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetDisplayValue(string name) => ResolveValue(name);
|
||||||
|
|
||||||
|
public void SaveValue(string name, string value)
|
||||||
|
{
|
||||||
|
var previous = _store.Get(name);
|
||||||
|
_store.Set(name, value);
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed value: {name}={value} (previous: {previous ?? "<none>"})");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveComment(string name, string comment)
|
||||||
|
{
|
||||||
|
var previous = _comments.GetValueOrDefault(name, string.Empty);
|
||||||
|
_comments[name] = comment;
|
||||||
|
PersistComments();
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed comment: {name}={comment} (previous: {(string.IsNullOrEmpty(previous) ? "<none>" : previous)})");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveVariable(string name)
|
||||||
|
{
|
||||||
|
ReloadMetadata();
|
||||||
|
|
||||||
|
if (IsCustom(name))
|
||||||
|
{
|
||||||
|
_customNames.Remove(name);
|
||||||
|
PersistCustomNames();
|
||||||
|
_comments.Remove(name);
|
||||||
|
PersistComments();
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Removed custom variable from list: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteFromUserStore(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteFromUserStore(string name)
|
||||||
|
{
|
||||||
|
if (!_store.IsPersistedInUserStore(name))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = _store.Get(name);
|
||||||
|
_store.RemoveFromUserStore(name);
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{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);
|
||||||
|
|
||||||
|
private void ReloadMetadata()
|
||||||
|
{
|
||||||
|
_comments = LoadComments();
|
||||||
|
_customNames = LoadCustomNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EnvironmentVariableRow CreateRow(string name, string value)
|
||||||
|
{
|
||||||
|
_comments.TryGetValue(name, out var comment);
|
||||||
|
return new EnvironmentVariableRow
|
||||||
|
{
|
||||||
|
Field = name,
|
||||||
|
Value = value,
|
||||||
|
Comment = comment ?? string.Empty,
|
||||||
|
IsFromAppSettings = _configuredNames.Contains(name),
|
||||||
|
IsCustom = IsCustom(name),
|
||||||
|
IsPersistedInUserStore = _store.IsPersistedInUserStore(name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldHideVariable(string name) =>
|
||||||
|
string.Equals(name, _options.CommentsVariableName, StringComparison.Ordinal)
|
||||||
|
|| string.Equals(name, _options.CustomVariablesVariableName, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private string ResolveValue(string name)
|
||||||
|
{
|
||||||
|
var existing = _store.Get(name);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_options.Defaults.TryGetValue(name, out var defaultValue))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> LoadCustomNames()
|
||||||
|
{
|
||||||
|
var raw = _store.Get(_options.CustomVariablesVariableName);
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(raw, JsonOptions) ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] Failed to parse custom variables {_options.CustomVariablesVariableName}: {ex.Message}");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistCustomNames()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_customNames, JsonOptions);
|
||||||
|
_store.Set(_options.CustomVariablesVariableName, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> LoadComments()
|
||||||
|
{
|
||||||
|
var raw = _store.Get(_options.CommentsVariableName);
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(raw, JsonOptions)
|
||||||
|
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_log.WriteLine(
|
||||||
|
$"{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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistComments()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_comments, JsonOptions);
|
||||||
|
_store.Set(_options.CommentsVariableName, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Sms.TaskTwo.ViewModels;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
113
src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
Normal file
113
src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Sms.TaskTwo.Core.Models;
|
||||||
|
using Sms.TaskTwo.Core.Services;
|
||||||
|
|
||||||
|
namespace Sms.TaskTwo.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly EnvironmentVariablesService _service;
|
||||||
|
private bool _isLoading;
|
||||||
|
|
||||||
|
public EnvironmentVariableRowViewModel(EnvironmentVariableRow row, EnvironmentVariablesService service)
|
||||||
|
{
|
||||||
|
Field = row.Field;
|
||||||
|
IsFromAppSettings = row.IsFromAppSettings;
|
||||||
|
IsCustom = row.IsCustom;
|
||||||
|
_service = service;
|
||||||
|
ApplySnapshot(row, suppressSave: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Field { get; }
|
||||||
|
|
||||||
|
public bool IsFromAppSettings { get; }
|
||||||
|
|
||||||
|
public bool IsCustom { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isPersistedInUserStore;
|
||||||
|
|
||||||
|
public string UserStoreBadge => IsPersistedInUserStore ? "USER" : string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _value = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _comment = string.Empty;
|
||||||
|
|
||||||
|
partial void OnIsPersistedInUserStoreChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(UserStoreBadge));
|
||||||
|
DeleteFromUserStoreCommand.NotifyCanExecuteChanged();
|
||||||
|
RowAppearanceChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? RowAppearanceChanged;
|
||||||
|
|
||||||
|
public void ApplySnapshot(EnvironmentVariableRow row, bool suppressSave = false)
|
||||||
|
{
|
||||||
|
if (suppressSave)
|
||||||
|
{
|
||||||
|
BeginLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
Value = row.Value;
|
||||||
|
Comment = row.Comment;
|
||||||
|
IsPersistedInUserStore = row.IsPersistedInUserStore;
|
||||||
|
|
||||||
|
if (suppressSave)
|
||||||
|
{
|
||||||
|
EndLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginLoad() => _isLoading = true;
|
||||||
|
|
||||||
|
public void EndLoad() => _isLoading = false;
|
||||||
|
|
||||||
|
partial void OnValueChanged(string value)
|
||||||
|
{
|
||||||
|
if (_isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_service.SaveValue(Field, value);
|
||||||
|
if (!IsPersistedInUserStore)
|
||||||
|
{
|
||||||
|
IsPersistedInUserStore = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnCommentChanged(string value)
|
||||||
|
{
|
||||||
|
if (_isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_service.SaveComment(Field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanDeleteFromUserStore))]
|
||||||
|
private void DeleteFromUserStore()
|
||||||
|
{
|
||||||
|
if (IsCustom)
|
||||||
|
{
|
||||||
|
_service.RemoveVariable(Field);
|
||||||
|
Removed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_service.DeleteFromUserStore(Field);
|
||||||
|
BeginLoad();
|
||||||
|
IsPersistedInUserStore = false;
|
||||||
|
Value = _service.GetDisplayValue(Field);
|
||||||
|
EndLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? Removed;
|
||||||
|
|
||||||
|
private bool CanDeleteFromUserStore() => IsPersistedInUserStore || IsCustom;
|
||||||
|
}
|
||||||
135
src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
Normal file
135
src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Sms.TaskTwo.Core.Services;
|
||||||
|
|
||||||
|
namespace Sms.TaskTwo.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class MainWindowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly EnvironmentVariablesService _service;
|
||||||
|
|
||||||
|
public MainWindowViewModel(EnvironmentVariablesService service)
|
||||||
|
{
|
||||||
|
_service = service;
|
||||||
|
Rows = new ObservableCollection<EnvironmentVariableRowViewModel>();
|
||||||
|
_service.EnsureConfiguredVariablesExist();
|
||||||
|
SyncRowsFromService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<EnvironmentVariableRowViewModel> Rows { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showAllVariables;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _newVariableName = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _newVariableValue = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _addVariableError;
|
||||||
|
|
||||||
|
partial void OnShowAllVariablesChanged(bool value) => SyncRowsFromService();
|
||||||
|
|
||||||
|
partial void OnNewVariableNameChanged(string value) => AddVariableCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanAddVariable))]
|
||||||
|
private void AddVariable()
|
||||||
|
{
|
||||||
|
if (!_service.TryAddCustomVariable(NewVariableName, NewVariableValue, out var error))
|
||||||
|
{
|
||||||
|
AddVariableError = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddVariableError = null;
|
||||||
|
var name = NewVariableName.Trim();
|
||||||
|
NewVariableName = string.Empty;
|
||||||
|
NewVariableValue = string.Empty;
|
||||||
|
|
||||||
|
var row = _service.GetRowSnapshot(name);
|
||||||
|
var viewModel = new EnvironmentVariableRowViewModel(row, _service);
|
||||||
|
viewModel.Removed += OnRowRemoved;
|
||||||
|
Rows.Insert(FindInsertIndexForCustomVariable(), viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRowRemoved(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is EnvironmentVariableRowViewModel row)
|
||||||
|
{
|
||||||
|
row.Removed -= OnRowRemoved;
|
||||||
|
Rows.Remove(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int FindInsertIndexForCustomVariable()
|
||||||
|
{
|
||||||
|
for (var index = Rows.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
if (Rows[index].IsFromAppSettings || Rows[index].IsCustom)
|
||||||
|
{
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Rows.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanAddVariable() => !string.IsNullOrWhiteSpace(NewVariableName);
|
||||||
|
|
||||||
|
private void SyncRowsFromService()
|
||||||
|
{
|
||||||
|
var data = _service.Load(ShowAllVariables);
|
||||||
|
var existingByField = Rows.ToDictionary(static r => r.Field, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
for (var index = Rows.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
var row = Rows[index];
|
||||||
|
if (data.All(item => item.Field != row.Field))
|
||||||
|
{
|
||||||
|
row.Removed -= OnRowRemoved;
|
||||||
|
Rows.RemoveAt(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var targetIndex = 0; targetIndex < data.Count; targetIndex++)
|
||||||
|
{
|
||||||
|
var row = data[targetIndex];
|
||||||
|
EnvironmentVariableRowViewModel viewModel;
|
||||||
|
|
||||||
|
if (existingByField.TryGetValue(row.Field, out var existing))
|
||||||
|
{
|
||||||
|
viewModel = existing;
|
||||||
|
viewModel.ApplySnapshot(row, suppressSave: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
viewModel = new EnvironmentVariableRowViewModel(row, _service);
|
||||||
|
viewModel.Removed += OnRowRemoved;
|
||||||
|
existingByField[row.Field] = viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex = Rows.IndexOf(viewModel);
|
||||||
|
if (currentIndex < 0)
|
||||||
|
{
|
||||||
|
Rows.Insert(targetIndex, viewModel);
|
||||||
|
}
|
||||||
|
else if (currentIndex != targetIndex)
|
||||||
|
{
|
||||||
|
MoveItem(Rows, currentIndex, targetIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveItem(
|
||||||
|
ObservableCollection<EnvironmentVariableRowViewModel> collection,
|
||||||
|
int oldIndex,
|
||||||
|
int newIndex)
|
||||||
|
{
|
||||||
|
var item = collection[oldIndex];
|
||||||
|
collection.RemoveAt(oldIndex);
|
||||||
|
collection.Insert(newIndex, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sms.TaskTwo.Core\Sms.TaskTwo.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user