В предыдущей статье серии сломавшийся установщик был последней каплей перед перезапуском VOG. Здесь — что пришло ему на смену: загрузчик Windows-клиента, написанный на Go. Он ставит клиент, следит за выходом версий, обновляет и клиент, и себя, откатывает неудачное обновление и при этом остаётся одним файлом без единой сторонней зависимости.
Код загрузчика открыт: github.com/vogclub/vog2-updater. Фрагменты ниже взяты оттуда.
В статье
- Как ломался старый установщик
- Что на самом деле было нужно
- Почему Go
- Окно на голом WinAPI
- Как устроено обновление
- Подписи в два слоя
- Запущенный файл нельзя перезаписать
- Антивирус: не прятаться
- Мелочи Windows, которые не поймали наши тесты
- Падения
- Что ещё не сделано
- Итог
Как ломался старый установщик
Старый установщик собирался на WiX Toolset 3: MSI-пакет и bundle-обёртка вокруг него, примерно по 14 МБ. Ставился он на всю машину, в Program Files, писал настройки в HKLM, регистрировал протокол vog3x и раскладывал звуки в системный каталог. Клиент — 32-битный, и ему нужен распространяемый пакет библиотек Visual C++ (redist).
Redist bundle ставил перед клиентом, а если не находил рядом, скачивал по постоянной ссылке Microsoft. Ссылка постоянная, а то, что по ней лежит, — нет. Летом 2026 года это и выстрелило. В июне игрок пишет в чат поддержки: на Windows 10 дистрибутив падает с ошибкой. Обходной путь — сначала вручную поставить свежий redist, потом отдельно сам MSI. Через месяц другой случай, Windows 7: свежий redist на семёрку уже не ставится вовсе. Помог только старый пакет с сайта Microsoft, который ещё понимает Windows 7 с SP1.
Установка всё чаще превращалась в переписку с поддержкой. Остальное тоже было тяжело:
- Обновление означало переустановку. Любая правка в клиенте уезжала к игрокам только новым MSI целиком.
- Номер версии жил в четырёх файлах проекта WiX, и при выпуске его правили руками.
- Состав пакета собирался скриптом, который не копировал зависимые DLL, и пропуски обнаруживались уже после сборки.
- Последний выпуск установщика — 2.7.8.10, начала 2021 года. Восстановить его сборку пять лет спустя было отдельной задачей.
Ещё раньше, в 2000-х, у VOG был собственный установщик на C++ с докачкой по HTTP и сжатием — по-своему передовой для времён модемов. Его приёмы, вроде докачки, пригодились как образец и сейчас.
Что на самом деле было нужно
Если бы задача была «поставить файлы на диск», хватило бы починить MSI. Но установщик должен был стать частью приложения:
- Ставить по частям. Сразу ставятся оболочка, языки и одна стартовая игра, выбранная жребием. Остальные игры и боты скачиваются, когда игрок их попросит, — прямо из клиента. Жребий бросает загрузчик по идентификатору своей установки, а выпуск только называет кандидатов. Если бы игру назначал выпуск, все установки этого выпуска получили бы одну и ту же: её комнаты были бы полны, остальные пусты.
- Быстро откатываться. Если новая версия не запустилась, игрок должен вернуться на предыдущую сам, без переустановки и без письма в поддержку.
- Быть точкой запуска. Игрок запускает не клиент, а загрузчик: тот проверяет целость установки, узнаёт о новых версиях и только потом запускает игру. Пока клиент закрыт, загрузчик сидит в трее и следит за выходом версий.
Последнее и решило дело. Готовые решения для установки и обновления хорошо делают свою часть, но загрузчик, который одновременно установщик, точка входа, резидент и менеджер модулей по запросу клиента, приходится собирать из них с большим количеством клея. Проще оказалось написать своё.
Почему Go
Требования к самому загрузчику получились жёсткими: один файл, который скачивают с сайта и запускают; никаких runtime и redist, иначе мы вернулись бы к исходной проблеме; сборка в CI без Windows-инструментов.
Go это даёт почти без усилий. Программа на Go — статический исполняемый файл. Windows API вызывается из Go напрямую через пакет syscall, без cgo, а значит, без компилятора C и без кросс-тулчейна: загрузчик для Windows собирается кросс-компиляцией с любой машины. В модуле нет ни одной директивы require и нет файла go.sum:
module vogclub.com/vog2-updater
// Версия не «свежая ради свежести»: загрузчик ходит по сети и проверяет
// подписи, то есть живёт ровно в тех местах стандартной библиотеки, куда
// приходят уязвимости. На 1.26.3 govulncheck показывал семь достижимых из
// нашего кода (net/url, crypto/tls, crypto/x509, net/http, net/textproto,
// encoding/asn1); закрыты они были в 1.26.6, и с тех пор мы держимся не ниже
// последней. 1.27 — общая для всех служб платформы (30.08.2026), чтобы
// проверки и линтер у всех совпадали.
go 1.27.0Окно, трей, ярлыки, именованный канал до клиента, права на канал, повышение прав, проверка подписей, отправка отчётов о падениях — всё на стандартной библиотеке. Ноль сторонних Go-зависимостей — это не аскеза, а свойство безопасности: загрузчик, который сам ставит и запускает код на машине игрока, не должен тянуть за собой чужой код, который никто из нас не читал.
Рядом с загрузчиком в том же репозитории лежит упаковщик. Он режет сборку клиента на пакеты и пишет манифест выпуска. Формат манифеста у них общий, поэтому и репозиторий общий. Упаковщик работает в обычном Linux-конвейере и ничего не знает о WinAPI.
Загрузчик собран 64-битным, хотя сам клиент пока 32-битный. Это задел: когда клиент станет 64-битным, менять загрузчик не придётся.
У выбора есть цена — минимальная версия Windows. Go начиная с 1.21 поддерживает только Windows 10 и новее, поэтому загрузчик рассчитан на 64-битные Windows 10 и 11. На Windows 7 и 8 мы его не проверяли: таких систем у нас нет. Старый MSI устарел и не поддерживается. Если появятся просьбы о более ранних системах, будем думать, но, скорее всего, такая поддержка обойдётся дорого.
Окно на голом WinAPI
Ни walk, ни webview, ни Fyne. Окно загрузчика создаётся вызовами user32 и gdi32 и рисуется целиком своими руками: заголовок с градиентом, кнопки, текст, клавиатура.
Так окно выглядит у игрока: всё, включая кнопки, флажок и выпадающий список, нарисовано загрузчиком самостоятельно.
Привязки к Windows API — только нужные функции, загружаемые при первом обращении:
//go:build windows
// Package win — минимальные привязки к Windows API.
//
// Вызовы идут напрямую через syscall, без cgo и без сторонних библиотек: это
// сохраняет главное свойство загрузчика — один статический файл без единой
// зависимости, собираемый кросс-компиляцией с любой машины.
//
// Здесь только то, что нужно окну загрузчика. Расширять по мере надобности,
// а не «на всякий случай»: каждая лишняя привязка — это код, который никто
// не проверяет.
package win
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
msimg32 = syscall.NewLazyDLL("msimg32.dll")
shell32 = syscall.NewLazyDLL("shell32.dll")
procRegisterClassEx = user32.NewProc("RegisterClassExW")
procCreateWindowEx = user32.NewProc("CreateWindowExW")
// …
)Самое необычное место — COM. Ярлык в меню «Пуск» создаётся через интерфейс IShellLink, а COM из Go без библиотек означает вызывать методы объекта по номеру в его таблице виртуальных функций. Две тонкости видны прямо в коде: HRESULT нужно обрезать до 32 бит, а горутину — закрепить за потоком ОС, потому что однопоточное отделение COM привязано к потоку, а планировщик Go волен переносить горутину между ними.
func (o *comObject) call(index int, args ...uintptr) uint32 {
full := append([]uintptr{uintptr(unsafe.Pointer(o))}, args...)
ret, _, _ := syscall.SyscallN(o.vtbl[index], full...)
// HRESULT — 32-разрядный, а регистр возвращается целиком: старшие биты
// в нём мусор, и сравнение с 0x80010106 без обрезки не сходилось бы.
return uint32(ret)
}
// withShellLink открывает COM, создаёт объект ярлыка и отдаёт его вместе с
// интерфейсом сохранения в файл.
//
// Поток закрепляется за нитью ОС: COM в однопоточном отделении привязан именно
// к нити, а горутину планировщик Go волен переносить между ними — тогда вызовы
// уходили бы в отделение, которого на этой нити нет.
func withShellLink(fn func(link, persist *comObject) error) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// COM на этой нити мог быть открыт раньше. В той же модели (STA) повторный
// вызов вернёт S_FALSE, и его, как и S_OK, надо закрыть парным
// CoUninitialize. RPC_E_CHANGED_MODE — другое: нить уже инициализирована
// в ДРУГОЙ модели, обычно многопоточной (MTA). Работать в ней можно —
// объект ярлыка зарегистрирован для обеих моделей (ThreadingModel=Both), —
// но инициализация чужая, и снимать её за собой нельзя.
ret, _, _ := procCoInitializeEx.Call(0, coinitApartmentThreaded)
switch hr := uint32(ret); hr {
case sOK, sFalse:
defer procCoUninitialize.Call()
case rpcEChangedMode:
default:
return fmt.Errorf("CoInitializeEx: 0x%08X", hr)
}
// … CoCreateInstance(CLSID_ShellLink), QueryInterface(IPersistFile)
}Интерфейс переведён на одиннадцать языков, и перевод тоже свой, без библиотек.
Как устроено обновление
Сборку клиента упаковщик режет на модули: ядро, языки, каждая игра, наборы звуков и скинов, боты. Каждый модуль — zip-пакет, а выпуск описывает манифест: какие версии есть, какая рекомендована, какая минимально допустима, из каких пакетов состоит каждая.
Пакеты хранятся по содержимому. Имя файла в кеше — его SHA-256, и время внутри zip зафиксировано. Пакет, который не изменился между выпусками, повторно не скачивается. Поэтому первая установка весит около 18 МБ, а обычное обновление — 2–3 МБ.
Каждая версия ставится в свой каталог. Загрузчик скачивает пакеты с докачкой, проверяет размер и хеш, распаковывает в versions/<версия> и сверяет каждый файл с описью внутри пакета. Только после этого версия становится активной. Ничего не перезаписывается поверх работающей установки: переключение — это одно поле в файле состояния.
Откат — сменой того же поля. Если при переключении новая версия не стартовала — процесс не создался или клиент завершился с кодом ошибки, — загрузчик возвращает предыдущую версию и записывает причину. Зависание или «запустился, но не работает» этот механизм не ловит. По умолчанию хранятся две предыдущие версии.
Откат и минимально допустимая версия друг другу не противоречат. Откат технически может вернуть версию ниже минимума, но запустить её загрузчик не даст, если сервер в этом же прогоне сказал, что она запрещена: тогда он предложит обновиться до допустимой. Без связи с сервером игра идёт на том, что стоит. Запрет, оставшийся с прошлого раза, права блокировать запуск не даёт: иначе вчерашний ответ запирал бы человека без интернета.
Перед каждым запуском — проверка целости. Быстрая проверка читает только метаданные: есть ли каждый файл из описи и того ли он размера. На настоящей установке, 150 файлов и 36 МБ, это 3–4 миллисекунды, содержимое не читается вовсе. Только при расхождении пакет, в котором оно нашлось, пересчитывается по SHA-256 целиком, и испорченное докачивается. Файл, испорченный с сохранением размера, быстрая проверка не видит — это сознательный обмен точности на скорость запуска.
Отдельно ставить redist больше не нужно. Библиотеки Visual C++ никуда не делись, изменился способ доставки: они едут рядом с клиентом в отдельном пакете. Ровно та проблема, с которой всё началось, исчезла: системе нечего доустанавливать.
Состояние установки сначала полностью пишется во временный файл, а затем заменяет основной переименованием. Атомарности такого переименования Go на Windows не обещает, поэтому есть и страховка: файл состояния, который не разбирается, откладывается в сторону как state.corrupt.<время>.json, и загрузчик начинает с чистого состояния, а установленные версии остаются на диске. На Windows нашлась и ещё одна тонкость — переименование поверх файла отказывает, пока тот кем-то открыт, а открывают его не только чужие:
// renameWithRetry переименовывает с несколькими попытками.
//
// На Windows переименование поверх существующего файла отказывает, пока тот
// открыт кем-то ещё, — и «кто-то ещё» тут не обязательно чужой: загрузчик
// перечитывает своё состояние на каждую команду, а пишет его из другого места,
// и два этих обращения изредка попадают друг на друга. Снаружи это выглядело
// как отказ обновиться: «The process cannot access the file because it is
// being used by another process» (2026-08-28, state.json беты).
//
// Держат файл доли секунды — и наш собственный читатель, и антивирус с
// индексатором, которые открывают всякий только что записанный файл. Поэтому
// несколько коротких попыток, а не одна: отказ после секунды ожидания уже
// означает настоящую занятость, о которой стоит сказать.
func renameWithRetry(from, to string) error {
var err error
for attempt := 0; attempt < 10; attempt++ {
if err = os.Rename(from, to); err == nil {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return err
}Клиент разговаривает с загрузчиком через именованный канал: просит доустановить игру или бота, спрашивает о состоянии. Права на канал выдаются только владельцу сеанса.
Подписи в два слоя
Загрузчик скачивает и запускает код. Значит, главный вопрос — кому он верит. Подписей две, обе на Ed25519 из стандартной библиотеки, ключи у них разные, и отвечают они на разные вопросы: подпись пакета — те ли это байты, подпись решения канала — тот ли это выбор версий. Сразу о текущей границе защиты:
- подпись пакетов обязательна с середины августа 2026 года: поддельная или отсутствующая — отказ, уже установленный клиент при этом продолжает запускаться;
- подпись решения канала пока не обязательна: служба её ставит, загрузчик проверяет, если она есть, но неподписанный ответ ещё принимает. Это переходный период, пока у игроков не обновятся старые загрузчики;
- номер решения и срок годности проверяются только у ответа, в котором решение есть. Без него остаётся третий слой, не требующий ключей вовсе: ответ службы сам по себе понижения версии не вызывает, переход на более старую называется понижением и требует согласия человека. Исключение — восстановительный откат после неудачного запуска, описанный выше.
Теперь — что именно подписывается.
- Пакеты и сам загрузчик подписывает ключ конвейера выпуска. Подписывается не JSON, а каноническая строка из полей с префиксами длины: так поля «ab» и «c» нельзя выдать за «a» и «bc». Адрес скачивания в подпись намеренно не входит: что принимать, решает хеш, а адрес может законно поменяться.
- Решение канала — какие версии допустимы, какая рекомендована, какая минимальна — подписывает служба обновлений своим ключом, с номером последовательности и сроком годности против повтора старого ответа.
Смысл разделения в том, что служба обновлений живёт в кластере, а ключ пакетов — только в конвейере выпуска. Захватив службу, можно выбирать только среди уже подписанных выпусков, но нельзя подсунуть свой. Доверенных ключей пакетов — список, а не один ключ: так его можно сменить, не отрезав никого от обновлений.
for _, encoded := range trusted {
pub, err := ParsePublicKey(encoded)
if err != nil {
// Испорченная запись в списке — наша поломка, а не чужая подделка.
// Она не должна выдавать пропуск, поэтому просто пропускается: если
// годных ключей не осталось, ниже будет отказ.
continue
}
if ed25519.Verify(pub, payload, sig) {
return nil
}
}
return fmt.Errorf("%w: ни один доверенный ключ не подошёл", ErrBadSignature)Запущенный файл нельзя перезаписать
Загрузчик обновляет и сам себя. На Windows для этого есть одна лазейка: работающий exe нельзя перезаписать, но можно переименовать. На ней и стоит самоподмена. Главный принцип в комментариях кода — вернуть как было важнее, чем обновиться:
// Windows не даёт перезаписать запущенный файл, но переименовать разрешает —
// на этом и стоит вся подмена. Прежний файл не удаляется сразу: если новый не
// запустится, у человека остаётся рабочий экземпляр.
const oldLauncherSuffix = ".old"
func replaceLauncher(exePath, downloadedPath string) error {
backupPath := exePath + oldLauncherSuffix
// Остаток прошлой подмены мешает переименованию, поэтому убирается заранее.
_ = os.Remove(backupPath)
if err := os.Rename(exePath, backupPath); err != nil {
return fmt.Errorf("прежний загрузчик не переименовался: %w", err)
}
if err := os.Rename(downloadedPath, exePath); err != nil {
// Вернуть как было — важнее, чем обновиться: иначе человек остаётся
// вообще без загрузчика.
restoreErr := os.Rename(backupPath, exePath)
if restoreErr == nil {
return fmt.Errorf("новый загрузчик не встал на место: %w", err)
}
// Переименование назад не прошло — пробуем копированием: оно создаёт
// новый файл, а занятый только читает.
if copyErr := copyFile(backupPath, exePath, 0o755); copyErr == nil {
return fmt.Errorf("новый загрузчик не встал на место: %w", err)
}
// Не вышло ничего: ошибка несёт оба пути, чтобы человек мог
// восстановить файл одним переименованием.
return &launcherLostError{
backupPath: backupPath,
expectedPath: exePath,
replaceErr: err,
restoreErr: restoreErr,
}
}
return nil
}Полная версия с запасным путём через копирование — в репозитории.
Сведения о новой версии загрузчика лежат в статическом файле рядом с бинарниками, а не в службе обновлений. Если служба сломается, обновление загрузчика всё равно дойдёт, и им же можно будет починить остальное. Если для подмены нужны права администратора, с повышением запускается уже скачанный и проверенный новый файл, а не старый: иначе ошибку в повышенном шаге нельзя было бы исправить новой версией.
Тот же приём с блокировкой помогает понять, не запущен ли клиент, не перебирая процессы. Запущенный exe Windows держит отображённым в память с запретом записи. Это эвристика, а не доказательство: отказ из-за совместного доступа может дать и любой другой открытый дескриптор файла. Но ошибается она в безопасную сторону — если файл занят, обновление просто откладывается:
// fileHeldByRunningImage отвечает, держит ли файл работающий процесс.
//
// Приём такой: Windows отображает запущенный .exe в память с запретом на запись,
// поэтому открыть его на запись нельзя, пока процесс жив. Файл при этом не
// меняется — открытие идёт без усечения и без единой записи, а дескриптор
// закрывается тут же.
//
// Проверено опытом (запущенная копия против той же копии после завершения
// процесса): работающий файл отвечает ERROR_SHARING_VIOLATION (32), лежащий —
// открывается.
//
// Любая другая ошибка — не «занят». Файла нет, каталог недоступен, атрибут
// «только для чтения» — всё это говорит о чём угодно, кроме работающей игры, а
// принять их за неё значит навсегда запретить обновление из резидента.
func fileHeldByRunningImage(path string) bool {
file, err := os.OpenFile(path, os.O_WRONLY, 0)
if err == nil {
_ = file.Close()
return false
}
var errno syscall.Errno
if errors.As(err, &errno) {
return errno == errorSharingViolation || errno == errorLockViolation
}
return false
}Резидент в трее этим и пользуется: если клиент открыт, он только отмечает, что есть обновление; если закрыт — обязательное обновление ставит сам, а необязательное предлагает.
Антивирус: не прятаться
Сначала загрузчик жил в профиле пользователя, в %LOCALAPPDATA%, и прописывал себя в автозапуск через ключ реестра Run. Прав администратора он не просил вовсе — казалось, так удобнее игроку. В августе 2026 года один из антивирусов удалил его с поведенческим вердиктом «шпионская программа».
Если посмотреть глазами антивируса, всё логично: неподписанный исполняемый файл в профиле пользователя, который сам себя обновляет, ходит в сеть, тихо ставится в автозапуск и не просит прав. Ровно так ведут себя трояны.
Первой мыслью было, что дело в бинарнике. Go-программы часто собирают с флагами -s -w, срезающими таблицу символов, и такие файлы считаются подозрительными. Проверили: без флагов файл растёт с 7,56 до 10,64 МБ, а число срабатываний на VirusTotal не меняется — 4 из 71 против 5 из 71. Флаги вернули, а в конфигурации сборки остался комментарий для будущих нас: не повторяйте круг.
Помогло обратное — перестать прятаться:
- Загрузчик живёт в Program Files, как обычная программа, а данные — в профиле пользователя.
- Права администратора он просит открыто, только в трёх видах операций: при установке, при самоподмене и при удалении. Причём сначала окно с кнопкой «Установить», и только по нажатию — запрос прав. Человек видит, что и зачем происходит.
- Автозапуск — ярлыком в папке «Автозагрузка», который видно и легко убрать, а не ключом реестра.
После изменения схемы установки поведенческих срабатываний у наших игроков мы не наблюдали. В сентябре файл всё же пометили уже при скачивании — по репутации неподписанного файла, и это вопрос сертификата. Опыт в целом оказался полезным: с антивирусом лучше не играть в прятки, а вести себя максимально открыто. Последний шаг в этом направлении — подпись кода сертификатом, о ней ниже.
Мелочи Windows, которые не поймали наши тесты
Часть ошибок проявилась только на живых машинах игроков. Наши тесты этого не покрывали: дело было не в логике, а в окружении. Блокировки, имена файлов и реестр можно проверять интеграционными тестами на Windows, и отдельный прогон тестов на Windows в конвейере есть, но эти случаи он не покрывал:
- Браузер дописывает « (1)» к имени файла. Поэтому стенд, боевой или тестовый, не определяется по имени скачанного файла, а зашивается в сборку флагом
-X. Тот же хвост в имени однажды сломал самообновление навсегда — исправили. - Загрузка ни разу не доходила до конца на Windows: файл переименовывался, пока был ещё открыт. На Linux такое проходит, на Windows — отказ из-за совместного доступа.
- Брандмауэр спрашивал разрешение на каждую версию клиента, потому что каждая лежит в своём каталоге, а клиент слушал порт для прямых соединений между игроками. Лечение — убрать прямые соединения: клиент больше не слушает ни одного порта.
- Стандартный пакет
debug/peтеряет DLL, импортированные по номеру. Упаковщик проверяет, что для клиента и ботов привезены все библиотеки, и таблицу импорта пришлось разбирать самим. - Занятый файл при удалении помечается на удаление после перезагрузки через
MoveFileExW, иначе после деинсталляции оставался мусор. - Тесты не трогают настоящий реестр. Все записи в тестах перенаправлены в отдельную ветку, а обращение к настоящей роняет тест. Повод — однажды тесты затёрли разработчику настройки клиента.
Падения
Клиент при падении пишет минидамп, и теперь загрузчик сам собирает такие дампы и отправляет их в Sentry, развёрнутый у нас же. Сначала и тут были потери: из десяти дампов доходили два, пока загрузчик не стал вести журнал и перестал удалять большие дампы. Отправка — обычный multipart-запрос, без SDK. Свои отказы загрузчик отправляет туда же.
Собственный Sentry — не самая лёгкая вещь: по месту он сопоставим со всем остальным проектом. Но изобретать свой разбор падений не хотелось. Основные усилия ушли на другое: чтобы стек из минидампа превращался в имена функций и строки, под каждый выпуск клиента в Sentry нужно загружать отладочные символы. Как это устроено — тема для отдельного рассказа.
Что ещё не сделано
- Подпись кода сертификатом. Загрузчик подписан нашими ключами, но не сертификатом, которому доверяет Windows, поэтому SmartScreen и антивирусы встречают его настороженно. Получить такой сертификат в нынешних условиях сложно. От шага мы не отказываемся.
- Обязательная подпись решения канала. Включится, когда старые загрузчики у игроков обновятся.
- Раскатка по процентам. Способ выбран — по хешу версии и идентификатора установки, — но пока не сделан.
- Старые MSI-установки и ранние Windows. Старых установок немного, а сам MSI устарел и не поддерживается. Загрузчику нужна 64-битная Windows 10 или новее; поддержку более ранних систем рассмотрим, если о ней попросят, но она, скорее всего, дорогая. Отдельного извещения внутри клиента не планируем: старый способ входа небезопасен и будет закрыт, а владельцам старых установок напишем письма.
Итог
- Для требований VOG своё решение оказалось проще, чем сборка из готовых. Загрузчик здесь одновременно установщик, точка входа, резидент и менеджер модулей. Готовые решения делают каждую из этих частей, но не все сразу.
- Go хорошо подходит для Windows-утилит, если не бояться WinAPI. Статический файл, кросс-компиляция с Linux, ни одной сторонней Go-зависимости, даже окно и COM.
- Ничего не ставить поверх. Каждая версия в своём каталоге, переключение — одно поле, откат — то же поле назад.
- Ключ пакетов и ключ решения разделены. Захват службы обновлений не даёт выполнить свой код.
- Windows поощряет переименование, а не перезапись. Переименование помогает заменить загрузчик и файл состояния, а блокировка записи служит эвристикой занятости клиента.
- С антивирусом лучше быть открытым. Program Files, явный запрос прав, видимый автозапуск — у наших игроков поведенческие срабатывания после этого прекратились.
- Самые неприятные ошибки — в окружении. Имя файла в браузере, брандмауэр, блокировки файлов наши тесты не поймали, их нашли живые установки.
Комментарии