easy-codex 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +78 -2
- package/bin/easy-codex.js +20 -0
- package/native/Overlay.swift +338 -0
- package/native/build/EasyCodexOverlay.app/Contents/Info.plist +12 -0
- package/native/build/EasyCodexOverlay.app/Contents/MacOS/EasyCodexOverlay +0 -0
- package/native/build/EasyCodexOverlay.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +37 -4
- package/scripts/build-overlay.mjs +34 -0
- package/src/overlay-data.js +25 -0
- package/src/overlay-query.js +14 -0
- package/src/overlay.js +15 -0
- package/src/usage.js +106 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UnionSchool
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# easy-codex
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
显示当前本机 Codex 登录账号的剩余额度百分比和重置时间。第三方辅助工具,与 OpenAI 无官方隶属关系。
|
|
4
|
+
|
|
5
|
+
## 安装与使用
|
|
6
|
+
|
|
7
|
+
安装一次:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm i -g easy-codex
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
查看额度:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
easy-codex
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
显示透明圆环(macOS):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
easy-codex overlay
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
需要 Node.js 22+ 和已登录的官方 Codex CLI。安装包自带浮窗,使用者无需下载源码、安装依赖或编译。
|
|
26
|
+
|
|
27
|
+
输出每个额度桶的主、次窗口。窗口时长采用服务端数据,重置时间使用本机时区。剩余比例为 `100 - usedPercent`,限制在 0–100%;未返回的字段显示“未知”。额度属于账号,不是当前会话的 Token 数,也不是 API 账单余额。即使有剩余百分比,也不代表其他服务端限制已解除。
|
|
28
|
+
|
|
29
|
+
## 数据与限制
|
|
30
|
+
|
|
31
|
+
通过官方 [App Server 协议](https://developers.openai.com/codex/app-server) 启动 `codex app-server --listen stdio://`,完成握手后调用 `account/rateLimits/read`,随后关闭本工具启动的进程。优先读取多额度桶,兼容单额度桶。
|
|
32
|
+
|
|
33
|
+
本工具不读取或保存凭据,不创建聊天、不发送模型请求、不购买额度、不消耗额度重置券。认证及必要的登录状态刷新由官方 CLI 处理,CLI 本身可能写入其运行日志。为避免敏感信息外泄,本工具不直接输出子进程日志及原始服务端错误。
|
|
34
|
+
|
|
35
|
+
查询总超时为 20 秒;失败返回非零退出码。找不到 CLI 时检查安装及 PATH;读取失败时检查 `codex login`、网络和 CLI 版本。仅安装桌面应用但没有可执行的 `codex` CLI 时无法使用。不同 CLI 版本的协议兼容性需实测。
|
|
36
|
+
|
|
37
|
+
## 开发
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
node bin/easy-codex.js
|
|
41
|
+
npm test
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
使用 Node.js 内置测试工具,覆盖格式化和模拟 App Server 通信,不依赖真实账号或网络。真实账号查询需单独执行上述命令。浮窗使用同一数据源;暂不提供多账号管理或自动安装官方 CLI。
|
|
45
|
+
|
|
46
|
+
## macOS 额度浮窗
|
|
47
|
+
|
|
48
|
+
macOS 14+ 安装后运行:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
easy-codex overlay
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
独立 AppKit 浮窗在 Codex 位于前台时显示,切到其他应用、最小化或关闭 Codex 窗口后隐藏。默认位于 Codex 窗口右下角上方;拖动可校准位置,偏移量保存在本机。它按窗口边界跟随,不自动识别头像;侧栏布局变化后可再次拖动。首版跟随最前面的一个 Codex 普通窗口。
|
|
55
|
+
|
|
56
|
+
- 圆环长度表示剩余百分比:大于 50% 绿色,20%–50% 橙色,低于 20% 红色。
|
|
57
|
+
- 浮窗为小尺寸(16×16),背景透明,无底板和阴影。圆环只显示百分比,无数据时显示 `—`,不显示窗口时长或状态文字;时长与状态见悬停提示和菜单。
|
|
58
|
+
- 未知或过期数据为灰色;失败后的过期说明见悬停提示。已到重置时间时也先标为过期,等待重新查询,不擅自恢复满额。
|
|
59
|
+
- 浮窗可见时每 60 秒刷新,查询不会重叠;回到前台且距离上次查询超过 60 秒会刷新。
|
|
60
|
+
- 默认优先 codex 额度桶,显示其中剩余比例最低的窗口;右键可切换其他窗口,不把各窗口相加。
|
|
61
|
+
- 悬停查看额度详情与更新时间;双击刷新;右键或菜单栏 **EC** 可立即刷新、恢复默认位置、退出。
|
|
62
|
+
- 这是 **CLI 登录账号** 的额度,不自动确认桌面端正在使用同一账号。切换账号后请核对并手动刷新。
|
|
63
|
+
|
|
64
|
+
浮窗不修改官方应用,不读取界面文字、不截图、不请求辅助功能权限,不自动开机启动。位置与选择保存在 `com.unionschool.easy-codex.overlay` 的用户偏好中,不保存额度历史或凭据。系统窗口接口只用于查询窗口边界及前台状态。
|
|
65
|
+
|
|
66
|
+
本地构建需要 Xcode Command Line Tools 与 Swift 6;构建包含 Apple Silicon 和 Intel 的通用应用:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm run build:overlay
|
|
70
|
+
node bin/easy-codex.js overlay
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
构建输出为 `native/build/EasyCodexOverlay.app`,使用本地 ad-hoc 签名,尚未做开发者签名或公证。全局安装的是快照,源码修改后需重建、打包并重新安装;升级前通过 EC 菜单退出旧浮窗。浮窗仅支持 macOS 14+;未做开发者签名或公证,系统策略可能阻止启动。
|
|
74
|
+
|
|
75
|
+
## 发布
|
|
76
|
+
|
|
77
|
+
GitHub Actions 在 `v*` 标签上测试、构建通用 macOS 浮窗,并发布 npm 包。标签必须与 package.json 版本一致;预发布版本使用 `next`,正式版本使用 `latest`。发布任务使用 GitHub `npm` Environment,部署记录链接到 npm 包。
|
|
78
|
+
|
|
79
|
+
首次发布需配置仓库 Secret `NPM_TOKEN`(具有该包发布权限、允许自动化发布的 npm Token)。不要把凭据写入源码。修改版本后创建对应标签触发发布;失败可在 Actions 中重新运行任务。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
4
|
+
import { readUsage, formatUsage } from '../src/usage.js';
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
8
|
+
console.log('用法:easy-codex [usage|overlay]\n显示已登录 Codex 账号的剩余百分比和重置时间。需要官方 Codex CLI。');
|
|
9
|
+
} else if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
10
|
+
console.log(version);
|
|
11
|
+
} else if (args.length === 1 && args[0] === 'overlay') {
|
|
12
|
+
try { const { launchOverlay } = await import('../src/overlay.js'); await launchOverlay(); }
|
|
13
|
+
catch (error) { console.error(`easy-codex: ${error.message}`); process.exitCode = 1; }
|
|
14
|
+
} else if (args.length === 0 || (args.length === 1 && args[0] === 'usage')) {
|
|
15
|
+
try { console.log(formatUsage(await readUsage())); }
|
|
16
|
+
catch (error) { console.error(`easy-codex: ${error.message}`); process.exitCode = 1; }
|
|
17
|
+
} else {
|
|
18
|
+
console.error('未知参数。用法:easy-codex [usage|overlay],或 easy-codex --help');
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
|
|
4
|
+
struct UsageWindow: Decodable {
|
|
5
|
+
let id: String
|
|
6
|
+
let label: String
|
|
7
|
+
let name: String
|
|
8
|
+
let remaining: Double?
|
|
9
|
+
let resetsAt: Double?
|
|
10
|
+
}
|
|
11
|
+
struct Snapshot: Decodable {
|
|
12
|
+
var windows: [UsageWindow] = []
|
|
13
|
+
var detail: String = ""
|
|
14
|
+
var fetchedAt: Double = 0
|
|
15
|
+
var restricted: Bool = false
|
|
16
|
+
var error: String?
|
|
17
|
+
|
|
18
|
+
enum CodingKeys: CodingKey { case windows, detail, fetchedAt, restricted, error }
|
|
19
|
+
init(from decoder: any Decoder) throws {
|
|
20
|
+
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
21
|
+
windows = try c.decodeIfPresent([UsageWindow].self, forKey: .windows) ?? []
|
|
22
|
+
detail = try c.decodeIfPresent(String.self, forKey: .detail) ?? ""
|
|
23
|
+
fetchedAt = try c.decodeIfPresent(Double.self, forKey: .fetchedAt) ?? 0
|
|
24
|
+
restricted = try c.decodeIfPresent(Bool.self, forKey: .restricted) ?? false
|
|
25
|
+
error = try c.decodeIfPresent(String.self, forKey: .error)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enum RingState: Equatable {
|
|
30
|
+
case green, orange, red, unknown
|
|
31
|
+
static func resolve(_ remaining: Double?, stale: Bool) -> RingState {
|
|
32
|
+
guard !stale, let remaining, remaining.isFinite else { return .unknown }
|
|
33
|
+
return remaining > 50 ? .green : remaining >= 20 ? .orange : .red
|
|
34
|
+
}
|
|
35
|
+
var color: NSColor {
|
|
36
|
+
switch self {
|
|
37
|
+
case .green: .systemGreen
|
|
38
|
+
case .orange: .systemOrange
|
|
39
|
+
case .red: .systemRed
|
|
40
|
+
case .unknown: .secondaryLabelColor
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func appKitRect(_ rect: CGRect, primaryHeight: CGFloat) -> CGRect {
|
|
46
|
+
CGRect(x: rect.minX, y: primaryHeight - rect.maxY, width: rect.width, height: rect.height)
|
|
47
|
+
}
|
|
48
|
+
func ringOrigin(in rect: CGRect, inset: CGPoint) -> CGPoint {
|
|
49
|
+
CGPoint(x: max(rect.minX, min(rect.maxX - 16, rect.maxX - 16 - inset.x)),
|
|
50
|
+
y: max(rect.minY, min(rect.maxY - 16, rect.minY + inset.y)))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@MainActor
|
|
54
|
+
func targetWindow() -> CGRect? {
|
|
55
|
+
guard let app = NSWorkspace.shared.frontmostApplication,
|
|
56
|
+
app.bundleIdentifier == "com.openai.codex" else { return nil }
|
|
57
|
+
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
|
|
58
|
+
for window in windows {
|
|
59
|
+
guard (window[kCGWindowOwnerPID as String] as? Int) == Int(app.processIdentifier),
|
|
60
|
+
(window[kCGWindowLayer as String] as? Int) == 0,
|
|
61
|
+
let bounds = window[kCGWindowBounds as String] as? [String: Any],
|
|
62
|
+
let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary),
|
|
63
|
+
rect.width >= 300, rect.height >= 200 else { continue }
|
|
64
|
+
// CG 坐标原点在主屏左上;AppKit 使用主屏左下,多屏位置也按同一基准转换。
|
|
65
|
+
return appKitRect(rect, primaryHeight: NSScreen.screens.first?.frame.height ?? 0)
|
|
66
|
+
}
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@MainActor
|
|
71
|
+
final class RingView: NSView {
|
|
72
|
+
var remaining: Double?
|
|
73
|
+
var stale = true
|
|
74
|
+
var caption = "等待额度"
|
|
75
|
+
var makeMenu: (() -> NSMenu)?
|
|
76
|
+
var moved: ((CGPoint) -> Void)?
|
|
77
|
+
var refresh: (() -> Void)?
|
|
78
|
+
var dragging = false
|
|
79
|
+
private var dragStart = CGPoint.zero
|
|
80
|
+
private var originStart = CGPoint.zero
|
|
81
|
+
|
|
82
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
83
|
+
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
84
|
+
let track = NSBezierPath(ovalIn: CGRect(x: center.x - 7, y: center.y - 7, width: 14, height: 14))
|
|
85
|
+
track.lineWidth = 1
|
|
86
|
+
NSColor.labelColor.withAlphaComponent(0.12).setStroke()
|
|
87
|
+
track.stroke()
|
|
88
|
+
let state = RingState.resolve(remaining, stale: stale)
|
|
89
|
+
if let remaining, remaining > 0, !stale {
|
|
90
|
+
let arc = NSBezierPath()
|
|
91
|
+
arc.lineWidth = 1
|
|
92
|
+
arc.lineCapStyle = .round
|
|
93
|
+
arc.appendArc(withCenter: center, radius: 7, 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: 5, font: .monospacedDigitSystemFont(ofSize: 4.5, weight: .semibold), color: state.color)
|
|
100
|
+
}
|
|
101
|
+
private func drawText(_ text: String, y: CGFloat, font: NSFont, color: NSColor) {
|
|
102
|
+
let style = NSMutableParagraphStyle()
|
|
103
|
+
style.alignment = .center
|
|
104
|
+
style.lineBreakMode = .byTruncatingTail
|
|
105
|
+
(text as NSString).draw(in: CGRect(x: 1, y: y, width: bounds.width - 2, height: 6),
|
|
106
|
+
withAttributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
|
|
107
|
+
}
|
|
108
|
+
override func mouseDown(with event: NSEvent) {
|
|
109
|
+
if event.clickCount == 2 { refresh?(); return }
|
|
110
|
+
dragging = true
|
|
111
|
+
dragStart = NSEvent.mouseLocation
|
|
112
|
+
originStart = window?.frame.origin ?? .zero
|
|
113
|
+
}
|
|
114
|
+
override func mouseDragged(with event: NSEvent) {
|
|
115
|
+
guard dragging else { return }
|
|
116
|
+
let point = NSEvent.mouseLocation
|
|
117
|
+
window?.setFrameOrigin(CGPoint(x: originStart.x + point.x - dragStart.x, y: originStart.y + point.y - dragStart.y))
|
|
118
|
+
}
|
|
119
|
+
override func mouseUp(with event: NSEvent) {
|
|
120
|
+
if dragging, let origin = window?.frame.origin { moved?(origin) }
|
|
121
|
+
dragging = false
|
|
122
|
+
}
|
|
123
|
+
override func rightMouseDown(with event: NSEvent) {
|
|
124
|
+
if let menu = makeMenu?() { NSMenu.popUpContextMenu(menu, with: event, for: self) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@MainActor
|
|
129
|
+
final class Overlay: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
130
|
+
let panel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 16, height: 16),
|
|
131
|
+
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
|
132
|
+
let ring = RingView(frame: CGRect(x: 0, y: 0, width: 16, height: 16))
|
|
133
|
+
var status: NSStatusItem?
|
|
134
|
+
var timer: Timer?
|
|
135
|
+
var query: Process?
|
|
136
|
+
var snapshot: Snapshot?
|
|
137
|
+
var error: String?
|
|
138
|
+
var selectedID: String?
|
|
139
|
+
var lastAttempt = Date.distantPast
|
|
140
|
+
var lastRect: CGRect?
|
|
141
|
+
var inset = CGPoint(x: 10, y: 74)
|
|
142
|
+
var node = ""
|
|
143
|
+
var script = ""
|
|
144
|
+
var searchPath = ""
|
|
145
|
+
var codexHome = ""
|
|
146
|
+
|
|
147
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
148
|
+
let args = CommandLine.arguments
|
|
149
|
+
guard args.count >= 4 else { NSApp.terminate(nil); return }
|
|
150
|
+
node = args[1]; script = args[2]; searchPath = args[3]
|
|
151
|
+
codexHome = args.count > 4 ? args[4] : ""
|
|
152
|
+
let prefs = UserDefaults.standard
|
|
153
|
+
if prefs.object(forKey: "rightInset") != nil {
|
|
154
|
+
inset = CGPoint(x: prefs.double(forKey: "rightInset"), y: prefs.double(forKey: "bottomInset"))
|
|
155
|
+
}
|
|
156
|
+
selectedID = prefs.string(forKey: "selectedWindow")
|
|
157
|
+
panel.isOpaque = false
|
|
158
|
+
panel.backgroundColor = .clear
|
|
159
|
+
panel.hasShadow = false
|
|
160
|
+
panel.level = .floating
|
|
161
|
+
panel.hidesOnDeactivate = false
|
|
162
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
163
|
+
panel.contentView = ring
|
|
164
|
+
ring.makeMenu = { [weak self] in self?.makeMenu() ?? NSMenu() }
|
|
165
|
+
ring.refresh = { [weak self] in self?.refresh() }
|
|
166
|
+
ring.moved = { [weak self] point in
|
|
167
|
+
guard let self, let rect = self.lastRect else { return }
|
|
168
|
+
self.inset = CGPoint(x: rect.maxX - point.x - 16, y: point.y - rect.minY)
|
|
169
|
+
prefs.set(self.inset.x, forKey: "rightInset")
|
|
170
|
+
prefs.set(self.inset.y, forKey: "bottomInset")
|
|
171
|
+
}
|
|
172
|
+
ring.setAccessibilityElement(true)
|
|
173
|
+
ring.setAccessibilityRole(.button)
|
|
174
|
+
status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
175
|
+
status?.button?.title = "EC"
|
|
176
|
+
status?.button?.toolTip = "Easy Codex 额度浮窗"
|
|
177
|
+
let menu = NSMenu(); menu.delegate = self; status?.menu = menu
|
|
178
|
+
timer = Timer.scheduledTimer(timeInterval: 0.4, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
|
|
179
|
+
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(hide), name: NSWorkspace.didDeactivateApplicationNotification, object: nil)
|
|
180
|
+
tick()
|
|
181
|
+
}
|
|
182
|
+
@objc func hide() { panel.orderOut(nil) }
|
|
183
|
+
@objc func tick() {
|
|
184
|
+
guard let rect = targetWindow() else { panel.orderOut(nil); lastRect = nil; return }
|
|
185
|
+
lastRect = rect
|
|
186
|
+
if !ring.dragging { panel.setFrameOrigin(ringOrigin(in: rect, inset: inset)) }
|
|
187
|
+
if !panel.isVisible { panel.orderFrontRegardless() }
|
|
188
|
+
if Date().timeIntervalSince(lastAttempt) >= 60 { refresh() }
|
|
189
|
+
updateRing()
|
|
190
|
+
}
|
|
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
|
+
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 ?? "额度未知"
|
|
205
|
+
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)")
|
|
208
|
+
ring.needsDisplay = true
|
|
209
|
+
}
|
|
210
|
+
@objc func refresh() {
|
|
211
|
+
guard query == nil else { return }
|
|
212
|
+
lastAttempt = Date()
|
|
213
|
+
let process = Process(), pipe = Pipe()
|
|
214
|
+
process.executableURL = URL(fileURLWithPath: node)
|
|
215
|
+
process.arguments = [script]
|
|
216
|
+
var env = ProcessInfo.processInfo.environment
|
|
217
|
+
env["PATH"] = searchPath
|
|
218
|
+
if !codexHome.isEmpty { env["CODEX_HOME"] = codexHome }
|
|
219
|
+
process.environment = env
|
|
220
|
+
process.standardOutput = pipe
|
|
221
|
+
process.standardError = FileHandle.nullDevice
|
|
222
|
+
process.terminationHandler = { [weak self] process in
|
|
223
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
224
|
+
Task { @MainActor in
|
|
225
|
+
guard let self else { return }
|
|
226
|
+
self.query = nil
|
|
227
|
+
do {
|
|
228
|
+
let value = try JSONDecoder().decode(Snapshot.self, from: data)
|
|
229
|
+
if let message = value.error { self.error = message }
|
|
230
|
+
else if process.terminationStatus == 0, value.fetchedAt > 0 {
|
|
231
|
+
self.snapshot = value; self.error = nil
|
|
232
|
+
} else { self.error = "额度查询失败" }
|
|
233
|
+
} catch { self.error = "无法读取额度响应,请检查 CLI 和网络" }
|
|
234
|
+
self.updateRing()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
query = process
|
|
238
|
+
do { try process.run() }
|
|
239
|
+
catch { query = nil; self.error = "无法启动额度查询,请重新运行 easy-codex overlay"; updateRing() }
|
|
240
|
+
}
|
|
241
|
+
func item(_ title: String, _ action: Selector?) -> NSMenuItem {
|
|
242
|
+
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
|
243
|
+
item.target = self
|
|
244
|
+
return item
|
|
245
|
+
}
|
|
246
|
+
func makeMenu() -> NSMenu {
|
|
247
|
+
let menu = NSMenu()
|
|
248
|
+
menu.addItem(item("Easy Codex · CLI 账号额度", nil))
|
|
249
|
+
menu.addItem(item(lastRect == nil ? "等待 Codex 前台窗口" : "拖动圆环可调整位置", nil))
|
|
250
|
+
if let error { menu.addItem(item(error, nil)) }
|
|
251
|
+
if snapshot?.restricted == true { menu.addItem(item("服务端提示:当前使用受限", nil)) }
|
|
252
|
+
menu.addItem(.separator())
|
|
253
|
+
for window in snapshot?.windows ?? [] {
|
|
254
|
+
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
|
|
258
|
+
menu.addItem(option)
|
|
259
|
+
}
|
|
260
|
+
menu.addItem(item(query == nil ? "立即刷新" : "正在刷新…", query == nil ? #selector(refresh) : nil))
|
|
261
|
+
menu.addItem(item("恢复默认位置", #selector(resetPosition)))
|
|
262
|
+
menu.addItem(.separator())
|
|
263
|
+
menu.addItem(item("退出浮窗", #selector(quit)))
|
|
264
|
+
return menu
|
|
265
|
+
}
|
|
266
|
+
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
267
|
+
menu.removeAllItems()
|
|
268
|
+
let fresh = makeMenu()
|
|
269
|
+
for item in fresh.items { fresh.removeItem(item); menu.addItem(item) }
|
|
270
|
+
}
|
|
271
|
+
@objc func selectWindow(_ sender: NSMenuItem) {
|
|
272
|
+
selectedID = sender.representedObject as? String
|
|
273
|
+
UserDefaults.standard.set(selectedID, forKey: "selectedWindow")
|
|
274
|
+
updateRing()
|
|
275
|
+
}
|
|
276
|
+
@objc func resetPosition() {
|
|
277
|
+
inset = CGPoint(x: 10, y: 74)
|
|
278
|
+
UserDefaults.standard.removeObject(forKey: "rightInset")
|
|
279
|
+
UserDefaults.standard.removeObject(forKey: "bottomInset")
|
|
280
|
+
tick()
|
|
281
|
+
}
|
|
282
|
+
@objc func quit() { NSApp.terminate(nil) }
|
|
283
|
+
func applicationWillTerminate(_ notification: Notification) {
|
|
284
|
+
timer?.invalidate()
|
|
285
|
+
query?.terminate()
|
|
286
|
+
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@main
|
|
291
|
+
struct Main {
|
|
292
|
+
@MainActor static func main() {
|
|
293
|
+
if CommandLine.arguments.contains("--self-test") {
|
|
294
|
+
assert(RingState.resolve(51, stale: false) == .green)
|
|
295
|
+
assert(RingState.resolve(50, stale: false) == .orange)
|
|
296
|
+
assert(RingState.resolve(20, stale: false) == .orange)
|
|
297
|
+
assert(RingState.resolve(19, stale: false) == .red)
|
|
298
|
+
assert(RingState.resolve(nil, stale: false) == .unknown)
|
|
299
|
+
assert(RingState.resolve(99, stale: true) == .unknown)
|
|
300
|
+
assert(appKitRect(CGRect(x: -900, y: 100, width: 800, height: 600), primaryHeight: 900).origin == CGPoint(x: -900, y: 200))
|
|
301
|
+
let bounds = CGRect(x: 100, y: 100, width: 800, height: 600)
|
|
302
|
+
assert(ringOrigin(in: bounds, inset: CGPoint(x: 10, y: 74)) == CGPoint(x: 874, y: 174))
|
|
303
|
+
assert(ringOrigin(in: bounds, inset: CGPoint(x: -100, y: -100)) == CGPoint(x: 884, y: 100))
|
|
304
|
+
print("Swift 圆环颜色与窗口坐标检查通过")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
let app = NSApplication.shared
|
|
308
|
+
app.setActivationPolicy(.accessory)
|
|
309
|
+
if CommandLine.arguments.contains("--diagnose") {
|
|
310
|
+
print("targetFrontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.openai.codex")")
|
|
311
|
+
print("targetWindow=\(targetWindow().map { NSStringFromRect($0) } ?? "unavailable")")
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if let i = CommandLine.arguments.firstIndex(of: "--render-preview"), CommandLine.arguments.count > i + 1 {
|
|
315
|
+
let image = NSImage(size: NSSize(width: 340, height: 100))
|
|
316
|
+
image.lockFocus()
|
|
317
|
+
for (index, value) in [75.0, 35.0, 10.0, -1.0].enumerated() {
|
|
318
|
+
NSGraphicsContext.saveGraphicsState()
|
|
319
|
+
let transform = AffineTransform(translationByX: CGFloat(index * 84 + 4), byY: 8)
|
|
320
|
+
(transform as NSAffineTransform).concat()
|
|
321
|
+
let view = RingView(frame: CGRect(x: 0, y: 0, width: 16, height: 16))
|
|
322
|
+
view.remaining = value < 0 ? nil : value; view.stale = value < 0
|
|
323
|
+
view.caption = value < 0 ? "额度未知" : "7天"
|
|
324
|
+
view.draw(view.bounds)
|
|
325
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
326
|
+
}
|
|
327
|
+
image.unlockFocus()
|
|
328
|
+
if let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), let png = bitmap.representation(using: .png, properties: [:]) {
|
|
329
|
+
do { try png.write(to: URL(fileURLWithPath: CommandLine.arguments[i+1])) }
|
|
330
|
+
catch { fputs("预览写入失败\n", stderr); exit(1) }
|
|
331
|
+
}
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
let delegate = Overlay()
|
|
335
|
+
app.delegate = delegate
|
|
336
|
+
withExtendedLifetime(delegate) { app.run() }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0"><dict>
|
|
4
|
+
<key>CFBundleExecutable</key><string>EasyCodexOverlay</string>
|
|
5
|
+
<key>CFBundleIdentifier</key><string>com.unionschool.easy-codex.overlay</string>
|
|
6
|
+
<key>CFBundleName</key><string>Easy Codex Overlay</string>
|
|
7
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
8
|
+
<key>CFBundleShortVersionString</key><string>0.1.1</string>
|
|
9
|
+
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
10
|
+
<key>LSUIElement</key><true/>
|
|
11
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
12
|
+
</dict></plist>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict/>
|
|
7
|
+
<key>files2</key>
|
|
8
|
+
<dict/>
|
|
9
|
+
<key>rules</key>
|
|
10
|
+
<dict>
|
|
11
|
+
<key>^Resources/</key>
|
|
12
|
+
<true/>
|
|
13
|
+
<key>^Resources/.*\.lproj/</key>
|
|
14
|
+
<dict>
|
|
15
|
+
<key>optional</key>
|
|
16
|
+
<true/>
|
|
17
|
+
<key>weight</key>
|
|
18
|
+
<real>1000</real>
|
|
19
|
+
</dict>
|
|
20
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
21
|
+
<dict>
|
|
22
|
+
<key>omit</key>
|
|
23
|
+
<true/>
|
|
24
|
+
<key>weight</key>
|
|
25
|
+
<real>1100</real>
|
|
26
|
+
</dict>
|
|
27
|
+
<key>^Resources/Base\.lproj/</key>
|
|
28
|
+
<dict>
|
|
29
|
+
<key>weight</key>
|
|
30
|
+
<real>1010</real>
|
|
31
|
+
</dict>
|
|
32
|
+
<key>^version.plist$</key>
|
|
33
|
+
<true/>
|
|
34
|
+
</dict>
|
|
35
|
+
<key>rules2</key>
|
|
36
|
+
<dict>
|
|
37
|
+
<key>.*\.dSYM($|/)</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>weight</key>
|
|
40
|
+
<real>11</real>
|
|
41
|
+
</dict>
|
|
42
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
43
|
+
<dict>
|
|
44
|
+
<key>omit</key>
|
|
45
|
+
<true/>
|
|
46
|
+
<key>weight</key>
|
|
47
|
+
<real>2000</real>
|
|
48
|
+
</dict>
|
|
49
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
50
|
+
<dict>
|
|
51
|
+
<key>nested</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>weight</key>
|
|
54
|
+
<real>10</real>
|
|
55
|
+
</dict>
|
|
56
|
+
<key>^.*</key>
|
|
57
|
+
<true/>
|
|
58
|
+
<key>^Info\.plist$</key>
|
|
59
|
+
<dict>
|
|
60
|
+
<key>omit</key>
|
|
61
|
+
<true/>
|
|
62
|
+
<key>weight</key>
|
|
63
|
+
<real>20</real>
|
|
64
|
+
</dict>
|
|
65
|
+
<key>^PkgInfo$</key>
|
|
66
|
+
<dict>
|
|
67
|
+
<key>omit</key>
|
|
68
|
+
<true/>
|
|
69
|
+
<key>weight</key>
|
|
70
|
+
<real>20</real>
|
|
71
|
+
</dict>
|
|
72
|
+
<key>^Resources/</key>
|
|
73
|
+
<dict>
|
|
74
|
+
<key>weight</key>
|
|
75
|
+
<real>20</real>
|
|
76
|
+
</dict>
|
|
77
|
+
<key>^Resources/.*\.lproj/</key>
|
|
78
|
+
<dict>
|
|
79
|
+
<key>optional</key>
|
|
80
|
+
<true/>
|
|
81
|
+
<key>weight</key>
|
|
82
|
+
<real>1000</real>
|
|
83
|
+
</dict>
|
|
84
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
85
|
+
<dict>
|
|
86
|
+
<key>omit</key>
|
|
87
|
+
<true/>
|
|
88
|
+
<key>weight</key>
|
|
89
|
+
<real>1100</real>
|
|
90
|
+
</dict>
|
|
91
|
+
<key>^Resources/Base\.lproj/</key>
|
|
92
|
+
<dict>
|
|
93
|
+
<key>weight</key>
|
|
94
|
+
<real>1010</real>
|
|
95
|
+
</dict>
|
|
96
|
+
<key>^[^/]+$</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>nested</key>
|
|
99
|
+
<true/>
|
|
100
|
+
<key>weight</key>
|
|
101
|
+
<real>10</real>
|
|
102
|
+
</dict>
|
|
103
|
+
<key>^embedded\.provisionprofile$</key>
|
|
104
|
+
<dict>
|
|
105
|
+
<key>weight</key>
|
|
106
|
+
<real>20</real>
|
|
107
|
+
</dict>
|
|
108
|
+
<key>^version\.plist$</key>
|
|
109
|
+
<dict>
|
|
110
|
+
<key>weight</key>
|
|
111
|
+
<real>20</real>
|
|
112
|
+
</dict>
|
|
113
|
+
</dict>
|
|
114
|
+
</dict>
|
|
115
|
+
</plist>
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "easy-codex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Display remaining Codex account usage limits",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"easy-codex": "bin/easy-codex.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"native/Overlay.swift",
|
|
14
|
+
"native/build/EasyCodexOverlay.app",
|
|
15
|
+
"scripts/build-overlay.mjs"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"start": "node bin/easy-codex.js",
|
|
23
|
+
"build:overlay": "node scripts/build-overlay.mjs"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "UnionSchool",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/UnionSchool/easy-codex.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/UnionSchool/easy-codex#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/UnionSchool/easy-codex/issues"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
3
|
+
import { mkdir, writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
7
|
+
if (process.platform !== 'darwin') throw new Error('浮窗只能在 macOS 构建。');
|
|
8
|
+
const app = `${root}native/build/EasyCodexOverlay.app/Contents`;
|
|
9
|
+
await mkdir(`${app}/MacOS`, { recursive: true });
|
|
10
|
+
const temporary = await mkdtemp('/tmp/easy-codex-build-');
|
|
11
|
+
try {
|
|
12
|
+
for (const architecture of ['arm64', 'x86_64']) {
|
|
13
|
+
execFileSync('xcrun', ['swiftc', '-swift-version', '6', '-parse-as-library',
|
|
14
|
+
'-target', `${architecture}-apple-macosx14.0`, '-module-cache-path', `${temporary}/cache`,
|
|
15
|
+
`${root}native/Overlay.swift`, '-o', `${temporary}/${architecture}`, '-framework', 'AppKit'], { stdio: 'inherit' });
|
|
16
|
+
}
|
|
17
|
+
execFileSync('lipo', ['-create', `${temporary}/arm64`, `${temporary}/x86_64`, '-output', `${app}/MacOS/EasyCodexOverlay`], { stdio: 'inherit' });
|
|
18
|
+
} finally {
|
|
19
|
+
await rm(temporary, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
await writeFile(`${app}/Info.plist`, `<?xml version="1.0" encoding="UTF-8"?>
|
|
22
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
23
|
+
<plist version="1.0"><dict>
|
|
24
|
+
<key>CFBundleExecutable</key><string>EasyCodexOverlay</string>
|
|
25
|
+
<key>CFBundleIdentifier</key><string>com.unionschool.easy-codex.overlay</string>
|
|
26
|
+
<key>CFBundleName</key><string>Easy Codex Overlay</string>
|
|
27
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
28
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
29
|
+
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
|
30
|
+
<key>LSUIElement</key><true/>
|
|
31
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
32
|
+
</dict></plist>`);
|
|
33
|
+
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', `${root}native/build/EasyCodexOverlay.app`], { stdio: 'inherit' });
|
|
34
|
+
execFileSync(`${app}/MacOS/EasyCodexOverlay`, ['--self-test'], { stdio: 'inherit' });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatUsage } from './usage.js';
|
|
2
|
+
|
|
3
|
+
export function overlayData(result, now = Date.now()) {
|
|
4
|
+
const detail = formatUsage(result);
|
|
5
|
+
const map = result.rateLimitsByLimitId;
|
|
6
|
+
const entries = map && typeof map === 'object' && !Array.isArray(map)
|
|
7
|
+
? Object.entries(map).filter(([, b]) => b && typeof b === 'object') : [];
|
|
8
|
+
if (!entries.length) entries.push([result.rateLimits.limitId ?? 'codex', result.rateLimits]);
|
|
9
|
+
const windows = [];
|
|
10
|
+
for (const [id, bucket] of entries) {
|
|
11
|
+
for (const key of ['primary', 'secondary']) {
|
|
12
|
+
const w = bucket[key];
|
|
13
|
+
if (!w) continue;
|
|
14
|
+
const mins = w.windowDurationMins;
|
|
15
|
+
const label = Number.isFinite(mins) && mins > 0
|
|
16
|
+
? mins % 1440 === 0 ? `${mins / 1440}天` : mins % 60 === 0 ? `${mins / 60}小时` : `${mins}分钟`
|
|
17
|
+
: key === 'primary' ? '主窗口' : '次窗口';
|
|
18
|
+
windows.push({ id: `${id}:${key}`, label,
|
|
19
|
+
name: `${String(bucket.limitName || id).replace(/[\x00-\x1f\x7f-\x9f]/g, '')} · ${label}`,
|
|
20
|
+
remaining: Number.isFinite(w.usedPercent) ? Math.max(0, Math.min(100, 100 - w.usedPercent)) : null,
|
|
21
|
+
resetsAt: Number.isFinite(w.resetsAt) && Math.abs(w.resetsAt) < 8.64e12 ? w.resetsAt : null });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { windows, detail, fetchedAt: now / 1000, restricted: result.ordinaryUsageAllowed === false };
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readUsage } from './usage.js';
|
|
2
|
+
import { overlayData } from './overlay-data.js';
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
process.once('SIGTERM', () => controller.abort());
|
|
5
|
+
process.once('SIGINT', () => controller.abort());
|
|
6
|
+
try {
|
|
7
|
+
const text = JSON.stringify(overlayData(await readUsage({ signal: controller.signal })));
|
|
8
|
+
// 原生端在进程结束后读取管道;限制体积以免写端等待管道空间。
|
|
9
|
+
if (Buffer.byteLength(text) > 12000) throw new Error('额度响应过大,请使用 easy-codex 查看完整数据。');
|
|
10
|
+
console.log(text);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.log(JSON.stringify({ error: error.message }));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
}
|
package/src/overlay.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
export async function launchOverlay() {
|
|
8
|
+
if (process.platform !== 'darwin') throw new Error('浮窗首版仅支持 macOS。');
|
|
9
|
+
const app = fileURLToPath(new URL('../native/build/EasyCodexOverlay.app', import.meta.url));
|
|
10
|
+
try { await access(`${app}/Contents/MacOS/EasyCodexOverlay`, constants.X_OK); }
|
|
11
|
+
catch { throw new Error('尚未构建浮窗,请在项目目录执行 npm run build:overlay。'); }
|
|
12
|
+
await promisify(execFile)('/usr/bin/open', ['-g', '-a', app, '--args',
|
|
13
|
+
process.execPath, fileURLToPath(new URL('./overlay-query.js', import.meta.url)), process.env.PATH ?? '', process.env.CODEX_HOME ?? '']);
|
|
14
|
+
console.log('额度浮窗已启动;Codex 在前台时显示。拖动调整位置,右键或菜单栏 EC 可刷新、切换额度和退出。');
|
|
15
|
+
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
// 认证交给官方 CLI;不读取凭据文件,也不转发可能含敏感信息的服务端日志。
|
|
7
|
+
export function readUsage({ command = 'codex', args = ['app-server', '--listen', 'stdio://'], timeoutMs = 20000, signal } = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
if (signal?.aborted) { reject(new Error('查询已取消。')); return; }
|
|
10
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], shell: false });
|
|
11
|
+
const lines = createInterface({ input: child.stdout });
|
|
12
|
+
let done = false;
|
|
13
|
+
let expectedId = 1;
|
|
14
|
+
let killTimer;
|
|
15
|
+
const timer = setTimeout(() => finish(new Error('查询超时,请检查网络和 Codex 登录状态。')), timeoutMs);
|
|
16
|
+
function finish(error, result) {
|
|
17
|
+
if (done) return;
|
|
18
|
+
done = true;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
signal?.removeEventListener('abort', onAbort);
|
|
21
|
+
lines.close();
|
|
22
|
+
child.stdin.end();
|
|
23
|
+
child.kill();
|
|
24
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
25
|
+
killTimer.unref();
|
|
26
|
+
if (error) reject(error);
|
|
27
|
+
else resolve(result);
|
|
28
|
+
}
|
|
29
|
+
function onAbort() { finish(new Error('查询已取消。')); }
|
|
30
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
31
|
+
function send(message) {
|
|
32
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
33
|
+
}
|
|
34
|
+
child.on('error', (error) => finish(new Error(error.code === 'ENOENT'
|
|
35
|
+
? '找不到 codex,请先安装官方 Codex CLI 并确保它位于 PATH 中。'
|
|
36
|
+
: '无法启动 Codex CLI,请检查安装和执行权限。')));
|
|
37
|
+
child.stdin.on('error', () => finish(new Error('Codex 通信中断,请检查 CLI 是否正常。')));
|
|
38
|
+
child.on('close', () => {
|
|
39
|
+
clearTimeout(killTimer);
|
|
40
|
+
finish(new Error('Codex 在返回额度前退出,请检查 CLI 版本、配置和登录状态。'));
|
|
41
|
+
});
|
|
42
|
+
lines.on('line', (line) => {
|
|
43
|
+
if (done) return;
|
|
44
|
+
let message;
|
|
45
|
+
try { message = JSON.parse(line); }
|
|
46
|
+
catch { finish(new Error('Codex 返回了无效的协议数据,请检查 CLI 版本。')); return; }
|
|
47
|
+
if (message?.id !== expectedId) return;
|
|
48
|
+
if (message.error) {
|
|
49
|
+
finish(new Error(expectedId === 1
|
|
50
|
+
? 'Codex 初始化失败,请检查 CLI 版本和配置。'
|
|
51
|
+
: '无法读取额度,请确认已通过 codex login 登录 ChatGPT,并检查网络;API Key 余额不在本工具范围内。'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!message.result || typeof message.result !== 'object') {
|
|
55
|
+
finish(new Error('Codex 返回了无效的结果。'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (expectedId === 1) {
|
|
59
|
+
expectedId = 2;
|
|
60
|
+
send({ method: 'initialized' });
|
|
61
|
+
send({ id: 2, method: 'account/rateLimits/read' });
|
|
62
|
+
} else finish(null, message.result);
|
|
63
|
+
});
|
|
64
|
+
send({ id: 1, method: 'initialize', params: {
|
|
65
|
+
clientInfo: { name: 'easy_codex', title: 'Easy Codex', version },
|
|
66
|
+
capabilities: null,
|
|
67
|
+
} });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function duration(minutes, fallback) {
|
|
72
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return fallback;
|
|
73
|
+
if (minutes % 1440 === 0) return `${minutes / 1440} 天`;
|
|
74
|
+
if (minutes % 60 === 0) return `${minutes / 60} 小时`;
|
|
75
|
+
return `${minutes} 分钟`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function windowText(window, label) {
|
|
79
|
+
if (!window || typeof window !== 'object') return ` ${label}:未知(未提供数据)`;
|
|
80
|
+
const remaining = Number.isFinite(window.usedPercent)
|
|
81
|
+
? `${Number(Math.max(0, Math.min(100, 100 - window.usedPercent)).toFixed(1))}%`
|
|
82
|
+
: '未知';
|
|
83
|
+
const date = Number.isFinite(window.resetsAt) ? new Date(window.resetsAt * 1000) : null;
|
|
84
|
+
const reset = date && Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', { hour12: false }) : '未知';
|
|
85
|
+
return ` ${duration(window.windowDurationMins, label)}:剩余 ${remaining};重置 ${reset}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatUsage(result) {
|
|
89
|
+
if (!result || typeof result !== 'object') throw new Error('额度响应格式无效。');
|
|
90
|
+
const buckets = result.rateLimitsByLimitId;
|
|
91
|
+
const entries = buckets && typeof buckets === 'object' && !Array.isArray(buckets)
|
|
92
|
+
? Object.entries(buckets).filter(([, value]) => value && typeof value === 'object') : [];
|
|
93
|
+
if (!entries.length && result.rateLimits && typeof result.rateLimits === 'object') {
|
|
94
|
+
entries.push([result.rateLimits.limitId ?? 'codex', result.rateLimits]);
|
|
95
|
+
}
|
|
96
|
+
if (!entries.length) throw new Error('服务端未提供额度数据,无法判断剩余额度。');
|
|
97
|
+
const output = ['Codex 剩余额度(账号共享,非当前会话)'];
|
|
98
|
+
for (const [id, bucket] of entries) {
|
|
99
|
+
// 限制服务端标签中的控制字符,避免终端转义序列影响显示。
|
|
100
|
+
const label = String(bucket.limitName || bucket.limitId || id).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
101
|
+
output.push(label, windowText(bucket.primary, '主窗口'), windowText(bucket.secondary, '次窗口'));
|
|
102
|
+
}
|
|
103
|
+
if (result.ordinaryUsageAllowed === false) output.push('服务端提示:当前普通额度使用受限。');
|
|
104
|
+
output.push(`重置时间使用本机时区:${Intl.DateTimeFormat().resolvedOptions().timeZone}`);
|
|
105
|
+
return output.join('\n');
|
|
106
|
+
}
|