Testing with Xcode
Lynx Go stops being enough the moment your app needs something the runtime does not ship. Camera capture, biometrics, keychain storage, push notifications, custom fonts, permission strings, and every third-party SDK live in native code compiled into an application, and Lynx Go carries only what its authors compiled into theirs.
A host of your own is one Xcode target, four Swift files, and a Podfile, and it does five things: boot the Lynx runtime, provide a template that fetches bytes, build a LynxView sized to the screen, load a URL into it, and fall back to an embedded bundle in Release. Each file below is one of those steps.
Everything here comes from a host running Vy UI components and a custom camera module on a physical iPhone, verified against Lynx 3.9.0 and Xcode 26. Your pnpm dev loop keeps working, because the host fetches the same bundle the QR code points at.
What you need once
- Full Xcode with any Apple ID signed in is enough, since a free personal team can install on your own device.
- CocoaPods and XcodeGen come from
brew install cocoapods xcodegen. - The iPhone needs Developer Mode, under Settings → Privacy & Security.
- The phone and the Mac have to share a network, because the host fetches the bundle over HTTP.
Generating the project instead of committing it
An .xcodeproj in git is a reliable way to generate merge conflicts nobody can read, so XcodeGen generates this one from a declarative spec. You commit the spec, the Podfile, and the Swift sources, and everything else is disposable. Create native/ios-host/project.yml:
name: VyHost
options:
bundleIdPrefix: com.example
deploymentTarget:
iOS: '15.0'
createIntermediateGroups: true
schemes:
VyHost:
build:
targets:
VyHost: all
run:
config: Debug
targets:
VyHost:
type: application
platform: iOS
sources:
- path: HostSources
- path: Assets.xcassets
info:
path: Info.plist
properties:
UILaunchScreen:
UIColorName: LaunchBackground
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
# UIKit warns and eventually asserts without a scene manifest.
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
UISceneConfigurations:
UIWindowSceneSessionRoleApplication:
- UISceneConfigurationName: Default
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
NSLocalNetworkUsageDescription: Dev builds load the app from your Mac over the local network.
NSAppTransportSecurity:
# Dev only. The Release build phase below strips this from the product.
NSAllowsArbitraryLoads: true
settings:
base:
SWIFT_VERSION: '5.9'
IPHONEOS_DEPLOYMENT_TARGET: '15.0'
CODE_SIGN_STYLE: Automatic
Pinning the pods
Lynx and PrimJS version independently, which is what a hand-written Podfile gets wrong first. Lynx 3.9.0 pairs with PrimJS 3.8.0-alpha.6, and CocoaPods will not resolve a prerelease unless you pin it explicitly. Take the current compatible set from the "Integrate with Existing Apps" guide on lynxjs.org before copying these numbers.
source 'https://cdn.cocoapods.org/'
platform :ios, '15.0'
# Swift code doing `import Lynx` needs the pods built as importable modules.
use_modular_headers!
target 'VyHost' do
pod 'Lynx', '3.9.0', :subspecs => ['Framework']
pod 'PrimJS', '3.8.0-alpha.6', :subspecs => ['quickjs', 'napi']
# Host services: image loading, logging, HTTP.
pod 'LynxService', '3.9.0', :subspecs => ['Image', 'Log', 'Http']
pod 'SDWebImage', '5.15.5'
pod 'SDWebImageWebPCoder', '0.11.0'
# Extended built-in elements (<x-input> and friends) that Vy UI renders.
pod 'XElement', '3.9.0'
end
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['GCC_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
# The Lynx podspec attaches -Werror as a per-file compiler flag, which the
# build-settings override above cannot reach. On recent Xcode this fails the
# build on iOS-13-era deprecations inside Lynx itself.
next unless target.respond_to?(:source_build_phase)
target.source_build_phase.files.each do |file|
flags = file.settings && file.settings['COMPILER_FLAGS']
next unless flags && flags.include?('-Werror')
file.settings['COMPILER_FLAGS'] = flags.gsub('-Werror', '-Wno-error')
end
end
end
XElement is easy to leave out and annoying to debug, because several Vy UI components render Lynx's extended elements and come up empty rather than throwing without it.
The Swift side
Here are all four files in step order, and the three non-obvious lines are explained underneath. Skip ahead if your host already boots.
import Lynx
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
LynxEnv.sharedInstance()
return true
}
}
import Lynx
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = ViewController()
window.makeKeyAndVisible()
self.window = window
}
func sceneDidBecomeActive(_ scene: UIScene) {
// safe-area insets read as 0/0 when initLayoutConfig runs before the
// window is key. Re-running it here is a harmless refresh.
guard let window else { return }
LynxEnv.sharedInstance().initLayoutConfig(window.bounds.size)
}
}
import Foundation
import Lynx
final class TemplateProvider: NSObject, LynxTemplateProvider {
func loadTemplate(withUrl urlString: String!, onComplete callback: LynxTemplateLoadBlock!) {
guard let urlString, let url = URL(string: urlString) else {
callback?(nil, NSError(
domain: "VyHost",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Bad bundle URL: \(urlString ?? "nil")"]
))
return
}
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
URLSession.shared.dataTask(with: request) { data, _, error in
callback?(data, error)
}.resume()
}
}
import Lynx
import UIKit
class ViewController: UIViewController {
private var lynxView: LynxView?
override func viewDidLoad() {
super.viewDidLoad()
let lynxView = LynxView { builder in
let config = LynxConfig(provider: TemplateProvider())
// Register custom native modules here:
// config.register(MyNativeModule.self)
builder.config = config
builder.screenSize = self.view.frame.size
builder.fontScale = 1.0
}
lynxView.preferredLayoutWidth = view.frame.size.width
lynxView.preferredLayoutHeight = view.frame.size.height
lynxView.layoutWidthMode = .exact
lynxView.layoutHeightMode = .exact
view.addSubview(lynxView)
self.lynxView = lynxView
lynxView.loadTemplate(fromURL: bundleURL, initData: nil)
}
}
AppDelegate has nothing to wire beyond LynxEnv, because the LynxService pods register themselves once linked.
The initLayoutConfig call in sceneDidBecomeActive costs an afternoon if you meet it cold. Lynx reads env(safe-area-inset-*) from statics that call fills from the key window, which is still nil while viewDidLoad builds the LynxView, so the insets arrive as zero and the layout sits under the notch with nothing logged. Re-running it once the scene is active is the fix.
The template provider asks for reloadIgnoringLocalCacheData because the dev server rewrites the bundle on every save, and URLSession otherwise hands you the previous one.
Making the dev loop bearable
Hardcoding http://192.168.1.x:3000/main.lynx.bundle works until you join a different network, and then it produces a blank screen with no explanation. A build phase under the target in project.yml writes the Mac's current LAN IP into the app bundle instead:
postBuildScripts:
- name: Write dev server host
basedOnDependencyAnalysis: false
script: |
set -u
if [ "${CONFIGURATION}" = "Release" ]; then
echo "Release build — skipping DevServerHost.txt."
exit 0
fi
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo localhost)
DEST_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
mkdir -p "${DEST_DIR}"
printf '%s\n' "${ip}" > "${DEST_DIR}/DevServerHost.txt"
echo "Dev server host: ${ip}"
Writing to TARGET_BUILD_DIR keeps the generated file out of the repository. The app then probes ports 3000 through 3010 on that host and takes whichever answers first, so the port shuffling that happens with two projects open stops mattering:
#if DEBUG
private let devServerPorts = Array(3000...3010)
private let devServerHost: String = {
guard let url = Bundle.main.url(forResource: "DevServerHost", withExtension: "txt"),
let contents = try? String(contentsOf: url, encoding: .utf8)
else { return "localhost" }
let host = contents.trimmingCharacters(in: .whitespacesAndNewlines)
return host.isEmpty ? "localhost" : host
}()
/// Probes each candidate port with a cheap HEAD request; first 2xx wins.
private func discoverDevServer(completion: @escaping (String?) -> Void) {
func tryPort(at index: Int) {
guard index < devServerPorts.count else { return completion(nil) }
let candidate = "http://\(devServerHost):\(devServerPorts[index])/main.lynx.bundle"
guard let url = URL(string: candidate) else { return tryPort(at: index + 1) }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 1.0)
request.httpMethod = "HEAD"
URLSession.shared.dataTask(with: request) { _, response, _ in
if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
completion(candidate)
} else {
tryPort(at: index + 1)
}
}.resume()
}
tryPort(at: 0)
}
#endif
Adopting LynxViewLifecycle and rendering didRecieveError into an on-screen label is worth the twenty lines, because the default failure mode is a blank screen with the real message buried in the Xcode console.
Running it
cd native/ios-host
xcodegen generate # writes VyHost.xcodeproj from project.yml
pod install # writes VyHost.xcworkspace
open VyHost.xcworkspace
Open the workspace rather than the project, or the pods will not be linked, and re-run both commands after editing project.yml or the Podfile. In Xcode, pick your iPhone as the destination, set your team under Signing & Capabilities, and run with pnpm dev already going so there is a server to find. Saving a component then reloads the bundle on the device, and Xcode is only for native changes after that.
Registering native modules
A custom native module is a Swift or Objective-C class registered on the LynxConfig the LynxView builds with:
let config = LynxConfig(provider: TemplateProvider())
config.register(MyCameraModule.self)
config.register(MyStorageModule.self)
builder.config = config
The per-view config above is invisible to the global registration helpers some packages ship, so register explicitly on the config you construct. Compiling the module's sources directly beats wiring a podspec, and XcodeGen pulls them straight from an installed package:
sources:
- path: HostSources
- path: ../../node_modules/@some/native-package/ios
name: NativePackage
excludes:
- 'README.md'
That path needs a name when createIntermediateGroups is on, because XcodeGen otherwise nests the group under the ../.. tree it derives for node_modules, doubles the path, and finds none of the files.
Custom fonts
A font referenced with url(...) from your CSS gets rewritten to webpack:///static/font/…, which native Lynx cannot fetch. Registering the font in the host and calling it local("Family Name") keeps the bundle small:
sources:
- path: ../ios/Resources/Fonts/YourFont-Regular.ttf
group: Fonts
buildPhase: resources
info:
properties:
UIAppFonts:
- YourFont-Regular.ttf
The name in your CSS has to match the font's internal family, full, or PostScript name rather than the filename. Inlining it as a data URI is the alternative, and the only option if the app also runs in Lynx Go.
Shipping a Release build
A Release build cannot reach your dev server, so the bundle travels inside the app, and one build phase produces it and copies it in:
- name: Embed Lynx bundle
basedOnDependencyAnalysis: false
script: |
set -u
if [ "${CONFIGURATION}" != "Release" ]; then
echo "Debug build — loading from the dev server instead."
exit 0
fi
APP_DIR="${SRCROOT}/../.."
# Xcode script phases get a minimal PATH; node is often managed by fnm.
export PATH="/opt/homebrew/bin:/usr/local/bin:${HOME}/.local/share/fnm/aliases/default/bin:${PATH}"
PNPM="$(command -v pnpm || true)"
if [ -z "${PNPM}" ]; then
echo "error: pnpm not found on PATH. Run \`pnpm build\` manually, then rebuild."
exit 1
fi
(cd "${APP_DIR}" && "${PNPM}" build) || exit 1
DEST_DIR="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
mkdir -p "${DEST_DIR}"
cp "${APP_DIR}/dist/main.lynx.bundle" "${DEST_DIR}/main.lynx.bundle"
Running pnpm build from a script phase needs ENABLE_USER_SCRIPT_SANDBOXING: 'NO' in the target's base settings, since the sandbox otherwise blocks writes outside the build directory.
A second phase strips the development-only Info.plist keys, so the shipped app carries no ATS exemption and never prompts for local network access:
- name: Strip dev Info.plist keys
basedOnDependencyAnalysis: false
script: |
set -u
[ "${CONFIGURATION}" = "Release" ] || exit 0
PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
/usr/libexec/PlistBuddy -c "Delete :NSAppTransportSecurity" "${PLIST}" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Delete :NSLocalNetworkUsageDescription" "${PLIST}" 2>/dev/null || true
Loading the embedded bundle is a #if DEBUG branch away from the dev-server path, which closes the fifth step:
private func loadEmbeddedBundle() {
guard let url = Bundle.main.url(forResource: "main.lynx", withExtension: "bundle") else { return }
guard let data = try? Data(contentsOf: url) else { return }
lynxView?.loadTemplate(data, withURL: url.absoluteString)
}
NSAllowsArbitraryLoads and a build phase that deletes it is easier to explain than keeping it.When it does not work
A blank screen means the bundle fetch failed, so open the URL in Safari on the phone and blame the network if it fails there too.
An engine version mismatch on load means the Lynx pod and your rspeedy version disagree, so match the pod to what lynxjs.org pairs with your @lynx-js/rspeedy.
Pod resolution failing usually means the pinned set has drifted, and PrimJS does not track Lynx's numbering.
-Werror errors inside the Lynx pods mean the post_install hook did not run or complete, which shows in the tail of pod install.
Layout sitting under the notch is the safe-area problem above, so confirm initLayoutConfig runs from sceneDidBecomeActive.
Components rendering empty where you expect an input or a text field usually means the XElement pod is missing.
Related
Testing on your phone
How to run a Vue Lynx app on a real iPhone or Android device with Lynx Go, and where the QR-code workflow stops being enough.
The rspeedy dev server
What rspeedy dev prints, how to cycle the QR code between entries and URL schemas from the terminal, and which of the three URLs to use when.