Guides

Testing with Xcode

Build a minimal iOS host app that embeds the Lynx runtime, loads your bundle from the dev server, and compiles in the native modules Lynx Go cannot provide.

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:

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.

Podfile
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.

HostSources/AppDelegate.swift
import Lynx
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        LynxEnv.sharedInstance()
        return true
    }
}
HostSources/SceneDelegate.swift
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)
    }
}
HostSources/TemplateProvider.swift
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()
    }
}
HostSources/ViewController.swift
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:

project.yml
    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:

HostSources/ViewController.swift
#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:

project.yml
    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:

project.yml
    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:

project.yml
      - 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:

project.yml
      - 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:

HostSources/ViewController.swift
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)
}
Gate the ATS exemption behind Release stripping from the start, because App Review asks about 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.

Testing on your phone

The faster Lynx Go loop, and where it runs out.

Installation

The Vy UI build configuration this host expects.

What is Vue Lynx

The Vue renderer for Lynx and what changes in your code.