ai-wrapped 0.0.1
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/.0spec/config.toml +50 -0
- package/.0spec/flows/ai-stats-build.toml +291 -0
- package/.0spec/flows/ai-stats-fix.toml +285 -0
- package/.0spec/flows/ai-stats.toml +400 -0
- package/.github/workflows/publish.yml +28 -0
- package/CLAUDE.md +111 -0
- package/README.md +64 -0
- package/bun.lock +635 -0
- package/electrobun.config.ts +25 -0
- package/package.json +36 -0
- package/public/tray-icon.png +0 -0
- package/src/bun/aggregator.test.ts +49 -0
- package/src/bun/aggregator.ts +130 -0
- package/src/bun/discovery/claude.ts +18 -0
- package/src/bun/discovery/codex.ts +20 -0
- package/src/bun/discovery/copilot.ts +13 -0
- package/src/bun/discovery/droid.ts +13 -0
- package/src/bun/discovery/gemini.ts +13 -0
- package/src/bun/discovery/index.ts +28 -0
- package/src/bun/discovery/opencode.ts +13 -0
- package/src/bun/discovery/types.ts +13 -0
- package/src/bun/discovery/utils.ts +48 -0
- package/src/bun/index.ts +722 -0
- package/src/bun/normalizer.test.ts +101 -0
- package/src/bun/normalizer.ts +454 -0
- package/src/bun/parsers/claude.ts +234 -0
- package/src/bun/parsers/codex.test.ts +180 -0
- package/src/bun/parsers/codex.ts +435 -0
- package/src/bun/parsers/copilot.ts +4 -0
- package/src/bun/parsers/droid.ts +4 -0
- package/src/bun/parsers/gemini.ts +4 -0
- package/src/bun/parsers/generic.test.ts +97 -0
- package/src/bun/parsers/generic.ts +260 -0
- package/src/bun/parsers/index.ts +37 -0
- package/src/bun/parsers/opencode.ts +4 -0
- package/src/bun/parsers/types.ts +23 -0
- package/src/bun/pricing.ts +52 -0
- package/src/bun/scan.ts +77 -0
- package/src/bun/session-schema.ts +1 -0
- package/src/bun/store.ts +283 -0
- package/src/mainview/App.tsx +42 -0
- package/src/mainview/components/AgentBadge.tsx +17 -0
- package/src/mainview/components/Dashboard.tsx +229 -0
- package/src/mainview/components/DashboardCharts.tsx +499 -0
- package/src/mainview/components/EmptyState.tsx +17 -0
- package/src/mainview/components/Sidebar.tsx +30 -0
- package/src/mainview/components/StatsCards.tsx +118 -0
- package/src/mainview/hooks/useDashboardData.ts +315 -0
- package/src/mainview/hooks/useRPC.ts +29 -0
- package/src/mainview/index.css +195 -0
- package/src/mainview/index.html +12 -0
- package/src/mainview/index.ts +12 -0
- package/src/mainview/lib/constants.ts +32 -0
- package/src/mainview/lib/formatters.ts +82 -0
- package/src/shared/constants.ts +1 -0
- package/src/shared/schema.ts +71 -0
- package/src/shared/session-types.ts +61 -0
- package/src/shared/types.ts +59 -0
- package/src/types/electrobun-bun.d.ts +117 -0
- package/src/types/electrobun-root.d.ts +3 -0
- package/src/types/electrobun-view.d.ts +38 -0
- package/tsconfig.json +18 -0
- package/tsconfig.typecheck.json +11 -0
- package/vite.config.ts +23 -0
package/src/bun/index.ts
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
ApplicationMenu,
|
|
5
|
+
BrowserView,
|
|
6
|
+
BrowserWindow,
|
|
7
|
+
PATHS,
|
|
8
|
+
Tray,
|
|
9
|
+
Utils,
|
|
10
|
+
} from "electrobun/bun";
|
|
11
|
+
import {
|
|
12
|
+
EMPTY_TOKEN_USAGE,
|
|
13
|
+
SESSION_SOURCES,
|
|
14
|
+
type DailyAggregate,
|
|
15
|
+
type DashboardSummary,
|
|
16
|
+
type SessionSource,
|
|
17
|
+
type TokenUsage,
|
|
18
|
+
type TrayStats,
|
|
19
|
+
} from "../shared/schema";
|
|
20
|
+
import type { AppSettings, AIStatsRPC } from "../shared/types";
|
|
21
|
+
import { runScan } from "./scan";
|
|
22
|
+
import {
|
|
23
|
+
createEmptyDayStats,
|
|
24
|
+
dailyStoreMissingRepoDimension,
|
|
25
|
+
getSettings,
|
|
26
|
+
readDailyStore,
|
|
27
|
+
setSettings,
|
|
28
|
+
type DayStats,
|
|
29
|
+
} from "./store";
|
|
30
|
+
|
|
31
|
+
const isMac = process.platform === "darwin";
|
|
32
|
+
const CUSTOM_MENU_ENABLED = false;
|
|
33
|
+
|
|
34
|
+
let isScanning = false;
|
|
35
|
+
let isQuitting = false;
|
|
36
|
+
let lastScanAt: string | null = null;
|
|
37
|
+
let scanIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
let mainWindow: BrowserWindow | null = null;
|
|
39
|
+
let tray: Tray | null = null;
|
|
40
|
+
|
|
41
|
+
const addDayStats = (target: DayStats, source: DayStats): void => {
|
|
42
|
+
target.sessions += source.sessions;
|
|
43
|
+
target.messages += source.messages;
|
|
44
|
+
target.toolCalls += source.toolCalls;
|
|
45
|
+
target.inputTokens += source.inputTokens;
|
|
46
|
+
target.outputTokens += source.outputTokens;
|
|
47
|
+
target.cacheReadTokens += source.cacheReadTokens;
|
|
48
|
+
target.cacheWriteTokens += source.cacheWriteTokens;
|
|
49
|
+
target.reasoningTokens += source.reasoningTokens;
|
|
50
|
+
target.costUsd += source.costUsd;
|
|
51
|
+
target.durationMs += source.durationMs;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const toTokenUsage = (stats: DayStats): TokenUsage => ({
|
|
55
|
+
inputTokens: stats.inputTokens,
|
|
56
|
+
outputTokens: stats.outputTokens,
|
|
57
|
+
cacheReadTokens: stats.cacheReadTokens,
|
|
58
|
+
cacheWriteTokens: stats.cacheWriteTokens,
|
|
59
|
+
reasoningTokens: stats.reasoningTokens,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const createEmptyByAgent = (): DashboardSummary["byAgent"] => ({
|
|
63
|
+
claude: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
64
|
+
codex: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
65
|
+
gemini: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
66
|
+
opencode: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
67
|
+
droid: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
68
|
+
copilot: { sessions: 0, events: 0, tokens: { ...EMPTY_TOKEN_USAGE }, costUsd: 0 },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const isInDateRange = (date: string, dateFrom?: string, dateTo?: string): boolean => {
|
|
72
|
+
if (dateFrom && date < dateFrom) return false;
|
|
73
|
+
if (dateTo && date > dateTo) return false;
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const getDailyTimelineFromStore = async (
|
|
78
|
+
dateFrom: string,
|
|
79
|
+
dateTo: string,
|
|
80
|
+
source?: SessionSource,
|
|
81
|
+
): Promise<DailyAggregate[]> => {
|
|
82
|
+
const daily = await readDailyStore();
|
|
83
|
+
const dates = Object.keys(daily).sort((a, b) => a.localeCompare(b));
|
|
84
|
+
|
|
85
|
+
const rows = dates
|
|
86
|
+
.filter((date) => isInDateRange(date, dateFrom, dateTo))
|
|
87
|
+
.map((date) => {
|
|
88
|
+
const entry = daily[date];
|
|
89
|
+
if (!entry) return null;
|
|
90
|
+
|
|
91
|
+
const stats = source ? entry.bySource[source] : entry.totals;
|
|
92
|
+
if (!stats) return null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
date,
|
|
96
|
+
source: source ?? "all",
|
|
97
|
+
model: "all",
|
|
98
|
+
sessionCount: stats.sessions,
|
|
99
|
+
messageCount: stats.messages,
|
|
100
|
+
toolCallCount: stats.toolCalls,
|
|
101
|
+
tokens: toTokenUsage(stats),
|
|
102
|
+
costUsd: stats.costUsd,
|
|
103
|
+
totalDurationMs: stats.durationMs,
|
|
104
|
+
} satisfies DailyAggregate;
|
|
105
|
+
})
|
|
106
|
+
.filter((row): row is DailyAggregate => row !== null);
|
|
107
|
+
|
|
108
|
+
return rows;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const getDashboardSummaryFromStore = async (
|
|
112
|
+
dateFrom?: string,
|
|
113
|
+
dateTo?: string,
|
|
114
|
+
): Promise<DashboardSummary> => {
|
|
115
|
+
const daily = await readDailyStore();
|
|
116
|
+
const byAgent = createEmptyByAgent();
|
|
117
|
+
const byModelMap = new Map<string, DayStats>();
|
|
118
|
+
const byRepoMap = new Map<string, DayStats>();
|
|
119
|
+
const totals = createEmptyDayStats();
|
|
120
|
+
|
|
121
|
+
for (const date of Object.keys(daily)) {
|
|
122
|
+
if (!isInDateRange(date, dateFrom, dateTo)) continue;
|
|
123
|
+
|
|
124
|
+
const entry = daily[date];
|
|
125
|
+
if (!entry) continue;
|
|
126
|
+
|
|
127
|
+
addDayStats(totals, entry.totals);
|
|
128
|
+
|
|
129
|
+
for (const source of SESSION_SOURCES) {
|
|
130
|
+
const stats = entry.bySource[source];
|
|
131
|
+
if (!stats) continue;
|
|
132
|
+
|
|
133
|
+
const target = byAgent[source];
|
|
134
|
+
target.sessions += stats.sessions;
|
|
135
|
+
target.events += stats.messages + stats.toolCalls;
|
|
136
|
+
target.tokens.inputTokens += stats.inputTokens;
|
|
137
|
+
target.tokens.outputTokens += stats.outputTokens;
|
|
138
|
+
target.tokens.cacheReadTokens += stats.cacheReadTokens;
|
|
139
|
+
target.tokens.cacheWriteTokens += stats.cacheWriteTokens;
|
|
140
|
+
target.tokens.reasoningTokens += stats.reasoningTokens;
|
|
141
|
+
target.costUsd += stats.costUsd;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [model, modelStats] of Object.entries(entry.byModel)) {
|
|
145
|
+
if (!byModelMap.has(model)) {
|
|
146
|
+
byModelMap.set(model, createEmptyDayStats());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
addDayStats(byModelMap.get(model) as DayStats, modelStats);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const [repo, repoStats] of Object.entries(entry.byRepo)) {
|
|
153
|
+
if (!byRepoMap.has(repo)) {
|
|
154
|
+
byRepoMap.set(repo, createEmptyDayStats());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
addDayStats(byRepoMap.get(repo) as DayStats, repoStats);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const byModel = [...byModelMap.entries()]
|
|
162
|
+
.map(([model, stats]) => ({
|
|
163
|
+
model,
|
|
164
|
+
sessions: stats.sessions,
|
|
165
|
+
tokens: toTokenUsage(stats),
|
|
166
|
+
costUsd: stats.costUsd,
|
|
167
|
+
}))
|
|
168
|
+
.sort((left, right) => {
|
|
169
|
+
if (right.sessions !== left.sessions) return right.sessions - left.sessions;
|
|
170
|
+
return right.costUsd - left.costUsd;
|
|
171
|
+
})
|
|
172
|
+
.slice(0, 100);
|
|
173
|
+
|
|
174
|
+
const dailyTimeline =
|
|
175
|
+
dateFrom && dateTo ? await getDailyTimelineFromStore(dateFrom, dateTo) : [];
|
|
176
|
+
const topRepos = [...byRepoMap.entries()]
|
|
177
|
+
.map(([repo, stats]) => ({
|
|
178
|
+
repo,
|
|
179
|
+
sessions: stats.sessions,
|
|
180
|
+
costUsd: stats.costUsd,
|
|
181
|
+
}))
|
|
182
|
+
.filter((entry) => entry.sessions > 0)
|
|
183
|
+
.sort((left, right) => {
|
|
184
|
+
if (right.sessions !== left.sessions) return right.sessions - left.sessions;
|
|
185
|
+
if (right.costUsd !== left.costUsd) return right.costUsd - left.costUsd;
|
|
186
|
+
return left.repo.localeCompare(right.repo);
|
|
187
|
+
})
|
|
188
|
+
.slice(0, 24);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
totals: {
|
|
192
|
+
sessions: totals.sessions,
|
|
193
|
+
events: totals.messages + totals.toolCalls,
|
|
194
|
+
messages: totals.messages,
|
|
195
|
+
toolCalls: totals.toolCalls,
|
|
196
|
+
tokens: toTokenUsage(totals),
|
|
197
|
+
costUsd: totals.costUsd,
|
|
198
|
+
durationMs: totals.durationMs,
|
|
199
|
+
},
|
|
200
|
+
byAgent,
|
|
201
|
+
byModel,
|
|
202
|
+
dailyTimeline,
|
|
203
|
+
topRepos,
|
|
204
|
+
topTools: [],
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const dailyStoreNeedsRepoBackfill = async (): Promise<boolean> => {
|
|
209
|
+
const daily = await readDailyStore();
|
|
210
|
+
let hasSessions = false;
|
|
211
|
+
|
|
212
|
+
for (const entry of Object.values(daily)) {
|
|
213
|
+
if (entry.totals.sessions > 0) {
|
|
214
|
+
hasSessions = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hasSessions) break;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!hasSessions) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return dailyStoreMissingRepoDimension();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const getSessionCountFromStore = async (): Promise<number> => {
|
|
228
|
+
const daily = await readDailyStore();
|
|
229
|
+
let count = 0;
|
|
230
|
+
|
|
231
|
+
for (const entry of Object.values(daily)) {
|
|
232
|
+
count += entry.totals.sessions;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return count;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const getTrayStatsFromStore = async (): Promise<TrayStats> => {
|
|
239
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
240
|
+
const daily = await readDailyStore();
|
|
241
|
+
const todayStats = daily[today]?.totals ?? createEmptyDayStats();
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
todayTokens:
|
|
245
|
+
todayStats.inputTokens +
|
|
246
|
+
todayStats.outputTokens +
|
|
247
|
+
todayStats.cacheReadTokens +
|
|
248
|
+
todayStats.cacheWriteTokens +
|
|
249
|
+
todayStats.reasoningTokens,
|
|
250
|
+
todayCost: todayStats.costUsd,
|
|
251
|
+
todaySessions: todayStats.sessions,
|
|
252
|
+
todayEvents: todayStats.messages + todayStats.toolCalls,
|
|
253
|
+
activeSessions: 0,
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const resolveTrayIconPath = (): string => {
|
|
258
|
+
const bundledPath = join(PATHS.VIEWS_FOLDER, "mainview", "tray-icon.png");
|
|
259
|
+
if (existsSync(bundledPath)) {
|
|
260
|
+
return bundledPath;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// In dev builds the launcher cwd is inside the .app bundle; walk parents so we
|
|
264
|
+
// can still find the project-level public/tray-icon.png.
|
|
265
|
+
let current = process.cwd();
|
|
266
|
+
while (true) {
|
|
267
|
+
const candidate = join(current, "public", "tray-icon.png");
|
|
268
|
+
if (existsSync(candidate)) {
|
|
269
|
+
return candidate;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parent = dirname(current);
|
|
273
|
+
if (parent === current) {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
current = parent;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return "";
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const updateSettings = async (patch: Partial<AppSettings>): Promise<AppSettings> => {
|
|
284
|
+
const current = await getSettings();
|
|
285
|
+
const next: AppSettings = {
|
|
286
|
+
...current,
|
|
287
|
+
...patch,
|
|
288
|
+
customPaths: {
|
|
289
|
+
...current.customPaths,
|
|
290
|
+
...(patch.customPaths ?? {}),
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
await setSettings(next);
|
|
295
|
+
return next;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const getEventAction = (event: unknown): string => {
|
|
299
|
+
if (!event || typeof event !== "object") return "";
|
|
300
|
+
const data = (event as { data?: unknown }).data;
|
|
301
|
+
if (!data || typeof data !== "object") return "";
|
|
302
|
+
const action = (data as { action?: unknown }).action;
|
|
303
|
+
return typeof action === "string" ? action : "";
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const buildTrayMenu = (stats: TrayStats) => [
|
|
307
|
+
{
|
|
308
|
+
label: `${stats.todaySessions} sessions today`,
|
|
309
|
+
type: "normal" as const,
|
|
310
|
+
action: "tray-stats-sessions",
|
|
311
|
+
data: { source: "tray" },
|
|
312
|
+
enabled: false,
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
label: `${stats.todayEvents} events today`,
|
|
316
|
+
type: "normal" as const,
|
|
317
|
+
action: "tray-stats-events",
|
|
318
|
+
data: { source: "tray" },
|
|
319
|
+
enabled: false,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
label: "Show Dashboard",
|
|
323
|
+
type: "normal" as const,
|
|
324
|
+
action: "show-dashboard",
|
|
325
|
+
data: { source: "tray" },
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
label: isScanning ? "Rescanning Sessions..." : "Rescan Sessions",
|
|
329
|
+
type: "normal" as const,
|
|
330
|
+
action: "rescan-sessions",
|
|
331
|
+
data: { source: "tray" },
|
|
332
|
+
enabled: !isScanning,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
label: "Quit",
|
|
336
|
+
type: "normal" as const,
|
|
337
|
+
action: "quit-app",
|
|
338
|
+
data: { source: "tray" },
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const updateTrayMenu = async () => {
|
|
343
|
+
if (!tray) return;
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const stats = await getTrayStatsFromStore();
|
|
347
|
+
tray.setMenu(buildTrayMenu(stats));
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.warn("[tray] Failed to update stats", error);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const createMainWindow = () => {
|
|
354
|
+
const devUrl = process.env.ELECTROBUN_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL ?? null;
|
|
355
|
+
const url = devUrl && devUrl.trim().length > 0 ? devUrl : "views://mainview/index.html";
|
|
356
|
+
|
|
357
|
+
const window = new BrowserWindow({
|
|
358
|
+
title: "AI Wrapped",
|
|
359
|
+
frame: {
|
|
360
|
+
x: 64,
|
|
361
|
+
y: 64,
|
|
362
|
+
width: 1320,
|
|
363
|
+
height: 860,
|
|
364
|
+
},
|
|
365
|
+
url,
|
|
366
|
+
renderer: "native",
|
|
367
|
+
titleBarStyle: "default",
|
|
368
|
+
rpc,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
window.on("close", () => {
|
|
372
|
+
// On macOS, closing should behave like hide-to-tray when possible.
|
|
373
|
+
if (!isQuitting && isMac) {
|
|
374
|
+
try {
|
|
375
|
+
window.minimize();
|
|
376
|
+
queueMicrotask(() => {
|
|
377
|
+
if (mainWindow === window && !canUseWindow(window)) {
|
|
378
|
+
mainWindow = null;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
void updateTrayMenu();
|
|
382
|
+
return;
|
|
383
|
+
} catch {
|
|
384
|
+
// If the native window was already closed, recreate lazily.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (mainWindow === window) {
|
|
389
|
+
mainWindow = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!isQuitting) {
|
|
393
|
+
void updateTrayMenu();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return window;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const canUseWindow = (window: BrowserWindow | null): window is BrowserWindow => {
|
|
401
|
+
if (!window) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const candidate = window as unknown as { id?: unknown };
|
|
406
|
+
const browserWindowClass = BrowserWindow as unknown as {
|
|
407
|
+
getById?: (id: number) => unknown;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (typeof candidate.id === "number" && typeof browserWindowClass.getById === "function") {
|
|
411
|
+
return Boolean(browserWindowClass.getById(candidate.id));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
window.isMinimized();
|
|
416
|
+
return true;
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const ensureMainWindow = () => {
|
|
423
|
+
if (canUseWindow(mainWindow)) {
|
|
424
|
+
return { window: mainWindow, created: false };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
mainWindow = createMainWindow();
|
|
428
|
+
return { window: mainWindow, created: true };
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const showMainWindow = (view: "dashboard" | "settings" = "dashboard") => {
|
|
432
|
+
const { window } = ensureMainWindow();
|
|
433
|
+
|
|
434
|
+
if (window.isMinimized()) {
|
|
435
|
+
window.unminimize();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
window.show();
|
|
439
|
+
window.focus();
|
|
440
|
+
rpc.send.navigate({ view });
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const hideMainWindow = () => {
|
|
444
|
+
if (!canUseWindow(mainWindow)) {
|
|
445
|
+
mainWindow = null;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!mainWindow.isMinimized()) {
|
|
450
|
+
mainWindow.minimize();
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const toggleMainWindowVisibility = () => {
|
|
455
|
+
const { window, created } = ensureMainWindow();
|
|
456
|
+
|
|
457
|
+
if (created || window.isMinimized()) {
|
|
458
|
+
showMainWindow("dashboard");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
hideMainWindow();
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const quitApp = () => {
|
|
466
|
+
isQuitting = true;
|
|
467
|
+
Utils.quit();
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const runScanWithNotifications = async (fullScan = false) => {
|
|
471
|
+
if (isScanning) {
|
|
472
|
+
return { scanned: 0, total: 0, errors: 0 };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
isScanning = true;
|
|
476
|
+
void updateTrayMenu();
|
|
477
|
+
void refreshApplicationMenu();
|
|
478
|
+
rpc.send.scanStarted({});
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const result = await runScan({ fullScan });
|
|
482
|
+
lastScanAt = new Date().toISOString();
|
|
483
|
+
|
|
484
|
+
rpc.send.scanCompleted({ scanned: result.scanned, total: result.total });
|
|
485
|
+
rpc.send.sessionsUpdated({
|
|
486
|
+
scanResult: {
|
|
487
|
+
scanned: result.scanned,
|
|
488
|
+
total: result.total,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return result;
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error("[scan] Failed", error);
|
|
495
|
+
return { scanned: 0, total: 0, errors: 1 };
|
|
496
|
+
} finally {
|
|
497
|
+
isScanning = false;
|
|
498
|
+
void updateTrayMenu();
|
|
499
|
+
void refreshApplicationMenu();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const configureBackgroundScan = (intervalMinutes: number) => {
|
|
504
|
+
if (scanIntervalId) {
|
|
505
|
+
clearInterval(scanIntervalId);
|
|
506
|
+
scanIntervalId = null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const safeMinutes = Number.isFinite(intervalMinutes) ? Math.max(1, Math.floor(intervalMinutes)) : 5;
|
|
510
|
+
scanIntervalId = setInterval(() => {
|
|
511
|
+
void runScanWithNotifications(false);
|
|
512
|
+
}, safeMinutes * 60_000);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const refreshApplicationMenu = async () => {
|
|
516
|
+
if (!CUSTOM_MENU_ENABLED) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const settings = await getSettings();
|
|
521
|
+
|
|
522
|
+
ApplicationMenu.setApplicationMenu([
|
|
523
|
+
...(isMac
|
|
524
|
+
? [
|
|
525
|
+
{
|
|
526
|
+
label: "AI Wrapped",
|
|
527
|
+
submenu: [
|
|
528
|
+
{
|
|
529
|
+
label: "Show Dashboard",
|
|
530
|
+
action: "show-dashboard",
|
|
531
|
+
data: { source: "application-menu" },
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
label: "Rescan Sessions",
|
|
535
|
+
action: "rescan-sessions",
|
|
536
|
+
data: { source: "application-menu" },
|
|
537
|
+
enabled: !isScanning,
|
|
538
|
+
},
|
|
539
|
+
{ type: "separator" },
|
|
540
|
+
{
|
|
541
|
+
label: "Quit",
|
|
542
|
+
action: "quit-app",
|
|
543
|
+
data: { source: "application-menu" },
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
]
|
|
548
|
+
: []),
|
|
549
|
+
{
|
|
550
|
+
label: "File",
|
|
551
|
+
submenu: [
|
|
552
|
+
{
|
|
553
|
+
label: "Rescan Sessions",
|
|
554
|
+
action: "rescan-sessions",
|
|
555
|
+
data: { source: "application-menu" },
|
|
556
|
+
enabled: !isScanning,
|
|
557
|
+
},
|
|
558
|
+
{ type: "separator" },
|
|
559
|
+
{
|
|
560
|
+
label: "Quit",
|
|
561
|
+
action: "quit-app",
|
|
562
|
+
data: { source: "application-menu" },
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
label: "View",
|
|
568
|
+
submenu: [
|
|
569
|
+
{
|
|
570
|
+
label: "Toggle Dark Mode",
|
|
571
|
+
type: "checkbox",
|
|
572
|
+
action: "toggle-dark-mode",
|
|
573
|
+
data: { source: "application-menu" },
|
|
574
|
+
checked: settings.theme === "dark",
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
]);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const toggleDarkMode = async () => {
|
|
582
|
+
const current = await getSettings();
|
|
583
|
+
const nextTheme: AppSettings["theme"] = current.theme === "dark" ? "light" : "dark";
|
|
584
|
+
const next = await updateSettings({ theme: nextTheme });
|
|
585
|
+
|
|
586
|
+
rpc.send.themeChanged({ theme: next.theme });
|
|
587
|
+
void refreshApplicationMenu();
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const createTray = () => {
|
|
591
|
+
const trayIconPath = resolveTrayIconPath();
|
|
592
|
+
if (!trayIconPath) {
|
|
593
|
+
console.warn("[tray] Icon not found at bundled or project paths; creating tray without an image");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
tray = new Tray({
|
|
597
|
+
...(trayIconPath ? { image: trayIconPath } : {}),
|
|
598
|
+
template: false,
|
|
599
|
+
width: 18,
|
|
600
|
+
height: 18,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
tray.on("tray-clicked", (event: unknown) => {
|
|
604
|
+
const action = getEventAction(event);
|
|
605
|
+
|
|
606
|
+
switch (action) {
|
|
607
|
+
case "tray-stats-sessions":
|
|
608
|
+
case "tray-stats-events":
|
|
609
|
+
break;
|
|
610
|
+
case "show-dashboard":
|
|
611
|
+
showMainWindow("dashboard");
|
|
612
|
+
break;
|
|
613
|
+
case "rescan-sessions":
|
|
614
|
+
void runScanWithNotifications(false);
|
|
615
|
+
break;
|
|
616
|
+
case "quit-app":
|
|
617
|
+
quitApp();
|
|
618
|
+
break;
|
|
619
|
+
default:
|
|
620
|
+
toggleMainWindowVisibility();
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
void updateTrayMenu();
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const rpc = BrowserView.defineRPC<AIStatsRPC>({
|
|
629
|
+
handlers: {
|
|
630
|
+
requests: {
|
|
631
|
+
getDashboardSummary: ({ dateFrom, dateTo }) => getDashboardSummaryFromStore(dateFrom, dateTo),
|
|
632
|
+
getDailyTimeline: ({ dateFrom, dateTo, source }) => getDailyTimelineFromStore(dateFrom, dateTo, source),
|
|
633
|
+
triggerScan: async ({ fullScan }) => {
|
|
634
|
+
const result = await runScanWithNotifications(Boolean(fullScan));
|
|
635
|
+
return { scanned: result.scanned, total: result.total };
|
|
636
|
+
},
|
|
637
|
+
getScanStatus: async () => ({
|
|
638
|
+
isScanning,
|
|
639
|
+
lastScanAt,
|
|
640
|
+
sessionCount: await getSessionCountFromStore(),
|
|
641
|
+
}),
|
|
642
|
+
getTrayStats: () => getTrayStatsFromStore(),
|
|
643
|
+
getSettings: () => getSettings(),
|
|
644
|
+
updateSettings: async (patch) => {
|
|
645
|
+
const next = await updateSettings(patch);
|
|
646
|
+
configureBackgroundScan(next.scanIntervalMinutes);
|
|
647
|
+
void refreshApplicationMenu();
|
|
648
|
+
rpc.send.themeChanged({ theme: next.theme });
|
|
649
|
+
return true;
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
messages: {
|
|
653
|
+
log: ({ msg, level }) => {
|
|
654
|
+
if (level === "warn") {
|
|
655
|
+
console.warn(`[webview] ${msg}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (level === "error") {
|
|
660
|
+
console.error(`[webview] ${msg}`);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
console.info(`[webview] ${msg}`);
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
mainWindow = createMainWindow();
|
|
671
|
+
createTray();
|
|
672
|
+
|
|
673
|
+
ApplicationMenu.on("application-menu-clicked", (event: unknown) => {
|
|
674
|
+
if (!CUSTOM_MENU_ENABLED) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const action = getEventAction(event);
|
|
679
|
+
|
|
680
|
+
switch (action) {
|
|
681
|
+
case "show-dashboard":
|
|
682
|
+
showMainWindow("dashboard");
|
|
683
|
+
break;
|
|
684
|
+
case "rescan-sessions":
|
|
685
|
+
void runScanWithNotifications(false);
|
|
686
|
+
break;
|
|
687
|
+
case "quit-app":
|
|
688
|
+
quitApp();
|
|
689
|
+
break;
|
|
690
|
+
case "toggle-dark-mode":
|
|
691
|
+
void toggleDarkMode();
|
|
692
|
+
break;
|
|
693
|
+
default:
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const bootstrap = async () => {
|
|
699
|
+
await refreshApplicationMenu();
|
|
700
|
+
|
|
701
|
+
const initialSettings = await getSettings();
|
|
702
|
+
configureBackgroundScan(initialSettings.scanIntervalMinutes);
|
|
703
|
+
|
|
704
|
+
if (initialSettings.scanOnLaunch) {
|
|
705
|
+
const fullScan = await dailyStoreNeedsRepoBackfill();
|
|
706
|
+
await runScanWithNotifications(fullScan);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
void bootstrap();
|
|
711
|
+
|
|
712
|
+
process.on("exit", () => {
|
|
713
|
+
if (scanIntervalId) {
|
|
714
|
+
clearInterval(scanIntervalId);
|
|
715
|
+
scanIntervalId = null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (tray) {
|
|
719
|
+
tray.remove();
|
|
720
|
+
tray = null;
|
|
721
|
+
}
|
|
722
|
+
});
|