diff --git a/.gitignore b/.gitignore index ae7e06b..2e85ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ example/example +webview_example/webview_example *~ *.swp -*.exe +**/*.exe Release Debug *.sdf diff --git a/README.md b/README.md index be16646..3e00f32 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ -Package systray is a cross platfrom Go library to place an icon and menu in the notification area. -Tested on Windows 8, Mac OSX, Ubuntu 14.10 and Debian 7.6. +systray is a cross-platform Go library to place an icon and menu in the notification area. + +## Features + +* Supported on Windows, macOS, and Linux +* Menu items can be checked and/or disabled +* Methods may be called from any Goroutine + +## API -## Usage ```go func main() { - // Should be called at the very beginning of main(). systray.Run(onReady, onExit) } @@ -14,7 +19,7 @@ func onReady() { systray.SetTooltip("Pretty awesome超级棒") mQuit := systray.AddMenuItem("Quit", "Quit the whole app") - // Sets the icon of a menu item. Only available on Mac. + // Sets the icon of a menu item. Only available on Mac and Windows. mQuit.SetIcon(icon.Data) } @@ -22,35 +27,61 @@ func onExit() { // clean up here } ``` -Menu item can be checked and / or disabled. Methods except `Run()` can be invoked from any goroutine. See demo code under `example` folder. -## Platform specific concerns +## Try the example app! + +Have go v1.12+ or higher installed? Here's an example to get started on macOS: + +```sh +git clone https://github.com/getlantern/systray +cd example +env GO111MODULE=on go build +./example +``` + +On Windows, you should build like this: + +``` +env GO111MODULE=on go build -ldflags "-H=windowsgui" +``` + +The following text will then appear on the console: + + +```sh +go: finding github.com/skratchdot/open-golang latest +go: finding github.com/getlantern/systray latest +go: finding github.com/getlantern/golog latest +``` + +Now look for *Awesome App* in your menu bar! + +![Awesome App screenshot](example/screenshot.png) + +## The Webview example + +The code under `webview_example` is to demostrate how it can co-exist with other UI elements. + +## Platform notes ### Linux -```sh -sudo apt-get install libgtk-3-dev libappindicator3-dev -``` -Checked menu item not implemented on Linux yet. - -## Try - -Under `example` folder. -Place tray icon under `icon`, and use `make_icon.bat` or `make_icon.sh`, whichever suit for your os, to convert the icon to byte array. -Your icon should be .ico file under Windows, whereas .ico, .jpg and .png is supported on other platform. +* Building apps requires gcc as well as the `gtk3` and `libappindicator3` development headers to be installed. For Debian or Ubuntu, you may install these using: ```sh -go get -go run main.go +sudo apt-get install gcc libgtk-3-dev libappindicator3-dev ``` -## Building and the Console Window +On Linux Mint, `libxapp-dev` is also required . + +To build `webview_example`, you also need to install `libwebkit2gtk-4.0-dev` and remove `webview_example/rsrc.syso` which is required on Windows. + +* Submenu and checked menu items are not yet implemented -By default, the binary created by `go build` will cause a console window to be opened on both Windows and macOS when run. ### Windows -To prevent launching a console window when running on Windows, add these command-line build flags: +* To avoid opening a console at application startup, use these compile flags: ```sh go build -ldflags -H=windowsgui @@ -70,6 +101,18 @@ SystrayApp.app/ SystrayApp.icns ``` +When running as an app bundle, you may want to add one or both of the following to your Info.plist: + +```xml + + NSHighResolutionCapable + True + + + LSUIElement + 1 +``` + Consult the [Official Apple Documentation here](https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1). ## Credits diff --git a/example/example_windows_386.exe b/example/example_windows_386.exe index b680331..3d33c6e 100755 Binary files a/example/example_windows_386.exe and b/example/example_windows_386.exe differ diff --git a/example/main.go b/example/main.go index 5542b89..8a02fed 100644 --- a/example/main.go +++ b/example/main.go @@ -12,17 +12,15 @@ import ( func main() { onExit := func() { - fmt.Println("Starting onExit") now := time.Now() ioutil.WriteFile(fmt.Sprintf(`on_exit_%d.txt`, now.UnixNano()), []byte(now.String()), 0644) - fmt.Println("Finished onExit") } - // Should be called at the very beginning of main(). + systray.Run(onReady, onExit) } func onReady() { - systray.SetIcon(icon.Data) + systray.SetTemplateIcon(icon.Data, icon.Data) systray.SetTitle("Awesome App") systray.SetTooltip("Lantern") mQuitOrig := systray.AddMenuItem("Quit", "Quit the whole app") @@ -35,14 +33,23 @@ func onReady() { // We can manipulate the systray in other goroutines go func() { - systray.SetIcon(icon.Data) + systray.SetTemplateIcon(icon.Data, icon.Data) systray.SetTitle("Awesome App") systray.SetTooltip("Pretty awesome棒棒嗒") mChange := systray.AddMenuItem("Change Me", "Change Me") mChecked := systray.AddMenuItem("Unchecked", "Check Me") mEnabled := systray.AddMenuItem("Enabled", "Enabled") + // Sets the icon of a menu item. Only available on Mac. + mEnabled.SetTemplateIcon(icon.Data, icon.Data) + systray.AddMenuItem("Ignored", "Ignored") - mUrl := systray.AddMenuItem("Open Lantern.org", "my home") + + subMenuTop := systray.AddMenuItem("SubMenu", "SubMenu Test (top)") + subMenuMiddle := subMenuTop.AddSubMenuItem("SubMenu - Level 2", "SubMenu Test (middle)") + subMenuBottom := subMenuMiddle.AddSubMenuItem("SubMenu - Level 3", "SubMenu Test (bottom)") + subMenuBottom2 := subMenuMiddle.AddSubMenuItem("Panic!", "SubMenu Test (bottom)") + + mUrl := systray.AddMenuItem("Open UI", "my home") mQuit := systray.AddMenuItem("退出", "Quit the whole app") // Sets the icon of a menu item. Only available on Mac. @@ -51,6 +58,22 @@ func onReady() { systray.AddSeparator() mToggle := systray.AddMenuItem("Toggle", "Toggle the Quit button") shown := true + toggle := func() { + if shown { + subMenuBottom.Check() + subMenuBottom2.Hide() + mQuitOrig.Hide() + mEnabled.Hide() + shown = false + } else { + subMenuBottom.Uncheck() + subMenuBottom2.Show() + mQuitOrig.Show() + mEnabled.Show() + shown = true + } + } + for { select { case <-mChange.ClickedCh: @@ -68,16 +91,12 @@ func onReady() { mEnabled.Disable() case <-mUrl.ClickedCh: open.Run("https://www.getlantern.org") + case <-subMenuBottom2.ClickedCh: + panic("panic button pressed") + case <-subMenuBottom.ClickedCh: + toggle() case <-mToggle.ClickedCh: - if shown { - mQuitOrig.Hide() - mEnabled.Hide() - shown = false - } else { - mQuitOrig.Show() - mEnabled.Show() - shown = true - } + toggle() case <-mQuit.ClickedCh: systray.Quit() fmt.Println("Quit2 now...") diff --git a/example/screenshot.png b/example/screenshot.png new file mode 100644 index 0000000..fb3ab91 Binary files /dev/null and b/example/screenshot.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d0a8fc3 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/getlantern/systray + +go 1.13 + +require ( + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 + github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1 + github.com/lxn/win v0.0.0-20191128105842-2da648fda5b4 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8be5240 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1 h1:/QwQcwWVOQXcoNuV9tHx30gQ3q7jCE/rKcGjwzsa5tg= +github.com/lxn/walk v0.0.0-20191128110447-55ccb3a9f5c1/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20191128105842-2da648fda5b4 h1:5BmtGkQbch91lglMHQ9JIDGiYCL3kBRBA0ItZTvOcEI= +github.com/lxn/win v0.0.0-20191128105842-2da648fda5b4/go.mod h1:ouWl4wViUNh8tPSIwxTVMuS014WakR1hqvBc2I0bMoA= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c h1:kISX68E8gSkNYAFRFiDU8rl5RIn1sJYKYb/r2vMLDrU= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/systray.go b/systray.go index b4970d5..987e9d2 100644 --- a/systray.go +++ b/systray.go @@ -1,13 +1,10 @@ /* -Package systray is a cross platfrom Go library to place an icon and menu in the -notification area. -Supports Windows, Mac OSX and Linux currently. -Methods can be called from any goroutine except Run(), which should be called -at the very beginning of main() to lock at main thread. +Package systray is a cross-platform Go library to place an icon and menu in the notification area. */ package systray import ( + "fmt" "runtime" "sync" "sync/atomic" @@ -16,11 +13,22 @@ import ( ) var ( - hasStarted = int64(0) - hasQuit = int64(0) + log = golog.LoggerFor("systray") + + systrayReady func() + systrayExit func() + menuItems = make(map[int32]*MenuItem) + menuItemsLock sync.RWMutex + + currentID = int32(-1) + quitOnce sync.Once ) -// MenuItem is used to keep track each menu item of systray +func init() { + runtime.LockOSThread() +} + +// MenuItem is used to keep track each menu item of systray. // Don't create it directly, use the one systray.AddMenuItem() returned type MenuItem struct { // ClickedCh is the channel which will be notified when the menu item is clicked @@ -36,27 +44,41 @@ type MenuItem struct { disabled bool // checked menu item has a tick before the title checked bool + // parent item, for sub menus + parent *MenuItem } -var ( - log = golog.LoggerFor("systray") +func (item *MenuItem) String() string { + if item.parent == nil { + return fmt.Sprintf("MenuItem[%d, %q]", item.id, item.title) + } + return fmt.Sprintf("MenuItem[%d, parent %d, %q]", item.id, item.parent.id, item.title) +} - systrayReady func() - systrayExit func() - menuItems = make(map[int32]*MenuItem) - menuItemsLock sync.RWMutex - - currentID = int32(-1) -) +// newMenuItem returns a populated MenuItem object +func newMenuItem(title string, tooltip string, parent *MenuItem) *MenuItem { + return &MenuItem{ + ClickedCh: make(chan struct{}), + id: atomic.AddInt32(¤tID, 1), + title: title, + tooltip: tooltip, + disabled: false, + checked: false, + parent: parent, + } +} // Run initializes GUI and starts the event loop, then invokes the onReady -// callback. -// It blocks until systray.Quit() is called. -// Should be called at the very beginning of main() to lock at main thread. +// callback. It blocks until systray.Quit() is called. func Run(onReady func(), onExit func()) { - runtime.LockOSThread() - atomic.StoreInt64(&hasStarted, 1) + Register(onReady, onExit) + nativeLoop() +} +// Register initializes GUI and registers the callbacks but relies on the +// caller to run the event loop somewhere else. It's useful if the program +// needs to show other UI elements, for example, webview. +func Register(onReady func(), onExit func()) { if onReady == nil { systrayReady = func() {} } else { @@ -70,32 +92,25 @@ func Run(onReady func(), onExit func()) { close(readyCh) } } - // unlike onReady, onExit runs in the event loop to make sure it has time to // finish before the process terminates if onExit == nil { onExit = func() {} } systrayExit = onExit - - nativeLoop() + registerSystray() } // Quit the systray func Quit() { - if atomic.LoadInt64(&hasStarted) == 1 && atomic.CompareAndSwapInt64(&hasQuit, 0, 1) { - quit() - } + quitOnce.Do(quit) } -// AddMenuItem adds menu item with designated title and tooltip, returning a channel -// that notifies whenever that menu item is clicked. +// AddMenuItem adds a menu item with the designated title and tooltip. // // It can be safely invoked from different goroutines. func AddMenuItem(title string, tooltip string) *MenuItem { - id := atomic.AddInt32(¤tID, 1) - item := &MenuItem{nil, id, title, tooltip, false, false} - item.ClickedCh = make(chan struct{}) + item := newMenuItem(title, tooltip, nil) item.update() return item } @@ -105,6 +120,14 @@ func AddSeparator() { addSeparator(atomic.AddInt32(¤tID, 1)) } +// AddSubMenuItem adds a nested sub-menu item with the designated title and tooltip. +// It can be safely invoked from different goroutines. +func (item *MenuItem) AddSubMenuItem(title string, tooltip string) *MenuItem { + child := newMenuItem(title, tooltip, item) + child.update() + return child +} + // SetTitle set the text to display on a menu item func (item *MenuItem) SetTitle(title string) { item.title = title @@ -117,7 +140,7 @@ func (item *MenuItem) SetTooltip(tooltip string) { item.update() } -// Disabled checkes if the menu item is disabled +// Disabled checks if the menu item is disabled func (item *MenuItem) Disabled() bool { return item.disabled } @@ -161,7 +184,7 @@ func (item *MenuItem) Uncheck() { item.update() } -// update propogates changes on a menu item to systray +// update propagates changes on a menu item to systray func (item *MenuItem) update() { menuItemsLock.Lock() defer menuItemsLock.Unlock() diff --git a/systray.h b/systray.h index 36bcf98..e1a1f98 100644 --- a/systray.h +++ b/systray.h @@ -1,13 +1,16 @@ +#include "stdbool.h" + extern void systray_ready(); extern void systray_on_exit(); extern void systray_menu_item_selected(int menu_id); +void registerSystray(void); int nativeLoop(void); -void setIcon(const char* iconBytes, int length); -void setMenuItemIcon(const char* iconBytes, int length, int menuId); +void setIcon(const char* iconBytes, int length, bool template); +void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template); void setTitle(char* title); void setTooltip(char* tooltip); -void add_or_update_menu_item(int menuId, char* title, char* tooltip, short disabled, short checked); +void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* tooltip, short disabled, short checked); void add_separator(int menuId); void hide_menu_item(int menuId); void show_menu_item(int menuId); diff --git a/systray_darwin.go b/systray_darwin.go new file mode 100644 index 0000000..568edab --- /dev/null +++ b/systray_darwin.go @@ -0,0 +1,37 @@ +package systray + +/* +#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit + +#include "systray.h" +*/ +import "C" + +import ( + "unsafe" +) + +// SetTemplateIcon sets the systray icon as a template icon (on Mac), falling back +// to a regular icon on other platforms. +// templateIconBytes and regularIconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { + cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0])) + C.setIcon(cstr, (C.int)(len(templateIconBytes)), true) +} + +// SetIcon sets the icon of a menu item. Only works on macOS and Windows. +// iconBytes should be the content of .ico/.jpg/.png +func (item *MenuItem) SetIcon(iconBytes []byte) { + SetTemplateIcon(iconBytes, iconBytes) +} + +// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it +// falls back to the regular icon bytes and on Linux it does nothing. +// templateIconBytes and regularIconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { + cstr := (*C.char)(unsafe.Pointer(&templateIconBytes[0])) + C.setMenuItemIcon(cstr, (C.int)(len(templateIconBytes)), C.int(item.id), true) +} \ No newline at end of file diff --git a/systray_darwin.m b/systray_darwin.m index b0f2172..b4f38bc 100644 --- a/systray_darwin.m +++ b/systray_darwin.m @@ -17,12 +17,14 @@ { @public NSNumber* menuId; + NSNumber* parentMenuId; NSString* title; NSString* tooltip; short disabled; short checked; } -(id) initWithId: (int)theMenuId +withParentMenuId: (int)theParentMenuId withTitle: (const char*)theTitle withTooltip: (const char*)theTooltip withDisabled: (short)theDisabled @@ -30,12 +32,14 @@ @end @implementation MenuItem -(id) initWithId: (int)theMenuId + withParentMenuId: (int)theParentMenuId withTitle: (const char*)theTitle withTooltip: (const char*)theTooltip withDisabled: (short)theDisabled withChecked: (short)theChecked { menuId = [NSNumber numberWithInt:theMenuId]; + parentMenuId = [NSNumber numberWithInt:theParentMenuId]; title = [[NSString alloc] initWithCString:theTitle encoding:NSUTF8StringEncoding]; tooltip = [[NSString alloc] initWithCString:theTooltip @@ -108,20 +112,30 @@ systray_menu_item_selected(menuId.intValue); } -- (void) add_or_update_menu_item:(MenuItem*) item -{ - NSMenuItem* menuItem; - int existedMenuIndex = [menu indexOfItemWithRepresentedObject: item->menuId]; - if (existedMenuIndex == -1) { - menuItem = [menu addItemWithTitle:item->title action:@selector(menuHandler:) keyEquivalent:@""]; - [menuItem setTarget:self]; - [menuItem setRepresentedObject: item->menuId]; - +- (void)add_or_update_menu_item:(MenuItem *)item { + NSMenu *theMenu = self->menu; + NSMenuItem *parentItem; + if ([item->parentMenuId integerValue] > 0) { + parentItem = find_menu_item(menu, item->parentMenuId); + if (parentItem.hasSubmenu) { + theMenu = parentItem.submenu; + } else { + theMenu = [[NSMenu alloc] init]; + [parentItem setSubmenu:theMenu]; + } } - else { - menuItem = [menu itemAtIndex: existedMenuIndex]; - [menuItem setTitle:item->title]; + + NSMenuItem *menuItem; + menuItem = find_menu_item(theMenu, item->menuId); + if (menuItem == NULL) { + menuItem = [theMenu addItemWithTitle:item->title + action:@selector(menuHandler:) + keyEquivalent:@""]; + [menuItem setRepresentedObject:item->menuId]; } + [menuItem setTitle:item->title]; + [menuItem setTag:[item->menuId integerValue]]; + [menuItem setTarget:self]; [menuItem setToolTip:item->tooltip]; if (item->disabled == 1) { menuItem.enabled = FALSE; @@ -135,6 +149,26 @@ } } +NSMenuItem *find_menu_item(NSMenu *ourMenu, NSNumber *menuId) { + NSMenuItem *foundItem = [ourMenu itemWithTag:[menuId integerValue]]; + if (foundItem != NULL) { + return foundItem; + } + NSArray *menu_items = ourMenu.itemArray; + int i; + for (i = 0; i < [menu_items count]; i++) { + NSMenuItem *i_item = [menu_items objectAtIndex:i]; + if (i_item.hasSubmenu) { + foundItem = find_menu_item(i_item.submenu, menuId); + if (foundItem != NULL) { + return foundItem; + } + } + } + + return NULL; +}; + - (void) add_separator:(NSNumber*) menuId { [menu addItem: [NSMenuItem separatorItem]]; @@ -142,37 +176,30 @@ - (void) hide_menu_item:(NSNumber*) menuId { - NSMenuItem* menuItem; - int existedMenuIndex = [menu indexOfItemWithRepresentedObject: menuId]; - if (existedMenuIndex == -1) { - return; + NSMenuItem* menuItem = find_menu_item(menu, menuId); + if (menuItem != NULL) { + [menuItem setHidden:TRUE]; } - menuItem = [menu itemAtIndex: existedMenuIndex]; - [menuItem setHidden:TRUE]; } -- (void)setMenuItemIcon:(NSArray*)imageAndMenuId { +- (void) setMenuItemIcon:(NSArray*)imageAndMenuId { NSImage* image = [imageAndMenuId objectAtIndex:0]; NSNumber* menuId = [imageAndMenuId objectAtIndex:1]; NSMenuItem* menuItem; - int existedMenuIndex = [menu indexOfItemWithRepresentedObject: menuId]; - if (existedMenuIndex == -1) { + menuItem = find_menu_item(menu, menuId); + if (menuItem == NULL) { return; } - menuItem = [menu itemAtIndex: existedMenuIndex]; menuItem.image = image; } - (void) show_menu_item:(NSNumber*) menuId { - NSMenuItem* menuItem; - int existedMenuIndex = [menu indexOfItemWithRepresentedObject: menuId]; - if (existedMenuIndex == -1) { - return; + NSMenuItem* menuItem = find_menu_item(menu, menuId); + if (menuItem != NULL) { + [menuItem setHidden:FALSE]; } - menuItem = [menu itemAtIndex: existedMenuIndex]; - [menuItem setHidden:FALSE]; } - (void) quit @@ -182,9 +209,12 @@ @end -int nativeLoop(void) { +void registerSystray(void) { AppDelegate *delegate = [[AppDelegate alloc] init]; [[NSApplication sharedApplication] setDelegate:delegate]; +} + +int nativeLoop(void) { [NSApp run]; return EXIT_SUCCESS; } @@ -196,18 +226,19 @@ void runInMainThread(SEL method, id object) { waitUntilDone: YES]; } -void setIcon(const char* iconBytes, int length) { +void setIcon(const char* iconBytes, int length, bool template) { NSData* buffer = [NSData dataWithBytes: iconBytes length:length]; NSImage *image = [[NSImage alloc] initWithData:buffer]; [image setSize:NSMakeSize(16, 16)]; + image.template = template; runInMainThread(@selector(setIcon:), (id)image); } -void setMenuItemIcon(const char* iconBytes, int length, int menuId) { +void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) { NSData* buffer = [NSData dataWithBytes: iconBytes length:length]; NSImage *image = [[NSImage alloc] initWithData:buffer]; [image setSize:NSMakeSize(16, 16)]; - + image.template = template; NSNumber *mId = [NSNumber numberWithInt:menuId]; runInMainThread(@selector(setMenuItemIcon:), @[image, (id)mId]); } @@ -226,8 +257,8 @@ void setTooltip(char* ctooltip) { runInMainThread(@selector(setTooltip:), (id)tooltip); } -void add_or_update_menu_item(int menuId, char* title, char* tooltip, short disabled, short checked) { - MenuItem* item = [[MenuItem alloc] initWithId: menuId withTitle: title withTooltip: tooltip withDisabled: disabled withChecked: checked]; +void add_or_update_menu_item(int menuId, int parentMenuId, char* title, char* tooltip, short disabled, short checked) { + MenuItem* item = [[MenuItem alloc] initWithId: menuId withParentMenuId: parentMenuId withTitle: title withTooltip: tooltip withDisabled: disabled withChecked: checked]; free(title); free(tooltip); runInMainThread(@selector(add_or_update_menu_item:), (id)item); diff --git a/systray_linux.c b/systray_linux.c index 72cd614..7586f1b 100644 --- a/systray_linux.c +++ b/systray_linux.c @@ -23,7 +23,7 @@ typedef struct { short checked; } MenuItemInfo; -int nativeLoop(void) { +void registerSystray(void) { gtk_init(0, NULL); global_app_indicator = app_indicator_new("systray", "", APP_INDICATOR_CATEGORY_APPLICATION_STATUS); @@ -31,6 +31,9 @@ int nativeLoop(void) { global_tray_menu = gtk_menu_new(); app_indicator_set_menu(global_app_indicator, GTK_MENU(global_tray_menu)); systray_ready(); +} + +int nativeLoop(void) { gtk_main(); systray_on_exit(); return 0; @@ -167,7 +170,7 @@ gboolean do_quit(gpointer data) { return FALSE; } -void setIcon(const char* iconBytes, int length) { +void setIcon(const char* iconBytes, int length, bool template) { GBytes* bytes = g_bytes_new_static(iconBytes, length); g_idle_add(do_set_icon, bytes); } @@ -181,10 +184,11 @@ void setTooltip(char* ctooltip) { free(ctooltip); } -void setMenuItemIcon(const char* iconBytes, int length, int menuId) { +void setMenuItemIcon(const char* iconBytes, int length, int menuId, bool template) { } -void add_or_update_menu_item(int menu_id, char* title, char* tooltip, short disabled, short checked) { +void add_or_update_menu_item(int menu_id, int parent_menu_id, char* title, char* tooltip, short disabled, short checked) { + // TODO: add support for sub-menus MenuItemInfo *mii = malloc(sizeof(MenuItemInfo)); mii->menu_id = menu_id; mii->title = title; diff --git a/systray_linux.go b/systray_linux.go new file mode 100644 index 0000000..1f508c7 --- /dev/null +++ b/systray_linux.go @@ -0,0 +1,29 @@ +package systray + +/* +#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Cocoa -framework WebKit + +#include "systray.h" +*/ +import "C" + +// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back +// to a regular icon on other platforms. +// templateIconBytes and iconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { + SetIcon(regularIconBytes) +} + +// SetIcon sets the icon of a menu item. Only works on macOS and Windows. +// iconBytes should be the content of .ico/.jpg/.png +func (item *MenuItem) SetIcon(iconBytes []byte) { +} + +// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it +// falls back to the regular icon bytes and on Linux it does nothing. +// templateIconBytes and regularIconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { +} diff --git a/systray_nonwindows.go b/systray_nonwindows.go index 4868b55..6e06c3d 100644 --- a/systray_nonwindows.go +++ b/systray_nonwindows.go @@ -15,6 +15,10 @@ import ( "unsafe" ) +func registerSystray() { + C.registerSystray() +} + func nativeLoop() { C.nativeLoop() } @@ -28,7 +32,7 @@ func quit() { // for other platforms. func SetIcon(iconBytes []byte) { cstr := (*C.char)(unsafe.Pointer(&iconBytes[0])) - C.setIcon(cstr, (C.int)(len(iconBytes))) + C.setIcon(cstr, (C.int)(len(iconBytes)), false) } // SetTitle sets the systray title, only available on Mac. @@ -51,8 +55,13 @@ func addOrUpdateMenuItem(item *MenuItem) { if item.checked { checked = 1 } + var parentID int32 = 0 + if item.parent != nil { + parentID = item.parent.id + } C.add_or_update_menu_item( C.int(item.id), + C.int(parentID), C.CString(item.title), C.CString(item.tooltip), disabled, @@ -60,13 +69,6 @@ func addOrUpdateMenuItem(item *MenuItem) { ) } -// SetIcon sets the icon of a menu item. Only available on Mac. -// iconBytes should be the content of .ico/.jpg/.png -func (item *MenuItem) SetIcon(iconBytes []byte) { - cstr := (*C.char)(unsafe.Pointer(&iconBytes[0])) - C.setMenuItemIcon(cstr, (C.int)(len(iconBytes)), C.int(item.id)) -} - func addSeparator(id int32) { C.add_separator(C.int(id)) } diff --git a/systray_windows.go b/systray_windows.go index 1cb453c..02b5e6d 100644 --- a/systray_windows.go +++ b/systray_windows.go @@ -18,28 +18,41 @@ import ( // Helpful sources: https://github.com/golang/exp/blob/master/shiny/driver/internal/win32 var ( - k32 = windows.NewLazySystemDLL("Kernel32.dll") - s32 = windows.NewLazySystemDLL("Shell32.dll") + g32 = windows.NewLazySystemDLL("Gdi32.dll") + pCreateCompatibleBitmap = g32.NewProc("CreateCompatibleBitmap") + pCreateCompatibleDC = g32.NewProc("CreateCompatibleDC") + pDeleteDC = g32.NewProc("DeleteDC") + pSelectObject = g32.NewProc("SelectObject") + + k32 = windows.NewLazySystemDLL("Kernel32.dll") + pGetModuleHandle = k32.NewProc("GetModuleHandleW") + + s32 = windows.NewLazySystemDLL("Shell32.dll") + pShellNotifyIcon = s32.NewProc("Shell_NotifyIconW") + u32 = windows.NewLazySystemDLL("User32.dll") - pGetModuleHandle = k32.NewProc("GetModuleHandleW") - pShellNotifyIcon = s32.NewProc("Shell_NotifyIconW") + pCreateMenu = u32.NewProc("CreateMenu") pCreatePopupMenu = u32.NewProc("CreatePopupMenu") pCreateWindowEx = u32.NewProc("CreateWindowExW") pDefWindowProc = u32.NewProc("DefWindowProcW") pDeleteMenu = u32.NewProc("DeleteMenu") pDestroyWindow = u32.NewProc("DestroyWindow") pDispatchMessage = u32.NewProc("DispatchMessageW") + pDrawIconEx = u32.NewProc("DrawIconEx") pGetCursorPos = u32.NewProc("GetCursorPos") + pGetDC = u32.NewProc("GetDC") pGetMenuItemID = u32.NewProc("GetMenuItemID") pGetMessage = u32.NewProc("GetMessageW") + pGetSystemMetrics = u32.NewProc("GetSystemMetrics") pInsertMenuItem = u32.NewProc("InsertMenuItemW") + pLoadCursor = u32.NewProc("LoadCursorW") pLoadIcon = u32.NewProc("LoadIconW") pLoadImage = u32.NewProc("LoadImageW") - pLoadCursor = u32.NewProc("LoadCursorW") pPostMessage = u32.NewProc("PostMessageW") pPostQuitMessage = u32.NewProc("PostQuitMessage") pRegisterClass = u32.NewProc("RegisterClassExW") pRegisterWindowMessage = u32.NewProc("RegisterWindowMessageW") + pReleaseDC = u32.NewProc("ReleaseDC") pSetForegroundWindow = u32.NewProc("SetForegroundWindow") pSetMenuInfo = u32.NewProc("SetMenuInfo") pSetMenuItemInfo = u32.NewProc("SetMenuItemInfoW") @@ -150,7 +163,7 @@ type menuItemInfo struct { ItemData uintptr TypeData *uint16 Cch uint32 - Item windows.Handle + BMPItem windows.Handle } // The POINT structure defines the x- and y- coordinates of a point. @@ -164,48 +177,35 @@ type winTray struct { instance, icon, cursor, - window, - menu windows.Handle + window windows.Handle loadedImages map[string]windows.Handle - nid *notifyIconData - wcex *wndClassEx + // menus keeps track of the submenus keyed by the menu item ID, plus 0 + // which corresponds to the main popup menu. + menus map[uint32]windows.Handle + // menuOf keeps track of the menu each menu item belongs to. + menuOf map[uint32]windows.Handle + // menuItemIcons maintains the bitmap of each menu item (if applies). It's + // needed to show the icon correctly when showing a previously hidden menu + // item again. + menuItemIcons map[uint32]windows.Handle + visibleItems map[uint32][]uint32 + + nid *notifyIconData + wcex *wndClassEx wmSystrayMessage, wmTaskbarCreated uint32 - - visibleItems []uint32 } // Loads an image from file and shows it in tray. -// LoadImage: https://msdn.microsoft.com/en-us/library/windows/desktop/ms648045(v=vs.85).aspx // Shell_NotifyIcon: https://msdn.microsoft.com/en-us/library/windows/desktop/bb762159(v=vs.85).aspx func (t *winTray) setIcon(src string) error { - const IMAGE_ICON = 1 // Loads an icon - const LR_LOADFROMFILE = 0x00000010 // Loads the stand-alone image from the file - const LR_DEFAULTSIZE = 0x00000040 // Loads default-size icon for windows(SM_CXICON x SM_CYICON) if cx, cy are set to zero const NIF_ICON = 0x00000002 - // Save and reuse handles of loaded images - h, ok := t.loadedImages[src] - if !ok { - srcPtr, err := windows.UTF16PtrFromString(src) - if err != nil { - return err - } - res, _, err := pLoadImage.Call( - 0, - uintptr(unsafe.Pointer(srcPtr)), - IMAGE_ICON, - 0, - 0, - LR_LOADFROMFILE|LR_DEFAULTSIZE, - ) - if res == 0 { - return err - } - h = windows.Handle(res) - t.loadedImages[src] = h + h, err := t.loadIconFrom(src) + if err != nil { + return err } t.nid.Icon = h @@ -236,18 +236,26 @@ var wt winTray // https://msdn.microsoft.com/en-us/library/windows/desktop/ms633573(v=vs.85).aspx func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam uintptr) (lResult uintptr) { const ( - WM_COMMAND = 0x0111 - WM_DESTROY = 0x0002 - WM_ENDSESSION = 0x16 WM_RBUTTONUP = 0x0205 WM_LBUTTONUP = 0x0202 + WM_COMMAND = 0x0111 + WM_ENDSESSION = 0x0016 + WM_CLOSE = 0x0010 + WM_DESTROY = 0x0002 + WM_CREATE = 0x0001 ) switch message { + case WM_CREATE: + systrayReady() case WM_COMMAND: - menuId := int32(wParam) - if menuId != -1 { - systrayMenuItemSelected(menuId) + menuItemId := int32(wParam) + // https://docs.microsoft.com/en-us/windows/win32/menurc/wm-command#menus + if menuItemId != -1 { + systrayMenuItemSelected(menuItemId) } + case WM_CLOSE: + pDestroyWindow.Call(uintptr(t.window)) + t.wcex.unregister() case WM_DESTROY: // same as WM_ENDSESSION, but throws 0 exit code after all defer pPostQuitMessage.Call(uintptr(int32(0))) @@ -310,6 +318,10 @@ func (t *winTray) initInstance() error { ) t.wmSystrayMessage = WM_USER + 1 + t.visibleItems = make(map[uint32][]uint32) + t.menus = make(map[uint32]windows.Handle) + t.menuOf = make(map[uint32]windows.Handle) + t.menuItemIcons = make(map[uint32]windows.Handle) taskbarEventNamePtr, _ := windows.UTF16PtrFromString("TaskbarCreated") // https://msdn.microsoft.com/en-us/library/windows/desktop/ms644947 @@ -410,7 +422,7 @@ func (t *winTray) createMenu() error { if menuHandle == 0 { return err } - t.menu = windows.Handle(menuHandle) + t.menus[0] = windows.Handle(menuHandle) // https://msdn.microsoft.com/en-us/library/windows/desktop/ms647575(v=vs.85).aspx mi := struct { @@ -424,7 +436,7 @@ func (t *winTray) createMenu() error { mi.Size = uint32(unsafe.Sizeof(mi)) res, _, err := pSetMenuInfo.Call( - uintptr(t.menu), + uintptr(t.menus[0]), uintptr(unsafe.Pointer(&mi)), ) if res == 0 { @@ -433,13 +445,39 @@ func (t *winTray) createMenu() error { return nil } -func (t *winTray) addOrUpdateMenuItem(menuId int32, title string, disabled, checked bool) error { +func (t *winTray) convertToSubMenu(menuItemId uint32) (windows.Handle, error) { + const MIIM_SUBMENU = 0x00000004 + + res, _, err := pCreateMenu.Call() + if res == 0 { + return 0, err + } + menu := windows.Handle(res) + + mi := menuItemInfo{Mask: MIIM_SUBMENU, SubMenu: menu} + mi.Size = uint32(unsafe.Sizeof(mi)) + res, _, err = pSetMenuItemInfo.Call( + uintptr(t.menuOf[menuItemId]), + uintptr(menuItemId), + 0, + uintptr(unsafe.Pointer(&mi)), + ) + if res == 0 { + return 0, err + } + t.menus[menuItemId] = menu + return menu, nil +} + +func (t *winTray) addOrUpdateMenuItem(menuItemId uint32, parentId uint32, title string, disabled, checked bool) error { // https://msdn.microsoft.com/en-us/library/windows/desktop/ms647578(v=vs.85).aspx const ( - MIIM_FTYPE = 0x00000100 - MIIM_STRING = 0x00000040 - MIIM_ID = 0x00000002 - MIIM_STATE = 0x00000001 + MIIM_FTYPE = 0x00000100 + MIIM_BITMAP = 0x00000080 + MIIM_STRING = 0x00000040 + MIIM_SUBMENU = 0x00000004 + MIIM_ID = 0x00000002 + MIIM_STATE = 0x00000001 ) const MFT_STRING = 0x00000000 const ( @@ -454,45 +492,61 @@ func (t *winTray) addOrUpdateMenuItem(menuId int32, title string, disabled, chec mi := menuItemInfo{ Mask: MIIM_FTYPE | MIIM_STRING | MIIM_ID | MIIM_STATE, Type: MFT_STRING, - ID: uint32(menuId), + ID: uint32(menuItemId), TypeData: titlePtr, Cch: uint32(len(title)), } + mi.Size = uint32(unsafe.Sizeof(mi)) if disabled { mi.State |= MFS_DISABLED } if checked { mi.State |= MFS_CHECKED } - mi.Size = uint32(unsafe.Sizeof(mi)) + hIcon := t.menuItemIcons[menuItemId] + if hIcon > 0 { + mi.Mask |= MIIM_BITMAP + mi.BMPItem = hIcon + } - // We set the menu item info based on the menuID - res, _, err := pSetMenuItemInfo.Call( - uintptr(t.menu), - uintptr(menuId), - 0, - uintptr(unsafe.Pointer(&mi)), - ) + var res uintptr + menu, exists := t.menus[parentId] + if !exists { + menu, err = t.convertToSubMenu(parentId) + if err != nil { + return err + } + t.menus[parentId] = menu + } else if t.getVisibleItemIndex(parentId, menuItemId) != -1 { + // We set the menu item info based on the menuID + res, _, err = pSetMenuItemInfo.Call( + uintptr(menu), + uintptr(menuItemId), + 0, + uintptr(unsafe.Pointer(&mi)), + ) + } if res == 0 { - t.addToVisibleItems(menuId) - position := t.getVisibleItemIndex(menuId) + t.addToVisibleItems(parentId, menuItemId) + position := t.getVisibleItemIndex(parentId, menuItemId) res, _, err = pInsertMenuItem.Call( - uintptr(t.menu), + uintptr(menu), uintptr(position), 1, uintptr(unsafe.Pointer(&mi)), ) if res == 0 { - t.delFromVisibleItems(menuId) + t.delFromVisibleItems(parentId, menuItemId) return err } + t.menuOf[menuItemId] = menu } return nil } -func (t *winTray) addSeparatorMenuItem(menuId int32) error { +func (t *winTray) addSeparatorMenuItem(menuItemId, parentId uint32) error { // https://msdn.microsoft.com/en-us/library/windows/desktop/ms647578(v=vs.85).aspx const ( MIIM_FTYPE = 0x00000100 @@ -504,15 +558,16 @@ func (t *winTray) addSeparatorMenuItem(menuId int32) error { mi := menuItemInfo{ Mask: MIIM_FTYPE | MIIM_ID | MIIM_STATE, Type: MFT_SEPARATOR, - ID: uint32(menuId), + ID: uint32(menuItemId), } mi.Size = uint32(unsafe.Sizeof(mi)) - t.addToVisibleItems(menuId) - position := t.getVisibleItemIndex(menuId) + t.addToVisibleItems(parentId, menuItemId) + position := t.getVisibleItemIndex(parentId, menuItemId) + menu := uintptr(t.menus[parentId]) res, _, err := pInsertMenuItem.Call( - uintptr(t.menu), + menu, uintptr(position), 1, uintptr(unsafe.Pointer(&mi)), @@ -524,20 +579,21 @@ func (t *winTray) addSeparatorMenuItem(menuId int32) error { return nil } -func (t *winTray) hideMenuItem(menuId int32) error { +func (t *winTray) hideMenuItem(menuItemId, parentId uint32) error { // https://msdn.microsoft.com/en-us/library/windows/desktop/ms647629(v=vs.85).aspx const MF_BYCOMMAND = 0x00000000 const ERROR_SUCCESS syscall.Errno = 0 + menu := uintptr(t.menus[parentId]) res, _, err := pDeleteMenu.Call( - uintptr(t.menu), - uintptr(uint32(menuId)), + menu, + uintptr(menuItemId), MF_BYCOMMAND, ) if res == 0 && err.(syscall.Errno) != ERROR_SUCCESS { return err } - t.delFromVisibleItems(menuId) + t.delFromVisibleItems(parentId, menuItemId) return nil } @@ -555,7 +611,7 @@ func (t *winTray) showMenu() error { pSetForegroundWindow.Call(uintptr(t.window)) res, _, err = pTrackPopupMenu.Call( - uintptr(t.menu), + uintptr(t.menus[0]), TPM_BOTTOMALIGN|TPM_LEFTALIGN, uintptr(p.X), uintptr(p.Y), @@ -570,31 +626,96 @@ func (t *winTray) showMenu() error { return nil } -func (t *winTray) delFromVisibleItems(val int32) { - for i, itemval := range t.visibleItems { - if uint32(val) == itemval { - t.visibleItems = append(t.visibleItems[:i], t.visibleItems[i+1:]...) +func (t *winTray) delFromVisibleItems(parent, val uint32) { + visibleItems := t.visibleItems[parent] + for i, itemval := range visibleItems { + if val == itemval { + visibleItems = append(visibleItems[:i], visibleItems[i+1:]...) break } } } -func (t *winTray) addToVisibleItems(val int32) { - newvisible := append(t.visibleItems, uint32(val)) - sort.Slice(newvisible, func(i, j int) bool { return newvisible[i] < newvisible[j] }) - t.visibleItems = newvisible +func (t *winTray) addToVisibleItems(parent, val uint32) { + if visibleItems, exists := t.visibleItems[parent]; !exists { + t.visibleItems[parent] = []uint32{val} + } else { + newvisible := append(visibleItems, val) + sort.Slice(newvisible, func(i, j int) bool { return newvisible[i] < newvisible[j] }) + t.visibleItems[parent] = newvisible + } } -func (t *winTray) getVisibleItemIndex(val int32) int { - for i, itemval := range t.visibleItems { - if uint32(val) == itemval { +func (t *winTray) getVisibleItemIndex(parent, val uint32) int { + for i, itemval := range t.visibleItems[parent] { + if val == itemval { return i } } return -1 } -func nativeLoop() { +// Loads an image from file to be shown in tray or menu item. +// LoadImage: https://msdn.microsoft.com/en-us/library/windows/desktop/ms648045(v=vs.85).aspx +func (t *winTray) loadIconFrom(src string) (windows.Handle, error) { + const IMAGE_ICON = 1 // Loads an icon + const LR_LOADFROMFILE = 0x00000010 // Loads the stand-alone image from the file + const LR_DEFAULTSIZE = 0x00000040 // Loads default-size icon for windows(SM_CXICON x SM_CYICON) if cx, cy are set to zero + + // Save and reuse handles of loaded images + h, ok := t.loadedImages[src] + if !ok { + srcPtr, err := windows.UTF16PtrFromString(src) + if err != nil { + return 0, err + } + res, _, err := pLoadImage.Call( + 0, + uintptr(unsafe.Pointer(srcPtr)), + IMAGE_ICON, + 0, + 0, + LR_LOADFROMFILE|LR_DEFAULTSIZE, + ) + if res == 0 { + return 0, err + } + h = windows.Handle(res) + t.loadedImages[src] = h + } + return h, nil +} + +func (t *winTray) iconToBitmap(hIcon windows.Handle) (windows.Handle, error) { + const SM_CXSMICON = 49 + const SM_CYSMICON = 50 + const DI_NORMAL = 0x3 + hDC, _, err := pGetDC.Call(uintptr(0)) + if hDC == 0 { + return 0, err + } + defer pReleaseDC.Call(uintptr(0), hDC) + hMemDC, _, err := pCreateCompatibleDC.Call(hDC) + if hMemDC == 0 { + return 0, err + } + defer pDeleteDC.Call(hMemDC) + cx, _, _ := pGetSystemMetrics.Call(SM_CXSMICON) + cy, _, _ := pGetSystemMetrics.Call(SM_CYSMICON) + hMemBmp, _, err := pCreateCompatibleBitmap.Call(hDC, cx, cy) + if hMemBmp == 0 { + return 0, err + } + hOriginalBmp, _, _ := pSelectObject.Call(hMemDC, hMemBmp) + defer pSelectObject.Call(hMemDC, hOriginalBmp) + res, _, err := pDrawIconEx.Call(hMemDC, 0, 0, uintptr(hIcon), cx, cy, 0, uintptr(0), DI_NORMAL) + if res == 0 { + return 0, err + } + return windows.Handle(hMemBmp), nil +} + +func registerSystray() { if err := wt.initInstance(); err != nil { log.Errorf("Unable to init instance: %v", err) return @@ -605,13 +726,9 @@ func nativeLoop() { return } - defer func() { - pDestroyWindow.Call(uintptr(wt.window)) - wt.wcex.unregister() - }() - - go systrayReady() +} +func nativeLoop() { // Main message pump. m := &struct { WindowHandle windows.Handle @@ -652,35 +769,81 @@ func quit() { ) } -// SetIcon sets the systray icon. -// iconBytes should be the content of .ico for windows and .ico/.jpg/.png -// for other platforms. -func SetIcon(iconBytes []byte) { +func iconBytesToFilePath(iconBytes []byte) (string, error) { bh := md5.Sum(iconBytes) dataHash := hex.EncodeToString(bh[:]) iconFilePath := filepath.Join(os.TempDir(), "systray_temp_icon_"+dataHash) if _, err := os.Stat(iconFilePath); os.IsNotExist(err) { if err := ioutil.WriteFile(iconFilePath, iconBytes, 0644); err != nil { - log.Errorf("Unable to write icon data to temp file: %v", err) - return + return "", err } } + return iconFilePath, nil +} +// SetIcon sets the systray icon. +// iconBytes should be the content of .ico for windows and .ico/.jpg/.png +// for other platforms. +func SetIcon(iconBytes []byte) { + iconFilePath, err := iconBytesToFilePath(iconBytes) + if err != nil { + log.Errorf("Unable to write icon data to temp file: %v", err) + return + } if err := wt.setIcon(iconFilePath); err != nil { log.Errorf("Unable to set icon: %v", err) return } } +// SetTemplateIcon sets the systray icon as a template icon (on macOS), falling back +// to a regular icon on other platforms. +// templateIconBytes and iconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { + SetIcon(regularIconBytes) +} + // SetTitle sets the systray title, only available on Mac. func SetTitle(title string) { // do nothing } -// SetIcon sets the icon of a menu item. Only available on Mac. +func (item *MenuItem) parentId() uint32 { + if item.parent != nil { + return uint32(item.parent.id) + } + return 0 +} + +// SetIcon sets the icon of a menu item. Only works on macOS and Windows. +// iconBytes should be the content of .ico/.jpg/.png func (item *MenuItem) SetIcon(iconBytes []byte) { - // do nothing + iconFilePath, err := iconBytesToFilePath(iconBytes) + if err != nil { + log.Errorf("Unable to write icon data to temp file: %v", err) + return + } + + h, err := wt.loadIconFrom(iconFilePath) + if err != nil { + log.Errorf("Unable to load icon from temp file: %v", err) + return + } + + h, err = wt.iconToBitmap(h) + if err != nil { + log.Errorf("Unable to convert icon to bitmap: %v", err) + return + } + wt.menuItemIcons[uint32(item.id)] = h + + err = wt.addOrUpdateMenuItem(uint32(item.id), item.parentId(), item.title, item.disabled, item.checked) + if err != nil { + log.Errorf("Unable to addOrUpdateMenuItem: %v", err) + return + } } // SetTooltip sets the systray tooltip to display on mouse hover of the tray icon, @@ -693,15 +856,23 @@ func SetTooltip(tooltip string) { } func addOrUpdateMenuItem(item *MenuItem) { - err := wt.addOrUpdateMenuItem(item.id, item.title, item.disabled, item.checked) + err := wt.addOrUpdateMenuItem(uint32(item.id), item.parentId(), item.title, item.disabled, item.checked) if err != nil { log.Errorf("Unable to addOrUpdateMenuItem: %v", err) return } } +// SetTemplateIcon sets the icon of a menu item as a template icon (on macOS). On Windows, it +// falls back to the regular icon bytes and on Linux it does nothing. +// templateIconBytes and regularIconBytes should be the content of .ico for windows and +// .ico/.jpg/.png for other platforms. +func (item *MenuItem) SetTemplateIcon(templateIconBytes []byte, regularIconBytes []byte) { + item.SetIcon(regularIconBytes) +} + func addSeparator(id int32) { - err := wt.addSeparatorMenuItem(id) + err := wt.addSeparatorMenuItem(uint32(id), 0) if err != nil { log.Errorf("Unable to addSeparator: %v", err) return @@ -709,7 +880,7 @@ func addSeparator(id int32) { } func hideMenuItem(item *MenuItem) { - err := wt.hideMenuItem(item.id) + err := wt.hideMenuItem(uint32(item.id), item.parentId()) if err != nil { log.Errorf("Unable to hideMenuItem: %v", err) return diff --git a/webview_example/example.manifest b/webview_example/example.manifest new file mode 100644 index 0000000..bf75b80 --- /dev/null +++ b/webview_example/example.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + PerMonitorV2, PerMonitor + True + + + \ No newline at end of file diff --git a/webview_example/main.go b/webview_example/main.go new file mode 100644 index 0000000..160a8a5 --- /dev/null +++ b/webview_example/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "github.com/getlantern/systray" + "github.com/getlantern/systray/example/icon" +) + +func main() { + systray.Register(onReady, nil) + configureWebview("Webview example", 1024, 768) +} + +func onReady() { + systray.SetTemplateIcon(icon.Data, icon.Data) + systray.SetTitle("Webview example") + mShowLantern := systray.AddMenuItem("Show Lantern", "") + mShowWikipedia := systray.AddMenuItem("Show Wikipedia", "") + mQuit := systray.AddMenuItem("Quit", "Quit the whole app") + go func() { + for { + select { + case <-mShowLantern.ClickedCh: + showWebview("https://www.getlantern.org") + case <-mShowWikipedia.ClickedCh: + showWebview("https://www.wikipedia.org") + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() + +} diff --git a/webview_example/rsrc.syso b/webview_example/rsrc.syso new file mode 100644 index 0000000..dbad196 Binary files /dev/null and b/webview_example/rsrc.syso differ diff --git a/webview_example/webview.h b/webview_example/webview.h new file mode 100644 index 0000000..2e39c6a --- /dev/null +++ b/webview_example/webview.h @@ -0,0 +1,3 @@ +void configureAppWindow(char* title, int width, int height); +void showAppWindow(char* url); + diff --git a/webview_example/webview_darwin.m b/webview_example/webview_darwin.m new file mode 100644 index 0000000..962f653 --- /dev/null +++ b/webview_example/webview_darwin.m @@ -0,0 +1,78 @@ +#import +#import +#include "webview.h" + +NSWindowController *windowController = nil; +NSWindow *window = nil; +WKWebView *webView = nil; + +void configureAppWindow(char* title, int width, int height) +{ + if (windowController != nil) { + // already configured, ignore + return; + } + + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + [app activateIgnoringOtherApps:YES]; + + NSRect frame = NSMakeRect(0, 0, width, height); + int mask = NSWindowStyleMaskTitled | NSWindowStyleMaskResizable | NSWindowStyleMaskClosable; + window = [[NSWindow alloc] initWithContentRect:frame + styleMask:mask + backing:NSBackingStoreBuffered + defer:NO]; + [window setTitle:[[NSString alloc] initWithUTF8String:title]]; + [window center]; + + NSView *contentView = [window contentView]; + webView = [[WKWebView alloc] initWithFrame:[contentView bounds]]; + [webView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [contentView addSubview:webView]; + [contentView addConstraint: + [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:contentView + attribute:NSLayoutAttributeWidth + multiplier:1 + constant:0]]; + [contentView addConstraint: + [NSLayoutConstraint constraintWithItem:webView + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:contentView + attribute:NSLayoutAttributeHeight + multiplier:1 + constant:0]]; + + // Window controller: + windowController = [[NSWindowController alloc] initWithWindow:window]; + + free(title); + [NSApp run]; +} + +void doShowAppWindow(char* url) +{ + if (windowController == nil) { + // no app window to open + return; + } + + id nsURL = [NSURL URLWithString:[[NSString alloc] initWithUTF8String:url]]; + id req = [[NSURLRequest alloc] initWithURL: nsURL + cachePolicy: NSURLRequestUseProtocolCachePolicy + timeoutInterval: 5]; + [webView loadRequest:req]; + [windowController showWindow:window]; + free(url); +} + +void showAppWindow(char* url) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + doShowAppWindow(url); + }); +} diff --git a/webview_example/webview_linux.c b/webview_example/webview_linux.c new file mode 100644 index 0000000..6983fdb --- /dev/null +++ b/webview_example/webview_linux.c @@ -0,0 +1,60 @@ +#include +#include +#include +#include "webview.h" + +static GtkWindow *web_window = NULL; +static WebKitWebView *web_view = NULL; + +static gint x, y; +static bool needsMove = false; + +gboolean on_window_deleted(GtkWidget *window, GdkEvent *event, gpointer data) +{ + gtk_window_get_position(GTK_WINDOW(window), &x, &y); + needsMove = true; + gtk_widget_hide(window); + return TRUE; +} + +void configureAppWindow(char* title, int width, int height) +{ + // Create an 800x600 window that will contain the browser instance + web_window = GTK_WINDOW(gtk_window_new(GTK_WINDOW_TOPLEVEL)); + gtk_window_set_title(web_window, title); + gtk_window_set_default_size(web_window, width, height); + gtk_window_set_skip_taskbar_hint (web_window, TRUE); + g_signal_connect(G_OBJECT(web_window), "delete-event", G_CALLBACK(on_window_deleted), NULL); + + // Create a browser instance + web_view = WEBKIT_WEB_VIEW(webkit_web_view_new()); + + // Put the browser area into the web window + gtk_container_add(GTK_CONTAINER(web_window), GTK_WIDGET(web_view)); + + // Make sure that when the browser area becomes visible, it will get mouse + // and keyboard events + gtk_widget_grab_focus(GTK_WIDGET(web_view)); + free(title); + gtk_main(); +} + +gboolean do_show_app_window(gpointer data) +{ + gtk_widget_show_all(GTK_WIDGET(web_window)); + if (needsMove) { + gtk_window_move(web_window, x, y); + needsMove = false; + } + gtk_window_present(web_window); + return FALSE; +} + +void showAppWindow(char* url) +{ + // Load a web page into the browser instance + webkit_web_view_load_uri(web_view, url); + + g_idle_add(do_show_app_window, NULL); + free(url); +} diff --git a/webview_example/webview_nonwindows.go b/webview_example/webview_nonwindows.go new file mode 100644 index 0000000..bd176cc --- /dev/null +++ b/webview_example/webview_nonwindows.go @@ -0,0 +1,20 @@ +//+build !windows + +package main + +/* +#cgo linux pkg-config: webkit2gtk-4.0 +#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Cocoa -framework Webkit + +#include "webview.h" +*/ +import "C" + +func configureWebview(title string, width, height int) { + C.configureAppWindow(C.CString(title), C.int(width), C.int(height)) +} + +func showWebview(url string) { + C.showAppWindow(C.CString(url)) +} diff --git a/webview_example/webview_windows.go b/webview_example/webview_windows.go new file mode 100644 index 0000000..f116c48 --- /dev/null +++ b/webview_example/webview_windows.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "github.com/lxn/walk" +) + +var webView *walk.WebView +var mainWindow *walk.MainWindow +var err error + +func configureWebview(title string, width, height int) { + mainWindow, err = walk.NewMainWindow() + if err != nil { + panic(fmt.Sprintf("Failed to create main window: %v\n", err)) + } + mainWindow.SetTitle(title) + mainWindow.SetWidth(width) + mainWindow.SetHeight(height) + layout := walk.NewVBoxLayout() + if err := mainWindow.SetLayout(layout); err != nil { + panic(fmt.Sprintf("Failed to set layout: %v\n", err)) + } + webView, err = walk.NewWebView(mainWindow) + if err != nil { + panic(fmt.Sprintf("Failed to create webview window: %v\n", err)) + } + mainWindow.SetVisible(false) + mainWindow.Run() +} +func showWebview(url string) { + mainWindow.SetVisible(true) + webView.SetURL(url) +}