Вступление

Наконец-то неплохо продвинувшись в написании своего мультиплатформенного приложения Modern Money мне понадобилась аутентификация для Kotlin Multiplatform.
Хотелось иметь один API для PIN-кода и биометрии, а под капотом использовать Android Biometric Prompt, Windows Hello, Touch ID.
Так появился Koncierge — набор KMP-модулей biometric, auth и auth-compose
Казалось бы, задача довольно стандартная: объявить expect-класс и написать несколько actual-реализаций.На Android это примерно так и получилось. А вот Windows Hello, JVM-модуль и публикация всех артефактов заставили немного разобраться в том, как Kotlin Multiplatform работает с нативным кодом.
Что хотелось получить
На уровне общего кода API должен был выглядеть максимально просто:
expect class Koncierge { fun isBiometricAvailable(): Boolean suspend fun authenticate( message: String, timeout: Duration = Duration.INFINITE, ): BiometricResults}
Дальше каждая платформа сама решает, каким способом показать запрос на аутентификацию.
when (val result = Koncierge().authenticate("Подтвердите личность")) { BiometricResults.AuthenticationSuccessful -> { // Можно открывать защищенный экран. } BiometricResults.AuthenticationCancelled -> { // Пользователь отменил запрос. } else -> { // Нет оборудования, не настроена биометрия или произошла ошибка. }}
Самым интересным местом оказался Windows.
Windows Hello
Сначала я пытался найти простой способ вызвать Windows Hello прямо из Kotlin/Native. Но API Windows построен вокруг WinRT, COM-интерфейсов, фабрик активации и асинхронных операций. Даже простой вызов проверки доступности быстро перестает быть простым.
CMake, потом Gradle
Первую рабочую версию C-моста я собирал через CMake. Это было логично: отдельный CMakeLists.txt, add_library, список системных библиотек — и через несколько команд получался libwindows_hello.a.
add_library(windows_hello STATIC src/windows_hello.c)target_include_directories(windows_hello PUBLIC include)target_link_libraries(windows_hello PRIVATE runtimeobject user32)
На этом этапе все работало, но в проекте появились две системы сборки. Kotlin/Native ожидал статическую библиотеку в определенной директории, а CMake отдельно должен был сконфигурировать проект, выбрать генератор и собрать нужную конфигурацию.
Для локального эксперимента это терпимо. Для Gradle-задач, CI и публикации библиотека уже начинала жить своей жизнью. Поэтому я решил отказаться от отдельного CMake-шага и перенести сборку C-кода в Gradle.
На Windows Kotlin/Native все равно устанавливает MinGW в свой toolchain. Оставалось найти его gcc и зарегистрировать обычные Exec-задачи:
val compileWindowsHelloNative = tasks.register<Exec>("compileWindowsHelloNative") { inputs.dir(windowsHelloNativeDir) outputs.file(windowsHelloNativeObjectFile) doFirst { commandLine( gcc.absolutePath, "-std=c11", "-DWIN32_LEAN_AND_MEAN", "-DNOMINMAX", "-DWINDOWS_HELLO_STATIC", "-I${windowsHelloNativeDir.dir("include").asFile}", "-c", windowsHelloNativeDir.file("src/windows_hello.c").asFile, "-o", windowsHelloNativeObjectFile.get().asFile, ) }}
Следующая задача превращает объектный файл в статическую библиотеку:
val buildWindowsHelloNative = tasks.register<Exec>("buildWindowsHelloNative") { dependsOn(compileWindowsHelloNative) inputs.file(windowsHelloNativeObjectFile) outputs.file(windowsHelloNativeArchiveFile) doFirst { commandLine( ar.absolutePath, "rcs", windowsHelloNativeArchiveFile.get().asFile, windowsHelloNativeObjectFile.get().asFile, ) }}
Здесь важны не сами вызовы gcc и ar, а связи между задачами. cinteropWindowsHelloMingwX64 и линковка Windows-бинарников зависят от buildWindowsHelloNative. Gradle понимает, какой файл является входом, какой — результатом, и может собрать все в правильном порядке.
В результате CMake остался удобным воспоминанием о первой версии, а проект перешел на чистый Gradle. Точнее, компилятор по-прежнему нативный, но точка входа для сборки теперь одна — Gradle.
Небольшой C-мост
В итоге я написал небольшую обертку на C. Она скрывает детали Windows API и оставляет Kotlin всего две функции:
typedef struct WindowsHelloResult { int32_t code; int32_t hresult;} WindowsHelloResult;WINDOWS_HELLO_API int32_t windows_hello_check_availability( WindowsHelloResult* out_result);WINDOWS_HELLO_API int32_t windows_hello_request_verification( uint64_t owner_window_handle, const wchar_t* prompt, WindowsHelloResult* out_result);
За этой границей происходит вся работа с WinRT:
-
через
RoGetActivationFactoryполучается фабрикаUserConsentVerifier; -
вызывается
CheckAvailabilityAsyncилиRequestVerificationForWindowAsync; -
асинхронная операция опрашивается до завершения;
-
код результата и
HRESULTзаписываются в простую C-структуру.
Особенно пригодился именно C API. Kotlin/Native умеет достаточно хорошо работать с C-заголовками через cinterop, а C++-ABI и WinRT-заголовки добавиляли еще один слой, и требовали хотя бы CMake. На стороне Kotlin/Native вызов теперь выглядит почти как обычная функция:
fun requestVerification( prompt: String, ownerWindowHandle: Long = 0,): WindowsHelloVerification = memScoped { val out = alloc<WindowsHelloResult>() windows_hello_request_verification( ownerWindowHandle.toULong(), prompt.wcstr.ptr, out.ptr, ) WindowsHelloVerification( result = verificationFromCode(out.code), hresult = out.hresult, )}
JVM или почему мы не можем просто использовать Kotlin/Native-библиотеку
Почти сразу после Windows-реализации появился еще один вопрос: а что делать с JVM?
Kotlin/Native использует C-заголовок через cinterop и линкует статическую библиотеку:
headers = windows_hello.hpackage = windowshellocompilerOpts = -DWINDOWS_HELLO_STATIC -Isrc/nativeInterop/windows-hello/includestaticLibraries = libwindows_hello.alibraryPaths = build/windows-hello-native/bin/Release build/windows-hello-native/binlinkerOpts = -lruntimeobject -luser32
Но JVM не умеет использовать сгенерированный пакет windowshello и статический .a-архив. Для нее нужен другой путь: динамическая библиотека и механизм загрузки. Поэтому один и тот же C-файл собирается в два разных результата:
|
Потребитель |
Результат |
Как подключается |
|
Kotlin/Native |
libwindows_hello.a |
|
|
JVM |
windows_hello.dll |
JNA и загрузка из JVM-ресурса |
На JVM я использовал JNA. Сначала объявил интерфейс библиотеки и структуру результата:
private interface WindowsHelloLibrary : Library { fun windows_hello_check_availability( outResult: WindowsHelloResult, ): Int fun windows_hello_request_verification( ownerWindowHandle: Long, prompt: WString, outResult: WindowsHelloResult, ): Int}
JVM-библиотеку я не стал добавлять в исходники вручную. Gradle собирает DLL во временную build-директорию, а затем задача syncWindowsHelloJvmNativeResource кладет ее в ресурс native/windows/x86-64/windows_hello.dll
Во время запуска библиотека извлекается из JAR во временный файл и загружается через JNA:
val resource = javaClass.getResourceAsStream(resourcePath)val libraryFile = Files.createTempFile("koncierge-windows-hello", ".dll")resource.use { input -> libraryFile.outputStream().use { output -> input.copyTo(output) }}Native.load(libraryFile.absolutePathString(), WindowsHelloLibrary::class.java)
Прямо загрузить DLL изнутри JAR нельзя, поэтому промежуточный файл здесь необходим.
Именно на этом месте пришлось явно связать JVM-модуль с native-целями и задачами сборки. Это не обычная зависимость вида implementation(project(...)). JVM-части нужна нативная DLL, а mingwX64-части — статическая библиотека. Значит, Gradle-граф должен знать про обе ветки:
jvmProcessResources └── syncWindowsHelloJvmNativeResource └── buildWindowsHelloJvmNative └── compileWindowsHelloJvmNativecinteropWindowsHelloMingwX64 └── buildWindowsHelloNative └── compileWindowsHelloNative
На Windows это связывание получается естественно. На macOS пришлось сделать еще один шаг: при подготовке публикации включать mingwX64 в метаданные, даже если сам Windows-бинарник на этом агенте не собирается. Иначе macOS-агент просто не знает, что в итоговой KMP-публикации должен присутствовать Windows target.
Публикация: почему одного build-агента оказалось недостаточно
Локально можно собрать нужный target на подходящей машине. В CI ситуация менее романтичная: разные native targets требуют разные host-системы.
В моем случае Windows-агент должен собрать:
-
mingwX64-публикацию; -
Windows Hello DLL для JVM.
А macOS-агент собирает:
-
macOS ARM64-библиотеку для Touch ID;
-
JVM- и Android-публикации;
-
общие KMP-метаданные;
-
финальный набор для отправки в Maven Central.
Сначала я пытался представить, что это можно выполнить одним универсальным Gradle-вызовом на любом агенте. На практике невозможно создать все libraries на одном build-агенте: Windows-артефакты требуют Windows, а macOS-артефакты — macOS.
Поэтому workflow разделен на две основные части.
Windows job
На Windows выполняются задачи:
:biometric:publishMingwX64PublicationToWindowsStagingRepository:biometric:syncWindowsHelloJvmNativeResource
После этого workflow сохраняет два артефакта:
-
подписанную
mingwX64-публикацию; -
windows_hello.dllдля JVM.
Важный момент: Windows job не пытается публиковать только часть библиотеки прямо в Maven Central. Она складывает результат в staging-репозиторий и передает его дальше.
macOS job
macOS-агент скачивает оба Windows-артефакта в заранее известные директории:
biometric/build/windows-maven-repositorybiometric/build/generated/windows-hello-jvm/resources/native/windows/x86-64
Затем собирает остальные targets и объединяет Windows-публикацию с общим набором:
tasks.register<Sync>("assembleMavenCentralStagingRepository") { group = "publishing" description = "Combines the biometric publications built on macOS and Windows for Maven Central." dependsOn( "prepareMavenCentralPublishing", "publishAndroidPublicationToMacosStagingRepository", "publishJvmPublicationToMacosStagingRepository", "publishKotlinMultiplatformPublicationToMacosStagingRepository", "publishMacosArm64PublicationToMacosStagingRepository", ) from(macosStaging) from(layout.buildDirectory.dir("windows-maven-repository")) into(mavenCentralStaging) doLast { val version = project.version.toString() val groupPath = project.group.toString().replace('.', '/') val repositoryRoot = mavenCentralStaging.get().asFile val expectedArtifactIds = listOf( "biometric", "biometric-android", "biometric-jvm", "biometric-macosarm64", "biometric-mingwx64", ) expectedArtifactIds.forEach { artifactId -> val pom = repositoryRoot.resolve("$groupPath/$artifactId/$version/$artifactId-$version.pom") if (!pom.isFile) { throw GradleException("Maven Central staging is missing $artifactId:$version (${pom.absolutePath}).") } } } }
В итоге единый Maven Central bundle создается на macOS, но содержит и уже собранный на Windows mingwX64-артефакт.
Что получилось в итоге
Пользователь библиотеки видит один API:
val koncierge = Koncierge()if (koncierge.isBiometricAvailable()) { val result = koncierge.authenticate("Подтвердите вход")}
А внутри произошло довольно много платформенной работы:
-
Windows Hello спрятан за C-мостом;
-
Kotlin/Native использует
cinteropи статическую библиотеку; -
JVM использует JNA и DLL, упакованную в ресурс;
-
Gradle управляет и C-сборкой, и зависимостями задач;
-
Windows и macOS собирают разные части публикации;
-
Maven Central получает единый набор подписанных артефактов.
Получилось, как мне кажется, создать довольно уникальную библиотеку, которая здорово облегчит добавление аутентификации через biometric в KMP решения.
Пришлось разобраться с интеграцией нативного кода в Kotlin, углубить понимание Gradle сборок, вспомнить первый курс университета и C, наконец!
Что дальше?
Сейчас хочется улучшить несколько вещей: добавить полноценные тесты для нативных реализаций, аккуратнее обрабатывать все коды Windows Hello и проверить публикацию на большем количестве архитектур.
Возможно, со временем получится отказаться и от части ручного кода вокруг WinRT. Но пока C-мост оказался вполне разумной границей: Windows API остается на своей стороне, а Kotlin получает небольшой и предсказуемый интерфейс.
Так же в процессе работы смог подключить WinBio, но пока этот код очень сырой.
Спасибо, что дочитали! Буду благодарен за любые идеи по развитию библиотеки или contributing’а.
ссылка на оригинал статьи https://habr.com/ru/articles/1059174/