@oharness/server 0.10.0 → 0.11.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/dist/analytics.d.ts +32 -29
- package/dist/analytics.js +225 -278
- package/dist/analytics.js.map +1 -1
- package/dist/i18n.js +152 -8
- package/dist/i18n.js.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/page/client/account.js +140 -4
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +40 -16
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +111 -7
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/dialogs.js +75 -0
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +25 -2
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +4 -4
- package/dist/page/client/ripple.d.ts +13 -0
- package/dist/page/client/ripple.js +247 -0
- package/dist/page/client/ripple.js.map +1 -0
- package/dist/page/client/routines.js +173 -41
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +200 -54
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +7 -2
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/terminal.js +34 -7
- package/dist/page/client/terminal.js.map +1 -1
- package/dist/page/client/theme.d.ts +17 -0
- package/dist/page/client/theme.js +365 -0
- package/dist/page/client/theme.js.map +1 -0
- package/dist/page/client/transcript.js +63 -7
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/style.js +183 -22
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +4 -5
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +17 -0
- package/dist/prefs.js +5 -0
- package/dist/prefs.js.map +1 -1
- package/dist/server.js +465 -42
- package/dist/server.js.map +1 -1
- package/dist/suggest.d.ts +42 -0
- package/dist/suggest.js +215 -0
- package/dist/suggest.js.map +1 -0
- package/dist/terminal.d.ts +28 -2
- package/dist/terminal.js +45 -3
- package/dist/terminal.js.map +1 -1
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +109 -0
- package/dist/theme-assets.js.map +1 -0
- package/dist/theme-store.d.ts +50 -0
- package/dist/theme-store.js +124 -0
- package/dist/theme-store.js.map +1 -0
- package/dist/theme-tools.d.ts +25 -0
- package/dist/theme-tools.js +243 -0
- package/dist/theme-tools.js.map +1 -0
- package/dist/turn-shots.d.ts +18 -2
- package/dist/turn-shots.js +10 -1
- package/dist/turn-shots.js.map +1 -1
- package/dist/user-theme.d.ts +143 -0
- package/dist/user-theme.js +510 -0
- package/dist/user-theme.js.map +1 -0
- package/package.json +2 -2
package/dist/analytics.d.ts
CHANGED
|
@@ -1,47 +1,50 @@
|
|
|
1
1
|
export interface AnalyticsState {
|
|
2
|
-
/** True only when this build carries a
|
|
2
|
+
/** True only when this build carries a key and the user has opted in. */
|
|
3
3
|
enabled: boolean;
|
|
4
4
|
/**
|
|
5
|
-
* Whether this build carries
|
|
6
|
-
*
|
|
7
|
-
* analytics is not being asked to arbitrate between two vendors they did not
|
|
8
|
-
* choose, and a build that carries only one is the normal case.
|
|
5
|
+
* Whether this build carries a key at all — the difference between "off"
|
|
6
|
+
* and "absent", so Settings can leave out a switch that would do nothing.
|
|
9
7
|
*/
|
|
10
8
|
available: boolean;
|
|
11
|
-
/** PostHog, present only when enabled and this build carries a key. */
|
|
12
|
-
key?: string;
|
|
13
|
-
host?: string;
|
|
14
|
-
options?: Record<string, unknown>;
|
|
15
|
-
/** DataFast, present only when enabled and this build carries an id. */
|
|
16
|
-
datafast?: {
|
|
17
|
-
id: string;
|
|
18
|
-
domain: string;
|
|
19
|
-
src: string;
|
|
20
|
-
};
|
|
21
9
|
}
|
|
10
|
+
/** What `GET /analytics` and `POST /analytics` answer. */
|
|
11
|
+
export declare function analyticsState(): Promise<AnalyticsState>;
|
|
12
|
+
/** Called after Settings writes the pref, so the next event sees it. */
|
|
13
|
+
export declare function setConsent(value: boolean): void;
|
|
14
|
+
/** Once a day the app runs. Safe to call as often as convenient. */
|
|
15
|
+
export declare function heartbeat(): Promise<void>;
|
|
16
|
+
/** Once a day the app is used — a prompt sent, not merely a window left open. */
|
|
17
|
+
export declare function markUsed(): Promise<void>;
|
|
18
|
+
/** Hourly, so a window left open across midnight still counts the new day. */
|
|
19
|
+
export declare function startHeartbeat(): () => void;
|
|
22
20
|
/**
|
|
23
|
-
* What
|
|
21
|
+
* What the page may send, reduced to what it is allowed to send.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* that
|
|
23
|
+
* The page's calls already pass only enumerable values, and this is the check
|
|
24
|
+
* that keeps it so: an event name, at most ten properties, each a boolean, a
|
|
25
|
+
* number or a short string. Anything that could be text someone typed — a long
|
|
26
|
+
* string, one carrying the page's token — is dropped here rather than trusted.
|
|
28
27
|
*/
|
|
29
|
-
export declare function
|
|
28
|
+
export declare function cleanEvent(body: Record<string, unknown>): {
|
|
29
|
+
event: string;
|
|
30
|
+
props: Record<string, unknown>;
|
|
31
|
+
} | null;
|
|
32
|
+
/** A usage event. Dropped unless this build has a key and the user opted in. */
|
|
33
|
+
export declare function track(event: string, props?: Record<string, unknown>): Promise<void>;
|
|
34
|
+
/** Send what is queued. Called on a timer, and on close so a quit loses nothing. */
|
|
35
|
+
export declare function flushAnalytics(): Promise<void>;
|
|
30
36
|
/**
|
|
31
37
|
* The queue, for the document head.
|
|
32
38
|
*
|
|
33
39
|
* Boot fires events before anything could have decided whether to send them,
|
|
34
|
-
* so they wait here. Nothing in this stub touches the network
|
|
35
|
-
* so that `track()` reads the same whether analytics end up on or off, and so
|
|
36
|
-
* that no caller has to remember which branch it is writing in.
|
|
40
|
+
* so they wait here. Nothing in this stub touches the network.
|
|
37
41
|
*/
|
|
38
|
-
export declare const ANALYTICS_STUB = "<script>\nwindow.__ANALYTICS_QUEUE = [];\nwindow.__TRACK = function (event, props) {\n
|
|
42
|
+
export declare const ANALYTICS_STUB = "<script>\nwindow.__ANALYTICS_QUEUE = [];\nwindow.__TRACK = function (event, props) {\n if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);\n};\n</script>";
|
|
39
43
|
/**
|
|
40
|
-
*
|
|
44
|
+
* Hands events to this server, and only once it has said they are wanted.
|
|
41
45
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the first place.
|
|
46
|
+
* No vendor code and no vendor address: the page talks to its own origin and
|
|
47
|
+
* nothing else, which is the rule page.ts states for everything in it. A build
|
|
48
|
+
* without a key does not even ship the forwarding, only the drain.
|
|
46
49
|
*/
|
|
47
50
|
export declare const ANALYTICS_CLIENT: string;
|
package/dist/analytics.js
CHANGED
|
@@ -1,323 +1,270 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { userPath } from "@oharness/engine/paths.ts";
|
|
5
|
+
import { parseJson } from "@oharness/engine/util/json.ts";
|
|
1
6
|
import { readPrefs } from "./prefs.js";
|
|
2
7
|
/**
|
|
3
|
-
* Usage analytics
|
|
4
|
-
* otherwise, and absent entirely from a build nobody published.
|
|
8
|
+
* Usage analytics, sent from this process rather than from the page.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* The page is served from `127.0.0.1:<port>` with a token in its address, and
|
|
11
|
+
* the port is a new one on every launch. Analytics run inside it had two
|
|
12
|
+
* problems no setting could fix: the vendor's id lived in that origin's
|
|
13
|
+
* localStorage, so every launch was a new person and nothing about retention
|
|
14
|
+
* could be read; and the vendor's script read the address, so the token had to
|
|
15
|
+
* be stripped from every property it might turn up in. Here the page holds no
|
|
16
|
+
* vendor code at all. It hands an event name and a few enumerable properties to
|
|
17
|
+
* `POST /track`, and this module decides whether it goes anywhere.
|
|
10
18
|
*
|
|
11
|
-
*
|
|
12
|
-
* time and is not in this repository — deliberately, because a default
|
|
13
|
-
* compiled into the source would mean every fork and every self-hosted
|
|
14
|
-
* install reporting into the publisher's dashboard. Someone who builds this
|
|
15
|
-
* themselves gets a build that cannot report anywhere, and gets it without
|
|
16
|
-
* having to configure anything.
|
|
19
|
+
* Gated twice, as before, and the two gates answer different questions:
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
* and
|
|
21
|
+
* A key, which only an official build carries. Injected at packaging time
|
|
22
|
+
* and not in this repository, because a default compiled into the source
|
|
23
|
+
* would be every fork reporting into the publisher's project. A build made
|
|
24
|
+
* from a checkout has no key, writes no id, and sends nothing — of either
|
|
25
|
+
* kind below.
|
|
20
26
|
*
|
|
21
|
-
*
|
|
22
|
-
* which is why the key is served from `/analytics` rather than inlined into
|
|
23
|
-
* the HTML. A user who never opts in never has it in their page source at all.
|
|
27
|
+
* Consent, stored in prefs, off by default and revocable from Settings.
|
|
24
28
|
*
|
|
25
|
-
*
|
|
26
|
-
* submitted, a permission answered, a model switched — and nothing about their
|
|
27
|
-
* content. No prompts, no file paths, no diffs, no transcript. Autocapture is
|
|
28
|
-
* off, so a click is never recorded by the text on the button; session replay
|
|
29
|
-
* is off; and URL properties are stripped, because the URL is where the token
|
|
30
|
-
* is. See `track()` in page.ts for the calls themselves.
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
33
|
-
* The published build's key, or nothing.
|
|
34
|
-
*
|
|
35
|
-
* Read from the environment so that the desktop shell can set it from the file
|
|
36
|
-
* packaging injects, and so that `oharness-web` run from a checkout can be
|
|
37
|
-
* pointed at a project of its own for a day without editing anything.
|
|
38
|
-
*/
|
|
39
|
-
const KEY = process.env["OHARNESS_POSTHOG_KEY"] ?? "";
|
|
40
|
-
/** US cloud unless told otherwise. EU is https://eu.i.posthog.com. */
|
|
41
|
-
const HOST = process.env["OHARNESS_POSTHOG_HOST"] ?? "https://us.i.posthog.com";
|
|
42
|
-
/**
|
|
43
|
-
* DataFast, alongside PostHog and under the same two gates.
|
|
44
|
-
*
|
|
45
|
-
* The site runs both for the reason written in the website's own
|
|
46
|
-
* `lib/analytics.ts`: PostHog answers product questions, DataFast answers "how
|
|
47
|
-
* many, from where" without anyone learning a query language. Here it answers
|
|
48
|
-
* one question PostHog also answers — how many launches, from which country —
|
|
49
|
-
* and it is here so that the desktop app and the site show up on the same
|
|
50
|
-
* dashboard rather than in two places that have to be added up by hand.
|
|
29
|
+
* Two kinds of event, and only the first needs consent:
|
|
51
30
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
31
|
+
* Usage — a panel opened, a prompt submitted, a model switched. Named events
|
|
32
|
+
* with enumerable properties and never their content: no prompts, file
|
|
33
|
+
* paths, diffs or transcripts. Sent only after the user turns it on.
|
|
54
34
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
35
|
+
* Counting — `app_installed` once, `daily_ping` once a day the app runs,
|
|
36
|
+
* `daily_used` once a day it is used. They carry the install id, the version
|
|
37
|
+
* and the platform and nothing else, and they are what makes "how many
|
|
38
|
+
* people use this" a number rather than a guess from the few who opted in.
|
|
39
|
+
* The privacy policy names them.
|
|
60
40
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* desktop window is handed a URL with a query string on it.
|
|
66
|
-
*
|
|
67
|
-
* Taking the token out of the browser's address bar was tried and does not
|
|
68
|
-
* work: the document request on a reload is the one the page cannot influence,
|
|
69
|
-
* so a scrubbed URL means F5 returns 401. The desktop shell gets around that
|
|
70
|
-
* with `onBeforeSendHeaders`; a browser tab has no equivalent. Anyone
|
|
71
|
-
* revisiting this should read that as the constraint it is — browser-mode
|
|
72
|
-
* DataFast needs the credential to stop travelling in the URL, and that is an
|
|
73
|
-
* authentication change, not an analytics one.
|
|
41
|
+
* Both are filed under one install id: a random UUID in the user directory,
|
|
42
|
+
* created the first time an official build runs, joined to nothing — not the
|
|
43
|
+
* account, not the machine. Desktop and `oharness-web` on the same machine
|
|
44
|
+
* share it, because they share that directory.
|
|
74
45
|
*/
|
|
75
|
-
const
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
46
|
+
const KEY = process.env["OHARNESS_POSTHOG_KEY"] ?? "";
|
|
47
|
+
/** US cloud unless told otherwise. EU is https://eu.i.posthog.com. */
|
|
48
|
+
const HOST = (process.env["OHARNESS_POSTHOG_HOST"] ?? "https://us.i.posthog.com").replace(/\/+$/, "");
|
|
49
|
+
/** Set by the desktop shell from the app bundle; a checkout reports "dev". */
|
|
50
|
+
const VERSION = process.env["OHARNESS_APP_VERSION"] ?? "dev";
|
|
51
|
+
/** Which frontend is serving: the desktop shell says so, anything else is the browser one. */
|
|
52
|
+
const SHELL = process.env["OHARNESS_SHELL"] ?? "web";
|
|
53
|
+
/** What `GET /analytics` and `POST /analytics` answer. */
|
|
54
|
+
export async function analyticsState() {
|
|
55
|
+
const available = KEY !== "";
|
|
56
|
+
return { enabled: available && (await consented()), available };
|
|
57
|
+
}
|
|
58
|
+
let consent;
|
|
59
|
+
async function consented() {
|
|
60
|
+
consent ??= (await readPrefs()).analytics === true;
|
|
61
|
+
return consent;
|
|
62
|
+
}
|
|
63
|
+
/** Called after Settings writes the pref, so the next event sees it. */
|
|
64
|
+
export function setConsent(value) {
|
|
65
|
+
consent = value;
|
|
66
|
+
}
|
|
67
|
+
const FILE = () => userPath("telemetry.json");
|
|
68
|
+
let identity;
|
|
69
|
+
function loadIdentity() {
|
|
70
|
+
identity ??= (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = parseJson(await readFile(FILE(), "utf8"));
|
|
73
|
+
if (parsed.ok && typeof parsed.value?.installId === "string" && parsed.value.installId) {
|
|
74
|
+
return parsed.value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// No file yet: this is the first run of an official build.
|
|
79
|
+
}
|
|
80
|
+
const created = { installId: randomUUID() };
|
|
81
|
+
await save(created);
|
|
82
|
+
// A user directory that already has prefs in it is a copy that was here
|
|
83
|
+
// before counting existed, updating into it — not a new install.
|
|
84
|
+
capture("app_installed", { existing: await exists(userPath("prefs.json")) }, created.installId);
|
|
85
|
+
return created;
|
|
86
|
+
})();
|
|
87
|
+
return identity;
|
|
88
|
+
}
|
|
89
|
+
let writing = Promise.resolve();
|
|
90
|
+
function save(telemetry) {
|
|
91
|
+
writing = writing.then(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await mkdir(path.dirname(FILE()), { recursive: true });
|
|
94
|
+
await writeFile(FILE(), JSON.stringify(telemetry, null, 2) + "\n");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// A read-only home directory costs the count, never the app.
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return writing;
|
|
101
|
+
}
|
|
102
|
+
async function exists(file) {
|
|
103
|
+
try {
|
|
104
|
+
await access(file);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
112
|
+
// --- counting ---------------------------------------------------------------
|
|
113
|
+
/** Once a day the app runs. Safe to call as often as convenient. */
|
|
114
|
+
export async function heartbeat() {
|
|
115
|
+
if (!KEY)
|
|
116
|
+
return;
|
|
117
|
+
const telemetry = await loadIdentity();
|
|
118
|
+
const day = today();
|
|
119
|
+
if (telemetry.pinged === day)
|
|
120
|
+
return;
|
|
121
|
+
telemetry.pinged = day;
|
|
122
|
+
await save(telemetry);
|
|
123
|
+
capture("daily_ping", {}, telemetry.installId);
|
|
124
|
+
}
|
|
125
|
+
/** Once a day the app is used — a prompt sent, not merely a window left open. */
|
|
126
|
+
export async function markUsed() {
|
|
127
|
+
if (!KEY)
|
|
128
|
+
return;
|
|
129
|
+
const telemetry = await loadIdentity();
|
|
130
|
+
const day = today();
|
|
131
|
+
if (telemetry.used === day)
|
|
132
|
+
return;
|
|
133
|
+
telemetry.used = day;
|
|
134
|
+
await save(telemetry);
|
|
135
|
+
capture("daily_used", {}, telemetry.installId);
|
|
136
|
+
}
|
|
137
|
+
/** Hourly, so a window left open across midnight still counts the new day. */
|
|
138
|
+
export function startHeartbeat() {
|
|
139
|
+
if (!KEY)
|
|
140
|
+
return () => { };
|
|
141
|
+
void heartbeat();
|
|
142
|
+
const timer = setInterval(() => void heartbeat(), 60 * 60 * 1000);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
return () => clearInterval(timer);
|
|
145
|
+
}
|
|
146
|
+
// --- usage ------------------------------------------------------------------
|
|
147
|
+
const EVENT = /^[a-z][a-z_]{0,39}$/;
|
|
148
|
+
const PROPERTY = /^[a-z][a-z_]{0,31}$/;
|
|
141
149
|
/**
|
|
142
|
-
* What
|
|
150
|
+
* What the page may send, reduced to what it is allowed to send.
|
|
143
151
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* that
|
|
152
|
+
* The page's calls already pass only enumerable values, and this is the check
|
|
153
|
+
* that keeps it so: an event name, at most ten properties, each a boolean, a
|
|
154
|
+
* number or a short string. Anything that could be text someone typed — a long
|
|
155
|
+
* string, one carrying the page's token — is dropped here rather than trusted.
|
|
147
156
|
*/
|
|
148
|
-
export
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
export function cleanEvent(body) {
|
|
158
|
+
const event = body["event"];
|
|
159
|
+
if (typeof event !== "string" || !EVENT.test(event))
|
|
160
|
+
return null;
|
|
161
|
+
const props = {};
|
|
162
|
+
const raw = body["props"];
|
|
163
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
164
|
+
for (const [key, value] of Object.entries(raw).slice(0, 10)) {
|
|
165
|
+
if (!PROPERTY.test(key))
|
|
166
|
+
continue;
|
|
167
|
+
if (typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) {
|
|
168
|
+
props[key] = value;
|
|
169
|
+
}
|
|
170
|
+
else if (typeof value === "string" && value.length <= 80 && !value.includes("token=")) {
|
|
171
|
+
props[key] = value;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { event, props };
|
|
176
|
+
}
|
|
177
|
+
/** A usage event. Dropped unless this build has a key and the user opted in. */
|
|
178
|
+
export async function track(event, props = {}) {
|
|
179
|
+
if (!KEY || !(await consented()))
|
|
180
|
+
return;
|
|
181
|
+
const telemetry = await loadIdentity();
|
|
182
|
+
capture(event, props, telemetry.installId);
|
|
183
|
+
}
|
|
184
|
+
// --- sending ----------------------------------------------------------------
|
|
185
|
+
const queue = [];
|
|
186
|
+
let flushTimer;
|
|
187
|
+
function capture(event, props, distinctId) {
|
|
188
|
+
// Bounded: a machine that is offline all week must not grow this all week.
|
|
189
|
+
if (queue.length >= 500)
|
|
190
|
+
return;
|
|
191
|
+
queue.push({
|
|
192
|
+
event,
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
properties: {
|
|
195
|
+
...props,
|
|
196
|
+
distinct_id: distinctId,
|
|
197
|
+
app_version: VERSION,
|
|
198
|
+
platform: process.platform,
|
|
199
|
+
arch: process.arch,
|
|
200
|
+
shell: SHELL,
|
|
201
|
+
$lib: "oharness-app",
|
|
202
|
+
// Counting installs, not people: no profile is ever created.
|
|
203
|
+
$process_person_profile: false,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
flushTimer ??= setTimeout(() => void flushAnalytics(), 10_000);
|
|
207
|
+
flushTimer.unref?.();
|
|
208
|
+
}
|
|
209
|
+
/** Send what is queued. Called on a timer, and on close so a quit loses nothing. */
|
|
210
|
+
export async function flushAnalytics() {
|
|
211
|
+
if (flushTimer)
|
|
212
|
+
clearTimeout(flushTimer);
|
|
213
|
+
flushTimer = undefined;
|
|
214
|
+
if (!KEY || !queue.length)
|
|
215
|
+
return;
|
|
216
|
+
const batch = queue.splice(0);
|
|
217
|
+
try {
|
|
218
|
+
await fetch(`${HOST}/batch/`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "content-type": "application/json" },
|
|
221
|
+
body: JSON.stringify({ api_key: KEY, batch }),
|
|
222
|
+
signal: AbortSignal.timeout(5000),
|
|
223
|
+
});
|
|
161
224
|
}
|
|
162
|
-
|
|
163
|
-
|
|
225
|
+
catch {
|
|
226
|
+
// Offline, blocked, or PostHog is down. Analytics are never something a
|
|
227
|
+
// person notices while trying to work, so there is no retry and no error.
|
|
164
228
|
}
|
|
165
|
-
return state;
|
|
166
229
|
}
|
|
230
|
+
// --- the page's half ----------------------------------------------------------
|
|
167
231
|
/**
|
|
168
232
|
* The queue, for the document head.
|
|
169
233
|
*
|
|
170
234
|
* Boot fires events before anything could have decided whether to send them,
|
|
171
|
-
* so they wait here. Nothing in this stub touches the network
|
|
172
|
-
* so that `track()` reads the same whether analytics end up on or off, and so
|
|
173
|
-
* that no caller has to remember which branch it is writing in.
|
|
235
|
+
* so they wait here. Nothing in this stub touches the network.
|
|
174
236
|
*/
|
|
175
237
|
export const ANALYTICS_STUB = `<script>
|
|
176
238
|
window.__ANALYTICS_QUEUE = [];
|
|
177
239
|
window.__TRACK = function (event, props) {
|
|
178
|
-
// Bounded: a queue nothing will ever drain is a leak in a page left open all
|
|
179
|
-
// day, and the events worth having are the early ones anyway.
|
|
180
240
|
if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);
|
|
181
241
|
};
|
|
182
242
|
</script>`;
|
|
183
243
|
/**
|
|
184
|
-
*
|
|
244
|
+
* Hands events to this server, and only once it has said they are wanted.
|
|
185
245
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* later change that quietly starts shipping the loader to everyone gets
|
|
190
|
-
* caught. It still drains the queue, because `track()` calls are unconditional
|
|
191
|
-
* and a queue nobody empties grows all day.
|
|
246
|
+
* No vendor code and no vendor address: the page talks to its own origin and
|
|
247
|
+
* nothing else, which is the rule page.ts states for everything in it. A build
|
|
248
|
+
* without a key does not even ship the forwarding, only the drain.
|
|
192
249
|
*/
|
|
193
|
-
const
|
|
250
|
+
export const ANALYTICS_CLIENT = KEY === "" ? String.raw `
|
|
194
251
|
function startAnalytics() {
|
|
195
252
|
window.__TRACK = function () {};
|
|
196
253
|
window.__ANALYTICS_QUEUE = [];
|
|
197
254
|
}
|
|
198
|
-
`;
|
|
199
|
-
/**
|
|
200
|
-
* The DataFast tag, or a no-op where this build carries no id.
|
|
201
|
-
*
|
|
202
|
-
* Two conditions, and the second is the load-bearing one. `config.datafast`
|
|
203
|
-
* says this build carries an id and the user has consented; the `href` check
|
|
204
|
-
* says this page's address has no token in it. See the comment on
|
|
205
|
-
* `DATAFAST_ID` for why the second cannot be relaxed — the script reads
|
|
206
|
-
* `location.href` and offers nothing to filter it with.
|
|
207
|
-
*
|
|
208
|
-
* Checked against the live `href` rather than against which shell this is, so
|
|
209
|
-
* that a regression in the token scrub turns this off rather than turning it
|
|
210
|
-
* into a leak. It is the last gate before a third party sees the address, and
|
|
211
|
-
* the only one that cannot be wrong about what the address contains.
|
|
212
|
-
*
|
|
213
|
-
* `data-allow-localhost` because the page is served from 127.0.0.1 and the
|
|
214
|
-
* script drops every event from a loopback host unless told not to. That flag
|
|
215
|
-
* is the only reason this reports at all, and it is also the reason the check
|
|
216
|
-
* above has to hold.
|
|
217
|
-
*/
|
|
218
|
-
const DATAFAST_CLIENT = DATAFAST_ID === "" ? String.raw `
|
|
219
|
-
function startDataFast() {}
|
|
220
|
-
` : String.raw `
|
|
221
|
-
function startDataFast(config) {
|
|
222
|
-
if (!config.datafast) return;
|
|
223
|
-
if (location.href.indexOf("token=") !== -1) return;
|
|
224
|
-
try {
|
|
225
|
-
const script = document.createElement("script");
|
|
226
|
-
script.src = config.datafast.src;
|
|
227
|
-
script.defer = true;
|
|
228
|
-
script.setAttribute("data-website-id", config.datafast.id);
|
|
229
|
-
script.setAttribute("data-domain", config.datafast.domain);
|
|
230
|
-
script.setAttribute("data-allow-localhost", "true");
|
|
231
|
-
document.head.appendChild(script);
|
|
232
|
-
} catch (err) {
|
|
233
|
-
// Analytics that fail must never be something a person notices while
|
|
234
|
-
// trying to work. There is nothing to retry and nothing to report.
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
`;
|
|
238
|
-
/**
|
|
239
|
-
* PostHog, or a no-op where this build carries no key.
|
|
240
|
-
*
|
|
241
|
-
* `array.js` loaded on its own rather than PostHog's inlined snippet: the
|
|
242
|
-
* snippet is a minified blob whose job is to queue calls made before the
|
|
243
|
-
* library arrives, and this does that job in code that can be read — which is
|
|
244
|
-
* the bar for anything that runs in this page.
|
|
245
|
-
*
|
|
246
|
-
* This is the half that drains the queue in every branch, because `track()` is
|
|
247
|
-
* PostHog's alone — DataFast counts launches and has no named events to send.
|
|
248
|
-
*
|
|
249
|
-
* A failure at any point is silence. Analytics that cannot reach their server
|
|
250
|
-
* must never be something a person notices while trying to work.
|
|
251
|
-
*/
|
|
252
|
-
const POSTHOG_CLIENT = KEY === "" ? String.raw `
|
|
253
|
-
function startPostHog() {
|
|
254
|
-
window.__TRACK = function () {};
|
|
255
|
-
window.__ANALYTICS_QUEUE = [];
|
|
256
|
-
}
|
|
257
255
|
` : String.raw `
|
|
258
|
-
function startPostHog(config) {
|
|
259
|
-
if (!config.key) {
|
|
260
|
-
window.__TRACK = function () {};
|
|
261
|
-
window.__ANALYTICS_QUEUE = [];
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const script = document.createElement("script");
|
|
266
|
-
script.src = config.host + "/static/array.js";
|
|
267
|
-
script.async = true;
|
|
268
|
-
script.onload = function () {
|
|
269
|
-
try {
|
|
270
|
-
window.posthog.init(config.key, Object.assign({}, config.options, {
|
|
271
|
-
// The denylist names the properties known to carry this page's address
|
|
272
|
-
// today. This catches the ones a future version of the library adds:
|
|
273
|
-
// whatever the property is called, if its value contains the token
|
|
274
|
-
// then it is the URL, and it does not leave.
|
|
275
|
-
before_send: function (event) {
|
|
276
|
-
const props = event && event.properties;
|
|
277
|
-
for (const key in props) {
|
|
278
|
-
if (typeof props[key] === "string" && props[key].indexOf("token=") !== -1) {
|
|
279
|
-
delete props[key];
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
return event;
|
|
283
|
-
},
|
|
284
|
-
}));
|
|
285
|
-
window.__TRACK = function (event, props) { window.posthog.capture(event, props); };
|
|
286
|
-
for (const queued of window.__ANALYTICS_QUEUE) window.__TRACK(queued[0], queued[1]);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
window.__TRACK = function () {};
|
|
289
|
-
}
|
|
290
|
-
window.__ANALYTICS_QUEUE = [];
|
|
291
|
-
};
|
|
292
|
-
script.onerror = function () {
|
|
293
|
-
window.__TRACK = function () {};
|
|
294
|
-
window.__ANALYTICS_QUEUE = [];
|
|
295
|
-
};
|
|
296
|
-
document.head.appendChild(script);
|
|
297
|
-
}
|
|
298
|
-
`;
|
|
299
|
-
/**
|
|
300
|
-
* The loader, appended to the client script where the token already exists.
|
|
301
|
-
*
|
|
302
|
-
* One request to `/analytics` answers for both vendors, and one consent
|
|
303
|
-
* decides both — so a person who has not opted in has neither vendor's key in
|
|
304
|
-
* their page source, which is the property that made the key server-served in
|
|
305
|
-
* the first place.
|
|
306
|
-
*/
|
|
307
|
-
export const ANALYTICS_CLIENT = KEY === "" && DATAFAST_ID === "" ? NO_ANALYTICS_CLIENT : String.raw `
|
|
308
|
-
${DATAFAST_CLIENT}
|
|
309
|
-
${POSTHOG_CLIENT}
|
|
310
256
|
async function startAnalytics() {
|
|
311
257
|
const config = await getJson("/analytics");
|
|
258
|
+
const queued = window.__ANALYTICS_QUEUE || [];
|
|
259
|
+
window.__ANALYTICS_QUEUE = [];
|
|
312
260
|
if (!config || config.error || !config.enabled) {
|
|
313
|
-
// Off, or this build carries nothing. Drop the queue rather than hold it:
|
|
314
|
-
// the answer will not change without a reload.
|
|
315
261
|
window.__TRACK = function () {};
|
|
316
|
-
window.__ANALYTICS_QUEUE = [];
|
|
317
262
|
return;
|
|
318
263
|
}
|
|
319
|
-
|
|
320
|
-
|
|
264
|
+
window.__TRACK = function (event, props) {
|
|
265
|
+
void api("/track", { event: event, props: props || {} });
|
|
266
|
+
};
|
|
267
|
+
for (const entry of queued) window.__TRACK(entry[0], entry[1]);
|
|
321
268
|
}
|
|
322
269
|
`;
|
|
323
270
|
//# sourceMappingURL=analytics.js.map
|