Skip to main content

Command Palette

Search for a command to run...

Golang: Building a windows parental control app using wails

Published
β€’16 min read
Golang: Building a windows parental control app using wails

C# is one of my favorite languages; if you're building Windows apps, you're probably better off with C#, but I'm kind of rusty, and WinForms is old school now, so I've no choice but to use WPF πŸ˜”.

The problem is I don't like XAML, the XML-based markup language used by WPF (Windows Presentation Foundation). WPF is basically a UI framework for building Windows desktop applications, and apparently, the Dotnet crowd prefers this over WinForms these days 🀷.

Luckily! I discovered wails, an interesting project that essentially uses a WebView to build Windows Forms UIs in CSS and JavaScript. Additionally, you can connect those UI elements to a Go backend and even access native Windows APIs, similar to how you would have a code-behind button click handler in C# WinForms.

It's also no secret that I'm a big fan of Golang. It's fast, it's clean, and I enjoy writing in Go all the time. I've built everything from scrapers to a full-on vector database in Go, so why not take a whack at building a desktop app too 🀷.

Anyway, I started experimenting with building a simple parental control app. The web can be a dangerous place, and I have an 8-year-old who loves binge-watching cartoons on my laptop after sucking the life out of every battery-operated mobile device in the house πŸ˜†.

So, as you can imagine, I’d like to block all the bad junk on the internet or at least filter out the obvious bad sites and inappropriate content. This article is merely an experimental log of my journey and is not meant to be used in an actual production app. You probably would be better off subscribing to one of the existing parental control apps on the market.

Which approach should we use?

TLDR: We'll use Windows API's to get a list of active windows, then scan their titles, and finally close running programs that contain inappropriate content.

There are many approaches to building a solid parental control app; as mentioned, we're just experimenting for now and not looking to build anything too overly complicated. Some options we have:

  • A VPN service. With a VPN-based approach, all network traffic is routed through your app. This allows your app to inspect the connections being made and potentially analyze the content being accessed. In theory, this means you could decrypt HTTPS traffic and inspect full web pages, including the document body, images, videos, and other resources. As you can imagine, this is a pretty solid approach and can help you build a fairly robust system.

  • A DNS service. While the VPN approach is the most comprehensive solution, decrypting HTTPS traffic comes with serious security risks. If your VPN implementation is compromised, it could effectively create a man-in-the-middle (MITM) attack, allowing someone to intercept sensitive user data. Instead, we could just build a lightweight DNS service that allows us to read the relevant hostnames (mywebsite.com). This way we can safely monitor incoming traffic and block bad websites without actually decrypting anything.

  • ETC Hosts. Another simple approach that works surprisingly well. Instead of building and managing a DNS service (which is slightly more complex), we can simply modify a small system file called hosts, located at:

    C:\Windows\System32\drivers\etc\hosts

    As a web developer, I use this file all the time to map local domains during development. The same mechanism can also be used to block websites. By mapping bad domains to 127.0.0.1 any request to those domains will just be redirected back to the local machine, effectively preventing the site from loading. I actually got this working nicely in the C# version.

  • Window titles. Even simpler, and this is the approach we'll use in our Golang program. With this approach, you basically get a list of all running windows and inspect their title. Many websites include SEO keywords in the page title, which often contains the bad words we're looking for. By scanning these titles for certain keywords, we can easily detect when inappropriate content is being viewed and respond accordingly.

Setting up Wails

Installing wails is a breeze, just like any other Go library:

go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails init -n parental-control-app -t vanilla-ts

The second line will set up a Skeleton project for you, which would look like this:

β”œβ”€β”€ README.md
β”œβ”€β”€ app.go
β”œβ”€β”€ go.mod
β”œβ”€β”€ go.sum
β”œβ”€β”€ main.go
β”œβ”€β”€ lib
β”‚Β Β  └── program_scanner.go
β”œβ”€β”€ myproject.exe
β”œβ”€β”€ parental-control-app
β”‚Β Β  β”œβ”€β”€ README.md
β”‚Β Β  β”œβ”€β”€ app.go
β”‚Β Β  β”œβ”€β”€ go.mod
β”‚Β Β  β”œβ”€β”€ go.sum
β”‚Β Β  β”œβ”€β”€ main.go
β”‚Β Β  └── wails.json
└── wails.json

I excluded the "frontend" folder from the tree structure. This is where your "UI" lives:

β”œβ”€β”€ index.html
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
β”œβ”€β”€ package.json.md5
β”œβ”€β”€ src
β”‚Β Β  β”œβ”€β”€ app.css
β”‚Β Β  β”œβ”€β”€ assets
β”‚Β Β  β”‚Β Β  β”œβ”€β”€ fonts
β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ OFL.txt
β”‚Β Β  β”‚Β Β  β”‚Β Β  └── nunito-v16-latin-regular.woff2
β”‚Β Β  β”‚Β Β  └── images
β”‚Β Β  β”‚Β Β      └── logo-universal.png
β”‚Β Β  β”œβ”€β”€ main.ts
β”‚Β Β  β”œβ”€β”€ style.css
β”‚Β Β  └── vite-env.d.ts
β”œβ”€β”€ tsconfig.json
└── wailsjs
    β”œβ”€β”€ go
    β”‚Β Β  └── main
    β”‚Β Β      β”œβ”€β”€ App.d.ts
    β”‚Β Β      └── App.js
    └── runtime
        β”œβ”€β”€ package.json
        β”œβ”€β”€ runtime.d.ts
        └── runtime.js

I chose the vanilla template to keep things simple, but Wails does support React, Vue, and a bunch of other JS frameworks too, so pick your poison accordingly ✊

When you open up the main.go file, you'll see something like this:

package main

import (
	"embed"

	"github.com/wailsapp/wails/v2"
	"github.com/wailsapp/wails/v2/pkg/options"
	"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)

//go:embed all:frontend/dist
var assets embed.FS

func main() {
	// Create an instance of the app structure
	app := NewApp()

	err := wails.Run(&options.App{
		Title:  "My App",
		Width:  1024,
		Height: 768,
		AssetServer: &assetserver.Options{
			Assets: assets,
		},
		BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
		OnStartup:        app.startup,
		Bind: []interface{}{
			app,
		},
	})

	if err != nil {
		println("Error:", err.Error())
	}
}

Fairly straightforward, this is basically setting up a standard Windows with a title, a height background, and so forth.

The most interesting thing in the boilerplate code is this part:

		Bind: []interface{}{
			app,
		},

This is where the JavaScript magic happens. We bind our app struct, so its methods become callable from the frontend JS code.

Let's peek inside app.go:

package main

import (
	"context"
	"fmt"
	"myproject/models"
)

// App struct
type App struct {
	ctx context.Context
}

// NewApp creates a new App application struct
func NewApp() *App {
	return &App{}
}

// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
	a.ctx = ctx
}

// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
	return fmt.Sprintf("Hello %s, It's show time!", name)
}

Nothing fancy, but pay close attention to the "Greet" method. This is a function that is now exposed to the frontend, and you can call it from anywhere in your JS/TypeScript code just like any other function.

Next, look inside frontend/src/main.ts:

window.greet = function () {
    // Get name
    let name = nameElement!.value;

    // Check if the input is empty
    if (name === "") return;

    // Call App.Greet(name)
    try {
        Greet(name)
            .then((result) => {
                // Update result with data back from App.Greet()
                resultElement!.innerText = result;
            })
            .catch((err) => {
                console.error(err);
            });
    } catch (err) {
        console.error(err);
    }
};

Notice the "Greet" in the try catch! We are effectively calling the Greet method on our App struct πŸŽ‰, how powerful right?

Okay, but where’s the native Windows stuff?

We managed to create a UI in TypeScript and then fire a function in Golang, but you could just launch a headless browser and use WASM to do the same thing, right? So, what's the point?

Yee of little faith 😏!

Since we can run Go code and Go has bindings for Win32 APIs, we can effectively access almost any native functionality we need.

If you don't know what Win32 is, basically it's a standard set of APIs that the Windows OS heavily relies on to manage low-level systems like the UI, networking, security, and so forth. Whether you've used XP, Vista, 7, 10, or 11, you've probably noticed that decade-old programs still run perfectly. This is largely thanks to Win32, enabling Microsoft's exceptional backward compatibility.

Essentially, it's a collection of DLLs, or Dynamic Link Libraries. If you think of this in terms of Golang, a DLL is similar to a package containing code that you can import and use. The key difference is that DLLs are precompiled and shared across programs at runtime, rather than being compiled into your binary like Go packages, but the concept is similar.

Golang provides a way to interact with Win32 via this package:

golang.org/x/sys/windows

And to enable access to Win32 API's, it's as easy as follows:

user32 = windows.NewLazySystemDLL("user32.dll")

In this case, we import user32.dll, which is a DLL that gives us access to the general Windows UI and windowing system, this is exactly what we'll need to access running programs and essential information about those programs to decide whether it's bad content or not.

NewLazySystemDLL allows Go to load the DLL lazily, meaning the DLL and its functions are only loaded when they are actually needed instead of when the program starts.

Moving along, lets setup references in Go to the functions we need to access:

var (
	user32                       = windows.NewLazySystemDLL("user32.dll")
	procEnumWindows              = user32.NewProc("EnumWindows")
	procIsWindowVisible          = user32.NewProc("IsWindowVisible")
	procGetWindowTextLengthW     = user32.NewProc("GetWindowTextLengthW")
	procGetWindowTextW           = user32.NewProc("GetWindowTextW")
	procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
	procPostMessageW             = user32.NewProc("PostMessageW")
)

In addition to importing the user32.dll DLL, we’re also creating pointers to several Win32 functions. These act as a bridge between Go and the native Windows API. Once defined, we can call these functions and pass arguments to them almost as if they were normal Go functions.

  • EnumWindows - Will give us a list of running programs.

  • IsWindowVisible - Checks whether a window is visible by Windows, even if it’s minimized or behind other windows.

  • GetWindowTextLengthW - Basically string.length of a Windows title bar, this allows us to allocate a buffer of the correct size before reading the text.

  • GetWindowTextW - As you probably guessed by now, this is the actual title bar text.

  • GetWindowThreadProcessId - We'll use this to get the program's process ID so that we can terminate the program if we detect bad content.

A working prototype

Finally, here's the full Go code for V1 of our parent control app:

//go:build windows

package models

import (
	"strings"
	"syscall"
	"unsafe"

	"golang.org/x/sys/windows"
)

var (
	user32                   = windows.NewLazySystemDLL("user32.dll")
	procEnumWindows          = user32.NewProc("EnumWindows")
	procIsWindowVisible      = user32.NewProc("IsWindowVisible")
	procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
	procGetWindowTextW       = user32.NewProc("GetWindowTextW")
	procPostMessageW         = user32.NewProc("PostMessageW")
)

func ScanWindowTitles(hwnd uintptr, lparam uintptr) uintptr {
	vis, _, _ := procIsWindowVisible.Call(hwnd)

	if vis == 0 {
		return 1
	}

	tlen, _, _ := procGetWindowTextLengthW.Call(hwnd)

	if tlen == 0 {
		return 1
	}

	buf := make([]uint16, tlen+1)
	procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), tlen+1)
	title := windows.UTF16ToString(buf)
	if title == "" {
		return 1
	}

	if strings.Contains(title, "lotto") {
		procPostMessageW.Call(hwnd, 0x0010, 0, 0)
	}

	return 1

}

func RunBadAppChecker() error {

	windowScannerCallback := syscall.NewCallback(ScanWindowTitles)
	result, _, err := procEnumWindows.Call(windowScannerCallback, 0)
	if result == 0 {
		if err != nil && err != syscall.Errno(0) {
			return err
		}
		return syscall.EINVAL
	}

	return nil
}

The naming convention is a bit weird; it comes from the C-style approach of Win32. Here's a breakdown block-by-block of what's going on.

The logic behind this code:

  • windowScannerCallback := syscall.NewCallback(ScanWindowTitles) ~ converts a Go function into a Windows-compatible callback function, essentially a pointer, so it can be passed to Win32 APIs correctly. Additionally, this prevents Golang's garbage collection from getting "confused". Since we sort of tunnelling through to native libraries, the Garbage collector isn't fully aware of its state, thus it might try to collect or move this around in memory when it shouldn't.

  • result, _, err := procEnumWindows.Call(windowScannerCallback, 0) ~ We basically loop through all open windows and run this callback on each one. Similar to an array map function. The second argument 0 (of type uintptr ) is just an additional state argument we can pass to the function; in this case, we are not using it, so we set it to 0.

πŸ’‘ The uintptr type can hold a large integer representing a memory address. In Go terms, when you pass &someVariable to get a pointer, you're getting a memory address - uintptr is just that address stored as a plain integer.

Looking inside ScanWindowTitles We first check if the current window is visible:

	vis, _, _ := procIsWindowVisible.Call(hwnd)

	if vis == 0 {
		return 1
	}

Next, we check if the window has a title:

	tlen, _, _ := procGetWindowTextLengthW.Call(hwnd)

	if tlen == 0 {
		return 1
	}

πŸ’‘ hwnd ~ is just a Windows convention for an ID. Think of it like HTML: you give a <div> an ID to reference it in CSS/JS. Similarly, hwnd is an ID that lets you reference and interact with a specific window through Windows API calls.

At this point, we now have a window that's a) visible and b) has a title. So, this means we can scan it for bad words.

PS: Much of what you've seen thus far in this code is ceremonial C/C++, since Windows is built on these languages for historical and performance reasons. As Go developers, we're very privileged to have a high-performance language that's also clean and abstract enough without compromising too much on performance.

Sure, C/C++ will outperform Go in nearly all cases, but for modern computing, especially for web developers, that kind of raw performance is often overkill. Nonetheless, since we are interfacing with Win32, we've just got to swallow that hard pill of C/C++ style programming!

Anyway, getting back to our window title scanner, we have this weird-looking block of code:

	buf := make([]uint16, tlen+1)
	procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), tlen+1)
	title := windows.UTF16ToString(buf)
	if title == "" {
		return 1
	}

We create a buffer of type uint16 - essentially an array with a fixed size of elements that will hold the characters making up the window title. Windows uses UTF-16 encoding, which encodes each character as one or two 16-bit values.

The length of this buffer will be n+1 where n is the number of the characters in the title plus one **null terminator (**like an invisible character that has no relevance to the actual string besides being a marker). The null terminator is a bit weird for us Go developers, but this is how C-style strings work; they need a way to know where the string ends.

procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), tlen+1)
if title == "" {
  return 1
}

We're essentially extracting the window title and writing it to the buffer we created earlier - similar to an I/O file write operation. We return one if nothing came through; this is a Win32 standard way of returning status codes. In this case, we return 1, which means just skip ahead to the next window.

Now, we finally get to the checking for bad words part. I made this super simple, but in reality, you'd probably want to check if the value is in a list or do a DB lookup of some sort.


if strings.Contains(title, "lotto") {
   procPostMessageW.Call(hwnd, 0x0010, 0, 0)
}

// close window function

0x0010 ~ is a hexadecimal message code (Constant in Win32 API: WM_CLOSE) that tells Windows we want to close that window.

PostMessageW is sort of like a postman; we can send messages to the underlying WIN32 API. This API does not wait for the response, so it's essentially a message queue and asynchronous. Since older systems had memory constraints, old-school C/C++ often used constant hexadecimal or integer values to represent actions.

The C# approach

While this article is not really about C#, I did also experiment with the Dotnet ecosystem to see how far I can take this. It was very easy, actually πŸ€” Although to be honest, I got a lot of help from Claude Code.

❗ Beginner developers be careful! I don't suggest using AI to learn initially. I strongly suggest reading books first, and spending 3-6 months coding on your own. I use AI to learn sometimes and generate code, but I have a lot of programming experience. I may not fully understand C#'s syntax and API's, however, I understand the logic behind what is happening and can easily steer the AI in the right direction when it goes off-track.

I didn't cover this aspect in the Go code yet, but here we go:

var hosts = File.ReadAllLines(Config.HostsPath).ToList();

hosts.RemoveAll(line => line.Contains(Config.HostsMarker));
hosts.Add($"\n{Config.HostsMarker} - START");

foreach (var url in Config.BlockedUrls) {
 hosts.Add($"127.0.0.1 {url} {Config.HostsMarker}");
 hosts.Add($"{Config.HostsMarker} - END");
 File.WriteAllLines(Config.HostsPath, hosts);
}

In our C# version, we open the hosts file and write a list of bad hostnames to the file, each entry points to 127.0.0.1 (localhost). This ensures that whenever you try to visit any of these domains in your browser, the request gets redirected to your own machine instead of the actual website.

Since nothing is typically running on port 80 locally, the browser will either show an error, display nothing, or render whatever service you happen to have running on that port.

Before writing, we also search all lines that contain our custom marker and delete them. The marker is simply a string like: # BAD DOMAINS that we append to the end of each line, so that we can keep track of any changes we make to the file. This is important because the file can be edited by the OS or users at any time, and we want to ensure that we don't interfere with any lines not managed by the program.

For the title scanner, we can implement it in this way:


foreach (var proc in Process.GetProcesses())
{
    try
    {
        var title = proc.MainWindowTitle;

        if (CheckIfBannedKeyword(title, out string? matchedKeyword))
        {
            if (matchedKeyword != null)
                BlockContent(proc.MainWindowHandle, proc.ProcessName, title, "Keyword", matchedKeyword);
        }

    }
    catch { }
}
static bool CheckIfBannedKeyword(string text, out string? matchedKeyword)
{
    matchedKeyword = null;
    if (string.IsNullOrEmpty(text)) return false;
    var lower = text.ToLower();

    foreach (var term in Config.BlockedTerms)
    {
        if (lower.Contains(term))
        {
            matchedKeyword = term;
            return true;
        }
    }

    return false;
}
static void BlockContent(IntPtr windowHandle, string browserName, string windowTitle, string triggerType, string triggerValue)
{
    if (windowHandle != IntPtr.Zero)
    {
        db.LogViolation(triggerType, triggerValue, browserName, windowTitle);

        SetForegroundWindow(windowHandle);
        Thread.Sleep(100);

        keybd_event(VK_CONTROL, 0, 0, UIntPtr.Zero);
        keybd_event(VK_W, 0, 0, UIntPtr.Zero);
        keybd_event(VK_W, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
        keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
    }
}

We:

  • First, get a list of running processes.

  • Next, get the Window title and check if it contains any strings that match a word in our banned keywords list.

  • Finally shutdown that process.

Golang: Building a windows parental control app using wails