coding-usage-bar 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/format.d.ts +1 -0
- package/dist/format.js +25 -0
- package/dist/kimi.d.ts +1 -0
- package/dist/kimi.js +45 -0
- package/dist/menubar-render.d.ts +67 -0
- package/dist/menubar-render.js +255 -0
- package/dist/menubar.d.ts +1 -1
- package/dist/menubar.js +26 -172
- package/dist/png.d.ts +7 -0
- package/dist/png.js +141 -0
- package/dist/status.js +17 -1
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/format.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BurnAnalysis, StatusIssue, ProviderUsage, UsageWindow, StatusSnapshot } from "./types.js";
|
|
2
2
|
export declare function formatDurationUntil(iso: string, now?: Date): string;
|
|
3
|
+
export declare function formatResetTime(iso: string, now?: Date): string;
|
|
3
4
|
export declare function formatProviderLabel(provider: string): "Claude" | "GLM" | "DeepSeek" | "MiniMax" | "Kimi" | "Codex";
|
|
4
5
|
export declare function formatWindowLabel(window: UsageWindow): "5h" | "7d";
|
|
5
6
|
export declare function usageBar(usedPercent: number, width?: number): string;
|
package/dist/format.js
CHANGED
|
@@ -11,6 +11,31 @@ export function formatDurationUntil(iso, now = new Date()) {
|
|
|
11
11
|
}
|
|
12
12
|
return `${mins}m`;
|
|
13
13
|
}
|
|
14
|
+
// Absolute reset label for surfaces that re-render on a timer (SwiftBar).
|
|
15
|
+
// A countdown changes every minute even when the underlying usage data is
|
|
16
|
+
// unchanged, which forces SwiftBar to rebuild the menu and repaint the menu
|
|
17
|
+
// bar on every refresh. An absolute clock time is a pure function of the
|
|
18
|
+
// reset timestamp, so unchanged data renders to identical bytes and SwiftBar
|
|
19
|
+
// skips the update entirely. `now` only decides "due" and whether the day
|
|
20
|
+
// prefix is needed, both of which change at most once per window/day.
|
|
21
|
+
export function formatResetTime(iso, now = new Date()) {
|
|
22
|
+
const resetMs = Date.parse(iso);
|
|
23
|
+
if (!Number.isFinite(resetMs)) {
|
|
24
|
+
return "due";
|
|
25
|
+
}
|
|
26
|
+
if (resetMs <= now.getTime()) {
|
|
27
|
+
return "due";
|
|
28
|
+
}
|
|
29
|
+
const reset = new Date(resetMs);
|
|
30
|
+
const sameDay = reset.getFullYear() === now.getFullYear()
|
|
31
|
+
&& reset.getMonth() === now.getMonth()
|
|
32
|
+
&& reset.getDate() === now.getDate();
|
|
33
|
+
const clock = `${String(reset.getHours()).padStart(2, "0")}:${String(reset.getMinutes()).padStart(2, "0")}`;
|
|
34
|
+
if (sameDay) {
|
|
35
|
+
return clock;
|
|
36
|
+
}
|
|
37
|
+
return `${reset.getMonth() + 1}/${reset.getDate()} ${clock}`;
|
|
38
|
+
}
|
|
14
39
|
function windowLabel(window) {
|
|
15
40
|
return window.name === "five_hour" ? "5h" : "7d";
|
|
16
41
|
}
|
package/dist/kimi.d.ts
CHANGED
|
@@ -28,5 +28,6 @@ export declare function usageFromKimiUsages(usages: KimiUsagesResponse, options:
|
|
|
28
28
|
}): ProviderUsage | null;
|
|
29
29
|
export declare function readClaudeLanesKimiConfig(configFile: string): KimiConfig | null;
|
|
30
30
|
export declare function resolveKimiConfig(config: KimiConfig | undefined, homeDir?: string): KimiConfig;
|
|
31
|
+
export declare function probeKimiAccessFrozen(config: KimiConfig): Promise<boolean>;
|
|
31
32
|
export declare function collectKimiUsage(config: KimiConfig): Promise<ProviderUsage>;
|
|
32
33
|
export {};
|
package/dist/kimi.js
CHANGED
|
@@ -132,6 +132,41 @@ export function resolveKimiConfig(config, homeDir = os.homedir()) {
|
|
|
132
132
|
apiKey: config?.apiKey || lanes?.apiKey,
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
// Kimi's monthly billing-cycle quota can freeze all chat access while
|
|
136
|
+
// /v1/usages still reports headroom in the 5h/weekly windows (the monthly
|
|
137
|
+
// number itself is not exposed on any API-key-authenticated endpoint; it only
|
|
138
|
+
// shows up in the logged-in web console). Detect the freeze with a
|
|
139
|
+
// zero-cost probe: the billing-cycle gate runs before model validation, so a
|
|
140
|
+
// chat request naming a nonexistent model returns 403 access_terminated_error
|
|
141
|
+
// when frozen — and a "model not found" error (no inference, no quota use)
|
|
142
|
+
// when healthy.
|
|
143
|
+
const ACCESS_PROBE_MODEL = "coding-usage-bar-access-probe";
|
|
144
|
+
export async function probeKimiAccessFrozen(config) {
|
|
145
|
+
const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
146
|
+
const response = await fetch(`${baseUrl}/v1/chat/completions`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"Authorization": `Bearer ${config.apiKey}`,
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: ACCESS_PROBE_MODEL,
|
|
154
|
+
max_tokens: 1,
|
|
155
|
+
messages: [{ role: "user", content: "hi" }],
|
|
156
|
+
}),
|
|
157
|
+
signal: AbortSignal.timeout(10_000),
|
|
158
|
+
});
|
|
159
|
+
if (response.status !== 403) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const body = (await response.json());
|
|
164
|
+
return body?.error?.type === "access_terminated_error";
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
135
170
|
export async function collectKimiUsage(config) {
|
|
136
171
|
if (!config.apiKey) {
|
|
137
172
|
throw new Error("Kimi API key not configured. Edit ~/.coding-usage-bar/config.json to set kimi.apiKey, or configure a kimi.com lane in ~/.config/claude-lanes/config.env.");
|
|
@@ -144,5 +179,15 @@ export async function collectKimiUsage(config) {
|
|
|
144
179
|
if (!usage) {
|
|
145
180
|
throw new Error("Kimi usages response did not contain expected 5h/7d quota data");
|
|
146
181
|
}
|
|
182
|
+
// The probe must never fail the collection: on network errors the window
|
|
183
|
+
// data stays valid and simply renders unblocked.
|
|
184
|
+
try {
|
|
185
|
+
if (await probeKimiAccessFrozen(config)) {
|
|
186
|
+
usage.blocked = { reason: "monthly quota exhausted; access frozen until the next billing cycle" };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Probe unavailable; keep the usage data as-is.
|
|
191
|
+
}
|
|
147
192
|
return usage;
|
|
148
193
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface TitleSegment {
|
|
2
|
+
provider: string;
|
|
3
|
+
bars: Array<{
|
|
4
|
+
pct: number;
|
|
5
|
+
lightColor: string;
|
|
6
|
+
darkColor: string;
|
|
7
|
+
}>;
|
|
8
|
+
iconPath: string | null;
|
|
9
|
+
iconPathDark: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface TitleVariant {
|
|
12
|
+
dividerColor: string;
|
|
13
|
+
dividerAlpha: number;
|
|
14
|
+
barBgColor: string;
|
|
15
|
+
barBgAlpha: number;
|
|
16
|
+
}
|
|
17
|
+
export interface TitlePayload {
|
|
18
|
+
segments: TitleSegment[];
|
|
19
|
+
scale: number;
|
|
20
|
+
height: number;
|
|
21
|
+
minWidth: number;
|
|
22
|
+
paddingX: number;
|
|
23
|
+
iconSize: number;
|
|
24
|
+
iconBarGap: number;
|
|
25
|
+
segmentGap: number;
|
|
26
|
+
barWidth: number;
|
|
27
|
+
barHeight: number;
|
|
28
|
+
barGap: number;
|
|
29
|
+
barRadius: number;
|
|
30
|
+
barMinFill: number;
|
|
31
|
+
light: TitleVariant;
|
|
32
|
+
dark: TitleVariant;
|
|
33
|
+
}
|
|
34
|
+
interface RenderedImage {
|
|
35
|
+
image: string;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
export declare function renderTitleImage(payload: TitlePayload): {
|
|
40
|
+
light: RenderedImage;
|
|
41
|
+
dark: RenderedImage;
|
|
42
|
+
};
|
|
43
|
+
export interface DropdownPayload {
|
|
44
|
+
bars: Array<{
|
|
45
|
+
pct: number;
|
|
46
|
+
lightColor: string;
|
|
47
|
+
darkColor: string;
|
|
48
|
+
}>;
|
|
49
|
+
scale: number;
|
|
50
|
+
barWidth: number;
|
|
51
|
+
barHeight: number;
|
|
52
|
+
barRadius: number;
|
|
53
|
+
light: {
|
|
54
|
+
barBgColor: string;
|
|
55
|
+
barBgAlpha: number;
|
|
56
|
+
};
|
|
57
|
+
dark: {
|
|
58
|
+
barBgColor: string;
|
|
59
|
+
barBgAlpha: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export declare function renderDropdownBars(payload: DropdownPayload): {
|
|
63
|
+
images: string[];
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
};
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { encodePNG, decodePNG } from "./png.js";
|
|
3
|
+
// Pure-JS reimplementation of the menu bar image rendering that previously ran
|
|
4
|
+
// through `osascript -l JavaScript` + AppKit. Spawning AppKit-linked processes
|
|
5
|
+
// connects to the WindowServer and stalls the whole display for 1-2s on every
|
|
6
|
+
// SwiftBar refresh; drawing the pixels in-process here never touches the
|
|
7
|
+
// WindowServer, so there is zero UI freeze. Geometry, colors and the y-up
|
|
8
|
+
// (bottom-left origin) coordinate system mirror the old AppKit code exactly so
|
|
9
|
+
// the rendered images stay visually identical.
|
|
10
|
+
const SUPERSAMPLE = 4; // 4x4 coverage sampling for anti-aliased rounded corners
|
|
11
|
+
function parseHex(hex) {
|
|
12
|
+
const v = hex.replace("#", "");
|
|
13
|
+
return {
|
|
14
|
+
r: parseInt(v.slice(0, 2), 16) / 255,
|
|
15
|
+
g: parseInt(v.slice(2, 4), 16) / 255,
|
|
16
|
+
b: parseInt(v.slice(4, 6), 16) / 255,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
class Canvas {
|
|
20
|
+
width;
|
|
21
|
+
height;
|
|
22
|
+
data; // straight-alpha RGBA, 0..1
|
|
23
|
+
constructor(width, height) {
|
|
24
|
+
this.width = width;
|
|
25
|
+
this.height = height;
|
|
26
|
+
this.data = new Float32Array(width * height * 4);
|
|
27
|
+
}
|
|
28
|
+
// Source-over compositing of a single pixel using straight alpha.
|
|
29
|
+
composite(px, py, color, alpha) {
|
|
30
|
+
if (alpha <= 0 || px < 0 || py < 0 || px >= this.width || py >= this.height) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const idx = (py * this.width + px) * 4;
|
|
34
|
+
const dr = this.data[idx];
|
|
35
|
+
const dg = this.data[idx + 1];
|
|
36
|
+
const db = this.data[idx + 2];
|
|
37
|
+
const da = this.data[idx + 3];
|
|
38
|
+
const outA = alpha + da * (1 - alpha);
|
|
39
|
+
if (outA <= 0) {
|
|
40
|
+
this.data[idx] = this.data[idx + 1] = this.data[idx + 2] = this.data[idx + 3] = 0;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.data[idx] = (color.r * alpha + dr * da * (1 - alpha)) / outA;
|
|
44
|
+
this.data[idx + 1] = (color.g * alpha + dg * da * (1 - alpha)) / outA;
|
|
45
|
+
this.data[idx + 2] = (color.b * alpha + db * da * (1 - alpha)) / outA;
|
|
46
|
+
this.data[idx + 3] = outA;
|
|
47
|
+
}
|
|
48
|
+
// Fill a rounded rectangle expressed in AppKit coordinates (origin bottom-left,
|
|
49
|
+
// y increases upward). r is clamped to half the smaller side, matching
|
|
50
|
+
// CGPathCreateWithRoundedRect.
|
|
51
|
+
fillRoundedRect(ax, ay, aw, ah, radius, color, alpha) {
|
|
52
|
+
const r = Math.min(radius, aw / 2, ah / 2);
|
|
53
|
+
const cx = ax + aw / 2;
|
|
54
|
+
const cy = ay + ah / 2;
|
|
55
|
+
const halfW = aw / 2 - r;
|
|
56
|
+
const halfH = ah / 2 - r;
|
|
57
|
+
const H = this.height;
|
|
58
|
+
const pxMin = Math.max(0, Math.floor(ax));
|
|
59
|
+
const pxMax = Math.min(this.width, Math.ceil(ax + aw));
|
|
60
|
+
const pyMin = Math.max(0, Math.floor(H - (ay + ah)));
|
|
61
|
+
const pyMax = Math.min(H, Math.ceil(H - ay));
|
|
62
|
+
for (let py = pyMin; py < pyMax; py++) {
|
|
63
|
+
for (let px = pxMin; px < pxMax; px++) {
|
|
64
|
+
let hits = 0;
|
|
65
|
+
for (let j = 0; j < SUPERSAMPLE; j++) {
|
|
66
|
+
for (let i = 0; i < SUPERSAMPLE; i++) {
|
|
67
|
+
const sx = px + (i + 0.5) / SUPERSAMPLE;
|
|
68
|
+
const sPngY = py + (j + 0.5) / SUPERSAMPLE;
|
|
69
|
+
const sy = H - sPngY; // back to AppKit y-up
|
|
70
|
+
const qx = Math.abs(sx - cx) - halfW;
|
|
71
|
+
const qy = Math.abs(sy - cy) - halfH;
|
|
72
|
+
const dxo = Math.max(qx, 0);
|
|
73
|
+
const dyo = Math.max(qy, 0);
|
|
74
|
+
const dist = Math.sqrt(dxo * dxo + dyo * dyo) + Math.min(Math.max(qx, qy), 0) - r;
|
|
75
|
+
if (dist <= 0)
|
|
76
|
+
hits++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (hits > 0) {
|
|
80
|
+
this.composite(px, py, color, alpha * (hits / (SUPERSAMPLE * SUPERSAMPLE)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Draw an RGBA source image upright into an AppKit-space rect (bottom-left
|
|
86
|
+
// origin), bilinearly scaled to `size` x `size`. Mirrors NSImage.drawInRect.
|
|
87
|
+
drawIcon(icon, ax, ay, size) {
|
|
88
|
+
const H = this.height;
|
|
89
|
+
const pxMin = Math.max(0, Math.floor(ax));
|
|
90
|
+
const pxMax = Math.min(this.width, Math.ceil(ax + size));
|
|
91
|
+
const pyMin = Math.max(0, Math.floor(H - (ay + size)));
|
|
92
|
+
const pyMax = Math.min(H, Math.ceil(H - ay));
|
|
93
|
+
const iw = icon.width;
|
|
94
|
+
const ih = icon.height;
|
|
95
|
+
for (let py = pyMin; py < pyMax; py++) {
|
|
96
|
+
for (let px = pxMin; px < pxMax; px++) {
|
|
97
|
+
const cxA = px + 0.5;
|
|
98
|
+
const cyA = H - (py + 0.5);
|
|
99
|
+
const u = (cxA - ax) / size; // 0..1 left -> right
|
|
100
|
+
const v = (ay + size - cyA) / size; // 0..1 top -> bottom
|
|
101
|
+
if (u < 0 || u >= 1 || v < 0 || v >= 1)
|
|
102
|
+
continue;
|
|
103
|
+
const sample = bilinear(icon.data, iw, ih, u * iw - 0.5, v * ih - 0.5);
|
|
104
|
+
if (sample.a > 0) {
|
|
105
|
+
this.composite(px, py, { r: sample.r, g: sample.g, b: sample.b }, sample.a);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
toPNGBase64() {
|
|
111
|
+
const out = new Uint8Array(this.width * this.height * 4);
|
|
112
|
+
for (let i = 0; i < this.width * this.height; i++) {
|
|
113
|
+
out[i * 4] = Math.round(clamp01(this.data[i * 4]) * 255);
|
|
114
|
+
out[i * 4 + 1] = Math.round(clamp01(this.data[i * 4 + 1]) * 255);
|
|
115
|
+
out[i * 4 + 2] = Math.round(clamp01(this.data[i * 4 + 2]) * 255);
|
|
116
|
+
out[i * 4 + 3] = Math.round(clamp01(this.data[i * 4 + 3]) * 255);
|
|
117
|
+
}
|
|
118
|
+
return encodePNG(out, this.width, this.height).toString("base64");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function clamp01(v) {
|
|
122
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
123
|
+
}
|
|
124
|
+
function bilinear(data, w, h, fx, fy) {
|
|
125
|
+
const x0 = Math.floor(fx);
|
|
126
|
+
const y0 = Math.floor(fy);
|
|
127
|
+
const tx = fx - x0;
|
|
128
|
+
const ty = fy - y0;
|
|
129
|
+
const at = (x, y) => {
|
|
130
|
+
const cx = x < 0 ? 0 : x >= w ? w - 1 : x;
|
|
131
|
+
const cy = y < 0 ? 0 : y >= h ? h - 1 : y;
|
|
132
|
+
const idx = (cy * w + cx) * 4;
|
|
133
|
+
return {
|
|
134
|
+
r: data[idx] / 255,
|
|
135
|
+
g: data[idx + 1] / 255,
|
|
136
|
+
b: data[idx + 2] / 255,
|
|
137
|
+
a: data[idx + 3] / 255,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
const c00 = at(x0, y0);
|
|
141
|
+
const c10 = at(x0 + 1, y0);
|
|
142
|
+
const c01 = at(x0, y0 + 1);
|
|
143
|
+
const c11 = at(x0 + 1, y0 + 1);
|
|
144
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
145
|
+
return {
|
|
146
|
+
r: lerp(lerp(c00.r, c10.r, tx), lerp(c01.r, c11.r, tx), ty),
|
|
147
|
+
g: lerp(lerp(c00.g, c10.g, tx), lerp(c01.g, c11.g, tx), ty),
|
|
148
|
+
b: lerp(lerp(c00.b, c10.b, tx), lerp(c01.b, c11.b, tx), ty),
|
|
149
|
+
a: lerp(lerp(c00.a, c10.a, tx), lerp(c01.a, c11.a, tx), ty),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const iconCache = new Map();
|
|
153
|
+
function loadIcon(iconPath) {
|
|
154
|
+
if (iconCache.has(iconPath)) {
|
|
155
|
+
return iconCache.get(iconPath) ?? null;
|
|
156
|
+
}
|
|
157
|
+
let decoded = null;
|
|
158
|
+
try {
|
|
159
|
+
decoded = decodePNG(fs.readFileSync(iconPath));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
decoded = null;
|
|
163
|
+
}
|
|
164
|
+
iconCache.set(iconPath, decoded);
|
|
165
|
+
return decoded;
|
|
166
|
+
}
|
|
167
|
+
function drawTitle(payload, variant, mode) {
|
|
168
|
+
const scale = payload.scale || 1;
|
|
169
|
+
const height = payload.height * scale;
|
|
170
|
+
const iconSize = payload.iconSize * scale;
|
|
171
|
+
const paddingX = payload.paddingX * scale;
|
|
172
|
+
const iconBarGap = payload.iconBarGap * scale;
|
|
173
|
+
const segmentGap = payload.segmentGap * scale;
|
|
174
|
+
const barWidth = payload.barWidth * scale;
|
|
175
|
+
const barHeight = payload.barHeight * scale;
|
|
176
|
+
const barGap = payload.barGap * scale;
|
|
177
|
+
const barRadius = payload.barRadius * scale;
|
|
178
|
+
const barMinFill = (payload.barMinFill || barRadius * 2) * scale;
|
|
179
|
+
const dividerW = 1 * scale;
|
|
180
|
+
let width = paddingX * 2;
|
|
181
|
+
payload.segments.forEach((seg, idx) => {
|
|
182
|
+
if (idx > 0)
|
|
183
|
+
width += segmentGap + dividerW + segmentGap;
|
|
184
|
+
width += (seg.iconPath ? iconSize + iconBarGap : 0) + barWidth;
|
|
185
|
+
});
|
|
186
|
+
width = Math.max(payload.minWidth * scale, width);
|
|
187
|
+
const canvas = new Canvas(Math.round(width), Math.round(height));
|
|
188
|
+
const dividerColor = parseHex(variant.dividerColor);
|
|
189
|
+
let x = paddingX;
|
|
190
|
+
const iconY = Math.floor((height - iconSize) / 2);
|
|
191
|
+
payload.segments.forEach((seg, idx) => {
|
|
192
|
+
if (idx > 0) {
|
|
193
|
+
x += segmentGap;
|
|
194
|
+
canvas.fillRoundedRect(x, Math.floor(height * 0.2), dividerW, Math.floor(height * 0.6), 0, dividerColor, variant.dividerAlpha);
|
|
195
|
+
x += dividerW + segmentGap;
|
|
196
|
+
}
|
|
197
|
+
const iconFile = mode === "dark" && seg.iconPathDark ? seg.iconPathDark : seg.iconPath;
|
|
198
|
+
if (iconFile) {
|
|
199
|
+
const icon = loadIcon(iconFile);
|
|
200
|
+
if (icon) {
|
|
201
|
+
canvas.drawIcon(icon, x, iconY, iconSize);
|
|
202
|
+
}
|
|
203
|
+
x += iconSize + iconBarGap;
|
|
204
|
+
}
|
|
205
|
+
const bars = seg.bars || [];
|
|
206
|
+
const totalH = bars.length * barHeight + Math.max(0, bars.length - 1) * barGap;
|
|
207
|
+
let barY = Math.floor((height + totalH) / 2) - barHeight;
|
|
208
|
+
const barBgColor = parseHex(variant.barBgColor);
|
|
209
|
+
for (let i = 0; i < bars.length; i++) {
|
|
210
|
+
canvas.fillRoundedRect(x, barY, barWidth, barHeight, barRadius, barBgColor, variant.barBgAlpha);
|
|
211
|
+
if (bars[i].pct > 0) {
|
|
212
|
+
const pct = Math.min(bars[i].pct, 100);
|
|
213
|
+
const fw = Math.max(barMinFill, Math.round((barWidth * pct) / 100));
|
|
214
|
+
const fillColor = parseHex(mode === "dark" ? bars[i].darkColor : bars[i].lightColor);
|
|
215
|
+
canvas.fillRoundedRect(x, barY, fw, barHeight, barRadius, fillColor, 1);
|
|
216
|
+
}
|
|
217
|
+
barY -= barHeight + barGap;
|
|
218
|
+
}
|
|
219
|
+
x += barWidth;
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
image: canvas.toPNGBase64(),
|
|
223
|
+
width: Math.ceil(width / scale),
|
|
224
|
+
height: payload.height,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export function renderTitleImage(payload) {
|
|
228
|
+
return {
|
|
229
|
+
light: drawTitle(payload, payload.light, "light"),
|
|
230
|
+
dark: drawTitle(payload, payload.dark, "dark"),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function makeBar(bw, bh, br, pct, fill, bgColor, bgAlpha) {
|
|
234
|
+
const canvas = new Canvas(bw, bh);
|
|
235
|
+
canvas.fillRoundedRect(0, 0, bw, bh, br, bgColor, bgAlpha);
|
|
236
|
+
if (pct > 0) {
|
|
237
|
+
const fw = Math.max(br * 2, Math.round((bw * Math.min(pct, 100)) / 100));
|
|
238
|
+
canvas.fillRoundedRect(0, 0, fw, bh, br, fill, 1);
|
|
239
|
+
}
|
|
240
|
+
return canvas.toPNGBase64();
|
|
241
|
+
}
|
|
242
|
+
export function renderDropdownBars(payload) {
|
|
243
|
+
const s = payload.scale || 2;
|
|
244
|
+
const bw = payload.barWidth * s;
|
|
245
|
+
const bh = payload.barHeight * s;
|
|
246
|
+
const br = payload.barRadius * s;
|
|
247
|
+
const lightBg = parseHex(payload.light.barBgColor);
|
|
248
|
+
const darkBg = parseHex(payload.dark.barBgColor);
|
|
249
|
+
const images = payload.bars.map((bar) => {
|
|
250
|
+
const light = makeBar(bw, bh, br, bar.pct, parseHex(bar.lightColor), lightBg, payload.light.barBgAlpha);
|
|
251
|
+
const dark = makeBar(bw, bh, br, bar.pct, parseHex(bar.darkColor), darkBg, payload.dark.barBgAlpha);
|
|
252
|
+
return `${light},${dark}`;
|
|
253
|
+
});
|
|
254
|
+
return { images, width: payload.barWidth, height: payload.barHeight };
|
|
255
|
+
}
|
package/dist/menubar.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare function openSwiftBar(): string[];
|
|
|
14
14
|
export declare function addSwiftBarToLoginItems(options?: {
|
|
15
15
|
dryRun?: boolean;
|
|
16
16
|
}): string[];
|
|
17
|
-
export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths): string;
|
|
17
|
+
export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths, now?: Date): string;
|
|
18
18
|
export declare function swiftBarStatusItemVisibilityKeys(defaultsOutput: string): string[];
|
|
19
19
|
export declare function installMenuBar(options?: {
|
|
20
20
|
dryRun?: boolean;
|
package/dist/menubar.js
CHANGED
|
@@ -3,10 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { ensureDir, isFile } from "./fs-util.js";
|
|
6
|
-
import {
|
|
6
|
+
import { formatProviderLabel, formatResetTime } from "./format.js";
|
|
7
7
|
import { buildPaths } from "./paths.js";
|
|
8
8
|
import { stableNodeExecutable } from "./node-runtime.js";
|
|
9
9
|
import { loadDisplayStatusSnapshot } from "./runtime.js";
|
|
10
|
+
import { renderTitleImage, renderDropdownBars } from "./menubar-render.js";
|
|
10
11
|
const MARKER = "coding-usage-bar managed SwiftBar plugin";
|
|
11
12
|
const SWIFTBAR_APP_PATHS = ["/Applications/SwiftBar.app", path.join(process.env.HOME ?? "", "Applications", "SwiftBar.app")];
|
|
12
13
|
const STATE_LABEL = {
|
|
@@ -65,154 +66,6 @@ const ASSET_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
65
66
|
const imageCache = new Map();
|
|
66
67
|
const TITLE_IMAGE_SCALE = 2;
|
|
67
68
|
const titleImageCache = new Map();
|
|
68
|
-
const TITLE_IMAGE_SCRIPT = `
|
|
69
|
-
ObjC.import('AppKit');
|
|
70
|
-
ObjC.import('CoreGraphics');
|
|
71
|
-
|
|
72
|
-
function drawImage(payload, variant, mode) {
|
|
73
|
-
var scale = payload.scale || 1;
|
|
74
|
-
var height = payload.height * scale;
|
|
75
|
-
var iconSize = payload.iconSize * scale;
|
|
76
|
-
var paddingX = payload.paddingX * scale;
|
|
77
|
-
var iconBarGap = payload.iconBarGap * scale;
|
|
78
|
-
var segmentGap = payload.segmentGap * scale;
|
|
79
|
-
var barWidth = payload.barWidth * scale;
|
|
80
|
-
var barHeight = payload.barHeight * scale;
|
|
81
|
-
var barGap = payload.barGap * scale;
|
|
82
|
-
var barRadius = payload.barRadius * scale;
|
|
83
|
-
var barMinFill = (payload.barMinFill || barRadius * 2) * scale;
|
|
84
|
-
var dividerW = 1 * scale;
|
|
85
|
-
|
|
86
|
-
var width = paddingX * 2;
|
|
87
|
-
payload.segments.forEach(function(seg, idx) {
|
|
88
|
-
if (idx > 0) width += segmentGap + dividerW + segmentGap;
|
|
89
|
-
width += (seg.iconPath ? iconSize + iconBarGap : 0) + barWidth;
|
|
90
|
-
});
|
|
91
|
-
width = Math.max(payload.minWidth * scale, width);
|
|
92
|
-
|
|
93
|
-
var rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBitmapFormatBytesPerRowBitsPerPixel(
|
|
94
|
-
null, width, height, 8, 4, true, false, $.NSDeviceRGBColorSpace, $.NSBitmapFormatAlphaPremultipliedLast, 0, 0
|
|
95
|
-
);
|
|
96
|
-
var nsctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
|
|
97
|
-
$.NSGraphicsContext.setCurrentContext(nsctx);
|
|
98
|
-
nsctx.setShouldAntialias(true);
|
|
99
|
-
nsctx.setImageInterpolation($.NSImageInterpolationHigh);
|
|
100
|
-
var cg = nsctx.CGContext;
|
|
101
|
-
|
|
102
|
-
function setFill(hex, alpha) {
|
|
103
|
-
var v = hex.replace('#', '');
|
|
104
|
-
$.CGContextSetRGBFillColor(cg,
|
|
105
|
-
parseInt(v.slice(0, 2), 16) / 255,
|
|
106
|
-
parseInt(v.slice(2, 4), 16) / 255,
|
|
107
|
-
parseInt(v.slice(4, 6), 16) / 255,
|
|
108
|
-
alpha !== undefined ? alpha : 1);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function fillRoundedRect(rx, ry, rw, rh, rr) {
|
|
112
|
-
var p = $.CGPathCreateWithRoundedRect($.CGRectMake(rx, ry, rw, rh), rr, rr, null);
|
|
113
|
-
$.CGContextAddPath(cg, p);
|
|
114
|
-
$.CGContextFillPath(cg);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
var x = paddingX;
|
|
118
|
-
var iconY = Math.floor((height - iconSize) / 2);
|
|
119
|
-
|
|
120
|
-
payload.segments.forEach(function(seg, idx) {
|
|
121
|
-
if (idx > 0) {
|
|
122
|
-
x += segmentGap;
|
|
123
|
-
setFill(variant.dividerColor, variant.dividerAlpha);
|
|
124
|
-
$.CGContextFillRect(cg, $.CGRectMake(x, Math.floor(height * 0.2), dividerW, Math.floor(height * 0.6)));
|
|
125
|
-
x += dividerW + segmentGap;
|
|
126
|
-
}
|
|
127
|
-
var iconFile = (mode === 'dark' && seg.iconPathDark) ? seg.iconPathDark : seg.iconPath;
|
|
128
|
-
if (iconFile) {
|
|
129
|
-
var icon = $.NSImage.alloc.initWithContentsOfFile($(iconFile));
|
|
130
|
-
if (icon) {
|
|
131
|
-
icon.drawInRectFromRectOperationFraction(
|
|
132
|
-
$.NSMakeRect(x, iconY, iconSize, iconSize),
|
|
133
|
-
$.NSZeroRect, $.NSCompositingOperationSourceOver, 1
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
x += iconSize + iconBarGap;
|
|
137
|
-
}
|
|
138
|
-
var bars = seg.bars || [];
|
|
139
|
-
var totalH = bars.length * barHeight + Math.max(0, bars.length - 1) * barGap;
|
|
140
|
-
var barY = Math.floor((height + totalH) / 2) - barHeight;
|
|
141
|
-
for (var i = 0; i < bars.length; i++) {
|
|
142
|
-
setFill(variant.barBgColor, variant.barBgAlpha);
|
|
143
|
-
fillRoundedRect(x, barY, barWidth, barHeight, barRadius);
|
|
144
|
-
if (bars[i].pct > 0) {
|
|
145
|
-
var pct = Math.min(bars[i].pct, 100);
|
|
146
|
-
var fw = Math.max(barMinFill, Math.round(barWidth * pct / 100));
|
|
147
|
-
setFill(bars[i][mode + 'Color'], 1);
|
|
148
|
-
fillRoundedRect(x, barY, fw, barHeight, barRadius);
|
|
149
|
-
}
|
|
150
|
-
barY -= barHeight + barGap;
|
|
151
|
-
}
|
|
152
|
-
x += barWidth;
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
var png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.alloc.init);
|
|
156
|
-
return {
|
|
157
|
-
image: ObjC.unwrap(png.base64EncodedStringWithOptions(0)),
|
|
158
|
-
width: Math.ceil(width / scale),
|
|
159
|
-
height: payload.height,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function run(argv) {
|
|
164
|
-
var payload = JSON.parse(argv[0]);
|
|
165
|
-
return JSON.stringify({
|
|
166
|
-
light: drawImage(payload, payload.light, 'light'),
|
|
167
|
-
dark: drawImage(payload, payload.dark, 'dark'),
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
`;
|
|
171
|
-
const DROPDOWN_BAR_SCRIPT = `
|
|
172
|
-
ObjC.import('AppKit');
|
|
173
|
-
ObjC.import('CoreGraphics');
|
|
174
|
-
|
|
175
|
-
function parseHex(hex) {
|
|
176
|
-
var v = hex.replace('#', '');
|
|
177
|
-
return [parseInt(v.slice(0,2),16)/255, parseInt(v.slice(2,4),16)/255, parseInt(v.slice(4,6),16)/255];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function makeBar(bw, bh, br, pct, fc, bgHex, bgAlpha) {
|
|
181
|
-
var rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBitmapFormatBytesPerRowBitsPerPixel(
|
|
182
|
-
null, bw, bh, 8, 4, true, false, $.NSDeviceRGBColorSpace, $.NSBitmapFormatAlphaPremultipliedLast, 0, 0);
|
|
183
|
-
var nsctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
|
|
184
|
-
$.NSGraphicsContext.setCurrentContext(nsctx);
|
|
185
|
-
nsctx.setShouldAntialias(true);
|
|
186
|
-
var cg = nsctx.CGContext;
|
|
187
|
-
var bg = parseHex(bgHex);
|
|
188
|
-
$.CGContextSetRGBFillColor(cg, bg[0], bg[1], bg[2], bgAlpha);
|
|
189
|
-
var bp = $.CGPathCreateWithRoundedRect($.CGRectMake(0,0,bw,bh), br, br, null);
|
|
190
|
-
$.CGContextAddPath(cg, bp); $.CGContextFillPath(cg);
|
|
191
|
-
if (pct > 0) {
|
|
192
|
-
var fw = Math.max(br*2, Math.round(bw * Math.min(pct,100) / 100));
|
|
193
|
-
$.CGContextSetRGBFillColor(cg, fc[0], fc[1], fc[2], 1);
|
|
194
|
-
var fp = $.CGPathCreateWithRoundedRect($.CGRectMake(0,0,fw,bh), br, br, null);
|
|
195
|
-
$.CGContextAddPath(cg, fp); $.CGContextFillPath(cg);
|
|
196
|
-
}
|
|
197
|
-
var png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.alloc.init);
|
|
198
|
-
return ObjC.unwrap(png.base64EncodedStringWithOptions(0));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function run(argv) {
|
|
202
|
-
var p = JSON.parse(argv[0]);
|
|
203
|
-
var s = p.scale || 2;
|
|
204
|
-
var bw = p.barWidth * s, bh = p.barHeight * s, br = p.barRadius * s;
|
|
205
|
-
var results = [];
|
|
206
|
-
for (var i = 0; i < p.bars.length; i++) {
|
|
207
|
-
var bar = p.bars[i];
|
|
208
|
-
var lc = parseHex(bar.lightColor), dc = parseHex(bar.darkColor);
|
|
209
|
-
var li = makeBar(bw, bh, br, bar.pct, lc, p.light.barBgColor, p.light.barBgAlpha);
|
|
210
|
-
var di = makeBar(bw, bh, br, bar.pct, dc, p.dark.barBgColor, p.dark.barBgAlpha);
|
|
211
|
-
results.push(li + ',' + di);
|
|
212
|
-
}
|
|
213
|
-
return JSON.stringify({images: results, width: p.barWidth, height: p.barHeight});
|
|
214
|
-
}
|
|
215
|
-
`;
|
|
216
69
|
const dropdownBarCache = new Map();
|
|
217
70
|
function renderDropdownBarImages(bars) {
|
|
218
71
|
if (bars.length === 0) {
|
|
@@ -233,11 +86,7 @@ function renderDropdownBarImages(bars) {
|
|
|
233
86
|
return cached ? cached.images.map((img) => ({ image: img, width: cached.width, height: cached.height })) : [];
|
|
234
87
|
}
|
|
235
88
|
try {
|
|
236
|
-
const
|
|
237
|
-
encoding: "utf8",
|
|
238
|
-
timeout: 3000,
|
|
239
|
-
}).trim();
|
|
240
|
-
const result = JSON.parse(output);
|
|
89
|
+
const result = renderDropdownBars(payload);
|
|
241
90
|
dropdownBarCache.set(cacheKey, result);
|
|
242
91
|
return result.images.map((img) => ({ image: img, width: result.width, height: result.height }));
|
|
243
92
|
}
|
|
@@ -489,7 +338,9 @@ function titleBars(provider) {
|
|
|
489
338
|
return [{ pct: isAvailable ? 100 : 0, lightColor, darkColor }];
|
|
490
339
|
}
|
|
491
340
|
const state = provider.analysis.state;
|
|
492
|
-
|
|
341
|
+
// Blocked (Kimi billing-cycle freeze): grey bars say "no access" without
|
|
342
|
+
// lying about the 5h/7d window numbers.
|
|
343
|
+
const [lightColor, darkColor] = (provider.usage.blocked ? MUTED_COLOR : STATE_COLOR[state]).split(",");
|
|
493
344
|
const fiveHour = provider.analysis.fiveHour;
|
|
494
345
|
const sevenDay = provider.analysis.sevenDay;
|
|
495
346
|
const bars = [];
|
|
@@ -559,11 +410,7 @@ function titleImageValue(providers) {
|
|
|
559
410
|
return titleImageCache.get(cacheKey);
|
|
560
411
|
}
|
|
561
412
|
try {
|
|
562
|
-
const
|
|
563
|
-
encoding: "utf8",
|
|
564
|
-
timeout: 2500,
|
|
565
|
-
}).trim();
|
|
566
|
-
const images = JSON.parse(output);
|
|
413
|
+
const images = renderTitleImage(payload);
|
|
567
414
|
const value = images.light?.image && images.dark?.image && images.light.width && images.light.height
|
|
568
415
|
? {
|
|
569
416
|
image: `${images.light.image},${images.dark.image}`,
|
|
@@ -605,10 +452,10 @@ function coloredMeter(usedPercent, fillColor, width = METER_WIDTH) {
|
|
|
605
452
|
return (filled > 0 ? ansiRGB(fillColor, "█".repeat(filled)) : "")
|
|
606
453
|
+ (empty > 0 ? ansiRGB(MUTED_COLOR, "░".repeat(empty)) : "");
|
|
607
454
|
}
|
|
608
|
-
function usageLine(label, usedPercent, resetsAt, color, barImage) {
|
|
455
|
+
function usageLine(label, usedPercent, resetsAt, color, now, barImage) {
|
|
609
456
|
const percent = `${Math.round(usedPercent).toString().padStart(3)}%`;
|
|
610
457
|
if (barImage) {
|
|
611
|
-
return line(`${label} ${percent} reset ${
|
|
458
|
+
return line(`${label} ${percent} reset ${formatResetTime(resetsAt, now)}`, {
|
|
612
459
|
image: barImage.image,
|
|
613
460
|
width: barImage.width,
|
|
614
461
|
height: barImage.height,
|
|
@@ -618,7 +465,7 @@ function usageLine(label, usedPercent, resetsAt, color, barImage) {
|
|
|
618
465
|
});
|
|
619
466
|
}
|
|
620
467
|
const bar = coloredMeter(usedPercent, color);
|
|
621
|
-
return line(`${label} ${bar} ${ansiRGB(color, percent)} reset ${
|
|
468
|
+
return line(`${label} ${bar} ${ansiRGB(color, percent)} reset ${formatResetTime(resetsAt, now)}`, {
|
|
622
469
|
ansi: true,
|
|
623
470
|
font: ROW_FONT,
|
|
624
471
|
size: 12,
|
|
@@ -645,8 +492,8 @@ function titleIconParams() {
|
|
|
645
492
|
}
|
|
646
493
|
return { sfimage: "flame.fill", sfcolor: RAW_COLOR };
|
|
647
494
|
}
|
|
648
|
-
function
|
|
649
|
-
return
|
|
495
|
+
function anyProviderStale(snapshot) {
|
|
496
|
+
return snapshot.providers.some((item) => item.meta.stale);
|
|
650
497
|
}
|
|
651
498
|
function issueLabel(code) {
|
|
652
499
|
if (code === "CLAUDE_INGEST_MISSING") {
|
|
@@ -672,7 +519,11 @@ function issueLabel(code) {
|
|
|
672
519
|
}
|
|
673
520
|
return code.replaceAll("_", " ").toLowerCase();
|
|
674
521
|
}
|
|
675
|
-
|
|
522
|
+
// `now` only feeds low-frequency decisions (reset due/day prefix). The output
|
|
523
|
+
// must stay byte-identical across renders while the snapshot is unchanged, so
|
|
524
|
+
// SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
|
|
525
|
+
// never render anything derived from wall-clock age or countdowns here.
|
|
526
|
+
export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths(), now = new Date()) {
|
|
676
527
|
const compact = readCompactMode(paths);
|
|
677
528
|
const providers = titleProviders(snapshot);
|
|
678
529
|
const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
|
|
@@ -726,7 +577,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
726
577
|
sfcolor: STATE_COLOR[topState],
|
|
727
578
|
badge: snapshot.profile.toUpperCase(),
|
|
728
579
|
}),
|
|
729
|
-
muted(
|
|
580
|
+
muted(anyProviderStale(snapshot) ? "Data stale" : "Data fresh"),
|
|
730
581
|
"---",
|
|
731
582
|
];
|
|
732
583
|
const barDataList = [];
|
|
@@ -734,7 +585,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
734
585
|
if (item.usage.balance && item.usage.windows.length === 0) {
|
|
735
586
|
continue;
|
|
736
587
|
}
|
|
737
|
-
const [lc, dc] = STATE_COLOR[item.analysis.state].split(",");
|
|
588
|
+
const [lc, dc] = (item.usage.blocked ? MUTED_COLOR : STATE_COLOR[item.analysis.state]).split(",");
|
|
738
589
|
const f = windowByName(item, "five_hour");
|
|
739
590
|
const s = windowByName(item, "seven_day");
|
|
740
591
|
if (f) {
|
|
@@ -769,17 +620,20 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
769
620
|
lines.push("---");
|
|
770
621
|
continue;
|
|
771
622
|
}
|
|
772
|
-
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${STATE_LABEL[item.analysis.state]}`, {
|
|
623
|
+
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${item.usage.blocked ? "Blocked" : STATE_LABEL[item.analysis.state]}`, {
|
|
773
624
|
...providerIconParams(item.usage.provider),
|
|
774
625
|
color: TEXT_COLOR,
|
|
775
626
|
size: 14,
|
|
776
|
-
badge: providerBadge(item),
|
|
627
|
+
badge: item.usage.blocked ? "Frozen" : providerBadge(item),
|
|
777
628
|
}));
|
|
778
629
|
if (five) {
|
|
779
|
-
lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, dropdownBars[barIdx++]));
|
|
630
|
+
lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
|
|
780
631
|
}
|
|
781
632
|
if (seven) {
|
|
782
|
-
lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, dropdownBars[barIdx++]));
|
|
633
|
+
lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
|
|
634
|
+
}
|
|
635
|
+
if (item.usage.blocked) {
|
|
636
|
+
lines.push(muted(`Blocked ${item.usage.blocked.reason}. 5h/7d numbers above still reflect the window quotas.`));
|
|
783
637
|
}
|
|
784
638
|
lines.push(muted(targetLabel(item)));
|
|
785
639
|
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
package/dist/png.d.ts
ADDED
package/dist/png.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import zlib from "node:zlib";
|
|
2
|
+
// Minimal zero-dependency PNG codec, scoped to 8-bit RGBA, non-interlaced images.
|
|
3
|
+
// That is the only format this tool renders (menu bar strips) and the only
|
|
4
|
+
// format its provider icon assets use (all 18x18 RGBA8). Keeping the codec tiny
|
|
5
|
+
// preserves the package's zero-runtime-dependency property.
|
|
6
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
7
|
+
const CRC_TABLE = (() => {
|
|
8
|
+
const table = new Uint32Array(256);
|
|
9
|
+
for (let n = 0; n < 256; n++) {
|
|
10
|
+
let c = n;
|
|
11
|
+
for (let k = 0; k < 8; k++) {
|
|
12
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
}
|
|
14
|
+
table[n] = c >>> 0;
|
|
15
|
+
}
|
|
16
|
+
return table;
|
|
17
|
+
})();
|
|
18
|
+
function crc32(buf) {
|
|
19
|
+
let c = 0xffffffff;
|
|
20
|
+
for (let i = 0; i < buf.length; i++) {
|
|
21
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
22
|
+
}
|
|
23
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
function chunk(type, data) {
|
|
26
|
+
const typeBuf = Buffer.from(type, "ascii");
|
|
27
|
+
const len = Buffer.alloc(4);
|
|
28
|
+
len.writeUInt32BE(data.length, 0);
|
|
29
|
+
const crcBuf = Buffer.alloc(4);
|
|
30
|
+
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
31
|
+
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
|
32
|
+
}
|
|
33
|
+
export function encodePNG(rgba, width, height) {
|
|
34
|
+
const stride = width * 4;
|
|
35
|
+
// One filter byte (0 = None) per scanline, followed by the raw RGBA row.
|
|
36
|
+
const raw = Buffer.alloc(height * (stride + 1));
|
|
37
|
+
for (let y = 0; y < height; y++) {
|
|
38
|
+
const dst = y * (stride + 1);
|
|
39
|
+
raw[dst] = 0;
|
|
40
|
+
for (let i = 0; i < stride; i++) {
|
|
41
|
+
raw[dst + 1 + i] = rgba[y * stride + i];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const ihdr = Buffer.alloc(13);
|
|
45
|
+
ihdr.writeUInt32BE(width, 0);
|
|
46
|
+
ihdr.writeUInt32BE(height, 4);
|
|
47
|
+
ihdr[8] = 8; // bit depth
|
|
48
|
+
ihdr[9] = 6; // color type: RGBA
|
|
49
|
+
ihdr[10] = 0; // compression
|
|
50
|
+
ihdr[11] = 0; // filter
|
|
51
|
+
ihdr[12] = 0; // interlace
|
|
52
|
+
const idat = zlib.deflateSync(raw, { level: 9 });
|
|
53
|
+
return Buffer.concat([
|
|
54
|
+
PNG_SIGNATURE,
|
|
55
|
+
chunk("IHDR", ihdr),
|
|
56
|
+
chunk("IDAT", idat),
|
|
57
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
function paeth(a, b, c) {
|
|
61
|
+
const p = a + b - c;
|
|
62
|
+
const pa = Math.abs(p - a);
|
|
63
|
+
const pb = Math.abs(p - b);
|
|
64
|
+
const pc = Math.abs(p - c);
|
|
65
|
+
if (pa <= pb && pa <= pc)
|
|
66
|
+
return a;
|
|
67
|
+
if (pb <= pc)
|
|
68
|
+
return b;
|
|
69
|
+
return c;
|
|
70
|
+
}
|
|
71
|
+
export function decodePNG(buffer) {
|
|
72
|
+
if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
73
|
+
throw new Error("decodePNG: not a PNG file");
|
|
74
|
+
}
|
|
75
|
+
let offset = 8;
|
|
76
|
+
let width = 0;
|
|
77
|
+
let height = 0;
|
|
78
|
+
const idatParts = [];
|
|
79
|
+
while (offset < buffer.length) {
|
|
80
|
+
const len = buffer.readUInt32BE(offset);
|
|
81
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
82
|
+
const dataStart = offset + 8;
|
|
83
|
+
const data = buffer.subarray(dataStart, dataStart + len);
|
|
84
|
+
if (type === "IHDR") {
|
|
85
|
+
width = data.readUInt32BE(0);
|
|
86
|
+
height = data.readUInt32BE(4);
|
|
87
|
+
const bitDepth = data[8];
|
|
88
|
+
const colorType = data[9];
|
|
89
|
+
const interlace = data[12];
|
|
90
|
+
if (bitDepth !== 8 || colorType !== 6 || interlace !== 0) {
|
|
91
|
+
throw new Error(`decodePNG: unsupported PNG (bitDepth=${bitDepth} colorType=${colorType} interlace=${interlace}); only 8-bit RGBA non-interlaced is supported`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (type === "IDAT") {
|
|
95
|
+
idatParts.push(Buffer.from(data));
|
|
96
|
+
}
|
|
97
|
+
else if (type === "IEND") {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
offset = dataStart + len + 4; // skip data + CRC
|
|
101
|
+
}
|
|
102
|
+
const inflated = zlib.inflateSync(Buffer.concat(idatParts));
|
|
103
|
+
const bpp = 4;
|
|
104
|
+
const stride = width * bpp;
|
|
105
|
+
const out = new Uint8Array(width * height * bpp);
|
|
106
|
+
let prev = new Uint8Array(stride);
|
|
107
|
+
for (let y = 0; y < height; y++) {
|
|
108
|
+
const filterType = inflated[y * (stride + 1)];
|
|
109
|
+
const rowStart = y * (stride + 1) + 1;
|
|
110
|
+
const cur = new Uint8Array(stride);
|
|
111
|
+
for (let i = 0; i < stride; i++) {
|
|
112
|
+
const rawByte = inflated[rowStart + i];
|
|
113
|
+
const a = i >= bpp ? cur[i - bpp] : 0;
|
|
114
|
+
const b = prev[i];
|
|
115
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
116
|
+
let value;
|
|
117
|
+
switch (filterType) {
|
|
118
|
+
case 0:
|
|
119
|
+
value = rawByte;
|
|
120
|
+
break;
|
|
121
|
+
case 1:
|
|
122
|
+
value = rawByte + a;
|
|
123
|
+
break;
|
|
124
|
+
case 2:
|
|
125
|
+
value = rawByte + b;
|
|
126
|
+
break;
|
|
127
|
+
case 3:
|
|
128
|
+
value = rawByte + ((a + b) >> 1);
|
|
129
|
+
break;
|
|
130
|
+
case 4:
|
|
131
|
+
value = rawByte + paeth(a, b, c);
|
|
132
|
+
break;
|
|
133
|
+
default: throw new Error(`decodePNG: unknown filter type ${filterType}`);
|
|
134
|
+
}
|
|
135
|
+
cur[i] = value & 0xff;
|
|
136
|
+
}
|
|
137
|
+
out.set(cur, y * stride);
|
|
138
|
+
prev = cur;
|
|
139
|
+
}
|
|
140
|
+
return { width, height, data: out };
|
|
141
|
+
}
|
package/dist/status.js
CHANGED
|
@@ -12,6 +12,22 @@ function buildProviderMeta(usage, generatedAt, staleAfterSeconds) {
|
|
|
12
12
|
stale: ageSeconds > staleAfterSeconds,
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
// Coarse buckets keep the message stable between renders: an exact age changes
|
|
16
|
+
// every second, which would defeat SwiftBar's unchanged-content guard and force
|
|
17
|
+
// a menu rebuild each refresh while the data itself is unchanged.
|
|
18
|
+
function staleAgeLabel(ageSeconds) {
|
|
19
|
+
const buckets = [
|
|
20
|
+
{ seconds: 86_400, label: "1d" },
|
|
21
|
+
{ seconds: 43_200, label: "12h" },
|
|
22
|
+
{ seconds: 10_800, label: "3h" },
|
|
23
|
+
{ seconds: 3_600, label: "1h" },
|
|
24
|
+
{ seconds: 1_800, label: "30m" },
|
|
25
|
+
{ seconds: 600, label: "10m" },
|
|
26
|
+
{ seconds: 60, label: "1m" },
|
|
27
|
+
];
|
|
28
|
+
const hit = buckets.find((bucket) => ageSeconds >= bucket.seconds);
|
|
29
|
+
return hit ? `over ${hit.label}` : "under 1m";
|
|
30
|
+
}
|
|
15
31
|
function staleIssuesFor(providers) {
|
|
16
32
|
return providers
|
|
17
33
|
.filter((provider) => provider.meta.stale)
|
|
@@ -19,7 +35,7 @@ function staleIssuesFor(providers) {
|
|
|
19
35
|
provider: provider.usage.provider,
|
|
20
36
|
severity: "warning",
|
|
21
37
|
code: "USAGE_STALE",
|
|
22
|
-
message: `${provider.usage.provider} usage is stale; latest observation is ${provider.meta.ageSeconds}
|
|
38
|
+
message: `${provider.usage.provider} usage is stale; latest observation is ${staleAgeLabel(provider.meta.ageSeconds)} old.`,
|
|
23
39
|
}));
|
|
24
40
|
}
|
|
25
41
|
export function createStatusSnapshot(usages, profile, options = {}) {
|
package/dist/types.d.ts
CHANGED