Skip to content
Closed
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
73 changes: 58 additions & 15 deletions Ice/MenuBar/MenuBarItems/MenuBarItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,14 @@ struct MenuBarItem: CustomStringConvertible {
/// Only call it if you are certain the window is a valid menu bar item
/// and the source pid belongs to the application that created it.
@available(macOS 26.0, *)
private init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) {
self.tag = MenuBarItemTag(uncheckedItemWindow: itemWindow, sourcePID: sourcePID)
private init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?, titleOverride: String? = nil) {
let effectiveTitle = titleOverride ?? itemWindow.title
self.tag = MenuBarItemTag(uncheckedItemWindow: itemWindow, sourcePID: sourcePID, titleOverride: effectiveTitle)
self.windowID = itemWindow.windowID
self.ownerPID = itemWindow.ownerPID
self.sourcePID = sourcePID
self.bounds = itemWindow.bounds
self.title = itemWindow.title
self.title = effectiveTitle
self.isOnScreen = itemWindow.isOnScreen
}
}
Expand Down Expand Up @@ -243,20 +244,38 @@ extension MenuBarItem {
/// Creates and returns a list of menu bar items using experimental
/// source pid retrieval for macOS 26.
@available(macOS 26.0, *)
private static func getMenuBarItemsExperimental(on display: CGDirectDisplayID?, option: ListOption) async -> [MenuBarItem] {
private static func getMenuBarItemsExperimental(
windows: [WindowInfo],
controlItemMap: [CGWindowID: ControlItem.Identifier] = [:]
) async -> [MenuBarItem] {
let icePID = ProcessInfo.processInfo.processIdentifier

var items = [MenuBarItem]()
for window in getMenuBarItemWindows(on: display, option: option) {
let sourcePID = await MenuBarItemService.Connection.shared.sourcePID(for: window)
let item = MenuBarItem(uncheckedItemWindow: window, sourcePID: sourcePID)
items.append(item)
for window in windows {
// On Tahoe, all menu bar items appear owned by Control Center
// and have nil titles. Identify Ice's own control items by
// matching their known window IDs and restore their titles.
// For all other items, resolve the source PID via XPC.
if let identifier = controlItemMap[window.windowID] {
let item = MenuBarItem(
uncheckedItemWindow: window,
sourcePID: icePID,
titleOverride: identifier.rawValue
)
items.append(item)
} else {
let sourcePID = await MenuBarItemService.Connection.shared.sourcePID(for: window)
let item = MenuBarItem(uncheckedItemWindow: window, sourcePID: sourcePID)
items.append(item)
}
}
return items
}

/// Creates and returns a list of menu bar items, defaulting to the
/// legacy source pid behavior, prior to macOS 26.
private static func getMenuBarItemsLegacyMethod(on display: CGDirectDisplayID?, option: ListOption) -> [MenuBarItem] {
getMenuBarItemWindows(on: display, option: option).map { window in
private static func getMenuBarItemsLegacyMethod(windows: [WindowInfo]) -> [MenuBarItem] {
windows.map { window in
MenuBarItem(uncheckedItemWindow: window)
}
}
Expand All @@ -268,11 +287,35 @@ extension MenuBarItem {
/// items across all available displays.
/// - option: Options that filter the returned list. Pass an empty option set
/// to return all available menu bar items.
static func getMenuBarItems(on display: CGDirectDisplayID? = nil, option: ListOption) async -> [MenuBarItem] {
/// - controlItemMap: A mapping of window IDs to control item identifiers,
/// used to identify Ice's own items on Tahoe.
static func getMenuBarItems(
on display: CGDirectDisplayID? = nil,
option: ListOption,
controlItemMap: [CGWindowID: ControlItem.Identifier] = [:]
) async -> [MenuBarItem] {
let windows = getMenuBarItemWindows(on: display, option: option)
if #available(macOS 26.0, *) {
await getMenuBarItemsExperimental(on: display, option: option)
return await getMenuBarItemsExperimental(windows: windows, controlItemMap: controlItemMap)
} else {
getMenuBarItemsLegacyMethod(on: display, option: option)
return getMenuBarItemsLegacyMethod(windows: windows)
}
}

/// Creates and returns a list of menu bar items from pre-fetched windows.
///
/// - Parameters:
/// - windows: Pre-fetched menu bar item windows.
/// - controlItemMap: A mapping of window IDs to control item identifiers,
/// used to identify Ice's own items on Tahoe.
static func getMenuBarItems(
windows: [WindowInfo],
controlItemMap: [CGWindowID: ControlItem.Identifier] = [:]
) async -> [MenuBarItem] {
if #available(macOS 26.0, *) {
return await getMenuBarItemsExperimental(windows: windows, controlItemMap: controlItemMap)
} else {
return getMenuBarItemsLegacyMethod(windows: windows)
}
}
}
Expand Down Expand Up @@ -321,9 +364,9 @@ private extension MenuBarItemTag {
/// Only call it if you are certain the window is a valid menu bar item
/// and the source pid belongs to the application that created it.
@available(macOS 26.0, *)
init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) {
init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?, titleOverride: String? = nil) {
self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID)
self.title = itemWindow.title ?? ""
self.title = titleOverride ?? itemWindow.title ?? ""
}
}

Expand Down
45 changes: 44 additions & 1 deletion Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,50 @@ extension MenuBarItemManager {
}

let displayID = Bridging.getActiveMenuBarDisplayID()
var items = await MenuBarItem.getMenuBarItems(option: .activeSpace)

// Build a mapping of control item window IDs to their identifiers
// so the item enumeration can identify Ice's own items on Tahoe
// (where all items appear owned by Control Center with nil titles).
//
// On Tahoe, NSWindow.windowNumber returns 64-bit IDs that don't match
// the 32-bit CGWindowIDs used by CGWindowListCopyWindowInfo.
// We match by converting NSWindow frames (Cocoa bottom-left origin)
// to CG screen coordinates (top-left origin) and comparing the full
// rect against the CGWindowList bounds.
let menuBarItemWindows = MenuBarItem.getMenuBarItemWindows(option: .activeSpace)

var controlItemMap = [CGWindowID: ControlItem.Identifier]()
if let appState {
// Build a list of control item bounds in CG screen coordinates.
var controlItemBounds = [(CGRect, ControlItem.Identifier)]()
for section in appState.menuBarManager.sections {
let ci = section.controlItem
if let frame = ci.window?.frame, let screen = ci.screen {
let cgRect = CGRect(
x: frame.origin.x,
y: screen.frame.height - frame.origin.y - frame.height,
width: frame.width,
height: frame.height
)
controlItemBounds.append((cgRect, ci.identifier))
}
}

// Match menu bar windows by their full bounds rect.
for window in menuBarItemWindows {
for (bounds, identifier) in controlItemBounds {
if window.bounds == bounds {
controlItemMap[window.windowID] = identifier
break
}
}
}
}

var items = await MenuBarItem.getMenuBarItems(
windows: menuBarItemWindows,
controlItemMap: controlItemMap
)

let itemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID }
await cacheActor.updateCachedItemWindowIDs(itemWindowIDs)
Expand Down
21 changes: 15 additions & 6 deletions Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,20 @@ extension MenuBarItemService {
if let session {
return session
}
let session = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in
guard let self else {
return
}
// Don't mutate `self.session` from the cancellation handler:
// it runs on the session's target queue, not under the
// storage lock, which races with `send` and trips
// `libdispatch: Resurrection of an object`. Detection of a
// dead session happens via `sendSync` failure in `send` below.
let session = try XPCSession(xpcService: name, options: .inactive) { [logger] error in
logger.warning("Session was cancelled with error \(error.localizedDescription)")
self.session = nil
}
session.setPeerRequirement(.isFromSameTeam())
// `.isFromSameTeam()` silently rejects all replies when the
// peer is ad-hoc signed (no team ID to compare against), so
// only apply the requirement when we have a team ID.
if currentProcessTeamID() != nil {
session.setPeerRequirement(.isFromSameTeam())
}
session.setTargetQueue(queue)
try session.activate()
self.session = session
Expand All @@ -125,6 +131,9 @@ extension MenuBarItemService {
return try reply.decode(as: Response.self)
} catch {
logger.error("Session failed with error \(error)")
if let dead = self.session.take() {
dead.cancel(reason: "Send failed: \(error.localizedDescription)")
}
return nil
}
}
Expand Down
10 changes: 10 additions & 0 deletions Ice/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct SettingsView: View {
sidebar
} detail: {
detailView
.id(navigationState.settingsNavigationIdentifier)
}
.navigationTitle(navigationTitle)
}
Expand Down Expand Up @@ -103,6 +104,15 @@ struct SettingsView: View {
}
.frame(height: sidebarItemHeight)
.tag(identifier)
// On macOS Tahoe, NavigationSplitView's List selection binding
// sometimes fails to propagate on single click (sidebar highlight
// moves, but the bound value stays stale until a second click).
// Mirror the selection into navigationState explicitly so the
// detail pane and title always reflect the visible selection.
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
navigationState.settingsNavigationIdentifier = identifier
})
}

@ToolbarContentBuilder
Expand Down
16 changes: 12 additions & 4 deletions MenuBarItemService/Listener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,20 @@ final class Listener {

Logger.default.debug("Activating listener")

do {
if #available(macOS 26.0, *) {
// `.isFromSameTeam()` silently drops every check-in when the
// service is ad-hoc signed (no team ID), with no way to detect
// the rejection from the listener side. Only enforce the same-team
// requirement when we actually have a team ID.
if #available(macOS 26.0, *), currentProcessTeamID() != nil {
do {
try uncheckedActivateWithSameTeamRequirement()
} else {
try uncheckedActivate()
return
} catch {
Logger.default.warning("Failed to activate with same-team requirement (\(error)), falling back to no requirement")
}
}
do {
try uncheckedActivate()
} catch {
Logger.default.error("Failed to activate listener with error \(error)")
}
Expand Down
35 changes: 35 additions & 0 deletions Shared/Utilities/CodeSigningHelpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// CodeSigningHelpers.swift
// Shared
//

import Foundation
import Security

/// Returns the team identifier of the currently running process,
/// or `nil` if the process is ad-hoc signed (or unsigned).
///
/// Used to gate XPC peer-requirement checks: `.isFromSameTeam()`
/// silently rejects every connection when both peers are ad-hoc
/// signed (no team identifier to compare), so callers must skip
/// the requirement entirely on ad-hoc builds.
func currentProcessTeamID() -> String? {
var code: SecCode?
guard SecCodeCopySelf([], &code) == errSecSuccess, let code else {
return nil
}
var info: CFDictionary?
let status = SecCodeCopySigningInformation(
code as! SecStaticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&info
)
guard status == errSecSuccess,
let dict = info as? [String: Any],
let teamID = dict[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty
else {
return nil
}
return teamID
}