@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.
@@ -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
- const nonCachedInput = Math.max(inputTokens - cacheReadTokens, 0);
32
- return (nonCachedInput / 1_000_000) * p.inputPer1M
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 + outputTokens + cacheReadTokens;
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 + e.outputTokens + e.cacheReadTokens;
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 + e.outputTokens + e.cacheReadTokens;
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 + bucket.outputTokens + bucket.cacheReadTokens;
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 tokenUsageKey(usage) {
25
- return [
26
- usage.input_tokens,
27
- usage.cached_input_tokens,
28
- usage.output_tokens,
29
- usage.reasoning_output_tokens,
30
- usage.total_tokens,
31
- ].join(':');
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 = tokenUsageKey(info.total_token_usage);
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
- tokenEvents.push({
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
- inputTokens: last.input_tokens,
150
- cachedInputTokens: last.cached_input_tokens,
151
- outputTokens: last.output_tokens,
152
- reasoningOutputTokens: last.reasoning_output_tokens,
153
- totalTokens: last.total_tokens,
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: acc.inputTokens,
237
- outputTokens: acc.outputTokens,
273
+ inputTokens: display.inputTokens,
274
+ outputTokens: display.outputTokens,
238
275
  cacheCreationTokens: 0,
239
- cacheReadTokens: acc.cachedInputTokens,
240
- totalTokens: acc.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
- modelName,
249
- inputTokens: acc.inputTokens,
250
- outputTokens: acc.outputTokens,
251
- cacheCreationTokens: 0,
252
- cacheReadTokens: acc.cachedInputTokens,
253
- cost: calculateCost(acc, new Set([modelName])),
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 cacheRate = totalTokens > 0 ? ((totalCacheRead / totalTokens) * 100).toFixed(1) : '0.0';
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
- const latest = await fetchHttpsJson(releasesUrl);
420
- // GitHub release tag may be "v1.3.0" or "1.3.0"
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();
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {