Cross-compile a CLI app for Sailfish OS
Sailfish OS is glibc Linux (Jolla). Its shipping devices today are 64-bit ARM (aarch64), which is what this guide targets — though the OS has also run on 32-bit ARM (armv7hl, e.g. the original Jolla phone, now stuck on a very old release) and on x86/x86_64 (the Jolla Tablet, plus community ports to Intel netbooks and tablets). We support aarch64 for now; see the scope note. The ordinary native pipeline is at home there: you cross-compile a normal command-line program to a 64-bit-ARM binary, then package it as an installable RPM — including Sailfish's Harbour naming and private-library bundling. This guide is CLI-tool focused; see the scope note below for GUI and terminal-UI status. Every step here was verified building and running on a physical Xperia 10 II (Sailfish OS 5.1.0.11).
What's supported today
- CLI programs — the whole runtime (fibers,
select()loop,fetch/libcurl, RegExp/pcre2, crypto,fs,process) cross-compiles and runs. - RPM packaging —
-package=rpmand-package=rpm:harbour, with non-allowlisted libraries bundled app-privately.
Scope. A native webview/GUI backend for Sailfish (its Gecko sailfish-components-webview) is planned but not yet implemented, so klain:webview apps don't target Sailfish yet. The klain:tui framework renders correctly on the device, and the shipped klaintop example runs on Sailfish once it reads process data portably (see "Write once, run everywhere" below). aarch64 first: it's what current devices ship, so that's where the initial support and on-device verification went. Sailfish also runs on 32-bit ARM (armv7hl, the earlier hardware) and x86/x86_64 (the Jolla Tablet and the community Intel ports); the --target machinery is generic, so those are a natural next step rather than a boundary — open an issue if you're building for one and it'll get prioritized.
1 · Where you build matters
Cross-compilation retargets both the C toolchain and the compiler's OS-specific codegen (the fiber scheduler's context layout, process.platform/arch, the os module, fs stat layouts, clocks), so the target OS is threaded through. Two build hosts work:
- A Linux host (box, VM, or container) — the simplest: cross-architecture on the same OS (x86-64/arm64 Linux → aarch64 Linux), and
rpmbuildfor the packaging step is available. On an Apple-Silicon Mac, anarm64Linux container runs natively (no emulation), which makes a fine build host. - macOS → Linux directly: the compiled binary links on the Mac with
lldonPATH(brew install lld) against the target--sysroot. Packaging (-package=rpm) still wants a host withrpmbuild, so the container route is handier when you also want the RPM.
Other cross-OS pairs (a Windows target, or a macOS target from a non-macOS host) are rejected with a clear message, as is a klain:webview/klain:tui program built cross-OS (those C++ subsystems still build for the host).
2 · Get a Sailfish sysroot
Linking needs the target's own headers and libraries — a sysroot. The cheapest way to obtain a real one, without the full Jolla SDK, is to export the filesystem of the community builder image (exporting runs nothing, so no emulation):
# Export a real aarch64 Sailfish rootfs to ./sfos-sysroot (needs Docker/Podman).
# Match the release to your device (here 5.1.0.11).
IMG=ghcr.io/sailfishos-open/docker-sailfishos-builder-aarch64:5.1.0.11
docker pull --platform=linux/arm64 "$IMG"
CID=$(docker create --platform=linux/arm64 "$IMG")
mkdir -p sfos-sysroot && docker export "$CID" | tar -C sfos-sysroot -xf -
docker rm "$CID" That tree carries Sailfish's real /usr/lib64 layout, glibc, the gcc install, and headers. A device image ships only versioned .so.N runtime libraries; to link against a library you also need its .so developer symlink (a proper SDK target with the -devel packages has them, or add them yourself, e.g. ln -s libpcre2-8.so.0 libpcre2-8.so).
3 · Cross-compile the program
Write an ordinary CLI program — nothing Sailfish-specific:
// greet.ts — an ordinary CLI program; nothing Sailfish-specific.
// In a compiled binary, argv[0] is the program and the first user arg is argv[1].
const who = process.argv[1] ?? "world";
console.log(`Hello, ${who}!`);
console.log(`running on ${process.platform}/${process.arch}`); Compile it with the sfos-aarch64 preset and point --sysroot at the exported tree:
# On a Linux host (or an arm64 Linux container on Apple Silicon).
klainmain --target sfos-aarch64 --sysroot ./sfos-sysroot -o greet greet.ts
# → ELF 64-bit aarch64, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux The preset resolves to Sailfish's native triple aarch64-meego-linux-gnu. That exact triple matters: it's how clang finds the target's gcc/crt objects and the RPM /usr/lib64 layout in the sysroot — the generic aarch64-linux-gnu would fail to link. A genuine cross-arch link uses lld when it's on PATH.
4 · Package it as an RPM
-package=rpm emits a plain package; -package=rpm:harbour applies Sailfish Harbour naming (harbour-<name>). The .spec and a full rpmbuild tree are always written, and rpmbuild is run when it's on PATH — so a build host with rpmbuild produces the .rpm directly:
# Harbour RPM (rpmbuild present → builds harbour-greet-1.0.0-1.aarch64.rpm)
klainmain --target sfos-aarch64 --sysroot ./sfos-sysroot \
-package=rpm:harbour \
-app-name greet -app-version 1.0.0 -app-license BSD-3-Clause \
-o greet greet.ts
# Without rpmbuild on PATH, the .spec + build tree are written and the exact
# rpmbuild command to run elsewhere is printed. Libraries not on the Harbour allowed-list — pcre2 (used by RegExp), and bdw-gc when you build with -mm=gc — are bundled automatically: the SONAME is copied into /usr/share/harbour-<name>/lib/ and the binary is linked with a matching rpath, the sanctioned Harbour pattern. libcurl, OpenSSL, libstdc++, pthread, and libm are Harbour-allowed and stay system libraries. (A bundled library must be present in the --sysroot with its .so dev symlink — e.g. gc-devel for -mm=gc — the same as pcre2.)
Metadata comes from -app-name, -app-version, and -app-license (the RPM License: tag, e.g. GPLv3+). A -app-icon .png adds a .desktop launcher + hicolor icon for a Harbour GUI app.
5 · Put it on the device
Copy the RPM to the phone and install it (installation needs root — devel-su), or copy the bare binary and run it directly:
# Install the RPM (as root on the device):
scp harbour-greet-1.0.0-1.aarch64.rpm defaultuser@phone:/home/defaultuser/
ssh defaultuser@phone 'devel-su rpm -i harbour-greet-1.0.0-1.aarch64.rpm'
# …or just run the bare binary:
scp greet defaultuser@phone:/home/defaultuser/ && ssh defaultuser@phone ./greet KyriakosWrite once, run everywhere — without a runtime
The whole promise is one native binary that behaves correctly on each OS, with no interpreter or VM shipped alongside. The way you keep that promise in real apps is: detect where you are and reach for the right primitive. process.platform is a compile-time constant baked from the target, so the branch costs nothing at runtime.
The shipped klaintop (a terminal process monitor) is the worked example. Its klain:tui UI — Yoga layout, the ANSI painter, Unicode box-drawing, and klain:tty raw-mode input — renders identically everywhere. The data is the interesting part: a process list has no single portable source. The naive version shelled out to ps -axo …, which works on macOS's BSD ps but fails on Sailfish, because Sailfish ships BusyBox, whose ps rejects those flags. The fix isn't to detect "am I on Sailfish?" — it's to prefer the portable interface over a platform-specific tool:
// One binary, right behaviour per OS. process.platform is a compile-time
// constant, so this branch is free at runtime.
export function listProcs(): Proc[] {
if (process.platform === "linux") return fromProc(); // glibc, Android, Sailfish…
return fromPs(); // macOS BSD ps
}
// Linux: read /proc directly — the portable interface every Linux flavour shares,
// including BusyBox-based Sailfish (where shelling out to `ps -axo` would fail).
function fromProc(): Proc[] {
const out: Proc[] = [];
for (const name of readdirSync("/proc")) {
const pid = parseInt(name, 10);
if (!(pid > 0) || String(pid) !== name) continue; // numeric pid dirs only
try {
const stat = readFileSync("/proc/" + pid + "/stat", "utf8").trim();
const m = stat.match(/^\d+ \((.*)\) (.+)$/); // comm may contain spaces/)
if (!m) continue;
const f = m[2].split(/\s+/);
// …utime f[11] + stime f[12] for CPU%, VmRSS from /proc/<pid>/status for MEM%
out.push({ pid, comm: m[1], /* … */ });
} catch (e) { /* process vanished between readdir and read — skip */ }
}
return out;
}/proc is the universal Linux interface — it reads the same on a glibc desktop, on Android/Termux, and on BusyBox-based Sailfish, none of which is true of a particular ps build. macOS has no /proc, so there the BSD ps branch is genuinely right. Same source, same binary shape, correct on each — the platform check is the only concession, and it's free.
Two runtime details make this "just work" once the branch is right: fs.readFileSync reads zero-length /proc//sys pseudo-files correctly (they report size 0 but stream real bytes), and a klain:tui app that sizes its root box from process.stdout.columns/rows (and repaints on SIGWINCH) fills whatever terminal it lands in — a phone's over SSH included. klaintop does both. raw mode is always restored on exit.
Verified on
Xperia 10 II, Sailfish OS 5.1.0.11 (aarch64, glibc 2.41): a plain CLI binary, a RegExp binary resolving its bundled libpcre2-8.so.0 from the Harbour lib path, a Harbour RPM read by the device's own rpm, and klaintop showing a live process table (reading Sailfish's /proc) that fills the terminal.