@blotch/angular-lynx-native
Builds AngularLynx apps into iOS and Android binaries. You don't call it directly: ng build --platform ios|android|all (the @blotch/angular-lynx-build:application builder) loads it after building the Lynx bundle. It's a dependency of @blotch/angular-lynx-build, which ng add @blotch/angular-lynx installs.
User documentation: Publishing your App. The design and its reasoning: investigations/native-app-builds.md.
What it does
- Generates the native project from
templates/iosortemplates/androidinto the project's folder in Angular's build cache (.angular/cache/<Angular version>/<project>/lynx-native/<platform>, whichng newalready gitignores andng cache cleanclears), writing the app options fromangular.jsoninto the few files the templates mark for it. It regenerates only when a hash of its inputs changes (this package's version, the template, the options, the icon), so Xcode and Gradle keep their incremental state. - Copies
dist/(without the web bundle) into the project. - Builds it with
xcodebuildor Gradle and copies the binaries todist/<platform>/. - Runs it, with
--run(src/ios/run.ts,src/android/run.ts): picks a simulator, emulator or phone (--device, else a running one, else the newest iPhone simulator or the first AVD) and starts it before step 3, so it boots while the app compiles; then installs the app and opens it. iOS usessimctland shows the simulator in DeviceHub (devices://device/open?id=<UDID>) or, before Xcode 27.1, the Simulator app. Android uses the SDK'sadbandemulatorand opens the launcher activity by component.
An ejected project (ios/ or android/ with a .angular-lynx-ejected marker, written by ng generate @blotch/angular-lynx:eject) is built as it is and never regenerated.
The shells
Both are one full-screen LynxView running main.lynx.bundle; Angular Router does all navigation inside it. Bundles and assets load from inside the app, so RSpeedy's default asset prefix (/static/...) works with no configuration. The LynxView fills the screen, under the status bar and the home indicator ("edgeToEdge": false in angular.json puts it inside the safe area instead). Lynx's own CSS env(safe-area-inset-*) is 0 there: Android never sets it, and the iOS shell creates its LynxView before the window is key, where Lynx reads it. The runtime gives every env(safe-area-inset-*) the insets the shell sends instead (see safe-area-insets.ts in the runtime), so they follow rotation. On Android the activity still pads the page for the keyboard, and the bottom inset is 0 while it's open. An <overlay> fills the screen too, so a bottom sheet covers the page down to the screen's edge and pads its content by the insets: on Android XElement's overlay window stops above the navigation bar, so the shell registers its own overlay behavior, EdgeToEdgeOverlay.kt, whose window draws under every bar, as the activity's does.
Both send the same global props, which the runtime's services read: platform, theme (again whenever the phone switches between light and dark), appLocale (the phone's language as a BCP 47 tag), appTimeZone (its time zone's IANA name, which the intl build option's Intl.DateTimeFormat uses), isNotchScreen, edgeToEdge, the safe-area insets safeAreaTop, safeAreaBottom, safeAreaLeft and safeAreaRight (again whenever they change), osVersion (which Lynx leaves out of the main thread's SystemInfo), and the accessibility settings reduceMotion, reduceTransparency (iOS only) and increaseContrast, again whenever the user changes one. Android 12 and later also send dynamicColors, the wallpaper's Material 3 color roles in light and dark (DynamicColors.kt), again when the user changes their wallpaper or colors. Lynx Explorer sends theme and isNotchScreen too, but no appLocale, appTimeZone, osVersion or accessibility settings. See templates/android/README.md for the Android shell's details.
Both register the native modules the runtime's services call, which Lynx Explorer doesn't have: AngularLynxWebAuth (WebAuthModule.swift, WebAuthModule.kt) opens LynxWebAuthSession's sign-in page, in ASWebAuthenticationSession on iOS and a Custom Tab on Android, and returns the URL it redirects to. AngularLynxLocalStorage (LocalStorageModule.swift, LocalStorageModule.kt) keeps LynxLocalStorage's values in a UserDefaults suite and a SharedPreferences file of their own. AngularLynxSecureStorage (SecureStorageModule.swift, SecureStorageModule.kt) keeps LynxSecureStorage's in the Keychain on iOS and, on Android, in SharedPreferences, each value encrypted with AES-GCM by an Android Keystore key, a file Auto Backup leaves out (res/xml/backup_rules.xml, data_extraction_rules.xml). AngularLynxLinking (LinkingModule.swift, LinkingModule.kt) opens LynxLinking's links: openURL with UIApplication.open and an ACTION_VIEW intent, openInApp in SFSafariViewController and a Custom Tab, and canOpenURL with UIApplication.canOpenURL and resolveActivity, which answer only for the schemes the generator writes from angular.json's canOpenURLSchemes (with http, https, mailto, tel and sms) into Info.plist's LSApplicationQueriesSchemes and the manifest's <queries>. AngularLynxImagePicker (ImagePickerModule.swift, ImagePickerModule.kt) lets the person pick a photo for LynxImagePicker, with PHPickerViewController on iOS and the Photo Picker on Android, neither of which needs a permission, and calls back with it as base64, re-encoded as an upright JPEG when the app asks for a smaller one or the photo is a format such as HEIC. Android also has AngularLynxShell, for the back button.
AngularLynxClipboard (ClipboardModule.swift, ClipboardModule.kt) copies LynxClipboard's text to UIPasteboard.general on iOS and ClipboardManager on Android, and reads it back.
Both link Lynx's XElement library, which holds <input>, <textarea>, <overlay>, <svg>, <blur-view>, <viewpager>, <refresh>, the <scroll-coordinator> family, <webview> and <video>. Lynx's core doesn't include these, and a host that doesn't register them renders nothing for those tags (on Android, nothing after them either). XElement's AnimaX and Markdown elements are left out: the runtime doesn't support them. On iOS, <blur-view> is XElement's with more of UIKit in it (App/BlurView.swift): a blur-material attribute for UIKit's adaptive blur materials, which the glass components draw their design before iOS 26 on (Lynx's blur-view has only the styles from before iOS 13); Liquid Glass that materializes, dissolves and eases between effects inside a glass-container, where Lynx's sets its effect at once; and a glass-effect-id attribute that pairs two glass shapes across a change so one morphs into the other (App/GlassTransitions.swift).
Both have <canvas> with WebGL2 (issue #211), which Lynx doesn't: a canvas element (App/AngularLynxWebGL.m's AngularLynxCanvas, a CAMetalLayer view; CanvasUI.kt, a TextureView) and the AngularLynxWebGL module the runtime's WebGL2 context sends its calls to, which runs them on OpenGL ES 3.0 (App/AngularLynxWebGLContext.m, WebGLContext.kt). On iOS that's ANGLE's OpenGL ES, on Metal, which Safari draws WebGL with too: iOS's own OpenGL ES is deprecated, and the Simulator runs it on the CPU, at seconds a frame for a lit three.js scene (issue #238), where Metal draws on the Mac's GPU. ANGLE's libEGL and libGLESv2 frameworks, and its headers, come in the Lynx frameworks zip (below). Android's OpenGL ES is the phone's, which the emulator already runs on the Mac's GPU. The runtime runs on Lynx's main thread and WebGL needs answers synchronously, so both turn on main-thread modules (enableMTSModule): the config's modules are then callable from main-thread JavaScript as lynx.module(name). AngularLynxAssets (App/AssetsModule.swift, AssetsModule.kt) reads a file the app bundles for the main thread's fetch, which has no page URL to resolve /static/... against. The command numbers the WebGL module reads (App/AngularLynxWebGLOps.h, WebGLOps.kt) are generated from the runtime's canvas/webgl-ops.ts with npm run generate:webgl-ops -w packages/native; scripts/webgl-ops.test.ts fails when they're stale. UIKit's module imports Apple's OpenGL ES headers into every file, so AngularLynxWebGLContext.m turns off their deprecation warnings, which land on ANGLE's functions of the same names; the explicit link to libGLESv2 comes before UIKit's automatic one to OpenGLES, so those calls bind to ANGLE.
| iOS | Android | |
|---|---|---|
| Language | Swift, scene lifecycle | Kotlin |
| Lynx | 4.1.0, prebuilt XCFrameworks (below) | 4.1.0 AARs from Maven Central |
| XElement | Linked and registered (App/XElementRegistry.swift) | Linked and registered (XElementBehaviors) |
| Toolchain | Xcode 27, iOS 15+ | AGP 9.4, Gradle 9.6, JDK 17+, SDK 36, minSdk 24 |
| Generated config | Config/App.xcconfig, App/Info.plist markers | angular-lynx.properties, AndroidManifest.xml markers |
Lynx for iOS
Lynx publishes iOS only as source pods, which take minutes to compile and need CocoaPods (and so Ruby). scripts/build-lynx-ios-frameworks.sh compiles them once into XCFrameworks (about 32 MB zipped, with debug symbols in a separate zip): Lynx, PrimJS, XElement and their dependencies, and ANGLE's libEGL and libGLESv2, with ANGLE's headers in ANGLE/include. The builder downloads the zip pinned in src/ios/frameworks.ts (URL and SHA-256) on first use and caches it in ~/.cache/angular-lynx/lynx-ios/<version>-<build>.
ANGLE only builds with Chromium's tooling (depot_tools, GN and Ninja), so the Lynx script runs scripts/build-angle-ios-frameworks.sh, which checks out the ANGLE revision a Chrome stable release shipped (ANGLE_REVISION), builds its Metal backend alone for the device and both Simulator architectures, with ANGLE's own Metal shaders prebuilt (otherwise it compiles them when an app first makes a context, which took 5.7 s on a fresh install in the Simulator), and leaves out the dependencies ANGLE's DEPS fetches for its tests and other backends. The checkout is about 5 GB and the three builds take about a minute and a half together; run it alone (npm run build:angle-ios-frameworks -w packages/native) to try a new revision. ANGLE adds about 6 MB to an app (2 MB compressed in the .ipa), against Lynx's 34 MB, and every app links it, so <canvas> works in every build.
The zip for Lynx 4.1.0 is the lynx-ios-4.1.0-3 release. The build number counts rebuilds of one Lynx version whose frameworks changed (2 added XElement, 3 ANGLE), and is part of the cache folder, so machines with an older zip download the new one. For a new Lynx version, bump the versions in the script and reset BUILD to 1; to change the frameworks of the same version, bump BUILD (in the script and in LYNX_IOS). Then run the Lynx iOS frameworks workflow with publish (or the script locally), and pin the new zip's URL and SHA-256. To test a locally built folder before publishing it:
npm run build:lynx-ios-frameworks -w packages/native # needs Xcode, Ruby >= 2.7 with Bundler for CocoaPods, and Python 3 for ANGLE (the script downloads Xcode's Metal toolchain if it's missing)
export ANGULAR_LYNX_IOS_FRAMEWORKS=$PWD/packages/native/.lynx-ios-build/out/LynxFrameworks
Changing a template
- iOS:
App/is a synchronized folder, so adding or removing a Swift file needs no project change. For anything else (a build setting, a framework, a resource folder), editAPP_PROJECTinscripts/generate-ios-project.ts.App.xcodeprojisn't in git: theiosTemplateProjectVite plugin invite.config.tsrenders it with@bacons/xcode, which needs only Node, on everynpm run buildandnpm test, and the npm package ships the result. Runnpm run generate:ios-project -w packages/nativeto render it without the TypeScript build, e.g. to open the template in Xcode. - Both: run
npm run e2e -- --ios --shell(and Layer 6 for Android, see the E2E README) to build and tap through a fresh app on each platform.