| 1 | //go:build (linux || freebsd || openbsd || netbsd) && !android |
| 2 | |
| 3 | package systray |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/godbus/dbus/v5" |
| 13 | "github.com/godbus/dbus/v5/prop" |
| 14 | |
| 15 | "fyne.io/systray/internal/generated/menu" |
| 16 | ) |
| 17 | |
| 18 | // SetIcon sets the icon of a menu item. |
| 19 | // iconBytes should be the content of .ico/.jpg/.png |
| 20 | func (item *MenuItem) SetIcon(iconBytes []byte) { |
| 21 | instance.menuLock.Lock() |
| 22 | defer instance.menuLock.Unlock() |
| 23 | m, exists := findLayout(int32(item.id)) |
| 24 | if exists { |
| 25 | m.V1["icon-data"] = dbus.MakeVariant(iconBytes) |
| 26 | emitItemPropertiesUpdated(int32(item.id), m.V1) |
| 27 | // Property-only change; per spec LayoutUpdated isn't required here, |
| 28 | // but kept as a fallback for clients that only watch LayoutUpdated. |
| 29 | refresh() |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // SetIconFromFilePath sets the icon of a menu item from a file path. |
| 34 | // iconFilePath should be the path to a .ico for windows and .ico/.jpg/.png for other platforms. |
| 35 | func (item *MenuItem) SetIconFromFilePath(iconFilePath string) error { |
| 36 | iconBytes, err := os.ReadFile(iconFilePath) |
| 37 | if err != nil { |
| 38 | return fmt.Errorf("failed to read icon file: %v", err) |
| 39 | } |
| 40 | item.SetIcon(iconBytes) |
| 41 | return nil |
| 42 | } |
| 43 | |
| 44 | // copyLayout makes full copy of layout |
| 45 | func copyLayout(in *menuLayout, depth int32) *menuLayout { |
| 46 | out := menuLayout{ |
| 47 | V0: in.V0, |
| 48 | V1: make(map[string]dbus.Variant, len(in.V1)), |
| 49 | } |
| 50 | for k, v := range in.V1 { |
| 51 | out.V1[k] = v |
| 52 | } |
| 53 | if depth != 0 { |
| 54 | depth-- |
| 55 | out.V2 = make([]dbus.Variant, len(in.V2)) |
| 56 | for i, v := range in.V2 { |
| 57 | out.V2[i] = dbus.MakeVariant(copyLayout(v.Value().(*menuLayout), depth)) |
| 58 | } |
| 59 | } else { |
| 60 | out.V2 = []dbus.Variant{} |
| 61 | } |
| 62 | return &out |
| 63 | } |
| 64 | |
| 65 | // firstGetLayoutDone tracks whether the initial GetLayout response has been served |
| 66 | // since the last menu reset. libdbusmenu-gtk3 (used by Cinnamon/Xfce/GNOME) requests |
| 67 | // GetGroupProperties for grandchildren before parents in the first call, causing children |
| 68 | // to be silently dropped (blank submenus) because parent GtkMenu containers don't exist |
| 69 | // yet. Returning depth=1 on the first call ensures parents get their GtkMenu containers |
| 70 | // before grandchildren are introduced in subsequent calls, where the order is correct. |
| 71 | // After serving depth=1, a goroutine automatically triggers a second GetLayout cycle so |
| 72 | // submenus populate without requiring user interaction. Multiple goroutines from rapid |
| 73 | // resets are harmless — they just emit extra LayoutUpdated signals. |
| 74 | var firstGetLayoutDone bool |
| 75 | |
| 76 | // GetLayout is com.canonical.dbusmenu.GetLayout method. |
| 77 | func (t *tray) GetLayout(parentID int32, recursionDepth int32, propertyNames []string) (revision uint32, layout menuLayout, err *dbus.Error) { |
| 78 | initialMenuBuilt.Wait() |
| 79 | instance.menuLock.Lock() |
| 80 | defer instance.menuLock.Unlock() |
| 81 | if m, ok := findLayout(parentID); ok { |
| 82 | depth := recursionDepth |
| 83 | if !firstGetLayoutDone { |
| 84 | firstGetLayoutDone = true |
| 85 | depth = 1 |
| 86 | go func() { |
| 87 | time.Sleep(150 * time.Millisecond) |
| 88 | refresh() |
| 89 | }() |
| 90 | } |
| 91 | // return copy of menu layout to prevent panic from cuncurrent access to layout |
| 92 | return instance.menuVersion, *copyLayout(m, depth), nil |
| 93 | } |
| 94 | return |
| 95 | } |
| 96 | |
| 97 | // GetGroupProperties is com.canonical.dbusmenu.GetGroupProperties method. |
| 98 | func (t *tray) GetGroupProperties(ids []int32, propertyNames []string) (properties []struct { |
| 99 | V0 int32 |
| 100 | V1 map[string]dbus.Variant |
| 101 | }, err *dbus.Error) { |
| 102 | instance.menuLock.Lock() |
| 103 | defer instance.menuLock.Unlock() |
| 104 | for _, id := range ids { |
| 105 | if m, ok := findLayout(id); ok { |
| 106 | p := struct { |
| 107 | V0 int32 |
| 108 | V1 map[string]dbus.Variant |
| 109 | }{ |
| 110 | V0: m.V0, |
| 111 | V1: make(map[string]dbus.Variant, len(m.V1)), |
| 112 | } |
| 113 | for k, v := range m.V1 { |
| 114 | p.V1[k] = v |
| 115 | } |
| 116 | properties = append(properties, p) |
| 117 | } |
| 118 | } |
| 119 | return |
| 120 | } |
| 121 | |
| 122 | // GetProperty is com.canonical.dbusmenu.GetProperty method. |
| 123 | func (t *tray) GetProperty(id int32, name string) (value dbus.Variant, err *dbus.Error) { |
| 124 | instance.menuLock.Lock() |
| 125 | defer instance.menuLock.Unlock() |
| 126 | if m, ok := findLayout(id); ok { |
| 127 | if p, ok := m.V1[name]; ok { |
| 128 | return p, nil |
| 129 | } |
| 130 | } |
| 131 | return |
| 132 | } |
| 133 | |
| 134 | // Event is com.canonical.dbusmenu.Event method. |
| 135 | func (t *tray) Event(id int32, eventID string, data dbus.Variant, timestamp uint32) (err *dbus.Error) { |
| 136 | switch eventID { |
| 137 | case "clicked": |
| 138 | systrayMenuItemSelected(uint32(id)) |
| 139 | case "opened": |
| 140 | t.menuLock.RLock() |
| 141 | rootMenuID := t.menu.V0 |
| 142 | t.menuLock.RUnlock() |
| 143 | |
| 144 | if id == rootMenuID { |
| 145 | select { |
| 146 | case TrayOpenedCh <- struct{}{}: |
| 147 | default: |
| 148 | } |
| 149 | } |
| 150 | } |
| 151 | return |
| 152 | } |
| 153 | |
| 154 | // EventGroup is com.canonical.dbusmenu.EventGroup method. |
| 155 | func (t *tray) EventGroup(events []struct { |
| 156 | V0 int32 |
| 157 | V1 string |
| 158 | V2 dbus.Variant |
| 159 | V3 uint32 |
| 160 | }) (idErrors []int32, err *dbus.Error) { |
| 161 | for _, event := range events { |
| 162 | if event.V1 == "clicked" { |
| 163 | systrayMenuItemSelected(uint32(event.V0)) |
| 164 | } |
| 165 | } |
| 166 | return |
| 167 | } |
| 168 | |
| 169 | // AboutToShow is com.canonical.dbusmenu.AboutToShow method. |
| 170 | func (t *tray) AboutToShow(id int32) (needUpdate bool, err *dbus.Error) { |
| 171 | return |
| 172 | } |
| 173 | |
| 174 | // AboutToShowGroup is com.canonical.dbusmenu.AboutToShowGroup method. |
| 175 | func (t *tray) AboutToShowGroup(ids []int32) (updatesNeeded []int32, idErrors []int32, err *dbus.Error) { |
| 176 | instance.menuLock.RLock() |
| 177 | defer instance.menuLock.RUnlock() |
| 178 | for _, id := range ids { |
| 179 | if m, ok := findLayout(id); ok && len(m.V2) > 0 { |
| 180 | updatesNeeded = append(updatesNeeded, id) |
| 181 | } |
| 182 | } |
| 183 | return |
| 184 | } |
| 185 | |
| 186 | func createMenuPropSpec() map[string]map[string]*prop.Prop { |
| 187 | instance.menuLock.Lock() |
| 188 | defer instance.menuLock.Unlock() |
| 189 | return map[string]map[string]*prop.Prop{ |
| 190 | "com.canonical.dbusmenu": { |
| 191 | "Version": { |
| 192 | Value: instance.menuVersion, |
| 193 | Writable: true, |
| 194 | Emit: prop.EmitTrue, |
| 195 | Callback: nil, |
| 196 | }, |
| 197 | "TextDirection": { |
| 198 | Value: "ltr", |
| 199 | Writable: false, |
| 200 | Emit: prop.EmitTrue, |
| 201 | Callback: nil, |
| 202 | }, |
| 203 | "Status": { |
| 204 | Value: "normal", |
| 205 | Writable: false, |
| 206 | Emit: prop.EmitTrue, |
| 207 | Callback: nil, |
| 208 | }, |
| 209 | "IconThemePath": { |
| 210 | Value: []string{}, |
| 211 | Writable: false, |
| 212 | Emit: prop.EmitTrue, |
| 213 | Callback: nil, |
| 214 | }, |
| 215 | }, |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // menuLayout is a named struct to map into generated bindings. It represents the layout of a menu item |
| 220 | type menuLayout = struct { |
| 221 | V0 int32 // the unique ID of this item |
| 222 | V1 map[string]dbus.Variant // properties for this menu item layout |
| 223 | V2 []dbus.Variant // child menu item layouts |
| 224 | } |
| 225 | |
| 226 | func addOrUpdateMenuItem(item *MenuItem) { |
| 227 | var layout *menuLayout |
| 228 | var parentForChildrenDisplayUpdate *menuLayout |
| 229 | instance.menuLock.Lock() |
| 230 | defer instance.menuLock.Unlock() |
| 231 | m, exists := findLayout(int32(item.id)) |
| 232 | if exists { |
| 233 | layout = m |
| 234 | } else { |
| 235 | layout = &menuLayout{ |
| 236 | V0: int32(item.id), |
| 237 | V1: map[string]dbus.Variant{}, |
| 238 | V2: []dbus.Variant{}, |
| 239 | } |
| 240 | |
| 241 | parent := instance.menu |
| 242 | if item.parent != nil { |
| 243 | m, ok := findLayout(int32(item.parent.id)) |
| 244 | if ok { |
| 245 | parent = m |
| 246 | if _, already := parent.V1["children-display"]; !already { |
| 247 | parent.V1["children-display"] = dbus.MakeVariant("submenu") |
| 248 | if parent.V0 != 0 { |
| 249 | parentForChildrenDisplayUpdate = parent |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | parent.V2 = append(parent.V2, dbus.MakeVariant(layout)) |
| 255 | } |
| 256 | |
| 257 | applyItemToLayout(item, layout) |
| 258 | if exists { |
| 259 | emitItemPropertiesUpdated(int32(item.id), layout.V1) |
| 260 | // Property-only change on an existing item; per spec LayoutUpdated isn't required here, |
| 261 | // but kept as a fallback for clients that only watch LayoutUpdated. |
| 262 | refresh() |
| 263 | } else { |
| 264 | // We've added "children-display", that's a property change |
| 265 | if parentForChildrenDisplayUpdate != nil { |
| 266 | emitItemPropertiesUpdated(parentForChildrenDisplayUpdate.V0, parentForChildrenDisplayUpdate.V1) |
| 267 | } |
| 268 | // New item appended to a parent's children, |
| 269 | // that's a structural change, so LayoutUpdated signal is required |
| 270 | refresh() |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func addSeparator(id uint32, parent uint32) { |
| 275 | menu, _ := findLayout(int32(parent)) |
| 276 | |
| 277 | instance.menuLock.Lock() |
| 278 | defer instance.menuLock.Unlock() |
| 279 | layout := &menuLayout{ |
| 280 | V0: int32(id), |
| 281 | V1: map[string]dbus.Variant{ |
| 282 | "type": dbus.MakeVariant("separator"), |
| 283 | }, |
| 284 | V2: []dbus.Variant{}, |
| 285 | } |
| 286 | menu.V2 = append(menu.V2, dbus.MakeVariant(layout)) |
| 287 | refresh() |
| 288 | } |
| 289 | |
| 290 | // dbusKeyNames maps the platform neutral key names used by SetShortcut to the |
| 291 | // key names that the dbusmenu specification expects. |
| 292 | var dbusKeyNames = map[string]string{ |
| 293 | "BackSpace": "BackSpace", |
| 294 | "Delete": "Delete", |
| 295 | "Down": "Down", |
| 296 | "End": "End", |
| 297 | "Enter": "KP_Enter", |
| 298 | "Escape": "Escape", |
| 299 | "F1": "F1", |
| 300 | "F2": "F2", |
| 301 | "F3": "F3", |
| 302 | "F4": "F4", |
| 303 | "F5": "F5", |
| 304 | "F6": "F6", |
| 305 | "F7": "F7", |
| 306 | "F8": "F8", |
| 307 | "F9": "F9", |
| 308 | "F10": "F10", |
| 309 | "F11": "F11", |
| 310 | "F12": "F12", |
| 311 | "Home": "Home", |
| 312 | "Insert": "Insert", |
| 313 | "Left": "Left", |
| 314 | "PageDown": "Page_Down", |
| 315 | "PageUp": "Page_Up", |
| 316 | "Return": "Return", |
| 317 | "Right": "Right", |
| 318 | "Space": "space", |
| 319 | "Tab": "Tab", |
| 320 | "Up": "Up", |
| 321 | } |
| 322 | |
| 323 | // shortcutForItem returns the dbusmenu "shortcut" value for an item, which is a list of |
| 324 | // key presses where each press is a list of modifier names followed by the key itself. |
| 325 | func shortcutForItem(in *MenuItem) [][]string { |
| 326 | if in.shortcutKey == "" { |
| 327 | return nil |
| 328 | } |
| 329 | |
| 330 | var keys []string |
| 331 | if in.shortcutMods&KeyModifierControl != 0 { |
| 332 | keys = append(keys, "Control") |
| 333 | } |
| 334 | if in.shortcutMods&KeyModifierAlt != 0 { |
| 335 | keys = append(keys, "Alt") |
| 336 | } |
| 337 | if in.shortcutMods&KeyModifierShift != 0 { |
| 338 | keys = append(keys, "Shift") |
| 339 | } |
| 340 | if in.shortcutMods&KeyModifierSuper != 0 { |
| 341 | keys = append(keys, "Super") |
| 342 | } |
| 343 | |
| 344 | key, ok := dbusKeyNames[in.shortcutKey] |
| 345 | if !ok { |
| 346 | if len(in.shortcutKey) > 1 { |
| 347 | log.Printf("systray error: unsupported key %q for menu shortcut\n", in.shortcutKey) |
| 348 | return nil |
| 349 | } |
| 350 | key = strings.ToLower(in.shortcutKey) |
| 351 | } |
| 352 | |
| 353 | return [][]string{append(keys, key)} |
| 354 | } |
| 355 | |
| 356 | func applyItemToLayout(in *MenuItem, out *menuLayout) { |
| 357 | out.V1["enabled"] = dbus.MakeVariant(!in.disabled) |
| 358 | out.V1["label"] = dbus.MakeVariant(in.title) |
| 359 | |
| 360 | if shortcut := shortcutForItem(in); shortcut != nil { |
| 361 | out.V1["shortcut"] = dbus.MakeVariant(shortcut) |
| 362 | } else { |
| 363 | delete(out.V1, "shortcut") |
| 364 | } |
| 365 | |
| 366 | if in.isCheckable { |
| 367 | out.V1["toggle-type"] = dbus.MakeVariant("checkmark") |
| 368 | if in.checked { |
| 369 | out.V1["toggle-state"] = dbus.MakeVariant(1) |
| 370 | } else { |
| 371 | out.V1["toggle-state"] = dbus.MakeVariant(0) |
| 372 | } |
| 373 | } else { |
| 374 | out.V1["toggle-type"] = dbus.MakeVariant("") |
| 375 | out.V1["toggle-state"] = dbus.MakeVariant(0) |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | func findLayout(id int32) (*menuLayout, bool) { |
| 380 | if id == 0 { |
| 381 | return instance.menu, true |
| 382 | } |
| 383 | return findSubLayout(id, instance.menu.V2) |
| 384 | } |
| 385 | |
| 386 | func findSubLayout(id int32, vals []dbus.Variant) (*menuLayout, bool) { |
| 387 | for _, i := range vals { |
| 388 | item := i.Value().(*menuLayout) |
| 389 | if item.V0 == id { |
| 390 | return item, true |
| 391 | } |
| 392 | |
| 393 | if len(item.V2) > 0 { |
| 394 | child, ok := findSubLayout(id, item.V2) |
| 395 | if ok { |
| 396 | return child, true |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | return nil, false |
| 402 | } |
| 403 | |
| 404 | func removeSubLayout(id int32, vals []dbus.Variant) ([]dbus.Variant, bool) { |
| 405 | for idx, i := range vals { |
| 406 | item := i.Value().(*menuLayout) |
| 407 | if item.V0 == id { |
| 408 | return append(vals[:idx], vals[idx+1:]...), true |
| 409 | } |
| 410 | |
| 411 | if len(item.V2) > 0 { |
| 412 | if child, removed := removeSubLayout(id, item.V2); removed { |
| 413 | return child, true |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | return vals, false |
| 419 | } |
| 420 | |
| 421 | func removeMenuItem(item *MenuItem) { |
| 422 | instance.menuLock.Lock() |
| 423 | defer instance.menuLock.Unlock() |
| 424 | |
| 425 | parent := instance.menu |
| 426 | if item.parent != nil { |
| 427 | m, ok := findLayout(int32(item.parent.id)) |
| 428 | if !ok { |
| 429 | return |
| 430 | } |
| 431 | parent = m |
| 432 | } |
| 433 | |
| 434 | if items, removed := removeSubLayout(int32(item.id), parent.V2); removed { |
| 435 | parent.V2 = items |
| 436 | refresh() |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | func hideMenuItem(item *MenuItem) { |
| 441 | instance.menuLock.Lock() |
| 442 | defer instance.menuLock.Unlock() |
| 443 | m, exists := findLayout(int32(item.id)) |
| 444 | if exists { |
| 445 | m.V1["visible"] = dbus.MakeVariant(false) |
| 446 | emitItemPropertiesUpdated(int32(item.id), m.V1) |
| 447 | // Property-only change; per spec LayoutUpdated isn't required here, |
| 448 | // but kept as a fallback for clients that only watch LayoutUpdated. |
| 449 | refresh() |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | func showMenuItem(item *MenuItem) { |
| 454 | instance.menuLock.Lock() |
| 455 | defer instance.menuLock.Unlock() |
| 456 | m, exists := findLayout(int32(item.id)) |
| 457 | if exists { |
| 458 | m.V1["visible"] = dbus.MakeVariant(true) |
| 459 | emitItemPropertiesUpdated(int32(item.id), m.V1) |
| 460 | // Property-only change; per spec LayoutUpdated isn't required here, |
| 461 | // but kept as a fallback for clients that only watch LayoutUpdated. |
| 462 | refresh() |
| 463 | } |
| 464 | } |
| 465 | |
| 466 | // emitItemPropertiesUpdated emits the com.canonical.dbusmenu.ItemsPropertiesUpdated |
| 467 | // signal so desktop clients refresh per-item state (label, enabled, toggle-state, |
| 468 | // visible, icon-data) without re-querying the whole layout. |
| 469 | func emitItemPropertiesUpdated(id int32, props map[string]dbus.Variant) { |
| 470 | instance.lock.Lock() |
| 471 | conn := instance.conn |
| 472 | instance.lock.Unlock() |
| 473 | if conn == nil { |
| 474 | return |
| 475 | } |
| 476 | err := menu.Emit(conn, &menu.Dbusmenu_ItemsPropertiesUpdatedSignal{ |
| 477 | Path: menuPath, |
| 478 | Body: &menu.Dbusmenu_ItemsPropertiesUpdatedSignalBody{ |
| 479 | UpdatedProps: []struct { |
| 480 | V0 int32 |
| 481 | V1 map[string]dbus.Variant |
| 482 | }{{V0: id, V1: props}}, |
| 483 | }, |
| 484 | }) |
| 485 | if err != nil { |
| 486 | log.Printf("systray error: failed to emit items properties updated signal: %v\n", err) |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | func refresh() { |
| 491 | instance.lock.Lock() |
| 492 | defer instance.lock.Unlock() |
| 493 | if instance.conn == nil || instance.menuProps == nil { |
| 494 | return |
| 495 | } |
| 496 | instance.menuVersion++ |
| 497 | dbusErr := instance.menuProps.Set("com.canonical.dbusmenu", "Version", |
| 498 | dbus.MakeVariant(instance.menuVersion)) |
| 499 | if dbusErr != nil { |
| 500 | log.Printf("systray error: failed to update menu version: %v\n", dbusErr) |
| 501 | return |
| 502 | } |
| 503 | err := menu.Emit(instance.conn, &menu.Dbusmenu_LayoutUpdatedSignal{ |
| 504 | Path: menuPath, |
| 505 | Body: &menu.Dbusmenu_LayoutUpdatedSignalBody{ |
| 506 | Revision: instance.menuVersion, |
| 507 | }, |
| 508 | }) |
| 509 | if err != nil { |
| 510 | log.Printf("systray error: failed to emit layout updated signal: %v\n", err) |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | func resetMenu() { |
| 515 | instance.menuLock.Lock() |
| 516 | defer instance.menuLock.Unlock() |
| 517 | instance.menu = &menuLayout{} |
| 518 | instance.menuVersion++ |
| 519 | firstGetLayoutDone = false |
| 520 | refresh() |
| 521 | } |
| 522 |