Установщик на Go для Windows-клиента: как VOG ушёл от MSI и redist

Старый MSI-установщик VOG перестал ставиться на свежие Windows из-за распространяемого пакета Visual C++. Как вместо него появился загрузчик на Go — один статический файл без cgo и без единой сторонней зависимости, с окном на голом WinAPI, модульной установкой, откатом версий, двумя слоями подписей Ed25519 и честными отношениями с антивирусами

В предыдущей статье серии сломавшийся установщик был последней каплей перед перезапуском VOG. Здесь — что пришло ему на смену: загрузчик Windows-клиента, написанный на Go. Он ставит клиент, следит за выходом версий, обновляет и клиент, и себя, откатывает неудачное обновление и при этом остаётся одним файлом без единой сторонней зависимости.

Код загрузчика открыт: github.com/vogclub/vog2-updater. Фрагменты ниже взяты оттуда.

Схема в стиле журнала «Полдень»: слева деревянный ящик «MSI» на тележке, привязанный перетёртой верёвкой к указателю «redist»; в центре машина-капсула «загрузчик» с кнопкой «Установить»; по пневмотрубам к ней и от неё идут посылки с печатями «Ed25519» и короткими хешами; справа стеллаж «версии» с ящиками 2.8.22, 2.8.23 с отметкой «откат» и подсвеченным 2.8.24, над ним полка игровых фигур «по запросу»; во врезке открытая дверь «Program Files» и охранник со списком

В статье

Как ломался старый установщик

Старый установщик собирался на 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 и рисуется целиком своими руками: заголовок с градиентом, кнопки, текст, клавиатура. Окно загрузчика «Vinco Online Games — обновление»: «Установка проверена: всё на месте», выбор языка игры, флажок «Запускать при входе в систему», кнопки «Закрыть» и «Запустить», версия загрузчика 0.2.55

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

Привязки к 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, явный запрос прав, видимый автозапуск — у наших игроков поведенческие срабатывания после этого прекратились.
  • Самые неприятные ошибки — в окружении. Имя файла в браузере, брандмауэр, блокировки файлов наши тесты не поймали, их нашли живые установки.

Обсуждение в Telegram

Присоединиться →

Комментарии