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/README.md +35 -8
- package/bin/cli.js +2 -23
- package/llms.txt +2 -2
- package/package.json +6 -4
- package/scripts/run-server.ps1 +6 -1
- package/scripts/tray-host.ps1 +158 -9
- package/src/ccusage.ts +40 -11
- package/src/client/App.tsx +53 -22
- package/src/client/components/Logo.tsx +19 -0
- package/src/client/components/SettingsDialog.tsx +21 -9
- package/src/client/components/ThemeSettings.tsx +116 -41
- package/src/client/components/UpdateBanner.tsx +42 -0
- package/src/client/components/UpdateSettings.tsx +65 -0
- package/src/client/index.html +19 -28
- package/src/client/lib/shareCard.ts +2 -6
- package/src/client/lib/trayTheme.ts +71 -0
- package/src/client/lib/updates.ts +94 -0
- package/src/client/logoMark.ts +103 -0
- package/src/client/main.tsx +3 -0
- package/src/client/styles.css +337 -77
- package/src/client/themes.ts +171 -34
- package/src/paths.ts +48 -0
- package/src/projects.ts +3 -2
- package/src/semver.ts +9 -0
- package/src/server.ts +83 -31
- package/src/startup.ts +36 -4
- package/src/tray.ts +226 -48
- package/src/trayStatus.ts +91 -0
- package/src/types.ts +10 -0
- package/src/updates.ts +62 -0
- package/src/client/logo.svg +0 -8
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.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
$
|
|
32
|
-
$
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
$
|
|
38
|
-
$
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
$
|
|
45
|
-
$
|
|
46
|
-
$
|
|
47
|
-
$g.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
$
|
|
51
|
-
$
|
|
52
|
-
$
|
|
53
|
-
$
|
|
54
|
-
|
|
55
|
-
$
|
|
56
|
-
$
|
|
57
|
-
$
|
|
58
|
-
$
|
|
59
|
-
|
|
60
|
-
$
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
$
|
|
66
|
-
$
|
|
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
|
-
#
|
|
153
|
-
$notifyIcon.
|
|
154
|
-
|
|
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", "
|
|
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
|
+
}
|
package/src/client/logo.svg
DELETED
|
@@ -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>
|