~eliasnaur/gio-cmd

6826ef0b64b5f30906c10ea69fd70ed2b7d4bd94 — Elias Naur 1 year, 7 months ago
all: initial import from gio main repository

Signed-off-by: Elias Naur <mail@eliasnaur.com>
A  => .builds/apple.yml +67 -0
@@ 1,67 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing
packages:
 - clang
 - cmake
 - curl
 - autoconf
 - libxml2-dev
 - libssl-dev
 - libz-dev
 - llvm-dev # for cctools
 - uuid-dev ## for cctools
 - libplist-utils # for gogio
sources:
 - https://git.sr.ht/~eliasnaur/gio-cmd
 - https://git.sr.ht/~eliasnaur/applesdks
 - https://git.sr.ht/~eliasnaur/giouiorg
 - https://github.com/tpoechtrager/cctools-port.git
 - https://github.com/tpoechtrager/apple-libtapi.git
 - https://github.com/mackyle/xar.git
environment:
   APPLE_TOOLCHAIN_ROOT: /home/build/appletools
   PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
tasks:
 - install_go: |
     mkdir -p /home/build/sdk
     curl -s https://dl.google.com/go/go1.17.7.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
 - prepare_toolchain: |
     mkdir -p $APPLE_TOOLCHAIN_ROOT
     cd $APPLE_TOOLCHAIN_ROOT
     tar xJf /home/build/applesdks/applesdks.tar.xz
     mkdir bin tools
     cd bin
     ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
     ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
     ln -s /home/build/cctools-port/cctools/misc/lipo lipo
     ln -s ../tools/appletoolchain xcrun
     ln -s /usr/bin/plistutil plutil
     cd ../tools
     ln -s appletoolchain clang-ios
     ln -s appletoolchain clang-macos
 - install_appletoolchain: |
     cd giouiorg
     go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
 - build_xar: |
     cd xar/xar
     ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
     make
     sudo make install
 - build_libtapi: |
     cd apple-libtapi
     INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
     ./install.sh
 - build_cctools: |
     cd cctools-port/cctools
     ./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
     make install
 - install_gogio: |
     cd gio-cmd
     go install ./gogio
 - test_ios_gogio: |
     mkdir tmp
     cd tmp
     go mod init example.com
     go get -d gioui.org/example/kitchen
     export PATH=/home/build/appletools/bin:$PATH
     gogio -target ios -o app.app gioui.org/example/kitchen

A  => .builds/freebsd.yml +22 -0
@@ 1,22 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: freebsd/13.x
packages:
 - libX11
 - libxkbcommon
 - libXcursor
 - libXfixes
 - vulkan-headers
 - wayland
 - mesa-libs
 - xorg-vfbserver
sources:
 - https://git.sr.ht/~eliasnaur/gio-cmd
environment:
 PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
tasks:
 - install_go: |
     mkdir -p /home/build/sdk
     curl https://dl.google.com/go/go1.17.7.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
 - test_cmd: |
     cd gio-cmd
     go test ./...

A  => .builds/linux.yml +91 -0
@@ 1,91 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing
packages:
 - curl
 - pkg-config
 - libwayland-dev
 - libx11-dev
 - libx11-xcb-dev
 - libxkbcommon-dev
 - libxkbcommon-x11-dev
 - libgles2-mesa-dev
 - libegl1-mesa-dev
 - libffi-dev
 - libvulkan-dev
 - libxcursor-dev
 - libxrandr-dev
 - libxinerama-dev
 - libxi-dev
 - libxxf86vm-dev
 - mesa-vulkan-drivers
 - wine
 - xvfb
 - xdotool
 - scrot
 - sway
 - grim
 - wine
 - unzip
sources:
 - https://git.sr.ht/~eliasnaur/gio-cmd
environment:
 PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
 ANDROID_SDK_ROOT: /home/build/android
 android_sdk_tools_zip: sdk-tools-linux-3859397.zip
 android_ndk_zip: android-ndk-r20-linux-x86_64.zip
 github_mirror: git@github.com:gioui/gio-cmd
secrets:
 - fdc570bf-87f4-4528-8aee-4d1711b1c86f
tasks:
 - install_go: |
     mkdir -p /home/build/sdk
     curl -s https://dl.google.com/go/go1.17.7.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
 - check_gofmt: |
     cd gio-cmd
     test -z "$(gofmt -s -l .)"
 - check_sign_off: |
     set +x -e
     cd gio-cmd
     for hash in $(git log -n 20 --format="%H"); do
        message=$(git log -1 --format=%B $hash)
        if [[ ! "$message" =~ "Signed-off-by: " ]]; then
            echo "Missing 'Signed-off-by' in commit $hash"
            exit 1
        fi
     done
 - mirror: |
     # mirror to github
     ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
 - install_chrome: |
     curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
     sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
     sudo apt-get -qq update
     sudo apt-get -qq install -y google-chrome-stable
 - test: |
     cd gio-cmd
     go test ./...
     go test -race ./...
 - install_jdk8: |
     curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
     sudo apt-get -qq install -y -f ./jdk.deb
 - install_android: |
     mkdir android
     cd android
     curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
     unzip -q sdk-tools.zip
     rm sdk-tools.zip
     curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
     unzip -q ndk.zip
     rm ndk.zip
     mv android-ndk-* ndk-bundle
     yes|sdkmanager --licenses
     sdkmanager "platforms;android-31" "build-tools;32.0.0"
 - install_gogio: |
     cd gio-cmd
     go install ./gogio
 - test_android_gogio: |
     mkdir tmp
     cd tmp
     go mod init example.com
     go get -d gioui.org/example/kitchen
     gogio -target android gioui.org/example/kitchen

A  => .builds/openbsd.yml +18 -0
@@ 1,18 @@
# SPDX-License-Identifier: Unlicense OR MIT
image: openbsd/latest
packages:
 - libxkbcommon
 - go
sources:
 - https://git.sr.ht/~eliasnaur/gio-cmd
environment:
 PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
tasks:
 - install_go: |
     mkdir -p /home/build/sdk
     curl https://dl.google.com/go/go1.17.7.src.tar.gz | tar -C /home/build/sdk -xzf -
     cd /home/build/sdk/go/src
     ./make.bash
 - test_cmd: |
     cd gio-cmd
     go test ./...

A  => LICENSE +63 -0
@@ 1,63 @@
This project is provided under the terms of the UNLICENSE or
the MIT license denoted by the following SPDX identifier:

SPDX-License-Identifier: Unlicense OR MIT

You may use the project under the terms of either license.

Both licenses are reproduced below.

----
The MIT License (MIT)

Copyright (c) 2019 The Gio authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---



---
The UNLICENSE

This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.

In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <https://unlicense.org/>
---

A  => README.md +21 -0
@@ 1,21 @@
# Gio Tools

Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.

[![builds.sr.ht status](https://builds.sr.ht/~eliasnaur/gio-cmd.svg)](https://builds.sr.ht/~eliasnaur/gio-cmd)

## Issues

File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).

## Contributing

Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
account is required and you can post without being subscribed.

See the [contribution guide](https://gioui.org/doc/contribute) for more details.

An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.

A  => go.mod +31 -0
@@ 1,31 @@
module gioui.org/cmd

go 1.17

require (
	gioui.org v0.0.0-20220328154813-a3f147541fd0
	github.com/akavel/rsrc v0.10.1
	github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
	github.com/chromedp/chromedp v0.5.2
	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
	golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
	golang.org/x/text v0.3.6
	golang.org/x/tools v0.1.0
)

require (
	gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
	gioui.org/shader v1.0.6 // indirect
	github.com/benoitkugler/textlayout v0.0.10 // indirect
	github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12 // indirect
	github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506 // indirect
	github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
	github.com/gobwas/pool v0.2.0 // indirect
	github.com/gobwas/ws v1.0.2 // indirect
	github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
	github.com/mailru/easyjson v0.7.0 // indirect
	golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 // indirect
	golang.org/x/mod v0.4.2 // indirect
	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)

A  => go.sum +118 -0
@@ 1,118 @@
gioui.org v0.0.0-20220328154813-a3f147541fd0 h1:n4FUiCT6P4a2wF6hwX4a5R8TpjAhu/d+3nhwZW16MAI=
gioui.org v0.0.0-20220328154813-a3f147541fd0/go.mod h1:b8vBukexG6eYuXZa14asjLAWJ+JjbZ/ophEnS2FjYUg=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/benoitkugler/textlayout v0.0.10 h1:uIaQgH4pBFw1LQ0tPkfjgxo94WYcckzzQaB41L2X84w=
github.com/benoitkugler/textlayout v0.0.10/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12 h1:1bjaB/5IIicfKpP4k0s30T2WEw//Kh00zULa8DQ0cxA=
github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12/go.mod h1:kDhBRTA/i3H46PVdhqcw26TdGSIj42TOKNWKY+Kipnw=
github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506 h1:1TPz/Gn/MsXwJ6bEtI9wdkPcQYr2X3V9I+wz4wPYUdY=
github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506/go.mod h1:R0mlTNeyszZ/tKQhbZA7SRGjx+OHsmNzgN2jTV7yZcs=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 h1:IlrJD2AM5p8JhN/wVny9jt6gJ9hut2VALhSeZ3SYluk=
golang.org/x/exp v0.0.0-20210722180016-6781d3edade3/go.mod h1:DVyR6MI7P4kEQgvZJSj1fQGrWIi2RzIrfYWycwheUAc=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

A  => gogio/android_test.go +143 -0
@@ 1,143 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bytes"
	"context"
	"fmt"
	"image"
	"image/png"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
)

type AndroidTestDriver struct {
	driverBase

	sdkDir  string
	adbPath string
}

var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)

func (d *AndroidTestDriver) Start(path string) {
	d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
	if d.sdkDir == "" {
		d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
	}
	d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
	if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
		d.Skipf("adb not found")
	}

	devOut := bytes.TrimSpace(d.adb("devices"))
	devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
	switch len(devices) {
	case 0:
		d.Skipf("no Android devices attached via adb; skipping")
	case 1:
	default:
		d.Skipf("multiple Android devices attached via adb; skipping")
	}

	// If the device is attached but asleep, it's probably just charging.
	// Don't use it; the screen needs to be on and unlocked for the test to
	// work.
	if !bytes.Contains(
		d.adb("shell", "dumpsys", "power"),
		[]byte(" mWakefulness=Awake"),
	) {
		d.Skipf("Android device isn't awake; skipping")
	}

	// First, build the app.
	apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
	d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)

	// Make sure the app isn't installed already, and try to uninstall it
	// when we finish. Previous failed test runs might have left the app.
	d.tryUninstall()
	d.adb("install", apk)
	d.Cleanup(d.tryUninstall)

	// Force our e2e app to be fullscreen, so that the android system bar at
	// the top doesn't mess with our screenshots.
	// TODO(mvdan): is there a way to do this via gio, so that we don't need
	// to set up a global Android setting via the shell?
	d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)

	// Make sure the app isn't already running.
	d.adb("shell", "pm", "clear", appid)

	// Start listening for log messages.
	{
		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, d.adbPath,
			"logcat",
			"-s",       // suppress other logs
			"-T1",      // don't show previous log messages
			appid+":*", // show all logs from our gio app ID
		)
		output, err := cmd.StdoutPipe()
		if err != nil {
			d.Fatal(err)
		}
		cmd.Stderr = cmd.Stdout
		d.output = output
		if err := cmd.Start(); err != nil {
			d.Fatal(err)
		}
		d.Cleanup(cancel)
	}

	// Start the app.
	d.adb("shell", "monkey", "-p", appid, "1")

	// Wait for the gio app to render.
	d.waitForFrame()
}

func (d *AndroidTestDriver) Screenshot() image.Image {
	out := d.adb("shell", "screencap", "-p")
	img, err := png.Decode(bytes.NewReader(out))
	if err != nil {
		d.Fatal(err)
	}
	return img
}

func (d *AndroidTestDriver) tryUninstall() {
	cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
	out, err := cmd.CombinedOutput()
	if err != nil {
		if bytes.Contains(out, []byte("Unknown package")) {
			// The package is not installed. Don't log anything.
			return
		}
		d.Logf("could not uninstall: %v\n%s", err, out)
	}
}

func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
	strs := []string{}
	for _, arg := range args {
		strs = append(strs, fmt.Sprint(arg))
	}
	cmd := exec.Command(d.adbPath, strs...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		d.Errorf("%s", out)
		d.Fatal(err)
	}
	return out
}

func (d *AndroidTestDriver) Click(x, y int) {
	d.adb("shell", "input", "tap", x, y)

	// Wait for the gio app to render after this click.
	d.waitForFrame()
}

A  => gogio/androidbuild.go +1044 -0
@@ 1,1044 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"archive/zip"
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"text/template"

	"golang.org/x/sync/errgroup"
	"golang.org/x/tools/go/packages"
)

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
}

var exeSuffix string

type manifestData struct {
	AppID       string
	Version     int
	MinSDK      int
	TargetSDK   int
	Permissions []string
	Features    []string
	IconSnip    string
	AppName     string
}

const (
	themes = `<?xml version="1.0" encoding="utf-8"?>
<resources>
	<style name="Theme.GioApp" parent="android:style/Theme.NoTitleBar">
		<item name="android:windowBackground">@android:color/white</item>
	</style>
</resources>`
	themesV21 = `<?xml version="1.0" encoding="utf-8"?>
<resources>
	<style name="Theme.GioApp" parent="android:style/Theme.NoTitleBar">
		<item name="android:windowBackground">@android:color/white</item>

		<item name="android:windowDrawsSystemBarBackgrounds">true</item>
		<item name="android:navigationBarColor">#40000000</item>
		<item name="android:statusBarColor">#40000000</item>
	</style>
</resources>`
)

func init() {
	if runtime.GOOS == "windows" {
		exeSuffix = ".exe"
	}
}

func buildAndroid(tmpDir string, bi *buildInfo) error {
	sdk := os.Getenv("ANDROID_SDK_ROOT")
	if sdk == "" {
		return errors.New("please set ANDROID_SDK_ROOT 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"),
	}
	perms := []string{"default"}
	const permPref = "gioui.org/app/permission/"
	cfg := &packages.Config{
		Mode: packages.NeedName +
			packages.NeedFiles +
			packages.NeedImports +
			packages.NeedDeps,
		Env: append(
			os.Environ(),
			"GOOS=android",
			"CGO_ENABLED=1",
		),
	}
	pkgs, err := packages.Load(cfg, bi.pkgPath)
	if err != nil {
		return err
	}
	var extraJars []string
	visitedPkgs := make(map[string]bool)
	var visitPkg func(*packages.Package) error
	visitPkg = func(p *packages.Package) error {
		if len(p.GoFiles) == 0 {
			return nil
		}
		dir := filepath.Dir(p.GoFiles[0])
		jars, err := filepath.Glob(filepath.Join(dir, "*.jar"))
		if err != nil {
			return err
		}
		extraJars = append(extraJars, jars...)
		switch {
		case p.PkgPath == "net":
			perms = append(perms, "network")
		case strings.HasPrefix(p.PkgPath, permPref):
			perms = append(perms, p.PkgPath[len(permPref):])
		}

		for _, imp := range p.Imports {
			if !visitedPkgs[imp.ID] {
				visitPkg(imp)
				visitedPkgs[imp.ID] = true
			}
		}
		return nil
	}
	if err := visitPkg(pkgs[0]); err != nil {
		return err
	}

	if err := compileAndroid(tmpDir, tools, bi); err != nil {
		return err
	}
	switch *buildMode {
	case "archive":
		return archiveAndroid(tmpDir, bi, perms)
	case "exe":
		file := *destPath
		if file == "" {
			file = fmt.Sprintf("%s.apk", bi.name)
		}

		isBundle := false
		switch filepath.Ext(file) {
		case ".apk":
		case ".aab":
			isBundle = true
		default:
			return fmt.Errorf("the specified output %q does not end in '.apk' or '.aab'", file)
		}

		if err := exeAndroid(tmpDir, tools, bi, extraJars, perms, isBundle); err != nil {
			return err
		}
		if isBundle {
			return signAAB(tmpDir, file, tools, bi)
		}
		return signAPK(tmpDir, file, tools, bi)
	default:
		panic("unreachable")
	}
}

func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) {
	androidHome := os.Getenv("ANDROID_SDK_ROOT")
	if androidHome == "" {
		return errors.New("ANDROID_SDK_ROOT is not set. Please point it to the root of the Android SDK")
	}
	javac, err := findJavaC()
	if err != nil {
		return fmt.Errorf("could not find javac: %v", err)
	}
	ndkRoot, err := findNDK(androidHome)
	if err != nil {
		return err
	}
	minSDK := 17
	if bi.minsdk > minSDK {
		minSDK = bi.minsdk
	}
	tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK())
	var builds errgroup.Group
	for _, a := range bi.archs {
		arch := allArchs[a]
		clang, err := latestCompiler(tcRoot, a, minSDK)
		if err != nil {
			return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err)
		}
		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 := 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",
			"-tags", bi.tags,
			"-o", libFile,
			bi.pkgPath,
		)
		cmd.Env = append(
			os.Environ(),
			"GOOS=android",
			"GOARCH="+a,
			"GOARM=7", // Avoid softfloat.
			"CGO_ENABLED=1",
			"CC="+clang,
		)
		builds.Go(func() error {
			_, err := runCmd(cmd)
			return err
		})
	}
	appDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", "gioui.org/app/"))
	if err != nil {
		return err
	}
	javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java"))
	if err != nil {
		return err
	}
	if len(javaFiles) == 0 {
		return fmt.Errorf("the gioui.org/app package contains no .java files (gioui.org module too old?)")
	}
	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, perms []string) (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")
	themesXML := aarw.Create("res/values/themes.xml")
	themesXML.Write([]byte(themes))
	themesXML21 := aarw.Create("res/values-v21/themes.xml")
	themesXML21.Write([]byte(themesV21))
	permissions, features := getPermissions(perms)
	// Disable input emulation on ChromeOS.
	manifest := aarw.Create("AndroidManifest.xml")
	manifestSrc := manifestData{
		AppID:       bi.appID,
		MinSDK:      bi.minsdk,
		Permissions: permissions,
		Features:    features,
	}
	tmpl, err := template.New("manifest").Parse(
		`<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{{.AppID}}">
        <uses-sdk android:minSdkVersion="{{.MinSDK}}"/>
{{range .Permissions}}	<uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}}	<uses-feature android:{{.}} android:required="false"/>
{{end}}</manifest>
`)
	if err != nil {
		panic(err)
	}
	err = tmpl.Execute(manifest, manifestSrc)
	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, extraJars, perms []string, isBundle bool) (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
	})
	classFiles = append(classFiles, extraJars...)
	dexDir := filepath.Join(tmpDir, "apk")
	if err := os.MkdirAll(dexDir, 0755); err != nil {
		return err
	}
	// https://developer.android.com/distribute/best-practices/develop/target-sdk
	targetSDK := 31
	if bi.minsdk > targetSDK {
		targetSDK = bi.minsdk
	}
	minSDK := 16
	if bi.minsdk > minSDK {
		minSDK = bi.minsdk
	}
	if len(classFiles) > 0 {
		d8 := exec.Command(
			filepath.Join(tools.buildtools, "d8"),
			"--lib", tools.androidjar,
			"--output", dexDir,
			"--min-api", strconv.Itoa(minSDK),
		)
		d8.Args = append(d8.Args, classFiles...)
		if _, err := runCmd(d8); err != nil {
			major, minor, ok := determineJDKVersion()
			if ok && (major != 1 || minor != 8) {
				return fmt.Errorf("unsupported JDK version %d.%d, expected 1.8\nd8 error: %v", major, minor, err)
			}
			return err
		}
	}

	// Compile resources.
	resDir := filepath.Join(tmpDir, "res")
	valDir := filepath.Join(resDir, "values")
	v21Dir := filepath.Join(resDir, "values-v21")
	v26mipmapDir := filepath.Join(resDir, `mipmap-anydpi-v26`)
	for _, dir := range []string{valDir, v21Dir, v26mipmapDir} {
		if err := os.MkdirAll(dir, 0755); err != nil {
			return err
		}
	}
	iconSnip := ""
	if _, err := os.Stat(bi.iconPath); err == nil {
		err := buildIcons(resDir, bi.iconPath, []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},
			{path: filepath.Join("mipmap-mdpi", "ic_launcher_adaptive.png"), size: 108},
			{path: filepath.Join("mipmap-hdpi", "ic_launcher_adaptive.png"), size: 162},
			{path: filepath.Join("mipmap-xhdpi", "ic_launcher_adaptive.png"), size: 216},
			{path: filepath.Join("mipmap-xxhdpi", "ic_launcher_adaptive.png"), size: 324},
			{path: filepath.Join("mipmap-xxxhdpi", "ic_launcher_adaptive.png"), size: 432},
		})
		if err != nil {
			return err
		}
		err = ioutil.WriteFile(filepath.Join(v26mipmapDir, `ic_launcher.xml`), []byte(`<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@mipmap/ic_launcher_adaptive" />
    <foreground android:drawable="@mipmap/ic_launcher_adaptive" />
</adaptive-icon>`), 0660)
		if err != nil {
			return err
		}
		iconSnip = `android:icon="@mipmap/ic_launcher"`
	}
	err = ioutil.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0660)
	if err != nil {
		return err
	}
	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.
	permissions, features := getPermissions(perms)
	appName := strings.Title(bi.name)
	manifestSrc := manifestData{
		AppID:       bi.appID,
		Version:     bi.version,
		MinSDK:      minSDK,
		TargetSDK:   targetSDK,
		Permissions: permissions,
		Features:    features,
		IconSnip:    iconSnip,
		AppName:     appName,
	}
	tmpl, err := template.New("test").Parse(
		`<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
	package="{{.AppID}}"
	android:versionCode="{{.Version}}"
	android:versionName="1.0.{{.Version}}">
	<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}}	<uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}}	<uses-feature android:{{.}} android:required="false"/>
{{end}}	<application {{.IconSnip}} android:label="{{.AppName}}">
		<activity android:name="org.gioui.GioActivity"
			android:label="{{.AppName}}"
			android:theme="@style/Theme.GioApp"
			android:configChanges="screenSize|screenLayout|smallestScreenSize|orientation|keyboardHidden"
			android:windowSoftInputMode="adjustResize"
			android:exported="true">
			<intent-filter>
				<action android:name="android.intent.action.MAIN" />
				<category android:name="android.intent.category.LAUNCHER" />
			</intent-filter>
		</activity>
	</application>
</manifest>`)
	var manifestBuffer bytes.Buffer
	if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil {
		return err
	}
	manifest := filepath.Join(tmpDir, "AndroidManifest.xml")
	if err := ioutil.WriteFile(manifest, manifestBuffer.Bytes(), 0660); err != nil {
		return err
	}

	linkAPK := filepath.Join(tmpDir, "link.apk")

	args := []string{
		"link",
		"--manifest", manifest,
		"-I", tools.androidjar,
		"-o", linkAPK,
	}
	if isBundle {
		args = append(args, "--proto-format")
	}
	args = append(args, resZip)

	if _, err := runCmd(exec.Command(aapt2, args...)); err != nil {
		return err
	}

	// The Go standard library archive/zip doesn't support appending to zip
	// files. Copy files from `link.apk` (generated by aapt2) along with classes.dex and
	// the Go libraries to a new `app.zip` file.

	// Load link.apk as zip.
	linkAPKZip, err := zip.OpenReader(linkAPK)
	if err != nil {
		return err
	}
	defer linkAPKZip.Close()

	// Create new "APK".
	unsignedAPK := filepath.Join(tmpDir, "app.zip")
	unsignedAPKFile, err := os.Create(unsignedAPK)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := unsignedAPKFile.Close(); err == nil {
			err = cerr
		}
	}()
	unsignedAPKZip := zip.NewWriter(unsignedAPKFile)
	defer unsignedAPKZip.Close()

	// Copy files from linkAPK to unsignedAPK.
	for _, f := range linkAPKZip.File {
		header := zip.FileHeader{
			Name:   f.FileHeader.Name,
			Method: f.FileHeader.Method,
		}

		if isBundle {
			// AAB have pre-defined folders.
			switch header.Name {
			case "AndroidManifest.xml":
				header.Name = "manifest/AndroidManifest.xml"
			}
		}

		w, err := unsignedAPKZip.CreateHeader(&header)
		if err != nil {
			return err
		}
		r, err := f.Open()
		if err != nil {
			return err
		}
		if _, err := io.Copy(w, r); err != nil {
			return err
		}
	}

	// Append new files (that doesn't exists inside the link.apk).
	appendToZip := func(path string, file string) error {
		f, err := os.Open(file)
		if err != nil {
			return err
		}
		defer f.Close()
		w, err := unsignedAPKZip.CreateHeader(&zip.FileHeader{
			Name:   filepath.ToSlash(path),
			Method: zip.Deflate,
		})
		if err != nil {
			return err
		}
		_, err = io.Copy(w, f)
		return err
	}

	// Append Go binaries (libgio.so).
	for _, a := range bi.archs {
		arch := allArchs[a]
		libFile := filepath.Join(arch.jniArch, "libgio.so")
		if err := appendToZip(filepath.Join("lib", libFile), filepath.Join(tmpDir, "jni", libFile)); err != nil {
			return err
		}
	}

	// Append classes.dex.
	if len(classFiles) > 0 {
		classesFolder := "classes.dex"
		if isBundle {
			classesFolder = "dex/classes.dex"
		}
		if err := appendToZip(classesFolder, filepath.Join(dexDir, "classes.dex")); err != nil {
			return err
		}
	}

	return unsignedAPKZip.Close()
}

func determineJDKVersion() (int, int, bool) {
	path, err := findJavaC()
	if err != nil {
		return 0, 0, false
	}
	java := exec.Command(filepath.Join(filepath.Dir(path), "java"), "-version")
	out, err := java.CombinedOutput()
	if err != nil {
		return 0, 0, false
	}
	var vendor string
	var major, minor int
	_, err = fmt.Sscanf(string(out), "%s version \"%d.%d", &vendor, &major, &minor)
	return major, minor, err == nil
}

func signAPK(tmpDir string, apkFile string, tools *androidTools, bi *buildInfo) error {
	if err := zipalign(tools, filepath.Join(tmpDir, "app.zip"), apkFile); err != nil {
		return err
	}

	if bi.key == "" {
		if err := defaultAndroidKeystore(tmpDir, bi); err != nil {
			return err
		}
	}

	_, err := runCmd(exec.Command(
		filepath.Join(tools.buildtools, "apksigner"),
		"sign",
		"--ks-pass", "pass:"+bi.password,
		"--ks", bi.key,
		apkFile,
	))

	return err
}

func signAAB(tmpDir string, aabFile string, tools *androidTools, bi *buildInfo) error {
	allBundleTools, err := filepath.Glob(filepath.Join(tools.buildtools, "bundletool*.jar"))
	if err != nil {
		return err
	}

	bundletool := ""
	for _, v := range allBundleTools {
		bundletool = v
		break
	}

	if bundletool == "" {
		return fmt.Errorf("bundletool was not found at %s. Download it from https://github.com/google/bundletool/releases and move to the respective folder", tools.buildtools)
	}

	_, err = runCmd(exec.Command(
		"java",
		"-jar", bundletool,
		"build-bundle",
		"--modules="+filepath.Join(tmpDir, "app.zip"),
		"--output="+filepath.Join(tmpDir, "app.aab"),
	))
	if err != nil {
		return err
	}

	if err := zipalign(tools, filepath.Join(tmpDir, "app.aab"), aabFile); err != nil {
		return err
	}

	if bi.key == "" {
		if err := defaultAndroidKeystore(tmpDir, bi); err != nil {
			return err
		}
	}

	keytoolList, err := runCmd(exec.Command(
		"keytool",
		"-keystore", bi.key,
		"-list",
		"-keypass", bi.password,
		"-v",
	))
	if err != nil {
		return err
	}

	var alias string
	for _, t := range strings.Split(keytoolList, "\n") {
		if i, _ := fmt.Sscanf(t, "Alias name: %s", &alias); i > 0 {
			break
		}
	}

	_, err = runCmd(exec.Command(
		filepath.Join("jarsigner"),
		"-sigalg", "SHA256withRSA",
		"-digestalg", "SHA-256",
		"-keystore", bi.key,
		"-storepass", bi.password,
		aabFile,
		strings.TrimSpace(alias),
	))

	return err
}

func zipalign(tools *androidTools, input, output string) error {
	_, err := runCmd(exec.Command(
		filepath.Join(tools.buildtools, "zipalign"),
		"-f",
		"4", // 32-bit alignment.
		input,
		output,
	))
	return err
}

func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error {
	home, err := os.UserHomeDir()
	if err != nil {
		return err
	}

	// Use debug.keystore, if exists.
	bi.key = filepath.Join(home, ".android", "debug.keystore")
	bi.password = "android"
	if _, err := os.Stat(bi.key); err == nil {
		return nil
	}

	// Generate new key.
	bi.key = filepath.Join(tmpDir, "sign.keystore")
	keytool, err := findKeytool()
	if err != nil {
		return err
	}
	_, err = runCmd(exec.Command(
		keytool,
		"-genkey",
		"-keystore", bi.key,
		"-storepass", bi.password,
		"-alias", "android",
		"-keyalg", "RSA", "-keysize", "2048",
		"-validity", "10000",
		"-noprompt",
		"-dname", "CN=android",
	))
	return err
}

func findNDK(androidHome string) (string, error) {
	ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*"))
	if err != nil {
		return "", err
	}
	if bestNDK, found := latestVersionPath(ndks); found {
		return bestNDK, nil
	}
	// The old NDK path was $ANDROID_SDK_ROOT/ndk-bundle.
	ndkBundle := filepath.Join(androidHome, "ndk-bundle")
	if _, err := os.Stat(ndkBundle); err == nil {
		return ndkBundle, nil
	}
	// Certain non-standard NDK isntallations set the $ANDROID_NDK_ROOT
	// environment variable
	if ndkBundle, ok := os.LookupEnv("ANDROID_NDK_ROOT"); ok {
		if _, err := os.Stat(ndkBundle); err == nil {
			return ndkBundle, nil
		}
	}

	return "", fmt.Errorf("no NDK found in $ANDROID_SDK_ROOT (%s). Set $ANDROID_NDK_ROOT or use `sdkmanager ndk-bundle` to install the NDK", androidHome)
}

func findKeytool() (string, error) {
	javaHome := os.Getenv("JAVA_HOME")
	if javaHome == "" {
		return exec.LookPath("keytool")
	}
	keytool := filepath.Join(javaHome, "jre", "bin", "keytool"+exeSuffix)
	if _, err := os.Stat(keytool); err != nil {
		return "", err
	}
	return keytool, nil
}

func findJavaC() (string, error) {
	javaHome := os.Getenv("JAVA_HOME")
	if javaHome == "" {
		return exec.LookPath("javac")
	}
	javac := filepath.Join(javaHome, "bin", "javac"+exeSuffix)
	if _, err := os.Stat(javac); err != nil {
		return "", err
	}
	return javac, 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 {
	var arch string
	switch runtime.GOARCH {
	case "386":
		arch = "x86"
	case "amd64":
		arch = "x86_64"
	case "arm64":
		if runtime.GOOS == "darwin" {
			// Workaround for arm64 macOS. This will keep working until
			// Apple deprecates Rosetta 2.
			arch = "x86_64"
		} else {
			panic("unsupported GOARCH: " + runtime.GOARCH)
		}
	default:
		panic("unsupported GOARCH: " + runtime.GOARCH)
	}
	return runtime.GOOS + "-" + arch
}

func getPermissions(ps []string) ([]string, []string) {
	var permissions, features []string
	seenPermissions := make(map[string]bool)
	seenFeatures := make(map[string]bool)
	for _, perm := range ps {
		for _, x := range AndroidPermissions[perm] {
			if !seenPermissions[x] {
				permissions = append(permissions, x)
				seenPermissions[x] = true
			}
		}
		for _, x := range AndroidFeatures[perm] {
			if !seenFeatures[x] {
				features = append(features, x)
				seenFeatures[x] = true
			}
		}
	}
	return permissions, features
}

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 latestCompiler(tcRoot, a string, minsdk int) (string, error) {
	arch := allArchs[a]
	allComps, err := filepath.Glob(filepath.Join(tcRoot, "bin", arch.clangArch+"*-clang"))
	if err != nil {
		return "", err
	}
	var bestVer int
	var firstVer int
	var bestCompiler string
	var firstCompiler string
	for _, compiler := range allComps {
		var ver int
		pattern := filepath.Join(tcRoot, "bin", arch.clangArch) + "%d-clang"
		if n, err := fmt.Sscanf(compiler, pattern, &ver); n < 1 || err != nil {
			continue
		}
		if firstCompiler == "" || ver < firstVer {
			firstVer = ver
			firstCompiler = compiler
		}
		if ver < bestVer {
			continue
		}
		if ver > minsdk {
			continue
		}
		bestVer = ver
		bestCompiler = compiler
	}
	if bestCompiler == "" {
		bestCompiler = firstCompiler
	}
	if bestCompiler == "" {
		return "", fmt.Errorf("no NDK compiler found for architecture %s in %s", a, tcRoot)
	}
	return bestCompiler, nil
}

func latestTools(sdk string) (string, error) {
	allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*"))
	if err != nil {
		return "", err
	}
	tools, found := latestVersionPath(allTools)
	if !found {
		return "", fmt.Errorf("no build-tools found in %q", sdk)
	}
	return tools, nil
}

// latestVersionFile finds the path with the highest version
// among paths on the form
//
//	/some/path/major.minor.patch
func latestVersionPath(paths []string) (string, bool) {
	var bestVer [3]int
	var bestDir string
loop:
	for _, path := range paths {
		name := filepath.Base(path)
		s := strings.SplitN(name, ".", 3)
		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
		bestDir = path
	}
	return bestDir, bestDir != ""
}

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
}

A  => gogio/build_info.go +156 -0
@@ 1,156 @@
package main

import (
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"runtime"
	"strings"
)

type buildInfo struct {
	appID    string
	archs    []string
	ldflags  string
	minsdk   int
	name     string
	pkgDir   string
	pkgPath  string
	iconPath string
	tags     string
	target   string
	version  int
	key      string
	password string
}

func newBuildInfo(pkgPath string) (*buildInfo, error) {
	pkgMetadata, err := getPkgMetadata(pkgPath)
	if err != nil {
		return nil, err
	}
	appID := getAppID(pkgMetadata)
	appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
	if *iconPath != "" {
		appIcon = *iconPath
	}
	bi := &buildInfo{
		appID:    appID,
		archs:    getArchs(),
		ldflags:  getLdFlags(appID),
		minsdk:   *minsdk,
		name:     getPkgName(pkgMetadata),
		pkgDir:   pkgMetadata.Dir,
		pkgPath:  pkgPath,
		iconPath: appIcon,
		tags:     *extraTags,
		target:   *target,
		version:  *version,
		key:      *signKey,
		password: *signPass,
	}
	return bi, nil
}

func getArchs() []string {
	if *archNames != "" {
		return strings.Split(*archNames, ",")
	}
	switch *target {
	case "js":
		return []string{"wasm"}
	case "ios", "tvos":
		// Only 64-bit support.
		return []string{"arm64", "amd64"}
	case "android":
		return []string{"arm", "arm64", "386", "amd64"}
	case "windows":
		goarch := os.Getenv("GOARCH")
		if goarch == "" {
			goarch = runtime.GOARCH
		}
		return []string{goarch}
	default:
		// TODO: Add flag tests.
		panic("The target value has already been validated, this will never execute.")
	}
}

func getLdFlags(appID string) string {
	var ldflags []string
	if extra := *extraLdflags; extra != "" {
		ldflags = append(ldflags, strings.Split(extra, " ")...)
	}
	// Pass appID along, to be used for logging on platforms like Android.
	ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
	// Pass along all remaining arguments to the app.
	if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
		ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
	}
	if m := *linkMode; m != "" {
		ldflags = append(ldflags, "-linkmode="+m)
	}
	return strings.Join(ldflags, " ")
}

type packageMetadata struct {
	PkgPath string
	Dir     string
}

func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
	pkgImportPath, err := runCmd(exec.Command("go", "list", "-f", "{{.ImportPath}}", pkgPath))
	if err != nil {
		return nil, err
	}
	pkgDir, err := runCmd(exec.Command("go", "list", "-f", "{{.Dir}}", pkgPath))
	if err != nil {
		return nil, err
	}
	return &packageMetadata{
		PkgPath: pkgImportPath,
		Dir:     pkgDir,
	}, nil
}

func getAppID(pkgMetadata *packageMetadata) string {
	if *appID != "" {
		return *appID
	}
	elems := strings.Split(pkgMetadata.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 getPkgName(pkgMetadata *packageMetadata) string {
	return path.Base(pkgMetadata.PkgPath)
}

A  => gogio/build_info_test.go +32 -0
@@ 1,32 @@
package main

import "testing"

type expval struct {
	in, out string
}

func TestAppID(t *testing.T) {
	t.Parallel()

	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 := getAppID(&packageMetadata{PkgPath: test.in})
		if exp := test.out; got != exp {
			t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
		}
	}
}

A  => gogio/doc.go +10 -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

A  => gogio/e2e_test.go +331 -0
@@ 1,331 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bufio"
	"errors"
	"flag"
	"fmt"
	"image"
	"image/color"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

var raceEnabled = false

var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")

const appid = "localhost.gogio.endtoend"

// TestDriver is implemented by each of the platforms we can run end-to-end
// tests on. None of its methods return any errors, as the errors are directly
// reported to testing.T via methods like Fatal.
type TestDriver interface {
	initBase(t *testing.T, width, height int)

	// Start opens the Gio app found at path. The driver should attempt to
	// run the app with the base driver's width and height, and the
	// platform's background should be white.
	//
	// When the function returns, the gio app must be ready to use on the
	// platform, with its initial frame fully drawn.
	Start(path string)

	// Screenshot takes a screenshot of the Gio app on the platform.
	Screenshot() image.Image

	// Click performs a pointer click at the specified coordinates,
	// including both press and release. It returns when the next frame is
	// fully drawn.
	Click(x, y int)
}

type driverBase struct {
	*testing.T

	width, height int

	output      io.Reader
	frameNotifs chan bool
}

func (d *driverBase) initBase(t *testing.T, width, height int) {
	d.T = t
	d.width, d.height = width, height
}

func TestEndToEnd(t *testing.T) {
	if testing.Short() {
		t.Skipf("end-to-end tests tend to be slow")
	}

	t.Parallel()

	const (
		testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/testdata"
		testdataWithRelativePkgPath = "testdata/testdata.go"
	)
	// Keep this list local, to not reuse TestDriver objects.
	subtests := []struct {
		name    string
		driver  TestDriver
		pkgPath string
	}{
		{"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath},
		{"X11", &X11TestDriver{}, testdataWithRelativePkgPath},
		// Doesn't work on the builders.
		//{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
		{"JS", &JSTestDriver{}, testdataWithRelativePkgPath},
		{"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath},
		{"Windows", &WineTestDriver{}, testdataWithRelativePkgPath},
	}

	for _, subtest := range subtests {
		t.Run(subtest.name, func(t *testing.T) {
			subtest := subtest // copy the changing loop variable
			t.Parallel()
			runEndToEndTest(t, subtest.driver, subtest.pkgPath)
		})
	}
}

func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
	size := image.Point{X: 800, Y: 600}
	driver.initBase(t, size.X, size.Y)

	t.Log("starting driver and gio app")
	driver.Start(pkgPath)

	beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
	white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
	black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
	gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
	red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}

	// These are the four colors at the beginning.
	t.Log("taking initial screenshot")
	withRetries(t, 4*time.Second, func() error {
		img := driver.Screenshot()
		size = img.Bounds().Size() // override the default size
		return checkImageCorners(img, beef, white, black, gray)
	})

	// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
	// almost works to automate clicks, but the button presses end up in the
	// wrong coordinates.
	if _, ok := driver.(*WaylandTestDriver); ok {
		return
	}

	// Click the first and last sections to turn them red.
	t.Log("clicking twice and taking another screenshot")
	driver.Click(1*(size.X/4), 1*(size.Y/4))
	driver.Click(3*(size.X/4), 3*(size.Y/4))
	withRetries(t, 4*time.Second, func() error {
		img := driver.Screenshot()
		return checkImageCorners(img, red, white, black, red)
	})
}

// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
// such, timeout should generally be in the order of seconds.
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
	t.Helper()

	timeoutTimer := time.NewTimer(timeout)
	defer timeoutTimer.Stop()
	backoff := 100 * time.Millisecond

	tries := 0
	var lastErr error
	for {
		if lastErr = fn(); lastErr == nil {
			return
		}
		tries++
		t.Logf("retrying after %s", backoff)

		// Use a timer instead of a sleep, so that the timeout can stop
		// the backoff early. Don't reuse this timer, since we're not in
		// a hot loop, and we don't want tricky code.
		backoffTimer := time.NewTimer(backoff)
		defer backoffTimer.Stop()

		select {
		case <-timeoutTimer.C:
			t.Errorf("last error: %v", lastErr)
			t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
		case <-backoffTimer.C:
		}

		// Keep doubling it until a maximum. With the start at 100ms,
		// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
		backoff *= 2
		if max := 2 * time.Second; backoff > max {
			backoff = max
		}
	}
}

type colorMismatch struct {
	x, y            int
	wantRGB, gotRGB [3]uint32
}

func (m colorMismatch) String() string {
	return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
		m.x, m.y,
		m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
		m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
	)
}

func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
	// The colors are split in four rectangular sections. Check the corners
	// of each of the sections. We check the corners left to right, top to
	// bottom, like when reading left-to-right text.

	size := img.Bounds().Size()
	var mismatches []colorMismatch

	checkColor := func(x, y int, want color.Color) {
		r, g, b, _ := want.RGBA()
		got := img.At(x, y)
		r_, g_, b_, _ := got.RGBA()
		if r_ != r || g_ != g || b_ != b {
			mismatches = append(mismatches, colorMismatch{
				x:       x,
				y:       y,
				wantRGB: [3]uint32{r, g, b},
				gotRGB:  [3]uint32{r_, g_, b_},
			})
		}
	}

	{
		minX, minY := 5, 5
		maxX, maxY := (size.X/2)-5, (size.Y/2)-5
		checkColor(minX, minY, topLeft)
		checkColor(maxX, minY, topLeft)
		checkColor(minX, maxY, topLeft)
		checkColor(maxX, maxY, topLeft)
	}
	{
		minX, minY := (size.X/2)+5, 5
		maxX, maxY := size.X-5, (size.Y/2)-5
		checkColor(minX, minY, topRight)
		checkColor(maxX, minY, topRight)
		checkColor(minX, maxY, topRight)
		checkColor(maxX, maxY, topRight)
	}
	{
		minX, minY := 5, (size.Y/2)+5
		maxX, maxY := (size.X/2)-5, size.Y-5
		checkColor(minX, minY, botLeft)
		checkColor(maxX, minY, botLeft)
		checkColor(minX, maxY, botLeft)
		checkColor(maxX, maxY, botLeft)
	}
	{
		minX, minY := (size.X/2)+5, (size.Y/2)+5
		maxX, maxY := size.X-5, size.Y-5
		checkColor(minX, minY, botRight)
		checkColor(maxX, minY, botRight)
		checkColor(minX, maxY, botRight)
		checkColor(maxX, maxY, botRight)
	}
	if n := len(mismatches); n > 0 {
		b := new(strings.Builder)
		fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
		for _, m := range mismatches {
			fmt.Fprintf(b, "%s\n", m)
		}
		return errors.New(b.String())
	}
	return nil
}

func (d *driverBase) waitForFrame() {
	d.Helper()

	if d.frameNotifs == nil {
		// Start the goroutine that reads output lines and notifies of
		// new frames via frameNotifs. The test doesn't wait for this
		// goroutine to finish; it will naturally end when the output
		// reader reaches an error like EOF.
		d.frameNotifs = make(chan bool, 1)
		if d.output == nil {
			d.Fatal("need an output reader to be notified of frames")
		}
		go func() {
			scanner := bufio.NewScanner(d.output)
			for scanner.Scan() {
				line := scanner.Text()
				d.Log(line)
				if strings.Contains(line, "gio frame ready") {
					d.frameNotifs <- true
				}
			}
			// Since we're only interested in the output while the
			// app runs, and we don't know when it finishes here,
			// ignore "already closed" pipe errors.
			if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
				d.Errorf("reading app output: %v", err)
			}
		}()
	}

	// Unfortunately, there isn't a way to select on a test failing, since
	// testing.T doesn't have anything like a context or a "done" channel.
	//
	// We can't let selects block forever, since the default -test.timeout
	// is ten minutes - far too long for tests that take seconds.
	//
	// For now, a static short timeout is better than nothing. 5s is plenty
	// for our simple test app to render on any device.
	select {
	case <-d.frameNotifs:
	case <-time.After(5 * time.Second):
		d.Fatalf("timed out waiting for a frame to be ready")
	}
}

func (d *driverBase) needPrograms(names ...string) {
	d.Helper()
	for _, name := range names {
		if _, err := exec.LookPath(name); err != nil {
			d.Skipf("%s needed to run", name)
		}
	}
}

func (d *driverBase) tempDir(name string) string {
	d.Helper()
	dir, err := ioutil.TempDir("", name)
	if err != nil {
		d.Fatal(err)
	}
	d.Cleanup(func() { os.RemoveAll(dir) })
	return dir
}

func (d *driverBase) gogio(args ...string) {
	d.Helper()
	prog, err := os.Executable()
	if err != nil {
		d.Fatal(err)
	}
	cmd := exec.Command(prog, args...)
	cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Fatalf("gogio error: %s:\n%s", err, out)
	}
}

A  => gogio/help.go +69 -0
@@ 1,69 @@
// 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 gogio tool builds and packages 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.

Compiled Java class files from jar files in the package directory are
included in Android builds.

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.

The -ldflags and -tags flags pass extra linker flags and tags to the go tool.

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 -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
If left unspecified, the appicon.png file from the main package is used
(if it exists).

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 code for Android and the last
component of the 1.0.X version for iOS and tvOS.

For Android builds the -minsdk flag specify the minimum SDK level. For example,
use -minsdk 22 to target Android 5.1 (Lollipop) and later.

For Windows builds the -minsdk flag specify the minimum OS version. For example,
use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.

For iOS builds the -minsdk flag specify the minimum iOS version. For example, 
use -mindk 15 to target iOS 15.0 and later.

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.

The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files.

The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
`

A  => gogio/iosbuild.go +589 -0
@@ 1,589 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"archive/zip"
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"golang.org/x/sync/errgroup"
)

const (
	minIOSVersion = 10
	// Metal is available from iOS 8 on devices, yet from version 13 on the
	// simulator.
	minSimulatorVersion = 13
)

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]
		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
		}
		identity := sha1.Sum(certDER)
		idHex := hex.EncodeToString(identity[:])
		_, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--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;

@interface GioAppDelegate : UIResponder <UIApplicationDelegate>
@property (strong, nonatomic) UIWindow *window;
@end

@implementation GioAppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
	self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
	GioViewController *controller = [[GioViewController alloc] initWithNibName:nil bundle:nil];
	self.window.rootViewController = controller;
	[self.window makeKeyAndVisible];
	return YES;
}
@end

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, bi.minsdk)
		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,
			"-Werror",
			"-fmodules",
			"-fobjc-arc",
			"-x", "objective-c",
			"-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
	}
	if _, err := os.Stat(bi.iconPath); err == nil {
		assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
		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")

	minsdk := bi.minsdk
	if minsdk == 0 {
		minsdk = minIOSVersion
	}
	compile := exec.Command(
		"actool",
		"--compile", appDir,
		"--platform", iosPlatformFor(bi.target),
		"--minimum-deployment-target", strconv.Itoa(minsdk),
		"--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>%d</string>
	<key>UIDeviceFamily</key>
	<array>
		<integer>1</integer>
		<integer>2</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
	tags := bi.tags
	goos := "ios"
	supportsIOS, err := supportsGOOS("ios")
	if err != nil {
		return err
	}
	if !supportsIOS {
		// Go 1.15 and earlier target iOS with GOOS=darwin, tags=ios.
		goos = "darwin"
		tags = "ios " + tags
	}
	for _, a := range bi.archs {
		clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
		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", tags,
			bi.pkgPath,
		)
		lipo.Args = append(lipo.Args, lib)
		cflagsLine := strings.Join(cflags, " ")
		cmd.Env = append(
			os.Environ(),
			"GOOS="+goos,
			"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/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 supportsGOOS(wantGoos string) (bool, error) {
	geese, err := runCmd(exec.Command("go", "tool", "dist", "list"))
	if err != nil {
		return false, err
	}
	for _, pair := range strings.Split(geese, "\n") {
		s := strings.SplitN(pair, "/", 2)
		if len(s) != 2 {
			return false, fmt.Errorf("go tool dist list: invalid GOOS/GOARCH pair: %s", pair)
		}
		goos := s[0]
		if goos == wantGoos {
			return true, nil
		}
	}
	return false, nil
}

func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
	var (
		platformSDK string
		platformOS  string
	)
	switch target {
	case "ios":
		platformOS = "ios"
		platformSDK = "iphone"
	case "tvos":
		platformOS = "tvos"
		platformSDK = "appletv"
	}
	switch arch {
	case "arm", "arm64":
		platformSDK += "os"
		if minsdk == 0 {
			minsdk = minIOSVersion
		}
	case "386", "amd64":
		platformOS += "-simulator"
		platformSDK += "simulator"
		if minsdk == 0 {
			minsdk = minSimulatorVersion
		}
	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{
		"-fembed-bitcode",
		"-arch", allArchs[arch].iosArch,
		"-isysroot", sdkPath,
		"-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
	}
	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()
}

A  => gogio/js_test.go +123 -0
@@ 1,123 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bytes"
	"context"
	"errors"
	"image"
	"image/png"
	"io"
	"net/http"
	"net/http/httptest"
	"os/exec"

	"github.com/chromedp/cdproto/runtime"
	"github.com/chromedp/chromedp"

	_ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
)

type JSTestDriver struct {
	driverBase

	// ctx is the chromedp context.
	ctx context.Context
}

func (d *JSTestDriver) Start(path string) {
	if raceEnabled {
		d.Skipf("js/wasm doesn't support -race; skipping")
	}

	// First, build the app.
	dir := d.tempDir("gio-endtoend-js")
	d.gogio("-target=js", "-o="+dir, path)

	// Second, start Chrome.
	opts := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.Flag("headless", *headless),
	)

	actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
	d.Cleanup(cancel)

	ctx, cancel := chromedp.NewContext(actx,
		// Send all logf/errf calls to t.Logf
		chromedp.WithLogf(d.Logf),
	)
	d.Cleanup(cancel)
	d.ctx = ctx

	if err := chromedp.Run(ctx); err != nil {
		if errors.Is(err, exec.ErrNotFound) {
			d.Skipf("test requires Chrome to be installed: %v", err)
			return
		}
		d.Fatal(err)
	}
	pr, pw := io.Pipe()
	d.Cleanup(func() { pw.Close() })
	d.output = pr
	chromedp.ListenTarget(ctx, func(ev interface{}) {
		switch ev := ev.(type) {
		case *runtime.EventConsoleAPICalled:
			switch ev.Type {
			case "log", "info", "warning", "error":
				var b bytes.Buffer
				b.WriteString("console.")
				b.WriteString(string(ev.Type))
				b.WriteString("(")
				for i, arg := range ev.Args {
					if i > 0 {
						b.WriteString(", ")
					}
					b.Write(arg.Value)
				}
				b.WriteString(")\n")
				pw.Write(b.Bytes())
			}
		}
	})

	// Third, serve the app folder, set the browser tab dimensions, and
	// navigate to the folder.
	ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
	d.Cleanup(ts.Close)

	if err := chromedp.Run(ctx,
		chromedp.EmulateViewport(int64(d.width), int64(d.height)),
		chromedp.Navigate(ts.URL),
	); err != nil {
		d.Fatal(err)
	}

	// Wait for the gio app to render.
	d.waitForFrame()
}

func (d *JSTestDriver) Screenshot() image.Image {
	var buf []byte
	if err := chromedp.Run(d.ctx,
		chromedp.CaptureScreenshot(&buf),
	); err != nil {
		d.Fatal(err)
	}
	img, err := png.Decode(bytes.NewReader(buf))
	if err != nil {
		d.Fatal(err)
	}
	return img
}

func (d *JSTestDriver) Click(x, y int) {
	if err := chromedp.Run(d.ctx,
		chromedp.MouseClickXY(float64(x), float64(y)),
	); err != nil {
		d.Fatal(err)
	}

	// Wait for the gio app to render after this click.
	d.waitForFrame()
}

A  => gogio/jsbuild.go +201 -0
@@ 1,201 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main

import (
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"text/template"

	"golang.org/x/tools/go/packages"
)

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,
		"-tags="+bi.tags,
		"-o", filepath.Join(out, "main.wasm"),
		bi.pkgPath,
	)
	cmd.Env = append(
		os.Environ(),
		"GOOS=js",
		"GOARCH=wasm",
	)
	_, err := runCmd(cmd)
	if err != nil {
		return err
	}

	var faviconPath string
	if _, err := os.Stat(bi.iconPath); err == nil {
		// Copy icon to the output folder
		icon, err := ioutil.ReadFile(bi.iconPath)
		if err != nil {
			return err
		}
		if err := ioutil.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
			return err
		}
		faviconPath = filepath.Base(bi.iconPath)
	}

	indexTemplate, err := template.New("").Parse(jsIndex)
	if err != nil {
		return err
	}

	var b bytes.Buffer
	if err := indexTemplate.Execute(&b, struct {
		Name string
		Icon string
	}{
		Name: bi.name,
		Icon: faviconPath,
	}); err != nil {
		return err
	}

	if err := ioutil.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 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)
	}
	pkgs, err := packages.Load(&packages.Config{
		Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
		Env:  append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
	}, bi.pkgPath)
	if err != nil {
		return err
	}
	extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
	if err != nil {
		return err
	}

	return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
}

func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
	if len(p.GoFiles) == 0 {
		return nil, nil
	}
	js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
	if err != nil {
		return nil, err
	}
	extraJS = append(extraJS, js...)
	for _, imp := range p.Imports {
		if !visited[imp.ID] {
			extra, err := findPackagesJS(imp, visited)
			if err != nil {
				return nil, err
			}
			extraJS = append(extraJS, extra...)
			visited[imp.ID] = true
		}
	}
	return extraJS, nil
}

// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
// and append the jsStartGo.
func mergeJSFiles(dst string, files ...string) (err error) {
	w, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := w.Close(); err != nil {
			err = cerr
		}
	}()
	_, err = io.Copy(w, strings.NewReader(jsSetGo))
	if err != nil {
		return err
	}
	for i := range files {
		r, err := os.Open(files[i])
		if err != nil {
			return err
		}
		_, err = io.Copy(w, r)
		r.Close()
		if err != nil {
			return err
		}
	}
	_, err = io.Copy(w, strings.NewReader(jsStartGo))
	return err
}

const (
	jsIndex = `<!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">
		{{ if .Icon }}<link rel="icon" href="{{.Icon}}" type="image/x-icon" />{{ end }}
		{{ if .Name }}<title>{{.Name}}</title>{{ end }}
		<script src="wasm.js"></script>
		<style>
			body,pre { margin:0;padding:0; }
		</style>
	</head>
	<body>
	</body>
</html>`
	// jsSetGo sets the `window.go` variable.
	jsSetGo = `(() => {
    window.go = {argv: [], env: {}, importObject: {go: {}}};
	const argv = new URLSearchParams(location.search).get("argv");
	if (argv) {
		window.go["argv"] = argv.split(" ");
	}
})();`
	// jsStartGo initializes the main.wasm.
	jsStartGo = `(() => {
	defaultGo = new Go();
	Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
	Object.assign(defaultGo["env"], go["env"]);
	for (let key in go["importObject"]) {
		if (typeof defaultGo["importObject"][key] === "undefined") {
			defaultGo["importObject"][key] = {};
		}
		Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
	}
	window.go = defaultGo;
    if (!WebAssembly.instantiateStreaming) { // polyfill
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
            const source = await (await resp).arrayBuffer();
            return await WebAssembly.instantiate(source, importObject);
        };
    }
    WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
        go.run(result.instance);
    });
})();`
)

A  => gogio/main.go +224 -0
@@ 1,224 @@
// 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).")
	minsdk        = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
	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.")
	linkMode      = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
	extraLdflags  = flag.String("ldflags", "", "extra flags to the Go linker")
	extraTags     = flag.String("tags", "", "extra tags to the Go tool")
	iconPath      = flag.String("icon", "", "specify an icon for iOS and Android")
	signKey       = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
	signPass      = flag.String("signpass", "", "specify the password to decrypt the signkey.")
)

func main() {
	flag.Usage = func() {
		fmt.Fprint(os.Stderr, mainUsage)
	}
	flag.Parse()
	if err := flagValidate(); err != nil {
		fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
		os.Exit(1)
	}
	buildInfo, err := newBuildInfo(flag.Arg(0))
	if err != nil {
		fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
		os.Exit(1)
	}
	if err := build(buildInfo); err != nil {
		fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
		os.Exit(1)
	}
	os.Exit(0)
}

func flagValidate() error {
	pkgPathArg := flag.Arg(0)
	if pkgPathArg == "" {
		return errors.New("specify a package")
	}
	if *target == "" {
		return errors.New("please specify -target")
	}
	switch *target {
	case "ios", "tvos", "android", "js", "windows":
	default:
		return fmt.Errorf("invalid -target %s", *target)
	}
	switch *buildMode {
	case "archive", "exe":
	default:
		return fmt.Errorf("invalid -buildmode %s", *buildMode)
	}
	return nil
}

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)
	case "windows":
		return buildWindows(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
	clangArch string
}

var allArchs = map[string]arch{
	"arm": {
		iosArch:   "armv7",
		jniArch:   "armeabi-v7a",
		clangArch: "armv7a-linux-androideabi",
	},
	"arm64": {
		iosArch:   "arm64",
		jniArch:   "arm64-v8a",
		clangArch: "aarch64-linux-android",
	},
	"386": {
		iosArch:   "i386",
		jniArch:   "x86",
		clangArch: "i686-linux-android",
	},
	"amd64": {
		iosArch:   "x86_64",
		jniArch:   "x86_64",
		clangArch: "x86_64-linux-android",
	},
}

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) {
			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, resizeIcon(v, img))
		})
	}
	return resizes.Wait()
}

func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
	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)

	return scaled
}

A  => gogio/main_test.go +17 -0
@@ 1,17 @@
package main

import (
	"os"
	"testing"
)

func TestMain(m *testing.M) {
	if os.Getenv("RUN_GOGIO") != "" {
		// Allow the end-to-end tests to call the gogio tool without
		// having to build it from scratch, nor having to refactor the
		// main function to avoid using global variables.
		main()
		os.Exit(0) // main already exits, but just in case.
	}
	os.Exit(m.Run())
}

A  => gogio/permission.go +33 -0
@@ 1,33 @@
package main

var AndroidPermissions = map[string][]string{
	"network": {
		"android.permission.INTERNET",
	},
	"networkstate": {
		"android.permission.ACCESS_NETWORK_STATE",
	},
	"bluetooth": {
		"android.permission.BLUETOOTH",
		"android.permission.BLUETOOTH_ADMIN",
		"android.permission.ACCESS_FINE_LOCATION",
	},
	"camera": {
		"android.permission.CAMERA",
	},
	"storage": {
		"android.permission.READ_EXTERNAL_STORAGE",
		"android.permission.WRITE_EXTERNAL_STORAGE",
	},
}

var AndroidFeatures = map[string][]string{
	"default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
	"bluetooth": {
		`name="android.hardware.bluetooth"`,
		`name="android.hardware.bluetooth_le"`,
	},
	"camera": {
		`name="android.hardware.camera"`,
	},
}

A  => gogio/race_test.go +8 -0
@@ 1,8 @@
// SPDX-License-Identifier: Unlicense OR MIT

//go:build race
// +build race

package main_test

func init() { raceEnabled = true }

A  => gogio/testdata/testdata.go +142 -0
@@ 1,142 @@
// SPDX-License-Identifier: Unlicense OR MIT

// A simple app used for gogio's end-to-end tests.
package main

import (
	"fmt"
	"image"
	"image/color"
	"log"

	"gioui.org/app"
	"gioui.org/io/pointer"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
)

func main() {
	go func() {
		w := app.NewWindow()
		if err := loop(w); err != nil {
			log.Fatal(err)
		}
	}()
	app.Main()
}

type notifyFrame int

const (
	notifyNone notifyFrame = iota
	notifyInvalidate
	notifyPrint
)

// notify keeps track of whether we want to print to stdout to notify the user
// when a frame is ready. Initially we want to notify about the first frame.
var notify = notifyInvalidate

type (
	C = layout.Context
	D = layout.Dimensions
)

func loop(w *app.Window) error {
	topLeft := quarterWidget{
		color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
	}
	topRight := quarterWidget{
		color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
	}
	botLeft := quarterWidget{
		color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
	}
	botRight := quarterWidget{
		color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
	}

	var ops op.Ops
	for {
		e := <-w.Events()
		switch e := e.(type) {
		case system.DestroyEvent:
			return e.Err
		case system.FrameEvent:
			gtx := layout.NewContext(&ops, e)
			// Clear background to white, even on embedded platforms such as webassembly.
			paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
			layout.Flex{Axis: layout.Vertical}.Layout(gtx,
				layout.Flexed(1, func(gtx C) D {
					return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
						// r1c1
						layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
						// r1c2
						layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
					)
				}),
				layout.Flexed(1, func(gtx C) D {
					return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
						// r2c1
						layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
						// r2c2
						layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
					)
				}),
			)

			e.Frame(gtx.Ops)

			switch notify {
			case notifyInvalidate:
				notify = notifyPrint
				w.Invalidate()
			case notifyPrint:
				notify = notifyNone
				fmt.Println("gio frame ready")
			}
		}
	}
}

// quarterWidget paints a quarter of the screen with one color. When clicked, it
// turns red, going back to its normal color when clicked again.
type quarterWidget struct {
	color color.NRGBA

	clicked bool
}

var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}

func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
	var color color.NRGBA
	if w.clicked {
		color = red
	} else {
		color = w.color
	}

	r := image.Rectangle{Max: gtx.Constraints.Max}
	paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())

	defer clip.Rect(image.Rectangle{
		Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
	}).Push(gtx.Ops).Pop()
	pointer.InputOp{
		Tag:   w,
		Types: pointer.Press,
	}.Add(gtx.Ops)

	for _, e := range gtx.Events(w) {
		if e, ok := e.(pointer.Event); ok && e.Type == pointer.Press {
			w.clicked = !w.clicked
			// notify when we're done updating the frame.
			notify = notifyInvalidate
		}
	}
	return layout.Dimensions{Size: gtx.Constraints.Max}
}

A  => gogio/wayland_test.go +196 -0
@@ 1,196 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"image"
	"image/png"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"text/template"
	"time"
)

type WaylandTestDriver struct {
	driverBase

	runtimeDir string
	socket     string
	display    string
}

// No bars or anything fancy. Just a white background with our dimensions.
var tmplSwayConfig = template.Must(template.New("").Parse(`
output * bg #FFFFFF solid_color
output * mode {{.Width}}x{{.Height}}
default_border none
`))

var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`)

func (d *WaylandTestDriver) Start(path string) {
	// We want os.Environ, so that it can e.g. find $DISPLAY to run within
	// X11. wlroots env vars are documented at:
	// https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md
	env := os.Environ()
	if *headless {
		env = append(env, "WLR_BACKENDS=headless")
	}

	d.needPrograms(
		"sway",    // to run a wayland compositor
		"grim",    // to take screenshots
		"swaymsg", // to send input
	)

	// First, build the app.
	dir := d.tempDir("gio-endtoend-wayland")
	bin := filepath.Join(dir, "red")
	flags := []string{"build", "-tags", "nox11", "-o=" + bin}
	if raceEnabled {
		flags = append(flags, "-race")
	}
	flags = append(flags, path)
	cmd := exec.Command("go", flags...)
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Fatalf("could not build app: %s:\n%s", err, out)
	}

	conf := filepath.Join(dir, "config")
	f, err := os.Create(conf)
	if err != nil {
		d.Fatal(err)
	}
	defer f.Close()
	if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{
		d.width, d.height,
	}); err != nil {
		d.Fatal(err)
	}

	d.socket = filepath.Join(dir, "socket")
	env = append(env, "SWAYSOCK="+d.socket)
	d.runtimeDir = dir
	env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir)

	var wg sync.WaitGroup
	d.Cleanup(wg.Wait)

	// First, start sway.
	{
		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose")
		cmd.Env = env
		stderr, err := cmd.StderrPipe()
		if err != nil {
			d.Fatal(err)
		}
		if err := cmd.Start(); err != nil {
			d.Fatal(err)
		}
		d.Cleanup(cancel)
		d.Cleanup(func() {
			// Give it a chance to exit gracefully, cleaning up
			// after itself. After 10ms, the deferred cancel above
			// will signal an os.Kill.
			cmd.Process.Signal(os.Interrupt)
			time.Sleep(10 * time.Millisecond)
		})

		// Wait for sway to be ready. We probably don't need a deadline
		// here.
		br := bufio.NewReader(stderr)
		for {
			line, err := br.ReadString('\n')
			if err != nil {
				d.Fatal(err)
			}
			if m := rxSwayReady.FindStringSubmatch(line); m != nil {
				d.display = m[1]
				break
			}
		}

		wg.Add(1)
		go func() {
			if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") {
				// Don't print all stderr, since we use --verbose.
				// TODO(mvdan): if it's useful, probably filter
				// errors and show them.
				d.Error(err)
			}
			wg.Done()
		}()
	}

	// Then, start our program on the sway compositor above.
	{
		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, bin)
		cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
		output, err := cmd.StdoutPipe()
		if err != nil {
			d.Fatal(err)
		}
		cmd.Stderr = cmd.Stdout
		d.output = output
		if err := cmd.Start(); err != nil {
			d.Fatal(err)
		}
		d.Cleanup(cancel)
		wg.Add(1)
		go func() {
			if err := cmd.Wait(); err != nil && ctx.Err() == nil {
				d.Error(err)
			}
			wg.Done()
		}()
	}

	// Wait for the gio app to render.
	d.waitForFrame()
}

func (d *WaylandTestDriver) Screenshot() image.Image {
	cmd := exec.Command("grim", "/dev/stdout")
	cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
	out, err := cmd.CombinedOutput()
	if err != nil {
		d.Errorf("%s", out)
		d.Fatal(err)
	}
	img, err := png.Decode(bytes.NewReader(out))
	if err != nil {
		d.Fatal(err)
	}
	return img
}

func (d *WaylandTestDriver) swaymsg(args ...interface{}) {
	strs := []string{"--socket", d.socket}
	for _, arg := range args {
		strs = append(strs, fmt.Sprint(arg))
	}
	cmd := exec.Command("swaymsg", strs...)
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Errorf("%s", out)
		d.Fatal(err)
	}
}

func (d *WaylandTestDriver) Click(x, y int) {
	d.swaymsg("seat", "-", "cursor", "set", x, y)
	d.swaymsg("seat", "-", "cursor", "press", "button1")
	d.swaymsg("seat", "-", "cursor", "release", "button1")

	// Wait for the gio app to render after this click.
	d.waitForFrame()
}

A  => gogio/windows_test.go +152 -0
@@ 1,152 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"context"
	"image"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sync"
	"time"

	"golang.org/x/image/draw"
)

// Wine is tightly coupled with X11 at the moment, and we can reuse the same
// methods to automate screenshots and clicks. The main difference is how we
// build and run the app.

// The only quirk is that it seems impossible for the Wine window to take the
// entirety of the X server's dimensions, even if we try to resize it to take
// the entire display. It seems to want to leave some vertical space empty,
// presumably for window decorations or the "start" bar on Windows. To work
// around that, make the X server 50x50px bigger, and crop the screenshots back
// to the original size.

type WineTestDriver struct {
	X11TestDriver
}

func (d *WineTestDriver) Start(path string) {
	d.needPrograms("wine")

	// First, build the app.
	bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe")
	flags := []string{"build", "-o=" + bin}
	if raceEnabled {
		if runtime.GOOS != "windows" {
			// cross-compilation disables CGo, which breaks -race.
			d.Skipf("can't cross-compile -race for Windows; skipping")
		}
		flags = append(flags, "-race")
	}
	flags = append(flags, path)
	cmd := exec.Command("go", flags...)
	cmd.Env = os.Environ()
	cmd.Env = append(cmd.Env, "GOOS=windows")
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Fatalf("could not build app: %s:\n%s", err, out)
	}

	var wg sync.WaitGroup
	d.Cleanup(wg.Wait)

	// Add 50x50px to the display dimensions, as discussed earlier.
	d.startServer(&wg, d.width+50, d.height+50)

	// Then, start our program via Wine on the X server above.
	{
		cacheDir, err := os.UserCacheDir()
		if err != nil {
			d.Fatal(err)
		}
		// Use a wine directory separate from the default ~/.wine, so
		// that the user's winecfg doesn't affect our test. This will
		// default to ~/.cache/gio-e2e-wine. We use the user's cache,
		// to reuse a previously set up wineprefix.
		wineprefix := filepath.Join(cacheDir, "gio-e2e-wine")

		// First, ensure that wineprefix is up to date with wineboot.
		// Wait for this separately from the first frame, as setting up
		// a new prefix might take 5s on its own.
		env := []string{
			"DISPLAY=" + d.display,
			"WINEDEBUG=fixme-all", // hide "fixme" noise
			"WINEPREFIX=" + wineprefix,

			// Disable wine-gecko (Explorer) and wine-mono (.NET).
			// Otherwise, if not installed, wineboot will get stuck
			// with a prompt to install them on the virtual X
			// display. Moreover, Gio doesn't need either, and wine
			// is faster without them.
			"WINEDLLOVERRIDES=mscoree,mshtml=",
		}
		{
			start := time.Now()
			cmd := exec.Command("wine", "wineboot", "-i")
			cmd.Env = env
			// Use a combined output pipe instead of CombinedOutput,
			// so that we only wait for the child process to exit,
			// and we don't need to wait for all of wine's
			// grandchildren to exit and stop writing. This is
			// relevant as wine leaves "wineserver" lingering for
			// three seconds by default, to be reused later.
			stdout, err := cmd.StdoutPipe()
			if err != nil {
				d.Fatal(err)
			}
			cmd.Stderr = cmd.Stdout
			if err := cmd.Run(); err != nil {
				io.Copy(os.Stderr, stdout)
				d.Fatal(err)
			}
			d.Logf("set up WINEPREFIX in %s", time.Since(start))
		}

		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, "wine", bin)
		cmd.Env = env
		output, err := cmd.StdoutPipe()
		if err != nil {
			d.Fatal(err)
		}
		cmd.Stderr = cmd.Stdout
		d.output = output
		if err := cmd.Start(); err != nil {
			d.Fatal(err)
		}
		d.Cleanup(cancel)
		wg.Add(1)
		go func() {
			if err := cmd.Wait(); err != nil && ctx.Err() == nil {
				d.Error(err)
			}
			wg.Done()
		}()
	}
	// Wait for the gio app to render.
	d.waitForFrame()

	// xdotool seems to fail at actually moving the window if we use it
	// immediately after Gio is ready. Why?
	// We can't tell if the windowmove operation worked until we take a
	// screenshot, because the getwindowgeometry op reports the 0x0
	// coordinates even if the window wasn't moved properly.
	// A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that.
	// TODO(mvdan): revisit this, when you have a spare three hours.
	time.Sleep(400 * time.Millisecond)
	id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio")
	d.xdotool("windowmove", "--sync", id, 0, 0)
}

func (d *WineTestDriver) Screenshot() image.Image {
	img := d.X11TestDriver.Screenshot()
	// Crop the screenshot back to the original dimensions.
	cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height))
	draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src)
	return cropped
}

A  => gogio/windowsbuild.go +416 -0
@@ 1,416 @@
package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"image/png"
	"io"
	"math"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"strconv"
	"strings"
	"text/template"

	"github.com/akavel/rsrc/binutil"
	"github.com/akavel/rsrc/coff"
	"golang.org/x/text/encoding/unicode"
)

func buildWindows(tmpDir string, bi *buildInfo) error {
	builder := &windowsBuilder{TempDir: tmpDir}
	builder.DestDir = *destPath
	if builder.DestDir == "" {
		builder.DestDir = bi.pkgPath
	}

	name := bi.name
	if *destPath != "" {
		if filepath.Ext(*destPath) != ".exe" {
			return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
		}
		name = filepath.Base(*destPath)
	}
	name = strings.TrimSuffix(name, ".exe")
	sdk := bi.minsdk
	if sdk > 10 {
		return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
	}
	version := strconv.Itoa(bi.version)
	if bi.version > math.MaxUint16 {
		return fmt.Errorf("version (%d) is larger than the maximum (%d)", bi.version, math.MaxUint16)
	}

	for _, arch := range bi.archs {
		builder.Coff = coff.NewRSRC()
		builder.Coff.Arch(arch)

		if err := builder.embedIcon(bi.iconPath); err != nil {
			return err
		}

		if err := builder.embedManifest(windowsManifest{
			Version:        "1.0.0." + version,
			WindowsVersion: sdk,
			Name:           name,
		}); err != nil {
			return fmt.Errorf("can't create manifest: %v", err)
		}

		if err := builder.embedInfo(windowsResources{
			Version:      [2]uint32{uint32(1) << 16, uint32(bi.version)},
			VersionHuman: "1.0.0." + version,
			Name:         name,
			Language:     0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10)
		}); err != nil {
			return fmt.Errorf("can't create info: %v", err)
		}

		if err := builder.buildResource(bi, name, arch); err != nil {
			return fmt.Errorf("can't build the resources: %v", err)
		}

		if err := builder.buildProgram(bi, name, arch); err != nil {
			return err
		}
	}

	return nil
}

type (
	windowsResources struct {
		Version      [2]uint32
		VersionHuman string
		Language     uint16
		Name         string
	}
	windowsManifest struct {
		Version        string
		WindowsVersion int
		Name           string
	}
	windowsBuilder struct {
		TempDir string
		DestDir string
		Coff    *coff.Coff
	}
)

const (
	// https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types
	windowsResourceIcon      = 3
	windowsResourceIconGroup = windowsResourceIcon + 11
	windowsResourceManifest  = 24
	windowsResourceVersion   = 16
)

type bufferCoff struct {
	bytes.Buffer
}

func (b *bufferCoff) Size() int64 {
	return int64(b.Len())
}

func (b *windowsBuilder) embedIcon(path string) (err error) {
	iconFile, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("can't read the icon located at %s: %v", path, err)
	}
	defer iconFile.Close()

	iconImage, err := png.Decode(iconFile)
	if err != nil {
		return fmt.Errorf("can't decode the PNG file (%s): %v", path, err)
	}

	sizes := []int{16, 32, 48, 64, 128, 256}
	var iconHeader bufferCoff

	// GRPICONDIR structure.
	if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
		return err
	}

	for _, size := range sizes {
		var iconBuffer bufferCoff

		if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil {
			return fmt.Errorf("can't encode image: %v", err)
		}

		b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer)

		if err := binary.Write(&iconHeader, binary.LittleEndian, struct {
			Size     [2]uint8
			Color    [2]uint8
			Planes   uint16
			BitCount uint16
			Length   uint32
			Id       uint16
		}{
			Size:     [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px.
			Planes:   1,
			BitCount: 32,
			Length:   uint32(iconBuffer.Len()),
			Id:       uint16(size),
		}); err != nil {
			return err
		}
	}

	b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader)

	return nil
}

func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error {
	out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso"))
	if err != nil {
		return err
	}
	defer out.Close()
	b.Coff.Freeze()

	// See https://github.com/akavel/rsrc/internal/write.go#L13.
	w := binutil.Writer{W: out}
	binutil.Walk(b.Coff, func(v reflect.Value, path string) error {
		if binutil.Plain(v.Kind()) {
			w.WriteLE(v.Interface())
			return nil
		}
		vv, ok := v.Interface().(binutil.SizedReader)
		if ok {
			w.WriteFromSized(vv)
			return binutil.WALK_SKIP
		}
		return nil
	})

	if w.Err != nil {
		return fmt.Errorf("error writing output file: %s", w.Err)
	}

	return nil
}

func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
	dest := b.DestDir
	if len(buildInfo.archs) > 1 {
		dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe")
	}

	cmd := exec.Command(
		"go",
		"build",
		"-ldflags=-H=windowsgui "+buildInfo.ldflags,
		"-tags="+buildInfo.tags,
		"-o", dest,
		buildInfo.pkgPath,
	)
	cmd.Env = append(
		os.Environ(),
		"GOOS=windows",
		"GOARCH="+arch,
	)
	_, err := runCmd(cmd)
	return err
}

func (b *windowsBuilder) embedManifest(v windowsManifest) error {
	t, err := template.New("manifest").Parse(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
    <assemblyIdentity type="win32" name="{{.Name}}" version="{{.Version}}" />
    <description>{{.Name}}</description>
    <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
        <application>
            {{if (le .WindowsVersion 10)}}<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
{{end}}
            {{if (le .WindowsVersion 9)}}<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
{{end}}
            {{if (le .WindowsVersion 8)}}<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
{{end}}
            {{if (le .WindowsVersion 7)}}<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
{{end}}
            {{if (le .WindowsVersion 6)}}<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
{{end}}
        </application>
    </compatibility>
    <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
        <security>
            <requestedPrivileges>
                <requestedExecutionLevel level="asInvoker" uiAccess="false" />
            </requestedPrivileges>
        </security>
    </trustInfo>
	<asmv3:application>
		<asmv3:windowsSettings>
			<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
		</asmv3:windowsSettings>
	</asmv3:application>
</assembly>`)
	if err != nil {
		return err
	}

	var manifest bufferCoff
	if err := t.Execute(&manifest, v); err != nil {
		return err
	}

	b.Coff.AddResource(windowsResourceManifest, 1, &manifest)

	return nil
}

func (b *windowsBuilder) embedInfo(v windowsResources) error {
	page := uint16(1)

	// https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo
	t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{
		// https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo
		windowsInfoValueFixed{
			Signature:      0xFEEF04BD,
			StructVersion:  0x00010000,
			FileVersion:    v.Version,
			ProductVersion: v.Version,
			FileFlagMask:   0x3F,
			FileFlags:      0,
			FileOS:         0x40004,
			FileType:       0x1,
			FileSubType:    0,
		},
		// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo
		newValue(valueText, "StringFileInfo", []io.WriterTo{
			// https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable
			newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{
				// https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str
				newValue(valueText, "ProductVersion", v.VersionHuman),
				newValue(valueText, "FileVersion", v.VersionHuman),
				newValue(valueText, "FileDescription", v.Name),
				newValue(valueText, "ProductName", v.Name),
				// TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...)
			}),
		}),
		// https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo
		newValue(valueBinary, "VarFileInfo", []io.WriterTo{
			// https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str
			newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)),
		}),
	})

	// For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`:
	t.ValueLength = 52

	var verrsrc bufferCoff
	if _, err := t.WriteTo(&verrsrc); err != nil {
		return err
	}

	b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc)

	return nil
}

type windowsInfoValueFixed struct {
	Signature      uint32
	StructVersion  uint32
	FileVersion    [2]uint32
	ProductVersion [2]uint32
	FileFlagMask   uint32
	FileFlags      uint32
	FileOS         uint32
	FileType       uint32
	FileSubType    uint32
	FileDate       [2]uint32
}

func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) {
	return 0, binary.Write(w, binary.LittleEndian, v)
}

type windowsInfoValue struct {
	Length      uint16
	ValueLength uint16
	Type        uint16
	Key         []byte
	Value       []byte
}

func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) {
	// binary.Write doesn't support []byte inside struct.
	if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil {
		return 0, err
	}
	if _, err = w.Write(v.Key); err != nil {
		return 0, err
	}
	if _, err = w.Write(v.Value); err != nil {
		return 0, err
	}
	return 0, nil
}

const (
	valueBinary uint16 = 0
	valueText   uint16 = 1
)

func newValue(valueType uint16, key string, input interface{}) windowsInfoValue {
	v := windowsInfoValue{
		Type:   valueType,
		Length: 6,
	}

	padding := func(in []byte) []byte {
		if l := uint16(len(in)) + v.Length; l%4 != 0 {
			return append(in, make([]byte, 4-l%4)...)
		}
		return in
	}

	v.Key = padding(utf16Encode(key))
	v.Length += uint16(len(v.Key))

	switch in := input.(type) {
	case string:
		v.Value = padding(utf16Encode(in))
		v.ValueLength = uint16(len(v.Value) / 2)
	case []io.WriterTo:
		var buff bytes.Buffer
		for k := range in {
			if _, err := in[k].WriteTo(&buff); err != nil {
				panic(err)
			}
		}
		v.Value = buff.Bytes()
	default:
		var buff bytes.Buffer
		if err := binary.Write(&buff, binary.LittleEndian, in); err != nil {
			panic(err)
		}
		v.ValueLength = uint16(buff.Len())
		v.Value = buff.Bytes()
	}

	v.Length += uint16(len(v.Value))

	return v
}

// utf16Encode encodes the string to UTF16 with null-termination.
func utf16Encode(s string) []byte {
	b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s))
	if err != nil {
		panic(err)
	}
	return append(b, 0x00, 0x00) // null-termination.
}

A  => gogio/x11_test.go +170 -0
@@ 1,170 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bytes"
	"context"
	"fmt"
	"image"
	"image/png"
	"io"
	"math/rand"
	"os"
	"os/exec"
	"path/filepath"
	"sync"
	"time"
)

type X11TestDriver struct {
	driverBase

	display string
}

func (d *X11TestDriver) Start(path string) {
	// First, build the app.
	bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red")
	flags := []string{"build", "-tags", "nowayland", "-o=" + bin}
	if raceEnabled {
		flags = append(flags, "-race")
	}
	flags = append(flags, path)
	cmd := exec.Command("go", flags...)
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Fatalf("could not build app: %s:\n%s", err, out)
	}

	var wg sync.WaitGroup
	d.Cleanup(wg.Wait)

	d.startServer(&wg, d.width, d.height)

	// Then, start our program on the X server above.
	{
		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, bin)
		cmd.Env = []string{"DISPLAY=" + d.display}
		output, err := cmd.StdoutPipe()
		if err != nil {
			d.Fatal(err)
		}
		cmd.Stderr = cmd.Stdout
		d.output = output
		if err := cmd.Start(); err != nil {
			d.Fatal(err)
		}
		d.Cleanup(cancel)
		wg.Add(1)
		go func() {
			if err := cmd.Wait(); err != nil && ctx.Err() == nil {
				d.Error(err)
			}
			wg.Done()
		}()
	}

	// Wait for the gio app to render.
	d.waitForFrame()
}

func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) {
	// Pick a random display number between 1 and 100,000. Most machines
	// will only be using :0, so there's only a 0.001% chance of two
	// concurrent test runs to run into a conflict.
	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
	d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1)

	var xprog string
	xflags := []string{
		"-wr", // we want a white background; the default is black
	}
	if *headless {
		xprog = "Xvfb" // virtual X server
		xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height))
	} else {
		xprog = "Xephyr" // nested X server as a window
		xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height))
	}
	xflags = append(xflags, d.display)

	d.needPrograms(
		xprog,     // to run the X server
		"scrot",   // to take screenshots
		"xdotool", // to send input
	)
	ctx, cancel := context.WithCancel(context.Background())
	cmd := exec.CommandContext(ctx, xprog, xflags...)
	combined := &bytes.Buffer{}
	cmd.Stdout = combined
	cmd.Stderr = combined
	if err := cmd.Start(); err != nil {
		d.Fatal(err)
	}
	d.Cleanup(cancel)
	d.Cleanup(func() {
		// Give it a chance to exit gracefully, cleaning up
		// after itself. After 10ms, the deferred cancel above
		// will signal an os.Kill.
		cmd.Process.Signal(os.Interrupt)
		time.Sleep(10 * time.Millisecond)
	})

	// Wait for the X server to be ready. The socket path isn't
	// terribly portable, but that's okay for now.
	withRetries(d.T, time.Second, func() error {
		socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:])
		_, err := os.Stat(socket)
		return err
	})

	wg.Add(1)
	go func() {
		if err := cmd.Wait(); err != nil && ctx.Err() == nil {
			// Print all output and error.
			io.Copy(os.Stdout, combined)
			d.Error(err)
		}
		wg.Done()
	}()
}

func (d *X11TestDriver) Screenshot() image.Image {
	cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout")
	cmd.Env = []string{"DISPLAY=" + d.display}
	out, err := cmd.CombinedOutput()
	if err != nil {
		d.Errorf("%s", out)
		d.Fatal(err)
	}
	img, err := png.Decode(bytes.NewReader(out))
	if err != nil {
		d.Fatal(err)
	}
	return img
}

func (d *X11TestDriver) xdotool(args ...interface{}) string {
	d.Helper()
	strs := make([]string, len(args))
	for i, arg := range args {
		strs[i] = fmt.Sprint(arg)
	}
	cmd := exec.Command("xdotool", strs...)
	cmd.Env = []string{"DISPLAY=" + d.display}
	out, err := cmd.CombinedOutput()
	if err != nil {
		d.Errorf("%s", out)
		d.Fatal(err)
	}
	return string(bytes.TrimSpace(out))
}

func (d *X11TestDriver) Click(x, y int) {
	d.xdotool("mousemove", "--sync", x, y)
	d.xdotool("click", "1")

	// Wait for the gio app to render after this click.
	d.waitForFrame()
}