tally/README.md
2026-10-04 08:34:17 -07:00

14 KiB

Tally

A calculator with one engine and three frontends: a command line, a terminal UI, and an Android app. The engine is Zig, and every frontend asks it for answers rather than computing its own, so 2^100 + 1 has the same last digit everywhere and an error is worded the same way everywhere.

$ tally '2^100 + 1'
1,267,650,600,228,229,401,496,703,205,377
$ tally '1/3 + 1/6'
0.5
$ tally 100 km to mi
100 km = 62.13711922373339696174 mi
$ tally -p '0xDEADBEEF and 0xFFFF'
  dec(signed):   48,879
  dec(unsigned): 48,879
  hex: 00 00 00 00 00 00 BE EF
  oct: 0 000 000 000 000 000 137 357
  bin: 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1011 1110 1110 1111

What it does

  • Exact arithmetic. Rationals of any size wherever the operations allow it, so 1/3 + 1/6 is exactly 0.5, factorial(500) has every digit, and a conversion like 12 in to ft is exactly 1. Where exactness is impossible (sqrt(2), sin), values fall back to f64. The engine reports which tier every result is in, and the frontends use it: the Android converter marks approximate rows.
  • Programmer mode. 8, 16, 32, 64 and 128-bit integers, signed or unsigned, in every base at once, with and or xor not << >> >>> rol ror. ^ is always power; XOR is the xor keyword, so an operator means the same thing in every mode. Byte order is a display choice for the hex and ASCII rows. The TUI adds an IEEE 754 float view.
  • Unit conversion. 118 units in 12 categories (length, mass, temperature, volume, speed, time, digital storage, area, energy, pressure, data rate, angle). A conversion is part of the expression language, 2*3 kg to lb, and is exact where the factors are.
  • Financial functions. CAGR, compound interest, time value of money and amortization, as ordinary functions: cagr(10000, 25000, 5) * 100. tally --help lists them all.
  • Variables and Ans. x = 21, then x * 2, then Ans + 1.

The requirements and design, including what is planned and not built (a struct layout visualizer, a financial screen on Android), are in .kiro/specs/calculator.

The three frontends

Command line. tally EXPRESSION evaluates and prints. -p is programmer mode; --bits N, --unsigned and --endian little set it up and imply -p. Subcommands: tally convert 100 km to mi and tally amort 200000 6 360 --monthly (a schedule). Run tally --help, or tally SUBCOMMAND --help.

Terminal UI. tally with no arguments. Standard, Programmer, Financial and Convert modes, switched with Tab; ? shows the keys for the mode you are in. The mouse works too: click a mode tab, a bit to flip it, a setting to change it.

Android. A calculator in the style of NCalc (NCalcLibre on F-Droid): a keypad, a live answer before =, history on its own screen. A programmer screen with a tappable bit grid. A converter that shows a value in every unit of its category and reopens exactly where you left it. Everything reopens as it was left, including variables. See android/README.md.

Install

Builds of master are published to the package registry, each file with a detached signature:

File What
tally-x86_64-linux CLI and TUI, static (musl), any distribution
tally-aarch64-linux the same for 64-bit ARM
tally-aarch64-macos Apple silicon
tally.apk the Android app, Android 8.0 and later
curl -fLO https://git.lerch.org/api/packages/lobo/generic/tally/latest/tally-x86_64-linux
chmod +x tally-x86_64-linux

latest moves with every push to master. Each build is also published under its short commit SHA, which never changes.

Verifying a download

Every file is signed by a key held in a hardware security module, and every signature is recorded in sigstore's public transparency log; the build log links each entry. To check a file against the published public key:

curl -fsSLO https://emil.lerch.org/serverpublic.pem
curl -fLO https://git.lerch.org/api/packages/lobo/generic/tally/latest/tally-x86_64-linux.sig
openssl dgst -sha256 -verify serverpublic.pem -signature tally-x86_64-linux.sig tally-x86_64-linux

The APK is signed twice: the detached signature above, and Android's own APK signature (schemes v2 and v3), made with the same HSM key. To confirm the app is signed by that key, compare the two digests below; they should be equal.

apksigner verify --verbose --print-certs tally.apk | grep 'public key SHA-256'
openssl pkey -pubin -in serverpublic.pem -outform DER | sha256sum

Android ties an app to its signing key permanently, so a published APK cannot update a copy you built yourself (which is signed with your debug key), and the reverse. Uninstall one before installing the other; the app's saved state goes with it.

Building

Every tool comes from mise, pinned in .mise.toml: Zig 0.16.0, zls, zlint, and for Android a JDK, Gradle and the Android SDK command-line tools. Nothing is installed system-wide.

mise install                 # the toolchain
zig build                    # zig-out/bin/tally, plus libtally (C ABI) and tally.h
zig build run -- '2 + 2'     # or with no arguments, the TUI
zig build test               # every test that needs only Zig

Cross-compiling is a flag: zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe.

The Android app is two steps, because Gradle never invokes Zig: zig build android writes one libtally.so per ABI, then Gradle packages it. Setup, the emulator and deploying to a phone (including over Wi-Fi) are in android/SETUP.md.

mise run android-sdk                          # the SDK packages, once
zig build android                             # the native library
cd android && gradle assembleDebug            # the APK
mise run android-install                      # all of the above, then install and launch

Build steps

Step What it does
zig build the CLI/TUI binary, the static engine, the shared C ABI library and its header
zig build run build and run; arguments after -- go to tally
zig build test engine, CLI, TUI, C ABI and JNI tests, and a C program linked through tally.h
zig build coverage the same tests under kcov, one report per tree; -Dcoverage-threshold=80 fails below 80%
zig build jvm-test the JNI layer loaded into a real JVM (needs a JDK; mise provides one)
zig build android zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so, ReleaseSmall

mise tasks

Task What it does
android-sdk install the SDK packages the app builds against
android-emulator, android-avd install the emulator and a 16 KB-page image, create the tally35 AVD in .tmp/avd
android-run boot that AVD
android-install build the libraries and APK, install on the attached device, launch it
android-audit check the Android libraries define every symbol they use (no libc, no TLS, no DT_NEEDED)
apk-signer-test build a release APK and sign it with CI's HSM signer, against SoftHSM (needs docker or podman)

How it is put together

engine/src/       the engine: tokenizer, Parser, evaluator, Rational, Integer, units,
                  financial, programmer, bitwise, float_interp
engine/src/c_api.zig   the C ABI every non-Zig frontend uses: sessions, JSON results
engine/src/jni.zig     JNI entry points for Android, written against the JNI function
                       table by index - no NDK, no jni.h
include/tally.h   the C header, checked against the library by engine/test/c_abi_test.c
src/main.zig, src/cli/   the command line
src/tui.zig, src/tui/    the terminal UI (libvaxis)
android/          the Compose app; Kotlin parses no expressions and formats no numbers
build/            coverage (kcov) support for build.zig
.kiro/specs/      requirements, design and task history

A few decisions shape everything else, and the design document explains each:

  • One engine, no reimplementation. Frontends format nothing and phrase no errors themselves. A wrong answer is wrong in all three places, which is the point.
  • The C ABI is the seam. A session owns its memory; results are JSON borrowed until the next call; one session per thread. Android reaches it through a hand-written JNI layer whose function-table indices are proved against the running JVM in JNI_OnLoad, and against a desktop JVM by zig build jvm-test.
  • The Android library links against nothing. No libc, no NDK. That is why android-audit exists: an undefined symbol links fine and fails in dlopen on a phone.
  • Display budgets belong to the frontend. The engine renders to whatever digit budget it is given; a phone, a terminal and a clipboard want different ones.

Tests

  • zig build test: about a thousand Zig tests across the engine, CLI, TUI (rendering real frames and reading the cells back), the C ABI, and the JNI layer against a synthetic function table; plus a C program that links the shared library through tally.h, which catches a header that disagrees with the library.
  • zig build jvm-test: the JNI entry points from Java, which is the only way to check the function-table indices without a device.
  • cd android && gradle testDebugUnitTest: JVM tests of the app's rules (the converter, programmer mode, saved state), no device needed.
  • cd android && gradle connectedAndroidTest: the engine through the real app on a device or emulator. It uninstalls the app afterwards.
  • mise run apk-signer-test: the CI signing path end to end against SoftHSM.

Pre-commit hooks (run with prek, which mise installs) check formatting, run zlint, build, and require 80% coverage. They also refuse smart punctuation: the source is ASCII.

CI/CD

.forgejo/workflows/build.yaml runs on every push:

Job Runner What
Engine, CLI and TUI ubuntu-latest zig fmt --check, zlint, build, zig build test, zig build jvm-test, then release builds for x86_64 and aarch64 Linux and aarch64 macOS
Android ubuntu-latest zig build android, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks)
Sign ubuntu-latest-with-hsm master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore
Publish ubuntu-latest master only: uploads the signed files to the generic package registry under the short SHA and latest

Every job reports to ntfy. Zlint and Gradle are downloaded at pinned versions and checked against pinned SHA-256 digests; Zig's version comes from build.zig.zon.

Secrets: HSM_USER_PIN, PACKAGE_PUSH, and NTFY_HOST, NTFY_TOPIC, NTFY_USER, NTFY_PASSWORD.

Signing

The Sign job signs with action-hsm-sign, in one step: the APK first (schemes v2 and v3, through Debian's apksigner and Java's SunPKCS11 provider), then a detached signature of every release file, the signed APK included. The action power cycles the HSM on its smart USB hub for the duration, and the private key never leaves the HSM.

An APK signature carries a certificate, and Java only offers a PKCS#11 key that has one on the token. Once, before the first signed build, make a self-signed certificate for the key and store it beside the key. On the HSM host, with the HSM powered, from a clone of action-hsm-sign:

docker build -t hsm-signer signer
read -rs PKCS11_PIN && export PKCS11_PIN
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
  -e PKCS11_PIN hsm-signer make-cert '/CN=Tally/O=lerch.org'

It prints the certificate; keep a copy. The key is 03 by default, the one the detached signatures use (KEY_ID changes it). The certificate is the app's identity on every device from then on: replacing it means users uninstall and reinstall. Its 30-year validity does not matter for APKs, whose certificate dates Android does not check.

mise run apk-signer-test builds the release APK and runs action-hsm-sign's signer/test.sh on it, at the version the workflow uses (or a local checkout named by ACTION_HSM_SIGN). That is the same signer image against SoftHSM: a token with a bare key, a detached signature, make-cert, APK signing, apksigner verify, and a check that the APK's signing key is the token's. What it cannot cover is the HSM itself and the host's pcscd, so the first run on the HSM runner is the real test of those.

License

MIT. See LICENSE.