From e07fc408ebd52e8b19af48b108a34aa84b7d9c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Thu, 4 Jun 2026 19:24:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BD=D0=B0=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + .vscode/launch.json | 21 ++ .vscode/tasks.json | 83 +++++ README.md | 91 ++++++ src/Sms.Environment.Linux/Class1.cs | 6 - .../LinuxEnvironmentVariableStore.cs | 172 +++++++++++ .../Sms.Environment.Linux.csproj | 5 +- .../SystemdEnvironmentFileParser.cs | 93 ++++++ src/Sms.Environment.Windows/Class1.cs | 6 - .../Sms.Environment.Windows.csproj | 5 +- .../WindowsEnvironmentVariableStore.cs | 97 ++++++ src/Sms.Environment/Class1.cs | 6 - .../EnvironmentVariableStoreException.cs | 14 + .../IEnvironmentVariableStore.cs | 20 ++ src/Sms.Environment/Sms.Environment.csproj | 2 - src/Sms.TaskTwo.Avalonia/App.axaml | 45 ++- src/Sms.TaskTwo.Avalonia/App.axaml.cs | 48 ++- src/Sms.TaskTwo.Avalonia/MainWindow.axaml | 9 - src/Sms.TaskTwo.Avalonia/MainWindow.axaml.cs | 11 - src/Sms.TaskTwo.Avalonia/Program.cs | 15 +- .../Sms.TaskTwo.Avalonia.csproj | 18 +- .../Views/MainWindow.axaml | 117 +++++++ .../Views/MainWindow.axaml.cs | 101 +++++++ src/Sms.TaskTwo.Avalonia/appsettings.json | 16 + src/Sms.TaskTwo.Core/AppResources.cs | 6 + src/Sms.TaskTwo.Core/Class1.cs | 6 - .../EnvironmentVariablesOptions.cs | 14 + .../Configuration/LoggingOptions.cs | 8 + .../ServiceCollectionExtensions.cs | 36 +++ src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs | 57 ++++ .../Models/EnvironmentVariableRow.cs | 16 + .../Services/EnvironmentVariablesService.cs | 286 ++++++++++++++++++ src/Sms.TaskTwo.Core/Sms.TaskTwo.Core.csproj | 12 +- src/Sms.TaskTwo.ViewModels/Class1.cs | 6 - .../EnvironmentVariableRowViewModel.cs | 113 +++++++ .../MainWindowViewModel.cs | 135 +++++++++ .../Sms.TaskTwo.ViewModels.csproj | 8 +- 37 files changed, 1632 insertions(+), 80 deletions(-) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 README.md delete mode 100644 src/Sms.Environment.Linux/Class1.cs create mode 100644 src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs create mode 100644 src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs delete mode 100644 src/Sms.Environment.Windows/Class1.cs create mode 100644 src/Sms.Environment.Windows/WindowsEnvironmentVariableStore.cs delete mode 100644 src/Sms.Environment/Class1.cs create mode 100644 src/Sms.Environment/EnvironmentVariableStoreException.cs create mode 100644 src/Sms.Environment/IEnvironmentVariableStore.cs delete mode 100644 src/Sms.TaskTwo.Avalonia/MainWindow.axaml delete mode 100644 src/Sms.TaskTwo.Avalonia/MainWindow.axaml.cs create mode 100644 src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml create mode 100644 src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml.cs create mode 100644 src/Sms.TaskTwo.Avalonia/appsettings.json create mode 100644 src/Sms.TaskTwo.Core/AppResources.cs delete mode 100644 src/Sms.TaskTwo.Core/Class1.cs create mode 100644 src/Sms.TaskTwo.Core/Configuration/EnvironmentVariablesOptions.cs create mode 100644 src/Sms.TaskTwo.Core/Configuration/LoggingOptions.cs create mode 100644 src/Sms.TaskTwo.Core/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs create mode 100644 src/Sms.TaskTwo.Core/Models/EnvironmentVariableRow.cs create mode 100644 src/Sms.TaskTwo.Core/Services/EnvironmentVariablesService.cs delete mode 100644 src/Sms.TaskTwo.ViewModels/Class1.cs create mode 100644 src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs create mode 100644 src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c229b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +obj/ +.idea/ +.vs/ +*.user +*.suo +logs/ +_mockup-page*.png diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cce1b42 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ad2a49b --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..504b5cb --- /dev/null +++ b/README.md @@ -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: ) +``` + +## 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-проекта. diff --git a/src/Sms.Environment.Linux/Class1.cs b/src/Sms.Environment.Linux/Class1.cs deleted file mode 100644 index f9da897..0000000 --- a/src/Sms.Environment.Linux/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Sms.Environment.Linux; - -public class Class1 -{ - -} diff --git a/src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs b/src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs new file mode 100644 index 0000000..7ce3a1c --- /dev/null +++ b/src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs @@ -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 GetAll(IEnumerable names) + { + var merged = LoadMergedFromDirectory(); + var result = new Dictionary(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 GetProcessEnvironment() => + ToDictionary(System.Environment.GetEnvironmentVariables()); + + public IReadOnlyDictionary 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 LoadManagedFile() + { + if (!File.Exists(_managedFilePath)) + { + return new Dictionary(StringComparer.Ordinal); + } + + return SystemdEnvironmentFileParser.Parse(File.ReadAllText(_managedFilePath)); + } + + private Dictionary LoadMergedFromDirectory() + { + var merged = new Dictionary(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 variables) => + WriteConfFileAtomic(_managedFilePath, variables); + + private static void WriteConfFileAtomic(string path, Dictionary variables) + { + var content = SystemdEnvironmentFileParser.Serialize(variables); + var tempPath = path + ".tmp"; + File.WriteAllText(tempPath, content); + File.Move(tempPath, path, overwrite: true); + } + + private static Dictionary ToDictionary(System.Collections.IDictionary source) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (string key in source.Keys) + { + result[key] = source[key]?.ToString() ?? string.Empty; + } + + return result; + } +} diff --git a/src/Sms.Environment.Linux/Sms.Environment.Linux.csproj b/src/Sms.Environment.Linux/Sms.Environment.Linux.csproj index fa71b7a..cd3615c 100644 --- a/src/Sms.Environment.Linux/Sms.Environment.Linux.csproj +++ b/src/Sms.Environment.Linux/Sms.Environment.Linux.csproj @@ -1,9 +1,10 @@  - net8.0 enable enable - + + + diff --git a/src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs b/src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs new file mode 100644 index 0000000..b3936b5 --- /dev/null +++ b/src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs @@ -0,0 +1,93 @@ +using System.Text; + +namespace Sms.Environment.Linux; + +internal static class SystemdEnvironmentFileParser +{ + public static Dictionary Parse(string content) + { + var result = new Dictionary(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 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(); + } +} diff --git a/src/Sms.Environment.Windows/Class1.cs b/src/Sms.Environment.Windows/Class1.cs deleted file mode 100644 index fc6968a..0000000 --- a/src/Sms.Environment.Windows/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Sms.Environment.Windows; - -public class Class1 -{ - -} diff --git a/src/Sms.Environment.Windows/Sms.Environment.Windows.csproj b/src/Sms.Environment.Windows/Sms.Environment.Windows.csproj index fa71b7a..cd3615c 100644 --- a/src/Sms.Environment.Windows/Sms.Environment.Windows.csproj +++ b/src/Sms.Environment.Windows/Sms.Environment.Windows.csproj @@ -1,9 +1,10 @@  - net8.0 enable enable - + + + diff --git a/src/Sms.Environment.Windows/WindowsEnvironmentVariableStore.cs b/src/Sms.Environment.Windows/WindowsEnvironmentVariableStore.cs new file mode 100644 index 0000000..4f2f35f --- /dev/null +++ b/src/Sms.Environment.Windows/WindowsEnvironmentVariableStore.cs @@ -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 GetAll(IEnumerable names) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var name in names) + { + var value = Get(name); + if (value is not null) + { + result[name] = value; + } + } + + return result; + } + + public IReadOnlyDictionary GetProcessEnvironment() => + ToDictionary(System.Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process)); + + public IReadOnlyDictionary 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 ToDictionary(System.Collections.IDictionary source) + { + var result = new Dictionary(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); + } +} diff --git a/src/Sms.Environment/Class1.cs b/src/Sms.Environment/Class1.cs deleted file mode 100644 index 791124b..0000000 --- a/src/Sms.Environment/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Sms.Environment; - -public class Class1 -{ - -} diff --git a/src/Sms.Environment/EnvironmentVariableStoreException.cs b/src/Sms.Environment/EnvironmentVariableStoreException.cs new file mode 100644 index 0000000..80c30c9 --- /dev/null +++ b/src/Sms.Environment/EnvironmentVariableStoreException.cs @@ -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) + { + } +} diff --git a/src/Sms.Environment/IEnvironmentVariableStore.cs b/src/Sms.Environment/IEnvironmentVariableStore.cs new file mode 100644 index 0000000..e961852 --- /dev/null +++ b/src/Sms.Environment/IEnvironmentVariableStore.cs @@ -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 GetAll(IEnumerable names); + + IReadOnlyDictionary GetProcessEnvironment(); + + IReadOnlyDictionary GetUserPersistedEnvironment(); + + bool IsPersistedInUserStore(string name); + + void RemoveFromUserStore(string name); +} diff --git a/src/Sms.Environment/Sms.Environment.csproj b/src/Sms.Environment/Sms.Environment.csproj index fa71b7a..d364a52 100644 --- a/src/Sms.Environment/Sms.Environment.csproj +++ b/src/Sms.Environment/Sms.Environment.csproj @@ -1,9 +1,7 @@  - net8.0 enable enable - diff --git a/src/Sms.TaskTwo.Avalonia/App.axaml b/src/Sms.TaskTwo.Avalonia/App.axaml index 1c4a4b6..3ffec8b 100644 --- a/src/Sms.TaskTwo.Avalonia/App.axaml +++ b/src/Sms.TaskTwo.Avalonia/App.axaml @@ -1,10 +1,41 @@ - - - - - - \ No newline at end of file + RequestedThemeVariant="Light"> + + + + + + + + + + + + + diff --git a/src/Sms.TaskTwo.Avalonia/App.axaml.cs b/src/Sms.TaskTwo.Avalonia/App.axaml.cs index 4dfa926..bd62654 100644 --- a/src/Sms.TaskTwo.Avalonia/App.axaml.cs +++ b/src/Sms.TaskTwo.Avalonia/App.axaml.cs @@ -1,11 +1,22 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; 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; public partial class App : Application { + public static IServiceProvider Services { get; private set; } = null!; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -13,11 +24,44 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + Services = ConfigureServices(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow(); + var mainViewModel = Services.GetRequiredService(); + desktop.MainWindow = new MainWindow(mainViewModel); + desktop.ShutdownRequested += OnShutdownRequested; } base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file + + private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) + { + if (Services.GetService() 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(configuration); + services.AddSingleton(CreateEnvironmentStore); + services.AddTaskTwoCore(configuration); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + private static IEnvironmentVariableStore CreateEnvironmentStore(IServiceProvider _) => + OperatingSystem.IsWindows() + ? new WindowsEnvironmentVariableStore() + : new LinuxEnvironmentVariableStore(); +} diff --git a/src/Sms.TaskTwo.Avalonia/MainWindow.axaml b/src/Sms.TaskTwo.Avalonia/MainWindow.axaml deleted file mode 100644 index fb0de79..0000000 --- a/src/Sms.TaskTwo.Avalonia/MainWindow.axaml +++ /dev/null @@ -1,9 +0,0 @@ - - Welcome to Avalonia! - diff --git a/src/Sms.TaskTwo.Avalonia/MainWindow.axaml.cs b/src/Sms.TaskTwo.Avalonia/MainWindow.axaml.cs deleted file mode 100644 index aac11fc..0000000 --- a/src/Sms.TaskTwo.Avalonia/MainWindow.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace Sms.TaskTwo.Avalonia; - -public partial class MainWindow : Window -{ - public MainWindow() - { - InitializeComponent(); - } -} \ No newline at end of file diff --git a/src/Sms.TaskTwo.Avalonia/Program.cs b/src/Sms.TaskTwo.Avalonia/Program.cs index 6d9deff..88af983 100644 --- a/src/Sms.TaskTwo.Avalonia/Program.cs +++ b/src/Sms.TaskTwo.Avalonia/Program.cs @@ -1,20 +1,15 @@ using Avalonia; -using System; 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] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - // Avalonia configuration, don't remove; also used by visual designer. - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); diff --git a/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj b/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj index 1fbaa0f..e224d2c 100644 --- a/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj +++ b/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj @@ -6,6 +6,7 @@ true app.manifest true + enable @@ -13,10 +14,25 @@ - + + + None All + + + + + + + + + + + PreserveNewest + + diff --git a/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml b/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml new file mode 100644 index 0000000..6d20673 --- /dev/null +++ b/src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml @@ -0,0 +1,117 @@ + + + + + +