dotnet publish single-file¶
Финальный артефакт - один .exe файл, ~250 МБ. Включает в себя .NET runtime, WPF, WebView2 loader, нативные DLL (RageLib native bits), весь React UI после npm run build.
Команда сборки¶
Пользовательская сборка - только с флагами флейвора
По умолчанию AdminBuild=true и VITE_ADMIN не задан - это админская сборка
(на ней живёт DEV владельца). Для релиза игрокам обязательны VITE_ADMIN=0 при
сборке UI и -p:AdminBuild=false у dotnet publish.
После publish csproj кладёт в publish/ маркер build-flavor.txt (user или
admin). scripts/zip-payload.cjs, scripts/gen-delta-manifest.mjs и
installer-user.iss проверяют его (плюс косвенные признаки: SharpCompress.dll,
additionals/admin, ui/assets/adminEntry-*.js) и без маркера или с admin
отказываются собирать пользовательский релиз (аудит 25.10.2026).
cd MiamiGraphics.Shell
# Step 1: build React UI (пользовательский флейвор)
Push-Location ui
npm install
$env:VITE_ADMIN = '0'
npm run build
Remove-Item Env:VITE_ADMIN
Pop-Location
# Step 2: dotnet publish (пользовательский флейвор)
dotnet publish MiamiGraphics.Shell.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:AdminBuild=false `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=None `
-p:DebugSymbols=false `
-o ../publish
Результат - publish/MiamiGraphics.Shell.exe. Это standalone бинарь, не требует .NET установленного у юзера.
Ключевые свойства MSBuild¶
| Property | Что делает |
|---|---|
PublishSingleFile=true |
Упаковать всё в один .exe |
IncludeNativeLibrariesForSelfExtract=true |
Включить нативные DLL (без этого они рядом останутся) |
EnableCompressionInSingleFile=true |
LZ4 сжатие (250 МБ → 80 МБ) |
DebugType=None, DebugSymbols=false |
Не включать PDB |
SelfContained=true |
Включить .NET runtime |
AdminBuild=false |
Пользовательский флейвор: без #if ADMIN, без SharpCompress и additionals/admin; маркер build-flavor.txt = user |
Это даёт .exe ~80 МБ после сжатия. Без сжатия был бы ~250 МБ. Запуск чуть медленнее (приходится распаковывать в %TEMP%), но размер критичнее.
Распаковка single-file в runtime¶
При первом запуске .NET runtime распаковывает содержимое в %TEMP%\.net\<HostId>\<version>\. Это ~250 МБ файлов на диске. Запуск занимает ~5 секунд только в первый раз. Последующие запуски используют extracted cache (если timestamp не изменился) - миллисекунды.
Это проблема для нашего auto-update'а: после update у нового .exe новый timestamp, кеш distrust'ится, юзер опять ждёт 5 секунд. Это причина почему мы скачиваем заранее - пока юзер в лаунчере, мы экстрагируем новый билд, чтобы при перезапуске был быстрый старт.
React UI как embedded resource¶
ui/dist/ после npm run build - это index.html + JS-чанки + CSS. Чтобы они оказались внутри .exe, в .csproj есть:
<ItemGroup>
<Content Include="ui\dist\**\*" CopyToOutputDirectory="PreserveNewest">
<Link>ui\%(RecursiveDir)%(FileName)%(Extension)</Link>
</Content>
</ItemGroup>
При publish все файлы из ui/dist/ попадают в publish/ui/. WebView2 потом грузит file:///<path-to-self-extract>/ui/index.html.
installed_version.txt¶
Critical файл для auto-update - см. installed-version-marker. Эта файла нет в publish output по дефолту. Inno installer его пишет рядом с .exe после установки. Лаунчер при старте читает.
Это решение, потому что прямое чтение из MSBuild <Version> через Assembly.GetExecutingAssembly().GetName().Version не работает в single-file bundle (reflection возвращает 1.0.0.0 всегда).
Размер vs Performance trade-offs¶
Альтернативы которые отвергнуты:
PublishTrimmed=true- обрезает unused code. Экономит ~30 МБ. Но ломает reflection в JSON serialization, breaks Supabase client, breaksXDocument.Load. Слишком хрупко для нашего размера codebase.- ReadyToRun (R2R) - pre-compile в native code. Ускорит startup, но добавит ~50 МБ.
- NativeAOT - полностью native compile. Не поддерживается WPF.
Выбран trade-off: single-file + compression, без trimming/R2R. Acceptable startup, простая поддержка.
Build script полностью¶
param(
[string]$Version = "1.0.0",
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
# Bump version in csproj
$csproj = "MiamiGraphics.Shell/MiamiGraphics.Shell.csproj"
(Get-Content $csproj) -replace '<Version>.*</Version>', "<Version>$Version</Version>" |
Set-Content $csproj
# Build UI (пользовательский флейвор: без VITE_ADMIN=0 собирается админка)
Push-Location MiamiGraphics.Shell/ui
npm install --silent
$env:VITE_ADMIN = '0'
npm run build
Remove-Item Env:VITE_ADMIN
Pop-Location
# Publish .NET (пользовательский флейвор: без AdminBuild=false собирается админка)
dotnet publish MiamiGraphics.Shell/MiamiGraphics.Shell.csproj `
-c $Configuration `
-r win-x64 `
--self-contained true `
-p:AdminBuild=false `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:Version=$Version `
-p:DebugType=None `
-p:DebugSymbols=false `
-o publish
# Флейвор: маркер пишет csproj (таргет WriteBuildFlavorMarkerToPublish)
if ((Get-Content publish/build-flavor.txt -ErrorAction Stop).Trim() -ne 'user') {
throw "publish/ - не пользовательская сборка (build-flavor.txt)"
}
# Rename output
Move-Item publish/MiamiGraphics.Shell.exe "publish/MiamiGraphics_$Version.exe" -Force
Write-Host "Published: publish/MiamiGraphics_$Version.exe"