diff --git a/platform/linux/runtime/backend.cpp b/platform/linux/runtime/backend.cpp index 4751e7e..76a660c 100644 --- a/platform/linux/runtime/backend.cpp +++ b/platform/linux/runtime/backend.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -67,13 +68,30 @@ 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 + int const rc = ::socketpair(AF_UNIX, SOCK_STREAM, 0, fds); +#endif + if (rc != 0) { throw std::runtime_error("socketpair failed: " + std::string(std::strerror(errno))); } UniqueFd native(fds[0]); UniqueFd server_read(fds[1]); +#if !defined(SOCK_CLOEXEC) + // Platforms without SOCK_CLOEXEC (macOS): set the flag on both ends right + // after the pair exists. Own the descriptors first so an fcntl failure + // cannot leak either endpoint while the exception unwinds. + for (int const fd : {native.get(), server_read.get()}) { + int const flags = ::fcntl(fd, F_GETFD, 0); + if (flags < 0 || ::fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != 0) { + throw std::runtime_error("fcntl(FD_CLOEXEC) failed: " + + std::string(std::strerror(errno))); + } + } +#endif UniqueFd server_write(::dup(server_read.get())); if (server_write.get() < 0) { throw std::runtime_error("dup failed: " + diff --git a/platform/macos/Sources/RivetSystem/SystemServices.swift b/platform/macos/Sources/RivetSystem/SystemServices.swift index 4c32106..65a3c04 100644 --- a/platform/macos/Sources/RivetSystem/SystemServices.swift +++ b/platform/macos/Sources/RivetSystem/SystemServices.swift @@ -140,36 +140,154 @@ 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 menu: NSMenu? private var actions: [String: () -> Void] = [:] + private var currentItems: [RivetMenuItem] = [] + private var currentToolTip: String? + // AppKit does not send the button action while NSStatusItem.menu is set. + // Click-action mode therefore detaches (but retains) the menu. + 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 + } + 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 + } + 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 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 + } + + /// Switch between click-action and menu modes. AppKit does not dispatch a + /// status-button action while a menu is attached, so a non-nil handler + /// temporarily hides the menu. Pass nil to restore menu-at-click. + public func setClickAction(_ handler: (() -> Void)?) { + clickHandler = handler + guard let item, let button = item.button else { return } + if handler != nil { + item.menu = nil + button.target = self + button.action = #selector(handleClick(_:)) + } else { + button.target = nil + button.action = nil + item.menu = menu } - statusItem.menu = menu - item = statusItem } public func remove() { if let item { NSStatusBar.system.removeStatusItem(item) } item = nil + menu = nil + actions.removeAll() + currentItems = [] + currentToolTip = nil + clickHandler = nil + } + + /// Test hook: the live NSMenu backing the status item. + var menuForTesting: NSMenu? { item?.menu } + var retainedMenuForTesting: NSMenu? { menu } + var buttonForTesting: NSStatusBarButton? { item?.button } + + // 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()) + } + } + self.menu = menu + item?.menu = clickHandler == nil ? menu : nil + 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..d9ac194 --- /dev/null +++ b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift @@ -0,0 +1,78 @@ +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") +} + +@MainActor +@Test func menuBarClickActionDetachesAndRestoresMenu() { + let controller = RivetMenuBarController() + var clickCount = 0 + controller.install(title: "Test", items: [ + .action(label: "Quit", identifier: "quit", handler: {}), + ]) + defer { controller.remove() } + + let installedMenu = controller.menuForTesting + #expect(installedMenu != nil) + + controller.setClickAction { clickCount += 1 } + #expect(controller.menuForTesting == nil) + #expect(controller.retainedMenuForTesting === installedMenu) + + if let button = controller.buttonForTesting, let action = button.action { + _ = button.target?.perform(action, with: button) + } + #expect(clickCount == 1) + + controller.setClickAction(nil) + #expect(controller.menuForTesting === installedMenu) + #expect(controller.buttonForTesting?.action == nil) +}