このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

ドキュメント

iOS

NSH アプリは iOS 上で、UIKit のビューと CoreText のテキストを使い、iOS 15.0 以降の実機とシミュレータで動作します。アプリ全体は他のプラットフォームと同じように実行・パッケージ化できます。既存のアプリは、リンクした Rust のルートや読み込んだ TSX(ダウンロードした署名付きパッケージを含む)を、自身が設定する制限と capabilities のもとで、独自のビューに NSH アプリとして表示できます。iOS では os() は Os::Ios です(TSX では platform.os は "ios")。また、TSX の ios={{…}} props が適用されます。

#要件

  • Mac 上の Xcode。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 でシミュレータを選びます。シミュレータを使わずに素早く開発したい場合は、Mac 上で nsh run --target catalyst を使うと、同じ 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 は、実機、TestFlight、App Store 向けに署名された .ipa を書き出します。署名 ID、プロビジョニングプロファイル、アイコンについては パッケージ化 を参照してください。

#iOS アプリへの組み込み

既存の iOS アプリは、自身の UI の中に NSH アプリを表示できます。Rust コードを持つ iOS アプリは、あなたのクレートと nsh-platform-ios クレートをリンクし、ルートを AppView に表示します(Rust アプリの組み込み)。Swift アプリは、Swift モジュールを持つフレームワーク NativeShell.xcframework をリンクします。NshAppView(UIView)を作成してビュー階層の任意の場所に追加し、TSX アプリや署名付きパッケージを読み込みます。ネイティブの画面、ナビゲーション、NSH アプリは、1 つの UIKit 階層の中に共存します。

各ビューは独立した 1 つのエンジンです。グローバルなシングルトンはないため、たとえばタブごとに 1 つずつなど、複数の 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 は、Rust で作られた iOS アプリ向けの UIView としての 1 つのエンジンです。JavaScript VM を使わずに、クレートのルートコンポーネントをマウントします。

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
}

ビューはスーパービューに合わせてリサイズされます。AppView::run は、それまで動いていたものを置き換えます。AppView::load は代わりに TSX バンドルを実行します(Bundle::source、またはアプリバンドル内の bundle.nshc には unsafe { Bundle::bytecode(bytes) })。SurfaceOptions は、以下の NshAppView のメンバーと同様に、制限、付与された capabilities、データディレクトリ、ネイティブコンポーネントを保持します。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 には 3 つの 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 を、--dynamic でビルドした動的フレームワークでは Embed & Sign を選びます。
  2. Swift では、import NativeShell。
  3. アプリの Info.plist で、CADisableMinimumFrameDurationOnPhone を YES に設定します。ProMotion 対応の iPhone では、スクロールと同様に、アプリのアニメーションも 120 Hz で動作するようになります(nsh package がビルドするアプリでは設定済みです)。

#アプリにバンドルを同梱する

Swift アプリは、TSX アプリをバンドルとして同梱します。NSH アプリをフォルダにビルドし、そのフォルダを nsh という名前のフォルダ参照としてアプリのリソースに追加します。

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

nsh build は次のファイルを書き出します。

ファイル内容
bundle.nshcバイトコードにコンパイルされたアプリ。最も高速に読み込めます。
bundle.jsJS ソースとしての同じアプリ。
bundle.js.mapソースマップ(スタックトレースは .tsx ファイルを指します)。
capabilities.jsonnsh.json に宣言がある場合に、アプリが宣言する capabilities。
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 はアプリをビューの bounds でレイアウトし、bounds が変わるたびに再度レイアウトします。再度読み込むと、実行中のアプリが置き換えられます。アプリはビューが解放されると停止します。

完全なサンプルである、独自のネイティブヘッダーの下に NSH アプリを表示する UIKit アプリは examples/embed-ios/main.swift にあります。

#NshAppView

NshAppView は、メインアクターに隔離された UIView のサブクラスです。

メンバー説明
init(frame: CGRect = .zero, trace: Bool = false)ビューを作成します。trace は、すべてのコミットの時間をログに出力します。init?(coder:) は storyboard と nib をサポートします。
onError: ((String) -> Void)?アプリの致命的なエラー(読み込みに失敗したバンドル、捕捉されなかった JS 例外、制限により停止した VM)をメインスレッドで受け取ります。エラーは常にログに出力され、開発ビルドではアプリの上にも表示されます。
limits: NshLimitsこれ以降に読み込まれるアプリの VM の制限。
grantCapabilities(_ json: String?) throwsこれ以降に読み込まれるアプリが行える最大限の範囲。nsh.json の capabilities の形式の JSON で指定します。nil は各アプリが宣言したものを付与します。無効な JSON に対して NshError をスローします。
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 MB)、.maxSize(megabytes:) または .off。
loadBundle(at url: URL) throwsnsh build が書き出したバンドルを実行します。アセットはその隣の assets/ から取得され、隣にある bundle.js.map と capabilities.json も読み込まれます。
run(_ package: NshPackage)検証済みの署名付きパッケージを実行します(後述)。パッケージは一度だけ実行されます。
loadPackage(at:trust:) async throws -> NshPackage.InfoURL のパッケージをメインスレッド外で検証してから実行します。拒否された理由とともにスローします。実行中のアプリはそのまま残ります。
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)と、name のポスト(host.post)をメインスレッドで処理します。{ data, reply in … }、または結果が呼び出しへの応答となる async throws 関数 { data in … } です。
removeHandler(_ name: String)name のハンドラーを削除します。アプリからのその呼び出しは直ちに失敗します。
static preferredStatusBarStyle、prefersStatusBarHidden、preferredStatusBarUpdateAnimation実行中のアプリが要求したもの(ページとダイアログの statusBar、nsh.json のデフォルト)。ホスティングするビューコントローラーのオーバーライドから返してください。

limits、grantCapabilities、storageDirectory、cacheOptions は次回の読み込みから適用されます。loadBundle / run の前に設定してください。

各アプリは、設定なしで自身の id のもとにストレージを保持します。署名付きパッケージは、そのリリースキーの証明書が保証する id のもとに、バンドルは nsh.json の id(隣にある 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])

ハンドラーはメインスレッドで実行されます。呼び出しは、どちら側もブロックせずに、アプリ内で応答を待ちます。応答されないまま解放された reply は、呼び出しを失敗させます。ハンドラーのない名前の呼び出しは直ちに失敗します。アプリがまだリッスンしていない名前のイベントは、リッスンするまで保持されます。アプリが実行されていない間(loadBundle の前、リロード中)に送られたイベントは次のアプリに渡されるため、アプリの起動中にタップされた通知もアプリに届きます。

#テーマのオーバーライド

複数のブランド付きホストアプリに組み込まれた 1 つのアプリは、各ホストのブランドを取り込みます。ホストは、ブランドが変更するトークンを、nsh.json の "theme"(shadcn/ui の cssVars、値は 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 に渡されます。ホストアプリが URL を処理した場合(たとえば独自のルーターに渡した場合)は true を返し、システムに UIApplication.open で開かせる場合は false を返します。onOpenURL がなければ、システムがすべての URL を開きます。URL は、アプリの openUrl capability が許可した後に、ここへ渡されます。

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?256JS ヒープ(MB)。使い切るとアプリが停止します。
stackKB: UInt32?4096(最大値でもあります)インタープリターのスタック(KB)。これより深い再帰は RangeError をスローします。
loadBudget: TimeInterval?10 秒バンドルの評価にかかる時間。
callBudget: TimeInterval?2 秒JS へのその他の呼び出し(1 つのイベント、1 つのタイマー、1 フレーム)にかかる時間。超過した呼び出しは打ち切られます。
maxNodes: UInt32?200 000アプリが保持できるノード数。

nil はデフォルトのままにします。各制限に達したときの動作については、サンドボックスと capabilities を参照してください。

#Capabilities

アプリは、アクセスできるものを nsh.json で宣言し、組み込み側のアプリはそれより少なく付与できます。リクエストは両方に許可されている必要があります。

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

この例では、アプリの nsh.json が要求していても、アプリはステータスバーを設定できません(statusBar がないため)。詳細は サンドボックスと capabilities を参照してください。

#署名付きパッケージ

アプリがダウンロードするコード(アップデート、サードパーティのミニアプリ)を実行するには、署名付きパッケージを使います。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
    }
}

先に検証して後で実行するには、2 つのステップに分けます。

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) throwsnsh revoke が書き出した失効リストを適用します。信頼されたルートによる署名があり、ストアが持つものより新しいものが対象です。
openPackage(at: URL) async throws -> NshPackage検証し、アセットを展開して、パッケージを受け入れます(そのアプリの古いビルドは、以降拒否されます)。
NshPackage.info内部のアプリの id、name、version、build。
NshErrorスローするすべての呼び出しがスローするエラー。message が理由を示します。

#開発

NativeShellDev.xcframework を使って、ビューをバンドルではなく開発サーバーに向けます。

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

Mac 上で nsh dev App.tsx を実行します。シミュレータは Mac のネットワークを共有するため、127.0.0.1 からサーバーにアクセスできます。実機の場合は、nsh dev が表示する LAN アドレス(devices on this network: ws://192.168.1.20:8790/ws)を使用します。保存するたびにアプリがその場で更新され、ログは nsh dev のターミナルに表示され、エラーはアプリ上に表示されます。開発サーバーとデバッグを参照してください。

#注意事項

  • スレッド。 NshAppView はメインアクターに隔離されています。NshTrust、NshPackage、NshComponentContext はスレッドセーフです。JS エンジンは専用のスレッドで動作し、メインスレッドがその完了を待つことはありません。
  • ステータスバー。 埋め込まれたアプリのページやダイアログ(statusBar)によって、NshAppView.preferredStatusBarStyle などが返す値が変わります。上記の例のように、ホスティングする view controller がそれらを転送する必要があります。
  • セーフエリア。 アプリでは、Rust では safe_area() を、TSX では padding に env(safe-area-inset-*) を、または nsh の safeAreaInsets() を、通常どおり使用します。独自のヘッダーの下に配置した NshAppView には、ウィンドウのインセットではなく、その位置に適用されるインセットが渡されます。
  • Mac Catalyst。 同じフレームワークが Catalyst アプリでも動作します。

#関連項目