├── .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 | [](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