Why is a raven like a writing-desk?
It's like flutter but instead of dart, haskell!
Write native mobile apps in Haskell. This works similar to react native where we have tight bindings on the existing UI frameworks provided by android and IOS.
This project cross-compiles a Haskell library to Android (APK) and iOS (static library / IPA), with a thin platform-native UI layer (Kotlin for Android, Swift for iOS). There is support for android wear and wearOS as well, because I personally want to build apps for those. IOS and Android support was just a side effect.
Supports native:
- android
- android wearable
- IOS
- WearOS (IOS on wearables)
The library fully controls the UI. This is different from say Simplex chat where they call into the library to do Haskell from dirty java/swift code. This library should've written all swift/java code you'll ever need, so you can focus on your sweet Haskell.
Haskell is a fantastic language for UI.
Having strong type safety around callbacks and widgets
makes it a lot easier to write them.
I basically copied flutters' approach to encoding UI,
but in flutter it's a fair bit of guess work,
it becomes /very/ nice in Haskell however.
I've been many times annoyed at the garbage languages
they keep shoving into our face for UI.
With vibes in hand I put my malice
into crafting something good.
Flutter is already pretty good, but the syntax is complex,
and it has many inherited footguns from Java.
I think I made here what flutter wanted to be.
Please note this is /new/ software, I've encountered a fair few bugs while using it (and addressed them). I'd not throw it into production yet (unless you really hate java/swift with a passion), you can see my confidence by the version of the release. If it reaches a 1.0.0 I'm confident enough that I would use it in production.
Your app is a Haskell module with a main :: IO (Ptr AppContext).
You define a MobileApp record and pass it to startMobileApp:
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Data.IORef (newIORef, readIORef, modifyIORef')
import Data.Text qualified as Text
import Foreign.Ptr (Ptr)
import Hatter
( startMobileApp, MobileApp(..)
, loggingMobileContext
, newActionState, runActionM, createAction, Action
)
import Hatter.AppContext (AppContext)
import Hatter.Widget
main :: IO (Ptr AppContext)
main = do
actionState <- newActionState
counter <- newIORef (0 :: Int)
increment <- runActionM actionState $
createAction (modifyIORef' counter (+ 1))
startMobileApp MobileApp
{ maContext = loggingMobileContext
, maView = \_userState -> do
n <- readIORef counter
pure $ column
[ text $ "Count: " <> Text.pack (show n)
, button "+" increment
]
, maActionState = actionState
}maView is called on every render cycle and returns a Widget tree.
Button taps (and other events) fire Action handles created via runActionM,
then the framework re-renders automatically.
Requires Nix. The build cross-compiles your Haskell to a .so shared library
and packages it into an APK with the Java UI layer.
nix-build nix/apk.nixThis produces result/hatter.apk containing both arm64-v8a and armeabi-v7a architectures.
To build just the shared library for a single architecture:
nix-build nix/android.nix # aarch64 (default)
nix-build nix/android.nix --arg androidArch '"armv7a"' # armv7aadb install result/hatter.apkConsumer apps create their own nix files that import hatter's nix/lib.nix
builder functions. Pin hatter via npins
(or builtins.fetchGit) and write thin wrappers.
nix/android.nix — build the shared library:
{ sources ? import ../npins
, androidArch ? "aarch64"
, mainModule ? ../app/Main.hs
}:
let
hatterSrc = sources.hatter;
lib = import "${hatterSrc}/nix/lib.nix" { inherit sources androidArch; };
crossDeps = import "${hatterSrc}/nix/cross-deps.nix" {
inherit sources androidArch hatterSrc;
# Option A: point to your .cabal file (uses IFD to extract deps)
consumerCabalFile = ../your-app.cabal;
# Option B: inline cabal2nix function
# consumerCabal2Nix = { mkDerivation, base, text, aeson, lib }:
# mkDerivation {
# pname = "your-app"; version = "0.1.0.0";
# libraryHaskellDepends = [ base text aeson ];
# license = lib.licenses.mit;
# };
};
in
lib.mkAndroidLib {
inherit hatterSrc mainModule crossDeps;
pname = "your-app-android";
javaPackageName = "com.example.yourapp";
# GHC uses one-shot compilation by default; consumer modules need --make
extraGhcFlags = ["--make" "-no-link"];
extraModuleCopy = ''
# Remove hatter source files — hatter is pre-compiled in the package DB
rm -f Hatter.hs
rm -rf Hatter/
# Copy your app's modules
mkdir -p YourApp
cp ${../src/YourApp/App.hs} YourApp/App.hs
'';
extraLinkObjects = [
"$(pwd)/YourApp/App.o"
];
}nix/apk.nix — package into an APK:
{ sources ? import ../npins, androidArch ? "aarch64" }:
let
hatterSrc = sources.hatter;
abiDir = { aarch64 = "arm64-v8a"; armv7a = "armeabi-v7a"; }.${androidArch};
lib = import "${hatterSrc}/nix/lib.nix" { inherit sources androidArch; };
sharedLib = import ./android.nix { inherit sources androidArch; };
in
lib.mkApk {
sharedLibs = [{ lib = sharedLib; inherit abiDir; }];
androidSrc = ../android; # your AndroidManifest.xml + res/
baseJavaSrc = "${hatterSrc}/android/java"; # hatter's Java sources
apkName = "your-app.apk";
name = "your-app-apk";
}install.sh — build and install on a phone:
#!/usr/bin/env bash
set -euo pipefail
adb install "$(nix-build nix/apk.nix)/your-app.apk"install-wear.sh — build and install on a Wear OS watch (armv7a):
#!/usr/bin/env bash
set -euo pipefail
adb install "$(nix-build nix/apk.nix --argstr androidArch armv7a)/your-app.apk"Your android/ directory needs AndroidManifest.xml and res/ with your
app's name, icon, and theme. Your MainActivity just extends HatterActivity:
package com.example.yourapp;
import me.jappie.hatter.HatterActivity;
public class MainActivity extends HatterActivity {}The Java activity (HatterActivity) loads the .so via System.loadLibrary,
which triggers JNI_OnLoad in cbits/jni_bridge.c. That initializes the GHC RTS,
runs your Haskell main, and stores the returned AppContext pointer.
When onCreate fires, Java calls renderUI through JNI, which invokes your maView
and the framework translates the Widget tree into Android View calls.
You never need to write Java — HatterActivity handles all the native UI,
permissions, camera, location, etc.
Requires macOS with Nix. The build produces a static .a library that links into
an Xcode project via a Swift bridge.
nix-build nix/ios.nix # device
nix-build nix/ios.nix --arg simulator true # simulatorThis produces result/lib/libHatter.a and headers in result/include/.
The nix build stages an Xcode project with the pre-built library via mkSimulatorApp.
A setup script copies the (read-only) nix output to a writable directory and
generates the Xcode project:
#!/usr/bin/env bash
set -euo pipefail
TARGET="device"
[ "${1:-}" = "--simulator" ] && TARGET="simulator"
if [ "$TARGET" = "simulator" ]; then
result=$(nix-build nix/ios-app.nix) # your wrapper calling lib.mkSimulatorApp
else
result=$(nix-build nix/ios-device-app.nix)
fi
rm -rf ios-project
cp -r "$result/share/ios/." ios-project/
chmod -R u+w ios-project
cd ios-project
xcodegen generate
echo "Open ios-project/Hatter.xcodeproj in Xcode, then Product → Run."Configure signing in Xcode (team, bundle ID, provisioning profile), then build and run on a device or simulator.
Consumer apps create their own nix files, similar to Android.
nix/ios.nix:
{ sources ? import ../npins, simulator ? false, mainModule ? ../app/Main.hs }:
let
hatterSrc = sources.hatter;
lib = import "${hatterSrc}/nix/lib.nix" { inherit sources; };
iosDeps = import "${hatterSrc}/nix/ios-deps.nix" {
inherit sources;
consumerCabalFile = ../your-app.cabal;
};
in
lib.mkIOSLib {
inherit hatterSrc mainModule simulator;
pname = "your-app-ios";
crossDeps = iosDeps;
extraModuleCopy = ''
mkdir -p YourApp
cp ${../src/YourApp/App.hs} YourApp/App.hs
'';
}nix/ios-app.nix — stage the Xcode project (simulator):
{ sources ? import ../npins }:
let
hatterSrc = sources.hatter;
lib = import "${hatterSrc}/nix/lib.nix" { inherit sources; };
iosLib = import ./ios.nix { inherit sources; simulator = true; };
in
lib.mkSimulatorApp {
inherit iosLib;
iosSrc = "${hatterSrc}/ios";
name = "your-app-ios-simulator";
}nix/ios-device-app.nix — stage the Xcode project (device):
{ sources ? import ../npins }:
let
hatterSrc = sources.hatter;
lib = import "${hatterSrc}/nix/lib.nix" { inherit sources; };
iosLib = import ./ios.nix { inherit sources; simulator = false; };
in
lib.mkSimulatorApp {
inherit iosLib;
iosSrc = "${hatterSrc}/ios";
name = "your-app-ios-device";
}The Swift bridge (ios/Hatter/HaskellBridge.swift) calls hs_init and
haskellRunMain to boot the GHC RTS and run your Haskell main.
It then sets up all the platform bridges (permissions, camera, location, etc.)
and calls haskellRenderUI when SwiftUI requests a view update.
The bridging header (Hatter-Bridging-Header.h) exposes the C FFI functions
to Swift. The project.yml links against the required system frameworks
(CoreLocation, CoreBluetooth, AVFoundation, WebKit, etc.).
nix-build nix/watchos.nixWorks the same as iOS — produces a static library for watchOS.
The watchos/ directory contains the WatchKit app structure.
For fast iteration, build and test natively:
nix-shell
cabal build all
cabal test allThe desktop build uses stub C bridges that simulate platform responses (e.g. permissions always granted, location returns fixed coordinates). This lets you develop and test your app logic without a device.
Always make sure to include tests. If we deal with platform integration or add native code we need tests in the simulator / emulator as well to ensure new builds don't crash.
Sometimes we're able to make some rudmentary tests on screen as well.
In general we can assume if something doesn't have tests it may as well not exist.
Please find or make issues about integration requests. I can prioritize adding these first. The real time sink for these is usually testing out if the integration works. Animations for example required several iterations, whereas HTTP worked on first try.
The claudes should be able to mostly implement this, especially if you use vibes.
I think you can implement this by hand as well but I find it way to tedious.
