From f9a63bde9a7680624210245a4dc271ddf95018b6 Mon Sep 17 00:00:00 2001 From: wangxinyue Date: Mon, 3 Aug 2026 21:31:21 +0800 Subject: [PATCH] Release quota orb v0.13.0 --- README.md | 156 +++---------- macOS/Sources/CodexQuotaWidget.swift | 326 +++++++++++++++------------ macOS/Sources/GlassBridge.m | 21 -- macOS/build-macos.sh | 12 +- macOS/package-release.sh | 4 +- 5 files changed, 213 insertions(+), 306 deletions(-) delete mode 100644 macOS/Sources/GlassBridge.m diff --git a/README.md b/README.md index 322fc68..40550ba 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,40 @@ -# Codex Quota Widget | Codex 额度插件 +# Codex Quota Widget / Codex 额度插件 -**Codex 额度插件**是一款面向 macOS 和 Windows 的轻量桌面小组件,用来查看 Codex 剩余额度、重置倒计时、Token 消耗和正在运行的任务。它也是一个 Codex quota widget、Codex usage monitor 和 token usage tracker,读取本机已经登录的 Codex 数据,不内置、不上传任何账号凭据。 +一个轻量的 Codex 额度桌面小组件,支持 macOS 和 Windows。它读取本机已经登录的 Codex 数据,显示剩余额度、重置时间、Token 和运行中的任务。 -[中文说明](#中文说明) | [English](#english) +![Codex Quota Widget](docs/quota-dashboard.png) -![Codex 额度插件 / Codex Quota Widget](docs/quota-dashboard.png) +## Download -## 最新版本 / Latest Release +- [macOS v0.13.0](https://github.com/wangxinyue385-tech/codex-quota-widget/releases/latest/download/CodexQuotaWidget-macOS-v0.13.0.zip) +- [Windows v0.13.0](https://github.com/wangxinyue385-tech/codex-quota-widget/releases/latest/download/CodexQuotaWidget-Windows-v0.13.0.zip) +- [All releases](https://github.com/wangxinyue385-tech/codex-quota-widget/releases) -直接下载可用版本,不需要先 clone 源码: +## Features -- [macOS v0.12.0(通用 ZIP,含安装/卸载入口)](https://github.com/wangxinyue385-tech/codex-quota-widget/releases/latest/download/CodexQuotaWidget-macOS-v0.12.0.zip) -- [Windows v0.12.0(ZIP,含一键安装)](https://github.com/wangxinyue385-tech/codex-quota-widget/releases/latest/download/CodexQuotaWidget-Windows-v0.12.0.zip) -- [查看更新说明 / Release notes](https://github.com/wangxinyue385-tech/codex-quota-widget/releases/latest) +- 显示当前 Codex 额度、重置倒计时和重置次数 +- 显示本次 Token、累计 Token、Token 消耗速度和运行任务数 +- macOS 菜单栏显示 Codex 图标与整数百分比 +- macOS 水球会随额度浓度变化,支持拖动受力形变和任务能量释放 +- Windows 系统托盘支持左键查看、右键刷新/退出 +- 网络异常时保留最后一次有效数据,不会直接跳到 100% +- 关闭小窗只隐藏,不退出后台服务 -macOS 解压后双击 `Install.command`;Windows 解压后双击 `Install.cmd`。首次使用前,请先在对应电脑上登录 Codex 或 ChatGPT。 +## Install -## 中文说明 +### macOS -### 主要功能 +解压 macOS 压缩包,双击 `Install.command`。要求 macOS 13+,并已登录 Codex 或 ChatGPT。 -- **真实额度数据**:直接显示 Codex 服务端返回的当前额度与重置时间,不把额度窗口写死为“5 小时”,也不会用本地 Token 伪造小数精度。 -- **动态额度水球(macOS)**:额度充足时呈深紫色,随着额度消耗逐渐过渡到蓝色、浅蓝色,接近 0 时变成透明水色。 -- **受力与能量动画(macOS)**:拖动窗口时球体会按惯性和表面张力轻微形变并平稳恢复;粒子从球体随机向外释放。1/2/3/4 个并行任务对应四档粒子数量,模型与 reasoning 等级越高,粒子消散越快;所有活动会话的实际 Token 消耗速度会继续放大或减弱档位内的密度与速度。 -- **Token 与任务状态**:显示本次任务 Token、累计 Token、额度重置倒计时、重置次数以及当前运行任务数。 -- **菜单栏常驻(macOS)**:使用经典 Codex 素描图标并显示整数百分比;左键打开额度窗口,右键可查看 Codex 任务与最近会话,并执行刷新或退出。 -- **桌面行为(macOS)**:启动 Codex 时自动运行;关闭小窗只会隐藏,不会退出后台;切换到其他桌面或全屏空间时,小窗不会跟随过去。 -- **稳定刷新**:请求失败时自动重试并保留最后一次有效数据,避免网络波动被错误显示为 100%。 -- **Windows 支持**:提供托盘图标、额度小窗、刷新/退出菜单、开机启动以及一键安装和卸载。 -- **轻量运行**:动画采用固定粒子池并在窗口隐藏时暂停,减少 CPU 和内存占用。 +卸载:双击同目录的 `Uninstall.command`。 -### macOS 安装 +### Windows -要求:macOS 13 或更高版本、已登录 Codex/ChatGPT、已安装 Xcode Command Line Tools。 +解压 Windows 压缩包,双击 `Install.cmd`。要求 Windows 10/11,并已登录 Codex 或 ChatGPT。 -推荐直接下载上方的 macOS Release。解压后双击 `Install.command`,它会把应用安装到 `~/Applications` 并注册自动启动;卸载时双击同目录的 `Uninstall.command`。 +卸载:运行 `Uninstall.cmd`。 -```bash -git clone https://github.com/wangxinyue385-tech/codex-quota-widget.git -cd codex-quota-widget -bash macOS/install-macos.sh -``` - -安装脚本会编译应用到 `~/Applications/CodexQuotaWidget.app`,安装后台启动项,并立即打开小组件。以后启动 Codex 时,小组件会自动启动。 - -卸载: - -```bash -bash macOS/uninstall-macos.sh -``` - -### Windows 安装 - -要求:Windows 10/11、已登录 Codex/ChatGPT、Windows PowerShell 5.1 或更高版本。 - -推荐直接下载上方的 Windows Release,解压后双击 `Install.cmd`。 - -1. 下载并解压 [`windows/CodexQuotaWidget-Windows.zip`](windows/CodexQuotaWidget-Windows.zip)。 -2. 打开 `CodexQuotaWidget-Windows` 文件夹。 -3. 双击 `Install.cmd`。 - -安装后会创建桌面快捷方式、开始菜单快捷方式和开机启动项。托盘图标左键打开额度详情,右键提供刷新和退出。卸载时运行开始菜单中的“卸载 Codex Quota Widget”或 `Uninstall.cmd`。 - -### 数据与隐私 - -本项目不是 OpenAI 官方客户端,也不会绕过登录或额度限制。额度来自当前登录的 Codex 客户端与服务端返回结果,Token 统计来自本机 Codex 会话文件: - -- macOS:`~/.codex/auth.json`、`~/.codex/sessions/` 和 Codex 本地会话数据库。 -- Windows:`%USERPROFILE%\.codex\auth.json` 和 `%USERPROFILE%\.codex\sessions\*.jsonl`。 - -源码只读取这些本地数据。仓库不包含访问令牌、刷新令牌、会话记录或个人路径。请勿把自己的 `auth.json`、`access-token.txt` 或 `.codex` 目录发送给任何人。 - -## English - -### Overview - -**Codex Quota Widget** is a lightweight Codex quota and usage monitor for macOS and Windows. It shows the official remaining quota, reset countdown, token usage, reset credits, and active task count by using the Codex session already signed in on the local computer. - -### Features - -- **Official quota data**: displays the quota and reset window returned by Codex without hard-coding a five-hour limit or inventing decimal precision from local token counts. -- **Animated quota orb on macOS**: the fluid orb starts deep purple at high quota, then transitions through blue and light blue toward clear water as quota is consumed. -- **Responsive motion and energy release on macOS**: dragging applies restrained inertia and surface-tension deformation. Particles leave the orb in randomized directions. One, two, three, and four active tasks map to four particle-count tiers; stronger models and reasoning levels make particles dissipate faster, while the combined real token burn rate modulates density and speed within each tier. -- **Live activity metrics**: shows current-task tokens, cumulative tokens, reset countdown, reset credits, and active Codex tasks. -- **macOS menu bar integration**: uses the classic sketch-style Codex icon with an integer percentage. Left-click opens the widget; right-click exposes Codex tasks, recent sessions, refresh, and quit actions. -- **Desktop-friendly behavior on macOS**: launches with Codex, hides instead of quitting when the widget is closed, and stays out of unrelated desktops and full-screen Spaces. -- **Resilient updates**: retries transient failures and keeps the last valid reading instead of incorrectly jumping to 100%. -- **Windows support**: includes a system-tray icon, quota window, refresh/quit menu, startup registration, and one-click install/uninstall scripts. -- **Low overhead**: uses a bounded particle pool and pauses animation while the widget is hidden to reduce CPU and memory use. - -### Install on macOS - -Requirements: macOS 13 or later, a signed-in Codex/ChatGPT session, and Xcode Command Line Tools. - -For the easiest install, download the macOS Release above, unzip it, and double-click `Install.command`. It installs the app in `~/Applications` and registers automatic startup. Use `Uninstall.command` in the same folder to remove it. +## Build ```bash git clone https://github.com/wangxinyue385-tech/codex-quota-widget.git @@ -101,56 +42,19 @@ cd codex-quota-widget bash macOS/install-macos.sh ``` -The installer builds the app at `~/Applications/CodexQuotaWidget.app`, installs its background launch agent, and opens it immediately. The widget will start automatically when Codex starts. - -Uninstall: - -```bash -bash macOS/uninstall-macos.sh -``` - -### Install on Windows - -Requirements: Windows 10/11, a signed-in Codex/ChatGPT session, and Windows PowerShell 5.1 or later. - -For the easiest install, download the Windows Release above, extract it, and double-click `Install.cmd`. - -1. Download and extract [`windows/CodexQuotaWidget-Windows.zip`](windows/CodexQuotaWidget-Windows.zip). -2. Open the extracted `CodexQuotaWidget-Windows` folder. -3. Double-click `Install.cmd`. +## Privacy -The installer adds desktop and Start menu shortcuts plus a startup entry. Left-click the tray icon to open quota details; right-click to refresh or quit. To remove it, use “Uninstall Codex Quota Widget” from the Start menu or run `Uninstall.cmd`. +这是非官方开源工具,不绕过登录或额度限制。它只读取本机的 Codex 登录文件、会话文件和额度接口,不会把 Token 或账号信息上传到本项目。 -### Data and privacy - -This is an unofficial community project. It does not bypass authentication or quota limits. Quota data comes from the currently signed-in Codex client and its service response; token totals come from local Codex session files. No credentials, access tokens, refresh tokens, session logs, or personal paths are included in this repository. - -## Project Structure - -```text -macOS/ - Sources/ Swift and Objective-C sources - assets/ Menu bar icon - build-macos.sh Build script - install-macos.sh Build, install, and enable startup - uninstall-macos.sh Uninstaller -windows/ - CodexQuotaWidget-Windows/ Windows source and install scripts - CodexQuotaWidget-Windows.zip Ready-to-share Windows package -docs/ - quota-dashboard.png Latest widget screenshot -``` +## English -## Known Limitations +Codex Quota Widget is a lightweight desktop monitor for macOS and Windows. It shows quota, reset time, reset credits, token usage, burn rate, and active tasks from the Codex session already signed in on the local computer. -- The Codex quota response usually contains an integer percentage. The widget will only show decimals if the service provides them. -- Native glass appearance varies by macOS version and available window APIs; older systems use a compatible blur material. -- The Windows PowerShell/WinForms version provides the core monitoring workflow but does not reproduce every macOS visual effect. -- The quota endpoint is used internally by the Codex client and may change in a future client release. +macOS includes a draggable quota orb with concentration-based color, surface-tension motion, and task energy effects. Windows includes a system-tray menu with quota details, refresh, and quit actions. -## Search Keywords / 搜索关键词 +This is an unofficial open-source project. It does not bypass authentication or quota limits and does not upload credentials or session data. -Codex 额度插件、Codex 额度监控、Codex 使用量、Codex Token 消耗、Codex 桌面小组件、Codex 菜单栏;Codex quota widget, Codex usage monitor, Codex token tracker, Codex desktop widget, Codex menu bar app, Codex system tray. +Search: `Codex quota widget`, `Codex usage monitor`, `Codex token tracker`, `Codex 额度插件`, `Codex 额度监控`. ## License diff --git a/macOS/Sources/CodexQuotaWidget.swift b/macOS/Sources/CodexQuotaWidget.swift index 59841a8..3edc397 100644 --- a/macOS/Sources/CodexQuotaWidget.swift +++ b/macOS/Sources/CodexQuotaWidget.swift @@ -7,12 +7,6 @@ private let chatGPTBaseURL = "https://chatgpt.com/backend-api" private let openAIAuthURL = "https://auth.openai.com/oauth/token" private let codexOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" -@_silgen_name("CQCreateGlassEffectView") -private func createGlassEffectView() -> UnsafeMutableRawPointer? - -@_silgen_name("CQCreateGlassEffectContainerView") -private func createGlassEffectContainerView() -> UnsafeMutableRawPointer? - private struct AuthInfo { let accessToken: String let refreshToken: String? @@ -967,77 +961,104 @@ private final class StatusPillView: NSView { } } -private final class LiquidGlassSheenView: NSView { - override var isOpaque: Bool { false } +private func makeWindowServerFilter( + type: String, + values: [String: Any] = [:] +) -> NSObject? { + guard let filterClass = NSClassFromString("CAFilter") as? NSObject.Type else { + return nil + } + let selector = NSSelectorFromString("filterWithType:") + guard + filterClass.responds(to: selector), + let filter = filterClass.perform(selector, with: type)?.takeUnretainedValue() as? NSObject + else { + return nil + } + for (key, value) in values { + filter.setValue(value, forKey: key) + } + return filter +} - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) +private class LiveGlassBackdropView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + configureBackdrop() + } - let tint = NSGradient(colors: [ - NSColor(calibratedRed: 0.08, green: 0.16, blue: 0.11, alpha: 0.15), - NSColor(calibratedWhite: 0.02, alpha: 0.04), - NSColor(calibratedWhite: 0.01, alpha: 0.19), - ]) - tint?.draw(in: bounds, angle: 0) - drawGlow( - in: NSRect(x: 18, y: 122, width: 250, height: 70), - centerAlpha: 0.08 - ) - drawGlow( - in: NSRect(x: 260, y: -14, width: 116, height: 150), - centerAlpha: 0.04 - ) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private func drawGlow(in rect: NSRect, centerAlpha: CGFloat) { - let gradient = NSGradient( - starting: NSColor.white.withAlphaComponent(centerAlpha), - ending: .clear - ) - gradient?.draw(in: rect, relativeCenterPosition: .zero) + override func makeBackingLayer() -> CALayer { + guard let backdropType = NSClassFromString("CABackdropLayer") as? CALayer.Type else { + return CALayer() + } + return backdropType.init() + } + + func setFilters(_ filters: [Any?]) { + layer?.filters = filters.compactMap { $0 } + } + + private func configureBackdrop() { + guard let layer else { return } + layer.setValue(true, forKey: "windowServerAware") + layer.setValue(true, forKey: "allowsGroupBlending") + layer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer.masksToBounds = false } } -private func makeSystemGlassView( - frame: NSRect, - style: Int, - cornerRadius: CGFloat, - tintColor: NSColor?, - contentView: NSView? = nil -) -> NSView? { - guard - ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26, - let pointer = createGlassEffectView() - else { - return nil +private final class OrbBackdropView: LiveGlassBackdropView { + private let blurFilter = makeWindowServerFilter( + type: "gaussianBlur", + values: [ + "inputRadius": 1.0, + "inputNormalizeEdges": true, + ] + ) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setFilters([blurFilter]) } - let glass = Unmanaged.fromOpaque(pointer).takeRetainedValue() - glass.frame = frame - glass.autoresizingMask = [.width, .height] - if let contentView { - glass.setValue(contentView, forKey: "contentView") + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - glass.setValue(NSNumber(value: Double(cornerRadius)), forKey: "cornerRadius") - glass.setValue(tintColor, forKey: "tintColor") - glass.setValue(NSNumber(value: style), forKey: "style") - return glass + } -private func makeSystemGlassContainer(contentView: NSView) -> NSView? { - guard - ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26, - let pointer = createGlassEffectContainerView() - else { - return nil +private final class PanelGlassBackdropView: LiveGlassBackdropView { + private let blurFilter = makeWindowServerFilter( + type: "gaussianBlur", + values: [ + "inputRadius": 1.8, + "inputNormalizeEdges": true, + ] + ) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setFilters([blurFilter]) + layer?.cornerRadius = 24 + layer?.cornerCurve = .continuous + layer?.masksToBounds = true + layer?.backgroundColor = NSColor( + calibratedWhite: 0.04, + alpha: 0.15 + ).cgColor + layer?.borderWidth = 2.0 + layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - let container = Unmanaged.fromOpaque(pointer).takeRetainedValue() - container.frame = contentView.frame - container.autoresizingMask = [.width, .height] - container.setValue(contentView, forKey: "contentView") - container.setValue(NSNumber(value: 3.0), forKey: "spacing") - return container } private final class EnergyOrbView: NSView { @@ -1047,9 +1068,11 @@ private final class EnergyOrbView: NSView { } private let waterEffectView = NSView(frame: .zero) + private let backdropView = OrbBackdropView(frame: .zero) + private let backdropShapeMask = CAShapeLayer() private let waterShapeMask = CAShapeLayer() - private let waterBaseLayer = CALayer() - private let pigmentLayer = CALayer() + private let waterBaseLayer = CAGradientLayer() + private let pigmentLayer = CAGradientLayer() private let waterEdgeLayer = CAShapeLayer() private let maximumParticleCount = 72 private var particleLayers: [CALayer] = [] @@ -1279,6 +1302,10 @@ private final class EnergyOrbView: NSView { private func configureLayers() { guard let hostLayer = layer else { return } + backdropShapeMask.fillColor = NSColor.black.cgColor + backdropView.layer?.mask = backdropShapeMask + addSubview(backdropView) + waterEffectView.wantsLayer = true addSubview(waterEffectView) @@ -1288,18 +1315,25 @@ private final class EnergyOrbView: NSView { waterLayer.masksToBounds = false waterLayer.backgroundColor = NSColor.clear.cgColor - waterBaseLayer.backgroundColor = NSColor( - calibratedRed: 0.50, - green: 0.88, - blue: 1.00, - alpha: 0.018 - ).cgColor + waterBaseLayer.type = .radial + waterBaseLayer.startPoint = CGPoint(x: 0.44, y: 0.46) + waterBaseLayer.endPoint = CGPoint(x: 0.98, y: 0.98) + waterBaseLayer.locations = [0, 0.72, 1] + waterBaseLayer.colors = [ + NSColor(calibratedRed: 0.24, green: 0.58, blue: 0.72, alpha: 0.01).cgColor, + NSColor(calibratedRed: 0.28, green: 0.68, blue: 0.82, alpha: 0.025).cgColor, + NSColor(calibratedRed: 0.36, green: 0.78, blue: 0.92, alpha: 0.075).cgColor, + ] + pigmentLayer.type = .radial + pigmentLayer.startPoint = CGPoint(x: 0.42, y: 0.44) + pigmentLayer.endPoint = CGPoint(x: 0.98, y: 0.98) + pigmentLayer.locations = [0, 0.68, 1] waterLayer.addSublayer(waterBaseLayer) waterLayer.addSublayer(pigmentLayer) } waterEdgeLayer.fillColor = NSColor.clear.cgColor - waterEdgeLayer.lineWidth = 0.6 + waterEdgeLayer.lineWidth = 2.2 waterEdgeLayer.lineJoin = .round hostLayer.addSublayer(waterEdgeLayer) @@ -1478,8 +1512,10 @@ private final class EnergyOrbView: NSView { width: bodySize, height: bodySize ) + backdropView.frame = waterEffectView.frame CATransaction.begin() CATransaction.setDisableActions(true) + backdropShapeMask.frame = backdropView.bounds waterShapeMask.frame = waterEffectView.bounds waterBaseLayer.frame = waterEffectView.bounds pigmentLayer.frame = waterEffectView.bounds @@ -1563,54 +1599,68 @@ private final class EnergyOrbView: NSView { CATransaction.begin() CATransaction.setDisableActions(true) + backdropShapeMask.path = path waterShapeMask.path = path waterEdgeLayer.path = path CATransaction.commit() } private func updateOrbColors(animated: Bool) { - let lowToBlue = Self.smoothstep(0, 0.48, quotaConcentration) - let blueToPurple = Self.smoothstep(0.42, 1, quotaConcentration) - let diluteWater = NSColor( - calibratedRed: 0.38, - green: 0.80, - blue: 1.00, + // Match the preview's chemical model: use potassium-permanganate-like + // dilution, where the violet dye fades toward a muted pink-violet trace. + // Lower concentration is represented by less pigment, not a white overlay. + let concentration = pow(quotaConcentration, 0.42) + let dilutePermanganate = NSColor( + calibratedRed: 0.30, + green: 0.018, + blue: 0.22, alpha: 1 ) - let deepBlue = NSColor( - calibratedRed: 0.018, - green: 0.050, - blue: 0.19, - alpha: 1 - ) - let deepPurple = NSColor( - calibratedRed: 0.055, + let concentratedPermanganate = NSColor( + calibratedRed: 0.075, green: 0.002, - blue: 0.16, + blue: 0.30, alpha: 1 ) let solution = Self.mix( - Self.mix(diluteWater, deepBlue, amount: lowToBlue), - deepPurple, - amount: blueToPurple + dilutePermanganate, + concentratedPermanganate, + amount: concentration + ) + let lowQuotaFade = Self.smoothstep(0.04, 0.18, quotaConcentration) + let pigmentOpacity = min( + 0.98, + 1.08 * pow(quotaConcentration, 0.38) * lowQuotaFade + ) + let core = Self.mix( + solution, + NSColor(calibratedRed: 0.018, green: 0.002, blue: 0.055, alpha: 1), + amount: 0.42 + ) + let edge = Self.mix( + solution, + NSColor(calibratedRed: 0.16, green: 0.20, blue: 0.48, alpha: 1), + amount: 0.24 ) - let pigmentOpacity = 0.985 * pow(quotaConcentration, 1.25) - let pigment = solution.withAlphaComponent(pigmentOpacity) let particle = concentrationColor( - water: NSColor(calibratedRed: 0.62, green: 0.91, blue: 1.00, alpha: 1), - blue: NSColor(calibratedRed: 0.18, green: 0.55, blue: 1.00, alpha: 0.85), + water: NSColor(calibratedRed: 0.40, green: 0.10, blue: 0.34, alpha: 0.90), + blue: NSColor(calibratedRed: 0.28, green: 0.03, blue: 0.55, alpha: 0.90), purple: NSColor(calibratedRed: 0.40, green: 0.04, blue: 0.94, alpha: 0.94) ) CATransaction.begin() CATransaction.setDisableActions(!animated) CATransaction.setAnimationDuration(animated ? 0.45 : 0) - pigmentLayer.backgroundColor = pigment.cgColor + pigmentLayer.colors = [ + core.withAlphaComponent(pigmentOpacity).cgColor, + solution.withAlphaComponent(pigmentOpacity * 0.92).cgColor, + edge.withAlphaComponent(pigmentOpacity * 0.72).cgColor, + ] waterEdgeLayer.strokeColor = NSColor( - calibratedRed: 0.72, - green: 0.93, - blue: 1, - alpha: 0.025 + (1 - quotaConcentration) * 0.10 + calibratedRed: 0.24, + green: 0.50, + blue: 0.68, + alpha: 0.16 + (1 - quotaConcentration) * 0.16 ).cgColor CATransaction.commit() particleStartColor = particle.cgColor @@ -1655,35 +1705,15 @@ private final class EnergyOrbView: NSView { } -private final class OrbInfoGlassView: NSVisualEffectView { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - material = .hudWindow - blendingMode = .behindWindow - state = .active - appearance = NSAppearance(named: .darkAqua) - wantsLayer = true - layer?.cornerRadius = 24 - layer?.masksToBounds = true - layer?.borderWidth = 0.7 - layer?.borderColor = NSColor.white.withAlphaComponent(0.13).cgColor - layer?.backgroundColor = NSColor.black.withAlphaComponent(0.08).cgColor - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - private final class WidgetView: NSView { + private let panelGlassView = PanelGlassBackdropView(frame: .zero) private let orbView = EnergyOrbView(frame: .zero) - private let infoGlassView = OrbInfoGlassView(frame: .zero) - private let quotaLabel = makeLabel("本周剩余", size: 11, weight: .semibold, alpha: 0.68) - private let resetCountLabel = makeLabel("重置 --/--", size: 9.5, weight: .medium, alpha: 0.52) + private let quotaLabel = makeLabel("本周剩余", size: 11, weight: .semibold, alpha: 0.84) + private let resetCountLabel = makeLabel("重置 --/--", size: 9.5, weight: .medium, alpha: 0.72) private let percentLabel = makeLabel("--%", size: 46, weight: .bold) - private let resetLabel = makeLabel("重置 --", size: 10.5, weight: .medium, alpha: 0.70) - private let tokenLabel = makeLabel("本次 -- Token", size: 10.5, weight: .semibold, alpha: 0.74) - private let activityLabel = makeLabel("当前空闲", size: 10, weight: .medium, alpha: 0.56) + private let resetLabel = makeLabel("重置 --", size: 10.5, weight: .medium, alpha: 0.86) + private let tokenLabel = makeLabel("本次 -- Token", size: 10.5, weight: .semibold, alpha: 0.88) + private let activityLabel = makeLabel("当前空闲", size: 10, weight: .medium, alpha: 0.76) private let closeButton = HoverCloseButton(frame: .zero) private var snapshot: QuotaSnapshot? private var tokenUsage: TokenUsageSnapshot? @@ -1697,11 +1727,12 @@ private final class WidgetView: NSView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true - addSubview(infoGlassView) + addSubview(panelGlassView) addSubview(orbView) for label in [quotaLabel, resetCountLabel, percentLabel, resetLabel, tokenLabel, activityLabel] { label.alignment = .left label.lineBreakMode = .byClipping + applyDesktopTextShadow(to: label) addSubview(label) } resetCountLabel.alignment = .right @@ -1786,8 +1817,8 @@ private final class WidgetView: NSView { override func layout() { super.layout() - infoGlassView.frame = NSRect(x: 6, y: 10, width: 348, height: 164) - orbView.frame = infoGlassView.frame + panelGlassView.frame = NSRect(x: 6, y: 10, width: 348, height: 164) + orbView.frame = NSRect(x: 6, y: 10, width: 348, height: 164) quotaLabel.frame = NSRect(x: 192, y: 137, width: 82, height: 16) resetCountLabel.frame = NSRect(x: 270, y: 137, width: 58, height: 16) percentLabel.frame = NSRect(x: 188, y: 81, width: 142, height: 56) @@ -1797,6 +1828,14 @@ private final class WidgetView: NSView { closeButton.frame = NSRect(x: 14, y: 150, width: 20, height: 20) } + private func applyDesktopTextShadow(to label: NSTextField) { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.88) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = NSSize(width: 0, height: -1) + label.shadow = shadow + } + override func hitTest(_ point: NSPoint) -> NSView? { if !closeButton.isHidden, closeButton.frame.contains(point) { return closeButton @@ -1804,11 +1843,16 @@ private final class WidgetView: NSView { return bounds.contains(point) ? self : nil } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + override func mouseDown(with event: NSEvent) { guard event.type == .leftMouseDown, let window else { super.mouseDown(with: event) return } + window.makeKey() let mouseLocation = NSEvent.mouseLocation dragStartMouseLocation = mouseLocation dragStartWindowOrigin = window.frame.origin @@ -2281,7 +2325,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(.separator()) let showItem = NSMenuItem( - title: "显示额度小窗", + title: "在当前桌面显示小窗", action: #selector(showPanelFromMenu), keyEquivalent: "" ) @@ -2352,26 +2396,6 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { widgetView?.apply(taskLevelEnergy: latestTaskLevelEnergy) panel.orderFrontRegardless() panel.makeKey() - NSApp.activate(ignoringOtherApps: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak panel] in - panel?.orderFrontRegardless() - panel?.makeKey() - } - } - - private func positionPanel() { - guard let panel else { return } - let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) - let statusFrame = statusItem?.button?.window? - .convertToScreen(statusItem?.button?.frame ?? .zero) - - let targetX = statusFrame.map { $0.midX - widgetSize.width + 44 } - ?? (screenFrame.maxX - widgetSize.width - 28) - let x = min(max(targetX, screenFrame.minX + 12), screenFrame.maxX - widgetSize.width - 12) - let y = statusFrame.map { $0.minY - widgetSize.height - 10 } - ?? (screenFrame.maxY - widgetSize.height - 48) - - panel.setFrameOrigin(NSPoint(x: x, y: max(screenFrame.minY + 12, y))) } private func buildWindow() { @@ -2397,10 +2421,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { panel.becomesKeyOnlyIfNeeded = false panel.isReleasedWhenClosed = false panel.hidesOnDeactivate = false - panel.collectionBehavior = [.managed] + panel.collectionBehavior = [.moveToActiveSpace] panel.isMovableByWindowBackground = false panel.acceptsMouseMovedEvents = true panel.delegate = self + panel.setFrameAutosaveName("CodexQuotaWidgetPanel") + panel.setFrameUsingName("CodexQuotaWidgetPanel") let content = WidgetView(frame: NSRect(origin: .zero, size: widgetSize)) content.autoresizingMask = [.width, .height] diff --git a/macOS/Sources/GlassBridge.m b/macOS/Sources/GlassBridge.m deleted file mode 100644 index 9544f91..0000000 --- a/macOS/Sources/GlassBridge.m +++ /dev/null @@ -1,21 +0,0 @@ -#import - -void *CQCreateGlassEffectView(void) { - Class glassClass = NSClassFromString(@"NSGlassEffectView"); - if (glassClass == Nil) { - return NULL; - } - - NSView *glassView = [[glassClass alloc] initWithFrame:NSZeroRect]; - return (__bridge_retained void *)glassView; -} - -void *CQCreateGlassEffectContainerView(void) { - Class containerClass = NSClassFromString(@"NSGlassEffectContainerView"); - if (containerClass == Nil) { - return NULL; - } - - NSView *containerView = [[containerClass alloc] initWithFrame:NSZeroRect]; - return (__bridge_retained void *)containerView; -} diff --git a/macOS/build-macos.sh b/macOS/build-macos.sh index 6df47c9..b580539 100755 --- a/macOS/build-macos.sh +++ b/macOS/build-macos.sh @@ -26,6 +26,8 @@ else fi SDK_PATH="$SELECTED_SDK" MIN_VERSION="${MACOS_MIN_VERSION:-13.0}" +VERSION="${VERSION:-0.13.0}" +BUILD_NUMBER="${BUILD_NUMBER:-43}" ARCH_SPEC="${MACOS_ARCHS:-$(uname -m)}" read -r -a ARCHES <<< "$ARCH_SPEC" @@ -35,12 +37,8 @@ mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" for ARCH in "${ARCHES[@]}"; do ARCH_DIR="$BUILD_DIR/$ARCH" mkdir -p "$ARCH_DIR" - xcrun clang -isysroot "$SDK_PATH" -arch "$ARCH" \ - -mmacosx-version-min="$MIN_VERSION" -fobjc-arc -c \ - "$SOURCE_DIR/GlassBridge.m" -o "$ARCH_DIR/GlassBridge.o" - xcrun swiftc -sdk "$SDK_PATH" -target "$ARCH-apple-macosx$MIN_VERSION" -O \ - "$SOURCE_DIR/CodexQuotaWidget.swift" "$ARCH_DIR/GlassBridge.o" \ + "$SOURCE_DIR/CodexQuotaWidget.swift" \ -framework AppKit -framework Foundation -framework QuartzCore \ -o "$ARCH_DIR/CodexQuotaWidget" @@ -80,9 +78,9 @@ cat > "$APP_DIR/Contents/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - 0.12.0 + $VERSION CFBundleVersion - 42 + $BUILD_NUMBER LSMinimumSystemVersion $MIN_VERSION LSUIElement diff --git a/macOS/package-release.sh b/macOS/package-release.sh index f5f73c9..4cd21bf 100755 --- a/macOS/package-release.sh +++ b/macOS/package-release.sh @@ -2,7 +2,7 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -VERSION="${VERSION:-0.12.0}" +VERSION="${VERSION:-0.13.0}" ARCH_SPEC="${MACOS_ARCHS:-arm64 x86_64}" BUILD_DIR="$PROJECT_ROOT/.build/release-macos" APP_DIR="$BUILD_DIR/CodexQuotaWidget.app" @@ -12,7 +12,7 @@ PACKAGE_DIR="$RELEASE_DIR/CodexQuotaWidget-macOS-v$VERSION" rm -rf "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" -MACOS_ARCHS="$ARCH_SPEC" BUILD_DIR="$BUILD_DIR" APP_DIR="$APP_DIR" \ +VERSION="$VERSION" MACOS_ARCHS="$ARCH_SPEC" BUILD_DIR="$BUILD_DIR" APP_DIR="$APP_DIR" \ bash "$PROJECT_ROOT/macOS/build-macos.sh" mkdir -p "$PACKAGE_DIR"