Please purchase the course to watch this video.

Full Course
Writing cross-platform applications in Go involves adapting code to accommodate different operating system behaviors, which can be challenging due to varying methods of functionality implementation. The lesson highlights three techniques for achieving cross-platform compatibility: using runtime checks, compile-time file segmentation, and build tags. Runtime checks utilize Go's runtime
package to conditionally execute code based on the operating system detected. Compile-time file segmentation relies on naming conventions to specify OS-specific implementations, while build tags offer a more flexible way to include or exclude features during compilation based on defined conditions. Each approach has its advantages and challenges, providing developers with valuable tools for creating robust, platform-agnostic software.
Throughout this course so far, we've managed to work with many different areas of our operating system, including:
- File system operations
- Networking
- Process handling
All of this has been achieved through the use of the Go standard library, which has made it really easy to write and build cross-platform software without needing to think too much about it.
However, sometimes when it comes to our own code, there's going to be a need to add our own cross-platform behavior in order to handle different behaviors on different operating systems.
When it comes to Go, there are actually three different ways we can achieve writing cross-platform code, which we're going to take a look at in this lesson.
Example Project: Pomodoro Timer
To demonstrate cross-platform development, we'll use a simple Pomodoro timer application. You can find the complete code in the lesson resources.
Project Structure:
pomodoro-timer/
├── main.go
└── config.yaml
config.yaml
tasks:
code_focus:
duration: 5s # Short for demo - normally 25m
reading:
duration: 10s # Short for demo - normally 1h
Basic Timer Implementation
package main
import (
"fmt"
"log"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Task struct {
Duration time.Duration `yaml:"duration"`
}
type Config struct {
Tasks map[string]Task `yaml:"tasks"`
}
func main() {
if len(os.Args) < 3 {
log.Fatal("Usage: ./timer <config.yaml> <task_name>")
}
configFile := os.Args[1]
taskName := os.Args[2]
// Load config
data, err := os.ReadFile(configFile)
if err != nil {
log.Fatalf("Failed to read config: %v", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
log.Fatalf("Failed to parse config: %v", err)
}
task, exists := config.Tasks[taskName]
if !exists {
log.Fatalf("Task '%s' not found", taskName)
}
// Start timer
fmt.Printf("Task '%s' starting. See you in %v!\n", taskName, task.Duration)
time.Sleep(task.Duration)
// Notify task end (currently just prints)
notifyTaskEnd(taskName, "Put down your keyboard, code focus is over!")
}
func notifyTaskEnd(taskName, message string) {
fmt.Printf("NOTIFICATION: %s - %s\n", taskName, message)
}
Testing the Basic Timer
go run main.go config.yaml code_focus
# Output: Task 'code_focus' starting. See you in 5s!
# (after 5 seconds)
# Output: NOTIFICATION: code_focus - Put down your keyboard, code focus is over!
The Challenge: OS-Specific Notifications
Let's say we want to improve this application by displaying native OS alert dialogs when the time is over. This is where things get challenging, as different operating systems have different ways of displaying alert dialogs:
- Linux: Zenity CLI tool
- macOS: OSAScript (AppleScript)
- Windows: PowerShell or native Windows dialogs
Method 1: Runtime Checks
The most straightforward approach is to perform a runtime check using the runtime
package.
Understanding runtime.GOOS
Go provides the runtime.GOOS
constant that tells us which operating system Go is running on at runtime.
# See all supported OS/architecture combinations
go tool dist list
Output includes:
darwin/amd64
darwin/arm64
linux/386
linux/amd64
windows/386
windows/amd64
...
The OS values we're interested in:
darwin
- macOSlinux
- Linuxwindows
- Windows
Implementing Runtime Checks
package main
import (
"fmt"
"os"
"os/exec"
"runtime"
"time"
// ... other imports
)
func notifyTaskEnd(taskName, message string) {
switch runtime.GOOS {
case "darwin":
if err := displayOSADialog(taskName, message); err != nil {
fmt.Printf("Error showing dialog: %v\n", err)
fallbackNotification(taskName, message)
}
case "linux":
if err := displayZenityDialog(taskName, message); err != nil {
fmt.Printf("Error showing dialog: %v\n", err)
fallbackNotification(taskName, message)
}
default:
fallbackNotification(taskName, message)
}
}
func displayOSADialog(title, message string) error {
script := fmt.Sprintf(`display alert "%s" message "%s"`, title, message)
cmd := exec.Command("osascript", "-e", script)
return cmd.Run()
}
func displayZenityDialog(title, message string) error {
cmd := exec.Command("zenity", "--info",
"--title="+title,
"--text="+message)
return cmd.Run()
}
func fallbackNotification(taskName, message string) {
fmt.Printf("NOTIFICATION: %s - %s\n", taskName, message)
}
Testing Runtime Checks
# On macOS
go run main.go config.yaml code_focus
# Shows native macOS dialog
# On Linux (with zenity installed)
go run main.go config.yaml code_focus
# Shows zenity dialog
# On Windows or other OS
go run main.go config.yaml code_focus
# Falls back to console output
Problems with Runtime Checks
While this approach works, it has some issues:
- All code is compiled - Even unused OS-specific code gets included
- Runtime errors - Missing dependencies cause runtime failures
- Larger binaries - All platform code is included in every build
- Runtime overhead - Checking OS on every call
Method 2: File Segmentation
A better approach is to use file segmentation, which works at compile time by using specific file naming conventions.
How File Segmentation Works
The Go compiler automatically includes/excludes files based on their names:
dialog_darwin.go
- Only compiled on macOSdialog_linux.go
- Only compiled on Linuxdialog_windows.go
- Only compiled on Windows
This is actually how the Go standard library works! Check out the Go source code:
# Examples from Go's runtime package:
# - mem_linux.go
# - vgetrandom_linux.go
# - createfile_unix.go
Implementing File Segmentation
Let's restructure our code using file segmentation:
Project Structure:
pomodoro-timer/
├── main.go
├── dialog_darwin.go
├── dialog_linux.go
├── dialog_windows.go
└── config.yaml
main.go
package main
import (
"fmt"
"log"
"os"
"time"
"gopkg.in/yaml.v3"
)
// ... Config and Task structs remain the same ...
func main() {
// ... main logic remains the same ...
// Notify task end using OS-specific implementation
if err := showDialog(taskName, "Put down your keyboard, code focus is over!"); err != nil {
fmt.Printf("Error showing dialog: %v\n", err)
}
}
// showDialog is implemented in OS-specific files
dialog_darwin.go
package main
import (
"fmt"
"os/exec"
)
func showDialog(title, message string) error {
script := fmt.Sprintf(`display alert "%s" message "%s"`, title, message)
cmd := exec.Command("osascript", "-e", script)
return cmd.Run()
}
dialog_linux.go
package main
import (
"os/exec"
)
func showDialog(title, message string) error {
cmd := exec.Command("zenity", "--info",
"--title="+title,
"--text="+message)
return cmd.Run()
}
dialog_windows.go
package main
import (
"fmt"
)
func showDialog(title, message string) error {
// Windows implementation using PowerShell
// (placeholder for now)
fmt.Printf("WINDOWS NOTIFICATION: %s - %s\n", title, message)
return nil
}
Benefits of File Segmentation
- Clean separation - Each OS has its own file
- Compile-time inclusion - Only relevant code is compiled
- Smaller binaries - No unused platform code
- Easy to maintain - Clear organization
Limitations of File Segmentation
- Must cover all target platforms - Need a file for each OS
- No "default case" - Hard to handle "everything else"
- Can't use boolean logic - Either/or conditions are complex
Method 3: Build Tags
The most flexible approach is using build tags (also called build constraints), which provide fine-grained control over what gets compiled.
Understanding Build Tags
Build tags are special comments that tell the Go compiler when to include a file:
//go:build darwin
// +build darwin
package main
// ... rest of file only compiled on macOS
Implementing Build Tags
Let's restructure using build tags for maximum flexibility:
Project Structure:
pomodoro-timer/
├── main.go
├── dialog_osa.go # macOS OSAScript implementation
├── dialog_zenity.go # Linux Zenity implementation
├── dialog_text.go # Fallback text implementation
└── config.yaml
dialog_osa.go
//go:build darwin
package main
import (
"fmt"
"os/exec"
)
func showDialog(title, message string) error {
script := fmt.Sprintf(`display alert "%s" message "%s"`, title, message)
cmd := exec.Command("osascript", "-e", script)
return cmd.Run()
}
dialog_zenity.go
//go:build linux
package main
import (
"os/exec"
)
func showDialog(title, message string) error {
cmd := exec.Command("zenity", "--info",
"--title="+title,
"--text="+message)
return cmd.Run()
}
dialog_text.go
//go:build !darwin && !linux
package main
import (
"fmt"
)
func showDialog(title, message string) error {
fmt.Printf("NOTIFICATION: %s - %s\n", title, message)
return nil
}
Advanced Build Tag Logic
Build tags support boolean operations:
// Only compile on Windows
//go:build windows
// Only compile on Linux OR macOS
//go:build linux || darwin
// Only compile on everything EXCEPT Linux and macOS
//go:build !linux && !darwin
// Compile on 64-bit architectures only
//go:build amd64 || arm64
// Combine OS and architecture
//go:build (linux || darwin) && amd64
Testing Cross-Platform Builds
# Build for current platform
go build .
# Cross-compile for different platforms
GOOS=darwin go build -o timer-mac .
GOOS=linux go build -o timer-linux .
GOOS=windows go build -o timer-win.exe .
# Build for specific platforms that don't match current
GOOS=openbsd go build . # Uses dialog_text.go
GOOS=freebsd go build . # Uses dialog_text.go
Comparison of Methods
Method | Pros | Cons | Best For |
---|---|---|---|
Runtime Checks | Simple to implement, all code in one place | Larger binaries, runtime overhead, all dependencies needed | Quick prototypes, simple logic |
File Segmentation | Clean separation, compile-time, smaller binaries | Must cover all platforms, no boolean logic | Well-defined platform support |
Build Tags | Maximum flexibility, boolean logic, fine-grained control | More complex, requires understanding of build system | Complex platform requirements |
Platform-Specific Dependencies
macOS (Darwin) Requirements
# OSAScript comes pre-installed on macOS
osascript -e 'display alert "Test" message "Hello World"'
Linux Requirements
# Install Zenity
sudo apt-get install zenity # Ubuntu/Debian
sudo yum install zenity # CentOS/RHEL
sudo pacman -S zenity # Arch Linux
# Test Zenity
zenity --info --title="Test" --text="Hello World"
Windows Requirements
# PowerShell comes pre-installed
# Test with:
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show("Hello World", "Test")
Real-World Example: Complete Cross-Platform Dialog
Here's a more robust implementation with error handling:
dialog_windows.go
//go:build windows
package main
import (
"fmt"
"os/exec"
)
func showDialog(title, message string) error {
// Use PowerShell to show a Windows MessageBox
script := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show('%s', '%s', 'OK', 'Information')
`, message, title)
cmd := exec.Command("powershell", "-Command", script)
return cmd.Run()
}
Enhanced error handling in main.go
func notifyTaskEnd(taskName, message string) {
if err := showDialog(taskName, message); err != nil {
// Fallback to console if dialog fails
fmt.Printf("Dialog failed (%v), using console notification:\n", err)
fmt.Printf("NOTIFICATION: %s - %s\n", taskName, message)
}
}
Best Practices
- Start with build tags - Most flexible for future requirements
- Always provide fallbacks - Handle missing dependencies gracefully
- Test on target platforms - Cross-compilation isn't the same as testing
- Document platform requirements - Make dependencies clear
- Use CI/CD for multiple platforms - Automate building and testing
- Consider third-party libraries - Libraries like
go-toast
handle cross-platform notifications
Common Use Cases
Operating System Detection
//go:build windows
func clearScreen() {
cmd := exec.Command("cls")
cmd.Run()
}
//go:build !windows
func clearScreen() {
cmd := exec.Command("clear")
cmd.Run()
}
Architecture-Specific Code
//go:build amd64
const PointerSize = 8
//go:build 386
const PointerSize = 4
Feature Flags
//go:build debug
func debugLog(msg string) {
fmt.Printf("DEBUG: %s\n", msg)
}
//go:build !debug
func debugLog(msg string) {
// No-op in release builds
}
Homework Assignment
Implement complete dialog systems for all three operating systems:
Requirements:
- macOS (OSAScript) - Already shown above
- Linux (Zenity) - Already shown above
- Windows (PowerShell) - Implement Windows MessageBox dialog
Resources:
- Windows PowerShell MessageBox: Use
System.Windows.Forms.MessageBox
- Alternative Windows approaches:
msg
command, Windows API calls - Error handling: Graceful fallbacks when tools aren't available
Bonus Challenges:
- Add button types - OK, Yes/No, OK/Cancel dialogs
- Return user choice - Capture which button was clicked
- Add icons - Information, Warning, Error icons
- Timeout support - Auto-close dialogs after X seconds
- Rich text - Support for formatting in messages
Testing:
# Test on current platform
go run . config.yaml code_focus
# Cross-compile and test on different platforms
GOOS=windows go build -o timer.exe .
GOOS=linux go build -o timer-linux .
GOOS=darwin go build -o timer-mac .
Once you've completed this homework, you'll have a solid understanding of cross-platform development in Go and be ready to move on to the next lesson about build tags in greater detail.
The complete reference implementation can be found in the lesson resources, including all platform-specific dialog implementations and error handling patterns.
Finish Implementation
Implement complete dialog systems for all three operating systems:
Requirements:
- macOS (OSAScript) - Already shown above
- Linux (Zenity) - Already shown above
- Windows (PowerShell) - Implement Windows MessageBox dialog
Resources:
- Windows PowerShell MessageBox: Use
System.Windows.Forms.MessageBox
- Alternative Windows approaches:
msg
command, Windows API calls - Error handling: Graceful fallbacks when tools aren't available
Bonus Challenges:
- Add button types - OK, Yes/No, OK/Cancel dialogs
- Return user choice - Capture which button was clicked
- Add icons - Information, Warning, Error icons
- Timeout support - Auto-close dialogs after X seconds
- Rich text - Support for formatting in messages
Testing:
# Test on current platform
go run . config.yaml code_focus
# Cross-compile and test on different platforms
GOOS=windows go build -o timer.exe .
GOOS=linux go build -o timer-linux .
GOOS=darwin go build -o timer-mac .
Once you've completed this homework, you'll have a solid understanding of cross-platform development in Go and be ready to move on to the next lesson about build tags in greater detail.
The complete reference implementation can be found in the lesson resources, including all platform-specific dialog implementations and error handling patterns.