easy-codex 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,10 +54,10 @@ easy-codex overlay
54
54
  独立 AppKit 浮窗在 Codex 位于前台时显示,切到其他应用、最小化或关闭 Codex 窗口后隐藏。默认位于 Codex 窗口左下角头像上方;拖动可校准位置,偏移量保存在本机。它按窗口边界跟随,不自动识别头像;侧栏布局变化后可再次拖动。首版跟随最前面的一个 Codex 普通窗口。
55
55
 
56
56
  - 圆环长度表示剩余百分比:大于 50% 绿色,20%–50% 橙色,低于 20% 红色。
57
- - 浮窗为头像大小(32×32),背景透明,无底板和阴影。圆环只显示百分比,无数据时显示 `—`,不显示窗口时长或状态文字;时长与状态见悬停提示和菜单。
57
+ - 浮窗为头像大小(32×32),背景透明,无底板和阴影。圆环只显示剩余数字,不带 %,无数据时显示 `—`,不显示窗口时长或状态文字;时长与状态见悬停提示和菜单。
58
58
  - 未知或过期数据为灰色;失败后的过期说明见悬停提示。已到重置时间时也先标为过期,等待重新查询,不擅自恢复满额。
59
59
  - 浮窗可见时每 60 秒刷新,查询不会重叠;回到前台且距离上次查询超过 60 秒会刷新。
60
- - 默认优先 codex 额度桶,显示其中剩余比例最低的窗口;右键可切换其他窗口,不把各窗口相加。
60
+ - 按服务端真实时长识别窗口并优先 codex 桶,不跨桶拼接:只有周额度时显示单周环和周剩余数字,不显示不存在的5小时项;两项都有时周外粗环、5小时内细环,均为实线剩余弧。菜单切换中心数字,显示本地时区重置时间。
61
61
  - 悬停查看额度详情与更新时间;双击刷新;右键或菜单栏 **EC** 可立即刷新、恢复默认位置、退出。
62
62
  - 这是 **CLI 登录账号** 的额度,不自动确认桌面端正在使用同一账号。切换账号后请核对并手动刷新。
63
63
 
@@ -77,3 +77,7 @@ node bin/easy-codex.js overlay
77
77
  GitHub Actions 在 `v*` 标签上测试、构建通用 macOS 浮窗,并发布 npm 包。标签必须与 package.json 版本一致;预发布版本使用 `next`,正式版本使用 `latest`。发布任务使用 GitHub `npm` Environment,部署记录链接到 npm 包。
78
78
 
79
79
  首次发布需配置仓库 Secret `NPM_TOKEN`(具有该包发布权限、允许自动化发布的 npm Token)。不要把凭据写入源码。修改版本后创建对应标签触发发布;失败可在 Actions 中重新运行任务。
80
+
81
+ ## 电子时钟
82
+
83
+ 透明时钟位于圆环上方,竖排居中,默认间距10px,可独立拖动。默认硅谷,城市顺序为硅谷、洛杉矶、西雅图、华盛顿特区、哥伦布(俄亥俄州)、新加坡、上海,也可自定义 IANA 时区;设置自动保存。仅显示时分,按目标当地时间00–09红色、09–15绿色、15–24橙色,自动处理夏令时,不修改电脑系统时区。菜单“恢复默认位置”应用竖排默认布局;已有拖动位置继续保留。
@@ -7,7 +7,15 @@ struct UsageWindow: Decodable {
7
7
  let name: String
8
8
  let remaining: Double?
9
9
  let resetsAt: Double?
10
+ let windowDurationMins: Double?
10
11
  }
12
+ // 主次窗口随套餐变化,只按服务端时长识别,且不跨额度桶拼接。
13
+ func codexWindow(_ windows: [UsageWindow], minutes: Double) -> UsageWindow? {
14
+ let bucket = windows.contains { $0.id.hasPrefix("codex:") } ? "codex" : windows.first?.id.components(separatedBy: ":").first
15
+ guard let bucket else { return nil }
16
+ return windows.first { $0.id.hasPrefix(bucket + ":") && $0.windowDurationMins == minutes }
17
+ }
18
+
11
19
  struct Snapshot: Decodable {
12
20
  var windows: [UsageWindow] = []
13
21
  var detail: String = ""
@@ -68,8 +76,14 @@ func targetWindow() -> CGRect? {
68
76
  }
69
77
 
70
78
  @MainActor
71
- final class RingView: NSView {
79
+ class RingView: NSView {
72
80
  var remaining: Double?
81
+ var weeklyRemaining: Double?
82
+ var weeklyStale = true
83
+ var showWeeklyPercentage = false
84
+ var showWeeklyRing = true
85
+ var showFiveHourRing = true
86
+ var displaysWeeklyPercentage: Bool { showWeeklyRing && (showWeeklyPercentage || !showFiveHourRing) }
73
87
  var stale = true
74
88
  var caption = "等待额度"
75
89
  var makeMenu: (() -> NSMenu)?
@@ -80,23 +94,31 @@ final class RingView: NSView {
80
94
  private var originStart = CGPoint.zero
81
95
 
82
96
  override func draw(_ dirtyRect: NSRect) {
97
+ if showWeeklyRing { drawRing(radius: 14, remaining: weeklyRemaining, stale: weeklyStale, dashed: true) }
98
+ if showFiveHourRing { drawRing(radius: showWeeklyRing ? 10 : 14, remaining: remaining, stale: stale) }
99
+ let useWeekly = displaysWeeklyPercentage
100
+ let percentage = useWeekly ? weeklyRemaining : remaining
101
+ let percentageStale = useWeekly ? weeklyStale : stale
102
+ let state = RingState.resolve(percentage, stale: percentageStale)
103
+ let text = percentage.map { "\(Int($0.rounded()))" } ?? "—"
104
+ drawText(text, y: 10, font: .monospacedDigitSystemFont(ofSize: 9, weight: .semibold), color: state.color)
105
+ }
106
+ private func drawRing(radius: CGFloat, remaining: Double?, stale: Bool, dashed: Bool = false) {
83
107
  let center = CGPoint(x: bounds.midX, y: bounds.midY)
84
- let track = NSBezierPath(ovalIn: CGRect(x: center.x - 14, y: center.y - 14, width: 28, height: 28))
85
- track.lineWidth = 2
108
+ let track = NSBezierPath(ovalIn: CGRect(x: center.x - radius, y: center.y - radius,
109
+ width: radius * 2, height: radius * 2))
110
+ track.lineWidth = dashed ? 3 : 2
111
+ if dashed { track.setLineDash([2, 2], count: 2, phase: 0) }
86
112
  NSColor.labelColor.withAlphaComponent(0.12).setStroke()
87
113
  track.stroke()
88
- let state = RingState.resolve(remaining, stale: stale)
89
- if let remaining, remaining > 0, !stale {
90
- let arc = NSBezierPath()
91
- arc.lineWidth = 2
92
- arc.lineCapStyle = .round
93
- arc.appendArc(withCenter: center, radius: 14, startAngle: 90,
94
- endAngle: 90 - CGFloat(min(100, max(0, remaining))) * 3.6, clockwise: true)
95
- state.color.setStroke()
96
- arc.stroke()
97
- }
98
- let text = remaining.map { "\(Int($0.rounded()))%" } ?? "—"
99
- drawText(text, y: 10, font: .monospacedDigitSystemFont(ofSize: 9, weight: .semibold), color: state.color)
114
+ guard let remaining, remaining > 0, !stale else { return }
115
+ let arc = NSBezierPath()
116
+ arc.lineWidth = dashed ? 3 : 2
117
+ arc.lineCapStyle = .round
118
+ arc.appendArc(withCenter: center, radius: radius, startAngle: 90,
119
+ endAngle: 90 - CGFloat(min(100, max(0, remaining))) * 3.6, clockwise: true)
120
+ RingState.resolve(remaining, stale: stale).color.setStroke()
121
+ arc.stroke()
100
122
  }
101
123
  private func drawText(_ text: String, y: CGFloat, font: NSFont, color: NSColor) {
102
124
  let style = NSMutableParagraphStyle()
@@ -125,17 +147,71 @@ final class RingView: NSView {
125
147
  }
126
148
  }
127
149
 
150
+ struct ClockCity {
151
+ let name: String
152
+ let identifier: String
153
+ static let presets: [ClockCity] = [
154
+ ClockCity(name: "硅谷", identifier: "America/Los_Angeles"),
155
+ ClockCity(name: "洛杉矶", identifier: "America/Los_Angeles"),
156
+ ClockCity(name: "西雅图", identifier: "America/Los_Angeles"),
157
+ ClockCity(name: "华盛顿", identifier: "America/New_York"),
158
+ ClockCity(name: "哥伦布", identifier: "America/New_York"),
159
+ ClockCity(name: "新加坡", identifier: "Asia/Singapore"),
160
+ ClockCity(name: "上海", identifier: "Asia/Shanghai")
161
+ ]
162
+ }
163
+
164
+ func clockText(at date: Date, zone: TimeZone) -> String {
165
+ let formatter = DateFormatter()
166
+ formatter.locale = Locale(identifier: "en_US_POSIX")
167
+ formatter.timeZone = zone
168
+ formatter.dateFormat = "HH:mm"
169
+ return formatter.string(from: date)
170
+ }
171
+
172
+ func clockState(at date: Date, zone: TimeZone) -> RingState {
173
+ var calendar = Calendar(identifier: .gregorian)
174
+ calendar.timeZone = zone
175
+ let hour = calendar.component(.hour, from: date)
176
+ if hour >= 9 && hour < 15 { return .green }
177
+ if hour >= 15 { return .orange }
178
+ return .red
179
+ }
180
+
181
+ @MainActor
182
+ final class ClockView: RingView {
183
+ var zone = TimeZone(identifier: "America/Los_Angeles")!
184
+ var cityName = "硅谷"
185
+ override func draw(_ dirtyRect: NSRect) {
186
+ let style = NSMutableParagraphStyle()
187
+ style.alignment = .center
188
+ let now = Date()
189
+ let time = clockText(at: now, zone: zone)
190
+ (time as NSString).draw(in: CGRect(x: 0, y: 17, width: bounds.width, height: 25),
191
+ withAttributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium),
192
+ .foregroundColor: clockState(at: now, zone: zone).color, .paragraphStyle: style])
193
+ let city = cityName
194
+ (city as NSString).draw(in: CGRect(x: 0, y: 1, width: bounds.width, height: 15),
195
+ withAttributes: [.font: NSFont.systemFont(ofSize: 9),
196
+ .foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: style])
197
+ }
198
+ }
199
+
128
200
  @MainActor
129
201
  final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
130
202
  let panel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 32, height: 32),
131
203
  styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
132
204
  let ring = RingView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
205
+ let clockPanel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 48, height: 46),
206
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
207
+ let clock = ClockView(frame: CGRect(x: 0, y: 0, width: 48, height: 46))
208
+ var clockInset = CGPoint(x: 2, y: 116)
209
+ var clockEnabled = true
133
210
  var status: NSStatusItem?
134
211
  var timer: Timer?
135
212
  var query: Process?
136
213
  var snapshot: Snapshot?
137
214
  var error: String?
138
- var selectedID: String?
139
215
  var lastAttempt = Date.distantPast
140
216
  var lastRect: CGRect?
141
217
  var inset = CGPoint(x: 10, y: 74)
@@ -153,7 +229,32 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
153
229
  if prefs.object(forKey: "leftInset") != nil {
154
230
  inset = CGPoint(x: prefs.double(forKey: "leftInset"), y: prefs.double(forKey: "bottomInset"))
155
231
  }
156
- selectedID = prefs.string(forKey: "selectedWindow")
232
+ if let identifier = prefs.string(forKey: "clockTimeZone"), let zone = TimeZone(identifier: identifier) {
233
+ clock.zone = zone
234
+ let savedCity = prefs.string(forKey: "clockCity")
235
+ let preset = ClockCity.presets.first { $0.name == savedCity && $0.identifier == identifier }
236
+ ?? ClockCity.presets.first { $0.identifier == identifier }
237
+ clock.cityName = preset?.name ?? identifier
238
+ }
239
+ ring.showWeeklyPercentage = prefs.bool(forKey: "showWeeklyPercentage")
240
+ clockEnabled = prefs.object(forKey: "clockEnabled") == nil || prefs.bool(forKey: "clockEnabled")
241
+ if prefs.object(forKey: "clockLeftInset") != nil {
242
+ clockInset = CGPoint(x: prefs.double(forKey: "clockLeftInset"), y: prefs.double(forKey: "clockBottomInset"))
243
+ }
244
+ clockPanel.isOpaque = false
245
+ clockPanel.backgroundColor = .clear
246
+ clockPanel.hasShadow = false
247
+ clockPanel.level = .floating
248
+ clockPanel.hidesOnDeactivate = false
249
+ clockPanel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
250
+ clockPanel.contentView = clock
251
+ clock.makeMenu = { [weak self] in self?.makeMenu() ?? NSMenu() }
252
+ clock.moved = { [weak self] point in
253
+ guard let self, let rect = self.lastRect else { return }
254
+ self.clockInset = CGPoint(x: point.x - rect.minX, y: point.y - rect.minY)
255
+ prefs.set(self.clockInset.x, forKey: "clockLeftInset")
256
+ prefs.set(self.clockInset.y, forKey: "clockBottomInset")
257
+ }
157
258
  panel.isOpaque = false
158
259
  panel.backgroundColor = .clear
159
260
  panel.hasShadow = false
@@ -179,32 +280,49 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
179
280
  NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(hide), name: NSWorkspace.didDeactivateApplicationNotification, object: nil)
180
281
  tick()
181
282
  }
182
- @objc func hide() { panel.orderOut(nil) }
283
+ @objc func hide() { panel.orderOut(nil); clockPanel.orderOut(nil) }
183
284
  @objc func tick() {
184
- guard let rect = targetWindow() else { panel.orderOut(nil); lastRect = nil; return }
285
+ guard let rect = targetWindow() else { hide(); lastRect = nil; return }
185
286
  lastRect = rect
287
+ if clockEnabled {
288
+ if !clock.dragging {
289
+ let x: CGFloat = max(rect.minX, min(rect.maxX - 48, rect.minX + clockInset.x))
290
+ let y: CGFloat = max(rect.minY, min(rect.maxY - 46, rect.minY + clockInset.y))
291
+ clockPanel.setFrameOrigin(CGPoint(x: x, y: y))
292
+ }
293
+ clock.needsDisplay = true
294
+ clock.toolTip = "\(clock.cityName) · \(clock.zone.identifier)"
295
+ clock.setAccessibilityLabel("\(clock.cityName) \(clock.zone.identifier) \(clockText(at: Date(), zone: clock.zone))")
296
+ if !clockPanel.isVisible { clockPanel.orderFrontRegardless() }
297
+ } else { clockPanel.orderOut(nil) }
186
298
  if !ring.dragging { panel.setFrameOrigin(ringOrigin(in: rect, inset: inset)) }
187
299
  if !panel.isVisible { panel.orderFrontRegardless() }
188
300
  if Date().timeIntervalSince(lastAttempt) >= 60 { refresh() }
189
301
  updateRing()
190
302
  }
191
- var chosen: UsageWindow? {
192
- guard let windows = snapshot?.windows else { return nil }
193
- if let selectedID { return windows.first { $0.id == selectedID } }
194
- // 默认优先 codex 桶,取其中最紧张的窗口,不把不同窗口相加。
195
- let codex = windows.filter { $0.id.hasPrefix("codex:") }
196
- return (codex.isEmpty ? windows : codex).filter { $0.remaining != nil }.min { $0.remaining! < $1.remaining! }
197
- ?? windows.first
198
- }
199
303
  func updateRing() {
200
- let stale = error != nil || snapshot == nil || Date().timeIntervalSince1970 - (snapshot?.fetchedAt ?? 0) > 120
201
- || (chosen?.resetsAt.map { $0 <= Date().timeIntervalSince1970 } ?? false)
202
- ring.remaining = chosen?.remaining
203
- ring.stale = stale
204
- ring.caption = stale ? (snapshot == nil ? "等待额度" : "数据过期") : chosen?.label ?? "额度未知"
304
+ let windows = snapshot?.windows ?? []
305
+ let weekly = codexWindow(windows, minutes: 10080)
306
+ let fiveHour = codexWindow(windows, minutes: 300)
307
+ let now = Date().timeIntervalSince1970
308
+ let snapshotStale = error != nil || snapshot == nil || now - (snapshot?.fetchedAt ?? 0) > 120
309
+ func stale(_ window: UsageWindow?) -> Bool {
310
+ snapshotStale || window?.remaining == nil || (window?.resetsAt.map { $0 <= now } ?? false)
311
+ }
312
+ ring.showWeeklyRing = weekly != nil || fiveHour == nil
313
+ ring.showFiveHourRing = fiveHour != nil
314
+ ring.remaining = fiveHour?.remaining
315
+ ring.stale = stale(fiveHour)
316
+ ring.weeklyRemaining = weekly?.remaining
317
+ ring.weeklyStale = stale(weekly)
318
+ func label(_ value: Double?) -> String { value.map { "\(Int($0.rounded()))%" } ?? "未知" }
319
+ let summary = [
320
+ weekly == nil ? nil : "外侧粗环 · 周额度:\(label(weekly?.remaining))\(ring.weeklyStale ? "(未知或已过期)" : "")",
321
+ fiveHour == nil ? nil : "\(weekly == nil ? "单环" : "内侧细环") · 5 小时:\(label(fiveHour?.remaining))\(ring.stale ? "(未知或已过期)" : "")"
322
+ ].compactMap { $0 }.joined(separator: "\n")
205
323
  let fetched = snapshot.map { Date(timeIntervalSince1970: $0.fetchedAt).formatted(date: .omitted, time: .standard) } ?? "尚未更新"
206
- ring.toolTip = "本机 Codex CLI 账号额度\n\(chosen?.name ?? "未选择窗口")\n\(error ?? snapshot?.detail ?? "正在读取")\n状态:\(stale ? "数据未知或已过期" : "已更新")\n更新时间:\(fetched)\n拖动校准位置;双击刷新;右键打开菜单"
207
- ring.setAccessibilityLabel("Codex \(chosen?.name ?? "") 剩余 \(chosen?.remaining.map { "\(Int($0.rounded()))%" } ?? "未知"),\(ring.caption)")
324
+ ring.toolTip = "本机 Codex CLI 账号额度\n\(summary)\n\(error ?? snapshot?.detail ?? "正在读取")\n更新时间:\(fetched)\n中心数字为\(ring.displaysWeeklyPercentage ? "周" : "5小时")剩余;拖动校准位置;双击刷新;右键打开菜单"
325
+ ring.setAccessibilityLabel("Codex \(summary)")
208
326
  ring.needsDisplay = true
209
327
  }
210
328
  @objc func refresh() {
@@ -236,7 +354,7 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
236
354
  }
237
355
  query = process
238
356
  do { try process.run() }
239
- catch { query = nil; self.error = "无法启动额度查询,请重新运行 easy-codex overlay"; updateRing() }
357
+ catch { query = nil; self.error = "无法启动额度查询,请重新运行 easy-claude overlay"; updateRing() }
240
358
  }
241
359
  func item(_ title: String, _ action: Selector?) -> NSMenuItem {
242
360
  let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
@@ -250,16 +368,41 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
250
368
  if let error { menu.addItem(item(error, nil)) }
251
369
  if snapshot?.restricted == true { menu.addItem(item("服务端提示:当前使用受限", nil)) }
252
370
  menu.addItem(.separator())
253
- for window in snapshot?.windows ?? [] {
371
+ for (minutes, label) in [(300.0, "5小时"), (10080.0, "周")] {
372
+ let id = minutes == 10080 ? "weekly" : "fiveHour"
373
+ guard let window = codexWindow(snapshot?.windows ?? [], minutes: minutes) else { continue }
254
374
  let remaining = window.remaining.map { "\(Int($0.rounded()))%" } ?? "未知"
255
- let option = item("\(window.name) · \(remaining)", #selector(selectWindow(_:)))
256
- option.representedObject = window.id
257
- option.state = chosen?.id == window.id ? .on : .off
375
+ let reset: String
376
+ if let seconds = window.resetsAt {
377
+ let formatter = DateFormatter()
378
+ formatter.locale = Locale(identifier: "zh_CN")
379
+ formatter.timeZone = .current
380
+ formatter.dateFormat = "MM-dd HH:mm"
381
+ reset = formatter.string(from: Date(timeIntervalSince1970: seconds))
382
+ } else { reset = "未知" }
383
+ let option = item("\(label) · \(remaining) · 重置 \(reset)", #selector(selectPercentage(_:)))
384
+ option.representedObject = id
385
+ option.state = (ring.displaysWeeklyPercentage == (id == "weekly")) ? .on : .off
258
386
  menu.addItem(option)
259
387
  }
260
388
  menu.addItem(item(query == nil ? "立即刷新" : "正在刷新…", query == nil ? #selector(refresh) : nil))
261
389
  menu.addItem(item("恢复默认位置", #selector(resetPosition)))
262
390
  menu.addItem(.separator())
391
+ let toggle = item("显示电子时钟", #selector(toggleClock))
392
+ toggle.state = clockEnabled ? .on : .off
393
+ menu.addItem(toggle)
394
+ let zones = NSMenu()
395
+ for city in ClockCity.presets {
396
+ let option = item(city.name, #selector(selectTimeZone(_:)))
397
+ option.representedObject = city.name
398
+ option.state = clock.cityName == city.name && clock.zone.identifier == city.identifier ? .on : .off
399
+ zones.addItem(option)
400
+ }
401
+ zones.addItem(item("自定义时区…", #selector(customTimeZone)))
402
+ let zoneItem = item("时区 · \(clock.cityName)", nil)
403
+ zoneItem.submenu = zones
404
+ menu.addItem(zoneItem)
405
+ menu.addItem(.separator())
263
406
  menu.addItem(item("退出浮窗", #selector(quit)))
264
407
  return menu
265
408
  }
@@ -268,12 +411,51 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
268
411
  let fresh = makeMenu()
269
412
  for item in fresh.items { fresh.removeItem(item); menu.addItem(item) }
270
413
  }
271
- @objc func selectWindow(_ sender: NSMenuItem) {
272
- selectedID = sender.representedObject as? String
273
- UserDefaults.standard.set(selectedID, forKey: "selectedWindow")
414
+ @objc func selectPercentage(_ sender: NSMenuItem) {
415
+ guard let id = sender.representedObject as? String else { return }
416
+ ring.showWeeklyPercentage = id == "weekly"
417
+ UserDefaults.standard.set(ring.showWeeklyPercentage, forKey: "showWeeklyPercentage")
274
418
  updateRing()
275
419
  }
420
+ @objc func toggleClock() {
421
+ clockEnabled.toggle()
422
+ UserDefaults.standard.set(clockEnabled, forKey: "clockEnabled")
423
+ tick()
424
+ }
425
+ func setTimeZone(_ identifier: String, cityName: String? = nil) -> Bool {
426
+ guard let zone = TimeZone(identifier: identifier) else { return false }
427
+ clock.zone = zone
428
+ clock.cityName = cityName ?? ClockCity.presets.first { $0.identifier == zone.identifier }?.name ?? zone.identifier
429
+ UserDefaults.standard.set(clock.cityName, forKey: "clockCity")
430
+ UserDefaults.standard.set(zone.identifier, forKey: "clockTimeZone")
431
+ clock.needsDisplay = true
432
+ return true
433
+ }
434
+ @objc func selectTimeZone(_ sender: NSMenuItem) {
435
+ if let name = sender.representedObject as? String,
436
+ let city = ClockCity.presets.first(where: { $0.name == name }) {
437
+ _ = setTimeZone(city.identifier, cityName: city.name)
438
+ }
439
+ }
440
+ @objc func customTimeZone() {
441
+ let alert = NSAlert()
442
+ alert.messageText = "设置电子时钟时区"
443
+ alert.informativeText = "输入 IANA 时区,例如 America/Los_Angeles;自动适应夏令时。"
444
+ let field = NSTextField(string: clock.zone.identifier)
445
+ field.frame = CGRect(x: 0, y: 0, width: 300, height: 24)
446
+ alert.accessoryView = field
447
+ alert.addButton(withTitle: "保存")
448
+ alert.addButton(withTitle: "取消")
449
+ NSApp.activate(ignoringOtherApps: true)
450
+ while alert.runModal() == .alertFirstButtonReturn {
451
+ if setTimeZone(field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) { break }
452
+ alert.informativeText = "无效时区,请输入有效的 IANA 名称,例如 America/Los_Angeles。"
453
+ }
454
+ }
276
455
  @objc func resetPosition() {
456
+ clockInset = CGPoint(x: 2, y: 116)
457
+ UserDefaults.standard.removeObject(forKey: "clockLeftInset")
458
+ UserDefaults.standard.removeObject(forKey: "clockBottomInset")
277
459
  inset = CGPoint(x: 10, y: 74)
278
460
  UserDefaults.standard.removeObject(forKey: "leftInset")
279
461
  UserDefaults.standard.removeObject(forKey: "bottomInset")
@@ -291,6 +473,34 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
291
473
  struct Main {
292
474
  @MainActor static func main() {
293
475
  if CommandLine.arguments.contains("--self-test") {
476
+ precondition(ClockCity.presets.map(\.name) == ["硅谷", "洛杉矶", "西雅图", "华盛顿", "哥伦布", "新加坡", "上海"])
477
+ precondition(ClockCity.presets.allSatisfy { TimeZone(identifier: $0.identifier) != nil })
478
+ let weekOnly = UsageWindow(id: "codex:primary", label: "7天", name: "codex", remaining: 80, resetsAt: nil, windowDurationMins: 10080)
479
+ let otherHours = UsageWindow(id: "other:primary", label: "5小时", name: "other", remaining: 10, resetsAt: nil, windowDurationMins: 300)
480
+ precondition(codexWindow([otherHours, weekOnly], minutes: 10080)?.remaining == 80)
481
+ precondition(codexWindow([otherHours, weekOnly], minutes: 300) == nil)
482
+ let testRing = RingView()
483
+ testRing.showFiveHourRing = false
484
+ precondition(testRing.displaysWeeklyPercentage)
485
+ testRing.showWeeklyPercentage = true
486
+ testRing.showWeeklyRing = false
487
+ testRing.showFiveHourRing = true
488
+ precondition(!testRing.displaysWeeklyPercentage)
489
+ let la = TimeZone(identifier: "America/Los_Angeles")!
490
+ let singapore = TimeZone(identifier: "Asia/Singapore")!
491
+ let winter = ISO8601DateFormatter().date(from: "2026-01-15T12:00:00Z")!
492
+ let summer = ISO8601DateFormatter().date(from: "2026-07-15T12:00:00Z")!
493
+ precondition(clockText(at: winter, zone: la) == "04:00")
494
+ precondition(clockText(at: summer, zone: la) == "05:00")
495
+ precondition(clockText(at: winter, zone: singapore) == "20:00")
496
+ precondition(TimeZone(identifier: "Invalid/Zone") == nil)
497
+ let utc = TimeZone(secondsFromGMT: 0)!
498
+ for (hour, state) in [(0, RingState.red), (6, .red), (8, .red), (9, .green), (14, .green), (15, .orange), (23, .orange)] {
499
+ let date = Date(timeIntervalSince1970: Double(hour * 3600))
500
+ precondition(clockState(at: date, zone: utc) == state)
501
+ }
502
+ precondition(clockState(at: winter, zone: la) == .red)
503
+ precondition(clockState(at: winter, zone: singapore) == .orange)
294
504
  assert(RingState.resolve(51, stale: false) == .green)
295
505
  assert(RingState.resolve(50, stale: false) == .orange)
296
506
  assert(RingState.resolve(20, stale: false) == .orange)
@@ -321,6 +531,9 @@ struct Main {
321
531
  (transform as NSAffineTransform).concat()
322
532
  let view = RingView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
323
533
  view.remaining = value < 0 ? nil : value; view.stale = value < 0
534
+ view.weeklyRemaining = value < 0 ? nil : 100 - value; view.weeklyStale = value < 0
535
+ if index == 0 { view.showFiveHourRing = false }
536
+ if index == 1 { view.showWeeklyRing = false }
324
537
  view.caption = value < 0 ? "额度未知" : "7天"
325
538
  view.draw(view.bounds)
326
539
  NSGraphicsContext.restoreGraphicsState()
@@ -5,7 +5,7 @@
5
5
  <key>CFBundleIdentifier</key><string>com.unionschool.easy-codex.overlay</string>
6
6
  <key>CFBundleName</key><string>Easy Codex Overlay</string>
7
7
  <key>CFBundlePackageType</key><string>APPL</string>
8
- <key>CFBundleShortVersionString</key><string>0.1.3</string>
8
+ <key>CFBundleShortVersionString</key><string>0.2.0</string>
9
9
  <key>LSMinimumSystemVersion</key><string>14.0</string>
10
10
  <key>LSUIElement</key><true/>
11
11
  <key>NSHighResolutionCapable</key><true/>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "easy-codex",
3
- "version": "0.1.3",
4
- "description": "Display remaining Codex account usage limits",
3
+ "version": "0.2.0",
4
+ "description": "Codex usage limits and transparent world clock overlay",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "easy-codex": "bin/easy-codex.js"
@@ -16,6 +16,7 @@ export function overlayData(result, now = Date.now()) {
16
16
  ? mins % 1440 === 0 ? `${mins / 1440}天` : mins % 60 === 0 ? `${mins / 60}小时` : `${mins}分钟`
17
17
  : key === 'primary' ? '主窗口' : '次窗口';
18
18
  windows.push({ id: `${id}:${key}`, label,
19
+ windowDurationMins: Number.isFinite(mins) && mins > 0 ? mins : null,
19
20
  name: `${String(bucket.limitName || id).replace(/[\x00-\x1f\x7f-\x9f]/g, '')} · ${label}`,
20
21
  remaining: Number.isFinite(w.usedPercent) ? Math.max(0, Math.min(100, 100 - w.usedPercent)) : null,
21
22
  resetsAt: Number.isFinite(w.resetsAt) && Math.abs(w.resetsAt) < 8.64e12 ? w.resetsAt : null });
package/src/overlay.js CHANGED
@@ -11,5 +11,5 @@ export async function launchOverlay() {
11
11
  catch { throw new Error('尚未构建浮窗,请在项目目录执行 npm run build:overlay。'); }
12
12
  await promisify(execFile)('/usr/bin/open', ['-g', '-a', app, '--args',
13
13
  process.execPath, fileURLToPath(new URL('./overlay-query.js', import.meta.url)), process.env.PATH ?? '', process.env.CODEX_HOME ?? '']);
14
- console.log('额度浮窗已启动;Codex 在前台时显示。拖动调整位置,右键或菜单栏 EC 可刷新、切换额度和退出。');
14
+ console.log('额度与电子时钟浮窗已启动;Codex 在前台时显示。拖动调整位置,右键或菜单栏 EC 可刷新、切换中心额度、设置时区和退出。');
15
15
  }