Skip to content

Fix iOS keyboard launch crash and canvas visibility#9

Merged
willwade merged 1 commit into
mainfrom
fix/keyboard-launch-crash
Jun 18, 2026
Merged

Fix iOS keyboard launch crash and canvas visibility#9
willwade merged 1 commit into
mainfrom
fix/keyboard-launch-crash

Conversation

@willwade

Copy link
Copy Markdown
Contributor

Problem

The iOS keyboard extension was unusable. Three independent failure modes stacked on top of each other:

  1. Hard jetsam kill on launchexceeded mem limit: ActiveHard 77 MB (fatal) ~1s after the system swapped to the Dasher keyboard. The host app silently fell back to the default keyboard.
  2. Canvas collapsed to zero height within ~10ms of appearing — even when we got past the memory cap, iOS shrunk the canvas to nothing because the extension didn't declare a desired height, leaving only the 44pt toolbar visible.
  3. Empty PPM — with no alphabet visible, even a working canvas looked blank.

Each had to be fixed before the keyboard would stay on screen for more than a few hundred milliseconds.

Root causes (verified via Console.app on a physical device)

  • The 311KB training_english_GB.txt produced a PPM trie that, combined with UIKit overhead and the alphabet/colour XMLs, blew the 77MB keyboard extension memory cap.
  • IsFileWriteable() in DasherCore opened every bundled file in append-write mode just to test writability, generating a Sandbox: deny file-write-data for each colour/alphabet XML during Realize(). Fixed in DasherCore #20, now merged.
  • The UIInputViewController's view had no explicit height constraint, so iOS measured the extension's required height as just the toolbar (44pt) and collapsed the canvas.
  • The CADisplayLink retained its target via target: self, leaking the canvas across open/close cycles and firing NewFrame() 60–120 times/sec, expanding the model and growing memory until iOS shrunk us.
  • The default alphabet was always English regardless of the user's system locale.

What's in this PR

Memory

  • Trim keyboard training text from 311KB → first 50 lines (~24KB) in the Prepare Keyboard Data build phase (project.yml).
  • configureForLowMemory() pins LP_NODE_BUDGET=200, LP_LM_MAX_ORDER=4, BP_LM_ADAPTIVE=0 so DasherApp's larger values don't leak into the extension.
  • didReceiveMemoryWarning() no longer calls bridge.reset() (that rebuilds the node tree — opposite of what a memory warning needs).

Canvas visibility

  • Add an explicit Auto Layout height constraint (320pt, .defaultHigh priority) on the input view controller's view. This was the last piece that turned a flash-then-disappear into a stable, usable keyboard.
  • CADisplayLink uses a weak DisplayLinkProxy (no more retain cycle), is invalidated in deinit.
  • preferredFramesPerSecond = 30, and tick() is gated on a needsRedraw flag set by touch handlers — stops the per-frame NewFrame() expansion that grew memory until iOS shrunk us.
  • viewWillAppear calls bridge.reset() so each open starts at the root.

Locale + UX

  • On first run (no dasher_settings.xml in the App Group), pick the alphabet from Locale.current rather than always defaulting to English. Currently de → German alphabet; everything else stays English. Extensible as more alphabets are bundled.

Diagnostics (kept — cheap, useful for future device bring-up)

  • os_log breadcrumbs through DasherBridge.init, Realize(), setScreenSize, the first layout pass, first CADisplayLink tick, first draw, and canvas-height changes. Console.app filter: subsystem at.dasher.Dasher.keyboard.

Submodule bump

DasherCore: 5533eb93ddedde2 — pulls in DasherCore #20 (IsFileWriteable rewrite), which removes the sandbox-deny storm the keyboard generated against every bundled alphabet/colour XML during Realize(). That change is platform-neutral and strictly safer for every DasherCore frontend (Windows, GTK, WASM included).

Settings sync (free win)

Most user-facing settings already sync automatically: both DasherApp and the keyboard read/write the same dasher_settings.xml in the shared App Group (group.at.dasher.Dasher). Alphabet, palette, speed, input filter, locale, font — all carry over. The only intentional overrides are the LM memory-pressure params above.

Out of scope (deliberately)

  • Control mode in the keyboard: tried, but most of its commands (select-all, paste, edit menu, speak) don't actually work in iOS keyboard context, and the control tree adds ~10MB of memory that re-triggers the jetsam kill. Worth revisiting as a separate feature if/when there's a proper keyboard-friendly control.xml + action callbacks wired to textDocumentProxy.
  • Live locale-change handling: the alphabet is only re-picked on first run. If the user changes their iOS system locale later, the saved alphabet stays. Easy follow-up if needed.

Verification

Built and tested on iPhone (iPhone 12-class, iOS 18). Keyboard now:

  • Stays alive across multiple open/close cycles
  • Renders the full alphabet with colours from the user's chosen palette
  • Picks the German alphabet automatically on a German-locale device on first run
  • Honours alphabet/palette/speed changes made in DasherApp on the next keyboard launch

The keyboard extension was hitting three independent failure modes
that together made it unusable: a hard jetsam kill on launch, a
canvas that collapsed to zero height immediately after appearing,
and a PPM language model whose size was dominated by ~311KB of
training text. Each is fixed below; all three were needed before
the keyboard would stay on screen for more than a few hundred
milliseconds.

Memory (jetsam on launch at 77MB ActiveHard cap)
- Trim keyboard training text from 311KB to the first 50 lines
  (~24KB) in the Prepare Keyboard Data build phase. PPM trie now
  fits comfortably under the cap.
- configureForLowMemory() sets LP_NODE_BUDGET=200, LP_LM_MAX_ORDER=4,
  BP_LM_ADAPTIVE=0 on every keyboard launch so DasherApp's larger
  values (fine for the host app) don't get used by the extension.
- didReceiveMemoryWarning() no longer calls bridge.reset() — that
  rebuilds the node tree and spikes memory, the opposite of what
  a memory warning needs.

Canvas visibility (collapsed to 0 height within ~10ms of launch)
- Add an explicit Auto Layout height constraint of 320pt on the
  input view controller's view (priority .defaultHigh). Without
  this, iOS computes the extension's required height as just the
  toolbar (44pt) and shrinks the canvas away. This was the last
  piece of the puzzle.
- CADisplayLink now uses a weak DisplayLinkProxy target (was
  retaining self), and is invalidated in deinit. Previous setup
  leaked the canvas every time the keyboard re-opened.
- displayLink preferredFramesPerSecond = 30, and tick() is gated
  on a needsRedraw flag set by touch handlers. Stops the per-frame
  NewFrame() expansion that grew memory until iOS shrunk us.
- viewWillAppear resets the bridge so each open starts at the root,
  not wherever the previous session left the model.

Locale + UX
- On first run (no dasher_settings.xml in the App Group yet),
  pick an alphabet from Locale.current rather than always
  defaulting to 'English with limited punctuation'. Currently
  maps 'de' to the German alphabet; everything else stays English.
  Easy to extend as more alphabets are bundled.
- bridge.saveSettings() is called after the first-run alphabet
  selection so subsequent launches honour the user's later
  DasherApp choices regardless of system locale.

Diagnostics (kept; cheap, useful for future device bring-up)
- os_log breadcrumbs through DasherBridge.init, Realize(),
  setScreenSize, layout pass, first CADisplayLink tick, first
  draw, and canvas height changes. Subsystem filter in Console.app:
  'at.dasher.Dasher.keyboard'.

DasherCore bump: 5533eb9 -> 3ddedde2
Pulls in #20 ('IsFileWriteable: probe via permission bits, not
append-open') which removes the sandbox-deny storm the keyboard
generated against every bundled alphabet/colour XML during Realize.

Signed-off-by: will wade <willwade@gmail.com>
@willwade willwade merged commit 85eea26 into main Jun 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant