diff --git a/Makefile b/Makefile
index 0e133f1..58d8728 100644
--- a/Makefile
+++ b/Makefile
@@ -9,7 +9,7 @@ ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35
.PHONY: apk
-apk:
+apk: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -30,3 +30,15 @@ apk:
-targetsdk $(ANDROID_TARGET_SDK) \
-icon assets/keepassgo-icon.png \
.
+
+android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
+ @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
+ @test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
+ @mkdir -p android
+ @zsh -lc 'tmpdir=$$(mktemp -d); \
+ trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \
+ "$(JAVA_HOME)/bin/javac" \
+ -classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
+ -d "$$tmpdir" \
+ $$(find androidsrc -name '\''*.java'\'' | sort); \
+ "$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
diff --git a/android/application_snippets.xml b/android/application_snippets.xml
new file mode 100644
index 0000000..322d1b8
--- /dev/null
+++ b/android/application_snippets.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar
new file mode 100644
index 0000000..d69db1e
Binary files /dev/null and b/android/keepassgo-android.jar differ
diff --git a/android/res/xml/keepassgo_autofill_service.xml b/android/res/xml/keepassgo_autofill_service.xml
new file mode 100644
index 0000000..5129cdc
--- /dev/null
+++ b/android/res/xml/keepassgo_autofill_service.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
new file mode 100644
index 0000000..3d491b9
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
@@ -0,0 +1,167 @@
+package org.julianfamily.keepassgo;
+
+import android.content.Context;
+import android.util.JsonReader;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+final class AutofillCacheStore {
+ private static final String TAG = "KeePassGOAutofill";
+
+ private AutofillCacheStore() {
+ }
+
+ static Entry findBestMatch(Context context, String webDomain) {
+ File cacheFile = findCacheFile(context);
+ if (cacheFile == null) {
+ Log.i(TAG, "autofill cache file not found");
+ return null;
+ }
+ List entries;
+ try {
+ entries = readEntries(cacheFile);
+ } catch (IOException err) {
+ Log.e(TAG, "failed to read autofill cache", err);
+ return null;
+ }
+ if (entries.isEmpty()) {
+ return null;
+ }
+ String normalizedDomain = normalizeHost(webDomain);
+ if (normalizedDomain.isEmpty()) {
+ return entries.get(0);
+ }
+ Entry fallback = null;
+ for (Entry entry : entries) {
+ if (entry.host.equals(normalizedDomain)) {
+ return entry;
+ }
+ if (fallback == null && normalizedDomain.endsWith("." + entry.host)) {
+ fallback = entry;
+ }
+ }
+ return fallback;
+ }
+
+ private static File findCacheFile(Context context) {
+ List candidates = new ArrayList<>();
+ File filesDir = context.getFilesDir();
+ if (filesDir != null) {
+ candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
+ candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
+ }
+ File baseDir = context.getDataDir();
+ if (baseDir != null) {
+ candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
+ candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
+ }
+ for (File candidate : candidates) {
+ if (candidate.isFile()) {
+ return candidate;
+ }
+ }
+ return null;
+ }
+
+ private static List readEntries(File cacheFile) throws IOException {
+ List entries = new ArrayList<>();
+ try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ if ("entries".equals(name)) {
+ reader.beginArray();
+ while (reader.hasNext()) {
+ entries.add(readEntry(reader));
+ }
+ reader.endArray();
+ } else {
+ reader.skipValue();
+ }
+ }
+ reader.endObject();
+ }
+ return entries;
+ }
+
+ private static Entry readEntry(JsonReader reader) throws IOException {
+ String title = "";
+ String username = "";
+ String password = "";
+ String host = "";
+ reader.beginObject();
+ while (reader.hasNext()) {
+ String name = reader.nextName();
+ switch (name) {
+ case "title":
+ title = nextString(reader);
+ break;
+ case "username":
+ username = nextString(reader);
+ break;
+ case "password":
+ password = nextString(reader);
+ break;
+ case "host":
+ host = normalizeHost(nextString(reader));
+ break;
+ default:
+ reader.skipValue();
+ break;
+ }
+ }
+ reader.endObject();
+ return new Entry(title, username, password, host);
+ }
+
+ private static String nextString(JsonReader reader) throws IOException {
+ if (reader.peek() == android.util.JsonToken.NULL) {
+ reader.nextNull();
+ return "";
+ }
+ return reader.nextString();
+ }
+
+ private static String normalizeHost(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ String value = raw.trim().toLowerCase(Locale.US);
+ if (value.startsWith("http://")) {
+ value = value.substring("http://".length());
+ } else if (value.startsWith("https://")) {
+ value = value.substring("https://".length());
+ }
+ int slash = value.indexOf('/');
+ if (slash >= 0) {
+ value = value.substring(0, slash);
+ }
+ int colon = value.indexOf(':');
+ if (colon >= 0) {
+ value = value.substring(0, colon);
+ }
+ return value;
+ }
+
+ static final class Entry {
+ final String title;
+ final String username;
+ final String password;
+ final String host;
+
+ Entry(String title, String username, String password, String host) {
+ this.title = title;
+ this.username = username;
+ this.password = password;
+ this.host = host;
+ }
+ }
+}
diff --git a/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
new file mode 100644
index 0000000..9133ad2
--- /dev/null
+++ b/androidsrc/org/julianfamily/keepassgo/KeePassGOAutofillService.java
@@ -0,0 +1,168 @@
+package org.julianfamily.keepassgo;
+
+import android.app.assist.AssistStructure;
+import android.os.CancellationSignal;
+import android.service.autofill.AutofillService;
+import android.service.autofill.Dataset;
+import android.service.autofill.FillCallback;
+import android.service.autofill.FillContext;
+import android.service.autofill.FillRequest;
+import android.service.autofill.FillResponse;
+import android.service.autofill.SaveCallback;
+import android.service.autofill.SaveRequest;
+import android.text.InputType;
+import android.util.Log;
+import android.view.View;
+import android.view.autofill.AutofillId;
+import android.view.autofill.AutofillValue;
+import android.widget.RemoteViews;
+
+import java.util.List;
+
+public final class KeePassGOAutofillService extends AutofillService {
+ private static final String TAG = "KeePassGOAutofill";
+
+ @Override
+ public void onFillRequest(
+ FillRequest request,
+ CancellationSignal cancellationSignal,
+ FillCallback callback
+ ) {
+ try {
+ List contexts = request.getFillContexts();
+ if (contexts.isEmpty()) {
+ callback.onSuccess(null);
+ return;
+ }
+
+ AssistStructure structure = contexts.get(contexts.size() - 1).getStructure();
+ ParsedFields fields = new ParsedFields();
+ String webDomain = parseWindow(structure, fields);
+ if (fields.passwordId == null) {
+ callback.onSuccess(null);
+ return;
+ }
+
+ AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, webDomain);
+ if (entry == null) {
+ callback.onSuccess(null);
+ return;
+ }
+
+ RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
+ presentation.setTextViewText(
+ android.R.id.text1,
+ entry.title + " (" + entry.username + ")"
+ );
+
+ Dataset.Builder dataset = new Dataset.Builder(presentation);
+ if (fields.usernameId != null) {
+ dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
+ }
+ dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
+
+ FillResponse response = new FillResponse.Builder()
+ .addDataset(dataset.build())
+ .build();
+ callback.onSuccess(response);
+ } catch (Exception err) {
+ Log.e(TAG, "fill request failed", err);
+ callback.onFailure(err.getMessage());
+ }
+ }
+
+ @Override
+ public void onSaveRequest(SaveRequest request, SaveCallback callback) {
+ callback.onSuccess();
+ }
+
+ private static String parseWindow(AssistStructure structure, ParsedFields fields) {
+ String domain = "";
+ final int windowCount = structure.getWindowNodeCount();
+ for (int i = 0; i < windowCount; i++) {
+ AssistStructure.ViewNode root = structure.getWindowNodeAt(i).getRootViewNode();
+ String next = parseNode(root, fields);
+ if (!next.isEmpty()) {
+ domain = next;
+ }
+ }
+ return domain;
+ }
+
+ private static String parseNode(AssistStructure.ViewNode node, ParsedFields fields) {
+ String domain = "";
+ if (node.getWebDomain() != null) {
+ domain = node.getWebDomain();
+ }
+
+ AutofillId id = node.getAutofillId();
+ if (id != null) {
+ if (fields.passwordId == null && isPasswordNode(node)) {
+ fields.passwordId = id;
+ } else if (fields.usernameId == null && isUsernameNode(node)) {
+ fields.usernameId = id;
+ }
+ }
+
+ for (int i = 0; i < node.getChildCount(); i++) {
+ String childDomain = parseNode(node.getChildAt(i), fields);
+ if (!childDomain.isEmpty()) {
+ domain = childDomain;
+ }
+ }
+ return domain;
+ }
+
+ private static boolean isPasswordNode(AssistStructure.ViewNode node) {
+ int inputType = node.getInputType();
+ int variation = inputType & InputType.TYPE_MASK_VARIATION;
+ if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
+ || variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ || variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD) {
+ return true;
+ }
+ return nodeMatches(node, "password");
+ }
+
+ private static boolean isUsernameNode(AssistStructure.ViewNode node) {
+ int autofillType = node.getAutofillType();
+ if (autofillType != View.AUTOFILL_TYPE_TEXT) {
+ return false;
+ }
+ if (isPasswordNode(node)) {
+ return false;
+ }
+ return nodeMatches(node, "username")
+ || nodeMatches(node, "email")
+ || nodeMatches(node, "login")
+ || nodeMatches(node, "user");
+ }
+
+ private static boolean nodeMatches(AssistStructure.ViewNode node, String term) {
+ String lowerTerm = term.toLowerCase();
+ String[] hints = node.getAutofillHints();
+ if (hints != null) {
+ for (String hint : hints) {
+ if (hint != null && hint.toLowerCase().contains(lowerTerm)) {
+ return true;
+ }
+ }
+ }
+ if (containsLower(node.getHint(), lowerTerm)
+ || containsLower(node.getIdEntry(), lowerTerm)
+ || containsLower(node.getText(), lowerTerm)
+ || containsLower(node.getContentDescription(), lowerTerm)) {
+ return true;
+ }
+ return false;
+ }
+
+ private static boolean containsLower(CharSequence value, String term) {
+ return value != null && value.toString().toLowerCase().contains(term);
+ }
+
+ private static final class ParsedFields {
+ AutofillId usernameId;
+ AutofillId passwordId;
+ }
+}
diff --git a/autofillcache/cache.go b/autofillcache/cache.go
new file mode 100644
index 0000000..bb4254f
--- /dev/null
+++ b/autofillcache/cache.go
@@ -0,0 +1,87 @@
+package autofillcache
+
+import (
+ "encoding/json"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.julianfamily.org/keepassgo/vault"
+)
+
+type Entry struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ URL string `json:"url"`
+ Host string `json:"host"`
+ Path []string `json:"path,omitempty"`
+}
+
+type File struct {
+ UpdatedAt string `json:"updatedAt"`
+ Entries []Entry `json:"entries"`
+}
+
+func Build(model vault.Model, now time.Time) File {
+ entries := make([]Entry, 0, len(model.Entries))
+ for _, item := range model.Entries {
+ host := normalizeHost(item.URL)
+ if host == "" {
+ continue
+ }
+ if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
+ continue
+ }
+ entries = append(entries, Entry{
+ ID: item.ID,
+ Title: item.Title,
+ Username: item.Username,
+ Password: item.Password,
+ URL: item.URL,
+ Host: host,
+ Path: append([]string(nil), item.Path...),
+ })
+ }
+ return File{
+ UpdatedAt: now.UTC().Format(time.RFC3339),
+ Entries: entries,
+ }
+}
+
+func Write(path string, model vault.Model, now time.Time) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(Build(model, now), "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0o600)
+}
+
+func Clear(path string) error {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func normalizeHost(raw string) string {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return ""
+ }
+ if !strings.Contains(value, "://") {
+ value = "https://" + value
+ }
+ parsed, err := url.Parse(value)
+ if err != nil {
+ return ""
+ }
+ host := strings.TrimSpace(parsed.Hostname())
+ return strings.ToLower(host)
+}
diff --git a/autofillcache/cache_test.go b/autofillcache/cache_test.go
new file mode 100644
index 0000000..bc7899e
--- /dev/null
+++ b/autofillcache/cache_test.go
@@ -0,0 +1,88 @@
+package autofillcache
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.julianfamily.org/keepassgo/vault"
+)
+
+func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
+ got := Build(vault.Model{
+ Entries: []vault.Entry{
+ {
+ ID: "one",
+ Title: "Chrome Test",
+ Username: "joe",
+ Password: "secret",
+ URL: "https://10.0.2.2:8443/login",
+ Path: []string{"Crew", "Internet"},
+ },
+ {
+ ID: "two",
+ Title: "No Password",
+ Username: "joe",
+ URL: "https://example.com",
+ },
+ {
+ ID: "three",
+ Title: "Bare Host",
+ Username: "user",
+ Password: "pass",
+ URL: "surveillance.crew.example.invalid",
+ },
+ },
+ }, now)
+
+ if len(got.Entries) != 2 {
+ t.Fatalf("entry count = %d, want 2", len(got.Entries))
+ }
+ if got.Entries[0].Host != "10.0.2.2" {
+ t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
+ }
+ if got.Entries[1].Host != "surveillance.crew.example.invalid" {
+ t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
+ }
+ if got.UpdatedAt != "2026-03-31T12:00:00Z" {
+ t.Fatalf("updatedAt = %q", got.UpdatedAt)
+ }
+}
+
+func TestWriteAndClear(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "autofill-cache.json")
+ model := vault.Model{
+ Entries: []vault.Entry{
+ {ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
+ },
+ }
+
+ if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatalf("Write() error = %v", err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ var got File
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+ if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
+ t.Fatalf("cache entries = %#v", got.Entries)
+ }
+ if err := Clear(path); err != nil {
+ t.Fatalf("Clear() error = %v", err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("cache path still exists, stat err = %v", err)
+ }
+}
diff --git a/go.mod b/go.mod
index 580a82e..2cc9d4b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,8 @@ module git.julianfamily.org/keepassgo
go 1.26
+replace gioui.org/cmd => ./third_party/gioui-cmd
+
require (
gioui.org v0.8.0
github.com/atotto/clipboard v0.1.4
diff --git a/go.sum b/go.sum
index 9a16141..50ce7f1 100644
--- a/go.sum
+++ b/go.sum
@@ -39,8 +39,6 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
-gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
-gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
diff --git a/main.go b/main.go
index 7833540..85cbd78 100644
--- a/main.go
+++ b/main.go
@@ -30,6 +30,7 @@ import (
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens"
keepassassets "git.julianfamily.org/keepassgo/assets"
+ "git.julianfamily.org/keepassgo/autofillcache"
"git.julianfamily.org/keepassgo/appstate"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
@@ -86,6 +87,7 @@ type statePaths struct {
RecentVaultsPath string
RecentRemotesPath string
UIPreferencesPath string
+ AutofillCachePath string
}
type recentVaultRecord struct {
@@ -293,6 +295,7 @@ type ui struct {
recentVaultsPath string
uiPreferencesPath string
recentRemotesPath string
+ autofillCachePath string
editingEntry bool
groupControlsHidden bool
recentVaults []string
@@ -409,6 +412,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
recentVaultsPath: paths.RecentVaultsPath,
uiPreferencesPath: paths.UIPreferencesPath,
recentRemotesPath: paths.RecentRemotesPath,
+ autofillCachePath: paths.AutofillCachePath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
now: time.Now,
@@ -436,6 +440,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.restoreStartupLifecycleTarget()
u.loadUIPreferences()
u.filter()
+ u.syncAutofillCache()
return u
}
@@ -469,6 +474,7 @@ func defaultStatePaths(stateDir string) statePaths {
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"),
+ AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
}
}
@@ -1432,6 +1438,7 @@ func (u *ui) runAction(label string, action func() error) {
return
}
u.loadingMessage = ""
+ u.syncAutofillCache()
u.state.ErrorMessage = ""
if suppressStatusMessage(label) {
u.state.StatusMessage = ""
@@ -1442,6 +1449,18 @@ func (u *ui) runAction(label string, action func() error) {
u.statusExpiresAt = u.now().Add(statusBannerDuration)
}
+func (u *ui) syncAutofillCache() {
+ if strings.TrimSpace(u.autofillCachePath) == "" {
+ return
+ }
+ model, err := u.state.Session.Current()
+ if err != nil {
+ _ = autofillcache.Clear(u.autofillCachePath)
+ return
+ }
+ _ = autofillcache.Write(u.autofillCachePath, model, u.now())
+}
+
func suppressStatusMessage(label string) bool {
switch strings.TrimSpace(label) {
case "open vault", "open remote vault":
diff --git a/main_test.go b/main_test.go
index c795c7d..2be676c 100644
--- a/main_test.go
+++ b/main_test.go
@@ -2725,6 +2725,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
}
+ if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
+ t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
+ }
}
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
@@ -2745,6 +2748,44 @@ func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
}
+ if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
+ t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
+ }
+}
+
+func TestRunActionSynchronizesAutofillCache(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ cachePath := filepath.Join(dir, "autofill-cache.json")
+ u := newUIWithSession("desktop", &session.Manager{}, statePaths{
+ DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
+ RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
+ RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
+ UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
+ AutofillCachePath: cachePath,
+ })
+ u.masterPassword.SetText("correct horse battery staple")
+
+ u.runAction("create vault", u.createVaultAction)
+ u.entryTitle.SetText("Chrome Test")
+ u.entryUsername.SetText("joe")
+ u.entryPassword.SetText("secret")
+ u.entryURL.SetText("https://10.0.2.2:8443/login")
+ u.runAction("save entry", u.saveEntryAction)
+
+ data, err := os.ReadFile(cachePath)
+ if err != nil {
+ t.Fatalf("ReadFile(cache) error = %v", err)
+ }
+ if !strings.Contains(string(data), "\"host\": \"10.0.2.2\"") {
+ t.Fatalf("cache contents = %s, want host entry", string(data))
+ }
+
+ u.runAction("lock vault", u.lockAction)
+ if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
+ t.Fatalf("cache path still exists after lock, stat err = %v", err)
+ }
}
func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) {
diff --git a/third_party/gioui-cmd/.builds/apple.yml b/third_party/gioui-cmd/.builds/apple.yml
new file mode 100644
index 0000000..d683fff
--- /dev/null
+++ b/third_party/gioui-cmd/.builds/apple.yml
@@ -0,0 +1,67 @@
+# SPDX-License-Identifier: Unlicense OR MIT
+image: debian/testing
+packages:
+ - clang
+ - cmake
+ - curl
+ - autoconf
+ - libxml2-dev
+ - libssl-dev
+ - libz-dev
+ - llvm-dev # for cctools
+ - uuid-dev ## for cctools
+ - libplist-utils # for gogio
+sources:
+ - https://git.sr.ht/~eliasnaur/gio-cmd
+ - https://git.sr.ht/~eliasnaur/applesdks
+ - https://git.sr.ht/~eliasnaur/giouiorg
+ - https://github.com/tpoechtrager/cctools-port.git
+ - https://github.com/tpoechtrager/apple-libtapi.git
+ - https://github.com/mackyle/xar.git
+environment:
+ APPLE_TOOLCHAIN_ROOT: /home/build/appletools
+ PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
+tasks:
+ - install_go: |
+ mkdir -p /home/build/sdk
+ curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
+ - prepare_toolchain: |
+ mkdir -p $APPLE_TOOLCHAIN_ROOT
+ cd $APPLE_TOOLCHAIN_ROOT
+ tar xJf /home/build/applesdks/applesdks.tar.xz
+ mkdir bin tools
+ cd bin
+ ln -s ../toolchain/bin/x86_64-apple-darwin19-ld ld
+ ln -s ../toolchain/bin/x86_64-apple-darwin19-ar ar
+ ln -s /home/build/cctools-port/cctools/misc/lipo lipo
+ ln -s ../tools/appletoolchain xcrun
+ ln -s /usr/bin/plistutil plutil
+ cd ../tools
+ ln -s appletoolchain clang-ios
+ ln -s appletoolchain clang-macos
+ - install_appletoolchain: |
+ cd giouiorg
+ go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
+ - build_xar: |
+ cd xar/xar
+ ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
+ make
+ sudo make install
+ - build_libtapi: |
+ cd apple-libtapi
+ INSTALLPREFIX=$APPLE_TOOLCHAIN_ROOT/libtapi ./build.sh
+ ./install.sh
+ - build_cctools: |
+ cd cctools-port/cctools
+ ./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
+ make install
+ - install_gogio: |
+ cd gio-cmd
+ go install ./gogio
+ - test_ios_gogio: |
+ mkdir tmp
+ cd tmp
+ go mod init example.com
+ go get -d gioui.org/example/kitchen
+ export PATH=/home/build/appletools/bin:$PATH
+ gogio -target ios -o app.app gioui.org/example/kitchen
diff --git a/third_party/gioui-cmd/.builds/freebsd.yml b/third_party/gioui-cmd/.builds/freebsd.yml
new file mode 100644
index 0000000..fe3734c
--- /dev/null
+++ b/third_party/gioui-cmd/.builds/freebsd.yml
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: Unlicense OR MIT
+image: freebsd/13.x
+packages:
+ - libX11
+ - libxkbcommon
+ - libXcursor
+ - libXfixes
+ - vulkan-headers
+ - wayland
+ - mesa-libs
+ - xorg-vfbserver
+sources:
+ - https://git.sr.ht/~eliasnaur/gio-cmd
+environment:
+ PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
+tasks:
+ - install_go: |
+ mkdir -p /home/build/sdk
+ curl https://dl.google.com/go/go1.19.8.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
+ - test_cmd: |
+ cd gio-cmd
+ go test ./...
diff --git a/third_party/gioui-cmd/.builds/linux.yml b/third_party/gioui-cmd/.builds/linux.yml
new file mode 100644
index 0000000..1fcba05
--- /dev/null
+++ b/third_party/gioui-cmd/.builds/linux.yml
@@ -0,0 +1,91 @@
+# SPDX-License-Identifier: Unlicense OR MIT
+image: debian/bookworm
+packages:
+ - curl
+ - pkg-config
+ - libwayland-dev
+ - libx11-dev
+ - libx11-xcb-dev
+ - libxkbcommon-dev
+ - libxkbcommon-x11-dev
+ - libgles2-mesa-dev
+ - libegl1-mesa-dev
+ - libffi-dev
+ - libvulkan-dev
+ - libxcursor-dev
+ - libxrandr-dev
+ - libxinerama-dev
+ - libxi-dev
+ - libxxf86vm-dev
+ - mesa-vulkan-drivers
+ - wine
+ - xvfb
+ - xdotool
+ - scrot
+ - sway
+ - grim
+ - wine
+ - unzip
+sources:
+ - https://git.sr.ht/~eliasnaur/gio-cmd
+environment:
+ PATH: /home/build/sdk/go/bin:/usr/bin:/home/build/go/bin:/home/build/android/tools/bin
+ ANDROID_SDK_ROOT: /home/build/android
+ android_sdk_tools_zip: sdk-tools-linux-3859397.zip
+ android_ndk_zip: android-ndk-r20-linux-x86_64.zip
+ github_mirror: git@github.com:gioui/gio-cmd
+secrets:
+ - fdc570bf-87f4-4528-8aee-4d1711b1c86f
+tasks:
+ - install_go: |
+ mkdir -p /home/build/sdk
+ curl -s https://dl.google.com/go/go1.19.8.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
+ - check_gofmt: |
+ cd gio-cmd
+ test -z "$(gofmt -s -l .)"
+ - check_sign_off: |
+ set +x -e
+ cd gio-cmd
+ for hash in $(git log -n 20 --format="%H"); do
+ message=$(git log -1 --format=%B $hash)
+ if [[ ! "$message" =~ "Signed-off-by: " ]]; then
+ echo "Missing 'Signed-off-by' in commit $hash"
+ exit 1
+ fi
+ done
+ - mirror: |
+ # mirror to github
+ ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd gio-cmd && git push --mirror "$github_mirror" || echo "failed mirroring"
+ - install_chrome: |
+ curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
+ sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
+ sudo apt-get -qq update
+ sudo apt-get -qq install -y google-chrome-stable
+ - test: |
+ cd gio-cmd
+ go test ./...
+ go test -race ./...
+ - install_jdk8: |
+ curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
+ sudo apt-get -qq install -y -f ./jdk.deb
+ - install_android: |
+ mkdir android
+ cd android
+ curl -so sdk-tools.zip https://dl.google.com/android/repository/$android_sdk_tools_zip
+ unzip -q sdk-tools.zip
+ rm sdk-tools.zip
+ curl -so ndk.zip https://dl.google.com/android/repository/$android_ndk_zip
+ unzip -q ndk.zip
+ rm ndk.zip
+ mv android-ndk-* ndk-bundle
+ yes|sdkmanager --licenses
+ sdkmanager "platforms;android-31" "build-tools;32.0.0"
+ - install_gogio: |
+ cd gio-cmd
+ go install ./gogio
+ - test_android_gogio: |
+ mkdir tmp
+ cd tmp
+ go mod init example.com
+ go get -d gioui.org/example/kitchen
+ gogio -target android gioui.org/example/kitchen
diff --git a/third_party/gioui-cmd/.builds/openbsd.yml b/third_party/gioui-cmd/.builds/openbsd.yml
new file mode 100644
index 0000000..afcf5d1
--- /dev/null
+++ b/third_party/gioui-cmd/.builds/openbsd.yml
@@ -0,0 +1,18 @@
+# SPDX-License-Identifier: Unlicense OR MIT
+image: openbsd/latest
+packages:
+ - libxkbcommon
+ - go
+sources:
+ - https://git.sr.ht/~eliasnaur/gio-cmd
+environment:
+ PATH: /home/build/sdk/go/bin:/bin:/usr/local/bin:/usr/bin
+tasks:
+ - install_go: |
+ mkdir -p /home/build/sdk
+ curl https://dl.google.com/go/go1.19.8.src.tar.gz | tar -C /home/build/sdk -xzf -
+ cd /home/build/sdk/go/src
+ ./make.bash
+ - test_cmd: |
+ cd gio-cmd
+ go test ./...
diff --git a/third_party/gioui-cmd/LICENSE b/third_party/gioui-cmd/LICENSE
new file mode 100644
index 0000000..81f4733
--- /dev/null
+++ b/third_party/gioui-cmd/LICENSE
@@ -0,0 +1,63 @@
+This project is provided under the terms of the UNLICENSE or
+the MIT license denoted by the following SPDX identifier:
+
+SPDX-License-Identifier: Unlicense OR MIT
+
+You may use the project under the terms of either license.
+
+Both licenses are reproduced below.
+
+----
+The MIT License (MIT)
+
+Copyright (c) 2019 The Gio authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+---
+
+
+
+---
+The UNLICENSE
+
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to
+---
diff --git a/third_party/gioui-cmd/README.md b/third_party/gioui-cmd/README.md
new file mode 100644
index 0000000..75e9166
--- /dev/null
+++ b/third_party/gioui-cmd/README.md
@@ -0,0 +1,21 @@
+# Gio Tools
+
+Tools for the [Gio project](https://gioui.org), most notably `gogio` for packaging Gio programs.
+
+[](https://builds.sr.ht/~eliasnaur/gio-cmd)
+
+## Issues
+
+File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~eliasnaur/gio) or send an email
+to [~eliasnaur/gio@todo.sr.ht](mailto:~eliasnaur/gio@todo.sr.ht). For general discussion, use the
+mailing list: [~eliasnaur/gio@lists.sr.ht](mailto:~eliasnaur/gio@lists.sr.ht).
+
+## Contributing
+
+Post discussion to the [mailing list](https://lists.sr.ht/~eliasnaur/gio) and patches to
+[gio-patches](https://lists.sr.ht/~eliasnaur/gio-patches). No Sourcehut
+account is required and you can post without being subscribed.
+
+See the [contribution guide](https://gioui.org/doc/contribute) for more details.
+
+An [official GitHub mirror](https://github.com/gioui/gio-cmd) is available.
diff --git a/third_party/gioui-cmd/go.mod b/third_party/gioui-cmd/go.mod
new file mode 100644
index 0000000..9a506b0
--- /dev/null
+++ b/third_party/gioui-cmd/go.mod
@@ -0,0 +1,28 @@
+module gioui.org/cmd
+
+go 1.21
+
+require (
+ gioui.org v0.8.0
+ github.com/akavel/rsrc v0.10.1
+ github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4
+ github.com/chromedp/chromedp v0.5.2
+ golang.org/x/image v0.18.0
+ golang.org/x/sync v0.7.0
+ golang.org/x/text v0.16.0
+ golang.org/x/tools v0.23.0
+)
+
+require (
+ gioui.org/shader v1.0.8 // indirect
+ github.com/go-text/typesetting v0.2.1 // indirect
+ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
+ github.com/gobwas/pool v0.2.0 // indirect
+ github.com/gobwas/ws v1.0.2 // indirect
+ github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
+ github.com/mailru/easyjson v0.7.0 // indirect
+ golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
+ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
+ golang.org/x/mod v0.19.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+)
diff --git a/third_party/gioui-cmd/go.sum b/third_party/gioui-cmd/go.sum
new file mode 100644
index 0000000..bc2c6ae
--- /dev/null
+++ b/third_party/gioui-cmd/go.sum
@@ -0,0 +1,44 @@
+eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
+eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
+gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
+gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
+gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
+gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
+gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
+github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
+github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
+github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
+github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
+github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
+github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
+github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
+github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
+github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
+github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM=
+github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w=
+golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU=
+golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o=
+golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
+golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
+golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
diff --git a/third_party/gioui-cmd/gogio/android_test.go b/third_party/gioui-cmd/gogio/android_test.go
new file mode 100644
index 0000000..e73386f
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/android_test.go
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/png"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+)
+
+type AndroidTestDriver struct {
+ driverBase
+
+ sdkDir string
+ adbPath string
+}
+
+var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)
+
+func (d *AndroidTestDriver) Start(path string) {
+ d.sdkDir = os.Getenv("ANDROID_SDK_ROOT")
+ if d.sdkDir == "" {
+ d.Skipf("Android SDK is required; set $ANDROID_SDK_ROOT")
+ }
+ d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")
+ if _, err := os.Stat(d.adbPath); os.IsNotExist(err) {
+ d.Skipf("adb not found")
+ }
+
+ devOut := bytes.TrimSpace(d.adb("devices"))
+ devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
+ switch len(devices) {
+ case 0:
+ d.Skipf("no Android devices attached via adb; skipping")
+ case 1:
+ default:
+ d.Skipf("multiple Android devices attached via adb; skipping")
+ }
+
+ // If the device is attached but asleep, it's probably just charging.
+ // Don't use it; the screen needs to be on and unlocked for the test to
+ // work.
+ if !bytes.Contains(
+ d.adb("shell", "dumpsys", "power"),
+ []byte(" mWakefulness=Awake"),
+ ) {
+ d.Skipf("Android device isn't awake; skipping")
+ }
+
+ // First, build the app.
+ apk := filepath.Join(d.tempDir("gio-endtoend-android"), "e2e.apk")
+ d.gogio("-target=android", "-appid="+appid, "-o="+apk, path)
+
+ // Make sure the app isn't installed already, and try to uninstall it
+ // when we finish. Previous failed test runs might have left the app.
+ d.tryUninstall()
+ d.adb("install", apk)
+ d.Cleanup(d.tryUninstall)
+
+ // Force our e2e app to be fullscreen, so that the android system bar at
+ // the top doesn't mess with our screenshots.
+ // TODO(mvdan): is there a way to do this via gio, so that we don't need
+ // to set up a global Android setting via the shell?
+ d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)
+
+ // Make sure the app isn't already running.
+ d.adb("shell", "pm", "clear", appid)
+
+ // Start listening for log messages.
+ {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, d.adbPath,
+ "logcat",
+ "-s", // suppress other logs
+ "-T1", // don't show previous log messages
+ appid+":*", // show all logs from our gio app ID
+ )
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ d.output = output
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ }
+
+ // Start the app.
+ d.adb("shell", "monkey", "-p", appid, "1")
+
+ // Wait for the gio app to render.
+ d.waitForFrame()
+}
+
+func (d *AndroidTestDriver) Screenshot() image.Image {
+ out := d.adb("shell", "screencap", "-p")
+ img, err := png.Decode(bytes.NewReader(out))
+ if err != nil {
+ d.Fatal(err)
+ }
+ return img
+}
+
+func (d *AndroidTestDriver) tryUninstall() {
+ cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ if bytes.Contains(out, []byte("Unknown package")) {
+ // The package is not installed. Don't log anything.
+ return
+ }
+ d.Logf("could not uninstall: %v\n%s", err, out)
+ }
+}
+
+func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
+ strs := []string{}
+ for _, arg := range args {
+ strs = append(strs, fmt.Sprint(arg))
+ }
+ cmd := exec.Command(d.adbPath, strs...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ d.Errorf("%s", out)
+ d.Fatal(err)
+ }
+ return out
+}
+
+func (d *AndroidTestDriver) Click(x, y int) {
+ d.adb("shell", "input", "tap", x, y)
+
+ // Wait for the gio app to render after this click.
+ d.waitForFrame()
+}
diff --git a/third_party/gioui-cmd/gogio/androidbuild.go b/third_party/gioui-cmd/gogio/androidbuild.go
new file mode 100644
index 0000000..a26c7b1
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/androidbuild.go
@@ -0,0 +1,1110 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "golang.org/x/sync/errgroup"
+ "golang.org/x/tools/go/packages"
+)
+
+type androidTools struct {
+ buildtools string
+ androidjar string
+}
+
+// zip.Writer with a sticky error.
+type zipWriter struct {
+ err error
+ w *zip.Writer
+}
+
+// Writer that saves any errors.
+type errWriter struct {
+ w io.Writer
+ err *error
+}
+
+var exeSuffix string
+
+type manifestData struct {
+ AppID string
+ Version Semver
+ MinSDK int
+ TargetSDK int
+ Permissions []string
+ Features []string
+ IconSnip string
+ AppName string
+ ManifestSnip string
+ AppSnip string
+}
+
+const (
+ themes = `
+
+
+`
+ themesV21 = `
+
+
+`
+)
+
+func init() {
+ if runtime.GOOS == "windows" {
+ exeSuffix = ".exe"
+ }
+}
+
+func buildAndroid(tmpDir string, bi *buildInfo) error {
+ sdk := os.Getenv("ANDROID_SDK_ROOT")
+ if sdk == "" {
+ return errors.New("please set ANDROID_SDK_ROOT to the Android SDK path")
+ }
+ if _, err := os.Stat(sdk); err != nil {
+ return err
+ }
+ platform, err := latestPlatform(sdk)
+ if err != nil {
+ return err
+ }
+ buildtools, err := latestTools(sdk)
+ if err != nil {
+ return err
+ }
+
+ tools := &androidTools{
+ buildtools: buildtools,
+ androidjar: filepath.Join(platform, "android.jar"),
+ }
+ perms := []string{"default"}
+ const permPref = "gioui.org/app/permission/"
+ cfg := &packages.Config{
+ Mode: packages.NeedName +
+ packages.NeedFiles +
+ packages.NeedImports +
+ packages.NeedDeps,
+ Env: append(
+ os.Environ(),
+ "GOOS=android",
+ "CGO_ENABLED=1",
+ ),
+ }
+ pkgs, err := packages.Load(cfg, bi.pkgPath)
+ if err != nil {
+ return err
+ }
+ var extraJars []string
+ visitedPkgs := make(map[string]bool)
+ var visitPkg func(*packages.Package) error
+ visitPkg = func(p *packages.Package) error {
+ if len(p.GoFiles) == 0 {
+ return nil
+ }
+ dir := filepath.Dir(p.GoFiles[0])
+ for _, pattern := range []string{
+ filepath.Join(dir, "*.jar"),
+ filepath.Join(dir, "android", "*.jar"),
+ } {
+ jars, err := filepath.Glob(pattern)
+ if err != nil {
+ return err
+ }
+ extraJars = append(extraJars, jars...)
+ }
+ switch {
+ case p.PkgPath == "net":
+ perms = append(perms, "network")
+ case strings.HasPrefix(p.PkgPath, permPref):
+ perms = append(perms, p.PkgPath[len(permPref):])
+ }
+
+ for _, imp := range p.Imports {
+ if !visitedPkgs[imp.ID] {
+ visitPkg(imp)
+ visitedPkgs[imp.ID] = true
+ }
+ }
+ return nil
+ }
+ if err := visitPkg(pkgs[0]); err != nil {
+ return err
+ }
+
+ if err := compileAndroid(tmpDir, tools, bi); err != nil {
+ return err
+ }
+ switch *buildMode {
+ case "archive":
+ return archiveAndroid(tmpDir, bi, perms)
+ case "exe":
+ file := *destPath
+ if file == "" {
+ file = fmt.Sprintf("%s.apk", bi.name)
+ }
+
+ isBundle := false
+ switch filepath.Ext(file) {
+ case ".apk":
+ case ".aab":
+ isBundle = true
+ default:
+ return fmt.Errorf("the specified output %q does not end in '.apk' or '.aab'", file)
+ }
+
+ if err := exeAndroid(tmpDir, tools, bi, extraJars, perms, isBundle); err != nil {
+ return err
+ }
+ if isBundle {
+ return signAAB(tmpDir, file, tools, bi)
+ }
+ return signAPK(tmpDir, file, tools, bi)
+ default:
+ panic("unreachable")
+ }
+}
+
+func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err error) {
+ androidHome := os.Getenv("ANDROID_SDK_ROOT")
+ if androidHome == "" {
+ return errors.New("ANDROID_SDK_ROOT is not set. Please point it to the root of the Android SDK")
+ }
+ javac, err := findJavaC()
+ if err != nil {
+ return fmt.Errorf("could not find javac: %v", err)
+ }
+ ndkRoot, err := findNDK(androidHome)
+ if err != nil {
+ return err
+ }
+ minSDK := 17
+ if bi.minsdk > minSDK {
+ minSDK = bi.minsdk
+ }
+ tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK())
+ var builds errgroup.Group
+ for _, a := range bi.archs {
+ arch := allArchs[a]
+ clang, err := latestCompiler(tcRoot, a, minSDK)
+ if err != nil {
+ return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err)
+ }
+ if runtime.GOOS == "windows" {
+ // Because of https://github.com/android-ndk/ndk/issues/920,
+ // we need NDK r19c, not just r19b. Check for the presence of
+ // clang++.cmd which is only available in r19c.
+ clangpp := clang + "++.cmd"
+ if _, err := os.Stat(clangpp); err != nil {
+ return fmt.Errorf("NDK version r19b detected, but >= r19c is required. Use the command `sdkmanager ndk-bundle` to install it")
+ }
+ }
+ archDir := filepath.Join(tmpDir, "jni", arch.jniArch)
+ if err := os.MkdirAll(archDir, 0755); err != nil {
+ return fmt.Errorf("failed to create %q: %v", archDir, err)
+ }
+ libFile := filepath.Join(archDir, "libgio.so")
+ cmd := exec.Command(
+ "go",
+ "build",
+ "-ldflags=-w -s "+bi.ldflags,
+ "-buildmode=c-shared",
+ "-tags", bi.tags,
+ "-o", libFile,
+ bi.pkgPath,
+ )
+ cmd.Env = append(
+ os.Environ(),
+ "GOOS=android",
+ "GOARCH="+a,
+ "GOARM=7", // Avoid softfloat.
+ "CGO_ENABLED=1",
+ "CC="+clang,
+ )
+ builds.Go(func() error {
+ _, err := runCmd(cmd)
+ return err
+ })
+ }
+ appDir, err := runCmd(exec.Command("go", "list", "-tags", bi.tags, "-f", "{{.Dir}}", "gioui.org/app/"))
+ if err != nil {
+ return err
+ }
+ javaFiles, err := filepath.Glob(filepath.Join(appDir, "*.java"))
+ if err != nil {
+ return err
+ }
+ if len(javaFiles) == 0 {
+ return fmt.Errorf("the gioui.org/app package contains no .java files (gioui.org module too old?)")
+ }
+ if len(javaFiles) > 0 {
+ classes := filepath.Join(tmpDir, "classes")
+ if err := os.MkdirAll(classes, 0755); err != nil {
+ return err
+ }
+ javac := exec.Command(
+ javac,
+ "-target", "1.8",
+ "-source", "1.8",
+ "-sourcepath", appDir,
+ "-bootclasspath", tools.androidjar,
+ "-d", classes,
+ )
+ javac.Args = append(javac.Args, javaFiles...)
+ builds.Go(func() error {
+ _, err := runCmd(javac)
+ return err
+ })
+ }
+ return builds.Wait()
+}
+
+func archiveAndroid(tmpDir string, bi *buildInfo, perms []string) (err error) {
+ aarFile := *destPath
+ if aarFile == "" {
+ aarFile = fmt.Sprintf("%s.aar", bi.name)
+ }
+ if filepath.Ext(aarFile) != ".aar" {
+ return fmt.Errorf("the specified output %q does not end in '.aar'", aarFile)
+ }
+ aar, err := os.Create(aarFile)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := aar.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ aarw := newZipWriter(aar)
+ defer aarw.Close()
+ aarw.Create("R.txt")
+ themesXML := aarw.Create("res/values/themes.xml")
+ themesXML.Write([]byte(themes))
+ themesXML21 := aarw.Create("res/values-v21/themes.xml")
+ themesXML21.Write([]byte(themesV21))
+ permissions, features := getPermissions(perms)
+ // Disable input emulation on ChromeOS.
+ manifest := aarw.Create("AndroidManifest.xml")
+ manifestSrc := manifestData{
+ AppID: bi.appID,
+ MinSDK: bi.minsdk,
+ Permissions: permissions,
+ Features: features,
+ }
+ tmpl, err := template.New("manifest").Parse(
+ `
+
+{{range .Permissions}}
+{{end}}{{range .Features}}
+{{end}}
+`)
+ if err != nil {
+ panic(err)
+ }
+ err = tmpl.Execute(manifest, manifestSrc)
+ proguard := aarw.Create("proguard.txt")
+ proguard.Write([]byte(`-keep class org.gioui.** { *; }`))
+
+ for _, a := range bi.archs {
+ arch := allArchs[a]
+ libFile := filepath.Join("jni", arch.jniArch, "libgio.so")
+ aarw.Add(filepath.ToSlash(libFile), filepath.Join(tmpDir, libFile))
+ }
+ classes := filepath.Join(tmpDir, "classes")
+ if _, err := os.Stat(classes); err == nil {
+ jarFile := filepath.Join(tmpDir, "classes.jar")
+ if err := writeJar(jarFile, classes); err != nil {
+ return err
+ }
+ aarw.Add("classes.jar", jarFile)
+ }
+ return aarw.Close()
+}
+
+func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, perms []string, isBundle bool) (err error) {
+ classes := filepath.Join(tmpDir, "classes")
+ var classFiles []string
+ err = filepath.Walk(classes, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if filepath.Ext(path) == ".class" {
+ classFiles = append(classFiles, path)
+ }
+ return nil
+ })
+ classFiles = append(classFiles, extraJars...)
+ dexDir := filepath.Join(tmpDir, "apk")
+ if err := os.MkdirAll(dexDir, 0755); err != nil {
+ return err
+ }
+ minSDK := 16
+ if bi.minsdk > minSDK {
+ minSDK = bi.minsdk
+ }
+ // https://developer.android.com/distribute/best-practices/develop/target-sdk
+ targetSDK := 33
+ if bi.targetsdk > 0 {
+ targetSDK = bi.targetsdk
+ }
+ if minSDK > targetSDK {
+ targetSDK = minSDK
+ }
+ if len(classFiles) > 0 {
+ d8 := exec.Command(
+ filepath.Join(tools.buildtools, "d8"),
+ "--lib", tools.androidjar,
+ "--output", dexDir,
+ "--min-api", strconv.Itoa(minSDK),
+ )
+ d8.Args = append(d8.Args, classFiles...)
+ if _, err := runCmd(d8); err != nil {
+ major, minor, ok := determineJDKVersion()
+ if ok && (major != 1 || minor != 8) {
+ return fmt.Errorf("unsupported JDK version %d.%d, expected 1.8\nd8 error: %v", major, minor, err)
+ }
+ return err
+ }
+ }
+
+ // Compile resources.
+ resDir := filepath.Join(tmpDir, "res")
+ valDir := filepath.Join(resDir, "values")
+ v21Dir := filepath.Join(resDir, "values-v21")
+ v26mipmapDir := filepath.Join(resDir, `mipmap-anydpi-v26`)
+ for _, dir := range []string{valDir, v21Dir, v26mipmapDir} {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+ }
+ iconSnip := ""
+ if _, err := os.Stat(bi.iconPath); err == nil {
+ err := buildIcons(resDir, bi.iconPath, []iconVariant{
+ {path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72},
+ {path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96},
+ {path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144},
+ {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher.png"), size: 192},
+ {path: filepath.Join("mipmap-mdpi", "ic_launcher_adaptive.png"), size: 108},
+ {path: filepath.Join("mipmap-hdpi", "ic_launcher_adaptive.png"), size: 162},
+ {path: filepath.Join("mipmap-xhdpi", "ic_launcher_adaptive.png"), size: 216},
+ {path: filepath.Join("mipmap-xxhdpi", "ic_launcher_adaptive.png"), size: 324},
+ {path: filepath.Join("mipmap-xxxhdpi", "ic_launcher_adaptive.png"), size: 432},
+ })
+ if err != nil {
+ return err
+ }
+ err = os.WriteFile(filepath.Join(v26mipmapDir, `ic_launcher.xml`), []byte(`
+
+
+
+`), 0660)
+ if err != nil {
+ return err
+ }
+ iconSnip = `android:icon="@mipmap/ic_launcher"`
+ }
+ err = os.WriteFile(filepath.Join(valDir, "themes.xml"), []byte(themes), 0660)
+ if err != nil {
+ return err
+ }
+ err = os.WriteFile(filepath.Join(v21Dir, "themes.xml"), []byte(themesV21), 0660)
+ if err != nil {
+ return err
+ }
+ extraResDir := filepath.Join(bi.pkgDir, "android", "res")
+ if err := copyTree(extraResDir, resDir); err != nil {
+ return err
+ }
+ resZip := filepath.Join(tmpDir, "resources.zip")
+ aapt2 := filepath.Join(tools.buildtools, "aapt2")
+ _, err = runCmd(exec.Command(
+ aapt2,
+ "compile",
+ "-o", resZip,
+ "--dir", resDir))
+ if err != nil {
+ return err
+ }
+
+ // Link APK.
+ permissions, features := getPermissions(perms)
+ appName := UppercaseName(bi.name)
+ manifestSrc := manifestData{
+ AppID: bi.appID,
+ Version: bi.version,
+ MinSDK: minSDK,
+ TargetSDK: targetSDK,
+ Permissions: permissions,
+ Features: features,
+ IconSnip: iconSnip,
+ AppName: appName,
+ ManifestSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "manifest_snippets.xml")),
+ AppSnip: readOptionalText(filepath.Join(bi.pkgDir, "android", "application_snippets.xml")),
+ }
+ tmpl, err := template.New("test").Parse(
+ `
+
+
+{{range .Permissions}}
+{{end}}{{range .Features}}
+{{end}}{{.ManifestSnip}}
+{{.AppSnip}}
+
+
+
+
+
+
+
+`)
+ var manifestBuffer bytes.Buffer
+ if err := tmpl.Execute(&manifestBuffer, manifestSrc); err != nil {
+ return err
+ }
+ manifest := filepath.Join(tmpDir, "AndroidManifest.xml")
+ if err := os.WriteFile(manifest, manifestBuffer.Bytes(), 0660); err != nil {
+ return err
+ }
+
+ linkAPK := filepath.Join(tmpDir, "link.apk")
+
+ args := []string{
+ "link",
+ "--manifest", manifest,
+ "-I", tools.androidjar,
+ "-o", linkAPK,
+ }
+ if isBundle {
+ args = append(args, "--proto-format")
+ }
+ args = append(args, resZip)
+
+ if _, err := runCmd(exec.Command(aapt2, args...)); err != nil {
+ return err
+ }
+
+ // The Go standard library archive/zip doesn't support appending to zip
+ // files. Copy files from `link.apk` (generated by aapt2) along with classes.dex and
+ // the Go libraries to a new `app.zip` file.
+
+ // Load link.apk as zip.
+ linkAPKZip, err := zip.OpenReader(linkAPK)
+ if err != nil {
+ return err
+ }
+ defer linkAPKZip.Close()
+
+ // Create new "APK".
+ unsignedAPK := filepath.Join(tmpDir, "app.zip")
+ unsignedAPKFile, err := os.Create(unsignedAPK)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := unsignedAPKFile.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ unsignedAPKZip := zip.NewWriter(unsignedAPKFile)
+ defer unsignedAPKZip.Close()
+
+ // Copy files from linkAPK to unsignedAPK.
+ for _, f := range linkAPKZip.File {
+ header := zip.FileHeader{
+ Name: f.FileHeader.Name,
+ Method: f.FileHeader.Method,
+ }
+
+ if isBundle {
+ // AAB have pre-defined folders.
+ switch header.Name {
+ case "AndroidManifest.xml":
+ header.Name = "manifest/AndroidManifest.xml"
+ }
+ }
+
+ w, err := unsignedAPKZip.CreateHeader(&header)
+ if err != nil {
+ return err
+ }
+ r, err := f.Open()
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(w, r); err != nil {
+ return err
+ }
+ }
+
+ // Append new files (that doesn't exists inside the link.apk).
+ appendToZip := func(path string, file string) error {
+ f, err := os.Open(file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ w, err := unsignedAPKZip.CreateHeader(&zip.FileHeader{
+ Name: filepath.ToSlash(path),
+ Method: zip.Deflate,
+ })
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, f)
+ return err
+ }
+
+ // Append Go binaries (libgio.so).
+ for _, a := range bi.archs {
+ arch := allArchs[a]
+ libFile := filepath.Join(arch.jniArch, "libgio.so")
+ if err := appendToZip(filepath.Join("lib", libFile), filepath.Join(tmpDir, "jni", libFile)); err != nil {
+ return err
+ }
+ }
+
+ // Append classes.dex.
+ if len(classFiles) > 0 {
+ classesFolder := "classes.dex"
+ if isBundle {
+ classesFolder = "dex/classes.dex"
+ }
+ if err := appendToZip(classesFolder, filepath.Join(dexDir, "classes.dex")); err != nil {
+ return err
+ }
+ }
+
+ return unsignedAPKZip.Close()
+}
+
+func determineJDKVersion() (int, int, bool) {
+ path, err := findJavaC()
+ if err != nil {
+ return 0, 0, false
+ }
+ java := exec.Command(filepath.Join(filepath.Dir(path), "java"), "-version")
+ out, err := java.CombinedOutput()
+ if err != nil {
+ return 0, 0, false
+ }
+ var vendor string
+ var major, minor int
+ _, err = fmt.Sscanf(string(out), "%s version \"%d.%d", &vendor, &major, &minor)
+ return major, minor, err == nil
+}
+
+func signAPK(tmpDir string, apkFile string, tools *androidTools, bi *buildInfo) error {
+ if err := zipalign(tools, filepath.Join(tmpDir, "app.zip"), apkFile); err != nil {
+ return err
+ }
+
+ if bi.key == "" {
+ if err := defaultAndroidKeystore(tmpDir, bi); err != nil {
+ return err
+ }
+ }
+
+ _, err := runCmd(exec.Command(
+ filepath.Join(tools.buildtools, "apksigner"),
+ "sign",
+ "--ks-pass", "pass:"+bi.password,
+ "--ks", bi.key,
+ apkFile,
+ ))
+
+ return err
+}
+
+func signAAB(tmpDir string, aabFile string, tools *androidTools, bi *buildInfo) error {
+ allBundleTools, err := filepath.Glob(filepath.Join(tools.buildtools, "bundletool*.jar"))
+ if err != nil {
+ return err
+ }
+
+ bundletool := ""
+ for _, v := range allBundleTools {
+ bundletool = v
+ break
+ }
+
+ if bundletool == "" {
+ return fmt.Errorf("bundletool was not found at %s. Download it from https://github.com/google/bundletool/releases and move to the respective folder", tools.buildtools)
+ }
+
+ _, err = runCmd(exec.Command(
+ "java",
+ "-jar", bundletool,
+ "build-bundle",
+ "--modules="+filepath.Join(tmpDir, "app.zip"),
+ "--output="+filepath.Join(tmpDir, "app.aab"),
+ ))
+ if err != nil {
+ return err
+ }
+
+ if err := zipalign(tools, filepath.Join(tmpDir, "app.aab"), aabFile); err != nil {
+ return err
+ }
+
+ if bi.key == "" {
+ if err := defaultAndroidKeystore(tmpDir, bi); err != nil {
+ return err
+ }
+ }
+
+ keytoolList, err := runCmd(exec.Command(
+ "keytool",
+ "-keystore", bi.key,
+ "-list",
+ "-keypass", bi.password,
+ "-v",
+ ))
+ if err != nil {
+ return err
+ }
+
+ var alias string
+ for _, t := range strings.Split(keytoolList, "\n") {
+ if i, _ := fmt.Sscanf(t, "Alias name: %s", &alias); i > 0 {
+ break
+ }
+ }
+
+ _, err = runCmd(exec.Command(
+ filepath.Join("jarsigner"),
+ "-sigalg", "SHA256withRSA",
+ "-digestalg", "SHA-256",
+ "-keystore", bi.key,
+ "-storepass", bi.password,
+ aabFile,
+ strings.TrimSpace(alias),
+ ))
+
+ return err
+}
+
+func zipalign(tools *androidTools, input, output string) error {
+ _, err := runCmd(exec.Command(
+ filepath.Join(tools.buildtools, "zipalign"),
+ "-f",
+ "4", // 32-bit alignment.
+ input,
+ output,
+ ))
+ return err
+}
+
+func defaultAndroidKeystore(tmpDir string, bi *buildInfo) error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+
+ // Use debug.keystore, if exists.
+ bi.key = filepath.Join(home, ".android", "debug.keystore")
+ bi.password = "android"
+ if _, err := os.Stat(bi.key); err == nil {
+ return nil
+ }
+
+ // Generate new key.
+ bi.key = filepath.Join(tmpDir, "sign.keystore")
+ keytool, err := findKeytool()
+ if err != nil {
+ return err
+ }
+ _, err = runCmd(exec.Command(
+ keytool,
+ "-genkey",
+ "-keystore", bi.key,
+ "-storepass", bi.password,
+ "-alias", "android",
+ "-keyalg", "RSA", "-keysize", "2048",
+ "-validity", "10000",
+ "-noprompt",
+ "-dname", "CN=android",
+ ))
+ return err
+}
+
+func readOptionalText(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ if len(data) == 0 {
+ return ""
+ }
+ return "\n" + string(data) + "\n"
+}
+
+func copyTree(src, dst string) error {
+ info, err := os.Stat(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("extra Android resources path is not a directory: %s", src)
+ }
+ return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ return nil
+ }
+ target := filepath.Join(dst, rel)
+ if entry.IsDir() {
+ return os.MkdirAll(target, 0755)
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(target, data, 0660)
+ })
+}
+
+func findNDK(androidHome string) (string, error) {
+ ndks, err := filepath.Glob(filepath.Join(androidHome, "ndk", "*"))
+ if err != nil {
+ return "", err
+ }
+ if bestNDK, found := latestVersionPath(ndks); found {
+ return bestNDK, nil
+ }
+ // The old NDK path was $ANDROID_SDK_ROOT/ndk-bundle.
+ ndkBundle := filepath.Join(androidHome, "ndk-bundle")
+ if _, err := os.Stat(ndkBundle); err == nil {
+ return ndkBundle, nil
+ }
+ // Certain non-standard NDK isntallations set the $ANDROID_NDK_ROOT
+ // environment variable
+ if ndkBundle, ok := os.LookupEnv("ANDROID_NDK_ROOT"); ok {
+ if _, err := os.Stat(ndkBundle); err == nil {
+ return ndkBundle, nil
+ }
+ }
+
+ return "", fmt.Errorf("no NDK found in $ANDROID_SDK_ROOT (%s). Set $ANDROID_NDK_ROOT or use `sdkmanager ndk-bundle` to install the NDK", androidHome)
+}
+
+func findKeytool() (string, error) {
+ javaHome := os.Getenv("JAVA_HOME")
+ if javaHome == "" {
+ return exec.LookPath("keytool")
+ }
+
+ // bin, instead of "jre". "jre" was for older JVM it seems.
+ keytool := filepath.Join(javaHome, "bin", "keytool"+exeSuffix)
+ if _, err := os.Stat(keytool); err != nil {
+ return "", err
+ }
+ return keytool, nil
+}
+
+func findJavaC() (string, error) {
+ javaHome := os.Getenv("JAVA_HOME")
+ if javaHome == "" {
+ return exec.LookPath("javac")
+ }
+ javac := filepath.Join(javaHome, "bin", "javac"+exeSuffix)
+ if _, err := os.Stat(javac); err != nil {
+ return "", err
+ }
+ return javac, nil
+}
+
+func writeJar(jarFile, dir string) (err error) {
+ jar, err := os.Create(jarFile)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := jar.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ jarw := newZipWriter(jar)
+ const manifestHeader = `Manifest-Version: 1.0
+Created-By: 1.0 (Go)
+
+`
+ jarw.Create("META-INF/MANIFEST.MF").Write([]byte(manifestHeader))
+ err = filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if f.IsDir() {
+ return nil
+ }
+ if filepath.Ext(path) == ".class" {
+ rel := filepath.ToSlash(path[len(dir)+1:])
+ jarw.Add(rel, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return jarw.Close()
+}
+
+func archNDK() string {
+ var arch string
+ switch runtime.GOARCH {
+ case "386":
+ arch = "x86"
+ case "amd64":
+ arch = "x86_64"
+ case "arm64":
+ if runtime.GOOS == "darwin" {
+ // Workaround for arm64 macOS. This will keep working until
+ // Apple deprecates Rosetta 2.
+ arch = "x86_64"
+ } else {
+ panic("unsupported GOARCH: " + runtime.GOARCH)
+ }
+ default:
+ panic("unsupported GOARCH: " + runtime.GOARCH)
+ }
+ return runtime.GOOS + "-" + arch
+}
+
+func getPermissions(ps []string) ([]string, []string) {
+ var permissions, features []string
+ seenPermissions := make(map[string]bool)
+ seenFeatures := make(map[string]bool)
+ for _, perm := range ps {
+ for _, x := range AndroidPermissions[perm] {
+ if !seenPermissions[x] {
+ permissions = append(permissions, x)
+ seenPermissions[x] = true
+ }
+ }
+ for _, x := range AndroidFeatures[perm] {
+ if !seenFeatures[x] {
+ features = append(features, x)
+ seenFeatures[x] = true
+ }
+ }
+ }
+ return permissions, features
+}
+
+func latestPlatform(sdk string) (string, error) {
+ allPlats, err := filepath.Glob(filepath.Join(sdk, "platforms", "android-*"))
+ if err != nil {
+ return "", err
+ }
+ var bestVer int
+ var bestPlat string
+ for _, platform := range allPlats {
+ _, name := filepath.Split(platform)
+ // The glob above guarantees the "android-" prefix.
+ verStr := name[len("android-"):]
+ ver, err := strconv.Atoi(verStr)
+ if err != nil {
+ continue
+ }
+ if ver < bestVer {
+ continue
+ }
+ bestVer = ver
+ bestPlat = platform
+ }
+ if bestPlat == "" {
+ return "", fmt.Errorf("no platforms found in %q", sdk)
+ }
+ return bestPlat, nil
+}
+
+func latestCompiler(tcRoot, a string, minsdk int) (string, error) {
+ arch := allArchs[a]
+ allComps, err := filepath.Glob(filepath.Join(tcRoot, "bin", arch.clangArch+"*-clang"))
+ if err != nil {
+ return "", err
+ }
+ var bestVer int
+ var firstVer int
+ var bestCompiler string
+ var firstCompiler string
+ for _, compiler := range allComps {
+ var ver int
+ pattern := filepath.Join(tcRoot, "bin", arch.clangArch) + "%d-clang"
+ if n, err := fmt.Sscanf(compiler, pattern, &ver); n < 1 || err != nil {
+ continue
+ }
+ if firstCompiler == "" || ver < firstVer {
+ firstVer = ver
+ firstCompiler = compiler
+ }
+ if ver < bestVer {
+ continue
+ }
+ if ver > minsdk {
+ continue
+ }
+ bestVer = ver
+ bestCompiler = compiler
+ }
+ if bestCompiler == "" {
+ bestCompiler = firstCompiler
+ }
+ if bestCompiler == "" {
+ return "", fmt.Errorf("no NDK compiler found for architecture %s in %s", a, tcRoot)
+ }
+ return bestCompiler, nil
+}
+
+func latestTools(sdk string) (string, error) {
+ allTools, err := filepath.Glob(filepath.Join(sdk, "build-tools", "*"))
+ if err != nil {
+ return "", err
+ }
+ tools, found := latestVersionPath(allTools)
+ if !found {
+ return "", fmt.Errorf("no build-tools found in %q", sdk)
+ }
+ return tools, nil
+}
+
+// latestVersionFile finds the path with the highest version
+// among paths on the form
+//
+// /some/path/major.minor.patch
+func latestVersionPath(paths []string) (string, bool) {
+ var bestVer [3]int
+ var bestDir string
+loop:
+ for _, path := range paths {
+ name := filepath.Base(path)
+ s := strings.SplitN(name, ".", 3)
+ var version [3]int
+ for i, v := range s {
+ v, err := strconv.Atoi(v)
+ if err != nil {
+ continue loop
+ }
+ if v < bestVer[i] {
+ continue loop
+ }
+ if v > bestVer[i] {
+ break
+ }
+ version[i] = v
+ }
+ bestVer = version
+ bestDir = path
+ }
+ return bestDir, bestDir != ""
+}
+
+func newZipWriter(w io.Writer) *zipWriter {
+ return &zipWriter{
+ w: zip.NewWriter(w),
+ }
+}
+
+func (z *zipWriter) Close() error {
+ err := z.w.Close()
+ if z.err == nil {
+ z.err = err
+ }
+ return z.err
+}
+
+func (z *zipWriter) Create(name string) io.Writer {
+ if z.err != nil {
+ return io.Discard
+ }
+ w, err := z.w.Create(name)
+ if err != nil {
+ z.err = err
+ return io.Discard
+ }
+ return &errWriter{w: w, err: &z.err}
+}
+
+func (z *zipWriter) Store(name, file string) {
+ z.add(name, file, false)
+}
+
+func (z *zipWriter) Add(name, file string) {
+ z.add(name, file, true)
+}
+
+func (z *zipWriter) add(name, file string, compressed bool) {
+ if z.err != nil {
+ return
+ }
+ f, err := os.Open(file)
+ if err != nil {
+ z.err = err
+ return
+ }
+ defer f.Close()
+ fh := &zip.FileHeader{
+ Name: name,
+ }
+ if compressed {
+ fh.Method = zip.Deflate
+ }
+ w, err := z.w.CreateHeader(fh)
+ if err != nil {
+ z.err = err
+ return
+ }
+ if _, err := io.Copy(w, f); err != nil {
+ z.err = err
+ return
+ }
+}
+
+func (w *errWriter) Write(p []byte) (n int, err error) {
+ if err := *w.err; err != nil {
+ return 0, err
+ }
+ n, err = w.w.Write(p)
+ *w.err = err
+ return
+}
diff --git a/third_party/gioui-cmd/gogio/build_info.go b/third_party/gioui-cmd/gogio/build_info.go
new file mode 100644
index 0000000..cbdb7fe
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/build_info.go
@@ -0,0 +1,206 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+type buildInfo struct {
+ appID string
+ archs []string
+ ldflags string
+ minsdk int
+ targetsdk int
+ name string
+ pkgDir string
+ pkgPath string
+ iconPath string
+ tags string
+ target string
+ version Semver
+ key string
+ password string
+ notaryAppleID string
+ notaryPassword string
+ notaryTeamID string
+}
+
+type Semver struct {
+ Major, Minor, Patch int
+ VersionCode uint32
+}
+
+func newBuildInfo(pkgPath string) (*buildInfo, error) {
+ pkgMetadata, err := getPkgMetadata(pkgPath)
+ if err != nil {
+ return nil, err
+ }
+ appID := getAppID(pkgMetadata)
+ appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
+ if *iconPath != "" {
+ appIcon = *iconPath
+ }
+ appName := getPkgName(pkgMetadata)
+ if *name != "" {
+ appName = *name
+ }
+ ver, err := parseSemver(*version)
+ if err != nil {
+ return nil, err
+ }
+ bi := &buildInfo{
+ appID: appID,
+ archs: getArchs(),
+ ldflags: getLdFlags(appID),
+ minsdk: *minsdk,
+ targetsdk: *targetsdk,
+ name: appName,
+ pkgDir: pkgMetadata.Dir,
+ pkgPath: pkgPath,
+ iconPath: appIcon,
+ tags: *extraTags,
+ target: *target,
+ version: ver,
+ key: *signKey,
+ password: *signPass,
+ notaryAppleID: *notaryID,
+ notaryPassword: *notaryPass,
+ notaryTeamID: *notaryTeamID,
+ }
+ return bi, nil
+}
+
+// UppercaseName returns a string with its first rune in uppercase.
+func UppercaseName(name string) string {
+ ch, w := utf8.DecodeRuneInString(name)
+ return string(unicode.ToUpper(ch)) + name[w:]
+}
+
+func (s Semver) String() string {
+ return fmt.Sprintf("%d.%d.%d.%d", s.Major, s.Minor, s.Patch, s.VersionCode)
+}
+
+func parseSemver(v string) (Semver, error) {
+ var sv Semver
+ _, err := fmt.Sscanf(v, "%d.%d.%d.%d", &sv.Major, &sv.Minor, &sv.Patch, &sv.VersionCode)
+ if err != nil {
+ return Semver{}, fmt.Errorf("invalid semver: %q", v)
+ }
+ if sv.String() != v {
+ return Semver{}, fmt.Errorf("invalid semver: %q", v)
+ }
+ return sv, nil
+}
+
+func getArchs() []string {
+ if *archNames != "" {
+ return strings.Split(*archNames, ",")
+ }
+ switch *target {
+ case "js":
+ return []string{"wasm"}
+ case "ios", "tvos":
+ // Only 64-bit support.
+ return []string{"arm64", "amd64"}
+ case "android":
+ return []string{"arm", "arm64", "386", "amd64"}
+ case "windows":
+ goarch := os.Getenv("GOARCH")
+ if goarch == "" {
+ goarch = runtime.GOARCH
+ }
+ return []string{goarch}
+ case "macos":
+ return []string{"arm64", "amd64"}
+ default:
+ // TODO: Add flag tests.
+ panic("The target value has already been validated, this will never execute.")
+ }
+}
+
+func getLdFlags(appID string) string {
+ var ldflags []string
+ if extra := *extraLdflags; extra != "" {
+ ldflags = append(ldflags, strings.Split(extra, " ")...)
+ }
+ // Pass appID along, to be used for logging on platforms like Android.
+ ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.ID=%s", appID))
+ // Support earlier Gio versions that had a separate app id recorded.
+ // TODO: delete this in the future.
+ ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app/internal/log.appID=%s", appID))
+ // Pass along all remaining arguments to the app.
+ if appArgs := flag.Args()[1:]; len(appArgs) > 0 {
+ ldflags = append(ldflags, fmt.Sprintf("-X gioui.org/app.extraArgs=%s", strings.Join(appArgs, "|")))
+ }
+ if m := *linkMode; m != "" {
+ ldflags = append(ldflags, "-linkmode="+m)
+ }
+ return strings.Join(ldflags, " ")
+}
+
+type packageMetadata struct {
+ PkgPath string
+ Dir string
+}
+
+func getPkgMetadata(pkgPath string) (*packageMetadata, error) {
+ pkgImportPath, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.ImportPath}}", pkgPath))
+ if err != nil {
+ return nil, err
+ }
+ pkgDir, err := runCmd(exec.Command("go", "list", "-tags", *extraTags, "-f", "{{.Dir}}", pkgPath))
+ if err != nil {
+ return nil, err
+ }
+ return &packageMetadata{
+ PkgPath: pkgImportPath,
+ Dir: pkgDir,
+ }, nil
+}
+
+func getAppID(pkgMetadata *packageMetadata) string {
+ if *appID != "" {
+ return *appID
+ }
+ elems := strings.Split(pkgMetadata.PkgPath, "/")
+ domain := strings.Split(elems[0], ".")
+ name := ""
+ if len(elems) > 1 {
+ name = "." + elems[len(elems)-1]
+ }
+ if len(elems) < 2 && len(domain) < 2 {
+ name = "." + domain[0]
+ domain[0] = "localhost"
+ } else {
+ for i := 0; i < len(domain)/2; i++ {
+ opp := len(domain) - 1 - i
+ domain[i], domain[opp] = domain[opp], domain[i]
+ }
+ }
+
+ pkgDomain := strings.Join(domain, ".")
+ appid := []rune(pkgDomain + name)
+
+ // a Java-language-style package name may contain upper- and lower-case
+ // letters and underscores with individual parts separated by '.'.
+ // https://developer.android.com/guide/topics/manifest/manifest-element
+ for i, c := range appid {
+ if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' ||
+ c == '_' || c == '.') {
+ appid[i] = '_'
+ }
+ }
+ return string(appid)
+}
+
+func getPkgName(pkgMetadata *packageMetadata) string {
+ return path.Base(pkgMetadata.PkgPath)
+}
diff --git a/third_party/gioui-cmd/gogio/build_info_test.go b/third_party/gioui-cmd/gogio/build_info_test.go
new file mode 100644
index 0000000..397e2a3
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/build_info_test.go
@@ -0,0 +1,32 @@
+package main
+
+import "testing"
+
+type expval struct {
+ in, out string
+}
+
+func TestAppID(t *testing.T) {
+ t.Parallel()
+
+ tests := []expval{
+ {"example", "localhost.example"},
+ {"example.com", "com.example"},
+ {"www.example.com", "com.example.www"},
+ {"examplecom/app", "examplecom.app"},
+ {"example.com/app", "com.example.app"},
+ {"www.example.com/app", "com.example.www.app"},
+ {"www.en.example.com/app", "com.example.en.www.app"},
+ {"example.com/dir/app", "com.example.app"},
+ {"example.com/dir.ext/app", "com.example.app"},
+ {"example.com/dir/app.ext", "com.example.app.ext"},
+ {"example-com.net/dir/app", "net.example_com.app"},
+ }
+
+ for i, test := range tests {
+ got := getAppID(&packageMetadata{PkgPath: test.in})
+ if exp := test.out; got != exp {
+ t.Errorf("(%d): expected '%s', got '%s'", i, exp, got)
+ }
+ }
+}
diff --git a/third_party/gioui-cmd/gogio/doc.go b/third_party/gioui-cmd/gogio/doc.go
new file mode 100644
index 0000000..82da812
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/doc.go
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+/*
+The gogio tool builds and packages Gio programs for Android, iOS/tvOS
+and WebAssembly.
+
+Run gogio with no arguments for instructions, or see the examples at
+https://gioui.org.
+*/
+package main
diff --git a/third_party/gioui-cmd/gogio/e2e_test.go b/third_party/gioui-cmd/gogio/e2e_test.go
new file mode 100644
index 0000000..46eee7f
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/e2e_test.go
@@ -0,0 +1,337 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "bufio"
+ "errors"
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+var raceEnabled = false
+
+var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")
+
+const appid = "localhost.gogio.endtoend"
+
+// TestDriver is implemented by each of the platforms we can run end-to-end
+// tests on. None of its methods return any errors, as the errors are directly
+// reported to testing.T via methods like Fatal.
+type TestDriver interface {
+ initBase(t *testing.T, width, height int)
+
+ // Start opens the Gio app found at path. The driver should attempt to
+ // run the app with the base driver's width and height, and the
+ // platform's background should be white.
+ //
+ // When the function returns, the gio app must be ready to use on the
+ // platform, with its initial frame fully drawn.
+ Start(path string)
+
+ // Screenshot takes a screenshot of the Gio app on the platform.
+ Screenshot() image.Image
+
+ // Click performs a pointer click at the specified coordinates,
+ // including both press and release. It returns when the next frame is
+ // fully drawn.
+ Click(x, y int)
+}
+
+type driverBase struct {
+ *testing.T
+
+ width, height int
+
+ output io.Reader
+ frameNotifs chan bool
+}
+
+func (d *driverBase) initBase(t *testing.T, width, height int) {
+ d.T = t
+ d.width, d.height = width, height
+}
+
+func TestEndToEnd(t *testing.T) {
+ if testing.Short() {
+ t.Skipf("end-to-end tests tend to be slow")
+ }
+
+ t.Parallel()
+
+ const (
+ testdataWithGoImportPkgPath = "gioui.org/cmd/gogio/internal/normal"
+ testdataWithRelativePkgPath = "internal/normal/testdata.go"
+ customRenderTestdataWithRelativePkgPath = "internal/custom/testdata.go"
+ )
+ // Keep this list local, to not reuse TestDriver objects.
+ subtests := []struct {
+ name string
+ driver TestDriver
+ pkgPath string
+ skipGeese string
+ }{
+ {"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath, ""},
+ {"X11", &X11TestDriver{}, testdataWithRelativePkgPath, ""},
+ {"X11 with custom rendering", &X11TestDriver{}, customRenderTestdataWithRelativePkgPath, "openbsd,darwin,windows,netbsd"},
+ // Doesn't work on the builders.
+ //{"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath},
+ {"JS", &JSTestDriver{}, testdataWithRelativePkgPath, ""},
+ {"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath, ""},
+ {"Windows", &WineTestDriver{}, testdataWithRelativePkgPath, ""},
+ }
+
+ for _, subtest := range subtests {
+ t.Run(subtest.name, func(t *testing.T) {
+ subtest := subtest // copy the changing loop variable
+ if strings.Contains(subtest.skipGeese, runtime.GOOS) {
+ t.Skipf("not supported on %s", runtime.GOOS)
+ }
+ t.Parallel()
+ runEndToEndTest(t, subtest.driver, subtest.pkgPath)
+ })
+ }
+}
+
+func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) {
+ size := image.Point{X: 800, Y: 600}
+ driver.initBase(t, size.X, size.Y)
+
+ t.Log("starting driver and gio app")
+ driver.Start(pkgPath)
+
+ beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff}
+ white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
+ black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff}
+ gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff}
+ red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
+
+ // These are the four colors at the beginning.
+ t.Log("taking initial screenshot")
+ withRetries(t, 4*time.Second, func() error {
+ img := driver.Screenshot()
+ size = img.Bounds().Size() // override the default size
+ return checkImageCorners(img, beef, white, black, gray)
+ })
+
+ // TODO(mvdan): implement this properly in the Wayland driver; swaymsg
+ // almost works to automate clicks, but the button presses end up in the
+ // wrong coordinates.
+ if _, ok := driver.(*WaylandTestDriver); ok {
+ return
+ }
+
+ // Click the first and last sections to turn them red.
+ t.Log("clicking twice and taking another screenshot")
+ driver.Click(1*(size.X/4), 1*(size.Y/4))
+ driver.Click(3*(size.X/4), 3*(size.Y/4))
+ withRetries(t, 4*time.Second, func() error {
+ img := driver.Screenshot()
+ return checkImageCorners(img, red, white, black, red)
+ })
+}
+
+// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
+// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
+// such, timeout should generally be in the order of seconds.
+func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
+ t.Helper()
+
+ timeoutTimer := time.NewTimer(timeout)
+ defer timeoutTimer.Stop()
+ backoff := 100 * time.Millisecond
+
+ tries := 0
+ var lastErr error
+ for {
+ if lastErr = fn(); lastErr == nil {
+ return
+ }
+ tries++
+ t.Logf("retrying after %s", backoff)
+
+ // Use a timer instead of a sleep, so that the timeout can stop
+ // the backoff early. Don't reuse this timer, since we're not in
+ // a hot loop, and we don't want tricky code.
+ backoffTimer := time.NewTimer(backoff)
+ defer backoffTimer.Stop()
+
+ select {
+ case <-timeoutTimer.C:
+ t.Errorf("last error: %v", lastErr)
+ t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
+ case <-backoffTimer.C:
+ }
+
+ // Keep doubling it until a maximum. With the start at 100ms,
+ // we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
+ backoff *= 2
+ if max := 2 * time.Second; backoff > max {
+ backoff = max
+ }
+ }
+}
+
+type colorMismatch struct {
+ x, y int
+ wantRGB, gotRGB [3]uint32
+}
+
+func (m colorMismatch) String() string {
+ return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
+ m.x, m.y,
+ m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
+ m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
+ )
+}
+
+func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error {
+ // The colors are split in four rectangular sections. Check the corners
+ // of each of the sections. We check the corners left to right, top to
+ // bottom, like when reading left-to-right text.
+
+ size := img.Bounds().Size()
+ var mismatches []colorMismatch
+
+ checkColor := func(x, y int, want color.Color) {
+ r, g, b, _ := want.RGBA()
+ got := img.At(x, y)
+ r_, g_, b_, _ := got.RGBA()
+ if r_ != r || g_ != g || b_ != b {
+ mismatches = append(mismatches, colorMismatch{
+ x: x,
+ y: y,
+ wantRGB: [3]uint32{r, g, b},
+ gotRGB: [3]uint32{r_, g_, b_},
+ })
+ }
+ }
+
+ {
+ minX, minY := 5, 5
+ maxX, maxY := (size.X/2)-5, (size.Y/2)-5
+ checkColor(minX, minY, topLeft)
+ checkColor(maxX, minY, topLeft)
+ checkColor(minX, maxY, topLeft)
+ checkColor(maxX, maxY, topLeft)
+ }
+ {
+ minX, minY := (size.X/2)+5, 5
+ maxX, maxY := size.X-5, (size.Y/2)-5
+ checkColor(minX, minY, topRight)
+ checkColor(maxX, minY, topRight)
+ checkColor(minX, maxY, topRight)
+ checkColor(maxX, maxY, topRight)
+ }
+ {
+ minX, minY := 5, (size.Y/2)+5
+ maxX, maxY := (size.X/2)-5, size.Y-5
+ checkColor(minX, minY, botLeft)
+ checkColor(maxX, minY, botLeft)
+ checkColor(minX, maxY, botLeft)
+ checkColor(maxX, maxY, botLeft)
+ }
+ {
+ minX, minY := (size.X/2)+5, (size.Y/2)+5
+ maxX, maxY := size.X-5, size.Y-5
+ checkColor(minX, minY, botRight)
+ checkColor(maxX, minY, botRight)
+ checkColor(minX, maxY, botRight)
+ checkColor(maxX, maxY, botRight)
+ }
+ if n := len(mismatches); n > 0 {
+ b := new(strings.Builder)
+ fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
+ for _, m := range mismatches {
+ fmt.Fprintf(b, "%s\n", m)
+ }
+ return errors.New(b.String())
+ }
+ return nil
+}
+
+func (d *driverBase) waitForFrame() {
+ d.Helper()
+
+ if d.frameNotifs == nil {
+ // Start the goroutine that reads output lines and notifies of
+ // new frames via frameNotifs. The test doesn't wait for this
+ // goroutine to finish; it will naturally end when the output
+ // reader reaches an error like EOF.
+ d.frameNotifs = make(chan bool, 1)
+ if d.output == nil {
+ d.Fatal("need an output reader to be notified of frames")
+ }
+ go func() {
+ scanner := bufio.NewScanner(d.output)
+ for scanner.Scan() {
+ line := scanner.Text()
+ d.Log(line)
+ if strings.Contains(line, "gio frame ready") {
+ d.frameNotifs <- true
+ }
+ }
+ // Since we're only interested in the output while the
+ // app runs, and we don't know when it finishes here,
+ // ignore "already closed" pipe errors.
+ if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
+ d.Errorf("reading app output: %v", err)
+ }
+ }()
+ }
+
+ // Unfortunately, there isn't a way to select on a test failing, since
+ // testing.T doesn't have anything like a context or a "done" channel.
+ //
+ // We can't let selects block forever, since the default -test.timeout
+ // is ten minutes - far too long for tests that take seconds.
+ //
+ // For now, a static short timeout is better than nothing. 5s is plenty
+ // for our simple test app to render on any device.
+ select {
+ case <-d.frameNotifs:
+ case <-time.After(5 * time.Second):
+ d.Fatalf("timed out waiting for a frame to be ready")
+ }
+}
+
+func (d *driverBase) needPrograms(names ...string) {
+ d.Helper()
+ for _, name := range names {
+ if _, err := exec.LookPath(name); err != nil {
+ d.Skipf("%s needed to run", name)
+ }
+ }
+}
+
+func (d *driverBase) tempDir(name string) string {
+ d.Helper()
+ dir, err := os.MkdirTemp("", name)
+ if err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+}
+
+func (d *driverBase) gogio(args ...string) {
+ d.Helper()
+ prog, err := os.Executable()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd := exec.Command(prog, args...)
+ cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ d.Fatalf("gogio error: %s:\n%s", err, out)
+ }
+}
diff --git a/third_party/gioui-cmd/gogio/help.go b/third_party/gioui-cmd/gogio/help.go
new file mode 100644
index 0000000..0399e39
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/help.go
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main
+
+const mainUsage = `The gogio command builds and packages Gio (gioui.org) programs.
+
+Usage:
+
+ gogio -target [flags] [run arguments]
+
+The gogio tool builds and packages Gio programs for platforms where additional
+metadata or support files are required.
+
+The package argument specifies an import path or a single Go source file to
+package. Any run arguments are appended to os.Args at runtime.
+
+Compiled Java class files from jar files in the package directory are
+included in Android builds.
+
+The mandatory -target flag selects the target platform: ios or android for the
+mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
+MacOS and windows for Windows.
+
+The -arch flag specifies a comma separated list of GOARCHs to include. The
+default is all supported architectures.
+
+The -o flag specifies an output file or directory, depending on the target.
+
+The -buildmode flag selects the build mode. Two build modes are available, exe
+and archive. Buildmode exe outputs an .ipa file for iOS or tvOS, an .apk file
+for Android or a directory with the WebAssembly module and support files for
+a browser.
+
+The -ldflags and -tags flags pass extra linker flags and tags to the go tool.
+
+As a special case for iOS or tvOS, specifying a path that ends with ".app"
+will output an app directory suitable for a simulator.
+
+The other buildmode is archive, which will output an .aar library for Android
+or a .framework for iOS and tvOS.
+
+The -icon flag specifies a path to a PNG image to use as app icon on iOS and Android.
+If left unspecified, the appicon.png file from the main package is used
+(if it exists).
+
+The -appid flag specifies the package name for Android or the bundle id for
+iOS and tvOS. A bundle id must be provisioned through Xcode before the gogio
+tool can use it.
+
+The -version flag specifies the integer version code for Android and the last
+component of the 1.0.X version for iOS and tvOS.
+
+For Android builds the -minsdk flag specify the minimum SDK level. For example,
+use -minsdk 22 to target Android 5.1 (Lollipop) and later.
+
+For Windows builds the -minsdk flag specify the minimum OS version. For example,
+use -mindk 10 to target Windows 10 and later, -minsdk 6 for Windows Vista and later.
+
+For iOS builds the -minsdk flag specify the minimum iOS version. For example,
+use -mindk 15 to target iOS 15.0 and later.
+
+For Android builds the -targetsdk flag specify the target SDK level. For example,
+use -targetsdk 33 to target Android 13 (Tiramisu) and later.
+
+The -work flag prints the path to the working directory and suppress
+its deletion.
+
+The -x flag will print all the external commands executed by the gogio tool.
+
+The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
+or specifies the name of key on Keychain to sign MacOS app.
+
+The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
+
+The -notaryid flag specifies the Apple ID to use for notarization of MacOS app.
+
+The -notarypass flag specifies the password of the Apple ID, ignored if -notaryid is not
+provided. That must be an app-specific password, see https://support.apple.com/en-us/HT204397
+for details. If not provided, the password will be prompted.
+
+The -notaryteamid flag specifies the team ID to use for notarization of MacOS app, ignored if
+-notaryid is not provided.
+`
diff --git a/third_party/gioui-cmd/gogio/internal/custom/testdata.go b/third_party/gioui-cmd/gogio/internal/custom/testdata.go
new file mode 100644
index 0000000..5a17448
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/internal/custom/testdata.go
@@ -0,0 +1,371 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+//go:build linux
+// +build linux
+
+// This program demonstrates the use of a custom OpenGL ES context with
+// app.Window.
+package main
+
+import (
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "log"
+ "os"
+ "runtime"
+ "strings"
+ "unsafe"
+
+ "gioui.org/app"
+ "gioui.org/gpu"
+ "gioui.org/io/event"
+ "gioui.org/io/pointer"
+ "gioui.org/layout"
+ "gioui.org/op"
+ "gioui.org/op/clip"
+ "gioui.org/op/paint"
+)
+
+/*
+#cgo linux pkg-config: egl wayland-egl
+#cgo freebsd openbsd CFLAGS: -I/usr/local/include
+#cgo openbsd CFLAGS: -I/usr/X11R6/include
+#cgo freebsd LDFLAGS: -L/usr/local/lib
+#cgo openbsd LDFLAGS: -L/usr/X11R6/lib
+#cgo freebsd openbsd LDFLAGS: -lwayland-egl
+#cgo CFLAGS: -DEGL_NO_X11
+#cgo LDFLAGS: -lEGL -lGLESv2
+
+#include
+#include
+#include
+#include
+#define EGL_EGLEXT_PROTOTYPES
+#include
+
+*/
+import "C"
+
+func getDisplay(ve app.ViewEvent) C.EGLDisplay {
+ switch ve := ve.(type) {
+ case app.X11ViewEvent:
+ return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
+ case app.WaylandViewEvent:
+ return C.eglGetDisplay(C.EGLNativeDisplayType(ve.Display))
+ }
+ panic("no display available")
+}
+
+func nativeViewFor(e app.ViewEvent, size image.Point) (C.EGLNativeWindowType, func()) {
+ switch e := e.(type) {
+ case app.X11ViewEvent:
+ return C.EGLNativeWindowType(uintptr(e.Window)), func() {}
+ case app.WaylandViewEvent:
+ eglWin := C.wl_egl_window_create((*C.struct_wl_surface)(e.Surface), C.int(size.X), C.int(size.Y))
+ return C.EGLNativeWindowType(uintptr(unsafe.Pointer(eglWin))), func() {
+ C.wl_egl_window_destroy(eglWin)
+ }
+ }
+ panic("no native view available")
+}
+
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
+type notifyFrame int
+
+const (
+ notifyNone notifyFrame = iota
+ notifyInvalidate
+ notifyPrint
+)
+
+// notify keeps track of whether we want to print to stdout to notify the user
+// when a frame is ready. Initially we want to notify about the first frame.
+var notify = notifyInvalidate
+
+type eglContext struct {
+ disp C.EGLDisplay
+ ctx C.EGLContext
+ surf C.EGLSurface
+ cleanup func()
+}
+
+func main() {
+ go func() {
+ // Set CustomRenderer so we can provide our own rendering context.
+ w := new(app.Window)
+ w.Option(app.CustomRenderer(true))
+ if err := loop(w); err != nil {
+ log.Fatal(err)
+ }
+ os.Exit(0)
+ }()
+ app.Main()
+}
+
+func loop(w *app.Window) error {
+ var ops op.Ops
+ var (
+ ctx *eglContext
+ gioCtx gpu.GPU
+ ve app.ViewEvent
+ init bool
+ size image.Point
+ )
+
+ recreateContext := func() {
+ w.Run(func() {
+ if gioCtx != nil {
+ gioCtx.Release()
+ gioCtx = nil
+ }
+ if ctx != nil {
+ C.eglMakeCurrent(ctx.disp, nil, nil, nil)
+ ctx.Release()
+ ctx = nil
+ }
+ c, err := createContext(ve, size)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx = c
+ })
+ if ok := C.eglMakeCurrent(ctx.disp, ctx.surf, ctx.surf, ctx.ctx); ok != C.EGL_TRUE {
+ err := fmt.Errorf("eglMakeCurrent failed (%#x)", C.eglGetError())
+ log.Fatal(err)
+ }
+ glGetString := func(e C.GLenum) string {
+ return C.GoString((*C.char)(unsafe.Pointer(C.glGetString(e))))
+ }
+ fmt.Printf("GL_VERSION: %s\nGL_RENDERER: %s\n", glGetString(C.GL_VERSION), glGetString(C.GL_RENDERER))
+ var err error
+ gioCtx, err = gpu.New(gpu.OpenGL{ES: true, Shared: true})
+ if err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ topLeft := quarterWidget{
+ color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
+ }
+ topRight := quarterWidget{
+ color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
+ }
+ botLeft := quarterWidget{
+ color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
+ }
+ botRight := quarterWidget{
+ color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
+ }
+
+ // eglMakeCurrent binds a context to an operating system thread. Prevent Go from switching thread.
+ runtime.LockOSThread()
+ for {
+ switch e := w.Event().(type) {
+ case app.ViewEvent:
+ ve = e
+ init = true
+ if size != (image.Point{}) {
+ recreateContext()
+ }
+ case app.DestroyEvent:
+ return e.Err
+ case app.FrameEvent:
+ if init && size != e.Size {
+ size = e.Size
+ recreateContext()
+ }
+ if gioCtx == nil || !init {
+ break
+ }
+ // Build ops.
+ gtx := app.NewContext(&ops, e)
+
+ // Clear background to white, even on embedded platforms such as webassembly.
+ paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
+ layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
+ // r1c1
+ layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
+ // r1c2
+ layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
+ )
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
+ // r2c1
+ layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
+ // r2c2
+ layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
+ )
+ }),
+ )
+ gtx.Execute(op.InvalidateCmd{})
+ log.Println("frame")
+
+ // Trigger window resize detection in ANGLE.
+ C.eglWaitClient()
+ // Draw custom OpenGL content.
+ drawGL()
+
+ // Render drawing ops.
+ if err := gioCtx.Frame(gtx.Ops, gpu.OpenGLRenderTarget{}, e.Size); err != nil {
+ log.Fatal(fmt.Errorf("render failed: %v", err))
+ }
+
+ // Process non-drawing ops.
+ e.Frame(gtx.Ops)
+ switch notify {
+ case notifyInvalidate:
+ notify = notifyPrint
+ w.Invalidate()
+ case notifyPrint:
+ notify = notifyNone
+ fmt.Println("gio frame ready")
+ }
+
+ if ok := C.eglSwapBuffers(ctx.disp, ctx.surf); ok != C.EGL_TRUE {
+ log.Fatal(fmt.Errorf("swap failed: %v", C.eglGetError()))
+ }
+
+ }
+ }
+ return nil
+}
+
+func drawGL() {
+ C.glClearColor(0, 0, 0, 1)
+ C.glClear(C.GL_COLOR_BUFFER_BIT | C.GL_DEPTH_BUFFER_BIT)
+}
+
+func createContext(ve app.ViewEvent, size image.Point) (*eglContext, error) {
+ view, cleanup := nativeViewFor(ve, size)
+ var nilv C.EGLNativeWindowType
+ if view == nilv {
+ return nil, fmt.Errorf("failed creating native view")
+ }
+ disp := getDisplay(ve)
+ if disp == 0 {
+ return nil, fmt.Errorf("eglGetPlatformDisplay failed: 0x%x", C.eglGetError())
+ }
+ var major, minor C.EGLint
+ if ok := C.eglInitialize(disp, &major, &minor); ok != C.EGL_TRUE {
+ return nil, fmt.Errorf("eglInitialize failed: 0x%x", C.eglGetError())
+ }
+ exts := strings.Split(C.GoString(C.eglQueryString(disp, C.EGL_EXTENSIONS)), " ")
+ srgb := hasExtension(exts, "EGL_KHR_gl_colorspace")
+ attribs := []C.EGLint{
+ C.EGL_RENDERABLE_TYPE, C.EGL_OPENGL_ES2_BIT,
+ C.EGL_SURFACE_TYPE, C.EGL_WINDOW_BIT,
+ C.EGL_BLUE_SIZE, 8,
+ C.EGL_GREEN_SIZE, 8,
+ C.EGL_RED_SIZE, 8,
+ C.EGL_CONFIG_CAVEAT, C.EGL_NONE,
+ }
+ if srgb {
+ // Some drivers need alpha for sRGB framebuffers to work.
+ attribs = append(attribs, C.EGL_ALPHA_SIZE, 8)
+ }
+ attribs = append(attribs, C.EGL_NONE)
+ var (
+ cfg C.EGLConfig
+ numCfgs C.EGLint
+ )
+ if ok := C.eglChooseConfig(disp, &attribs[0], &cfg, 1, &numCfgs); ok != C.EGL_TRUE {
+ return nil, fmt.Errorf("eglChooseConfig failed: 0x%x", C.eglGetError())
+ }
+ if numCfgs == 0 {
+ supportsNoCfg := hasExtension(exts, "EGL_KHR_no_config_context")
+ if !supportsNoCfg {
+ return nil, errors.New("eglChooseConfig returned no configs")
+ }
+ }
+ ctxAttribs := []C.EGLint{
+ C.EGL_CONTEXT_CLIENT_VERSION, 3,
+ C.EGL_NONE,
+ }
+ ctx := C.eglCreateContext(disp, cfg, nil, &ctxAttribs[0])
+ if ctx == nil {
+ return nil, fmt.Errorf("eglCreateContext failed: 0x%x", C.eglGetError())
+ }
+ var surfAttribs []C.EGLint
+ if srgb {
+ surfAttribs = append(surfAttribs, C.EGL_GL_COLORSPACE, C.EGL_GL_COLORSPACE_SRGB)
+ }
+ surfAttribs = append(surfAttribs, C.EGL_NONE)
+ surf := C.eglCreateWindowSurface(disp, cfg, view, &surfAttribs[0])
+ if surf == nil {
+ return nil, fmt.Errorf("eglCreateWindowSurface failed (0x%x)", C.eglGetError())
+ }
+ return &eglContext{disp: disp, ctx: ctx, surf: surf, cleanup: cleanup}, nil
+}
+
+func (c *eglContext) Release() {
+ if c.ctx != nil {
+ C.eglDestroyContext(c.disp, c.ctx)
+ }
+ if c.surf != nil {
+ C.eglDestroySurface(c.disp, c.surf)
+ }
+ if c.cleanup != nil {
+ c.cleanup()
+ }
+ *c = eglContext{}
+}
+
+func hasExtension(exts []string, ext string) bool {
+ for _, e := range exts {
+ if ext == e {
+ return true
+ }
+ }
+ return false
+}
+
+// quarterWidget paints a quarter of the screen with one color. When clicked, it
+// turns red, going back to its normal color when clicked again.
+type quarterWidget struct {
+ color color.NRGBA
+
+ clicked bool
+}
+
+var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
+
+func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
+ var color color.NRGBA
+ if w.clicked {
+ color = red
+ } else {
+ color = w.color
+ }
+
+ r := image.Rectangle{Max: gtx.Constraints.Max}
+ paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
+
+ defer clip.Rect(image.Rectangle{
+ Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
+ }).Push(gtx.Ops).Pop()
+ event.Op(gtx.Ops, w)
+ for {
+ e, ok := gtx.Event(pointer.Filter{
+ Target: w,
+ Kinds: pointer.Press,
+ })
+ if !ok {
+ break
+ }
+ if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
+ w.clicked = !w.clicked
+ // notify when we're done updating the frame.
+ notify = notifyInvalidate
+ }
+ }
+ return layout.Dimensions{Size: gtx.Constraints.Max}
+}
diff --git a/third_party/gioui-cmd/gogio/internal/normal/testdata.go b/third_party/gioui-cmd/gogio/internal/normal/testdata.go
new file mode 100644
index 0000000..a78f884
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/internal/normal/testdata.go
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+// A simple app used for gogio's end-to-end tests.
+package main
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "log"
+
+ "gioui.org/app"
+ "gioui.org/io/event"
+ "gioui.org/io/pointer"
+ "gioui.org/layout"
+ "gioui.org/op"
+ "gioui.org/op/clip"
+ "gioui.org/op/paint"
+)
+
+func main() {
+ go func() {
+ w := new(app.Window)
+ if err := loop(w); err != nil {
+ log.Fatal(err)
+ }
+ }()
+ app.Main()
+}
+
+type notifyFrame int
+
+const (
+ notifyNone notifyFrame = iota
+ notifyInvalidate
+ notifyPrint
+)
+
+// notify keeps track of whether we want to print to stdout to notify the user
+// when a frame is ready. Initially we want to notify about the first frame.
+var notify = notifyInvalidate
+
+type (
+ C = layout.Context
+ D = layout.Dimensions
+)
+
+func loop(w *app.Window) error {
+ topLeft := quarterWidget{
+ color: color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff},
+ }
+ topRight := quarterWidget{
+ color: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
+ }
+ botLeft := quarterWidget{
+ color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff},
+ }
+ botRight := quarterWidget{
+ color: color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x80},
+ }
+
+ var ops op.Ops
+ for {
+ e := w.Event()
+ switch e := e.(type) {
+ case app.DestroyEvent:
+ return e.Err
+ case app.FrameEvent:
+ gtx := app.NewContext(&ops, e)
+ // Clear background to white, even on embedded platforms such as webassembly.
+ paint.Fill(gtx.Ops, color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff})
+ layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
+ // r1c1
+ layout.Flexed(1, func(gtx C) D { return topLeft.Layout(gtx) }),
+ // r1c2
+ layout.Flexed(1, func(gtx C) D { return topRight.Layout(gtx) }),
+ )
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
+ // r2c1
+ layout.Flexed(1, func(gtx C) D { return botLeft.Layout(gtx) }),
+ // r2c2
+ layout.Flexed(1, func(gtx C) D { return botRight.Layout(gtx) }),
+ )
+ }),
+ )
+
+ e.Frame(gtx.Ops)
+
+ switch notify {
+ case notifyInvalidate:
+ notify = notifyPrint
+ w.Invalidate()
+ case notifyPrint:
+ notify = notifyNone
+ fmt.Println("gio frame ready")
+ }
+ }
+ }
+}
+
+// quarterWidget paints a quarter of the screen with one color. When clicked, it
+// turns red, going back to its normal color when clicked again.
+type quarterWidget struct {
+ color color.NRGBA
+
+ clicked bool
+}
+
+var red = color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}
+
+func (w *quarterWidget) Layout(gtx layout.Context) layout.Dimensions {
+ var color color.NRGBA
+ if w.clicked {
+ color = red
+ } else {
+ color = w.color
+ }
+
+ r := image.Rectangle{Max: gtx.Constraints.Max}
+ paint.FillShape(gtx.Ops, color, clip.Rect(r).Op())
+
+ defer clip.Rect(image.Rectangle{
+ Max: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y),
+ }).Push(gtx.Ops).Pop()
+ event.Op(gtx.Ops, w)
+ filter := pointer.Filter{
+ Target: w,
+ Kinds: pointer.Press,
+ }
+
+ for {
+ e, ok := gtx.Event(filter)
+ if !ok {
+ break
+ }
+ if e, ok := e.(pointer.Event); ok && e.Kind == pointer.Press {
+ w.clicked = !w.clicked
+ // notify when we're done updating the frame.
+ notify = notifyInvalidate
+ }
+ }
+ return layout.Dimensions{Size: gtx.Constraints.Max}
+}
diff --git a/third_party/gioui-cmd/gogio/iosbuild.go b/third_party/gioui-cmd/gogio/iosbuild.go
new file mode 100644
index 0000000..1126cd5
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/iosbuild.go
@@ -0,0 +1,546 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main
+
+import (
+ "archive/zip"
+ "crypto/sha1"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/sync/errgroup"
+)
+
+const (
+ minIOSVersion = 10
+ // Some Metal features require tvOS 11
+ minTVOSVersion = 11
+ // Metal is available from iOS 8 on devices, yet from version 13 on the
+ // simulator.
+ minSimulatorVersion = 13
+)
+
+func buildIOS(tmpDir, target string, bi *buildInfo) error {
+ appName := bi.name
+ switch *buildMode {
+ case "archive":
+ framework := *destPath
+ if framework == "" {
+ framework = fmt.Sprintf("%s.framework", UppercaseName(appName))
+ }
+ return archiveIOS(tmpDir, target, framework, bi)
+ case "exe":
+ out := *destPath
+ if out == "" {
+ out = appName + ".ipa"
+ }
+ forDevice := strings.HasSuffix(out, ".ipa")
+ // Filter out unsupported architectures.
+ for i := len(bi.archs) - 1; i >= 0; i-- {
+ switch bi.archs[i] {
+ case "arm", "arm64":
+ if forDevice {
+ continue
+ }
+ case "386", "amd64":
+ if !forDevice {
+ continue
+ }
+ }
+
+ bi.archs = append(bi.archs[:i], bi.archs[i+1:]...)
+ }
+ if !forDevice && !strings.HasSuffix(out, ".app") {
+ return fmt.Errorf("the specified output directory %q does not end in .app or .ipa", out)
+ }
+ if !forDevice {
+ return exeIOS(tmpDir, target, out, bi)
+ }
+ payload := filepath.Join(tmpDir, "Payload")
+ appDir := filepath.Join(payload, appName+".app")
+ if err := os.MkdirAll(appDir, 0755); err != nil {
+ return err
+ }
+ if err := exeIOS(tmpDir, target, appDir, bi); err != nil {
+ return err
+ }
+ if err := signIOS(bi, tmpDir, appDir); err != nil {
+ return err
+ }
+ return zipDir(out, tmpDir, "Payload")
+ default:
+ panic("unreachable")
+ }
+}
+
+func signIOS(bi *buildInfo, tmpDir, app string) error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ provPattern := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles", "*.mobileprovision")
+ provisions, err := filepath.Glob(provPattern)
+ if err != nil {
+ return err
+ }
+ provInfo := filepath.Join(tmpDir, "provision.plist")
+ var avail []string
+ for _, prov := range provisions {
+ // Decode the provision file to a plist.
+ _, err := runCmd(exec.Command("security", "cms", "-D", "-i", prov, "-o", provInfo))
+ if err != nil {
+ return err
+ }
+ expUnix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ExpirationDate", provInfo))
+ if err != nil {
+ return err
+ }
+ exp, err := time.Parse(time.UnixDate, expUnix)
+ if err != nil {
+ return fmt.Errorf("sign: failed to parse expiration date from %q: %v", prov, err)
+ }
+ if exp.Before(time.Now()) {
+ continue
+ }
+ appIDPrefix, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:ApplicationIdentifierPrefix:0", provInfo))
+ if err != nil {
+ return err
+ }
+ provAppID, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:Entitlements:application-identifier", provInfo))
+ if err != nil {
+ return err
+ }
+ expAppID := fmt.Sprintf("%s.%s", appIDPrefix, bi.appID)
+ avail = append(avail, provAppID)
+ if expAppID != provAppID {
+ continue
+ }
+ // Copy provisioning file.
+ embedded := filepath.Join(app, "embedded.mobileprovision")
+ if err := copyFile(embedded, prov); err != nil {
+ return err
+ }
+ certDER, err := runCmdRaw(exec.Command("/usr/libexec/PlistBuddy", "-c", "Print:DeveloperCertificates:0", provInfo))
+ if err != nil {
+ return err
+ }
+ // Omit trailing newline.
+ certDER = certDER[:len(certDER)-1]
+ entitlements, err := runCmd(exec.Command("/usr/libexec/PlistBuddy", "-x", "-c", "Print:Entitlements", provInfo))
+ if err != nil {
+ return err
+ }
+ entFile := filepath.Join(tmpDir, "entitlements.plist")
+ if err := os.WriteFile(entFile, []byte(entitlements), 0660); err != nil {
+ return err
+ }
+ identity := sha1.Sum(certDER)
+ idHex := hex.EncodeToString(identity[:])
+ _, err = runCmd(exec.Command("codesign", "-s", idHex, "-v", "--entitlements", entFile, app))
+ return err
+ }
+ return fmt.Errorf("sign: no valid provisioning profile found for bundle id %q among %v", bi.appID, avail)
+}
+
+func exeIOS(tmpDir, target, app string, bi *buildInfo) error {
+ if bi.appID == "" {
+ return errors.New("app id is empty; use -appid to set it")
+ }
+ if err := os.RemoveAll(app); err != nil {
+ return err
+ }
+ if err := os.Mkdir(app, 0755); err != nil {
+ return err
+ }
+ appName := UppercaseName(bi.name)
+ exe := filepath.Join(app, appName)
+ lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
+ var builds errgroup.Group
+ for _, a := range bi.archs {
+ clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
+ if err != nil {
+ return err
+ }
+ cflags = append(cflags,
+ "-fobjc-arc",
+ )
+ cflagsLine := strings.Join(cflags, " ")
+ exeSlice := filepath.Join(tmpDir, "app-"+a)
+ lipo.Args = append(lipo.Args, exeSlice)
+ compile := exec.Command(
+ "go",
+ "build",
+ "-ldflags=-s -w "+bi.ldflags,
+ "-o", exeSlice,
+ "-tags", bi.tags,
+ bi.pkgPath,
+ )
+ compile.Env = append(
+ os.Environ(),
+ "GOOS=ios",
+ "GOARCH="+a,
+ "CGO_ENABLED=1",
+ "CC="+clang,
+ "CGO_CFLAGS="+cflagsLine,
+ "CGO_LDFLAGS=-lresolv "+cflagsLine,
+ )
+ builds.Go(func() error {
+ _, err := runCmd(compile)
+ return err
+ })
+ }
+ if err := builds.Wait(); err != nil {
+ return err
+ }
+ if _, err := runCmd(lipo); err != nil {
+ return err
+ }
+ infoPlist := buildInfoPlist(bi)
+ plistFile := filepath.Join(app, "Info.plist")
+ if err := os.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
+ return err
+ }
+ if _, err := os.Stat(bi.iconPath); err == nil {
+ assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
+ if err != nil {
+ return err
+ }
+ // Merge assets plist with Info.plist
+ cmd := exec.Command(
+ "/usr/libexec/PlistBuddy",
+ "-c", "Merge "+assetPlist,
+ plistFile,
+ )
+ if _, err := runCmd(cmd); err != nil {
+ return err
+ }
+ }
+ if _, err := runCmd(exec.Command("plutil", "-convert", "binary1", plistFile)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// iosIcons builds an asset catalog and compile it with the Xcode command actool.
+// iosIcons returns the asset plist file to be merged into Info.plist.
+func iosIcons(bi *buildInfo, tmpDir, appDir, icon string) (string, error) {
+ assets := filepath.Join(tmpDir, "Assets.xcassets")
+ if err := os.Mkdir(assets, 0700); err != nil {
+ return "", err
+ }
+ appIcon := filepath.Join(assets, "AppIcon.appiconset")
+ err := buildIcons(appIcon, icon, []iconVariant{
+ {path: "ios_2x.png", size: 120},
+ {path: "ios_3x.png", size: 180},
+ // The App Store icon is not allowed to contain
+ // transparent pixels.
+ {path: "ios_store.png", size: 1024, fill: true},
+ })
+ if err != nil {
+ return "", err
+ }
+ contentJson := `{
+ "images" : [
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "ios_2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "ios_3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "ios_store.png",
+ "scale" : "1x"
+ }
+ ]
+}`
+ contentFile := filepath.Join(appIcon, "Contents.json")
+ if err := os.WriteFile(contentFile, []byte(contentJson), 0600); err != nil {
+ return "", err
+ }
+ assetPlist := filepath.Join(tmpDir, "assets.plist")
+
+ minsdk := bi.minsdk
+ if minsdk == 0 {
+ minsdk = minIOSVersion
+ }
+ compile := exec.Command(
+ "actool",
+ "--compile", appDir,
+ "--platform", iosPlatformFor(bi.target),
+ "--minimum-deployment-target", strconv.Itoa(minsdk),
+ "--app-icon", "AppIcon",
+ "--output-partial-info-plist", assetPlist,
+ assets)
+ _, err = runCmd(compile)
+ return assetPlist, err
+}
+
+func buildInfoPlist(bi *buildInfo) string {
+ appName := UppercaseName(bi.name)
+ platform := iosPlatformFor(bi.target)
+ var supportPlatform string
+ switch bi.target {
+ case "ios":
+ supportPlatform = "iPhoneOS"
+ case "tvos":
+ supportPlatform = "AppleTVOS"
+ }
+ return fmt.Sprintf(`
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ %s
+ CFBundleIdentifier
+ %s
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ %s
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ %s
+ CFBundleVersion
+ %d
+ UILaunchStoryboardName
+ LaunchScreen
+ UIRequiredDeviceCapabilities
+ arm64
+ DTPlatformName
+ %s
+ DTPlatformVersion
+ 12.4
+ MinimumOSVersion
+ %d
+ UIDeviceFamily
+
+ 1
+ 2
+
+ CFBundleSupportedPlatforms
+
+ %s
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ DTCompiler
+ com.apple.compilers.llvm.clang.1_0
+ DTPlatformBuild
+ 16G73
+ DTSDKBuild
+ 16G73
+ DTSDKName
+ %s12.4
+ DTXcode
+ 1030
+ DTXcodeBuild
+ 10G8
+
+`, appName, bi.appID, appName, bi.version, bi.version.VersionCode, platform, minIOSVersion, supportPlatform, platform)
+}
+
+func iosPlatformFor(target string) string {
+ switch target {
+ case "ios":
+ return "iphoneos"
+ case "tvos":
+ return "appletvos"
+ default:
+ panic("invalid platform " + target)
+ }
+}
+
+func archiveIOS(tmpDir, target, frameworkRoot string, bi *buildInfo) error {
+ framework := filepath.Base(frameworkRoot)
+ const suf = ".framework"
+ if !strings.HasSuffix(framework, suf) {
+ return fmt.Errorf("the specified output %q does not end in '.framework'", frameworkRoot)
+ }
+ framework = framework[:len(framework)-len(suf)]
+ if err := os.RemoveAll(frameworkRoot); err != nil {
+ return err
+ }
+ frameworkDir := filepath.Join(frameworkRoot, "Versions", "A")
+ for _, dir := range []string{"Headers", "Modules"} {
+ p := filepath.Join(frameworkDir, dir)
+ if err := os.MkdirAll(p, 0755); err != nil {
+ return err
+ }
+ }
+ symlinks := [][2]string{
+ {"Versions/Current/Headers", "Headers"},
+ {"Versions/Current/Modules", "Modules"},
+ {"Versions/Current/" + framework, framework},
+ {"A", filepath.Join("Versions", "Current")},
+ }
+ for _, l := range symlinks {
+ if err := os.Symlink(l[0], filepath.Join(frameworkRoot, l[1])); err != nil && !os.IsExist(err) {
+ return err
+ }
+ }
+ exe := filepath.Join(frameworkDir, framework)
+ lipo := exec.Command("xcrun", "lipo", "-o", exe, "-create")
+ var builds errgroup.Group
+ tags := bi.tags
+ for _, a := range bi.archs {
+ clang, cflags, err := iosCompilerFor(target, a, bi.minsdk)
+ if err != nil {
+ return err
+ }
+ lib := filepath.Join(tmpDir, "gio-"+a)
+ cmd := exec.Command(
+ "go",
+ "build",
+ "-ldflags=-s -w "+bi.ldflags,
+ "-buildmode=c-archive",
+ "-o", lib,
+ "-tags", tags,
+ bi.pkgPath,
+ )
+ lipo.Args = append(lipo.Args, lib)
+ cflagsLine := strings.Join(cflags, " ")
+ cmd.Env = append(
+ os.Environ(),
+ "GOOS=ios",
+ "GOARCH="+a,
+ "CGO_ENABLED=1",
+ "CC="+clang,
+ "CGO_CFLAGS="+cflagsLine,
+ "CGO_LDFLAGS="+cflagsLine,
+ )
+ builds.Go(func() error {
+ _, err := runCmd(cmd)
+ return err
+ })
+ }
+ if err := builds.Wait(); err != nil {
+ return err
+ }
+ if _, err := runCmd(lipo); err != nil {
+ return err
+ }
+ appDir, err := runCmd(exec.Command("go", "list", "-tags", tags, "-f", "{{.Dir}}", "gioui.org/app/"))
+ if err != nil {
+ return err
+ }
+ headerDst := filepath.Join(frameworkDir, "Headers", framework+".h")
+ headerSrc := filepath.Join(appDir, "framework_ios.h")
+ if err := copyFile(headerDst, headerSrc); err != nil {
+ return err
+ }
+ module := fmt.Sprintf(`framework module "%s" {
+ header "%[1]s.h"
+
+ export *
+}`, framework)
+ moduleFile := filepath.Join(frameworkDir, "Modules", "module.modulemap")
+ return os.WriteFile(moduleFile, []byte(module), 0644)
+}
+
+func iosCompilerFor(target, arch string, minsdk int) (string, []string, error) {
+ var (
+ platformSDK string
+ platformOS string
+ )
+ switch target {
+ case "ios":
+ platformOS = "ios"
+ platformSDK = "iphone"
+ case "tvos":
+ platformOS = "tvos"
+ platformSDK = "appletv"
+ }
+ switch arch {
+ case "arm", "arm64":
+ platformSDK += "os"
+ if minsdk == 0 {
+ minsdk = minIOSVersion
+ if target == "tvos" {
+ minsdk = minTVOSVersion
+ }
+ }
+ case "386", "amd64":
+ platformOS += "-simulator"
+ platformSDK += "simulator"
+ if minsdk == 0 {
+ minsdk = minSimulatorVersion
+ }
+ default:
+ return "", nil, fmt.Errorf("unsupported -arch: %s", arch)
+ }
+ sdkPath, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--show-sdk-path"))
+ if err != nil {
+ return "", nil, err
+ }
+ clang, err := runCmd(exec.Command("xcrun", "--sdk", platformSDK, "--find", "clang"))
+ if err != nil {
+ return "", nil, err
+ }
+ cflags := []string{
+ "-fembed-bitcode",
+ "-arch", allArchs[arch].iosArch,
+ "-isysroot", sdkPath,
+ "-m" + platformOS + "-version-min=" + strconv.Itoa(minsdk),
+ }
+ return clang, cflags, nil
+}
+
+func zipDir(dst, base, dir string) (err error) {
+ f, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ zipf := zip.NewWriter(f)
+ err = filepath.Walk(filepath.Join(base, dir), func(path string, f os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if f.IsDir() {
+ return nil
+ }
+ rel := filepath.ToSlash(path[len(base)+1:])
+ entry, err := zipf.Create(rel)
+ if err != nil {
+ return err
+ }
+ src, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer src.Close()
+ _, err = io.Copy(entry, src)
+ return err
+ })
+ if err != nil {
+ return err
+ }
+ return zipf.Close()
+}
diff --git a/third_party/gioui-cmd/gogio/js_test.go b/third_party/gioui-cmd/gogio/js_test.go
new file mode 100644
index 0000000..8584894
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/js_test.go
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image"
+ "image/png"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ _ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there
+)
+
+type JSTestDriver struct {
+ driverBase
+
+ // ctx is the chromedp context.
+ ctx context.Context
+}
+
+func (d *JSTestDriver) Start(path string) {
+ if raceEnabled {
+ d.Skipf("js/wasm doesn't support -race; skipping")
+ }
+
+ // First, build the app.
+ dir := d.tempDir("gio-endtoend-js")
+ d.gogio("-target=js", "-o="+dir, path)
+
+ // Second, start Chrome.
+ opts := append(chromedp.DefaultExecAllocatorOptions[:],
+ chromedp.Flag("headless", *headless),
+ )
+
+ actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
+ d.Cleanup(cancel)
+
+ ctx, cancel := chromedp.NewContext(actx,
+ // Send all logf/errf calls to t.Logf
+ chromedp.WithLogf(d.Logf),
+ )
+ d.Cleanup(cancel)
+ d.ctx = ctx
+
+ if err := chromedp.Run(ctx); err != nil {
+ if errors.Is(err, exec.ErrNotFound) {
+ d.Skipf("test requires Chrome to be installed: %v", err)
+ return
+ }
+ d.Fatal(err)
+ }
+ pr, pw := io.Pipe()
+ d.Cleanup(func() { pw.Close() })
+ d.output = pr
+ chromedp.ListenTarget(ctx, func(ev interface{}) {
+ switch ev := ev.(type) {
+ case *runtime.EventConsoleAPICalled:
+ switch ev.Type {
+ case "log", "info", "warning", "error":
+ var b bytes.Buffer
+ b.WriteString("console.")
+ b.WriteString(string(ev.Type))
+ b.WriteString("(")
+ for i, arg := range ev.Args {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ b.Write(arg.Value)
+ }
+ b.WriteString(")\n")
+ pw.Write(b.Bytes())
+ }
+ }
+ })
+
+ // Third, serve the app folder, set the browser tab dimensions, and
+ // navigate to the folder.
+ ts := httptest.NewServer(http.FileServer(http.Dir(dir)))
+ d.Cleanup(ts.Close)
+
+ if err := chromedp.Run(ctx,
+ chromedp.EmulateViewport(int64(d.width), int64(d.height)),
+ chromedp.Navigate(ts.URL),
+ ); err != nil {
+ d.Fatal(err)
+ }
+
+ // Wait for the gio app to render.
+ d.waitForFrame()
+}
+
+func (d *JSTestDriver) Screenshot() image.Image {
+ var buf []byte
+ if err := chromedp.Run(d.ctx,
+ chromedp.CaptureScreenshot(&buf),
+ ); err != nil {
+ d.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(buf))
+ if err != nil {
+ d.Fatal(err)
+ }
+ return img
+}
+
+func (d *JSTestDriver) Click(x, y int) {
+ if err := chromedp.Run(d.ctx,
+ chromedp.MouseClickXY(float64(x), float64(y)),
+ ); err != nil {
+ d.Fatal(err)
+ }
+
+ // Wait for the gio app to render after this click.
+ d.waitForFrame()
+}
diff --git a/third_party/gioui-cmd/gogio/jsbuild.go b/third_party/gioui-cmd/gogio/jsbuild.go
new file mode 100644
index 0000000..b99f048
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/jsbuild.go
@@ -0,0 +1,200 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "text/template"
+
+ "golang.org/x/tools/go/packages"
+)
+
+func buildJS(bi *buildInfo) error {
+ out := *destPath
+ if out == "" {
+ out = bi.name
+ }
+ if err := os.MkdirAll(out, 0700); err != nil {
+ return err
+ }
+ cmd := exec.Command(
+ "go",
+ "build",
+ "-ldflags="+bi.ldflags,
+ "-tags="+bi.tags,
+ "-o", filepath.Join(out, "main.wasm"),
+ bi.pkgPath,
+ )
+ cmd.Env = append(
+ os.Environ(),
+ "GOOS=js",
+ "GOARCH=wasm",
+ )
+ _, err := runCmd(cmd)
+ if err != nil {
+ return err
+ }
+
+ var faviconPath string
+ if _, err := os.Stat(bi.iconPath); err == nil {
+ // Copy icon to the output folder
+ icon, err := os.ReadFile(bi.iconPath)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(out, filepath.Base(bi.iconPath)), icon, 0600); err != nil {
+ return err
+ }
+ faviconPath = filepath.Base(bi.iconPath)
+ }
+
+ indexTemplate, err := template.New("").Parse(jsIndex)
+ if err != nil {
+ return err
+ }
+
+ var b bytes.Buffer
+ if err := indexTemplate.Execute(&b, struct {
+ Name string
+ Icon string
+ }{
+ Name: bi.name,
+ Icon: faviconPath,
+ }); err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(filepath.Join(out, "index.html"), b.Bytes(), 0600); err != nil {
+ return err
+ }
+
+ goroot, err := runCmd(exec.Command("go", "env", "GOROOT"))
+ if err != nil {
+ return err
+ }
+ wasmJS := filepath.Join(goroot, "misc", "wasm", "wasm_exec.js")
+ if _, err := os.Stat(wasmJS); err != nil {
+ return fmt.Errorf("failed to find $GOROOT/misc/wasm/wasm_exec.js driver: %v", err)
+ }
+ pkgs, err := packages.Load(&packages.Config{
+ Mode: packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedDeps,
+ Env: append(os.Environ(), "GOOS=js", "GOARCH=wasm"),
+ }, bi.pkgPath)
+ if err != nil {
+ return err
+ }
+ extraJS, err := findPackagesJS(pkgs[0], make(map[string]bool))
+ if err != nil {
+ return err
+ }
+
+ return mergeJSFiles(filepath.Join(out, "wasm.js"), append([]string{wasmJS}, extraJS...)...)
+}
+
+func findPackagesJS(p *packages.Package, visited map[string]bool) (extraJS []string, err error) {
+ if len(p.GoFiles) == 0 {
+ return nil, nil
+ }
+ js, err := filepath.Glob(filepath.Join(filepath.Dir(p.GoFiles[0]), "*_js.js"))
+ if err != nil {
+ return nil, err
+ }
+ extraJS = append(extraJS, js...)
+ for _, imp := range p.Imports {
+ if !visited[imp.ID] {
+ extra, err := findPackagesJS(imp, visited)
+ if err != nil {
+ return nil, err
+ }
+ extraJS = append(extraJS, extra...)
+ visited[imp.ID] = true
+ }
+ }
+ return extraJS, nil
+}
+
+// mergeJSFiles will merge all files into a single `wasm.js`. It will prepend the jsSetGo
+// and append the jsStartGo.
+func mergeJSFiles(dst string, files ...string) (err error) {
+ w, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := w.Close(); err != nil {
+ err = cerr
+ }
+ }()
+ _, err = io.Copy(w, strings.NewReader(jsSetGo))
+ if err != nil {
+ return err
+ }
+ for i := range files {
+ r, err := os.Open(files[i])
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, r)
+ r.Close()
+ if err != nil {
+ return err
+ }
+ }
+ _, err = io.Copy(w, strings.NewReader(jsStartGo))
+ return err
+}
+
+const (
+ jsIndex = `
+
+
+
+
+
+ {{ if .Icon }}{{ end }}
+ {{ if .Name }}{{.Name}}{{ end }}
+
+
+
+
+
+`
+ // jsSetGo sets the `window.go` variable.
+ jsSetGo = `(() => {
+ window.go = {argv: [], env: {}, importObject: {go: {}}};
+ const argv = new URLSearchParams(location.search).get("argv");
+ if (argv) {
+ window.go["argv"] = argv.split(" ");
+ }
+})();`
+ // jsStartGo initializes the main.wasm.
+ jsStartGo = `(() => {
+ defaultGo = new Go();
+ Object.assign(defaultGo["argv"], defaultGo["argv"].concat(go["argv"]));
+ Object.assign(defaultGo["env"], go["env"]);
+ for (let key in go["importObject"]) {
+ if (typeof defaultGo["importObject"][key] === "undefined") {
+ defaultGo["importObject"][key] = {};
+ }
+ Object.assign(defaultGo["importObject"][key], go["importObject"][key]);
+ }
+ window.go = defaultGo;
+ if (!WebAssembly.instantiateStreaming) { // polyfill
+ WebAssembly.instantiateStreaming = async (resp, importObject) => {
+ const source = await (await resp).arrayBuffer();
+ return await WebAssembly.instantiate(source, importObject);
+ };
+ }
+ WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
+ go.run(result.instance);
+ });
+})();`
+)
diff --git a/third_party/gioui-cmd/gogio/macosbuild.go b/third_party/gioui-cmd/gogio/macosbuild.go
new file mode 100644
index 0000000..88e9463
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/macosbuild.go
@@ -0,0 +1,262 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "text/template"
+)
+
+func buildMac(tmpDir string, bi *buildInfo) error {
+ builder := &macBuilder{TempDir: tmpDir}
+ builder.DestDir = *destPath
+ if builder.DestDir == "" {
+ builder.DestDir = bi.pkgPath
+ }
+
+ name := bi.name
+ if *destPath != "" {
+ if filepath.Ext(*destPath) != ".app" {
+ return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
+ }
+ name = filepath.Base(*destPath)
+ }
+ name = strings.TrimSuffix(name, ".app")
+
+ if bi.appID == "" {
+ return errors.New("app id is empty; use -appid to set it")
+ }
+
+ if err := builder.setIcon(bi.iconPath); err != nil {
+ return err
+ }
+
+ if err := builder.setInfo(bi, name); err != nil {
+ return fmt.Errorf("can't build the resources: %v", err)
+ }
+
+ for _, arch := range bi.archs {
+ tmpDest := filepath.Join(builder.TempDir, filepath.Base(builder.DestDir))
+ finalDest := builder.DestDir
+ if len(bi.archs) > 1 {
+ tmpDest = filepath.Join(builder.TempDir, name+"_"+arch+".app")
+ finalDest = filepath.Join(builder.DestDir, name+"_"+arch+".app")
+ }
+
+ if err := builder.buildProgram(bi, tmpDest, name, arch); err != nil {
+ return err
+ }
+
+ if bi.key != "" {
+ if err := builder.signProgram(bi, tmpDest, name, arch); err != nil {
+ return err
+ }
+ }
+
+ if err := dittozip(tmpDest, tmpDest+".zip"); err != nil {
+ return err
+ }
+
+ if bi.notaryAppleID != "" {
+ if err := builder.notarize(bi, tmpDest+".zip"); err != nil {
+ return err
+ }
+ }
+
+ if err := dittounzip(tmpDest+".zip", finalDest); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type macBuilder struct {
+ TempDir string
+ DestDir string
+
+ Icons []byte
+ Manifest []byte
+ Entitlements []byte
+}
+
+func (b *macBuilder) setIcon(path string) (err error) {
+ if _, err := os.Stat(path); err != nil {
+ return nil
+ }
+
+ out := filepath.Join(b.TempDir, "iconset.iconset")
+ if err := os.MkdirAll(out, 0777); err != nil {
+ return err
+ }
+
+ err = buildIcons(out, path, []iconVariant{
+ {path: "icon_512x512@2x.png", size: 1024},
+ {path: "icon_512x512.png", size: 512},
+ {path: "icon_256x256@2x.png", size: 512},
+ {path: "icon_256x256.png", size: 256},
+ {path: "icon_128x128@2x.png", size: 256},
+ {path: "icon_128x128.png", size: 128},
+ {path: "icon_64x64@2x.png", size: 128},
+ {path: "icon_64x64.png", size: 64},
+ {path: "icon_32x32@2x.png", size: 64},
+ {path: "icon_32x32.png", size: 32},
+ {path: "icon_16x16@2x.png", size: 32},
+ {path: "icon_16x16.png", size: 16},
+ })
+
+ if err != nil {
+ return err
+ }
+
+ cmd := exec.Command("iconutil",
+ "-c", "icns", out,
+ "-o", filepath.Join(b.TempDir, "icon.icns"))
+ if _, err := runCmd(cmd); err != nil {
+ return err
+ }
+
+ b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
+ return err
+}
+
+func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
+ t, err := template.New("manifest").Parse(`
+
+
+
+ CFBundleExecutable
+ {{.Name}}
+ CFBundleIconFile
+ icon.icns
+ CFBundleIdentifier
+ {{.Bundle}}
+ NSHighResolutionCapable
+
+ CFBundlePackageType
+ APPL
+
+`)
+ if err != nil {
+ return err
+ }
+
+ var manifest bufferCoff
+ if err := t.Execute(&manifest, struct {
+ Name, Bundle string
+ }{
+ Name: name,
+ Bundle: buildInfo.appID,
+ }); err != nil {
+ return err
+ }
+ b.Manifest = manifest.Bytes()
+
+ b.Entitlements = []byte(`
+
+
+
+com.apple.security.cs.allow-unsigned-executable-memory
+
+com.apple.security.cs.allow-jit
+
+
+`)
+
+ return nil
+}
+
+func (b *macBuilder) buildProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
+ for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
+ if err := os.MkdirAll(filepath.Join(binDest, path), 0755); err != nil {
+ return err
+ }
+ }
+
+ if len(b.Icons) > 0 {
+ if err := os.WriteFile(filepath.Join(binDest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
+ return err
+ }
+ }
+
+ if err := os.WriteFile(filepath.Join(binDest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
+ return err
+ }
+
+ cmd := exec.Command(
+ "go",
+ "build",
+ "-ldflags="+buildInfo.ldflags,
+ "-tags="+buildInfo.tags,
+ "-o", filepath.Join(binDest, "/Contents/MacOS/"+name),
+ buildInfo.pkgPath,
+ )
+ cmd.Env = append(
+ os.Environ(),
+ "GOOS=darwin",
+ "GOARCH="+arch,
+ "CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
+ )
+ _, err := runCmd(cmd)
+ return err
+}
+
+func (b *macBuilder) signProgram(buildInfo *buildInfo, binDest string, name string, arch string) error {
+ options := filepath.Join(b.TempDir, "ent.ent")
+ if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
+ return err
+ }
+
+ xattr := exec.Command("xattr", "-rc", binDest)
+ if _, err := runCmd(xattr); err != nil {
+ return err
+ }
+
+ cmd := exec.Command(
+ "codesign",
+ "--deep",
+ "--force",
+ "--options", "runtime",
+ "--entitlements", options,
+ "--sign", buildInfo.key,
+ binDest,
+ )
+ _, err := runCmd(cmd)
+ return err
+}
+
+func (b *macBuilder) notarize(buildInfo *buildInfo, binDest string) error {
+ cmd := exec.Command(
+ "xcrun",
+ "notarytool",
+ "submit",
+ binDest,
+ "--apple-id", buildInfo.notaryAppleID,
+ "--team-id", buildInfo.notaryTeamID,
+ "--wait",
+ )
+
+ if buildInfo.notaryPassword != "" {
+ cmd.Args = append(cmd.Args, "--password", buildInfo.notaryPassword)
+ }
+
+ _, err := runCmd(cmd)
+ return err
+}
+
+func dittozip(input, output string) error {
+ cmd := exec.Command("ditto", "-c", "-k", "-X", "--rsrc", input, output)
+
+ _, err := runCmd(cmd)
+ return err
+}
+
+func dittounzip(input, output string) error {
+ cmd := exec.Command("ditto", "-x", "-k", "-X", "--rsrc", input, output)
+
+ _, err := runCmd(cmd)
+ return err
+}
diff --git a/third_party/gioui-cmd/gogio/main.go b/third_party/gioui-cmd/gogio/main.go
new file mode 100644
index 0000000..9917918
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/main.go
@@ -0,0 +1,230 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main
+
+import (
+ "bytes"
+ "errors"
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/image/draw"
+ "golang.org/x/sync/errgroup"
+)
+
+var (
+ target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
+ archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
+ minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
+ targetsdk = flag.Int("targetsdk", 0, "specify the target supported operating system level for Android")
+ buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
+ destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
+ appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
+ name = flag.String("name", "", "app name (for -buildmode=exe)")
+ version = flag.String("version", "1.0.0.1", "semver app version (for -buildmode=exe) on the form major.minor.patch.versioncode")
+ printCommands = flag.Bool("x", false, "print the commands")
+ keepWorkdir = flag.Bool("work", false, "print the name of the temporary work directory and do not delete it when exiting.")
+ linkMode = flag.String("linkmode", "", "set the -linkmode flag of the go tool")
+ extraLdflags = flag.String("ldflags", "", "extra flags to the Go linker")
+ extraTags = flag.String("tags", "", "extra tags to the Go tool")
+ iconPath = flag.String("icon", "", "specify an icon for iOS and Android")
+ signKey = flag.String("signkey", "", "specify the path of the keystore to be used to sign Android apk files.")
+ signPass = flag.String("signpass", "", "specify the password to decrypt the signkey.")
+ notaryID = flag.String("notaryid", "", "specify the apple id to use for notarization.")
+ notaryPass = flag.String("notarypass", "", "specify app-specific password of the Apple ID to be used for notarization.")
+ notaryTeamID = flag.String("notaryteamid", "", "specify the team id to use for notarization.")
+)
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprint(os.Stderr, mainUsage)
+ }
+ flag.Parse()
+ if err := flagValidate(); err != nil {
+ fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
+ os.Exit(1)
+ }
+ buildInfo, err := newBuildInfo(flag.Arg(0))
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
+ os.Exit(1)
+ }
+ if err := build(buildInfo); err != nil {
+ fmt.Fprintf(os.Stderr, "gogio: %v\n", err)
+ os.Exit(1)
+ }
+ os.Exit(0)
+}
+
+func flagValidate() error {
+ pkgPathArg := flag.Arg(0)
+ if pkgPathArg == "" {
+ return errors.New("specify a package")
+ }
+ if *target == "" {
+ return errors.New("please specify -target")
+ }
+ switch *target {
+ case "ios", "tvos", "android", "js", "windows", "macos":
+ default:
+ return fmt.Errorf("invalid -target %s", *target)
+ }
+ switch *buildMode {
+ case "archive", "exe":
+ default:
+ return fmt.Errorf("invalid -buildmode %s", *buildMode)
+ }
+ return nil
+}
+
+func build(bi *buildInfo) error {
+ tmpDir, err := os.MkdirTemp("", "gogio-")
+ if err != nil {
+ return err
+ }
+ if *keepWorkdir {
+ fmt.Fprintf(os.Stderr, "WORKDIR=%s\n", tmpDir)
+ } else {
+ defer os.RemoveAll(tmpDir)
+ }
+ switch *target {
+ case "js":
+ return buildJS(bi)
+ case "ios", "tvos":
+ return buildIOS(tmpDir, *target, bi)
+ case "android":
+ return buildAndroid(tmpDir, bi)
+ case "windows":
+ return buildWindows(tmpDir, bi)
+ case "macos":
+ return buildMac(tmpDir, bi)
+ default:
+ panic("unreachable")
+ }
+}
+
+func runCmdRaw(cmd *exec.Cmd) ([]byte, error) {
+ if *printCommands {
+ fmt.Printf("%s\n", strings.Join(cmd.Args, " "))
+ }
+ out, err := cmd.Output()
+ if err == nil {
+ return out, nil
+ }
+ if err, ok := err.(*exec.ExitError); ok {
+ return nil, fmt.Errorf("%s failed: %s%s", strings.Join(cmd.Args, " "), out, err.Stderr)
+ }
+ return nil, err
+}
+
+func runCmd(cmd *exec.Cmd) (string, error) {
+ out, err := runCmdRaw(cmd)
+ return string(bytes.TrimSpace(out)), err
+}
+
+func copyFile(dst, src string) (err error) {
+ r, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+ w, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := w.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ _, err = io.Copy(w, r)
+ return err
+}
+
+type arch struct {
+ iosArch string
+ jniArch string
+ clangArch string
+}
+
+var allArchs = map[string]arch{
+ "arm": {
+ iosArch: "armv7",
+ jniArch: "armeabi-v7a",
+ clangArch: "armv7a-linux-androideabi",
+ },
+ "arm64": {
+ iosArch: "arm64",
+ jniArch: "arm64-v8a",
+ clangArch: "aarch64-linux-android",
+ },
+ "386": {
+ iosArch: "i386",
+ jniArch: "x86",
+ clangArch: "i686-linux-android",
+ },
+ "amd64": {
+ iosArch: "x86_64",
+ jniArch: "x86_64",
+ clangArch: "x86_64-linux-android",
+ },
+}
+
+type iconVariant struct {
+ path string
+ size int
+ fill bool
+}
+
+func buildIcons(baseDir, icon string, variants []iconVariant) error {
+ f, err := os.Open(icon)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ img, _, err := image.Decode(f)
+ if err != nil {
+ return err
+ }
+ var resizes errgroup.Group
+ for _, v := range variants {
+ v := v
+ resizes.Go(func() (err error) {
+ path := filepath.Join(baseDir, v.path)
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ }()
+ return png.Encode(f, resizeIcon(v, img))
+ })
+ }
+ return resizes.Wait()
+}
+
+func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
+ scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
+ op := draw.Src
+ if v.fill {
+ op = draw.Over
+ draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
+ }
+ draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
+
+ return scaled
+}
diff --git a/third_party/gioui-cmd/gogio/main_test.go b/third_party/gioui-cmd/gogio/main_test.go
new file mode 100644
index 0000000..98dcb27
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/main_test.go
@@ -0,0 +1,17 @@
+package main
+
+import (
+ "os"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ if os.Getenv("RUN_GOGIO") != "" {
+ // Allow the end-to-end tests to call the gogio tool without
+ // having to build it from scratch, nor having to refactor the
+ // main function to avoid using global variables.
+ main()
+ os.Exit(0) // main already exits, but just in case.
+ }
+ os.Exit(m.Run())
+}
diff --git a/third_party/gioui-cmd/gogio/permission.go b/third_party/gioui-cmd/gogio/permission.go
new file mode 100644
index 0000000..c3227ac
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/permission.go
@@ -0,0 +1,36 @@
+package main
+
+var AndroidPermissions = map[string][]string{
+ "network": {
+ "android.permission.INTERNET",
+ },
+ "networkstate": {
+ "android.permission.ACCESS_NETWORK_STATE",
+ },
+ "bluetooth": {
+ "android.permission.BLUETOOTH",
+ "android.permission.BLUETOOTH_ADMIN",
+ "android.permission.ACCESS_FINE_LOCATION",
+ },
+ "camera": {
+ "android.permission.CAMERA",
+ },
+ "storage": {
+ "android.permission.READ_EXTERNAL_STORAGE",
+ "android.permission.WRITE_EXTERNAL_STORAGE",
+ },
+ "wakelock": {
+ "android.permission.WAKE_LOCK",
+ },
+}
+
+var AndroidFeatures = map[string][]string{
+ "default": {`glEsVersion="0x00020000"`, `name="android.hardware.type.pc"`},
+ "bluetooth": {
+ `name="android.hardware.bluetooth"`,
+ `name="android.hardware.bluetooth_le"`,
+ },
+ "camera": {
+ `name="android.hardware.camera"`,
+ },
+}
diff --git a/third_party/gioui-cmd/gogio/race_test.go b/third_party/gioui-cmd/gogio/race_test.go
new file mode 100644
index 0000000..1f3c689
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/race_test.go
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+//go:build race
+// +build race
+
+package main_test
+
+func init() { raceEnabled = true }
diff --git a/third_party/gioui-cmd/gogio/wayland_test.go b/third_party/gioui-cmd/gogio/wayland_test.go
new file mode 100644
index 0000000..df10410
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/wayland_test.go
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/png"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "text/template"
+ "time"
+)
+
+type WaylandTestDriver struct {
+ driverBase
+
+ runtimeDir string
+ socket string
+ display string
+}
+
+// No bars or anything fancy. Just a white background with our dimensions.
+var tmplSwayConfig = template.Must(template.New("").Parse(`
+output * bg #FFFFFF solid_color
+output * mode {{.Width}}x{{.Height}}
+default_border none
+`))
+
+var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`)
+
+func (d *WaylandTestDriver) Start(path string) {
+ // We want os.Environ, so that it can e.g. find $DISPLAY to run within
+ // X11. wlroots env vars are documented at:
+ // https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md
+ env := os.Environ()
+ if *headless {
+ env = append(env, "WLR_BACKENDS=headless")
+ }
+
+ d.needPrograms(
+ "sway", // to run a wayland compositor
+ "grim", // to take screenshots
+ "swaymsg", // to send input
+ )
+
+ // First, build the app.
+ dir := d.tempDir("gio-endtoend-wayland")
+ bin := filepath.Join(dir, "red")
+ flags := []string{"build", "-tags", "nox11", "-o=" + bin}
+ if raceEnabled {
+ flags = append(flags, "-race")
+ }
+ flags = append(flags, path)
+ cmd := exec.Command("go", flags...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ d.Fatalf("could not build app: %s:\n%s", err, out)
+ }
+
+ conf := filepath.Join(dir, "config")
+ f, err := os.Create(conf)
+ if err != nil {
+ d.Fatal(err)
+ }
+ defer f.Close()
+ if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{
+ d.width, d.height,
+ }); err != nil {
+ d.Fatal(err)
+ }
+
+ d.socket = filepath.Join(dir, "socket")
+ env = append(env, "SWAYSOCK="+d.socket)
+ d.runtimeDir = dir
+ env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir)
+
+ var wg sync.WaitGroup
+ d.Cleanup(wg.Wait)
+
+ // First, start sway.
+ {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose")
+ cmd.Env = env
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ d.Cleanup(func() {
+ // Give it a chance to exit gracefully, cleaning up
+ // after itself. After 10ms, the deferred cancel above
+ // will signal an os.Kill.
+ cmd.Process.Signal(os.Interrupt)
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ // Wait for sway to be ready. We probably don't need a deadline
+ // here.
+ br := bufio.NewReader(stderr)
+ for {
+ line, err := br.ReadString('\n')
+ if err != nil {
+ d.Fatal(err)
+ }
+ if m := rxSwayReady.FindStringSubmatch(line); m != nil {
+ d.display = m[1]
+ break
+ }
+ }
+
+ wg.Add(1)
+ go func() {
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") {
+ // Don't print all stderr, since we use --verbose.
+ // TODO(mvdan): if it's useful, probably filter
+ // errors and show them.
+ d.Error(err)
+ }
+ wg.Done()
+ }()
+ }
+
+ // Then, start our program on the sway compositor above.
+ {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, bin)
+ cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ d.output = output
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ wg.Add(1)
+ go func() {
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ d.Error(err)
+ }
+ wg.Done()
+ }()
+ }
+
+ // Wait for the gio app to render.
+ d.waitForFrame()
+}
+
+func (d *WaylandTestDriver) Screenshot() image.Image {
+ cmd := exec.Command("grim", "/dev/stdout")
+ cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ d.Errorf("%s", out)
+ d.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(out))
+ if err != nil {
+ d.Fatal(err)
+ }
+ return img
+}
+
+func (d *WaylandTestDriver) swaymsg(args ...interface{}) {
+ strs := []string{"--socket", d.socket}
+ for _, arg := range args {
+ strs = append(strs, fmt.Sprint(arg))
+ }
+ cmd := exec.Command("swaymsg", strs...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ d.Errorf("%s", out)
+ d.Fatal(err)
+ }
+}
+
+func (d *WaylandTestDriver) Click(x, y int) {
+ d.swaymsg("seat", "-", "cursor", "set", x, y)
+ d.swaymsg("seat", "-", "cursor", "press", "button1")
+ d.swaymsg("seat", "-", "cursor", "release", "button1")
+
+ // Wait for the gio app to render after this click.
+ d.waitForFrame()
+}
diff --git a/third_party/gioui-cmd/gogio/windows_test.go b/third_party/gioui-cmd/gogio/windows_test.go
new file mode 100644
index 0000000..996b511
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/windows_test.go
@@ -0,0 +1,152 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "context"
+ "image"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sync"
+ "time"
+
+ "golang.org/x/image/draw"
+)
+
+// Wine is tightly coupled with X11 at the moment, and we can reuse the same
+// methods to automate screenshots and clicks. The main difference is how we
+// build and run the app.
+
+// The only quirk is that it seems impossible for the Wine window to take the
+// entirety of the X server's dimensions, even if we try to resize it to take
+// the entire display. It seems to want to leave some vertical space empty,
+// presumably for window decorations or the "start" bar on Windows. To work
+// around that, make the X server 50x50px bigger, and crop the screenshots back
+// to the original size.
+
+type WineTestDriver struct {
+ X11TestDriver
+}
+
+func (d *WineTestDriver) Start(path string) {
+ d.needPrograms("wine")
+
+ // First, build the app.
+ bin := filepath.Join(d.tempDir("gio-endtoend-windows"), "red.exe")
+ flags := []string{"build", "-o=" + bin}
+ if raceEnabled {
+ if runtime.GOOS != "windows" {
+ // cross-compilation disables CGo, which breaks -race.
+ d.Skipf("can't cross-compile -race for Windows; skipping")
+ }
+ flags = append(flags, "-race")
+ }
+ flags = append(flags, path)
+ cmd := exec.Command("go", flags...)
+ cmd.Env = os.Environ()
+ cmd.Env = append(cmd.Env, "GOOS=windows")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ d.Fatalf("could not build app: %s:\n%s", err, out)
+ }
+
+ var wg sync.WaitGroup
+ d.Cleanup(wg.Wait)
+
+ // Add 50x50px to the display dimensions, as discussed earlier.
+ d.startServer(&wg, d.width+50, d.height+50)
+
+ // Then, start our program via Wine on the X server above.
+ {
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ d.Fatal(err)
+ }
+ // Use a wine directory separate from the default ~/.wine, so
+ // that the user's winecfg doesn't affect our test. This will
+ // default to ~/.cache/gio-e2e-wine. We use the user's cache,
+ // to reuse a previously set up wineprefix.
+ wineprefix := filepath.Join(cacheDir, "gio-e2e-wine")
+
+ // First, ensure that wineprefix is up to date with wineboot.
+ // Wait for this separately from the first frame, as setting up
+ // a new prefix might take 5s on its own.
+ env := []string{
+ "DISPLAY=" + d.display,
+ "WINEDEBUG=fixme-all", // hide "fixme" noise
+ "WINEPREFIX=" + wineprefix,
+
+ // Disable wine-gecko (Explorer) and wine-mono (.NET).
+ // Otherwise, if not installed, wineboot will get stuck
+ // with a prompt to install them on the virtual X
+ // display. Moreover, Gio doesn't need either, and wine
+ // is faster without them.
+ "WINEDLLOVERRIDES=mscoree,mshtml=",
+ }
+ {
+ start := time.Now()
+ cmd := exec.Command("wine", "wineboot", "-i")
+ cmd.Env = env
+ // Use a combined output pipe instead of CombinedOutput,
+ // so that we only wait for the child process to exit,
+ // and we don't need to wait for all of wine's
+ // grandchildren to exit and stop writing. This is
+ // relevant as wine leaves "wineserver" lingering for
+ // three seconds by default, to be reused later.
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ if err := cmd.Run(); err != nil {
+ io.Copy(os.Stderr, stdout)
+ d.Fatal(err)
+ }
+ d.Logf("set up WINEPREFIX in %s", time.Since(start))
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, "wine", bin)
+ cmd.Env = env
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ d.output = output
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ wg.Add(1)
+ go func() {
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ d.Error(err)
+ }
+ wg.Done()
+ }()
+ }
+ // Wait for the gio app to render.
+ d.waitForFrame()
+
+ // xdotool seems to fail at actually moving the window if we use it
+ // immediately after Gio is ready. Why?
+ // We can't tell if the windowmove operation worked until we take a
+ // screenshot, because the getwindowgeometry op reports the 0x0
+ // coordinates even if the window wasn't moved properly.
+ // A sleep of ~20ms seems to be enough on an idle laptop. Use 20x that.
+ // TODO(mvdan): revisit this, when you have a spare three hours.
+ time.Sleep(400 * time.Millisecond)
+ id := d.xdotool("search", "--sync", "--onlyvisible", "--name", "Gio")
+ d.xdotool("windowmove", "--sync", id, 0, 0)
+}
+
+func (d *WineTestDriver) Screenshot() image.Image {
+ img := d.X11TestDriver.Screenshot()
+ // Crop the screenshot back to the original dimensions.
+ cropped := image.NewRGBA(image.Rect(0, 0, d.width, d.height))
+ draw.Draw(cropped, cropped.Bounds(), img, image.Point{}, draw.Src)
+ return cropped
+}
diff --git a/third_party/gioui-cmd/gogio/windowsbuild.go b/third_party/gioui-cmd/gogio/windowsbuild.go
new file mode 100644
index 0000000..c867e03
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/windowsbuild.go
@@ -0,0 +1,410 @@
+package main
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "image/png"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "text/template"
+
+ "github.com/akavel/rsrc/binutil"
+ "github.com/akavel/rsrc/coff"
+ "golang.org/x/text/encoding/unicode"
+)
+
+func buildWindows(tmpDir string, bi *buildInfo) error {
+ builder := &windowsBuilder{TempDir: tmpDir}
+ builder.DestDir = *destPath
+ if builder.DestDir == "" {
+ builder.DestDir = bi.pkgPath
+ }
+
+ name := bi.name
+ if *destPath != "" {
+ if filepath.Ext(*destPath) != ".exe" {
+ return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
+ }
+ name = filepath.Base(*destPath)
+ }
+ name = strings.TrimSuffix(name, ".exe")
+ sdk := bi.minsdk
+ if sdk > 10 {
+ return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
+ }
+
+ for _, arch := range bi.archs {
+ builder.Coff = coff.NewRSRC()
+ builder.Coff.Arch(arch)
+
+ if err := builder.embedIcon(bi.iconPath); err != nil {
+ return err
+ }
+
+ if err := builder.embedManifest(windowsManifest{
+ Version: bi.version.String(),
+ WindowsVersion: sdk,
+ Name: name,
+ }); err != nil {
+ return fmt.Errorf("can't create manifest: %v", err)
+ }
+
+ if err := builder.embedInfo(windowsResources{
+ Version: [2]uint32{uint32(bi.version.Major), uint32(bi.version.Minor)<<16 | uint32(bi.version.Patch)},
+ VersionHuman: bi.version.String(),
+ Name: name,
+ Language: 0x0400, // Process Default Language: https://docs.microsoft.com/en-us/previous-versions/ms957130(v=msdn.10)
+ }); err != nil {
+ return fmt.Errorf("can't create info: %v", err)
+ }
+
+ if err := builder.buildResource(bi, name, arch); err != nil {
+ return fmt.Errorf("can't build the resources: %v", err)
+ }
+
+ if err := builder.buildProgram(bi, name, arch); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+type (
+ windowsResources struct {
+ Version [2]uint32
+ VersionHuman string
+ Language uint16
+ Name string
+ }
+ windowsManifest struct {
+ Version string
+ WindowsVersion int
+ Name string
+ }
+ windowsBuilder struct {
+ TempDir string
+ DestDir string
+ Coff *coff.Coff
+ }
+)
+
+const (
+ // https://docs.microsoft.com/en-us/windows/win32/menurc/resource-types
+ windowsResourceIcon = 3
+ windowsResourceIconGroup = windowsResourceIcon + 11
+ windowsResourceManifest = 24
+ windowsResourceVersion = 16
+)
+
+type bufferCoff struct {
+ bytes.Buffer
+}
+
+func (b *bufferCoff) Size() int64 {
+ return int64(b.Len())
+}
+
+func (b *windowsBuilder) embedIcon(path string) (err error) {
+ iconFile, err := os.Open(path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("can't read the icon located at %s: %v", path, err)
+ }
+ defer iconFile.Close()
+
+ iconImage, err := png.Decode(iconFile)
+ if err != nil {
+ return fmt.Errorf("can't decode the PNG file (%s): %v", path, err)
+ }
+
+ sizes := []int{16, 32, 48, 64, 128, 256}
+ var iconHeader bufferCoff
+
+ // GRPICONDIR structure.
+ if err := binary.Write(&iconHeader, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
+ return err
+ }
+
+ for _, size := range sizes {
+ var iconBuffer bufferCoff
+
+ if err := png.Encode(&iconBuffer, resizeIcon(iconVariant{size: size, fill: false}, iconImage)); err != nil {
+ return fmt.Errorf("can't encode image: %v", err)
+ }
+
+ b.Coff.AddResource(windowsResourceIcon, uint16(size), &iconBuffer)
+
+ if err := binary.Write(&iconHeader, binary.LittleEndian, struct {
+ Size [2]uint8
+ Color [2]uint8
+ Planes uint16
+ BitCount uint16
+ Length uint32
+ Id uint16
+ }{
+ Size: [2]uint8{uint8(size % 256), uint8(size % 256)}, // "0" means 256px.
+ Planes: 1,
+ BitCount: 32,
+ Length: uint32(iconBuffer.Len()),
+ Id: uint16(size),
+ }); err != nil {
+ return err
+ }
+ }
+
+ b.Coff.AddResource(windowsResourceIconGroup, 1, &iconHeader)
+
+ return nil
+}
+
+func (b *windowsBuilder) buildResource(buildInfo *buildInfo, name string, arch string) error {
+ out, err := os.Create(filepath.Join(buildInfo.pkgPath, name+"_windows_"+arch+".syso"))
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ b.Coff.Freeze()
+
+ // See https://github.com/akavel/rsrc/internal/write.go#L13.
+ w := binutil.Writer{W: out}
+ binutil.Walk(b.Coff, func(v reflect.Value, path string) error {
+ if binutil.Plain(v.Kind()) {
+ w.WriteLE(v.Interface())
+ return nil
+ }
+ vv, ok := v.Interface().(binutil.SizedReader)
+ if ok {
+ w.WriteFromSized(vv)
+ return binutil.WALK_SKIP
+ }
+ return nil
+ })
+
+ if w.Err != nil {
+ return fmt.Errorf("error writing output file: %s", w.Err)
+ }
+
+ return nil
+}
+
+func (b *windowsBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
+ dest := b.DestDir
+ if len(buildInfo.archs) > 1 {
+ dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".exe")
+ }
+
+ cmd := exec.Command(
+ "go",
+ "build",
+ "-ldflags=-H=windowsgui "+buildInfo.ldflags,
+ "-tags="+buildInfo.tags,
+ "-o", dest,
+ buildInfo.pkgPath,
+ )
+ cmd.Env = append(
+ os.Environ(),
+ "GOOS=windows",
+ "GOARCH="+arch,
+ )
+ _, err := runCmd(cmd)
+ return err
+}
+
+func (b *windowsBuilder) embedManifest(v windowsManifest) error {
+ t, err := template.New("manifest").Parse(`
+
+
+ {{.Name}}
+
+
+ {{if (le .WindowsVersion 10)}}
+{{end}}
+ {{if (le .WindowsVersion 9)}}
+{{end}}
+ {{if (le .WindowsVersion 8)}}
+{{end}}
+ {{if (le .WindowsVersion 7)}}
+{{end}}
+ {{if (le .WindowsVersion 6)}}
+{{end}}
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+`)
+ if err != nil {
+ return err
+ }
+
+ var manifest bufferCoff
+ if err := t.Execute(&manifest, v); err != nil {
+ return err
+ }
+
+ b.Coff.AddResource(windowsResourceManifest, 1, &manifest)
+
+ return nil
+}
+
+func (b *windowsBuilder) embedInfo(v windowsResources) error {
+ page := uint16(1)
+
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/vs-versioninfo
+ t := newValue(valueBinary, "VS_VERSION_INFO", []io.WriterTo{
+ // https://docs.microsoft.com/pt-br/windows/win32/api/VerRsrc/ns-verrsrc-vs_fixedfileinfo
+ windowsInfoValueFixed{
+ Signature: 0xFEEF04BD,
+ StructVersion: 0x00010000,
+ FileVersion: v.Version,
+ ProductVersion: v.Version,
+ FileFlagMask: 0x3F,
+ FileFlags: 0,
+ FileOS: 0x40004,
+ FileType: 0x1,
+ FileSubType: 0,
+ },
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringfileinfo
+ newValue(valueText, "StringFileInfo", []io.WriterTo{
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/stringtable
+ newValue(valueText, fmt.Sprintf("%04X%04X", v.Language, page), []io.WriterTo{
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/string-str
+ newValue(valueText, "ProductVersion", v.VersionHuman),
+ newValue(valueText, "FileVersion", v.VersionHuman),
+ newValue(valueText, "FileDescription", v.Name),
+ newValue(valueText, "ProductName", v.Name),
+ // TODO include more data: gogio must have some way to provide such information (like Company Name, Copyright...)
+ }),
+ }),
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/varfileinfo
+ newValue(valueBinary, "VarFileInfo", []io.WriterTo{
+ // https://docs.microsoft.com/pt-br/windows/win32/menurc/var-str
+ newValue(valueBinary, "Translation", uint32(page)<<16|uint32(v.Language)),
+ }),
+ })
+
+ // For some reason the ValueLength of the VS_VERSIONINFO must be the byte-length of `windowsInfoValueFixed`:
+ t.ValueLength = 52
+
+ var verrsrc bufferCoff
+ if _, err := t.WriteTo(&verrsrc); err != nil {
+ return err
+ }
+
+ b.Coff.AddResource(windowsResourceVersion, 1, &verrsrc)
+
+ return nil
+}
+
+type windowsInfoValueFixed struct {
+ Signature uint32
+ StructVersion uint32
+ FileVersion [2]uint32
+ ProductVersion [2]uint32
+ FileFlagMask uint32
+ FileFlags uint32
+ FileOS uint32
+ FileType uint32
+ FileSubType uint32
+ FileDate [2]uint32
+}
+
+func (v windowsInfoValueFixed) WriteTo(w io.Writer) (_ int64, err error) {
+ return 0, binary.Write(w, binary.LittleEndian, v)
+}
+
+type windowsInfoValue struct {
+ Length uint16
+ ValueLength uint16
+ Type uint16
+ Key []byte
+ Value []byte
+}
+
+func (v windowsInfoValue) WriteTo(w io.Writer) (_ int64, err error) {
+ // binary.Write doesn't support []byte inside struct.
+ if err = binary.Write(w, binary.LittleEndian, [3]uint16{v.Length, v.ValueLength, v.Type}); err != nil {
+ return 0, err
+ }
+ if _, err = w.Write(v.Key); err != nil {
+ return 0, err
+ }
+ if _, err = w.Write(v.Value); err != nil {
+ return 0, err
+ }
+ return 0, nil
+}
+
+const (
+ valueBinary uint16 = 0
+ valueText uint16 = 1
+)
+
+func newValue(valueType uint16, key string, input interface{}) windowsInfoValue {
+ v := windowsInfoValue{
+ Type: valueType,
+ Length: 6,
+ }
+
+ padding := func(in []byte) []byte {
+ if l := uint16(len(in)) + v.Length; l%4 != 0 {
+ return append(in, make([]byte, 4-l%4)...)
+ }
+ return in
+ }
+
+ v.Key = padding(utf16Encode(key))
+ v.Length += uint16(len(v.Key))
+
+ switch in := input.(type) {
+ case string:
+ v.Value = padding(utf16Encode(in))
+ v.ValueLength = uint16(len(v.Value) / 2)
+ case []io.WriterTo:
+ var buff bytes.Buffer
+ for k := range in {
+ if _, err := in[k].WriteTo(&buff); err != nil {
+ panic(err)
+ }
+ }
+ v.Value = buff.Bytes()
+ default:
+ var buff bytes.Buffer
+ if err := binary.Write(&buff, binary.LittleEndian, in); err != nil {
+ panic(err)
+ }
+ v.ValueLength = uint16(buff.Len())
+ v.Value = buff.Bytes()
+ }
+
+ v.Length += uint16(len(v.Value))
+
+ return v
+}
+
+// utf16Encode encodes the string to UTF16 with null-termination.
+func utf16Encode(s string) []byte {
+ b, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes([]byte(s))
+ if err != nil {
+ panic(err)
+ }
+ return append(b, 0x00, 0x00) // null-termination.
+}
diff --git a/third_party/gioui-cmd/gogio/x11_test.go b/third_party/gioui-cmd/gogio/x11_test.go
new file mode 100644
index 0000000..9bb3174
--- /dev/null
+++ b/third_party/gioui-cmd/gogio/x11_test.go
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+package main_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "image"
+ "image/png"
+ "io"
+ "math/rand"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type X11TestDriver struct {
+ driverBase
+
+ display string
+}
+
+func (d *X11TestDriver) Start(path string) {
+ // First, build the app.
+ bin := filepath.Join(d.tempDir("gio-endtoend-x11"), "red")
+ flags := []string{"build", "-tags", "nowayland", "-o=" + bin}
+ if raceEnabled {
+ flags = append(flags, "-race")
+ }
+ flags = append(flags, path)
+ cmd := exec.Command("go", flags...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ d.Fatalf("could not build app: %s:\n%s", err, out)
+ }
+
+ var wg sync.WaitGroup
+ d.Cleanup(wg.Wait)
+
+ d.startServer(&wg, d.width, d.height)
+
+ // Then, start our program on the X server above.
+ {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, bin)
+ cmd.Env = []string{"DISPLAY=" + d.display}
+ output, err := cmd.StdoutPipe()
+ if err != nil {
+ d.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ d.output = output
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ wg.Add(1)
+ go func() {
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ d.Error(err)
+ }
+ wg.Done()
+ }()
+ }
+
+ // Wait for the gio app to render.
+ d.waitForFrame()
+}
+
+func (d *X11TestDriver) startServer(wg *sync.WaitGroup, width, height int) {
+ // Pick a random display number between 1 and 100,000. Most machines
+ // will only be using :0, so there's only a 0.001% chance of two
+ // concurrent test runs to run into a conflict.
+ rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
+ d.display = fmt.Sprintf(":%d", rnd.Intn(100000)+1)
+
+ var xprog string
+ xflags := []string{
+ "-wr", // we want a white background; the default is black
+ }
+ if *headless {
+ xprog = "Xvfb" // virtual X server
+ xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height))
+ } else {
+ xprog = "Xephyr" // nested X server as a window
+ xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height))
+ }
+ xflags = append(xflags, d.display)
+
+ d.needPrograms(
+ xprog, // to run the X server
+ "scrot", // to take screenshots
+ "xdotool", // to send input
+ )
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, xprog, xflags...)
+ combined := &bytes.Buffer{}
+ cmd.Stdout = combined
+ cmd.Stderr = combined
+ if err := cmd.Start(); err != nil {
+ d.Fatal(err)
+ }
+ d.Cleanup(cancel)
+ d.Cleanup(func() {
+ // Give it a chance to exit gracefully, cleaning up
+ // after itself. After 10ms, the deferred cancel above
+ // will signal an os.Kill.
+ cmd.Process.Signal(os.Interrupt)
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ // Wait for the X server to be ready. The socket path isn't
+ // terribly portable, but that's okay for now.
+ withRetries(d.T, time.Second, func() error {
+ socket := fmt.Sprintf("/tmp/.X11-unix/X%s", d.display[1:])
+ _, err := os.Stat(socket)
+ return err
+ })
+
+ wg.Add(1)
+ go func() {
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ // Print all output and error.
+ io.Copy(os.Stdout, combined)
+ d.Error(err)
+ }
+ wg.Done()
+ }()
+}
+
+func (d *X11TestDriver) Screenshot() image.Image {
+ cmd := exec.Command("scrot", "--silent", "--overwrite", "/dev/stdout")
+ cmd.Env = []string{"DISPLAY=" + d.display}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ d.Errorf("%s", out)
+ d.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(out))
+ if err != nil {
+ d.Fatal(err)
+ }
+ return img
+}
+
+func (d *X11TestDriver) xdotool(args ...interface{}) string {
+ d.Helper()
+ strs := make([]string, len(args))
+ for i, arg := range args {
+ strs[i] = fmt.Sprint(arg)
+ }
+ cmd := exec.Command("xdotool", strs...)
+ cmd.Env = []string{"DISPLAY=" + d.display}
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ d.Errorf("%s", out)
+ d.Fatal(err)
+ }
+ return string(bytes.TrimSpace(out))
+}
+
+func (d *X11TestDriver) Click(x, y int) {
+ d.xdotool("mousemove", "--sync", x, y)
+ d.xdotool("click", "1")
+
+ // Wait for the gio app to render after this click.
+ d.waitForFrame()
+}
diff --git a/third_party/gioui-cmd/svg2gio/main.go b/third_party/gioui-cmd/svg2gio/main.go
new file mode 100644
index 0000000..81a0227
--- /dev/null
+++ b/third_party/gioui-cmd/svg2gio/main.go
@@ -0,0 +1,582 @@
+// SPDX-License-Identifier: Unlicense OR MIT
+
+// Command svg2gio converts SVG files to Gio functions. Only a limited subset of
+// SVG files are supported.
+package main
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "go/format"
+
+ "gioui.org/f32"
+)
+
+var (
+ pkg = flag.String("pkg", "", "Go package")
+ output = flag.String("o", "svg.go", "Output Go file")
+)
+
+func main() {
+ flag.Parse()
+ if *pkg == "" {
+ fmt.Fprintf(os.Stderr, "specify a package name (-pkg)\n")
+ os.Exit(1)
+ }
+ args := flag.Args()
+ if err := convertAll(args); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ os.Exit(2)
+ }
+}
+
+type Points []float32
+
+func (p *Points) UnmarshalText(text []byte) error {
+ for {
+ text = bytes.TrimLeft(text, "\t\n")
+ if len(text) == 0 {
+ break
+ }
+ var num []byte
+ end := bytes.IndexAny(text, " ,")
+ if end != -1 {
+ num = text[:end]
+ text = text[end+1:]
+ } else {
+ num = text
+ text = nil
+ }
+ f, err := strconv.ParseFloat(string(num), 32)
+ if err != nil {
+ return err
+ }
+ *p = append(*p, float32(f))
+ }
+ return nil
+}
+
+type Transform f32.Affine2D
+
+func (t *Transform) UnmarshalText(text []byte) error {
+ switch {
+ case bytes.HasPrefix(text, []byte("matrix(")) && bytes.HasSuffix(text, []byte(")")):
+ trans := text[7 : len(text)-1]
+ var p Points
+ if err := p.UnmarshalText(trans); err != nil {
+ return err
+ }
+ if len(p) != 6 {
+ return fmt.Errorf("malformed transform matrix: %q", text)
+ }
+ *t = Transform(f32.NewAffine2D(p[0], p[2], p[4], p[1], p[3], p[5]))
+ return nil
+ default:
+ return fmt.Errorf("unsupported transform: %q", text)
+ }
+}
+
+type Fill struct {
+ Transform Transform `xml:"transform,attr"`
+ Fill Color `xml:"fill,attr"`
+ Stroke Color `xml:"stroke,attr"`
+ StrokeLinejoin string `xml:"stroke-linejoin,attr"`
+ StrokeLinecap string `xml:"stroke-linecap,attr"`
+ StrokeWidth float32 `xml:"stroke-width,attr"`
+}
+
+type Color struct {
+ Set bool
+ Value int
+}
+
+func (c *Color) UnmarshalText(text []byte) error {
+ if string(text) == "none" {
+ *c = Color{}
+ return nil
+ }
+ if !bytes.HasPrefix(text, []byte("#")) {
+ return fmt.Errorf("invalid color: %q", text)
+ }
+ text = text[1:]
+ i, err := strconv.ParseInt(string(text), 16, 32)
+ // Implied alpha.
+ if len(text) == 6 {
+ i |= 0xff000000
+ }
+ *c = Color{
+ Set: true,
+ Value: int(i),
+ }
+ return err
+}
+
+func convertAll(files []string) error {
+ w := new(bytes.Buffer)
+ fmt.Fprintf(w, "// Code generated by gioui.org/cmd/svg2gio; DO NOT EDIT.\n\n")
+ fmt.Fprintf(w, "package %s\n\n", *pkg)
+ fmt.Fprintf(w, "import \"image/color\"\n")
+ fmt.Fprintf(w, "import \"math\"\n")
+ fmt.Fprintf(w, "import \"gioui.org/op\"\n")
+ fmt.Fprintf(w, "import \"gioui.org/op/clip\"\n")
+ fmt.Fprintf(w, "import \"gioui.org/op/paint\"\n")
+ fmt.Fprintf(w, "import \"gioui.org/f32\"\n\n")
+ fmt.Fprintf(w, "var ops op.Ops\n\n")
+ fmt.Fprintf(w, funcs)
+ for _, filename := range files {
+ if err := convert(w, filename); err != nil {
+ return err
+ }
+ }
+ src, err := format.Source(w.Bytes())
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(*output, src, 0o660)
+}
+
+func convert(w io.Writer, filename string) error {
+ base := filepath.Base(filename)
+ ext := filepath.Ext(base)
+ name := "Image_" + base[:len(base)-len(ext)]
+
+ fmt.Fprintf(w, "var %s struct {\n", name)
+ fmt.Fprintf(w, "ViewBox struct { Min, Max f32.Point }\n")
+ fmt.Fprintf(w, "Call op.CallOp\n\n")
+ fmt.Fprintf(w, "}\n")
+ fmt.Fprintf(w, "func init() {\n")
+ defer fmt.Fprintf(w, "}\n")
+ f, err := os.Open(filename)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ d := xml.NewDecoder(f)
+ if err := parse(w, d, name); err != nil {
+ line, col := d.InputPos()
+ return fmt.Errorf("%s:%d:%d: %w", filename, line, col, err)
+ }
+ return nil
+}
+
+func parse(w io.Writer, d *xml.Decoder, name string) error {
+ for {
+ tok, err := d.Token()
+ if err != nil {
+ if err == io.EOF {
+ return errors.New("unexpected end of file")
+ }
+ return err
+ }
+ switch tok := tok.(type) {
+ case xml.StartElement:
+ if n := tok.Name.Local; n != "svg" {
+ return fmt.Errorf("invalid SVG root: <%s>", n)
+ }
+ if n := tok.Name.Space; n != "http://www.w3.org/2000/svg" {
+ return fmt.Errorf("unsupported SVG namespace: %s", n)
+ }
+ fmt.Fprintf(w, "m := op.Record(&ops)\n")
+ defer fmt.Fprintf(w, "%s.Call = m.Stop()\n", name)
+ for _, a := range tok.Attr {
+ if a.Name.Local == "viewBox" {
+ var p Points
+ if err := p.UnmarshalText([]byte(a.Value)); err != nil {
+ return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
+ }
+ if len(p) != 4 {
+ return fmt.Errorf("invalid viewBox attribute: %s", a.Value)
+ }
+ fmt.Fprintf(w, "%s.ViewBox.Min = %s\n", name, point(f32.Pt(p[0], p[1])))
+ fmt.Fprintf(w, "%s.ViewBox.Max = %s\n", name, point(f32.Pt(p[2], p[3])))
+ }
+ }
+ return parseSVG(w, d)
+ }
+ }
+}
+
+func point(p f32.Point) string {
+ return fmt.Sprintf("f32.Pt(%g, %g)", p.X, p.Y)
+}
+
+type Poly struct {
+ XMLName xml.Name
+ Points Points `xml:"points,attr"`
+ Fill
+}
+
+func (p *Poly) Path(w io.Writer) error {
+ if len(p.Points) <= 1 {
+ return nil
+ }
+ pen := f32.Pt(p.Points[0], p.Points[1])
+ fmt.Fprintf(w, "p.MoveTo(%s)\n", point(pen))
+ last := pen
+ for i := 2; i < len(p.Points); i += 2 {
+ last = f32.Pt(p.Points[i], p.Points[i+1])
+ fmt.Fprintf(w, "p.LineTo(%s)\n", point(last))
+ }
+ if p.XMLName.Local == "polygon" && last != pen {
+ fmt.Fprintf(w, "p.LineTo(%s)\n", point(pen))
+ }
+ return nil
+}
+
+type Path struct {
+ D string `xml:"d,attr"`
+ Fill
+}
+
+func (p *Path) Path(w io.Writer) error {
+ return printPathCommands(w, p.D)
+}
+
+type Line struct {
+ X1 float32 `xml:"x1,attr"`
+ Y1 float32 `xml:"y1,attr"`
+ X2 float32 `xml:"x2,attr"`
+ Y2 float32 `xml:"y2,attr"`
+ Fill
+}
+
+func (l *Line) Path(w io.Writer) error {
+ fmt.Fprintf(w, "p.MoveTo(%s)\n", point(f32.Pt(l.X1, l.Y1)))
+ fmt.Fprintf(w, "p.LineTo(%s)\n", point(f32.Pt(l.X2, l.Y2)))
+ return nil
+}
+
+type Ellipse struct {
+ Cx float32 `xml:"cx,attr"`
+ Cy float32 `xml:"cy,attr"`
+ Rx float32 `xml:"rx,attr"`
+ Ry float32 `xml:"ry,attr"`
+ Fill
+}
+
+func (e *Ellipse) Path(w io.Writer) error {
+ c := f32.Pt(e.Cx, e.Cy)
+ r := f32.Pt(e.Rx, e.Ry)
+ fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(c), point(r))
+ return nil
+}
+
+type Rect struct {
+ X float32 `xml:"x,attr"`
+ Y float32 `xml:"y,attr"`
+ Width float32 `xml:"width,attr"`
+ Height float32 `xml:"height,attr"`
+ Fill
+}
+
+func (r *Rect) Path(w io.Writer) error {
+ o := f32.Pt(r.X, r.Y)
+ sz := f32.Pt(r.Width, r.Height)
+ fmt.Fprintf(w, "rect(&p, %s, %s)\n", point(o), point(sz))
+ return nil
+}
+
+type Circle struct {
+ Cx float32 `xml:"cx,attr"`
+ Cy float32 `xml:"cy,attr"`
+ R float32 `xml:"r,attr"`
+ Fill
+}
+
+func (c *Circle) Path(w io.Writer) error {
+ center := f32.Pt(c.Cx, c.Cy)
+ r := f32.Pt(c.R, c.R)
+ fmt.Fprintf(w, "ellipse(&p, %s, %s)\n", point(center), point(r))
+ return nil
+}
+
+func parseSVG(w io.Writer, d *xml.Decoder) error {
+ for {
+ tok, err := d.Token()
+ if err != nil {
+ if err == io.EOF {
+ return errors.New("unexpected end of