SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11

в 11:53, , рубрики: Без рубрики
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 1

Я от лица команды хочу показать вам SophiApp — графический наследник Sophia Script for Windows: бесплатная, портативная и полностью опенсорная программа для тонкой настройки Windows 10 и Windows 11.

В этой статье я расскажу, как оброненная мной фраза в комментарии 3 года назад под моей статьей из цикла про тонкую настройку Windows развернула мою жизнь на 180°, а чуть позже — и еще одного человека.

Все это время у меня была идея сделать графическую версию моего модуля на PowerShell, чтобы показать пользователям, каким должен быть современный твикер для Windows, какие функции может в себе нести, а главное — посыл программы: настроить (а не оптимизировать) ОС официальным образом, задокументированным Microsoft, ничего не сломав и не обещая мнимое увеличение производительности, чем грешат аналогичные программы, целенаправленно вводя пользователей в заблуждение.

Уже есть идеи насчет версии 2.0 с более современными UI а-ля Windows 11 и UX, а также расширенной функциональностью, но первый блин, вроде как, не оказался комом. Программа все это время делалась на голом энтузиазме, и мы искренне хотим, чтобы пользователи Windows перестали воспринимать так называемые твикеры как что-то по определению вредное, не несущее пользы, а узнали, как можно настроить современные Windows 10 и 11 и что они в себе таят.

Как появилась идея программы, и знакомство с Дмитрием

Как-то летом 2019 года в моей первой статье Скрипт настройки Windows 10 в ответ на предложение о создании графической версии моего PowerShell-скрипта я посетовал, что PowerShell-грамоте не обучены мы, но есть желание что-нибудь сотворить. На тот момент знания в PowerShell-ремесле были скудны (как и сейчас), потому максимум, на что я рассчитывал, — сварганить что-нибудь на Windows Forms, как делают многие на GitHub. Но вдруг 3 сентября 2019 года в личные сообщения на Хабре мне написал некто, представившись Дмитрием (старый аккаунт на Хабре, GitHub), с предложением сделать то, о чем я мечтал! Сказать, что я был удивлен, что кто-то откликнулся мне помочь, — ничего не сказать. Как выяснилось, он уже собаку съел на такого рода GUI-окнах с кнопками, так как это была часть его работы. А показав нам реальные примеры своих работ, он укрепил меня во мнении, что у нас все получится. Ну, скажем, месяца за 2—3. Кто бы мог помыслить, во что это все выльется для нас обоих…

Что не так с "рынком" твикеров

Наверное, надо немного отвлечься и затронуть тему особенности "рынка" так называемых твикеров для Windows. Фундаментально все программы такого назначения можно разделить на 2 категории:

  1. Настраивают внешний вид ОС;

  2. Вмешиваются в работу ОС (и иногда ломая ее работоспособность на корню):

    1. Удаление Microsoft Defender, вырывая его с корнем из системы;

    2. Удаление несчастных UWP-приложений, варварски выкорчевывая файлы из %ProgramFiles%WindowsApps;

    3. Отключение получения обновлений через Центр обновления Windows.

В основном эта сборная солянка сдобрена парочкой настроек проводника в том или ином виде, добавлением пары политик и так далее. Не забудем также про маниакальное желание пользователей ежесекундно чистить папки по всей ОС, где могут быть хоть какие-то временные файлы. Сие действие возведено просто в абсолют.

Но это все крайности. В основном, когда выходит очередной обзор такого рода программы на YouTube, на превью-картинку ставится текст, что во всех играх у вас повысятся до 300 FPS (вне зависимости от текущих характеристик ПК) и также обязательно употребляется слово "оптимизация". 

В случае с англоязычным сегментом интернета обязательно употребляются "debloat" или "debotnet", намекая, что Windows состоит чуть менее, чем полностью из ненужных программ. Ведь только разработчики такого рода программ знают, что Windows "из коробки" работает нестабильно, а Microsoft скрывает от нас секретные ключи реестра, которые-то и сделают из вашего ПК ракету. И вообще всему виной, по их мнению, Microsoft Defender, сжирающий МБ ОЗУ, предустановленные UWP-приложения и логи, создаваемые бесчисленными сборщиками из Просмотрщика событий, — практически всадники Апокалипсиса!

Пользователи, недовольные быстродействием Windows
Пользователи, недовольные быстродействием Windows

Такого рода действия могут совершаться по следующим причинам:

  1. Привлечение внимания пользователей, у которых в большинстве своем могут быть не самые мощный ПК;

  2. Искренняя убежденность в правильности своих действий в силу отсутствия знаний о работе Windows;

  3. Целенаправленное введение в заблуждение пользователей с целью создания вокруг себя ауры гуру в вопросах работы Windows и того, как ее "ускорить";

  4. Распространение зловредных программ с целью извлечения прибыли от доверчивых пользователей.

Первые попытки на PowerShell, или осознание, что надо писать на C#

Мы с Дмитрием быстро нашли общий язык, и, обговорив вектор разработки, я объяснил, как работают функции в скрипте, чтобы он перенес их в графику.

Долго ли, коротко ли, но уже к концу сентября, когда количество строк кода в его PowerShell-скрипте перевалило за 20 000, powershell.exe встал колом: запуск уже занимал около 10 секунд, и никто не понимал, что там написано и как это работает. Надо было идти дальше. Но куда? Мы явно не рассчитали ни наши силы, ни знания, ни время, необходимое на такой проект. Ответ родился сам по себе: я позвонил Дмитрию и робко предложил ему написать программу на чистом C#. Судя по звукам, которые он начал издавать, я подумал, случилось примерно следующее:

SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 3

Он не отрицал, что пишет иногда для себя простенькие консольные программы на C# для облегчения своей работы, но это не входит в его круг обязанностей — это как хобби, и он не потянет. Уж не знаю, какими словами, но я убедил его попробовать. Тут же встала новая проблема: как на предыдущем GUI-приложении не напихаешь кнопок — тут нужен настоящий дизайн программы! Наверное, именно в то время Дмитрий начал догадываться, что его втягивают в какую-то авантюру.

Такого твикера не дай бог никому! Кадры из к/ф Ширли-мырли
Такого твикера не дай бог никому! Кадры из к/ф Ширли-мырли
Страшные наброски 3000

Все прочие скриншоты с видео утрачены или удалены, и никто уже не увидит, как плохо у нас все выглядело. :)

Где-то к июню 2020 мы окончательно признались себе, что ничего у нас не выйдет с таким подходом, и надо заказывать у какого-нибудь фрилансера UI с нормальным, проработанным UX. И вот впервые нам улыбнулась удача, когда на сайте фрилансеров мы наткнулись на Владимира.

Не будем заострять внимание на этой итерации разработки, так как и она потерпела крах даже с привлеченным разработчиком интерфейсов. Вся проблема в том, что если ты сам до конца не понимаешь, что тебе надо, то и результат будет соответствующий. После получения готового макета в Zeplin наша эйфория длилась недолго: его вариант даже нельзя было сравнивать с нашими потугами, но все разбилось о скалы "незакладывания масштабирования". Мы не учли столько вещей, что было даже стыдно признаться ему, что по сути надо все переделывать. Но осознание провала еще не накрыло нас, и мы барахтались с тем вариантом дизайна еще где-то до декабря 2020, когда пришло окончательно понимание, что так больше не может продолжаться.

В декабре 2020 в третий раз мы закинули невод с твердым намерением завершить проект во что бы то ни стало! Учтя все недостатки предыдущего дизайна (так считали), мы вновь составили техническое задание для Владимира, надеясь на богов верхней реки, что на этот-то раз у нас все получится.

Дмитрий: Ah shit, here we go again
Дмитрий: Ah shit, here we go again

Нельзя сказать, что с приобретением последнего макета разработка пошла легче, просто нами уже двигали как желание сделать наперекор всему, так и уж больно понравился дизайн. В особенности после того, что выдавали мы.

Текущая версии SophiApp и то, как она работает

SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 6

Полномочия в команде мы разделили следующим образом: на мои плечи возложено было написание всех логически верных проверок на PowerShell, чтобы Дмитрий мог в дальнейшем понять, как выставлять чекбоксы в интерфейсе, то бишь, когда ложь, а когда истина. Кроме того, перевод интерфейса на английский язык, тестирование сборок, PR, написание начальных проверок и проверок для определения работоспособности Microsoft Defender (чтобы отсеять пользователей, у которых он сломан или сломан кэш WMI), логика работы каждой функции. Дмитрию же достались написание кода и отладка.

Стоит заострить внимание, почему мы так трепетно относимся к проверке версии сборки Windows и работоспособности Microsoft Defender. На самом деле все достаточно банально: как показала практика поддержки моего скрипта Sophia Script for Windows (более 500 000 скачиваний за 2,5 года, а также более 5 000 звезд на GitHub), есть достаточная прослойка пользователей, которые целенаправленно ломают Windows, используя сомнительные программы, с целью  выключения встроенного антивируса или его полного вырезания из системы. Все опять же растет из YouTube, где нечистые на руку блогеры специально ведут риторику о том, что все беды в ОС от наличия в ней Microsoft Defender. 

Другая крайность, с которой мы столкнулись, — это отказ некоторых пользователей вообще обновлять их Windows, так как они уверены (опять же с подачи блоггеров), что обновления к ОС выходят каждый день, и их надо срочно отключить. Эти два фактора напрямую влияют, какой фидбек мы получаем от пользователя после использования SophiApp. И чем сильнее укрепляется в вере пользователь, что Defender нужно сломать, а ОС никогда не обновлять, тем больше ошибок возникает в работе нашей программы.

С учетом этих вводных, волевым усилием было принято решение где-то раз в полгода повышать требование к минорной версии билда Windows. На текущий момент это 1904x.1766+ для Windows 10 и 22000.739+, 22509+ для Windows 11 и Windows 11 Insider Preview соответственно.

То же касается и проверки через интернет, последняя ли версия программы запущена: в каждом билде исправляются ошибки (и, конечно, добавляются новые), потому мы хотим предоставить лучший опыт использования пользователям. Для этого программа не только уведомляет об обнаружении новой версии, но и блокирует текущую, если ее версия ниже, чем уже имеется. А проверка идется достаточно примитивно. Как у многих софтверных компаний в облаке хранится JSON-файл, где прописываются последние версии для стабильной ветки и для бета-версии.

Итак мы плавно подходим к описанию того, как работает под капотом SophiApp, но сначала напомню особенности программы:

  • Динамически отрисовывающийся UI: все элемент НЕ захардкожены;

  • 25 000+ строк кода (не считая JSON-конфигов);

  • Больше 130 твиков;

  • Копировать описание функций через ПКМ;

  • Переведена носителями на английский, украинский, немецкий, итальянский, французский, чешский и турецкий языки;

  • SophiApp использует паттерн MVVM;

  • Поддержка многопоточности;

  • SophiApp проверяется статическим анализатором, лицензию на который любезно предоставили в PVS-Studio;

  • Все билды компилируются в облаке с использованием GitHub Actions (конфиг). Вы можете сравнить хэш-сумму архива на странице релиза с хэш-суммой в облачной консоли на шаге «Compress Files», чтобы быть уверенным, что архив не подменялся после релиза (для открытия облачных логов вы должны войти в вашу учетную запись GitHub);

  • Описание к функциям при наведении курсора на функцию;

  • Имеет встроенный движок поиска по заголовкам и описанию;

  • Программа поддерживает темную и светлую темы. Может менять тему мгновенно в зависимости от выставленного режима приложений в Windows;

  • Настроить конфиденциальность и телеметрию;

  • Выключить задания диагностического характера в Планировщике заданий;

  • Настроить UI и персонализацию;

  • Правильно и до конца удалить OneDrive, не нарушив целостность ОС;

  • Удалить UWP-приложения, отображая локализованные имена пакетов. Список приложений рендерится динамически, используя локальные иконки самих приложений. Ничего не захардкожено;

  • Скачать и установить расширение HEVC Video Extensions from Device Manufacturer, чтобы появилась возможность открывать файлы формата .heic и .heif;

  • Создать задание "Windows Cleanup" по очистке неиспользуемых файлов и обновлений Windows в Планировщике заданий. Перед началом очистки всплывет нативный тост, где вы сможете выбрать: отложить, отменить или запустить задание;

  • Создать задание "SoftwareDistribution" по очистке папок %SystemRoot% SoftwareDistributionDownload и %TEMP% в Планировщике заданий;

  • Настроить безопасность Windows;

  • Программа полностью портативная: в реестре не сохраняются никакие специальные ключи, а после закрытия чистится лог .NET Framework от программы;

  • Огромное количество твиков по кастомизации проводника и контекстного меню;

  • Все настройки проводятся задокументированными возможностями ОС, что исключает шанс навредить работоспособности системы.

Системные требования

  • Windows 10 2004/20H2/21H1/21H2/22H2 x64;

    • Билд 1904x.1706+.

  • Windows 11 21H2/22H2/23H2;

    • 22000.739+, 22509+.

  • Чтобы запустить SophiApp, вы должны быть единственным вошедшим пользователем с правами администратора на ПК;

  • Правильная работоспособность программы гарантируется лишь при использовании оригинального образа ОС. SophiApp может не работать на сломанных сборках Windows;

  • Некоторые функции зависят от доступа в интернет. При отсутствии последнего соответствующие функции будут скрыты в UI до тех пор, пока не появится доступ;

  • Вы можете включить скрытые функции в UI, включив «Расширенные настройки» в Настройках программы. Скрытые функции будут помечены соответствующей шестеренкой;

  • После закрытия SophiApp будет автоматически создан лог-файл, который можно прикрепить, если возникла проблема, чтобы помочь нам понять, что пошло не так.

Сторонние библиотеки

Много скриншотов!
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 7
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 8
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 9
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 10
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 11
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 12
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 13
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 14
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 15
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 16
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 17
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 18
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 19
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 20

Передаю слово Дмитрию, который и написал SophiApp.

Здравствуйте. У меня нет какого-то опыта в написании статей, но постараюсь описать, как мы пришли к идее принципа работы SophiApp в том виде, в котором она сейчас и работает. Жаль, что нельзя, как математики в древней Индии, просто дать ссылку на папку с кодом на GitHub и подписать: "Смотри!". В общем по мере возможности я буду приводить примеры кода, того, как реализован тот или иной аспект работы программы.

В сумме SophiApp мной переписывалась 5 раз с нуля, и каждый раз я был готов бросить сию затею, так как нервы дороже. Но, как видите, мы живы, и программа работает. :) 

Мое перманентное состояние в начале разработки
Мое перманентное состояние в начале разработки

В начале этой истории мне почему-то казалось, что максимально правильно будет не использовать сторонние библиотеки UI, а делать все средствами WPF. К тому же дизайн программы не подразумевал стандартных элементов UI — все должно быть нестандартно, стильно и вызывать wow-эффект. И вообще в программе не используется каких-то особых хаков или ноу-хау. Все достаточно приземленно выглядит.

Пришлось с нуля изучать стилизацию и анимации в WPF. Простой переключатель (switch) я делал неделю, перечитав множество тем на StackOverflow десятилетней давности и испортив тонны кода. А когда он заработал — Дмитрий "обрадовал" меня тем, что все элементы должны поддерживать две темы: тёмную и светлую, и темы должны иметь возможность переключаться "на лету".

Премного благодарен
Премного благодарен

Количество идей и функций, которые нам хотелось сделать, всё нарастало, код становился сложнее: одни методы конфликтовали с другими, что-то работало стабильно, что-то все время ломалось — давал о себе знать человеческий фактор. С каждым днём этот хаос нарастал как лавина и этому не было конца. Конечно, мы быстро достигли того момента, когда любое изменение ломало приложение уже на этапе компиляции.

Думаю, если бы я не пришел к пониманию, что нужна поддержка шаблона MVVM, то еще бы долго и упорно копал не туда. Сначала я очень скептически отнесся к этой идее: "Зачем усложнять и без того сложный код?". Но после тестового приложения все встало на свои места: и я снова переписал SophiApp, добавив поддержку MVVM и заодно RelayCommand для элементов интерфейса. Самое лучшее в коде — это то, что его всегда можно переписать!

This is fine.
This is fine.

Для того чтобы проверить, как будет выглядеть программа "вживую", я сделал специальный дебаг-билд, который считывал из главного JSON-файла названия, описания и тип элементов, и отрисовывал их в интерфейсе. Этот файл послужил основой для текущей версии SophiApp. В папке рядом с исполняемым файлом лежал JSON-файл, и при редактировании последнего без перекомпиляции исполняемого файла программа отрисовывала новые элементы интерфейса, их тип и описание к заголовкам и кнопкам. Это надолго заняло Дмитрия созданием и редактированием текста на русском и английском языках, заодно обогатив его жизненный опыт тонкостями различий между флажком (checkbox) и радиокнопкой (radiobutton) и т. п. Как оказалось, емко и грамотно сформулировать описание ко всем функциям достаточно тяжело, не скатываясь в популизм и не опускаясь до уровня фраз и словечек вроде "выпилить телеметрию", "бессовестное поведение Microsoft", "шпионский модуль", "назойливый Центр безопасности" и прочего, не относящегося к работе Windows. Кстати, все фразы — выдержки из реальных программ.

Так как в приложении много текста, его нужно было как-то хранить. Выбор ожидаемо пал на JSON. JSON — это модно и молодежно, думали мы. Так оно и есть, когда его используют рационально, у нас получился огромный файл, где хранятся все локализации, описания и заголовки ко всем функциям. Огромное преимущество в использовании такого подхода было в том, что его удобно парсить — хоть тем же PowerShell, но про минусы мы узнали, когда нам стали предлагать переводы интерфейса SophiApp. И тут стало понятно, что никого палкой не заставишь вписывать сотни новых строк в этот огромный файл! Выход виделся лишь один: искусственно разбить единый файл на множество маленьких файлов под каждую локализацию, чтобы человек мог перевести файл только с английской локализацией или улучшить уже существующий. Сказано — сделано.

Выбор пал на Json.NET, так как это де-факто стандарт парсинга JSON. Благодаря библиотеке, парсим и превращаем JSON в объекты:

private async Task DeserializeTextedElementsAsync()
{
    await Task.Run(() =>
    {
        var deserializedElements = JsonConvert.DeserializeObject<IEnumerable<TextedElementDto>>Encoding.UTF8.GetString(Properties.Resources.UIData))
                                              .Where(dto => IsWindows11 ? dto.Windows11Supported : dto.Windows10Supported)
                                              .Select(dto => FabricHelper.CreateTextedElement(dto: dto, errorHandler: OnTextedElementErrorAsync, statusHandler: OnTextedElementStatusChanged, language: Localization.Language))
                                              .OrderByDescending(element => element.ViewId);
        TextedElements = new ConcurrentBag<TextedElement>(deserializedElements);
    });
}

Все было безветренно, пока не встал вопрос: "как добавить новый перевод в главный файл". На этот раз на помощь пришел PowerShell. В данном примере показывается, как можно интегрировать турецкую локализацию в основной JSON-файл.

# Compare 2 JSONs and merge them into one
Remove-TypeData System.Array -ErrorAction Ignore

$Parameters = @{
	Uri             = "https://raw.githubusercontent.com/Sophia-Community/SophiApp/master/SophiApp/SophiApp/Resources/UIData.json"
	UseBasicParsing = $true
}
$Full = Invoke-RestMethod @Parameters

$Parameters = @{
	Uri             = "https://raw.githubusercontent.com/Sophia-Community/SophiApp/master/SophiApp/SophiApp/Localizations/UIData_TR.json"
	UseBasicParsing = $true
}
$Translation = Invoke-RestMethod @Parameters

# In this case we add Turkish translation
$ID = "TR"

$Full | ForEach-Object -Process {
	$UiData = $_
	$Data = $Translation | Where-Object -FilterScript {$_.Id -eq $UiData.Id}

	$UiData.Header | Add-Member -Name $ID -MemberType NoteProperty -Value $Data.Header.$ID -Force
	$UiData.Description | Add-Member -Name $ID -MemberType NoteProperty -Value $Data.Description.$ID -Force
	
	if ($UiData.ChildElements)
	{
		$UiData.ChildElements | ForEach-Object -Process {
			$UiChild = $_
			$Child = $Data.ChildElements | Where-Object -FilterScript {$_.Id -eq $UiChild.Id}

			$UiChild.ChildHeader | Add-Member -Name $ID -MemberType NoteProperty -Value $Child.ChildHeader.$ID -Force
			$UiChild.ChildDescription | Add-Member -Name $ID -MemberType NoteProperty -Value $Child.ChildDescription.$ID -Force
		}
	}
}

ConvertTo-Json -InputObject $Full -Depth 4 | ForEach-Object -Process {$_.Replace("u0027", "'")} | Set-Content -Path "D:3.json" -Encoding UTF8 -Force

# Re-save in the UTF-8 without BOM encoding due to JSON must not has the BOM: https://datatracker.ietf.org/doc/html/rfc8259#section-8.1
Set-Content -Value (New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false).GetBytes($(Get-Content -Path "D:3.json" -Raw)) -Encoding Byte -Path "D:3.json" -Force

Хранение всех данных в таком виде позволяет на лету менять язык программы без ее перезапуска — это, конечно, победа, но оборотная сторона такого подхода — крайнее усложнение добавления новых локализаций.

После этих изменений в разработке образовалась — так любимая всеми нами — стабильность. Добавлялись новые фичи, фиксились и вносились баги. Появились люди, которые стали скачивать и запускать тестовые билды, делясь своими предложениями и впечатлениями. После нескольких сеансов общения далеко за полночь по Москве с кем-то из другого полушария стало понятно, что нужно фиксировать как и с какими данными работает приложение. Другими словами, нужен был лог работы программы.

Hidden text
Windows 11 Pro 21H2 build 22000.856
Computer name: DESKTOP-B3E2G5O
User: Sanctuary
User domain: DESKTOP-B3E2G5O
User culture: Russian (Russia)
User region: Russia
App version: 1.0.77.0
App is release: False
App folder: "D:DownloadsSophiApp"
App localization: RU
App theme: DARK
App has access to Internet: True
Release version is available: 1.0.77
Pre-release version is available: 1.0.77
No update required

An error occured in element: 313
Information: PC is not domain-joined
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 502
Information: The UWP package MicrosoftTeams wasn't found in OS
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 230
Information: The UWP package MicrosoftWindows.Client.WebExperience wasn't found in OS
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 262
Information: Unsupported Windows edition
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 601
Information: The UWP package Microsoft.XboxGamingOverlay or Microsoft.GamingApp wasn't found in OS
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 501
Information: The UWP package Microsoft.549981C3F5F10 wasn't found in OS
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 349
Information: OneDrive is not installed on this PC
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 351
Information: The latest version of Visual C++ Redistributable 2015–2022 x64 is installed
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 354
Information: .Net version 6.0.8 already installed on this PC
The method that caused the error: SophiApp.Customisations.CustomisationStatus

An error occured in element: 357
Information: .Net version 6.0.8 already installed on this PC
The method that caused the error: SophiApp.Customisations.CustomisationStatus

The 100 element was initialized in 0,019 second(s)
The 101 element was initialized in 0,001 second(s)
The 104 element was initialized in 0,004 second(s)
The 105 element was initialized in 0,000 second(s)
The 108 element was initialized in 0,015 second(s)
The 118 element was initialized in 0,000 second(s)
The 119 element was initialized in 0,000 second(s)
The 120 element was initialized in 0,000 second(s)
The 121 element was initialized in 0,000 second(s)
The 122 element was initialized in 0,000 second(s)
The 123 element was initialized in 0,000 second(s)
The 124 element was initialized in 0,000 second(s)
The 125 element was initialized in 0,000 second(s)
The 126 element was initialized in 0,000 second(s)
The 127 element was initialized in 0,000 second(s)
The 200 element was initialized in 0,003 second(s)
The 204 element was initialized in 0,000 second(s)
The 205 element was initialized in 0,000 second(s)
The 206 element was initialized in 0,000 second(s)
The 207 element was initialized in 0,000 second(s)
The 208 element was initialized in 0,000 second(s)
The 212 element was initialized in 0,000 second(s)
The 213 element was initialized in 0,000 second(s)
The 214 element was initialized in 0,000 second(s)
The 215 element was initialized in 0,000 second(s)
The 216 element was initialized in 0,000 second(s)
The 220 element was initialized in 0,000 second(s)
The 222 element was initialized in 0,000 second(s)
The 223 element was initialized in 0,000 second(s)
The 224 element was initialized in 0,000 second(s)
The 227 element was initialized in 0,000 second(s)
The 229 element was initialized in 0,000 second(s)
The 230 element was initialized in 0,075 second(s)
The 241 element was initialized in 0,000 second(s)
The 242 element was initialized in 0,001 second(s)
The 246 element was initialized in 0,000 second(s)
The 249 element was initialized in 0,000 second(s)
The 253 element was initialized in 0,000 second(s)
The 254 element was initialized in 0,000 second(s)
The 257 element was initialized in 0,000 second(s)
The 258 element was initialized in 0,000 second(s)
The 259 element was initialized in 0,000 second(s)
The 260 element was initialized in 0,000 second(s)
The 261 element was initialized in 0,000 second(s)
The 262 element was initialized in 0,001 second(s)
The 268 element was initialized in 0,007 second(s)
The 300 element was initialized in 0,002 second(s)
The 301 element was initialized in 0,001 second(s)
The 304 element was initialized in 0,000 second(s)
The 305 element was initialized in 0,000 second(s)
The 306 element was initialized in 0,000 second(s)
The 309 element was initialized in 0,000 second(s)
The 310 element was initialized in 0,000 second(s)
The 311 element was initialized in 0,000 second(s)
The 312 element was initialized in 0,000 second(s)
The 313 element was initialized in 0,007 second(s)
The 314 element was initialized in 0,000 second(s)
The 315 element was initialized in 0,146 second(s)
The 316 element was initialized in 0,030 second(s)
The 319 element was initialized in 0,000 second(s)
The 320 element was initialized in 0,077 second(s)
The 321 element was initialized in 0,000 second(s)
The 324 element was initialized in 0,000 second(s)
The 327 element was initialized in 0,000 second(s)
The 330 element was initialized in 0,000 second(s)
The 331 element was initialized in 0,000 second(s)
The 332 element was initialized in 0,000 second(s)
The 333 element was initialized in 0,000 second(s)
The 334 element was initialized in 0,000 second(s)
The 335 element was initialized in 0,000 second(s)
The 336 element was initialized in 0,000 second(s)
The 337 element was initialized in 0,000 second(s)
The 338 element was initialized in 0,000 second(s)
The 339 element was initialized in 0,005 second(s)
The 340 element was initialized in 0,000 second(s)
The 341 element was initialized in 0,000 second(s)
The 342 element was initialized in 0,013 second(s)
The 347 element was initialized in 0,002 second(s)
The 350 element was initialized in 0,241 second(s)
The 353 element was initialized in 1,921 second(s)
The 356 element was initialized in 0,576 second(s)
The 500 element was initialized in 0,018 second(s)
The 501 element was initialized in 0,004 second(s)
The 502 element was initialized in 0,081 second(s)
The 503 element was initialized in 0,001 second(s)
The 600 element was initialized in 0,002 second(s)
The 601 element was initialized in 0,097 second(s)
The 602 element was initialized in 0,025 second(s)
The 700 element was initialized in 0,020 second(s)
The 701 element was initialized in 0,001 second(s)
The 702 element was initialized in 0,002 second(s)
The 800 element was initialized in 0,051 second(s)
The 801 element was initialized in 0,012 second(s)
The 803 element was initialized in 0,173 second(s)
The 804 element was initialized in 0,043 second(s)
The 805 element was initialized in 0,040 second(s)
The 806 element was initialized in 0,000 second(s)
The 807 element was initialized in 0,000 second(s)
The 808 element was initialized in 0,013 second(s)
The 809 element was initialized in 0,000 second(s)
The 810 element was initialized in 0,000 second(s)
The 811 element was initialized in 1,138 second(s)
The 812 element was initialized in 0,000 second(s)
The 900 element was initialized in 0,002 second(s)
The 901 element was initialized in 0,000 second(s)
The 902 element was initialized in 0,000 second(s)
The 903 element was initialized in 0,000 second(s)
The 904 element was initialized in 0,000 second(s)
The 914 element was initialized in 0,078 second(s)
The 915 element was initialized in 0,012 second(s)
The 917 element was initialized in 0,000 second(s)
The 918 element was initialized in 0,000 second(s)
The 919 element was initialized in 0,000 second(s)
The 920 element was initialized in 1,193 second(s)
The 923 element was initialized in 0,000 second(s)
The 924 element was initialized in 0,000 second(s)
The 925 element was initialized in 0,000 second(s)
The 926 element was initialized in 0,003 second(s)
The 927 element was initialized in 0,002 second(s)
The 928 element was initialized in 0,000 second(s)

10.08.2022 18:34:40 Debug mode is: False
10.08.2022 18:34:40 Active view is: Loading
10.08.2022 18:34:40 Advanced settings is visible: False
10.08.2022 18:34:40 The "UWP for all users" switch state is: UNCHECKED
10.08.2022 18:34:40 The OS conditions checkings started
10.08.2022 18:34:40 OsVersionCondition run result: True
10.08.2022 18:34:40 The next condition to be run: OsBuildVersionCondition
10.08.2022 18:34:40 OsBuildVersionCondition run result: True
10.08.2022 18:34:40 The next condition to be run: OsFilesCorruptedCondition
10.08.2022 18:34:40 OsFilesCorruptedCondition run result: True
10.08.2022 18:34:40 The next condition to be run: RebootRequiredCondition
10.08.2022 18:34:40 RebootRequiredCondition run result: True
10.08.2022 18:34:40 The next condition to be run: SingleInstanceCondition
10.08.2022 18:34:40 SingleInstanceCondition run result: True
10.08.2022 18:34:40 The next condition to be run: SingleAdminSessionCondition
10.08.2022 18:34:40 SingleAdminSessionCondition run result: True
10.08.2022 18:34:40 The next condition to be run: Win10TweakerCondition
10.08.2022 18:34:40 Win10TweakerCondition run result: True
10.08.2022 18:34:40 The next condition to be run: SycnexScriptCondition
10.08.2022 18:34:40 SycnexScriptCondition run result: True
10.08.2022 18:34:40 The next condition to be run: DefenderCorruptedCondition
10.08.2022 18:34:40 DefenderCorruptedCondition run result: True
10.08.2022 18:34:40 The next condition to be run: NewVersionCondition
10.08.2022 18:34:40 NewVersionCondition run result: True
10.08.2022 18:34:40 This is last condition
10.08.2022 18:34:40 It took 1 second(s) to check the OS conditions
10.08.2022 18:34:40 Active view is: Loading
10.08.2022 18:34:41 Initialization of the elements started
10.08.2022 18:34:41 The 900 element changed status to: CHECKED
10.08.2022 18:34:41 The 600 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 300 element changed status to: CHECKED
10.08.2022 18:34:41 The 901 element changed status to: CHECKED
10.08.2022 18:34:41 The 200 element changed status to: CHECKED
10.08.2022 18:34:41 The 301 element changed status to: CHECKED
10.08.2022 18:34:41 The 902 element changed status to: CHECKED
10.08.2022 18:34:41 The 201 element changed status to: CHECKED
10.08.2022 18:34:41 The 302 element changed status to: CHECKED
10.08.2022 18:34:41 The 202 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 903 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 303 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 904 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 204 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 304 element changed status to: CHECKED
10.08.2022 18:34:41 The 205 element changed status to: CHECKED
10.08.2022 18:34:41 The 305 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 306 element changed status to: CHECKED
10.08.2022 18:34:41 The 307 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 206 element changed status to: CHECKED
10.08.2022 18:34:41 The 308 element changed status to: CHECKED
10.08.2022 18:34:41 The 207 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 208 element changed status to: CHECKED
10.08.2022 18:34:41 The 309 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 209 element changed status to: CHECKED
10.08.2022 18:34:41 The 210 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 310 element changed status to: CHECKED
10.08.2022 18:34:41 The 212 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 311 element changed status to: CHECKED
10.08.2022 18:34:41 The 213 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 312 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 214 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 215 element changed status to: CHECKED
10.08.2022 18:34:41 The 216 element changed status to: CHECKED
10.08.2022 18:34:41 The 217 element changed status to: CHECKED
10.08.2022 18:34:41 The 218 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 220 element changed status to: CHECKED
10.08.2022 18:34:41 The 222 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 223 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 224 element changed status to: CHECKED
10.08.2022 18:34:41 The 225 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 226 element changed status to: CHECKED
10.08.2022 18:34:41 The 227 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 229 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 268 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 313 element changed status to: DISABLED
10.08.2022 18:34:41 The 314 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 100 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 101 element changed status to: CHECKED
10.08.2022 18:34:41 The 102 element changed status to: CHECKED
10.08.2022 18:34:41 The 103 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 700 element changed status to: CHECKED
10.08.2022 18:34:41 The 701 element changed status to: CHECKED
10.08.2022 18:34:41 The 104 element changed status to: CHECKED
10.08.2022 18:34:41 The 105 element changed status to: CHECKED
10.08.2022 18:34:41 The 702 element changed status to: CHECKED
10.08.2022 18:34:41 The 106 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 107 element changed status to: CHECKED
10.08.2022 18:34:41 The 108 element changed status to: CHECKED
10.08.2022 18:34:41 The 109 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 110 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 111 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 112 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 113 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 114 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 115 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 116 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 117 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 118 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 119 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 120 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 121 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 122 element changed status to: CHECKED
10.08.2022 18:34:41 The 123 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 124 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 125 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 126 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 127 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 800 element changed status to: CHECKED
10.08.2022 18:34:41 The 801 element changed status to: CHECKED
10.08.2022 18:34:41 The 914 element changed status to: CHECKED
10.08.2022 18:34:41 The 502 element changed status to: DISABLED
10.08.2022 18:34:41 The 230 element changed status to: DISABLED
10.08.2022 18:34:41 The 241 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 242 element changed status to: CHECKED
10.08.2022 18:34:41 The 243 element changed status to: CHECKED
10.08.2022 18:34:41 The 244 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 245 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 246 element changed status to: CHECKED
10.08.2022 18:34:41 The 247 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 248 element changed status to: CHECKED
10.08.2022 18:34:41 The 249 element changed status to: CHECKED
10.08.2022 18:34:41 The 250 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 251 element changed status to: CHECKED
10.08.2022 18:34:41 The 253 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 254 element changed status to: CHECKED
10.08.2022 18:34:41 The 255 element changed status to: CHECKED
10.08.2022 18:34:41 The 256 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 257 element changed status to: CHECKED
10.08.2022 18:34:41 The 258 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 259 element changed status to: CHECKED
10.08.2022 18:34:41 The 260 element changed status to: CHECKED
10.08.2022 18:34:41 The 261 element changed status to: CHECKED
10.08.2022 18:34:41 The 262 element changed status to: CHECKED
10.08.2022 18:34:41 The 262 element changed status to: DISABLED
10.08.2022 18:34:41 The 264 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 265 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 915 element changed status to: CHECKED
10.08.2022 18:34:41 The 917 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 918 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 919 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 500 element changed status to: CHECKED
10.08.2022 18:34:41 The 601 element changed status to: DISABLED
10.08.2022 18:34:41 The 501 element changed status to: DISABLED
10.08.2022 18:34:41 The 503 element changed status to: CHECKED
10.08.2022 18:34:41 The 504 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 602 element changed status to: CHECKED
10.08.2022 18:34:41 The 315 element changed status to: CHECKED
10.08.2022 18:34:41 The 316 element changed status to: CHECKED
10.08.2022 18:34:41 The 317 element changed status to: CHECKED
10.08.2022 18:34:41 The 318 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 319 element changed status to: CHECKED
10.08.2022 18:34:41 The 803 element changed status to: CHECKED
10.08.2022 18:34:41 The 320 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 321 element changed status to: CHECKED
10.08.2022 18:34:41 The 322 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 323 element changed status to: CHECKED
10.08.2022 18:34:41 The 324 element changed status to: CHECKED
10.08.2022 18:34:41 The 325 element changed status to: CHECKED
10.08.2022 18:34:41 The 326 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 327 element changed status to: CHECKED
10.08.2022 18:34:41 The 328 element changed status to: CHECKED
10.08.2022 18:34:41 The 329 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 330 element changed status to: CHECKED
10.08.2022 18:34:41 The 331 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 332 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 333 element changed status to: CHECKED
10.08.2022 18:34:41 The 334 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 335 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 336 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 337 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 338 element changed status to: CHECKED
10.08.2022 18:34:41 The 339 element changed status to: CHECKED
10.08.2022 18:34:41 The 340 element changed status to: CHECKED
10.08.2022 18:34:41 The 341 element changed status to: CHECKED
10.08.2022 18:34:41 The 342 element changed status to: CHECKED
10.08.2022 18:34:41 The 804 element changed status to: CHECKED
10.08.2022 18:34:41 The 343 element changed status to: CHECKED
10.08.2022 18:34:41 The 344 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 347 element changed status to: CHECKED
10.08.2022 18:34:41 The 348 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 349 element changed status to: DISABLED
10.08.2022 18:34:41 The 350 element changed status to: CHECKED
10.08.2022 18:34:41 The 805 element changed status to: CHECKED
10.08.2022 18:34:41 The 806 element changed status to: CHECKED
10.08.2022 18:34:41 The 807 element changed status to: CHECKED
10.08.2022 18:34:41 The 808 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 809 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 810 element changed status to: CHECKED
10.08.2022 18:34:41 The 351 element changed status to: DISABLED
10.08.2022 18:34:41 The 352 element changed status to: UNCHECKED
10.08.2022 18:34:41 The 353 element changed status to: CHECKED
10.08.2022 18:34:42 The 920 element changed status to: CHECKED
10.08.2022 18:34:42 The 923 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 924 element changed status to: CHECKED
10.08.2022 18:34:42 The 925 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 926 element changed status to: CHECKED
10.08.2022 18:34:42 The 927 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 928 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 811 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 812 element changed status to: CHECKED
10.08.2022 18:34:42 The 813 element changed status to: UNCHECKED
10.08.2022 18:34:42 The 814 element changed status to: CHECKED
10.08.2022 18:34:43 The 354 element changed status to: DISABLED
10.08.2022 18:34:43 The 355 element changed status to: UNCHECKED
10.08.2022 18:34:43 The 356 element changed status to: CHECKED
10.08.2022 18:34:44 The 357 element changed status to: DISABLED
10.08.2022 18:34:44 The 358 element changed status to: UNCHECKED
10.08.2022 18:34:44 It took 3 second(s) to initialize elements
10.08.2022 18:34:44 Initialization of the UWP elements started
10.08.2022 18:34:44 It took 1 second(s) to initialize the UWP elements
10.08.2022 18:34:44 Active view is: Privacy

Без создания логов человеческих ресурсов не хватало для вычленения багов. А после добавления логирования можно было просто сказать: "Пришлите лог работы программы", — и идти спокойно спать (нет лога — нет фикса), предвкушая завтрашний дебаг. С логом жить стало лучше, жить стало веселее!

Нельзя обойти стороной и PVS-Studio, который помогает мне найти ошибки в логике работы. Да, их было немного (ведь и проект в сухих цифрах не самый большой), но то ли я стал писать со временем лучше, то ли что-то еще повлияло. Первый раз, когда я натравил PVS-Studio на проект, она нашла в сумме около 30 предупреждений, которые и были исправлены. Многие из этих предупреждений были совсем не очевидны для меня.

После того, как основные функции приложения были готовы и более-менее заработали, оказалось, что найти даже в одной категории нужный твик не так просто, если их много. Захотелось функции поиска. :) Были идеи сделать файлы индекса для каждой локализации и искать в них, динамически подгружая, но пока мы ограничились обычным перебором названий и описаний элементов, использовав функцию Contains.

private async void SearchClickedAsync(object arg)
{
    await Task.Run(() =>
    {
        var stopwatch = Stopwatch.StartNew();
        var searchString = arg as string;
        FoundTextedElement.Clear();
        Search = SearchState.Running;
        SetVisibleViewTag(Tags.ViewSearch);
        FoundTextedElement = TextedElements.Where(element => element.Status != ElementStatus.DISABLED
		&& element.ContainsText(searchString))
		.ToList();
        Search = SearchState.Stopped;
        stopwatch.Stop();
        DebugHelper.StopSearch(searchString, stopwatch.Elapsed.TotalSeconds, foundTextedElement.Count);
    });
}

С ростом функционала я стал понимать, что очень много действий строится на одной парадигме. Потому было решено написать парочку так называемых хелперов по автоматизации однотипных действий. Так появилась папка Helpers, да и не особо уже парочка хелперов там. Они записывают данные, начиная от реестра и заканчивая проверкой на здоровье Microsoft Defender.

Так как в программе достаточно часто исправляются ошибки, то было принято решение при запуске делать проверку через интернет на наличие новой версии и блокировать интерфейс при наличии нового релиза.. Это избавит в теории пользователей от возможности наткнуться на баг, который уже был исправлен.

В облаке хранится JSON-файл, который и парсится. Есть две ветки: стабильная и бета-версия. При компиляции через GitHub Actions считывается тип релиза (release или pre-release) и через скрипт в файл AppHelper.cs записывается свойство билда "private const bool IS_RELEASE" $true или $false. В зависимости от этого при запуске программа читает то или иное свойство файла в облаке, чтобы определить наличие новой версии.

internal class NewVersionCondition : IStartupCondition
    {
        public bool HasProblem { get; set; }
        public ConditionsTag Tag { get; set; } = ConditionsTag.NewVersion;

        public bool Invoke()
        {
            DebugHelper.IsOnline();

            try
            {
                if (HttpHelper.IsOnline)
                {
                    HttpWebRequest request = WebRequest.CreateHttp(AppHelper.SophiAppVersionsJson);
                    request.UserAgent = AppHelper.UserAgent;
                    var response = request.GetResponse();

                    using (Stream dataStream = response.GetResponseStream())
                    {
                        StreamReader reader = new StreamReader(dataStream);
                        var serverResponse = reader.ReadToEnd();
                        var release = JsonConvert.DeserializeObject<ReleaseDto>(serverResponse);
                        DebugHelper.HasUpdateRelease(release);
                        var releasedVersion = new Version(AppHelper.IsRelease ? release.SophiApp_release : release.SophiApp_pre_release);
                        var hasNewVersion = releasedVersion > AppHelper.Version;

                        if (hasNewVersion)
                        {
                            DebugHelper.IsNewRelease();
                            ToastHelper.ShowUpdateToast(currentVersion: $"{AppHelper.Version}", newVersion: $"{releasedVersion}");
                        }
                        else
                        {
                            DebugHelper.UpdateNotNecessary();
                        }

                        return HasProblem = hasNewVersion;
                    }
                }

                return HasProblem;
            }
            catch (WebException e)
            {
                DebugHelper.HasException("An error occurred while checking for an update", e);
                return HasProblem;
            }
            catch (Exception e)
            {
                throw new Exception(e.Message.Replace(":", null));
            }
        }
    }

А при нахождении всплывет вот такой милый тост с уведомлением:

ЗАМЕНИТЬ
ЗАМЕНИТЬ

SophiApp поддерживает динамически-генерируемый список установленных UWP-приложений, подгружая их локализованные имена и беря соответствующую иконку приложения, — ничего не захардкожено. Такое умеют лишь SophiApp и O&O AppBuster от O&O Software. Но эту функцию надо будет тоже как-нибудь переписать, так как она далеко не быстро работает.

SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 25
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 26

В интерфейсе есть тумблер для переключения, в какой области удалять пакеты: в области пользователя или для всей ОС. Тогда все новые создаваемые пользователи не получат удаленные пакеты.

using SophiApp.Dto;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Management.Automation;
using System.Threading;
using Windows.ApplicationModel;
using Windows.Foundation;
using Windows.Management.Deployment;

namespace SophiApp.Helpers
{
    internal class UwpHelper
    {
        internal static Package GetPackage(string packageName)
        {
            var sid = OsHelper.GetCurrentUserSid().Value;
            var packageManager = new PackageManager();
            return packageManager.FindPackagesForUser(sid)
                                 .First(package => package.Id.Name.Equals(packageName));
        }

        internal static IEnumerable<UwpElementDto> GetPackagesDto(bool forAllUsers = false)
        {
            var currentUserScript = @"# The following UWP apps will be excluded from the display
$ExcludedAppxPackages = @(
# Microsoft Desktop App Installer
'Microsoft.DesktopAppInstaller',

# Store Experience Host
'Microsoft.StorePurchaseApp',

# Notepad
'Microsoft.WindowsNotepad',

# Microsoft Store
'Microsoft.WindowsStore',

# Windows Terminal
'Microsoft.WindowsTerminal',
'Microsoft.WindowsTerminalPreview',

# Web Media Extensions
'Microsoft.WebMediaExtensions'
)

$AppxPackages = Get-AppxPackage -PackageTypeFilter Bundle | Where-Object -FilterScript {$_.Name -notin $ExcludedAppxPackages}

# The Bundle packages contains no Microsoft Teams
if (Get-AppxPackage -Name MicrosoftTeams -AllUsers:$false)
{
	# Temporarily hack: due to the fact that there are actually two Microsoft Teams packages, we need to choose the first one to display
	$AppxPackages += Get-AppxPackage -Name MicrosoftTeams -AllUsers:$false | Select-Object -Index 0
}

# The Bundle packages contains no Spotify
if (Get-AppxPackage -Name SpotifyAB.SpotifyMusic -AllUsers:$false)
{
	# Temporarily hack: due to the fact that there are actually two Microsoft Teams packages, we need to choose the first one to display
	$AppxPackages += Get-AppxPackage -Name SpotifyAB.SpotifyMusic -AllUsers:$false | Select-Object -Index 0
}

$PackagesIds = [Windows.Management.Deployment.PackageManager, Windows.Web, ContentType = WindowsRuntime]::new().FindPackages() | Select-Object -Property DisplayName, Logo -ExpandProperty Id | Select-Object -Property Name, DisplayName, Logo

foreach ($AppxPackage in $AppxPackages)
{
	$PackageId = $PackagesIds | Where-Object -FilterScript {$_.Name -eq $AppxPackage.Name}

	if (-not $PackageId)
	{
		continue
	}

	 [PSCustomObject]@{
		Name            = $AppxPackage.Name
		PackageFullName = $AppxPackage.PackageFullName
		Logo            = $PackageId.Logo
		DisplayName     = $PackageId.DisplayName
	}
}";
            var allUsersScript = @"# The following UWP apps will be excluded from the display
$ExcludedAppxPackages = @(
# Microsoft Desktop App Installer
'Microsoft.DesktopAppInstaller',

# Store Experience Host
'Microsoft.StorePurchaseApp',

# Notepad
'Microsoft.WindowsNotepad',

# Microsoft Store
'Microsoft.WindowsStore',

# Windows Terminal
'Microsoft.WindowsTerminal',
'Microsoft.WindowsTerminalPreview',

# Web Media Extensions
'Microsoft.WebMediaExtensions'
)

$AppxPackages = Get-AppxPackage -PackageTypeFilter Bundle -AllUsers | Where-Object -FilterScript {$_.Name -notin $ExcludedAppxPackages}

# The Bundle packages contains no Microsoft Teams
if (Get-AppxPackage -Name MicrosoftTeams -AllUsers:$true)
{
	# Temporarily hack: due to the fact that there are actually two Microsoft Teams packages, we need to choose the first one to display
	$AppxPackages += Get-AppxPackage -Name MicrosoftTeams -AllUsers:$true | Select-Object -Index 0
}

# The Bundle packages contains no Spotify
if (Get-AppxPackage -Name SpotifyAB.SpotifyMusic -AllUsers:$true)
{
	# Temporarily hack: due to the fact that there are actually two Microsoft Teams packages, we need to choose the first one to display
	$AppxPackages += Get-AppxPackage -Name SpotifyAB.SpotifyMusic -AllUsers:$true | Select-Object -Index 0
}

$PackagesIds = [Windows.Management.Deployment.PackageManager, Windows.Web, ContentType = WindowsRuntime]::new().FindPackages() | Select-Object -Property DisplayName, Logo -ExpandProperty Id | Select-Object -Property Name, DisplayName, Logo

foreach ($AppxPackage in $AppxPackages)
{
	$PackageId = $PackagesIds | Where-Object -FilterScript {$_.Name -eq $AppxPackage.Name}

	if (-not $PackageId)
	{
		continue
	}

	 [PSCustomObject]@{
		Name            = $AppxPackage.Name
		PackageFullName = $AppxPackage.PackageFullName
		Logo            = $PackageId.Logo
		DisplayName     = $PackageId.DisplayName
	}
}";

            return PowerShell.Create()
                             .AddScript(forAllUsers ? allUsersScript : currentUserScript)
                             .Invoke()
                             .Where(uwp => uwp.Properties["Logo"].Value != null)
                             .Select(uwp => new UwpElementDto()
                             {
                                 Name = uwp.Properties["Name"].Value as string,
                                 PackageFullName = uwp.Properties["PackageFullName"].Value as string,
                                 Logo = uwp.Properties["Logo"].Value.GetFirstValue<Uri>(),
                                 DisplayName = uwp.Properties["DisplayName"].Value.GetFirstValue<string>()
                             });
        }

        internal static void InstallPackage(string package)
        {
            var packageUri = new Uri(package);
            var packageManager = new PackageManager();
            var deploymentOperation = packageManager.AddPackageAsync(packageUri, null, DeploymentOptions.None);
            var opCompletedEvent = new ManualResetEvent(false);
            deploymentOperation.Completed = (depProgress, status) => { opCompletedEvent.Set(); };
            opCompletedEvent.WaitOne();
        }

        internal static bool PackageExist(string packageName)
        {
            var sid = OsHelper.GetCurrentUserSid().Value;
            var packageManager = new PackageManager();
            return packageManager.FindPackagesForUser(sid)
                                 .Where(package => package.Id.Name == packageName)
                                 .Count() > 0;
        }

        internal static void RemovePackage(string packageFullName, bool allUsers)
        {
            var stopwatch = Stopwatch.StartNew();
            var packageManager = new PackageManager();
            var deploymentOperation = packageManager.RemovePackageAsync(packageFullName, allUsers ? RemovalOptions.RemoveForAllUsers : RemovalOptions.None);
            var opCompletedEvent = new ManualResetEvent(false);
            deploymentOperation.Completed = (depProgress, status) => { opCompletedEvent.Set(); };
            opCompletedEvent.WaitOne();
            stopwatch.Stop();

            if (deploymentOperation.Status == AsyncStatus.Error)
            {
                var deploymentResult = deploymentOperation.GetResults();
                DebugHelper.UwpRemovedHasException(packageFullName, deploymentResult.ErrorText);
                return;
            }

            DebugHelper.UwpRemoved(packageFullName, stopwatch.Elapsed.TotalSeconds, deploymentOperation.Status);
        }
    }
}
private void GetUwpElements()
{
    DebugHelper.StartInitUwpApps();
    var stopwatch = Stopwatch.StartNew();
    UwpElementsCurrentUser = UwpHelper.GetPackagesDto(forAllUsers: false)
	.Select(dto => FabricHelper.CreateUwpElement(dto))
	.OrderBy(uwp => uwp.DisplayName)
	.ToList();

    UwpElementsAllUsers = UwpHelper.GetPackagesDto(forAllUsers: true)
	.Select(dto => FabricHelper.CreateUwpElement(dto))
	.OrderBy(uwp => uwp.DisplayName)
	.ToList();
    stopwatch.Stop();
    DebugHelper.StopInitUwpApps(stopwatch.Elapsed.TotalSeconds);
}

При генерации списка, чтобы случайно не сломать Windows, кроме системных пакетов, в исключение попадают следующие пакеты:

Microsoft.DesktopAppInstaller,
Microsoft.StorePurchaseApp,
Microsoft.WindowsNotepad,
Microsoft.WindowsStore,
Microsoft.WindowsTerminal,
Microsoft.WindowsTerminalPreview,
Microsoft.WebMediaExtensions,
Microsoft.AV1VideoExtension,
Microsoft.HEVCVideoExtension

У каждого элемента в приложении есть состояние: включен или выключен. При запуске все элементы проверяют свое состояние и отрисовывают его в UI, показывая реальное состояние каждой функции в Windows. Да, для этого пришлось написать и отладить 130 функций для проверки состояния каждого элемента. И это не всегда быстро, так как WMI еще живее всех живых.

После применения какой-либо функции идет повторное считывание всех функций (и проставление галочек с радиокнопками соответственно), мягкий перезапуск переменных, панели задач, меню "Пуск" и отправка команды по обновлению Рабочего стола (F5). Кто в комментариях напишет, для какой функции это надо, получит зачет автоматом. :D

private const int WM_SETTINGCHANGE = 0x1a;
private const int SMTO_ABORTIFHUNG = 0x0002;
private const string TRAY_SETTINGS = "TraySettings";
private static readonly IntPtr hWnd = new IntPtr(65535);
private static readonly IntPtr HWND_BROADCAST = new IntPtr(0xffff);

// Virtual key ID of the F5 in File Explorer
private static readonly UIntPtr UIntPtr = new UIntPtr(41504);

public static void PostMessage() => PostMessageW(hWnd, Msg, UIntPtr, IntPtr.Zero);

public static void RefreshEnvironment()
{
	// Update Desktop Icons
	SHChangeNotify(0x8000000, 0x1000, IntPtr.Zero, IntPtr.Zero);
	// Update Environment Variables
	SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, IntPtr.Zero, null, SMTO_ABORTIFHUNG, 100, IntPtr.Zero);
	// Update Taskbar
	SendNotifyMessage(HWND_BROADCAST, WM_SETTINGCHANGE, IntPtr.Zero, TRAY_SETTINGS);
	// Update Start Menu
	ProcessHelper.Stop(START_MENU_PROCESS);
}

Код логики элемента интерфейса:

internal class TextedElement : IElement
    {
        private string description;
        private string header;
        private ElementStatus status;

        public TextedElement((TextedElementDto Dto, Action<TextedElement, Exception> ErrorHandler,
                                EventHandler<TextedElement> StatusHandler, Func<bool> Customisation, UILanguage Language) parameters)
        {
            CustomisationStatus = parameters.Customisation;
            Descriptions = parameters.Dto.Description ?? parameters.Dto.ChildDescription;
            ErrorOccurred = parameters.ErrorHandler;
            Headers = parameters.Dto.Header ?? parameters.Dto.ChildHeader;
            Id = parameters.Dto.Id;
            Language = parameters.Language;
            StatusChanged = parameters.StatusHandler;
            Tag = parameters.Dto.Tag;
            ViewId = parameters.Dto.ViewId;
            Windows10Supported = parameters.Dto.Windows10Supported;
            Windows11Supported = parameters.Dto.Windows11Supported;
        }

        public event PropertyChangedEventHandler PropertyChanged;

        public event EventHandler<TextedElement> StatusChanged;

        protected Dictionary<UILanguage, string> Descriptions { get; set; }

        internal Func<bool> CustomisationStatus { get; set; }

        internal Action<TextedElement, Exception> ErrorOccurred { get; set; }
        internal UILanguage Language { get; set; }
        internal bool Windows10Supported { get; private set; }
        internal bool Windows11Supported { get; private set; }

        public string Description
        {
            get => description;
            set
            {
                description = value;
                OnPropertyChanged("Description");
            }
        }

        public string Header
        {
            get => header;
            set
            {
                header = value;
                OnPropertyChanged("Header");
            }
        }

        public Dictionary<UILanguage, string> Headers { get; set; }
        public uint Id { get; }

        public ElementStatus Status
        {
            get => status;
            set
            {
                status = value;
                OnPropertyChanged("Status");
                StatusChanged?.Invoke(null, this);
            }
        }

        public string Tag { get; }
        public uint ViewId { get; }

        private void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

        internal void ChangeStatus() => Status = Status == ElementStatus.UNCHECKED ? ElementStatus.CHECKED : ElementStatus.UNCHECKED;

        internal virtual bool ContainsText(string text)
        {
            var desiredText = text.ToLower();
            return Header.ToLower().Contains(desiredText) || Description.ToLower().Contains(desiredText);
        }

        internal virtual void GetCustomisationStatus()
        {
            try
            {
                Status = CustomisationStatus.Invoke() ? ElementStatus.CHECKED : ElementStatus.UNCHECKED;
            }
            catch (Exception e)
            {
                ErrorOccurred?.Invoke(this, e);
            }
        }

        internal virtual void Initialize()
        {
            var stopwatch = Stopwatch.StartNew();
            ChangeLanguage(Language);
            GetCustomisationStatus();
            stopwatch.Stop();
            DebugHelper.TextedElementInit(Id, stopwatch.Elapsed.TotalSeconds);
        }

        public virtual void ChangeLanguage(UILanguage language)
        {
            Header = Headers[language];
            Description = Descriptions[language];
        }
    }

SophiApp умеет создавать специализированные задания в Планировщике заданий, чтобы помочь пользователю автоматизировать рутину запуска очистки диска с предзаготовленным набором флагов.

internal const string _700_CLEANUP_TASK_ARGS = @"-WindowStyle Hidden -Command Get-Process -Name cleanmgr | Stop-Process -Force
Get-Process -Name Dism | Stop-Process -Force
Get-Process -Name DismHost | Stop-Process -Force
$ProcessInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = """"""$env:SystemRootsystem32cleanmgr.exe""""""
$ProcessInfo.Arguments = """"""/sagerun:1337""""""
$ProcessInfo.UseShellExecute = $true
$ProcessInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Minimized
$Process = New-Object -TypeName System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
$Process.Start() | Out-Null
Start-Sleep -Seconds 3
[int]$SourceMainWindowHandle = (Get-Process -Name cleanmgr | Where-Object -FilterScript {$_.PriorityClass -eq """"""BelowNormal""""""}).MainWindowHandle
function MinimizeWindow
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        $Process
    )
    $ShowWindowAsync = @{
        Namespace = """"""WinAPI""""""
        Name = """"""Win32ShowWindowAsync""""""
        Language = """"""CSharp""""""
        MemberDefinition = @'
[DllImport(""""""user32.dll"""""")]
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow)
'@
    }

    if (-not(""""""WinAPI.Win32ShowWindowAsync"""""" -as [type]))
    {
        Add-Type @ShowWindowAsync
    }
	$MainWindowHandle = (Get-Process -Name $Process | Where-Object -FilterScript {$_.PriorityClass -eq """"""BelowNormal""""""}).MainWindowHandle
    [WinAPI.Win32ShowWindowAsync]::ShowWindowAsync($MainWindowHandle, 2)
}

while ($true)
{
    [int]$CurrentMainWindowHandle = (Get-Process -Name cleanmgr | Where-Object -FilterScript {$_.PriorityClass -eq """"""BelowNormal""""""}).MainWindowHandle
    if ($SourceMainWindowHandle -ne $CurrentMainWindowHandle)
    {
        MinimizeWindow -Process cleanmgr
        break
    }
    Start-Sleep -Milliseconds 5
}
$ProcessInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = """"""$env:SystemRootsystem32dism.exe""""""
$ProcessInfo.Arguments = """"""/Online /English /Cleanup-Image /StartComponentCleanup /NoRestart""""""
$ProcessInfo.UseShellExecute = $true
$ProcessInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Minimized
$Process = New-Object -TypeName System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
$Process.Start() | Out-Null";

internal const string _700_CLEANUP_TOAST_TASK_ARGS = @"-WindowStyle Hidden -Command [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
[xml]$ToastTemplate = @""""""
<toast duration=""""""Long"""""" scenario=""""""reminder"""""">
	<visual>
		<binding template = """"""ToastGeneric"""""" >
            <text>*</text>
			<group>
				<subgroup>
					<text hint-style=""""""title"""""" hint-wrap=""""""true"""""">*</text>
				</subgroup>
			</group>
			<group>
				<subgroup>
					<text hint-style=""""""body"""""" hint-wrap=""""""true"""""">*</text>
				</subgroup>
			</group>
		</binding>
	</visual>
	<audio src=""""""ms-winsoundevent:notification.default""""""/>
    <actions>
        <input id=""""""SnoozeTimer"""""" type=""""""selection"""""" title=""""""*"""""" defaultInput=""""""1"""""">
			<selection id=""""""1"""""" content=""""""*"""""" />
			<selection id=""""""30"""""" content=""""""*"""""" />
			<selection id=""""""240"""""" content=""""""*"""""" />
		</input>
		<action activationType=""""""system"""""" arguments=""""""snooze"""""" hint-inputId=""""""SnoozeTimer"""""" content="""""""""""" id=""""""test-snooze""""""/>
		<action arguments=""""""WindowsCleanup:"""""" content=""""""*"""""" activationType=""""""protocol""""""/>
		<action arguments=""""""dismiss"""""" content="""""""""""" activationType=""""""system""""""/>
	</actions>
</toast>
""""""@
$ToastXml = [Windows.Data.Xml.Dom.XmlDocument]::New()
$ToastXml.LoadXml($ToastTemplate.OuterXml)
$ToastMessage = [Windows.UI.Notifications.ToastNotification]::New($ToastXML)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(""""""windows.immersivecontrolpanel_cw5n1h2txyewy!microsoft.windows.immersivecontrolpanel"""""").Show($ToastMessage)";

При триггере, например, задания по очистке временных файлов пользователь увидит, как откроется специально написанное окно с предложением запустить очистку Windows, а также удалить неиспользованные обновления (замененные). В интерактивном тосте пользователь может отложить запуск на разные интервалы времени, отменить вовсе или запустить. При запуске начнет работать cleanmgr.exe, а затем dism.exe /Online /English /Cleanup-Image /StartComponentCleanup /NoRestart, что позволяет автоматизировать столь рутинное занятие. Аналогичные тосты всплывают при очистке папки временных файлов и папки %SystemRoot%SoftwareDistributionDownload с той лишь разницей, что задание для очистки последней папки будет ждать, когда остановится служба Центра обновлений Windows, чтобы случайно не удалить уже скачанные пакеты обновления, предназначенные для установки.

Всплывающие тосты
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 27
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 28
SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 29

Некоторые функции рассчитаны на настоящих любителей экзотики, потому было принято решение скрыть их по умолчанию. К таким, например, относится функция по переносу папки %TEMP% официальным образом в корень диска C:.

Вишенкой креатива Дмитрия стала идея сделать несколько функций, но не показывать их в интерфейсе. Мол, это потенциально опасные для пользователя функции, и ему не нужно про них знать. На мой резонный вопрос: "Зачем?", — он парировал: "Программировай!". К счастью, WPF очень гибкий, да и я к тому времени уже не впадал в шок от его идей, хотя часто не представлял, как это сделать.

SophiApp, или Как мы делали опенсорс программу для настройки Windows 10 & 11 - 30
public bool AdvancedSettingsVisibility
{
	get => advancedSettingsVisibility;
	set
	{
		advancedSettingsVisibility = value;
		DebugHelper.AdvancedSettinsVisibility(value);
		OnPropertyChanged(AdvancedSettingsVisibilityPropertyName);
	}
}

private void AdvancedSettingsClicked(object args) => AdvancedSettingsVisibility = AdvancedSettingsVisibility.Invert();

В 2020 году встал вопрос об автоматизации компиляции. Легче сказать, чем сделать. В это же время GitHub аккурат выкатил свой Actions, и после множества сломанных копий при релизе триггерится Action, беря версию тэга релиза и записывает перед компиляцией в файл AssemblyInfo.cs, чтобы в заголовке программы была видна версия. Дальше идет непосредственно компиляция с выкачиванием последних версий зависимостей из своих репозиториев, архивация в ZIP-архив и автоматическая загрузка готово архива на страницу релиза. На опенсорсе, конечно, свет не сошелся, но для успокоения пользователей мы решили добавить в сборку шаг получения хэш-суммы собранного архива.

cloc творит чудеса!
cloc творит чудеса!

Также через Actions идет подсчет количества строк кода в репозитории. При пуше или пулл реквесте триггерится Action, который выкачивает cloc. Дальше через GitHub-секрет в gist Дмитрия записывается JSON-файл с данными по количеству строк кода в репозитории. В данном случае записалось "message":"25.1k". В дальнейшем этот JSON отдается бэйджику от shields.io, который и рендерит уже красивую зеленую плашку.

Ну, и пару слов о логотипе. Перед новогодним релизом, когда было уже не так стыдно показать работающий билд SophiApp, Владимир посоветовал Наталью как фриланс-дизайнера, которая и нарисовала текущий логотип, за что ей и спасибо.

Такую разработку не дай бог каждому!
Такую разработку не дай бог каждому!

Выводы и планы на будущее

Выводы напрашиваются, собственно, сами собой:

  • Надо здраво рассчитывать свои силы, если берешься за такой проект; 

  • Четко прорабатывать техническое задание, чтобы знать объем работ;

  • Уметь терпеть и не бросать все на полпути;

  • И вообще счастье — в преодолении. :-)

Отдельное спасибо Дмитрию за то, что он прошел этот путь со мной от начала и до конца. Ну, и еще кое-что: мечтайте осторожно — мечты сбываются, ведь Дмитрий после 20 лет работы системным администратором решился сменить профессию, подавшись в разработчики. Сейчас он работает в крупной российской девелоперской компании. Если бы не мой комментарий, ничто бы в нашей жизни не изменилось.

Если вам интересны новости ИТ и технологий из первоисточников на английском, можете подписаться на мой новостной канал Sophia News, обсудить их в чате Sophia Chat, где можно задать вопросы по SophiApp, Sophia Script, ПК, ОС и прочим темам про ИТ.

Все баги и пожелания можете оставлять здесь, в Discord, в чате Telegram-группы или создайте Issue на GitHub.

Спасибо, что пережили с нами еще раз разработку SophiApp!

P.S. Спасибо за правки и редактуру текста DoubleSharp и Инне Пристягиной из PVS-Studio.

Жизнь с SophiApp
Жизнь с SophiApp

Автор: Sanctuary

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js