diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3108a6e..8c88011 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,13 +48,21 @@ jobs: run: | sudo apt-get update sudo apt-get install -y xvfb xauth libgl1-mesa-dri libglx-mesa0 \ - libxcursor1 libxfixes3 libxtst6 + libxcursor1 libxfixes3 libxtst6 libdbus-1-3 dbus-daemon xfonts-base nim check tests/test_x11.nim nim c tests/test_x11.nim xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 ./tests/test_x11 xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ MESA_GL_VERSION_OVERRIDE=3.3 ./tests/test_x11 --unsupported-version + - name: Test Linux tray backends + if: matrix.os == 'ubuntu-latest' + run: | + nim c tests/test_tray.nim + ./tests/test_tray + nim c -d:release tests/test_tray.nim + ./tests/test_tray + # Build native examples. - run: nim c examples/basic.nim - run: nim c examples/basic_boxy.nim diff --git a/README.md b/README.md index 6ab5e45..92f89cf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Features: * Easy polling of keyboard state via `buttonDown[Button]` and more * IME support (for Chinese, Japanese, etc. text input) * System clipboard (copy and paste) support -* Show a system tray icon and menu (Windows only) +* Show a system tray icon and menu (Windows and Linux) * Non-blocking HTTP requests and WebSockets ## Basic Example @@ -52,6 +52,18 @@ while not window.closeRequested: [Check out more examples here.](https://github.com/treeform/windy/tree/master/examples) +## Tray icons + +`showTrayIcon(image, tooltip, onClick, menu)` shows one icon; +calling it again replaces the icon and menu. Call `pollEvents()` to process +interaction, and `hideTrayIcon()` to remove it. See [examples/tray.nim](examples/tray.nim). + +Linux selects an available StatusNotifierItem host, or an XEmbed tray when +SNI is unavailable. It waits if neither exists and recovers when the tray host +restarts. SNI uses the optional `libdbus-1.so.3` runtime library. XEmbed uses +Xlib with a basic popup menu; SNI menus follow the desktop's presentation. +Tray-only Linux applications do not need to create a window or OpenGL context. + ## CPU Pixels On Windows and X11 Linux, compile with `-d:useCpu` to create a plain window without an diff --git a/examples/tray.nim b/examples/tray.nim index 2700161..8ff484a 100644 --- a/examples/tray.nim +++ b/examples/tray.nim @@ -1,7 +1,6 @@ import pixie, windy -when defined(windows): - # Tray API only currently supported on Windows +when defined(windows) or (defined(linux) and not defined(emscripten)): let window = newWindow("Windy Tray Icon", ivec2(1280, 800)) window.makeContextCurrent() diff --git a/src/windy/common.nim b/src/windy/common.nim index 30e8fde..572cc4b 100644 --- a/src/windy/common.nim +++ b/src/windy/common.nim @@ -91,6 +91,18 @@ type DecoratedResizable, Decorated, Undecorated, Transparent Callback* = proc() + TrayMenuEntryKind* = enum + TrayMenuOption, TrayMenuSeparator + + TrayMenuEntry* = object + ## A tray menu option or separator. Option labels are literal UTF-8 text. + case kind*: TrayMenuEntryKind + of TrayMenuOption: + text*: string + onClick*: Callback + of TrayMenuSeparator: + discard + ButtonCallback* = proc(button: Button) RuneCallback* = proc(rune: Rune) TextInputCallback* = proc(text: string) diff --git a/src/windy/platforms/linux/platform.nim b/src/windy/platforms/linux/platform.nim index 9ae5f62..91fdf2c 100644 --- a/src/windy/platforms/linux/platform.nim +++ b/src/windy/platforms/linux/platform.nim @@ -1,5 +1,6 @@ -import ../../common, ./backends +import ../../common, ./backends, ./tray export backends +export tray except pollTrayEvents when not HasDirect: import ./x11 @@ -61,6 +62,7 @@ else: if selected == X11Backend: when HasX11: x11.pollEvents() else: + pollTrayEvents() direct.pollEvents() pollHttp() diff --git a/src/windy/platforms/linux/tray.nim b/src/windy/platforms/linux/tray.nim new file mode 100644 index 0000000..64b4deb --- /dev/null +++ b/src/windy/platforms/linux/tray.nim @@ -0,0 +1,105 @@ +import + std/unicode, + pixie, + ../../common, + ./backends, + ./tray/[state, sni] + +when HasX11: + import ./tray/xembed + +type + TrayBackend = enum + noTray, sniTray, xembedTray + +var + requested: TrayState + busTray: SniTray + backend: TrayBackend + revision: uint32 + nextMenuId = 1.int32 + +when HasX11: + var plugTray: XembedTray + +proc hideTrayIcon*() = + ## Removes the Linux tray icon and cancels pending registration. Safe to repeat. + requested = nil + backend = noTray + busTray.close() + when HasX11: + plugTray.close() + plugTray = nil + busTray = nil + +proc showTrayIcon*(icon: Image, tooltip: string, onClick: Callback, + menu: seq[TrayMenuEntry] = @[]) = + ## Shows or replaces one tray icon. Call pollEvents to process tray interaction. + ## Waits for a tray host when none is present; prefers SNI, then XEmbed. + ## Labels are literal UTF-8. SNI hosts determine how activation is presented. + if icon == nil or icon.width <= 0 or icon.height <= 0: + raise newException(WindyError, "A tray icon must contain pixels") + if '\0' in tooltip or tooltip.validateUtf8() != -1: + raise newException(WindyError, "A tray tooltip must be UTF-8 without null bytes") + for entry in menu: + if entry.kind == TrayMenuOption and + ('\0' in entry.text or entry.text.validateUtf8() != -1): + raise newException(WindyError, "A tray menu label must be UTF-8 without null bytes") + if menu.len > (high(int32) - nextMenuId).int: + raise newException(WindyError, "Tray menu IDs exhausted") + inc revision + let state = TrayState(icon: icon.copy(), tooltip: tooltip, + onClick: onClick, revision: revision, firstMenuId: nextMenuId) + nextMenuId += menu.len.int32 + # Own the sequence so later application edits cannot change a published menu. + for entry in menu: state.menu.add entry + if requested == nil: + requested = state + try: + busTray = newSniTray(state) + when HasX11: + plugTray = newXembedTray(state) + except: + hideTrayIcon() + raise + else: + requested = state + busTray.update(state) + when HasX11: + plugTray.update(state) + +proc pollTrayEvents*() = + ## Pumps both host monitors and dispatches callbacks for the current snapshot. + if requested == nil: return + let snapshot = requested + var callbacks = busTray.poll() + when HasX11: + callbacks.add plugTray.poll() + if backend == sniTray and not busTray.hostAvailable and not busTray.selecting: + # Closing the connection unregisters even if the watcher itself still exists. + let failedOwner = busTray.failedOwner + busTray.close() + busTray = newSniTray(requested, failedOwner) + backend = noTray + when HasX11: + if backend == xembedTray and not plugTray.available: + plugTray.setActive(false) + backend = noTray + if backend == noTray: + if busTray.hostAvailable: + backend = sniTray + busTray.setActive(true) + elif not busTray.selecting: + when HasX11: + if plugTray.available: + backend = xembedTray + plugTray.setActive(true) + elif backend == sniTray: + busTray.setActive(true) + when HasX11: + if backend == xembedTray: + # Redock if the manager retained ownership but recreated the plug. + plugTray.setActive(true) + for callback in callbacks: + if requested != snapshot: break + callback() diff --git a/src/windy/platforms/linux/tray/sni.nim b/src/windy/platforms/linux/tray/sni.nim new file mode 100644 index 0000000..0e9fef8 --- /dev/null +++ b/src/windy/platforms/linux/tray/sni.nim @@ -0,0 +1,450 @@ +import + std/[monotimes, os, strutils, times, unicode], + pixie, + ../../../common, + ./state, + ./sni/dbus + +type + SniTray* = ref object + connection: Connection + state: TrayState + owner: string + querySerial, ownerSerial, registerSerial: uint32 + hostAvailable*, registered*: bool + selecting*: bool + deadline: MonoTime + blockedOwner: string + failedOwner*: string + +const + Watcher = "org.kde.StatusNotifierWatcher" + WatcherPath = "/StatusNotifierWatcher" + Item = "org.kde.StatusNotifierItem" + ItemPath = "/StatusNotifierItem" + Menu = "com.canonical.dbusmenu" + MenuPath = "/Menu" + ReplyTimeoutSeconds = 2 + EventBatchSize = 64 + Properties = "org.freedesktop.DBus.Properties" + ItemProperties = [ + ("Category", "s"), ("Id", "s"), ("Title", "s"), ("Status", "s"), + ("WindowId", "u"), ("IconName", "s"), ("IconPixmap", "a(iiay)"), + ("OverlayIconName", "s"), ("OverlayIconPixmap", "a(iiay)"), + ("AttentionIconName", "s"), ("AttentionIconPixmap", "a(iiay)"), + ("AttentionMovieName", "s"), ("ToolTip", "(sa(iiay)ss)"), + ("ItemIsMenu", "b"), ("Menu", "o")] + MenuProperties = [("Version", "u"), ("Status", "s"), + ("TextDirection", "s"), ("IconThemePath", "as")] + IntrospectionHeader = "" & + "" & + "" & + "" + ItemMethods = "" & + "" & + "" & + "" & + "" + MenuMethods = "" & + "" & + "" & + "" & + "" & + "" & + "" & + "" & + "" + +proc introspection(isMenu: bool): string = + ## Describes only the interfaces implemented at the requested object path. + result = IntrospectionHeader + result.add "" + if isMenu: + for (name, signature) in MenuProperties: + result.add "" + result.add MenuMethods + else: + for (name, signature) in ItemProperties: + result.add "" + result.add ItemMethods + result.add "" + +proc pixmaps(i: var Iter, state: TrayState, present: bool) = + ## Serializes straight-alpha ARGB pixels in network byte order. + container(i, 'a', "(iiay)", images): + if present: + container(images, 'r', nil, image): + image.addInt state.icon.width.int32 + image.addInt state.icon.height.int32 + container(image, 'a', "y", bytes): + for pixel in state.icon.data: + let c = pixel.rgba() + bytes.addByte c.a + bytes.addByte c.r + bytes.addByte c.g + bytes.addByte c.b + +proc propertyValue(i: var Iter, state: TrayState, name, signature: string) = + ## Writes a supported property as a D-Bus variant. + container(i, 'v', signature.cstring, value): + case name + of "Category": value.addString "ApplicationStatus" + of "Id": + let name = getAppFilename().extractFilename() + value.addString(if name.validateUtf8() == -1: name else: "windy") + of "Title": value.addString state.tooltip + of "Status": value.addString "Active" + of "WindowId": value.addUint 0 + of "Version": value.addUint 4 + of "ItemIsMenu": value.addBool false + of "Menu": value.addString MenuPath, 'o' + of "TextDirection": value.addString "ltr" + of "IconThemePath": + container(value, 'a', "s", paths): discard + of "ToolTip": + container(value, 'r', nil, tooltip): + tooltip.addString "" + tooltip.pixmaps(state, false) + tooltip.addString state.tooltip + tooltip.addString "" + of "IconPixmap", "AttentionIconPixmap", "OverlayIconPixmap": + value.pixmaps(state, name == "IconPixmap") + else: value.addString "" + +proc error(tray: SniTray, call: Message, name, text: string) = + ## Replies with a protocol error instead of passing bad arguments to libdbus. + discard tray.connection.send(messageNewError(call, ("org.freedesktop.DBus.Error." & name).cstring, text.cstring)) + +proc validId(state: TrayState, id: int32): bool = + ## Accepts the root and currently published menu IDs. + id == 0 or (id >= state.firstMenuId and id - state.firstMenuId < state.menu.len) + +proc strings(i: var Iter): seq[string] = + ## Copies a validated array of strings before the native message is released. + var child = i.recurse() + while messageIterGetArgType(child.addr) == 's'.cint: + result.add child.stringValue() + child.next() + +proc ids(i: var Iter): seq[int32] = + ## Copies a validated array of menu IDs. + var child = i.recurse() + while messageIterGetArgType(child.addr) == 'i'.cint: + result.add child.intValue() + child.next() + +proc menuProperty(i: var Iter, state: TrayState, id: int32, name: string): bool = + ## Writes one property for the deliberately flat menu model. + let index = id - state.firstMenuId + let separator = id > 0 and state.menu[index].kind == TrayMenuSeparator + case name + of "label": + container(i, 'v', "s", value): + value.addString(if id > 0 and not separator: state.menu[index].text.replace("_", "__") else: "") + of "type": + container(i, 'v', "s", value): + value.addString(if separator: "separator" else: "standard") + of "enabled", "visible": + container(i, 'v', "b", value): value.addBool true + of "children-display": + container(i, 'v', "s", value): value.addString(if id == 0: "submenu" else: "") + else: return false + true + +proc menuProperties(i: var Iter, state: TrayState, id: int32, requested: seq[string]) = + ## Emits requested properties, or all properties for an empty filter. + container(i, 'a', "{sv}", props): + for name in ["label", "type", "enabled", "visible", "children-display"]: + if requested.len == 0 or name in requested: + container(props, 'e', nil, entry): + entry.addString name + discard entry.menuProperty(state, id, name) + +proc layout(i: var Iter, state: TrayState, id, depth: int32, requested: seq[string]) = + ## Emits the root and its flat children, respecting the requested depth. + container(i, 'r', nil, node): + node.addInt id + node.menuProperties(state, id, requested) + container(node, 'a', "v", children): + if id == 0 and depth != 0: + for index in 0.. 0 and tray.state.validId(id): + let callback = tray.state.menuCallback(id - tray.state.firstMenuId + 1) + if callback != nil: callbacks.add callback + if member == "Event": + event(args) + else: + var events = args.recurse() + container(outArgs, 'a', "i", errors): + while messageIterGetArgType(events.addr) == 'r'.cint: + var entry = events.recurse() + let id = entry.intValue() + if not tray.state.validId(id): errors.addInt id + else: event(entry) + events.next() + else: + tray.error(call, "UnknownMethod", "Unknown menu method") + return + else: + tray.error(call, "UnknownInterface", "Unknown tray interface") + return + checked connectionSend(tray.connection, reply, nil) + +proc queryHost(tray: SniTray) = + ## Asynchronously resolves the watcher before trusting its signals or replies. + let call = requireMessage(messageNewMethodCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetNameOwner")) + var args = call.writer() + args.addString Watcher + tray.ownerSerial = tray.connection.send(call) + tray.selecting = true + tray.deadline = getMonoTime() + initDuration(seconds = ReplyTimeoutSeconds) + +proc newSniTray*(state: TrayState, blockedOwner = ""): SniTray = + ## Starts optional session-bus discovery without requiring an X11 display. + result = SniTray(state: state, blockedOwner: blockedOwner) + if not loadDbus(): return + result.connection = busGetPrivate(0, nil) + if result.connection == nil: return + try: + connectionSetExitOnDisconnect(result.connection, 0) + busAddMatch(result.connection, "type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.kde.StatusNotifierWatcher'", nil) + busAddMatch(result.connection, "type='signal',interface='org.kde.StatusNotifierWatcher'", nil) + result.queryHost() + except: + connectionClose(result.connection) + connectionUnref(result.connection) + result.connection = nil + raise + +proc setActive*(tray: SniTray, active: bool) = + ## Registers only the selected backend, using the connection's unique bus name. + if active and tray.hostAvailable and not tray.selecting and + tray.registerSerial == 0 and not tray.registered: + let call = requireMessage(messageNewMethodCall(Watcher, WatcherPath, Watcher, "RegisterStatusNotifierItem")) + var args = call.writer() + args.addString ItemPath + tray.registerSerial = tray.connection.send(call) + tray.deadline = getMonoTime() + initDuration(seconds = ReplyTimeoutSeconds) + +proc update*(tray: SniTray, state: TrayState) = + ## Replaces the snapshot and notifies hosts that cached content changed. + tray.state = state + if tray.connection == nil: return + for name in ["NewIcon", "NewToolTip", "NewTitle"]: + discard tray.connection.send(messageNewSignal(ItemPath, Item, name.cstring)) + let signal = requireMessage(messageNewSignal(MenuPath, Menu, "LayoutUpdated")) + var args = signal.writer() + args.addUint state.revision + args.addInt 0 + discard tray.connection.send(signal) + +proc poll*(tray: SniTray): seq[Callback] = + ## Processes a bounded batch without waiting for bus traffic or method replies. + if tray.connection == nil: return + if (tray.selecting or tray.registerSerial != 0) and getMonoTime() > tray.deadline: + if tray.registerSerial != 0: + tray.failedOwner = tray.owner + tray.selecting = false + tray.hostAvailable = false + tray.ownerSerial = 0 + tray.querySerial = 0 + tray.registerSerial = 0 + if connectionReadWrite(tray.connection, 0) == 0: + tray.hostAvailable = false + tray.registered = false + tray.selecting = false + return + for unused in 0.. 0: + let call = requireMessage(messageNewMethodCall(tray.owner.cstring, WatcherPath, Properties, "Get")) + var query = call.writer() + query.addString Watcher + query.addString "IsStatusNotifierHostRegistered" + tray.querySerial = tray.connection.send(call) + else: tray.selecting = false + elif serial == tray.querySerial: + tray.querySerial = 0 + tray.selecting = false + if kind == 2 and $messageGetSignature(message) == "v": + var value = args.recurse() + if messageIterGetArgType(value.addr) == 'b'.cint: + var available: uint32 + messageIterGetBasic(value.addr, available.addr) + tray.hostAvailable = available != 0 and tray.owner != tray.blockedOwner + elif serial == tray.registerSerial: + tray.registerSerial = 0 + tray.registered = kind == 2 + if kind != 2: + tray.hostAvailable = false + tray.failedOwner = tray.owner + elif kind == 4: + let iface = $messageGetInterface(message) + if iface == "org.freedesktop.DBus" and + $messageGetMember(message) == "NameOwnerChanged" and + $messageGetSender(message) == "org.freedesktop.DBus" and + $messageGetSignature(message) == "sss" and args.stringValue() == Watcher: + tray.registered = false + tray.registerSerial = 0 + tray.blockedOwner = "" + tray.querySerial = 0 + tray.queryHost() + elif iface == Watcher and $messageGetSender(message) == tray.owner: + if $messageGetMember(message) in ["StatusNotifierHostRegistered", "StatusNotifierHostUnregistered"]: + tray.registered = false + tray.blockedOwner = "" + tray.queryHost() + elif kind == 1: + tray.methodCall(message, result) + finally: + messageUnref(message) + +proc close*(tray: SniTray) = + ## Removes registration by closing the private connection and releases bus state. + if tray != nil and tray.connection != nil: + connectionClose(tray.connection) + connectionUnref(tray.connection) + tray.connection = nil + if tray != nil: + tray.registered = false + tray.hostAvailable = false diff --git a/src/windy/platforms/linux/tray/sni/dbus.nim b/src/windy/platforms/linux/tray/sni/dbus.nim new file mode 100644 index 0000000..8667c89 --- /dev/null +++ b/src/windy/platforms/linux/tray/sni/dbus.nim @@ -0,0 +1,176 @@ +import + std/dynlib, + ../../../../common + +type + Connection* = ptr object + Message* = ptr object + Iter* = object + # Public libdbus iterator storage; pointer-sized fields preserve the C ABI. + dummy1, dummy2: pointer + dummy3: uint32 + dummy4, dummy5, dummy6, dummy7, dummy8, dummy9, dummy10, dummy11: cint + pad1: cint + pad2, pad3: pointer + +var + library: LibHandle + busGetPrivate*: proc(kind: cint, error: pointer): Connection {.cdecl.} + busAddMatch*: proc(c: Connection, rule: cstring, error: pointer) {.cdecl.} + connectionSetExitOnDisconnect*: proc(c: Connection, enabled: uint32) {.cdecl.} + connectionReadWrite*: proc(c: Connection, timeout: cint): uint32 {.cdecl.} + connectionPopMessage*: proc(c: Connection): Message {.cdecl.} + connectionSend*: proc(c: Connection, m: Message, serial: ptr uint32): uint32 {.cdecl.} + connectionClose*: proc(c: Connection) {.cdecl.} + connectionUnref*: proc(c: Connection) {.cdecl.} + messageNewMethodCall*: proc(dest, path, iface, name: cstring): Message {.cdecl.} + messageNewMethodReturn*: proc(m: Message): Message {.cdecl.} + messageNewError*: proc(m: Message, name, text: cstring): Message {.cdecl.} + messageNewSignal*: proc(path, iface, name: cstring): Message {.cdecl.} + messageUnref*: proc(m: Message) {.cdecl.} + messageGetType*: proc(m: Message): cint {.cdecl.} + messageGetReplySerial*: proc(m: Message): uint32 {.cdecl.} + messageGetPath*: proc(m: Message): cstring {.cdecl.} + messageGetInterface*: proc(m: Message): cstring {.cdecl.} + messageGetMember*: proc(m: Message): cstring {.cdecl.} + messageGetSender*: proc(m: Message): cstring {.cdecl.} + messageGetSignature*: proc(m: Message): cstring {.cdecl.} + messageIterInitAppend*: proc(m: Message, i: ptr Iter) {.cdecl.} + messageIterInit*: proc(m: Message, i: ptr Iter): uint32 {.cdecl.} + messageIterGetArgType*: proc(i: ptr Iter): cint {.cdecl.} + messageIterGetBasic*: proc(i: ptr Iter, value: pointer) {.cdecl.} + messageIterNext*: proc(i: ptr Iter): uint32 {.cdecl.} + messageIterRecurse*: proc(i, child: ptr Iter) {.cdecl.} + messageIterAppendBasic*: proc(i: ptr Iter, kind: cint, value: pointer): uint32 {.cdecl.} + messageIterOpenContainer*: proc(i: ptr Iter, kind: cint, signature: cstring, child: ptr Iter): uint32 {.cdecl.} + messageIterCloseContainer*: proc(i, child: ptr Iter): uint32 {.cdecl.} + messageIterAbandonContainer*: proc(i, child: ptr Iter) {.cdecl.} + +proc loadDbus*(): bool = + ## Loads the optional runtime library without requiring development headers. + if library != nil: + return true + let handle = loadLib("libdbus-1.so.3") + if handle == nil: + return false + template bindProc(name: untyped, symbol: string) = + name = cast[typeof(name)](symAddr(handle, symbol)) + if name == nil: + unloadLib(handle) + return false + bindProc(busGetPrivate, "dbus_bus_get_private") + bindProc(busAddMatch, "dbus_bus_add_match") + bindProc(connectionSetExitOnDisconnect, "dbus_connection_set_exit_on_disconnect") + bindProc(connectionReadWrite, "dbus_connection_read_write") + bindProc(connectionPopMessage, "dbus_connection_pop_message") + bindProc(connectionSend, "dbus_connection_send") + bindProc(connectionClose, "dbus_connection_close") + bindProc(connectionUnref, "dbus_connection_unref") + bindProc(messageNewMethodCall, "dbus_message_new_method_call") + bindProc(messageNewMethodReturn, "dbus_message_new_method_return") + bindProc(messageNewError, "dbus_message_new_error") + bindProc(messageNewSignal, "dbus_message_new_signal") + bindProc(messageUnref, "dbus_message_unref") + bindProc(messageGetType, "dbus_message_get_type") + bindProc(messageGetReplySerial, "dbus_message_get_reply_serial") + bindProc(messageGetPath, "dbus_message_get_path") + bindProc(messageGetInterface, "dbus_message_get_interface") + bindProc(messageGetMember, "dbus_message_get_member") + bindProc(messageGetSender, "dbus_message_get_sender") + bindProc(messageGetSignature, "dbus_message_get_signature") + bindProc(messageIterInitAppend, "dbus_message_iter_init_append") + bindProc(messageIterInit, "dbus_message_iter_init") + bindProc(messageIterGetArgType, "dbus_message_iter_get_arg_type") + bindProc(messageIterGetBasic, "dbus_message_iter_get_basic") + bindProc(messageIterNext, "dbus_message_iter_next") + bindProc(messageIterRecurse, "dbus_message_iter_recurse") + bindProc(messageIterAppendBasic, "dbus_message_iter_append_basic") + bindProc(messageIterOpenContainer, "dbus_message_iter_open_container") + bindProc(messageIterCloseContainer, "dbus_message_iter_close_container") + bindProc(messageIterAbandonContainer, "dbus_message_iter_abandon_container") + library = handle + true + +proc checked*(ok: uint32) = + ## Turns allocation failures into a Nim exception before using an invalid message. + if ok == 0: + raise newException(WindyError, "Unable to allocate D-Bus message storage") + +proc requireMessage*(m: Message): Message = + ## Checks message allocation before dereferencing the native pointer. + if m == nil: + raise newException(WindyError, "Unable to allocate D-Bus message") + m + +proc writer*(m: Message): Iter = + ## Creates an append iterator for a message. + messageIterInitAppend(m, result.addr) + +proc reader*(m: Message): Iter = + ## Creates a read iterator, including for an empty message. + discard messageIterInit(m, result.addr) + +proc addString*(i: var Iter, value: string, kind = 's') = + ## Appends a string, object path, or signature. + var value = value.cstring + checked messageIterAppendBasic(i.addr, kind.cint, value.addr) + +proc addInt*(i: var Iter, value: int32) = + ## Appends a signed 32-bit integer. + var value = value + checked messageIterAppendBasic(i.addr, 'i'.cint, value.addr) + +proc addUint*(i: var Iter, value: uint32) = + ## Appends an unsigned 32-bit integer. + var value = value + checked messageIterAppendBasic(i.addr, 'u'.cint, value.addr) + +proc addBool*(i: var Iter, value: bool) = + ## Appends the 32-bit D-Bus boolean representation. + var value = uint32(value) + checked messageIterAppendBasic(i.addr, 'b'.cint, value.addr) + +proc addByte*(i: var Iter, value: uint8) = + ## Appends one byte. + var value = value + checked messageIterAppendBasic(i.addr, 'y'.cint, value.addr) + +template container*(parent: var Iter, kind: char, signature: cstring, + child: untyped, body: untyped) = + block: + var child: Iter + checked messageIterOpenContainer(parent.addr, kind.cint, signature, child.addr) + try: + body + except: + messageIterAbandonContainer(parent.addr, child.addr) + raise + checked messageIterCloseContainer(parent.addr, child.addr) + +proc stringValue*(i: var Iter): string = + ## Reads a string only after verifying its native type. + if messageIterGetArgType(i.addr) notin ['s'.cint, 'o'.cint, 'g'.cint]: + return + var value: cstring + messageIterGetBasic(i.addr, value.addr) + $value + +proc intValue*(i: var Iter): int32 = + ## Reads a signed integer from a validated argument. + if messageIterGetArgType(i.addr) == 'i'.cint: + messageIterGetBasic(i.addr, result.addr) + +proc next*(i: var Iter) = + ## Advances to the next argument. + discard messageIterNext(i.addr) + +proc recurse*(i: var Iter): Iter = + ## Opens a container for reading after its type has been checked. + if messageIterGetArgType(i.addr) in ['a'.cint, 'r'.cint, 'v'.cint, 'e'.cint]: + messageIterRecurse(i.addr, result.addr) + +proc send*(c: Connection, m: Message): uint32 = + ## Queues a message and releases its reference without blocking on a reply. + discard requireMessage(m) + defer: messageUnref(m) + checked connectionSend(c, m, result.addr) diff --git a/src/windy/platforms/linux/tray/state.nim b/src/windy/platforms/linux/tray/state.nim new file mode 100644 index 0000000..48971bf --- /dev/null +++ b/src/windy/platforms/linux/tray/state.nim @@ -0,0 +1,19 @@ +import + pixie, + ../../../common + +type + TrayState* = ref object + ## One immutable snapshot shared by the selected tray backend. + icon*: Image + tooltip*: string + onClick*: Callback + menu*: seq[TrayMenuEntry] + revision*: uint32 + firstMenuId*: int32 + +proc menuCallback*(state: TrayState, id: int32): Callback = + ## Returns the action for a valid option ID, ignoring roots and separators. + if id > 0 and id <= state.menu.len and + state.menu[id - 1].kind == TrayMenuOption: + result = state.menu[id - 1].onClick diff --git a/src/windy/platforms/linux/tray/xembed.nim b/src/windy/platforms/linux/tray/xembed.nim new file mode 100644 index 0000000..3ca7402 --- /dev/null +++ b/src/windy/platforms/linux/tray/xembed.nim @@ -0,0 +1,476 @@ +import + std/[monotimes, times], + pixie, + ../../../common, + ../x11/keysym, + ./state, + ./xembed/xlib + +type + XembedTray* = ref object + display: Display + root, manager, icon, popup, tooltipWindow: xlib.Window + selection, managerAtom, opcode, embed, embedInfo: Atom + visual: ptr Visual + depth: cint + colormap: Colormap + gc, popupGc: GC + fonts: FontSet + state: TrayState + width, height, popupWidth, popupHeight: int + hovered: int + hoverStart: MonoTime + pointerInside: bool + errorCode: int + available*: bool + +const + RowHeight = 26 + SeparatorHeight = 9 + MenuPadding = 6 + TooltipDelayMs = 700 + EventBatchSize = 64 + MaximumIconExtent = 1024 + CwBackPixmap = 1.culong + CwBackPixel = 2.culong + CwBorderPixel = 8.culong + CwOverrideRedirect = 512.culong + CwEventMask = 2048.culong + CwColormap = 8192.culong + ZPixmap = 2.cint + GetImageRequest = 73 + +var + errorTray: XembedTray + previousErrorHandler: ErrorHandleProc + +proc trayError(d: Display, event: ptr XErrorEvent): cint {.cdecl.} = + ## Captures only this private connection's errors, preserving other clients. + if errorTray != nil and d == errorTray.display: + # The panel can unmap or resize the plug between exposure and XGetImage. + if event.errorCode.int == badMatch.ord and event.requestCode.int == GetImageRequest: + return 0 + if errorTray.errorCode == 0 or event.errorCode.int notin [badWindow.ord, badDrawable.ord]: + errorTray.errorCode = event.errorCode.int + elif previousErrorHandler != nil: + return previousErrorHandler(d, event) + +template withErrors(tray: XembedTray, body: untyped) = + block: + errorTray = tray + previousErrorHandler = XSetErrorHandler(trayError) + try: + body + finally: + # Drain errors before restoring the process-wide handler. + tray.display.XSync() + discard XSetErrorHandler(previousErrorHandler) + errorTray = nil + if tray.errorCode notin [0, badWindow.ord, badDrawable.ord]: + let code = tray.errorCode + tray.errorCode = 0 + raise newException(WindyError, "XEmbed X11 error: " & $code) + tray.errorCode = 0 + +proc atom(tray: XembedTray, name: string): Atom = + ## Interns an atom on the private display. + tray.display.XInternAtom(name.cstring, 0) + +proc hidePopup(tray: XembedTray) = + ## Releases menu grabs before destroying the popup. + if tray.popup != 0: + tray.display.XUngrabPointer(CurrentTime) + discard tray.display.XUngrabKeyboard(CurrentTime) + tray.display.XDestroyWindow(tray.popup) + tray.popup = 0 + if tray.popupGc != nil: + tray.display.XFreeGC(tray.popupGc) + tray.popupGc = nil + +proc hideTooltip(tray: XembedTray) = + ## Removes a visible tooltip when interaction or embedding changes. + if tray.tooltipWindow != 0: + tray.display.XDestroyWindow(tray.tooltipWindow) + tray.tooltipWindow = 0 + +proc removeIcon(tray: XembedTray) = + ## Releases the plug and its resources while keeping manager discovery alive. + tray.hidePopup() + tray.hideTooltip() + if tray.icon != 0: + tray.display.XDestroyWindow(tray.icon) + tray.icon = 0 + if tray.gc != nil: + tray.display.XFreeGC(tray.gc) + tray.gc = nil + if tray.colormap != 0: + tray.display.XFreeColormap(tray.colormap) + tray.colormap = 0 + tray.pointerInside = false + +proc managerChanged(tray: XembedTray) = + ## Resolves the manager after startup or an ownership announcement. + let owner = tray.display.XGetSelectionOwner(tray.selection) + if owner != tray.manager: + tray.removeIcon() + tray.manager = owner + if owner != 0: + tray.display.XSelectInput(owner, StructureNotifyMask) + tray.available = owner != 0 + +proc newXembedTray*(state: TrayState): XembedTray = + ## Starts XEmbed discovery without initializing Windy's window or GL backend. + result = XembedTray(state: state, hovered: -1) + result.display = XOpenDisplay(nil) + if result.display == nil: return + try: + result.root = result.display.defaultRootWindow() + result.selection = result.atom("_NET_SYSTEM_TRAY_S" & $result.display.defaultScreen()) + result.managerAtom = result.atom("MANAGER") + result.opcode = result.atom("_NET_SYSTEM_TRAY_OPCODE") + result.embed = result.atom("_XEMBED") + result.embedInfo = result.atom("_XEMBED_INFO") + withErrors(result): + result.display.XSelectInput(result.root, StructureNotifyMask) + result.managerChanged() + except: + discard result.display.XCloseDisplay() + result.display = nil + raise + +proc visualInfo(tray: XembedTray) = + ## Uses the manager's requested visual, falling back to the screen default. + let screen = tray.display.screen(tray.display.defaultScreen()) + tray.visual = screen.rootVisual + tray.depth = screen.rootDepth + var + kind: Atom + format: cint + length, remaining: culong + data: cstring + tray.display.XGetWindowProperty(tray.manager, tray.atom("_NET_SYSTEM_TRAY_VISUAL"), + 0, 1, false, AnyPropertyType, kind.addr, format.addr, length.addr, remaining.addr, data.addr) + if data != nil: + defer: XFree(data) + if format == 32 and length == 1: + var info = XVisualInfo(visualid: cast[ptr culong](data)[]) + var count: cint + let found = tray.display.XGetVisualInfo(1, info.addr, count.addr) + if found != nil: + tray.visual = found.visual + tray.depth = found.depth + XFree(found) + +proc dock(tray: XembedTray) = + ## Creates a mapped XEmbed plug and requests docking with the current manager. + tray.visualInfo() + tray.width = 24 + tray.height = 24 + tray.colormap = tray.display.XCreateColormap(tray.root, tray.visual, 0) + var attrs = XSetWindowAttributes( + colormap: tray.colormap, borderPixel: 0, + eventMask: ExposureMask or StructureNotifyMask or ButtonReleaseMask or + EnterWindowMask or LeaveWindowMask) + # ParentRelative is valid when the embedder uses the same visual/depth. + let screen = tray.display.screen(tray.display.defaultScreen()) + var mask = CwColormap or CwBorderPixel or CwEventMask + if tray.depth == screen.rootDepth: + attrs.backgroundPixmap = ParentRelative + mask = mask or CwBackPixmap + else: + mask = mask or CwBackPixel + tray.icon = tray.display.XCreateWindow(tray.root, 0, 0, 24, 24, 0, + tray.depth.cuint, 1, tray.visual, mask, attrs.addr) + tray.gc = tray.display.XCreateGC(tray.icon, 0, nil) + var info: array[2, culong] = [0.culong, 1.culong] + tray.display.XChangeProperty(tray.icon, tray.embedInfo, tray.embedInfo, 32, + pmReplace, info[0].addr, 2) + tray.display.Xutf8SetWMProperties(tray.icon, tray.state.tooltip.cstring, + tray.state.tooltip.cstring, nil, 0, nil, nil, nil) + var event: XEvent + event.client = XClientMessageEvent(kind: xeClientMessage, window: tray.manager, + message_type: tray.opcode, format: 32) + event.client.data.l = [CurrentTime.clong, 0.clong, tray.icon.clong, 0.clong, 0.clong] + tray.display.XSendEvent(tray.manager, false, NoEventMask, event.addr) + tray.display.XMapWindow(tray.icon) + +proc channel(value: uint8, mask: culong): culong = + ## Scales a color channel into an arbitrary TrueColor visual mask. + if mask == 0: return 0 + var shift = 0 + while ((mask shr shift) and 1) == 0: inc shift + ((value.culong * (mask shr shift) + 127) div 255 shl shift) and mask + +proc paintIcon(tray: XembedTray) = + ## Scales the icon and paints premultiplied alpha using the selected visual. + if tray.icon == 0 or tray.width <= 0 or tray.height <= 0: return + let image = tray.display.XCreateImage(tray.visual, tray.depth.cuint, ZPixmap, 0, + nil, tray.width.cuint, tray.height.cuint, 32, 0) + if image == nil: raise newException(WindyError, "Unable to allocate XEmbed image") + defer: discard XDestroyImage(image) + image.data = cast[cstring](calloc(tray.height.csize_t, image.bytesPerLine.csize_t)) + if image.data == nil: raise newException(WindyError, "Unable to allocate XEmbed pixels") + let + rgbMask = tray.visual.redMask or tray.visual.greenMask or tray.visual.blueMask + alphaMask = if tray.depth == 32: (0xffffffff.culong xor rgbMask) else: 0.culong + scale = min(tray.width.float32 / tray.state.icon.width.float32, + tray.height.float32 / tray.state.icon.height.float32) + width = max(1, int(tray.state.icon.width.float32 * scale)) + height = max(1, int(tray.state.icon.height.float32 * scale)) + resized = tray.state.icon.resize(width, height) + offsetX = (tray.width - width) div 2 + offsetY = (tray.height - height) div 2 + var background: ptr XImage + if alphaMask == 0: + discard tray.display.XClearArea(tray.icon, 0, 0, 0, 0, 0) + background = tray.display.XGetImage(tray.icon, 0, 0, tray.width.cuint, + tray.height.cuint, high(culong), ZPixmap) + defer: + if background != nil: discard XDestroyImage(background) + for y in 0..= offsetX and x < offsetX + width and y >= offsetY and y < offsetY + height: + c = resized[x - offsetX, y - offsetY] + var pixel = channel(c.r, tray.visual.redMask) or + channel(c.g, tray.visual.greenMask) or channel(c.b, tray.visual.blueMask) + if alphaMask != 0: + pixel = pixel or channel(c.a, alphaMask) + elif background != nil: + let bg = XGetPixel(background, x.cint, y.cint) + for mask in [tray.visual.redMask, tray.visual.greenMask, tray.visual.blueMask]: + let part = ((bg and mask) * (255 - c.a).culong div 255) and mask + pixel = pixel + part + discard XPutPixel(image, x.cint, y.cint, pixel) + discard tray.display.XPutImage(tray.icon, tray.gc, image, 0, 0, 0, 0, + tray.width.cuint, tray.height.cuint) + +proc ensureFonts(tray: XembedTray) = + ## Uses Xlib UTF-8 text with server fonts, without changing process locale. + if tray.fonts != nil: return + var + missing: ptr cstring + count: cint + defaultString: cstring + tray.fonts = tray.display.XCreateFontSet("-*-*-medium-r-normal--14-*-*-*-*-*-*-*,fixed", + missing.addr, count.addr, defaultString.addr) + if missing != nil: XFreeStringList(missing) + if tray.fonts == nil: + raise newException(WindyError, "No X11 font set is available for the tray menu") + +proc textWidth(tray: XembedTray, text: string): int = + ## Measures a UTF-8 label using the popup's font set. + tray.ensureFonts() + Xutf8TextEscapement(tray.fonts, text.cstring, text.len.cint).int + +proc rowHeight(entry: TrayMenuEntry): int = + ## Gives separators less space than selectable rows. + if entry.kind == TrayMenuSeparator: SeparatorHeight else: RowHeight + +proc rowAt(tray: XembedTray, x, y: int): int = + ## Returns a selectable row within the popup bounds. + if x < 0 or x >= tray.popupWidth: return -1 + var top = MenuPadding + for index, entry in tray.state.menu: + if y >= top and y < top + entry.rowHeight(): + return if entry.kind == TrayMenuOption: index else: -1 + top += entry.rowHeight() + -1 + +proc popupPixel(tray: XembedTray, rgb: culong): culong = + ## Packs popup colors for the default screen visual, including RGB565. + let visual = tray.display.screen(tray.display.defaultScreen()).rootVisual + channel(uint8((rgb shr 16) and 255), visual.redMask) or + channel(uint8((rgb shr 8) and 255), visual.greenMask) or + channel(uint8(rgb and 255), visual.blueMask) + +proc paintPopup(tray: XembedTray) = + ## Draws the flat menu, highlighting the selected option. + discard tray.display.XSetForeground(tray.popupGc, tray.popupPixel(0xf4f4f4)) + discard tray.display.XFillRectangle(tray.popup, tray.popupGc, 0, 0, + tray.popupWidth.cuint, tray.popupHeight.cuint) + var top = MenuPadding + for index, entry in tray.state.menu: + if index == tray.hovered: + discard tray.display.XSetForeground(tray.popupGc, tray.popupPixel(0x3366aa)) + discard tray.display.XFillRectangle(tray.popup, tray.popupGc, 2, top.cint, + (tray.popupWidth - 4).cuint, RowHeight.cuint) + discard tray.display.XSetForeground(tray.popupGc, + tray.popupPixel(if index == tray.hovered: 0xffffff else: 0x202020)) + if entry.kind == TrayMenuSeparator: + discard tray.display.XDrawLine(tray.popup, tray.popupGc, MenuPadding.cint, + (top + 4).cint, (tray.popupWidth - MenuPadding).cint, (top + 4).cint) + else: + tray.display.Xutf8DrawString(tray.popup, tray.fonts, tray.popupGc, + MenuPadding.cint, (top + 18).cint, entry.text.cstring, entry.text.len.cint) + top += entry.rowHeight() + +proc floatingWindow(tray: XembedTray, x, y, width, height: int): xlib.Window = + ## Creates an undecorated popup constrained to the X screen. + let screen = tray.display.screen(tray.display.defaultScreen()) + var attrs = XSetWindowAttributes(overrideRedirect: 1, backgroundPixel: tray.popupPixel(0xf4f4f4), + borderPixel: tray.popupPixel(0x555555), eventMask: ExposureMask or ButtonPressMask or + ButtonReleaseMask or PointerMotionMask or KeyPressMask) + result = tray.display.XCreateWindow(tray.root, + max(0, min(x, screen.size.x.int - width - 2)).cint, + max(0, min(y, screen.size.y.int - height - 2)).cint, + width.cuint, height.cuint, 1, screen.rootDepth.cuint, 1, screen.rootVisual, + CwBackPixel or CwBorderPixel or CwOverrideRedirect or CwEventMask, attrs.addr) + tray.display.XMapWindow(result) + tray.display.XRaiseWindow(result) + +proc showPopup(tray: XembedTray, x, y: int) = + ## Opens the menu and acquires input only for its lifetime. + tray.hideTooltip() + tray.hidePopup() + if tray.state.menu.len == 0: return + tray.popupWidth = 100 + tray.popupHeight = MenuPadding * 2 + for entry in tray.state.menu: + tray.popupHeight += entry.rowHeight() + if entry.kind == TrayMenuOption: + tray.popupWidth = max(tray.popupWidth, tray.textWidth(entry.text) + MenuPadding * 2) + tray.popup = tray.floatingWindow(x, y, tray.popupWidth, tray.popupHeight) + tray.popupGc = tray.display.XCreateGC(tray.popup, 0, nil) + tray.hovered = -1 + if tray.display.XGrabPointer(tray.popup, 0, (ButtonPressMask or ButtonReleaseMask or + PointerMotionMask).cuint, GrabModeAsync, GrabModeAsync, 0, 0, CurrentTime) != GrabSuccess: + tray.hidePopup() + return + if tray.display.XGrabKeyboard(tray.popup, 0, GrabModeAsync, GrabModeAsync, CurrentTime) != GrabSuccess: + tray.hidePopup() + return + tray.paintPopup() + +proc showTooltip(tray: XembedTray) = + ## Displays the current tooltip after a quiet hover interval. + if tray.state.tooltip.len == 0: return + var root, child: xlib.Window + var rootX, rootY, windowX, windowY: cint + var mask: cuint + if tray.display.XQueryPointer(tray.root, root.addr, child.addr, rootX.addr, + rootY.addr, windowX.addr, windowY.addr, mask.addr) == 0: return + let width = tray.textWidth(tray.state.tooltip) + MenuPadding * 2 + tray.tooltipWindow = tray.floatingWindow(rootX.int + 12, rootY.int + 20, width, RowHeight) + +proc paintTooltip(tray: XembedTray) = + ## Paints tooltip text after the window becomes visible. + let gc = tray.display.XCreateGC(tray.tooltipWindow, 0, nil) + defer: tray.display.XFreeGC(gc) + discard tray.display.XSetForeground(gc, tray.popupPixel(0x202020)) + tray.display.Xutf8DrawString(tray.tooltipWindow, tray.fonts, gc, MenuPadding, + 18, tray.state.tooltip.cstring, tray.state.tooltip.len.cint) + +proc setActive*(tray: XembedTray, active: bool) = + ## Docks or removes this backend's icon without stopping host discovery. + if tray.display == nil: return + if (active and tray.available and tray.icon == 0) or (not active and tray.icon != 0): + withErrors(tray): + if active: tray.dock() + else: tray.removeIcon() + +proc update*(tray: XembedTray, state: TrayState) = + ## Replaces icon content and dismisses menus containing old callbacks. + tray.state = state + if tray.display == nil or tray.icon == 0: return + withErrors(tray): + tray.hidePopup() + tray.hideTooltip() + tray.display.Xutf8SetWMProperties(tray.icon, state.tooltip.cstring, + state.tooltip.cstring, nil, 0, nil, nil, nil) + tray.paintIcon() + +proc poll*(tray: XembedTray): seq[Callback] = + ## Reads only this connection's events; idle calls make no server round trips. + if tray.display == nil: return + let tooltipDue = tray.pointerInside and tray.popup == 0 and tray.tooltipWindow == 0 and + tray.state.tooltip.len > 0 and (getMonoTime() - tray.hoverStart).inMilliseconds >= TooltipDelayMs + if tray.display.XPending() == 0 and not tooltipDue: return + withErrors(tray): + for unused in 0..= 0: + let callback = tray.state.menuCallback((index + 1).int32) + tray.hidePopup() + if callback != nil: result.add callback + of xeMotion: + if window == tray.popup: + tray.hovered = tray.rowAt(event.motion.pos.x.int, event.motion.pos.y.int) + tray.paintPopup() + of xeKeyPress: + if window == tray.popup: + let key = XLookupKeysym(event.key.addr, 0) + if key == xk_Escape: tray.hidePopup() + elif key == xk_Return: + let callback = tray.state.menuCallback((tray.hovered + 1).int32) + tray.hidePopup() + if callback != nil: result.add callback + elif key in [xk_Up, xk_Down]: + let direction = if key == xk_Up: -1 else: 1 + if tray.hovered < 0 and direction < 0: tray.hovered = 0 + for unused in 0..".} diff --git a/src/windy/platforms/linux/x11.nim b/src/windy/platforms/linux/x11.nim index 867dede..b5445f6 100644 --- a/src/windy/platforms/linux/x11.nim +++ b/src/windy/platforms/linux/x11.nim @@ -3,10 +3,11 @@ import ../../[common, internal], opengl, vmath, pixie, x11/[glx, keysym, x, xevent, xlib, xcursor], - ./native/pixels + ./native/pixels, ./tray import ../../http export http +export tray except pollTrayEvents import ./backends when HasDirect: @@ -1619,6 +1620,7 @@ proc getClipboardImage*(): Image = nil proc pollEvents*() = + pollTrayEvents() for window in windows: if window.onFrame != nil: window.onFrame() diff --git a/src/windy/platforms/win32/platform.nim b/src/windy/platforms/win32/platform.nim index 16d1416..ab5c801 100644 --- a/src/windy/platforms/win32/platform.nim +++ b/src/windy/platforms/win32/platform.nim @@ -98,17 +98,6 @@ type style: LONG rect: RECT - TrayMenuEntryKind* = enum - TrayMenuOption, TrayMenuSeparator - - TrayMenuEntry* = object - case kind*: TrayMenuEntryKind - of TrayMenuOption: - text*: string - onClick*: Callback - of TrayMenuSeparator: - discard - HttpRequestState = object url, verb: string headers: seq[HttpHeader] diff --git a/tests/test_tray.nim b/tests/test_tray.nim new file mode 100644 index 0000000..a6c6f46 --- /dev/null +++ b/tests/test_tray.nim @@ -0,0 +1,482 @@ +when defined(linux) and not defined(emscripten): + import + std/[os, osproc, monotimes, streams, times, strutils, tables], + pixie, windy, + windy/platforms/linux/tray/sni/dbus, + windy/platforms/linux/tray/xembed/xlib + + type + Host = object + bus: Connection + item: string + registrations: int + attempts: int + replies: Table[uint32, Message] + available: bool + reject: bool + + const + Watcher = "org.kde.StatusNotifierWatcher" + Item = "org.kde.StatusNotifierItem" + Properties = "org.freedesktop.DBus.Properties" + Menu = "com.canonical.dbusmenu" + WaitSeconds = 15 + + proc requestName(c: Connection, name: cstring, flags: uint32, error: pointer): cint + {.cdecl, dynlib: "libdbus-1.so.3", importc: "dbus_bus_request_name".} + ## Owns the mock watcher's well-known name on the private test bus. + + proc reparent(d: Display, window, parent: xlib.Window, x, y: cint): cint + {.cdecl, dynlib: libX11, importc: "XReparentWindow".} + ## Embeds the test plug into the independent host window. + + proc children(d: Display, window: xlib.Window, root, parent: ptr xlib.Window, + windows: ptr ptr UncheckedArray[xlib.Window], count: ptr cuint): cint + {.cdecl, dynlib: libX11, importc: "XQueryTree".} + ## Enumerates windows to find the popup without inspecting implementation state. + + proc icon(): Image = + ## Includes transparency and unequal color channels to expose channel mistakes. + result = newImage(12, 8) + result.fill(rgba(200, 40, 80, 128)) + + proc app() = + ## Runs the real public API in another process, including callback replacement. + var done = false + var clicks = 0 + proc clicked() = + ## Replaces the icon from inside its own activation callback. + inc clicks + let green = newImage(10, 10) + green.fill(rgba(0, 255, 0, 255)) + showTrayIcon(green, "Updated", clicked, @[ + TrayMenuEntry(kind: TrayMenuOption, text: "Quit_now", onClick: proc() = + hideTrayIcon() + hideTrayIcon() + done = true + )]) + showTrayIcon(icon(), "Initial", clicked, @[ + TrayMenuEntry(kind: TrayMenuOption, text: "Café_one", onClick: clicked), + TrayMenuEntry(kind: TrayMenuSeparator), + TrayMenuEntry(kind: TrayMenuOption, text: "Last", onClick: clicked)]) + let deadline = getMonoTime() + initDuration(seconds = WaitSeconds) + while not done and getMonoTime() < deadline: + pollEvents() + sleep(1) + hideTrayIcon() + doAssert done, "The host did not finish the tray test" + doAssert clicks == 1 + + proc newHost(): Host = + ## Creates the watcher on an isolated session bus. + result.bus = busGetPrivate(0, nil) + doAssert result.bus != nil + connectionSetExitOnDisconnect(result.bus, 0) + doAssert requestName(result.bus, Watcher, 4, nil) == 1 + result.available = true + + proc close(host: var Host) = + ## Releases outstanding replies and ownership before restarting the watcher. + for message in host.replies.values: messageUnref(message) + host.replies.clear() + connectionClose(host.bus) + connectionUnref(host.bus) + host.bus = nil + + proc pump(host: var Host) = + ## Answers the same watcher methods that a desktop host exposes. + doAssert connectionReadWrite(host.bus, 0) != 0 + for unused in 0..<128: + let message = connectionPopMessage(host.bus) + if message == nil: break + let kind = messageGetType(message) + if kind in [2.cint, 3.cint]: + host.replies[messageGetReplySerial(message)] = message + continue + if kind == 1: + let member = $messageGetMember(message) + let reply = requireMessage(messageNewMethodReturn(message)) + var args = reply.writer() + if member == "Get": + container(args, 'v', "b", value): value.addBool host.available + elif member == "RegisterStatusNotifierItem": + inc host.attempts + if host.reject: + messageUnref(reply) + discard host.bus.send(messageNewError(message, + "org.freedesktop.DBus.Error.Failed", "Rejected by test watcher")) + messageUnref(message) + continue + var input = message.reader() + doAssert input.stringValue() == "/StatusNotifierItem" + host.item = $messageGetSender(message) + inc host.registrations + discard host.bus.send(reply) + messageUnref(message) + + proc hostSignal(host: var Host, member: string) = + ## Announces a host change through the watcher's normal signal interface. + discard host.bus.send(messageNewSignal("/StatusNotifierWatcher", Watcher, member.cstring)) + + proc settle(host: var Host) = + ## Gives both processes time to handle a negative discovery result. + let deadline = getMonoTime() + initDuration(milliseconds = 150) + while getMonoTime() < deadline: + host.pump() + sleep(1) + + proc waitRegistration(host: var Host) = + ## Waits for actual client registration, with a bounded test deadline. + let deadline = getMonoTime() + initDuration(seconds = 5) + while host.item.len == 0 and getMonoTime() < deadline: + host.pump() + sleep(1) + doAssert host.item.len > 0, "No SNI registration received" + + proc call(host: var Host, path, iface, member: string): Message = + ## Allocates a call addressed by the client's unique name, as real hosts do. + requireMessage(messageNewMethodCall(host.item.cstring, path.cstring, iface.cstring, member.cstring)) + + proc response(host: var Host, call: Message, expected = 2): Message = + ## Waits for one reply while continuing to service watcher requests. + let serial = host.bus.send(call) + let deadline = getMonoTime() + initDuration(seconds = 5) + while serial notin host.replies and getMonoTime() < deadline: + host.pump() + sleep(1) + doAssert serial in host.replies, "Missing D-Bus reply" + result = host.replies[serial] + host.replies.del(serial) + doAssert messageGetType(result) == expected.cint + + proc property(host: var Host, iface, name: string, menu = false): Message = + ## Reads a property through the public D-Bus interface. + let call = host.call(if menu: "/Menu" else: "/StatusNotifierItem", Properties, "Get") + var args = call.writer() + args.addString iface + args.addString name + host.response(call) + + proc propertyText(host: var Host, name: string, menu = false): string = + ## Decodes a text property from its variant. + let reply = host.property(if menu: Menu else: Item, name, menu) + defer: messageUnref(reply) + var args = reply.reader() + var value = args.recurse() + value.stringValue() + + proc testSni() = + ## Verifies real bus messages, late hosts, restart, updates, and callback cleanup. + doAssert loadDbus() + let previousDisplay = getEnv("DISPLAY") + defer: putEnv("DISPLAY", previousDisplay) + putEnv("DISPLAY", "") + let child = startProcess(getAppFilename(), args = ["--app"], options = {poParentStreams}) + defer: child.close() + sleep(100) + var host = newHost() + defer: host.close() + host.reject = true + let attemptDeadline = getMonoTime() + initDuration(seconds = 5) + while host.attempts == 0 and getMonoTime() < attemptDeadline: + host.pump() + sleep(1) + host.settle() + doAssert host.attempts == 1, "A failed registration was retried without a host change" + doAssert host.registrations == 0 + host.reject = false + host.hostSignal("StatusNotifierHostRegistered") + host.waitRegistration() + doAssert host.propertyText("Title") == "Initial" + doAssert host.propertyText("Status", true) == "normal" + let all = host.call("/StatusNotifierItem", Properties, "GetAll") + var allArgs = all.writer() + allArgs.addString Item + let allReply = host.response(all) + doAssert $messageGetSignature(allReply) == "a{sv}" + messageUnref(allReply) + let imageReply = host.property(Item, "IconPixmap") + var args = imageReply.reader() + var value = args.recurse() + var images = value.recurse() + var image = images.recurse() + doAssert image.intValue() == 12 + image.next() + doAssert image.intValue() == 8 + image.next() + var bytes = image.recurse() + let c = icon().data[0].rgba() + for expected in [c.a, c.r, c.g, c.b]: + var actual: uint8 + doAssert messageIterGetArgType(bytes.addr) == 'y'.cint + messageIterGetBasic(bytes.addr, actual.addr) + doAssert actual == expected + bytes.next() + messageUnref(imageReply) + let bad = host.call("/StatusNotifierItem", Item, "Activate") + messageUnref(host.response(bad, 3)) + let introspect = host.call("/Menu", "org.freedesktop.DBus.Introspectable", "Introspect") + let description = host.response(introspect) + args = description.reader() + doAssert "GetLayout" in args.stringValue() + messageUnref(description) + let layoutCall = host.call("/Menu", Menu, "GetLayout") + var layoutArgs = layoutCall.writer() + layoutArgs.addInt 0 + layoutArgs.addInt -1 + container(layoutArgs, 'a', "s", names): discard + let layoutReply = host.response(layoutCall) + doAssert $messageGetSignature(layoutReply) == "u(ia{sv}av)" + messageUnref(layoutReply) + let labelCall = host.call("/Menu", Menu, "GetProperty") + var labelArgs = labelCall.writer() + labelArgs.addInt 1 + labelArgs.addString "label" + let labelReply = host.response(labelCall) + args = labelReply.reader() + value = args.recurse() + doAssert value.stringValue() == "Café__one" + messageUnref(labelReply) + host.item = "" + host.available = false + host.hostSignal("StatusNotifierHostUnregistered") + host.settle() + let registrations = host.registrations + host.available = true + host.hostSignal("StatusNotifierHostRegistered") + host.waitRegistration() + doAssert host.registrations == registrations + 1 + host.close() + host = newHost() + host.waitRegistration() + doAssert host.propertyText("Title") == "Initial" + let activate = host.call("/StatusNotifierItem", Item, "Activate") + var activationArgs = activate.writer() + activationArgs.addInt 0 + activationArgs.addInt 0 + messageUnref(host.response(activate)) + doAssert host.propertyText("Title") == "Updated" + let stale = host.call("/Menu", Menu, "Event") + var staleArgs = stale.writer() + staleArgs.addInt 1 + staleArgs.addString "clicked" + container(staleArgs, 'v', "i", data): data.addInt 0 + staleArgs.addUint 0 + messageUnref(host.response(stale)) + doAssert child.running(), "A stale menu event invoked a replacement action" + let quit = host.call("/Menu", Menu, "Event") + var quitArgs = quit.writer() + quitArgs.addInt 4 + quitArgs.addString "clicked" + container(quitArgs, 'v', "i", data): data.addInt 0 + quitArgs.addUint 0 + messageUnref(host.response(quit)) + doAssert child.waitForExit(5000) == 0 + + proc sendButton(d: Display, window: xlib.Window, button: cuint, release = true, + x = 10, y = 10) = + ## Sends input through the server to the client's selected event mask. + var event: XEvent + event.button = XButtonEvent(kind: if release: xeButtonRelease else: xeButtonPress, + display: d, window: window, root: d.defaultRootWindow(), + pos: ivec2(x.int32, y.int32), rootPos: ivec2(40, 40), button: button, same_screen: 1) + d.XSendEvent(window, false, if release: ButtonReleaseMask else: ButtonPressMask, event.addr) + d.XFlush() + + proc sendKey(d: Display, window: xlib.Window, keysym: KeySym) = + ## Sends a keyboard command to the popup through the independent connection. + proc keycode(d: Display, symbol: KeySym): uint8 + {.cdecl, importc: "XKeysymToKeycode", dynlib: libX11.} + var event: XEvent + event.key = XKeyEvent(kind: xeKeyPress, display: d, window: window, + root: d.defaultRootWindow(), keycode: keycode(d, keysym).cuint, same_screen: 1) + d.XSendEvent(window, false, KeyPressMask, event.addr) + d.XFlush() + + proc testXembed(withSni = false) = + ## Acts as a tray manager on Xvfb, validating windows, pixels, menus, and restart. + let previousBus = getEnv("DBUS_SESSION_BUS_ADDRESS") + defer: putEnv("DBUS_SESSION_BUS_ADDRESS", previousBus) + if not withSni: + putEnv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent-windy-tray-test") + let d = XOpenDisplay(nil) + doAssert d != nil + defer: discard d.XCloseDisplay() + let root = d.defaultRootWindow() + let selection = d.XInternAtom(("_NET_SYSTEM_TRAY_S" & $d.defaultScreen()).cstring, 0) + let managerAtom = d.XInternAtom("MANAGER", 0) + let opcode = d.XInternAtom("_NET_SYSTEM_TRAY_OPCODE", 0) + var manager: xlib.Window + var plug: xlib.Window + proc createManager(useAlpha = false) = + ## Publishes a manager and announces its appearance after the client starts. + manager = d.XCreateSimpleWindow(root, 0, 0, 100, 60, 0, 0, 0xffffff) + if useAlpha: + var info: XVisualInfo + d.XMatchVisualInfo(d.defaultScreen(), 32, 4, info.addr) + doAssert info.visual != nil, "Xvfb must expose an ARGB visual" + d.XChangeProperty(manager, d.XInternAtom("_NET_SYSTEM_TRAY_VISUAL", 0), + d.XInternAtom("VISUALID", 0), 32, pmReplace, info.visualid.addr, 1) + d.XSetSelectionOwner(selection, manager) + d.XMapWindow(manager) + var event: XEvent + event.client = XClientMessageEvent(kind: xeClientMessage, window: root, + message_type: managerAtom, format: 32) + event.client.data.l[1] = selection.clong + event.client.data.l[2] = manager.clong + d.XSendEvent(root, false, StructureNotifyMask, event.addr) + d.XSync() + proc waitDock() = + ## Handles a real docking request and embeds the advertised plug. + let deadline = getMonoTime() + initDuration(seconds = 5) + plug = 0 + while plug == 0 and getMonoTime() < deadline: + while d.XPending() > 0: + var event: XEvent + d.XNextEvent(event.addr) + if event.kind == xeClientMessage and event.client.message_type == opcode: + doAssert event.client.data.l[1] == 0 + plug = event.client.data.l[2].culong + discard d.reparent(plug, manager, 0, 0) + d.XResizeWindow(plug, 32, 24) + d.XMapWindow(plug) + var embedded: XEvent + embedded.client = XClientMessageEvent(kind: xeClientMessage, window: plug, + message_type: d.XInternAtom("_XEMBED", 0), format: 32) + embedded.client.data.l[3] = manager.clong + d.XSendEvent(plug, false, NoEventMask, embedded.addr) + d.XSync() + sleep(1) + doAssert plug != 0, "No XEmbed docking request received" + proc pixel(): culong = + ## Reads the rendered center pixel without inspecting backend state. + let image = d.XGetImage(plug, 16, 12, 1, 1, high(culong), 2) + doAssert image != nil + result = XGetPixel(image, 0, 0) + discard XDestroyImage(image) + var host: Host + if withSni: + host = newHost() + createManager() + let child = startProcess(getAppFilename(), args = ["--app"], options = {poParentStreams}) + defer: child.close() + sleep(100) + if withSni: + host.waitRegistration() + doAssert host.propertyText("Title") == "Initial" + while d.XPending() > 0: + var event: XEvent + d.XNextEvent(event.addr) + doAssert event.kind != xeClientMessage or event.client.message_type != opcode, + "Both tray backends registered an icon" + host.close() + else: + createManager() + waitDock() + let firstDeadline = getMonoTime() + initDuration(seconds = 5) + while pixel() != 0xe393a7 and getMonoTime() < firstDeadline: sleep(1) + doAssert pixel() == 0xe393a7, "Transparent icon did not blend with the tray background" + d.XDestroyWindow(manager) + d.XSync() + sleep(100) + createManager(true) + waitDock() + let alphaDeadline = getMonoTime() + initDuration(seconds = 5) + while pixel() != 0x80641428.culong and getMonoTime() < alphaDeadline: sleep(1) + doAssert pixel() == 0x80641428.culong, "ARGB visual did not preserve premultiplied alpha" + sendButton(d, plug, 1) + let deadline = getMonoTime() + initDuration(seconds = 5) + while (pixel() and 0xffffff) != 0x00ff00 and getMonoTime() < deadline: sleep(1) + doAssert (pixel() and 0xffffff) == 0x00ff00, "Updated icon was not painted" + sendButton(d, plug, 3) + var popup: xlib.Window + while popup == 0 and getMonoTime() < deadline: + var rootOut, parent: xlib.Window + var windows: ptr UncheckedArray[xlib.Window] + var count: cuint + discard d.children(root, rootOut.addr, parent.addr, windows.addr, count.addr) + for index in 0.. 0 and displayNumber.allCharsInSet({'0'..'9'}) + putEnv("DISPLAY", ":" & displayNumber) + testPending() + testSni() + when HasX11: + testXembed() + testXembed(true) + + if paramCount() > 0 and paramStr(1) == "--app": + app() + elif paramCount() > 0 and paramStr(1) == "--isolated": + suite() + else: + # Never acquire tray ownership or create windows on the user's session. + let isolated = startProcess("dbus-run-session", args = ["--", getAppFilename(), + "--isolated"], options = {poUsePath, poParentStreams}) + defer: isolated.close() + doAssert isolated.waitForExit(90000) == 0