Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Doubao: confirm zero-remaining HTTP 200 request limits before falling back, preserving genuine exhaustion and avoiding false 100% usage (#1383). Thanks @LeoLin990405 and @foobra!
- Menu bar: defer pasteboard writes and copy feedback outside the `NSMenu` tracking callback so in-menu copy buttons no longer beachball on macOS 26 (#1388). Thanks @LeoLin990405!
- Menu bar: defer merged status-icon redraws until the tracked menu closes while preserving animation lifecycle and quota-warning timing, reducing WindowServer churn during long menu sessions (#1409, fixes #1399). Thanks @kiranmagic7!
- Provider status: decode status feeds on the concurrent executor and reuse ISO8601 formatters, removing a measured main-thread stall during refreshes (#1406). Thanks @ProspectOre!
- Menu bar: keep one stable width across merged provider tabs and resize every hosted card row to AppKit's final menu width so provider switching no longer leaves a widened menu with inset submenu arrows (#1410).
- Menu bar: defer Overview-row provider transitions out of AppKit's click callback so opening provider detail no longer performs a full synchronous menu rebuild (#1325).
- Menu bar: open cached menus immediately after data-only invalidations, then refresh missing or stale provider data asynchronously without queuing redundant work on close (#1398). Thanks @joshuavial!
Expand Down
82 changes: 55 additions & 27 deletions Sources/CodexBar/UsageStore+Status.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
import CodexBarCore
import Foundation

/// Shared, lock-guarded ISO8601 formatters for status feeds. Allocating a fresh
/// `ISO8601DateFormatter` per decoded date field is a measurable share of decoding the
/// Google Workspace incidents feed, which can run to hundreds of kilobytes (#1399).
private final class StatusISO8601FormatterBox: @unchecked Sendable {
let lock = NSLock()
let withFractional: ISO8601DateFormatter = {
let fmt = ISO8601DateFormatter()
fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return fmt
}()

let plain: ISO8601DateFormatter = {
let fmt = ISO8601DateFormatter()
fmt.formatOptions = [.withInternetDateTime]
return fmt
}()
}

private enum StatusFeedDateParser {
static let box = StatusISO8601FormatterBox()

static func parse(_ text: String) -> Date? {
self.box.lock.lock()
defer { self.box.lock.unlock() }
return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text)
}

static func decodingStrategy() -> JSONDecoder.DateDecodingStrategy {
.custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
guard let date = StatusFeedDateParser.parse(raw) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date")
}
return date
}
}
}

extension UsageStore {
static func fetchStatus(
/// Status feeds decode off the main actor: the Google Workspace incidents payload alone
/// can be hundreds of kilobytes and cost 150-340ms to decode (#1399), and these helpers
/// touch no store state.
@concurrent
nonisolated static func fetchStatus(
from baseURL: URL,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared)
async throws -> ProviderStatus
Expand Down Expand Up @@ -32,16 +75,7 @@ extension UsageStore {
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: raw) { return date }
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: raw) { return date }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date")
}
decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy()

let response = try decoder.decode(Response.self, from: data)
let indicator = ProviderStatusIndicator(rawValue: response.status.indicator) ?? .unknown
Expand All @@ -51,9 +85,11 @@ extension UsageStore {
updatedAt: response.page?.updatedAt)
}

static func fetchWorkspaceStatus(
@concurrent
nonisolated static func fetchWorkspaceStatus(
productID: String,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared)
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
beforeDecoding: (@Sendable () -> Void)? = nil)
async throws -> ProviderStatus
{
guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else {
Expand All @@ -62,22 +98,14 @@ extension UsageStore {
var request = URLRequest(url: url)
request.timeoutInterval = 10
let (data, _) = try await transport.data(for: request)
beforeDecoding?()
return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID)
}

static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus {
nonisolated static func parseGoogleWorkspaceStatus(data: Data, productID: String) throws -> ProviderStatus {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: raw) { return date }
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: raw) { return date }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date")
}
decoder.dateDecodingStrategy = StatusFeedDateParser.decodingStrategy()

let incidents = try decoder.decode([GoogleWorkspaceIncident].self, from: data)
let active = incidents.filter { $0.isRelevant(productID: productID) && $0.isActive }
Expand Down Expand Up @@ -105,7 +133,7 @@ extension UsageStore {
return ProviderStatus(indicator: best.indicator, description: description, updatedAt: updatedAt)
}

private static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int {
private nonisolated static func indicatorRank(_ indicator: ProviderStatusIndicator) -> Int {
switch indicator {
case .none: 0
case .maintenance: 1
Expand All @@ -116,7 +144,7 @@ extension UsageStore {
}
}

private static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator {
private nonisolated static func workspaceIndicator(status: String?, severity: String?) -> ProviderStatusIndicator {
switch status?.uppercased() {
case "AVAILABLE": return .none
case "SERVICE_INFORMATION": return .minor
Expand All @@ -134,7 +162,7 @@ extension UsageStore {
}
}

private static func workspaceSummary(from text: String?) -> String? {
private nonisolated static func workspaceSummary(from text: String?) -> String? {
guard let text else { return nil }
let normalized = text
.replacingOccurrences(of: "\r\n", with: "\n")
Expand Down
26 changes: 26 additions & 0 deletions Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import os
import Testing
@testable import CodexBar

Expand Down Expand Up @@ -41,4 +42,29 @@ struct GoogleWorkspaceStatusNetworkTests {
#expect(requests.count == 1)
#expect(requests.first?.url?.host == "www.google.com")
}

@Test
func `fetchWorkspaceStatus decodes off the main thread when called from the main actor`() async throws {
// The incidents feed can run to hundreds of kilobytes; decoding it on the main
// actor stalls the UI for 150-340ms per Google-status provider per refresh (#1399).
let decodedOffMainThread = OSAllocatedUnfairLock(initialState: false)
let transport = ProviderHTTPTransportStub { request in
let response = try HTTPURLResponse(
url: #require(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (Data("[]".utf8), response)
}

let status = try await UsageStore.fetchWorkspaceStatus(
productID: "npdyhgECDJ6tB66MxXyo",
transport: transport,
beforeDecoding: {
decodedOffMainThread.withLock { $0 = !Thread.isMainThread }
})

#expect(status.indicator == .none)
#expect(decodedOffMainThread.withLock { $0 })
}
}