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.
- package/README.md +44 -10
- package/bin/cli.js +140 -42
- 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 +85 -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/client/themes.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
13
|
-
{ id: "
|
|
14
|
-
{ id: "
|
|
15
|
-
{ id: "
|
|
16
|
-
{ id: "
|
|
17
|
-
{ id: "
|
|
18
|
-
{ id: "
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
-
if (!
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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 =
|
|
8
|
-
const SETTINGS_FILE =
|
|
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(
|
|
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
|
+
}
|