diff --git a/platform/linux/runtime/backend.cpp b/platform/linux/runtime/backend.cpp index 4751e7e..48b4440 100644 --- a/platform/linux/runtime/backend.cpp +++ b/platform/linux/runtime/backend.cpp @@ -67,7 +67,20 @@ struct SocketEndpoints { SocketEndpoints create_socket_endpoints() { int fds[2]{-1, -1}; - if (::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) { +#if defined(SOCK_CLOEXEC) + int const rc = ::socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds); +#else + // Platforms without SOCK_CLOEXEC (macOS): set the flag on both ends right + // after the pair exists; this thread does no I/O in between. + int rc = ::socketpair(AF_UNIX, SOCK_STREAM, 0, fds); + if (rc == 0) { + if (::fcntl(fds[0], F_SETFD, FD_CLOEXEC) != 0 || + ::fcntl(fds[1], F_SETFD, FD_CLOEXEC) != 0) { + rc = -1; + } + } +#endif + if (rc != 0) { throw std::runtime_error("socketpair failed: " + std::string(std::strerror(errno))); } diff --git a/platform/macos/Sources/RivetSystem/SystemServices.swift b/platform/macos/Sources/RivetSystem/SystemServices.swift index 4c32106..b97e612 100644 --- a/platform/macos/Sources/RivetSystem/SystemServices.swift +++ b/platform/macos/Sources/RivetSystem/SystemServices.swift @@ -140,36 +140,155 @@ public enum RivetLoginItem { } } +/// One entry of a status-bar menu. `.separator` renders an `NSMenuItem.separator()` +/// and never carries an action; `.action` entries dispatch by `identifier`. +public enum RivetMenuItem { + case action(label: String, identifier: String, handler: () -> Void) + case separator +} + @MainActor public final class RivetMenuBarController: NSObject { private var item: NSStatusItem? private var actions: [String: () -> Void] = [:] + private var currentItems: [RivetMenuItem] = [] + private var currentTitle: String = "" + private var currentIcon: NSImage? + private var currentToolTip: String? + // When set, a plain click fires this handler instead of opening the menu; + // the menu stays reachable through click-and-hold (NSStatusItem behavior). + private var clickHandler: (() -> Void)? public func install(title: String, menuItems: [(String, String, () -> Void)]) { - let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - statusItem.button?.title = title - let menu = NSMenu() - for (label, identifier, action) in menuItems { - actions[identifier] = action - let entry = NSMenuItem(title: label, action: #selector(invoke(_:)), keyEquivalent: "") - entry.representedObject = identifier - entry.target = self - menu.addItem(entry) + install( + title: title, + items: menuItems.map { + .action(label: $0.0, identifier: $0.1, handler: $0.2) + }) + } + + public func install(title: String, items: [RivetMenuItem]) { + installStatusItem { button in + button.title = title + } + currentTitle = title + currentIcon = nil + rebuild(items: items) + } + + /// Template image in place of a text title; rendered as a template so it + /// follows the menu bar's light/dark appearance. + public func install(icon: NSImage, items: [RivetMenuItem]) { + icon.isTemplate = true + installStatusItem { button in + button.image = icon + } + currentTitle = "" + currentIcon = icon + rebuild(items: items) + } + + /// Replace every menu entry in place (labels, handlers, separators), + /// keeping the status item, title/icon, and tooltip. + public func update(items: [RivetMenuItem]) { + rebuild(items: items) + } + + /// Swap one entry's label without rebuilding the whole menu. + public func setItem(_ identifier: String, label: String) { + guard let menu = item?.menu else { return } + for entry in menu.items + where entry.representedObject as? String == identifier { + entry.title = label + } + currentItems = currentItems.map { current in + guard case let .action(_, currentIdentifier, handler) = current, + currentIdentifier == identifier + else { return current } + return .action(label: label, identifier: currentIdentifier, handler: handler) + } + } + + public func setToolTip(_ text: String?) { + currentToolTip = text + item?.button?.toolTip = text + } + + /// Fire this handler on a plain click instead of opening the menu. The + /// menu remains available through click-and-hold. Pass nil to restore + /// menu-at-click. + public func setClickAction(_ handler: (() -> Void)?) { + clickHandler = handler + guard let button = item?.button else { return } + if handler != nil { + button.target = self + button.action = #selector(handleClick(_:)) + } else { + button.target = nil + button.action = nil } - statusItem.menu = menu - item = statusItem } public func remove() { if let item { NSStatusBar.system.removeStatusItem(item) } item = nil actions.removeAll() + currentItems = [] + currentTitle = "" + currentIcon = nil + currentToolTip = nil + clickHandler = nil + } + + /// Test hook: the live NSMenu backing the status item. + var menuForTesting: NSMenu? { item?.menu } + + // MARK: internals + + private func installStatusItem(_ configure: (NSStatusBarButton) -> Void) { + if let existing = item { + NSStatusBar.system.removeStatusItem(existing) + } + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + configure(button) + button.toolTip = currentToolTip + if clickHandler != nil { + button.action = #selector(handleClick(_:)) + button.target = self + } + } + item = statusItem + } + + private func rebuild(items: [RivetMenuItem]) { + let menu = NSMenu() + actions.removeAll() + for entry in items { + switch entry { + case let .action(label, identifier, action): + actions[identifier] = action + let item = NSMenuItem( + title: label, action: #selector(invoke(_:)), keyEquivalent: "") + item.representedObject = identifier + item.target = self + menu.addItem(item) + case .separator: + menu.addItem(NSMenuItem.separator()) + } + } + item?.menu = menu + currentItems = items } @objc private func invoke(_ sender: NSMenuItem) { guard let identifier = sender.representedObject as? String else { return } actions[identifier]?() } + + @objc private func handleClick(_ sender: NSStatusBarButton) { + clickHandler?() + } } public final class RivetActivationRouter { diff --git a/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift new file mode 100644 index 0000000..088bdcd --- /dev/null +++ b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift @@ -0,0 +1,52 @@ +import AppKit +import Testing +@testable import RivetSystem + +@MainActor +@Test func menuBarMenuContainsSeparatorsAndActions() { + let controller = RivetMenuBarController() + var fired = false + controller.install( + title: "Test", + items: [ + .action(label: "Show", identifier: "show", handler: {}), + .separator, + .action(label: "Quit", identifier: "quit", handler: { fired = true }), + ]) + defer { controller.remove() } + + let menu = controller.menuForTesting! + #expect(menu.numberOfItems == 3) + #expect(menu.item(at: 0)?.title == "Show") + #expect(menu.item(at: 1)?.isSeparatorItem == true) + #expect(menu.item(at: 2)?.title == "Quit") + + // Action dispatch flows through the represented-object identifier. + if let quit = menu.item(at: 2) { + _ = quit.target?.perform(Selector(("invoke:")), with: quit) + } + #expect(fired) +} + +@MainActor +@Test func menuBarUpdateReplacesEntriesAndSetItemSwapsOneLabel() { + let controller = RivetMenuBarController() + controller.install(title: "Test", items: [ + .action(label: "Pause reminders", identifier: "toggle", handler: {}), + ]) + defer { controller.remove() } + + controller.update(items: [ + .action(label: "Resume reminders", identifier: "toggle", handler: {}), + .separator, + .action(label: "Quit", identifier: "quit", handler: {}), + ]) + let menu = controller.menuForTesting! + #expect(menu.numberOfItems == 3) + #expect(menu.item(at: 0)?.title == "Resume reminders") + + controller.setItem("toggle", label: "Pause again") + #expect(menu.item(at: 0)?.title == "Pause again") + // Untouched entries keep their labels. + #expect(menu.item(at: 2)?.title == "Quit") +}