Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Документация

iOS

Приложения NSH работают на iOS с представлениями UIKit и текстом CoreText, на устройствах и в симуляторе, начиная с iOS 15.0. Приложения целиком запускаются и упаковываются так же, как на других платформах, а существующее приложение может показывать приложения NSH в своих представлениях: корневой компонент на Rust, который оно линкует, или TSX, который оно загружает, включая загружаемые им подписанные пакеты, в рамках лимитов и возможностей, которые оно задаёт. На iOS os() — это Os::Ios (platform.os — это "ios" в TSX), и действуют свойства ios={{…}} из TSX.

#Требования

  • Xcode на Mac с средой выполнения симулятора iOS (xcodebuild -downloadPlatform iOS устанавливает её). См. Установка.
  • Для приложений нужна iOS 15.0 или новее, на iPhone и iPad.

#Запуск приложения

Приложение на Rust запускается из своего каталога, а приложение на TSX указывает свою точку входа:

nsh run --target ios                                  # a Rust app
nsh run --target ios --device "iPhone 16 Pro"
nsh run App.tsx --target ios                          # a TSX app

nsh использует уже запущенный симулятор или запускает новейший iPhone. Для приложения на Rust он собирает собственный раннер приложения вместе с вашим крейтом, устанавливает его и показывает его логи в терминале; при изменении Rust-кода крейта сборка выполняется снова, а приложение перезапускается. Для приложения на TSX он устанавливает хост для разработки и поддерживает приложение актуальным по мере сохранения файлов, а логи выводит в терминал. --device выбирает симулятор по имени или UDID. Для более быстрого цикла без симулятора nsh run --target catalyst на Mac открывает тот же бэкенд UIKit в окне размером с iPhone на основе Mac Catalyst.

#Упаковка

nsh package --target ios-simulator --run      # .nsh/out/<Name>.app, installed and launched in the simulator
nsh package --target ios --sign "Apple Distribution: Example Inc (ABCDE12345)" --profile AppStore.mobileprovision

Из каталога приложения на Rust приложением становится раннер iOS, собранный с вашим крейтом; приложение на TSX указывает свою точку входа (nsh package App.tsx --target ios-simulator --run).

--target ios создаёт подписанный .ipa для устройств, TestFlight или App Store. Подписывающие идентификаторы, профили provisioning и иконки: Упаковка.

#Встраивание в приложение iOS

Существующее приложение iOS может показывать приложения NSH внутри собственного интерфейса. Приложение iOS с кодом на Rust линкует ваш крейт и крейт nsh-platform-ios и показывает ваш корневой компонент в AppView (Встраивание приложения на Rust). Приложение на Swift линкует NativeShell.xcframework, фреймворк с модулем Swift: вы создаёте NshAppView (UIView), добавляете его в любое место иерархии представлений и загружаете в него приложение на TSX или подписанный пакет. Ваши нативные экраны, навигация и приложение NSH сосуществуют в одной иерархии UIKit.

Каждое представление — это один независимый движок; глобальных синглтонов нет, поэтому приложение может показывать сразу несколько приложений NSH, например по одному на вкладку.

One host app with two NshAppView regions: each is its own engine with its own thread, VM, limits and capabilities; the host grants capabilities, sets limits and registers native components such as a map. One host app with two NshAppView regions: each is its own engine with its own thread, VM, limits and capabilities; the host grants capabilities, sets limits and registers native components such as a map.

#Встраивание приложения на Rust

nsh_platform_ios::AppView — это один движок в виде UIView для приложения iOS, написанного на Rust: он монтирует корневой компонент вашего крейта без участия виртуальной машины JavaScript.

use nsh_platform_ios::{set_ui_sets, AppView, Root, SurfaceOptions};
use objc2::MainThreadMarker;
use objc2_ui_kit::UIView;

fn show(container: &UIView) -> AppView {
    // Once, before the first view: the UI sets the apps import (NSH's and your crate's).
    set_ui_sets(vec![nsh_base::registry(), my_app::registry()]);
    let mtm = MainThreadMarker::new().expect("main thread");
    let app = AppView::new(mtm, container.bounds(), SurfaceOptions::default());
    container.addSubview(app.view());
    app.run(Root::of::<my_app::App>());
    // Keep the AppView: dropping it stops the app.
    app
}

Представление изменяет размер вместе со своим superview. AppView::run заменяет всё, что работало до этого; AppView::load вместо этого запускает бандл TSX (Bundle::source или unsafe { Bundle::bytecode(bytes) } для bundle.nshc в бандле приложения). SurfaceOptions хранит лимиты, предоставленные возможности, каталог данных и нативные компоненты, как и перечисленные ниже члены NshAppView; AppView::set_locale, set_font_scale и set_error_handler соответствуют locale, fontScale и onError. nsh package --target ios собирает такое приложение за вас, с уже смонтированным корневым компонентом.

#Получение фреймворка

Дистрибутив содержит оба варианта:

dist/nsh/embed/ios/NativeShell.xcframework      release: bundles and signed packages
dist/nsh/embed/ios/NativeShellDev.xcframework   also connects to a `nsh dev` server

Из репозитория NSH можно собрать только фреймворки:

cargo xtask ios-embed    # writes dist/nsh/embed/ios/{NativeShell,NativeShellDev}.xcframework

Сборка по умолчанию создаёт статические фреймворки. Чтобы собрать динамические фреймворки, используйте:

cargo xtask ios-embed --dynamic

Динамические фреймворки дают среде выполнения собственный образ библиотеки и собственную таблицу раскрутки стека, что поддерживает хост-приложения, уже линкующие другие среды выполнения Rust. Выберите приведённую ниже настройку встраивания в Xcode в соответствии с собираемым фреймворком.

По умолчанию каждый XCFramework содержит три слайса arm64: устройства iOS, симулятор iOS (Apple silicon) и Mac Catalyst. Минимальная целевая версия развёртывания — iOS 15.0. Модуль Swift компилируется с library evolution, поэтому приложения, собранные другими версиями компилятора Swift, могут его импортировать, а фреймворк линкует всё необходимое: ничего дополнительно добавлять не нужно.

#Добавление в проект Xcode

  1. Перетащите NativeShell.xcframework (или NativeShellDev.xcframework для сборок разработки) в раздел Frameworks, Libraries, and Embedded Content целевого приложения. Выберите Do Not Embed для статических фреймворков по умолчанию или Embed & Sign для динамических фреймворков, собранных с --dynamic.
  2. В Swift: import NativeShell.
  3. В Info.plist приложения установите CADisableMinimumFrameDurationOnPhone в YES: на iPhone с ProMotion анимации приложения тогда работают на 120 Гц, как и прокрутка (у приложений, собранных nsh package, это включено).

#Поставка бандла вместе с приложением

Приложение на Swift поставляет приложения на TSX в виде бандлов. Соберите приложение NSH в папку и добавьте её в ресурсы приложения как folder reference с именем nsh:

nsh build App.tsx --release --out MyApp/nsh

nsh build создаёт:

ФайлСодержимое
bundle.nshcПриложение, скомпилированное в байт-код: загружается быстрее всего.
bundle.jsТо же приложение в виде исходного кода JS.
bundle.js.mapЕго source map (трассировки стека указывают на ваши файлы .tsx).
capabilities.jsonВозможности, которые объявляет приложение, если они есть в nsh.json.
assets/Изображения и шрифты, которые импортирует приложение, по путям относительно точки входа.

#Показ приложения

import NativeShell
import UIKit

final class EmbedController: UIViewController {
    private let app = NshAppView()

    override func viewDidLoad() {
        super.viewDidLoad()
        app.translatesAutoresizingMaskIntoConstraints = false
        app.onError = { message in print("nsh error: \(message)") }
        view.addSubview(app)
        NSLayoutConstraint.activate([
            app.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            app.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            app.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            app.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        guard let url = Bundle.main.url(forResource: "bundle", withExtension: "nshc", subdirectory: "nsh") else { return }
        do {
            try app.loadBundle(at: url)
        } catch {
            print("cannot load: \(error)")
        }
    }

    // Let the embedded app's pages and dialogs decide the status bar.
    override var preferredStatusBarStyle: UIStatusBarStyle { NshAppView.preferredStatusBarStyle }
    override var prefersStatusBarHidden: Bool { NshAppView.prefersStatusBarHidden }
    override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
        NshAppView.preferredStatusBarUpdateAnimation
    }
}

Размер представления — это окно приложения: NSH раскладывает приложение по границам представления и делает это снова при каждом их изменении. Повторная загрузка заменяет запущенное приложение; приложение останавливается при освобождении представления.

Полный пример, приложение UIKit, которое показывает приложение NSH под собственной нативной шапкой, — examples/embed-ios/main.swift.

#NshAppView

NshAppView — это подкласс UIView, изолированный на главном акторе.

ЧленОписание
init(frame: CGRect = .zero, trace: Bool = false)Создаёт представление. trace логирует время каждого коммита. init?(coder:) поддерживает storyboard и nib.
onError: ((String) -> Void)?Получает фатальные ошибки приложения (бандл, который не удалось загрузить, необработанные исключения JS, ВМ, остановленная лимитом) в главном потоке. Ошибки всегда логируются; сборки разработки дополнительно показывают их поверх приложения.
limits: NshLimitsЛимиты ВМ для приложений, загружаемых начиная с этого момента.
grantCapabilities(_ json: String?) throwsМаксимум, что смогут делать приложения, загружаемые начиная с этого момента, в виде JSON в формате nsh.json из capabilities; nil предоставляет то, что объявляет каждое приложение. Выбрасывает NshError при некорректном JSON.
locale: Locale?Язык и регион приложения вместо пользовательских: Intl, locale() и компоненты сразу следуют им, а приложения, загруженные позже, запускаются с ними. nil следует системным.
fontScale: CGFloat?Размер текста вместо размера из Dynamic Type (1 — по умолчанию): тексты и поля ввода заново раскладываются с этим размером, и fontScale() читает его, сразу и для приложений, загруженных позже; длины сохраняют свой размер. nil следует системному.
setThemeOverrides(_ json: String?) throwsБренд хоста поверх темы приложения: файл темы в формате nsh.json из "theme". Его токены имеют приоритет над токенами приложения с теми же именами, в светлом и тёмном режиме, а остальные токены приложения сохраняются. Применяется сразу и к приложениям, загруженным позже; nil возвращает приложению его собственную тему. Выбрасывает NshError для файла, который не разбирается.
storageDirectory: URL?Где приложения, загружаемые начиная с этого момента, хранят своё хранилище; nil: Library/Application Support/nsh/<app id> по собственному id приложения.
cacheOptions: NshCacheOptionsГде приложения, загружаемые начиная с этого момента, хранят свои кэши, загружаемые ими изображения, и сколько хранит каждое: location .system (Library/Caches/nsh, каждое приложение в <app id>), .directory(URL) или .off; images .default (256 МБ), .maxSize(megabytes:) или .off.
loadBundle(at url: URL) throwsЗапускает бандл, созданный nsh build. Ассеты берутся из assets/ рядом с ним; bundle.js.map и capabilities.json рядом с ним тоже читаются.
run(_ package: NshPackage)Запускает проверенный подписанный пакет (см. ниже). Пакет запускается один раз.
loadPackage(at:trust:) async throws -> NshPackage.InfoПроверяет пакет по URL вне главного потока, затем запускает его. Выбрасывает ошибку с причиной отказа; запущенное приложение остаётся.
connectDevServer(_ url: URL) throwsРазработка: загружает с сервера nsh dev и перезагружает при каждом изменении. Выбрасывает ошибку с NativeShell.xcframework, в котором нет поддержки разработки.
register(component:_:)Регистрирует нативный компонент.
params: [String: Any]Параметры запуска приложений, загружаемых начиная с этого момента: приложение читает их при старте как host.params (host).
send(_ name: String, _ data: Any? = nil)Отправляет событие приложению (host.on(name, …)); хранится, пока приложение не начнёт его слушать, или для следующего приложения, пока ни одно не запущено.
handle(_ name: String, _ handler:)Обрабатывает вызовы (host.call) и сообщения (host.post) приложения с именем name в главном потоке: { data, reply in … } или функция async throws { data in … }, результат которой отвечает на вызов.
removeHandler(_ name: String)Удаляет обработчик name: вызовы этого имени из приложения сразу завершаются ошибкой.
static preferredStatusBarStyle, prefersStatusBarHidden, preferredStatusBarUpdateAnimationТо, что запросили запущенные приложения (statusBar их страниц и диалогов, значение по умолчанию nsh.json): возвращайте их из переопределений в hosting view controller.

limits, grantCapabilities, storageDirectory и cacheOptions применяются начиная со следующей загрузки: задавайте их до loadBundle / run.

Каждое приложение хранит своё хранилище под собственным id без какой-либо настройки: подписанный пакет — под id, за который ручается сертификат его ключа релиза, бандл — под id из его nsh.json (app.json рядом с ним, создаётся nsh build). Задайте storageDirectory, чтобы хранить данные приложения в месте по выбору хоста:

app.storageDirectory = FileManager.default
    .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("nsh/com.example.orders")
try app.loadBundle(at: url)

#Общение с приложением

Представление и запущенное в нём приложение общаются через собственный канал (host): параметры запуска, которые приложение читает при старте, события от хост-приложения, а также вызовы и сообщения от приложения, с данными в формате JSON (на этой стороне — объекты Foundation). Для этого не нужны ни представление в приложении, ни нативный компонент.

// Before loading: what the app opens first (`host.params` in TSX).
app.params = ["conversation": notification.conversationID]
try app.loadBundle(at: url)

// The app's `await host.call("user.profile", { id })`: return the answer, or throw to reject it.
app.handle("user.profile") { data in
    let id = (data as? [String: Any])?["id"] as? Int ?? 0
    let profile = try await api.profile(id: id)
    return ["id": id, "name": profile.name]
}

// A handler that answers later, from any thread, with its reply.
app.handle("photo.pick") { _, reply in
    picker.present { url in
        if let url { reply.resolve(url.absoluteString) } else { reply.reject("cancelled") }
    }
}

// The app's `host.post("analytics", …)`: no answer.
app.handle("analytics") { data, _ in analytics.log(data) }

// Later: the app hears `host.on("conversation.open", …)`.
app.send("conversation.open", ["id": tapped.conversationID])

Обработчики выполняются в главном потоке; вызов ждёт ответа в приложении, не блокируя ни одну из сторон. Ответ, освобождённый без ответа, завершает вызов ошибкой, а вызов имени без обработчика сразу завершается ошибкой. События с именами, которые приложение пока не слушает, сохраняются, пока оно не начнёт их слушать, а события, отправленные, когда приложение не запущено (до loadBundle, во время перезагрузки), попадают к следующему, так что нажатое уведомление, пришедшее во время запуска приложения, всё равно до него дойдёт.

#Переопределение темы

Одно приложение, встроенное в несколько брендированных хост-приложений, принимает бренд каждого хоста: хост передаёт токены, которые меняет его бренд, в виде файла темы в формате nsh.json из "theme" (cssVars из shadcn/ui, значения записываются так, как их пишет CSS). Они имеют приоритет над токенами приложения с теми же именами; остальные токены приложения сохраняются. Значение токена для тёмного режима задаётся в dark (токен, заданный только в light или theme, имеет этот цвет в обоих режимах):

try app.setThemeOverrides("""
{ "cssVars": {
    "theme": { "radius": "0.75rem" },
    "light": { "primary": "#e11d48", "primary-foreground": "#ffffff" },
    "dark": { "primary": "#fb7185", "primary-foreground": "#1c1917" }
} }
""")
try app.loadBundle(at: url)

Вызывайте до или после загрузки: классы вроде bg-primary и rounded-(--radius) сразу меняют стиль, а каждое приложение, загруженное позже, стартует с переопределениями уже в первом кадре. setThemeOverrides(nil) возвращает приложению его собственную тему.

#Ссылки, которые открывает приложение

URL, которые открывает приложение (openUrl, ссылки TextView с links="open"), сначала попадают в onOpenURL в главном потоке: верните true, если хост-приложение обработало URL, например направив его через собственный роутер, и false, чтобы система открыла его через UIApplication.open. Без onOpenURL система открывает каждый URL. URL попадают в него после того, как их разрешила возможность openUrl приложения.

app.onOpenURL = { url in
    // The host app's own links open in the host app.
    if url.scheme == "lb" || url.host == "longbridge.com" {
        router.open(url)
        return true
    }
    return false
}

#Лимиты

app.limits = NshLimits(memoryMB: 128, callBudget: 1, maxNodes: 50_000)
Поле NshLimitsПо умолчаниюЗначение
memoryMB: UInt32?256Куча JS в МБ. При исчерпании приложение останавливается.
stackKB: UInt32?4096 (это и максимум)Стек интерпретатора в КБ. Более глубокая рекурсия выбрасывает RangeError.
loadBudget: TimeInterval?10 сВремя на вычисление бандла.
callBudget: TimeInterval?2 сВремя любого другого вызова в JS (одно событие, один таймер, один кадр). Вызов, превысивший лимит, прерывается.
maxNodes: UInt32?200 000Узлы, которые приложение может хранить.

nil оставляет значение по умолчанию. Что делает каждый лимит при достижении: Песочница и возможности.

#Возможности

Приложение объявляет, к чему оно может обращаться, в своём nsh.json; встраивающее приложение может предоставить меньше. Запрос должен быть разрешён обеими сторонами:

try app.grantCapabilities("""
{
  "network": { "hosts": ["api.example.com", "*.cdn.example.com"] },
  "webview": { "hosts": ["help.example.com"] }
}
""")
try app.loadBundle(at: url)

Здесь приложение не может управлять строкой состояния (нет statusBar), даже если его nsh.json этого требует. Подробности: Песочница и возможности.

#Подписанные пакеты

Чтобы запускать код, который приложение загружает (обновления, сторонние мини-приложения), используйте подписанные пакеты: nsh publish создаёт .nshpkg, подписанный ключом релиза, который сертифицировал ваш корневой ключ, а приложение проверяет его перед запуском.

let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let trust = try NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey)   // the contents of root.pub from `nsh keygen`

Task {
    do {
        let info = try await app.loadPackage(at: downloadedFile, trust: trust)
        print("running \(info.id) \(info.version) (build \(info.build))")
    } catch {
        print("refused: \(error)")   // the running app stays
    }
}

Чтобы сначала проверить, а запустить позже, разделите два шага:

let package = try await trust.openPackage(at: downloadedFile)   // verifies off the main thread
// ... decide, ask the user, ...
app.run(package)
APIОписание
NshTrust(stateDirectory: URL) throwsОткрывает или создаёт хранилище доверия. В нём сохраняется его состояние (новейший список отзыва, наибольшая принятая сборка для каждого приложения), и под ним распаковываются ассеты пакетов. Не доверяет ни одному ключу до addRoot. Потокобезопасно.
addRoot(_ publicKey: String) throwsДоверяет корневому открытому ключу (base64, содержимое файла .pub). Можно доверять нескольким корневым ключам.
updateRevocations(_ json: String) throwsПрименяет список отзыва, созданный nsh revoke, подписанный доверенным корневым ключом и более новый, чем тот, что есть в хранилище.
openPackage(at: URL) async throws -> NshPackageПроверяет, распаковывает ассеты и принимает пакет (более старые сборки этого приложения с этого момента отклоняются).
NshPackage.infoid, name, version, build приложения внутри.
NshErrorОшибка, которую выбрасывает каждый вызов, способный выбросить ошибку; message объясняет причину.

#Разработка

С NativeShellDev.xcframework направьте представление на dev-сервер вместо бандла:

#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!)
#else
try app.loadBundle(at: bundledURL)
#endif

Запустите nsh dev App.tsx на вашем Mac. Симулятор использует сеть Mac, поэтому 127.0.0.1 достигает сервера; устройство использует адрес в локальной сети, который выводит nsh dev (devices on this network: ws://192.168.1.20:8790/ws). Каждое сохранение обновляет приложение на месте, логи появляются в терминале nsh dev, а ошибки отображаются поверх приложения. См. Dev-сервер и отладка.

#Примечания

  • Потоки. NshAppView изолирован на main actor. NshTrust, NshPackage и NshComponentContext потокобезопасны. JS-движок работает в собственном потоке; главный поток никогда не ждёт его.
  • Строка состояния. Страницы и диалоги встроенного приложения (statusBar) меняют значения, которые возвращают NshAppView.preferredStatusBarStyle и связанные методы; хостящий view controller должен пробрасывать их, как в примере выше.
  • Безопасная область. Приложения используют safe_area() в Rust, а в TSX, как обычно, — env(safe-area-inset-*) в отступах или safeAreaInsets() из nsh. NshAppView, размещённый под вашим собственным заголовком, получает применимые к нему отступы, а не отступы окна.
  • Mac Catalyst. Тот же фреймворк работает в приложениях Catalyst.

#См. также