| .forgejo/workflows | ||
| .kiro/specs/calculator | ||
| android | ||
| build | ||
| engine | ||
| include | ||
| src | ||
| .gitignore | ||
| .mise.toml | ||
| .pre-commit-config.yaml | ||
| build.zig | ||
| build.zig.zon | ||
| LICENSE | ||
| README.md | ||
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/6is exactly0.5,factorial(500)has every digit, and a conversion like12 in to ftis exactly1. 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 thexorkeyword, 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 --helplists them all. - Variables and
Ans.x = 21, thenx * 2, thenAns + 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 byzig build jvm-test. - The Android library links against nothing. No libc, no NDK. That is why
android-auditexists: an undefined symbol links fine and fails indlopenon 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 throughtally.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.