├── .builds ├── apple.yml ├── freebsd.yml ├── linux.yml └── openbsd.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── gogio ├── android_test.go ├── androidbuild.go ├── build_info.go ├── build_info_test.go ├── doc.go ├── e2e_test.go ├── help.go ├── internal │ ├── custom │ │ └── testdata.go │ └── normal │ │ └── testdata.go ├── iosbuild.go ├── js_test.go ├── jsbuild.go ├── macosbuild.go ├── main.go ├── main_test.go ├── permission.go ├── race_test.go ├── wayland_test.go ├── windows_test.go ├── windowsbuild.go └── x11_test.go └── svg2gio └── main.go /.builds/apple.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: debian/testing 3 | packages: 4 | - clang 5 | - cmake 6 | - curl 7 | - autoconf 8 | - libxml2-dev 9 | - libssl-dev 10 | - libz-dev 11 | - ninja-build # cctools 12 | - llvm-dev # for cctools 13 | - uuid-dev # for cctools 14 | - libblocksruntime-dev # for cctools 15 | - libplist-utils # for gogio 16 | sources: 17 | - https://git.sr.ht/~eliasnaur/gio-cmd 18 | - https://git.sr.ht/~eliasnaur/applesdks 19 | - https://git.sr.ht/~eliasnaur/giouiorg 20 | - https://github.com/tpoechtrager/cctools-port.git 21 | - https://github.com/tpoechtrager/apple-libtapi.git 22 | - https://github.com/tpoechtrager/apple-libdispatch 23 | - https://github.com/mackyle/xar.git 24 | environment: 25 | APPLE_TOOLCHAIN_ROOT: /home/build/appletools 26 | PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin 27 | tasks: 28 | - install_go: | 29 | mkdir -p /home/build/sdk 30 | curl -s https://dl.google.com/go/go1.24.1.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf - 31 | - prepare_toolchain: | 32 | mkdir -p $APPLE_TOOLCHAIN_ROOT 33 | cd $APPLE_TOOLCHAIN_ROOT 34 | tar xJf /home/build/applesdks/applesdks.tar.xz 35 | mkdir bin tools 36 | cd bin 37 | ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld 38 | ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar 39 | ln -s /home/build/cctools-port/cctools/misc/lipo lipo 40 | ln -s ../tools/appletoolchain xcrun 41 | ln -s /usr/bin/plistutil plutil 42 | cd ../tools 43 | ln -s appletoolchain clang-ios 44 | ln -s appletoolchain clang-macos 45 | - install_appletoolchain: | 46 | cd giouiorg 47 | go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain 48 | - build_libdispatch: | 49 | cd apple-libdispatch 50 | cmake -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_INSTALL_PREFIX=$APPLE_TOOLCHAIN_ROOT/libdispatch . 51 | ninja 52 | ninja install 53 | - build_xar: | 54 | cd xar/xar 55 | ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr 56 | make 57 | sudo make install 58 | - build_libtapi: | 59 | cd apple-libtapi 60 | INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh 61 | ./install.sh 62 | - build_cctools: | 63 | cd cctools-port/cctools 64 | ./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --with-libdispatch=$APPLE_TOOLCHAIN_ROOT/libdispatch --target=x86_64-apple-darwin19 65 | make install 66 | - install_gogio: | 67 | cd gio-cmd 68 | go install ./gogio 69 | # Broken test. 70 | # - test_ios_gogio: | 71 | # mkdir tmp 72 | # cd tmp 73 | # go mod init example.com 74 | # go get -d gioui.org/example/kitchen 75 | # export PATH=/home/build/appletools/bin:$PATH 76 | # gogio -target ios -o app.app gioui.org/example/kitchen 77 | -------------------------------------------------------------------------------- /.builds/freebsd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: freebsd/13.x 3 | packages: 4 | - libX11 5 | - libxkbcommon 6 | - libXcursor 7 | - libXfixes 8 | - vulkan-headers 9 | - wayland 10 | - mesa-libs 11 | - xorg-vfbserver 12 | sources: 13 | - https://git.sr.ht/~eliasnaur/gio-cmd 14 | environment: 15 | PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin 16 | tasks: 17 | - install_go: | 18 | mkdir -p /home/build/sdk 19 | curl https://dl.google.com/go/go1.24.1.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf - 20 | - test_cmd: | 21 | cd gio-cmd 22 | go test ./... 23 | -------------------------------------------------------------------------------- /.builds/linux.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: debian/testing 3 | packages: 4 | - curl 5 | - pkg-config 6 | - libwayland-dev 7 | - libx11-dev 8 | - libx11-xcb-dev 9 | - libxkbcommon-dev 10 | - libxkbcommon-x11-dev 11 | - libgles2-mesa-dev 12 | - libegl1-mesa-dev 13 | - libffi-dev 14 | - libvulkan-dev 15 | - libxcursor-dev 16 | - libxrandr-dev 17 | - libxinerama-dev 18 | - libxi-dev 19 | - libxxf86vm-dev 20 | - mesa-vulkan-drivers 21 | - wine 22 | - xvfb 23 | - xdotool 24 | - scrot 25 | - sway 26 | - grim 27 | - unzip 28 | sources: 29 | - https://git.sr.ht/~eliasnaur/gio-cmd 30 | environment: 31 | PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin 32 | ANDROID_SDK_ROOT: /home/build/android 33 | android_sdk_tools_zip: sdk-tools-linux-3859397.zip 34 | android_ndk_zip: android-ndk-r20-linux-x86_64.zip 35 | github_mirror: git@github.com:gioui/gio-cmd 36 | secrets: 37 | - fdc570bf-87f4-4528-8aee-4d1711b1c86f 38 | tasks: 39 | - install_go: | 40 | mkdir -p /home/build/sdk 41 | curl -s https://dl.google.com/go/go1.24.1.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf - 42 | - check_gofmt: | 43 | cd gio-cmd 44 | test -z "$(gofmt -s -l .)" 45 | - check_sign_off: | 46 | set +x -e 47 | cd gio-cmd 48 | for hash in $(git log -n 20 --format="%H"); do 49 | message=$(git log -1 --format=%B $hash) 50 | if [[ ! "$message" =~ "Signed-off-by: " ]]; then 51 | echo "Missing 'Signed-off-by' in commit $hash" 52 | exit 1 53 | fi 54 | done 55 | - mirror: | 56 | # mirror to github 57 | ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring" 58 | - install_chrome: | 59 | sudo curl -o /etc/apt/keyrings/google.pub -s https://dl.google.com/linux/linux_signing_key.pub 60 | sudo sh -c 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google.pub] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 61 | sudo apt-get -qq update 62 | sudo apt-get -qq install -y google-chrome-stable 63 | - test: | 64 | cd gio-cmd 65 | go test ./... 66 | go test -race ./... 67 | - install_jdk8: | 68 | curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb" 69 | sudo apt-get -qq install -y -f ./jdk.deb 70 | - install_android: | 71 | mkdir android 72 | cd android 73 | curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip 74 | unzip -q sdk-tools.zip 75 | rm sdk-tools.zip 76 | curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip 77 | unzip -q ndk.zip 78 | rm ndk.zip 79 | mv android-ndk-* ndk-bundle 80 | yes|sdkmanager --licenses 81 | sdkmanager "platforms;android-31" "build-tools;32.0.0" 82 | - install_gogio: | 83 | cd gio-cmd 84 | go install ./gogio 85 | - test_android_gogio: | 86 | mkdir tmp 87 | cd tmp 88 | go mod init example.com 89 | go get -d gioui.org/example/kitchen 90 | gogio -target android gioui.org/example/kitchen 91 | -------------------------------------------------------------------------------- /.builds/openbsd.yml: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Unlicense OR MIT 2 | image: openbsd/latest 3 | packages: 4 | - libxkbcommon 5 | - go 6 | sources: 7 | - https://git.sr.ht/~eliasnaur/gio-cmd 8 | environment: 9 | PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin 10 | tasks: 11 | - install_go: | 12 | mkdir -p /home/build/sdk 13 | curl https://dl.google.com/go/go1.24.1.src.tar.gz | tar -C /home/build/sdk -xzf - 14 | cd /home/build/sdk/go/src 15 | ./make.bash 16 | - test_cmd: | 17 | cd gio-cmd 18 | go test ./... 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This project is provided under the terms of the UNLICENSE or 2 | the MIT license denoted by the following SPDX identifier: 3 | 4 | SPDX-License-Identifier: Unlicense OR MIT 5 | 6 | You may use the project under the terms of either license. 7 | 8 | Both licenses are reproduced below. 9 | 10 | ---- 11 | The MIT License (MIT) 12 | 13 | Copyright (c) 2019 The Gio authors 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in 23 | all copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 31 | THE SOFTWARE. 32 | --- 33 | 34 | 35 | 36 | --- 37 | The UNLICENSE 38 | 39 | This is free and unencumbered software released into the public domain. 40 | 41 | Anyone is free to copy, modify, publish, use, compile, sell, or 42 | distribute this software, either in source code form or as a compiled 43 | binary, for any purpose, commercial or non-commercial, and by any 44 | means. 45 | 46 | In jurisdictions that recognize copyright laws, the author or authors 47 | of this software dedicate any and all copyright interest in the 48 | software to the public domain. We make this dedication for the benefit 49 | of the public at large and to the detriment of our heirs and 50 | successors. We intend this dedication to be an overt act of 51 | relinquishment in perpetuity of all present and future rights to this 52 | software under copyright law. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 55 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 56 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 57 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 58 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 59 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 60 | OTHER DEALINGS IN THE SOFTWARE. 61 | 62 | For more information, please refer to 63 | --- 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gio Tools 2 | 3 | Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs. 4 | 5 | [![builds.sr.ht status](https://builds.sr.ht/~eliasnaur/gio-cmd.svg)](https://builds.sr.ht/~eliasnaur/gio-cmd) 6 | 7 | ## Issues 8 | 9 | File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email 10 | to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the 11 | mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht). 12 | 13 | ## Contributing 14 | 15 | Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to 16 | [gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut 17 | account is required and you can post without being subscribed. 18 | 19 | See the [contribution guide](https://gioui.org/doc/contribute) for more details. 20 | 21 | An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gioui.org/cmd 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | gioui.org v0.8.1-0.20250424183133-e18db649912a 7 | github.com/akavel/rsrc v0.10.1 8 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 9 | github.com/chromedp/chromedp v0.13.6 10 | golang.org/x/image v0.25.0 11 | golang.org/x/sync v0.13.0 12 | golang.org/x/text v0.24.0 13 | golang.org/x/tools v0.31.0 14 | ) 15 | 16 | require ( 17 | gioui.org/shader v1.0.8 // indirect 18 | github.com/chromedp/sysutil v1.1.0 // indirect 19 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 // indirect 20 | github.com/go-text/typesetting v0.3.0 // indirect 21 | github.com/gobwas/httphead v0.1.0 // indirect 22 | github.com/gobwas/pool v0.2.1 // indirect 23 | github.com/gobwas/ws v1.4.0 // indirect 24 | github.com/google/go-cmp v0.7.0 // indirect 25 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 26 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 27 | golang.org/x/mod v0.24.0 // indirect 28 | golang.org/x/sys v0.32.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= 2 | eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= 3 | gioui.org v0.8.1-0.20250424183133-e18db649912a h1:hqcxAFkm5lKJlYvi9hkUvK0s0XcN2xGP5cRGZfbUVKU= 4 | gioui.org v0.8.1-0.20250424183133-e18db649912a/go.mod h1:JnoLsqpYezue9ZRMG7E2hOXar1/oAE9ZFkiFfF4oULs= 5 | gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= 6 | gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= 7 | gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= 8 | github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o= 9 | github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 10 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE= 11 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 12 | github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk= 13 | github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A= 14 | github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 15 | github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 16 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk= 17 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 18 | github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= 19 | github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= 20 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= 21 | github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= 22 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 23 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 24 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 25 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 26 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 27 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 28 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 31 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 32 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 33 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 34 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= 35 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= 36 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= 37 | golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 38 | golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= 39 | golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 40 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 41 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 42 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 43 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 44 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 46 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 47 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 48 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 49 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 50 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 51 | -------------------------------------------------------------------------------- /gogio/android_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "image" 10 | "image/png" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "regexp" 15 | ) 16 | 17 | type AndroidTestDriver struct { 18 | driverBase 19 | 20 | sdkDir string 21 | adbPath string 22 | } 23 | 24 | var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`) 25 | 26 | func (d *AndroidTestDriver) Start(path string) { 27 | d.sdkDir = os.Getenv("ANDROID_HOME") 28 | if d.sdkDir == "" { 29 | d.Skipf("Android SDK is required; set $ANDROID_HOME") 30 | } 31 | d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb") 32 | if _, err := os.Stat(d.adbPath); os.IsNotExist(err) { 33 | d.Skipf("adb not found") 34 | } 35 | 36 | devOut := bytes.TrimSpace(d.adb("devices")) 37 | devices := rxAdbDevice.FindAllSubmatch(devOut, -1) 38 | switch len(devices) { 39 | case 0: 40 | d.Skipf("no Android devices attached via adb; skipping") 41 | case 1: 42 | default: 43 | d.Skipf("multiple Android devices attached via adb; skipping") 44 | } 45 | 46 | // If the device is attached but asleep, it's probably just charging. 47 | // Don't use it; the screen needs to be on and unlocked for the test to 48 | // work. 49 | if !bytes.Contains( 50 | d.adb("shell", "dumpsys", "power"), 51 | []byte(" mWakefulness=Awake"), 52 | ) { 53 | d.Skipf("Android device isn't awake; skipping") 54 | } 55 | 56 | // First, build the app. 57 | apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk") 58 | d.gogio("-target=android", "-appid="+appid, "-o="+apk, path) 59 | 60 | // Make sure the app isn't installed already, and try to uninstall it 61 | // when we finish. Previous failed test runs might have left the app. 62 | d.tryUninstall() 63 | d.adb("install", apk) 64 | d.Cleanup(d.tryUninstall) 65 | 66 | // Force our e2e app to be fullscreen, so that the android system bar at 67 | // the top doesn't mess with our screenshots. 68 | // TODO(mvdan): is there a way to do this via gio, so that we don't need 69 | // to set up a global Android setting via the shell? 70 | d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid) 71 | 72 | // Make sure the app isn't already running. 73 | d.adb("shell", "pm", "clear", appid) 74 | 75 | // Start listening for log messages. 76 | { 77 | ctx, cancel := context.WithCancel(context.Background()) 78 | cmd := exec.CommandContext(ctx, d.adbPath, 79 | "logcat", 80 | "-s", // suppress other logs 81 | "-T1", // don't show previous log messages 82 | appid+":*", // show all logs from our gio app ID 83 | ) 84 | output, err := cmd.StdoutPipe() 85 | if err != nil { 86 | d.Fatal(err) 87 | } 88 | cmd.Stderr = cmd.Stdout 89 | d.output = output 90 | if err := cmd.Start(); err != nil { 91 | d.Fatal(err) 92 | } 93 | d.Cleanup(cancel) 94 | } 95 | 96 | // Start the app. 97 | d.adb("shell", "monkey", "-p", appid, "1") 98 | 99 | // Wait for the gio app to render. 100 | d.waitForFrame() 101 | } 102 | 103 | func (d *AndroidTestDriver) Screenshot() image.Image { 104 | out := d.adb("shell", "screencap", "-p") 105 | img, err := png.Decode(bytes.NewReader(out)) 106 | if err != nil { 107 | d.Fatal(err) 108 | } 109 | return img 110 | } 111 | 112 | func (d *AndroidTestDriver) tryUninstall() { 113 | cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid) 114 | out, err := cmd.CombinedOutput() 115 | if err != nil { 116 | if bytes.Contains(out, []byte("Unknown package")) { 117 | // The package is not installed. Don't log anything. 118 | return 119 | } 120 | d.Logf("could not uninstall: %v\n%s", err, out) 121 | } 122 | } 123 | 124 | func (d *AndroidTestDriver) adb(args ...any) []byte { 125 | strs := []string{} 126 | for _, arg := range args { 127 | strs = append(strs, fmt.Sprint(arg)) 128 | } 129 | cmd := exec.Command(d.adbPath, strs...) 130 | out, err := cmd.CombinedOutput() 131 | if err != nil { 132 | d.Errorf("%s", out) 133 | d.Fatal(err) 134 | } 135 | return out 136 | } 137 | 138 | func (d *AndroidTestDriver) Click(x, y int) { 139 | d.adb("shell", "input", "tap", x, y) 140 | 141 | // Wait for the gio app to render after this click. 142 | d.waitForFrame() 143 | } 144 | -------------------------------------------------------------------------------- /gogio/androidbuild.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "archive/zip" 7 | "bytes" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "runtime" 15 | "strconv" 16 | "strings" 17 | "text/template" 18 | 19 | "golang.org/x/sync/errgroup" 20 | "golang.org/x/tools/go/packages" 21 | ) 22 | 23 | type androidTools struct { 24 | buildtools string 25 | androidjar string 26 | } 27 | 28 | // zip.Writer with a sticky error. 29 | type zipWriter struct { 30 | err error 31 | w *zip.Writer 32 | } 33 | 34 | // Writer that saves any errors. 35 | type errWriter struct { 36 | w io.Writer 37 | err *error 38 | } 39 | 40 | var exeSuffix string 41 | 42 | type manifestData struct { 43 | AppID string 44 | Version Semver 45 | MinSDK int 46 | TargetSDK int 47 | Permissions []string 48 | Features []string 49 | IconSnip string 50 | AppName string 51 | } 52 | 53 | const ( 54 | themes = ` 55 | 56 | 59 | ` 60 | themesV21 = ` 61 | 62 | 69 | ` 70 | ) 71 | 72 | func init() { 73 | if runtime.GOOS == "windows" { 74 | exeSuffix = ".exe" 75 | } 76 | } 77 | 78 | func buildAndroid(tmpDir string, bi *buildInfo) error { 79 | sdk := os.Getenv("ANDROID_HOME") 80 | if sdk == "" { 81 | return errors.New("please set ANDROID_HOME to the Android SDK path") 82 | } 83 | if _, err := os.Stat(sdk); err != nil { 84 | return err 85 | } 86 | platform, err := latestPlatform(sdk) 87 | if err != nil { 88 | return err 89 | } 90 | buildtools, err := latestTools(sdk) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | tools := &androidTools{ 96 | buildtools: buildtools, 97 | androidjar: filepath.Join(platform, "android.jar"), 98 | } 99 | perms := []string{"default"} 100 | const permPref = "gioui.org/app/permission/" 101 | cfg := &packages.Config{ 102 | Mode: packages.NeedName + 103 | packages.NeedFiles + 104 | packages.NeedImports + 105 | packages.NeedDeps, 106 | Env: append( 107 | os.Environ(), 108 | "GOOS=android", 109 | "CGO_ENABLED=1", 110 | ), 111 | } 112 | pkgs, err := packages.Load(cfg, bi.pkgPath) 113 | if err != nil { 114 | return err 115 | } 116 | var extraJars []string 117 | visitedPkgs := make(map[string]bool) 118 | var visitPkg func(*packages.Package) error 119 | visitPkg = func(p *packages.Package) error { 120 | if len(p.GoFiles) == 0 { 121 | return nil 122 | } 123 | dir := filepath.Dir(p.GoFiles[0]) 124 | jars, err := filepath.Glob(filepath.Join(dir, "*.jar")) 125 | if err != nil { 126 | return err 127 | } 128 | extraJars = append(extraJars, jars...) 129 | switch { 130 | case p.PkgPath == "net": 131 | perms = append(perms, "network") 132 | case strings.HasPrefix(p.PkgPath, permPref): 133 | perms = append(perms, p.PkgPath[len(permPref):]) 134 | } 135 | 136 | for _, imp := range p.Imports { 137 | if !visitedPkgs[imp.ID] { 138 | visitPkg(imp) 139 | visitedPkgs[imp.ID] = true 140 | } 141 | } 142 | return nil 143 | } 144 | if err := visitPkg(pkgs[0]); err != nil { 145 | return err 146 | } 147 | 148 | if err := compileAndroid(tmpDir, tools, bi); err != nil { 149 | return err 150 | } 151 | switch *buildMode { 152 | case "archive": 153 | return archiveAndroid(tmpDir, bi, perms) 154 | case "exe": 155 | file := *destPath 156 | if file == "" { 157 | file = fmt.Sprintf("%s.apk", bi.name) 158 | } 159 | 160 | isBundle := false 161 | switch filepath.Ext(file) { 162 | case ".apk": 163 | case ".aab": 164 | isBundle = true 165 | default: 166 | return fmt.Errorf("the specified output %q does not end in '.apk' or '.aab'", file) 167 | } 168 | 169 | if err := exeAndroid(tmpDir, tools, bi, extraJars, perms, isBundle); err != nil { 170 | return err 171 | } 172 | if isBundle { 173 | return signAAB(tmpDir, file, tools, bi) 174 | } 175 | return signAPK(tmpDir, file, tools, bi) 176 | default: 177 | panic("unreachable") 178 | } 179 | } 180 | 181 | func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) { 182 | androidHome := os.Getenv("ANDROID_HOME") 183 | if androidHome == "" { 184 | return errors.New("ANDROID_HOME is not set. Please point it to the root of the Android SDK") 185 | } 186 | javac, err := findJavaC() 187 | if err != nil { 188 | return fmt.Errorf("could not find javac: %v", err) 189 | } 190 | ndkRoot, err := findNDK(androidHome) 191 | if err != nil { 192 | return err 193 | } 194 | minSDK := max(bi.minsdk, 17) 195 | tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK()) 196 | var builds errgroup.Group 197 | for _, a := range bi.archs { 198 | arch := allArchs[a] 199 | clang, err := latestCompiler(tcRoot, a, minSDK) 200 | if err != nil { 201 | return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err) 202 | } 203 | if runtime.GOOS == "windows" { 204 | // Because of https://github.com/android-ndk/ndk/issues/920, 205 | // we need NDK r19c, not just r19b. Check for the presence of 206 | // clang++.cmd which is only available in r19c. 207 | clangpp := clang + "++.cmd" 208 | if _, err := os.Stat(clangpp); err != nil { 209 | return fmt.Errorf("NDK version r19b detected, but >= r19c is required. Use the command `sdkmanager ndk-bundle` to install it") 210 | } 211 | } 212 | archDir := filepath.Join(tmpDir, "jni", arch.jniArch) 213 | if err := os.MkdirAll(archDir, 0o755); err != nil { 214 | return fmt.Errorf("failed to create %q: %v", archDir, err) 215 | } 216 | libFile := filepath.Join(archDir, "libgio.so") 217 | cmd := exec.Command( 218 | "go", 219 | "build", 220 | "-ldflags=-w -s -extldflags \"-Wl,-z,max-page-size=65536\" "+bi.ldflags, 221 | "-buildmode=c-shared", 222 | "-tags", bi.tags, 223 | "-o", libFile, 224 | bi.pkgPath, 225 | ) 226 | cmd.Env = append( 227 | os.Environ(), 228 | "GOOS=android", 229 | "GOARCH="+a, 230 | "GOARM=7", // Avoid softfloat. 231 | "CGO_ENABLED=1", 232 | "CC="+clang, 233 | ) 234 | builds.Go(func() error { 235 | _, err := runCmd(cmd) 236 | return err 237 | }) 238 | } 239 | appDir, err := runCmd(exec.Command("go", "list", "-tags", bi.tags, "-f", "{{.Dir}}", "gioui.org/app/")) 240 | if err != nil { 241 | return err 242 | } 243 | javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java")) 244 | if err != nil { 245 | return err 246 | } 247 | if len(javaFiles) == 0 { 248 | return fmt.Errorf("the gioui.org/app package contains no .java files (gioui.org module too old?)") 249 | } 250 | if len(javaFiles) > 0 { 251 | classes := filepath.Join(tmpDir, "classes") 252 | if err := os.MkdirAll(classes, 0o755); err != nil { 253 | return err 254 | } 255 | javac := exec.Command( 256 | javac, 257 | "-target", "1.8", 258 | "-source", "1.8", 259 | "-sourcepath", appDir, 260 | "-bootclasspath", tools.androidjar, 261 | "-d", classes, 262 | ) 263 | javac.Args = append(javac.Args, javaFiles...) 264 | builds.Go(func() error { 265 | _, err := runCmd(javac) 266 | return err 267 | }) 268 | } 269 | return builds.Wait() 270 | } 271 | 272 | func archiveAndroid(tmpDir string, bi *buildInfo, perms []string) (err error) { 273 | aarFile := *destPath 274 | if aarFile == "" { 275 | aarFile = fmt.Sprintf("%s.aar", bi.name) 276 | } 277 | if filepath.Ext(aarFile) != ".aar" { 278 | return fmt.Errorf("the specified output %q does not end in '.aar'", aarFile) 279 | } 280 | aar, err := os.Create(aarFile) 281 | if err != nil { 282 | return err 283 | } 284 | defer func() { 285 | if cerr := aar.Close(); err == nil { 286 | err = cerr 287 | } 288 | }() 289 | aarw := newZipWriter(aar) 290 | defer aarw.Close() 291 | aarw.Create("R.txt") 292 | themesXML := aarw.Create("res/values/themes.xml") 293 | themesXML.Write([]byte(themes)) 294 | themesXML21 := aarw.Create("res/values-v21/themes.xml") 295 | themesXML21.Write([]byte(themesV21)) 296 | permissions, features := getPermissions(perms) 297 | // Disable input emulation on ChromeOS. 298 | manifest := aarw.Create("AndroidManifest.xml") 299 | manifestSrc := manifestData{ 300 | AppID: bi.appID, 301 | MinSDK: bi.minsdk, 302 | Permissions: permissions, 303 | Features: features, 304 | } 305 | tmpl, err := template.New("manifest").Parse( 306 | ` 307 | 308 | {{range .Permissions}} 309 | {{end}}{{range .Features}} 310 | {{end}} 311 | `) 312 | if err != nil { 313 | panic(err) 314 | } 315 | err = tmpl.Execute(manifest, manifestSrc) 316 | proguard := aarw.Create("proguard.txt") 317 | proguard.Write([]byte(`-keep class org.gioui.** { *; }`)) 318 | 319 | for _, a := range bi.archs { 320 | arch := allArchs[a] 321 | libFile := filepath.Join("jni", arch.jniArch, "libgio.so") 322 | aarw.Add(filepath.ToSlash(libFile), filepath.Join(tmpDir, libFile)) 323 | } 324 | classes := filepath.Join(tmpDir, "classes") 325 | if _, err := os.Stat(classes); err == nil { 326 | jarFile := filepath.Join(tmpDir, "classes.jar") 327 | if err := writeJar(jarFile, classes); err != nil { 328 | return err 329 | } 330 | aarw.Add("classes.jar", jarFile) 331 | } 332 | return aarw.Close() 333 | } 334 | 335 | func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, perms []string, isBundle bool) (err error) { 336 | classes := filepath.Join(tmpDir, "classes") 337 | var classFiles []string 338 | err = filepath.Walk(classes, func(path string, f os.FileInfo, err error) error { 339 | if err != nil { 340 | return err 341 | } 342 | if filepath.Ext(path) == ".class" { 343 | classFiles = append(classFiles, path) 344 | } 345 | return nil 346 | }) 347 | classFiles = append(classFiles, extraJars...) 348 | dexDir := filepath.Join(tmpDir, "apk") 349 | if err := os.MkdirAll(dexDir, 0o755); err != nil { 350 | return err 351 | } 352 | minSDK := max(bi.minsdk, 16) 353 | // https://developer.android.com/distribute/best-practices/develop/target-sdk 354 | targetSDK := 33 355 | if bi.targetsdk > 0 { 356 | targetSDK = bi.targetsdk 357 | } 358 | if minSDK > targetSDK { 359 | targetSDK = minSDK 360 | } 361 | if len(classFiles) > 0 { 362 | d8 := exec.Command( 363 | filepath.Join(tools.buildtools, "d8"), 364 | "--lib", tools.androidjar, 365 | "--output", dexDir, 366 | "--min-api", strconv.Itoa(minSDK), 367 | ) 368 | d8.Args = append(d8.Args, classFiles...) 369 | if _, err := runCmd(d8); err != nil { 370 | major, minor, ok := determineJDKVersion() 371 | if ok && (major != 1 || minor != 8) { 372 | return fmt.Errorf("unsupported JDK version %d.%d, expected 1.8\nd8 error: %v", major, minor, err) 373 | } 374 | return err 375 | } 376 | } 377 | 378 | // Compile resources. 379 | resDir := filepath.Join(tmpDir, "res") 380 | valDir := filepath.Join(resDir, "values") 381 | v21Dir := filepath.Join(resDir, "values-v21") 382 | v26mipmapDir := filepath.Join(resDir, `mipmap-anydpi-v26`) 383 | for _, dir := range []string{valDir, v21Dir, v26mipmapDir} { 384 | if err := os.MkdirAll(dir, 0o755); err != nil { 385 | return err 386 | } 387 | } 388 | iconSnip := "" 389 | if _, err := os.Stat(bi.iconPath); err == nil { 390 | err := buildIcons(resDir, bi.iconPath, []iconVariant{ 391 | {path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72}, 392 | {path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96}, 393 | {path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144}, 394 | {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher.png"), size: 192}, 395 | {path: filepath.Join("mipmap-mdpi", "ic_launcher_adaptive.png"), size: 108}, 396 | {path: filepath.Join("mipmap-hdpi", "ic_launcher_adaptive.png"), size: 162}, 397 | {path: filepath.Join("mipmap-xhdpi", "ic_launcher_adaptive.png"), size: 216}, 398 | {path: filepath.Join("mipmap-xxhdpi", "ic_launcher_adaptive.png"), size: 324}, 399 | {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher_adaptive.png"), size: 432}, 400 | }) 401 | if err != nil { 402 | return err 403 | } 404 | err = os.WriteFile(filepath.Join(v26mipmapDir, `ic_launcher.xml`), []byte(` 405 | 406 | 407 | 408 | `), 0o660) 409 | if err != nil { 410 | return err 411 | } 412 | iconSnip = `android:icon="@mipmap/ic_launcher"` 413 | } 414 | err = os.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0o660) 415 | if err != nil { 416 | return err 417 | } 418 | err = os.WriteFile(filepath.Join(v21Dir, "themes.xml"), []byte(themesV21), 0o660) 419 | if err != nil { 420 | return err 421 | } 422 | resZip := filepath.Join(tmpDir, "resources.zip") 423 | aapt2 := filepath.Join(tools.buildtools, "aapt2") 424 | _, err = runCmd(exec.Command( 425 | aapt2, 426 | "compile", 427 | "-o", resZip, 428 | "--dir", resDir)) 429 | if err != nil { 430 | return err 431 | } 432 | 433 | // Link APK. 434 | permissions, features := getPermissions(perms) 435 | appName := UppercaseName(bi.name) 436 | manifestSrc := manifestData{ 437 | AppID: bi.appID, 438 | Version: bi.version, 439 | MinSDK: minSDK, 440 | TargetSDK: targetSDK, 441 | Permissions: permissions, 442 | Features: features, 443 | IconSnip: iconSnip, 444 | AppName: appName, 445 | } 446 | tmpl, err := template.New("test").Parse( 447 | ` 448 | 452 | 453 | {{range .Permissions}} 454 | {{end}}{{range .Features}} 455 | {{end}} 456 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | `) 469 | var manifestBuffer bytes.Buffer 470 | if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil { 471 | return err 472 | } 473 | manifest := filepath.Join(tmpDir, "AndroidManifest.xml") 474 | if err := os.WriteFile(manifest, manifestBuffer.Bytes(), 0o660); err != nil { 475 | return err 476 | } 477 | 478 | linkAPK := filepath.Join(tmpDir, "link.apk") 479 | 480 | args := []string{ 481 | "link", 482 | "--manifest", manifest, 483 | "-I", tools.androidjar, 484 | "-o", linkAPK, 485 | } 486 | if isBundle { 487 | args = append(args, "--proto-format") 488 | } 489 | args = append(args, resZip) 490 | 491 | if _, err := runCmd(exec.Command(aapt2, args...)); err != nil { 492 | return err 493 | } 494 | 495 | // The Go standard library archive/zip doesn't support appending to zip 496 | // files. Copy files from `link.apk` (generated by aapt2) along with classes.dex and 497 | // the Go libraries to a new `app.zip` file. 498 | 499 | // Load link.apk as zip. 500 | linkAPKZip, err := zip.OpenReader(linkAPK) 501 | if err != nil { 502 | return err 503 | } 504 | defer linkAPKZip.Close() 505 | 506 | // Create new "APK". 507 | unsignedAPK := filepath.Join(tmpDir, "app.zip") 508 | unsignedAPKFile, err := os.Create(unsignedAPK) 509 | if err != nil { 510 | return err 511 | } 512 | defer func() { 513 | if cerr := unsignedAPKFile.Close(); err == nil { 514 | err = cerr 515 | } 516 | }() 517 | unsignedAPKZip := zip.NewWriter(unsignedAPKFile) 518 | defer unsignedAPKZip.Close() 519 | 520 | // Copy files from linkAPK to unsignedAPK. 521 | for _, f := range linkAPKZip.File { 522 | header := zip.FileHeader{ 523 | Name: f.FileHeader.Name, 524 | Method: f.FileHeader.Method, 525 | } 526 | 527 | if isBundle { 528 | // AAB have pre-defined folders. 529 | switch header.Name { 530 | case "AndroidManifest.xml": 531 | header.Name = "manifest/AndroidManifest.xml" 532 | } 533 | } 534 | 535 | w, err := unsignedAPKZip.CreateHeader(&header) 536 | if err != nil { 537 | return err 538 | } 539 | r, err := f.Open() 540 | if err != nil { 541 | return err 542 | } 543 | if _, err := io.Copy(w, r); err != nil { 544 | return err 545 | } 546 | } 547 | 548 | // Append new files (that doesn't exists inside the link.apk). 549 | appendToZip := func(path string, file string) error { 550 | f, err := os.Open(file) 551 | if err != nil { 552 | return err 553 | } 554 | defer f.Close() 555 | w, err := unsignedAPKZip.CreateHeader(&zip.FileHeader{ 556 | Name: filepath.ToSlash(path), 557 | Method: zip.Deflate, 558 | }) 559 | if err != nil { 560 | return err 561 | } 562 | _, err = io.Copy(w, f) 563 | return err 564 | } 565 | 566 | // Append Go binaries (libgio.so). 567 | for _, a := range bi.archs { 568 | arch := allArchs[a] 569 | libFile := filepath.Join(arch.jniArch, "libgio.so") 570 | if err := appendToZip(filepath.Join("lib", libFile), filepath.Join(tmpDir, "jni", libFile)); err != nil { 571 | return err 572 | } 573 | } 574 | 575 | // Append classes.dex. 576 | if len(classFiles) > 0 { 577 | classesFolder := "classes.dex" 578 | if isBundle { 579 | classesFolder = "dex/classes.dex" 580 | } 581 | if err := appendToZip(classesFolder, filepath.Join(dexDir, "classes.dex")); err != nil { 582 | return err 583 | } 584 | } 585 | 586 | return unsignedAPKZip.Close() 587 | } 588 | 589 | func determineJDKVersion() (int, int, bool) { 590 | path, err := findJavaC() 591 | if err != nil { 592 | return 0, 0, false 593 | } 594 | java := exec.Command(filepath.Join(filepath.Dir(path), "java"), "-version") 595 | out, err := java.CombinedOutput() 596 | if err != nil { 597 | return 0, 0, false 598 | } 599 | var vendor string 600 | var major, minor int 601 | _, err = fmt.Sscanf(string(out), "%s version \"%d.%d", &vendor, &major, &minor) 602 | return major, minor, err == nil 603 | } 604 | 605 | func signAPK(tmpDir string, apkFile string, tools *androidTools, bi *buildInfo) error { 606 | if err := zipalign(tools, filepath.Join(tmpDir, "app.zip"), apkFile); err != nil { 607 | return err 608 | } 609 | 610 | if bi.key == "" { 611 | if err := defaultAndroidKeystore(tmpDir, bi); err != nil { 612 | return err 613 | } 614 | } 615 | 616 | _, err := runCmd(exec.Command( 617 | filepath.Join(tools.buildtools, "apksigner"), 618 | "sign", 619 | "--ks-pass", "pass:"+bi.password, 620 | "--ks", bi.key, 621 | apkFile, 622 | )) 623 | 624 | return err 625 | } 626 | 627 | func signAAB(tmpDir string, aabFile string, tools *androidTools, bi *buildInfo) error { 628 | allBundleTools, err := filepath.Glob(filepath.Join(tools.buildtools, "bundletool*.jar")) 629 | if err != nil { 630 | return err 631 | } 632 | 633 | bundletool := "" 634 | for _, v := range allBundleTools { 635 | bundletool = v 636 | break 637 | } 638 | 639 | if bundletool == "" { 640 | 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) 641 | } 642 | 643 | _, err = runCmd(exec.Command( 644 | "java", 645 | "-jar", bundletool, 646 | "build-bundle", 647 | "--modules="+filepath.Join(tmpDir, "app.zip"), 648 | "--output="+filepath.Join(tmpDir, "app.aab"), 649 | )) 650 | if err != nil { 651 | return err 652 | } 653 | 654 | if err := zipalign(tools, filepath.Join(tmpDir, "app.aab"), aabFile); err != nil { 655 | return err 656 | } 657 | 658 | if bi.key == "" { 659 | if err := defaultAndroidKeystore(tmpDir, bi); err != nil { 660 | return err 661 | } 662 | } 663 | 664 | keytoolList, err := runCmd(exec.Command( 665 | "keytool", 666 | "-keystore", bi.key, 667 | "-list", 668 | "-keypass", bi.password, 669 | "-v", 670 | )) 671 | if err != nil { 672 | return err 673 | } 674 | 675 | var alias string 676 | for t := range strings.SplitSeq(keytoolList, "\n") { 677 | if i, _ := fmt.Sscanf(t, "Alias name: %s", &alias); i > 0 { 678 | break 679 | } 680 | } 681 | 682 | _, err = runCmd(exec.Command( 683 | filepath.Join("jarsigner"), 684 | "-sigalg", "SHA256withRSA", 685 | "-digestalg", "SHA-256", 686 | "-keystore", bi.key, 687 | "-storepass", bi.password, 688 | aabFile, 689 | strings.TrimSpace(alias), 690 | )) 691 | 692 | return err 693 | } 694 | 695 | func zipalign(tools *androidTools, input, output string) error { 696 | _, err := runCmd(exec.Command( 697 | filepath.Join(tools.buildtools, "zipalign"), 698 | "-f", 699 | "4", // 32-bit alignment. 700 | input, 701 | output, 702 | )) 703 | return err 704 | } 705 | 706 | func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error { 707 | home, err := os.UserHomeDir() 708 | if err != nil { 709 | return err 710 | } 711 | 712 | // Use debug.keystore, if exists. 713 | bi.key = filepath.Join(home, ".android", "debug.keystore") 714 | bi.password = "android" 715 | if _, err := os.Stat(bi.key); err == nil { 716 | return nil 717 | } 718 | 719 | // Generate new key. 720 | bi.key = filepath.Join(tmpDir, "sign.keystore") 721 | keytool, err := findKeytool() 722 | if err != nil { 723 | return err 724 | } 725 | _, err = runCmd(exec.Command( 726 | keytool, 727 | "-genkey", 728 | "-keystore", bi.key, 729 | "-storepass", bi.password, 730 | "-alias", "android", 731 | "-keyalg", "RSA", "-keysize", "2048", 732 | "-validity", "10000", 733 | "-noprompt", 734 | "-dname", "CN=android", 735 | )) 736 | return err 737 | } 738 | 739 | func findNDK(androidHome string) (string, error) { 740 | ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*")) 741 | if err != nil { 742 | return "", err 743 | } 744 | if bestNDK, found := latestVersionPath(ndks); found { 745 | return bestNDK, nil 746 | } 747 | // The old NDK path was $ANDROID_HOME/ndk-bundle. 748 | ndkBundle := filepath.Join(androidHome, "ndk-bundle") 749 | if _, err := os.Stat(ndkBundle); err == nil { 750 | return ndkBundle, nil 751 | } 752 | // Certain non-standard NDK isntallations set the $ANDROID_NDK_ROOT 753 | // environment variable 754 | if ndkBundle, ok := os.LookupEnv("ANDROID_NDK_ROOT"); ok { 755 | if _, err := os.Stat(ndkBundle); err == nil { 756 | return ndkBundle, nil 757 | } 758 | } 759 | 760 | return "", fmt.Errorf("no NDK found in $ANDROID_HOME (%s). Set $ANDROID_NDK_ROOT or use `sdkmanager ndk-bundle` to install the NDK", androidHome) 761 | } 762 | 763 | func findKeytool() (string, error) { 764 | javaHome := os.Getenv("JAVA_HOME") 765 | if javaHome == "" { 766 | return exec.LookPath("keytool") 767 | } 768 | 769 | // bin, instead of "jre". "jre" was for older JVM it seems. 770 | keytool := filepath.Join(javaHome, "bin", "keytool"+exeSuffix) 771 | if _, err := os.Stat(keytool); err != nil { 772 | return "", err 773 | } 774 | return keytool, nil 775 | } 776 | 777 | func findJavaC() (string, error) { 778 | javaHome := os.Getenv("JAVA_HOME") 779 | if javaHome == "" { 780 | return exec.LookPath("javac") 781 | } 782 | javac := filepath.Join(javaHome, "bin", "javac"+exeSuffix) 783 | if _, err := os.Stat(javac); err != nil { 784 | return "", err 785 | } 786 | return javac, nil 787 | } 788 | 789 | func writeJar(jarFile, dir string) (err error) { 790 | jar, err := os.Create(jarFile) 791 | if err != nil { 792 | return err 793 | } 794 | defer func() { 795 | if cerr := jar.Close(); err == nil { 796 | err = cerr 797 | } 798 | }() 799 | jarw := newZipWriter(jar) 800 | const manifestHeader = `Manifest-Version: 1.0 801 | Created-By: 1.0 (Go) 802 | 803 | ` 804 | jarw.Create("META-INF/MANIFEST.MF").Write([]byte(manifestHeader)) 805 | err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { 806 | if err != nil { 807 | return err 808 | } 809 | if f.IsDir() { 810 | return nil 811 | } 812 | if filepath.Ext(path) == ".class" { 813 | rel := filepath.ToSlash(path[len(dir)+1:]) 814 | jarw.Add(rel, path) 815 | } 816 | return nil 817 | }) 818 | if err != nil { 819 | return err 820 | } 821 | return jarw.Close() 822 | } 823 | 824 | func archNDK() string { 825 | var arch string 826 | switch runtime.GOARCH { 827 | case "386": 828 | arch = "x86" 829 | case "amd64": 830 | arch = "x86_64" 831 | case "arm64": 832 | if runtime.GOOS == "darwin" { 833 | // Workaround for arm64 macOS. This will keep working until 834 | // Apple deprecates Rosetta 2. 835 | arch = "x86_64" 836 | } else { 837 | panic("unsupported GOARCH: " + runtime.GOARCH) 838 | } 839 | default: 840 | panic("unsupported GOARCH: " + runtime.GOARCH) 841 | } 842 | return runtime.GOOS + "-" + arch 843 | } 844 | 845 | func getPermissions(ps []string) ([]string, []string) { 846 | var permissions, features []string 847 | seenPermissions := make(map[string]bool) 848 | seenFeatures := make(map[string]bool) 849 | for _, perm := range ps { 850 | for _, x := range AndroidPermissions[perm] { 851 | if !seenPermissions[x] { 852 | permissions = append(permissions, x) 853 | seenPermissions[x] = true 854 | } 855 | } 856 | for _, x := range AndroidFeatures[perm] { 857 | if !seenFeatures[x] { 858 | features = append(features, x) 859 | seenFeatures[x] = true 860 | } 861 | } 862 | } 863 | return permissions, features 864 | } 865 | 866 | func latestPlatform(sdk string) (string, error) { 867 | allPlats, err := filepath.Glob(filepath.Join(sdk, "platforms", "android-*")) 868 | if err != nil { 869 | return "", err 870 | } 871 | var bestVer int 872 | var bestPlat string 873 | for _, platform := range allPlats { 874 | _, name := filepath.Split(platform) 875 | // The glob above guarantees the "android-" prefix. 876 | verStr := name[len("android-"):] 877 | ver, err := strconv.Atoi(verStr) 878 | if err != nil { 879 | continue 880 | } 881 | if ver < bestVer { 882 | continue 883 | } 884 | bestVer = ver 885 | bestPlat = platform 886 | } 887 | if bestPlat == "" { 888 | return "", fmt.Errorf("no platforms found in %q", sdk) 889 | } 890 | return bestPlat, nil 891 | } 892 | 893 | func latestCompiler(tcRoot, a string, minsdk int) (string, error) { 894 | arch := allArchs[a] 895 | allComps, err := filepath.Glob(filepath.Join(tcRoot, "bin", arch.clangArch+"*-clang")) 896 | if err != nil { 897 | return "", err 898 | } 899 | var bestVer int 900 | var firstVer int 901 | var bestCompiler string 902 | var firstCompiler string 903 | for _, compiler := range allComps { 904 | var ver int 905 | pattern := filepath.Join(tcRoot, "bin", arch.clangArch) + "%d-clang" 906 | if n, err := fmt.Sscanf(compiler, pattern, &ver); n < 1 || err != nil { 907 | continue 908 | } 909 | if firstCompiler == "" || ver < firstVer { 910 | firstVer = ver 911 | firstCompiler = compiler 912 | } 913 | if ver < bestVer { 914 | continue 915 | } 916 | if ver > minsdk { 917 | continue 918 | } 919 | bestVer = ver 920 | bestCompiler = compiler 921 | } 922 | if bestCompiler == "" { 923 | bestCompiler = firstCompiler 924 | } 925 | if bestCompiler == "" { 926 | return "", fmt.Errorf("no NDK compiler found for architecture %s in %s", a, tcRoot) 927 | } 928 | return bestCompiler, nil 929 | } 930 | 931 | func latestTools(sdk string) (string, error) { 932 | allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*")) 933 | if err != nil { 934 | return "", err 935 | } 936 | tools, found := latestVersionPath(allTools) 937 | if !found { 938 | return "", fmt.Errorf("no build-tools found in %q", sdk) 939 | } 940 | return tools, nil 941 | } 942 | 943 | // latestVersionFile finds the path with the highest version 944 | // among paths on the form 945 | // 946 | // /some/path/major.minor.patch 947 | func latestVersionPath(paths []string) (string, bool) { 948 | var bestVer [3]int 949 | var bestDir string 950 | loop: 951 | for _, path := range paths { 952 | name := filepath.Base(path) 953 | s := strings.SplitN(name, ".", 3) 954 | var version [3]int 955 | for i, v := range s { 956 | v, err := strconv.Atoi(v) 957 | if err != nil { 958 | continue loop 959 | } 960 | if v < bestVer[i] { 961 | continue loop 962 | } 963 | if v > bestVer[i] { 964 | break 965 | } 966 | version[i] = v 967 | } 968 | bestVer = version 969 | bestDir = path 970 | } 971 | return bestDir, bestDir != "" 972 | } 973 | 974 | func newZipWriter(w io.Writer) *zipWriter { 975 | return &zipWriter{ 976 | w: zip.NewWriter(w), 977 | } 978 | } 979 | 980 | func (z *zipWriter) Close() error { 981 | err := z.w.Close() 982 | if z.err == nil { 983 | z.err = err 984 | } 985 | return z.err 986 | } 987 | 988 | func (z *zipWriter) Create(name string) io.Writer { 989 | if z.err != nil { 990 | return io.Discard 991 | } 992 | w, err := z.w.Create(name) 993 | if err != nil { 994 | z.err = err 995 | return io.Discard 996 | } 997 | return &errWriter{w: w, err: &z.err} 998 | } 999 | 1000 | func (z *zipWriter) Store(name, file string) { 1001 | z.add(name, file, false) 1002 | } 1003 | 1004 | func (z *zipWriter) Add(name, file string) { 1005 | z.add(name, file, true) 1006 | } 1007 | 1008 | func (z *zipWriter) add(name, file string, compressed bool) { 1009 | if z.err != nil { 1010 | return 1011 | } 1012 | f, err := os.Open(file) 1013 | if err != nil { 1014 | z.err = err 1015 | return 1016 | } 1017 | defer f.Close() 1018 | fh := &zip.FileHeader{ 1019 | Name: name, 1020 | } 1021 | if compressed { 1022 | fh.Method = zip.Deflate 1023 | } 1024 | w, err := z.w.CreateHeader(fh) 1025 | if err != nil { 1026 | z.err = err 1027 | return 1028 | } 1029 | if _, err := io.Copy(w, f); err != nil { 1030 | z.err = err 1031 | return 1032 | } 1033 | } 1034 | 1035 | func (w *errWriter) Write(p []byte) (n int, err error) { 1036 | if err := *w.err; err != nil { 1037 | return 0, err 1038 | } 1039 | n, err = w.w.Write(p) 1040 | *w.err = err 1041 | return 1042 | } 1043 | -------------------------------------------------------------------------------- /gogio/build_info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "unicode" 13 | "unicode/utf8" 14 | ) 15 | 16 | type buildInfo struct { 17 | appID string 18 | archs []string 19 | ldflags string 20 | minsdk int 21 | targetsdk int 22 | name string 23 | pkgDir string 24 | pkgPath string 25 | iconPath string 26 | tags string 27 | target string 28 | version Semver 29 | key string 30 | password string 31 | notaryAppleID string 32 | notaryPassword string 33 | notaryTeamID string 34 | } 35 | 36 | type Semver struct { 37 | Major, Minor, Patch int 38 | VersionCode uint32 39 | } 40 | 41 | func newBuildInfo(pkgPath string) (*buildInfo, error) { 42 | pkgMetadata, err := getPkgMetadata(pkgPath) 43 | if err != nil { 44 | return nil, err 45 | } 46 | appID := getAppID(pkgMetadata) 47 | appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png") 48 | if *iconPath != "" { 49 | appIcon = *iconPath 50 | } 51 | appName := getPkgName(pkgMetadata) 52 | if *name != "" { 53 | appName = *name 54 | } 55 | ver, err := parseSemver(*version) 56 | if err != nil { 57 | return nil, err 58 | } 59 | sp := *signPass 60 | if sp == "" { 61 | sp = os.Getenv("GOGIO_SIGNPASS") 62 | } 63 | bi := &buildInfo{ 64 | appID: appID, 65 | archs: getArchs(), 66 | ldflags: getLdFlags(appID), 67 | minsdk: *minsdk, 68 | targetsdk: *targetsdk, 69 | name: appName, 70 | pkgDir: pkgMetadata.Dir, 71 | pkgPath: pkgPath, 72 | iconPath: appIcon, 73 | tags: *extraTags, 74 | target: *target, 75 | version: ver, 76 | key: *signKey, 77 | password: sp, 78 | notaryAppleID: *notaryID, 79 | notaryPassword: *notaryPass, 80 | notaryTeamID: *notaryTeamID, 81 | } 82 | return bi, nil 83 | } 84 | 85 | // UppercaseName returns a string with its first rune in uppercase. 86 | func UppercaseName(name string) string { 87 | ch, w := utf8.DecodeRuneInString(name) 88 | return string(unicode.ToUpper(ch)) + name[w:] 89 | } 90 | 91 | func (s Semver) String() string { 92 | return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode) 93 | } 94 | 95 | func parseSemver(v string) (Semver, error) { 96 | var sv Semver 97 | _, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode) 98 | if err != nil || sv.String() != v { 99 | return Semver{}, fmt.Errorf("invalid semver: %q (must match major.minor.patch.versioncode)", v) 100 | } 101 | return sv, nil 102 | } 103 | 104 | func getArchs() []string { 105 | if *archNames != "" { 106 | return strings.Split(*archNames, ",") 107 | } 108 | switch *target { 109 | case "js": 110 | return []string{"wasm"} 111 | case "ios", "tvos": 112 | // Only 64-bit support. 113 | return []string{"arm64", "amd64"} 114 | case "android": 115 | return []string{"arm", "arm64", "386", "amd64"} 116 | case "windows": 117 | goarch := os.Getenv("GOARCH") 118 | if goarch == "" { 119 | goarch = runtime.GOARCH 120 | } 121 | return []string{goarch} 122 | case "macos": 123 | return []string{"arm64", "amd64"} 124 | default: 125 | // TODO: Add flag tests. 126 | panic("The target value has already been validated, this will never execute.") 127 | } 128 | } 129 | 130 | func getLdFlags(appID string) string { 131 | var ldflags []string 132 | if extra := *extraLdflags; extra != "" { 133 | ldflags = append(ldflags, strings.Split(extra, " ")...) 134 | } 135 | // Pass appID along, to be used for logging on platforms like Android. 136 | ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID)) 137 | // Support earlier Gio versions that had a separate app id recorded. 138 | // TODO: delete this in the future. 139 | ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID)) 140 | // Pass along all remaining arguments to the app. 141 | if appArgs := flag.Args()[1:]; len(appArgs) > 0 { 142 | ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|"))) 143 | } 144 | if m := *linkMode; m != "" { 145 | ldflags = append(ldflags, "-linkmode="+m) 146 | } 147 | return strings.Join(ldflags, " ") 148 | } 149 | 150 | type packageMetadata struct { 151 | PkgPath string 152 | Dir string 153 | } 154 | 155 | func getPkgMetadata(pkgPath string) (*packageMetadata, error) { 156 | pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath)) 157 | if err != nil { 158 | return nil, err 159 | } 160 | pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath)) 161 | if err != nil { 162 | return nil, err 163 | } 164 | return &packageMetadata{ 165 | PkgPath: pkgImportPath, 166 | Dir: pkgDir, 167 | }, nil 168 | } 169 | 170 | func getAppID(pkgMetadata *packageMetadata) string { 171 | if *appID != "" { 172 | return *appID 173 | } 174 | elems := strings.Split(pkgMetadata.PkgPath, "/") 175 | domain := strings.Split(elems[0], ".") 176 | name := "" 177 | if len(elems) > 1 { 178 | name = "." + elems[len(elems)-1] 179 | } 180 | if len(elems) < 2 && len(domain) < 2 { 181 | name = "." + domain[0] 182 | domain[0] = "localhost" 183 | } else { 184 | for i := range len(domain) / 2 { 185 | opp := len(domain) - 1 - i 186 | domain[i], domain[opp] = domain[opp], domain[i] 187 | } 188 | } 189 | 190 | pkgDomain := strings.Join(domain, ".") 191 | appid := []rune(pkgDomain + name) 192 | 193 | // a Java-language-style package name may contain upper- and lower-case 194 | // letters and underscores with individual parts separated by '.'. 195 | // https://developer.android.com/guide/topics/manifest/manifest-element 196 | for i, c := range appid { 197 | if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || 198 | c == '_' || c == '.') { 199 | appid[i] = '_' 200 | } 201 | } 202 | return string(appid) 203 | } 204 | 205 | func getPkgName(pkgMetadata *packageMetadata) string { 206 | return path.Base(pkgMetadata.PkgPath) 207 | } 208 | -------------------------------------------------------------------------------- /gogio/build_info_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestAppID(t *testing.T) { 6 | t.Parallel() 7 | 8 | tests := []struct { 9 | in, out string 10 | }{ 11 | {"example", "localhost.example"}, 12 | {"example.com", "com.example"}, 13 | {"www.example.com", "com.example.www"}, 14 | {"examplecom/app", "examplecom.app"}, 15 | {"example.com/app", "com.example.app"}, 16 | {"www.example.com/app", "com.example.www.app"}, 17 | {"www.en.example.com/app", "com.example.en.www.app"}, 18 | {"example.com/dir/app", "com.example.app"}, 19 | {"example.com/dir.ext/app", "com.example.app"}, 20 | {"example.com/dir/app.ext", "com.example.app.ext"}, 21 | {"example-com.net/dir/app", "net.example_com.app"}, 22 | } 23 | 24 | for i, test := range tests { 25 | got := getAppID(&packageMetadata{PkgPath: test.in}) 26 | if exp := test.out; got != exp { 27 | t.Errorf("(%d): expected '%s', got '%s'", i, exp, got) 28 | } 29 | } 30 | } 31 | 32 | func TestVersion(t *testing.T) { 33 | t.Parallel() 34 | 35 | tests := []struct { 36 | version string 37 | valid bool 38 | }{ 39 | {"v1", false}, 40 | {"v10.21.333.12", false}, 41 | {"1.2.3", false}, 42 | {"1.2.3.4", true}, 43 | } 44 | 45 | for i, test := range tests { 46 | ver, err := parseSemver(test.version) 47 | if err != nil { 48 | if test.valid { 49 | t.Errorf("(%d): %q failed to parse: %v", i, test.version, err) 50 | } 51 | continue 52 | } else if !test.valid { 53 | t.Errorf("(%d): %q was unexpectedly accepted", i, test.version) 54 | } 55 | if got := ver.String(); got != test.version { 56 | t.Errorf("(%d): %q parsed to %q", i, test.version, got) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /gogio/doc.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | /* 4 | The gogio tool builds and packages Gio programs for Android, iOS/tvOS 5 | and WebAssembly. 6 | 7 | Run gogio with no arguments for instructions, or see the examples at 8 | https://gioui.org. 9 | */ 10 | package main 11 | -------------------------------------------------------------------------------- /gogio/e2e_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "bufio" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "image" 11 | "image/color" 12 | "io" 13 | "os" 14 | "os/exec" 15 | "runtime" 16 | "strings" 17 | "testing" 18 | "time" 19 | ) 20 | 21 | var raceEnabled = false 22 | 23 | var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode") 24 | 25 | const appid = "localhost.gogio.endtoend" 26 | 27 | // TestDriver is implemented by each of the platforms we can run end-to-end 28 | // tests on. None of its methods return any errors, as the errors are directly 29 | // reported to testing.T via methods like Fatal. 30 | type TestDriver interface { 31 | initBase(t *testing.T, width, height int) 32 | 33 | // Start opens the Gio app found at path. The driver should attempt to 34 | // run the app with the base driver's width and height, and the 35 | // platform's background should be white. 36 | // 37 | // When the function returns, the gio app must be ready to use on the 38 | // platform, with its initial frame fully drawn. 39 | Start(path string) 40 | 41 | // Screenshot takes a screenshot of the Gio app on the platform. 42 | Screenshot() image.Image 43 | 44 | // Click performs a pointer click at the specified coordinates, 45 | // including both press and release. It returns when the next frame is 46 | // fully drawn. 47 | Click(x, y int) 48 | } 49 | 50 | type driverBase struct { 51 | *testing.T 52 | 53 | width, height int 54 | 55 | output io.Reader 56 | frameNotifs chan bool 57 | } 58 | 59 | func (d *driverBase) initBase(t *testing.T, width, height int) { 60 | d.T = t 61 | d.width, d.height = width, height 62 | } 63 | 64 | func TestEndToEnd(t *testing.T) { 65 | if testing.Short() { 66 | t.Skipf("end-to-end tests tend to be slow") 67 | } 68 | 69 | t.Parallel() 70 | 71 | const ( 72 | testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal" 73 | testdataWithRelativePkgPath = "internal/normal/testdata.go" 74 | customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go" 75 | ) 76 | // Keep this list local, to not reuse TestDriver objects. 77 | subtests := []struct { 78 | name string 79 | driver TestDriver 80 | pkgPath string 81 | skipGeese string 82 | }{ 83 | {"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""}, 84 | {"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""}, 85 | {"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"}, 86 | // Doesn't work on the builders. 87 | //{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath}, 88 | {"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""}, 89 | {"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""}, 90 | {"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""}, 91 | } 92 | 93 | for _, subtest := range subtests { 94 | t.Run(subtest.name, func(t *testing.T) { 95 | subtest := subtest // copy the changing loop variable 96 | if strings.Contains(subtest.skipGeese, runtime.GOOS) { 97 | t.Skipf("not supported on %s", runtime.GOOS) 98 | } 99 | t.Parallel() 100 | runEndToEndTest(t, subtest.driver, subtest.pkgPath) 101 | }) 102 | } 103 | } 104 | 105 | func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) { 106 | size := image.Point{X: 800, Y: 600} 107 | driver.initBase(t, size.X, size.Y) 108 | 109 | t.Log("starting driver and gio app") 110 | driver.Start(pkgPath) 111 | 112 | beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff} 113 | white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} 114 | black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff} 115 | gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff} 116 | red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} 117 | 118 | // These are the four colors at the beginning. 119 | t.Log("taking initial screenshot") 120 | withRetries(t, 4*time.Second, func() error { 121 | img := driver.Screenshot() 122 | size = img.Bounds().Size() // override the default size 123 | return checkImageCorners(img, beef, white, black, gray) 124 | }) 125 | 126 | // TODO(mvdan): implement this properly in the Wayland driver; swaymsg 127 | // almost works to automate clicks, but the button presses end up in the 128 | // wrong coordinates. 129 | if _, ok := driver.(*WaylandTestDriver); ok { 130 | return 131 | } 132 | 133 | // Click the first and last sections to turn them red. 134 | t.Log("clicking twice and taking another screenshot") 135 | driver.Click(1*(size.X/4), 1*(size.Y/4)) 136 | driver.Click(3*(size.X/4), 3*(size.Y/4)) 137 | withRetries(t, 4*time.Second, func() error { 138 | img := driver.Screenshot() 139 | return checkImageCorners(img, red, white, black, red) 140 | }) 141 | } 142 | 143 | // withRetries keeps retrying fn until it succeeds, or until the timeout is hit. 144 | // It uses a rudimentary kind of backoff, which starts with 100ms delays. As 145 | // such, timeout should generally be in the order of seconds. 146 | func withRetries(t *testing.T, timeout time.Duration, fn func() error) { 147 | t.Helper() 148 | 149 | timeoutTimer := time.NewTimer(timeout) 150 | defer timeoutTimer.Stop() 151 | backoff := 100 * time.Millisecond 152 | 153 | tries := 0 154 | var lastErr error 155 | for { 156 | if lastErr = fn(); lastErr == nil { 157 | return 158 | } 159 | tries++ 160 | t.Logf("retrying after %s", backoff) 161 | 162 | // Use a timer instead of a sleep, so that the timeout can stop 163 | // the backoff early. Don't reuse this timer, since we're not in 164 | // a hot loop, and we don't want tricky code. 165 | backoffTimer := time.NewTimer(backoff) 166 | defer backoffTimer.Stop() 167 | 168 | select { 169 | case <-timeoutTimer.C: 170 | t.Errorf("last error: %v", lastErr) 171 | t.Fatalf("hit timeout of %s after %d tries", timeout, tries) 172 | case <-backoffTimer.C: 173 | } 174 | 175 | // Keep doubling it until a maximum. With the start at 100ms, 176 | // we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever. 177 | backoff *= 2 178 | if max := 2 * time.Second; backoff > max { 179 | backoff = max 180 | } 181 | } 182 | } 183 | 184 | type colorMismatch struct { 185 | x, y int 186 | wantRGB, gotRGB [3]uint32 187 | } 188 | 189 | func (m colorMismatch) String() string { 190 | return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x", 191 | m.x, m.y, 192 | m.gotRGB[0], m.gotRGB[1], m.gotRGB[2], 193 | m.wantRGB[0], m.wantRGB[1], m.wantRGB[2], 194 | ) 195 | } 196 | 197 | func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error { 198 | // The colors are split in four rectangular sections. Check the corners 199 | // of each of the sections. We check the corners left to right, top to 200 | // bottom, like when reading left-to-right text. 201 | 202 | size := img.Bounds().Size() 203 | var mismatches []colorMismatch 204 | 205 | checkColor := func(x, y int, want color.Color) { 206 | r, g, b, _ := want.RGBA() 207 | got := img.At(x, y) 208 | r_, g_, b_, _ := got.RGBA() 209 | if r_ != r || g_ != g || b_ != b { 210 | mismatches = append(mismatches, colorMismatch{ 211 | x: x, 212 | y: y, 213 | wantRGB: [3]uint32{r, g, b}, 214 | gotRGB: [3]uint32{r_, g_, b_}, 215 | }) 216 | } 217 | } 218 | 219 | { 220 | minX, minY := 5, 5 221 | maxX, maxY := (size.X/2)-5, (size.Y/2)-5 222 | checkColor(minX, minY, topLeft) 223 | checkColor(maxX, minY, topLeft) 224 | checkColor(minX, maxY, topLeft) 225 | checkColor(maxX, maxY, topLeft) 226 | } 227 | { 228 | minX, minY := (size.X/2)+5, 5 229 | maxX, maxY := size.X-5, (size.Y/2)-5 230 | checkColor(minX, minY, topRight) 231 | checkColor(maxX, minY, topRight) 232 | checkColor(minX, maxY, topRight) 233 | checkColor(maxX, maxY, topRight) 234 | } 235 | { 236 | minX, minY := 5, (size.Y/2)+5 237 | maxX, maxY := (size.X/2)-5, size.Y-5 238 | checkColor(minX, minY, botLeft) 239 | checkColor(maxX, minY, botLeft) 240 | checkColor(minX, maxY, botLeft) 241 | checkColor(maxX, maxY, botLeft) 242 | } 243 | { 244 | minX, minY := (size.X/2)+5, (size.Y/2)+5 245 | maxX, maxY := size.X-5, size.Y-5 246 | checkColor(minX, minY, botRight) 247 | checkColor(maxX, minY, botRight) 248 | checkColor(minX, maxY, botRight) 249 | checkColor(maxX, maxY, botRight) 250 | } 251 | if n := len(mismatches); n > 0 { 252 | b := new(strings.Builder) 253 | fmt.Fprintf(b, "encountered %d color mismatches:\n", n) 254 | for _, m := range mismatches { 255 | fmt.Fprintf(b, "%s\n", m) 256 | } 257 | return errors.New(b.String()) 258 | } 259 | return nil 260 | } 261 | 262 | func (d *driverBase) waitForFrame() { 263 | d.Helper() 264 | 265 | if d.frameNotifs == nil { 266 | // Start the goroutine that reads output lines and notifies of 267 | // new frames via frameNotifs. The test doesn't wait for this 268 | // goroutine to finish; it will naturally end when the output 269 | // reader reaches an error like EOF. 270 | d.frameNotifs = make(chan bool, 1) 271 | if d.output == nil { 272 | d.Fatal("need an output reader to be notified of frames") 273 | } 274 | go func() { 275 | scanner := bufio.NewScanner(d.output) 276 | for scanner.Scan() { 277 | line := scanner.Text() 278 | d.Log(line) 279 | if strings.Contains(line, "gio frame ready") { 280 | d.frameNotifs <- true 281 | } 282 | } 283 | // Since we're only interested in the output while the 284 | // app runs, and we don't know when it finishes here, 285 | // ignore "already closed" pipe errors. 286 | if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) { 287 | d.Errorf("reading app output: %v", err) 288 | } 289 | }() 290 | } 291 | 292 | // Unfortunately, there isn't a way to select on a test failing, since 293 | // testing.T doesn't have anything like a context or a "done" channel. 294 | // 295 | // We can't let selects block forever, since the default -test.timeout 296 | // is ten minutes - far too long for tests that take seconds. 297 | // 298 | // For now, a static short timeout is better than nothing. 5s is plenty 299 | // for our simple test app to render on any device. 300 | select { 301 | case <-d.frameNotifs: 302 | case <-time.After(5 * time.Second): 303 | d.Fatalf("timed out waiting for a frame to be ready") 304 | } 305 | } 306 | 307 | func (d *driverBase) needPrograms(names ...string) { 308 | d.Helper() 309 | for _, name := range names { 310 | if _, err := exec.LookPath(name); err != nil { 311 | d.Skipf("%s needed to run", name) 312 | } 313 | } 314 | } 315 | 316 | func (d *driverBase) tempDir(name string) string { 317 | d.Helper() 318 | dir, err := os.MkdirTemp("", name) 319 | if err != nil { 320 | d.Fatal(err) 321 | } 322 | d.Cleanup(func() { os.RemoveAll(dir) }) 323 | return dir 324 | } 325 | 326 | func (d *driverBase) gogio(args ...string) { 327 | d.Helper() 328 | prog, err := os.Executable() 329 | if err != nil { 330 | d.Fatal(err) 331 | } 332 | cmd := exec.Command(prog, args...) 333 | cmd.Env = append(os.Environ(), "RUN_GOGIO=1") 334 | if out, err := cmd.CombinedOutput(); err != nil { 335 | d.Fatalf("gogio error: %s:\n%s", err, out) 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /gogio/help.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs. 6 | 7 | Usage: 8 | 9 | gogio -target [flags] [run arguments] 10 | 11 | The gogio tool builds and packages Gio programs for platforms where additional 12 | metadata or support files are required. 13 | 14 | The package argument specifies an import path or a single Go source file to 15 | package. Any run arguments are appended to os.Args at runtime. 16 | 17 | Compiled Java class files from jar files in the package directory are 18 | included in Android builds. 19 | 20 | The mandatory -target flag selects the target platform: ios or android for the 21 | mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for 22 | MacOS and windows for Windows. 23 | 24 | The -arch flag specifies a comma separated list of GOARCHs to include. The 25 | default is all supported architectures. 26 | 27 | The -o flag specifies an output file or directory, depending on the target. 28 | 29 | The -buildmode flag selects the build mode. Two build modes are available, exe 30 | and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file 31 | for Android or a directory with the WebAssembly module and support files for 32 | a browser. 33 | 34 | The -ldflags and -tags flags pass extra linker flags and tags to the go tool. 35 | 36 | As a special case for iOS or tvOS, specifying a path that ends with ".app" 37 | will output an app directory suitable for a simulator. 38 | 39 | The other buildmode is archive, which will output an .aar library for Android 40 | or a .framework for iOS and tvOS. 41 | 42 | The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android. 43 | If left unspecified, the appicon.png file from the main package is used 44 | (if it exists). 45 | 46 | The -appid flag specifies the package name for Android or the bundle id for 47 | iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio 48 | tool can use it. 49 | 50 | The -version flag specifies the semantic version for -buildmode exe. It must 51 | be on the form major.minor.patch.versioncode where the version code is used for 52 | the integer version number for Android, iOS and tvOS. 53 | 54 | For Android builds the -minsdk flag specify the minimum SDK level. For example, 55 | use -minsdk 22 to target Android 5.1 (Lollipop) and later. 56 | 57 | For Windows builds the -minsdk flag specify the minimum OS version. For example, 58 | use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later. 59 | 60 | For iOS builds the -minsdk flag specify the minimum iOS version. For example, 61 | use -mindk 15 to target iOS 15.0 and later. 62 | 63 | For Android builds the -targetsdk flag specify the target SDK level. For example, 64 | use -targetsdk 33 to target Android 13 (Tiramisu) and later. 65 | 66 | The -work flag prints the path to the working directory and suppress 67 | its deletion. 68 | 69 | The -x flag will print all the external commands executed by the gogio tool. 70 | 71 | The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files 72 | or specifies the name of key on Keychain to sign MacOS app. 73 | 74 | The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided. 75 | If -signpass is not sepecified it will be read from the environment variable GOGIO_SIGNPASS. 76 | 77 | The -notaryid flag specifies the Apple ID to use for notarization of MacOS app. 78 | 79 | The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not 80 | provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397 81 | for details. If not provided, the password will be prompted. 82 | 83 | The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if 84 | -notaryid is not provided. 85 | ` 86 | -------------------------------------------------------------------------------- /gogio/internal/custom/testdata.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build linux 4 | // +build linux 5 | 6 | // This program demonstrates the use of a custom OpenGL ES context with 7 | // app.Window. 8 | package main 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "image" 14 | "image/color" 15 | "log" 16 | "os" 17 | "runtime" 18 | "strings" 19 | "unsafe" 20 | 21 | "gioui.org/app" 22 | "gioui.org/gpu" 23 | "gioui.org/io/event" 24 | "gioui.org/io/pointer" 25 | "gioui.org/layout" 26 | "gioui.org/op" 27 | "gioui.org/op/clip" 28 | "gioui.org/op/paint" 29 | ) 30 | 31 | /* 32 | #cgo linux pkg-config: egl wayland-egl 33 | #cgo freebsd openbsd CFLAGS: -I/usr/local/include 34 | #cgo openbsd CFLAGS: -I/usr/X11R6/include 35 | #cgo freebsd LDFLAGS: -L/usr/local/lib 36 | #cgo openbsd LDFLAGS: -L/usr/X11R6/lib 37 | #cgo freebsd openbsd LDFLAGS: -lwayland-egl 38 | #cgo CFLAGS: -DEGL_NO_X11 39 | #cgo LDFLAGS: -lEGL -lGLESv2 40 | 41 | #include 42 | #include 43 | #include 44 | #include 45 | #define EGL_EGLEXT_PROTOTYPES 46 | #include 47 | 48 | */ 49 | import "C" 50 | 51 | func getDisplay(ve app.ViewEvent) C.EGLDisplay { 52 | switch ve := ve.(type) { 53 | case app.X11ViewEvent: 54 | return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display)) 55 | case app.WaylandViewEvent: 56 | return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display)) 57 | } 58 | panic("no display available") 59 | } 60 | 61 | func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) { 62 | switch e := e.(type) { 63 | case app.X11ViewEvent: 64 | return C.EGLNativeWindowType(uintptr(e.Window)), func() {} 65 | case app.WaylandViewEvent: 66 | eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y)) 67 | return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() { 68 | C.wl_egl_window_destroy(eglWin) 69 | } 70 | } 71 | panic("no native view available") 72 | } 73 | 74 | type ( 75 | C = layout.Context 76 | D = layout.Dimensions 77 | ) 78 | 79 | type notifyFrame int 80 | 81 | const ( 82 | notifyNone notifyFrame = iota 83 | notifyInvalidate 84 | notifyPrint 85 | ) 86 | 87 | // notify keeps track of whether we want to print to stdout to notify the user 88 | // when a frame is ready. Initially we want to notify about the first frame. 89 | var notify = notifyInvalidate 90 | 91 | type eglContext struct { 92 | disp C.EGLDisplay 93 | ctx C.EGLContext 94 | surf C.EGLSurface 95 | cleanup func() 96 | } 97 | 98 | func main() { 99 | go func() { 100 | // Set CustomRenderer so we can provide our own rendering context. 101 | w := new(app.Window) 102 | w.Option(app.CustomRenderer(true)) 103 | if err := loop(w); err != nil { 104 | log.Fatal(err) 105 | } 106 | os.Exit(0) 107 | }() 108 | app.Main() 109 | } 110 | 111 | func loop(w *app.Window) error { 112 | var ops op.Ops 113 | var ( 114 | ctx *eglContext 115 | gioCtx gpu.GPU 116 | ve app.ViewEvent 117 | init bool 118 | size image.Point 119 | ) 120 | 121 | recreateContext := func() { 122 | w.Run(func() { 123 | if gioCtx != nil { 124 | gioCtx.Release() 125 | gioCtx = nil 126 | } 127 | if ctx != nil { 128 | C.eglMakeCurrent(ctx.disp, nil, nil, nil) 129 | ctx.Release() 130 | ctx = nil 131 | } 132 | c, err := createContext(ve, size) 133 | if err != nil { 134 | log.Fatal(err) 135 | } 136 | ctx = c 137 | }) 138 | if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE { 139 | err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError()) 140 | log.Fatal(err) 141 | } 142 | glGetString := func(e C.GLenum) string { 143 | return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e)))) 144 | } 145 | fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER)) 146 | var err error 147 | gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true}) 148 | if err != nil { 149 | log.Fatal(err) 150 | } 151 | } 152 | 153 | topLeft := quarterWidget{ 154 | color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}, 155 | } 156 | topRight := quarterWidget{ 157 | color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, 158 | } 159 | botLeft := quarterWidget{ 160 | color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}, 161 | } 162 | botRight := quarterWidget{ 163 | color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80}, 164 | } 165 | 166 | // eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread. 167 | runtime.LockOSThread() 168 | for { 169 | switch e := w.Event().(type) { 170 | case app.ViewEvent: 171 | ve = e 172 | init = true 173 | if size != (image.Point{}) { 174 | recreateContext() 175 | } 176 | case app.DestroyEvent: 177 | return e.Err 178 | case app.FrameEvent: 179 | if init && size != e.Size { 180 | size = e.Size 181 | recreateContext() 182 | } 183 | if gioCtx == nil || !init { 184 | break 185 | } 186 | // Build ops. 187 | gtx := app.NewContext(&ops, e) 188 | 189 | // Clear background to white, even on embedded platforms such as webassembly. 190 | paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff}) 191 | layout.Flex{Axis: layout.Vertical}.Layout(gtx, 192 | layout.Flexed(1, func(gtx C) D { 193 | return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 194 | // r1c1 195 | layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }), 196 | // r1c2 197 | layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }), 198 | ) 199 | }), 200 | layout.Flexed(1, func(gtx C) D { 201 | return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 202 | // r2c1 203 | layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }), 204 | // r2c2 205 | layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }), 206 | ) 207 | }), 208 | ) 209 | gtx.Execute(op.InvalidateCmd{}) 210 | log.Println("frame") 211 | 212 | // Trigger window resize detection in ANGLE. 213 | C.eglWaitClient() 214 | // Draw custom OpenGL content. 215 | drawGL() 216 | 217 | // Render drawing ops. 218 | if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil { 219 | log.Fatal(fmt.Errorf("render failed: %v", err)) 220 | } 221 | 222 | // Process non-drawing ops. 223 | e.Frame(gtx.Ops) 224 | switch notify { 225 | case notifyInvalidate: 226 | notify = notifyPrint 227 | w.Invalidate() 228 | case notifyPrint: 229 | notify = notifyNone 230 | fmt.Println("gio frame ready") 231 | } 232 | 233 | if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE { 234 | log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError())) 235 | } 236 | 237 | } 238 | } 239 | return nil 240 | } 241 | 242 | func drawGL() { 243 | C.glClearColor(0, 0, 0, 1) 244 | C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT) 245 | } 246 | 247 | func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) { 248 | view, cleanup := nativeViewFor(ve, size) 249 | var nilv C.EGLNativeWindowType 250 | if view == nilv { 251 | return nil, fmt.Errorf("failed creating native view") 252 | } 253 | disp := getDisplay(ve) 254 | if disp == 0 { 255 | return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError()) 256 | } 257 | var major, minor C.EGLint 258 | if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE { 259 | return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError()) 260 | } 261 | exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ") 262 | srgb := hasExtension(exts, "EGL_KHR_gl_colorspace") 263 | attribs := []C.EGLint{ 264 | C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT, 265 | C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT, 266 | C.EGL_BLUE_SIZE, 8, 267 | C.EGL_GREEN_SIZE, 8, 268 | C.EGL_RED_SIZE, 8, 269 | C.EGL_CONFIG_CAVEAT, C.EGL_NONE, 270 | } 271 | if srgb { 272 | // Some drivers need alpha for sRGB framebuffers to work. 273 | attribs = append(attribs, C.EGL_ALPHA_SIZE, 8) 274 | } 275 | attribs = append(attribs, C.EGL_NONE) 276 | var ( 277 | cfg C.EGLConfig 278 | numCfgs C.EGLint 279 | ) 280 | if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE { 281 | return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError()) 282 | } 283 | if numCfgs == 0 { 284 | supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context") 285 | if !supportsNoCfg { 286 | return nil, errors.New("eglChooseConfig returned no configs") 287 | } 288 | } 289 | ctxAttribs := []C.EGLint{ 290 | C.EGL_CONTEXT_CLIENT_VERSION, 3, 291 | C.EGL_NONE, 292 | } 293 | ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0]) 294 | if ctx == nil { 295 | return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError()) 296 | } 297 | var surfAttribs []C.EGLint 298 | if srgb { 299 | surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB) 300 | } 301 | surfAttribs = append(surfAttribs, C.EGL_NONE) 302 | surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0]) 303 | if surf == nil { 304 | return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError()) 305 | } 306 | return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil 307 | } 308 | 309 | func (c *eglContext) Release() { 310 | if c.ctx != nil { 311 | C.eglDestroyContext(c.disp, c.ctx) 312 | } 313 | if c.surf != nil { 314 | C.eglDestroySurface(c.disp, c.surf) 315 | } 316 | if c.cleanup != nil { 317 | c.cleanup() 318 | } 319 | *c = eglContext{} 320 | } 321 | 322 | func hasExtension(exts []string, ext string) bool { 323 | for _, e := range exts { 324 | if ext == e { 325 | return true 326 | } 327 | } 328 | return false 329 | } 330 | 331 | // quarterWidget paints a quarter of the screen with one color. When clicked, it 332 | // turns red, going back to its normal color when clicked again. 333 | type quarterWidget struct { 334 | color color.NRGBA 335 | 336 | clicked bool 337 | } 338 | 339 | var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} 340 | 341 | func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions { 342 | var color color.NRGBA 343 | if w.clicked { 344 | color = red 345 | } else { 346 | color = w.color 347 | } 348 | 349 | r := image.Rectangle{Max: gtx.Constraints.Max} 350 | paint.FillShape(gtx.Ops, color, clip.Rect(r).Op()) 351 | 352 | defer clip.Rect(image.Rectangle{ 353 | Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y), 354 | }).Push(gtx.Ops).Pop() 355 | event.Op(gtx.Ops, w) 356 | for { 357 | e, ok := gtx.Event(pointer.Filter{ 358 | Target: w, 359 | Kinds: pointer.Press, 360 | }) 361 | if !ok { 362 | break 363 | } 364 | if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press { 365 | w.clicked = !w.clicked 366 | // notify when we're done updating the frame. 367 | notify = notifyInvalidate 368 | } 369 | } 370 | return layout.Dimensions{Size: gtx.Constraints.Max} 371 | } 372 | -------------------------------------------------------------------------------- /gogio/internal/normal/testdata.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | // A simple app used for gogio's end-to-end tests. 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "image" 9 | "image/color" 10 | "log" 11 | 12 | "gioui.org/app" 13 | "gioui.org/io/event" 14 | "gioui.org/io/pointer" 15 | "gioui.org/layout" 16 | "gioui.org/op" 17 | "gioui.org/op/clip" 18 | "gioui.org/op/paint" 19 | ) 20 | 21 | func main() { 22 | go func() { 23 | w := new(app.Window) 24 | if err := loop(w); err != nil { 25 | log.Fatal(err) 26 | } 27 | }() 28 | app.Main() 29 | } 30 | 31 | type notifyFrame int 32 | 33 | const ( 34 | notifyNone notifyFrame = iota 35 | notifyInvalidate 36 | notifyPrint 37 | ) 38 | 39 | // notify keeps track of whether we want to print to stdout to notify the user 40 | // when a frame is ready. Initially we want to notify about the first frame. 41 | var notify = notifyInvalidate 42 | 43 | type ( 44 | C = layout.Context 45 | D = layout.Dimensions 46 | ) 47 | 48 | func loop(w *app.Window) error { 49 | topLeft := quarterWidget{ 50 | color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}, 51 | } 52 | topRight := quarterWidget{ 53 | color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, 54 | } 55 | botLeft := quarterWidget{ 56 | color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}, 57 | } 58 | botRight := quarterWidget{ 59 | color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80}, 60 | } 61 | 62 | var ops op.Ops 63 | for { 64 | e := w.Event() 65 | switch e := e.(type) { 66 | case app.DestroyEvent: 67 | return e.Err 68 | case app.FrameEvent: 69 | gtx := app.NewContext(&ops, e) 70 | // Clear background to white, even on embedded platforms such as webassembly. 71 | paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff}) 72 | layout.Flex{Axis: layout.Vertical}.Layout(gtx, 73 | layout.Flexed(1, func(gtx C) D { 74 | return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 75 | // r1c1 76 | layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }), 77 | // r1c2 78 | layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }), 79 | ) 80 | }), 81 | layout.Flexed(1, func(gtx C) D { 82 | return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, 83 | // r2c1 84 | layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }), 85 | // r2c2 86 | layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }), 87 | ) 88 | }), 89 | ) 90 | 91 | e.Frame(gtx.Ops) 92 | 93 | switch notify { 94 | case notifyInvalidate: 95 | notify = notifyPrint 96 | w.Invalidate() 97 | case notifyPrint: 98 | notify = notifyNone 99 | fmt.Println("gio frame ready") 100 | } 101 | } 102 | } 103 | } 104 | 105 | // quarterWidget paints a quarter of the screen with one color. When clicked, it 106 | // turns red, going back to its normal color when clicked again. 107 | type quarterWidget struct { 108 | color color.NRGBA 109 | 110 | clicked bool 111 | } 112 | 113 | var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} 114 | 115 | func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions { 116 | var color color.NRGBA 117 | if w.clicked { 118 | color = red 119 | } else { 120 | color = w.color 121 | } 122 | 123 | r := image.Rectangle{Max: gtx.Constraints.Max} 124 | paint.FillShape(gtx.Ops, color, clip.Rect(r).Op()) 125 | 126 | defer clip.Rect(image.Rectangle{ 127 | Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y), 128 | }).Push(gtx.Ops).Pop() 129 | event.Op(gtx.Ops, w) 130 | filter := pointer.Filter{ 131 | Target: w, 132 | Kinds: pointer.Press, 133 | } 134 | 135 | for { 136 | e, ok := gtx.Event(filter) 137 | if !ok { 138 | break 139 | } 140 | if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press { 141 | w.clicked = !w.clicked 142 | // notify when we're done updating the frame. 143 | notify = notifyInvalidate 144 | } 145 | } 146 | return layout.Dimensions{Size: gtx.Constraints.Max} 147 | } 148 | -------------------------------------------------------------------------------- /gogio/iosbuild.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "archive/zip" 7 | "crypto/sha1" 8 | "encoding/hex" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "slices" 16 | "strconv" 17 | "strings" 18 | "time" 19 | 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | const ( 24 | minIOSVersion = 10 25 | // Some Metal features require tvOS 11 26 | minTVOSVersion = 11 27 | // Metal is available from iOS 8 on devices, yet from version 13 on the 28 | // simulator. 29 | minSimulatorVersion = 13 30 | ) 31 | 32 | func buildIOS(tmpDir, target string, bi *buildInfo) error { 33 | appName := bi.name 34 | switch *buildMode { 35 | case "archive": 36 | framework := *destPath 37 | if framework == "" { 38 | framework = fmt.Sprintf("%s.framework", UppercaseName(appName)) 39 | } 40 | return archiveIOS(tmpDir, target, framework, bi) 41 | case "exe": 42 | out := *destPath 43 | if out == "" { 44 | out = appName + ".ipa" 45 | } 46 | forDevice := strings.HasSuffix(out, ".ipa") 47 | // Filter out unsupported architectures. 48 | for i := len(bi.archs) - 1; i >= 0; i-- { 49 | switch bi.archs[i] { 50 | case "arm", "arm64": 51 | if forDevice { 52 | continue 53 | } 54 | case "386", "amd64": 55 | if !forDevice { 56 | continue 57 | } 58 | } 59 | 60 | bi.archs = slices.Delete(bi.archs, i, i+1) 61 | } 62 | if !forDevice && !strings.HasSuffix(out, ".app") { 63 | return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out) 64 | } 65 | if !forDevice { 66 | return exeIOS(tmpDir, target, out, bi) 67 | } 68 | payload := filepath.Join(tmpDir, "Payload") 69 | appDir := filepath.Join(payload, appName+".app") 70 | if err := os.MkdirAll(appDir, 0o755); err != nil { 71 | return err 72 | } 73 | if err := exeIOS(tmpDir, target, appDir, bi); err != nil { 74 | return err 75 | } 76 | if err := signIOS(bi, tmpDir, appDir); err != nil { 77 | return err 78 | } 79 | return zipDir(out, tmpDir, "Payload") 80 | default: 81 | panic("unreachable") 82 | } 83 | } 84 | 85 | func signIOS(bi *buildInfo, tmpDir, app string) error { 86 | home, err := os.UserHomeDir() 87 | if err != nil { 88 | return err 89 | } 90 | provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision") 91 | provisions, err := filepath.Glob(provPattern) 92 | if err != nil { 93 | return err 94 | } 95 | provInfo := filepath.Join(tmpDir, "provision.plist") 96 | var avail []string 97 | for _, prov := range provisions { 98 | // Decode the provision file to a plist. 99 | _, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo)) 100 | if err != nil { 101 | return err 102 | } 103 | expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo)) 104 | if err != nil { 105 | return err 106 | } 107 | exp, err := time.Parse(time.UnixDate, expUnix) 108 | if err != nil { 109 | return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err) 110 | } 111 | if exp.Before(time.Now()) { 112 | continue 113 | } 114 | appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo)) 115 | if err != nil { 116 | return err 117 | } 118 | provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo)) 119 | if err != nil { 120 | return err 121 | } 122 | expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID) 123 | avail = append(avail, provAppID) 124 | if expAppID != provAppID { 125 | continue 126 | } 127 | // Copy provisioning file. 128 | embedded := filepath.Join(app, "embedded.mobileprovision") 129 | if err := copyFile(embedded, prov); err != nil { 130 | return err 131 | } 132 | certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo)) 133 | if err != nil { 134 | return err 135 | } 136 | // Omit trailing newline. 137 | certDER = certDER[:len(certDER)-1] 138 | entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo)) 139 | if err != nil { 140 | return err 141 | } 142 | entFile := filepath.Join(tmpDir, "entitlements.plist") 143 | if err := os.WriteFile(entFile, []byte(entitlements), 0o660); err != nil { 144 | return err 145 | } 146 | identity := sha1.Sum(certDER) 147 | idHex := hex.EncodeToString(identity[:]) 148 | _, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app)) 149 | return err 150 | } 151 | return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail) 152 | } 153 | 154 | func exeIOS(tmpDir, target, app string, bi *buildInfo) error { 155 | if bi.appID == "" { 156 | return errors.New("app id is empty; use -appid to set it") 157 | } 158 | if err := os.RemoveAll(app); err != nil { 159 | return err 160 | } 161 | if err := os.Mkdir(app, 0o755); err != nil { 162 | return err 163 | } 164 | appName := UppercaseName(bi.name) 165 | exe := filepath.Join(app, appName) 166 | lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") 167 | var builds errgroup.Group 168 | for _, a := range bi.archs { 169 | clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) 170 | if err != nil { 171 | return err 172 | } 173 | cflags = append(cflags, 174 | "-fobjc-arc", 175 | ) 176 | cflagsLine := strings.Join(cflags, " ") 177 | exeSlice := filepath.Join(tmpDir, "app-"+a) 178 | lipo.Args = append(lipo.Args, exeSlice) 179 | compile := exec.Command( 180 | "go", 181 | "build", 182 | "-ldflags=-s -w "+bi.ldflags, 183 | "-o", exeSlice, 184 | "-tags", bi.tags, 185 | bi.pkgPath, 186 | ) 187 | compile.Env = append( 188 | os.Environ(), 189 | "GOOS=ios", 190 | "GOARCH="+a, 191 | "CGO_ENABLED=1", 192 | "CC="+clang, 193 | "CGO_CFLAGS="+cflagsLine, 194 | "CGO_LDFLAGS=-lresolv "+cflagsLine, 195 | ) 196 | builds.Go(func() error { 197 | _, err := runCmd(compile) 198 | return err 199 | }) 200 | } 201 | if err := builds.Wait(); err != nil { 202 | return err 203 | } 204 | if _, err := runCmd(lipo); err != nil { 205 | return err 206 | } 207 | infoPlist := buildInfoPlist(bi) 208 | plistFile := filepath.Join(app, "Info.plist") 209 | if err := os.WriteFile(plistFile, []byte(infoPlist), 0o660); err != nil { 210 | return err 211 | } 212 | if _, err := os.Stat(bi.iconPath); err == nil { 213 | assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath) 214 | if err != nil { 215 | return err 216 | } 217 | // Merge assets plist with Info.plist 218 | cmd := exec.Command( 219 | "/usr/libexec/PlistBuddy", 220 | "-c", "Merge "+assetPlist, 221 | plistFile, 222 | ) 223 | if _, err := runCmd(cmd); err != nil { 224 | return err 225 | } 226 | } 227 | if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil { 228 | return err 229 | } 230 | return nil 231 | } 232 | 233 | // iosIcons builds an asset catalog and compile it with the Xcode command actool. 234 | // iosIcons returns the asset plist file to be merged into Info.plist. 235 | func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) { 236 | assets := filepath.Join(tmpDir, "Assets.xcassets") 237 | if err := os.Mkdir(assets, 0o700); err != nil { 238 | return "", err 239 | } 240 | appIcon := filepath.Join(assets, "AppIcon.appiconset") 241 | err := buildIcons(appIcon, icon, []iconVariant{ 242 | {path: "ios_2x.png", size: 120}, 243 | {path: "ios_3x.png", size: 180}, 244 | // The App Store icon is not allowed to contain 245 | // transparent pixels. 246 | {path: "ios_store.png", size: 1024, fill: true}, 247 | }) 248 | if err != nil { 249 | return "", err 250 | } 251 | contentJson := `{ 252 | "images" : [ 253 | { 254 | "size" : "60x60", 255 | "idiom" : "iphone", 256 | "filename" : "ios_2x.png", 257 | "scale" : "2x" 258 | }, 259 | { 260 | "size" : "60x60", 261 | "idiom" : "iphone", 262 | "filename" : "ios_3x.png", 263 | "scale" : "3x" 264 | }, 265 | { 266 | "size" : "1024x1024", 267 | "idiom" : "ios-marketing", 268 | "filename" : "ios_store.png", 269 | "scale" : "1x" 270 | } 271 | ] 272 | }` 273 | contentFile := filepath.Join(appIcon, "Contents.json") 274 | if err := os.WriteFile(contentFile, []byte(contentJson), 0o600); err != nil { 275 | return "", err 276 | } 277 | assetPlist := filepath.Join(tmpDir, "assets.plist") 278 | 279 | minsdk := bi.minsdk 280 | if minsdk == 0 { 281 | minsdk = minIOSVersion 282 | } 283 | compile := exec.Command( 284 | "actool", 285 | "--compile", appDir, 286 | "--platform", iosPlatformFor(bi.target), 287 | "--minimum-deployment-target", strconv.Itoa(minsdk), 288 | "--app-icon", "AppIcon", 289 | "--output-partial-info-plist", assetPlist, 290 | assets) 291 | _, err = runCmd(compile) 292 | return assetPlist, err 293 | } 294 | 295 | func buildInfoPlist(bi *buildInfo) string { 296 | appName := UppercaseName(bi.name) 297 | platform := iosPlatformFor(bi.target) 298 | var supportPlatform string 299 | switch bi.target { 300 | case "ios": 301 | supportPlatform = "iPhoneOS" 302 | case "tvos": 303 | supportPlatform = "AppleTVOS" 304 | } 305 | return fmt.Sprintf(` 306 | 307 | 308 | 309 | CFBundleDevelopmentRegion 310 | en 311 | CFBundleExecutable 312 | %s 313 | CFBundleIdentifier 314 | %s 315 | CFBundleInfoDictionaryVersion 316 | 6.0 317 | CFBundleName 318 | %s 319 | CFBundlePackageType 320 | APPL 321 | CFBundleShortVersionString 322 | %s 323 | CFBundleVersion 324 | %d 325 | UILaunchStoryboardName 326 | LaunchScreen 327 | UIRequiredDeviceCapabilities 328 | arm64 329 | DTPlatformName 330 | %s 331 | DTPlatformVersion 332 | 12.4 333 | MinimumOSVersion 334 | %d 335 | UIDeviceFamily 336 | 337 | 1 338 | 2 339 | 340 | CFBundleSupportedPlatforms 341 | 342 | %s 343 | 344 | UISupportedInterfaceOrientations 345 | 346 | UIInterfaceOrientationPortrait 347 | UIInterfaceOrientationLandscapeLeft 348 | UIInterfaceOrientationLandscapeRight 349 | 350 | DTCompiler 351 | com.apple.compilers.llvm.clang.1_0 352 | DTPlatformBuild 353 | 16G73 354 | DTSDKBuild 355 | 16G73 356 | DTSDKName 357 | %s12.4 358 | DTXcode 359 | 1030 360 | DTXcodeBuild 361 | 10G8 362 | 363 | `, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform) 364 | } 365 | 366 | func iosPlatformFor(target string) string { 367 | switch target { 368 | case "ios": 369 | return "iphoneos" 370 | case "tvos": 371 | return "appletvos" 372 | default: 373 | panic("invalid platform " + target) 374 | } 375 | } 376 | 377 | func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error { 378 | framework := filepath.Base(frameworkRoot) 379 | const suf = ".framework" 380 | if !strings.HasSuffix(framework, suf) { 381 | return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot) 382 | } 383 | framework = framework[:len(framework)-len(suf)] 384 | if err := os.RemoveAll(frameworkRoot); err != nil { 385 | return err 386 | } 387 | frameworkDir := filepath.Join(frameworkRoot, "Versions", "A") 388 | for _, dir := range []string{"Headers", "Modules"} { 389 | p := filepath.Join(frameworkDir, dir) 390 | if err := os.MkdirAll(p, 0o755); err != nil { 391 | return err 392 | } 393 | } 394 | symlinks := [][2]string{ 395 | {"Versions/Current/Headers", "Headers"}, 396 | {"Versions/Current/Modules", "Modules"}, 397 | {"Versions/Current/" + framework, framework}, 398 | {"A", filepath.Join("Versions", "Current")}, 399 | } 400 | for _, l := range symlinks { 401 | if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) { 402 | return err 403 | } 404 | } 405 | exe := filepath.Join(frameworkDir, framework) 406 | lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create") 407 | var builds errgroup.Group 408 | tags := bi.tags 409 | for _, a := range bi.archs { 410 | clang, cflags, err := iosCompilerFor(target, a, bi.minsdk) 411 | if err != nil { 412 | return err 413 | } 414 | lib := filepath.Join(tmpDir, "gio-"+a) 415 | cmd := exec.Command( 416 | "go", 417 | "build", 418 | "-ldflags=-s -w "+bi.ldflags, 419 | "-buildmode=c-archive", 420 | "-o", lib, 421 | "-tags", tags, 422 | bi.pkgPath, 423 | ) 424 | lipo.Args = append(lipo.Args, lib) 425 | cflagsLine := strings.Join(cflags, " ") 426 | cmd.Env = append( 427 | os.Environ(), 428 | "GOOS=ios", 429 | "GOARCH="+a, 430 | "CGO_ENABLED=1", 431 | "CC="+clang, 432 | "CGO_CFLAGS="+cflagsLine, 433 | "CGO_LDFLAGS="+cflagsLine, 434 | ) 435 | builds.Go(func() error { 436 | _, err := runCmd(cmd) 437 | return err 438 | }) 439 | } 440 | if err := builds.Wait(); err != nil { 441 | return err 442 | } 443 | if _, err := runCmd(lipo); err != nil { 444 | return err 445 | } 446 | appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/")) 447 | if err != nil { 448 | return err 449 | } 450 | headerDst := filepath.Join(frameworkDir, "Headers", framework+".h") 451 | headerSrc := filepath.Join(appDir, "framework_ios.h") 452 | if err := copyFile(headerDst, headerSrc); err != nil { 453 | return err 454 | } 455 | module := fmt.Sprintf(`framework module "%s" { 456 | header "%[1]s.h" 457 | 458 | export * 459 | }`, framework) 460 | moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap") 461 | return os.WriteFile(moduleFile, []byte(module), 0o644) 462 | } 463 | 464 | func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) { 465 | var ( 466 | platformSDK string 467 | platformOS string 468 | ) 469 | switch target { 470 | case "ios": 471 | platformOS = "ios" 472 | platformSDK = "iphone" 473 | case "tvos": 474 | platformOS = "tvos" 475 | platformSDK = "appletv" 476 | } 477 | switch arch { 478 | case "arm", "arm64": 479 | platformSDK += "os" 480 | if minsdk == 0 { 481 | minsdk = minIOSVersion 482 | if target == "tvos" { 483 | minsdk = minTVOSVersion 484 | } 485 | } 486 | case "386", "amd64": 487 | platformOS += "-simulator" 488 | platformSDK += "simulator" 489 | if minsdk == 0 { 490 | minsdk = minSimulatorVersion 491 | } 492 | default: 493 | return "", nil, fmt.Errorf("unsupported -arch: %s", arch) 494 | } 495 | sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path")) 496 | if err != nil { 497 | return "", nil, err 498 | } 499 | clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang")) 500 | if err != nil { 501 | return "", nil, err 502 | } 503 | cflags := []string{ 504 | "-fembed-bitcode", 505 | "-arch", allArchs[arch].iosArch, 506 | "-isysroot", sdkPath, 507 | "-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk), 508 | } 509 | return clang, cflags, nil 510 | } 511 | 512 | func zipDir(dst, base, dir string) (err error) { 513 | f, err := os.Create(dst) 514 | if err != nil { 515 | return err 516 | } 517 | defer func() { 518 | if cerr := f.Close(); err == nil { 519 | err = cerr 520 | } 521 | }() 522 | zipf := zip.NewWriter(f) 523 | err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error { 524 | if err != nil { 525 | return err 526 | } 527 | if f.IsDir() { 528 | return nil 529 | } 530 | rel := filepath.ToSlash(path[len(base)+1:]) 531 | entry, err := zipf.Create(rel) 532 | if err != nil { 533 | return err 534 | } 535 | src, err := os.Open(path) 536 | if err != nil { 537 | return err 538 | } 539 | defer src.Close() 540 | _, err = io.Copy(entry, src) 541 | return err 542 | }) 543 | if err != nil { 544 | return err 545 | } 546 | return zipf.Close() 547 | } 548 | -------------------------------------------------------------------------------- /gogio/js_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "errors" 9 | "image" 10 | "image/png" 11 | "io" 12 | "net/http" 13 | "net/http/httptest" 14 | "os/exec" 15 | 16 | "github.com/chromedp/cdproto/runtime" 17 | "github.com/chromedp/chromedp" 18 | 19 | _ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there 20 | ) 21 | 22 | type JSTestDriver struct { 23 | driverBase 24 | 25 | // ctx is the chromedp context. 26 | ctx context.Context 27 | } 28 | 29 | func (d *JSTestDriver) Start(path string) { 30 | if raceEnabled { 31 | d.Skipf("js/wasm doesn't support -race; skipping") 32 | } 33 | d.Skipf("test fails with \"timed out waiting for a frame to be ready\"") 34 | 35 | // First, build the app. 36 | dir := d.tempDir("gio-endtoend-js") 37 | d.gogio("-target=js", "-o="+dir, path) 38 | 39 | // Second, start Chrome. 40 | opts := append(chromedp.DefaultExecAllocatorOptions[:], 41 | chromedp.Flag("headless", *headless), 42 | ) 43 | 44 | actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 45 | d.Cleanup(cancel) 46 | 47 | ctx, cancel := chromedp.NewContext(actx, 48 | // Send all logf/errf calls to t.Logf 49 | chromedp.WithLogf(d.Logf), 50 | ) 51 | d.Cleanup(cancel) 52 | d.ctx = ctx 53 | 54 | if err := chromedp.Run(ctx); err != nil { 55 | if errors.Is(err, exec.ErrNotFound) { 56 | d.Skipf("test requires Chrome to be installed: %v", err) 57 | return 58 | } 59 | d.Fatal(err) 60 | } 61 | pr, pw := io.Pipe() 62 | d.Cleanup(func() { pw.Close() }) 63 | d.output = pr 64 | chromedp.ListenTarget(ctx, func(ev any) { 65 | switch ev := ev.(type) { 66 | case *runtime.EventConsoleAPICalled: 67 | switch ev.Type { 68 | case "log", "info", "warning", "error": 69 | var b bytes.Buffer 70 | b.WriteString("console.") 71 | b.WriteString(string(ev.Type)) 72 | b.WriteString("(") 73 | for i, arg := range ev.Args { 74 | if i > 0 { 75 | b.WriteString(", ") 76 | } 77 | b.Write(arg.Value) 78 | } 79 | b.WriteString(")\n") 80 | pw.Write(b.Bytes()) 81 | } 82 | } 83 | }) 84 | 85 | // Third, serve the app folder, set the browser tab dimensions, and 86 | // navigate to the folder. 87 | ts := httptest.NewServer(http.FileServer(http.Dir(dir))) 88 | d.Cleanup(ts.Close) 89 | 90 | if err := chromedp.Run(ctx, 91 | chromedp.EmulateViewport(int64(d.width), int64(d.height)), 92 | chromedp.Navigate(ts.URL), 93 | ); err != nil { 94 | d.Fatal(err) 95 | } 96 | 97 | // Wait for the gio app to render. 98 | d.waitForFrame() 99 | } 100 | 101 | func (d *JSTestDriver) Screenshot() image.Image { 102 | var buf []byte 103 | if err := chromedp.Run(d.ctx, 104 | chromedp.CaptureScreenshot(&buf), 105 | ); err != nil { 106 | d.Fatal(err) 107 | } 108 | img, err := png.Decode(bytes.NewReader(buf)) 109 | if err != nil { 110 | d.Fatal(err) 111 | } 112 | return img 113 | } 114 | 115 | func (d *JSTestDriver) Click(x, y int) { 116 | if err := chromedp.Run(d.ctx, 117 | chromedp.MouseClickXY(float64(x), float64(y)), 118 | ); err != nil { 119 | d.Fatal(err) 120 | } 121 | 122 | // Wait for the gio app to render after this click. 123 | d.waitForFrame() 124 | } 125 | -------------------------------------------------------------------------------- /gogio/jsbuild.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "text/template" 14 | 15 | "golang.org/x/tools/go/packages" 16 | ) 17 | 18 | func buildJS(bi *buildInfo) error { 19 | out := *destPath 20 | if out == "" { 21 | out = bi.name 22 | } 23 | if err := os.MkdirAll(out, 0o700); err != nil { 24 | return err 25 | } 26 | cmd := exec.Command( 27 | "go", 28 | "build", 29 | "-ldflags="+bi.ldflags, 30 | "-tags="+bi.tags, 31 | "-o", filepath.Join(out, "main.wasm"), 32 | bi.pkgPath, 33 | ) 34 | cmd.Env = append( 35 | os.Environ(), 36 | "GOOS=js", 37 | "GOARCH=wasm", 38 | ) 39 | _, err := runCmd(cmd) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var faviconPath string 45 | if _, err := os.Stat(bi.iconPath); err == nil { 46 | // Copy icon to the output folder 47 | icon, err := os.ReadFile(bi.iconPath) 48 | if err != nil { 49 | return err 50 | } 51 | if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0o600); err != nil { 52 | return err 53 | } 54 | faviconPath = filepath.Base(bi.iconPath) 55 | } 56 | 57 | indexTemplate, err := template.New("").Parse(jsIndex) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | var b bytes.Buffer 63 | if err := indexTemplate.Execute(&b, struct { 64 | Name string 65 | Icon string 66 | }{ 67 | Name: bi.name, 68 | Icon: faviconPath, 69 | }); err != nil { 70 | return err 71 | } 72 | 73 | if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0o600); err != nil { 74 | return err 75 | } 76 | 77 | goroot, err := runCmd(exec.Command("go", "env", "GOROOT")) 78 | if err != nil { 79 | return err 80 | } 81 | // Location of the wasm_exec.js for go>=1.24 82 | wasmJS := filepath.Join(goroot, "lib", "wasm", "wasm_exec.js") 83 | if _, err := os.Stat(wasmJS); err != nil { 84 | // Location of the wasm_exec.js for go<1.24 85 | wasmJS = filepath.Join(goroot, "misc", "wasm", "wasm_exec.js") 86 | if _, err := os.Stat(wasmJS); err != nil { 87 | return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err) 88 | } 89 | } 90 | pkgs, err := packages.Load(&packages.Config{ 91 | Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps, 92 | Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"), 93 | }, bi.pkgPath) 94 | if err != nil { 95 | return err 96 | } 97 | extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool)) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...) 103 | } 104 | 105 | func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) { 106 | if len(p.GoFiles) == 0 { 107 | return nil, nil 108 | } 109 | js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js")) 110 | if err != nil { 111 | return nil, err 112 | } 113 | extraJS = append(extraJS, js...) 114 | for _, imp := range p.Imports { 115 | if !visited[imp.ID] { 116 | extra, err := findPackagesJS(imp, visited) 117 | if err != nil { 118 | return nil, err 119 | } 120 | extraJS = append(extraJS, extra...) 121 | visited[imp.ID] = true 122 | } 123 | } 124 | return extraJS, nil 125 | } 126 | 127 | // mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo 128 | // and append the jsStartGo. 129 | func mergeJSFiles(dst string, files ...string) (err error) { 130 | w, err := os.Create(dst) 131 | if err != nil { 132 | return err 133 | } 134 | defer func() { 135 | if cerr := w.Close(); err != nil { 136 | err = cerr 137 | } 138 | }() 139 | _, err = io.Copy(w, strings.NewReader(jsSetGo)) 140 | if err != nil { 141 | return err 142 | } 143 | for i := range files { 144 | r, err := os.Open(files[i]) 145 | if err != nil { 146 | return err 147 | } 148 | _, err = io.Copy(w, r) 149 | r.Close() 150 | if err != nil { 151 | return err 152 | } 153 | } 154 | _, err = io.Copy(w, strings.NewReader(jsStartGo)) 155 | return err 156 | } 157 | 158 | const ( 159 | jsIndex = ` 160 | 161 | 162 | 163 | 164 | 165 | {{ if .Icon }}{{ end }} 166 | {{ if .Name }}{{.Name}}{{ end }} 167 | 168 | 171 | 172 | 173 | 174 | ` 175 | // jsSetGo sets the `window.go` variable. 176 | jsSetGo = `(() => { 177 | window.go = {argv: [], env: {}, importObject: {go: {}}}; 178 | const argv = new URLSearchParams(location.search).get("argv"); 179 | if (argv) { 180 | window.go["argv"] = argv.split(" "); 181 | } 182 | })();` 183 | // jsStartGo initializes the main.wasm. 184 | jsStartGo = `(() => { 185 | defaultGo = new Go(); 186 | Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"])); 187 | Object.assign(defaultGo["env"], go["env"]); 188 | for (let key in go["importObject"]) { 189 | if (typeof defaultGo["importObject"][key] === "undefined") { 190 | defaultGo["importObject"][key] = {}; 191 | } 192 | Object.assign(defaultGo["importObject"][key], go["importObject"][key]); 193 | } 194 | window.go = defaultGo; 195 | if (!WebAssembly.instantiateStreaming) { // polyfill 196 | WebAssembly.instantiateStreaming = async (resp, importObject) => { 197 | const source = await (await resp).arrayBuffer(); 198 | return await WebAssembly.instantiate(source, importObject); 199 | }; 200 | } 201 | WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => { 202 | go.run(result.instance); 203 | }); 204 | })();` 205 | ) 206 | -------------------------------------------------------------------------------- /gogio/macosbuild.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "text/template" 11 | ) 12 | 13 | func buildMac(tmpDir string, bi *buildInfo) error { 14 | builder := &macBuilder{TempDir: tmpDir} 15 | builder.DestDir = *destPath 16 | if builder.DestDir == "" { 17 | builder.DestDir = bi.pkgPath 18 | } 19 | 20 | name := bi.name 21 | if *destPath != "" { 22 | if filepath.Ext(*destPath) != ".app" { 23 | return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath) 24 | } 25 | name = filepath.Base(*destPath) 26 | } 27 | name = strings.TrimSuffix(name, ".app") 28 | 29 | if bi.appID == "" { 30 | return errors.New("app id is empty; use -appid to set it") 31 | } 32 | 33 | if err := builder.setIcon(bi.iconPath); err != nil { 34 | return err 35 | } 36 | 37 | if err := builder.setInfo(bi, name); err != nil { 38 | return fmt.Errorf("can't build the resources: %v", err) 39 | } 40 | 41 | for _, arch := range bi.archs { 42 | tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir)) 43 | finalDest := builder.DestDir 44 | if len(bi.archs) > 1 { 45 | tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app") 46 | finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app") 47 | } 48 | 49 | if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil { 50 | return err 51 | } 52 | 53 | if bi.key != "" { 54 | if err := builder.signProgram(bi, tmpDest, name, arch); err != nil { 55 | return err 56 | } 57 | } 58 | 59 | if err := dittozip(tmpDest, tmpDest+".zip"); err != nil { 60 | return err 61 | } 62 | 63 | if bi.notaryAppleID != "" { 64 | if err := builder.notarize(bi, tmpDest+".zip"); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | if err := dittounzip(tmpDest+".zip", finalDest); err != nil { 70 | return err 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | type macBuilder struct { 78 | TempDir string 79 | DestDir string 80 | 81 | Icons []byte 82 | Manifest []byte 83 | Entitlements []byte 84 | } 85 | 86 | func (b *macBuilder) setIcon(path string) (err error) { 87 | if _, err := os.Stat(path); err != nil { 88 | return nil 89 | } 90 | 91 | out := filepath.Join(b.TempDir, "iconset.iconset") 92 | if err := os.MkdirAll(out, 0o777); err != nil { 93 | return err 94 | } 95 | 96 | err = buildIcons(out, path, []iconVariant{ 97 | {path: "icon_512x512@2x.png", size: 1024}, 98 | {path: "icon_512x512.png", size: 512}, 99 | {path: "icon_256x256@2x.png", size: 512}, 100 | {path: "icon_256x256.png", size: 256}, 101 | {path: "icon_128x128@2x.png", size: 256}, 102 | {path: "icon_128x128.png", size: 128}, 103 | {path: "icon_64x64@2x.png", size: 128}, 104 | {path: "icon_64x64.png", size: 64}, 105 | {path: "icon_32x32@2x.png", size: 64}, 106 | {path: "icon_32x32.png", size: 32}, 107 | {path: "icon_16x16@2x.png", size: 32}, 108 | {path: "icon_16x16.png", size: 16}, 109 | }) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | cmd := exec.Command("iconutil", 115 | "-c", "icns", out, 116 | "-o", filepath.Join(b.TempDir, "icon.icns")) 117 | if _, err := runCmd(cmd); err != nil { 118 | return err 119 | } 120 | 121 | b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns")) 122 | return err 123 | } 124 | 125 | func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error { 126 | t, err := template.New("manifest").Parse(` 127 | 128 | 129 | 130 | CFBundleExecutable 131 | {{.Name}} 132 | CFBundleIconFile 133 | icon.icns 134 | CFBundleIdentifier 135 | {{.Bundle}} 136 | NSHighResolutionCapable 137 | 138 | CFBundlePackageType 139 | APPL 140 | 141 | `) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | var manifest bufferCoff 147 | if err := t.Execute(&manifest, struct { 148 | Name, Bundle string 149 | }{ 150 | Name: name, 151 | Bundle: buildInfo.appID, 152 | }); err != nil { 153 | return err 154 | } 155 | b.Manifest = manifest.Bytes() 156 | 157 | b.Entitlements = []byte(` 158 | 159 | 160 | 161 | com.apple.security.cs.allow-unsigned-executable-memory 162 | 163 | com.apple.security.cs.allow-jit 164 | 165 | 166 | `) 167 | 168 | return nil 169 | } 170 | 171 | func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error { 172 | for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} { 173 | if err := os.MkdirAll(filepath.Join(binDest, path), 0o755); err != nil { 174 | return err 175 | } 176 | } 177 | 178 | if len(b.Icons) > 0 { 179 | if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0o755); err != nil { 180 | return err 181 | } 182 | } 183 | 184 | if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0o755); err != nil { 185 | return err 186 | } 187 | 188 | cmd := exec.Command( 189 | "go", 190 | "build", 191 | "-ldflags="+buildInfo.ldflags, 192 | "-tags="+buildInfo.tags, 193 | "-o", filepath.Join(binDest, "/Contents/MacOS/"+name), 194 | buildInfo.pkgPath, 195 | ) 196 | cmd.Env = append( 197 | os.Environ(), 198 | "GOOS=darwin", 199 | "GOARCH="+arch, 200 | "CGO_ENABLED=1", // Required to cross-compile between AMD/ARM 201 | ) 202 | _, err := runCmd(cmd) 203 | return err 204 | } 205 | 206 | func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error { 207 | options := filepath.Join(b.TempDir, "ent.ent") 208 | if err := os.WriteFile(options, b.Entitlements, 0o777); err != nil { 209 | return err 210 | } 211 | 212 | xattr := exec.Command("xattr", "-rc", binDest) 213 | if _, err := runCmd(xattr); err != nil { 214 | return err 215 | } 216 | 217 | cmd := exec.Command( 218 | "codesign", 219 | "--deep", 220 | "--force", 221 | "--options", "runtime", 222 | "--entitlements", options, 223 | "--sign", buildInfo.key, 224 | binDest, 225 | ) 226 | _, err := runCmd(cmd) 227 | return err 228 | } 229 | 230 | func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error { 231 | cmd := exec.Command( 232 | "xcrun", 233 | "notarytool", 234 | "submit", 235 | binDest, 236 | "--apple-id", buildInfo.notaryAppleID, 237 | "--team-id", buildInfo.notaryTeamID, 238 | "--wait", 239 | ) 240 | 241 | if buildInfo.notaryPassword != "" { 242 | cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword) 243 | } 244 | 245 | _, err := runCmd(cmd) 246 | return err 247 | } 248 | 249 | func dittozip(input, output string) error { 250 | cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output) 251 | 252 | _, err := runCmd(cmd) 253 | return err 254 | } 255 | 256 | func dittounzip(input, output string) error { 257 | cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output) 258 | 259 | _, err := runCmd(cmd) 260 | return err 261 | } 262 | -------------------------------------------------------------------------------- /gogio/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "image" 11 | "image/color" 12 | "image/png" 13 | "io" 14 | "os" 15 | "os/exec" 16 | "path/filepath" 17 | "strings" 18 | 19 | "golang.org/x/image/draw" 20 | "golang.org/x/sync/errgroup" 21 | ) 22 | 23 | var ( 24 | target = flag.String("target", "", "specify target (ios, tvos, android, js).\n") 25 | archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).") 26 | minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level") 27 | targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android") 28 | buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)") 29 | destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.") 30 | appID = flag.String("appid", "", "app identifier (for -buildmode=exe)") 31 | name = flag.String("name", "", "app name (for -buildmode=exe)") 32 | version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode") 33 | printCommands = flag.Bool("x", false, "print the commands") 34 | keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.") 35 | linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool") 36 | extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker") 37 | extraTags = flag.String("tags", "", "extra tags to the Go tool") 38 | iconPath = flag.String("icon", "", "specify an icon for iOS and Android") 39 | signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.") 40 | signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.") 41 | notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.") 42 | notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.") 43 | notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.") 44 | ) 45 | 46 | func main() { 47 | flag.Usage = func() { 48 | fmt.Fprint(os.Stderr, mainUsage) 49 | } 50 | flag.Parse() 51 | if err := flagValidate(); err != nil { 52 | fmt.Fprintf(os.Stderr, "gogio: %v\n", err) 53 | os.Exit(1) 54 | } 55 | buildInfo, err := newBuildInfo(flag.Arg(0)) 56 | if err != nil { 57 | fmt.Fprintf(os.Stderr, "gogio: %v\n", err) 58 | os.Exit(1) 59 | } 60 | if err := build(buildInfo); err != nil { 61 | fmt.Fprintf(os.Stderr, "gogio: %v\n", err) 62 | os.Exit(1) 63 | } 64 | os.Exit(0) 65 | } 66 | 67 | func flagValidate() error { 68 | pkgPathArg := flag.Arg(0) 69 | if pkgPathArg == "" { 70 | return errors.New("specify a package") 71 | } 72 | if *target == "" { 73 | return errors.New("please specify -target") 74 | } 75 | switch *target { 76 | case "ios", "tvos", "android", "js", "windows", "macos": 77 | default: 78 | return fmt.Errorf("invalid -target %s", *target) 79 | } 80 | switch *buildMode { 81 | case "archive", "exe": 82 | default: 83 | return fmt.Errorf("invalid -buildmode %s", *buildMode) 84 | } 85 | return nil 86 | } 87 | 88 | func build(bi *buildInfo) error { 89 | tmpDir, err := os.MkdirTemp("", "gogio-") 90 | if err != nil { 91 | return err 92 | } 93 | if *keepWorkdir { 94 | fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir) 95 | } else { 96 | defer os.RemoveAll(tmpDir) 97 | } 98 | switch *target { 99 | case "js": 100 | return buildJS(bi) 101 | case "ios", "tvos": 102 | return buildIOS(tmpDir, *target, bi) 103 | case "android": 104 | return buildAndroid(tmpDir, bi) 105 | case "windows": 106 | return buildWindows(tmpDir, bi) 107 | case "macos": 108 | return buildMac(tmpDir, bi) 109 | default: 110 | panic("unreachable") 111 | } 112 | } 113 | 114 | func runCmdRaw(cmd *exec.Cmd) ([]byte, error) { 115 | if *printCommands { 116 | fmt.Printf("%s\n", strings.Join(cmd.Args, " ")) 117 | } 118 | out, err := cmd.Output() 119 | if err == nil { 120 | return out, nil 121 | } 122 | if err, ok := err.(*exec.ExitError); ok { 123 | return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr) 124 | } 125 | return nil, err 126 | } 127 | 128 | func runCmd(cmd *exec.Cmd) (string, error) { 129 | out, err := runCmdRaw(cmd) 130 | return string(bytes.TrimSpace(out)), err 131 | } 132 | 133 | func copyFile(dst, src string) (err error) { 134 | r, err := os.Open(src) 135 | if err != nil { 136 | return err 137 | } 138 | defer r.Close() 139 | w, err := os.Create(dst) 140 | if err != nil { 141 | return err 142 | } 143 | defer func() { 144 | if cerr := w.Close(); err == nil { 145 | err = cerr 146 | } 147 | }() 148 | _, err = io.Copy(w, r) 149 | return err 150 | } 151 | 152 | type arch struct { 153 | iosArch string 154 | jniArch string 155 | clangArch string 156 | } 157 | 158 | var allArchs = map[string]arch{ 159 | "arm": { 160 | iosArch: "armv7", 161 | jniArch: "armeabi-v7a", 162 | clangArch: "armv7a-linux-androideabi", 163 | }, 164 | "arm64": { 165 | iosArch: "arm64", 166 | jniArch: "arm64-v8a", 167 | clangArch: "aarch64-linux-android", 168 | }, 169 | "386": { 170 | iosArch: "i386", 171 | jniArch: "x86", 172 | clangArch: "i686-linux-android", 173 | }, 174 | "amd64": { 175 | iosArch: "x86_64", 176 | jniArch: "x86_64", 177 | clangArch: "x86_64-linux-android", 178 | }, 179 | } 180 | 181 | type iconVariant struct { 182 | path string 183 | size int 184 | fill bool 185 | } 186 | 187 | func buildIcons(baseDir, icon string, variants []iconVariant) error { 188 | f, err := os.Open(icon) 189 | if err != nil { 190 | return err 191 | } 192 | defer f.Close() 193 | img, _, err := image.Decode(f) 194 | if err != nil { 195 | return err 196 | } 197 | var resizes errgroup.Group 198 | for _, v := range variants { 199 | v := v 200 | resizes.Go(func() (err error) { 201 | path := filepath.Join(baseDir, v.path) 202 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 203 | return err 204 | } 205 | f, err := os.Create(path) 206 | if err != nil { 207 | return err 208 | } 209 | defer func() { 210 | if cerr := f.Close(); err == nil { 211 | err = cerr 212 | } 213 | }() 214 | return png.Encode(f, resizeIcon(v, img)) 215 | }) 216 | } 217 | return resizes.Wait() 218 | } 219 | 220 | func resizeIcon(v iconVariant, img image.Image) *image.NRGBA { 221 | scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}}) 222 | op := draw.Src 223 | if v.fill { 224 | op = draw.Over 225 | draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) 226 | } 227 | draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil) 228 | 229 | return scaled 230 | } 231 | -------------------------------------------------------------------------------- /gogio/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMain(m *testing.M) { 9 | if os.Getenv("RUN_GOGIO") != "" { 10 | // Allow the end-to-end tests to call the gogio tool without 11 | // having to build it from scratch, nor having to refactor the 12 | // main function to avoid using global variables. 13 | main() 14 | os.Exit(0) // main already exits, but just in case. 15 | } 16 | os.Exit(m.Run()) 17 | } 18 | -------------------------------------------------------------------------------- /gogio/permission.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var AndroidPermissions = map[string][]string{ 4 | "network": { 5 | "android.permission.INTERNET", 6 | }, 7 | "networkstate": { 8 | "android.permission.ACCESS_NETWORK_STATE", 9 | }, 10 | "bluetooth": { 11 | "android.permission.BLUETOOTH", 12 | "android.permission.BLUETOOTH_ADMIN", 13 | "android.permission.ACCESS_FINE_LOCATION", 14 | }, 15 | "camera": { 16 | "android.permission.CAMERA", 17 | }, 18 | "storage": { 19 | "android.permission.READ_EXTERNAL_STORAGE", 20 | "android.permission.WRITE_EXTERNAL_STORAGE", 21 | }, 22 | "wakelock": { 23 | "android.permission.WAKE_LOCK", 24 | }, 25 | } 26 | 27 | var AndroidFeatures = map[string][]string{ 28 | "default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`}, 29 | "bluetooth": { 30 | `name="android.hardware.bluetooth"`, 31 | `name="android.hardware.bluetooth_le"`, 32 | }, 33 | "camera": { 34 | `name="android.hardware.camera"`, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /gogio/race_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | //go:build race 4 | // +build race 5 | 6 | package main_test 7 | 8 | func init() { raceEnabled = true } 9 | -------------------------------------------------------------------------------- /gogio/wayland_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "context" 9 | "fmt" 10 | "image" 11 | "image/png" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "regexp" 16 | "strings" 17 | "sync" 18 | "text/template" 19 | "time" 20 | ) 21 | 22 | type WaylandTestDriver struct { 23 | driverBase 24 | 25 | runtimeDir string 26 | socket string 27 | display string 28 | } 29 | 30 | // No bars or anything fancy. Just a white background with our dimensions. 31 | var tmplSwayConfig = template.Must(template.New("").Parse(` 32 | output * bg #FFFFFF solid_color 33 | output * mode {{.Width}}x{{.Height}} 34 | default_border none 35 | `)) 36 | 37 | var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`) 38 | 39 | func (d *WaylandTestDriver) Start(path string) { 40 | // We want os.Environ, so that it can e.g. find $DISPLAY to run within 41 | // X11. wlroots env vars are documented at: 42 | // https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md 43 | env := os.Environ() 44 | if *headless { 45 | env = append(env, "WLR_BACKENDS=headless") 46 | } 47 | 48 | d.needPrograms( 49 | "sway", // to run a wayland compositor 50 | "grim", // to take screenshots 51 | "swaymsg", // to send input 52 | ) 53 | 54 | // First, build the app. 55 | dir := d.tempDir("gio-endtoend-wayland") 56 | bin := filepath.Join(dir, "red") 57 | flags := []string{"build", "-tags", "nox11", "-o=" + bin} 58 | if raceEnabled { 59 | flags = append(flags, "-race") 60 | } 61 | flags = append(flags, path) 62 | cmd := exec.Command("go", flags...) 63 | if out, err := cmd.CombinedOutput(); err != nil { 64 | d.Fatalf("could not build app: %s:\n%s", err, out) 65 | } 66 | 67 | conf := filepath.Join(dir, "config") 68 | f, err := os.Create(conf) 69 | if err != nil { 70 | d.Fatal(err) 71 | } 72 | defer f.Close() 73 | if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{ 74 | d.width, d.height, 75 | }); err != nil { 76 | d.Fatal(err) 77 | } 78 | 79 | d.socket = filepath.Join(dir, "socket") 80 | env = append(env, "SWAYSOCK="+d.socket) 81 | d.runtimeDir = dir 82 | env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir) 83 | 84 | var wg sync.WaitGroup 85 | d.Cleanup(wg.Wait) 86 | 87 | // First, start sway. 88 | { 89 | ctx, cancel := context.WithCancel(context.Background()) 90 | cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose") 91 | cmd.Env = env 92 | stderr, err := cmd.StderrPipe() 93 | if err != nil { 94 | d.Fatal(err) 95 | } 96 | if err := cmd.Start(); err != nil { 97 | d.Fatal(err) 98 | } 99 | d.Cleanup(cancel) 100 | d.Cleanup(func() { 101 | // Give it a chance to exit gracefully, cleaning up 102 | // after itself. After 10ms, the deferred cancel above 103 | // will signal an os.Kill. 104 | cmd.Process.Signal(os.Interrupt) 105 | time.Sleep(10 * time.Millisecond) 106 | }) 107 | 108 | // Wait for sway to be ready. We probably don't need a deadline 109 | // here. 110 | br := bufio.NewReader(stderr) 111 | for { 112 | line, err := br.ReadString('\n') 113 | if err != nil { 114 | d.Fatal(err) 115 | } 116 | if m := rxSwayReady.FindStringSubmatch(line); m != nil { 117 | d.display = m[1] 118 | break 119 | } 120 | } 121 | 122 | wg.Add(1) 123 | go func() { 124 | if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") { 125 | // Don't print all stderr, since we use --verbose. 126 | // TODO(mvdan): if it's useful, probably filter 127 | // errors and show them. 128 | d.Error(err) 129 | } 130 | wg.Done() 131 | }() 132 | } 133 | 134 | // Then, start our program on the sway compositor above. 135 | { 136 | ctx, cancel := context.WithCancel(context.Background()) 137 | cmd := exec.CommandContext(ctx, bin) 138 | cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} 139 | output, err := cmd.StdoutPipe() 140 | if err != nil { 141 | d.Fatal(err) 142 | } 143 | cmd.Stderr = cmd.Stdout 144 | d.output = output 145 | if err := cmd.Start(); err != nil { 146 | d.Fatal(err) 147 | } 148 | d.Cleanup(cancel) 149 | wg.Add(1) 150 | go func() { 151 | if err := cmd.Wait(); err != nil && ctx.Err() == nil { 152 | d.Error(err) 153 | } 154 | wg.Done() 155 | }() 156 | } 157 | 158 | // Wait for the gio app to render. 159 | d.waitForFrame() 160 | } 161 | 162 | func (d *WaylandTestDriver) Screenshot() image.Image { 163 | cmd := exec.Command("grim", "/dev/stdout") 164 | cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} 165 | out, err := cmd.CombinedOutput() 166 | if err != nil { 167 | d.Errorf("%s", out) 168 | d.Fatal(err) 169 | } 170 | img, err := png.Decode(bytes.NewReader(out)) 171 | if err != nil { 172 | d.Fatal(err) 173 | } 174 | return img 175 | } 176 | 177 | func (d *WaylandTestDriver) swaymsg(args ...any) { 178 | strs := []string{"--socket", d.socket} 179 | for _, arg := range args { 180 | strs = append(strs, fmt.Sprint(arg)) 181 | } 182 | cmd := exec.Command("swaymsg", strs...) 183 | if out, err := cmd.CombinedOutput(); err != nil { 184 | d.Errorf("%s", out) 185 | d.Fatal(err) 186 | } 187 | } 188 | 189 | func (d *WaylandTestDriver) Click(x, y int) { 190 | d.swaymsg("seat", "-", "cursor", "set", x, y) 191 | d.swaymsg("seat", "-", "cursor", "press", "button1") 192 | d.swaymsg("seat", "-", "cursor", "release", "button1") 193 | 194 | // Wait for the gio app to render after this click. 195 | d.waitForFrame() 196 | } 197 | -------------------------------------------------------------------------------- /gogio/windows_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "context" 7 | "image" 8 | "io" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "runtime" 13 | "sync" 14 | "time" 15 | 16 | "golang.org/x/image/draw" 17 | ) 18 | 19 | // Wine is tightly coupled with X11 at the moment, and we can reuse the same 20 | // methods to automate screenshots and clicks. The main difference is how we 21 | // build and run the app. 22 | 23 | // The only quirk is that it seems impossible for the Wine window to take the 24 | // entirety of the X server's dimensions, even if we try to resize it to take 25 | // the entire display. It seems to want to leave some vertical space empty, 26 | // presumably for window decorations or the "start" bar on Windows. To work 27 | // around that, make the X server 50x50px bigger, and crop the screenshots back 28 | // to the original size. 29 | 30 | type WineTestDriver struct { 31 | X11TestDriver 32 | } 33 | 34 | func (d *WineTestDriver) Start(path string) { 35 | d.needPrograms("wine") 36 | 37 | // First, build the app. 38 | bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe") 39 | flags := []string{"build", "-o=" + bin} 40 | if raceEnabled { 41 | if runtime.GOOS != "windows" { 42 | // cross-compilation disables CGo, which breaks -race. 43 | d.Skipf("can't cross-compile -race for Windows; skipping") 44 | } 45 | flags = append(flags, "-race") 46 | } 47 | flags = append(flags, path) 48 | cmd := exec.Command("go", flags...) 49 | cmd.Env = os.Environ() 50 | cmd.Env = append(cmd.Env, "GOOS=windows") 51 | if out, err := cmd.CombinedOutput(); err != nil { 52 | d.Fatalf("could not build app: %s:\n%s", err, out) 53 | } 54 | 55 | var wg sync.WaitGroup 56 | d.Cleanup(wg.Wait) 57 | 58 | // Add 50x50px to the display dimensions, as discussed earlier. 59 | d.startServer(&wg, d.width+50, d.height+50) 60 | 61 | // Then, start our program via Wine on the X server above. 62 | { 63 | cacheDir, err := os.UserCacheDir() 64 | if err != nil { 65 | d.Fatal(err) 66 | } 67 | // Use a wine directory separate from the default ~/.wine, so 68 | // that the user's winecfg doesn't affect our test. This will 69 | // default to ~/.cache/gio-e2e-wine. We use the user's cache, 70 | // to reuse a previously set up wineprefix. 71 | wineprefix := filepath.Join(cacheDir, "gio-e2e-wine") 72 | 73 | // First, ensure that wineprefix is up to date with wineboot. 74 | // Wait for this separately from the first frame, as setting up 75 | // a new prefix might take 5s on its own. 76 | env := []string{ 77 | "DISPLAY=" + d.display, 78 | "WINEDEBUG=fixme-all", // hide "fixme" noise 79 | "WINEPREFIX=" + wineprefix, 80 | 81 | // Disable wine-gecko (Explorer) and wine-mono (.NET). 82 | // Otherwise, if not installed, wineboot will get stuck 83 | // with a prompt to install them on the virtual X 84 | // display. Moreover, Gio doesn't need either, and wine 85 | // is faster without them. 86 | "WINEDLLOVERRIDES=mscoree,mshtml=", 87 | } 88 | { 89 | start := time.Now() 90 | cmd := exec.Command("wine", "wineboot", "-i") 91 | cmd.Env = env 92 | // Use a combined output pipe instead of CombinedOutput, 93 | // so that we only wait for the child process to exit, 94 | // and we don't need to wait for all of wine's 95 | // grandchildren to exit and stop writing. This is 96 | // relevant as wine leaves "wineserver" lingering for 97 | // three seconds by default, to be reused later. 98 | stdout, err := cmd.StdoutPipe() 99 | if err != nil { 100 | d.Fatal(err) 101 | } 102 | cmd.Stderr = cmd.Stdout 103 | if err := cmd.Run(); err != nil { 104 | io.Copy(os.Stderr, stdout) 105 | d.Fatal(err) 106 | } 107 | d.Logf("set up WINEPREFIX in %s", time.Since(start)) 108 | } 109 | 110 | ctx, cancel := context.WithCancel(context.Background()) 111 | cmd := exec.CommandContext(ctx, "wine", bin) 112 | cmd.Env = env 113 | output, err := cmd.StdoutPipe() 114 | if err != nil { 115 | d.Fatal(err) 116 | } 117 | cmd.Stderr = cmd.Stdout 118 | d.output = output 119 | if err := cmd.Start(); err != nil { 120 | d.Fatal(err) 121 | } 122 | d.Cleanup(cancel) 123 | wg.Add(1) 124 | go func() { 125 | if err := cmd.Wait(); err != nil && ctx.Err() == nil { 126 | d.Error(err) 127 | } 128 | wg.Done() 129 | }() 130 | } 131 | // Wait for the gio app to render. 132 | d.waitForFrame() 133 | 134 | // xdotool seems to fail at actually moving the window if we use it 135 | // immediately after Gio is ready. Why? 136 | // We can't tell if the windowmove operation worked until we take a 137 | // screenshot, because the getwindowgeometry op reports the 0x0 138 | // coordinates even if the window wasn't moved properly. 139 | // A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that. 140 | // TODO(mvdan): revisit this, when you have a spare three hours. 141 | time.Sleep(400 * time.Millisecond) 142 | id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio") 143 | d.xdotool("windowmove", "--sync", id, 0, 0) 144 | } 145 | 146 | func (d *WineTestDriver) Screenshot() image.Image { 147 | img := d.X11TestDriver.Screenshot() 148 | // Crop the screenshot back to the original dimensions. 149 | cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height)) 150 | draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src) 151 | return cropped 152 | } 153 | -------------------------------------------------------------------------------- /gogio/windowsbuild.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "fmt" 8 | "image/png" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "reflect" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/akavel/rsrc/binutil" 18 | "github.com/akavel/rsrc/coff" 19 | "golang.org/x/text/encoding/unicode" 20 | ) 21 | 22 | func buildWindows(tmpDir string, bi *buildInfo) error { 23 | builder := &windowsBuilder{TempDir: tmpDir} 24 | builder.DestDir = *destPath 25 | if builder.DestDir == "" { 26 | builder.DestDir = bi.pkgPath 27 | } 28 | 29 | name := bi.name 30 | if *destPath != "" { 31 | if filepath.Ext(*destPath) != ".exe" { 32 | return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath) 33 | } 34 | name = filepath.Base(*destPath) 35 | } 36 | name = strings.TrimSuffix(name, ".exe") 37 | sdk := bi.minsdk 38 | if sdk > 10 { 39 | return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk) 40 | } 41 | 42 | for _, arch := range bi.archs { 43 | builder.Coff = coff.NewRSRC() 44 | builder.Coff.Arch(arch) 45 | 46 | if err := builder.embedIcon(bi.iconPath); err != nil { 47 | return err 48 | } 49 | 50 | if err := builder.embedManifest(windowsManifest{ 51 | Version: bi.version.String(), 52 | WindowsVersion: sdk, 53 | Name: name, 54 | }); err != nil { 55 | return fmt.Errorf("can't create manifest: %v", err) 56 | } 57 | 58 | if err := builder.embedInfo(windowsResources{ 59 | Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)}, 60 | VersionHuman: bi.version.String(), 61 | Name: name, 62 | Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10) 63 | }); err != nil { 64 | return fmt.Errorf("can't create info: %v", err) 65 | } 66 | 67 | if err := builder.buildResource(bi, name, arch); err != nil { 68 | return fmt.Errorf("can't build the resources: %v", err) 69 | } 70 | 71 | if err := builder.buildProgram(bi, name, arch); err != nil { 72 | return err 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | type ( 80 | windowsResources struct { 81 | Version [2]uint32 82 | VersionHuman string 83 | Language uint16 84 | Name string 85 | } 86 | windowsManifest struct { 87 | Version string 88 | WindowsVersion int 89 | Name string 90 | } 91 | windowsBuilder struct { 92 | TempDir string 93 | DestDir string 94 | Coff *coff.Coff 95 | } 96 | ) 97 | 98 | const ( 99 | // https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types 100 | windowsResourceIcon = 3 101 | windowsResourceIconGroup = windowsResourceIcon + 11 102 | windowsResourceManifest = 24 103 | windowsResourceVersion = 16 104 | ) 105 | 106 | type bufferCoff struct { 107 | bytes.Buffer 108 | } 109 | 110 | func (b *bufferCoff) Size() int64 { 111 | return int64(b.Len()) 112 | } 113 | 114 | func (b *windowsBuilder) embedIcon(path string) (err error) { 115 | iconFile, err := os.Open(path) 116 | if err != nil { 117 | if errors.Is(err, os.ErrNotExist) { 118 | return nil 119 | } 120 | return fmt.Errorf("can't read the icon located at %s: %v", path, err) 121 | } 122 | defer iconFile.Close() 123 | 124 | iconImage, err := png.Decode(iconFile) 125 | if err != nil { 126 | return fmt.Errorf("can't decode the PNG file (%s): %v", path, err) 127 | } 128 | 129 | sizes := []int{16, 32, 48, 64, 128, 256} 130 | var iconHeader bufferCoff 131 | 132 | // GRPICONDIR structure. 133 | if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil { 134 | return err 135 | } 136 | 137 | for _, size := range sizes { 138 | var iconBuffer bufferCoff 139 | 140 | if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil { 141 | return fmt.Errorf("can't encode image: %v", err) 142 | } 143 | 144 | b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer) 145 | 146 | if err := binary.Write(&iconHeader, binary.LittleEndian, struct { 147 | Size [2]uint8 148 | Color [2]uint8 149 | Planes uint16 150 | BitCount uint16 151 | Length uint32 152 | Id uint16 153 | }{ 154 | Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px. 155 | Planes: 1, 156 | BitCount: 32, 157 | Length: uint32(iconBuffer.Len()), 158 | Id: uint16(size), 159 | }); err != nil { 160 | return err 161 | } 162 | } 163 | 164 | b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader) 165 | 166 | return nil 167 | } 168 | 169 | func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error { 170 | out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso")) 171 | if err != nil { 172 | return err 173 | } 174 | defer out.Close() 175 | b.Coff.Freeze() 176 | 177 | // See https://github.com/akavel/rsrc/internal/write.go#L13. 178 | w := binutil.Writer{W: out} 179 | binutil.Walk(b.Coff, func(v reflect.Value, path string) error { 180 | if binutil.Plain(v.Kind()) { 181 | w.WriteLE(v.Interface()) 182 | return nil 183 | } 184 | vv, ok := v.Interface().(binutil.SizedReader) 185 | if ok { 186 | w.WriteFromSized(vv) 187 | return binutil.WALK_SKIP 188 | } 189 | return nil 190 | }) 191 | 192 | if w.Err != nil { 193 | return fmt.Errorf("error writing output file: %s", w.Err) 194 | } 195 | 196 | return nil 197 | } 198 | 199 | func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error { 200 | dest := b.DestDir 201 | if len(buildInfo.archs) > 1 { 202 | dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe") 203 | } 204 | 205 | cmd := exec.Command( 206 | "go", 207 | "build", 208 | "-ldflags=-H=windowsgui "+buildInfo.ldflags, 209 | "-tags="+buildInfo.tags, 210 | "-o", dest, 211 | buildInfo.pkgPath, 212 | ) 213 | cmd.Env = append( 214 | os.Environ(), 215 | "GOOS=windows", 216 | "GOARCH="+arch, 217 | ) 218 | _, err := runCmd(cmd) 219 | return err 220 | } 221 | 222 | func (b *windowsBuilder) embedManifest(v windowsManifest) error { 223 | t, err := template.New("manifest").Parse(` 224 | 225 | 226 | {{.Name}} 227 | 228 | 229 | {{if (le .WindowsVersion 10)}} 230 | {{end}} 231 | {{if (le .WindowsVersion 9)}} 232 | {{end}} 233 | {{if (le .WindowsVersion 8)}} 234 | {{end}} 235 | {{if (le .WindowsVersion 7)}} 236 | {{end}} 237 | {{if (le .WindowsVersion 6)}} 238 | {{end}} 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | true 251 | 252 | 253 | `) 254 | if err != nil { 255 | return err 256 | } 257 | 258 | var manifest bufferCoff 259 | if err := t.Execute(&manifest, v); err != nil { 260 | return err 261 | } 262 | 263 | b.Coff.AddResource(windowsResourceManifest, 1, &manifest) 264 | 265 | return nil 266 | } 267 | 268 | func (b *windowsBuilder) embedInfo(v windowsResources) error { 269 | page := uint16(1) 270 | 271 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo 272 | t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{ 273 | // https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo 274 | windowsInfoValueFixed{ 275 | Signature: 0xFEEF04BD, 276 | StructVersion: 0x00010000, 277 | FileVersion: v.Version, 278 | ProductVersion: v.Version, 279 | FileFlagMask: 0x3F, 280 | FileFlags: 0, 281 | FileOS: 0x40004, 282 | FileType: 0x1, 283 | FileSubType: 0, 284 | }, 285 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo 286 | newValue(valueText, "StringFileInfo", []io.WriterTo{ 287 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable 288 | newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{ 289 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str 290 | newValue(valueText, "ProductVersion", v.VersionHuman), 291 | newValue(valueText, "FileVersion", v.VersionHuman), 292 | newValue(valueText, "FileDescription", v.Name), 293 | newValue(valueText, "ProductName", v.Name), 294 | // TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...) 295 | }), 296 | }), 297 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo 298 | newValue(valueBinary, "VarFileInfo", []io.WriterTo{ 299 | // https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str 300 | newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)), 301 | }), 302 | }) 303 | 304 | // For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`: 305 | t.ValueLength = 52 306 | 307 | var verrsrc bufferCoff 308 | if _, err := t.WriteTo(&verrsrc); err != nil { 309 | return err 310 | } 311 | 312 | b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc) 313 | 314 | return nil 315 | } 316 | 317 | type windowsInfoValueFixed struct { 318 | Signature uint32 319 | StructVersion uint32 320 | FileVersion [2]uint32 321 | ProductVersion [2]uint32 322 | FileFlagMask uint32 323 | FileFlags uint32 324 | FileOS uint32 325 | FileType uint32 326 | FileSubType uint32 327 | FileDate [2]uint32 328 | } 329 | 330 | func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) { 331 | return 0, binary.Write(w, binary.LittleEndian, v) 332 | } 333 | 334 | type windowsInfoValue struct { 335 | Length uint16 336 | ValueLength uint16 337 | Type uint16 338 | Key []byte 339 | Value []byte 340 | } 341 | 342 | func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) { 343 | // binary.Write doesn't support []byte inside struct. 344 | if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil { 345 | return 0, err 346 | } 347 | if _, err = w.Write(v.Key); err != nil { 348 | return 0, err 349 | } 350 | if _, err = w.Write(v.Value); err != nil { 351 | return 0, err 352 | } 353 | return 0, nil 354 | } 355 | 356 | const ( 357 | valueBinary uint16 = 0 358 | valueText uint16 = 1 359 | ) 360 | 361 | func newValue(valueType uint16, key string, input any) windowsInfoValue { 362 | v := windowsInfoValue{ 363 | Type: valueType, 364 | Length: 6, 365 | } 366 | 367 | padding := func(in []byte) []byte { 368 | if l := uint16(len(in)) + v.Length; l%4 != 0 { 369 | return append(in, make([]byte, 4-l%4)...) 370 | } 371 | return in 372 | } 373 | 374 | v.Key = padding(utf16Encode(key)) 375 | v.Length += uint16(len(v.Key)) 376 | 377 | switch in := input.(type) { 378 | case string: 379 | v.Value = padding(utf16Encode(in)) 380 | v.ValueLength = uint16(len(v.Value) / 2) 381 | case []io.WriterTo: 382 | var buff bytes.Buffer 383 | for k := range in { 384 | if _, err := in[k].WriteTo(&buff); err != nil { 385 | panic(err) 386 | } 387 | } 388 | v.Value = buff.Bytes() 389 | default: 390 | var buff bytes.Buffer 391 | if err := binary.Write(&buff, binary.LittleEndian, in); err != nil { 392 | panic(err) 393 | } 394 | v.ValueLength = uint16(buff.Len()) 395 | v.Value = buff.Bytes() 396 | } 397 | 398 | v.Length += uint16(len(v.Value)) 399 | 400 | return v 401 | } 402 | 403 | // utf16Encode encodes the string to UTF16 with null-termination. 404 | func utf16Encode(s string) []byte { 405 | b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s)) 406 | if err != nil { 407 | panic(err) 408 | } 409 | return append(b, 0x00, 0x00) // null-termination. 410 | } 411 | -------------------------------------------------------------------------------- /gogio/x11_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | package main_test 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "fmt" 9 | "image" 10 | "image/png" 11 | "io" 12 | "math/rand" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "sync" 17 | "time" 18 | ) 19 | 20 | type X11TestDriver struct { 21 | driverBase 22 | 23 | display string 24 | } 25 | 26 | func (d *X11TestDriver) Start(path string) { 27 | // First, build the app. 28 | bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red") 29 | flags := []string{"build", "-tags", "nowayland", "-o=" + bin} 30 | if raceEnabled { 31 | flags = append(flags, "-race") 32 | } 33 | flags = append(flags, path) 34 | cmd := exec.Command("go", flags...) 35 | if out, err := cmd.CombinedOutput(); err != nil { 36 | d.Fatalf("could not build app: %s:\n%s", err, out) 37 | } 38 | 39 | var wg sync.WaitGroup 40 | d.Cleanup(wg.Wait) 41 | 42 | d.startServer(&wg, d.width, d.height) 43 | 44 | // Then, start our program on the X server above. 45 | { 46 | ctx, cancel := context.WithCancel(context.Background()) 47 | cmd := exec.CommandContext(ctx, bin) 48 | cmd.Env = []string{"DISPLAY=" + d.display} 49 | output, err := cmd.StdoutPipe() 50 | if err != nil { 51 | d.Fatal(err) 52 | } 53 | cmd.Stderr = cmd.Stdout 54 | d.output = output 55 | if err := cmd.Start(); err != nil { 56 | d.Fatal(err) 57 | } 58 | d.Cleanup(cancel) 59 | wg.Add(1) 60 | go func() { 61 | if err := cmd.Wait(); err != nil && ctx.Err() == nil { 62 | d.Error(err) 63 | } 64 | wg.Done() 65 | }() 66 | } 67 | 68 | // Wait for the gio app to render. 69 | d.waitForFrame() 70 | } 71 | 72 | func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) { 73 | // Pick a random display number between 1 and 100,000. Most machines 74 | // will only be using :0, so there's only a 0.001% chance of two 75 | // concurrent test runs to run into a conflict. 76 | rnd := rand.New(rand.NewSource(time.Now().UnixNano())) 77 | d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1) 78 | 79 | var xprog string 80 | xflags := []string{ 81 | "-wr", // we want a white background; the default is black 82 | } 83 | if *headless { 84 | xprog = "Xvfb" // virtual X server 85 | xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height)) 86 | } else { 87 | xprog = "Xephyr" // nested X server as a window 88 | xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height)) 89 | } 90 | xflags = append(xflags, d.display) 91 | 92 | d.needPrograms( 93 | xprog, // to run the X server 94 | "scrot", // to take screenshots 95 | "xdotool", // to send input 96 | ) 97 | ctx, cancel := context.WithCancel(context.Background()) 98 | cmd := exec.CommandContext(ctx, xprog, xflags...) 99 | combined := &bytes.Buffer{} 100 | cmd.Stdout = combined 101 | cmd.Stderr = combined 102 | if err := cmd.Start(); err != nil { 103 | d.Fatal(err) 104 | } 105 | d.Cleanup(cancel) 106 | d.Cleanup(func() { 107 | // Give it a chance to exit gracefully, cleaning up 108 | // after itself. After 10ms, the deferred cancel above 109 | // will signal an os.Kill. 110 | cmd.Process.Signal(os.Interrupt) 111 | time.Sleep(10 * time.Millisecond) 112 | }) 113 | 114 | // Wait for the X server to be ready. The socket path isn't 115 | // terribly portable, but that's okay for now. 116 | withRetries(d.T, time.Second, func() error { 117 | socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:]) 118 | _, err := os.Stat(socket) 119 | return err 120 | }) 121 | 122 | wg.Add(1) 123 | go func() { 124 | if err := cmd.Wait(); err != nil && ctx.Err() == nil { 125 | // Print all output and error. 126 | io.Copy(os.Stdout, combined) 127 | d.Error(err) 128 | } 129 | wg.Done() 130 | }() 131 | } 132 | 133 | func (d *X11TestDriver) Screenshot() image.Image { 134 | cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout") 135 | cmd.Env = []string{"DISPLAY=" + d.display} 136 | out, err := cmd.CombinedOutput() 137 | if err != nil { 138 | d.Errorf("%s", out) 139 | d.Fatal(err) 140 | } 141 | img, err := png.Decode(bytes.NewReader(out)) 142 | if err != nil { 143 | d.Fatal(err) 144 | } 145 | return img 146 | } 147 | 148 | func (d *X11TestDriver) xdotool(args ...any) string { 149 | d.Helper() 150 | strs := make([]string, len(args)) 151 | for i, arg := range args { 152 | strs[i] = fmt.Sprint(arg) 153 | } 154 | cmd := exec.Command("xdotool", strs...) 155 | cmd.Env = []string{"DISPLAY=" + d.display} 156 | out, err := cmd.CombinedOutput() 157 | if err != nil { 158 | d.Errorf("%s", out) 159 | d.Fatal(err) 160 | } 161 | return string(bytes.TrimSpace(out)) 162 | } 163 | 164 | func (d *X11TestDriver) Click(x, y int) { 165 | d.xdotool("mousemove", "--sync", x, y) 166 | d.xdotool("click", "1") 167 | 168 | // Wait for the gio app to render after this click. 169 | d.waitForFrame() 170 | } 171 | -------------------------------------------------------------------------------- /svg2gio/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense OR MIT 2 | 3 | // Command svg2gio converts SVG files to Gio functions. Only a limited subset of 4 | // SVG files are supported. 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "encoding/xml" 10 | "errors" 11 | "flag" 12 | "fmt" 13 | "go/format" 14 | "io" 15 | "os" 16 | "path/filepath" 17 | "strconv" 18 | "strings" 19 | "unicode" 20 | 21 | "gioui.org/f32" 22 | ) 23 | 24 | var ( 25 | pkg = flag.String("pkg", "", "Go package") 26 | output = flag.String("o", "svg.go", "Output Go file") 27 | ) 28 | 29 | func main() { 30 | flag.Parse() 31 | if *pkg == "" { 32 | fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n") 33 | os.Exit(1) 34 | } 35 | args := flag.Args() 36 | if err := convertAll(args); err != nil { 37 | fmt.Fprintf(os.Stderr, "%v\n", err) 38 | os.Exit(2) 39 | } 40 | } 41 | 42 | type Points []float32 43 | 44 | func (p *Points) UnmarshalText(text []byte) error { 45 | for { 46 | text = bytes.TrimLeft(text, "\t\n") 47 | if len(text) == 0 { 48 | break 49 | } 50 | var num []byte 51 | end := bytes.IndexAny(text, " ,") 52 | if end != -1 { 53 | num = text[:end] 54 | text = text[end+1:] 55 | } else { 56 | num = text 57 | text = nil 58 | } 59 | f, err := strconv.ParseFloat(string(num), 32) 60 | if err != nil { 61 | return err 62 | } 63 | *p = append(*p, float32(f)) 64 | } 65 | return nil 66 | } 67 | 68 | type Transform f32.Affine2D 69 | 70 | func (t *Transform) UnmarshalText(text []byte) error { 71 | switch { 72 | case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")): 73 | trans := text[7 : len(text)-1] 74 | var p Points 75 | if err := p.UnmarshalText(trans); err != nil { 76 | return err 77 | } 78 | if len(p) != 6 { 79 | return fmt.Errorf("malformed transform matrix: %q", text) 80 | } 81 | *t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5])) 82 | return nil 83 | default: 84 | return fmt.Errorf("unsupported transform: %q", text) 85 | } 86 | } 87 | 88 | type Fill struct { 89 | Transform Transform `xml:"transform,attr"` 90 | Fill Color `xml:"fill,attr"` 91 | Stroke Color `xml:"stroke,attr"` 92 | StrokeLinejoin string `xml:"stroke-linejoin,attr"` 93 | StrokeLinecap string `xml:"stroke-linecap,attr"` 94 | StrokeWidth float32 `xml:"stroke-width,attr"` 95 | } 96 | 97 | type Color struct { 98 | Set bool 99 | Value int 100 | } 101 | 102 | func (c *Color) UnmarshalText(text []byte) error { 103 | if string(text) == "none" { 104 | *c = Color{} 105 | return nil 106 | } 107 | if !bytes.HasPrefix(text, []byte("#")) { 108 | return fmt.Errorf("invalid color: %q", text) 109 | } 110 | text = text[1:] 111 | i, err := strconv.ParseInt(string(text), 16, 32) 112 | // Implied alpha. 113 | if len(text) == 6 { 114 | i |= 0xff000000 115 | } 116 | *c = Color{ 117 | Set: true, 118 | Value: int(i), 119 | } 120 | return err 121 | } 122 | 123 | func convertAll(files []string) error { 124 | w := new(bytes.Buffer) 125 | fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n") 126 | fmt.Fprintf(w, "package %s\n\n", *pkg) 127 | fmt.Fprintf(w, "import \"image/color\"\n") 128 | fmt.Fprintf(w, "import \"math\"\n") 129 | fmt.Fprintf(w, "import \"gioui.org/op\"\n") 130 | fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n") 131 | fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n") 132 | fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n") 133 | fmt.Fprintf(w, "var ops op.Ops\n\n") 134 | fmt.Fprintf(w, funcs) 135 | for _, filename := range files { 136 | if err := convert(w, filename); err != nil { 137 | return err 138 | } 139 | } 140 | src, err := format.Source(w.Bytes()) 141 | if err != nil { 142 | return err 143 | } 144 | return os.WriteFile(*output, src, 0o660) 145 | } 146 | 147 | func convert(w io.Writer, filename string) error { 148 | base := filepath.Base(filename) 149 | ext := filepath.Ext(base) 150 | name := "Image_" + base[:len(base)-len(ext)] 151 | 152 | fmt.Fprintf(w, "var %s struct {\n", name) 153 | fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n") 154 | fmt.Fprintf(w, "Call op.CallOp\n\n") 155 | fmt.Fprintf(w, "}\n") 156 | fmt.Fprintf(w, "func init() {\n") 157 | defer fmt.Fprintf(w, "}\n") 158 | f, err := os.Open(filename) 159 | if err != nil { 160 | return err 161 | } 162 | defer f.Close() 163 | d := xml.NewDecoder(f) 164 | if err := parse(w, d, name); err != nil { 165 | line, col := d.InputPos() 166 | return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err) 167 | } 168 | return nil 169 | } 170 | 171 | func parse(w io.Writer, d *xml.Decoder, name string) error { 172 | for { 173 | tok, err := d.Token() 174 | if err != nil { 175 | if err == io.EOF { 176 | return errors.New("unexpected end of file") 177 | } 178 | return err 179 | } 180 | switch tok := tok.(type) { 181 | case xml.StartElement: 182 | if n := tok.Name.Local; n != "svg" { 183 | return fmt.Errorf("invalid SVG root: <%s>", n) 184 | } 185 | if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" { 186 | return fmt.Errorf("unsupported SVG namespace: %s", n) 187 | } 188 | fmt.Fprintf(w, "m := op.Record(&ops)\n") 189 | defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name) 190 | for _, a := range tok.Attr { 191 | if a.Name.Local == "viewBox" { 192 | var p Points 193 | if err := p.UnmarshalText([]byte(a.Value)); err != nil { 194 | return fmt.Errorf("invalid viewBox attribute: %s", a.Value) 195 | } 196 | if len(p) != 4 { 197 | return fmt.Errorf("invalid viewBox attribute: %s", a.Value) 198 | } 199 | fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1]))) 200 | fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3]))) 201 | } 202 | } 203 | return parseSVG(w, d) 204 | } 205 | } 206 | } 207 | 208 | func point(p f32.Point) string { 209 | return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y) 210 | } 211 | 212 | type Poly struct { 213 | XMLName xml.Name 214 | Points Points `xml:"points,attr"` 215 | Fill 216 | } 217 | 218 | func (p *Poly) Path(w io.Writer) error { 219 | if len(p.Points) <= 1 { 220 | return nil 221 | } 222 | pen := f32.Pt(p.Points[0], p.Points[1]) 223 | fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen)) 224 | last := pen 225 | for i := 2; i < len(p.Points); i += 2 { 226 | last = f32.Pt(p.Points[i], p.Points[i+1]) 227 | fmt.Fprintf(w, "p.LineTo(%s)\n", point(last)) 228 | } 229 | if p.XMLName.Local == "polygon" && last != pen { 230 | fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen)) 231 | } 232 | return nil 233 | } 234 | 235 | type Path struct { 236 | D string `xml:"d,attr"` 237 | Fill 238 | } 239 | 240 | func (p *Path) Path(w io.Writer) error { 241 | return printPathCommands(w, p.D) 242 | } 243 | 244 | type Line struct { 245 | X1 float32 `xml:"x1,attr"` 246 | Y1 float32 `xml:"y1,attr"` 247 | X2 float32 `xml:"x2,attr"` 248 | Y2 float32 `xml:"y2,attr"` 249 | Fill 250 | } 251 | 252 | func (l *Line) Path(w io.Writer) error { 253 | fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1))) 254 | fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2))) 255 | return nil 256 | } 257 | 258 | type Ellipse struct { 259 | Cx float32 `xml:"cx,attr"` 260 | Cy float32 `xml:"cy,attr"` 261 | Rx float32 `xml:"rx,attr"` 262 | Ry float32 `xml:"ry,attr"` 263 | Fill 264 | } 265 | 266 | func (e *Ellipse) Path(w io.Writer) error { 267 | c := f32.Pt(e.Cx, e.Cy) 268 | r := f32.Pt(e.Rx, e.Ry) 269 | fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r)) 270 | return nil 271 | } 272 | 273 | type Rect struct { 274 | X float32 `xml:"x,attr"` 275 | Y float32 `xml:"y,attr"` 276 | Width float32 `xml:"width,attr"` 277 | Height float32 `xml:"height,attr"` 278 | Fill 279 | } 280 | 281 | func (r *Rect) Path(w io.Writer) error { 282 | o := f32.Pt(r.X, r.Y) 283 | sz := f32.Pt(r.Width, r.Height) 284 | fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz)) 285 | return nil 286 | } 287 | 288 | type Circle struct { 289 | Cx float32 `xml:"cx,attr"` 290 | Cy float32 `xml:"cy,attr"` 291 | R float32 `xml:"r,attr"` 292 | Fill 293 | } 294 | 295 | func (c *Circle) Path(w io.Writer) error { 296 | center := f32.Pt(c.Cx, c.Cy) 297 | r := f32.Pt(c.R, c.R) 298 | fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r)) 299 | return nil 300 | } 301 | 302 | func parseSVG(w io.Writer, d *xml.Decoder) error { 303 | for { 304 | tok, err := d.Token() 305 | if err != nil { 306 | if err == io.EOF { 307 | return errors.New("unexpected end of element") 308 | } 309 | return err 310 | } 311 | var start xml.StartElement 312 | switch tok := tok.(type) { 313 | case xml.EndElement: 314 | return nil 315 | case xml.StartElement: 316 | start = tok 317 | default: 318 | continue 319 | } 320 | var elem interface { 321 | Path(w io.Writer) error 322 | } 323 | var fill *Fill 324 | switch n := start.Name.Local; n { 325 | case "g": 326 | // Flatten groups. 327 | if err := parseSVG(w, d); err != nil { 328 | return err 329 | } 330 | continue 331 | case "title": 332 | d.Skip() 333 | continue 334 | case "polygon", "polyline": 335 | p := new(Poly) 336 | elem = p 337 | fill = &p.Fill 338 | case "path": 339 | p := new(Path) 340 | elem = p 341 | fill = &p.Fill 342 | case "line": 343 | l := new(Line) 344 | elem = l 345 | fill = &l.Fill 346 | case "ellipse": 347 | e := new(Ellipse) 348 | elem = e 349 | fill = &e.Fill 350 | case "rect": 351 | r := new(Rect) 352 | elem = r 353 | fill = &r.Fill 354 | case "circle": 355 | c := new(Circle) 356 | elem = c 357 | fill = &c.Fill 358 | default: 359 | return fmt.Errorf("unsupported tag: <%s>", n) 360 | } 361 | if err := d.DecodeElement(elem, &start); err != nil { 362 | return err 363 | } 364 | if !fill.Fill.Set && !fill.Stroke.Set { 365 | continue 366 | } 367 | fmt.Fprintf(w, "{\n") 368 | trans := f32.Affine2D(fill.Transform) 369 | if trans != (f32.Affine2D{}) { 370 | sx, hx, ox, sy, hy, oy := trans.Elems() 371 | fmt.Fprintf(w, "t := op.Affine(f32.NewAffine2D(%g, %g, %g, %g, %g, %g)).Push(&ops)\n", sx, hx, ox, sy, hy, oy) 372 | } 373 | fmt.Fprintf(w, "var p clip.Path\n") 374 | fmt.Fprintf(w, "p.Begin(&ops)\n") 375 | if err := elem.Path(w); err != nil { 376 | return err 377 | } 378 | fmt.Fprintf(w, "spec := p.End()\n") 379 | if fill.Fill.Set { 380 | fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Outline{Path: spec}.Op())\n", fill.Fill.Value) 381 | } 382 | if fill.Stroke.Set { 383 | fmt.Fprintf(w, "paint.FillShape(&ops, argb(%#.8x), clip.Stroke{Width: %g, Path: spec}.Op())\n", fill.Stroke.Value, fill.StrokeWidth) 384 | } 385 | if trans != (f32.Affine2D{}) { 386 | fmt.Fprintf(w, "t.Pop()\n") 387 | } 388 | fmt.Fprintf(w, "}\n") 389 | } 390 | } 391 | 392 | func printPathCommands(w io.Writer, cmds string) error { 393 | moveTo := func(p f32.Point) { 394 | fmt.Fprintf(w, "p.MoveTo(%s)\n", point(p)) 395 | } 396 | lineTo := func(p f32.Point) { 397 | fmt.Fprintf(w, "p.LineTo(%s)\n", point(p)) 398 | } 399 | cubeTo := func(p0, p1, p2 f32.Point) { 400 | fmt.Fprintf(w, "p.CubeTo(%s, %s, %s)\n", point(p0), point(p1), point(p2)) 401 | } 402 | cmds = strings.TrimSpace(cmds) 403 | var pen f32.Point 404 | initPoint := pen 405 | ctrl2 := pen 406 | for { 407 | cmds = strings.TrimLeft(cmds, " ,\t\n") 408 | if len(cmds) == 0 { 409 | break 410 | } 411 | orig := cmds 412 | op := rune(cmds[0]) 413 | cmds = cmds[1:] 414 | switch op { 415 | case 'M', 'm', 'V', 'v', 'L', 'l', 'H', 'h', 'C', 'c', 'S', 's': 416 | case 'Z', 'z': 417 | if pen != initPoint { 418 | lineTo(initPoint) 419 | pen = initPoint 420 | } 421 | ctrl2 = initPoint 422 | continue 423 | default: 424 | return fmt.Errorf("unknown command %s in %q", string(op), orig) 425 | } 426 | var coords []float64 427 | for { 428 | cmds = strings.TrimLeft(cmds, " ,\t\n") 429 | if len(cmds) == 0 { 430 | break 431 | } 432 | n, x, ok := parseFloat(cmds) 433 | if !ok { 434 | break 435 | } 436 | cmds = cmds[n:] 437 | coords = append(coords, x) 438 | } 439 | rel := unicode.IsLower(op) 440 | newPen := pen 441 | switch unicode.ToLower(op) { 442 | case 'h': 443 | for _, x := range coords { 444 | p := f32.Pt(float32(x), pen.Y) 445 | if rel { 446 | p.X += pen.X 447 | } 448 | lineTo(p) 449 | newPen = p 450 | } 451 | pen = newPen 452 | ctrl2 = newPen 453 | continue 454 | case 'v': 455 | for _, y := range coords { 456 | p := f32.Pt(pen.X, float32(y)) 457 | if rel { 458 | p.Y += pen.Y 459 | } 460 | lineTo(p) 461 | newPen = p 462 | } 463 | pen = newPen 464 | ctrl2 = newPen 465 | continue 466 | } 467 | if len(coords)%2 != 0 { 468 | return fmt.Errorf("odd number of coordinates in data: %q", orig) 469 | } 470 | var off f32.Point 471 | if rel { 472 | // Relative command. 473 | off = pen 474 | } else { 475 | off = f32.Pt(0, 0) 476 | } 477 | var points []f32.Point 478 | for i := 0; i < len(coords); i += 2 { 479 | p := f32.Pt(float32(coords[i]), float32(coords[i+1])) 480 | p = p.Add(off) 481 | points = append(points, p) 482 | } 483 | newCtrl2 := ctrl2 484 | switch op := unicode.ToLower(op); op { 485 | case 'm', 'l': 486 | sop := moveTo 487 | if op == 'l' { 488 | sop = lineTo 489 | } 490 | for _, p := range points { 491 | sop(p) 492 | newPen = p 493 | } 494 | if op == 'm' { 495 | initPoint = newPen 496 | } 497 | case 'c': 498 | for i := 0; i < len(points); i += 3 { 499 | p1, p2, p3 := points[i], points[i+1], points[i+2] 500 | cubeTo(p1, p2, p3) 501 | newPen = p3 502 | newCtrl2 = p2 503 | } 504 | case 's': 505 | for i := 0; i < len(points); i += 2 { 506 | p2, p3 := points[i], points[i+1] 507 | // Compute p1 by reflecting p2 on to the line that contains pen and p2. 508 | p1 := pen.Mul(2).Sub(ctrl2) 509 | cubeTo(p1, p2, p3) 510 | newPen = p3 511 | newCtrl2 = p2 512 | } 513 | } 514 | pen = newPen 515 | ctrl2 = newCtrl2 516 | } 517 | return nil 518 | } 519 | 520 | func parseFloat(s string) (int, float64, bool) { 521 | n := 0 522 | if len(s) > 0 && s[0] == '-' { 523 | n++ 524 | } 525 | for ; n < len(s); n++ { 526 | if !(unicode.IsDigit(rune(s[n])) || s[n] == '.') { 527 | break 528 | } 529 | } 530 | f, err := strconv.ParseFloat(s[:n], 64) 531 | return n, f, err == nil 532 | } 533 | 534 | const funcs = ` 535 | func argb(c uint32) color.NRGBA { 536 | return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)} 537 | } 538 | 539 | func rect(p *clip.Path, origin, size f32.Point) { 540 | p.MoveTo(origin) 541 | p.LineTo(origin.Add(f32.Pt(size.X, 0))) 542 | p.LineTo(origin.Add(size)) 543 | p.LineTo(origin.Add(f32.Pt(0, size.Y))) 544 | p.Close() 545 | } 546 | 547 | func ellipse(p *clip.Path, center, radius f32.Point) { 548 | r := radius.X 549 | // We'll model the ellipse as a circle scaled in the Y 550 | // direction. 551 | scale := radius.Y / r 552 | 553 | // https://pomax.github.io/bezierinfo/#circles_cubic. 554 | const q = 4 * (math.Sqrt2 - 1) / 3 555 | 556 | curve := r * q 557 | top := f32.Point{X: center.X, Y: center.Y - r*scale} 558 | 559 | p.MoveTo(top) 560 | p.CubeTo( 561 | f32.Point{X: center.X + curve, Y: center.Y - r*scale}, 562 | f32.Point{X: center.X + r, Y: center.Y - curve*scale}, 563 | f32.Point{X: center.X + r, Y: center.Y}, 564 | ) 565 | p.CubeTo( 566 | f32.Point{X: center.X + r, Y: center.Y + curve*scale}, 567 | f32.Point{X: center.X + curve, Y: center.Y + r*scale}, 568 | f32.Point{X: center.X, Y: center.Y + r*scale}, 569 | ) 570 | p.CubeTo( 571 | f32.Point{X: center.X - curve, Y: center.Y + r*scale}, 572 | f32.Point{X: center.X - r, Y: center.Y + curve*scale}, 573 | f32.Point{X: center.X - r, Y: center.Y}, 574 | ) 575 | p.CubeTo( 576 | f32.Point{X: center.X - r, Y: center.Y - curve*scale}, 577 | f32.Point{X: center.X - curve, Y: center.Y - r*scale}, 578 | top, 579 | ) 580 | } 581 | ` 582 | --------------------------------------------------------------------------------