Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,43 @@ jobs:
name: ${{ matrix.binary }}
path: ${{ matrix.binary }}

build-linux-desktop:
needs: test
runs-on: ubuntu-latest
name: Build Linux desktop (amd64)

steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Install GTK and WebKitGTK dev libraries
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev

- name: Read version
id: version
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"

- name: Build Linux desktop artifact
run: |
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
-tags menubar,desktop,production \
-ldflags="-s -w -X main.version=${{ steps.version.outputs.version }}" \
-o onwatch-linux-amd64-desktop .

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: onwatch-linux-amd64-desktop
path: onwatch-linux-amd64-desktop

build-macos-amd64:
needs: test
runs-on: macos-14
Expand Down Expand Up @@ -147,7 +184,7 @@ jobs:

# Create GitHub release with all binaries
release:
needs: [build-standard, build-macos-amd64, build-macos-arm64]
needs: [build-standard, build-macos-amd64, build-macos-arm64, build-linux-desktop]
runs-on: ubuntu-latest
name: Release

Expand Down
39 changes: 39 additions & 0 deletions app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ VERSION=$(cat "$SCRIPT_DIR/VERSION")
BINARY="onwatch"
DARWIN_FULL_TAGS="menubar,desktop,production"
DARWIN_CGO_LDFLAGS="-framework UniformTypeIdentifiers"
LINUX_DESKTOP_TAGS="menubar,desktop,production"

# --- Colors ---
RED='\033[0;31m'
Expand Down Expand Up @@ -145,6 +146,13 @@ do_deps() {
else
success "git already installed: $(git --version)"
fi
# Install GTK/WebKit dev libs for Linux desktop menubar variant
if ! pkg-config --exists gtk+-3.0 webkit2gtk-4.1 2>/dev/null; then
info "Installing GTK3/WebKitGTK dev libs for desktop menubar support..."
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
else
success "GTK3/WebKitGTK dev libs already installed"
fi
elif [[ -f /etc/redhat-release ]] || [[ -f /etc/fedora-release ]]; then
info "Detected Fedora/RHEL -- using dnf"
if ! command -v go &>/dev/null; then
Expand All @@ -159,6 +167,13 @@ do_deps() {
else
success "git already installed: $(git --version)"
fi
# Install GTK/WebKit dev libs for Linux desktop menubar variant
if ! pkg-config --exists gtk+-3.0 webkit2gtk-4.1 2>/dev/null; then
info "Installing GTK3/WebKitGTK dev libs for desktop menubar support..."
sudo dnf install -y gtk3-devel webkit2gtk4.1-devel libayatana-appindicator-gtk3-devel
else
success "GTK3/WebKitGTK dev libs already installed"
fi
else
error "Unsupported OS. Please install Go and git manually."
exit 1
Expand Down Expand Up @@ -193,6 +208,16 @@ build_native_binary() {
return
fi

# On Linux, build with menubar support if GTK and WebKitGTK dev libs are available
if [[ "$(uname)" == "Linux" ]] && pkg-config --exists gtk+-3.0 webkit2gtk-4.1 2>/dev/null; then
info "GTK3 and WebKitGTK detected - building with menubar support"
CGO_ENABLED=1 go build \
-tags "$LINUX_DESKTOP_TAGS" \
-ldflags="-s -w -X main.version=$VERSION" \
-o "$output" .
return
fi

go build \
-ldflags="-s -w -X main.version=$VERSION" \
-o "$output" .
Expand Down Expand Up @@ -271,6 +296,20 @@ do_release() {
-o "$SCRIPT_DIR/$output" .
done

# Build Linux desktop variant with menubar support (requires GTK/WebKit dev libs)
if [[ "$(uname)" == "Linux" ]] && pkg-config --exists gtk+-3.0 webkit2gtk-4.1 2>/dev/null; then
for arch in amd64; do
local output="dist/onwatch-linux-${arch}-desktop"
info " Building ${output} (with menubar support)..."
CGO_ENABLED=1 GOOS=linux GOARCH="$arch" go build \
-tags "$LINUX_DESKTOP_TAGS" \
-ldflags="-s -w -X main.version=$VERSION" \
-o "$SCRIPT_DIR/$output" .
done
else
warn "Skipping Linux desktop binary - GTK3/WebKitGTK dev libs not found. Install libgtk-3-dev and libwebkit2gtk-4.1-dev."
fi

success "Release build complete. Binaries in dist/:"
ls -lh "$SCRIPT_DIR/dist/"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build menubar && darwin
//go:build menubar && (darwin || linux)

package menubar

Expand Down Expand Up @@ -75,7 +75,7 @@ func (c *trayController) onReady() {
})

if popover, err := newMenubarPopover(menubarPopoverWidth, menubarPopoverHeight); err != nil {
logger.Warn("native macOS menubar host unavailable, using browser fallback", "error", err)
logger.Warn("native menubar host unavailable, using browser fallback", "error", err)
} else {
c.popover = popover
}
Expand Down Expand Up @@ -128,18 +128,6 @@ func (c *trayController) toggleMenubar() {
_ = browser.OpenURL(url)
}

func (c *trayController) showMenubar() {
url := c.menubarURL()
if c.popover != nil {
if err := c.popover.ShowURL(url); err == nil {
return
} else {
slog.Default().Warn("failed to show native menubar host, opening browser fallback", "error", err)
}
}
_ = browser.OpenURL(url)
}

func (c *trayController) refreshLoop() {
interval := time.Duration(normalizeRefreshSeconds(c.cfg.RefreshSeconds)) * time.Second
ticker := time.NewTicker(interval)
Expand Down
160 changes: 160 additions & 0 deletions internal/menubar/companion_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//go:build menubar && (darwin || linux)

package menubar

import (
"testing"
"time"
)

func TestNormalizeRefreshSecondsBelowMinimum(t *testing.T) {
cases := []struct {
input int
expected int
}{
{0, 60},
{1, 60},
{5, 60},
{9, 60},
{-1, 60},
{10, 10},
{11, 11},
{60, 60},
{300, 300},
}
for _, tc := range cases {
got := normalizeRefreshSeconds(tc.input)
if got != tc.expected {
t.Errorf("normalizeRefreshSeconds(%d) = %d, want %d", tc.input, got, tc.expected)
}
}
}

func TestTrayTooltipNilSnapshot(t *testing.T) {
got := trayTooltip(nil)
if got != "onWatch menubar companion" {
t.Fatalf("expected default tooltip, got %q", got)
}
}

func TestTrayTooltipZeroProviders(t *testing.T) {
snapshot := &Snapshot{
Aggregate: Aggregate{ProviderCount: 0},
}
got := trayTooltip(snapshot)
if got != "onWatch menubar companion: no provider data available" {
t.Fatalf("expected no-provider tooltip, got %q", got)
}
}

func TestTrayTooltipWithProviders(t *testing.T) {
snapshot := &Snapshot{
UpdatedAgo: "2m ago",
Aggregate: Aggregate{
ProviderCount: 3,
Label: "42%",
},
}
got := trayTooltip(snapshot)
expected := "onWatch menubar companion: 42% across 3 providers, updated 2m ago"
if got != expected {
t.Fatalf("expected %q, got %q", expected, got)
}
}

func TestTrayControllerMenubarURL(t *testing.T) {
cases := []struct {
port int
expected string
}{
{0, "http://localhost:9211/menubar"},
{8080, "http://localhost:8080/menubar"},
{9211, "http://localhost:9211/menubar"},
}
for _, tc := range cases {
c := &trayController{cfg: &Config{Port: tc.port}}
got := c.menubarURL()
if got != tc.expected {
t.Errorf("menubarURL() with port=%d = %q, want %q", tc.port, got, tc.expected)
}
}
}

func TestTrayControllerDashboardURL(t *testing.T) {
c := &trayController{cfg: &Config{Port: 8888}}
got := c.dashboardURL()
if got != "http://localhost:8888" {
t.Fatalf("expected http://localhost:8888, got %q", got)
}
}

func TestTrayControllerPreferencesURL(t *testing.T) {
c := &trayController{cfg: &Config{Port: 9211}}
got := c.preferencesURL()
if got != "http://localhost:9211/api/menubar/preferences" {
t.Fatalf("expected preferences URL, got %q", got)
}
}

func TestTrayControllerMenubarURLNilConfig(t *testing.T) {
c := &trayController{}
got := c.menubarURL()
if got != "http://localhost:9211/menubar" {
t.Fatalf("expected default menubar URL, got %q", got)
}
}

func TestTrayControllerDashboardURLNilConfig(t *testing.T) {
c := &trayController{}
got := c.dashboardURL()
if got != "http://localhost:9211" {
t.Fatalf("expected default dashboard URL, got %q", got)
}
}

func TestRefreshStatusRequiresSystray(t *testing.T) {
t.Skip("refreshStatus calls systray.SetTitle which requires systray init; covered by integration tests")
}

func TestRefreshStatusHandlesSnapshotError(t *testing.T) {
errCalled := false
cfg := &Config{
Port: 9211,
SnapshotProvider: func() (*Snapshot, error) {
errCalled = true
return nil, &time.ParseError{}
},
}
c := &trayController{cfg: cfg}
// We can't call refreshStatus directly because it calls systray.SetTitle,
// but we can verify the provider is callable.
_, _ = c.cfg.SnapshotProvider()
if !errCalled {
t.Fatal("expected snapshot provider to be called")
}
}

func TestStopCompanionQuitOnceIdempotency(t *testing.T) {
// Reset global state for this test
originalQuitOnce := quitOnce
originalQuitFn := quitFn
defer func() {
quitOnce = originalQuitOnce
quitFn = originalQuitFn
}()

callCount := 0
quitFn = func() {
callCount++
}

// First call should invoke quitFn
_ = stopCompanion()

// Second call should be no-op due to sync.Once
_ = stopCompanion()

if callCount != 1 {
t.Fatalf("expected quitFn called exactly once, got %d", callCount)
}
}
2 changes: 1 addition & 1 deletion internal/menubar/menubar_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "sync/atomic"
var running atomic.Bool

// Init starts the real menubar companion. The implementation lives in
// companion_darwin.go to keep macOS-specific UI code isolated.
// companion_unix.go which is shared between macOS and Linux.
func Init(cfg *Config) error {
if cfg == nil {
cfg = DefaultConfig()
Expand Down
30 changes: 30 additions & 0 deletions internal/menubar/menubar_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build menubar && linux

package menubar

import "sync/atomic"

var running atomic.Bool

// Init starts the real menubar companion. The implementation lives in
// companion_unix.go which is shared between macOS and Linux.
func Init(cfg *Config) error {
if cfg == nil {
cfg = DefaultConfig()
}
running.Store(true)
defer running.Store(false)
return runCompanion(cfg)
}

// Stop requests the menubar companion to exit.
func Stop() error {
running.Store(false)
return stopCompanion()
}

// IsSupported reports whether this build can run the real menubar companion.
func IsSupported() bool { return true }

// IsRunning reports whether the companion is marked as active.
func IsRunning() bool { return running.Load() || companionProcessRunning() }
2 changes: 1 addition & 1 deletion internal/menubar/menubar_stub.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build !menubar || !darwin
//go:build !menubar || (!darwin && !linux)

package menubar

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
//go:build menubar && darwin

package menubar

import "errors"
Expand All @@ -9,7 +7,7 @@ const (
menubarPopoverHeight = 680
)

var errNativePopoverUnavailable = errors.New("native macOS menubar host unavailable")
var errNativePopoverUnavailable = errors.New("native menubar host unavailable")

type menubarPopover interface {
ShowURL(string) error
Expand Down
Loading
Loading