| 1 | // Package systray is a cross-platform Go library to place an icon and menu in the notification area. |
| 2 | package systray |
| 3 | |
| 4 | import ( |
| 5 | "fmt" |
| 6 | "log" |
| 7 | "runtime" |
| 8 | "sync" |
| 9 | "sync/atomic" |
| 10 | ) |
| 11 | |
| 12 | var ( |
| 13 | systrayReady, systrayExit func() |
| 14 | tappedLeft, tappedRight func() |
| 15 | systrayExitCalled bool |
| 16 | menuItems = make(map[uint32]*MenuItem) |
| 17 | menuItemsLock sync.RWMutex |
| 18 | |
| 19 | initialMenuBuilt sync.WaitGroup |
| 20 | currentID atomic.Uint32 |
| 21 | quitOnce sync.Once |
| 22 | |
| 23 | // TrayOpenedCh receives an entry each time the system tray menu is opened. |
| 24 | TrayOpenedCh = make(chan struct{}) |
| 25 | ) |
| 26 | |
| 27 | // This helper function allows us to call systrayExit only once, |
| 28 | // without accidentally calling it twice in the same lifetime. |
| 29 | func runSystrayExit() { |
| 30 | if !systrayExitCalled { |
| 31 | systrayExitCalled = true |
| 32 | systrayExit() |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func init() { |
| 37 | runtime.LockOSThread() |
| 38 | } |
| 39 | |
| 40 | // KeyModifier is a bit mask of the modifier keys that form part of a menu item shortcut. |
| 41 | type KeyModifier int |
| 42 | |
| 43 | const ( |
| 44 | // KeyModifierShift represents the "Shift" key. |
| 45 | KeyModifierShift KeyModifier = 1 << iota |
| 46 | // KeyModifierControl represents the "Control" key. |
| 47 | KeyModifierControl |
| 48 | // KeyModifierAlt represents the "Alt" key (also known as "Option" on macOS). |
| 49 | KeyModifierAlt |
| 50 | // KeyModifierSuper represents the "Super" key (also known as "Command" on macOS |
| 51 | // and "Windows" on Microsoft Windows). |
| 52 | KeyModifierSuper |
| 53 | ) |
| 54 | |
| 55 | // MenuItem is used to keep track each menu item of systray. |
| 56 | // Don't create it directly, use the one systray.AddMenuItem() returned |
| 57 | type MenuItem struct { |
| 58 | // ClickedCh is the channel which will be notified when the menu item is clicked |
| 59 | ClickedCh chan struct{} |
| 60 | |
| 61 | // id uniquely identify a menu item, not supposed to be modified |
| 62 | id uint32 |
| 63 | // title is the text shown on menu item |
| 64 | title string |
| 65 | // tooltip is the text shown when pointing to menu item |
| 66 | tooltip string |
| 67 | // disabled menu item is grayed out and has no effect when clicked |
| 68 | disabled bool |
| 69 | // checked menu item has a tick before the title |
| 70 | checked bool |
| 71 | // has the menu item a checkbox (Linux) |
| 72 | isCheckable bool |
| 73 | // shortcutKey is the key of the keyboard shortcut for this item, if any |
| 74 | shortcutKey string |
| 75 | // shortcutMods are the modifier keys of the keyboard shortcut for this item |
| 76 | shortcutMods KeyModifier |
| 77 | // parent item, for sub menus |
| 78 | parent *MenuItem |
| 79 | } |
| 80 | |
| 81 | func (item *MenuItem) String() string { |
| 82 | if item.parent == nil { |
| 83 | return fmt.Sprintf("MenuItem[%d, %q]", item.id, item.title) |
| 84 | } |
| 85 | return fmt.Sprintf("MenuItem[%d, parent %d, %q]", item.id, item.parent.id, item.title) |
| 86 | } |
| 87 | |
| 88 | // newMenuItem returns a populated MenuItem object |
| 89 | func newMenuItem(title string, tooltip string, parent *MenuItem) *MenuItem { |
| 90 | item := &MenuItem{ |
| 91 | ClickedCh: make(chan struct{}), |
| 92 | id: currentID.Add(1), |
| 93 | title: title, |
| 94 | tooltip: tooltip, |
| 95 | disabled: false, |
| 96 | checked: false, |
| 97 | isCheckable: false, |
| 98 | parent: parent, |
| 99 | } |
| 100 | |
| 101 | menuItemsLock.Lock() |
| 102 | menuItems[item.id] = item |
| 103 | menuItemsLock.Unlock() |
| 104 | |
| 105 | return item |
| 106 | } |
| 107 | |
| 108 | // Run initializes GUI and starts the event loop, then invokes the onReady |
| 109 | // callback. It blocks until systray.Quit() is called. |
| 110 | func Run(onReady, onExit func()) { |
| 111 | setInternalLoop(true) |
| 112 | Register(onReady, onExit) |
| 113 | |
| 114 | nativeLoop() |
| 115 | } |
| 116 | |
| 117 | // RunWithExternalLoop allows the system tray module to operate with other toolkits. |
| 118 | // The returned start and end functions should be called by the toolkit when the application has started and will end. |
| 119 | func RunWithExternalLoop(onReady, onExit func()) (start, end func()) { |
| 120 | Register(onReady, onExit) |
| 121 | |
| 122 | return nativeStart, func() { |
| 123 | nativeEnd() |
| 124 | Quit() |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // Register initializes GUI and registers the callbacks but relies on the |
| 129 | // caller to run the event loop somewhere else. It's useful if the program |
| 130 | // needs to show other UI elements, for example, webview. |
| 131 | // To overcome some OS weirdness, On macOS versions before Catalina, calling |
| 132 | // this does exactly the same as Run(). |
| 133 | func Register(onReady func(), onExit func()) { |
| 134 | if onReady == nil { |
| 135 | systrayReady = func() {} |
| 136 | } else { |
| 137 | // Run onReady on separate goroutine to avoid blocking event loop |
| 138 | readyCh := make(chan interface{}) |
| 139 | initialMenuBuilt.Add(1) |
| 140 | go func() { |
| 141 | <-readyCh |
| 142 | onReady() |
| 143 | initialMenuBuilt.Done() |
| 144 | }() |
| 145 | systrayReady = func() { |
| 146 | close(readyCh) |
| 147 | } |
| 148 | } |
| 149 | // unlike onReady, onExit runs in the event loop to make sure it has time to |
| 150 | // finish before the process terminates |
| 151 | if onExit == nil { |
| 152 | onExit = func() {} |
| 153 | } |
| 154 | systrayExit = onExit |
| 155 | systrayExitCalled = false |
| 156 | registerSystray() |
| 157 | } |
| 158 | |
| 159 | // ResetMenu will remove all menu items |
| 160 | func ResetMenu() { |
| 161 | menuItemsLock.Lock() |
| 162 | id := currentID.Load() |
| 163 | items := make([]*MenuItem, 0, len(menuItems)) |
| 164 | for _, item := range menuItems { |
| 165 | items = append(items, item) |
| 166 | } |
| 167 | menuItemsLock.Unlock() |
| 168 | for _, item := range items { |
| 169 | if item.id <= id && item.parent == nil { |
| 170 | item.Remove() |
| 171 | } |
| 172 | } |
| 173 | resetMenu() |
| 174 | } |
| 175 | |
| 176 | // Quit the systray |
| 177 | func Quit() { |
| 178 | quitOnce.Do(quit) |
| 179 | } |
| 180 | |
| 181 | func SetOnTapped(f func()) { |
| 182 | tappedLeft = f |
| 183 | } |
| 184 | |
| 185 | func SetOnSecondaryTapped(f func()) { |
| 186 | tappedRight = f |
| 187 | } |
| 188 | |
| 189 | // AddMenuItem adds a menu item with the designated title and tooltip. |
| 190 | // It can be safely invoked from different goroutines. |
| 191 | // Created menu items are checkable on Windows and OSX by default. For Linux you have to use AddMenuItemCheckbox |
| 192 | func AddMenuItem(title string, tooltip string) *MenuItem { |
| 193 | item := newMenuItem(title, tooltip, nil) |
| 194 | item.update() |
| 195 | return item |
| 196 | } |
| 197 | |
| 198 | // AddMenuItemCheckbox adds a menu item with the designated title and tooltip and a checkbox for Linux. |
| 199 | // On other platforms there will be a check indicated next to the item if `checked` is true. |
| 200 | // It can be safely invoked from different goroutines. |
| 201 | func AddMenuItemCheckbox(title string, tooltip string, checked bool) *MenuItem { |
| 202 | item := newMenuItem(title, tooltip, nil) |
| 203 | item.isCheckable = true |
| 204 | item.checked = checked |
| 205 | item.update() |
| 206 | return item |
| 207 | } |
| 208 | |
| 209 | // AddSeparator adds a separator bar to the menu |
| 210 | func AddSeparator() { |
| 211 | addSeparator(currentID.Add(1), 0) |
| 212 | } |
| 213 | |
| 214 | // AddSeparator adds a separator bar to the submenu |
| 215 | func (item *MenuItem) AddSeparator() { |
| 216 | addSeparator(currentID.Add(1), item.id) |
| 217 | } |
| 218 | |
| 219 | // AddSubMenuItem adds a nested sub-menu item with the designated title and tooltip. |
| 220 | // It can be safely invoked from different goroutines. |
| 221 | // Created menu items are checkable on Windows and OSX by default. For Linux you have to use AddSubMenuItemCheckbox |
| 222 | func (item *MenuItem) AddSubMenuItem(title string, tooltip string) *MenuItem { |
| 223 | child := newMenuItem(title, tooltip, item) |
| 224 | child.update() |
| 225 | return child |
| 226 | } |
| 227 | |
| 228 | // AddSubMenuItemCheckbox adds a nested sub-menu item with the designated title and tooltip and a checkbox for Linux. |
| 229 | // It can be safely invoked from different goroutines. |
| 230 | // On Windows and OSX this is the same as calling AddSubMenuItem |
| 231 | func (item *MenuItem) AddSubMenuItemCheckbox(title string, tooltip string, checked bool) *MenuItem { |
| 232 | child := newMenuItem(title, tooltip, item) |
| 233 | child.isCheckable = true |
| 234 | child.checked = checked |
| 235 | child.update() |
| 236 | return child |
| 237 | } |
| 238 | |
| 239 | // SetTitle set the text to display on a menu item |
| 240 | func (item *MenuItem) SetTitle(title string) { |
| 241 | item.title = title |
| 242 | item.update() |
| 243 | } |
| 244 | |
| 245 | // SetTooltip set the tooltip to show when mouse hover |
| 246 | func (item *MenuItem) SetTooltip(tooltip string) { |
| 247 | item.tooltip = tooltip |
| 248 | item.update() |
| 249 | } |
| 250 | |
| 251 | // SetShortcut sets the keyboard shortcut that will be displayed alongside this menu item. |
| 252 | // The key should be a single character such as "S" or one of the named keys understood by |
| 253 | // all platforms, namely "BackSpace", "Delete", "Down", "End", "Enter", "Escape", "F1" to "F12", |
| 254 | // "Home", "Insert", "Left", "PageDown", "PageUp", "Return", "Right", "Space", "Tab" and "Up". |
| 255 | // Passing an empty key removes any shortcut previously set. |
| 256 | // |
| 257 | // On macOS the shortcut will also be registered so it can trigger the item, on Linux and |
| 258 | // Windows it is presented next to the item label but not handled by the system tray. |
| 259 | func (item *MenuItem) SetShortcut(mods KeyModifier, key string) { |
| 260 | item.shortcutMods = mods |
| 261 | item.shortcutKey = key |
| 262 | item.update() |
| 263 | } |
| 264 | |
| 265 | // Shortcut returns the modifiers and key of the keyboard shortcut for this menu item. |
| 266 | // An empty key means that no shortcut is set. |
| 267 | func (item *MenuItem) Shortcut() (mods KeyModifier, key string) { |
| 268 | return item.shortcutMods, item.shortcutKey |
| 269 | } |
| 270 | |
| 271 | // Disabled checks if the menu item is disabled |
| 272 | func (item *MenuItem) Disabled() bool { |
| 273 | return item.disabled |
| 274 | } |
| 275 | |
| 276 | // Enable a menu item regardless if it's previously enabled or not |
| 277 | func (item *MenuItem) Enable() { |
| 278 | item.disabled = false |
| 279 | item.update() |
| 280 | } |
| 281 | |
| 282 | // Disable a menu item regardless if it's previously disabled or not |
| 283 | func (item *MenuItem) Disable() { |
| 284 | item.disabled = true |
| 285 | item.update() |
| 286 | } |
| 287 | |
| 288 | // Hide hides a menu item |
| 289 | func (item *MenuItem) Hide() { |
| 290 | hideMenuItem(item) |
| 291 | } |
| 292 | |
| 293 | // Remove removes a menu item |
| 294 | func (item *MenuItem) Remove() { |
| 295 | menuItemsLock.RLock() |
| 296 | var childList []*MenuItem |
| 297 | for _, child := range menuItems { |
| 298 | if child.parent == item { |
| 299 | childList = append(childList, child) |
| 300 | } |
| 301 | } |
| 302 | menuItemsLock.RUnlock() |
| 303 | for _, child := range childList { |
| 304 | child.Remove() |
| 305 | } |
| 306 | removeMenuItem(item) |
| 307 | menuItemsLock.Lock() |
| 308 | defer menuItemsLock.Unlock() |
| 309 | delete(menuItems, item.id) |
| 310 | if item.ClickedCh == nil { |
| 311 | return |
| 312 | } |
| 313 | select { |
| 314 | case _, ok := <-item.ClickedCh: |
| 315 | if !ok { |
| 316 | return |
| 317 | } |
| 318 | default: |
| 319 | } |
| 320 | close(item.ClickedCh) |
| 321 | } |
| 322 | |
| 323 | // Show shows a previously hidden menu item |
| 324 | func (item *MenuItem) Show() { |
| 325 | showMenuItem(item) |
| 326 | } |
| 327 | |
| 328 | // Checked returns if the menu item has a check mark |
| 329 | func (item *MenuItem) Checked() bool { |
| 330 | return item.checked |
| 331 | } |
| 332 | |
| 333 | // Check a menu item regardless if it's previously checked or not |
| 334 | func (item *MenuItem) Check() { |
| 335 | item.checked = true |
| 336 | item.update() |
| 337 | } |
| 338 | |
| 339 | // Uncheck a menu item regardless if it's previously unchecked or not |
| 340 | func (item *MenuItem) Uncheck() { |
| 341 | item.checked = false |
| 342 | item.update() |
| 343 | } |
| 344 | |
| 345 | // update propagates changes on a menu item to systray |
| 346 | func (item *MenuItem) update() { |
| 347 | menuItemsLock.Lock() |
| 348 | _, exists := menuItems[item.id] |
| 349 | menuItemsLock.Unlock() |
| 350 | |
| 351 | if !exists { |
| 352 | return |
| 353 | } |
| 354 | addOrUpdateMenuItem(item) |
| 355 | } |
| 356 | |
| 357 | func systrayMenuItemSelected(id uint32) { |
| 358 | menuItemsLock.RLock() |
| 359 | item, ok := menuItems[id] |
| 360 | menuItemsLock.RUnlock() |
| 361 | if !ok { |
| 362 | log.Printf("systray error: no menu item with ID %d\n", id) |
| 363 | return |
| 364 | } |
| 365 | select { |
| 366 | case item.ClickedCh <- struct{}{}: |
| 367 | // in case no one waiting for the channel |
| 368 | default: |
| 369 | } |
| 370 | } |
| 371 |