Developer guide
Builds and tests run in Docker through the Makefile; the host needs only Docker and make. This guide covers the daily workflow, how the tooling works underneath, and the conventions every change follows. The original design and build plan are in PROJECT_PLAN.md. Optional host secret scanning and contribution guidelines are in CONTRIBUTING.md.
First-time setup
- Docker access: your user must be in the
dockergroup (log out and in after joining, or runnewgrp dockerin a terminal). make imagebuilds the development image (about 1.1 GB; it needs the network).make sdkis interactive: you accept Garmin's agreements, log in, and the SDK (9.2.0) plus the device files of all 72 products download into.cache/home(about 1.1 GB). The login token is deleted at the end.make keycreates the developer key in.cache/keys/. Back that folder up; see PROJECT_PLAN 15.6.
Daily workflow
| Task | Command |
|---|---|
| Build for one device | make build DEVICE=fr70 |
| Run the app and watch it in your browser | make sim DEVICE=fr70, then open http://127.0.0.1:6080/vnc.html |
| Unit tests (Python tools and Monkey C, default device) | make test |
| All static checks | make lint |
| Screenshots against the approved images | make screenshots |
| Accept changed screens after reviewing build/screenshots/ | make screenshots-approve |
| Start-up time and memory budgets (app and glance) | make perf, make memtest |
| Largest 2D codes at twice the steps per event (watchdog) | make watchdog |
| Signed package for the Store beta | make package |
| Regenerate the crypto/OTP test vectors | make vectors |
| Build the M1 settings spike | make spike (instructions: SETTINGS_SPIKE.md) |
| Everything, in order | make ci |
| A shell in the container | make shell |
By default every target that takes a device list runs on the Forerunner 70 only (DEVICE, TEST_DEVICES and WATCHDOG_DEVICES are fr70, SPOT_DEVICES is empty). Add ALL=1 for the full set (Forerunner 255S, Venu Sq 2, Forerunner 70 and 965, plus fēnix 7, Instinct 3 AMOLED and Venu X1 for screenshots, and the 255S for the watchdog), or name devices with TEST_DEVICES="fr70 venu3". The goldens of the other watches are only refreshed by an ALL=1 run, so they may lag behind the Forerunner 70's. Every target runs serially, even under make -j.
Repository layout
| Path | Content |
|---|---|
source/ |
The watch app (Monkey C), one folder per module (PROJECT_PLAN section 5) |
test/ |
Monkey C unit tests ((:test) functions), compiled only into test builds |
test/golden/ |
Approved simulator screenshots: one folder per device, one image per screen of the UI flow |
resources/ |
Strings, the default launcher icon, the option properties and, from M7, the settings form |
resources-icons/, icons.jungle |
Launcher icons at each size the products need, generated by make icons |
manifest.xml, monkey.jungle, test.jungle, uitest.jungle, stress.jungle |
App manifest and build configuration |
docker/ |
Images (Dockerfile, docs.Dockerfile), hash-locked dependencies, and the container scripts |
tools/ |
Python tools (test-log judge, screenshot checker, linters, icon generator) and their tests |
docs/ |
This guide and the other documents |
.cache/, build/ |
Git-ignored: SDK, device files, key; build outputs |
How the tooling works
Builds
docker/scripts/build-app.sh compiles one device in one mode:
debug: whatmake build,make simandmake perfuse. It contains the(:debug)diagnostics, the debug menu items (demo entries, performance overlay, on-watch benchmark) and the fixtures.uitest: a debug build plusuitest.jungle, formake screenshots. It keeps the(:uitest)half ofsource/util/TestMode.mc, so the app starts with a wiped store, fake entries, the PIN 1234, a frozen clock and a 10 s auto-lock.uitest-extra: likeuitest, but withuitest-extra.jungle, which setsTestMode.SCENARIOtoSCENARIO_EXTRA, soFixtures.loadExtraloads a second scenario: a legacy 6-digit PIN without thewlmarker, a queued Require PIN, UPC-A, Code 39, a broken EAN-13, a large QR and the light theme.screenshots.shruns it after the main flow withui_flow.py --scenario extra.stress: a debug build plusstress.jungle, formake watchdog. It keeps the(:stress)half ofsource/util/StressTest.mc: the app starts straight into the watchdog stress test instead of the list.release:monkeyc -rremoves every(:debug)declaration; used bymake package.test: addstest.jungleand--unit-test.
Every build uses strict type checking (-l 3) and fails on any compiler warning. The launcher icon is the classic source of warnings: the compiler warns when it has to scale an icon, so every product gets an icon of exactly its size through icons.jungle. After adding a product to the manifest, run make sdk (device files) and then make icons.
The headless simulator
The simulator is a GTK program. The scripts start it on a virtual display (Xvfb on :99) with the openbox window manager, so its window can be found for screenshots. Behaviour verified with SDK 9.2.0:
- It accepts
monkeydoon TCP port 1234; the scripts wait until that port listens (read from/proc/net/tcp). - It keeps a single-instance lock file,
~/Sim-dev, holding its process ID. That file survives in.cache/home, and a later container often reuses the process ID for something else, so the simulator would believe it's already running.ciq_start_simulatordeletes the stale lock first. monkeydoexits with 1 even when every unit test passed, sotools/ciq_test_report.pyjudges the console output instead.- For a normal (non-test) run,
monkeydoblocks while the app runs and streams the app'sSystem.printlnoutput. Quotes in that output arrive escaped, which is why the diagnostics use plainkey=valuepairs. - It starts apps that have a glance in the glance.
ciq_launch_appwaits for the glance'sDIAG shown glanceline, then opens the app the way the simulator accepts. On touch screens that's a tap on the glance; on button-only watches it's DOWN (which selects the glance) and then START. START alone does nothing in the simulator. The press is retried if the app doesn't open. - A second simulator in the same container opens a second window, and input may go to the wrong one.
ciq_start_simulatorreuses one that's already listening. - Both
monkeydoand the harness append to the app log; with>one would overwrite the other's lines.
Monkey C facts that matter here (verified with SDK 9.2.0)
Longis 64-bit and wraps on overflow;>>is an arithmetic shift, so logical shifts and rotations mask off the copied sign bits (seesource/crypto/Sha512.mc).- Assigning a value above 255 to a ByteArray element throws; mask with
& 0xFF. - Native HMAC supports only SHA-256; HMAC-SHA1 and HMAC-SHA512 go through
Hmac.generic. - Function calls are expensive in the VM; hot loops inline small helpers (SHA-512 went 37 % faster that way).
- Long
&&chains withinstanceofcan make the compiler's type checker run out of memory: one test condition needed more than 6 GB until it was split into separateifstatements. - JSON resources over 8 KB produce a compiler warning, so large tables are split; the PDF417 patterns are three resources, one per cluster.
- The watchdog ("Code Executed Too Long") is not enforced in unit-test runs, but it is in normal simulator runs, with the device's
watchdogCountper event: 120,000 bytecodes on the Forerunner 255/255S, 240,000 on the others. M4 measured what it means. 200 byte-wise PBKDF2 iterations in one event tripped it on the 255S, and so did two SHA-512 codes in one draw. So: - Anything long runs as a
Jobs.Jobwith a bounded step (Kdf.ITERATIONS_PER_STEP= 100). - Screens compute at most one SHA-512 code per event, in a timer event separate from drawing.
- The scripted UI flow runs on the 255S profile, so a regression trips there.
- M8 found that step sizes guessed from "bytecodes per loop" were 2 to 3 times too large: a simple loop iteration costs 50 to 70 counted bytecodes, not 10 to 20. Every step of the 2D encoders, the cache packer and the run finder is now sized by measurement in
make watchdog, which runs the largest input of each with two steps per event: a pass means every step uses at most half the budget. Do the same for any new job: add its worst input toStressTest.start(). Work on the vault goes in aStressTest.VaultCase: a setup in bounded steps on a RAM backend, then the measured event, done for two identical vaults. - Per-byte VM loops, measured on the Forerunner 70 in 2026-10: zeroing (
Bytes.wipe) about 15 bytecodes per byte, the UTF-8 check 20 to 29. With about 60,000 bytecodes per step (half the 255S's event), a 4 KB buffer can't be wiped or checked in one step:Jobs.Wiperwipes 2 KB per step,Bytes.utf8Prefixchecks in pieces, andSealedBox.sealKeep/openKeephand AES's padded copies to the caller's wiper. - The work after a correct PIN (promoting pending entries, applying "Require PIN") is a job too (
ChangeQueue.LockJob), run byPinViewbefore it continues. In 0.9.0 it ran in the event of the last PBKDF2 step and 16 waiting "Require PIN" changes tripped the watchdog on the Forerunner 70. Jobs.JobRunnerruns one step per 50 ms tick on the 512 KB watches and two on the others (Jobs.stepsPerTick, by app memory).tools/gen_products.py --check(part ofmake lint) fails if a product'swatchdogCountdoesn't match that rule.- Drawing counts too: one
fillRectangleper run inonUpdateis fine up to about 1,500 runs per 120,000 bytecodes.RunsPainterpaints larger codes into a buffered bitmap in steps first (the Venu X1 fits a QR with 8,000 runs). StringUtil.convertEncodedStringstops the app with an uncatchable "System Error" on invalid UTF-8;trydoesn't help. Decode untrusted bytes withBytes.toUtf8, which validates first.Application.Storagestores ByteArrays (itsValueTypeincludes them, unlikeApplication.PropertyValueType), so records are stored as single ByteArrays of known size. Dictionaries passed to it must be typedDictionary<Application.Storage.KeyType, Application.Storage.ValueType>;Dictionary<String, ...>doesn't type-check.Array.slicereturnsArray<Object or Null>; cast it back (as Array<String>).Application.Properties.getValueon a property that isn't inproperties.xmlstops the app with an uncatchable "Unexpected Type Error". Every option is therefore declared inresources/settings/properties.xml.- On touch screens, a tap is delivered as the
onSelectbehaviour first, andonTaponly runs ifonSelectreturns false. Delegates that need the tap position (the list, the PIN keypad) return false fromonSelecton touch screens, and handle the START button inonKey. - Glances: because
getGlanceViewis(:glance), the compiler checks the whole app class against the glance scope. Methods that never run in glance mode opt out with(:typecheck(disableGlanceCheck)). AppBase.initializeruns before non-glance code is loaded, soDiag(which it calls) is(:glance).onStopalso runs after the glance, so it only touchesAppStateonce the full app has started.Attention.vibratemakes the simulator shake the device drawing, which spoils screenshots; the UI-test build doesn't vibrate.- A
ConfirmationDelegatecloses its dialog afteronResponse, so a view switch fromonResponsemust wait for a short timer. Attention has :vibrateinside a class that has a privatevibratemethod warns (the symbol lookup finds the method); the helper is calledbuzz.hasandhiddenare reserved words; they can't name a function or a variable.- Under strict typing, a property value (
Application.Properties.ValueType) can't be compared with==to a Boolean or Number: narrow it withinstanceoffirst (Forms.flag,Forms.number). - Memory of objects (measured on the Forerunner 255S): an
Entrytakes about 490 bytes even when empty, an import item with its entry about 760, and an 80-byte ByteArray about 110. Hold many small records packed in ByteArrays (ImportItem.pack) rather than as objects, and grids in ByteArrays rather thanArray<Number>(a 132 x 132 grid of Numbers took about 90 KB). - The app's code and constant data are loaded whole at start: about 156 KB of the 255S's 512 KB before anything runs (release and debug builds alike). Only resources (
WatchUi.loadResource) load on demand. moduleis a reserved word too.- The glance loads every
(:glance)module; the crypto it needs for the favourite code (Hkdf,Hmac,Hashes,Sha512,Aes,SealedBox,Otp,Bytes,Clock,TestMode) took it from 19 % to 37 % of its 64 KB. A glance module can't name app-only classes (the compiler checks the glance scope), henceGlanceCode(glance) andGlanceStore(app) in one file. In the simulator, an app that exits does not return to its glance; a new launch starts there (screenshots.shlaunches again forglance_code.png). - Zeroing a ByteArray is a byte loop in the VM: zeroing a 10 KB import text in one event tripped the watchdog on the Forerunner 255S. The native HMAC takes data in pieces (
update), so large content is hashed without copying it into one buffer.
Screenshots and the UI flow
make screenshots builds both UI-test variants and runs tools/ui_flow.py on every device in TEST_DEVICES and SPOT_DEVICES: the main flow, the glance with its code, the extra scenario, then the glance without a code. The Forerunner 70 alone (the default) takes about 8 minutes; all seven watches (ALL=1) about 65.
- Input. The flow clicks the buttons where the device's
simulator.jsondraws them. It taps and swipes on touch screens, and long-presses for the menu where there's no MENU button. - Waiting. After each input it waits for the screen's
DIAG shown <screen>line. The list and the OTP screen print it only once every visible code is drawn. - Touch targets. It reads where to tap from
DIAG list_focus(the focused row) andDIAG pin_keys(the keypad). - Comparison. Each screen is cut out of the window capture by
tools/sim_screenshot.pyand compared withtest/golden/<device>/<screen>.png. That tool finds the device drawing by its bezel, so it doesn't assume a window layout. Differences go tobuild/screenshots/<device>/<screen>-diff.png.
Because the entry IDs, the clock and the codes are fixed in the UI-test build, every run is pixel-identical. Menu items are opened by label from the DIAG menu items= line; touch-only watches show menus as a freely scrolling list without focus, so their menu geometry is in TOUCH_MENU_ROWS in tools/ui_flow.py (a new touch-only test device needs an entry). PIN steps wait for DIAG pin_state, the encoding progress is checked from DIAG code_progress, and shot(..., review=True) saves screens that change from run to run (the benchmark, the progress) without comparing them. A state that needs other fixture data goes into the extra scenario, or a new SCENARIO_* with its own jungle and build mode.
Performance and memory
Debug builds print DIAG first_frame startup_ms=... mem_used=... mem_total=... after their first frame, and the glance DIAG glance mem_used=... mem_total=... (source/util/Diag.mc). docker/scripts/app-diag.sh adds a HARNESS first_frame wall_ms=... line, the wall-clock time from launching monkeydo until that first frame. tools/check_budgets.py compares both with tools/budgets.json. See PERFORMANCE.md for the numbers and their limits.
Conventions
- Headers: every file starts with
Author: cirakg & claude aiand a one-lineSummary:in its comment syntax. JSON files useauthor/_authoranddescription/_summarykeys, and PNG images use Author and Description text chunks. Onlydocker/docs/package-lock.json, which npm rewrites, is exempt.make lintenforces this (rule HDR001). - Comments: every module, class and non-trivial function explains its purpose, inputs, outputs, errors and security implications.
- No logging:
System.printlnis allowed only insource/util/Diag.mc, and only in(:debug)code. Secrets, codes and entry names never go to a log. - No network:
manifest.xmlrequests no permissions and no code usesToybox.Communications.make lintenforces both. - Secrets: no real secrets anywhere in the repository; test data is fake and says so.
- Small functions, named constants, strict typing in Monkey C (
-l 3) and Python (mypy --strict), and warnings treated as errors in the compiler, pytest and the linters. - Commits: Conventional Commits on
main, one finished and verified unit per commit, withCHANGELOG.mdupdated.
Adding things
- A device: add
<iq:product id="..."/>tomanifest.xml, thenmake sdk(downloads its files),make iconsandmake products(checks it meets the target rule: a watch, API 5.0 or newer, at least 512 KB, and the watchdog the job runner assumes for its memory). - A test device: add it to the
ALL=1lists ofTEST_DEVICES(unit tests, perf and screenshots) orSPOT_DEVICES(screenshots only) in theMakefile. Then runmake screenshots, reviewbuild/screenshots/<device>/, and runmake screenshots-approve. - A screen in the UI flow: print
Diag.shown("<name>")after its first complete draw, add the steps torun_flowintools/ui_flow.py, and approve the new goldens after reviewing them. - Import inputs: new parser cases go into
test/import/(fixtures are built inImportSupport.mc, all fake). To widen the fuzz corpus, change the seeds or mutations intools/gen_fuzz_corpus.pyand runmake corpus. The Python tests check that the committed corpus matches the generator. - A settings form or field:
1. Declare each property in
resources/settings/properties.xml. Reading an undeclared property crashes the app in a way that can't be caught. 2. Add the setting toresources/settings/settings.xmland its strings toresources/strings/strings.xml.make lintvalidates both against the SDK schema and checks that they agree (tools/check_resources.py). 3. Read the fields through aForms.Storein a reader likesource/settings/OtpForm.mc, so tests can useForms.MemoryStore. Put every field in the form's hash content and its default in the form'sdefaults(); the testsettingsDefaultsMatchPropertieschecks those againstproperties.xml. 4. Add a phase toSettingsJobin PROJECT_PLAN 10.3 order. Heavy work (encoding, many records) goes into its own steps: one step is one timer event under the watchdog. - On-watch numbers: sideload a debug build (
make build DEVICE=fr70, copybuild/debug/fr70.prgto the watch'sGARMIN/APPSfolder over USB). Then open the menu and choose "Debug: benchmark". The watch installs the app and removes the.prgfrom the folder; to remove the app, use the watch's own app menu, then delete thefr70.*storage files inGARMIN/APPS/DATAandGARMIN/APPS/SETTINGS(YOUR_STEPS.md). - A barcode symbology: add an encoder module under
source/codes/and dispatch to it inCodes.encode. Then: - Add reference rows in
tools/gen_code_vectors.py(zxing-cpp's writer, or segno for QR) and runmake codes. - Add
CODEDUMPlines for seeded payloads in its test, sotools/check_codes.pydecodes them in every test run. - Add its zxing-cpp format to
tools/barcode_ref.py.
When decoding with zxing-cpp, restrict the format: by default it reads Code 39 as full-ASCII, which changes pairs such as "+S".
