When the App Store Deletes Your Muscle Memory: Building an Open Source Keyboard
When the App Store Deletes Your Muscle Memory: Building an Open Source Keyboard
There is a specific, quiet kind of panic reserved for the moment you realize a tool you rely on has ceased to exist. I am not talking about a library deprecation warning where you have six months to migrate. I am talking about waking up, reaching for your phone to fire off a Slack message, and realizing your fingers are dancing on a ghost.
For years, I used a third-party keyboard on iOS called "dev keyboard." It used the "Moaki" input method, a gesture-based system popular in Korea where you tap a consonant and drag for the vowel. It is fast. Unreasonably fast. I have the typing speed of a teenage influencer on caffeine when I use it.
Then, about a month ago, it vanished. Pulled from the App Store. Gone.
Suddenly, I was back to the stock QWERTY layout, pecking away like a pigeon at a bread crust. My productivity on mobile did not just dip. It plummeted. I had committed the cardinal sin of systems architecture: I introduced a single point of failure into my own nervous system.
I could not fix the app because I did not own the code. So, I did what any reasonable, slightly obsessed engineer would do. I spent three weeks building my own, and I made it open source so nobody can ever take it away from me again.
The System of Input
Most of us think of keyboards as static grids. You press 'A', you get 'A'. But the Moaki system, and gesture typing in general, is a state machine. It is a vector processing problem wrapped in a UI layer.
The core interaction is not a tap. It is a vector.
- Touch Start: Register the base key (the consonant).
- Touch Move: Track the delta (dx, dy).
- Touch End: Resolve the vector to a specific vowel or character variant.
When you rely on proprietary software for this logic, you are renting your own hands. If the landlord decides to sell the building (or the developer stops paying the Apple Developer fee), you are out on the street.
The Logic: Vector Resolution
While the actual implementation for iOS is in Swift, the logic is universal. As a system thinker, I prototype logic in TypeScript before wrestling with Xcode. It clarifies the state transitions without the noise of memory management or UI constraints.
Here is how I modeled the gesture resolver. We need to translate a drag distance into a discrete directional intent (Up, Down, Left, Right, or Stay).
Defining the Vectors
First, we define our directions and a threshold. Mobile screens are noisy. You need a "dead zone" so a jittery thumb does not trigger an accidental swipe.
type Direction = 'CENTER' | 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_RIGHT' | 'UP_LEFT' | 'DOWN_RIGHT' | 'DOWN_LEFT';
interface Point {
x: number;
y: number;
}
interface GestureConfig {
sensitivity: number; // Minimum pixels to trigger a drag
diagonalThreshold: number; // Angle tolerance for diagonals
}
const DEFAULT_CONFIG: GestureConfig = {
sensitivity: 15,
diagonalThreshold: 0.5 // roughly 30 degrees
};The Resolver Core
The math is not complex, but it has to be robust. We calculate the angle using Math.atan2 and the distance using the hypotenuse.
class VectorResolver {
private startPoint: Point | null = null;
private config: GestureConfig;
constructor(config: GestureConfig = DEFAULT_CONFIG) {
this.config = config;
}
public start(x: number, y: number): void {
this.startPoint = { x, y };
}
public resolve(currentX: number, currentY: number): Direction {
if (!this.startPoint) return 'CENTER';
const dx = currentX - this.startPoint.x;
const dy = currentY - this.startPoint.y;
// 1. Check distance threshold (Dead zone)
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.config.sensitivity) {
return 'CENTER';
}
// 2. Calculate Angle (radians)
// Math.atan2 returns -PI to PI.
// 0 is Right, -PI/2 is Up, PI/2 is Down, PI/-PI is Left
const angle = Math.atan2(dy, dx);
// 3. Map angle to discrete direction
return this.mapAngleToDirection(angle);
}
private mapAngleToDirection(angle: number): Direction {
// Normalize angle to degrees for easier mental mapping
const degrees = angle * (180 / Math.PI);
if (degrees >= -22.5 && degrees < 22.5) return 'RIGHT';
if (degrees >= 22.5 && degrees < 67.5) return 'DOWN_RIGHT';
if (degrees >= 67.5 && degrees < 112.5) return 'DOWN';
if (degrees >= 112.5 && degrees < 157.5) return 'DOWN_LEFT';
if (degrees >= -67.5 && degrees < -22.5) return 'UP_RIGHT';
if (degrees >= -112.5 && degrees < -67.5) return 'UP';
if (degrees >= -157.5 && degrees < -112.5) return 'UP_LEFT';
return 'LEFT';
}
}This logic is the heartbeat of the keyboard. In the Swift version, this resolve function runs on every touchMoved event. If the user drags their finger right, the key 'ㄱ' (g) might morph into '가' (ga). If they drag down, it becomes '거' (geo).
State Management: The Input Buffer
Keyboards are deceptively hard because they manage a volatile state: the "composing" text. When you type in English, it is mostly linear (append char). In Korean (Hangul) or gesture systems, you are constantly mutating the last character.
Here is how we handle the composition state. We do not just append strings. We maintain a buffer of the current syllable construction.
type HangulState = {
initial: string | null; // Chosung
medial: string | null; // Jungsung
final: string | null; // Jongsung
};
class InputEngine {
private state: HangulState = { initial: null, medial: null, final: null };
private history: string[] = [];
public handleGesture(baseKey: string, direction: Direction): void {
const mappedChar = this.getCharFromLayout(baseKey, direction);
if (this.isConsonant(mappedChar)) {
this.handleConsonant(mappedChar);
} else {
this.handleVowel(mappedChar);
}
}
private handleConsonant(char: string) {
// If we already have a full syllable, commit it and start new
if (this.state.final) {
this.commit();
this.state.initial = char;
}
// If we have initial + medial, this might be the final consonant (Jongsung)
else if (this.state.initial && this.state.medial) {
this.state.final = char;
}
// Start of new block
else {
this.commit(); // Clear any partials
this.state.initial = char;
}
this.updatePreview();
}
// ... connection to UI rendering
}This InputEngine is the bridge between the raw vector data and the screen. It decides whether the user is adding to the current letter or starting a new one. In the iOS implementation, this hooks directly into UIInputViewController.
The Friction of Distribution
The hardest part was not the code. It was the ecosystem.
Building an iOS keyboard extension requires navigating a maze of entitlements and constraints. You have memory limits (extensions crash if they consume more than about 50MB). You have to handle "Full Access" requests if you want haptic feedback.
But the beauty of open sourcing this logic is that the friction is now documented.
I published the code on GitHub not as a portfolio piece, but as an insurance policy. If I get hit by a bus, or more likely, simply lose interest and stop paying my Apple Developer fees, anyone can fork the repo, build it in Xcode, and install it on their device.
Conclusion
We live in an era of rental software. We rent our music, our servers, and apparently, even the input methods we use to communicate.
When "dev keyboard" disappeared, I felt helpless. But helplessness is a choice. The code I wrote over the last three weeks is not polished to a mirror shine. It has bugs. The haptics are a bit aggressive. But it works.
More importantly, it is mine. And now, it is yours too.
If you rely on a niche tool, check its license today. If it is closed source, you are one policy update away from losing your hands. Build your own tools. It is a pain, but the sleep you get knowing your workflow is safe? That is worth every line of code.