How we built Localazy CLI for our developer-friendly software localization tool for smart & lazy developers :-).
Initially, Localazy supported only Android through our simple to use but potent Gradle plugin that automatically handles everything for the developer. Our vision of developer-friendly software localizations proves to be the right way to go. We started to receive a lot of requests to support other platforms too.
Our accurate shared memory ShareTM, proactive review, and intuitive interface were compelling reasons to use Localazy also for developers outside the Android ecosystem, even if the integration would be less sophisticated than the Gradle plugin.
We thought about the right solution that would enable other developers to use Localazy on all platforms. We wanted to keep the developer-friendly / developer-centric approach and CLI, aka the command-line interface, was the logical answer.
Kotlin Multiplatform
We wanted Localazy CLI to be supported on all major platforms. Ideally, we wanted no dependency on Java or Node as those usually mean a huge binary or giant virtual machine. Small and fast binary for a given operating system looked like a perfect solution.
Kotlin is already heavily used at Localazy, and it powers the whole backend. And so Kotlin Multiplatform was a logical choice to look at for this task. It allowed us to write a single codebase for native Linux, macOS, Windows and Java in a programming language we know well.
Almost all the code is shared between platforms and written in pure Kotlin. There were only two platform-dependent features - filesystem access and HTTP communication.
The filesystem access is quite simple, and we just create an actual implementation for Java (using java.io.File and streams), Linux/macOS (using POSIX) and Windows (slightly modified POSIX as there are small differences on Windows).
For HTTP communication, good old HTTPUrlConnection is used for Java and ktor client for Linux, macOS and Windows.
The prototype of CLI was ready in a couple of days, and with a bit of fine-tuning, we were able to compile it to Java’s JAR and native binary for Linux, macOS and Windows.
Everything seemed to be great, except it wasn’t…
Migrating to WinInet
Well, everything worked well, but the ktor client introduced a dependency on libcurl. It’s not a big deal on Linux or macOS as you either already have libcurl installed or you can install it with a single line through APT, YUM or Brew.
On Windows, however, it was necessary to include almost 20 DLL files in the same directory where the CLI binary is placed. Ough, that’s not good, and we didn't want that for sure.
What were the possible solutions?
Create an installer that installs DLL files to the system folder. No! This doesn’t seem to be the simple developer-friendly solution in a spirit download and go, and I didn’t like the idea of installing anything on the system level.
Statically link libcurl to the binary. Several days lost trying to get this work. Partially, we were able to do so, but the resulting binary was huge, and it still needed some dependencies. We even recompiled the libcurl from the source, so it didn’t depend on OpenSSL and other libraries and rather use Windows native subsystems. No luck.
Rewrite the whole HTTP connection part to use WinInet - Windows native communication stack. Well, it meant a lot of work and another implementation, but it’s worth it as the resulting binary is small and has no dependencies. And so we did.
Powerful Gradle Build
We tweaked Gradle build script, so it works in two different modes - when IntelliJ is active and when the final build process runs.
When IntelliJ is active, only the selected platform based on the host OS is used. This setup allows us to develop the CLI easily, and there is only one compilation target with no additional operations.
When the full build is run, the binary for the respective platform is compiled, packaged, renamed and placed into the correct folder. Additional operations are performed for optimising the binary size. For the Linux host, not only the native binary but also the Java version is built, a binary for Docker image is prepared, and DEB and RPM packages are generated.
Cross-Compilation
The big drawback of Kotlin MPP is cross-compilation. It’s currently possible to build some targets only on specific host platforms. Windows binary can only be built on the Windows host. The same applies to macOS.
At that moment, we already planned to use Github Actions for compilation, but as we had our own Linux based runner, we wanted to cover as much of the build process as possible using it. Even without our own runner, Linux actions are cheaper.
The second reason for cross-compilation was that I simply didn’t have Windows and cross-compilation allowed me to check that everything is alright and I could still be able to test binary with virtualized Windows. Technically, I could build the binary in the virtual machine but for the reason above, I was more interested in some kind of cross-compilation.
There is no solution for macOS. It simply needs the macOS host.
For Windows, however, there is a solution. Install Wine in Ubuntu-based docker and unpack the Windows version of OpenJDK, so it’s accessible from Wine. Be sure to set JAVA_HOME
and PATH
too. Invoking ./gradlew.bat
fails due to missing TTY, but there is a simple workaround:
faketty () {
script -qfec "$(printf "%q " "$@")"
}
faketty wine cmd /c gradlew.bat --no-daemon --console=plain clean buildWindows
And that’s it! The Windows binary can be built on the Linux host.
The Distribution
Once the binaries for all platforms were built, we needed to make them available for you.
- Java JAR is simply available for download. It's a standalone app and not a library so Maven doesn’t make much sense. However, it’s probably better this way. Download and go on any platform with JVM available. Instantly.
- Docker image is distributed through Docker Hub. Simple. No need to think about it.
- The Windows binary is available for download on our website and in the future, we could possibly investigate how to make it available in more places.
- The macOS binary is available for download on our website and for installation through Homebrew.
- The Linux binary is available for download on our website - as a native binary and DEB and RPM packages. We already have Sonatype Nexus Repository installed for the distribution of our Gradle plugin, and so we use it also as an APT and YUM repository. There’s also the bash autocomplete script installed along with the binary.
Github Actions
We have two Actions - one that is run on macOS and one that is run on our self-hosted Linux runner. Actions are bound to a release, so whenever we want to release a new version, we just create a tag and release on Github. The name of the release is automatically used as a version identifier.
What exactly do our Actions do?
- build a native binary for Linux, macOS and Windows
- build the Java version of the CLI
- build a versioned Docker image and also the :latest one
- package the Linux version as DEB and RPM
- zip, tar, tar.gz all binaries and upload them to our distribution server
- upload DEB and RPM to Nexus Repository
- update, commit and push a new version of configuration file for Brew
- change the version of the CLI in the CMS
- rebuild static parts of our website including the documentation
So, we change the code, test it, commit, push and create a release on Github. And that’s it. Everything else is taken care about automatically including changes on the website. Less manual work and repetitive tasks, less human errors :-).
Personally, I love how a few minutes after creating a release on Github, invoking apt update && apt upgrade
makes sure that you are running the latest version of the Localazy CLI ;-).
The JSON Schema
Even we don’t remember all details about our configuration file localazy.json
that is used by the CLI. Thankfully, my colleague Jan spent quite some time tweaking JSON schema for the file and so you can enjoy smart completion in supported IDEs. We tested on Visual Studio Code and IntelliJ IDEA and both work like a charm.
It needed some time to study it, create it and distribute it, but it simply makes our tool more developer-friendly and that’s important. We really strive to bring you a great tool and not only to fill some missing gap. Details matter.
What To Do Next?
We are now in the process of obtaining a certificate from Comodo, so we can sign the Windows binary and get rid of warnings about a possibly dangerous unsigned app.
It seems that we can sign the Windows binary on Linux, so it’s going to be part of the Github Action once we have the certificate available.
Results
Kotlin MPP is a viable solution for building CLI apps - it only needs some tweaks to support all platforms - and with Github Actions, it’s possible to automate everything.
It's important to have as much of the logic as possible in the common module.
Some suggestions for Kotlin MPP
- cross-compilation should be possible out of the box
- ktor client should introduce WinInet / WinHTTP implementation for Windows
However, thanks to JetBrains for Kotlin, it was fun to write the CLI, and it works smoothly on all platforms.
This article has been originally published as How we built Localazy CLI: Kotlin MPP and Github Actions on localazy.com.
Top comments (0)