@kitkat-vn/kitcode 0.2.0-staging.9 → 0.3.0-staging.16
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 +50 -28
- package/bin/kitcode.mjs +65 -5
- package/package.json +2 -2
- package/src/api.mjs +105 -3
- package/src/break-progress.mjs +94 -9
- package/src/claim-bundle.mjs +18 -5
- package/src/companion-window.mjs +9 -3
- package/src/cors.mjs +3 -1
- package/src/cycle-receipt.mjs +97 -0
- package/src/fonts/jetbrains-mono/OFL.txt +93 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-700-normal.woff2 +0 -0
- package/src/hook-prompt.mjs +24 -3
- package/src/integration-spec.mjs +175 -48
- package/src/milestone-record.mjs +214 -0
- package/src/milestones-reached.mjs +168 -64
- package/src/next-break-reminder.mjs +4 -3
- package/src/onboarding-electron.mjs +12 -11
- package/src/onboarding-platform.mjs +5 -2
- package/src/onboarding-window.mjs +94 -63
- package/src/reward.mjs +247 -68
- package/src/runtime.mjs +48 -4
- package/src/tracking-config.mjs +501 -0
- package/src/welcome-words.mjs +115 -0
- package/src/window-page.mjs +16 -2
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {writeFileAtomically} from './atomic-file.mjs';
|
|
5
|
+
import {cycleReceiptKey} from './cycle-receipt.mjs';
|
|
6
|
+
import {CYCLE_RECEIPT_PUBLIC_KEY, DASHBOARD_URL, KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
|
|
7
|
+
import {STORE_DIR} from './store.mjs';
|
|
8
|
+
|
|
9
|
+
// The campaign's targets and its end, from the server (docs/plans/2026-10-22-server-tracking-config.md, THE TRACKER; docs/backend.md,
|
|
10
|
+
// "The tracking config"; docs/reward-rules.md §2). The claim backend serves one signed document at the dashboard's address plus
|
|
11
|
+
// /v1/tracking-config: the six milestone targets in force and when the campaign ends. A tracker of a build that carries the claim
|
|
12
|
+
// backend's public key fetches it about once an hour, checks its signature, and counts by it; so a change of targets reaches every
|
|
13
|
+
// install within the hour, with no new version of KitCode to install. Nothing else fetches it: never the prompt hook, a command or a
|
|
14
|
+
// window (hard rule 7). The repository's own build carries no key, and so never fetches, and counts by the table it bundles (R22.1).
|
|
15
|
+
//
|
|
16
|
+
// <payload>.<signature>
|
|
17
|
+
//
|
|
18
|
+
// `payload` is base64url (no padding) of the UTF-8 JSON {"v":1,"issuedAt":…,"milestones":[{"percent":30,"focusSeconds":…,"equals":…},
|
|
19
|
+
// {"percent":50,…},{"percent":100,…}],"campaignEndsAt":…|null}, those four fields alone; `signature`, base64url of the 64-byte Ed25519
|
|
20
|
+
// signature of the ASCII text `kitcode-tracking-config-1.` followed by `payload` as it stands, with the cycle receipts' key
|
|
21
|
+
// (cycle-receipt.mjs), whose text is `kitcode-cycle-receipt-1.`: a receipt never passes for a config, or a config for a receipt. The
|
|
22
|
+
// contract is packages/claim-contract/tracking-config.mjs, which only tests read; this reader is the CLI's own, held to the
|
|
23
|
+
// contract's fixtures by test/tracking-config.test.mjs, problem for problem, in the contract's order.
|
|
24
|
+
//
|
|
25
|
+
// What the tracker took is kept in memory, and in ~/.kitcode/tracking-config.json, its last good copy: written whole, 0600, never
|
|
26
|
+
// state.json (hard rule 4), and never deleted or written over by anything but a later good document. The targets in force are,
|
|
27
|
+
// in order: the copy the tracker took this run, the last good copy on disk, then the table this KitCode bundles
|
|
28
|
+
// (integration-spec.mjs). The prompt hook and the commands read the copy on disk and check its shape and bounds, never its
|
|
29
|
+
// signature, which only the tracker checks, as it fetched it; and `targets.source` says which of the three is in force, so that a
|
|
30
|
+
// fallback never looks like the healthy path.
|
|
31
|
+
|
|
32
|
+
export const TRACKING_CONFIG_CONTEXT = 'kitcode-tracking-config-1.';
|
|
33
|
+
export const TRACKING_CONFIG_PATH = path.join(STORE_DIR, 'tracking-config.json');
|
|
34
|
+
export const TRACKING_CONFIG_DISPLAY_PATH = '~/.kitcode/tracking-config.json';
|
|
35
|
+
// The longest document read, in characters, and the most of a body the tracker reads, in bytes: 2 KiB. A document is about 430.
|
|
36
|
+
export const MAX_TRACKING_CONFIG_LENGTH = 2048;
|
|
37
|
+
// The bounds (the operator's decision D2 of 2026-10-22): the percents 30, 50 and 100, in that order; each milestone's focus time
|
|
38
|
+
// 60 to 172,800 s and its = 1 to 100,000; each milestone's targets at least the one before's.
|
|
39
|
+
export const TRACKING_CONFIG_PERCENTS = Object.freeze([30, 50, 100]);
|
|
40
|
+
export const TRACKING_CONFIG_BOUNDS = Object.freeze({
|
|
41
|
+
focusSeconds: Object.freeze({min: 60, max: 172_800}),
|
|
42
|
+
equals: Object.freeze({min: 1, max: 100_000}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// When the tracker fetches: a random moment 5 to 120 s after it starts, so that a fleet started together does not ask at once;
|
|
46
|
+
// then whenever its last try is over an hour old, which it checks every minute; and at once when it finds the computer has slept,
|
|
47
|
+
// a check that came three minutes or more after the one before. 10 s for the whole answer, its body included.
|
|
48
|
+
export const FIRST_FETCH_MS = Object.freeze({min: 5_000, max: 120_000});
|
|
49
|
+
export const FETCH_AGAIN_AFTER_MS = 60 * 60 * 1000;
|
|
50
|
+
export const CHECK_EVERY_MS = 60 * 1000;
|
|
51
|
+
export const WOKE_AFTER_MS = 3 * CHECK_EVERY_MS;
|
|
52
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
53
|
+
|
|
54
|
+
const FILE_MODE = 0o600;
|
|
55
|
+
const PAYLOAD_KEYS = ['v', 'issuedAt', 'milestones', 'campaignEndsAt'];
|
|
56
|
+
const MILESTONE_KEYS = ['percent', 'focusSeconds', 'equals'];
|
|
57
|
+
const DOCUMENT_TEXT = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{86})$/u;
|
|
58
|
+
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
59
|
+
const SILENT_LOG = {info() {}, warn() {}, error() {}};
|
|
60
|
+
|
|
61
|
+
function isObject(value) {
|
|
62
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasExactly(value, keys) {
|
|
66
|
+
return isObject(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isInstantText(value) {
|
|
70
|
+
return typeof value === 'string' && INSTANT.test(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isRealInstant(value) {
|
|
74
|
+
const time = Date.parse(value);
|
|
75
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function within(value, {min, max}) {
|
|
79
|
+
return value >= min && value <= max;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Whether `words`, a payload as JSON.parse reads it, is a tracking config: {ok: true, config: {issuedAt, milestones, campaignEndsAt}},
|
|
83
|
+
// a copy of its words, or {ok: false, problem}, the contract's: 'version', a `v` present and not 1, a later version, refused whole;
|
|
84
|
+
// 'unreadable', not the contract's shape, or an instant that is not a real one; 'bounds', outside the bounds above. Pure. What the
|
|
85
|
+
// tracker holds a signed document to, and what the hook and the commands hold the last good copy's words to.
|
|
86
|
+
export function judgeTrackingConfig(words) {
|
|
87
|
+
if (!isObject(words)) {
|
|
88
|
+
return {ok: false, problem: 'unreadable'};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Object.hasOwn(words, 'v') && words.v !== 1) {
|
|
92
|
+
return {ok: false, problem: 'version'};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const shaped = hasExactly(words, PAYLOAD_KEYS)
|
|
96
|
+
&& isInstantText(words.issuedAt)
|
|
97
|
+
&& (words.campaignEndsAt === null || isInstantText(words.campaignEndsAt))
|
|
98
|
+
&& Array.isArray(words.milestones) && words.milestones.length === TRACKING_CONFIG_PERCENTS.length
|
|
99
|
+
&& words.milestones.every((milestone) => hasExactly(milestone, MILESTONE_KEYS) && MILESTONE_KEYS.every((key) => Number.isInteger(milestone[key])));
|
|
100
|
+
|
|
101
|
+
if (!shaped || !isRealInstant(words.issuedAt) || (words.campaignEndsAt !== null && !isRealInstant(words.campaignEndsAt))) {
|
|
102
|
+
return {ok: false, problem: 'unreadable'};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const inBounds = words.milestones.every((milestone, index) => {
|
|
106
|
+
const before = words.milestones[index - 1];
|
|
107
|
+
|
|
108
|
+
return milestone.percent === TRACKING_CONFIG_PERCENTS[index]
|
|
109
|
+
&& within(milestone.focusSeconds, TRACKING_CONFIG_BOUNDS.focusSeconds)
|
|
110
|
+
&& within(milestone.equals, TRACKING_CONFIG_BOUNDS.equals)
|
|
111
|
+
&& (before === undefined || (milestone.focusSeconds >= before.focusSeconds && milestone.equals >= before.equals));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!inBounds) {
|
|
115
|
+
return {ok: false, problem: 'bounds'};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
config: {
|
|
121
|
+
issuedAt: words.issuedAt,
|
|
122
|
+
milestones: words.milestones.map(({percent, focusSeconds, equals}) => ({percent, focusSeconds, equals})),
|
|
123
|
+
campaignEndsAt: words.campaignEndsAt,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The public key a build carries: the cycle receipts' own (integration-spec.mjs, CYCLE_RECEIPT_PUBLIC_KEY). Null when it is not one.
|
|
129
|
+
export function trackingConfigKey(text = CYCLE_RECEIPT_PUBLIC_KEY) {
|
|
130
|
+
return cycleReceiptKey(text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Reads `text` with `publicKey` (a KeyObject, trackingConfigKey's): {ok: true, config}, or {ok: false, problem}, the first of the
|
|
134
|
+
// contract's, in its order: 'oversized', longer than MAX_TRACKING_CONFIG_LENGTH; 'unreadable', not two base64url parts joined by a
|
|
135
|
+
// dot; 'signature', not signed over the context and the payload with the key's pair, checked over the bytes as they came, before
|
|
136
|
+
// anything in them is read; then judgeTrackingConfig's; and 'stale', an issuedAt no later than `after`, the issuedAt of the last
|
|
137
|
+
// document taken: an older one replayed, or the same one again.
|
|
138
|
+
export function readTrackingConfig(text, publicKey, {after = null} = {}) {
|
|
139
|
+
if (after !== null && !(isInstantText(after) && isRealInstant(after))) {
|
|
140
|
+
throw new TypeError(`after must be an instant, as toISOString writes it, or null: ${String(after)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof text !== 'string') {
|
|
144
|
+
return {ok: false, problem: 'unreadable'};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (text.length > MAX_TRACKING_CONFIG_LENGTH) {
|
|
148
|
+
return {ok: false, problem: 'oversized'};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const parts = DOCUMENT_TEXT.exec(text);
|
|
152
|
+
|
|
153
|
+
if (parts === null) {
|
|
154
|
+
return {ok: false, problem: 'unreadable'};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const [, payload, signature] = parts;
|
|
158
|
+
let signed;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
signed = crypto.verify(null, Buffer.from(`${TRACKING_CONFIG_CONTEXT}${payload}`, 'ascii'), publicKey, Buffer.from(signature, 'base64url'));
|
|
162
|
+
} catch {
|
|
163
|
+
signed = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!signed) {
|
|
167
|
+
return {ok: false, problem: 'signature'};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let words;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
words = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
174
|
+
} catch {
|
|
175
|
+
return {ok: false, problem: 'unreadable'};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const judged = judgeTrackingConfig(words);
|
|
179
|
+
|
|
180
|
+
if (!judged.ok) {
|
|
181
|
+
return judged;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (after !== null && Date.parse(judged.config.issuedAt) <= Date.parse(after)) {
|
|
185
|
+
return {ok: false, problem: 'stale'};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return judged;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Where this build fetches: DASHBOARD_URL plus v1/tracking-config, an https URL, or, for this repository's tests, an http one on this
|
|
192
|
+
// machine; and only in a build that carries a key. Null otherwise: such a build never fetches, and never reads a copy either.
|
|
193
|
+
export function trackingConfigUrl({dashboardUrl = DASHBOARD_URL, publicKey = CYCLE_RECEIPT_PUBLIC_KEY} = {}) {
|
|
194
|
+
if (trackingConfigKey(publicKey) === null || typeof dashboardUrl !== 'string') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const url = new URL('v1/tracking-config', dashboardUrl);
|
|
200
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
201
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- The targets in force ----------------------------------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
// The table this KitCode bundles, in the document's words: the milestone table in force (integration-spec.mjs, MILESTONE_TABLE).
|
|
210
|
+
export const BUNDLED_TRACKING_CONFIG = Object.freeze({
|
|
211
|
+
issuedAt: null,
|
|
212
|
+
milestones: Object.freeze([...KITCODE_REWARD_TIERS, ...KITCODE_DISPLAY_MILESTONES]
|
|
213
|
+
.map(({percent, requiredSeconds, requiredEquals}) => Object.freeze({percent, focusSeconds: requiredSeconds, equals: requiredEquals}))),
|
|
214
|
+
campaignEndsAt: null,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// What the tracker took this run: {config, document}. Only the tracker's loop sets it, once a document is taken; nothing else in
|
|
218
|
+
// any process does.
|
|
219
|
+
let held = null;
|
|
220
|
+
// The last good copy as last read, by the file's identity, so that a summary built every second reads the file again only once it
|
|
221
|
+
// has changed.
|
|
222
|
+
let savedRead = {identity: null, copy: null};
|
|
223
|
+
|
|
224
|
+
function fileIdentity(filePath) {
|
|
225
|
+
try {
|
|
226
|
+
const stat = fs.statSync(filePath);
|
|
227
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
228
|
+
} catch {
|
|
229
|
+
return 'missing';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The last good copy on disk, {document, issuedAt, milestones, campaignEndsAt, receivedAt}, its words checked against the shape and
|
|
234
|
+
// the bounds, not its signature; null when there is none, or it cannot be read, or its words are not a tracking config's. Never
|
|
235
|
+
// throws, never writes: a copy that is not one is left as it is, and the bundled table counts.
|
|
236
|
+
export function readSavedTrackingConfig() {
|
|
237
|
+
const identity = fileIdentity(TRACKING_CONFIG_PATH);
|
|
238
|
+
|
|
239
|
+
if (identity === savedRead.identity) {
|
|
240
|
+
return savedRead.copy;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let copy = null;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const stored = JSON.parse(fs.readFileSync(TRACKING_CONFIG_PATH, 'utf8'));
|
|
247
|
+
const judged = isObject(stored)
|
|
248
|
+
? judgeTrackingConfig({v: 1, issuedAt: stored.issuedAt, milestones: stored.milestones, campaignEndsAt: stored.campaignEndsAt})
|
|
249
|
+
: {ok: false};
|
|
250
|
+
|
|
251
|
+
if (judged.ok) {
|
|
252
|
+
copy = {
|
|
253
|
+
document: typeof stored.document === 'string' ? stored.document : null,
|
|
254
|
+
...judged.config,
|
|
255
|
+
receivedAt: typeof stored.receivedAt === 'string' ? stored.receivedAt : null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
copy = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
savedRead = {identity, copy};
|
|
263
|
+
return copy;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Writes the last good copy: the document as it came, its words, and when this tracker took it. Whole, 0600; throws what the write
|
|
267
|
+
// throws, and the copy before stays as it was.
|
|
268
|
+
export function saveTrackingConfig({config, document}, receivedAt = new Date()) {
|
|
269
|
+
fs.mkdirSync(STORE_DIR, {recursive: true});
|
|
270
|
+
const copy = {document, issuedAt: config.issuedAt, milestones: config.milestones, campaignEndsAt: config.campaignEndsAt, receivedAt: receivedAt.toISOString()};
|
|
271
|
+
writeFileAtomically(TRACKING_CONFIG_PATH, `${JSON.stringify(copy, null, 2)}\n`, {mode: FILE_MODE});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Whether this build fetches, worked out once: the key and the address are the build's own, and never change while it runs.
|
|
275
|
+
let fetches;
|
|
276
|
+
|
|
277
|
+
function buildFetches() {
|
|
278
|
+
fetches ??= trackingConfigUrl() !== null;
|
|
279
|
+
return fetches;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The targets and the end in force now: {source, issuedAt, milestones, campaignEndsAt}, `source` 'server' for the document this
|
|
283
|
+
// tracker took this run, 'saved' for the last good copy, 'bundled' for the table this KitCode bundles; `milestones` in the
|
|
284
|
+
// document's words, {percent, focusSeconds, equals}. A build that never fetches counts by the bundled table alone.
|
|
285
|
+
export function trackingConfigInForce() {
|
|
286
|
+
if (!buildFetches()) {
|
|
287
|
+
return {source: 'bundled', ...BUNDLED_TRACKING_CONFIG};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (held !== null) {
|
|
291
|
+
return {source: 'server', ...held.config};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const saved = readSavedTrackingConfig();
|
|
295
|
+
|
|
296
|
+
if (saved !== null) {
|
|
297
|
+
return {source: 'saved', issuedAt: saved.issuedAt, milestones: saved.milestones, campaignEndsAt: saved.campaignEndsAt};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {source: 'bundled', ...BUNDLED_TRACKING_CONFIG};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Whether the campaign has ended by `now`, the user's clock: an end set, and reached.
|
|
304
|
+
export function campaignEnded(config, now = Date.now()) {
|
|
305
|
+
return typeof config?.campaignEndsAt === 'string' && Date.parse(config.campaignEndsAt) <= now;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// For this repository's tests alone: forgets what this process took, as a tracker that has just started.
|
|
309
|
+
export function forgetHeldTrackingConfig() {
|
|
310
|
+
held = null;
|
|
311
|
+
savedRead = {identity: null, copy: null};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- The fetch ---------------------------------------------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
// The body, at most `limit` bytes, or null past it: a length declared above it is refused before a byte is read, and a body
|
|
317
|
+
// that grows past it is cancelled as it does.
|
|
318
|
+
async function readLimited(response, limit) {
|
|
319
|
+
const declared = Number(response.headers.get('content-length'));
|
|
320
|
+
|
|
321
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
322
|
+
await response.body?.cancel().catch(() => {});
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!response.body) {
|
|
327
|
+
return '';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const reader = response.body.getReader();
|
|
331
|
+
const chunks = [];
|
|
332
|
+
let size = 0;
|
|
333
|
+
|
|
334
|
+
for (;;) {
|
|
335
|
+
const {done, value} = await reader.read();
|
|
336
|
+
|
|
337
|
+
if (done) {
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
size += value.byteLength;
|
|
342
|
+
|
|
343
|
+
if (size > limit) {
|
|
344
|
+
await reader.cancel().catch(() => {});
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
chunks.push(value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// One try: one GET of `url`, with no redirect followed, no cookie, and nothing of this install, the user or this KitCode's version
|
|
355
|
+
// in it; `timeoutMs` for the whole answer. The document is judged against what this tracker holds, `current`, {config, document}
|
|
356
|
+
// or null. Returns {outcome: 'taken', config, document}; {outcome: 'unchanged'}, the very document `current` holds, fetched again;
|
|
357
|
+
// {outcome: 'refused', problem, status?}, an answer that is not a document to take, for the first of: 'status', not 200 (a
|
|
358
|
+
// redirect among them); 'oversized', a body over 2 KiB; the reader's problems; or {outcome: 'failed', problem}: 'timeout', or
|
|
359
|
+
// 'unreachable'. Never throws.
|
|
360
|
+
export async function fetchTrackingConfig({url, publicKey, current = null, fetchImpl = globalThis.fetch, timeoutMs = FETCH_TIMEOUT_MS, signal} = {}) {
|
|
361
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
362
|
+
const abort = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const response = await fetchImpl(url, {method: 'GET', redirect: 'manual', credentials: 'omit', signal: abort});
|
|
366
|
+
|
|
367
|
+
if (response.status !== 200) {
|
|
368
|
+
await response.body?.cancel().catch(() => {});
|
|
369
|
+
return {outcome: 'refused', problem: 'status', status: response.status};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const text = await readLimited(response, MAX_TRACKING_CONFIG_LENGTH);
|
|
373
|
+
|
|
374
|
+
if (text === null) {
|
|
375
|
+
return {outcome: 'refused', problem: 'oversized'};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (current !== null && text === current.document) {
|
|
379
|
+
return {outcome: 'unchanged'};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const read = readTrackingConfig(text, publicKey, {after: current?.config.issuedAt ?? null});
|
|
383
|
+
return read.ok ? {outcome: 'taken', config: read.config, document: text} : {outcome: 'refused', problem: read.problem};
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return {outcome: 'failed', problem: deadline.aborted || error?.name === 'TimeoutError' ? 'timeout' : 'unreachable'};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// What a try did, for tracker.log: fixed words, the address and the instants alone.
|
|
390
|
+
function describeTry(result, url, inForce) {
|
|
391
|
+
const kept = `KitCode keeps the targets it had (${inForce.source}${inForce.issuedAt ? `, issued ${inForce.issuedAt}` : ''})`;
|
|
392
|
+
|
|
393
|
+
switch (result.outcome) {
|
|
394
|
+
case 'taken':
|
|
395
|
+
return `took the campaign's targets issued ${result.config.issuedAt} from ${url}${result.config.campaignEndsAt ? `, the campaign ending ${result.config.campaignEndsAt}` : ', no end set'}.`;
|
|
396
|
+
case 'unchanged':
|
|
397
|
+
return `the campaign's targets from ${url} are unchanged (issued ${inForce.issuedAt}).`;
|
|
398
|
+
case 'refused':
|
|
399
|
+
return `refused the answer from ${url} (${result.problem}${result.status === undefined ? '' : ` ${result.status}`}); ${kept}.`;
|
|
400
|
+
default:
|
|
401
|
+
return `could not fetch the campaign's targets from ${url} (${result.problem}); ${kept}.`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// While the tracker runs (bin/kitcode.mjs, runDaemon): the fetches, as above, each try alone, and none of them able to stop, block or
|
|
406
|
+
// slow the tracker; every failure stays here, in tracker.log. A document taken is saved, then held: the tracker counts by it at once.
|
|
407
|
+
// In a build that never fetches it does nothing. `now`, the wall clock, which tells a sleep from a minute; `random`, for the first
|
|
408
|
+
// fetch's moment. Returns {stop, tryNow}: `stop` ends the loop and any try under way; `tryNow`, a try at once, for tests.
|
|
409
|
+
export function startTrackingConfigLoop({log = SILENT_LOG, fetchImpl = globalThis.fetch, now = () => Date.now(), random = Math.random, url = trackingConfigUrl(), publicKey = trackingConfigKey(), timeoutMs = FETCH_TIMEOUT_MS} = {}) {
|
|
410
|
+
if (url === null || publicKey === null) {
|
|
411
|
+
return {stop: async () => {}, tryNow: async () => ({outcome: 'disabled'})};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
let lastTry = null;
|
|
416
|
+
let lastCheck = now();
|
|
417
|
+
let trying = null;
|
|
418
|
+
let stopped = false;
|
|
419
|
+
|
|
420
|
+
// The copy this try is judged against: what this run took, or the last good copy, whose issuedAt a document must be later than.
|
|
421
|
+
const current = () => {
|
|
422
|
+
if (held !== null) {
|
|
423
|
+
return held;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const saved = readSavedTrackingConfig();
|
|
427
|
+
return saved === null ? null : {config: {issuedAt: saved.issuedAt, milestones: saved.milestones, campaignEndsAt: saved.campaignEndsAt}, document: saved.document};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const attempt = async () => {
|
|
431
|
+
const before = current();
|
|
432
|
+
const result = await fetchTrackingConfig({url, publicKey, current: before, fetchImpl, timeoutMs, signal: controller.signal});
|
|
433
|
+
|
|
434
|
+
if (result.outcome === 'taken' || result.outcome === 'unchanged') {
|
|
435
|
+
const taken = result.outcome === 'taken' ? {config: result.config, document: result.document} : before;
|
|
436
|
+
const saved = readSavedTrackingConfig();
|
|
437
|
+
|
|
438
|
+
// Saved first, unless the copy on disk is this one already; held whatever the save does, so that the tracker counts by what
|
|
439
|
+
// it checked, and a save that failed is tried again at the next try.
|
|
440
|
+
if (saved === null || saved.document !== taken.document) {
|
|
441
|
+
try {
|
|
442
|
+
saveTrackingConfig(taken, new Date(now()));
|
|
443
|
+
} catch (error) {
|
|
444
|
+
log.warn(`KitCode could not save the campaign's targets to ${TRACKING_CONFIG_DISPLAY_PATH} (${error?.code ?? error?.name ?? 'error'}); it counts by them until it stops, and tries again at its next fetch.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
held = taken;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
(result.outcome === 'taken' ? log.info : result.outcome === 'unchanged' ? () => {} : log.warn)(describeTry(result, url.href, trackingConfigInForce()));
|
|
452
|
+
return result;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const tryNow = () => {
|
|
456
|
+
if (trying !== null) {
|
|
457
|
+
return trying;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
lastTry = now();
|
|
461
|
+
trying = attempt().catch(() => ({outcome: 'failed', problem: 'error'})).finally(() => {
|
|
462
|
+
trying = null;
|
|
463
|
+
});
|
|
464
|
+
return trying;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const first = setTimeout(() => {
|
|
468
|
+
if (!stopped && lastTry === null) {
|
|
469
|
+
tryNow();
|
|
470
|
+
}
|
|
471
|
+
}, FIRST_FETCH_MS.min + Math.floor(random() * (FIRST_FETCH_MS.max - FIRST_FETCH_MS.min + 1)));
|
|
472
|
+
|
|
473
|
+
const check = setInterval(() => {
|
|
474
|
+
const at = now();
|
|
475
|
+
const woke = at - lastCheck >= WOKE_AFTER_MS;
|
|
476
|
+
lastCheck = at;
|
|
477
|
+
|
|
478
|
+
if (stopped || trying !== null) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Before the first try, only a wake brings it forward. After it: a try over an hour old, a wake, or a clock moved back past it.
|
|
483
|
+
if (lastTry === null ? woke : woke || at - lastTry > FETCH_AGAIN_AFTER_MS || at < lastTry) {
|
|
484
|
+
tryNow();
|
|
485
|
+
}
|
|
486
|
+
}, CHECK_EVERY_MS);
|
|
487
|
+
|
|
488
|
+
first.unref?.();
|
|
489
|
+
check.unref?.();
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
tryNow,
|
|
493
|
+
async stop() {
|
|
494
|
+
stopped = true;
|
|
495
|
+
clearTimeout(first);
|
|
496
|
+
clearInterval(check);
|
|
497
|
+
controller.abort();
|
|
498
|
+
await trying?.catch(() => {});
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Every word Welcome shows, in one place: the client's Vietnamese, word for word as the deck has it, and the operator's
|
|
2
|
+
// approved drafts where the deck does not reach (docs/plans/2026-10-22-vietnamese-setup-and-welcome-strings.md; each line
|
|
3
|
+
// names its row there, a number for the deck's, an id for a draft's). "just make sure the translation stay truthful to the
|
|
4
|
+
// deck": the deck's own spelling stays, "Kitcode" in row 56 included. A "\n" is the deck's line break; "{N}" is a count and
|
|
5
|
+
// "{name}" a folder's name, which the page fills in. Everything here is NFC, and every character is in the fonts Welcome
|
|
6
|
+
// embeds (test/welcome-words.test.mjs). Nothing here may say a reward was claimed, saved or confirmed (hard rule 8): "Đã lưu
|
|
7
|
+
// project" is about projects.
|
|
8
|
+
//
|
|
9
|
+
// The page shows some of these in capitals, as the English was: the rail, the + THÊM PROJECT button (typed so by
|
|
10
|
+
// onboarding-window.mjs), and the section titles and the hint (by its stylesheet).
|
|
11
|
+
|
|
12
|
+
export const WELCOME_WORDS = {
|
|
13
|
+
// The page's own name, which the OS lists the window by (X8a), and the native window's, before the page loads (X8b).
|
|
14
|
+
title: 'Thiết lập KitCode',
|
|
15
|
+
windowTitle: 'Chào mừng bạn đến với KitCode',
|
|
16
|
+
|
|
17
|
+
// The title bar (src/onboarding-platform.mjs): its tooltip (X7e), and the close dot's tooltip and name (X7g, X7f).
|
|
18
|
+
chrome: {
|
|
19
|
+
drag: 'Kéo để di chuyển cửa sổ',
|
|
20
|
+
close: 'Đóng',
|
|
21
|
+
closeName: 'Đóng cửa sổ thiết lập',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// The rail's steps 01 and 02 (rows 54, 55).
|
|
25
|
+
rail: {
|
|
26
|
+
tracking: 'Thiết lập',
|
|
27
|
+
projects: 'Chọn project',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Step 01: the question (row 56), its radio group's name (X7c, row 56 again), and the answers (rows 57, 58), between "[ ]".
|
|
31
|
+
tracking: {
|
|
32
|
+
question: 'Bắt đầu tracking sau khi thiết lập Kitcode',
|
|
33
|
+
groupName: 'Bắt đầu tracking sau khi thiết lập Kitcode',
|
|
34
|
+
yes: 'CÓ',
|
|
35
|
+
no: 'KHÔNG',
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Step 02: the heading (row 59), the list's name (X7d), the button after "+ " (row 60), the hint beside it (row 61), the
|
|
39
|
+
// empty list (X4), and each project's remove button, its tooltip (X7a) and its name (X7b).
|
|
40
|
+
projects: {
|
|
41
|
+
heading: 'Chọn một hoặc nhiều folder local',
|
|
42
|
+
listName: 'Danh sách project của KitCode',
|
|
43
|
+
add: 'Thêm project',
|
|
44
|
+
hint: 'Các tệp hiện có sẽ được lấy làm mốc để theo dõi thay đổi.\nMã nguồn và mọi thay đổi đều chỉ được lưu trên máy bạn.',
|
|
45
|
+
empty: 'Chưa thêm project nào',
|
|
46
|
+
removeTitle: 'Gỡ project',
|
|
47
|
+
removeName: 'Gỡ {name}',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// The main button: between "+ " and " ->" (row 62); while saving (N18); saved (row 72); after a failed save (X5a).
|
|
51
|
+
buttons: {
|
|
52
|
+
save: 'ĐÃ XONG, MỞ KITCODE ĐỂ TÍNH CÔNG',
|
|
53
|
+
saving: 'Đang lưu…',
|
|
54
|
+
saved: 'Thiết lập hoàn tất.',
|
|
55
|
+
retry: 'Thử lưu lại',
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// The message line, after "/* ".
|
|
59
|
+
messages: {
|
|
60
|
+
ready: 'Chọn một hay nhiều project để tiếp tục', // row 77
|
|
61
|
+
suggested: 'Đã thêm {N} folder project. Kiểm tra và lưu để tiếp tục.', // row 64
|
|
62
|
+
loaded: 'Đã chọn {N} project', // row 70
|
|
63
|
+
stateFailed: 'Không thể tải trạng thái thiết lập trên máy. Hãy khởi động lại quá trình thiết lập và thử lại nhé!', // row 76
|
|
64
|
+
noBridge: 'Không thể kết nối với KitCode setup. Vui lòng đóng cửa sổ này và thử lại.', // row 73
|
|
65
|
+
picking: 'Đang mở trình chọn folder…', // N14
|
|
66
|
+
pickCanceled: 'Đã hủy chọn project', // row 65
|
|
67
|
+
readyToAdd: '{N} project đã sẵn sàng', // row 69
|
|
68
|
+
alreadyAdded: 'Project đã được thêm', // row 63
|
|
69
|
+
pickerFailed: 'Không thể mở trình chọn folder.\nHãy khởi động lại phần thiết lập và thử lại nhé!', // row 66
|
|
70
|
+
removed: 'Đã gỡ project', // row 67
|
|
71
|
+
removedFromSetup: 'Đã gỡ project khỏi phần thiết lập này.', // N15
|
|
72
|
+
removeFailed: 'Không thể gỡ project.', // N16a
|
|
73
|
+
removeFailedRetry: 'Không thể gỡ project. Hãy thử lại nhé!', // N16b
|
|
74
|
+
noProjects: 'Hãy chọn ít nhất một project', // row 68
|
|
75
|
+
saving: 'Đang lưu project và tùy chỉnh…', // row 75
|
|
76
|
+
saveFailed: 'Không thể lưu thiết lập.', // N17
|
|
77
|
+
requestFailed: 'Không thể gửi yêu cầu thiết lập. Yên tâm, lựa chọn của bạn vẫn còn đây.', // X5b
|
|
78
|
+
saved: 'Vậy là xong. KitCode sắp lên sóng!', // row 71
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// A tester build's alone (src/diagnostics.mjs): the rail's step 03 (N19), the step's heading (row 78), what the build sends
|
|
83
|
+
// and never holds (row 79, two paragraphs), and the invited email's label (row 80). Any other build's page holds none of it.
|
|
84
|
+
export const WELCOME_TESTER_WORDS = {
|
|
85
|
+
rail: 'Tìm sự cố',
|
|
86
|
+
heading: 'TÌM SỰ CỐ ĐỂ KHÔNG SAI SỐ',
|
|
87
|
+
text: 'KitCode gửi diagnostic log đến máy chủ, ghi lại các hoạt động và lỗi của KitCode (như lỗi khởi động tracker hoặc lỗi hook). Nhờ vậy, đội ngũ phát triển có thể truy vết sự cố mà không cần hỏi thêm bạn.\n\nDiagnostic log không chứa mã nguồn, tệp, prompt hay tên project của bạn.',
|
|
88
|
+
emailLabel: 'Email nhận lời mời (không bắt buộc): Giúp đội ngũ hỗ trợ tìm đúng diagnostic log của bạn nhanh hơn.',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// What the main process answers (src/onboarding-electron.mjs), which the page shows on its message line as it is.
|
|
92
|
+
export const WELCOME_ERRORS = {
|
|
93
|
+
invalidRequest: 'Yêu cầu thiết lập không hợp lệ.', // X6a, answered only to a sender other than Welcome's page
|
|
94
|
+
noProjects: 'Hãy chọn ít nhất một project', // X6c, row 68's
|
|
95
|
+
registerFailed: 'KitCode không thể thêm một trong các folder bạn vừa chọn.', // X6d
|
|
96
|
+
trackerFailed: 'Đã lưu project, nhưng tracker không khởi động được', // row 74
|
|
97
|
+
projectNotFound: 'Không tìm thấy project.', // X6e
|
|
98
|
+
removeFailed: 'KitCode không thể gỡ project này.', // X6f
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// A tester build's alone: an invited email that is not an address (X6b).
|
|
102
|
+
export const WELCOME_TESTER_ERRORS = {
|
|
103
|
+
invalidEmail: 'Email này có vẻ chưa đúng. Kiểm tra lại hoặc để trống nhé.',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// What the deck keeps English ("ko cần dịch", rows 81-83), and what is never shown (N13): marked lang="en" in the page.
|
|
107
|
+
// The status bar's codes (setup:ready, projects:removed…) are codes, and stay where the script sets them (K7).
|
|
108
|
+
export const WELCOME_ENGLISH = {
|
|
109
|
+
topTag: 'local-first setup', // row 81
|
|
110
|
+
mode: 'NORMAL', // row 82
|
|
111
|
+
shortcuts: '↑↓ move # space select # enter continue', // row 82
|
|
112
|
+
pending: 'PENDING', // row 83
|
|
113
|
+
tracked: 'TRACKED', // row 83
|
|
114
|
+
added: '{N} ADDED', // N13, display: none
|
|
115
|
+
};
|
package/src/window-page.mjs
CHANGED
|
@@ -7,12 +7,26 @@ import fs from 'node:fs';
|
|
|
7
7
|
// 5.3.0, under the SIL Open Font License 1.1 (fonts/<family>/OFL.txt, from github.com/google/fonts).
|
|
8
8
|
// Embedded as data: URLs, so a page asks no server for them, whether it is a file, a data: URL or
|
|
9
9
|
// served by the tracker. A character outside the subset falls back to the next font in the stack.
|
|
10
|
+
// Since 2026-10-22 also JetBrains Mono, Welcome's alone, which speaks Vietnamese: its latin and vietnamese subsets, from
|
|
11
|
+
// @fontsource/jetbrains-mono 5.3.0, each with the unicode-range Google Fonts gives it, so that the two read as one font.
|
|
12
|
+
// Fira Code has no Vietnamese subset (docs/plans/2026-10-22-vietnamese-setup-and-welcome.md, "Fonts"); the Mini keeps it.
|
|
13
|
+
const JETBRAINS_MONO_RANGES = {
|
|
14
|
+
latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
|
|
15
|
+
vietnamese: 'U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB',
|
|
16
|
+
};
|
|
17
|
+
|
|
10
18
|
const FONTS = {
|
|
11
19
|
'vt323': {family: 'VT323', faces: [{weight: 400, file: 'vt323/vt323-latin-400-normal.woff2'}]},
|
|
12
20
|
'fira-code': {
|
|
13
21
|
family: 'Fira Code',
|
|
14
22
|
faces: [400, 500, 700].map((weight) => ({weight, file: `fira-code/fira-code-latin-${weight}-normal.woff2`})),
|
|
15
23
|
},
|
|
24
|
+
'jetbrains-mono': {
|
|
25
|
+
family: 'JetBrains Mono',
|
|
26
|
+
faces: [400, 500, 700].flatMap((weight) => ['vietnamese', 'latin'].map((subset) => (
|
|
27
|
+
{weight, file: `jetbrains-mono/jetbrains-mono-${subset}-${weight}-normal.woff2`, range: JETBRAINS_MONO_RANGES[subset]}
|
|
28
|
+
))),
|
|
29
|
+
},
|
|
16
30
|
};
|
|
17
31
|
|
|
18
32
|
const fontFaceCache = new Map();
|
|
@@ -21,9 +35,9 @@ export function fontFaces(names) {
|
|
|
21
35
|
const key = names.join(',');
|
|
22
36
|
|
|
23
37
|
if (!fontFaceCache.has(key)) {
|
|
24
|
-
fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file}) => {
|
|
38
|
+
fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file, range}) => {
|
|
25
39
|
const data = fs.readFileSync(new URL(`./fonts/${file}`, import.meta.url)).toString('base64');
|
|
26
|
-
return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')}`;
|
|
40
|
+
return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')${range ? `;unicode-range:${range}` : ''}}`;
|
|
27
41
|
})).join('\n'));
|
|
28
42
|
}
|
|
29
43
|
|