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.
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.
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.
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 haveAbout 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.
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:
ecl_library_symbolcallsdlsym(0, symbol)for the:defaultmodule. On Darwin a null handle is not the global scope —RTLD_DEFAULTis(void *)-2— so it always returned NULL, and CFFI, whose ECL backend resolves foreign functions by name, could not work at all.ffi:callbackreturned 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.si:call-cfunpasses and returns structures by value, so aCGRectno 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.
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.
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.
| 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.
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.
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,defmethodat run time all work — the boot installs the bytecodes compiler, soCOMPILEproduces 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_msgSendthroughsi:call-cfunneeds 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.
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.0d0Files 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.
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.
- The C must be plain C, not Objective-C. A trampoline is a cast of
objc_msgSendto one concrete prototype, which is C anyway. - No
@may appear in ac-inlinebody, 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:cstringargument instead. - Declare the package in an ordinary component. A trampoline file's
defpackagenever runs on the host, and ordinary sources — which are read there — cannot then read a symbol in it.
: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.
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.
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'sfdefinitionfrom 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 withFUNCTION: Not a valid argumentbefore the thunk is reached.
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
:DONERedefining 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.
: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.
(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, unlockedA 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.
(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.
$ 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.
- 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-replon 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 throughiproxyis answered, with:remote-repl tin the.asdand 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 withoutiproxy; do not widen:interfaceoutside a network you own, and do not ship a build with:remote-replon. .ipaexport 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
defconstantof a non-eqlvalue may complain. Alexandria and CFFI both survive it.:bundle-interpretedis the escape. - The iOS prefixes carry fewer ECL modules than the host.
--disable-shareddropsserve-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 withPackage 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
crossjob builds a cross ECL, caches it, and runs the examples on a booted simulator; no runner has a phone.
MIT.





































