@zhangferry-dev/tokendash 1.4.2 → 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.
@@ -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,
148
168
  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
- });
169
+ cachedInputTokens: Math.min(rawEvent.cachedInputTokens, rawEvent.inputTokens),
170
+ };
171
+ const eventKey = [
172
+ timestamp,
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;
@@ -219,33 +255,43 @@ function mergeAcc(a, b) {
219
255
  a.reasoningOutputTokens += b.reasoningOutputTokens;
220
256
  a.totalTokens += b.totalTokens;
221
257
  }
222
- function accToEntry(date, acc, models) {
223
- const cost = calculateCost(acc, models);
258
+ function addAccToBucket(bucket, ev, model) {
259
+ addAcc(bucket.acc, ev);
260
+ if (!model)
261
+ return;
262
+ if (!bucket.models.has(model))
263
+ bucket.models.set(model, emptyAcc());
264
+ addAcc(bucket.models.get(model), ev);
265
+ }
266
+ function accToEntry(date, acc, modelAccs) {
267
+ const display = displayAcc(acc);
268
+ const modelNames = [...modelAccs.keys()];
269
+ const modelBreakdowns = buildModelBreakdowns(modelAccs);
270
+ const totalCost = modelBreakdowns.reduce((sum, model) => sum + model.cost, 0);
224
271
  return {
225
272
  date,
226
- inputTokens: acc.inputTokens,
227
- outputTokens: acc.outputTokens,
273
+ inputTokens: display.inputTokens,
274
+ outputTokens: display.outputTokens,
228
275
  cacheCreationTokens: 0,
229
- cacheReadTokens: acc.cachedInputTokens,
230
- totalTokens: acc.totalTokens,
231
- totalCost: cost,
232
- modelsUsed: [...models],
233
- modelBreakdowns: buildModelBreakdowns(acc, models, cost),
276
+ cacheReadTokens: display.cachedInputTokens,
277
+ totalTokens: display.totalTokens,
278
+ totalCost,
279
+ modelsUsed: modelNames,
280
+ modelBreakdowns,
234
281
  };
235
282
  }
236
- function buildModelBreakdowns(acc, models, totalCost) {
237
- const modelList = [...models];
238
- if (modelList.length === 0)
239
- return [];
240
- const costPerModel = totalCost / modelList.length;
241
- return modelList.map(name => ({
242
- modelName: name,
243
- inputTokens: acc.inputTokens,
244
- outputTokens: acc.outputTokens,
245
- cacheCreationTokens: 0,
246
- cacheReadTokens: acc.cachedInputTokens,
247
- cost: costPerModel,
248
- }));
283
+ function buildModelBreakdowns(modelAccs) {
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
+ });
249
295
  }
250
296
  function groupSessions(sessions, options) {
251
297
  const tz = options.timezone || 'Asia/Shanghai';
@@ -278,12 +324,9 @@ function groupSessions(sessions, options) {
278
324
  break;
279
325
  }
280
326
  if (!grouped.has(key)) {
281
- grouped.set(key, { acc: emptyAcc(), models: new Set() });
327
+ grouped.set(key, { acc: emptyAcc(), models: new Map() });
282
328
  }
283
- const entry = grouped.get(key);
284
- addAcc(entry.acc, ev);
285
- if (session.model)
286
- entry.models.add(session.model);
329
+ addAccToBucket(grouped.get(key), ev, session.model);
287
330
  }
288
331
  }
289
332
  return grouped;
@@ -291,27 +334,33 @@ function groupSessions(sessions, options) {
291
334
  // ---------------------------------------------------------------------------
292
335
  // Public API — response builders for route handlers
293
336
  // ---------------------------------------------------------------------------
294
- /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
295
- export function getDailyResponse(options) {
296
- const sessions = parseAllSessions();
337
+ export function buildCodexResponsesFromSessions(sessions, options) {
338
+ return {
339
+ daily: buildDailyResponse(sessions, options),
340
+ projects: buildProjectsResponse(sessions, options),
341
+ blocks: buildBlocksResponse(sessions, options),
342
+ };
343
+ }
344
+ function buildDailyResponse(sessions, options) {
297
345
  const grouped = groupSessions(sessions, { groupBy: 'day', ...options });
298
346
  const daily = [];
299
347
  const totalsAcc = emptyAcc();
348
+ const totalModels = new Map();
300
349
  for (const [date, { acc, models }] of grouped) {
301
350
  daily.push(accToEntry(date, acc, models));
302
351
  mergeAcc(totalsAcc, acc);
352
+ for (const [model, modelAcc] of models) {
353
+ if (!totalModels.has(model))
354
+ totalModels.set(model, emptyAcc());
355
+ mergeAcc(totalModels.get(model), modelAcc);
356
+ }
303
357
  }
304
- // Sort by date ascending
305
358
  daily.sort((a, b) => a.date.localeCompare(b.date));
306
- const models = new Set();
307
- for (const s of sessions)
308
- if (s.model)
309
- models.add(s.model);
310
- const totalCost = calculateCost(totalsAcc, models);
359
+ const totalCost = buildModelBreakdowns(totalModels).reduce((sum, model) => sum + model.cost, 0);
311
360
  return {
312
361
  daily,
313
362
  totals: {
314
- inputTokens: totalsAcc.inputTokens,
363
+ inputTokens: displayInputTokens(totalsAcc.inputTokens, totalsAcc.cachedInputTokens),
315
364
  outputTokens: totalsAcc.outputTokens,
316
365
  cacheCreationTokens: 0,
317
366
  cacheReadTokens: totalsAcc.cachedInputTokens,
@@ -320,15 +369,16 @@ export function getDailyResponse(options) {
320
369
  },
321
370
  };
322
371
  }
323
- /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
324
- export function getProjectsResponse(options) {
325
- const sessions = parseAllSessions();
372
+ function buildProjectsResponse(sessions, options) {
326
373
  const tz = options?.timezone || 'Asia/Shanghai';
327
- const projects = {};
374
+ const projectGroups = new Map();
328
375
  for (const session of sessions) {
329
376
  const projectName = extractProjectName(session.cwd);
330
- // Per-project daily grouping
331
- const dailyMap = new Map();
377
+ if (options?.project && projectName !== options.project)
378
+ continue;
379
+ if (!projectGroups.has(projectName))
380
+ projectGroups.set(projectName, new Map());
381
+ const dailyMap = projectGroups.get(projectName);
332
382
  for (const ev of session.tokenEvents) {
333
383
  const evDate = new Date(ev.timestamp);
334
384
  if (options?.since && evDate < options.since)
@@ -337,32 +387,25 @@ export function getProjectsResponse(options) {
337
387
  continue;
338
388
  const dayKey = getDateKey(ev.timestamp, tz);
339
389
  if (!dailyMap.has(dayKey)) {
340
- dailyMap.set(dayKey, { acc: emptyAcc(), models: new Set() });
390
+ dailyMap.set(dayKey, { acc: emptyAcc(), models: new Map() });
341
391
  }
342
- addAcc(dailyMap.get(dayKey).acc, ev);
343
- if (session.model)
344
- dailyMap.get(dayKey).models.add(session.model);
345
- }
346
- if (!projects[projectName])
347
- projects[projectName] = [];
348
- for (const [date, { acc, models }] of dailyMap) {
349
- projects[projectName].push(accToEntry(date, acc, models));
392
+ addAccToBucket(dailyMap.get(dayKey), ev, session.model);
350
393
  }
351
394
  }
352
- // Sort each project's daily entries
353
- for (const key of Object.keys(projects)) {
354
- projects[key].sort((a, b) => a.date.localeCompare(b.date));
395
+ const projects = {};
396
+ for (const [projectName, dailyMap] of projectGroups) {
397
+ projects[projectName] = [...dailyMap.entries()]
398
+ .sort(([a], [b]) => a.localeCompare(b))
399
+ .map(([date, { acc, models }]) => accToEntry(date, acc, models));
355
400
  }
356
401
  return { projects };
357
402
  }
358
- /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
359
- export function getBlocksResponse(options) {
360
- const sessions = parseAllSessions();
403
+ function buildBlocksResponse(sessions, options) {
361
404
  const grouped = groupSessions(sessions, { groupBy: 'hour', ...options });
362
405
  const blocks = [];
363
406
  let idx = 0;
364
407
  for (const [hourKey, { acc, models }] of grouped) {
365
- const cost = calculateCost(acc, models);
408
+ const cost = buildModelBreakdowns(models).reduce((sum, model) => sum + model.cost, 0);
366
409
  const [datePart, timePart] = hourKey.split(' ');
367
410
  const hour = timePart.split(':')[0];
368
411
  blocks.push({
@@ -374,18 +417,29 @@ export function getBlocksResponse(options) {
374
417
  isGap: false,
375
418
  entries: acc.totalTokens > 0 ? 1 : 0,
376
419
  tokenCounts: {
377
- inputTokens: acc.inputTokens,
420
+ inputTokens: displayInputTokens(acc.inputTokens, acc.cachedInputTokens),
378
421
  outputTokens: acc.outputTokens,
379
422
  cacheCreationInputTokens: 0,
380
423
  cacheReadInputTokens: acc.cachedInputTokens,
381
424
  },
382
425
  totalTokens: acc.totalTokens,
383
426
  costUSD: cost,
384
- models: [...models],
427
+ models: [...models.keys()],
385
428
  });
386
429
  idx++;
387
430
  }
388
- // Sort by startTime
389
431
  blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
390
432
  return { blocks };
391
433
  }
434
+ /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
435
+ export function getDailyResponse(options) {
436
+ return buildDailyResponse(parseAllSessions(), options);
437
+ }
438
+ /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
439
+ export function getProjectsResponse(options) {
440
+ return buildProjectsResponse(parseAllSessions(), options);
441
+ }
442
+ /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
443
+ export function getBlocksResponse(options) {
444
+ return buildBlocksResponse(parseAllSessions(), options);
445
+ }
@@ -1,4 +1,8 @@
1
1
  import type { Express } from 'express';
2
+ export declare function resolveStaticAssetBaseDir(moduleUrl?: string, baseDir?: string): {
3
+ baseDir: string;
4
+ isProduction: boolean;
5
+ };
2
6
  export declare function createApp(_port: number, baseDir?: string): Express;
3
7
  declare function main(): Promise<void>;
4
8
  export { main };
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { dirname, join } from 'node:path';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { registerApiRoutes } from './routes/api.js';
6
6
  import { detectAvailableAgents } from './agentDetection.js';
7
7
  import open from 'open';
@@ -117,22 +117,38 @@ async function listenWithPortFallback(app, preferredPort) {
117
117
  }
118
118
  throw new Error(`Could not find an available port starting from ${preferredPort}`);
119
119
  }
120
+ export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir) {
121
+ if (baseDir)
122
+ return { baseDir: resolve(baseDir), isProduction: true };
123
+ const moduleDir = dirname(fileURLToPath(moduleUrl));
124
+ const isProduction = moduleUrl.includes('/dist/');
125
+ if (!isProduction)
126
+ return { baseDir: resolve(moduleDir), isProduction: false };
127
+ // The CLI entrypoint runs from dist/server/index.js while the Vite assets are
128
+ // emitted to dist/client. Resolve the production asset base to dist instead
129
+ // of dist/server so / resolves to dist/client/index.html in installed npm
130
+ // packages. Electron passes dist explicitly and is unaffected by this branch.
131
+ if (basename(moduleDir) === 'server') {
132
+ return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
133
+ }
134
+ return { baseDir: resolve(moduleDir), isProduction: true };
135
+ }
120
136
  export function createApp(_port, baseDir) {
121
137
  const app = express();
122
138
  const router = express.Router();
123
139
  // Register API routes
124
140
  registerApiRoutes(router);
125
141
  app.use('/api', router);
126
- // Resolve paths: use provided baseDir or compute from import.meta.url
127
- const _baseDir = baseDir ?? dirname(fileURLToPath(import.meta.url));
128
- const isProduction = baseDir
129
- ? true
130
- : import.meta.url.includes('dist/');
142
+ const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
131
143
  const popoverPath = isProduction
132
144
  ? join(_baseDir, 'client', 'popover.html')
133
145
  : join(_baseDir, '..', '..', 'public', 'popover.html');
134
- app.get('/popover.html', (_req, res) => {
135
- res.sendFile(popoverPath);
146
+ app.get('/popover.html', (_req, res, next) => {
147
+ if (!existsSync(popoverPath)) {
148
+ next();
149
+ return;
150
+ }
151
+ res.type('html').send(readFileSync(popoverPath, 'utf8'));
136
152
  });
137
153
  // Check if running from dist (production build)
138
154
  if (isProduction) {
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
  },