Skip to content

About

Build iOS .app bundles from Common Lisp with ASDF and ECL. No Xcode project.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

72 Commits

Folders and files

Repository files navigation

asdf-ios-app

An ASDF extension that builds an iOS .app bundle from an ECL image. No Xcode project, no xcodebuild: the operation drives clang, assembles the bundle and signs it.

(defsystem "attractor"
  :defsystem-depends-on ("asdf-ios-app")
  :class :ios-app-system
  :build-operation "ios-app-op"
  :entry-point "attractor:start"
  :version "1.0.0"
  :bundle-identifier "com.example.attractor"
  :bundle-name "Attractor"
  :bundle-platforms (:simulator :device)
  :depends-on ("alexandria")
  :components ((:file "attractor")))
$ ecl --eval '(asdf:make "attractor")' --eval '(quit)'
; cross-compiling attractor for iphonesimulator
; built /path/to/build/iphonesimulator/Attractor.app/

Your Lisp is cross-compiled to native arm64 and linked in. It is not frozen by that: defun, defclass and defmethod all work at run time, because the boot installs the bytecodes compiler.

examples/attractor-aot/ is a UIView whose drawRect: is a Lisp function, drawing 300,000 points of a de Jong attractor; examples/attractor-dynamic/ is the same figure with no C in the app at all. examples/hello/ is the least you can write. There are more below.

The examples

hello repl browser
A centred label reading ECL 26.5.5 on arm64, then the result of an expression evaluated at run time, then a line loaded from interpreted source. A transcript of evaluated forms above a row of buttons and a text field. A table view listing Lisp packages with their symbol counts and nicknames.
chart layers physics
A sine curve drawn as SVG in a web view, with a table of details below it. A ten-petalled rose curve stroking itself onto a gradient, with a glowing dot at the tip of the line. Coloured squares and circles falling and settling into a heap at the bottom of a dark screen.
attractor-aot attractor-dynamic abi-probe
A de Jong strange attractor in pale blue on black, filling the screen. The same attractor, rendered from a Lisp array into one image, with no C in the app. A dense monospaced report comparing si:call-cfun against the C compiler.
live swift model
A grid of coloured dots on a dark canvas under a caption, with a status line saying slynk is listening and a tap button. A SwiftUI bar chart of prime counts in eight colours, above it three monospaced lines of CryptoKit results, below it a New data button and a line saying the on-device language model is available. A transcript: three questions about large numbers, under each the Lisp tool call the model made and its exact result, then the model's answer; below, the three questions as buttons and a field for another.
surface scene opener
A lit, coloured 3D surface of concentric ripples on a grid of axes, under a title saying the surface is a Lisp function, above a caption counting how many times Chart3D has called into Lisp and a row of buttons naming other surfaces. A log of scene lifecycle events with times, from will-connect through did-enter-background and back to did-become-active, under a line counting returns to the foreground. Three lisp:// URLs the app received, each followed by the form evaluated and its result or the text shown, and buttons that open each link.
ledger settings fetch
A table of ledger entries with their number, time and hex fingerprint, under a text field and an Add button. A ring of coloured circles with a SwiftUI form sheet over the lower half, with sliders, a picker and a toggle. A table of GitHub repositories with language, stars and last push, under a status line naming the session thread the reply arrived on.
notify mosaic sensors
A log of authorisation, scheduling and presentation of a notification, with buttons to schedule another. A grid of coloured tiles of different sizes, each a package name and its symbol count. A grey disc with a blue bubble, a reading line, and buttons for a haptic tap and Face ID.
ink photos agenda
A drawing canvas holding an indigo spiral, under it a line with the drawing's bounds and byte count, then a small rendering of the same spiral and buttons for Spiral, Clear and Save. The system photo picker over the app, showing the simulator's sample photos with a pattern of coloured interference rings among them, the picture Lisp drew and added. A monospaced listing: THIS WEEK with two events and their times, then CONTACTS grouped under initials; above it a count of events and contacts, below a button to add a meeting.
map editor traits
A map with a pink figure-of-eight route drawn around a pin, under a line giving the route's point count and the coordinates. A text view of Lisp source with parentheses coloured by depth, strings in red, comments in grey and keywords in purple, a matching pair of parentheses highlighted in yellow, and below it the result of evaluating the buffer. Ten coloured tiles in five columns on a light background, under a line reading light, text L, compact width, 3x, 0 changes.
compute report keychain
A Julia set fractal in yellow and purple on blue filling a square, under it a line naming the parameter, the size and the milliseconds the GPU took. A Quick Look preview of a one-page PDF titled The image, as a report, with a bar chart of package symbol counts in eight colours and a footer line. A twelve-character token in monospace and the line launch 5 of this install, under it a status line saying it was read back from the Keychain, and buttons to read again or forget it.
serve peers coredata
A status line reading listener ready on port 8080 and three requests served, over a log of request lines with times. A log on one phone: advertising, found the other peer and invited it, connected, then a form sent and the other phone's answer, and a form the other phone asked with this phone's answer; a field holding a form and an Ask peers button. A table of notes each with its word count and time, under a text field and an Add button, and a status line counting the notes and how many a fetch with a predicate matched.
sprites coreml closure-probe
A dark scene with four grey circular obstacles, a faint blue path threading between them, a gold leader square on the path and a flock of coloured squares following it. A monospaced table with a header row, base, height, model, exact and off by, and six rows of triangle dimensions with the model's predicted area beside the exact area and the difference, under a line naming the model's inputs and outputs. A monospaced report: five numbered checks of a libffi closure, each marked correct, and the line a libffi closure works on this device.
favourites exceptions
On a phone, in dark mode: a table of songs, each with its artist, album, length and play count, under a status line saying 488 songs from the Favorite Songs playlist, first 200 listed, and above a Reload button. On a phone, in dark mode: four lines of text, an NSRangeException with Cocoa's reason for it, an NSInvalidArgumentException from an unrecognized selector, an NSError from a missing file with NSCocoaErrorDomain 260, and the line the next send works: T.

Every one of these but the last two is a screenshot of the simulator, from a clean install of a build made by asdf:make — there is no Xcode project anywhere in the repository. The last two are the phone itself, captured with devicectl: favourites because a simulator has no music library, and exceptions because a static device image with libffi callbacks and no C compiler is the build in which catching an Objective-C exception was least certain to work. The seventeen from sensors onwards have all run on an iPhone 16e, built with the signing environment set and judged by the console each app keeps in its own container — see Deploying. All but four of them are written against objc, the LispWorks-compatible Objective-C interface, and its objc/uikit conveniences — see below.

what it is what it shows
hello a label the least that builds
repl a REPL with a keyboard a UITextFieldDelegate written in Lisp, and keyboardLayoutGuide instead of a CGRect
browser the running image, as a table a UITableViewDataSource written in Lisp — packages, symbols, and what a symbol is
chart SVG in a WKWebView a framework beyond UIKit, a bundled resource, and state that survives a relaunch
layers a rose curve drawing itself Core Animation — a CGPath computed in Lisp, stroked by a CAShapeLayer, with a dot riding the tip
physics shapes falling into a heap UIKit Dynamics — gravity, collision, elasticity and rotation; a CGRect in and a CGPoint out, by value, with no C
attractor-aot a strange attractor you can drag drawRect: in Lisp, gestures, a C trampoline compiled ahead of time and kept on purpose, and redefining the mathematics over SLY
attractor-dynamic the same attractor, with no C at all drawRect: as a Lisp method taking its CGRect by value, the frame rendered into a Lisp array and shown as one CGImage, and the gestures reaching closures
swift a SwiftUI bar chart, CryptoKit results and a button frameworks with no Objective-C surface — CryptoKit, Swift Charts, FoundationModels — reached through a hundred lines of @objc Swift shipped as an embedded framework; the chart is a UIHostingController child, the data is Lisp's
surface a lit, rotatable 3D surface and a row of buttons Swift Charts 3D, new in iOS 26: Chart3D samples z = f(x, y) by calling a block made from a Lisp lambda, thousands of times per mesh; each button hands it a different lambda
scene a log of lifecycle events an application delegate of the app's own, scene based, through :bundle-app-delegate and :bundle-objc-sources, with every UIScene event reported to Lisp; an icon through :bundle-icon
opener a log of URLs received :bundle-url-schemes and :bundle-document-types: lisp:// links and .lisp files open the app and land in Lisp, through the delegate's URL entry points
ledger a table of entries and a field SQLite through :bundle-link-flags, a C function of the app's own through :bundle-static-libraries, both called by name through the dynamic FFI; the ledger persists across launches
settings a pattern of marks and a SwiftUI sheet the other direction: a SwiftUI Form edits Lisp variables through one block, and Lisp redraws
fetch a table of repositories NSURLSession from Lisp, the completion handler a Lisp closure called on a session thread, JSON walked in Lisp
notify a log and three buttons UNUserNotificationCenter: authorisation through a block, a notification scheduled from Lisp, a delegate in Lisp that presents it by calling UIKit's block
mosaic a mosaic of coloured tiles a collection view whose flow-layout delegate is a Lisp class returning CGSize and UIEdgeInsets by value, the tiles the packages of the running image
sensors a bubble level and two buttons the accelerometer, haptics and Face ID, three things only a device has, each an Objective-C object calling a Lisp block from its own thread; builds for the device too
model a transcript, three questions as buttons, a field the on-device language model of iOS 26 answering questions about large numbers by calling tools that are Lisp closures — (expt 2 200) evaluated exactly in the image, the model reading the digits back; the model's own thread arrives in a block made from a lambda
live a canvas, a caption and a button programming the phone from Emacs over the USB cable: SLY connected to the app on a device, and every function on the screen redefined without a rebuild — see examples/live/ and its tour.lisp
abi-probe a report, not an interface exactly which structs si:call-cfun can carry, measured
closure-probe a report, not an interface what ECL's dynamic FFI can do on a phone, measured on an iPhone 16e: a libffi closure, a CGRect in and an NSRange out through one, and a variadic call
ink a drawing canvas and three buttons PencilKit: a PKCanvasView whose delegate is a Lisp class, the drawing's bounds read as a CGRect, the strokes rendered to an image by Lisp's request and the data kept across launches
photos the system photo picker over a rendered image PhotoKit: PHPickerViewController with a Lisp delegate, add-only authorisation asked for through a block, and an image drawn pixel by pixel in Lisp saved into the library through a change block
agenda this week's events and the contacts, grouped EventKit and Contacts: access asked for through blocks, an event created and the week fetched with a predicate, contacts enumerated through a block and grouped by initial in Lisp
map a map with a route around the phone MapKit and CoreLocation: the location delegate and the overlay renderer delegate are Lisp classes, the route a lemniscate computed in Lisp into a C array, CLLocationCoordinate2D read by value as a vector and MKCoordinateRegion passed as a nested one
editor a Lisp editor TextKit: a UITextView re-coloured on every keystroke by a Lisp tokenizer through NSAttributedString, the parenthesis under the cursor matched, and the buffer evaluated in the image it runs in
traits coloured tiles that re-lay themselves the trait collection: dark mode, text size, size class and scale read in Lisp, and registerForTraitChanges:withHandler: given a Lisp block that re-lays the screen when the system flips them
compute a Julia set Metal: a compute kernel compiled from a string at run time, its parameters and buffer from Lisp, MTLSize threadgroups passed as vectors, and the output buffer turned into a CGImage
report a PDF, previewed UIGraphicsPDFRenderer drawing a bar chart of the image inside its actions block, which is a Lisp closure; Quick Look previewing it through a Lisp data source; the share sheet offering it
keychain a token and a launch count the Security framework's C API — SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete — with their CFDictionary queries built from Foundation objects; the entitlements the simulator's keychain wants, linked into the binary
serve a status line and a request log Network.framework: an nw_listener whose connection, receive and send handlers are Lisp blocks on a dispatch queue, serving a page about the image to any browser and advertised over Bonjour
peers a log of the other phone's forms MultipeerConnectivity: each phone advertises and browses, the invitation is answered by calling the block the framework handed over, and connected peers evaluate each other's forms
coredata a table of notes Core Data with the model described in code: an entity and its attributes as objects, a persistent container loaded through a block, notes inserted, saved and fetched with a predicate and a sort
sprites a flock threading obstacles SpriteKit and GameplayKit: agents steered by goals and weights chosen in Lisp, the scene's per-frame update a Lisp method, and a Lisp-built graph pathfound by GameplayKit
coreml a table of predictions beside exact answers Core ML: a model trained by build.sh with Create ML and compiled by coremlc, shipped as a resource, loaded, and asked for predictions through feature providers built from Lisp numbers
favourites the songs favourited in Music MediaPlayer: the library's authorisation asked for through a block, the Favorite Songs playlist found among the playlists with MPMediaQuery, each song read property by property through valueForProperty:, and a table whose data source is a Lisp class; the phone's own screenshot, since a simulator has no library
exceptions three failures, caught objc's exception bridging on a device: an NSRangeException and an NSInvalidArgumentException raised inside sends and caught as objc:objc-exception with Cocoa's own reasons, an NSError from objc:invoke-with-error, and a send afterwards to show the app is whole; the phone's own screenshot, since the static image is the build that mattered

Build any of them with asdf:make, with examples/ on your source registry:

(asdf:make "browser")
(ios-app:run-in-simulator "browser")

All but one need no C compiler at build time and no :bundle-trampolines. A message send is objc:invoke, a class whose methods are Lisp is objc:define-objc-class, and structures go by value in both directions — a UITableViewDataSource, a CGRect to -initWithFrame:, a CGPoint back from -locationInView: — all through ECL's dynamic FFI, on the phone, with nothing compiled by a C compiler. That takes the ECL that bootstrap-ecl builds; see The ECL it builds.

attractor-aot is the one that keeps a trampoline file, and it keeps it by choice rather than necessity: its drawRect: calls CoreGraphics two million times a frame, which is a reasonable thing to have in C. attractor-dynamic is the same figure without it, and shows the other way to do a hot loop: not two million foreign calls from Lisp, but the frame rendered into a Lisp array and handed to CoreGraphics as one image. The two are meant to be read side by side.

objc and objc/uikit

The examples take objc as a dependency: put its directory and its ocicl/ tree on your source registry beside examples/. objc is the whole LispWorks Objective-C interface — invoke, define-objc-class, define-objc-method, blocks from Lisp closures — and it runs on ECL on a phone exactly as it does on a Mac.

objc/uikit is the dozen things every UIKit screen does, as a package UIKIT: a view with autoresizing translation off, a system button, the root view, colours and fonts, anchors pinned and fixed, and a Lisp function behind a control, a gesture recognizer or a timer.

(objc:invoke label "setText:" "hello")
(ui:pin label "centerXAnchor" view "centerXAnchor")
(ui:on-tap (ui:system-button "again") (lambda (sender) (restart-animations)))

(objc:define-objc-method ("tableView:numberOfRowsInSection:" (:signed :long-long))
    ((self table-source) (table objc:objc-object-pointer) (section (:signed :long-long)))
  (length (rows)))

It works compiled and interpreted alike, so an interface can be built a form at a time at a remote REPL; and because the dynamic FFI now carries a structure, -bounds, -frame and -setFrame: are ordinary sends.

There used to be an objc-lite here — three hundred lines of si:call-cfun over objc_msgSend, with the rule that no message may take or return a struct by value, because ECL's foreign type table could not name one. That rule came from the ECL of the time and not from the platform, and once the table was opened there was no reason to keep a second, smaller bridge. hello still open-codes the six lines it needs, so that the minimal example stays minimal.

Getting a toolchain

An iOS build needs an ECL cross-built for the target, and a host ECL from the same source tree. One command produces all of it:

(asdf-ios-app:bootstrap-ecl)                        ; clone, patch, build
(asdf-ios-app:bootstrap-ecl :source #p"~/src/ecl/") ; use a tree you have

About ten minutes from nothing. It records what it built in ~/.cache/asdf-ios-app/toolchain.sexp, so nothing needs configuring afterwards, and re-running rebuilds nothing. tools/build-ecl-ios.sh is the same recipe as a shell script, for when you would rather not run a Lisp to get a Lisp.

The host ECL is not a convenience. The cross build reuses its dpp and ecl_min, and dpp resolves each @[pkg::sym] in ECL's C sources to a numeric index into symbols_list.h — so a host ECL from another revision does not fail, it silently resolves every symbol to the wrong index. bootstrap-ecl builds a matched set and check-ecl-prefix refuses a mismatch.

The ECL it builds

bootstrap-ecl clones lispnik/ecl at develop — upstream develop plus fixes that are not upstream yet, each also on its own branch there for sending on:

  1. ecl_library_symbol calls dlsym(0, symbol) for the :default module. On Darwin a null handle is not the global scope — RTLD_DEFAULT is (void *)-2 — so it always returned NULL, and CFFI, whose ECL backend resolves foreign functions by name, could not work at all.
  2. ffi:callback returned a libffi closure's writable record rather than its entry point. The two coincide only where memory may be both; on iOS and arm64 macOS they are mapped apart, and calling the record jumped into the heap. examples/closure-probe/ measures this on a phone.
  3. si:call-cfun passes and returns structures by value, so a CGRect no longer has to be smuggled through as scalars and hoped about.

One more is a build matter rather than a patch: configure.ac ties ENABLE_DLOPEN to --enable-shared, and an iOS app must link statically while still being able to dlsym, so the cross build defines it anyway. That one is detectable from a built prefix and is checked.

tools/patches/ still holds the RTLD_DEFAULT change as a patch, applied only if the tree lacks it, so a build from upstream develop works too.

Three decisions that shape the design

There is no image dump. ECL has no save-lisp-and-die, and iOS would not run the result. The system is cross-compiled to a static library and linked with libecl.a, so the linker is the install step — there is no core, and no symlink reconciling a runtime with it.

A callback must be compiled, not allocated. si::make-dynamic-callback — the libffi-closure path behind ffi:defcallback — kills the process on iOS, silently and uncatchably, because ffi_closure_alloc needs writable-then-executable memory. Compiled ahead of time with ffi::*use-dffi* bound to NIL, defcallback emits an ordinary C function instead. That is why the whole design is ahead-of-time.

A simulator build is signed with no entitlements at all. iOS validates a binary's entitlements against its provisioning profile, and a simulator app has none — so an ad-hoc signature carrying get-task-allow is refused at launch:

The request to open "org.example.app" failed.
The request was denied by service delegate (SBMainWorkspace).

which says nothing about entitlements and sends you to Info.plist. An Xcode-built simulator app carries none in its signature either. Device builds get theirs from the profile, which is the only authority on what they may be.

What a simulator build does carry is its entitlements linked into the binary, as a __TEXT,__entitlements section, which is what Xcode's -Simulated.xcent file is for. The simulator's securityd reads the application-identifier and keychain-access-groups there, and without them every Keychain call fails with errSecMissingEntitlement, -34018; SpringBoard checks only the signature and never sees them. :entitlements :ios-default derives the pair from the bundle identifier, a file is embedded as it is, and nil embeds nothing.

Layout produced

An iOS bundle is flat. There is no Contents/.

build/iphonesimulator/Attractor.app/
  attractor                  the linked executable
  Info.plist   PkgInfo   _CodeSignature/
  embedded.mobileprovision   device builds only
  lisp/                      only with :bundle-interpreted
  <resources at the top level>

Which means a resource called Info.plist does not land somewhere harmless — it lands on the Info.plist. Reserved names are refused.

Options

Option Default
:bundle-identifier — required
:bundle-name capitalised system name CFBundleName, and the .app name
:bundle-executable downcased system name
:bundle-display-name bundle name
:bundle-short-version :version
:bundle-platforms (:simulator) any of :simulator :device
:bundle-minimum-os-version "15.0" MinimumOSVersion, not LSMinimumSystemVersion
:bundle-device-family (:iphone :ipad)
:bundle-orientations (:portrait)
:bundle-ipad-orientations — written only if given
:bundle-launch-screen t an empty UILaunchScreen; without it iOS letterboxes the app
:bundle-required-capabilities ("arm64")
:bundle-status-bar-hidden nil
:bundle-url-schemes, :bundle-document-types, :bundle-category, :bundle-copyright — a URL or document the system hands the app goes to ios-app-runtime:*open-url-hook*, a function of the URL as a string, set in the entry point; a file is readable only until it returns
:bundle-info-plist — alist merged over the generated plist
:bundle-resources — paths, or (path . "destination")
:bundle-interpreted — systems shipped as source; see below
:bundle-trampolines — files compiled for the target only; see below
:bundle-ecl-modules — e.g. ("sockets"); asdf is added when needed
:bundle-frameworks ("UIKit" "Foundation" "CoreGraphics")
:bundle-static-libraries — .a files to link, force-loaded so Lisp can call every function by name; ~a in a path is the platform name
:bundle-link-flags, :bundle-objc-flags —
:bundle-embedded-frameworks — .framework directories to ship in Frameworks/, link, and sign; ~a in a path is the platform name
:bundle-objc-sources — your own .m, compiled after ours
:bundle-objc-main — replaces ECLMain.m
:bundle-app-delegate "ECLAppDelegate" scene based; a delegate of your own brings its own scene manifest, or none
:bundle-output-directory <system>/build/
:bundle-icon — a .xcassets directory, compiled by actool
:remote-repl nil t, a port, or a plist; see below
:code-signing-identity :automatic ad hoc on simulator; required on device
:development-team, :provisioning-profile — device
:entitlements :ios-default linked into the binary on simulator, from the profile in the signature on device
:get-task-allow t lets a debugger attach; a distribution build must not

:entry-point is stock ASDF, and means something different here. It is not a toplevel that runs to completion: it is called once, on the main thread, from the scene delegate's -scene:willConnectToSession:options:, and it must return so the run loop can go on.

The shipped delegate is scene based. ECLAppDelegate names ECLSceneDelegate and does nothing else; the scene delegate makes the window, boots the image, calls the entry point, and puts a failed one on screen. The generated Info.plist carries the UIApplicationSceneManifest that points UIKit at it, and only then: an app that sets :bundle-app-delegate decides about scenes itself and supplies its own manifest through :bundle-info-plist, as examples/scene does.

Embedding a framework

A framework of your own goes in with :bundle-embedded-frameworks. It is copied into the bundle's Frameworks/, the executable is linked against it with an rpath of @executable_path/Frameworks, and it is signed with the app's identity before the app is. A framework is built per platform, so an entry is a format control string whose ~a becomes iphonesimulator or iphoneos:

:bundle-embedded-frameworks ("build/~a/LispSwift.framework")

The framework must call itself @rpath/<Name>.framework/<Name>; the build refuses one that does not, naming the linker flag that fixes it, because the alternative is an app that installs and dies at launch with dyld's "Library not loaded". This is how Swift gets in: a framework with no Objective-C surface is reached through a few @objc Swift methods, and that Swift ships as a framework — see examples/swift/. iOS has carried the Swift runtime since 12.2, so nothing else needs to travel with it.

Ahead of time does not mean frozen

This is the point most likely to be got wrong. Compiling ahead of time is about how code ships, not whether the image is alive. On the device, with a fully compiled system:

  • defun, defvar, defclass, defmethod at run time all work — the boot installs the bytecodes compiler, so COMPILE produces bytecode rather than failing.
  • Redefining a compiled function works. The new definition is bytecode and replaces the fdefinition; the native one is simply no longer called.
  • UIKit can be driven from Lisp: objc_msgSend through si:call-cfun needs no compiler.

What ahead-of-time buys, and nothing else can: native speed, and ffi:defcallback.

:bundle-interpreted names systems to ship as source, loaded at boot from a manifest that carries dependency order into an image with no ASDF. The intended shape is a compiled core with an editable skin:

:depends-on ("my-app-scripts")
:bundle-interpreted ("my-app-scripts")

Edit a file in MyApp.app/lisp/, reinstall, and the behaviour changes with no compiler, no relink and no re-sign.

One caution: compiled modules are initialised before any bundled source is loaded, so compiled code must not touch an interpreted package at load time. A reference deferred to run time is fine, and is what examples/hello does. The build says so when the shape arises; it cannot decide it for you.

Trampolines, and the ABI

ffi:c-inline is normally useless on iOS because it needs a C compiler. Cross compiling means there is one, at build time — and then the C compiler implements the ABI, so the things ECL's dynamic FFI cannot express come free:

;; NSRange, returned BY VALUE
(range-of-string haystack needle)  ; => (6 . 5)
;; CGRect, a 32-byte homogeneous float aggregate in v0-v3
(cgrect-inset-area 0d0 0d0 10d0 10d0)  ; => 48.0d0

Files named by :bundle-trampolines are compiled for the target only, never on the host. That exemption is the whole feature. Ordinary sources are compiled twice — natively in the child, so the cross compiler has their macros, and then for iOS — and c-inline survives neither half: it cannot be interpreted, and compiling it natively makes ECL build and link a host fasl, which fails the moment the C mentions CoreGraphics or objc_msgSend.

Why a trampoline, and not just more si:call-cfun

Less reason than there was. ECL's foreign type table (src/c/ffi.d, ecl_foreign_type_table) was a closed enum of scalars ending at ECL_FFI_VOID, with no struct, union or array member and no way to add one, so si:call-cfun had no way to say CGRect. The ECL that bootstrap-ecl now builds takes (:struct …) designators and lets libffi classify them, and a CGRect goes by value in either direction with nothing compiled. What still needs c-inline is C that is not a call — a drawRect: body, a CGContextRef — and a variadic send.

The workaround the old table forced was to decompose the struct into the scalars it is made of. examples/abi-probe/ measures when that is right, against ground truth produced by the C compiler in the same binary calling the same functions, and the answer is why it was never safe to keep. On arm64:

as an argument as a return value
NSRange — 2 ints, 16 bytes works (x0, x1) only the first field
CGRect — 4 doubles, an HFA works (v0–v3) only origin.x
long + double, 16 bytes wrong — both halves go in general registers, so the double is expected in x1 only the first field
CGAffineTransform — 6 doubles wrong: not an HFA and over 16 bytes, so it is passed by pointer wrong

So decomposition works exactly when AAPCS64 happens to put the fields where that many separate scalars would have gone, and never on the way back — which is the direction that matters, because -bounds and -frame are how you ask a view anything.

None of the failures is a Lisp error. They are wrong numbers. The two indirect cases are worse than wrong: the callee reads 48 bytes through a register the caller never set, and for the return value writes 48 bytes through one. Neither faulted when measured, which is luck rather than safety — and the value read back changed between launches of the same binary (-48200.0d0, then 2.32e-318). There is no handler to write and nothing to test for at run time.

$ xcrun simctl launch <device> org.asdf-ios-app.abi-probe
$ SIMCTL_CHILD_ABI_PROBE_CASE=take_hfa6 xcrun simctl launch <device> org.asdf-ios-app.abi-probe

The report is written to the app's Documents/abi-report.txt and shown on screen. Each indirect case gets its own launch, and every line is flushed, so a process that does fault still leaves its evidence.

Three things to know when writing one

  • The C must be plain C, not Objective-C. A trampoline is a cast of objc_msgSend to one concrete prototype, which is C anyway.
  • No @ may appear in a c-inline body, because ECL reads it as the start of its own @(return) syntax. That rules out @"literals" and Objective-C type encodings; pass an encoding in as a :cstring argument instead.
  • Declare the package in an ordinary component. A trampoline file's defpackage never runs on the host, and ordinary sources — which are read there — cannot then read a symbol in it.

A REPL on the phone

:depends-on ("slynk")
:remote-repl t                      ; or 9999, or (:port 4005 :interface nil)

That starts a slynk server at boot -- before the entry point, because an entry point that signals is exactly when you most want a way in, and a REPL that came up afterwards would leave you rebuilding to find out why.

Slynk anywhere in the closure links sockets, sb-bsd-sockets and cmp, whether or not :remote-repl is on: slynk loads either way, and its (require 'sockets) with no linked module is an error inside module initialisation, where nothing catches it. A module that fails to initialise ends the app with an uncaught ECLBootModuleInitFailed exception whose reason names the module and the condition -- in the crash report and on the console -- rather than a bare segmentation fault in ecl_unwind, which is what it was.

Slynk is not a dependency of this extension: which REPL server you want is yours to say. It is also not on Quicklisp under that name, so put sly's slynk/ directory on your source registry. A :remote-repl build whose closure has no slynk in it is refused at build time rather than at boot.

Connecting: the simulator shares the Mac's loopback, so M-x sly-connect to localhost 4005 just works. A device needs a forwarder -- brew install libimobiledevice, then iproxy 4005:4005 -- and examples/live/ is built around exactly that: its iphone.el is one Emacs command that starts the forwarder and connects, and its tour.lisp is ten forms to send to the phone. Measured on an iPhone 16e: the server is up before the entry point, the entry point runs, and the whole tour runs through the cable.

The live example's tour, form by form, with each form captioned under the screen.

That is the tour, recorded on the simulator, each form captioned as it is sent; doc/videos/live-tour.mp4 is the full-rate version.

Everything that touches UIKit goes through on-main

Slynk evaluates on its own worker thread and UIKit is main-thread only, so this is not a style preference -- a view built from the REPL thread is undefined behaviour that usually looks like it worked.

(ios-app-runtime:with-main-thread
  (make-a-view))

with-main-thread (and on-main, which takes a thunk) dispatches to the main queue, waits, returns every value, and re-signals a condition on the thread that asked -- so an error still lands in your debugger rather than on a thread nobody is watching. With no application around it, on the host, it just calls: that is what lets UI-building code be exercised by the test suite.

Two things about the bridge are worth knowing, because both failures are silent:

  • It is installed into a variable, *on-main-hook*, not over a function definition. ECL compiles a call to a function defined in the same file as a direct C call, so replacing that function's fdefinition from Objective-C changes nothing -- and the symptom is a bridge that appears to work while running everything on the wrong thread.
  • The Objective-C side calls the thunk with cl_funcall, not by evaluating a constructed form. ECL's evaluator will not accept a literal function object as an argument: (funcall '#<bytecompiled-function>) fails with FUNCTION: Not a valid argument before the thunk is reached.

What a session looks like

Connected to a running app on the simulator, with nothing but :remote-repl t in the .asd:

CL-USER> (defun fresh (x) (* x 111))     ; a definition that was never built in
FRESH
CL-USER> (fresh 3)
333
CL-USER> (with-main-thread (say "Hello from SLY"))   ; a label appears on screen
:DONE

Redefining a function that was cross-compiled works too: the new definition is bytecode and replaces the fdefinition. examples/attractor-aot/ is built around that -- redefine step-point at a SLY prompt and the phone draws different mathematics on the next frame, with nothing rebuilt.

One trap, and it is the same one as the bridge's. ECL compiles a call to a function defined in the same file as a direct C call, so redefining the callee changes nothing for its neighbours -- the picture keeps coming out of the version compiled on the Mac. Anything you intend to redefine at runtime, and that its own file calls, wants a (declaim (notinline ...)). The attractor carries one on step-point for exactly this reason.

Icons

:bundle-icon "Attractor.xcassets"

iOS icons are compiled, not copied. actool turns the catalogue into an Assets.car and, separately, reports which Info.plist keys the result needs -- CFBundleIcons, CFBundleIcons~ipad, the file names it chose. Those keys are merged into the plist rather than guessed, because guessing them is how you get an app that installs with a white square and no error anywhere.

Only a .xcassets is accepted; a bare PNG is refused. iOS wants a family of sizes and actool is the thing that knows which. The catalogue must hold exactly one .appiconset, and its name is read rather than assumed to be AppIcon -- a set called something else compiles to an Assets.car with no icon in it, silently.

examples/attractor-aot/make-icon.py writes a catalogue from scratch with the standard library alone, by iterating the attractor at 1024x1024. It is a reasonable starting point if you have artwork but no Xcode.

Deploying

(asdf-ios-app:run-in-simulator "attractor")          ; build, install, launch; returns the output
(asdf-ios-app::install-in-simulator bundle)
(asdf-ios-app::launch-in-simulator bundle "com.example.attractor" :console t)
(asdf-ios-app:install-on-device bundle)

What the app prints is kept. There is no console behind an app on a phone -- a devicectl console attach relays nothing the app writes -- and the simulator's is a pty simctl holds and sometimes drops. So from boot on, everything written to *standard-output* and *error-output* also goes to Documents/console.log in the app's own container, superseded at each launch. simulator-console-log and device-console-log read it back by bundle identifier; run-in-simulator falls back to it when the console attach came up empty. Lisp output only: what ECL's C runtime prints to file descriptor 1 does not pass through those streams.

(asdf-ios-app:simulator-console-log "com.example.attractor")
(asdf-ios-app:device-console-log "com.example.attractor")   ; the phone, unlocked

A device needs Developer Mode enabled on the phone (Settings › Privacy & Security › Developer Mode, then restart), the phone unlocked while connected, and a signing identity with a matching provisioning profile.

.ipa

(ios-app:export-ipa #p"build/iphoneos/Hello.app")

A zip with the bundle under Payload/. Not a build product but a repackaging of one, so it has an entry point rather than a slot. A simulator bundle is refused: the two are indistinguishable from the outside -- same layout, same keys, an ad-hoc signature that verifies -- and the only symptom of shipping the wrong one is a rejected upload much later.

Entitlements are taken from the profile, so an App Store profile's beta-reports-active -- what lets a build be tested in TestFlight -- is carried into the signature, and get-task-allow, which a distribution build may not have, is dropped by :get-task-allow nil.

Device builds also carry the DT* provenance keys (DTPlatformName, DTSDKBuild, DTXcode, BuildMachineOSBuild), which submission requires. The simulator gets none: they would be noise, and computing them means shelling out to xcodebuild, which a machine with only the command line tools does not have.

Tests

$ ecl --eval '(asdf:test-system "asdf-ios-app")' --eval '(quit)'
115 checks, 0 failures, 1 skipped

No test-library dependency. tests/unit.lisp needs neither Xcode nor a prefix nor a simulator; tests/build.lisp builds a real fixture and skips wholesale without a cross-compiled prefix. The one test that boots a simulator is gated:

$ ASDF_IOS_APP_SIMULATOR_TESTS=1 ecl --eval '(asdf:test-system "asdf-ios-app")'

because booting one is thirty seconds and varies by machine.

Known limits, and things to check

  • Device builds are verified by hand, not by CI. The argument construction, the profile parsing and the refusals are tested; the examples have been built, installed and run on an iPhone 16e from this machine, and nothing automated does that.
  • :remote-repl on a device was once reported to block the entry point. It does not: measured again on an iPhone 16e, iOS 26.6, the server starts, the entry point runs, and a client through iproxy is answered, with :remote-repl t in the .asd and nothing else. The earlier report was not reproduced and its cause was not found; if it comes back, the boot now reports a module that fails to initialise by name rather than dying silently.
  • The remote REPL is unauthenticated. Anyone who can reach the port gets eval. It binds loopback, which on a device means nothing can reach it without iproxy; do not widen :interface outside a network you own, and do not ship a build with :remote-repl on.
  • .ipa export is unexercised on a real submission. The layout is tested (Payload/<Name>.app, on a bundle with the platform key rewritten), and the simulator refusal is tested. Nothing has been uploaded to App Store Connect.
  • A file is compiled twice in the child — natively, then for iOS — so a system with a defconstant of a non-eql value may complain. Alexandria and CFFI both survive it. :bundle-interpreted is the escape.
  • The iOS prefixes carry fewer ECL modules than the host. --disable-shared drops serve-event, among others. Cross-compilation compensates: SYS: is repointed at the target's module directory, so a system that probes for a module at compile time gets the target's answer, and any *features* entry named after a module only the host has is dropped for the duration. Without that, slynk's (probe-file "sys:serve-event.fas") succeeds on the Mac and the app dies at boot with Package SERVE-EVENT ... referenced in compiled file but has not been created. If you hit that message for some other package, this is the mechanism to look at.
  • CI stops at the simulator. The cross job builds a cross ECL, caches it, and runs the examples on a booted simulator; no runner has a phone.

Licence

MIT.

About

Build iOS .app bundles from Common Lisp with ASDF and ECL. No Xcode project.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages