token-larper 1.0.1 → 1.6.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/src/tray.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { flattenPath, LOGO_PATHS } from "./client/logoMark.ts";
3
4
 
4
5
  const ROOT_DIR = resolve(import.meta.dir, "..");
5
6
  const SCRIPTS_DIR = join(ROOT_DIR, "scripts");
@@ -10,6 +11,10 @@ let trayPid: number | null = null;
10
11
 
11
12
  export function generateTrayScript(port: number, serverPid: number): string {
12
13
  mkdirSync(SCRIPTS_DIR, { recursive: true });
14
+ const logoPolygons = [LOGO_PATHS.t, LOGO_PATHS.flame]
15
+ .flatMap((d) => flattenPath(d))
16
+ .map((poly) => ` @(${poly.flat().map((n) => n.toFixed(2)).join(", ")})`)
17
+ .join(",\n");
13
18
 
14
19
  const psScript = `
15
20
  Add-Type -AssemblyName System.Windows.Forms
@@ -24,52 +29,193 @@ $BaseUrl = "http://127.0.0.1:$ServerPort"
24
29
  Add-Type -Namespace TokenLarper -Name Dpi -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();'
25
30
  [TokenLarper.Dpi]::SetProcessDPIAware() | Out-Null
26
31
 
27
- # The t. monogram from src/client/logo.svg, drawn in its 64-unit coordinates.
28
- $iconSize = [Math]::Max(16, [System.Windows.Forms.SystemInformation]::SmallIconSize.Width)
29
- $bmp = New-Object System.Drawing.Bitmap($iconSize, $iconSize)
30
- $g = [System.Drawing.Graphics]::FromImage($bmp)
31
- $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
32
- $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
33
- $g.Clear([System.Drawing.Color]::Transparent)
34
- $g.ScaleTransform($iconSize / 64, $iconSize / 64)
35
-
36
- $green = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 47, 90, 67))
37
- $cream = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 243, 227, 191))
38
- $gold = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 225, 183, 92))
39
-
40
- # Rounded square: x 3, y 3, size 58, corner radius 14.
41
- $tile = New-Object System.Drawing.Drawing2D.GraphicsPath
42
- $tile.AddArc(3, 3, 28, 28, 180, 90)
43
- $tile.AddArc(33, 3, 28, 28, 270, 90)
44
- $tile.AddArc(33, 33, 28, 28, 0, 90)
45
- $tile.AddArc(3, 33, 28, 28, 90, 90)
46
- $tile.CloseFigure()
47
- $g.FillPath($green, $tile)
48
-
49
- # Letter t: a stem with a curved foot, plus the crossbar.
50
- $stem = New-Object System.Drawing.Drawing2D.GraphicsPath
51
- $stem.AddLine(20, 12, 28, 12)
52
- $stem.AddLine(28, 12, 28, 43.5)
53
- $stem.AddBezier(28, 43.5, 28, 45.2, 28.8, 46, 30.5, 46)
54
- $stem.AddLine(30.5, 46, 35, 46)
55
- $stem.AddLine(35, 46, 35, 53)
56
- $stem.AddLine(35, 53, 28.5, 53)
57
- $stem.AddBezier(28.5, 53, 22.8, 53, 20, 50.2, 20, 44.5)
58
- $stem.CloseFigure()
59
- $g.FillPath($cream, $stem)
60
- $g.FillRectangle($cream, 13, 21, 22, 7)
61
-
62
- # The dot.
63
- $g.FillEllipse($gold, 39.5, 42, 11, 11)
64
-
65
- $hIcon = $bmp.GetHicon()
66
- $icon = [System.Drawing.Icon]::FromHandle($hIcon)
32
+ # The Burning t. logo from src/client/logoMark.ts, flattened to polygons in 64-unit
33
+ # coordinates. It has no background, so it is black on a light taskbar and white on a
34
+ # dark one, and redrawn if that Windows setting changes.
35
+ Add-Type -Namespace TokenLarper -Name IconNative -MemberDefinition '[DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr handle);'
36
+ $LogoPolygons = @(
37
+ ${logoPolygons}
38
+ )
39
+
40
+ function Test-LightTaskbar {
41
+ try {
42
+ $value = Get-ItemPropertyValue -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -Name SystemUsesLightTheme -ErrorAction Stop
43
+ return ($value -eq 1)
44
+ } catch {
45
+ return $false
46
+ }
47
+ }
48
+
49
+ function New-LogoIcon([bool]$light) {
50
+ $size = [Math]::Max(16, [System.Windows.Forms.SystemInformation]::SmallIconSize.Width)
51
+ $bmp = New-Object System.Drawing.Bitmap($size, $size)
52
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
53
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
54
+ $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
55
+ $g.Clear([System.Drawing.Color]::Transparent)
56
+ $g.ScaleTransform($size / 64, $size / 64)
57
+ $color = if ($light) { [System.Drawing.Color]::FromArgb(255, 20, 20, 20) } else { [System.Drawing.Color]::FromArgb(255, 245, 245, 245) }
58
+ $brush = New-Object System.Drawing.SolidBrush($color)
59
+ # Alternate (even-odd) filling cuts the flame's core out of the flame.
60
+ $path = New-Object System.Drawing.Drawing2D.GraphicsPath([System.Drawing.Drawing2D.FillMode]::Alternate)
61
+ foreach ($poly in $LogoPolygons) {
62
+ $count = [int]($poly.Length / 2)
63
+ $points = New-Object 'System.Drawing.PointF[]' $count
64
+ for ($i = 0; $i -lt $count; $i++) {
65
+ $points[$i] = New-Object System.Drawing.PointF([single]$poly[2 * $i], [single]$poly[2 * $i + 1])
66
+ }
67
+ $path.AddPolygon($points)
68
+ }
69
+ $g.FillPath($brush, $path)
70
+ $path.Dispose()
71
+ $brush.Dispose()
72
+ $g.Dispose()
73
+ $icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
74
+ $bmp.Dispose()
75
+ return $icon
76
+ }
77
+
78
+ $script:lightTaskbar = Test-LightTaskbar
79
+ $icon = New-LogoIcon $script:lightTaskbar
67
80
 
68
81
  $notifyIcon = New-Object System.Windows.Forms.NotifyIcon
69
82
  $notifyIcon.Icon = $icon
70
83
  $notifyIcon.Text = "Token Larper - AI Coding Telemetry (Port $ServerPort)"
71
84
  $notifyIcon.Visible = $true
72
85
 
86
+ # Compact left-click popup. Every string comes from /api/tray-status, and its colors come
87
+ # from the dashboard's current theme (sent to the server whenever the theme changes).
88
+ Add-Type -Namespace TokenLarper -Name Dwm -MemberDefinition '[DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);'
89
+
90
+ function New-PopupLabel([string]$text, [int]$x, [int]$y, [int]$w, [int]$h, [float]$size, [bool]$bold, [string]$align) {
91
+ $label = New-Object System.Windows.Forms.Label
92
+ $label.Text = $text
93
+ $label.Location = New-Object System.Drawing.Point($x, $y)
94
+ $label.Size = New-Object System.Drawing.Size($w, $h)
95
+ $style = if ($bold) { [System.Drawing.FontStyle]::Bold } else { [System.Drawing.FontStyle]::Regular }
96
+ $label.Font = New-Object System.Drawing.Font("Segoe UI", $size, $style)
97
+ $label.TextAlign = [System.Drawing.ContentAlignment]::$align
98
+ $label.AutoEllipsis = $true
99
+ $popup.Controls.Add($label)
100
+ return $label
101
+ }
102
+
103
+ $popup = New-Object System.Windows.Forms.Form
104
+ $popup.Text = "Token Larper"
105
+ $popup.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
106
+ $popup.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
107
+ $popup.ShowInTaskbar = $false
108
+ $popup.TopMost = $true
109
+ $popup.KeyPreview = $true
110
+ $popup.ClientSize = New-Object System.Drawing.Size(300, 222)
111
+
112
+ $popupTitle = New-PopupLabel "Token Larper" 20 16 170 22 10 $true "MiddleLeft"
113
+ $popupLevel = New-PopupLabel "" 190 16 90 22 9 $true "MiddleRight"
114
+ $popupTodayCaption = New-PopupLabel "Today" 20 50 260 18 9 $false "MiddleLeft"
115
+ $popupTodayTokens = New-PopupLabel "Loading..." 17 68 160 40 21 $true "MiddleLeft"
116
+ $popupTodayCost = New-PopupLabel "" 160 76 120 28 12 $true "MiddleRight"
117
+ $popupRule = New-Object System.Windows.Forms.Panel
118
+ $popupRule.Location = New-Object System.Drawing.Point(20, 118)
119
+ $popupRule.Size = New-Object System.Drawing.Size(260, 1)
120
+ $popup.Controls.Add($popupRule)
121
+ $popupWeekCaption = New-PopupLabel "Last 7 days" 20 128 110 22 9 $false "MiddleLeft"
122
+ $popupWeek = New-PopupLabel "" 120 128 160 22 9 $true "MiddleRight"
123
+ $popupAllCaption = New-PopupLabel "All time" 20 152 110 22 9 $false "MiddleLeft"
124
+ $popupAll = New-PopupLabel "" 120 152 160 22 9 $true "MiddleRight"
125
+ $popupUpdated = New-PopupLabel "" 150 186 130 22 8 $false "MiddleRight"
126
+
127
+ # A text link instead of a filled button bar; it takes focus so Enter opens the dashboard.
128
+ $popupOpen = New-Object System.Windows.Forms.LinkLabel
129
+ $popupOpen.Text = "Open dashboard"
130
+ $popupOpen.Location = New-Object System.Drawing.Point(20, 186)
131
+ $popupOpen.Size = New-Object System.Drawing.Size(130, 22)
132
+ $popupOpen.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Bold)
133
+ $popupOpen.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
134
+ $popupOpen.LinkBehavior = [System.Windows.Forms.LinkBehavior]::HoverUnderline
135
+ $popupOpen.TabStop = $true
136
+ $popup.Controls.Add($popupOpen)
137
+
138
+ $script:popupThemeKey = ""
139
+ function Set-PopupTheme($theme) {
140
+ # Defaults match the Pine dark theme until the dashboard reports its colors.
141
+ $t = @{ surface = "#1b221e"; text = "#e8eee7"; text2 = "#b5c1b7"; text3 = "#98a59b"; border = "#2e3932"; accent = "#9fcaa8"; gold = "#e1b75c" }
142
+ if ($null -ne $theme) { foreach ($key in @($t.Keys)) { if ($theme.$key) { $t[$key] = [string]$theme.$key } } }
143
+ $key = ($t.GetEnumerator() | Sort-Object Name | ForEach-Object { $_.Value }) -join ","
144
+ if ($key -eq $script:popupThemeKey) { return }
145
+ $script:popupThemeKey = $key
146
+
147
+ $c = @{}
148
+ foreach ($name in $t.Keys) { $c[$name] = [System.Drawing.ColorTranslator]::FromHtml($t[$name]) }
149
+ $popup.BackColor = $c.surface
150
+ foreach ($label in @($popupTitle, $popupTodayTokens, $popupTodayCost, $popupWeek, $popupAll)) { $label.ForeColor = $c.text }
151
+ foreach ($label in @($popupTodayCaption, $popupWeekCaption, $popupAllCaption, $popupUpdated)) { $label.ForeColor = $c.text3 }
152
+ $popupLevel.ForeColor = $c.gold
153
+ $popupRule.BackColor = $c.border
154
+ $popupOpen.LinkColor = $c.accent
155
+ $popupOpen.ActiveLinkColor = $c.text
156
+ $popupOpen.VisitedLinkColor = $c.accent
157
+ Set-PopupChrome $c.border
158
+ }
159
+
160
+ # Windows 11 draws rounded corners and a hairline border in the theme color; older
161
+ # versions ignore both attributes and keep a square window.
162
+ function Set-PopupChrome([System.Drawing.Color]$border) {
163
+ try {
164
+ $round = 2
165
+ [TokenLarper.Dwm]::DwmSetWindowAttribute($popup.Handle, 33, [ref]$round, 4) | Out-Null
166
+ $colorRef = [int]$border.R -bor ([int]$border.G -shl 8) -bor ([int]$border.B -shl 16)
167
+ [TokenLarper.Dwm]::DwmSetWindowAttribute($popup.Handle, 34, [ref]$colorRef, 4) | Out-Null
168
+ } catch {}
169
+ }
170
+ Set-PopupTheme $null
171
+
172
+ function Open-Dashboard {
173
+ $popup.Hide()
174
+ try {
175
+ Start-Process -FilePath $BaseUrl -ErrorAction Stop
176
+ } catch {
177
+ $notifyIcon.ShowBalloonTip(4000, "Token Larper", "Could not open the browser. Visit $BaseUrl manually.", [System.Windows.Forms.ToolTipIcon]::Warning)
178
+ }
179
+ }
180
+
181
+ $popupOpen.Add_LinkClicked({ Open-Dashboard })
182
+ $popup.Add_KeyDown({
183
+ param($sender, $eventArgs)
184
+ if ($eventArgs.KeyCode -eq [System.Windows.Forms.Keys]::Escape) {
185
+ $popup.Hide()
186
+ $eventArgs.Handled = $true
187
+ } elseif ($eventArgs.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
188
+ Open-Dashboard
189
+ $eventArgs.Handled = $true
190
+ }
191
+ })
192
+ $script:lastPopupDismissedAt = -1000
193
+ $popup.Add_Deactivate({
194
+ if ($popup.Visible) {
195
+ $popup.Hide()
196
+ $script:lastPopupDismissedAt = [Environment]::TickCount
197
+ }
198
+ })
199
+
200
+ function Show-StatusPopup {
201
+ # Refresh first so the numbers and colors are current when it appears.
202
+ Update-TrayStatus
203
+ $cursor = [System.Windows.Forms.Cursor]::Position
204
+ $area = [System.Windows.Forms.Screen]::FromPoint($cursor).WorkingArea
205
+ $x = [Math]::Max($area.Left + 8, [Math]::Min($cursor.X - $popup.Width + 24, $area.Right - $popup.Width - 8))
206
+ if ($cursor.Y -ge $area.Bottom) {
207
+ $y = $area.Bottom - $popup.Height - 8
208
+ } elseif ($cursor.Y -lt $area.Top) {
209
+ $y = $area.Top + 8
210
+ } else {
211
+ $y = [Math]::Max($area.Top + 8, [Math]::Min($cursor.Y - $popup.Height - 12, $area.Bottom - $popup.Height - 8))
212
+ }
213
+ $popup.Location = New-Object System.Drawing.Point($x, $y)
214
+ $popup.Show()
215
+ $popup.Activate() | Out-Null
216
+ $popupOpen.Focus() | Out-Null
217
+ }
218
+
73
219
  # Context Menu
74
220
  $menu = New-Object System.Windows.Forms.ContextMenuStrip
75
221
 
@@ -85,9 +231,7 @@ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
85
231
 
86
232
  $itemOpen = New-Object System.Windows.Forms.ToolStripMenuItem("Open Dashboard (http://localhost:$ServerPort)")
87
233
  $itemOpen.Font = New-Object System.Drawing.Font($itemOpen.Font, [System.Drawing.FontStyle]::Bold)
88
- $itemOpen.Add_Click({
89
- Start-Process "$BaseUrl"
90
- })
234
+ $itemOpen.Add_Click({ Open-Dashboard })
91
235
  $menu.Items.Add($itemOpen) | Out-Null
92
236
 
93
237
  $itemSync = New-Object System.Windows.Forms.ToolStripMenuItem("Sync Harnesses Now (ccusage)")
@@ -136,6 +280,8 @@ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
136
280
  $itemQuit = New-Object System.Windows.Forms.ToolStripMenuItem("Quit Token Larper")
137
281
  $itemQuit.Add_Click({
138
282
  $notifyIcon.Visible = $false
283
+ $popup.Close()
284
+ $popup.Dispose()
139
285
  try {
140
286
  Invoke-RestMethod -Uri "$BaseUrl/api/shutdown" -Method Post -ContentType "application/json" -TimeoutSec 3 | Out-Null
141
287
  } catch {}
@@ -149,9 +295,18 @@ $menu.Items.Add($itemQuit) | Out-Null
149
295
 
150
296
  $notifyIcon.ContextMenuStrip = $menu
151
297
 
152
- # Double-click tray icon opens dashboard
153
- $notifyIcon.Add_DoubleClick({
154
- Start-Process "$BaseUrl"
298
+ # Left-click toggles the status popup; right-click keeps the context menu.
299
+ $notifyIcon.Add_MouseClick({
300
+ param($sender, $eventArgs)
301
+ if ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
302
+ if ($popup.Visible) {
303
+ $popup.Hide()
304
+ } elseif ([Environment]::TickCount - $script:lastPopupDismissedAt -gt 250) {
305
+ Show-StatusPopup
306
+ }
307
+ } elseif ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Right) {
308
+ $popup.Hide()
309
+ }
155
310
  })
156
311
 
157
312
  function Update-TrayStatus {
@@ -164,10 +319,24 @@ function Update-TrayStatus {
164
319
  $tooltip = $tooltip.Substring(0, 63)
165
320
  }
166
321
  $notifyIcon.Text = $tooltip
322
+ $popupLevel.Text = $st.levelText
323
+ $popupTodayCaption.Text = $st.todayCaptionText
324
+ $popupTodayTokens.Text = $st.todayTokensText
325
+ $popupTodayCost.Text = $st.todayCostText
326
+ $popupWeek.Text = $st.weekText
327
+ $popupAll.Text = $st.allTimeText
328
+ $popupUpdated.Text = $st.updatedText
329
+ Set-PopupTheme $st.theme
167
330
  $itemBoot.Checked = [bool]$st.bootEnabled
168
331
  $itemOpenOnBoot.Checked = [bool]$st.openBrowserOnBoot
169
332
  }
170
- } catch {}
333
+ } catch {
334
+ $popupTodayTokens.Text = "Unavailable"
335
+ $popupTodayCost.Text = ""
336
+ $popupWeek.Text = "--"
337
+ $popupAll.Text = "--"
338
+ $popupUpdated.Text = ""
339
+ }
171
340
  }
172
341
 
173
342
  # Timer to check parent Bun server health & update stats
@@ -178,11 +347,20 @@ $timer.Add_Tick({
178
347
  $proc = Get-Process -Id $ServerPid -ErrorAction SilentlyContinue
179
348
  if ($null -eq $proc) {
180
349
  $notifyIcon.Visible = $false
350
+ $popup.Close()
351
+ $popup.Dispose()
181
352
  $notifyIcon.Dispose()
182
353
  [System.Windows.Forms.Application]::Exit()
183
354
  return
184
355
  }
185
356
  $script:tickCount++
357
+ $nowLight = Test-LightTaskbar
358
+ if ($nowLight -ne $script:lightTaskbar) {
359
+ $script:lightTaskbar = $nowLight
360
+ $oldIcon = $notifyIcon.Icon
361
+ $notifyIcon.Icon = New-LogoIcon $nowLight
362
+ [TokenLarper.IconNative]::DestroyIcon($oldIcon.Handle) | Out-Null
363
+ }
186
364
  if ($script:tickCount -eq 1 -or ($script:tickCount % 3 -eq 0)) {
187
365
  Update-TrayStatus
188
366
  }
@@ -190,7 +368,7 @@ $timer.Add_Tick({
190
368
  $timer.Start()
191
369
 
192
370
  Update-TrayStatus
193
- $notifyIcon.ShowBalloonTip(2500, "Token Larper Running", "Right-click or double-click the green t. icon in your system tray.", [System.Windows.Forms.ToolTipIcon]::Info)
371
+ $notifyIcon.ShowBalloonTip(2500, "Token Larper Running", "Left-click the t. icon for usage; right-click for options.", [System.Windows.Forms.ToolTipIcon]::Info)
194
372
 
195
373
  [System.Windows.Forms.Application]::Run()
196
374
  `.trim();
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { DashboardPayload, StartupConfig } from "./types.ts";
4
+ import { dataPath } from "./paths.ts";
5
+ import { rankFor } from "./client/lib/rank.ts";
6
+ import { formatCurrency, localDateKey } from "./client/utils.ts";
7
+
8
+ // Everything the tray shows is formatted here, so the PowerShell script only places
9
+ // strings and never needs non-ASCII literals of its own.
10
+
11
+ const THEME_FILE = dataPath("ui-theme.json");
12
+ const THEME_KEYS = ["surface", "text", "text2", "text3", "border", "accent", "accentInk", "gold"] as const;
13
+
14
+ export type TrayTheme = Record<(typeof THEME_KEYS)[number], string> & { mode: "light" | "dark" };
15
+
16
+ let theme: TrayTheme | null = loadTheme();
17
+
18
+ function loadTheme(): TrayTheme | null {
19
+ try {
20
+ return existsSync(THEME_FILE) ? parseTheme(JSON.parse(readFileSync(THEME_FILE, "utf8"))) : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Accept only #rrggbb colors; the dashboard resolves every theme color to hex before sending. */
27
+ export function parseTheme(value: unknown): TrayTheme | null {
28
+ if (!value || typeof value !== "object") return null;
29
+ const source = value as Record<string, unknown>;
30
+ const out: Partial<TrayTheme> = { mode: source.mode === "light" ? "light" : "dark" };
31
+ for (const key of THEME_KEYS) {
32
+ const color = source[key];
33
+ if (typeof color !== "string" || !/^#[0-9a-f]{6}$/i.test(color)) return null;
34
+ out[key] = color.toLowerCase();
35
+ }
36
+ return out as TrayTheme;
37
+ }
38
+
39
+ export function saveTheme(next: TrayTheme): void {
40
+ theme = next;
41
+ try {
42
+ mkdirSync(dirname(THEME_FILE), { recursive: true });
43
+ writeFileSync(THEME_FILE, JSON.stringify(next), "utf8");
44
+ } catch {
45
+ // The theme still applies for this session.
46
+ }
47
+ }
48
+
49
+ const compact = (n: number) =>
50
+ new Intl.NumberFormat("en-US", { notation: "compact", maximumSignificantDigits: 3 }).format(n);
51
+
52
+ export function buildTrayStatus(data: DashboardPayload, startup: StartupConfig) {
53
+ const today = localDateKey(new Date());
54
+ const weekStart = new Date();
55
+ weekStart.setDate(weekStart.getDate() - 6);
56
+ const since = localDateKey(weekStart);
57
+
58
+ const todayRow = data.daily.find((d) => d.period === today);
59
+ const week = data.daily.filter((d) => d.period >= since && d.period <= today);
60
+ const weekTokens = week.reduce((acc, d) => acc + d.totalTokens, 0);
61
+ const weekCost = week.reduce((acc, d) => acc + d.verifiedCost, 0);
62
+ const rank = rankFor(data.totals.totalTokens);
63
+
64
+ const todayTokens = todayRow?.totalTokens ?? 0;
65
+ const todayCost = todayRow?.verifiedCost ?? 0;
66
+ const allTime = `${compact(data.totals.totalTokens)} · ${formatCurrency(data.totals.verifiedCost, 0)}`;
67
+ const syncing = data.syncingHarnesses.length > 0;
68
+ const updated = new Date(data.generatedAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
69
+
70
+ return {
71
+ // Popup
72
+ todayTokensText: todayTokens > 0 ? compact(todayTokens) : "0",
73
+ todayCaptionText: todayTokens > 0 ? "Today" : "Today · no usage yet",
74
+ todayCostText: formatCurrency(todayCost),
75
+ weekText: `${compact(weekTokens)} · ${formatCurrency(weekCost)}`,
76
+ allTimeText: allTime,
77
+ levelText: `Lv ${rank.tier.level}`,
78
+ levelTitle: rank.tier.title,
79
+ updatedText: syncing ? "Syncing…" : `Updated ${updated}`,
80
+ theme,
81
+ // Menu and tooltip
82
+ summaryText: `Today ${compact(todayTokens)} · ${formatCurrency(todayCost)} All time ${allTime}`,
83
+ shortTooltip: `today ${compact(todayTokens)} · ${formatCurrency(todayCost)}`,
84
+ // Older tray scripts read these; keep them until every running tray has restarted.
85
+ totalTokensText: compact(data.totals.totalTokens),
86
+ verifiedCostText: formatCurrency(data.totals.verifiedCost),
87
+ estimatedCostText: formatCurrency(data.totals.estimatedCost),
88
+ bootEnabled: startup.enabled,
89
+ openBrowserOnBoot: startup.openBrowserOnBoot,
90
+ };
91
+ }
package/src/types.ts CHANGED
@@ -153,3 +153,13 @@ export interface StartupConfig {
153
153
  registryValue: string | null;
154
154
  launcherPath: string;
155
155
  }
156
+
157
+ export interface UpdateStatus {
158
+ current: string;
159
+ latest: string | null;
160
+ updateAvailable: boolean;
161
+ checkedAt: string | null;
162
+ /** "npm" can update itself; "source" is a git checkout and updates with git pull. */
163
+ source: "npm" | "source";
164
+ error?: string;
165
+ }
package/src/updates.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+ import { APP_VERSION, RUNNING_FROM_SOURCE } from "./paths.ts";
3
+ import { compareVersions } from "./semver.ts";
4
+ import type { UpdateStatus } from "./types.ts";
5
+
6
+ export { compareVersions };
7
+
8
+ // Token Larper's own update check. It runs only when the dashboard asks (on open, if
9
+ // automatic checks are on, or from Settings) and sends nothing but the package name.
10
+
11
+ const PACKAGE = "token-larper";
12
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
13
+ const RECHECK_MS = 6 * 60 * 60 * 1000;
14
+
15
+ let lastCheck: { latest: string; at: number } | null = null;
16
+ let inFlight: Promise<void> | null = null;
17
+
18
+ async function fetchLatest(): Promise<void> {
19
+ const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5000), headers: { Accept: "application/json" } });
20
+ if (!res.ok) throw new Error(`npm registry responded ${res.status}`);
21
+ const body = (await res.json()) as { version?: unknown };
22
+ if (typeof body.version !== "string") throw new Error("npm registry response had no version");
23
+ lastCheck = { latest: body.version, at: Date.now() };
24
+ }
25
+
26
+ export function localStatus(): UpdateStatus {
27
+ const latest = lastCheck?.latest ?? null;
28
+ return {
29
+ current: APP_VERSION,
30
+ latest,
31
+ updateAvailable: latest !== null && compareVersions(latest, APP_VERSION) > 0,
32
+ checkedAt: lastCheck ? new Date(lastCheck.at).toISOString() : null,
33
+ source: RUNNING_FROM_SOURCE ? "source" : "npm",
34
+ };
35
+ }
36
+
37
+ /** Check npm (at most every few hours unless forced) and report. */
38
+ export async function checkForUpdates(force = false): Promise<UpdateStatus> {
39
+ if (force || !lastCheck || Date.now() - lastCheck.at > RECHECK_MS) {
40
+ inFlight ??= fetchLatest().finally(() => (inFlight = null));
41
+ try {
42
+ await inFlight;
43
+ } catch (error) {
44
+ return { ...localStatus(), error: error instanceof Error ? error.message : "Could not reach npm" };
45
+ }
46
+ }
47
+ return localStatus();
48
+ }
49
+
50
+ /**
51
+ * Start the latest version in the background. The new copy sees this server running
52
+ * an older version, asks it to shut down, and takes over the same port (bin/cli.js).
53
+ */
54
+ export function startUpdate(port: number): void {
55
+ const child = spawn(process.execPath, ["x", `${PACKAGE}@latest`], {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ windowsHide: true,
59
+ env: { ...process.env, PORT: String(port), TOKEN_LARPER_NO_BROWSER: "1" },
60
+ });
61
+ child.unref();
62
+ }
@@ -1,8 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
2
- <title id="title">Token Larper</title>
3
- <!-- The tray icon in src/tray.ts draws this same geometry; keep the two in sync. -->
4
- <rect x="3" y="3" width="58" height="58" rx="14" fill="#2f5a43"/>
5
- <path d="M20 12h8v31.5c0 1.7.8 2.5 2.5 2.5H35v7h-6.5C22.8 53 20 50.2 20 44.5z" fill="#f3e3bf"/>
6
- <rect x="13" y="21" width="22" height="7" fill="#f3e3bf"/>
7
- <circle cx="45" cy="47.5" r="5.5" fill="#e1b75c"/>
8
- </svg>