The Go patterns behind Gemini-Config-Checker: injecting os.Getenv and exec.LookPath so path discovery is unit-testable, build tags for the registry lookup, and why the configs stay as maps.
· 6 min read

How Gemini-Config-Checker Uses Go to Find and Drive a Chromium Browser


“Find the installed browser” sounds like an afternoon’s work. Then you open the code. A Chromium browser might be installed under several Windows roots, hidden inside an .app bundle on macOS, or available only through $PATH on Linux. On Android, this project cannot launch an installed browser with the remote-debugging setup its region check needs.

Gemini-Config-Checker has to handle those cases. In region-check mode, it starts a real Chromium browser with a DevTools debugging port and checks whether a signed-in Google account can reach AI Studio and Gemini through a proxy chain. The discovery code in internal/scan/browser.go is worth reading because it keeps computing candidate paths separate from checking the filesystem.

Pure functions for platform paths, impure functions for the disk

Here is the entry point from internal/scan/browser.go:

func findBrowser() (string, error) {
	if runtime.GOOS == "android" {
		return "", errors.New("...")
	}
	home, _ := os.UserHomeDir()
	for _, p := range browserCandidates(runtime.GOOS, os.Getenv, home, exec.LookPath) {
		if isFile(p) {
			return p, nil
		}
	}
	for _, p := range systemBrowsers() {
		if isFile(p) {
			return p, nil
		}
	}
	return "", errNoBrowser
}

Look at what gets passed into browserCandidates: the GOOS string, environment lookup, home directory, and exec.LookPath function. The candidate builder does not read the environment or filesystem directly. Only findBrowser checks whether each result is a real file.

That lets a Linux or macOS test exercise the Windows branch, including %LOCALAPPDATA%, without a Windows machine. The upstream test normalizes separators and checks representative suffixes rather than pretending that filepath.Join produces Windows separators on every host. It is the same idea as injecting time.Now, applied to OS discovery instead of time.

The signature is the part worth stealing:

func browserCandidates(
	goos string,
	env func(string) string,
	home string,
	look func(string) (string, error),
) []string

os.Getenv is already func(string) string. exec.LookPath is already func(string) (string, error). No wrapper interface, no mockgen step, no BrowserPathProvider with one implementation. Production passes the stdlib functions. Tests pass closures over a map.

// A test fake needs no extra types.
env := func(k string) string {
	return map[string]string{
		"ProgramFiles": `C:\Program Files`,
		"LOCALAPPDATA": `C:\Users\matt\AppData\Local`,
	}[k]
}
look := func(string) (string, error) { return "", exec.ErrNotFound }

got := browserCandidates("windows", env, `C:\Users\matt`, look)

One more detail makes the test portable: Windows paths are assembled with filepath.Join over path elements instead of hard-coded backslashes. filepath.Join uses the host OS separator, so a non-Windows test gets forward slashes and the real Windows build gets backslashes. The test calls filepath.ToSlash before checking suffixes.

Build tags for the parts that genuinely can’t be portable

Standard locations miss a browser on an external drive or in some custom folder a user picked in 2019. The fallback is systemBrowsers(), and that one cannot be pure. It has three implementations chosen at compile time:

  • internal/scan/browser_windows.go reads the App Paths registry key via golang.org/x/sys/windows/registry.
  • internal/scan/browser_darwin.go shells out to mdfind to query Spotlight by bundle ID.
  • internal/scan/browser_other.go returns nil.

The Windows and Darwin files get implicit build constraints from their _windows.go and _darwin.go filename suffixes. No //go:build line is needed. The fallback needs an explicit constraint because browser_other.go has no recognized GOOS suffix:

//go:build !windows && !darwin

package scan

// systemBrowsers: nothing beyond the standard locations on this platform.
func systemBrowsers() []string { return nil }

That no-op file is doing real work. Android satisfies !windows && !darwin, so it gets the stub, as do Linux, FreeBSD, and the other targets without a platform-specific implementation. Delete it and systemBrowsers is undefined on those builds. The Go build-constraint documentation describes both filename constraints and the special relationship between Android and Linux tags.

The Windows version is about as small as a registry read gets:

func systemBrowsers() []string {
	var r []string
	for _, exe := range []string{"chrome.exe", "msedge.exe", "brave.exe", "vivaldi.exe", "opera.exe"} {
		for _, root := range []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} {
			k, err := registry.OpenKey(root, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\`+exe, registry.QUERY_VALUE)
			if err != nil {
				continue
			}
			if v, _, err := k.GetStringValue(""); err == nil {
				r = append(r, strings.Trim(v, `"`))
			}
			k.Close()
		}
	}
	return r
}

Two details are easy to miss. The project queries registry.CURRENT_USER as well as registry.LOCAL_MACHINE so it can find per-user registrations. GetStringValue("") reads the key’s default value, and the code trims surrounding quote characters before passing the path to os.Stat.

On macOS it runs a query such as mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" and keeps results ending in .app. A failed query is skipped rather than aborting discovery, so unavailable Spotlight metadata produces a shorter candidate list instead of a fatal error.

The config parsing side uses map[string]any deliberately

internal/scan/base.go deals with the other mess: whatever the user pasted, turned into an xray outbound chain. That input might be a share link, a complete xray JSON config, a lone outbound object, or an array of them. parseBase dispatches on the first non-space byte. Anything other than { or [ goes to parseLink in internal/scan/link.go.

Outbounds are then modelled as obj, a map type, instead of structs. Three small helpers exist because of that choice:

func toInt(v any) int {
	switch x := v.(type) {
	case int:
		return x
	case float64:
		return int(x)
	case string:
		var n int
		fmt.Sscan(x, &n)
		return n
	}
	return 0
}

With encoding/json, a JSON number decoded through any becomes a float64; the helper also accepts an int created in code or a quoted decimal string from a generator. A plain struct field of type int would reject that string unless it had custom unmarshalling. The JSON package documentation spells out the default any representations.

endpoint normalizes three xray layouts: flat settings, settings.vnext[0], and settings.servers[0]. clone uses a JSON round trip to copy an outbound before retagging it:

func clone(o obj) obj {
	b, _ := json.Marshal(o)
	var r obj
	json.Unmarshal(b, &r)
	return r
}

This is slower than a hand-written copy and it deliberately ignores errors, but the values here are already JSON-shaped maps and slices. For the small number of outbounds in a scan, the concise implementation is a reasonable trade-off.

The cost is real: map keys have no compile-time checking. Misspell "streamSettings" and the compiler cannot help. Helpers such as sub, endpoint, addrPort, and dialerOf centralize repeated traversal rules, although the package still accesses some keys inline. That reduces duplication; it does not provide a guarantee.

Why entryHops exists

Six lines in base.go carry most of the anti-DPI behaviour:

func (b Base) entryHops() []obj {
	var r []obj
	for _, o := range b.Outs {
		if dialerOf(o) == "" {
			r = append(r, o)
		}
	}
	return r
}

In xray, a non-empty streamSettings.sockopt.dialerProxy names the outbound used to establish that connection. That is the basis of a chained proxy. The outbounds without a dialerProxy establish the chain’s underlying network connections, so entryHops selects the connections that the anti-DPI profile needs to modify. The xray sockopt documentation defines dialerProxy in those terms.

Applying the profile to an inner outbound would target a connection that xray establishes through another outbound rather than the underlying entry connection. dialerOf treats a missing field, a non-string value, and an empty string alike: all return "", meaning there is no named dialer proxy. A struct with a string field could model that same zero-value behaviour; the map is useful here because the code accepts several protocol-specific shapes and preserves fields it does not explicitly model.

The pattern underneath all three sections is the same: push platform and format edge cases into small functions with boring signatures, then keep the filesystem, registry, Spotlight, and process-launching code easy to inspect. Quoted registry values and string-typed ports are exactly the details that appear only after software meets other people’s machines; here, each one is isolated behind a focused helper.

For more Go in the path of network traffic, CrabTrap and CrossLink approach the problem from the proxy side.