| Field | Value |
|---|---|
| Author | TBD |
| Date | 2026-09-04 |
| Status | Draft |
| Repo | lua-objc |
| Intended path | docs/ios.md |
This document is both an implementation spec and an operator how-to. An engineer should be able to land PR1 without inventing host boot, Lua loading, or the ns.Window mapping.
lua-objc already has a complete AppKit product and a UIKit stub: make builds build/UIKit.dylib with the iPhone Simulator SDK when Xcode is present, but there is no iOS host, no scene, no packager, and lua/embedded/UIKit.lua is a thin copy of a few AppKit constructors. src/uikit/views.m bridge_window requires an attached UIWindowScene, so the macOS loader src/host.c cannot run UIKit.
This work adds a real iPhone Simulator host that statically compiles Lua 5.4.8 plus the UIKit translation unit (src/uikit_module.m includes src/uikit/*.m and src/shared/*.m — those fragments are not extra Compile Sources). The .app is a runtime, not an app bundle: it contains no Lua, templates, or images. A Mac packager streams every .lua / .etlua file and every asset (png, svg, json, game data, …) over HTTP, and pushes change events over WebSocket. The operator loop never rebuilds or reinstalls.
The host process does not quit on reload. A save does not simctl launch, does not terminate LuaRuntime, and does not tear down the UIWindowScene. The packager sends update over WebSocket; the running host recycles the in-process lua_State, boots the entry again, which replaces rootViewController, and keeps the Simulator on screen. A Lua error is a redbox overlay, not a crash. This is the React Native Fast Refresh analogue: native runtime stays up, app Lua and assets stream in.
Success is coverage, not a port: AdventureArena’s SwiftUI screens at /Users/igor/Developer/adventure-arena define the primitive set that must exist in Lua + XML + real UIKit. AdventureArena itself is not ported in this workstream.
AppKit is the product. lua/embedded/AppKit.lua exposes Window, Panel, stacks, splits, ScrollView, List, OutlineView, ToolbarItem, Button, Toggle, Slider, Stepper, Picker, Text/TextField/SearchField/TextEditor, SystemImage, ForEach, Group, async/fetch/json. src/host.c dlopens build/AppKit.dylib and calls lua_objc_main in src/main.m. Product apps live in apps/<app>/, runnable framework demos in demo/<name>/, and test harness apps in test/<name>/. All follow the MVP layout (init.lua, Model.lua, Controller.lua, views/*.etlua). XML in lua/ui/xml.lua is already documented as cross-platform: the same <Label> is ns.Text → NSTextField on AppKit and ns.Label/ns.Text → UILabel on UIKit.
UIKit is a compile-check, not a runtime:
| Piece | Reality |
|---|---|
Makefile build/UIKit.dylib |
xcrun --sdk iphonesimulator + -undefined dynamic_lookup + Homebrew Lua headers. Not a loadable iOS image. |
src/uikit_module.m |
Embeds lua/embedded/UIKit.lua, includes src/uikit/bridge.m. Correct module shape. |
src/uikit/bridge.m bridge_lib |
_vstack, _hstack, _hsplit, _spacer, _textField, _label, _separator, _progressIndicator, _button, _toggle, _window, _image, _add, _layout, _setContentSize, _tableview, _show, _font, _timerAfter, _httpGet, _jsonParse. |
src/uikit/views.m bridge_window |
Iterates UIApplication.sharedApplication.connectedScenes and errors with "UIKit.Window requires an attached UIWindowScene". |
src/uikit/constructors.m |
UIView stacks, UITextField, UILabel, 1pt separator, UIActivityIndicatorView, UIButtonTypeSystem, UISwitch. |
src/uikit/tables.m |
UITableViewStylePlain only; LuaTableViewSource joins column values into cell.textLabel. No sections, swipe, refresh, replaceRows. |
lua/embedded/UIKit.lua |
Window clones AppKit’s 480×360 constructor; Switch instead of Toggle; no ScrollView, SystemImage, TabView, NavigationStack, Picker, Slider, TextEditor, sheets. |
| File watch | bridge_watch_file in src/appkit/platform.m is FSEvents. Unavailable in the simulator for a Mac source tree. |
| Preview | src/appkit/canvas_eval.m is an isolated AppKit canvas lua_State that monkey-patches ns.Window. Not an iOS path. |
| Layout dump / screenshot | --dump-layout / --screenshot live in lua_objc_main (src/main.m). iOS has no equivalent. |
ARCHITECTURE.md already states the intended load:
require("UIKit") -- luaopen_UIKit in UIKit.dylib, inside an iOS hostThe host is the missing piece. UIKit also uses LUA_OBJC_EXTERNAL_STATE_OWNER: install_external_lua_state_owner in src/shared/lua_async.m installs a non-closing LuaStateOwner. That is correct for an iOS host that owns lua_close.
AdventureArena is a shipping SwiftUI iPhone app (deployment target iOS 26.5, same generation as lua-objc’s macOS 26 rule) with the chrome an iOS lua-objc app actually needs: TabView, NavigationStack, paging carousel, List + swipe-to-delete, ScrollView, composer + keyboard, sheets, menus, SF Symbols, @AppStorage, pull-to-refresh, empty states. Mapping those primitives is the contract. CloudKit, Translation.framework, and the in-app LLM are not.
- There is no way to run
demo/helloon a phone. - UIKit.lua’s
Window(title, width, height)is a macOS window pretending to be iOS. - XML already claims Slider/Stepper/Picker are AppKit-only (
docs/agents/xml-syntax.md); iOS apps cannot share those templates. - Reloading Lua on iOS cannot use FSEvents inside the simulator.
- Agents iterating SwiftUI pay an Xcode compile per change; lua-objc’s pitch is “edit Lua, native UI updates.” That pitch is currently macOS-only.
- Launch Lua apps on the iPhone Simulator (iOS 26.5 SDK, Xcode 26) with real UIKit controls.
- The host is compiled once. After that, the only data path is the packager: Lua, etlua, and assets stream over HTTP; saves hot-reload over WebSocket. No rsync into the
.app, nomake ios-hostin the daily loop. - Cross-platform XML: the same
views/*.etluacompiles throughlua/ui/xml.luawithnsinjected. New tags (TabView,NavigationStack,ZStack,Section,Sheetpresentation is controller-side) land inTAG_SCHEMA. - Coverage of the AdventureArena primitive set (table below). App-level widgets (message bubbles, compass, star rating, cover image) are composed in Lua, not new native classes.
- Headless tests for XML/API/packager on macOS; simulator tests for native construction, layout dump, and screenshots.
- Operator workflow:
make ios-run ARGS=demo/hello. That starts the packager, boots the already-built host if needed, and streams the app.
- Porting AdventureArena into
apps/adventure. Optional later example; not this workstream. - CloudKit, Translation.framework, on-device LLM, Game Center, App Intents, widgets, live activities.
- Shipping to a physical device or App Store in v1 (design the host so a later device PR is codesign + SDK switch, not an architecture change).
- Running
UIKit.dylibinsidesrc/host.con macOS. - Mac Catalyst.
- Wrapping UIKit in SwiftUI /
UIViewRepresentable. - Using React Native, Expo, or Yoga as the iOS layer.
- True view-tree reconciliation in v1 hot reload (
lua/ui/viewdesc.luaexists for a later PR). - Bundling
lua/,apps/,views/, or assets intoLuaRuntime.appas a development path. The host has no app payload. Packager-down is a redbox, not a fallback copy. - Rebuilding or reinstalling the host because a Lua file, template, or asset changed.
- FSEvents (or
bridge_watch_file) inside the simulator pointed at the Mac repo. - Routing iOS previews through
src/appkit/canvas_eval.m. - Custom-drawn iMessage chrome, fake tab bars (
UISegmentedControlas a tab bar), fake navigation bars, or privateNSTabBarequivalents. - Per-panel
cornerRadius/ shadow APIs (project rule). Image clipping may useUIView.layervia a view accessor if a later PR needs it; not a panel chrome API. - Changing AppKit window tabbing or replacing
NSToolbaron macOS. - Bumping Lua to 5.5 (LuaKit’s vendored version). lua-objc stays on Lua 5.4.8.
-
The product on iOS is a Simulator host app, not
UIKit.dylib.build/UIKit.dylibstays as an optional compile-check (make uikit) until the host exists; the host statically includes the same translation unit. iOS apps cannotdlopena-undefined dynamic_lookupdylib as the runtime, and App Store rules make a static host the React Native analogue. -
Vendor and compile Lua 5.4.8 for
iphonesimulator. Homebrew Lua is a macOS dylib. AdventureArena’s LuaKit vendors Lua 5.5 (External/LuaKit/Sources/Lua/include/lua.h). lua-objc’s macOS toolchain is Lua 5.4.8. The host compilesvendor/lua-5.4.8/with-DLUA_USE_IOS. Do not link LuaKit. -
ns.Windowon iOS is SwiftUIWindowGroup, not a sizedUIWindow. The current UIKit constructor (title,width=480,height=360,win:add(vstack),win:show()) is deleted, not aliased. XML<Window>is already a config record (kind = "record",__isWindowConfig) and never callsns.Window. The controller still doesreturn ns.Window(cfg). On iOS that installscfg.contentasUIWindow.rootViewControllerand fills theUIWindowScene. Width/height are ignored. -
iOS navigation is
UIViewController-based.UITabBarControllerandUINavigationControllerare first-class. Stacks, labels, and buttons remainUIView. ALuaHostingControllerwraps a Lua-laid-outUIViewwhen a controller child is a view. Do not fake a tab bar or nav bar with segments or custom drawing. -
The host injects the platform module as
require("ns"). macOSlua_objc_mainalso registers AppKit asns(the same table, not a forwarding stub).xml.renderdefaults torequire("ns"). Existingrequire("AppKit")/require("UIKit")keep working because those names stay inpackage.loadedpointing at the same table. Call-site migration torequire("ns")is a follow-up, not a host-boot blocker. Noor require("AppKit")fallback shim. -
Hot reload recycles the
lua_State. Not a virtual-DOM diff, and no state survives a save beyond what the app persists itself. An earlier design re-ran the entry inside the live state and keptModelmodules loaded; it leaked every previous app. Native views, targets and timers hold registry references to Lua closures that capture the old controller, a cycle across the two collectors that neither breaks, so each reload pinned a full view tree (about 50 MB and 100 Mach ports on Adventure Arena; a long session reached 4 GB and 10,000 ports).lua_closecancels the state's timers and tasks and finalizes every userdata, so the previous app goes at once.lua/ui/viewdesc.luaremains the later reconciliation path for preserving view identity. -
The Mac packager is the only source of Lua and assets. The host is a native VM + UIKit bridge. It does not copy the repo into the
.app. HTTP serves modules, templates, and binary assets; WebSocket pushesupdateevents; the Simulator is a client. No FSEvents inside the sim for~/Developer/lua-objc. Changing.lua,.etlua, images, or game data never rebuilds the host. Native.mchanges are a framework-engineer event outside this loop and are not watched. -
Do not reuse
canvas_eval.m. That path creates a throwaway AppKitlua_Stateand monkey-patchesns.Window→ns.VStackfor Xcode-like macOS previews. iOS previews are a real SimulatorKit device. Hot reload keeps one host-ownedlua_State. -
Deployment target is iOS 26.5. Match AdventureArena (
IPHONEOS_DEPLOYMENT_TARGET = 26.5) and lua-objc’s macOS 26 rule. MakefileIOS_MIN/ Info.plistMinimumOSVersionare 26.5, not 26.0. Use current UIKit (UIView.keyboardLayoutGuide,UISheetPresentationControllerdetents,UIButton.Configuration). Mapstyle="borderedProminent"to+[UIButtonConfiguration borderedProminentButtonConfiguration](public since iOS 15). Do not inventprominentFilledButtonConfiguration. No appearance shims, no old-material fallbacks. -
Default simulator device is
iPhone 17. Current Xcode 26.6 / iOS 26.5 runtimes on this machine: iPhone 17, 17 Pro, 17 Pro Max, 17e, iPhone Air. Override withDEVICE. Detect viaxcrun simctl list devices available. -
AdventureArena widgets that are not system controls are Lua compositions. Compass = buttons +
SystemImage; stars = five SF Symbols; message bubbles =Label+ semantic colors + layout; cover image =Imagefrom file. NoCGPathfake chrome. -
ContentUnavailableViewis not a UIView. There is no public UIKit class.ns.ContentUnavailableis a Lua function that composesSystemImage+Title+Labelwith system fonts and semantic colors.
flowchart TB
subgraph mac [Mac]
srcLua["apps/* lua/** views/*.etlua\nassets png/svg/json/zil"]
packager["build/lua-objc-packager\nFSEvents + HTTP :8081 + WS /hot"]
xcode["xcodebuild / Makefile ios-host\niphonesimulator, arm64"]
srcLua --> packager
srcNative["src/uikit/*.m src/shared/*.m\nios/host/*.m vendor/lua-5.4.8"] --> xcode
end
subgraph sim [iPhone Simulator]
app["LuaRuntime.app"]
scene["UIWindowScene + UIWindow"]
lua["lua_State + luaopen_UIKitNative\nstreamed UIKit.lua"]
tree["UITabBarController / UINavigationController\nLuaHostingController + UIView tree"]
app --> scene --> lua --> tree
packager -->|"HTTP GET /file /module (Lua + assets)"| lua
packager -->|"WS update"| lua
end
Native UIKit is compiled into the host once. App Lua is either:
- Dev: fetched from the packager (
LUA_OBJC_PACKAGER=http://127.0.0.1:8081). - Bundled: copied into
LuaRuntime.app/luaandLuaRuntime.app/appsfor packager-less simulator runs.
ios/
LuaRuntime.xcodeproj ← scheme LuaRuntime, iphonesimulator
LuaRuntime/
Info.plist
LuaRuntime.h ← shared host declarations; one header for this folder
main.m ← UIApplicationMain
LRTApplicationDelegate.m ← UIApplicationDelegate
LRTSceneDelegate.m ← creates UIWindow, starts LRTApplicationController
LRTApplicationController.m ← lua_State, searcher, createWindow, hot reload
LRTResourceLoader.m ← packager HTTP only (Lua + assets)
LRTReloadConnection.m ← WebSocket client
LRTErrorViewController.m ← redbox
LuaHostingController.m ← UIViewController hosting a Lua UIView
LuaLayoutDump.m ← UIKit hierarchy XML
Assets.xcassets / AppIcon
vendor/
lua-5.4.8/ ← official Lua 5.4.8 sources (no lua.c/luac.c in lib)
src/packager/
packager.m ← Mac FSEvents + HTTP + WS → build/lua-objc-packager
src/uikit/ ← existing fragments, extended in later PRs
navigation.m ← NEW: TabView, NavigationStack, sheets, bar items
scroll.m ← NEW: UIScrollView
overlay.m ← NEW: ZStack
defaults.m ← NEW: NSUserDefaults
layout_debug.m ← NEW: dump analogue of src/appkit/layout_debug.m
Fragments remain #included by src/uikit/bridge.m. They are not separate linker inputs. src/README.md is updated when a fragment is added.
Xcode Compile Sources (do not glob src/uikit/*.m or src/shared/*.m — bridge.m already #includes them, and compiling both double-defines every static):
ios/LuaRuntime/*.m(host only: scene, loader, overlay, dump)src/uikit_module.m(providesluaopen_UIKit/luaopen_UIKitNative;#includessrc/uikit/bridge.m)liblua.afromvendor/lua-5.4.8/*.c(no ARC, excludelua.c/luac.c,-DLUA_USE_IOS,ARCHS=arm64)
LUA_OBJC_EXTERNAL_STATE_OWNER stays defined in src/uikit/bridge.m. The host creates the lua_State, calls luaopen_UIKit, and is responsible for lua_close on teardown / full restart.
LuaHostingController is compiled as part of ios/LuaRuntime/*.m. After luaopen_UIKitNative and streamed require("UIKit"), LRTApplicationController pushes _hostingController / _installScene into package.loaded.UIKitNative (or LuaHostingController.m is #included from bridge.m — pick one; default: host injects the two C functions so the dylib compile-check does not need the host symbols). extern UIWindow *LRTApplicationWindow(void); is defined in LRTApplicationController.m. The make uikit dylib does not call it.
The .app contains no lua/, apps/, views/, or asset trees. LRTResourceLoader talks only to the packager (GET /module, GET /file). package.path is unused for app code; a packager searcher is installed first and is the only searcher that can load apps.*, ui.*, etlua, App, and lua/embedded/UIKit.lua. io.open of a process-relative path will miss (Simulator cwd is not the git root) and is not a fallback.
If the packager is unreachable at boot, show the redbox: “Packager not running. make ios-run ARGS=…”. Do not ship a bundled copy of hello “just in case.”
sequenceDiagram
participant SD as LRTSceneDelegate
participant H as LRTApplicationController
participant P as Packager
participant L as lua_State
participant C as Controller
SD->>SD: window = UIWindow(windowScene)
SD->>H: start(window, launchArgs)
H->>P: GET /health (required)
alt packager down
H->>SD: redbox "Packager not running"
else packager up
H->>H: luaL_newstate + luaL_openlibs
H->>L: luaopen_UIKitNative
H->>L: install HTTP searcher + _readFile + _readAsset
H->>L: require("UIKit") via GET /module (lua/embedded/UIKit.lua)
H->>L: package.loaded.ns = UIKit
H->>P: GET /entry
H->>L: require(entry) -- streamed, not dofile of a bundle path
L->>C: class.new()
C->>C: createWindow()
C->>L: ns.Window(cfg)
L->>SD: rootViewController = hosting/tab/nav
SD->>SD: makeKeyAndVisible
H->>P: WS /hot
P-->>H: {"type":"update","path":"...","kind":"lua"|"asset"}
H->>H: fastRefresh or fullRestart
end
The host calls luaopen_UIKitNative only. lua/embedded/UIKit.lua is ordinary streamed Lua (GET /module?name=UIKit), not the xxd blob inside luaopen_UIKit. That blob may remain for the make uikit dylib compile-check; the Simulator host does not use it. Streaming the declarative layer means editing UIKit.lua also does not rebuild the .app.
LRTSceneDelegate is the only place that creates a UIWindow. Lua never allocates extra windows in v1. bridge_window is replaced by bridge_install_scene:
/* src/uikit/views.m — replaces today's title/width/height UIWindow */
static int bridge_install_scene(lua_State *L) {
UIViewController *root = check_view_controller(L, 1);
const char *title = luaL_optstring(L, 2, "");
UIWindow *window = LRTApplicationWindow(); /* set by LRTSceneDelegate */
if (!window) return luaL_error(L, "UIKit.Window requires an attached UIWindowScene");
window.rootViewController = root;
window.accessibilityLabel = @(title);
[window makeKeyAndVisible];
push_objc(L, window, "uiwindow");
return 1;
}LRTApplicationController defines UIWindow *LRTApplicationWindow(void) (the scene window set before Lua runs). Prefer that pointer over walking connectedScenes so hot reload cannot attach to a stale scene. Register a "uiviewcontroller" metatable; check_view_controller accepts a VC userdata or a LuaHostingController. Today src/uikit/runtime.m check_objc only accepts "uiview" / "uiwindow".
Launch arguments. simctl launch has no --env flag. Set child environment on the calling process with the SIMCTL_CHILD_ prefix, then pass only the device and bundle id (optional argv after the bundle id is allowed; do not document --env):
| Env / arg | Meaning |
|---|---|
LUA_OBJC_APP |
App directory or init.lua path, default demo/hello |
LUA_OBJC_PACKAGER |
Base URL. Default http://127.0.0.1:8081. Required. There is no bundled-Lua mode |
LUA_OBJC_APPEARANCE |
light / dark / system |
LUA_OBJC_DUMP_LAYOUT |
Filename under the app container (NSTemporaryDirectory() or Documents). Makefile copies it out with xcrun simctl get_app_container — OUT= is a Mac path |
LUA_OBJC_SCREENSHOT |
If set, host signals readiness; Makefile uses simctl io screenshot (full device frame, including status bar / home indicator — not AppKit contentView) |
LUA_OBJC_INTERNAL_SCREENSHOT |
Test-only filename under the app container. After Lua instantiation and layout, the host renders the live UIWindow with UIGraphicsImageRenderer and writes a PNG without simctl or Screen Recording. |
Capture the live app window from inside the host and copy the resulting PNG to the Mac with:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
make ios-internal-screenshot PROJECT=demo/hello OUT=/tmp/ios-hello.pngThis is a content-window capture, not a device-frame screenshot. It requires a
booted Simulator and the normal local packager, but does not invoke
simctl io screenshot.
For the normal device-frame capture, which includes the status bar and home indicator, use the existing Simulator API directly:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
make ios-screenshot OUT=/tmp/ios-device.pngThe host protocol stays class.new():createWindow() — same as src/main.m lines 552–588. The method name is the framework instantiation contract, not “make an NSWindow.” Keep the registry refs of the controller and the returned window/VC so the tree is not collected.
Delete UIKit.Window as written in lua/embedded/UIKit.lua (the 480×360 bridge._window + inner VStack + layout(width) + show() path). Replace with:
function UIKit.Window(props)
props = props or {}
local content = props.content or props[1]
if content == nil then
error("UIKit.Window requires content")
end
local vc = asViewController(content)
return bridge._installScene(vc, props.title or "")
endasViewController:
- userdata
uiviewcontroller→ use as-is (TabView,NavigationStack). - userdata
uiview→ wrap inLuaHostingController. - table of views (XML
<Window>cfg.contentmay be a raw array) →VStackthen wrap.
lua/embedded/UIKit.lua starts as local UIKit = require("UIKitNative") (same pattern as AppKit.lua local AppKit = bridge), then overwrites Window and adds constructors. Keep UIKit.Text = UIKit.Label. Delete Switch; XML alias Switch → Toggle stays.
LuaHostingController:
viewis a containerUIViewwithbackgroundColor = UIColor.systemBackgroundColor.- On
viewDidLayoutSubviews, keep the Lua root view edge-to-edge inview.bounds, then add the live topsafeAreaInsets.topto its layout padding (unlessignoresSafeAreaincludestop) before callinglayout_recursive(root, width). - Subscribes to
view.keyboardLayoutGuidewhenkeyboardAvoidance ~= false(default on). - Forwards
prefersLargeTitles,hidesBottomBarWhenPushed, navigation item from props.
XML <Window> does not change shape. views/AppWindow.etlua width/height remain for AppKit; iOS ignores them. Do not add iOS conditionals to the parent template.
Extend TAG_SCHEMA with kind = "controller" (still calls ns[constructor], result is a VC userdata). compile() already stores whatever the handler returns in refs.
| Tag | kind | iOS native | AppKit native |
|---|---|---|---|
TabView |
constructor returns VC userdata | UITabBarController, or UIPageViewController + UIPageControl when style="page" (indexViewStyle / backgroundDisplayMode = .always for the Adventures carousel) |
existing NSTabView (src/appkit/tabview.m bridge_tabview); wrap it as AppKit.TabView in the XML PR |
Tab |
record | UITabBarItem + child VC |
NSTabViewItem |
NavigationStack |
constructor returns VC userdata | UINavigationController; stashes the nav on child hosting VCs |
iOS-only in v1. Do not ship a fake AppKit UINavigationBar. macOS xml.render tests that include this tag use a fake ns (tests/xml_ios_schema.test.lua). Error on AppKit: "xml: platform does not support constructor ns.NavigationStack" |
NavigationLink |
view | UIButton that walks targetViewController.navigationController (or the associated object set by NavigationStack) and calls nav:push. XML value is a string id; the controller maps it to a model object. nav:destination(key, fn) keys are strings; fn(value) returns a view or VC |
n/a in v1 |
List |
always a view | UITableView (plain / insetGrouped). Attach UIRefreshControl to the table (supported without UITableViewController). Wrap in LuaHostingController when a Tab/Nav child |
NSTableView in NSScrollView (unchanged; still requires columns) |
ScrollView |
view | UIScrollView |
existing bridge._scrollView |
ZStack |
view | parent fills the host; each child is measured then placed using stack alignment (and optional per-child alignment). Children are not stretched to parent bounds unless fillWidth/fillHeight |
NSView same model |
Sheet |
not a persistent tree node | UISheetPresentationController via ns.presentSheet(content, props) with an explicit presenter VC (root or top nav) |
existing ns.Panel / ns.present |
kind = "controller" is not required. makeSchemaHandler in lua/ui/xml.lua only branches kind == "record" vs constructor. Constructor tags that return VC userdata work unchanged; compile() already stores whatever the handler returns. Do not add a schema kind until a real compile-time check is needed.
Wrapping rule (Lua, not XML conditionals):
local function childController(child)
if isViewController(child) then return child end
return UIKit.HostingController(child)
endTab and NavigationStack always store controllers. A <Tab> whose child is a <VStack> still works.
Keep the existing C flex engine in src/uikit/layout.m (layout_recursive, kStackSpacing = 8.0 in src/uikit/bridge.m). It is currently a weaker copy of AppKit’s src/appkit/layout.m.
PR1 must port the accessors hello actually writes onto UIView (LuaLayoutProperties) and UIKit.lua applyLayout: flexGrow, spacing, fillWidth (AppKit NSView (LuaLayoutProperties) already has these; UIKit today only implements padding / alignment / fixedWidth / fixedHeight). UIKit.Text/Label must apply size/weight/color via existing _font plus a new _systemColor. Without that, streamed hello’s Window.etlua (flexGrow, spacing, size, weight, color="secondary", <SystemImage>) will not layout.
A later layout PR (PR 3) brings measure/distribute/min/max up to AppKit parity and wires safe area + keyboard. Named constants stay in src/uikit/bridge.m.
LuaHostingController is the only place that decides the root width: the full scene bounds, with the status-bar inset represented as top layout padding. Do not call content:layout(480) from UIKit.Window.
Safe area:
- Default: root view fills the window so the app background continues behind the system status and home-indicator regions; only the top inset is added to content layout.
ignoresSafeArea="top"(XML / prop): pin toview.boundson that edge so hero images bleed under the nav bar (AdventuresView / GameInfoView).safeAreaInsetedge bottom: extra bottom constraint (Create Game primary button, session composer). PreferkeyboardLayoutGuidefor the composer.- Vertical scroll views (
ScrollView, lazy stacks and grids) keep their full frame under the tab bar, its bottom accessory, and the home indicator, so content shows through the Liquid Glass. The scroll view's own livesafeAreaInsets.bottombecomes bottom content and indicator inset, so the last row scrolls clear of the bars. The inset updates when the accessory appears, hides, or moves inline beside a minimized bar.ignoresSafeArea="bottom"(orall) opts out.
Keyboard: use UIView.keyboardLayoutGuide (iOS 15+, current on iOS 26). No notification math.
sequenceDiagram
participant FS as Mac FSEvents
participant P as lua-objc-packager
participant WS as WebSocket /hot
participant H as LRTApplicationController
participant VC as rootViewController
FS->>P: demo/hello/views/Window.etlua changed
P->>WS: {"type":"update","path":"demo/hello/views/Window.etlua","kind":"view"}
WS->>H: same JSON
H->>H: drop cached path; lua_close; new lua_State
H->>H: require entry; Controller.new()
H->>VC: createWindow() → _installScene replaces root VC
Rules
| Changed path | Refresh |
|---|---|
*.lua, *.etlua (including lua/embedded/UIKit.lua, lua/ui/**) |
Drop that path from the LRTResourceLoader cache, recycle the lua_State, boot the entry. Streamed; no .app rebuild |
Assets (png, jpg, jpeg, gif, webp, svg, json, zil, txt, …) |
Same. Streamed; no .app rebuild |
Hidden folders (.git, .claude worktrees under the root) |
Not watched |
src/**/*.m, ios/**, vendor/lua-5.4.8/** |
Out of this loop. The packager does not watch native sources and does not send a “rebuild the host” event. Framework engineers rebuild the host themselves when they change the bridge |
Reload implementation (LRTApplicationController.m handleReloadEvent:):
- Drop the changed path from the
LRTResourceLoadercache, both a cached body and a remembered 404. Every other file is served from the cache, so a reload fetches only what changed; the remembered 404s keeprequirefrom probing everypackage.pathcandidate again (over a hundred requests per boot). lua_objc_prepare_closecancels the state's timers and tasks, thenlua_closefinalizes every userdata. The previous app, its view tree and its registry references go at once.- Create a new state,
requirethe entry,new(),createWindow()._installScenereplaceswindow.rootViewController. - On Lua error: show
LRTErrorViewControllerwith the error and traceback. Try Again boots from scratch.
Not in v1: preserving UITextField first responder, scroll offset, or UINavigationController stack. Document this; a later viewdesc PR can preserve identity.
Binary: src/packager/packager.m → build/lua-objc-packager (macOS). Transport is BSD sockets (HTTP/1.1 + a small WebSocket upgrade). FSEvents for the watch set. Do not use Network.framework for the packager — one listener, one implementation. Path mapping, jail, and kind classification live in lua/packager/paths.lua so tests/packager.test.lua hits the real module; packager.m dofiles it. Repo root is the jail. Bind 127.0.0.1:8081 by default (PORT override). Never bind 0.0.0.0 in v1.
HTTP (all paths relative to repo root; .. rejected):
| Method | Path | Response |
|---|---|---|
| GET | /health |
200 {"status":"ok","root":"..."} |
| GET | /file?path=lua/ui/xml.lua |
raw bytes. Content-Type from extension (text/plain for Lua/etlua, image/png, image/svg+xml, application/json, application/octet-stream otherwise). X-Path header |
| GET | /module?name=demo.hello.Controller |
Lua source after mapping dots → path (see below) |
| GET | /entry |
{"path":"demo/hello/init.lua"} reflecting ARGS |
| GET | /list |
JSON array of watched relative paths (debug) |
Module name mapping:
ns → not served (alias of streamed UIKit)
UIKitNative → not served (luaopen_UIKitNative in-process)
UIKit → lua/embedded/UIKit.lua (streamed, not xxd)
ui.xml → lua/ui/xml.lua
ui.viewdesc → lua/ui/viewdesc.lua
etlua → lua/etlua.lua (which requires vendor.etlua.etlua)
vendor.etlua.etlua → lua/vendor/etlua/etlua.lua
App → lua/App.lua
TestKit → lua/TestKit.lua
demo.hello.Controller → demo/hello/Controller.lua
demo.hello.Model → demo/hello/Model.lua
If /module 404s, try name with dots replaced by / plus .lua under repo root, then under lua/.
WebSocket GET /hot upgrade:
The server sends {"type":"hello","protocol":1} as soon as the upgrade completes. The client waits for that frame. If protocol ~= 1, the client disconnects and shows the redbox (“packager protocol mismatch”). On packager restart the client retries /hot with exponential backoff (200 ms, 400 ms, 800 ms, cap 5 s) until hello succeeds, then does a full Lua restart so the entry module matches /entry. No host rebuild.
Subsequent frames:
{"type":"update","path":"demo/hello/views/Window.etlua","kind":"view"}
{"type":"update","path":"demo/hello/Controller.lua","kind":"controller"}
{"type":"update","path":"demo/hello/Model.lua","kind":"model"}
{"type":"update","path":"lua/ui/xml.lua","kind":"runtime"}
{"type":"update","path":"lua/embedded/UIKit.lua","kind":"runtime"}
{"type":"update","path":"apps/weather/assets/sunny.svg","kind":"asset"}
{"type":"error","message":"..."}kind is derived from the path (see refresh rules). There is no native event. The packager does not watch src/, ios/, or vendor/.
Watch set: *.lua, *.etlua, plus assets *.png *.jpg *.jpeg *.gif *.webp *.svg *.json *.zil *.txt under the jail. Ignore build/, .git/, vendor/, src/, ios/.
Lua package.path on iOS: do not point at the Mac filesystem. Install a package.searchers entry before the filesystem searcher:
static int searcher_packager(lua_State *L) {
const char *name = luaL_checkstring(L, 1);
NSString *src = [LRTResourceLoader.shared sourceForModule:@(name) error:&err];
if (!src) { lua_pushstring(L, err.UTF8String); return 1; }
NSData *bytes = [src dataUsingEncoding:NSUTF8StringEncoding];
if (luaL_loadbuffer(L, bytes.bytes, bytes.length, name) != LUA_OK)
return lua_error(L);
lua_pushstring(L, name);
return 2;
}xml.renderFile today uses io.open. On iOS that cannot see the Mac tree. Add bridge._readFile(path) used by a one-line change in xml.renderFile / readTemplate:
local function readFile(path)
if bridge._readFile then
local body, err = bridge._readFile(path)
if err then error(err) end
return body
end
local f = io.open(path, "r")
...
endbridge._readFile in the host is packager GET /file?path= only. Paths stay repo-relative (demo/hello/views/Window.etlua). There is no NSBundle Lua tree.
Assets. ns.Image / bridge._image on iOS must not call imageWithContentsOfFile on a Mac path or imageNamed: expecting a bundled catalog. Load GET /file?path= as NSData → UIImage imageWithData:. SVG can stay a later leaf if AppKit already has a path; v1 PNG/JPEG is enough for cover art. Cache by path in LRTResourceLoader; an kind=asset event drops that entry. Game data (json, zil) uses the same _readFile / _readAsset so zilscript content is streamed too — still app runtime, not a widget.
New leaf controls go in src/uikit/constructors.m (or a focused new fragment) and bridge_lib. Public wrappers in lua/embedded/UIKit.lua. Visual constants in src/uikit/bridge.m.
UIKit.Toggle replaces UIKit.Switch (XML already aliases Switch → Toggle). UIKit.Text remains an alias of Label because xml.lua Label.constructor = "Text".
Picker native choice:
style |
Control | Used by |
|---|---|---|
"segmented" |
UISegmentedControl |
CreateGame tone/length; reading font/theme |
"menu" |
UIButton + UIMenu (showsMenuAsPrimaryAction) |
compact menus |
"wheel" |
UIPickerView |
rare |
"automatic" (default) |
In a List row: cell that pushes a checkmark list or shows a menu; outside a list: "menu" |
Settings “Game Language” |
Do not use UIPickerView for Settings. Do not fake a picker with a UILabel plus emoji.
Button role="destructive" → UIButton.Configuration destructive / UIColor.systemRed. style="borderedProminent" → +[UIButtonConfiguration borderedProminentButtonConfiguration]. systemImage → UIImage systemName: on the configuration. enabled=false / XML disabled="true" maps to UIControl.enabled.
Sheets: ns.presentSheet(content, { detents = { "medium", "large" }, title = "Reading" }) using UISheetPresentationController. Confirmation: ns.confirm({ title, message, destructive = "Delete Save", cancel = "Cancel", action = fn }) → UIAlertController.
Menus: UIButton.menu / UIBarButtonItem.menu. XML <Menu> record of items, attached in the controller or as a Button prop.
Images: systemName: for SystemImage (in-process, no stream). File images (ns.Image, cover art) load from the packager (GET /file) as UIImage imageWithData:. Do not use imageNamed: expecting a bundled catalog, and do not imageWithContentsOfFile on a Mac path. CloudKit CDN stays out of v1; apps that already have an HTTP URL may ns.fetch into the same loader.
App responsibility: game saves, zilscript state. Framework: a small Defaults bridge so @AppStorage has a home.
ns.Defaults.get("translationLanguage") -- string/number/bool/nil
ns.Defaults.set("translationLanguage", "en")
ns.Defaults.observe("gameReadingFontSize", fn) -- optional v1.1Implemented with NSUserDefaults.standardUserDefaults. Files: Lua io against NSHomeDirectory() / NSDocumentDirectory exposed as ns.DocumentDirectory. No CloudKit.
lua-objc is a Lua host. An app may require additional Lua packages from its apps/<app>/ tree; those files are streamed like everything else. The framework does not ship zilscript. A later AdventureArena port would vendor the engine as app Lua, same process, same lua_State or a child state the app creates via a documented bridge._newState — out of v1. Document that one process, one UI lua_State, is the supported model; game engines run as ordinary required modules unless they need an isolated state.
Inventory from the Swift sources. Framework scope is the primitive column. App-composed widgets are marked app.
| Screen / use | SwiftUI | UIKit class | lua-objc API | XML |
|---|---|---|---|---|
| Root tabs | TabView + .tabItem { Label(_, systemImage:) } |
UITabBarController + UITabBarItem |
ns.TabView / ns.Tab |
<TabView>, <Tab title systemImage> |
| Scene refresh | scenePhase == .active |
UIScene activation |
ns.onScenePhase(fn) |
controller |
| Adventures chrome | NavigationStack |
UINavigationController |
ns.NavigationStack |
<NavigationStack> |
| Hide nav bar | .toolbar(.hidden, for: .navigationBar) |
setNavigationBarHidden |
hidesNavigationBar |
attr |
| Loading | ProgressView("Loading games…") |
UIActivityIndicatorView + UILabel |
ns.ProgressView + Label |
existing |
| Empty / error | ContentUnavailableView |
composed UIImageView + UILabels |
ns.ContentUnavailable |
Lua helper, not a fake UIView |
| Pull to refresh | .refreshable |
UIRefreshControl |
List/ScrollView.refresh |
controller |
.task |
Swift concurrency | ns.async |
existing | controller |
| Hero carousel | paging TabView + indexViewStyle |
UIPageViewController + UIPageControl |
ns.TabView { style = "page" } |
<TabView style="page"> |
| Horizontal covers | ScrollView(.horizontal) |
UIScrollView |
ns.ScrollView { horizontal = true } |
<ScrollView horizontal="true"> |
| Cover card tap | NavigationLink(value: TextGame) |
pushViewController |
ns.NavigationLink / nav:push / nav:destination |
<NavigationLink> |
| Destination | navigationDestination(for:) |
registered factory | nav:destination(key, fn) |
controller |
| Hero bleed | .ignoresSafeArea(edges: .top) |
layout in view.bounds top |
ignoresSafeArea |
attr |
| GameInfo body | ScrollView + VStack |
UIScrollView |
ns.ScrollView |
<ScrollView> |
| Cover image | Image(uiImage:) |
UIImageView |
ns.Image |
<Image src> |
| Chips | Label + SF Symbol |
UILabel + UIImage |
app HStack + SystemImage + Label |
templates |
| Stars | Image(systemName: "star.fill") |
UIImage systemName: |
app five SystemImage |
templates |
| Play CTA | Button + Label + systemImage |
UIButton.Configuration |
ns.Button { style, systemImage } |
<Button> |
| Bottom inset CTA | .safeAreaInset(edge: .bottom) |
extra bottom layout / keyboardLayoutGuide |
safeAreaInset |
attr |
| How to Play link | NavigationLink(destination:) |
push | NavigationLink |
tag |
| Translation | TranslationSession |
— | out of scope (app may call ns.fetch) |
— |
| Session transcript | ScrollView + LazyVStack + ForEach |
UIScrollView + stack |
ns.ScrollView + ns.ForEach / etlua |
templates |
| Scroll to latest | ScrollViewReader / scrollTo |
setContentOffset / scrollRectToVisible |
scrollView:scrollTo(id or "bottom") |
controller |
| Transcript insets | .contentMargins |
UIScrollView.contentInset |
contentInset on ScrollView |
attr |
| Message bubble | Swift UIViewRepresentable text view |
UILabel + layout |
app composition with Label (not a representable, not custom iMessage chrome) |
templates |
| Companion cards | CompanionOptionsSection |
buttons + labels | app composition; not a native class | templates |
| Composer | TextField(axis: .vertical) |
UITextView |
ns.TextField { axis = "vertical", lineLimit } |
<TextField axis="vertical"> |
| Send | Button + arrow.up.circle.fill |
UIButton |
ns.Button + SystemImage |
templates |
| Keyboard | focus + keyboard avoidance | keyboardLayoutGuide |
default on hosting VC | — |
| Suggestions bar | horizontal chips | UIScrollView + UIButtons |
app | templates |
| Compass | custom Path + drag |
not a system control | app: 8 UIButton + SystemImage in ZStack. Drag-to-select is app-level; no custom-drawn ring in the framework |
<ZStack> |
| Overlay layout | ZStack(alignment: .bottom) |
sibling UIViews measured then placed |
ns.ZStack (intrinsic children; fillWidth/fillHeight to stretch) |
<ZStack> |
| Image clip | clipShape(RoundedRectangle) |
CALayer.cornerRadius + masksToBounds |
view cornerRadius accessor (not a panel chrome API) |
attr on Image |
| Disabled controls | .disabled |
UIControl.enabled |
enabled / XML disabled |
attr |
| Color scheme | .preferredColorScheme |
overrideUserInterfaceStyle |
`ns.preferredColorScheme("light" | "dark" |
| Loading overlay | ProgressView centered |
UIActivityIndicatorView |
ns.ProgressView in ZStack |
templates |
| Nav title + subtitle | ToolbarItem(placement: .principal) |
navigationItem.titleView |
ToolbarItem placement="principal" |
<ToolbarItem placement> |
| Trailing gear | ToolbarItem(placement: .navigationBarTrailing) |
UIBarButtonItem |
placement="trailing" |
tag |
| Hide tab bar | .toolbar(.hidden, for: .tabBar) |
hidesBottomBarWhenPushed |
hidesTabBar |
attr |
| Reading sheet | .sheet + presentationDetents |
UISheetPresentationController |
ns.presentSheet |
controller |
| Delete menu | Menu + Button(role: .destructive) |
UIMenu + destructive action |
ns.Menu / Button role="destructive" |
<Menu> |
@AppStorage |
UserDefaults | NSUserDefaults |
ns.Defaults |
controller / Model |
| Reading slider | Slider |
UISlider |
ns.Slider |
<Slider> |
| Segmented pickers | Picker .segmented |
UISegmentedControl |
ns.Picker { style = "segmented" } |
<Picker style="segmented"> |
| Ongoing list | List .plain + swipe delete |
UITableView + UIContextualAction |
ns.List { style = "plain", swipe } |
<List style="plain"> |
| Empty ongoing | ContentUnavailableView |
composed | ns.ContentUnavailable |
helper |
| Settings | List .insetGrouped + Section |
UITableViewStyleInsetGrouped |
style="insetGrouped" |
<List style="insetGrouped"><Section> |
| Settings picker | Picker in List |
menu or pushed list | style="automatic" |
<Picker> |
LabeledContent |
key/value row | UIListContentConfiguration |
HStack of Labels or list row |
templates |
| Destructive list button | Button(..., role: .destructive) |
red UIButton / cell |
role="destructive" |
<Button role="destructive"> |
| HowToPlay | long ScrollView + section headers + SF Symbols |
UIScrollView |
ScrollView + SystemImage + Label |
templates |
| HowToPlay hero | LinearGradient header |
app: VStack of Label/SystemImage on a semantic background. No custom-drawn gradient in the framework |
app | templates |
| Reading settings | Form |
inset grouped UITableView |
<List style="insetGrouped"> (no separate <Form> tag in v1) |
templates |
| Create Game fields | vertical TextField |
UITextView |
axis="vertical" |
<TextField> |
| Dismiss keyboard on scroll | .scrollDismissesKeyboard |
UIScrollView.keyboardDismissMode = .interactive |
scrollDismissesKeyboard="interactively" on ScrollView |
attr |
| Create primary | Button .borderedProminent + safeAreaInset |
borderedProminentButtonConfiguration |
style="borderedProminent" |
<Button style="borderedProminent"> |
| Glass composer | glassEffect / GlassEffectContainer |
iOS 26 system material via UIVisualEffectView |
optional ns.MaterialView { material = "regular" } wrapping system UIVisualEffectView. No custom shadow/corner APIs |
app may wrap composer |
| Catalog HTTP | CloudKit | — | out of scope; apps use ns.fetch / ns.fetch_json |
— |
| Game engine | LuaKit + zilscript | same process Lua | app runtime, not a widget | — |
ForEach stays a Lua helper (AppKit.ForEach already in lua/embedded/AppKit.lua); XML templates keep using etlua <% for %>. Port ForEach onto UIKit.lua.
List XML today requires <Column> children (lua/ui/xml.lua collect). That is AppKit-table-centric.
XML collect becomes permissive: columns optional; <Section> children allowed. ns.List on AppKit still requires columns and errors on insetGrouped with the existing constructor message. iOS schema tests use a fake ns. On UIKit, List is always a UITableView view; replaceRows row shape is { title, subtitle?, systemImage?, destructive?, swipe? } matching LuaTableViewSource (today it joins column values into cell.textLabel). Implement _tableSetRefresh / showLoading / hideLoading in the same PR as swipe/refresh — UIKit.lua already calls bridge._tableSetRefresh, which is not in src/uikit/bridge.m bridge_lib.
Must-have APIs not yet in TAG_SCHEMA (framework, not app):
| API | Why |
|---|---|
ScrollView:scrollTo |
GameSession transcript |
disabled / enabled on Button |
Create Game / composer send |
ns.preferredColorScheme |
reading theme |
view cornerRadius on Image |
cover cards |
ScrollView contentInset |
transcript / horizontal covers |
scrollDismissesKeyboard |
Create Game |
App-composed, listed so ports do not invent native classes: companion cards, chips, stars, bubbles (UILabel), compass as 8 buttons (no drag-to-select ring Path in v1), HowToPlay gradient hero.
-- demo/hello/init.lua
return require("demo.hello.Controller")
-- Controller.lua
local ns = require("ns")
local xml = require("ui.xml")
function Controller:createWindow()
local cfg, refs = xml.renderFile("demo/hello/views/Window.etlua", {}, ns)
self.refs = refs
return ns.Window(cfg)
endmacOS src/main.m already does new() + createWindow(). iOS LRTApplicationController copies that block.
ns.Window(cfg) -- scene installer; not a sized window
ns.HostingController(view)
ns.VStack / HStack / ZStack / Spacer / Divider / Separator
ns.ScrollView { content, horizontal, vertical, refresh, contentInset, scrollDismissesKeyboard }
:scrollTo(id or "bottom")
ns.TabView { style = "tabs"|"page", selected, [Tab...] }
ns.Tab { id, title, systemImage, [child] }
ns.NavigationStack { title, largeTitle, path, [root], hidesTabBar }
:destination(key, fn)
:push(keyOrVC, value?)
:pop()
ns.NavigationLink { value, destination, title, [label child] }
ns.List { style, columns?, sections?, data, refresh, swipe }
ns.Section { title, [rows or views] }
ns.Text / Label / Title / TextField / TextEditor
ns.Button { title, systemImage, style, role, action, menu, enabled }
ns.Toggle { label, is_on, action } -- UISwitch; Switch name deleted
ns.Slider / Stepper / Picker
ns.SystemImage / Image -- Image.cornerRadius clips via CALayer
ns.preferredColorScheme("light"|"dark"|"system")
ns.ProgressView
ns.ContentUnavailable { title, systemImage, description }
ns.MaterialView { material = "regular"|"thick"|"thin", [child] } -- UIVisualEffectView
ns.presentSheet(content, { detents, title })
ns.dismiss()
ns.confirm { title, message, destructive, cancel, action }
ns.Menu { children = { { title, systemImage, role, action } } }
ns.Defaults.get/set
ns.DocumentDirectory()
ns.onScenePhase(fn)
ns.ForEach / Group
ns.async / sleep / fetch / fetch_json / json_parseTabView = {
constructor = "TabView",
children = "array",
props = { style = "str", selected = "str" },
}
Tab = {
kind = "record",
flag = "__tab",
children = "content",
props = {
id = "str",
title = { aliases = { "label" }, type = "str", default = "" },
systemImage = "str",
},
}
NavigationStack = {
constructor = "NavigationStack",
children = "content",
props = {
title = "str",
largeTitle = "bool",
hidesTabBar = "bool",
hidesNavigationBar = "bool",
},
}
NavigationLink = {
constructor = "NavigationLink",
children = "content",
props = { value = "str", destination = "str", title = "str" },
}
ZStack = { constructor = "ZStack", children = "array", props = { alignment = "str" } },
Section = {
kind = "record",
flag = "__section",
children = "array",
props = { title = "str" },
}
Menu = { kind = "record", flag = "__menu", children = "items" },Extend existing tags (no aliases of old names):
Window: ignore width/height on UIKit; keep for AppKit.List:styleaddsinsetGrouped;collectacceptsSectionchildren; columns no longer mandatory in XML. AppKitns.Liststill requirescolumnsat runtime.TextField:axis("vertical"),lineLimit.Button:role("destructive"),stylealready exists,disabledbool.Picker:style("segmented"|"menu"|"wheel"|"automatic").ToolbarItem:placement("trailing"|"leading"|"principal"|"confirmation"). On iOS, collectToolbarItemchildren ofNavigationStack(new collect hook) intoUINavigationItembar buttons. Window-levelcfg.toolbarstays AppKit-only;UIKit.Windowignores it. GameSession trailing items live inside the stack, not on the scene window.ScrollView: already hashorizontal/vertical; addcontentInset,scrollDismissesKeyboard.Image:cornerRadiusnumber (CALayer clip). Not a panel chrome API.- Layout props:
ignoresSafeArea(string:top/all/bottom).
xml.render default module: require("ns").
docs/agents/xml-syntax.md must be updated in the same PR as the schema (existing drift rule).
UIKit.Window480×360 path andbridge._window(title, width, height, ...).UIKit.Switchas the public name (XML aliasSwitch→Toggleremains, because that is the XML tag alias table, not a Lua API shim).- Any idea of loading
UIKit.dylibfromsrc/host.c. - AppKit canvas eval as an iOS preview backend.
No database. Persistence:
- Dev source: Mac disk, streamed by the packager (Lua, etlua, and assets). The
.appholds none of it. - App defaults:
NSUserDefaultsvians.Defaults. - App files: Documents directory (saves, not source).
Hot reload keeps no Lua state: every save boots a fresh lua_State. State survives only when the app writes Defaults or files itself.
Honest split: UIKit constructors cannot run on macOS. UIView is not available in the AppKit host. Do not pretend otherwise.
| File | Asserts |
|---|---|
tests/uikit_api.test.lua |
PR1. Parse lua/embedded/UIKit.lua source: Window / Toggle / SystemImage / HostingController exist; no bridge._window(; Switch constructor gone; Text aliases Label. Do not require("UIKit") on macOS. |
tests/xml_ios_schema.test.lua |
New tags exist in xml.schema; fake ns records constructor calls for TabView, NavigationStack, ZStack, Section, vertical TextField, Picker style=segmented; require("ns") default; unknown tags still error |
tests/xml.etlua.test.lua |
List without columns for insetGrouped compiles against fake ns; existing AppKit ns.List tests still require columns and pass |
tests/packager.test.lua |
Requires lua/packager/paths.lua (the real mapping): jail (.., absolute, symlink escape), module map, kind classification. No sockets. |
tests/examples.test.lua |
iOS-capable examples still load under AppKit |
Makefile targets (not part of default make test — they boot a simulator):
make ios-test # xcodebuild test -scheme LuaRuntime
make ios-dump-layout ARGS=demo/hello OUT=/tmp/ios-layout.xml
make ios-screenshot ARGS=demo/hello OUT=/tmp/ios-hello.pngHost XCTest (or a Lua script launched with LUA_OBJC_DUMP_LAYOUT) constructs VStack+Label+Button+TabView and asserts class names via the dump:
UITabBarController,UINavigationController,UILabel,UIButton,UITableView.- Flags analogous to AppKit:
cropped,outsideParent,contentClipped.
LuaLayoutDump.m mirrors src/appkit/layout_debug.m: walk window.rootViewController.view, emit XML, mark clipping. Application Lua must not build this tree by hand.
Visual QA (required for UI PRs, same bar as AppKit): launch hello and controls, rotate if needed, light and dark (LUA_OBJC_APPEARANCE or simctl ui booted appearance), keyboard on a TextField, tab switching, push/pop.
Detected on this machine: Xcode 26.6, iPhoneSimulator 26.5 SDK, devices iPhone 17 / 17 Pro / 17 Pro Max / 17e / iPhone Air. xcode-select may point at CommandLineTools; ios targets must set DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
Lua 5.4.8: vendor the official tarball under vendor/lua-5.4.8/. Pin URL + SHA-256 in vendor/README. Compile as build/ios/liblua.a with xcrun --sdk iphonesimulator, -DLUA_USE_IOS, no readline, exclude lua.c/luac.c, ARCHS=arm64.
Proposed Makefile additions:
DEVELOPER_DIR ?= /Applications/Xcode.app/Contents/Developer
IOS_SIM_SDK := $(shell DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun --sdk iphonesimulator --show-sdk-path)
IOS_MIN := 26.5
DEVICE ?= iPhone 17
HOST_BUNDLE := build/ios/LuaRuntime.app
PACKAGER := build/lua-objc-packager
ios-host: $(HOST_BUNDLE)
$(HOST_BUNDLE):
DEVELOPER_DIR=$(DEVELOPER_DIR) xcodebuild \
-project ios/LuaRuntime.xcodeproj \
-scheme LuaRuntime \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=$(DEVICE)' \
-derivedDataPath build/ios/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
# copy .app to $(HOST_BUNDLE)
ios-packager: $(PACKAGER)
$(PACKAGER): src/packager/packager.m
$(CC) -fobjc-arc -framework Foundation -framework Network \
-o $@ src/packager/packager.m
ios-sim: ios-host
DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun simctl boot "$(DEVICE)" || true
DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun simctl bootstatus "$(DEVICE)" -b
DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun simctl install booted $(HOST_BUNDLE)
SIMCTL_CHILD_LUA_OBJC_APP="$(or $(ARGS),demo/hello)" \
SIMCTL_CHILD_LUA_OBJC_PACKAGER="$(PACKAGER_URL)" \
SIMCTL_CHILD_LUA_OBJC_APPEARANCE="$(APPEARANCE)" \
DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun simctl launch \
--console --terminate-running-process booted org.luaobjc.host
ios-run: ios-host ios-packager
# start packager in the background, poll GET /health, trap EXIT to kill it,
# then ios-sim with PACKAGER_URL=http://127.0.0.1:8081
ios-screenshot:
# device-frame PNG (status bar included), not AppKit contentView
DEVELOPER_DIR=$(DEVELOPER_DIR) xcrun simctl io booted screenshot $(or $(OUT),/tmp/ios-screenshot.png)
ios-dump-layout: ios-host
# 1. launch with SIMCTL_CHILD_LUA_OBJC_DUMP_LAYOUT=<container-relative file>
# 2. wait for the host to exit
# 3. DATA=$$(xcrun simctl get_app_container booted org.luaobjc.host data)
# 4. cp "$$DATA/tmp/layout.xml" $(or $(OUT),/tmp/ios-layout.xml)make uikit remains a dylib compile-check or becomes an alias of ios-host once the host links the same sources. Prefer aliasing after PR1 so we do not maintain two iOS link lines. Until then, keep the dylib target but do not load it.
Info.plist essentials:
CFBundleIdentifier=org.luaobjc.hostUIApplicationSceneManifestwithLRTSceneDelegateNSAllowsLocalNetworking= true (ATS exception domains for127.0.0.1are unreliable; ATS keys are hostnames)UILaunchScreenemptyMinimumOSVersion= 26.5CFBundleDisplayName=lua-objc
Requires Xcode 26 with the iOS 26.5 simulator runtime. If xcode-select -p is CommandLineTools, export DEVELOPER_DIR first.
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
make ios-run ARGS=demo/hellosimctl boot only starts the runtime. ios-run opens Xcode’s Simulator at $DEVELOPER_DIR/Applications/Simulator.app (do not open -a Simulator — on this machine that name is a different app). Logs in the terminal (boot ok) mean the process is running; the UI is the iPhone window, not the terminal.
That is the whole loop. It starts the packager (http://127.0.0.1:8081, ws://127.0.0.1:8081/hot), boots the Simulator if needed, opens Simulator.app, installs the already-built host if it is missing, and launches with SIMCTL_CHILD_LUA_OBJC_PACKAGER / LUA_OBJC_APP. Lua, templates, and assets stream from the packager. Edit demo/hello/views/Window.etlua, Controller.lua, or an image and save: the Simulator updates without quitting; Model state remains. No make, no xcodebuild, no reinstall, no simctl launch.
The host binary is built once (CI or the first make ios-host on a clean machine). After that, ios-run does not rebuild it.
Manual equivalent (no --env; simctl reads SIMCTL_CHILD_* from this process):
make ios-packager ARGS=demo/hello # terminal 1
# terminal 2:
xcrun simctl boot "iPhone 17" || true
xcrun simctl bootstatus "iPhone 17" -b
open -a "$DEVELOPER_DIR/Applications/Simulator.app" \
--args -CurrentDeviceUDID "$(xcrun simctl list devices booted -j | python3 -c 'import json,sys; d=json.load(sys.stdin);
[print(x["udid"]) for devs in d["devices"].values() for x in devs if x.get("state")=="Booted"]')"
xcrun simctl install booted build/ios/LuaRuntime.app # only if not already installed
SIMCTL_CHILD_LUA_OBJC_PACKAGER=http://127.0.0.1:8081 \
SIMCTL_CHILD_LUA_OBJC_APP=demo/hello \
xcrun simctl launch --console --terminate-running-process booted org.luaobjc.hostAppearance:
xcrun simctl ui booted appearance dark
xcrun simctl ui booted appearance light
# or
SIMCTL_CHILD_LUA_OBJC_APPEARANCE=dark \
xcrun simctl launch --terminate-running-process booted org.luaobjc.hostScreenshot / layout dump:
make ios-screenshot ARGS=demo/hello OUT=/tmp/ios-hello.png
make ios-dump-layout ARGS=demo/hello OUT=/tmp/ios-layout.xml
rg -n 'cropped="true"|outsideParent="true"|contentClipped="true"' /tmp/ios-layout.xmlDevice override:
make ios-run DEVICE="iPhone 17 Pro" ARGS=demo/helloChanging the native bridge (.m, Lua 5.4.8 C sources) is a framework change, not this loop: make ios-host then launch again. The packager will not tell you to do that. lua/embedded/UIKit.lua is streamed and does not need a host rebuild.
Rejected. bridge_window already requires UIWindowScene. src/host.c is a macOS C executable that dlopens AppKit. UIKit is not available as a windowing API on macOS in this configuration. The dylib is built with -undefined dynamic_lookup and Homebrew Lua headers; it is not an iOS process. Severity if attempted: immediate runtime failure.
Rejected. The product requirement is iPhone Simulator: phone chrome, tab bar, navigation stack, safe area, keyboard. Catalyst would produce a Mac idiom and skip the coverage target.
Rejected. The project rule is native UIKit from Lua, never a decorative or extra framework layer. SwiftUI in the middle adds a second layout system, preview fragility (the README’s original motivation), and hides UITabBarController behind SwiftUI’s TabView. Lua would no longer own the control instances.
Rejected. Product is UIKit widgets from Lua, not Yoga + RN components. RN would duplicate the packager idea while abandoning the native bridge already in src/uikit/.
lua/ui/viewdesc.lua already describes tag/props/children diffs. True reconciliation would preserve first responder, scroll position, and navigation stack.
v1 choice: recycle the lua_State and rebuild rootViewController. Keeping the state alive to preserve Model leaked every previous view tree (Key Decision 6). Reasons for rebuilding rather than diffing:
- lua-objc construction is still eager (
ARCHITECTURE.md: leaf controls fit the eager bridge; structural changes need retained descriptions — that is a later step, not a blocker for a host). - A wrong-but-complex diff on
UINavigationControllerchildren would ship bugs (duplicate VCs, leaked delegates) in the first iOS PRs. - Rebuild is independently testable: dump the new hierarchy after save.
Reconciliation is a follow-up PR that applies viewdesc.apply inside LuaHostingController and, separately, diffs tab/nav children. Do not block the host on it.
Viable for clang + codesign -s -, and consistent with AppKit’s Makefile. Rejected as the only path: xcodebuild test, lldb, and Instruments expect a scheme. Commit ios/LuaRuntime.xcodeproj and wrap it with Makefile targets.
Workable but worse: delayed refresh, more HTTP chatter, harder “hello” handshake. Network.framework WebSocket on both sides is available on macOS 26 / iOS 26. Use WS.
| Threat | Severity | Mitigation |
|---|---|---|
| Packager serves arbitrary filesystem paths | High | Jail to repo root; reject .., absolute paths, and symlinks that escape. /file serves assets from the same jail |
| Simulator talks to a non-local packager | Medium | Default bind 127.0.0.1; ATS exception only for localhost. Device PR must not keep a wide ATS hole |
| Hot reload executes newly saved Lua in-process | Medium (dev-only) | Dev workflow, not a production server. Bundled mode does not connect |
ns.Defaults / Documents contain app data |
Low | Standard iOS sandbox; no iCloud in v1 |
| Error overlay shows source paths | Low | Dev-only; do not log packager file bodies at info level |
No auth on the packager: it is localhost-only. Do not add a public bind in v1.
- Packager: stdout lines
watch,get /file,ws update(path + kind). - Host:
NSLog/ stderr throughreport_lua_error(src/shared/lua_error.m). Prefix[lua-objc]. - Redbox on device for Lua errors (message + traceback).
- Layout dump XML for geometry regressions (
cropped,outsideParent,contentClipped). - No telemetry. No crash reporter in v1; Simulator crash logs are enough.
Alerting: not applicable for a local toolchain. CI (later) fails make test on XML schema drift and make ios-dump-layout on cropped="true" for demo/hello at default phone size.
There is no production flag. Staging is:
- Host + packager stream
demo/hello(Lua + assets). No payload inside the.app. - Save a Lua/etlua/asset file; Simulator updates without rebuild.
- Navigation/tabs example (
demo/helloor a newapps/phone— only if needed for tests; prefer extending hello/controls rather than a new app unless XML needs a phone root). - Controls coverage (picker, slider, list swipe, sheet) via
demo/controlson iOS. - Simulator dump/screenshot in the developer loop.
Rollback: macOS AppKit path is untouched except require("ns") and XML schema additions. If iOS host is broken, make / make test on macOS still run. XML tags that AppKit does not implement must error with the existing message: "xml: platform does not support constructor ns.TabView for tag <TabView>" until AppKit constructors exist. AppKit TabView can wrap the already-native bridge_tabview in the same PR that adds the XML tag so macOS examples that do not use it stay green.
| Risk | Severity | Mitigation |
|---|---|---|
| UIKit layout engine lags AppKit; phone UI clips | High | Port measure/distribute from src/appkit/layout.m; dump XML at 393×852 and a smaller phone |
UIViewController vs UIView confusion in XML |
High | kind = "controller", asViewController wrap, tests with fake ns |
Hot reload leaks LuaButtonTarget registry refs |
Medium | Unref on VC dealloc; refresh generation drops old callbacks |
xcode-select points at CLT; xcrun --sdk iphonesimulator empty |
Medium | Makefile sets DEVELOPER_DIR; document it in the how-to |
| Lua 5.4.8 vs LuaKit 5.5 confusion | Low | Vendor 5.4.8 in-tree; comment in vendor/README |
| Ignoring width/height surprises AppKit-first templates on iPhone | Low | Hosting VC always fills the scene; templates that assume 480pt width reflow via flex |
| Rebuild-root drops nav stack during session-screen edits | Low (v1 accepted) | Document; later viewdesc PR |
These are product choices, not architecture holes.
- Should a later workstream add
apps/adventure? This design says no. If yes, it is a separate app folder that consumes the primitives here; it must not live insrc/. - Default device: iPhone 17 is the current Xcode 26.6 non-Pro phone. Confirm if the team prefers 17 Pro.
- iOS 26 liquid glass: AdventureArena’s composer uses
glassEffect. Framework v1 exposesns.MaterialViewover publicUIVisualEffectViewmaterials only. Do we require the new iOS 26 glass material API by name in v1, or isregularsystem material enough until that API is used by a real example? - AppKit constructors for
TabView/ZStackin the same PRs as XML? Yes for TabView (already haveNSTabView) and ZStack. NavigationStack stays iOS-only until a real AppKit container exists; macOS tests use fakens.
Not open: whether to build an iOS host (yes), whether to fake UIKit on macOS (no), whether to use RN (no), whether ns.Window stays a 480×360 phone window (no), whether Fast Refresh may call LuaStateOwner -cancel on the live owner (no), whether app Lua/assets are copied into the .app (no — stream only).
ARCHITECTURE.md— runtime layers, external UIKit state owner, XML, viewdesc,createWindowsrc/README.md— fragment map; UIKit root issrc/uikit/bridge.mMakefile—build/UIKit.dylibcompile-checksrc/host.c— macOS AppKit loader onlysrc/main.mlua_objc_main— arg parsing,package.path,new/createWindow, dump/screenshotsrc/uikit/views.mbridge_window— scene requirementsrc/uikit/bridge.mbridge_lib— current UIKit surfacelua/embedded/UIKit.lua— stub declarative layerlua/ui/xml.luaTAG_SCHEMA— cross-platform tagsdocs/agents/xml-syntax.md— Slider/Stepper/Picker AppKit-only notesrc/appkit/platform.mbridge_watch_file— FSEventssrc/appkit/canvas_eval.m— AppKit-only previewsrc/shared/lua_async.minstall_external_lua_state_ownersrc/appkit/tabview.m— existingNSTabViewlua/ui/viewdesc.lua— future reconciliation- AdventureArena:
ContentView.swift,AdventuresView.swift,GameInfoView.swift,GameSessionView.swift,OngoingGamesView.swift,CreateGameView.swift,SettingsView.swift,HowToPlayView.swift,AppTab.swift - AdventureArena
IPHONEOS_DEPLOYMENT_TARGET = 26.5; LuaKit Lua 5.5 (do not use)
Each PR is independently reviewable and mergeable. Native .m / .lua use tabs. Every PR that changes behavior includes tests as specified.
- Title: Add iPhone Simulator host that streams Lua and assets from a Mac packager
- Files:
vendor/lua-5.4.8/**,vendor/README,ios/LuaRuntime.xcodeproj,ios/LuaRuntime/{main,LRTApplicationDelegate,LRTSceneDelegate,LRTApplicationController,LRTResourceLoader,LRTReloadConnection,LRTErrorViewController,LuaHostingController}.*,ios/LuaRuntime/Info.plist,src/packager/packager.m,lua/packager/paths.lua,Makefile(ios-host,ios-packager,ios-run,DEVELOPER_DIR,SIMCTL_CHILD_*),src/uikit/views.m(bridge_install_scene),src/uikit/runtime.m(uiviewcontrollermetatable + layout accessors hello needs:flexGrow,spacing,fillWidth),src/uikit/constructors.m(systemNameimage,_systemColor;_imageviaNSData),src/uikit/bridge.m(_readFile),lua/embedded/UIKit.lua(start fromUIKitNative; rewriteWindow; addHostingController,Toggle,SystemImage; Label typography),lua/ui/xml.lua(require("ns")default,bridge._readFile),src/main.m(register AppKit asns= same table),tests/uikit_api.test.lua,tests/packager.test.lua - Depends on: none
- Description: Vendor Lua 5.4.8; build an empty
LuaRuntime.app(runtime only — no rsync oflua/orapps/). Compile Sources = host.m+src/uikit_module.m+liblua.aonly. Packager on:8081serves/health/file/module/entryand/hot. Host callsluaopen_UIKitNative, loads streamedUIKit.lua,package.loaded.ns,GET /entry,new():createWindow(). Delete the 480×360 UIKit window.SystemImageviaUIImage systemName:; file images via packager bytes. Operator:make ios-run ARGS=demo/hello. Packager-down is a redbox. Do not rewrite every example torequire("ns")in this PR. Do not ship a bundled-Lua fallback.
PR 2 in the previous draft (packager as a follow-up) is absorbed here: without the packager the host has nothing to run.
- Title: Preserve Model across streamed Lua/asset updates
- Files:
LRTApplicationController.m(fast refresh / full restart; no live-owner-cancel),LRTResourceLoader.m(asset cache bust),demo/hello/Controller.lua(self.model),tests/packager.test.lua(kindincludingasset) - Depends on: PR 1
- Description: Every update drops the changed path from the file cache and recycles the
lua_State(lua_close+ reboot, no host rebuild); see Key Decision 6 for why the state is never kept. Lua errors presentLRTErrorViewController.NSAllowsLocalNetworking.
- Title: Lay out UIKit trees in the scene safe area with keyboard avoidance
- Files:
src/uikit/layout.m(measure/distribute parity with AppKit),src/uikit/bridge.m(named constants),LuaHostingController.m,lua/embedded/UIKit.lua(ignoresSafeArea),lua/ui/xml.lualayout prop, tests/schema - Depends on: PR 1 (hello already has flexGrow/spacing/fillWidth from PR1; still streamed)
- Description: Root width is scene bounds. Default safe area;
ignoresSafeArea="top"for hero bleed.keyboardLayoutGuideon by default. Frame logging is fine here; the XML dump writer is PR 8.
- Title: Add UITabBarController and UINavigationController to UIKit and XML
- Files:
src/uikit/navigation.m(new fragment, included frombridge.m),lua/embedded/UIKit.lua,lua/embedded/AppKit.lua(TabViewwrapping existingbridge_tabviewonly — no AppKit NavigationStack),lua/ui/xml.lua(TabView,Tab,NavigationStack,NavigationLink; ToolbarItem collect on NavigationStack),docs/agents/xml-syntax.md,src/README.md,tests/xml_ios_schema.test.lua - Depends on: PR 1, PR 3
- Description: Real
UITabBarController/UINavigationController.style="page"→UIPageViewController+UIPageControlwithbackgroundDisplayMode = always.NavigationLinkwalksnavigationController;destination(key, fn)keys are strings. Toolbar items on iOS becomeUINavigationItembar buttons (placement).hidesTabBar→hidesBottomBarWhenPushed. Do not useUISegmentedControlas a tab bar. NavigationStack is iOS-only; macOS schema tests use fakens.
- Title: Add UIScrollView and ZStack overlays
- Files:
src/uikit/scroll.m,src/uikit/overlay.m,lua/embedded/UIKit.lua,lua/embedded/AppKit.lua(ZStack),lua/ui/xml.lua, xml-syntax, tests - Depends on: PR 3
- Description: Vertical/horizontal
UIScrollViewwithcontentInset,scrollDismissesKeyboard, and:scrollTo.ZStackmeasures children then places them (no forced shared bounds). AppKitScrollViewalready exists; only add ZStack there.
- Title: Bring UITableView to insetGrouped sections, swipe actions, and refresh
- Files:
src/uikit/tables.m,src/uikit/table_data_source.m,lua/embedded/UIKit.lua,lua/ui/xml.lua(Section, List collect), xml-syntax,docs/tableview_swiftui.md(iOS note),tests/xml_ios_schema.test.lua, tests - Depends on: PR 1, PR 3 (layout constants), PR 4 (schema-test pattern)
- Description:
UITableViewStyleInsetGrouped/Plain. List is always a view. Sections.replaceRowsrow shape{ title, subtitle?, systemImage?, destructive?, swipe? }.UIContextualActiontrailing swipe.UIRefreshControl._tableSetRefresh/showLoading/hideLoadingwith a centeredUIActivityIndicatorView. AppKitns.Liststill requires columns.
- Title: Add UIKit Picker, Slider, Stepper, TextEditor, sheets, menus, Defaults
- Files:
src/uikit/constructors.m,src/uikit/controls.m,src/uikit/defaults.m,lua/embedded/UIKit.lua,lua/ui/xml.lua(Pickerstyle, TextFieldaxis, Buttonrole/disabled, ImagecornerRadius), xml-syntax,demo/controls(must render on UIKit; needs PR3 accessors), tests - Depends on: PR 1, PR 3, PR 4 (sheets sit on a nav/scene)
- Description:
UISegmentedControl/UIMenupickers,UISlider,UIStepper,UITextVieweditor and vertical text field,UISheetPresentationController(presentSheetwith presenter VC),UIAlertControllerconfirm,UIMenu,ns.Defaults,ns.ContentUnavailable(Lua compose),ns.onScenePhase,ns.preferredColorScheme,ns.MaterialViewviaUIVisualEffectView. Update xml-syntax to drop “AppKit-only” for Slider/Stepper/Picker.
- Title: Add UIKit layout dump, simctl screenshots, and simulator tests
- Files:
ios/LuaRuntime/LuaLayoutDump.m,LRTApplicationController.m(SIMCTL_CHILD_LUA_OBJC_DUMP_LAYOUT),Makefile(ios-dump-layoutcopy-out viaget_app_container,ios-screenshot,ios-test),tests/orios/LuaRuntimeTests/, Agents.md / README how-to pointer todocs/ios.md - Depends on: PR 1, PR 3
- Description: Hierarchy XML with clipping flags, written inside the container then copied to
OUTon the Mac.ios-screenshotis a device-frame capture. XCTest or host-flag Lua script asserts hello dump containsUILabeland nocropped="true". Not part of defaultmake test.
- Title: Document iOS host workflow and make controls example cross-platform
- Files:
docs/ios.md(this spec, status → Implemented sections as they land),README.md(Where to work + quick start),docs/agents/xml-syntax.md,docs/agents/quickstart.md,demo/hello/**,demo/controls/**,tests/examples.test.lua - Depends on: PRs 1–8 as features land; can ship incrementally with PR 1’s how-to
- Description: README commands for
make ios-run. Hello/controls XML runs on both platforms. Optional call-site migration torequire("ns"). No AdventureArena port.
- Title: Add apps/adventure Lua app using iOS primitives
- Files:
apps/adventure/{init,Model,Controller,views/**} - Depends on: PRs 1–8
- Description: Only if product wants a demo of the coverage contract. Must be a lua-objc example (MVP folder layout), not a copy of CloudKit/Translation. Out of the framework PRs above.
Round 2 (product): stream Lua and assets only. The host is a runtime with no app payload. No bundled lua//apps/ copy, no NSBundle fallback, no rebuild/reinstall in the operator loop, no native WebSocket event. UIKit.lua is fetched from the packager (luaopen_UIKitNative only). Images and game data use GET /file. make ios-run is the whole command; PR1 includes the packager because without it the host has nothing to run.
Addressed design review (round 1):
- PR1 is a closed boot via the packager: layout accessors hello actually uses;
uiviewcontrollermetatable; Compile Sources = host +uikit_module.m+liblua.aonly. No rsync of Lua into the.app. simctl launchusesSIMCTL_CHILD_*, not--env.ios-runpolls/healthand traps EXIT.- Layout dump writes in the app container; Makefile copies out. Screenshots are device-frame.
- NavigationStack is iOS-only; List is always a view; ZStack measures then places; NavigationLink values are string ids.
- Coverage gaps:
scrollTo,disabled,preferredColorScheme, imagecornerRadius, content inset, Form-as-List, companion cards as app, HowToPlay hero as app. - Packager: BSD sockets, server hello,
NSDatabyte length,NSAllowsLocalNetworking,lua/packager/paths.luaformake test. - Deployment target 26.5;
borderedProminentButtonConfiguration; PR plan split sorequire("ns")migration is not a host blocker.