Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
229 lines
8.4 KiB
Swift
229 lines
8.4 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|
static let shared = MenuContextCardInjector()
|
|
|
|
private let tag = 9_415_227
|
|
private let fallbackCardWidth: CGFloat = 320
|
|
private var lastKnownMenuWidth: CGFloat?
|
|
private weak var originalDelegate: NSMenuDelegate?
|
|
private var loadTask: Task<Void, Never>?
|
|
private var warmTask: Task<Void, Never>?
|
|
private var cachedRows: [SessionRow] = []
|
|
private var cacheErrorText: String?
|
|
private var cacheUpdatedAt: Date?
|
|
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
|
private let refreshIntervalSeconds: TimeInterval = 15
|
|
private var isMenuOpen = false
|
|
|
|
func install(into statusItem: NSStatusItem) {
|
|
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
|
guard let menu = statusItem.menu else { return }
|
|
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
|
|
if menu.delegate !== self {
|
|
self.originalDelegate = menu.delegate
|
|
menu.delegate = self
|
|
}
|
|
|
|
if self.warmTask == nil {
|
|
self.warmTask = Task { await self.refreshCache(force: true) }
|
|
}
|
|
}
|
|
|
|
func menuWillOpen(_ menu: NSMenu) {
|
|
self.originalDelegate?.menuWillOpen?(menu)
|
|
self.isMenuOpen = true
|
|
|
|
// Remove any previous injected card items.
|
|
for item in menu.items where item.tag == self.tag {
|
|
menu.removeItem(item)
|
|
}
|
|
|
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
|
|
|
self.loadTask?.cancel()
|
|
|
|
let initialRows = self.cachedRows
|
|
let initialIsLoading = initialRows.isEmpty
|
|
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
|
let initialWidth = self.initialCardWidth(for: menu)
|
|
|
|
let initial = AnyView(ContextMenuCardView(
|
|
rows: initialRows,
|
|
statusText: initialStatusText,
|
|
isLoading: initialIsLoading))
|
|
|
|
let hosting = NSHostingView(rootView: initial)
|
|
hosting.frame.size.width = max(1, initialWidth)
|
|
let size = hosting.fittingSize
|
|
hosting.frame = NSRect(
|
|
origin: .zero,
|
|
size: NSSize(width: initialWidth, height: size.height))
|
|
|
|
let item = NSMenuItem()
|
|
item.tag = self.tag
|
|
item.view = hosting
|
|
item.isEnabled = false
|
|
|
|
menu.insertItem(item, at: insertIndex)
|
|
|
|
// Capture the menu window width for next open, but do not mutate widths while the menu is visible.
|
|
DispatchQueue.main.async { [weak self, weak hosting] in
|
|
guard let self, let hosting else { return }
|
|
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
|
}
|
|
|
|
if initialIsLoading {
|
|
self.loadTask = Task { [weak hosting] in
|
|
await self.refreshCache(force: true)
|
|
guard let hosting else { return }
|
|
let view = self.cachedView()
|
|
await MainActor.run {
|
|
hosting.rootView = view
|
|
hosting.invalidateIntrinsicContentSize()
|
|
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
|
hosting.frame.size.width = max(1, initialWidth)
|
|
let size = hosting.fittingSize
|
|
hosting.frame.size.height = size.height
|
|
}
|
|
}
|
|
} else {
|
|
// Keep the menu stable while it's open; refresh in the background for next open.
|
|
self.loadTask = Task { await self.refreshCache(force: false) }
|
|
}
|
|
}
|
|
|
|
func menuDidClose(_ menu: NSMenu) {
|
|
self.originalDelegate?.menuDidClose?(menu)
|
|
self.isMenuOpen = false
|
|
self.loadTask?.cancel()
|
|
}
|
|
|
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
self.originalDelegate?.menuNeedsUpdate?(menu)
|
|
}
|
|
|
|
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
|
|
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
|
|
return rect
|
|
}
|
|
return NSRect.zero
|
|
}
|
|
|
|
private func refreshCache(force: Bool) async {
|
|
if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let rows = try await self.loadCurrentRows()
|
|
self.cachedRows = rows
|
|
self.cacheErrorText = nil
|
|
self.cacheUpdatedAt = Date()
|
|
} catch {
|
|
if self.cachedRows.isEmpty {
|
|
let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
self.cacheErrorText = "Could not load sessions"
|
|
} else {
|
|
// Keep the menu readable: one line, short.
|
|
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
|
self.cacheErrorText = firstLine.count > 90 ? "\(firstLine.prefix(87))…" : firstLine
|
|
}
|
|
}
|
|
self.cacheUpdatedAt = Date()
|
|
}
|
|
}
|
|
|
|
private func cachedView() -> AnyView {
|
|
let rows = self.cachedRows
|
|
let isLoading = rows.isEmpty && self.cacheErrorText == nil
|
|
return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading))
|
|
}
|
|
|
|
private func loadCurrentRows() async throws -> [SessionRow] {
|
|
let snapshot = try await SessionLoader.loadSnapshot()
|
|
let loaded = snapshot.rows
|
|
let now = Date()
|
|
let current = loaded.filter { row in
|
|
if row.key == "main" { return true }
|
|
guard let updatedAt = row.updatedAt else { return false }
|
|
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
|
}
|
|
|
|
return current.sorted { lhs, rhs in
|
|
if lhs.key == "main" { return true }
|
|
if rhs.key == "main" { return false }
|
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
|
}
|
|
}
|
|
|
|
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
|
// Prefer inserting before the first separator (so the card sits right below the Active toggle).
|
|
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
|
// SwiftUI menus typically include a separator right after the first toggle; insert before it so the
|
|
// separator appears below the context card.
|
|
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
|
return sepIdx
|
|
}
|
|
return idx
|
|
}
|
|
|
|
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
|
return sepIdx
|
|
}
|
|
|
|
// Fallback: insert after the first item.
|
|
if menu.items.count >= 1 { return 1 }
|
|
return menu.items.count
|
|
}
|
|
|
|
private func initialCardWidth(for menu: NSMenu) -> CGFloat {
|
|
let widthCandidates: [CGFloat] = [
|
|
menu.minimumWidth,
|
|
self.lastKnownMenuWidth ?? 0,
|
|
self.fallbackCardWidth,
|
|
]
|
|
let resolved = widthCandidates.max() ?? self.fallbackCardWidth
|
|
return max(300, resolved)
|
|
}
|
|
|
|
private func captureMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView<AnyView>) {
|
|
let targetWidth: CGFloat? = {
|
|
if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth }
|
|
if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth }
|
|
let minimumWidth = menu.minimumWidth
|
|
if minimumWidth > 0 { return minimumWidth }
|
|
return nil
|
|
}()
|
|
|
|
guard let targetWidth else { return }
|
|
self.lastKnownMenuWidth = max(300, targetWidth)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension MenuContextCardInjector {
|
|
func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) {
|
|
self.cachedRows = rows
|
|
self.cacheErrorText = errorText
|
|
self.cacheUpdatedAt = updatedAt
|
|
}
|
|
|
|
func _testFindInsertIndex(in menu: NSMenu) -> Int? {
|
|
self.findInsertIndex(in: menu)
|
|
}
|
|
|
|
func _testInitialCardWidth(for menu: NSMenu) -> CGFloat {
|
|
self.initialCardWidth(for: menu)
|
|
}
|
|
|
|
func _testCachedView() -> AnyView {
|
|
self.cachedView()
|
|
}
|
|
}
|
|
#endif
|