Skip to content

Commit a2bafd2

Browse files
pdurlejclaude
andcommitted
fix(ai-quotas): place readout left of system icons + harden codex fetch
1. Menu-bar placement. The AI Quotas readout forced "Preferred Position" 0 (far right), landing it to the RIGHT of Spotlight, among the system icons. Place it just inside Ice's Visible control item instead — the leftmost always-visible slot — so it sits to the LEFT of the system icons (Spotlight, Control Center, clock) while staying on-screen. (Left of the Visible control item is Ice's wide divider → off-screen, so that is the limit.) Also stop clobbering the position on every launch: the old ">100 → reset to 0" rule snapped any manual drag back to the far right. A one-time migration now relocates existing installs, then manual drags are respected and only an off-screen position self-heals. 2. Codex "?" robustness. runProcess resumed from the terminationHandler using a snapshot that could race the readabilityHandler's final chunk → empty/truncated stdout → spurious "?". Read stdout to EOF after exit instead (codexbar output is a few KB, no deadlock risk). Also defensively trim any non-JSON prefix before decoding. Bump to 0.11.13-fire.9.7 (build 1143). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c54dc8c commit a2bafd2

3 files changed

Lines changed: 93 additions & 49 deletions

File tree

Ice.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@
520520
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
521521
CODE_SIGN_STYLE = Automatic;
522522
COMBINE_HIDPI_IMAGES = YES;
523-
CURRENT_PROJECT_VERSION = 1142;
523+
CURRENT_PROJECT_VERSION = 1143;
524524
DEAD_CODE_STRIPPING = YES;
525525
DEVELOPMENT_ASSET_PATHS = "";
526526
ENABLE_APP_SANDBOX = NO;
@@ -536,7 +536,7 @@
536536
"$(inherited)",
537537
"@executable_path/../Frameworks",
538538
);
539-
MARKETING_VERSION = "0.11.13-fire.9.6";
539+
MARKETING_VERSION = "0.11.13-fire.9.7";
540540
PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice;
541541
PRODUCT_NAME = "$(TARGET_NAME)";
542542
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -553,7 +553,7 @@
553553
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
554554
CODE_SIGN_STYLE = Automatic;
555555
COMBINE_HIDPI_IMAGES = YES;
556-
CURRENT_PROJECT_VERSION = 1142;
556+
CURRENT_PROJECT_VERSION = 1143;
557557
DEAD_CODE_STRIPPING = YES;
558558
DEVELOPMENT_ASSET_PATHS = "";
559559
ENABLE_APP_SANDBOX = NO;
@@ -569,7 +569,7 @@
569569
"$(inherited)",
570570
"@executable_path/../Frameworks",
571571
);
572-
MARKETING_VERSION = "0.11.13-fire.9.6";
572+
MARKETING_VERSION = "0.11.13-fire.9.7";
573573
PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice;
574574
PRODUCT_NAME = "$(TARGET_NAME)";
575575
SWIFT_EMIT_LOC_STRINGS = YES;

Ice/AIQuotas/AIQuotaStatusItemController.swift

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ final class AIQuotaStatusItemController {
4141
/// control items and makes its window title recognizable.
4242
static let autosaveName = "Ice.ControlItem.AIQuotas"
4343

44+
/// UserDefaults flag: set once we've moved the item to the leftmost
45+
/// visible slot (fire.9.7). Older builds forced position 0 (far right,
46+
/// right of Spotlight); this one-time migration relocates existing
47+
/// installs, after which manual drags are respected.
48+
private static let positionMigratedKey = "AIQuotasPositionLeftOfSystemV1"
49+
50+
/// UserDefaults key holding Ice's own Visible control item position —
51+
/// the left edge of the always-visible zone. We read it to place AI
52+
/// Quotas just inside it. (We run inside Ice, so it's our own domain.)
53+
private static let visibleControlItemPositionKey =
54+
"NSStatusItem Preferred Position Ice.ControlItem.Visible"
55+
4456
private var statusItem: NSStatusItem?
4557
private let logger = Logger(category: "AIQuota.StatusItem")
4658

@@ -53,16 +65,40 @@ final class AIQuotaStatusItemController {
5365
private func ensureStatusItem() -> NSStatusItem {
5466
if let statusItem { return statusItem }
5567

56-
// STEP 1 — force a low preferred position BEFORE creation. macOS
57-
// places items with a lower preferred position toward the
58-
// trailing (visible, clock-adjacent) edge. Ice's visible control
59-
// item uses 0 for exactly this reason. We set it if unset or if
60-
// a previous build left it parked far left (a large value).
68+
// STEP 1 — choose the preferred position BEFORE creation (setting
69+
// it afterward is a no-op). macOS places lower preferred positions
70+
// toward the trailing/right (visible) edge, higher ones toward the
71+
// leading/left edge.
72+
//
73+
// Goal: land AI Quotas as the LEFTMOST always-visible element, just
74+
// inside Ice's Visible control item (the "•••"), so it sits to the
75+
// LEFT of the system icons (Spotlight, Control Center, clock) yet
76+
// stays on-screen. Left of the Visible control item is Ice's wide
77+
// section divider, which pushes items off-screen — so this is as
78+
// far left as a visible item can go.
6179
let defaults = UserDefaults.standard
80+
let visiblePos = defaults.object(forKey: Self.visibleControlItemPositionKey) as? Double
81+
// Just inside the Visible control item. Fall back to 0 (guaranteed
82+
// visible, far right) only if Ice hasn't persisted its own position
83+
// yet — the self-heal below corrects it on a later launch.
84+
let target = visiblePos.map { $0 - 1 } ?? 0
6285
let current = defaults.object(forKey: preferredPositionKey) as? Double
63-
if current == nil || (current ?? 0) > 100 {
64-
defaults.set(0.0, forKey: preferredPositionKey)
65-
logger.debug("Forced AI Quotas preferred position to 0 (was \(String(describing: current)))")
86+
87+
let needsPlacement: Bool
88+
if !defaults.bool(forKey: Self.positionMigratedKey) {
89+
needsPlacement = true // one-time migration
90+
defaults.set(true, forKey: Self.positionMigratedKey)
91+
} else if let current {
92+
// Re-place only if parked off-screen (left of the Visible
93+
// control item, behind Ice's divider). Otherwise respect the
94+
// user's manual placement so drags stick.
95+
needsPlacement = visiblePos.map { current > $0 } ?? false
96+
} else {
97+
needsPlacement = true // unset
98+
}
99+
if needsPlacement {
100+
defaults.set(target, forKey: preferredPositionKey)
101+
logger.debug("Set AI Quotas preferred position to \(target) (was \(String(describing: current)))")
66102
}
67103

68104
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

Ice/AIQuotas/CodexBarCLIQuotaBackend.swift

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -121,32 +121,23 @@ struct CodexBarCLIQuotaBackend: AIQuotaBackend {
121121
process.standardOutput = stdout
122122
process.standardError = stderr
123123

124-
// Read pipes on background queues to avoid a deadlock when the
125-
// child writes more than a pipe buffer's worth before exit.
126-
let stdoutData = LockedData()
127-
stdout.fileHandleForReading.readabilityHandler = { handle in
128-
let chunk = handle.availableData
129-
if chunk.isEmpty {
130-
handle.readabilityHandler = nil
131-
} else {
132-
stdoutData.append(chunk)
133-
}
134-
}
135-
// Drain stderr so the child never blocks on a full stderr pipe.
136-
stderr.fileHandleForReading.readabilityHandler = { handle in
137-
if handle.availableData.isEmpty { handle.readabilityHandler = nil }
138-
}
139-
140124
return try await withThrowingTaskGroup(of: Data.self) { group in
141125
group.addTask {
142-
try await withCheckedThrowingContinuation { continuation in
126+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
143127
process.terminationHandler = { proc in
144-
// Give the readability handler a beat to flush.
145-
let status = proc.terminationStatus
146-
if status == 0 {
147-
continuation.resume(returning: stdoutData.snapshot())
128+
// Read stdout to EOF *after* the process exits, so we
129+
// never resume on a partially-flushed pipe. The old
130+
// readabilityHandler + snapshot path could race the
131+
// final chunk and resume with empty/truncated JSON →
132+
// a spurious "?" in the menu bar. codexbar's output is
133+
// a few KB, well under the 64KB pipe buffer, so reading
134+
// post-exit cannot deadlock.
135+
let out = stdout.fileHandleForReading.readDataToEndOfFile()
136+
_ = stderr.fileHandleForReading.readDataToEndOfFile() // drain
137+
if proc.terminationStatus == 0 {
138+
continuation.resume(returning: out)
148139
} else {
149-
continuation.resume(throwing: ProcessError.nonZeroExit(status))
140+
continuation.resume(throwing: ProcessError.nonZeroExit(proc.terminationStatus))
150141
}
151142
}
152143
do {
@@ -172,25 +163,16 @@ struct CodexBarCLIQuotaBackend: AIQuotaBackend {
172163
}
173164
}
174165

175-
/// Thread-safe accumulator for piped stdout chunks.
176-
private final class LockedData: @unchecked Sendable {
177-
private let lock = NSLock()
178-
private var data = Data()
179-
func append(_ chunk: Data) {
180-
lock.lock(); defer { lock.unlock() }
181-
data.append(chunk)
182-
}
183-
func snapshot() -> Data {
184-
lock.lock(); defer { lock.unlock() }
185-
return data
186-
}
187-
}
188-
189166
// MARK: Parsing (pure, unit-testable)
190167

191168
/// Parses CodexBar CLI JSON (object or array) into a snapshot.
192169
/// Pure function: no I/O, so tests can feed it fixtures.
193-
static func parse(data: Data, provider: AIQuotaProvider) -> AIQuotaSnapshot {
170+
static func parse(data rawData: Data, provider: AIQuotaProvider) -> AIQuotaSnapshot {
171+
// Defensive: trim any non-JSON noise before the first top-level
172+
// opener. Status lines like "[codex notify] …" belong on stderr
173+
// (which we drop), but if one ever lands on stdout this keeps the
174+
// decode from failing.
175+
let data = Self.jsonSlice(of: rawData)
194176
guard !data.isEmpty else {
195177
return .failure(provider, "empty CLI output")
196178
}
@@ -238,6 +220,32 @@ struct CodexBarCLIQuotaBackend: AIQuotaBackend {
238220
)
239221
}
240222

223+
/// Returns the data starting at the first byte that actually begins
224+
/// JSON: a '[' or '{' whose next non-whitespace byte is JSON-structural
225+
/// (so a log line such as "[codex notify] …", where '[' is followed by a
226+
/// letter, is skipped). Returns the input unchanged if none is found.
227+
static func jsonSlice(of data: Data) -> Data {
228+
let bytes = [UInt8](data)
229+
func isWS(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D }
230+
func looksLikeJSONStart(after i: Int) -> Bool {
231+
var j = i + 1
232+
while j < bytes.count, isWS(bytes[j]) { j += 1 }
233+
guard j < bytes.count else { return false }
234+
let b = bytes[j]
235+
return b == UInt8(ascii: "{") || b == UInt8(ascii: "[")
236+
|| b == UInt8(ascii: "\"") || b == UInt8(ascii: "}") || b == UInt8(ascii: "]")
237+
|| (b >= UInt8(ascii: "0") && b <= UInt8(ascii: "9")) || b == UInt8(ascii: "-")
238+
|| b == UInt8(ascii: "t") || b == UInt8(ascii: "f") || b == UInt8(ascii: "n")
239+
}
240+
for i in bytes.indices {
241+
let b = bytes[i]
242+
if (b == UInt8(ascii: "[") || b == UInt8(ascii: "{")), looksLikeJSONStart(after: i) {
243+
return Data(bytes[i...])
244+
}
245+
}
246+
return data
247+
}
248+
241249
private static let isoFormatter = ISO8601DateFormatter()
242250

243251
static func parseDate(_ string: String?) -> Date? {

0 commit comments

Comments
 (0)