cmd/gogio: rename the gio too to gogio

The `gio` name clashes with a widely deployed GNOME tool.
Rename our tool to `gogio`, "the go tool for gio programs".

Fixes gio#20

Signed-off-by: Elias Naur <mail@eliasnaur.com>
This commit is contained in:
Elias Naur
2019-09-18 22:42:31 +02:00
parent 31f7f04181
commit a12912c944
9 changed files with 28 additions and 28 deletions
+623
View File
@@ -0,0 +1,623 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"archive/zip"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"golang.org/x/sync/errgroup"
)
type androidTools struct {
buildtools string
androidjar string
}
// zip.Writer with a sticky error.
type zipWriter struct {
err error
w *zip.Writer
}
// Writer that saves any errors.
type errWriter struct {
w io.Writer
err *error
}
func buildAndroid(tmpDir string, bi *buildInfo) error {
sdk := os.Getenv("ANDROID_HOME")
if sdk == "" {
return errors.New("please set ANDROID_HOME to the Android SDK path")
}
if _, err := os.Stat(sdk); err != nil {
return err
}
platform, err := latestPlatform(sdk)
if err != nil {
return err
}
buildtools, err := latestTools(sdk)
if err != nil {
return err
}
tools := &androidTools{
buildtools: buildtools,
androidjar: filepath.Join(platform, "android.jar"),
}
if err := compileAndroid(tmpDir, tools, bi); err != nil {
return err
}
switch *buildMode {
case "archive":
return archiveAndroid(tmpDir, bi)
case "exe":
if err := exeAndroid(tmpDir, tools, bi); err != nil {
return err
}
return signAPK(tmpDir, tools, bi)
default:
panic("unreachable")
}
}
func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) {
androidHome := os.Getenv("ANDROID_HOME")
if androidHome == "" {
return errors.New("ANDROID_HOME is not set. Please point it to the root of the Android SDK")
}
ndkRoot := filepath.Join(androidHome, "ndk-bundle")
if _, err := os.Stat(ndkRoot); err != nil {
return fmt.Errorf("no NDK found in $ANDROID_HOME/ndk-bundle (%s). Use `sdkmanager ndk-bundle` to install it", ndkRoot)
}
tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK())
var builds errgroup.Group
for _, a := range bi.archs {
arch := allArchs[a]
clang := filepath.Join(tcRoot, "bin", arch.clang)
if _, err := os.Stat(clang); err != nil {
return fmt.Errorf("no NDK compiler found. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it. Path %s", clang)
}
if runtime.GOOS == "windows" {
// Because of https://github.com/android-ndk/ndk/issues/920,
// we need NDK r19c, not just r19b. Check for the presence of
// clang++.cmd which is only available in r19c.
clangpp := filepath.Join(tcRoot, "bin", arch.clang+"++.cmd")
if _, err := os.Stat(clangpp); err != nil {
return fmt.Errorf("NDK version r19b detected, but >= r19c is required. Use the command `sdkmanager ndk-bundle` to install it")
}
}
archDir := filepath.Join(tmpDir, "jni", arch.jniArch)
if err := os.MkdirAll(archDir, 0755); err != nil {
return fmt.Errorf("failed to create %q: %v", archDir, err)
}
libFile := filepath.Join(archDir, "libgio.so")
cmd := exec.Command(
"go",
"build",
"-ldflags=-w -s "+bi.ldflags,
"-buildmode=c-shared",
"-o", libFile,
bi.pkg,
)
cmd.Env = append(
os.Environ(),
"GOOS=android",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS=-Werror",
)
builds.Go(func() error {
_, err := runCmd(cmd)
return err
})
}
appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "gioui.org/ui/app"))
if err != nil {
return err
}
javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java"))
if err != nil {
return err
}
if len(javaFiles) > 0 {
classes := filepath.Join(tmpDir, "classes")
if err := os.MkdirAll(classes, 0755); err != nil {
return err
}
javac := exec.Command(
"javac",
"-target", "1.8",
"-source", "1.8",
"-sourcepath", appDir,
"-bootclasspath", tools.androidjar,
"-d", classes,
)
javac.Args = append(javac.Args, javaFiles...)
builds.Go(func() error {
_, err := runCmd(javac)
return err
})
}
return builds.Wait()
}
func archiveAndroid(tmpDir string, bi *buildInfo) (err error) {
aarFile := *destPath
if aarFile == "" {
aarFile = fmt.Sprintf("%s.aar", bi.name)
}
if filepath.Ext(aarFile) != ".aar" {
return fmt.Errorf("the specified output %q does not end in '.aar'", aarFile)
}
aar, err := os.Create(aarFile)
if err != nil {
return err
}
defer func() {
if cerr := aar.Close(); err == nil {
err = cerr
}
}()
aarw := newZipWriter(aar)
defer aarw.Close()
aarw.Create("R.txt")
aarw.Create("res/")
manifest := aarw.Create("AndroidManifest.xml")
manifest.Write([]byte(fmt.Sprintf(`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="%s">
<uses-sdk android:minSdkVersion="16"/>
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
</manifest>`, bi.appID)))
proguard := aarw.Create("proguard.txt")
proguard.Write([]byte(`-keep class org.gioui.** { *; }`))
for _, a := range bi.archs {
arch := allArchs[a]
libFile := filepath.Join("jni", arch.jniArch, "libgio.so")
aarw.Add(filepath.ToSlash(libFile), filepath.Join(tmpDir, libFile))
}
classes := filepath.Join(tmpDir, "classes")
if _, err := os.Stat(classes); err == nil {
jarFile := filepath.Join(tmpDir, "classes.jar")
if err := writeJar(jarFile, classes); err != nil {
return err
}
aarw.Add("classes.jar", jarFile)
}
return aarw.Close()
}
func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) {
classes := filepath.Join(tmpDir, "classes")
var classFiles []string
err = filepath.Walk(classes, func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if filepath.Ext(path) == ".class" {
classFiles = append(classFiles, path)
}
return nil
})
if err != nil {
return err
}
apkDir := filepath.Join(tmpDir, "apk")
if err := os.MkdirAll(apkDir, 0755); err != nil {
return err
}
if len(classFiles) > 0 {
d8 := exec.Command(
filepath.Join(tools.buildtools, "d8"),
"--classpath", tools.androidjar,
"--output", apkDir,
)
d8.Args = append(d8.Args, classFiles...)
if _, err := runCmd(d8); err != nil {
return err
}
}
// Compile resources.
resDir := filepath.Join(tmpDir, "res")
valDir := filepath.Join(resDir, "values")
v21Dir := filepath.Join(resDir, "values-v21")
for _, dir := range []string{valDir, v21Dir} {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
icon := filepath.Join(bi.dir, "appicon.png")
iconSnip := ""
if _, err := os.Stat(icon); err == nil {
err := buildIcons(resDir, icon, []iconVariant{
{path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72},
{path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96},
{path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144},
{path: filepath.Join("mipmap-xxxhdpi", "ic_launcher.png"), size: 192},
})
if err != nil {
return err
}
iconSnip = `android:icon="@mipmap/ic_launcher"`
}
themes := `<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GioApp" parent="android:style/Theme.NoTitleBar">
</style>
</resources>`
err = ioutil.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0660)
if err != nil {
return err
}
themesV21 := `<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.GioApp" parent="android:style/Theme.NoTitleBar">
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:navigationBarColor">#40000000</item>
<item name="android:statusBarColor">#40000000</item>
</style>
</resources>`
err = ioutil.WriteFile(filepath.Join(v21Dir, "themes.xml"), []byte(themesV21), 0660)
if err != nil {
return err
}
resZip := filepath.Join(tmpDir, "resources.zip")
aapt2 := filepath.Join(tools.buildtools, "aapt2")
_, err = runCmd(exec.Command(
aapt2,
"compile",
"-o", resZip,
"--dir", resDir))
if err != nil {
return err
}
// Link APK.
appName := strings.Title(bi.name)
manifestSrc := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="%s"
android:versionCode="%d"
android:versionName="1.0.%d">
<uses-sdk android:minSdkVersion="16" android:targetSdkVersion="28" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:glEsVersion="0x00030000"/>
<application %s android:label="%s">
<activity android:name="org.gioui.GioActivity"
android:label="%s"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>`, bi.appID, bi.version, bi.version, iconSnip, appName, appName)
manifest := filepath.Join(tmpDir, "AndroidManifest.xml")
if err := ioutil.WriteFile(manifest, []byte(manifestSrc), 0660); err != nil {
return err
}
tmpapk := filepath.Join(tmpDir, "link.apk")
link := exec.Command(
aapt2,
"link",
"--manifest", manifest,
"-I", tools.androidjar,
"-o", tmpapk,
resZip,
)
if _, err := runCmd(link); err != nil {
return err
}
// The Go standard library archive/zip doesn't support appending to zip
// files. Unpack the apk from aapt2 and re-zip its contents along with
// classes.dex and the Go libraries.
if err := unzip(apkDir, tmpapk); err != nil {
return err
}
tmpApk := filepath.Join(tmpDir, "app.ap_")
ap_, err := os.Create(tmpApk)
if err != nil {
return err
}
defer func() {
if cerr := ap_.Close(); err == nil {
err = cerr
}
}()
apkw := newZipWriter(ap_)
defer apkw.Close()
err = filepath.Walk(apkDir, func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
zpath := path[len(apkDir)+1:]
if filepath.Base(path) == "resources.arsc" {
apkw.Store(zpath, path)
} else {
apkw.Add(zpath, path)
}
return nil
})
if err != nil {
return err
}
for _, a := range bi.archs {
arch := allArchs[a]
libFile := filepath.Join(arch.jniArch, "libgio.so")
apkw.Add(filepath.ToSlash(filepath.Join("lib", libFile)), filepath.Join(tmpDir, "jni", libFile))
}
return apkw.Close()
}
func signAPK(tmpDir string, tools *androidTools, bi *buildInfo) error {
apkFile := *destPath
if apkFile == "" {
apkFile = fmt.Sprintf("%s.apk", bi.name)
}
if filepath.Ext(apkFile) != ".apk" {
return fmt.Errorf("the specified output %q does not end in '.apk'", apkFile)
}
_, err := runCmd(exec.Command(
filepath.Join(tools.buildtools, "zipalign"),
"-f",
"4", // 32-bit alignment.
filepath.Join(tmpDir, "app.ap_"),
apkFile,
))
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
keystore := filepath.Join(home, ".android", "debug.keystore")
_, err = runCmd(exec.Command(
filepath.Join(tools.buildtools, "apksigner"),
"sign",
"--ks-pass", "pass:android",
"--ks", keystore,
apkFile,
))
if err != nil {
return err
}
return nil
}
func unzip(dir, zipfile string) (err error) {
zipr, err := zip.OpenReader(zipfile)
if err != nil {
return err
}
defer zipr.Close()
for _, f := range zipr.File {
path := filepath.Join(dir, f.Name)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
out, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := out.Close(); err == nil {
err = cerr
}
}()
in, err := f.Open()
if err != nil {
return err
}
defer in.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
}
return nil
}
func writeJar(jarFile, dir string) (err error) {
jar, err := os.Create(jarFile)
if err != nil {
return err
}
defer func() {
if cerr := jar.Close(); err == nil {
err = cerr
}
}()
jarw := newZipWriter(jar)
const manifestHeader = `Manifest-Version: 1.0
Created-By: 1.0 (Go)
`
jarw.Create("META-INF/MANIFEST.MF").Write([]byte(manifestHeader))
err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
if filepath.Ext(path) == ".class" {
rel := filepath.ToSlash(path[len(dir)+1:])
jarw.Add(rel, path)
}
return nil
})
if err != nil {
return err
}
return jarw.Close()
}
func archNDK() string {
if runtime.GOOS == "windows" && runtime.GOARCH == "386" {
return "windows"
} else {
var arch string
switch runtime.GOARCH {
case "386":
arch = "x86"
case "amd64":
arch = "x86_64"
default:
panic("unsupported GOARCH: " + runtime.GOARCH)
}
return runtime.GOOS + "-" + arch
}
}
func latestPlatform(sdk string) (string, error) {
allPlats, err := filepath.Glob(filepath.Join(sdk, "platforms", "android-*"))
if err != nil {
return "", err
}
var bestVer int
var bestPlat string
for _, platform := range allPlats {
_, name := filepath.Split(platform)
// The glob above guarantees the "android-" prefix.
verStr := name[len("android-"):]
ver, err := strconv.Atoi(verStr)
if err != nil {
continue
}
if ver < bestVer {
continue
}
bestVer = ver
bestPlat = platform
}
if bestPlat == "" {
return "", fmt.Errorf("no platforms found in %q", sdk)
}
return bestPlat, nil
}
func latestTools(sdk string) (string, error) {
allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*"))
if err != nil {
return "", err
}
var bestVer [3]int
var bestTools string
loop:
for _, tools := range allTools {
_, name := filepath.Split(tools)
s := strings.SplitN(name, ".", 3)
if len(s) != len(bestVer) {
continue
}
var version [3]int
for i, v := range s {
v, err := strconv.Atoi(v)
if err != nil {
continue loop
}
if v < bestVer[i] {
continue loop
}
if v > bestVer[i] {
break
}
version[i] = v
}
bestVer = version
bestTools = tools
}
if bestTools == "" {
return "", fmt.Errorf("no build-tools found in %q", sdk)
}
return bestTools, nil
}
func newZipWriter(w io.Writer) *zipWriter {
return &zipWriter{
w: zip.NewWriter(w),
}
}
func (z *zipWriter) Close() error {
err := z.w.Close()
if z.err == nil {
z.err = err
}
return z.err
}
func (z *zipWriter) Create(name string) io.Writer {
if z.err != nil {
return ioutil.Discard
}
w, err := z.w.Create(name)
if err != nil {
z.err = err
return ioutil.Discard
}
return &errWriter{w: w, err: &z.err}
}
func (z *zipWriter) Store(name, file string) {
z.add(name, file, false)
}
func (z *zipWriter) Add(name, file string) {
z.add(name, file, true)
}
func (z *zipWriter) add(name, file string, compressed bool) {
if z.err != nil {
return
}
f, err := os.Open(file)
if err != nil {
z.err = err
return
}
defer f.Close()
fh := &zip.FileHeader{
Name: name,
}
if compressed {
fh.Method = zip.Deflate
}
w, err := z.w.CreateHeader(fh)
if err != nil {
z.err = err
return
}
if _, err := io.Copy(w, f); err != nil {
z.err = err
return
}
}
func (w *errWriter) Write(p []byte) (n int, err error) {
if err := *w.err; err != nil {
return 0, err
}
n, err = w.w.Write(p)
*w.err = err
return
}
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: Unlicense OR MIT
/*
The gogio tool builds and packages Gio programs for Android, iOS/tvOS
and WebAssembly.
Run gogio with no arguments for instructions, or see the examples at
https://gioui.org.
*/
package main
+286
View File
@@ -0,0 +1,286 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/image/draw"
"golang.org/x/sync/errgroup"
)
var (
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
version = flag.Int("version", 1, "app version (for -buildmode=exe)")
printCommands = flag.Bool("x", false, "print the commands")
keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
)
type buildInfo struct {
name string
pkg string
ldflags string
target string
appID string
version int
dir string
archs []string
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, mainUsage)
}
flag.Parse()
if err := mainErr(); err != nil {
fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
func mainErr() error {
pkg := flag.Arg(0)
if pkg == "" {
return errors.New("specify a package")
}
if *target == "" {
return errors.New("please specify -target")
}
switch *target {
case "ios", "tvos", "android", "js":
default:
return fmt.Errorf("invalid -target %s", *target)
}
switch *buildMode {
case "archive", "exe":
default:
return fmt.Errorf("invalid -buildmode %s", *buildMode)
}
// Find package name.
pkgPath, err := runCmd(exec.Command("go", "list", "-f", "{{.ImportPath}}", pkg))
if err != nil {
return err
}
dir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", pkg))
if err != nil {
return err
}
elems := strings.Split(pkgPath, "/")
name := elems[len(elems)-1]
bi := &buildInfo{
name: name,
pkg: pkg,
target: *target,
appID: *appID,
dir: dir,
version: *version,
}
if bi.appID == "" {
bi.appID = appIDFromPackage(pkgPath)
}
switch *target {
case "js":
bi.archs = []string{"wasm"}
case "ios", "tvos":
// Only 64-bit support.
bi.archs = []string{"arm64", "amd64"}
case "android":
bi.archs = []string{"arm", "arm64", "386", "amd64"}
}
if *archNames != "" {
bi.archs = strings.Split(*archNames, ",")
}
if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
// Pass along arguments to the app.
bi.ldflags = fmt.Sprintf("-X gioui.org/ui/app.extraArgs=%s", strings.Join(appArgs, "|"))
}
if err := build(bi); err != nil {
return err
}
return nil
}
func appIDFromPackage(pkgPath string) string {
elems := strings.Split(pkgPath, "/")
domain := strings.Split(elems[0], ".")
name := ""
if len(elems) > 1 {
name = "." + elems[len(elems)-1]
}
if len(elems) < 2 && len(domain) < 2 {
name = "." + domain[0]
domain[0] = "localhost"
} else {
for i := 0; i < len(domain)/2; i++ {
opp := len(domain) - 1 - i
domain[i], domain[opp] = domain[opp], domain[i]
}
}
pkgDomain := strings.Join(domain, ".")
appid := []rune(pkgDomain + name)
// a Java-language-style package name may contain upper- and lower-case
// letters and underscores with individual parts separated by '.'.
// https://developer.android.com/guide/topics/manifest/manifest-element
for i, c := range appid {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
c == '_' || c == '.') {
appid[i] = '_'
}
}
return string(appid)
}
func build(bi *buildInfo) error {
tmpDir, err := ioutil.TempDir("", "gogio-")
if err != nil {
return err
}
if *keepWorkdir {
fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
} else {
defer os.RemoveAll(tmpDir)
}
switch *target {
case "js":
return buildJS(bi)
case "ios", "tvos":
return buildIOS(tmpDir, *target, bi)
case "android":
return buildAndroid(tmpDir, bi)
default:
panic("unreachable")
}
}
func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
if *printCommands {
fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
}
out, err := cmd.Output()
if err == nil {
return out, nil
}
if err, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
}
return nil, err
}
func runCmd(cmd *exec.Cmd) (string, error) {
out, err := runCmdRaw(cmd)
return string(bytes.TrimSpace(out)), err
}
func copyFile(dst, src string) (err error) {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := w.Close(); err == nil {
err = cerr
}
}()
_, err = io.Copy(w, r)
return err
}
type arch struct {
iosArch string
jniArch string
clang string
}
var allArchs = map[string]arch{
"arm": arch{
iosArch: "armv7",
jniArch: "armeabi-v7a",
clang: "armv7a-linux-androideabi16-clang",
},
"arm64": arch{
iosArch: "arm64",
jniArch: "arm64-v8a",
clang: "aarch64-linux-android21-clang",
},
"386": arch{
iosArch: "i386",
jniArch: "x86",
clang: "i686-linux-android16-clang",
},
"amd64": arch{
iosArch: "x86_64",
jniArch: "x86_64",
clang: "x86_64-linux-android21-clang",
},
}
type iconVariant struct {
path string
size int
fill bool
}
func buildIcons(baseDir, icon string, variants []iconVariant) error {
f, err := os.Open(icon)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
var resizes errgroup.Group
for _, v := range variants {
v := v
resizes.Go(func() (err error) {
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
op := draw.Src
if v.fill {
op = draw.Over
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
}
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
path := filepath.Join(baseDir, v.path)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
return png.Encode(f, scaled)
})
}
return resizes.Wait()
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"testing"
)
type expval struct {
in, out string
}
func TestAppID(t *testing.T) {
tests := []expval{
{"example", "localhost.example"},
{"example.com", "com.example"},
{"www.example.com", "com.example.www"},
{"examplecom/app", "examplecom.app"},
{"example.com/app", "com.example.app"},
{"www.example.com/app", "com.example.www.app"},
{"www.en.example.com/app", "com.example.en.www.app"},
{"example.com/dir/app", "com.example.app"},
{"example.com/dir.ext/app", "com.example.app"},
{"example.com/dir/app.ext", "com.example.app.ext"},
{"example-com.net/dir/app", "net.example_com.app"},
}
for i, test := range tests {
got := appIDFromPackage(test.in)
if exp := test.out; got != exp {
t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
Usage:
gogio -target <target> [flags] <package> [run arguments]
The go tool is sufficient to build, install and run Gio programs on platforms
where a single executable is sufficient. The gogio tool can build and package Gio
programs for platforms where additional metadata or support files are required.
The package argument specifies an import path or a single Go source file to
package. Any run arguments are appended to os.Args at runtime.
If the package contains an appicon.png file, it is used as the app icon on
supported platforms.
The mandatory -target flag selects the target platform: ios or android for the
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL.
The -arch flag specifies a comma separated list of GOARCHs to include. The
default is all supported architectures.
The -o flag specifies an output file or directory, depending on the target.
The -buildmode flag selects the build mode. Two build modes are available, exe
and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
for Android or a directory with the WebAssembly module and support files for
a browser.
As a special case for iOS or tvOS, specifying a path that ends with ".app"
will output an app directory suitable for a simulator.
The other buildmode is archive, which will output an .aar library for Android
or a .framework for iOS and tvOS.
The -appid flag specifies the package name for Android or the bundle id for
iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
tool can use it.
The -version flag specifies the integer version for Android and the last
component of the 1.0.X version for iOS and tvOS.
The -work flag prints the path to the working directory and suppress
its deletion.
The -x flag will print all the external commands executed by the gogio tool.
`
+528
View File
@@ -0,0 +1,528 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"archive/zip"
"crypto/x509"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"golang.org/x/sync/errgroup"
)
const minIOSVersion = "9.0"
func buildIOS(tmpDir, target string, bi *buildInfo) error {
appName := bi.name
switch *buildMode {
case "archive":
framework := *destPath
if framework == "" {
framework = fmt.Sprintf("%s.framework", strings.Title(appName))
}
return archiveIOS(tmpDir, target, framework, bi)
case "exe":
out := *destPath
if out == "" {
out = appName + ".ipa"
}
forDevice := strings.HasSuffix(out, ".ipa")
// Filter out unsupported architectures.
for i := len(bi.archs) - 1; i >= 0; i-- {
switch bi.archs[i] {
case "arm", "arm64":
if forDevice {
continue
}
case "386", "amd64":
if !forDevice {
continue
}
}
bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
}
tmpFramework := filepath.Join(tmpDir, "Gio.framework")
if err := archiveIOS(tmpDir, target, tmpFramework, bi); err != nil {
return err
}
if !forDevice && !strings.HasSuffix(out, ".app") {
return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
}
if !forDevice {
return exeIOS(tmpDir, target, out, bi)
}
payload := filepath.Join(tmpDir, "Payload")
appDir := filepath.Join(payload, appName+".app")
if err := os.MkdirAll(appDir, 0755); err != nil {
return err
}
if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
return err
}
if err := signIOS(bi, tmpDir, appDir); err != nil {
return err
}
return zipDir(out, tmpDir, "Payload")
default:
panic("unreachable")
}
}
func signIOS(bi *buildInfo, tmpDir, app string) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
provisions, err := filepath.Glob(provPattern)
if err != nil {
return err
}
provInfo := filepath.Join(tmpDir, "provision.plist")
var avail []string
for _, prov := range provisions {
// Decode the provision file to a plist.
_, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
if err != nil {
return err
}
expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
if err != nil {
return err
}
exp, err := time.Parse(time.UnixDate, expUnix)
if err != nil {
return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
}
if exp.Before(time.Now()) {
continue
}
appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
if err != nil {
return err
}
provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
if err != nil {
return err
}
expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
avail = append(avail, provAppID)
if expAppID != provAppID {
continue
}
// Copy provisioning file.
embedded := filepath.Join(app, "embedded.mobileprovision")
if err := copyFile(embedded, prov); err != nil {
return err
}
certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
if err != nil {
return err
}
// Omit trailing newline.
certDER = certDER[:len(certDER)-1]
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return fmt.Errorf("sign: failed to parse developer certificate from %q: %v", prov, err)
}
entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
if err != nil {
return err
}
entFile := filepath.Join(tmpDir, "entitlements.plist")
if err := ioutil.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
return err
}
signIdentity := cert.Subject.CommonName
_, err = runCmd(exec.Command("codesign", "-s", signIdentity, "--entitlements", entFile, app))
return err
}
return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
}
func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
if bi.appID == "" {
return errors.New("app id is empty; use -appid to set it")
}
if err := os.RemoveAll(app); err != nil {
return err
}
if err := os.Mkdir(app, 0755); err != nil {
return err
}
mainm := filepath.Join(tmpDir, "main.m")
const mainmSrc = `@import UIKit;
@import Gio;
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([GioAppDelegate class]));
}
}`
if err := ioutil.WriteFile(mainm, []byte(mainmSrc), 0660); err != nil {
return err
}
appName := strings.Title(bi.name)
exe := filepath.Join(app, appName)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a)
if err != nil {
return err
}
exeSlice := filepath.Join(tmpDir, "app-"+a)
lipo.Args = append(lipo.Args, exeSlice)
compile := exec.Command(clang, cflags...)
compile.Args = append(compile.Args,
"-F", tmpDir,
"-o", exeSlice,
mainm,
)
builds.Go(func() error {
_, err := runCmd(compile)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
infoPlist := buildInfoPlist(bi)
plistFile := filepath.Join(app, "Info.plist")
if err := ioutil.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
return err
}
icon := filepath.Join(bi.dir, "appicon.png")
if _, err := os.Stat(icon); err == nil {
assetPlist, err := iosIcons(bi, tmpDir, app, icon)
if err != nil {
return err
}
// Merge assets plist with Info.plist
cmd := exec.Command(
"/usr/libexec/PlistBuddy",
"-c", "Merge "+assetPlist,
plistFile,
)
if _, err := runCmd(cmd); err != nil {
return err
}
}
if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
return err
}
return nil
}
// iosIcons builds an asset catalog and compile it with the Xcode command actool.
// iosIcons returns the asset plist file to be merged into Info.plist.
func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
assets := filepath.Join(tmpDir, "Assets.xcassets")
if err := os.Mkdir(assets, 0700); err != nil {
return "", err
}
appIcon := filepath.Join(assets, "AppIcon.appiconset")
err := buildIcons(appIcon, icon, []iconVariant{
{path: "ios_2x.png", size: 120},
{path: "ios_3x.png", size: 180},
// The App Store icon is not allowed to contain
// transparent pixels.
{path: "ios_store.png", size: 1024, fill: true},
})
if err != nil {
return "", err
}
contentJson := `{
"images" : [
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "ios_3x.png",
"scale" : "3x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "ios_store.png",
"scale" : "1x"
}
]
}`
contentFile := filepath.Join(appIcon, "Contents.json")
if err := ioutil.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
return "", err
}
assetPlist := filepath.Join(tmpDir, "assets.plist")
compile := exec.Command(
"actool",
"--compile", appDir,
"--platform", iosPlatformFor(bi.target),
"--minimum-deployment-target", minIOSVersion,
"--app-icon", "AppIcon",
"--output-partial-info-plist", assetPlist,
assets)
_, err = runCmd(compile)
return assetPlist, err
}
func buildInfoPlist(bi *buildInfo) string {
appName := strings.Title(bi.name)
platform := iosPlatformFor(bi.target)
var supportPlatform string
switch bi.target {
case "ios":
supportPlatform = "iPhoneOS"
case "tvos":
supportPlatform = "AppleTVOS"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>%s</string>
<key>CFBundleIdentifier</key>
<string>%s</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>%s</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.%d</string>
<key>CFBundleVersion</key>
<string>%d</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array><string>arm64</string></array>
<key>DTPlatformName</key>
<string>%s</string>
<key>DTPlatformVersion</key>
<string>12.4</string>
<key>MinimumOSVersion</key>
<string>%s</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>%s</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>16G73</string>
<key>DTSDKBuild</key>
<string>16G73</string>
<key>DTSDKName</key>
<string>%s12.4</string>
<key>DTXcode</key>
<string>1030</string>
<key>DTXcodeBuild</key>
<string>10G8</string>
</dict>
</plist>`, appName, bi.appID, appName, bi.version, bi.version, platform, minIOSVersion, supportPlatform, platform)
}
func iosPlatformFor(target string) string {
switch target {
case "ios":
return "iphoneos"
case "tvos":
return "appletvos"
default:
panic("invalid platform " + target)
}
}
func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
framework := filepath.Base(frameworkRoot)
const suf = ".framework"
if !strings.HasSuffix(framework, suf) {
return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
}
framework = framework[:len(framework)-len(suf)]
if err := os.RemoveAll(frameworkRoot); err != nil {
return err
}
frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
for _, dir := range []string{"Headers", "Modules"} {
p := filepath.Join(frameworkDir, dir)
if err := os.MkdirAll(p, 0755); err != nil {
return err
}
}
symlinks := [][2]string{
{"Versions/Current/Headers", "Headers"},
{"Versions/Current/Modules", "Modules"},
{"Versions/Current/" + framework, framework},
{"A", filepath.Join("Versions", "Current")},
}
for _, l := range symlinks {
if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
return err
}
}
exe := filepath.Join(frameworkDir, framework)
lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
var builds errgroup.Group
for _, a := range bi.archs {
clang, cflags, err := iosCompilerFor(target, a)
if err != nil {
return err
}
lib := filepath.Join(tmpDir, "gio-"+a)
cmd := exec.Command(
"go",
"build",
"-ldflags=-s -w "+bi.ldflags,
"-buildmode=c-archive",
"-o", lib,
"-tags", "ios",
bi.pkg,
)
lipo.Args = append(lipo.Args, lib)
cflagsLine := strings.Join(cflags, " ")
cmd.Env = append(
os.Environ(),
"GOOS=darwin",
"GOARCH="+a,
"CGO_ENABLED=1",
"CC="+clang,
"CGO_CFLAGS="+cflagsLine,
"CGO_LDFLAGS="+cflagsLine,
)
builds.Go(func() error {
_, err := runCmd(cmd)
return err
})
}
if err := builds.Wait(); err != nil {
return err
}
if _, err := runCmd(lipo); err != nil {
return err
}
appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "gioui.org/ui/app"))
if err != nil {
return err
}
headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
headerSrc := filepath.Join(appDir, "framework_ios.h")
if err := copyFile(headerDst, headerSrc); err != nil {
return err
}
module := fmt.Sprintf(`framework module "%s" {
header "%[1]s.h"
export *
}`, framework)
moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
return ioutil.WriteFile(moduleFile, []byte(module), 0644)
}
func iosCompilerFor(target, arch string) (string, []string, error) {
var platformSDK string
var platformOS string
switch target {
case "ios":
platformOS = "ios"
platformSDK = "iphone"
case "tvos":
platformOS = "tvos"
platformSDK = "appletv"
}
switch arch {
case "arm", "arm64":
platformSDK += "os"
case "386", "amd64":
platformOS += "-simulator"
platformSDK += "simulator"
default:
return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
}
sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
if err != nil {
return "", nil, err
}
clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
if err != nil {
return "", nil, err
}
cflags := []string{
"-fmodules",
"-fobjc-arc",
"-fembed-bitcode",
"-Werror",
"-arch", allArchs[arch].iosArch,
"-isysroot", sdkPath,
"-m" + platformOS + "-version-min=" + minIOSVersion,
}
return clang, cflags, nil
}
func zipDir(dst, base, dir string) (err error) {
f, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
zipf := zip.NewWriter(f)
err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
rel := filepath.ToSlash(path[len(base)+1:])
entry, err := zipf.Create(rel)
if err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(entry, src)
return err
})
if err != nil {
return err
}
return zipf.Close()
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
)
func buildJS(bi *buildInfo) error {
out := *destPath
if out == "" {
out = bi.name
}
if err := os.MkdirAll(out, 0700); err != nil {
return err
}
cmd := exec.Command(
"go",
"build",
"-ldflags="+bi.ldflags,
"-o", filepath.Join(out, "main.wasm"),
bi.pkg,
)
cmd.Env = append(
os.Environ(),
"GOOS=js",
"GOARCH=wasm",
)
_, err := runCmd(cmd)
if err != nil {
return err
}
const indexhtml = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<script src="wasm_exec.js"></script>
<script>
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
<style>
body,pre { margin:0;padding:0; }
</style>
</head>
<body>
</body>
</html>`
if err := ioutil.WriteFile(filepath.Join(out, "index.html"), []byte(indexhtml), 0600); err != nil {
return err
}
goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
if err != nil {
return err
}
wasmjs := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
if _, err := os.Stat(wasmjs); err != nil {
return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
}
return copyFile(filepath.Join(out, "wasm_exec.js"), wasmjs)
}