├── go.mod ├── example ├── icon.pdf └── main.go ├── tray_darwin.h ├── api.go ├── LICENSE ├── xtray_darwin.go ├── README.md ├── tray_darwin.m └── .github └── workflows └── release.yml /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tetsuo/xtray 2 | 3 | go 1.24.2 4 | -------------------------------------------------------------------------------- /example/icon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tetsuo/xtray/HEAD/example/icon.pdf -------------------------------------------------------------------------------- /tray_darwin.h: -------------------------------------------------------------------------------- 1 | #ifndef TRAY_DARWIN_H 2 | #define TRAY_DARWIN_H 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | void runTray(const char *tooltip, const char *iconPath); 9 | 10 | #ifdef __cplusplus 11 | } 12 | #endif 13 | #endif 14 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/tetsuo/xtray" 8 | ) 9 | 10 | func main() { 11 | if err := xtray.Tray( 12 | xtray.WithTooltip("MyApp"), 13 | xtray.WithIcon("icon.pdf"), 14 | xtray.WithLaunchCallback(func() { fmt.Println("Launched!") }), 15 | xtray.WithQuitCallback(func() { fmt.Println("Quitting...") }), 16 | ); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package xtray 2 | 3 | // Option configures a Tray. 4 | type Option func(*cfg) 5 | 6 | // Tray blocks on the macOS run-loop and returns when the application is exited. 7 | // It is safe to call only once. 8 | func Tray(opts ...Option) error { 9 | c := cfg{ 10 | tooltip: "", 11 | iconPath: "", 12 | } 13 | for _, op := range opts { 14 | op(&c) 15 | } 16 | return runCore(c) 17 | } 18 | 19 | func WithTooltip(s string) Option { return func(c *cfg) { c.tooltip = s } } 20 | func WithIcon(path string) Option { return func(c *cfg) { c.iconPath = path } } 21 | func WithLaunchCallback(f func()) Option { return func(c *cfg) { c.launch = f } } 22 | func WithQuitCallback(f func()) Option { return func(c *cfg) { c.quit = f } } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Onur Gündüz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /xtray_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package xtray 5 | 6 | /* 7 | #cgo darwin CFLAGS: -fobjc-arc -x objective-c 8 | #cgo darwin LDFLAGS: -framework Cocoa 9 | #include 10 | #include "tray_darwin.h" 11 | */ 12 | import "C" 13 | 14 | import ( 15 | "fmt" 16 | "os" 17 | "runtime" 18 | "unsafe" 19 | ) 20 | 21 | type cfg struct { 22 | tooltip string 23 | iconPath string 24 | launch func() 25 | quit func() 26 | } 27 | 28 | //export goOnLaunch 29 | func goOnLaunch() { 30 | if current.launch != nil { 31 | current.launch() 32 | } 33 | } 34 | 35 | //export goOnQuit 36 | func goOnQuit() { 37 | if current.quit != nil { 38 | current.quit() 39 | } 40 | } 41 | 42 | var current cfg // holds the active tray's callbacks 43 | 44 | func runCore(c cfg) error { 45 | if c.iconPath != "" { 46 | if _, err := os.Stat(c.iconPath); err != nil { 47 | return fmt.Errorf("icon path: %w", err) 48 | } 49 | } 50 | 51 | current = c // make callbacks reachable from C 52 | 53 | // c-strings 54 | var cTip, cIcon *C.char 55 | if c.tooltip != "" { 56 | cTip = C.CString(c.tooltip) 57 | defer C.free(unsafe.Pointer(cTip)) 58 | } 59 | if c.iconPath != "" { 60 | cIcon = C.CString(c.iconPath) 61 | defer C.free(unsafe.Pointer(cIcon)) 62 | } 63 | 64 | // Cocoa must live on the main OS thread 65 | runtime.LockOSThread() 66 | C.runTray(cTip, cIcon) // blocks until NSApp.terminate: 67 | runtime.UnlockOSThread() 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xtray 🧃 2 | 3 | Tiny macOS menubar utility for Go. 4 | 5 | ![screenshot](http://i.imgur.com/gVCZMN7.png) 6 | 7 | Adds a system tray icon with a "Quit" menu item and simple callbacks. 8 | 9 | ## Install 10 | 11 | ```bash 12 | go get github.com/tetsuo/xtray 13 | ``` 14 | 15 | Requires macOS. Uses `cgo` and the Cocoa framework. 16 | 17 | ## Usage 18 | 19 | ```go 20 | package main 21 | 22 | import ( 23 | "fmt" 24 | "log" 25 | 26 | "github.com/tetsuo/xtray" 27 | ) 28 | 29 | func main() { 30 | if err := xtray.Tray( 31 | xtray.WithTooltip("MyApp"), 32 | xtray.WithIcon("icon.pdf"), 33 | xtray.WithLaunchCallback(func() { 34 | fmt.Println("Launched!") 35 | }), 36 | xtray.WithQuitCallback(func() { 37 | fmt.Println("Quitting...") 38 | }), 39 | ); err != nil { 40 | log.Fatal(err) 41 | } 42 | } 43 | ``` 44 | 45 | ## API 46 | 47 | ```go 48 | func Tray(opts ...Option) error 49 | ``` 50 | 51 | Launches the macOS menubar app and blocks until it is quit. 52 | 53 | ### Options 54 | 55 | | Option | Description | 56 | | ---------------------------- | ------------------------------------------- | 57 | | `WithTooltip(string)` | Sets the tooltip shown on hover | 58 | | `WithIcon(string)` | Path to a `.pdf` tray icon | 59 | | `WithLaunchCallback(func())` | Called after the app has finished launching | 60 | | `WithQuitCallback(func())` | Called before the app terminates | 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /tray_darwin.m: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | #import 5 | #import 6 | #import 7 | #import "tray_darwin.h" 8 | 9 | void goOnLaunch(void); 10 | void goOnQuit(void); 11 | 12 | @interface TrayDelegate : NSObject 13 | - (instancetype)initWithTooltip:(const char *)tooltip 14 | iconPath:(const char *)iconPath; 15 | @end 16 | 17 | @implementation TrayDelegate { 18 | NSStatusItem *_item; 19 | } 20 | 21 | - (instancetype)initWithTooltip:(const char *)tooltip 22 | iconPath:(const char *)iconPath 23 | { 24 | self = [super init]; 25 | if (!self) return nil; 26 | 27 | _item = [[NSStatusBar systemStatusBar] 28 | statusItemWithLength:NSVariableStatusItemLength]; 29 | 30 | if (tooltip) { 31 | _item.button.toolTip = [NSString stringWithUTF8String:tooltip]; 32 | } 33 | 34 | if (iconPath) { 35 | NSImage *img = [[NSImage alloc] initWithContentsOfFile: 36 | [NSString stringWithUTF8String:iconPath]]; 37 | img.template = YES; 38 | img.resizingMode = NSImageResizingModeStretch; 39 | _item.button.image = img; 40 | } 41 | 42 | // "Quit [tooltip]" menu 43 | NSMenu *menu = [[NSMenu alloc] init]; 44 | NSString *title = @"Quit"; 45 | if (_item.button.toolTip.length) 46 | title = [title stringByAppendingFormat:@" %@", _item.button.toolTip]; 47 | [menu addItem:[[NSMenuItem alloc] initWithTitle:title 48 | action:@selector(terminate:) 49 | keyEquivalent:@""]]; 50 | _item.menu = menu; 51 | return self; 52 | } 53 | 54 | - (void)applicationDidFinishLaunching:(NSNotification *)n { goOnLaunch(); } 55 | - (void)applicationWillTerminate:(NSNotification *)n { 56 | _item.menu = nil; 57 | _item.button.image = nil; 58 | goOnQuit(); 59 | } 60 | 61 | @end // TrayDelegate 62 | 63 | 64 | // C API exposed to Go 65 | 66 | void runTray(const char *tooltip, const char *iconPath) 67 | { 68 | @autoreleasepool { 69 | NSApplication *app = [NSApplication sharedApplication]; 70 | app.activationPolicy = NSApplicationActivationPolicyAccessory; 71 | 72 | TrayDelegate *d = [[TrayDelegate alloc] initWithTooltip:tooltip 73 | iconPath:iconPath]; 74 | objc_setAssociatedObject(app, @selector(delegate), d, OBJC_ASSOCIATION_RETAIN); 75 | app.delegate = d; 76 | [app activateIgnoringOtherApps:YES]; 77 | 78 | [app run]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | concurrency: 4 | group: release-${{ github.head_ref }} 5 | cancel-in-progress: true 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: "Impact" 11 | required: true 12 | default: 'patch' 13 | type: choice 14 | options: 15 | - minor 16 | - patch 17 | 18 | jobs: 19 | linux: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | go-version: [1.24.x] 24 | 25 | steps: 26 | - name: Set up Go 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: ${{ matrix.go-version }} 30 | id: go 31 | 32 | - name: Check out code 33 | uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | 37 | - name: Release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | set -euxo pipefail 42 | 43 | MAJOR=$(echo "$GITHUB_REF_NAME" | sed -nE 's/^v([0-9]+)\.x$/\1/p') 44 | if [[ -z "$MAJOR" ]]; then 45 | echo "Invalid branch name format (expected vX.x)" >&2 46 | exit 1 47 | fi 48 | 49 | next_version () { 50 | local current impact MINOR PATCH 51 | impact="${1:-patch}" 52 | 53 | current=$(git tag | grep "^v$MAJOR\.[0-9]*\.[0-9]*" | sort -t "." -k1,1nr -k2,2nr -k3,3nr | cut -c2- | head -1) || true 54 | 55 | if [[ -z "$current" ]]; then 56 | if [[ "$MAJOR" -eq 0 ]]; then 57 | echo "0.0.1" 58 | else 59 | echo "$MAJOR.0.0" 60 | fi 61 | return 62 | fi 63 | 64 | IFS='.' read -r _ MINOR PATCH <<< "$current" 65 | 66 | case "$impact" in 67 | major) 68 | ((MAJOR+=1)) 69 | MINOR=0 70 | PATCH=0 71 | ;; 72 | minor) 73 | ((MINOR+=1)) 74 | PATCH=0 75 | ;; 76 | patch) 77 | ((PATCH+=1)) 78 | ;; 79 | *) 80 | echo "Invalid impact: $impact" >&2 81 | exit 1 82 | ;; 83 | esac 84 | 85 | echo "$MAJOR.$MINOR.$PATCH" 86 | } 87 | 88 | push_release () { 89 | local commit next 90 | 91 | commit=$(git rev-parse HEAD) 92 | 93 | if git tag --points-at "$commit" | grep -q .; then 94 | echo "Commit already tagged, skipping release" 95 | return 96 | fi 97 | 98 | next="v$(next_version "${1:-patch}")" 99 | 100 | git config user.name github-actions 101 | git config user.email github-actions@github.com 102 | 103 | git tag "$next" 104 | git push --tags 105 | 106 | echo "BUILD_VERSION=$next" >> "$GITHUB_ENV" 107 | } 108 | 109 | git fetch --all --tags 110 | 111 | push_release minor 112 | --------------------------------------------------------------------------------