add README
This commit is contained in:
parent
1f7cc9226e
commit
6e6043cbda
2 changed files with 279 additions and 0 deletions
26
.mise.toml
26
.mise.toml
|
|
@ -58,3 +58,29 @@ adb shell am start -n dev.lerch.tally/.MainActivity
|
||||||
[tasks.android-run]
|
[tasks.android-run]
|
||||||
description = "Boot the tally35 AVD - does not install the app (add -- -no-window for headless)"
|
description = "Boot the tally35 AVD - does not install the app (add -- -no-window for headless)"
|
||||||
run = 'ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" emulator -avd tally35 -no-audio -no-boot-anim -gpu swiftshader_indirect'
|
run = 'ANDROID_AVD_HOME="$MISE_PROJECT_ROOT/.tmp/avd" emulator -avd tally35 -no-audio -no-boot-anim -gpu swiftshader_indirect'
|
||||||
|
|
||||||
|
[tasks.android-audit]
|
||||||
|
description = "Check the Android libraries define every symbol they use (CI runs this too)"
|
||||||
|
run = 'zig build android && "$MISE_PROJECT_ROOT/android/audit-libs.sh" "$MISE_PROJECT_ROOT/zig-out/android"'
|
||||||
|
|
||||||
|
[tasks.apk-signer-test]
|
||||||
|
description = "Build a release APK and sign it with the CI's HSM signer (action-hsm-sign), against SoftHSM (needs docker or podman)"
|
||||||
|
run = '''
|
||||||
|
set -e
|
||||||
|
zig build android
|
||||||
|
(cd "$MISE_PROJECT_ROOT/android" && gradle --no-daemon assembleRelease)
|
||||||
|
# The signer and its test are action-hsm-sign's: by default at the version the workflow
|
||||||
|
# uses, or a local checkout named by ACTION_HSM_SIGN.
|
||||||
|
export TMPDIR="$MISE_PROJECT_ROOT/.tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
action="${ACTION_HSM_SIGN:-}"
|
||||||
|
if [ -z "$action" ]; then
|
||||||
|
ref="$(sed -n 's|.*lobo/action-hsm-sign@\([^[:space:]]*\).*|\1|p' "$MISE_PROJECT_ROOT/.forgejo/workflows/build.yaml" | head -n 1)"
|
||||||
|
[ -n "$ref" ] || { echo "no action-hsm-sign version in .forgejo/workflows/build.yaml" >&2; exit 1; }
|
||||||
|
action="$TMPDIR/action-hsm-sign"
|
||||||
|
rm -rf "$action"
|
||||||
|
git clone -q --depth 1 --branch "$ref" https://git.lerch.org/lobo/action-hsm-sign.git "$action"
|
||||||
|
fi
|
||||||
|
"$action/signer/test.sh" \
|
||||||
|
"$MISE_PROJECT_ROOT/android/app/build/outputs/apk/release/app-release-unsigned.apk"
|
||||||
|
'''
|
||||||
|
|
|
||||||
253
README.md
Normal file
253
README.md
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# 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](.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](android/README.md).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Builds of `master` are published to the
|
||||||
|
[package registry](https://git.lerch.org/lobo/-/packages/generic/tally/latest), 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](https://search.sigstore.dev); 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](https://mise.jdx.dev), pinned in [.mise.toml](.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](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](https://pre-commit.com) 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](.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](https://git.lerch.org/lobo/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](LICENSE).
|
||||||
Loading…
Add table
Reference in a new issue