easy-codex 0.1.3 → 0.2.1
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 +14 -4
- package/bin/easy-codex.js +3 -3
- package/native/Overlay.swift +284 -42
- package/native/build/EasyCodexOverlay.app/Contents/Info.plist +1 -1
- package/native/build/EasyCodexOverlay.app/Contents/MacOS/EasyCodexOverlay +0 -0
- package/package.json +2 -2
- package/src/overlay-data.js +1 -0
- package/src/overlay.js +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ easy-codex
|
|
|
19
19
|
显示透明圆环(macOS):
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
easy-codex
|
|
22
|
+
easy-codex show
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
需要 Node.js 22+ 和已登录的官方 Codex CLI。安装包自带浮窗,使用者无需下载源码、安装依赖或编译。
|
|
@@ -48,16 +48,16 @@ npm test
|
|
|
48
48
|
macOS 14+ 安装后运行:
|
|
49
49
|
|
|
50
50
|
```sh
|
|
51
|
-
easy-codex
|
|
51
|
+
easy-codex show
|
|
52
52
|
```
|
|
53
53
|
|
|
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
|
-
-
|
|
60
|
+
- 按服务端真实时长识别窗口并优先 codex 桶,不跨桶拼接:只有周额度时显示单周环和周剩余数字,不显示不存在的5小时项;两项都有时周外粗环、5小时内细环,均为实线剩余弧。菜单切换中心数字,显示本地时区重置时间。
|
|
61
61
|
- 悬停查看额度详情与更新时间;双击刷新;右键或菜单栏 **EC** 可立即刷新、恢复默认位置、退出。
|
|
62
62
|
- 这是 **CLI 登录账号** 的额度,不自动确认桌面端正在使用同一账号。切换账号后请核对并手动刷新。
|
|
63
63
|
|
|
@@ -77,3 +77,13 @@ 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橙色,自动处理夏令时,不修改电脑系统时区。菜单“恢复默认位置”应用竖排默认布局;已有拖动位置继续保留。
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
浮窗使用 `easy-codex show` 启动;原 `overlay` 命令保留兼容。
|
|
88
|
+
|
|
89
|
+
周倒计时只显示最大单位,例如“6天后 25日 06:17 重置”,不足一天按小时、不足一小时按分钟。5小时窗口保留时分组合,例如“3小时2分后 07:59 重置”,没有小时则“22分后 07:59 重置”,省略为零的单位。绝对重置时刻使用电脑本地时区;周含日,5小时不含日期。
|
package/bin/easy-codex.js
CHANGED
|
@@ -5,16 +5,16 @@ import { readUsage, formatUsage } from '../src/usage.js';
|
|
|
5
5
|
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
7
|
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
8
|
-
console.log('用法:easy-codex [usage|overlay]\n显示已登录 Codex 账号的剩余百分比和重置时间。需要官方 Codex CLI。');
|
|
8
|
+
console.log('用法:easy-codex [usage|show|overlay]\n显示已登录 Codex 账号的剩余百分比和重置时间。需要官方 Codex CLI。');
|
|
9
9
|
} else if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
10
10
|
console.log(version);
|
|
11
|
-
} else if (args.length === 1 &&
|
|
11
|
+
} else if (args.length === 1 && ['show', 'overlay'].includes(args[0])) {
|
|
12
12
|
try { const { launchOverlay } = await import('../src/overlay.js'); await launchOverlay(); }
|
|
13
13
|
catch (error) { console.error(`easy-codex: ${error.message}`); process.exitCode = 1; }
|
|
14
14
|
} else if (args.length === 0 || (args.length === 1 && args[0] === 'usage')) {
|
|
15
15
|
try { console.log(formatUsage(await readUsage())); }
|
|
16
16
|
catch (error) { console.error(`easy-codex: ${error.message}`); process.exitCode = 1; }
|
|
17
17
|
} else {
|
|
18
|
-
console.error('未知参数。用法:easy-codex [usage|overlay],或 easy-codex --help');
|
|
18
|
+
console.error('未知参数。用法:easy-codex [usage|show|overlay],或 easy-codex --help');
|
|
19
19
|
process.exitCode = 1;
|
|
20
20
|
}
|
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 = ""
|
|
@@ -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,94 @@ 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 resetDescription(_ resetsAt: Double?, weekly: Bool, now: Date = Date(), zone: TimeZone = .current) -> String {
|
|
165
|
+
guard let resetsAt, resetsAt.isFinite, abs(resetsAt) < 8.64e12 else { return "重置时间未知" }
|
|
166
|
+
let remaining = resetsAt - now.timeIntervalSince1970
|
|
167
|
+
guard remaining > 0 else { return "已到重置时间,等待更新" }
|
|
168
|
+
let formatter = DateFormatter()
|
|
169
|
+
formatter.locale = Locale(identifier: "zh_CN")
|
|
170
|
+
formatter.timeZone = zone
|
|
171
|
+
formatter.dateFormat = weekly ? "d'日' HH:mm" : "HH:mm"
|
|
172
|
+
let time = formatter.string(from: Date(timeIntervalSince1970: resetsAt))
|
|
173
|
+
let duration: String
|
|
174
|
+
if weekly {
|
|
175
|
+
if remaining >= 86400 { duration = "\(Int(remaining / 86400))天" }
|
|
176
|
+
else if remaining >= 3600 { duration = "\(Int(remaining / 3600))小时" }
|
|
177
|
+
else { duration = "\(Int(ceil(remaining / 60)))分钟" }
|
|
178
|
+
} else {
|
|
179
|
+
let totalMinutes = Int(ceil(remaining / 60))
|
|
180
|
+
let hours = totalMinutes / 60
|
|
181
|
+
let minutes = totalMinutes % 60
|
|
182
|
+
duration = (hours > 0 ? "\(hours)小时" : "") + (minutes > 0 ? "\(minutes)分" : "")
|
|
183
|
+
}
|
|
184
|
+
return "\(duration)后 \(time) 重置"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func clockText(at date: Date, zone: TimeZone) -> String {
|
|
188
|
+
let formatter = DateFormatter()
|
|
189
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
190
|
+
formatter.timeZone = zone
|
|
191
|
+
formatter.dateFormat = "HH:mm"
|
|
192
|
+
return formatter.string(from: date)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func clockState(at date: Date, zone: TimeZone) -> RingState {
|
|
196
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
197
|
+
calendar.timeZone = zone
|
|
198
|
+
let hour = calendar.component(.hour, from: date)
|
|
199
|
+
if hour >= 9 && hour < 15 { return .green }
|
|
200
|
+
if hour >= 15 { return .orange }
|
|
201
|
+
return .red
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@MainActor
|
|
205
|
+
final class ClockView: RingView {
|
|
206
|
+
var zone = TimeZone(identifier: "America/Los_Angeles")!
|
|
207
|
+
var cityName = "硅谷"
|
|
208
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
209
|
+
let style = NSMutableParagraphStyle()
|
|
210
|
+
style.alignment = .center
|
|
211
|
+
let now = Date()
|
|
212
|
+
let time = clockText(at: now, zone: zone)
|
|
213
|
+
(time as NSString).draw(in: CGRect(x: 0, y: 17, width: bounds.width, height: 25),
|
|
214
|
+
withAttributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium),
|
|
215
|
+
.foregroundColor: clockState(at: now, zone: zone).color, .paragraphStyle: style])
|
|
216
|
+
let city = cityName
|
|
217
|
+
(city as NSString).draw(in: CGRect(x: 0, y: 1, width: bounds.width, height: 15),
|
|
218
|
+
withAttributes: [.font: NSFont.systemFont(ofSize: 9),
|
|
219
|
+
.foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: style])
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
128
223
|
@MainActor
|
|
129
224
|
final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
130
225
|
let panel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 32, height: 32),
|
|
131
226
|
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
132
227
|
let ring = RingView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
|
|
228
|
+
let clockPanel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 48, height: 46),
|
|
229
|
+
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
230
|
+
let clock = ClockView(frame: CGRect(x: 0, y: 0, width: 48, height: 46))
|
|
231
|
+
var clockInset = CGPoint(x: 2, y: 116)
|
|
232
|
+
var clockEnabled = true
|
|
133
233
|
var status: NSStatusItem?
|
|
134
234
|
var timer: Timer?
|
|
135
235
|
var query: Process?
|
|
136
236
|
var snapshot: Snapshot?
|
|
137
237
|
var error: String?
|
|
138
|
-
var selectedID: String?
|
|
139
238
|
var lastAttempt = Date.distantPast
|
|
140
239
|
var lastRect: CGRect?
|
|
141
240
|
var inset = CGPoint(x: 10, y: 74)
|
|
@@ -153,7 +252,32 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
153
252
|
if prefs.object(forKey: "leftInset") != nil {
|
|
154
253
|
inset = CGPoint(x: prefs.double(forKey: "leftInset"), y: prefs.double(forKey: "bottomInset"))
|
|
155
254
|
}
|
|
156
|
-
|
|
255
|
+
if let identifier = prefs.string(forKey: "clockTimeZone"), let zone = TimeZone(identifier: identifier) {
|
|
256
|
+
clock.zone = zone
|
|
257
|
+
let savedCity = prefs.string(forKey: "clockCity")
|
|
258
|
+
let preset = ClockCity.presets.first { $0.name == savedCity && $0.identifier == identifier }
|
|
259
|
+
?? ClockCity.presets.first { $0.identifier == identifier }
|
|
260
|
+
clock.cityName = preset?.name ?? identifier
|
|
261
|
+
}
|
|
262
|
+
ring.showWeeklyPercentage = prefs.bool(forKey: "showWeeklyPercentage")
|
|
263
|
+
clockEnabled = prefs.object(forKey: "clockEnabled") == nil || prefs.bool(forKey: "clockEnabled")
|
|
264
|
+
if prefs.object(forKey: "clockLeftInset") != nil {
|
|
265
|
+
clockInset = CGPoint(x: prefs.double(forKey: "clockLeftInset"), y: prefs.double(forKey: "clockBottomInset"))
|
|
266
|
+
}
|
|
267
|
+
clockPanel.isOpaque = false
|
|
268
|
+
clockPanel.backgroundColor = .clear
|
|
269
|
+
clockPanel.hasShadow = false
|
|
270
|
+
clockPanel.level = .floating
|
|
271
|
+
clockPanel.hidesOnDeactivate = false
|
|
272
|
+
clockPanel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
273
|
+
clockPanel.contentView = clock
|
|
274
|
+
clock.makeMenu = { [weak self] in self?.makeMenu() ?? NSMenu() }
|
|
275
|
+
clock.moved = { [weak self] point in
|
|
276
|
+
guard let self, let rect = self.lastRect else { return }
|
|
277
|
+
self.clockInset = CGPoint(x: point.x - rect.minX, y: point.y - rect.minY)
|
|
278
|
+
prefs.set(self.clockInset.x, forKey: "clockLeftInset")
|
|
279
|
+
prefs.set(self.clockInset.y, forKey: "clockBottomInset")
|
|
280
|
+
}
|
|
157
281
|
panel.isOpaque = false
|
|
158
282
|
panel.backgroundColor = .clear
|
|
159
283
|
panel.hasShadow = false
|
|
@@ -179,32 +303,49 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
179
303
|
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(hide), name: NSWorkspace.didDeactivateApplicationNotification, object: nil)
|
|
180
304
|
tick()
|
|
181
305
|
}
|
|
182
|
-
@objc func hide() { panel.orderOut(nil) }
|
|
306
|
+
@objc func hide() { panel.orderOut(nil); clockPanel.orderOut(nil) }
|
|
183
307
|
@objc func tick() {
|
|
184
|
-
guard let rect = targetWindow() else {
|
|
308
|
+
guard let rect = targetWindow() else { hide(); lastRect = nil; return }
|
|
185
309
|
lastRect = rect
|
|
310
|
+
if clockEnabled {
|
|
311
|
+
if !clock.dragging {
|
|
312
|
+
let x: CGFloat = max(rect.minX, min(rect.maxX - 48, rect.minX + clockInset.x))
|
|
313
|
+
let y: CGFloat = max(rect.minY, min(rect.maxY - 46, rect.minY + clockInset.y))
|
|
314
|
+
clockPanel.setFrameOrigin(CGPoint(x: x, y: y))
|
|
315
|
+
}
|
|
316
|
+
clock.needsDisplay = true
|
|
317
|
+
clock.toolTip = "\(clock.cityName) · \(clock.zone.identifier)"
|
|
318
|
+
clock.setAccessibilityLabel("\(clock.cityName) \(clock.zone.identifier) \(clockText(at: Date(), zone: clock.zone))")
|
|
319
|
+
if !clockPanel.isVisible { clockPanel.orderFrontRegardless() }
|
|
320
|
+
} else { clockPanel.orderOut(nil) }
|
|
186
321
|
if !ring.dragging { panel.setFrameOrigin(ringOrigin(in: rect, inset: inset)) }
|
|
187
322
|
if !panel.isVisible { panel.orderFrontRegardless() }
|
|
188
323
|
if Date().timeIntervalSince(lastAttempt) >= 60 { refresh() }
|
|
189
324
|
updateRing()
|
|
190
325
|
}
|
|
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
326
|
func updateRing() {
|
|
200
|
-
let
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
327
|
+
let windows = snapshot?.windows ?? []
|
|
328
|
+
let weekly = codexWindow(windows, minutes: 10080)
|
|
329
|
+
let fiveHour = codexWindow(windows, minutes: 300)
|
|
330
|
+
let now = Date().timeIntervalSince1970
|
|
331
|
+
let snapshotStale = error != nil || snapshot == nil || now - (snapshot?.fetchedAt ?? 0) > 120
|
|
332
|
+
func stale(_ window: UsageWindow?) -> Bool {
|
|
333
|
+
snapshotStale || window?.remaining == nil || (window?.resetsAt.map { $0 <= now } ?? false)
|
|
334
|
+
}
|
|
335
|
+
ring.showWeeklyRing = weekly != nil || fiveHour == nil
|
|
336
|
+
ring.showFiveHourRing = fiveHour != nil
|
|
337
|
+
ring.remaining = fiveHour?.remaining
|
|
338
|
+
ring.stale = stale(fiveHour)
|
|
339
|
+
ring.weeklyRemaining = weekly?.remaining
|
|
340
|
+
ring.weeklyStale = stale(weekly)
|
|
341
|
+
func label(_ value: Double?) -> String { value.map { "\(Int($0.rounded()))%" } ?? "未知" }
|
|
342
|
+
let summary = [
|
|
343
|
+
weekly == nil ? nil : "外侧粗环 · 周额度:\(label(weekly?.remaining))\(ring.weeklyStale ? "(未知或已过期)" : "")",
|
|
344
|
+
fiveHour == nil ? nil : "\(weekly == nil ? "单环" : "内侧细环") · 5 小时:\(label(fiveHour?.remaining))\(ring.stale ? "(未知或已过期)" : "")"
|
|
345
|
+
].compactMap { $0 }.joined(separator: "\n")
|
|
205
346
|
let fetched = snapshot.map { Date(timeIntervalSince1970: $0.fetchedAt).formatted(date: .omitted, time: .standard) } ?? "尚未更新"
|
|
206
|
-
ring.toolTip = "本机 Codex CLI 账号额度\n\(
|
|
207
|
-
ring.setAccessibilityLabel("Codex \(
|
|
347
|
+
ring.toolTip = "本机 Codex CLI 账号额度\n\(summary)\n\(error ?? snapshot?.detail ?? "正在读取")\n更新时间:\(fetched)\n中心数字为\(ring.displaysWeeklyPercentage ? "周" : "5小时")剩余;拖动校准位置;双击刷新;右键打开菜单"
|
|
348
|
+
ring.setAccessibilityLabel("Codex \(summary)")
|
|
208
349
|
ring.needsDisplay = true
|
|
209
350
|
}
|
|
210
351
|
@objc func refresh() {
|
|
@@ -236,7 +377,7 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
236
377
|
}
|
|
237
378
|
query = process
|
|
238
379
|
do { try process.run() }
|
|
239
|
-
catch { query = nil; self.error = "无法启动额度查询,请重新运行 easy-
|
|
380
|
+
catch { query = nil; self.error = "无法启动额度查询,请重新运行 easy-claude overlay"; updateRing() }
|
|
240
381
|
}
|
|
241
382
|
func item(_ title: String, _ action: Selector?) -> NSMenuItem {
|
|
242
383
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
|
@@ -250,16 +391,34 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
250
391
|
if let error { menu.addItem(item(error, nil)) }
|
|
251
392
|
if snapshot?.restricted == true { menu.addItem(item("服务端提示:当前使用受限", nil)) }
|
|
252
393
|
menu.addItem(.separator())
|
|
253
|
-
for
|
|
394
|
+
for (minutes, label) in [(300.0, "5小时"), (10080.0, "周")] {
|
|
395
|
+
let id = minutes == 10080 ? "weekly" : "fiveHour"
|
|
396
|
+
guard let window = codexWindow(snapshot?.windows ?? [], minutes: minutes) else { continue }
|
|
254
397
|
let remaining = window.remaining.map { "\(Int($0.rounded()))%" } ?? "未知"
|
|
255
|
-
let
|
|
256
|
-
option
|
|
257
|
-
option.
|
|
398
|
+
let reset = resetDescription(window.resetsAt, weekly: minutes == 10080)
|
|
399
|
+
let option = item("\(label) · \(remaining) · \(reset)", #selector(selectPercentage(_:)))
|
|
400
|
+
option.representedObject = id
|
|
401
|
+
option.state = (ring.displaysWeeklyPercentage == (id == "weekly")) ? .on : .off
|
|
258
402
|
menu.addItem(option)
|
|
259
403
|
}
|
|
260
404
|
menu.addItem(item(query == nil ? "立即刷新" : "正在刷新…", query == nil ? #selector(refresh) : nil))
|
|
261
405
|
menu.addItem(item("恢复默认位置", #selector(resetPosition)))
|
|
262
406
|
menu.addItem(.separator())
|
|
407
|
+
let toggle = item("显示时间", #selector(toggleClock))
|
|
408
|
+
toggle.state = clockEnabled ? .on : .off
|
|
409
|
+
menu.addItem(toggle)
|
|
410
|
+
let zones = NSMenu()
|
|
411
|
+
for city in ClockCity.presets {
|
|
412
|
+
let option = item(city.name, #selector(selectTimeZone(_:)))
|
|
413
|
+
option.representedObject = city.name
|
|
414
|
+
option.state = clock.cityName == city.name && clock.zone.identifier == city.identifier ? .on : .off
|
|
415
|
+
zones.addItem(option)
|
|
416
|
+
}
|
|
417
|
+
zones.addItem(item("自定义时区…", #selector(customTimeZone)))
|
|
418
|
+
let zoneItem = item("\(clock.cityName)·\(clockText(at: Date(), zone: clock.zone))", nil)
|
|
419
|
+
zoneItem.submenu = zones
|
|
420
|
+
menu.addItem(zoneItem)
|
|
421
|
+
menu.addItem(.separator())
|
|
263
422
|
menu.addItem(item("退出浮窗", #selector(quit)))
|
|
264
423
|
return menu
|
|
265
424
|
}
|
|
@@ -268,12 +427,51 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
268
427
|
let fresh = makeMenu()
|
|
269
428
|
for item in fresh.items { fresh.removeItem(item); menu.addItem(item) }
|
|
270
429
|
}
|
|
271
|
-
@objc func
|
|
272
|
-
|
|
273
|
-
|
|
430
|
+
@objc func selectPercentage(_ sender: NSMenuItem) {
|
|
431
|
+
guard let id = sender.representedObject as? String else { return }
|
|
432
|
+
ring.showWeeklyPercentage = id == "weekly"
|
|
433
|
+
UserDefaults.standard.set(ring.showWeeklyPercentage, forKey: "showWeeklyPercentage")
|
|
274
434
|
updateRing()
|
|
275
435
|
}
|
|
436
|
+
@objc func toggleClock() {
|
|
437
|
+
clockEnabled.toggle()
|
|
438
|
+
UserDefaults.standard.set(clockEnabled, forKey: "clockEnabled")
|
|
439
|
+
tick()
|
|
440
|
+
}
|
|
441
|
+
func setTimeZone(_ identifier: String, cityName: String? = nil) -> Bool {
|
|
442
|
+
guard let zone = TimeZone(identifier: identifier) else { return false }
|
|
443
|
+
clock.zone = zone
|
|
444
|
+
clock.cityName = cityName ?? ClockCity.presets.first { $0.identifier == zone.identifier }?.name ?? zone.identifier
|
|
445
|
+
UserDefaults.standard.set(clock.cityName, forKey: "clockCity")
|
|
446
|
+
UserDefaults.standard.set(zone.identifier, forKey: "clockTimeZone")
|
|
447
|
+
clock.needsDisplay = true
|
|
448
|
+
return true
|
|
449
|
+
}
|
|
450
|
+
@objc func selectTimeZone(_ sender: NSMenuItem) {
|
|
451
|
+
if let name = sender.representedObject as? String,
|
|
452
|
+
let city = ClockCity.presets.first(where: { $0.name == name }) {
|
|
453
|
+
_ = setTimeZone(city.identifier, cityName: city.name)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
@objc func customTimeZone() {
|
|
457
|
+
let alert = NSAlert()
|
|
458
|
+
alert.messageText = "设置电子时钟时区"
|
|
459
|
+
alert.informativeText = "输入 IANA 时区,例如 America/Los_Angeles;自动适应夏令时。"
|
|
460
|
+
let field = NSTextField(string: clock.zone.identifier)
|
|
461
|
+
field.frame = CGRect(x: 0, y: 0, width: 300, height: 24)
|
|
462
|
+
alert.accessoryView = field
|
|
463
|
+
alert.addButton(withTitle: "保存")
|
|
464
|
+
alert.addButton(withTitle: "取消")
|
|
465
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
466
|
+
while alert.runModal() == .alertFirstButtonReturn {
|
|
467
|
+
if setTimeZone(field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) { break }
|
|
468
|
+
alert.informativeText = "无效时区,请输入有效的 IANA 名称,例如 America/Los_Angeles。"
|
|
469
|
+
}
|
|
470
|
+
}
|
|
276
471
|
@objc func resetPosition() {
|
|
472
|
+
clockInset = CGPoint(x: 2, y: 116)
|
|
473
|
+
UserDefaults.standard.removeObject(forKey: "clockLeftInset")
|
|
474
|
+
UserDefaults.standard.removeObject(forKey: "clockBottomInset")
|
|
277
475
|
inset = CGPoint(x: 10, y: 74)
|
|
278
476
|
UserDefaults.standard.removeObject(forKey: "leftInset")
|
|
279
477
|
UserDefaults.standard.removeObject(forKey: "bottomInset")
|
|
@@ -291,6 +489,47 @@ final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
291
489
|
struct Main {
|
|
292
490
|
@MainActor static func main() {
|
|
293
491
|
if CommandLine.arguments.contains("--self-test") {
|
|
492
|
+
precondition(ClockCity.presets.map(\.name) == ["硅谷", "洛杉矶", "西雅图", "华盛顿", "哥伦布", "新加坡", "上海"])
|
|
493
|
+
precondition(ClockCity.presets.allSatisfy { TimeZone(identifier: $0.identifier) != nil })
|
|
494
|
+
let weekOnly = UsageWindow(id: "codex:primary", label: "7天", name: "codex", remaining: 80, resetsAt: nil, windowDurationMins: 10080)
|
|
495
|
+
let otherHours = UsageWindow(id: "other:primary", label: "5小时", name: "other", remaining: 10, resetsAt: nil, windowDurationMins: 300)
|
|
496
|
+
precondition(codexWindow([otherHours, weekOnly], minutes: 10080)?.remaining == 80)
|
|
497
|
+
precondition(codexWindow([otherHours, weekOnly], minutes: 300) == nil)
|
|
498
|
+
let countdownStart = ISO8601DateFormatter().date(from: "2026-10-15T04:57:00Z")!
|
|
499
|
+
let countdownUTC = TimeZone(secondsFromGMT: 0)!
|
|
500
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 10920, weekly: false, now: countdownStart, zone: countdownUTC) == "3小时2分后 07:59 重置")
|
|
501
|
+
let weekStart = ISO8601DateFormatter().date(from: "2026-10-14T08:03:00Z")!
|
|
502
|
+
precondition(resetDescription(weekStart.timeIntervalSince1970 + 345360, weekly: true, now: weekStart, zone: countdownUTC) == "3天后 18日 07:59 重置")
|
|
503
|
+
precondition(resetDescription(nil, weekly: true) == "重置时间未知")
|
|
504
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970, weekly: false, now: countdownStart) == "已到重置时间,等待更新")
|
|
505
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 1, weekly: false, now: countdownStart, zone: countdownUTC) == "1分后 04:57 重置")
|
|
506
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 86160, weekly: true, now: countdownStart, zone: countdownUTC) == "23小时后 16日 04:53 重置")
|
|
507
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 120, weekly: true, now: countdownStart, zone: countdownUTC) == "2分钟后 15日 04:59 重置")
|
|
508
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 86400, weekly: true, now: countdownStart, zone: countdownUTC) == "1天后 16日 04:57 重置")
|
|
509
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 86399, weekly: true, now: countdownStart, zone: countdownUTC) == "23小时后 16日 04:56 重置")
|
|
510
|
+
precondition(resetDescription(countdownStart.timeIntervalSince1970 + 3599, weekly: false, now: countdownStart, zone: countdownUTC) == "1小时后 05:56 重置")
|
|
511
|
+
let testRing = RingView()
|
|
512
|
+
testRing.showFiveHourRing = false
|
|
513
|
+
precondition(testRing.displaysWeeklyPercentage)
|
|
514
|
+
testRing.showWeeklyPercentage = true
|
|
515
|
+
testRing.showWeeklyRing = false
|
|
516
|
+
testRing.showFiveHourRing = true
|
|
517
|
+
precondition(!testRing.displaysWeeklyPercentage)
|
|
518
|
+
let la = TimeZone(identifier: "America/Los_Angeles")!
|
|
519
|
+
let singapore = TimeZone(identifier: "Asia/Singapore")!
|
|
520
|
+
let winter = ISO8601DateFormatter().date(from: "2026-01-15T12:00:00Z")!
|
|
521
|
+
let summer = ISO8601DateFormatter().date(from: "2026-07-15T12:00:00Z")!
|
|
522
|
+
precondition(clockText(at: winter, zone: la) == "04:00")
|
|
523
|
+
precondition(clockText(at: summer, zone: la) == "05:00")
|
|
524
|
+
precondition(clockText(at: winter, zone: singapore) == "20:00")
|
|
525
|
+
precondition(TimeZone(identifier: "Invalid/Zone") == nil)
|
|
526
|
+
let utc = TimeZone(secondsFromGMT: 0)!
|
|
527
|
+
for (hour, state) in [(0, RingState.red), (6, .red), (8, .red), (9, .green), (14, .green), (15, .orange), (23, .orange)] {
|
|
528
|
+
let date = Date(timeIntervalSince1970: Double(hour * 3600))
|
|
529
|
+
precondition(clockState(at: date, zone: utc) == state)
|
|
530
|
+
}
|
|
531
|
+
precondition(clockState(at: winter, zone: la) == .red)
|
|
532
|
+
precondition(clockState(at: winter, zone: singapore) == .orange)
|
|
294
533
|
assert(RingState.resolve(51, stale: false) == .green)
|
|
295
534
|
assert(RingState.resolve(50, stale: false) == .orange)
|
|
296
535
|
assert(RingState.resolve(20, stale: false) == .orange)
|
|
@@ -321,6 +560,9 @@ struct Main {
|
|
|
321
560
|
(transform as NSAffineTransform).concat()
|
|
322
561
|
let view = RingView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
|
|
323
562
|
view.remaining = value < 0 ? nil : value; view.stale = value < 0
|
|
563
|
+
view.weeklyRemaining = value < 0 ? nil : 100 - value; view.weeklyStale = value < 0
|
|
564
|
+
if index == 0 { view.showFiveHourRing = false }
|
|
565
|
+
if index == 1 { view.showWeeklyRing = false }
|
|
324
566
|
view.caption = value < 0 ? "额度未知" : "7天"
|
|
325
567
|
view.draw(view.bounds)
|
|
326
568
|
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
|
|
8
|
+
<key>CFBundleShortVersionString</key><string>0.2.1</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.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
}
|