🔼

zero-nativeのファーストインプレッション

に公開

Vercel Labsから突如「zero-native」というOSSがリリースされました。

https://x.com/ctatedev/status/2052907884728467699?s=20

パッと見る限りはElectronやtauriのようにネイティブアプリをWebフロントエンドで作れるフレームワークに見えます。また、アプローチとしてはどちらかというとtauriに近い印象を受けますが、tauriのようにネイティブのWebViewを使うだけでなく、CEFを用いてChromiumをバンドルするElectronのようなアプローチを選択することも可能となっているようです。

また、tauriとは異なりネイティブ部分はRustではなくZig実装になっています。Zigは前に触った限りではRustと比べてエコシステムや安定性の面で難があるように思えますが、ビルド速度やネイティブとの統合という面ではRustと比べて優位...なのかもしれません。(ぶっちゃけtauriとの差別化のためにZigを選択したんじゃ...と思わなくもないですが)

とはいえ、現状この分野のニ強であるElectron/tauriはどちらも一長一短なので、第三の選択肢が生まれるのは嬉しいところです。というわけでこの記事では早速zero-nativeを触ってみて、その感想やらなんやらを書いていこうと思います。

現在の開発状況

zero-nativeは現在プレビューのため、Electronやtauriと比べると全く安定していません。 プラットフォームのサポートとしてはmacOSが第一で、Linux、Windowsは徐々に進めていく感じらしいです。SystemのWebViewは今のところLinuxが限定的、Windowsはまだサポートおらず、CEFの組み込みは現状macOSのみとなっています。

ドキュメントに関しても最小限のものは提供されているものの、細かい説明が省かれており、かなり不親切です。

今回はmacOSで開発を行いますが、マルチプラットフォームなアプリを作る際にzero-nativeを本格的に採用するには流石に時期尚早でしょう。

プロジェクトを作成する

早速始めていきましょう。専用のCLIがnpm経由で配布されているので、これをインストールします。

$ npm install -g zero-native

プロジェクトの作成はinitコマンドで行います。

$ zero-native init my_app --frontend next
$ cd my_app

--frontendの指定は必須です。これにはnextreactvueなどを選択できます。Vercelのフレームワークなので、とりあえず一旦Next.jsで始めてみます。

作成されたプロジェクトの構成は以下の通り。

my_app
├── README.md
├── app.zon
├── assets
│   └── icon.icns
├── build.zig
├── build.zig.zon
├── frontend
│   ├── app
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── next.config.js
│   ├── package.json
│   └── tsconfig.json
└── src
    ├── main.zig
    └── runner.zig

中身は普通のZigプロジェクトで、frontend/以下にNext.jsのプロジェクトが作られています。構成としてはかなりシンプルで良さげですね。

色々なファイルがありますが、エントリーポイントとなるmain.zigの中身はこんな感じ。

main.zig
const std = @import("std");
const runner = @import("runner");
const zero_native = @import("zero-native");

pub const panic = std.debug.FullPanic(zero_native.debug.capturePanic);

const App = struct {
    env_map: *std.process.Environ.Map,

    fn app(self: *@This()) zero_native.App {
        return .{
            .context = self,
            .name ="my-app",
            .source = zero_native.frontend.productionSource(.{ .dist ="frontend/out" }),
            .source_fn = source,
        };
    }

    fn source(context: *anyopaque) anyerror!zero_native.WebViewSource {
        const self: *@This() = @ptrCast(@alignCast(context));
        return zero_native.frontend.sourceFromEnv(self.env_map, .{
            .dist ="frontend/out",
            .entry = "index.html",
        });
    }
};

const dev_origins = [_][]const u8{ "zero://app", "zero://inline", "http://127.0.0.1:3000" };

pub fn main(init: std.process.Init) !void {
    var app = App{ .env_map = init.environ_map };
    try runner.runWithOptions(app.app(), .{
        .app_name ="My App",
        .window_title ="My App",
        .bundle_id ="dev.zero_native.my-app",
        .icon_path = "assets/icon.icns",
        .security = .{
            .navigation = .{ .allowed_origins = &dev_origins },
        },
    }, init);
}

test "app name is configured" {
    try std.testing.expectEqualStrings("my-app","my-app");
}

それでは起動してみましょう。ビルドは普通のZigプロジェクトと同様にzig build runで行えます。

ちゃんとアプリが起動しました。ビルドもtauriに比べると遥かに高速でいい感じです。

Chromium (CEF)に切り替える

zero-nativeはデフォルトではシステムのWebViewを利用します。これをChromiumに切り替えてみましょう。

app.zonを開き、.web_engineの部分を変更します。

.{
    // 省略...
-   .web_engine = "system",
+   .web_engine = "chromium",
    .cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
    .windows = .{
        .{ .label = "main", .title ="My App", .width = 720, .height = 480, .restore_state = true },
    },
}

続いてCEFのインストールを行います。これはzero-native cef installコマンドから実行可能で、CEFのバイナリがthird_party/cef/<platform>に配置されます。

これでセットアップは完了です。あとは同様にzig build runで実行できます。

ただし、手元の環境ではアプリの起動には成功したものの、ERR_FILE_NOT_FOUNDが表示されてうまく動きませんでした。何が原因なんだろう...

Zig側のコマンドをフロントエンドから呼び出す

zero-nativeでもtauriのようにネイティブ側で定義したコマンドをフロントエンドから呼び出すことができます。これもやってみましょう。

https://zero-native.dev/bridge

zero-nativeのコマンドはJSONのリクエスト/レスポンスで表現されます。

WebView JS                       Zig Runtime
──────────                       ───────────
window.zero.invoke(cmd, payload)
        │                              │
        ├──── JSON message ───────────►│
        │                         Size check (16 KiB max)
        │                         Policy check (origin + permissions)
        │                         Handler lookup + execute
        │◄─── JSON response ──────────┤

Zig側のコマンドを定義

まずはZig側でping関数を定義しましょう。中身はoutputバッファにJSONのレスポンスを書き込むだけです。

fn ping(context: *anyopaque, invocation: zero_native.bridge.Invocation, output: []u8) anyerror![]const u8 {
    _ = invocation;
    _ = context;
    return std.fmt.bufPrint(output, "{{\"message\":\"pong from Zig\"}}", .{});
}

続いて、App構造体にbridgeメソッドを定義します。これはJSとZigを繋ぐためのものです。

// コマンドの権限などを設定するためのBridgeCommandPolicyを定義
+ const bridge_policies = [_]zero_native.BridgeCommandPolicy{.{ .name = "native.ping" }};

const App = struct {
+   bridge_handlers: [1]zero_native.BridgeHandler = undefined,
    env_map: *std.process.Environ.Map,

    // 省略...

+   fn bridge(self: *@This()) zero_native.BridgeDispatcher {
+       // ハンドラにコマンドを追加
+       self.bridge_handlers = .{.{ .name = "native.ping", .context = self, .invoke_fn = ping }};
+
+       // BridgeDispatcherを作成して返す
+       return .{
+           .policy = .{ .enabled = true, .commands = &bridge_policies },
+           .registry = .{ .handlers = &self.bridge_handlers },
+       };
+   }
};

そしてこのbeidge関数で作成したBridgeDispatcherrunnerに渡します。

pub fn main(init: std.process.Init) !void {
    var app = App{ .env_map = init.environ_map };
    try runner.runWithOptions(app.app(), .{
        .app_name = "My App",
+       .bridge = app.bridge(),
        .window_title = "My App",
        .bundle_id = "dev.zero_native.my-app",
        .icon_path = "assets/icon.icns",
        .security = .{
            .navigation = .{ .allowed_origins = &dev_origins },
        },
    }, init);
}

フロントエンドからコマンドを呼び出す

それでは定義したnative.pingコマンドをフロントエンド側から呼んでみましょう。page.tsxを適当に書き換えます。

// page.tsx
"use client";

import { useEffect, useState } from "react";

export default function Home() {
  const [message, setMessage] = useState("checking...");

  useEffect(() => {
    const ping = async () => {
      // window.zero.invoke()でコマンドを呼び出す
      const result = await (window as any).zero.invoke("native.ping");
      setMessage(result.message);
    };
    ping();
  }, [message]);

  return (
    <main>
      <p className="eyebrow">zero-native + Next.js</p>
      <h1>My App</h1>
      <p className="lede">
        A Next.js frontend running inside the system WebView.
      </p>
      <div className="card">
        <span>Ping</span>
        <strong>{message}</strong>
      </div>
    </main>
  );
}

これで完了です。実際にアプリを起動するとZig側のコマンドが返すレスポンスを正しく受け取れていることが確かめられます。

アプリを.appとしてビルドする

最後に作成したアプリを.appとしてビルドしておきましょう。これには以下のコマンドを利用します。

$ zig build package

# 細かい設定を行い場合はこっちで
$ zero-native package --target macos --manifest app.zon --binary zig-out/bin/MyApp

これを実行するとzig-out/package/以下に.appファイルとしてビルドされます。出力された.appのサイズは3.4MBほどでした。Electronだと100MBを軽く超えるサイズになるので、かなり小さくなっています。

なお、アプリに署名が必要な場合はzero-native packageの引数を追加する必要があります。

https://zero-native.dev/packaging/signing

感想

簡単に触ってみた感想ですが、まだまだ発展途上ではあるものの、最低限必要そうな機能は揃っていて良い感じになっています。やや複雑なtauriと比べるとかなりミニマルかつ分かりやすくまとまっている印象で、ドキュメントが不足気味ではあるものの、割と雰囲気で進められるようになっていて良いですね。

一方、これはzero-nateiveの問題ではないのですが、Zigを書くのがつらい。言語自体は非常に良いのですが、コンパイルエラーが不親切だったり、LSPがあまり役に立たなかったり(エラーや補完が全然でない)で体験はあまりよくありません。

とはいえネイティブのライブラリが必要ない場面でZigを書く必要はほとんどなさそうなので、フロントエンド側で完結出来るアプリであればほとんど問題ないでしょう。このあたりはtauriと同じですね。

流石にまだ実用には早そうですが、このまま発展していけばいずれはtauriやElectronに並ぶ選択肢となりそうな予感がします。今後の開発が楽しみですね。

Discussion