Recently the Rust programming language has enjoyed the success and thrilled the many developers specially working on System Level Software and building decentralized applications. When releasing your software, it is usually desirable to provide an easy to use installer for end users, ideally reducing the configuration burden on users and providing individual developers with control of how the application should function at the end users environment.
There are advantages of having installers for your binaries and a few are mentioned here.
- User doesn't need to provide any configuration, environment variables or base directories for the binary to run on users machine. Rather software manufacturer packages those defaults with the installer.
- User can be alerted in the event of a new version release of the software.
- Developer can have more control on managing context of the app, providing backward compatibility, cleaning up storage used by the app.
Build a small binary app in Rust & Create an Installer
In this sections we will build a small rust binary and an installer for macOS using Homebrew.
Sample code for the rust binary
In this example we will exercise a basic rust binary application to demonstrate how to build an installer. The rust program which listens to a TCP Socket and write the text message on to listener's standard output (i.e. stdout) stream. The program also has an option to set the port from an environment variable. Final goal is to run the software as a background process and log the text to Listener's output stream whenever any other application sends message to the socket.
Let's create a rust project named rust_app
using cargo new rust_app --bin
. It will create binary project structure
as below.
sample_rust_app
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
Content of Cargo.toml
[package]
name = "rust_app"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
A very simple main.rs
which listens on a port and writes the content into the STDOUT stream.
use std::env;
use std::io;
use std::io::{BufRead, Error};
use std::net::{TcpListener, TcpStream};
fn main() -> std::io::Result<()> {
let port = env::var("LISTEN_PORT").unwrap_or(String::from("8080"));
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))?;
// accept connections and process them serially
for stream in listener.incoming() {
handle_client(stream?)?;
}
Ok(())
}
fn handle_client(mut stream: TcpStream) -> Result<(), Error> {
let mut reader = io::BufReader::new(&mut stream);
let received: Vec<u8> = reader.fill_buf()?.to_vec();
reader.consume(received.len());
String::from_utf8(received)
.map(|msg| println!("{}", msg))
.map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"Couldn't parse received string as utf8",
)
})
}
Build
Build the binary project using cargo build --all-targets --workspace --release
. On successful build completion, binary
will be placed under target/release/
directory. Let's try the binary with a sample input.
Run the binary while setting LISTEN_PORT
to 9090
to accept messages on localhost 9090 TCP port.
$ export LISTEN_PORT=9090
$ ./target/release/rust_app
We are going to use netcat client to send message to TCP socket like below.
$ echo sample_text | nc localhost 9090
Now check the rust_app program terminal. It will show the same text i.e. sample_text
which we sent through netcat
client.
Upload Archive in Cloud Storage
Now we will concentrate how to build an installer for macOS. We choose Homebrew package manager. Homebrew requires a URL to download your binary. There are a few choices to host your executable, like any cloud provider with file storage service or may be GitHub Releases. For the current use case, I am using GitHub Releases because it is free and easy to use.
From the rust source code example, the binary is archived (using tar -C target/release -czf rust_app.tar.gz rust_app
)
and uploaded into a GitHub release and is available under
https://github.com/dasmanas/homebrew-rust-app/releases/download/v0.0.1/rust_app.tar.gz link. We will require this link
in the installation instruction.
Homebrew to Create the macOS Installer
Homebrew offers a way to install formula from third party repositories (i.e. Taps) like GitHub repositories instead of
having it as part of core homebrew formula homebrew-core. We will create
one such GitHub project to host our formula. GitHub project name is homebrew-rust-app
while following the naming
convention homebrew-<project_name>
to have better convenience for the user while tapping the repository.
We will create the homebrew project in following structure which will hold the Ruby formula file under Formula directory like shown here.
homebrew-rust-app
├── Formula
│ └── listener.rb
└── README.md
The Ruby file contains the installation instruction of your binary. Here is a sample ruby file for the formula. Here we will try to achieve a few things for our example.
- A macOS installer for the binary.
- User of the software doesn't need manage any configuration. Defaults (config, env vars etc.) will be manage as part of the installation.
- Install the software under a managed directory by Homebrew. This gives more control to the software manufacturer to support backward compatibility or cleaning up memory in at times specially during software version update.
- An installer which can be run as background service.
- Sample test case for the installer which can be performed even before starting the installer as background service will provide a better determine if there is any potential issue.
class Listener < Formula
desc "A small description of your formula"
# A home page URL for your software
homepage "https://github.com/dasmanas/homebrew-rust-app/releases"
# URL from where the installer archive is available
url "https://github.com/dasmanas/homebrew-rust-app/releases/download/v0.0.1/rust_app.tar.gz"
# Sha256 can be calculated using "shasum -a 256 <archive_name.tar.gz>"
sha256 "c19c678e755d98506c372dd631640702ef2b7da225ffaee20453deb0c2b4a213"
license "Apache-2.0"
version "0.0.1"
def install
ENV.deparallelize
# Installing the app
bin.install "rust_app"
end
def post_install
# Instruction to create a directory which may be used to manage file resources for the app. rust_app directory
# will be created under /usr/local/var directory.
(var/"rust_app").mkpath
end
# Section to add different instruction for the user
def caveats
s = <<~EOS
We've installed your rust_app.
To test rust_app installation:
brew test listener
To run rust_app Node as a background service:
brew services start listener
To check the service status:
brew services list
To stop rust_app Node background service:
brew services stop listener
EOS
s
end
service do
def envvarhash
return {PATH: std_service_path_env, LISTEN_PORT: "9090"}
end
run [opt_bin/"rust_app"]
keep_alive true
process_type :background
environment_variables envvarhash
log_path var/"rust_app/logs/stdout/rust_app.log"
error_log_path var/"rust_app/logs/stdout/rust_app.log"
working_dir var/"rust_app"
end
test do
(testpath/"rust_app").mkpath
(testpath/"tmp").mkpath
puts testpath
child_pid = fork do
puts "Child process initiated to run rust_app"
puts "Child pid: #{Process.pid}, pgid: #{Process.getpgrp}"
#setsid() creates a new session if the calling process is not a process group leader.
Process.setsid
puts "Child new pgid: #{Process.getpgrp}"
puts "Initiating rust_app..."
ENV.prepend "LISTEN_PORT", "9090"
system "#{bin}/rust_app"
end
puts "Waiting for rust_app TCP socket listener to be up..."
sleep 10
begin
system "echo sample_text | nc localhost 9090"
assert_match "rust_app",
shell_output("lsof -nP -i4TCP:9090 | grep LISTEN")
rescue
puts "test1"
assert_raises(CustomError) do
raise CustomError, 'This is really bad'
end
ensure
pgid = Process.getpgid(child_pid)
puts "Sending HUP to group #{pgid}..."
Process.kill('HUP', -pgid)
Process.detach(pgid)
puts "Parent process exiting..."
end
end
end
In the ruby script, the section service do ... end
takes care of registering the software as background service. The
block of ruby script creates the required plist file under ~/Library/LaunchAgents/
for the Launch Agents. At the same
time it eliminates the need of handcrafted plist file. For more information you can always refer to Homebrew's
Service files official doc.
We also added a test do ... end
section to the ruby script to demonstrate the test capability. User can run a test on
the installer even before starting the same as a service.
How to install your software as service
Now it is ready to release the installer. To use the installer in macOS, here are some important tips.
- Tap the third party repository for the brew formula. E.g.
brew tap dasmanas/rust-app
. - Install the formula from the tap. E.g.
brew tap listener
- Test the installed binary. E.g.
brew test listener
- Install the binary as service. E.g.
brew services start listener
. Once service has started as background process, the status of the service can be verified usingbrew services list
. - Background service can be stopped at any time using brew's services utility. E.g.
brew services stop listener
In Pyrsia we built similar kind of installer for macOS. If you are a macOS user, you may also like to take a look at the code base from homebrew-pyrsia or try out Quick Installation guide to install Pyrsia. For any improvement suggestion or to contribute open issue or pull request accordingly under Pyrsia project.