Plugins
GuidesEpisode 15

第拾伍話

Plugins

Packages that bring native libraries, prebuilt for each platform, shipped with the app; SQLite is one.

A plugin is a Go package that brings libraries with it: C, Zig or Rust, prebuilt for each platform, that its Go code calls without cgo. Using one means importing it, and the CLI ships its libraries with the app on every platform. asuka/sdk/sqlite is one (Data).

Using a plugin

Import it:

Go
import "github.com/egoist/asuka/sdk/sqlite"

db, err := sqlite.Open("notes.db")

Nothing else. asuka build (and asuka dev on a phone) finds the plugins among the app's dependencies and ships each one's libraries for the build's platform. go run, go test and asuka dev on a desktop download them the first time, into the user's cache, and use them from there after. Every file is checked against the SHA-256 the plugin's manifest names before it's used.

Where the libraries go

Platform Where
macOS Contents/Frameworks signed with the app
Windows beside the .exe signed with it, with ASUKA_WINDOWS_CERT
Linux lib/<app> of the install prefix in the tarball and the Flatpak
iOS Frameworks/<library>.framework its Info.plist and privacy manifest, signed with the app
Android beside libasuka.so, in each ABI uncompressed, 16 KB pages
The web lib/ of the host's files the page's libs option names each file's URL

The libraries of an app's plugins all go in one place, so no two may have a file of the same name for a platform: the build says which two do.

An app built into another (asuka build --platform ios --xcframework) comes with an XCFramework of each library, for that app to embed too.

Writing a plugin

A plugin is a package with a manifest beside it, asuka-plugin.json:

JSON
{
  "name": "sqlite",
  "sources": ["csrc", "internal/build"],
  "libraries": [
    {
      "name": "asqlite",
      "version": "3.54.1",
      "release": "https://github.com/egoist/asuka/releases/download/plugin-sqlite",
      "files": {
        "darwin": {"name": "libasqlite.dylib"},
        "ios": {"name": "asqlite"},
        "ios-simulator": {"name": "asqlite"},
        "android-arm64": {"name": "libasqlite.so"},
        "linux-amd64": {"name": "libasqlite.so"},
        "windows-amd64": {"name": "asqlite.dll"},
        "wasm": {"name": "asqlite.wasm"}
      }
    }
  ]
}
  • name is the plugin's, as the CLI's messages name it.
  • sources are what its libraries are built from, relative to the manifest: a library whose sources changed since it was built isn't published. Without it, every file of the package but its tests.
  • Each library's files name its file for each platform: darwin (one file for both Macs: arm64 and x86_64), ios and ios-simulator, android-arm64, android-arm, android-amd64, linux-amd64, linux-arm64, windows-amd64, windows-arm64 and wasm. A Mac's is a .dylib; an iOS one is named for the library (its framework's binary, with @rpath/<library>.framework/<library> as its install name). release is the GitHub release they're published to. version is the library's, for its iOS framework.

The plugin's Go code embeds the manifest and loads the library from the path plugin.LibraryPath gives, from a file in the manifest's directory:

Go
//go:embed asuka-plugin.json
var manifest []byte

func load() (uintptr, error) {
	path, err := plugin.LibraryPath(manifest, "asqlite")
	if err != nil {
		return 0, err
	}
	return purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL) // syscall.LoadLibrary on Windows
}

Keep a library's exported symbols few, its own and nothing it links: another copy of what it's built from may be in the process (a system SQLite that GTK loads). Call it a whole operation at a time, not a function per value: each call from Go to C costs more than a small one's work (the sqlite plugin calls once per statement).

Building and publishing

A plugin builds its libraries with a tool of its own, as they need: sdk/sqlite/internal/build compiles SQLite with Xcode, the Android NDK and Zig. It writes each target's file with plugin.StoreBuild, with the plugin's plugin.SourceHash, into build/<target> beside the manifest. That directory isn't checked in. In a checkout, the plugin's own tests and the apps beside it use the build there.

Shell
go generate ./sdk/sqlite                         # build every target
go run ./cmd/asuka plugin publish sdk/sqlite     # publish

asuka plugin publish gzips each file and names it for its hash (asqlite-darwin-1982f596dd7c-libasqlite.dylib.gz). It uploads the ones the release doesn't have yet, with gh, making the release if it isn't there. It downloads each one back to check it, and records them in the manifest, which is what you commit. A file a release has is never replaced, so every manifest a commit had keeps working. A build older than the plugin's sources stops it: build again first.

Native code for the platforms' hosts

A package that calls a platform's APIs (Swift on iOS and macOS, Kotlin on Android, JavaScript on the web), or shows a platform's view, is a native package. It needs no manifest: its Swift package and its Gradle module are its own, and they bring their dependencies. A package can be a plugin and a native package at once.