token-larper 1.0.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,59 @@
1
1
  export type ThemeMode = "light" | "dark";
2
- export type ThemePalette = "pine" | "slate" | "ocean" | "iris" | "clay" | "rose" | "custom";
3
2
 
4
- export interface ThemePreset {
5
- id: Exclude<ThemePalette, "custom">;
3
+ // A theme is two independent choices. The style is the whole look and feel: fonts,
4
+ // radius, spacing, borders, shadows, label casing, and its own light and dark colors
5
+ // (all defined in styles.css under html[data-style]). The accent is either the style's
6
+ // own, one of the preset colors below, or any color the user picks. An imported CSS
7
+ // theme can replace the colors of whichever style is active.
8
+
9
+ export type StyleId = "grove" | "terminal" | "paper" | "brutal" | "soft" | "mono";
10
+
11
+ export interface StylePreset {
12
+ id: StyleId;
6
13
  name: string;
7
14
  description: string;
8
- accent: string;
9
- darkAccent: string;
15
+ /** Swatches for the picker preview: [background, surface, text, accent] per mode. */
16
+ light: [string, string, string, string];
17
+ dark: [string, string, string, string];
10
18
  }
11
19
 
12
- export const THEME_PRESETS: ThemePreset[] = [
13
- { id: "pine", name: "Pine", description: "The original forest green", accent: "#2f5a43", darkAccent: "#9fcaa8" },
14
- { id: "slate", name: "Slate", description: "Quiet blue gray", accent: "#495d75", darkAccent: "#9bb4d2" },
15
- { id: "ocean", name: "Ocean", description: "Cool blue and teal", accent: "#17627a", darkAccent: "#78c5d8" },
16
- { id: "iris", name: "Iris", description: "Soft violet", accent: "#68518f", darkAccent: "#c0a4ec" },
17
- { id: "clay", name: "Clay", description: "Warm terracotta", accent: "#995431", darkAccent: "#e1a177" },
18
- { id: "rose", name: "Rose", description: "Muted berry", accent: "#934f67", darkAccent: "#dfa1b4" },
20
+ export const STYLE_PRESETS: StylePreset[] = [
21
+ { id: "grove", name: "Grove", description: "Calm and roomy, the original look", light: ["#f7f8f6", "#ffffff", "#1f2a23", "#2f5a43"], dark: ["#141916", "#1b221e", "#e8eee7", "#9fcaa8"] },
22
+ { id: "terminal", name: "Terminal", description: "Monospace, square, and dense", light: ["#f4f6f1", "#fbfcf8", "#121a13", "#146c36"], dark: ["#0b0f0c", "#0f1511", "#c8f7d0", "#5cf08a"] },
23
+ { id: "paper", name: "Paper", description: "Serif headings on warm paper", light: ["#f5efe3", "#fbf7ee", "#2a2118", "#8c2f2b"], dark: ["#1b1712", "#221d17", "#f1e8da", "#e0907f"] },
24
+ { id: "brutal", name: "Brutal", description: "Thick outlines and hard shadows", light: ["#fff8e7", "#ffffff", "#111111", "#d81b60"], dark: ["#121212", "#1c1c1c", "#f5f5f5", "#ff79b0"] },
25
+ { id: "soft", name: "Soft", description: "Rounded, borderless, and airy", light: ["#f4f2fb", "#ffffff", "#26213a", "#6d5bd0"], dark: ["#16141f", "#1f1c2b", "#efecf8", "#b3a6ff"] },
26
+ { id: "mono", name: "Mono", description: "Black and white, flips with the mode", light: ["#ffffff", "#ffffff", "#0a0a0a", "#0a0a0a"], dark: ["#000000", "#0a0a0a", "#fafafa", "#fafafa"] },
27
+ ];
28
+
29
+ export type AccentId = "pine" | "slate" | "ocean" | "iris" | "clay" | "rose";
30
+
31
+ export const ACCENT_PRESETS: { id: AccentId; name: string; light: string; dark: string }[] = [
32
+ { id: "pine", name: "Pine", light: "#2f5a43", dark: "#9fcaa8" },
33
+ { id: "slate", name: "Slate", light: "#495d75", dark: "#9bb4d2" },
34
+ { id: "ocean", name: "Ocean", light: "#17627a", dark: "#78c5d8" },
35
+ { id: "iris", name: "Iris", light: "#68518f", dark: "#c0a4ec" },
36
+ { id: "clay", name: "Clay", light: "#995431", dark: "#e1a177" },
37
+ { id: "rose", name: "Rose", light: "#934f67", dark: "#dfa1b4" },
19
38
  ];
20
39
 
40
+ export type AccentChoice = { kind: "style" } | { kind: "preset"; id: AccentId } | { kind: "custom"; color: string };
41
+
42
+ export interface Appearance {
43
+ style: StyleId;
44
+ accent: AccentChoice;
45
+ /** Use the imported CSS theme's colors on top of the style. */
46
+ imported: boolean;
47
+ }
48
+
49
+ export const DEFAULT_APPEARANCE: Appearance = { style: "grove", accent: { kind: "style" }, imported: false };
50
+
21
51
  export const THEME_MODE_STORAGE_KEY = "token-larper-theme";
22
- export const THEME_PALETTE_STORAGE_KEY = "token-larper-palette";
52
+ export const APPEARANCE_STORAGE_KEY = "token-larper-appearance";
53
+ /** Resolved inline variables for both modes; index.html applies them before first paint. */
54
+ export const THEME_VARS_STORAGE_KEY = "token-larper-theme-vars";
55
+ /** Earlier versions stored a color palette here; read once to migrate. */
56
+ const LEGACY_PALETTE_STORAGE_KEY = "token-larper-palette";
23
57
  export const IMPORTED_THEME_STORAGE_KEY = "token-larper-imported-theme";
24
58
 
25
59
  const APP_COLOR_PROPERTIES = [
@@ -42,10 +76,6 @@ export interface ImportedTheme {
42
76
 
43
77
  const REQUIRED_PROPERTIES: AppColorProperty[] = ["--bg", "--surface", "--text", "--accent", "--accent-ink"];
44
78
 
45
- export function isThemePalette(value: string | null): value is ThemePalette {
46
- return value === "custom" || THEME_PRESETS.some((preset) => preset.id === value);
47
- }
48
-
49
79
  function validColor(value: unknown): value is string {
50
80
  return typeof value === "string"
51
81
  && value.length <= 180
@@ -86,31 +116,138 @@ export function readImportedTheme(): ImportedTheme | null {
86
116
  }
87
117
  }
88
118
 
89
- export function readThemePalette(): ThemePalette {
119
+ const isStyle = (value: unknown): value is StyleId => STYLE_PRESETS.some((s) => s.id === value);
120
+ const isAccentId = (value: unknown): value is AccentId => ACCENT_PRESETS.some((a) => a.id === value);
121
+ const isHex = (value: unknown): value is string => typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
122
+
123
+ function sanitizeAppearance(value: unknown): Appearance | null {
124
+ if (!value || typeof value !== "object") return null;
125
+ const v = value as Record<string, unknown>;
126
+ const a = (v.accent ?? {}) as Record<string, unknown>;
127
+ const accent: AccentChoice =
128
+ a.kind === "preset" && isAccentId(a.id)
129
+ ? { kind: "preset", id: a.id }
130
+ : a.kind === "custom" && isHex(a.color)
131
+ ? { kind: "custom", color: a.color.toLowerCase() }
132
+ : { kind: "style" };
133
+ return { style: isStyle(v.style) ? v.style : "grove", accent, imported: v.imported === true };
134
+ }
135
+
136
+ export function readAppearance(): Appearance {
90
137
  try {
91
- const stored = localStorage.getItem(THEME_PALETTE_STORAGE_KEY);
92
- if (!isThemePalette(stored)) return "pine";
93
- return stored === "custom" && !readImportedTheme() ? "pine" : stored;
138
+ const saved = sanitizeAppearance(JSON.parse(localStorage.getItem(APPEARANCE_STORAGE_KEY) || "null"));
139
+ if (saved) return saved.imported && !readImportedTheme() ? { ...saved, imported: false } : saved;
140
+ // Migrate the old color-palette setting: its colors live on as the accent.
141
+ const legacy = localStorage.getItem(LEGACY_PALETTE_STORAGE_KEY);
142
+ if (legacy === "custom" && readImportedTheme()) return { ...DEFAULT_APPEARANCE, imported: true };
143
+ if (isAccentId(legacy) && legacy !== "pine") return { ...DEFAULT_APPEARANCE, accent: { kind: "preset", id: legacy } };
94
144
  } catch {
95
- return "pine";
145
+ // Storage may be disabled.
146
+ }
147
+ return DEFAULT_APPEARANCE;
148
+ }
149
+
150
+ function hexToRgb(hex: string): [number, number, number] {
151
+ const n = parseInt(hex.slice(1), 16);
152
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
153
+ }
154
+
155
+ function rgbToHex([r, g, b]: [number, number, number]): string {
156
+ return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
157
+ }
158
+
159
+ function mix(a: string, b: string, weightOfA: number): string {
160
+ const x = hexToRgb(a);
161
+ const y = hexToRgb(b);
162
+ return rgbToHex([0, 1, 2].map((i) => x[i]! * weightOfA + y[i]! * (1 - weightOfA)) as [number, number, number]);
163
+ }
164
+
165
+ function luminance(hex: string): number {
166
+ const [r, g, b] = hexToRgb(hex).map((v) => {
167
+ const c = v / 255;
168
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
169
+ }) as [number, number, number];
170
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
171
+ }
172
+
173
+ function contrast(a: string, b: string): number {
174
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) as [number, number];
175
+ return (x + 0.05) / (y + 0.05);
176
+ }
177
+
178
+ /** Whichever of white or near-black reads better on the accent (buttons, badges). */
179
+ function inkFor(accent: string): string {
180
+ return contrast("#ffffff", accent) >= contrast("#101010", accent) ? "#ffffff" : "#101010";
181
+ }
182
+
183
+ /**
184
+ * The accent colors links, tab underlines and focus rings, so a custom pick is darkened
185
+ * (light mode) or lightened (dark mode) in small steps until it reads at 4.5:1 against
186
+ * a typical background. Picks that already pass are used unchanged.
187
+ */
188
+ function accentFor(choice: AccentChoice, mode: ThemeMode): string | null {
189
+ if (choice.kind === "preset") {
190
+ const preset = ACCENT_PRESETS.find((a) => a.id === choice.id)!;
191
+ return mode === "dark" ? preset.dark : preset.light;
192
+ }
193
+ if (choice.kind === "custom") {
194
+ const [background, toward] = mode === "light" ? ["#ffffff", "#000000"] : ["#161616", "#ffffff"];
195
+ let color = choice.color;
196
+ for (let step = 0; step < 12 && contrast(color, background) < 4.5; step++) color = mix(color, toward, 0.88);
197
+ return color;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ export type ThemeVars = Record<ThemeMode, Record<string, string>>;
203
+
204
+ /** Inline variables for an accent override or an imported theme, for both modes. */
205
+ export function resolveThemeVars(appearance: Appearance, importedTheme: ImportedTheme | null): ThemeVars {
206
+ const vars: ThemeVars = { light: {}, dark: {} };
207
+ for (const mode of ["light", "dark"] as const) {
208
+ const out = vars[mode];
209
+ if (appearance.imported && importedTheme) {
210
+ const tokens = importedTheme[mode];
211
+ for (const property of APP_COLOR_PROPERTIES) if (validColor(tokens[property])) out[property] = tokens[property]!;
212
+ for (const property of APP_RADIUS_PROPERTIES) if (validRadius(tokens[property])) out[property] = tokens[property]!;
213
+ }
214
+ const accent = accentFor(appearance.accent, mode);
215
+ if (accent) {
216
+ out["--accent"] = accent;
217
+ out["--focus"] = accent;
218
+ out["--accent-ink"] = inkFor(accent);
219
+ out["--accent-soft"] = `color-mix(in srgb, ${accent} ${mode === "dark" ? 18 : 12}%, var(--bg))`;
220
+ }
96
221
  }
222
+ return vars;
97
223
  }
98
224
 
99
- export function applyTheme(mode: ThemeMode, palette: ThemePalette, importedTheme: ImportedTheme | null): void {
225
+ let appliedProperties: string[] = [];
226
+
227
+ export function applyTheme(mode: ThemeMode, appearance: Appearance, importedTheme: ImportedTheme | null): void {
100
228
  const root = document.documentElement;
101
229
  root.dataset.theme = mode;
102
- root.dataset.palette = palette;
103
- for (const property of APP_COLOR_PROPERTIES) root.style.removeProperty(property);
104
- for (const property of APP_RADIUS_PROPERTIES) root.style.removeProperty(property);
105
-
106
- if (palette !== "custom" || !importedTheme) return;
107
- const tokens = importedTheme[mode];
108
- for (const property of APP_COLOR_PROPERTIES) {
109
- const value = tokens[property];
110
- if (validColor(value)) root.style.setProperty(property, value);
230
+ root.dataset.style = appearance.style;
231
+ root.dataset.accent = appearance.accent.kind === "style" && !appearance.imported ? "style" : "custom";
232
+ // Clear what an earlier call (or index.html) set before applying the new values.
233
+ for (const property of new Set([...appliedProperties, ...APP_COLOR_PROPERTIES, ...APP_RADIUS_PROPERTIES])) {
234
+ root.style.removeProperty(property);
111
235
  }
112
- for (const property of APP_RADIUS_PROPERTIES) {
113
- if (validRadius(tokens[property])) root.style.setProperty(property, tokens[property]);
236
+ const vars = resolveThemeVars(appearance, importedTheme);
237
+ for (const [property, value] of Object.entries(vars[mode])) root.style.setProperty(property, value);
238
+ appliedProperties = Object.keys(vars[mode]);
239
+ try {
240
+ localStorage.setItem(THEME_VARS_STORAGE_KEY, JSON.stringify(vars));
241
+ } catch {
242
+ // Storage may be disabled; the theme still applies for this page.
243
+ }
244
+ }
245
+
246
+ export function saveAppearance(appearance: Appearance): void {
247
+ try {
248
+ localStorage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(appearance));
249
+ } catch {
250
+ // Storage may be disabled.
114
251
  }
115
252
  }
116
253
 
package/src/paths.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ // Where the app's code lives. With npx or bunx this is a package cache folder that
6
+ // changes with every version, so nothing that must survive an update is kept here.
7
+ export const APP_ROOT = resolve(import.meta.dir, "..");
8
+
9
+ export const APP_VERSION: string = (() => {
10
+ try {
11
+ return String(JSON.parse(readFileSync(join(APP_ROOT, "package.json"), "utf8")).version || "0.0.0");
12
+ } catch {
13
+ return "0.0.0";
14
+ }
15
+ })();
16
+
17
+ /** A git checkout updates with `git pull`, not through npm. */
18
+ export const RUNNING_FROM_SOURCE = existsSync(join(APP_ROOT, ".git"));
19
+
20
+ function defaultDataDir(): string {
21
+ if (process.env.TOKEN_LARPER_DATA_DIR) return resolve(process.env.TOKEN_LARPER_DATA_DIR);
22
+ const home = homedir();
23
+ if (process.platform === "win32") return join(process.env.LOCALAPPDATA || join(home, "AppData", "Local"), "TokenLarper");
24
+ if (process.platform === "darwin") return join(home, "Library", "Application Support", "TokenLarper");
25
+ return join(process.env.XDG_DATA_HOME || join(home, ".local", "share"), "token-larper");
26
+ }
27
+
28
+ /** Cache, settings and the tray theme; the same folder for every installed version. */
29
+ export const DATA_DIR = defaultDataDir();
30
+
31
+ // Earlier versions kept this data in <app>/.cache. Copy it over once so the first
32
+ // update to this layout keeps history and settings.
33
+ function migrateLegacyCache(): void {
34
+ const legacy = join(APP_ROOT, ".cache");
35
+ try {
36
+ mkdirSync(DATA_DIR, { recursive: true });
37
+ if (!existsSync(legacy) || readdirSync(DATA_DIR).length > 0) return;
38
+ for (const name of readdirSync(legacy)) {
39
+ const from = join(legacy, name);
40
+ if (statSync(from).isFile()) copyFileSync(from, join(DATA_DIR, name));
41
+ }
42
+ } catch {
43
+ // Starting fresh is fine; the data is a cache.
44
+ }
45
+ }
46
+ migrateLegacyCache();
47
+
48
+ export const dataPath = (name: string) => join(DATA_DIR, name);
package/src/projects.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
5
5
  import type { HarnessId } from "./types.ts";
6
+ import { dataPath } from "./paths.ts";
6
7
 
7
8
  // ccusage reports a usable project for only some harnesses, so the working directory is
8
9
  // read from each harness's own session files. A session's cwd never changes, so found
@@ -11,7 +12,7 @@ import type { HarnessId } from "./types.ts";
11
12
  const HOME = homedir();
12
13
  const cwdCache = new Map<string, string | null>();
13
14
  const repoCache = new Map<string, string | null>();
14
- const CACHE_FILE = join(resolve(import.meta.dir, ".."), ".cache", "session-projects.json");
15
+ const CACHE_FILE = dataPath("session-projects.json");
15
16
  let diskLoaded = false;
16
17
  let dirty = false;
17
18
  let opencodeDirs: Map<string, { directory: string; updated: number }> | null = null;
package/src/semver.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Compare x.y.z versions numerically; anything after a "-" is ignored. */
2
+ export function compareVersions(a: string, b: string): number {
3
+ const parse = (v: string) => v.split("-")[0]!.split(".").map((n) => Number(n) || 0);
4
+ const [x, y] = [parse(a), parse(b)];
5
+ for (let i = 0; i < 3; i++) {
6
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0) ? 1 : -1;
7
+ }
8
+ return 0;
9
+ }
package/src/server.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { getDashboardData } from "./ccusage.ts";
3
- import { getStartupStatus, setStartupStatus } from "./startup.ts";
3
+ import { getStartupStatus, repointStartupIfStale, setStartupStatus } from "./startup.ts";
4
4
  import { startSystemTray, stopSystemTray } from "./tray.ts";
5
- import { formatCompactNumber, formatCurrency } from "./client/utils.ts";
5
+ import { buildTrayStatus, parseTheme, saveTheme } from "./trayStatus.ts";
6
+ import { logoSvgFile } from "./client/logoMark.ts";
7
+ import { APP_VERSION, RUNNING_FROM_SOURCE } from "./paths.ts";
8
+ import { checkForUpdates, compareVersions, localStatus, startUpdate } from "./updates.ts";
6
9
 
7
10
  const ROOT_DIR = resolve(import.meta.dir, "..");
8
11
  const CLIENT_DIR = join(ROOT_DIR, "src", "client");
@@ -48,26 +51,58 @@ function isAllowedMutation(req: Request, port: number): boolean {
48
51
  return (req.headers.get("content-type") || "").toLowerCase().startsWith("application/json");
49
52
  }
50
53
 
51
- // Check if Token Larper is already running on the target port
52
- try {
53
- const existing = await fetch(`http://127.0.0.1:${PORT}/api/tray-status`, {
54
- signal: AbortSignal.timeout(600),
55
- });
56
- if (existing.ok) {
57
- console.log(`🔥 Token Larper is already running at http://localhost:${PORT}`);
58
- console.log(`🚀 Opening dashboard in your default browser...`);
59
- const cmd =
60
- process.platform === "win32"
61
- ? `start http://localhost:${PORT}`
62
- : process.platform === "darwin"
63
- ? `open http://localhost:${PORT}`
64
- : `xdg-open http://localhost:${PORT}`;
65
- const { exec } = await import("node:child_process");
66
- exec(cmd);
67
- process.exit(0);
54
+ function openDashboard(port: number) {
55
+ if (process.env.TOKEN_LARPER_NO_BROWSER === "1") return;
56
+ const cmd =
57
+ process.platform === "win32"
58
+ ? `start http://localhost:${port}`
59
+ : process.platform === "darwin"
60
+ ? `open http://localhost:${port}`
61
+ : `xdg-open http://localhost:${port}`;
62
+ import("node:child_process").then(({ exec }) => exec(cmd));
63
+ }
64
+
65
+ /** The version of a Token Larper already on the port, "0.0.0" for one too old to say, or null if none. */
66
+ async function runningVersion(port: number): Promise<string | null> {
67
+ // /api/version answers instantly; versions before 1.5.0 don't have it, so fall back to
68
+ // /api/tray-status, which can take a few seconds while usage data loads.
69
+ try {
70
+ const res = await fetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(1500) });
71
+ const body = (await res.json().catch(() => null)) as { current?: unknown } | null;
72
+ if (res.ok && typeof body?.current === "string") return body.current;
73
+ } catch {
74
+ // Nothing on the port, or it is busy; the next check decides.
75
+ }
76
+ try {
77
+ const status = await fetch(`http://127.0.0.1:${port}/api/tray-status`, { signal: AbortSignal.timeout(5000) });
78
+ return status.ok ? "0.0.0" : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // A newer copy (after an update, or a fresh npx run) replaces an older one on the
85
+ // same port. The same or a newer version already running just gets opened.
86
+ const running = await runningVersion(PORT);
87
+ if (running !== null && compareVersions(running, APP_VERSION) < 0) {
88
+ console.log(`⬆️ Replacing Token Larper ${running} with ${APP_VERSION}...`);
89
+ try {
90
+ await fetch(`http://127.0.0.1:${PORT}/api/shutdown`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: "{}",
94
+ signal: AbortSignal.timeout(2000),
95
+ });
96
+ } catch {
97
+ // It may exit before answering.
68
98
  }
69
- } catch {
70
- // Not running, proceed with startup
99
+ for (let i = 0; i < 40 && (await runningVersion(PORT)) !== null; i++) await Bun.sleep(250);
100
+ } else if (running !== null) {
101
+ console.log(`🔥 Token Larper is already running at http://localhost:${PORT}`);
102
+ console.log(`🚀 Opening dashboard in your default browser...`);
103
+ openDashboard(PORT);
104
+ await Bun.sleep(300);
105
+ process.exit(0);
71
106
  }
72
107
 
73
108
  // Warm cached usage before the first dashboard request.
@@ -105,7 +140,7 @@ function startServer(preferredPort: number) {
105
140
  }
106
141
 
107
142
  if (url.pathname === "/logo.svg") {
108
- return new Response(Bun.file(join(CLIENT_DIR, "logo.svg")), {
143
+ return new Response(logoSvgFile(), {
109
144
  headers: { "Content-Type": "image/svg+xml; charset=utf-8" },
110
145
  });
111
146
  }
@@ -129,15 +164,15 @@ function startServer(preferredPort: number) {
129
164
  getDashboardData({ refresh: false }),
130
165
  getStartupStatus(PORT),
131
166
  ]);
132
- const tokStr = formatCompactNumber(data.totals.totalTokens);
133
- const verStr = formatCurrency(data.totals.verifiedCost);
134
- const estStr = formatCurrency(data.totals.estimatedCost);
135
- return Response.json({
136
- summaryText: `${tokStr} tokens · ${verStr} (${estStr} LARP) · ${data.totals.activeHarnesses} harnesses`,
137
- shortTooltip: `${tokStr} tok · ${estStr} LARP`,
138
- bootEnabled: startup.enabled,
139
- openBrowserOnBoot: startup.openBrowserOnBoot,
140
- });
167
+ return Response.json(buildTrayStatus(data, startup));
168
+ }
169
+
170
+ // The dashboard reports its resolved theme colors so the tray popup can match them.
171
+ if (url.pathname === "/api/ui-theme" && req.method === "POST") {
172
+ const next = parseTheme(await req.json().catch(() => null));
173
+ if (!next) return Response.json({ error: "Expected #rrggbb colors" }, { status: 400 });
174
+ saveTheme(next);
175
+ return Response.json({ ok: true });
141
176
  }
142
177
 
143
178
  if (url.pathname === "/api/startup" && req.method === "GET") {
@@ -172,6 +207,24 @@ function startServer(preferredPort: number) {
172
207
  }
173
208
  }
174
209
 
210
+ if (url.pathname === "/api/version" && req.method === "GET") {
211
+ // Only a request from the dashboard reaches out to npm.
212
+ if (url.searchParams.get("check") !== "1") return Response.json(localStatus());
213
+ return Response.json(await checkForUpdates(url.searchParams.get("force") === "1"));
214
+ }
215
+
216
+ if (url.pathname === "/api/update" && req.method === "POST") {
217
+ if (RUNNING_FROM_SOURCE) {
218
+ return Response.json({ error: "This copy runs from a git checkout. Update it with git pull." }, { status: 409 });
219
+ }
220
+ try {
221
+ startUpdate(activePort);
222
+ return Response.json({ ok: true });
223
+ } catch (error) {
224
+ return Response.json({ error: error instanceof Error ? error.message : "Could not start the update" }, { status: 500 });
225
+ }
226
+ }
227
+
175
228
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
176
229
  setTimeout(() => {
177
230
  stopSystemTray();
@@ -198,6 +251,7 @@ function startServer(preferredPort: number) {
198
251
 
199
252
  const server = startServer(PORT);
200
253
  startSystemTray(server.port ?? PORT);
254
+ void repointStartupIfStale();
201
255
 
202
256
  process.on("SIGINT", () => {
203
257
  stopSystemTray();
package/src/startup.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { join } from "node:path";
3
3
  import type { StartupConfig } from "./types.ts";
4
+ import { APP_ROOT, APP_VERSION, DATA_DIR, dataPath } from "./paths.ts";
5
+ import { compareVersions } from "./semver.ts";
4
6
 
5
7
  const REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
6
8
  const REG_VALUE_NAME = "TokenLarper";
7
- const ROOT_DIR = resolve(import.meta.dir, "..");
8
- const SETTINGS_FILE = join(ROOT_DIR, ".cache", "settings.json");
9
+ const ROOT_DIR = APP_ROOT;
10
+ const SETTINGS_FILE = dataPath("settings.json");
9
11
  const VBS_LAUNCHER = join(ROOT_DIR, "scripts", "launch-silent.vbs");
10
12
  const PS1_RUNNER = join(ROOT_DIR, "scripts", "run-server.ps1");
11
13
 
@@ -35,7 +37,7 @@ function loadSavedSettings(defaultPort = 4269): SavedSettings {
35
37
  }
36
38
 
37
39
  function saveSettings(settings: SavedSettings): void {
38
- mkdirSync(join(ROOT_DIR, ".cache"), { recursive: true });
40
+ mkdirSync(DATA_DIR, { recursive: true });
39
41
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), "utf8");
40
42
  }
41
43
 
@@ -108,3 +110,33 @@ export async function setStartupStatus(options: {
108
110
  saveSettings(next);
109
111
  return getStartupStatus(next.port);
110
112
  }
113
+
114
+ function versionAt(launcher: string): string | null {
115
+ try {
116
+ const pkg = join(launcher, "..", "..", "package.json");
117
+ return String(JSON.parse(readFileSync(pkg, "utf8")).version || "0.0.0");
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * npx and bunx put every version in its own folder, so a startup entry made by an
125
+ * older version keeps launching that version. When startup is on and the entry points
126
+ * at an older or deleted copy, point it at this one.
127
+ */
128
+ export async function repointStartupIfStale(): Promise<void> {
129
+ if (process.platform !== "win32") return;
130
+ try {
131
+ const value = await readRegistryValue();
132
+ const target = value?.match(/"([^"]*launch-silent\.vbs)"/i)?.[1];
133
+ if (!target || target.toLowerCase() === VBS_LAUNCHER.toLowerCase()) return;
134
+ const theirs = versionAt(target);
135
+ if (theirs !== null && existsSync(target) && compareVersions(theirs, APP_VERSION) >= 0) return;
136
+ if (!existsSync(VBS_LAUNCHER) || !existsSync(PS1_RUNNER)) return;
137
+ const command = `wscript.exe //B //Nologo "${VBS_LAUNCHER}"`;
138
+ await runReg(["add", REG_KEY, "/v", REG_VALUE_NAME, "/t", "REG_SZ", "/d", command, "/f"]);
139
+ } catch {
140
+ // The old entry keeps working; Settings can turn startup off and on again.
141
+ }
142
+ }