commit 6826ef0b64b5f30906c10ea69fd70ed2b7d4bd94 Author: Elias Naur Date: Sun Apr 24 13:06:19 2022 +0200 all: initial import from gio main repository Signed-off-by: Elias Naur diff --git a/.builds/apple.yml b/.builds/apple.yml new file mode 100644 index 0000000..c103534 --- /dev/null +++ b/.builds/apple.yml @@ -0,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 diff --git a/.builds/freebsd.yml b/.builds/freebsd.yml new file mode 100644 index 0000000..8587cf8 --- /dev/null +++ b/.builds/freebsd.yml @@ -0,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 ./... diff --git a/.builds/linux.yml b/.builds/linux.yml new file mode 100644 index 0000000..9568167 --- /dev/null +++ b/.builds/linux.yml @@ -0,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 diff --git a/.builds/openbsd.yml b/.builds/openbsd.yml new file mode 100644 index 0000000..63152de --- /dev/null +++ b/.builds/openbsd.yml @@ -0,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 ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81f4733 --- /dev/null +++ b/LICENSE @@ -0,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 +--- diff --git a/README.md b/README.md new file mode 100644 index 0000000..75e9166 --- /dev/null +++ b/README.md @@ -0,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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e7ee58 --- /dev/null +++ b/go.mod @@ -0,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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..adf9991 --- /dev/null +++ b/go.sum @@ -0,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= diff --git a/gogio/android_test.go b/gogio/android_test.go new file mode 100644 index 0000000..e73386f --- /dev/null +++ b/gogio/android_test.go @@ -0,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() +} diff --git a/gogio/androidbuild.go b/gogio/androidbuild.go new file mode 100644 index 0000000..cddba99 --- /dev/null +++ b/gogio/androidbuild.go @@ -0,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 = ` + + +` + themesV21 = ` + + +` +) + +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( + ` + +{{range .Permissions}} +{{end}}{{range .Features}} +{{end}} +`) + 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(` + + + +`), 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( + ` + + +{{range .Permissions}} +{{end}}{{range .Features}} +{{end}} + + + + + + + +`) + 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 +} diff --git a/gogio/build_info.go b/gogio/build_info.go new file mode 100644 index 0000000..13818fa --- /dev/null +++ b/gogio/build_info.go @@ -0,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) +} diff --git a/gogio/build_info_test.go b/gogio/build_info_test.go new file mode 100644 index 0000000..397e2a3 --- /dev/null +++ b/gogio/build_info_test.go @@ -0,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) + } + } +} diff --git a/gogio/doc.go b/gogio/doc.go new file mode 100644 index 0000000..82da812 --- /dev/null +++ b/gogio/doc.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +/* +The gogio tool builds and packages Gio programs for Android, iOS/tvOS +and WebAssembly. + +Run gogio with no arguments for instructions, or see the examples at +https://gioui.org. +*/ +package main diff --git a/gogio/e2e_test.go b/gogio/e2e_test.go new file mode 100644 index 0000000..0b3ee97 --- /dev/null +++ b/gogio/e2e_test.go @@ -0,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) + } +} diff --git a/gogio/help.go b/gogio/help.go new file mode 100644 index 0000000..87879ae --- /dev/null +++ b/gogio/help.go @@ -0,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 [flags] [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. +` diff --git a/gogio/iosbuild.go b/gogio/iosbuild.go new file mode 100644 index 0000000..c3b596a --- /dev/null +++ b/gogio/iosbuild.go @@ -0,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 +@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(` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + %s + CFBundleIdentifier + %s + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + %s + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.%d + CFBundleVersion + %d + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + arm64 + DTPlatformName + %s + DTPlatformVersion + 12.4 + MinimumOSVersion + %d + UIDeviceFamily + + 1 + 2 + + CFBundleSupportedPlatforms + + %s + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 16G73 + DTSDKBuild + 16G73 + DTSDKName + %s12.4 + DTXcode + 1030 + DTXcodeBuild + 10G8 + +`, 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() +} diff --git a/gogio/js_test.go b/gogio/js_test.go new file mode 100644 index 0000000..8584894 --- /dev/null +++ b/gogio/js_test.go @@ -0,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() +} diff --git a/gogio/jsbuild.go b/gogio/jsbuild.go new file mode 100644 index 0000000..ba59a99 --- /dev/null +++ b/gogio/jsbuild.go @@ -0,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 = ` + + + + + + {{ if .Icon }}{{ end }} + {{ if .Name }}{{.Name}}{{ end }} + + + + + +` + // 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); + }); +})();` +) diff --git a/gogio/main.go b/gogio/main.go new file mode 100644 index 0000000..38018f7 --- /dev/null +++ b/gogio/main.go @@ -0,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 +} diff --git a/gogio/main_test.go b/gogio/main_test.go new file mode 100644 index 0000000..98dcb27 --- /dev/null +++ b/gogio/main_test.go @@ -0,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()) +} diff --git a/gogio/permission.go b/gogio/permission.go new file mode 100644 index 0000000..b22fcef --- /dev/null +++ b/gogio/permission.go @@ -0,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"`, + }, +} diff --git a/gogio/race_test.go b/gogio/race_test.go new file mode 100644 index 0000000..1f3c689 --- /dev/null +++ b/gogio/race_test.go @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +//go:build race +// +build race + +package main_test + +func init() { raceEnabled = true } diff --git a/gogio/testdata/testdata.go b/gogio/testdata/testdata.go new file mode 100644 index 0000000..dca562e --- /dev/null +++ b/gogio/testdata/testdata.go @@ -0,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} +} diff --git a/gogio/wayland_test.go b/gogio/wayland_test.go new file mode 100644 index 0000000..df10410 --- /dev/null +++ b/gogio/wayland_test.go @@ -0,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() +} diff --git a/gogio/windows_test.go b/gogio/windows_test.go new file mode 100644 index 0000000..996b511 --- /dev/null +++ b/gogio/windows_test.go @@ -0,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 +} diff --git a/gogio/windowsbuild.go b/gogio/windowsbuild.go new file mode 100644 index 0000000..23cdd51 --- /dev/null +++ b/gogio/windowsbuild.go @@ -0,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(` + + + {{.Name}} + + + {{if (le .WindowsVersion 10)}} +{{end}} + {{if (le .WindowsVersion 9)}} +{{end}} + {{if (le .WindowsVersion 8)}} +{{end}} + {{if (le .WindowsVersion 7)}} +{{end}} + {{if (le .WindowsVersion 6)}} +{{end}} + + + + + + + + + + + + true + + +`) + 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. +} diff --git a/gogio/x11_test.go b/gogio/x11_test.go new file mode 100644 index 0000000..9bb3174 --- /dev/null +++ b/gogio/x11_test.go @@ -0,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() +}