easy-codex 0.1.2 → 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
|
@@ -51,13 +51,13 @@ macOS 14+ 安装后运行:
|
|
|
51
51
|
easy-codex overlay
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
独立 AppKit 浮窗在 Codex 位于前台时显示,切到其他应用、最小化或关闭 Codex 窗口后隐藏。默认位于 Codex
|
|
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
|
-
-
|
|
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橙色,自动处理夏令时,不修改电脑系统时区。菜单“恢复默认位置”应用竖排默认布局;已有拖动位置继续保留。
|
package/native/Overlay.swift
CHANGED
|
@@ -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 = ""
|
|
@@ -46,7 +54,7 @@ func appKitRect(_ rect: CGRect, primaryHeight: CGFloat) -> CGRect {
|
|
|
46
54
|
CGRect(x: rect.minX, y: primaryHeight - rect.maxY, width: rect.width, height: rect.height)
|
|
47
55
|
}
|
|
48
56
|
func ringOrigin(in rect: CGRect, inset: CGPoint) -> CGPoint {
|
|
49
|
-
CGPoint(x: max(rect.minX, min(rect.maxX - 32, rect.
|
|
57
|
+
CGPoint(x: max(rect.minX, min(rect.maxX - 32, rect.minX + inset.x)),
|
|
50
58
|
y: max(rect.minY, min(rect.maxY - 32, rect.minY + inset.y)))
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -68,8 +76,14 @@ func targetWindow() -> CGRect? {
|
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
@MainActor
|
|
71
|
-
|
|
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 -
|
|
85
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
|
@@ -150,10 +226,35 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
150
226
|
node = args[1]; script = args[2]; searchPath = args[3]
|
|
151
227
|
codexHome = args.count > 4 ? args[4] : ""
|
|
152
228
|
let prefs = UserDefaults.standard
|
|
153
|
-
if prefs.object(forKey: "
|
|
154
|
-
inset = CGPoint(x: prefs.double(forKey: "
|
|
229
|
+
if prefs.object(forKey: "leftInset") != nil {
|
|
230
|
+
inset = CGPoint(x: prefs.double(forKey: "leftInset"), y: prefs.double(forKey: "bottomInset"))
|
|
231
|
+
}
|
|
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")
|
|
155
257
|
}
|
|
156
|
-
selectedID = prefs.string(forKey: "selectedWindow")
|
|
157
258
|
panel.isOpaque = false
|
|
158
259
|
panel.backgroundColor = .clear
|
|
159
260
|
panel.hasShadow = false
|
|
@@ -165,8 +266,8 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
165
266
|
ring.refresh = { [weak self] in self?.refresh() }
|
|
166
267
|
ring.moved = { [weak self] point in
|
|
167
268
|
guard let self, let rect = self.lastRect else { return }
|
|
168
|
-
self.inset = CGPoint(x:
|
|
169
|
-
prefs.set(self.inset.x, forKey: "
|
|
269
|
+
self.inset = CGPoint(x: point.x - rect.minX, y: point.y - rect.minY)
|
|
270
|
+
prefs.set(self.inset.x, forKey: "leftInset")
|
|
170
271
|
prefs.set(self.inset.y, forKey: "bottomInset")
|
|
171
272
|
}
|
|
172
273
|
ring.setAccessibilityElement(true)
|
|
@@ -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 {
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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\(
|
|
207
|
-
ring.setAccessibilityLabel("Codex \(
|
|
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-
|
|
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
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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,14 +411,53 @@ 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
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
UserDefaults.standard.removeObject(forKey: "
|
|
460
|
+
UserDefaults.standard.removeObject(forKey: "leftInset")
|
|
279
461
|
UserDefaults.standard.removeObject(forKey: "bottomInset")
|
|
280
462
|
tick()
|
|
281
463
|
}
|
|
@@ -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)
|
|
@@ -299,8 +509,9 @@ struct Main {
|
|
|
299
509
|
assert(RingState.resolve(99, stale: true) == .unknown)
|
|
300
510
|
assert(appKitRect(CGRect(x: -900, y: 100, width: 800, height: 600), primaryHeight: 900).origin == CGPoint(x: -900, y: 200))
|
|
301
511
|
let bounds = CGRect(x: 100, y: 100, width: 800, height: 600)
|
|
302
|
-
assert(ringOrigin(in: bounds, inset: CGPoint(x: 10, y: 74)) == CGPoint(x:
|
|
303
|
-
assert(ringOrigin(in: bounds, inset: CGPoint(x: -100, y: -100)) == CGPoint(x:
|
|
512
|
+
assert(ringOrigin(in: bounds, inset: CGPoint(x: 10, y: 74)) == CGPoint(x: 110, y: 174))
|
|
513
|
+
assert(ringOrigin(in: bounds, inset: CGPoint(x: -100, y: -100)) == CGPoint(x: 100, y: 100))
|
|
514
|
+
assert(ringOrigin(in: bounds, inset: CGPoint(x: 9999, y: 9999)) == CGPoint(x: 868, y: 668))
|
|
304
515
|
print("Swift 圆环颜色与窗口坐标检查通过")
|
|
305
516
|
return
|
|
306
517
|
}
|
|
@@ -320,6 +531,9 @@ struct Main {
|
|
|
320
531
|
(transform as NSAffineTransform).concat()
|
|
321
532
|
let view = RingView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
|
|
322
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 }
|
|
323
537
|
view.caption = value < 0 ? "额度未知" : "7天"
|
|
324
538
|
view.draw(view.bounds)
|
|
325
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.
|
|
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/>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "easy-codex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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"
|
package/src/overlay-data.js
CHANGED
|
@@ -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('
|
|
14
|
+
console.log('额度与电子时钟浮窗已启动;Codex 在前台时显示。拖动调整位置,右键或菜单栏 EC 可刷新、切换中心额度、设置时区和退出。');
|
|
15
15
|
}
|