@zhangferry-dev/tokendash 1.6.0 → 1.6.2
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/README.md +146 -83
- package/dist/client/assets/index-Bw503sNp.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/client/popover.html +4 -3
- package/dist/daemon.cjs +3306 -0
- package/dist/daemon.cjs.map +7 -0
- package/dist/electron-server.cjs +1043 -27
- package/dist/electron-server.cjs.map +4 -4
- package/dist/server/daemon.d.ts +12 -0
- package/dist/server/daemon.js +176 -0
- package/dist/server/index.js +39 -13
- package/dist/server/insightsCalculator.d.ts +15 -0
- package/dist/server/insightsCalculator.js +276 -0
- package/dist/server/quota/adapter.d.ts +47 -0
- package/dist/server/quota/adapter.js +41 -0
- package/dist/server/quota/adapters/claude.d.ts +2 -0
- package/dist/server/quota/adapters/claude.js +124 -0
- package/dist/server/quota/adapters/codex.d.ts +2 -0
- package/dist/server/quota/adapters/codex.js +188 -0
- package/dist/server/quota/adapters/glm.d.ts +2 -0
- package/dist/server/quota/adapters/glm.js +133 -0
- package/dist/server/quota/adapters/kimi.d.ts +2 -0
- package/dist/server/quota/adapters/kimi.js +184 -0
- package/dist/server/quota/adapters/minimax.d.ts +2 -0
- package/dist/server/quota/adapters/minimax.js +77 -0
- package/dist/server/quota/cache.d.ts +20 -0
- package/dist/server/quota/cache.js +44 -0
- package/dist/server/quota/credentialsFile.d.ts +13 -0
- package/dist/server/quota/credentialsFile.js +23 -0
- package/dist/server/quota/helpers.d.ts +39 -0
- package/dist/server/quota/helpers.js +93 -0
- package/dist/server/quota/index.d.ts +5 -0
- package/dist/server/quota/index.js +23 -0
- package/dist/server/quota/quotaService.d.ts +37 -0
- package/dist/server/quota/quotaService.js +141 -0
- package/dist/server/quota/schemas.d.ts +358 -0
- package/dist/server/quota/schemas.js +53 -0
- package/dist/server/quota/types.d.ts +65 -0
- package/dist/server/quota/types.js +10 -0
- package/dist/server/routes/api.d.ts +6 -1
- package/dist/server/routes/api.js +26 -1
- package/dist/server/routes/insights.d.ts +2 -0
- package/dist/server/routes/insights.js +155 -0
- package/package.json +6 -10
- package/resources/icon-1024.png +0 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.png +0 -0
- package/resources/product_menu.png +0 -0
- package/resources/readme-hero.png +0 -0
- package/dist/client/assets/index-_yA9tOzZ.css +0 -1
- package/electron/main.cjs +0 -490
- package/electron/main.js +0 -291
- package/electron/preload.cjs +0 -36
- package/electron/trayBadge.cjs +0 -27
- package/electron/trayBadge.js +0 -30
- package/electron/trayHelper +0 -0
- package/electron/trayHelper.swift +0 -152
- package/electron/updateService.cjs +0 -148
- package/electron-builder.yml +0 -20
- /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
package/electron/main.js
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
const { app, BrowserWindow, screen, shell } = require('electron');
|
|
2
|
-
const path = require('node:path');
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const http = require('node:http');
|
|
5
|
-
const { spawn } = require('node:child_process');
|
|
6
|
-
|
|
7
|
-
// Global debug logger (writes to file since stdout is lost in packaged apps)
|
|
8
|
-
const DEBUG_LOG = '/tmp/tokendash-debug.log';
|
|
9
|
-
try { fs.writeFileSync(DEBUG_LOG, 'main.js loaded\n'); } catch(_){}
|
|
10
|
-
|
|
11
|
-
// Import from bundled server (created by esbuild)
|
|
12
|
-
let createApp;
|
|
13
|
-
try {
|
|
14
|
-
createApp = require('../dist/electron-server.cjs').createApp;
|
|
15
|
-
} catch (e) {
|
|
16
|
-
console.error('Failed to load bundled server. Did you run the build?', e.message);
|
|
17
|
-
app.quit();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const { formatCost } = require('./trayBadge.js');
|
|
21
|
-
|
|
22
|
-
// Resolve trayHelper binary: extract from asar if needed
|
|
23
|
-
function resolveTrayHelperPath() {
|
|
24
|
-
const srcPath = path.join(__dirname, 'trayHelper');
|
|
25
|
-
const isAsar = srcPath.includes('.asar');
|
|
26
|
-
const debugLog = (msg) => {
|
|
27
|
-
const logPath = '/tmp/tokendash-debug.log';
|
|
28
|
-
fs.appendFileSync(logPath, msg + '\n');
|
|
29
|
-
};
|
|
30
|
-
debugLog('[trayHelper] __dirname: ' + __dirname);
|
|
31
|
-
debugLog('[trayHelper] srcPath: ' + srcPath + ' isAsar: ' + isAsar);
|
|
32
|
-
if (isAsar) {
|
|
33
|
-
const destDir = path.join(app.getPath('userData'), 'helpers');
|
|
34
|
-
const destPath = path.join(destDir, 'trayHelper');
|
|
35
|
-
debugLog('[trayHelper] extracting to: ' + destPath);
|
|
36
|
-
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
37
|
-
fs.copyFileSync(srcPath, destPath);
|
|
38
|
-
fs.chmodSync(destPath, 0o755);
|
|
39
|
-
debugLog('[trayHelper] extracted OK');
|
|
40
|
-
return destPath;
|
|
41
|
-
}
|
|
42
|
-
return srcPath;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// State
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
let popover = null;
|
|
50
|
-
let server = null;
|
|
51
|
-
let trayProcess = null;
|
|
52
|
-
const serverPort = parseInt(process.env.TOKENDASH_PORT || '3456', 10);
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Helpers
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
function listenWithFallback(expressApp, port) {
|
|
59
|
-
return new Promise((resolve, reject) => {
|
|
60
|
-
let currentPort = port;
|
|
61
|
-
let attempts = 0;
|
|
62
|
-
|
|
63
|
-
function tryListen() {
|
|
64
|
-
const s = expressApp.listen(currentPort);
|
|
65
|
-
s.once('listening', () => resolve({ server: s, port: currentPort }));
|
|
66
|
-
s.once('error', (err) => {
|
|
67
|
-
if (err.code === 'EADDRINUSE' && attempts < 20) {
|
|
68
|
-
attempts++;
|
|
69
|
-
currentPort++;
|
|
70
|
-
tryListen();
|
|
71
|
-
} else {
|
|
72
|
-
reject(err);
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
tryListen();
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function fetchJson(url) {
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
http.get(url, (res) => {
|
|
84
|
-
let data = '';
|
|
85
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
86
|
-
res.on('end', () => {
|
|
87
|
-
try { resolve(JSON.parse(data)); }
|
|
88
|
-
catch (e) { reject(e); }
|
|
89
|
-
});
|
|
90
|
-
}).on('error', reject);
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function positionPopoverBelowTray() {
|
|
95
|
-
if (!trayProcess || !popover) return;
|
|
96
|
-
|
|
97
|
-
// Use primary screen top-right area for positioning
|
|
98
|
-
const primaryDisplay = screen.getPrimaryDisplay();
|
|
99
|
-
const { width: screenW } = primaryDisplay.workArea;
|
|
100
|
-
const popoverWidth = 340;
|
|
101
|
-
const popoverHeight = 460;
|
|
102
|
-
|
|
103
|
-
// Position in top-right area, below menu bar
|
|
104
|
-
const x = screenW - popoverWidth - 16;
|
|
105
|
-
const y = 32; // below menu bar
|
|
106
|
-
|
|
107
|
-
popover.setPosition(Math.round(x), Math.round(y), false);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function togglePopover() {
|
|
111
|
-
if (!popover) return;
|
|
112
|
-
|
|
113
|
-
if (popover.isVisible()) {
|
|
114
|
-
popover.hide();
|
|
115
|
-
} else {
|
|
116
|
-
positionPopoverBelowTray();
|
|
117
|
-
popover.show();
|
|
118
|
-
popover.focus();
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Native tray helper (Swift binary for macOS 26+ compatibility)
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
function startTrayHelper() {
|
|
127
|
-
const helperPath = resolveTrayHelperPath();
|
|
128
|
-
trayProcess = spawn(helperPath, [], {
|
|
129
|
-
stdio: ['pipe', 'pipe', 'inherit'],
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
let buffer = '';
|
|
133
|
-
|
|
134
|
-
trayProcess.stdout.on('data', (data) => {
|
|
135
|
-
buffer += data.toString();
|
|
136
|
-
const lines = buffer.split('\n');
|
|
137
|
-
buffer = lines.pop(); // keep incomplete line in buffer
|
|
138
|
-
|
|
139
|
-
for (const line of lines) {
|
|
140
|
-
const event = line.trim();
|
|
141
|
-
if (event === 'click') {
|
|
142
|
-
togglePopover();
|
|
143
|
-
} else if (event === 'open-dashboard') {
|
|
144
|
-
const port = serverPort;
|
|
145
|
-
shell.openExternal(`http://localhost:${port}`);
|
|
146
|
-
} else if (event === 'quit-request') {
|
|
147
|
-
app.quit();
|
|
148
|
-
} else if (event === 'ready') {
|
|
149
|
-
// Helper is ready, start badge updates
|
|
150
|
-
startBadgeUpdates();
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
trayProcess.on('close', (code) => {
|
|
156
|
-
console.log('Tray helper exited with code', code);
|
|
157
|
-
trayProcess = null;
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
trayProcess.on('error', (err) => {
|
|
161
|
-
console.error('Failed to start tray helper:', err.message);
|
|
162
|
-
trayProcess = null;
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function sendTrayCommand(command) {
|
|
167
|
-
if (trayProcess && trayProcess.stdin && !trayProcess.stdin.destroyed) {
|
|
168
|
-
trayProcess.stdin.write(command + '\n');
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function stopTrayHelper() {
|
|
173
|
-
if (trayProcess) {
|
|
174
|
-
sendTrayCommand('quit');
|
|
175
|
-
trayProcess = null;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
// Tray badge updater
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
let updateTimer = null;
|
|
184
|
-
|
|
185
|
-
function updateTrayBadge() {
|
|
186
|
-
fetchJson(`http://localhost:${serverPort}/api/daily`)
|
|
187
|
-
.then((data) => {
|
|
188
|
-
if (!data || !data.totals) return;
|
|
189
|
-
const cost = data.totals.totalCost || 0;
|
|
190
|
-
const tokens = data.totals.totalTokens || 0;
|
|
191
|
-
|
|
192
|
-
const badgeText = formatCost(cost);
|
|
193
|
-
sendTrayCommand('title:' + badgeText);
|
|
194
|
-
|
|
195
|
-
const tokenStr = tokens >= 1e6
|
|
196
|
-
? (tokens / 1e6).toFixed(1) + 'M'
|
|
197
|
-
: tokens >= 1e3
|
|
198
|
-
? (tokens / 1e3).toFixed(1) + 'K'
|
|
199
|
-
: String(tokens);
|
|
200
|
-
sendTrayCommand('tooltip:TokenDash - $' + cost.toFixed(2) + ' today - ' + tokenStr + ' tokens');
|
|
201
|
-
})
|
|
202
|
-
.catch((err) => {
|
|
203
|
-
if (err.code !== 'ECONNREFUSED') {
|
|
204
|
-
console.error('Tray badge update error:', err.message);
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function startBadgeUpdates() {
|
|
210
|
-
updateTrayBadge();
|
|
211
|
-
updateTimer = setInterval(updateTrayBadge, 30000);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function stopBadgeUpdates() {
|
|
215
|
-
if (updateTimer) {
|
|
216
|
-
clearInterval(updateTimer);
|
|
217
|
-
updateTimer = null;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// Create popover window
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
function createPopoverWindow() {
|
|
226
|
-
popover = new BrowserWindow({
|
|
227
|
-
width: 340,
|
|
228
|
-
height: 460,
|
|
229
|
-
frame: false,
|
|
230
|
-
resizable: false,
|
|
231
|
-
hasShadow: true,
|
|
232
|
-
alwaysOnTop: true,
|
|
233
|
-
skipTaskbar: true,
|
|
234
|
-
show: false,
|
|
235
|
-
fullscreenable: false,
|
|
236
|
-
transparent: false,
|
|
237
|
-
webPreferences: {
|
|
238
|
-
nodeIntegration: false,
|
|
239
|
-
contextIsolation: true,
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
popover.loadURL(`http://localhost:${serverPort}/popover.html`);
|
|
244
|
-
|
|
245
|
-
popover.on('blur', () => {
|
|
246
|
-
popover.hide();
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
popover.on('close', (e) => {
|
|
250
|
-
if (!app.isQuitting) {
|
|
251
|
-
e.preventDefault();
|
|
252
|
-
popover.hide();
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
// App lifecycle
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
|
|
261
|
-
app.whenReady().then(async () => {
|
|
262
|
-
app.on('before-quit', () => {
|
|
263
|
-
app.isQuitting = true;
|
|
264
|
-
stopBadgeUpdates();
|
|
265
|
-
stopTrayHelper();
|
|
266
|
-
if (server) server.close();
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// Create and bind Express server
|
|
270
|
-
const expressApp = createApp(serverPort);
|
|
271
|
-
try {
|
|
272
|
-
const result = await listenWithFallback(expressApp, serverPort);
|
|
273
|
-
server = result.server;
|
|
274
|
-
console.log(`tokendash running on http://localhost:${result.port}`);
|
|
275
|
-
} catch (err) {
|
|
276
|
-
console.error('Failed to start server:', err);
|
|
277
|
-
app.quit();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Start native tray helper
|
|
282
|
-
startTrayHelper();
|
|
283
|
-
|
|
284
|
-
// Create popover
|
|
285
|
-
createPopoverWindow();
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
process.on('uncaughtException', (err) => {
|
|
289
|
-
console.error('Fatal error in Electron main:', err);
|
|
290
|
-
app.quit();
|
|
291
|
-
});
|
package/electron/preload.cjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
const { contextBridge, ipcRenderer } = require('electron');
|
|
2
|
-
|
|
3
|
-
contextBridge.exposeInMainWorld('electronAPI', {
|
|
4
|
-
openDashboard(url) {
|
|
5
|
-
return ipcRenderer.invoke('tokendash:open-dashboard', url);
|
|
6
|
-
},
|
|
7
|
-
getAppInfo() {
|
|
8
|
-
return ipcRenderer.invoke('tokendash:get-app-info');
|
|
9
|
-
},
|
|
10
|
-
setLaunchAtLogin(enabled) {
|
|
11
|
-
return ipcRenderer.invoke('tokendash:set-launch-at-login', enabled);
|
|
12
|
-
},
|
|
13
|
-
checkForUpdates() {
|
|
14
|
-
return ipcRenderer.invoke('tokendash:check-for-updates');
|
|
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
|
-
},
|
|
27
|
-
quitApp() {
|
|
28
|
-
return ipcRenderer.invoke('tokendash:quit');
|
|
29
|
-
},
|
|
30
|
-
setSelectedAgents(agents) {
|
|
31
|
-
return ipcRenderer.invoke('tokendash:set-selected-agents', agents);
|
|
32
|
-
},
|
|
33
|
-
updateTraySnapshot(snapshot) {
|
|
34
|
-
return ipcRenderer.invoke('tokendash:update-tray-snapshot', snapshot);
|
|
35
|
-
},
|
|
36
|
-
});
|
package/electron/trayBadge.cjs
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// electron/trayBadge.cjs
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Format token count as compact string for tray badge.
|
|
5
|
-
* Examples: 1234 -> "1.2K", 567890 -> "567.9K", 1500000 -> "1.5M"
|
|
6
|
-
*/
|
|
7
|
-
function formatTokens(tokens) {
|
|
8
|
-
tokens = Number(tokens) || 0;
|
|
9
|
-
|
|
10
|
-
if (tokens >= 1e6) return (tokens / 1e6).toFixed(1) + 'M';
|
|
11
|
-
if (tokens >= 1e3) return (tokens / 1e3).toFixed(1) + 'K';
|
|
12
|
-
if (tokens > 0) return String(tokens);
|
|
13
|
-
return '0';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Format cost as compact string for tray badge (max 5 chars).
|
|
18
|
-
* Examples: 1.234 -> "$1.2", 12.5 -> "$12", 0.05 -> "$0.1", 123.4 -> "$123"
|
|
19
|
-
*/
|
|
20
|
-
function formatCost(cost) {
|
|
21
|
-
if (cost < 0.05) return '$0';
|
|
22
|
-
if (cost < 10) return '$' + cost.toFixed(1);
|
|
23
|
-
if (cost < 100) return '$' + Math.round(cost);
|
|
24
|
-
return '$' + Math.round(cost);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
module.exports = { formatCost, formatTokens };
|
package/electron/trayBadge.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
// electron/trayBadge.js
|
|
2
|
-
const { nativeImage } = require('electron');
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Format cost as compact string for tray badge (max 5 chars).
|
|
6
|
-
* Examples: 1.234 -> "$1.2", 12.5 -> "$12", 0.05 -> "$0.1", 123.4 -> "$123"
|
|
7
|
-
*/
|
|
8
|
-
function formatCost(cost) {
|
|
9
|
-
if (cost < 0.05) return '$0';
|
|
10
|
-
if (cost < 10) return '$' + cost.toFixed(1);
|
|
11
|
-
if (cost < 100) return '$' + Math.round(cost);
|
|
12
|
-
return '$' + Math.round(cost);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// Embedded 22x22 PNG: white circle on transparent background
|
|
16
|
-
// Used as a template image — macOS auto-adapts to menu bar appearance
|
|
17
|
-
const TRAY_PNG_BASE64 =
|
|
18
|
-
'iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAAWElEQVR4nO2U2QkAMAjFnNatOqsFP+2BJxRpBgjh0QrQCiIadGZ4hHgRSjCj0ldvLNWXB6RM5gSSdZIEKfPFzcQ1zy2jeiuFyi8dmER3QkvOpqHefuifZgKh/EKNb7YAbgAAAABJRU5ErkJggg==';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Create a macOS tray icon template.
|
|
22
|
-
* setTemplate(true) lets macOS automatically invert colors for light/dark menu bars.
|
|
23
|
-
*/
|
|
24
|
-
function createBadgeIcon(_text) {
|
|
25
|
-
const img = nativeImage.createFromDataURL(`data:image/png;base64,${TRAY_PNG_BASE64}`);
|
|
26
|
-
img.setTemplateImage(true);
|
|
27
|
-
return img;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
module.exports = { createBadgeIcon, formatCost };
|
package/electron/trayHelper
DELETED
|
Binary file
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import Cocoa
|
|
2
|
-
|
|
3
|
-
// TokenDash Native Tray Helper for macOS 26+
|
|
4
|
-
// Communicates with Electron main process via stdin/stdout
|
|
5
|
-
// Protocol:
|
|
6
|
-
// stdin commands: "title:<text>\n" "tooltip:<text>\n" "quit\n"
|
|
7
|
-
// stdout events: "click:<screenX>,<screenY>\n"
|
|
8
|
-
|
|
9
|
-
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
10
|
-
var statusItem: NSStatusItem!
|
|
11
|
-
var readHandle: FileHandle?
|
|
12
|
-
var currentTitle = "0"
|
|
13
|
-
|
|
14
|
-
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
15
|
-
// Create status bar item
|
|
16
|
-
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
17
|
-
|
|
18
|
-
statusItem.button?.image = renderCombinedImage(title: currentTitle)
|
|
19
|
-
statusItem.button?.imagePosition = .imageOnly
|
|
20
|
-
statusItem.button?.imageScaling = .scaleProportionallyDown
|
|
21
|
-
statusItem.button?.isBordered = false
|
|
22
|
-
statusItem.button?.title = ""
|
|
23
|
-
statusItem.button?.toolTip = "TokenDash"
|
|
24
|
-
|
|
25
|
-
// Set up click actions — both left and right click
|
|
26
|
-
statusItem.button?.target = self
|
|
27
|
-
statusItem.button?.action = #selector(handleClick(_:))
|
|
28
|
-
statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
29
|
-
|
|
30
|
-
// Read commands from stdin
|
|
31
|
-
readHandle = FileHandle.standardInput
|
|
32
|
-
NotificationCenter.default.addObserver(
|
|
33
|
-
self,
|
|
34
|
-
selector: #selector(handleStdin),
|
|
35
|
-
name: .NSFileHandleDataAvailable,
|
|
36
|
-
object: readHandle
|
|
37
|
-
)
|
|
38
|
-
readHandle?.waitForDataInBackgroundAndNotify()
|
|
39
|
-
|
|
40
|
-
// Signal ready
|
|
41
|
-
sendEvent("ready")
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/// Render icon + title text into a single template image for the status bar.
|
|
45
|
-
func renderCombinedImage(title: String) -> NSImage {
|
|
46
|
-
let iconW: CGFloat = 18
|
|
47
|
-
let iconH: CGFloat = 18
|
|
48
|
-
let fontSize: CGFloat = 13
|
|
49
|
-
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium)
|
|
50
|
-
let textAttrs: [NSAttributedString.Key: Any] = [.font: font]
|
|
51
|
-
let textWidth = (title as NSString).size(withAttributes: textAttrs).width
|
|
52
|
-
let padding: CGFloat = 4 // gap between icon and text
|
|
53
|
-
|
|
54
|
-
let totalWidth = iconW + padding + textWidth
|
|
55
|
-
// Status bar height is ~22pt; center vertically
|
|
56
|
-
let totalHeight: CGFloat = 20
|
|
57
|
-
|
|
58
|
-
let image = NSImage(size: NSSize(width: totalWidth, height: totalHeight))
|
|
59
|
-
image.lockFocus()
|
|
60
|
-
|
|
61
|
-
// Draw icon centered vertically
|
|
62
|
-
let icon = createTemplateIcon(size: NSSize(width: iconW, height: iconH))
|
|
63
|
-
let iconY = (totalHeight - iconH) / 2.0
|
|
64
|
-
icon.draw(in: NSRect(x: 0, y: iconY, width: iconW, height: iconH))
|
|
65
|
-
|
|
66
|
-
// Draw text centered vertically (baseline-adjusted)
|
|
67
|
-
let textY = (totalHeight - fontSize) / 2.0 - 1
|
|
68
|
-
(title as NSString).draw(at: NSPoint(x: iconW + padding, y: textY), withAttributes: textAttrs)
|
|
69
|
-
|
|
70
|
-
image.unlockFocus()
|
|
71
|
-
image.isTemplate = true
|
|
72
|
-
return image
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
@objc func handleClick(_ sender: Any?) {
|
|
76
|
-
guard let _ = NSApp.currentEvent else { return }
|
|
77
|
-
let loc = NSEvent.mouseLocation
|
|
78
|
-
sendEvent("click:\(Int(loc.x)),\(Int(loc.y))")
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
@objc func handleStdin() {
|
|
82
|
-
guard let data = readHandle?.availableData, data.count > 0 else {
|
|
83
|
-
readHandle?.waitForDataInBackgroundAndNotify()
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if let line = String(data: data, encoding: .utf8) {
|
|
88
|
-
for command in line.split(separator: "\n") {
|
|
89
|
-
let cmd = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
90
|
-
if cmd.hasPrefix("title:") {
|
|
91
|
-
let title = String(cmd.dropFirst(6))
|
|
92
|
-
currentTitle = title
|
|
93
|
-
statusItem.button?.image = renderCombinedImage(title: title)
|
|
94
|
-
} else if cmd.hasPrefix("tooltip:") {
|
|
95
|
-
let tooltip = String(cmd.dropFirst(8))
|
|
96
|
-
statusItem.button?.toolTip = tooltip
|
|
97
|
-
} else if cmd == "quit" {
|
|
98
|
-
NSApp.terminate(nil)
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
readHandle?.waitForDataInBackgroundAndNotify()
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
func sendEvent(_ event: String) {
|
|
108
|
-
print(event)
|
|
109
|
-
fflush(stdout)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
func createTemplateIcon(size: NSSize) -> NSImage {
|
|
113
|
-
let image = NSImage(size: size)
|
|
114
|
-
image.lockFocus()
|
|
115
|
-
|
|
116
|
-
let sx = size.width / 64.0
|
|
117
|
-
let sy = size.height / 64.0
|
|
118
|
-
|
|
119
|
-
let path = NSBezierPath()
|
|
120
|
-
path.move(to: NSPoint(x: 6 * sx, y: (64 - 32) * sy))
|
|
121
|
-
path.line(to: NSPoint(x: 18 * sx, y: (64 - 32) * sy))
|
|
122
|
-
path.curve(to: NSPoint(x: 24.5 * sx, y: (64 - 39) * sy),
|
|
123
|
-
controlPoint1: NSPoint(x: 21 * sx, y: (64 - 32) * sy),
|
|
124
|
-
controlPoint2: NSPoint(x: 22.5 * sx, y: (64 - 34) * sy))
|
|
125
|
-
path.curve(to: NSPoint(x: 34 * sx, y: (64 - 50) * sy),
|
|
126
|
-
controlPoint1: NSPoint(x: 27 * sx, y: (64 - 45.5) * sy),
|
|
127
|
-
controlPoint2: NSPoint(x: 30 * sx, y: (64 - 50) * sy))
|
|
128
|
-
path.curve(to: NSPoint(x: 44 * sx, y: (64 - 22) * sy),
|
|
129
|
-
controlPoint1: NSPoint(x: 38 * sx, y: (64 - 50) * sy),
|
|
130
|
-
controlPoint2: NSPoint(x: 40.5 * sx, y: (64 - 42) * sy))
|
|
131
|
-
path.curve(to: NSPoint(x: 52 * sx, y: (64 - 8) * sy),
|
|
132
|
-
controlPoint1: NSPoint(x: 46 * sx, y: (64 - 11) * sy),
|
|
133
|
-
controlPoint2: NSPoint(x: 49 * sx, y: (64 - 8) * sy))
|
|
134
|
-
path.curve(to: NSPoint(x: 60 * sx, y: (64 - 22) * sy),
|
|
135
|
-
controlPoint1: NSPoint(x: 55 * sx, y: (64 - 8) * sy),
|
|
136
|
-
controlPoint2: NSPoint(x: 57.5 * sx, y: (64 - 13) * sy))
|
|
137
|
-
|
|
138
|
-
path.lineWidth = 5 * sx
|
|
139
|
-
path.lineCapStyle = .round
|
|
140
|
-
path.lineJoinStyle = .round
|
|
141
|
-
NSColor.black.setStroke()
|
|
142
|
-
path.stroke()
|
|
143
|
-
|
|
144
|
-
image.unlockFocus()
|
|
145
|
-
image.isTemplate = true
|
|
146
|
-
return image
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let delegate = AppDelegate()
|
|
151
|
-
NSApplication.shared.delegate = delegate
|
|
152
|
-
NSApp.run()
|
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
};
|