@zhangferry-dev/tokendash 1.5.0 → 1.6.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/client/assets/{index-BPWY9q0y.js → index-CY4G_b0x.js} +47 -47
- package/dist/client/assets/index-_yA9tOzZ.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/client/popover.html +822 -396
- package/dist/electron-server.cjs +91 -51
- package/dist/electron-server.cjs.map +2 -2
- package/dist/server/claudeBlocksParser.js +2 -2
- package/dist/server/claudeJsonlParser.d.ts +1 -1
- package/dist/server/claudeJsonlParser.js +22 -19
- package/dist/server/codexParser.js +72 -32
- package/electron/main.cjs +35 -50
- package/electron/preload.cjs +11 -0
- package/electron/updateService.cjs +148 -0
- package/package.json +1 -1
- package/dist/client/assets/index-iYDpTV63.css +0 -1
|
@@ -142,7 +142,7 @@ export function getClaudeBlocksByProject(project) {
|
|
|
142
142
|
const outputTokens = usage.output_tokens;
|
|
143
143
|
const cacheCreationTokens = usage.cache_creation_input_tokens;
|
|
144
144
|
const cacheReadTokens = usage.cache_read_input_tokens;
|
|
145
|
-
const totalTokens = inputTokens + outputTokens + cacheReadTokens;
|
|
145
|
+
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
146
146
|
if (totalTokens === 0)
|
|
147
147
|
continue;
|
|
148
148
|
const hourKey = getHourKey(event.timestamp);
|
|
@@ -167,7 +167,7 @@ export function getClaudeBlocksByProject(project) {
|
|
|
167
167
|
const blocks = [];
|
|
168
168
|
let idx = 0;
|
|
169
169
|
for (const [hourKey, bucket] of hourMap) {
|
|
170
|
-
const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens;
|
|
170
|
+
const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheCreationTokens + bucket.cacheReadTokens;
|
|
171
171
|
blocks.push({
|
|
172
172
|
id: `claude-project-${idx}`,
|
|
173
173
|
startTime: `${hourKey}:00:00.000Z`,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DailyResponse, ProjectsResponse, BlockEntry } from '../shared/types.js';
|
|
2
|
-
export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string): number;
|
|
2
|
+
export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string, cacheCreationTokens?: number): number;
|
|
3
3
|
/** Decode Claude's encoded project directory name.
|
|
4
4
|
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
5
5
|
* Since '-' replaces '/' and project names can contain '-',
|
|
@@ -3,18 +3,18 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
const MODEL_PRICING = {
|
|
5
5
|
// Claude 4.6
|
|
6
|
-
'claude-opus-4-6': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
7
|
-
'claude-sonnet-4-6': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
6
|
+
'claude-opus-4-6': { inputPer1M: 15, cacheCreationPer1M: 18.75, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
7
|
+
'claude-sonnet-4-6': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
8
8
|
// Claude 4.5
|
|
9
|
-
'claude-sonnet-4-5-20250514': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
10
|
-
'claude-haiku-4-5-20251001': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
9
|
+
'claude-sonnet-4-5-20250514': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
10
|
+
'claude-haiku-4-5-20251001': { inputPer1M: 0.80, cacheCreationPer1M: 1, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
11
11
|
// Older Claude models
|
|
12
|
-
'claude-3-5-sonnet-20241022': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
13
|
-
'claude-3-5-haiku-20241022': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
14
|
-
'claude-3-opus-20240229': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
15
|
-
'claude-3-haiku-20240307': { inputPer1M: 0.25, cacheReadPer1M: 0.03, outputPer1M: 1.25 },
|
|
12
|
+
'claude-3-5-sonnet-20241022': { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 },
|
|
13
|
+
'claude-3-5-haiku-20241022': { inputPer1M: 0.80, cacheCreationPer1M: 1, cacheReadPer1M: 0.08, outputPer1M: 4 },
|
|
14
|
+
'claude-3-opus-20240229': { inputPer1M: 15, cacheCreationPer1M: 18.75, cacheReadPer1M: 1.50, outputPer1M: 75 },
|
|
15
|
+
'claude-3-haiku-20240307': { inputPer1M: 0.25, cacheCreationPer1M: 0.30, cacheReadPer1M: 0.03, outputPer1M: 1.25 },
|
|
16
16
|
};
|
|
17
|
-
const DEFAULT_PRICING = { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 };
|
|
17
|
+
const DEFAULT_PRICING = { inputPer1M: 3, cacheCreationPer1M: 3.75, cacheReadPer1M: 0.30, outputPer1M: 15 };
|
|
18
18
|
function getPricing(model) {
|
|
19
19
|
// Try exact match first, then prefix match
|
|
20
20
|
if (MODEL_PRICING[model])
|
|
@@ -26,13 +26,16 @@ function getPricing(model) {
|
|
|
26
26
|
}
|
|
27
27
|
return DEFAULT_PRICING;
|
|
28
28
|
}
|
|
29
|
-
export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model) {
|
|
29
|
+
export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model, cacheCreationTokens = 0) {
|
|
30
30
|
const p = getPricing(model);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
return (inputTokens / 1_000_000) * p.inputPer1M
|
|
32
|
+
+ (cacheCreationTokens / 1_000_000) * p.cacheCreationPer1M
|
|
33
33
|
+ (cacheReadTokens / 1_000_000) * p.cacheReadPer1M
|
|
34
34
|
+ (outputTokens / 1_000_000) * p.outputPer1M;
|
|
35
35
|
}
|
|
36
|
+
function totalClaudeTokens(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
|
|
37
|
+
return inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
|
38
|
+
}
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
// JSONL parsing with mtime cache
|
|
38
41
|
// ---------------------------------------------------------------------------
|
|
@@ -160,7 +163,7 @@ function parseAllSessions(project) {
|
|
|
160
163
|
const outputTokens = usage.output_tokens || 0;
|
|
161
164
|
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
162
165
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
163
|
-
const totalTokens = inputTokens
|
|
166
|
+
const totalTokens = totalClaudeTokens(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
|
|
164
167
|
if (totalTokens === 0)
|
|
165
168
|
continue;
|
|
166
169
|
entries.push({
|
|
@@ -248,8 +251,8 @@ export function getDailyResponse(project, tz = DEFAULT_TZ) {
|
|
|
248
251
|
agg.outputTokens += e.outputTokens;
|
|
249
252
|
agg.cacheCreationTokens += e.cacheCreationTokens;
|
|
250
253
|
agg.cacheReadTokens += e.cacheReadTokens;
|
|
251
|
-
agg.totalTokens += e.inputTokens
|
|
252
|
-
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
254
|
+
agg.totalTokens += totalClaudeTokens(e.inputTokens, e.outputTokens, e.cacheCreationTokens, e.cacheReadTokens);
|
|
255
|
+
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
253
256
|
agg.totalCost += cost;
|
|
254
257
|
if (!agg.models.has(e.model)) {
|
|
255
258
|
agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
|
|
@@ -294,8 +297,8 @@ export function getProjectsResponse(tz = DEFAULT_TZ) {
|
|
|
294
297
|
agg.outputTokens += e.outputTokens;
|
|
295
298
|
agg.cacheCreationTokens += e.cacheCreationTokens;
|
|
296
299
|
agg.cacheReadTokens += e.cacheReadTokens;
|
|
297
|
-
agg.totalTokens += e.inputTokens
|
|
298
|
-
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
300
|
+
agg.totalTokens += totalClaudeTokens(e.inputTokens, e.outputTokens, e.cacheCreationTokens, e.cacheReadTokens);
|
|
301
|
+
const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
299
302
|
agg.totalCost += cost;
|
|
300
303
|
if (!agg.models.has(e.model)) {
|
|
301
304
|
agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
|
|
@@ -331,13 +334,13 @@ export function getBlocksResponse(project, tz = DEFAULT_TZ) {
|
|
|
331
334
|
bucket.outputTokens += e.outputTokens;
|
|
332
335
|
bucket.cacheCreationTokens += e.cacheCreationTokens;
|
|
333
336
|
bucket.cacheReadTokens += e.cacheReadTokens;
|
|
334
|
-
bucket.costUSD += calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
|
|
337
|
+
bucket.costUSD += calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model, e.cacheCreationTokens);
|
|
335
338
|
bucket.models.add(e.model);
|
|
336
339
|
}
|
|
337
340
|
const blocks = [];
|
|
338
341
|
let idx = 0;
|
|
339
342
|
for (const [hourKey, bucket] of hourMap) {
|
|
340
|
-
const totalTokens = bucket.inputTokens
|
|
343
|
+
const totalTokens = totalClaudeTokens(bucket.inputTokens, bucket.outputTokens, bucket.cacheCreationTokens, bucket.cacheReadTokens);
|
|
341
344
|
blocks.push({
|
|
342
345
|
id: `claude-${idx}`,
|
|
343
346
|
startTime: `${hourKey}:00:00`,
|
|
@@ -15,20 +15,24 @@ const TokenUsageSchema = z.object({
|
|
|
15
15
|
}).default({ input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 });
|
|
16
16
|
const TokenCountInfoSchema = z.object({
|
|
17
17
|
total_token_usage: TokenUsageSchema,
|
|
18
|
-
last_token_usage: TokenUsageSchema,
|
|
18
|
+
last_token_usage: TokenUsageSchema.optional(),
|
|
19
19
|
}).nullable().default(null);
|
|
20
20
|
const TokenCountPayloadSchema = z.object({
|
|
21
21
|
type: z.literal('token_count'),
|
|
22
22
|
info: TokenCountInfoSchema,
|
|
23
23
|
});
|
|
24
|
-
function
|
|
25
|
-
return
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
function subtractTokenUsage(current, previous) {
|
|
25
|
+
return {
|
|
26
|
+
timestamp: '',
|
|
27
|
+
inputTokens: Math.max(0, current.input_tokens - (previous?.input_tokens ?? 0)),
|
|
28
|
+
cachedInputTokens: Math.max(0, current.cached_input_tokens - (previous?.cached_input_tokens ?? 0)),
|
|
29
|
+
outputTokens: Math.max(0, current.output_tokens - (previous?.output_tokens ?? 0)),
|
|
30
|
+
reasoningOutputTokens: Math.max(0, current.reasoning_output_tokens - (previous?.reasoning_output_tokens ?? 0)),
|
|
31
|
+
totalTokens: Math.max(0, current.total_tokens - (previous?.total_tokens ?? 0)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function displayInputTokens(inputTokens, cachedInputTokens) {
|
|
35
|
+
return Math.max(0, inputTokens - cachedInputTokens);
|
|
32
36
|
}
|
|
33
37
|
// ---------------------------------------------------------------------------
|
|
34
38
|
// Helpers
|
|
@@ -101,7 +105,9 @@ export function parseCodexSession(filepath) {
|
|
|
101
105
|
let model = '';
|
|
102
106
|
let createdAt = '';
|
|
103
107
|
const tokenEvents = [];
|
|
108
|
+
let previousTotalUsage = null;
|
|
104
109
|
const seenTotalUsageSnapshots = new Set();
|
|
110
|
+
const seenUsageEvents = new Set();
|
|
105
111
|
for (const line of lines) {
|
|
106
112
|
const trimmed = line.trim();
|
|
107
113
|
if (!trimmed)
|
|
@@ -139,19 +145,43 @@ export function parseCodexSession(filepath) {
|
|
|
139
145
|
const info = parseResult.data.info;
|
|
140
146
|
if (!info)
|
|
141
147
|
continue;
|
|
142
|
-
const totalUsageKey =
|
|
148
|
+
const totalUsageKey = [
|
|
149
|
+
info.total_token_usage.input_tokens,
|
|
150
|
+
info.total_token_usage.cached_input_tokens,
|
|
151
|
+
info.total_token_usage.output_tokens,
|
|
152
|
+
info.total_token_usage.reasoning_output_tokens,
|
|
153
|
+
info.total_token_usage.total_tokens,
|
|
154
|
+
].join(':');
|
|
143
155
|
if (seenTotalUsageSnapshots.has(totalUsageKey))
|
|
144
156
|
continue;
|
|
145
157
|
seenTotalUsageSnapshots.add(totalUsageKey);
|
|
146
|
-
const last = info.last_token_usage;
|
|
147
|
-
|
|
158
|
+
const last = info.last_token_usage ?? info.total_token_usage;
|
|
159
|
+
const rawEvent = info.last_token_usage
|
|
160
|
+
? subtractTokenUsage(last, null)
|
|
161
|
+
: subtractTokenUsage(last, previousTotalUsage);
|
|
162
|
+
previousTotalUsage = info.total_token_usage;
|
|
163
|
+
if (rawEvent.inputTokens === 0 && rawEvent.cachedInputTokens === 0 && rawEvent.outputTokens === 0 && rawEvent.reasoningOutputTokens === 0) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const event = {
|
|
167
|
+
...rawEvent,
|
|
168
|
+
timestamp,
|
|
169
|
+
cachedInputTokens: Math.min(rawEvent.cachedInputTokens, rawEvent.inputTokens),
|
|
170
|
+
};
|
|
171
|
+
const eventKey = [
|
|
148
172
|
timestamp,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
173
|
+
model,
|
|
174
|
+
event.inputTokens,
|
|
175
|
+
event.cachedInputTokens,
|
|
176
|
+
event.outputTokens,
|
|
177
|
+
event.reasoningOutputTokens,
|
|
178
|
+
event.totalTokens,
|
|
179
|
+
].join(':');
|
|
180
|
+
if (seenUsageEvents.has(eventKey)) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
seenUsageEvents.add(eventKey);
|
|
184
|
+
tokenEvents.push(event);
|
|
155
185
|
}
|
|
156
186
|
}
|
|
157
187
|
}
|
|
@@ -212,6 +242,12 @@ function addAcc(a, ev) {
|
|
|
212
242
|
a.reasoningOutputTokens += ev.reasoningOutputTokens;
|
|
213
243
|
a.totalTokens += ev.totalTokens;
|
|
214
244
|
}
|
|
245
|
+
function displayAcc(acc) {
|
|
246
|
+
return {
|
|
247
|
+
...acc,
|
|
248
|
+
inputTokens: displayInputTokens(acc.inputTokens, acc.cachedInputTokens),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
215
251
|
function mergeAcc(a, b) {
|
|
216
252
|
a.inputTokens += b.inputTokens;
|
|
217
253
|
a.cachedInputTokens += b.cachedInputTokens;
|
|
@@ -228,30 +264,34 @@ function addAccToBucket(bucket, ev, model) {
|
|
|
228
264
|
addAcc(bucket.models.get(model), ev);
|
|
229
265
|
}
|
|
230
266
|
function accToEntry(date, acc, modelAccs) {
|
|
267
|
+
const display = displayAcc(acc);
|
|
231
268
|
const modelNames = [...modelAccs.keys()];
|
|
232
269
|
const modelBreakdowns = buildModelBreakdowns(modelAccs);
|
|
233
270
|
const totalCost = modelBreakdowns.reduce((sum, model) => sum + model.cost, 0);
|
|
234
271
|
return {
|
|
235
272
|
date,
|
|
236
|
-
inputTokens:
|
|
237
|
-
outputTokens:
|
|
273
|
+
inputTokens: display.inputTokens,
|
|
274
|
+
outputTokens: display.outputTokens,
|
|
238
275
|
cacheCreationTokens: 0,
|
|
239
|
-
cacheReadTokens:
|
|
240
|
-
totalTokens:
|
|
276
|
+
cacheReadTokens: display.cachedInputTokens,
|
|
277
|
+
totalTokens: display.totalTokens,
|
|
241
278
|
totalCost,
|
|
242
279
|
modelsUsed: modelNames,
|
|
243
280
|
modelBreakdowns,
|
|
244
281
|
};
|
|
245
282
|
}
|
|
246
283
|
function buildModelBreakdowns(modelAccs) {
|
|
247
|
-
return [...modelAccs.entries()].map(([modelName, acc]) =>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
284
|
+
return [...modelAccs.entries()].map(([modelName, acc]) => {
|
|
285
|
+
const display = displayAcc(acc);
|
|
286
|
+
return {
|
|
287
|
+
modelName,
|
|
288
|
+
inputTokens: display.inputTokens,
|
|
289
|
+
outputTokens: display.outputTokens,
|
|
290
|
+
cacheCreationTokens: 0,
|
|
291
|
+
cacheReadTokens: display.cachedInputTokens,
|
|
292
|
+
cost: calculateCost(acc, new Set([modelName])),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
255
295
|
}
|
|
256
296
|
function groupSessions(sessions, options) {
|
|
257
297
|
const tz = options.timezone || 'Asia/Shanghai';
|
|
@@ -320,7 +360,7 @@ function buildDailyResponse(sessions, options) {
|
|
|
320
360
|
return {
|
|
321
361
|
daily,
|
|
322
362
|
totals: {
|
|
323
|
-
inputTokens: totalsAcc.inputTokens,
|
|
363
|
+
inputTokens: displayInputTokens(totalsAcc.inputTokens, totalsAcc.cachedInputTokens),
|
|
324
364
|
outputTokens: totalsAcc.outputTokens,
|
|
325
365
|
cacheCreationTokens: 0,
|
|
326
366
|
cacheReadTokens: totalsAcc.cachedInputTokens,
|
|
@@ -377,7 +417,7 @@ function buildBlocksResponse(sessions, options) {
|
|
|
377
417
|
isGap: false,
|
|
378
418
|
entries: acc.totalTokens > 0 ? 1 : 0,
|
|
379
419
|
tokenCounts: {
|
|
380
|
-
inputTokens: acc.inputTokens,
|
|
420
|
+
inputTokens: displayInputTokens(acc.inputTokens, acc.cachedInputTokens),
|
|
381
421
|
outputTokens: acc.outputTokens,
|
|
382
422
|
cacheCreationInputTokens: 0,
|
|
383
423
|
cacheReadInputTokens: acc.cachedInputTokens,
|
package/electron/main.cjs
CHANGED
|
@@ -2,7 +2,6 @@ const { app, BrowserWindow, ipcMain, screen, shell } = require('electron');
|
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const http = require('node:http');
|
|
5
|
-
const https = require('node:https');
|
|
6
5
|
const { spawn } = require('node:child_process');
|
|
7
6
|
|
|
8
7
|
// Global debug logger (writes to file since stdout is lost in packaged apps)
|
|
@@ -19,6 +18,7 @@ try {
|
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
const { formatTokens } = require('./trayBadge.cjs');
|
|
21
|
+
const { checkForUpdates, downloadUpdateAsset } = require('./updateService.cjs');
|
|
22
22
|
|
|
23
23
|
// Resolve trayHelper binary: extract from asar if needed
|
|
24
24
|
function resolveTrayHelperPath() {
|
|
@@ -52,6 +52,8 @@ let server = null;
|
|
|
52
52
|
let trayProcess = null;
|
|
53
53
|
let selectedAgents = null; // null = use all available agents
|
|
54
54
|
let serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
|
|
55
|
+
let lastUpdateInfo = null;
|
|
56
|
+
let isDownloadingUpdate = false;
|
|
55
57
|
const POPOVER_WIDTH = 380;
|
|
56
58
|
const POPOVER_HEIGHT = 540;
|
|
57
59
|
const PACKAGE_NAME = '@zhangferry-dev/tokendash';
|
|
@@ -97,41 +99,6 @@ function fetchJson(url) {
|
|
|
97
99
|
});
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
function fetchHttpsJson(url) {
|
|
101
|
-
return new Promise((resolve, reject) => {
|
|
102
|
-
const opts = new URL(url);
|
|
103
|
-
const reqOpts = {
|
|
104
|
-
hostname: opts.hostname,
|
|
105
|
-
path: opts.pathname + opts.search,
|
|
106
|
-
method: 'GET',
|
|
107
|
-
headers: { 'User-Agent': 'TokenDash' },
|
|
108
|
-
};
|
|
109
|
-
https.get(reqOpts, (res) => {
|
|
110
|
-
let data = '';
|
|
111
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
112
|
-
res.on('end', () => {
|
|
113
|
-
if (res.statusCode && res.statusCode >= 400) {
|
|
114
|
-
reject(new Error(`HTTP ${res.statusCode}`));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
try { resolve(JSON.parse(data)); }
|
|
118
|
-
catch (e) { reject(e); }
|
|
119
|
-
});
|
|
120
|
-
}).on('error', reject);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function compareVersions(a, b) {
|
|
125
|
-
const aParts = String(a).split('.').map((part) => parseInt(part, 10) || 0);
|
|
126
|
-
const bParts = String(b).split('.').map((part) => parseInt(part, 10) || 0);
|
|
127
|
-
const maxLen = Math.max(aParts.length, bParts.length);
|
|
128
|
-
for (let i = 0; i < maxLen; i++) {
|
|
129
|
-
const delta = (aParts[i] || 0) - (bParts[i] || 0);
|
|
130
|
-
if (delta !== 0) return delta;
|
|
131
|
-
}
|
|
132
|
-
return 0;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
102
|
function getAppInfo() {
|
|
136
103
|
// app.getVersion() returns Electron's version in dev mode (e.g. 41.5).
|
|
137
104
|
// Always read from package.json to get the app's own version.
|
|
@@ -258,17 +225,19 @@ function getTrayAgentKey(agents) {
|
|
|
258
225
|
|
|
259
226
|
function applyTraySnapshot(snapshot) {
|
|
260
227
|
const totalTokens = Number(snapshot && snapshot.totalTokens) || 0;
|
|
228
|
+
const totalInput = Number(snapshot && snapshot.totalInput) || 0;
|
|
261
229
|
const totalCost = Number(snapshot && snapshot.totalCost) || 0;
|
|
262
230
|
const totalCacheRead = Number(snapshot && snapshot.totalCacheRead) || 0;
|
|
263
231
|
const today = snapshot && snapshot.today;
|
|
264
232
|
const agentKey = snapshot && snapshot.agentKey;
|
|
265
233
|
|
|
266
|
-
lastTraySnapshot = { today, agentKey, totalTokens, totalCost, totalCacheRead };
|
|
234
|
+
lastTraySnapshot = { today, agentKey, totalTokens, totalInput, totalCost, totalCacheRead };
|
|
267
235
|
|
|
268
236
|
const tokenStr = formatTokens(totalTokens);
|
|
269
237
|
sendTrayCommand('title:' + tokenStr);
|
|
270
238
|
|
|
271
|
-
const
|
|
239
|
+
const cacheInput = totalInput + totalCacheRead;
|
|
240
|
+
const cacheRate = cacheInput > 0 ? ((totalCacheRead / cacheInput) * 100).toFixed(1) : '0.0';
|
|
272
241
|
sendTrayCommand('tooltip:TokenDash - ' + tokenStr + ' tokens today ($' + totalCost.toFixed(2) + ') | cache: ' + cacheRate + '%');
|
|
273
242
|
}
|
|
274
243
|
|
|
@@ -337,7 +306,7 @@ function updateTrayBadge() {
|
|
|
337
306
|
return;
|
|
338
307
|
}
|
|
339
308
|
|
|
340
|
-
applyTraySnapshot({ today, agentKey, totalTokens, totalCost, totalCacheRead });
|
|
309
|
+
applyTraySnapshot({ today, agentKey, totalTokens, totalInput, totalCost, totalCacheRead });
|
|
341
310
|
})
|
|
342
311
|
.catch((err) => {
|
|
343
312
|
if (err.code !== 'ECONNREFUSED') {
|
|
@@ -413,19 +382,10 @@ function registerIpcHandlers() {
|
|
|
413
382
|
|
|
414
383
|
ipcMain.handle('tokendash:check-for-updates', async () => {
|
|
415
384
|
const currentVersion = getAppInfo().version;
|
|
416
|
-
const releasesUrl = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`;
|
|
417
385
|
|
|
418
386
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const tag = (latest.tag_name || '').replace(/^v/, '');
|
|
422
|
-
const latestVersion = tag || currentVersion;
|
|
423
|
-
return {
|
|
424
|
-
currentVersion,
|
|
425
|
-
latestVersion,
|
|
426
|
-
upToDate: compareVersions(currentVersion, latestVersion) >= 0,
|
|
427
|
-
releaseUrl: latest.html_url || null,
|
|
428
|
-
};
|
|
387
|
+
lastUpdateInfo = await checkForUpdates({ repo: GITHUB_REPO, currentVersion });
|
|
388
|
+
return lastUpdateInfo;
|
|
429
389
|
} catch (error) {
|
|
430
390
|
return {
|
|
431
391
|
currentVersion,
|
|
@@ -436,6 +396,31 @@ function registerIpcHandlers() {
|
|
|
436
396
|
}
|
|
437
397
|
});
|
|
438
398
|
|
|
399
|
+
ipcMain.handle('tokendash:download-update', async (event) => {
|
|
400
|
+
if (isDownloadingUpdate) {
|
|
401
|
+
return { ok: false, error: 'An update download is already in progress.' };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const info = lastUpdateInfo;
|
|
405
|
+
if (!info || info.upToDate || !info.asset || !info.asset.url) {
|
|
406
|
+
return { ok: false, error: 'No downloadable update is available.' };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
isDownloadingUpdate = true;
|
|
410
|
+
try {
|
|
411
|
+
const downloadsDir = path.join(app.getPath('downloads'), 'TokenDash Updates');
|
|
412
|
+
const filePath = await downloadUpdateAsset(info.asset, downloadsDir, (progress) => {
|
|
413
|
+
event.sender.send('tokendash:update-download-progress', progress);
|
|
414
|
+
});
|
|
415
|
+
await shell.openPath(filePath);
|
|
416
|
+
return { ok: true, filePath };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
419
|
+
} finally {
|
|
420
|
+
isDownloadingUpdate = false;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
439
424
|
ipcMain.handle('tokendash:quit', () => {
|
|
440
425
|
app.isQuitting = true;
|
|
441
426
|
stopBadgeUpdates();
|
package/electron/preload.cjs
CHANGED
|
@@ -13,6 +13,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|
|
13
13
|
checkForUpdates() {
|
|
14
14
|
return ipcRenderer.invoke('tokendash:check-for-updates');
|
|
15
15
|
},
|
|
16
|
+
downloadUpdate(updateInfo) {
|
|
17
|
+
return ipcRenderer.invoke('tokendash:download-update', updateInfo);
|
|
18
|
+
},
|
|
19
|
+
onUpdateDownloadProgress(callback) {
|
|
20
|
+
if (typeof callback !== 'function') return function noop() {};
|
|
21
|
+
const listener = (_event, progress) => callback(progress);
|
|
22
|
+
ipcRenderer.on('tokendash:update-download-progress', listener);
|
|
23
|
+
return function unsubscribe() {
|
|
24
|
+
ipcRenderer.removeListener('tokendash:update-download-progress', listener);
|
|
25
|
+
};
|
|
26
|
+
},
|
|
16
27
|
quitApp() {
|
|
17
28
|
return ipcRenderer.invoke('tokendash:quit');
|
|
18
29
|
},
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const https = require('node:https');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
function fetchHttpsJson(url) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const opts = new URL(url);
|
|
8
|
+
const reqOpts = {
|
|
9
|
+
hostname: opts.hostname,
|
|
10
|
+
path: opts.pathname + opts.search,
|
|
11
|
+
method: 'GET',
|
|
12
|
+
headers: {
|
|
13
|
+
Accept: 'application/vnd.github+json',
|
|
14
|
+
'User-Agent': 'TokenDash',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
https.get(reqOpts, (res) => {
|
|
19
|
+
let data = '';
|
|
20
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
21
|
+
res.on('end', () => {
|
|
22
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
23
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
try { resolve(JSON.parse(data)); }
|
|
27
|
+
catch (e) { reject(e); }
|
|
28
|
+
});
|
|
29
|
+
}).on('error', reject);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compareVersions(a, b) {
|
|
34
|
+
const aParts = String(a).replace(/^v/, '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
|
|
35
|
+
const bParts = String(b).replace(/^v/, '').split(/[.-]/).map((part) => parseInt(part, 10) || 0);
|
|
36
|
+
const maxLen = Math.max(aParts.length, bParts.length);
|
|
37
|
+
for (let i = 0; i < maxLen; i++) {
|
|
38
|
+
const delta = (aParts[i] || 0) - (bParts[i] || 0);
|
|
39
|
+
if (delta !== 0) return delta;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isDmgAsset(asset) {
|
|
45
|
+
return Boolean(asset && typeof asset.name === 'string' && /\.dmg$/i.test(asset.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function selectMacDmgAsset(assets, arch = process.arch) {
|
|
49
|
+
const dmgAssets = (Array.isArray(assets) ? assets : []).filter(isDmgAsset);
|
|
50
|
+
if (dmgAssets.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
const archNeedle = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'x64' : '';
|
|
53
|
+
if (archNeedle) {
|
|
54
|
+
const archMatch = dmgAssets.find((asset) => asset.name.toLowerCase().includes(archNeedle));
|
|
55
|
+
if (archMatch) return archMatch;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const universal = dmgAssets.find((asset) => /universal/i.test(asset.name));
|
|
59
|
+
return universal || dmgAssets[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getReleaseUpdateInfo(release, currentVersion, arch = process.arch) {
|
|
63
|
+
const tag = String((release && release.tag_name) || '').replace(/^v/, '');
|
|
64
|
+
const latestVersion = tag || currentVersion;
|
|
65
|
+
const asset = selectMacDmgAsset(release && release.assets, arch);
|
|
66
|
+
const upToDate = compareVersions(currentVersion, latestVersion) >= 0;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
currentVersion,
|
|
70
|
+
latestVersion,
|
|
71
|
+
upToDate,
|
|
72
|
+
releaseUrl: (release && release.html_url) || null,
|
|
73
|
+
asset: asset ? {
|
|
74
|
+
name: asset.name,
|
|
75
|
+
size: Number(asset.size) || 0,
|
|
76
|
+
url: asset.browser_download_url,
|
|
77
|
+
} : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function checkForUpdates({ repo, currentVersion, arch = process.arch }) {
|
|
82
|
+
const release = await fetchHttpsJson(`https://api.github.com/repos/${repo}/releases/latest`);
|
|
83
|
+
return getReleaseUpdateInfo(release, currentVersion, arch);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeDownloadName(name) {
|
|
87
|
+
return path.basename(String(name || 'TokenDash-update.dmg')).replace(/[^\w .()+@-]/g, '-');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function downloadFile(url, destination, onProgress) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const file = fs.createWriteStream(destination);
|
|
93
|
+
let received = 0;
|
|
94
|
+
|
|
95
|
+
const request = https.get(url, { headers: { 'User-Agent': 'TokenDash' } }, (res) => {
|
|
96
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
97
|
+
file.close(() => fs.rm(destination, { force: true }, () => {}));
|
|
98
|
+
downloadFile(res.headers.location, destination, onProgress).then(resolve, reject);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
103
|
+
file.close(() => fs.rm(destination, { force: true }, () => {}));
|
|
104
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const total = Number(res.headers['content-length']) || 0;
|
|
109
|
+
res.on('data', (chunk) => {
|
|
110
|
+
received += chunk.length;
|
|
111
|
+
if (typeof onProgress === 'function') {
|
|
112
|
+
onProgress({ received, total, percent: total > 0 ? Math.round((received / total) * 100) : null });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
res.pipe(file);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
request.on('error', (error) => {
|
|
119
|
+
file.close(() => fs.rm(destination, { force: true }, () => {}));
|
|
120
|
+
reject(error);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
file.on('finish', () => {
|
|
124
|
+
file.close(() => resolve(destination));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
file.on('error', (error) => {
|
|
128
|
+
file.close(() => fs.rm(destination, { force: true }, () => {}));
|
|
129
|
+
reject(error);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function downloadUpdateAsset(asset, downloadsDir, onProgress) {
|
|
135
|
+
if (!asset || !asset.url) throw new Error('No downloadable macOS update asset was found.');
|
|
136
|
+
fs.mkdirSync(downloadsDir, { recursive: true });
|
|
137
|
+
const destination = path.join(downloadsDir, safeDownloadName(asset.name));
|
|
138
|
+
await downloadFile(asset.url, destination, onProgress);
|
|
139
|
+
return destination;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
checkForUpdates,
|
|
144
|
+
compareVersions,
|
|
145
|
+
downloadUpdateAsset,
|
|
146
|
+
getReleaseUpdateInfo,
|
|
147
|
+
selectMacDmgAsset,
|
|
148
|
+
};
|