mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
2b0da0e193
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook * feat(macos): add live execution approval queue * refactor(macos): replace menu injectors with owned status menu * fix(macos): keep unconfigured menu header calm * fix(macos): route status-item right-clicks through a local event monitor NSControl's send-action mask ignores right mouse buttons, so the previous sendAction(on: [.rightMouseUp]) wiring never fired and the menu was unreachable by pointer. A local monitor now owns pointer routing (left = dashboard, right = menu) — the same mechanism the shipped StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy guard the old injector carried, since reconciling tracked rows can re-enter the callback without a close. * chore(i18n): refresh native inventory for status menu strings * chore(macos): remove menu-refactor dead code Periphery flagged the orphans the status-menu refactor left behind: the ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry surface (its only consumer was the deleted menu picker; the Settings pane owns exec-approval policy UI), SessionMenuLabelView, TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and two fixture-only initializers. StatusMenuController.stop() is now wired into applicationWillTerminate. The menu-highlight environment key moved from the deleted view file into MenuItemHighlightColors. * chore(macos): fix status-menu lint style and refresh i18n inventory * fix(macos): converge approval cards after losing a resolution race The status-menu queue and the modal prompter intentionally share the gateway approval event stream: the gateway resolves each approval exactly once, the resolved broadcast removes the card, and the modal stays the active presentation owner while the menu is the passive, ambient one. What was missing: when the menu's resolve loses the race (modal or another client answered first), the gateway rejection left a zombie card if the resolved event was dropped. Resolve failures now re-list from the authoritative queue. Regression test simulates the race at the socket boundary and fails pre-fix.
113 lines
3.6 KiB
Swift
113 lines
3.6 KiB
Swift
import Foundation
|
|
|
|
struct GatewayUsageWindow: Codable {
|
|
let label: String
|
|
let usedPercent: Double
|
|
let resetAt: Double?
|
|
}
|
|
|
|
struct GatewayUsageProvider: Codable {
|
|
let provider: String
|
|
let displayName: String
|
|
let windows: [GatewayUsageWindow]
|
|
let plan: String?
|
|
let error: String?
|
|
}
|
|
|
|
struct GatewayUsageSummary: Codable {
|
|
let updatedAt: Double
|
|
let providers: [GatewayUsageProvider]
|
|
let refreshing: Bool?
|
|
}
|
|
|
|
struct UsageRow: Identifiable {
|
|
let id: String
|
|
let providerId: String
|
|
let displayName: String
|
|
let plan: String?
|
|
let windowLabel: String?
|
|
let usedPercent: Double?
|
|
let resetAt: Date?
|
|
let errorText: String?
|
|
|
|
var titleText: String {
|
|
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
|
|
return self.displayName
|
|
}
|
|
|
|
var remainingPercent: Int? {
|
|
guard let usedPercent, usedPercent.isFinite else { return nil }
|
|
return max(0, min(100, Int(round(100 - usedPercent))))
|
|
}
|
|
|
|
func detailText(now: Date = .init()) -> String {
|
|
if let errorText, !errorText.isEmpty { return errorText }
|
|
guard let remaining = self.remainingPercent else { return "No data" }
|
|
var parts = ["\(remaining)% left"]
|
|
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
|
|
if let resetAt {
|
|
let reset = UsageRow.formatResetRemaining(target: resetAt, now: now)
|
|
if let reset { parts.append("⏱\(reset)") }
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func formatResetRemaining(target: Date, now: Date) -> String? {
|
|
let diff = target.timeIntervalSince(now)
|
|
if diff <= 0 { return "now" }
|
|
let minutes = Int(floor(diff / 60))
|
|
if minutes < 60 { return "\(minutes)m" }
|
|
let hours = minutes / 60
|
|
let mins = minutes % 60
|
|
if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" }
|
|
let days = hours / 24
|
|
if days < 7 { return "\(days)d \(hours % 24)h" }
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMM d"
|
|
return formatter.string(from: target)
|
|
}
|
|
}
|
|
|
|
extension GatewayUsageSummary {
|
|
func primaryRows() -> [UsageRow] {
|
|
self.providers.compactMap { provider in
|
|
if let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) {
|
|
return UsageRow(
|
|
id: "\(provider.provider)-\(window.label)",
|
|
providerId: provider.provider,
|
|
displayName: provider.displayName,
|
|
plan: provider.plan,
|
|
windowLabel: window.label,
|
|
usedPercent: window.usedPercent,
|
|
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
|
errorText: nil)
|
|
}
|
|
|
|
guard let error = provider.error?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!error.isEmpty
|
|
else { return nil }
|
|
|
|
return UsageRow(
|
|
id: "\(provider.provider)-error",
|
|
providerId: provider.provider,
|
|
displayName: provider.displayName,
|
|
plan: provider.plan,
|
|
windowLabel: nil,
|
|
usedPercent: nil,
|
|
resetAt: nil,
|
|
errorText: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
enum UsageLoader {
|
|
static func loadSummary() async throws -> GatewayUsageSummary {
|
|
let data = try await ControlChannel.shared.request(
|
|
method: "usage.status",
|
|
params: nil,
|
|
timeoutMs: 5000)
|
|
return try JSONDecoder().decode(GatewayUsageSummary.self, from: data)
|
|
}
|
|
}
|