Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion platform/linux/runtime/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
141 changes: 130 additions & 11 deletions platform/macos/Sources/RivetSystem/SystemServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
Loading