Выполнить flutter create --platforms=web,macos,windows,linux . в корне проекта. Команда добавит по папке на платформу с хост-проектом этой платформы и не тронет lib/; эти папки входят в ваш исходный код, их коммитят.
**web/**: index.html, manifest.json, favicon.png и icons/: здесь meta-теги, скелетон загрузки и собственный flutter_bootstrap.js
**windows/, macos/, linux/: нативный runner под каждую ОС (C++ на Win32, Xcode-проект на Swift, C++ на GTK); начальный размер окна и заголовок живут там
Десктоп собирается только на своей ОС**: flutter build macos требует Mac с Xcode, для Windows нужна Visual Studio с C++-нагрузкой, для Linux нужны clang, CMake, Ninja и заголовки GTK; кросс-компиляции нет
Проверить тулчейн: flutter doctor и flutter devices, затем flutter run -d chrome или -d macos
FlutterSenior
Flutter в вебе и на десктопе
Рендереры и wasm, отложенная загрузка, PWA, JS-интероп, окна десктопа, пробелы в плагинах
Закрыто вопросов
0 / 20
- 0Повторить
- 3Часто спрашивают
Вопросы
Flutter web рисует весь UI в canvas, поэтому он хорош для продуктов-приложений за логином (дашбордов, внутренних инструментов, редакторов, веб-версии мобильного приложения) и плохо подходит там, где страница должна открываться мгновенно, индексироваться или вести себя как документ.
Первая загрузка: каждый новый посетитель скачивает рендерер в несколько мегабайт до первого кадра; потом он в кэше, поэтому лендингам это вредит гораздо сильнее, чем инструментам на каждый день
SEO и превью ссылок: контент состоит из пикселей, а не из текста в DOM, и на любой маршрут отдаётся один и тот же index.html, поэтому краулеры видят мало, а любая расшаренная ссылка показывает один заголовок
Поведение браузера переизобретено: текст выделяется только внутри SelectionArea, Ctrl+F ничего не находит, а прокрутка, автозаполнение и контекстные меню ощущаются чуть иначе, чем родные
Доступность включается отдельно: дерево семантики на вебе по умолчанию выключено, пока его не активирует пользователь скринридера или вы не вызовете SemanticsBinding.instance.ensureSemantics()
**Нет dart:io, меньше плагинов**: ни файловой системы, ни сырых сокетов, а каждому плагину нужна веб-реализация
Обычное разделение: маркетинговые страницы, документация и блог живут на HTML, а сам продукт на Flutter.
Два, и оба представляют собой Skia, скомпилированную в WebAssembly: CanvasKit для сборки по умолчанию на JavaScript и skwasm для сборки с --wasm. HTML-рендерер на DOM объявили устаревшим во Flutter 3.27 и удалили в 3.29, так что лёгкого запасного варианта больше нет.
CanvasKit: Skia плюс JS-обвязка, пара мегабайт wasm, работает в любом современном браузере и растеризует в главном потоке рядом с вашим Dart-кодом
skwasm: сборка Skia под приложение из dart2wasm; при cross-origin isolation растеризует в web worker, и кадры перестают конкурировать с вашим Dart. Используется только там, где браузер может запустить wasm-сборку; в остальных случаях загрузчик отдаёт JS с CanvasKit
Откуда берётся движок: по умолчанию с CDN Google (gstatic.com); --no-web-resources-cdn отдаёт его с вашего домена
Цену платят за первый визит: движок занимает большую часть пустой сборки, дальше он лежит в HTTP-кэше
Одинаковые пиксели везде: идентичная картинка в любом браузере и есть цель, и именно поэтому текст не попадает в DOM
--web-renderer больше нет: выбор делается сборкой с --wasm или без него.
Потому что --wasm включается явно, и даже тогда в сборке рядом с wasm лежит JavaScript-версия: flutter.js выбирает её по браузеру, и каждый, чей браузер не может запустить wasm-сборку, всё равно получает JS.
Что даёт wasm: код из dart2wasm работает быстрее, чем вывод dart2js, а skwasm может рендерить в воркере; выигрыш виден во времени кадра больше, чем в размере загрузки
Нужен WasmGC: wasm-сборку Flutter запускает Chromium 119+; в Firefox и Safari WasmGC есть, но известные баги браузеров её блокируют, а браузеры на iOS (все на WebKit) запустить её не могут, поэтому получают JS-сборку; актуальное состояние смотрите в документации
Потокам нужны два заголовка: Cross-Origin-Embedder-Policy: credentialless (или require-corp) и Cross-Origin-Opener-Policy: same-origin на странице; без них вы молча теряете рендер-воркер
У заголовков есть цена: они меняют загрузку кросс-доменных iframe и ресурсов с учётными данными, так что проверьте сторонние встраивания и попапы авторизации
Interop строже: dart:html, dart:js и package:js не компилируются в wasm, поэтому одна транзитивная зависимость на них блокирует сборку; свежий flutter build web делает пробный прогон wasm и перечисляет их
Относитесь к wasm как к прогрессивному улучшению: почистить дерево зависимостей, выставить заголовки, замерить, оставить JS как нижнюю планку.
Загружается HTML, flutter_bootstrap.js выбирает сборку, скачиваются и компилируются main.dart.js и рендерер, инициализируется движок, и только потом runApp рисует. Всё, что вы await-ите в main() до runApp, добавляется сверху.
Последовательность: index.html запускает flutter_bootstrap.js; _flutter.loader.load() скачивает точку входа и CanvasKit или skwasm; initializeEngine() поднимает рендерер и шрифты; runApp() создаёт view и запускает ваши виджеты
Сначала транспорт: свой домен, HTTP/2 или HTTP/3, brotli или gzip для .js и .wasm, <link rel="preload"> для крупных файлов, долгое кэширование версионированных файлов движка
Нарисуйте что-то в HTML: скелетон в index.html (разметка и CSS, не GIF-спиннер), который убирается по событию flutter-first-frame
**Никаких await до runApp: инициализация Firebase, remote config и чтение хранилища стартуют после первого кадра, за сплэш-виджетом
Меньше Dart: отложенные импорты для крупных редко нужных фич; шрифты кладите в бандл, потому что при отсутствующих глифах движок качает fallback-шрифты с серверов Google
Меряйте в браузере**: Lighthouse и панели Network и Performance; Flutter DevTools не видит ничего до запуска движка
dart2js выносит библиотеку в отдельный part-файл, только когда каждый путь к ней отложенный. Здесь routes.dart импортирует admin_page.dart обычным импортом, поэтому AdminPage и всё, до чего он дотягивается, остаются в основном бандле, и deferred-импорт ничего не отделяет.
Единицы загрузки считаются по всей программе: код, достижимый и из обычной точки входа, и из deferred-импорта, остаётся в основной единице; поэтому фича на 400 КБ часто освобождает лишь часть этого
Типичные утечки: таблица маршрутов, где упомянут тип страницы, barrel-файл, реэкспортируемый из обычного кода, DI-контейнер, регистрирующий тип на старте
Проверяйте в байтах: удачное разбиение оставляет в build/web файлы main.dart.js_1.part.js и соседей; если part-файлов нет, ничего не отложено
**Пользоваться только после loadLibrary()**: обращение к admin.AdminPage до завершения future падает в рантайме
**loadLibrary() может упасть: нет сети или деплой между загрузкой страницы и кликом отдаёт part из другой сборки; нужен нормальный экран ошибки, а не вечный спиннер
Это не deferred-components**: та секция pubspec.yaml относится к Android Play Feature Delivery и в вебе ничего не делает
index.html разрешает каждый относительный URL от своего <base href>, который по умолчанию равен /, поэтому приложение запрашивает /main.dart.js вместо /my-app/main.dart.js. Собирайте с --base-href /my-app/; значение должно начинаться и заканчиваться слешем.
Что переписывает флаг: плейсхолдер $FLUTTER_BASE_HREF в web/index.html; захардкоженный там <base href="/"> остаётся как есть, так что плейсхолдер не трогайте
Маршруты остаются от корня: path URL strategy отрезает base, поэтому /settings в роутере соответствует /my-app/settings в адресной строке; не повторяйте префикс в путях маршрутов
Глубоким ссылкам нужен fallback: с usePathUrlStrategy() перезагрузка /my-app/settings должна вернуть /my-app/index.html; у GitHub Pages нет rewrite, поэтому выкладывают копию index.html как 404.html или остаются на hash-URL
Абсолютные пути, которые вы пишете сами, base не учитывают: вызов /api/... или ссылка /assets/... в рукописном HTML по-прежнему смотрят в корень домена
Локальный запуск это прячет: flutter run -d chrome отдаёт приложение с /, поэтому проверяйте, раздавая build/web под тем же префиксом
Изнутри Flutter-приложения ничего не сделать. Контент нарисован в canvas, а на каждый маршрут отдаётся один и тот же статический index.html, поэтому краулеры находят мало текста, а сборщики превью читают один заголовок для любого URL.
Текст становится пикселями: Googlebot выполняет JavaScript и рендерит оболочку, но там нет текста в DOM, структуры заголовков и своего <title> на маршрут, которые стоило бы индексировать
Сборщики превью не выполняют JavaScript: Slack, Telegram, WhatsApp и X читают og:-теги прямо из HTML, так что виджет Title, который выставляет document.title в рантайме, до них не доходит
Лучше всего вынести документы из Flutter: лендинг, цены, документацию и блог отдавать статическим или серверным HTML; Flutter оставить для приложения за логином
Следующий по качеству вариант отдаёт свой head на каждый маршрут на уровне CDN (edge): заранее сгенерированная копия index.html на каждый публичный маршрут со своим <title> и og:-тегами или edge-функция, переписывающая head на запрос; из каждой копии грузится то же Flutter-приложение
В любом случае: path-URL (фрагмент после # до сервера не доходит, и HTML под маршрут невозможен), sitemap.xml и проверка через curl -A Twitterbot, а не в своём браузере
Сгенерированные manifest.json и иконки уже делают его устанавливаемым; офлайн-кэш и обновления остаются вашей заботой. Собственный кэширующий service worker Flutter объявлен устаревшим, так что какую сборку видит пользователь, решают ваши HTTP-заголовки кэша и тот воркер, который вы выкатите сами.
Устанавливаемость: HTTPS плюс web/manifest.json (name, icons, start_url, display: standalone); Chromium предлагает кнопку установки, а iOS предлагает «На экран „Домой“»
Почему пользователи сидят на вчерашней сборке: index.html, flutter_bootstrap.js и main.dart.js сохраняют имена при каждом деплое; при длинном max-age браузер даже не спрашивает сервер
Почините заголовки: Cache-Control: no-cache на эти точки входа (перепроверять каждый раз, 304 стоит дёшево). Flutter не добавляет хэш к именам файлов, поэтому долгое кэширование безопасно только для файлов, версию которых вы ведёте сами; движок на CDN Google уже версионирован по ревизии
Старые воркеры переживают деплой: service worker от старой сборки Flutter продолжает отдавать свою закэшированную оболочку; выкатите по тому же URL воркер, который сам себя разрегистрирует
Офлайн включается по желанию: свой воркер (обычно Workbox) с явным сценарием обновления: заметить ожидающий воркер, показать «Новая версия, перезагрузить», затем skipWaiting()
Браузер заблокировал ответ раньше, чем его увидел Dart; почти всегда это CORS, иногда смешанный контент. Кода статуса нет, потому что ваш код ответа так и не получил, и никакая правка в Dart это не исправит: сервер должен разрешить ваш origin.
Почему только веб: мобильное приложение не ограничено same-origin policy; браузер добавляет заголовок Origin и проверяет ответ, прежде чем отдать его
Preflight: JSON-тело или заголовок Authorization делают запрос «непростым», поэтому браузер сначала шлёт OPTIONS, и сервер обязан ответить на него 2xx
Что должен прислать сервер: Access-Control-Allow-Origin с вашим точным origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers: authorization, content-type; с cookies ещё Allow-Credentials: true, при котором origin * запрещён
Читайте Console, а не исключение: браузер печатает настоящую причину (нет заголовка, упал preflight, вызов http:// со страницы на https://), а вкладка Network показывает запрос OPTIONS
Локальная разработка: flutter run -d chrome берёт случайный порт; зафиксируйте его через --web-port и разрешите этот origin или проксируйте API. --disable-web-security только прячет баг
dart:html, dart:js, dart:js_util и package:js относятся к устаревшему interop, и для dart2wasm их нет. DOM теперь берётся из package:web, а всё остальное из dart:js_interop; оба работают и под dart2js, и под dart2wasm.
**package:web**: типизированные биндинги, сгенерированные из Web IDL браузера: web.document.querySelector('#root'), web.window.localStorage. Имена следуют спецификации, так что миграция сводится в основном к переименованию
**dart:js_interop**: типы для JS-значений (JSObject, JSString, JSPromise, JSFunction); SDK описывают external-членами на extension type поверх JSObject, который не создаёт обёртку в рантайме
Преобразования явные: 'x'.toJS, jsString.toDart, await promise.toDart, callback.toJS и jsify() / dartify() для нетипизированных данных; setProperty / getProperty / callMethod на JSObject лежат в dart:js_interop_unsafe
Проверяйте на границе: объявленные типы не проверяются, поэтому external-геттер с типом String после обновления SDK может вернуть undefined; is и as на JS-типах ведут себя по-разному в разных компиляторах, поэтому используйте value.isA<JSArray>()
Условие спрашивает, есть ли у цели компиляции dart:html, а у dart2wasm его нет. Третья ветка никогда не выбирается, заглушка спокойно компилируется, и ничего не падает: токен просто читается как null.
**dart.library.X спрашивает, что существует**: под dart2js истинны и dart.library.html, и dart.library.js_interop; под dart2wasm истинен только js_interop; на мобильных и десктопе истинен только io
Исправление в одно слово: if (dart.library.js_interop), что истинно для обоих веб-компиляторов; storage_web.dart уже использует package:web, так что компилируется под оба
Почему было тихо: заглушка, возвращающая безобидное значение по умолчанию, компилируется везде; пусть она бросает UnsupportedError, тогда неверная ветка упадёт на первом тесте, а не в проде
Проверьте и зависимости: пакет с условием на dart.library.html содержит тот же баг, и его нужно обновить до выпуска с --wasm
Platform берётся из dart:io, который веб-компиляторы принимают, но не реализуют: Platform.isMacOS бросает Unsupported operation: Platform._operatingSystem прямо в build, и релизная сборка рисует серый ErrorWidget вместо тулбара.
Почему CI зелёный: импорт dart:io не ошибка компиляции ни для dart2js, ни для dart2wasm, поэтому flutter build web проходит; заглушка бросает исключение только при вызове геттера, а это впервые происходит на реальном кадре в браузере
Исправление: defaultTargetPlatform из package:flutter/foundation.dart работает на любой платформе; на вебе он сообщает ОС, на которой запущен браузер, так что Chrome на Windows получает Ctrl+S, а Safari на Mac получает Cmd+S
**kIsWeb против defaultTargetPlatform**: kIsWeb отвечает на вопрос «это браузерная сборка?» (скачать файл вместо записи на диск); defaultTargetPlatform отвечает на вопрос «какие платформенные конвенции применять» (шорткаты, физика прокрутки, раскладка). Если действительно нужен API из dart:io, сначала проверьте !kIsWeb или вынесите код за условный импорт
Проще тестировать: debugDefaultTargetPlatformOverride позволяет одним виджет-тестом покрыть и десктопную, и мобильную ветку; Platform.isMacOS переопределить нельзя
Как поймать в следующий раз: гонять виджет-тесты через flutter test --platform chrome и делать smoke-тест веб-сборки в CI; любой импорт dart:io в виджете, общем с вебом, даёт повод для вопроса на ревью
В браузере ваш Dart выполняется как JavaScript или wasm в главном потоке страницы, а с CanvasKit там же идёт и растеризация, так что build, layout, paint и raster делят один поток, который на мобильных разделён надвое.
Почему медленнее
Один поток: CanvasKit растеризует в главном потоке; skwasm при cross-origin isolation уносит это в воркер, что часто даёт самый крупный выигрыш
Нет нативного AOT: вывод dart2js медленнее скомпилированного ARM-кода; wasm-сборка сокращает большую часть разрыва
Platform views дорогие: каждый HtmlElementView (карта, видео, iframe) разбивает сцену на дополнительные canvas вокруг DOM-элемента; два на экране вполне допустимы, а по одному на строку списка нет
Как измерять
Никогда не судите по debug: flutter run -d chrome использует неоптимизирующий dev-компилятор; профилируйте с --profile или релизной сборкой
У каждого инструмента своя задача: панель Performance в Chrome на profile- или релизной сборке для времени скриптов, GC и растеризации; DevTools только в debug, чтобы найти, какие виджеты перестраиваются или перерисовываются, а не читать тайминги
Что исправлять
Обычные правила, бюджет теснее: меньше перестроений, const-виджеты, RepaintBoundary вокруг анимаций и никаких источников saveLayer (Opacity, ShaderMask, BackdropFilter) внутри прокручиваемого контента
Изображения: декодируйте под размер отображения через cacheWidth; кросс-доменным картинкам нужны CORS-заголовки, иначе CanvasKit не прочитает их пиксели
Шрифты: кладите в бандл все шрифты и письменности, которые рисуете; отсутствующий глиф заставляет движок качать fallback-шрифты посреди прокрутки
**Выпускайте --wasm с заголовками**, если ваша аудитория на Chromium
Flutter web монтируется в тот элемент, который вы передали загрузчику как hostElement, а стороны обмениваются данными только через явный JS interop, без общего состояния, роутера или шины событий.
**Монтирование через hostElement**: _flutter.loader.load({config: {hostElement: el}}) в собственном web/flutter_bootstrap.js. Без этого Flutter забирает весь <body>. У элемента должна быть реальная высота, иначе Flutter ничего не заполнит, и это выглядит как неудачная загрузка
Из JS в Dart: на старте положите Dart-замыкание в globalContext и передавайте пришедшее в ValueNotifier
Из Dart в JS: отправьте CustomEvent на хост-элемент, чтобы страница слушала его как событие любого компонента, или вызовите external-функцию, объявленную хостом; конвертируйте на границе, а доменные объекты держите на стороне Dart
Один движок на приложение: два отдельных монтирования загружают рендерер дважды; для нескольких областей на одной странице используйте multi-view (multiViewEnabled: true, затем runWidget вместо runApp); через app.addView({hostElement, initialData}) у каждого view свои начальные данные
**Вызовите setUrlStrategy(null)**: иначе встроенное приложение пишет в адресную строку и воюет с роутером хоста
Каждый .toJS оборачивает tear-off в новую JS-функцию, и removeEventListener получает функцию, которую window не видел, поэтому ничего не снимается. Закрытый экран оставляет свой слушатель: сообщение обрабатывается столько раз, сколько экран открывали, а мёртвые State вызывают setState.
Почему выглядит правильно: в Dart два tear-off одного метода одного объекта равны по ==, поэтому removeListener(_onTick) работает с ChangeNotifier; браузер же сравнивает слушатели по идентичности JS-объекта, а toJS по документации на каждый вызов возвращает новую функцию
Исправление: сконвертировать один раз и сохранить результат: поле late final web.EventListener _listener = _onMessage.toJS, которое передаётся и в addEventListener, и в removeEventListener
Альтернатива: web.EventStreamProviders.messageEvent.forTarget(web.window).listen(...) возвращает StreamSubscription; отмените её в dispose, и ошибиться с идентичностью JS-функции будет негде
Это ещё и утечка памяти: JS-функция держит Dart-замыкание, а оно держит State и его поддерево, так что каждый закрытый экран остаётся достижимым из window
Как поймать в следующий раз: тест, который трижды монтирует и размонтирует виджет, отправляет один MessageEvent и ожидает один вызов обработчика; в Chrome DevTools getEventListeners(window) показывает, как растёт список
В вебе нет файловой системы. dart:io и path_provider там не работают, поэтому сохранение превращается в скачивание, а любое чтение начинается с файла, который пользователь сам отдал через пикер или перетаскивание.
Чтение: openFile из file_selector везде возвращает XFile, и переносимым вызовом на нём служит readAsBytes(). XFile.path на десктопе содержит настоящий путь, а в вебе blob:-URL, поэтому код, открывающий путь, ломается
Запись на десктопе: getSaveLocation() для пути, затем dart:io в файле за условным импортом
Запись в вебе: собрать Blob и кликнуть синтетический <a download>; showSaveFilePicker даёт настоящий диалог сохранения, но только в Chromium и только в secure context
Активация пользователем: пикеру сохранения и чтению буфера обмена нужен недавний жест пользователя, поэтому долгий await перед вызовом приводит к отказу браузера
Буфер обмена асимметричен: Clipboard.setData работает везде; getData в вебе требует разрешения или подтверждения вставки
macOS по умолчанию с App Sandbox: добавьте com.apple.security.files.user-selected.read-write в оба файла entitlements и сохраняйте security-scoped bookmark, чтобы открыть этот файл при следующем запуске
Проверяйте поддержку платформ как данные ещё до первой веб-сборки, потому что отсутствующая реализация проявляется тремя способами (упавшей сборкой, MissingPluginException на первом вызове или тихим бездействием), и громкий из них только первый.
Читайте заявленные платформы: для каждого плагина из flutter pub deps посмотрите теги платформ на pub.dev, которые берутся из flutter: plugin: platforms: в его pubspec; если web нет, то нет и веб-реализации
Одобренная или нет: в федеративном плагине foo_web регистрируется сам, только если foo его одобряет (endorse); неодобренную реализацию от сообщества нужно добавить в собственный pubspec.yaml, иначе будет MissingPluginException
Пробелы на этапе компиляции обходятся дёшево: зависимость с импортом dart:ffi, а под --wasm ещё и dart:html/package:js, роняет сборку; dart:io в вебе компилируется и бросает UnsupportedError только при выполнении, то есть это пробел рантайма. Собирайте все платформы в CI на каждом pull request, и громкие найдутся в первый же день
Пробелы в рантайме требуют запуска: интеграционные тесты в Chrome (flutter drive -d chrome с chromedriver), открывающие каждый экран, где вызывается плагин; зелёная сборка ничего не говорит о канале без получателя
**Деградируйте в дереве, а не в catch**: фасад на каждую ненадёжную возможность с bool get isSupported, чтобы экран показывал альтернативу, а не кнопку, которая бросает исключение
Решайте по каждому пробелу: пакет-альтернатива с поддержкой веба, своя веб-реализация поверх package:web или фича, скрытая в вебе
Окно почти не принадлежит Dart. Первое окно создаёт нативный runner в вашем же репозитории, меню и обработку выхода даёт API фреймворка, а менять окно в рантайме можно только пакетом.
Первым кадром владеет runner: начальный размер и заголовок живут в windows/runner/main.cpp, macos/Runner/MainFlutterWindow.swift и linux/runner/my_application.cc. Это ваши исходники, их и правьте; ресайз из Dart после старта даёт заметный скачок
**PlatformMenuBar**: строит настоящую системную строку меню, нативно поддерживается на macOS; для меню внутри окна на Windows и Linux берите Material MenuBar с SubmenuButton и MenuItemButton
**AppLifecycleListener(onExitRequested:)**: получает Cmd+Q, Alt+F4 и закрытие последнего окна; верните AppExitResponse.cancel, спросите про несохранённое и тогда решайте
Всё ещё пакет: window_manager для размера в рантайме, минимального размера, «поверх всех окон» и перехвата закрытия; tray_manager для трея; screen_retriever для дисплеев и масштаба
Несколько настоящих окон ОС: multi-window API фреймворка пока экспериментальный; в продакшене используют desktop_multi_window, где у каждого окна свой движок и изолят, так что общего Dart-состояния у окон нет
Подписывайте и пакуйте под каждую ОС: подписанный MSIX или инсталлятор на Windows, подпись Developer ID плюс нотаризация на macOS, бандл, собранный на самой старой glibc и упакованный в .deb, Flatpak или AppImage на Linux. Обновлялки во Flutter нет, так что она на вас.
Windows: flutter build windows --release кладёт .exe рядом с flutter_windows.dll, DLL плагинов и data/; поставляйте всю папку, потому что один exe запускается и тут же падает. Упакуйте в MSIX (dart run msix:create) или инсталлятор и подпишите; неподписанный файл или новый сертификат всё равно получает предупреждения SmartScreen, пока не наберёт репутацию
macOS: подпись идентичностью Developer ID Application с hardened runtime, отправка через xcrun notarytool submit --wait, затем xcrun stapler staple; ненотаризованное приложение Gatekeeper блокирует. При повторной подписи нужен --deep, чтобы покрыть вложенные фреймворки, а файл entitlements надо передать явно через --entitlements, иначе codesign его сбросит
Песочница macOS: исходящей сети нужен com.apple.security.network.client, а в шаблоне его нет ни в DebugProfile.entitlements, ни в Release.entitlements
Linux: бандл привязан к glibc и GTK машины сборки, поэтому собирайте в контейнере с самым старым поддерживаемым дистрибутивом и упаковывайте в .deb, Flatpak, Snap или AppImage
Обновление: auto_updater оборачивает Sparkle и WinSparkle поверх appcast, который вы хостите; MSIX может обновляться через App Installer; в Linux это пакетный менеджер или Flatpak. Что бы вы ни выбрали, клиент обязан проверять подпись обновления