Skip to content

Commit 2ea44ff

Browse files
authored
Merge branch 'master' into feature/auto-random-theme
2 parents 42766a3 + a3765b9 commit 2ea44ff

File tree

13 files changed

+217
-43
lines changed

13 files changed

+217
-43
lines changed

frontend/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
"not op_mini all",
3232
"not dead"
3333
],
34+
"lint-staged": {
35+
"*.{json,scss,css,html}": [
36+
"prettier --write"
37+
],
38+
"*.{ts,js}": [
39+
"prettier --write",
40+
"oxlint",
41+
"eslint"
42+
]
43+
},
3444
"devDependencies": {
3545
"@fortawesome/fontawesome-free": "5.15.4",
3646
"@monkeytype/eslint-config": "workspace:*",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Plugin } from "vite";
2+
import { readdirSync, readFileSync } from "fs";
3+
import { TextEncoder } from "util";
4+
import { createHash } from "crypto";
5+
6+
const virtualModuleId = "virtual:language-hashes";
7+
const resolvedVirtualModuleId = "\0" + virtualModuleId;
8+
let skip = false;
9+
10+
export function languageHashes(): Plugin {
11+
return {
12+
name: "virtual-language-hashes",
13+
resolveId(id) {
14+
if (id === virtualModuleId) return resolvedVirtualModuleId;
15+
return;
16+
},
17+
load(id) {
18+
if (id === resolvedVirtualModuleId) {
19+
const hashes: Record<string, string> = skip ? {} : getHashes();
20+
return `
21+
export const languageHashes = ${JSON.stringify(hashes)};
22+
`;
23+
}
24+
return;
25+
},
26+
configResolved(resolvedConfig) {
27+
if (resolvedConfig?.define?.["IS_DEVELOPMENT"] === "true") {
28+
skip = true;
29+
console.log("Skipping language hashing in dev environment.");
30+
}
31+
},
32+
};
33+
}
34+
35+
function getHashes(): Record<string, string> {
36+
const start = performance.now();
37+
38+
console.log("\nHashing languages...");
39+
40+
const hashes = Object.fromEntries(
41+
readdirSync("./static/languages").map((file) => {
42+
return [file.slice(0, -5), calcHash(file)];
43+
})
44+
);
45+
46+
const end = performance.now();
47+
48+
console.log(`Creating language hashes took ${Math.round(end - start)} ms`);
49+
50+
return hashes;
51+
}
52+
53+
function calcHash(file: string): string {
54+
const currentLanguage = JSON.stringify(
55+
JSON.parse(readFileSync("./static/languages/" + file).toString()),
56+
null,
57+
0
58+
);
59+
const encoder = new TextEncoder();
60+
const data = encoder.encode(currentLanguage);
61+
return createHash("sha256").update(data).digest("hex");
62+
}
63+
64+
if (import.meta.url.endsWith(process.argv[1] as string)) {
65+
console.log(JSON.stringify(getHashes(), null, 4));
66+
}

frontend/src/ts/controllers/input-controller.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,11 +1182,9 @@ $("#wordsInput").on("keydown", (event) => {
11821182
}
11831183

11841184
const now = performance.now();
1185-
setTimeout(() => {
1186-
const eventCode =
1187-
event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code;
1188-
TestInput.recordKeydownTime(now, eventCode);
1189-
}, 0);
1185+
const eventCode =
1186+
event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code;
1187+
TestInput.recordKeydownTime(now, eventCode);
11901188
});
11911189

11921190
$("#wordsInput").on("keyup", (event) => {
@@ -1213,11 +1211,9 @@ $("#wordsInput").on("keyup", (event) => {
12131211
}
12141212

12151213
const now = performance.now();
1216-
setTimeout(() => {
1217-
const eventCode =
1218-
event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code;
1219-
TestInput.recordKeyupTime(now, eventCode);
1220-
}, 0);
1214+
const eventCode =
1215+
event.code === "" || event.key === "Unidentified" ? "NoCode" : event.code;
1216+
TestInput.recordKeyupTime(now, eventCode);
12211217
});
12221218

12231219
$("#wordsInput").on("keyup", (event) => {

frontend/src/ts/module.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Language } from "@monkeytype/schemas/languages";
2+
3+
declare module "virtual:language-hashes" {
4+
export const languageHashes: Record<Language, string>;
5+
}

frontend/src/ts/sentry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ export async function activateSentry(): Promise<void> {
2626
environment: envConfig.isDevelopment ? "development" : "production",
2727
integrations: [
2828
Sentry.browserTracingIntegration(),
29-
Sentry.replayIntegration({
30-
unmask: ["#notificationCenter"],
31-
block: ["#commandLine .modal .suggestions"],
32-
}),
29+
// Sentry.replayIntegration({
30+
// unmask: ["#notificationCenter"],
31+
// block: ["#commandLine .modal .suggestions"],
32+
// }),
3333
Sentry.thirdPartyErrorFilterIntegration({
3434
filterKeys: ["monkeytype-frontend"],
3535
// Defines how to handle errors that contain third party stack frames.

frontend/src/ts/test/test-input.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,37 @@ export function forceKeyup(now: number): void {
262262
//using mean here because for words mode, the last keypress ends the test.
263263
//if we then force keyup on that last keypress, it will record a duration of 0
264264
//skewing the average and standard deviation
265-
const avg = roundTo2(mean(keypressTimings.duration.array));
266-
const keysOrder = Object.entries(keyDownData);
267-
keysOrder.sort((a, b) => a[1].timestamp - b[1].timestamp);
268-
for (const keyOrder of keysOrder) {
269-
recordKeyupTime(now, keyOrder[0]);
265+
266+
const indexesToRemove = new Set(
267+
Object.values(keyDownData).map((data) => data.index)
268+
);
269+
270+
const keypressDurations = keypressTimings.duration.array.filter(
271+
(_, index) => !indexesToRemove.has(index)
272+
);
273+
if (keypressDurations.length === 0) {
274+
// this means the test ended while all keys were still held - probably safe to ignore
275+
// since this will result in a "too short" test anyway
276+
return;
270277
}
271-
const last = lastElementFromArray(keysOrder)?.[0] as string;
272-
const index = keyDownData[last]?.index;
273-
if (last !== undefined && index !== undefined) {
278+
279+
const avg = roundTo2(mean(keypressDurations));
280+
281+
const orderedKeys = Object.entries(keyDownData).sort(
282+
(a, b) => a[1].timestamp - b[1].timestamp
283+
);
284+
285+
for (const [key, { index }] of orderedKeys) {
274286
keypressTimings.duration.array[index] = avg;
287+
288+
if (key === "NoCode") {
289+
noCodeIndex--;
290+
}
291+
292+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
293+
delete keyDownData[key];
294+
295+
updateOverlap(now);
275296
}
276297
}
277298

@@ -350,6 +371,21 @@ function updateOverlap(now: number): void {
350371
}
351372

352373
export function resetKeypressTimings(): void {
374+
//because keydown triggers before input, we need to grab the first keypress data here and carry it over
375+
376+
//take the key with the largest index
377+
const lastKey = Object.keys(keyDownData).reduce((a, b) => {
378+
const aIndex = keyDownData[a]?.index;
379+
const bIndex = keyDownData[b]?.index;
380+
if (aIndex === undefined) return b;
381+
if (bIndex === undefined) return a;
382+
return aIndex > bIndex ? a : b;
383+
});
384+
385+
//get the data
386+
const lastKeyData = keyDownData[lastKey];
387+
388+
//reset
353389
keypressTimings = {
354390
spacing: {
355391
first: -1,
@@ -366,6 +402,26 @@ export function resetKeypressTimings(): void {
366402
};
367403
keyDownData = {};
368404
noCodeIndex = 0;
405+
406+
//carry over
407+
if (lastKeyData !== undefined) {
408+
keypressTimings = {
409+
spacing: {
410+
first: lastKeyData.timestamp,
411+
last: lastKeyData.timestamp,
412+
array: [],
413+
},
414+
duration: {
415+
array: [0],
416+
},
417+
};
418+
keyDownData[lastKey] = {
419+
timestamp: lastKeyData.timestamp,
420+
// make sure to set it to the first index
421+
index: 0,
422+
};
423+
}
424+
369425
console.debug("Keypress timings reset");
370426
}
371427

@@ -413,14 +469,4 @@ export function restart(): void {
413469
correct: 0,
414470
incorrect: 0,
415471
};
416-
keypressTimings = {
417-
spacing: {
418-
first: -1,
419-
last: -1,
420-
array: [],
421-
},
422-
duration: {
423-
array: [],
424-
},
425-
};
426472
}

frontend/src/ts/test/test-logic.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,6 @@ async function init(): Promise<boolean> {
456456
}
457457

458458
if (!language || language.name !== Config.language) {
459-
UpdateConfig.setLanguage("english");
460459
return await init();
461460
}
462461

@@ -968,7 +967,7 @@ export async function finish(difficultyFailed = false): Promise<void> {
968967
}
969968

970969
// stats
971-
const stats = TestStats.calculateStats();
970+
const stats = TestStats.calculateFinalStats();
972971
if (stats.time % 1 !== 0 && Config.mode !== "time") {
973972
TestStats.setLastSecondNotRound();
974973
}

frontend/src/ts/test/test-stats.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as TestState from "./test-state";
77
import * as Numbers from "@monkeytype/util/numbers";
88
import { CompletedEvent, IncompleteTest } from "@monkeytype/schemas/results";
99
import { isFunboxActiveWithProperty } from "./funbox/list";
10+
import * as CustomText from "./custom-text";
1011

1112
type CharCount = {
1213
spaces: number;
@@ -144,14 +145,17 @@ export function calculateTestSeconds(now?: number): number {
144145
}
145146
}
146147

147-
export function calculateWpmAndRaw(withDecimalPoints?: true): {
148+
export function calculateWpmAndRaw(
149+
withDecimalPoints?: true,
150+
final = false
151+
): {
148152
wpm: number;
149153
raw: number;
150154
} {
151155
const testSeconds = calculateTestSeconds(
152156
TestState.isActive ? performance.now() : end
153157
);
154-
const chars = countChars();
158+
const chars = countChars(final);
155159
const wpm = Numbers.roundTo2(
156160
((chars.correctWordChars + chars.correctSpaces) * (60 / testSeconds)) / 5
157161
);
@@ -280,7 +284,7 @@ function getTargetWords(): string[] {
280284
return targetWords;
281285
}
282286

283-
function countChars(): CharCount {
287+
function countChars(final = false): CharCount {
284288
let correctWordChars = 0;
285289
let correctChars = 0;
286290
let incorrectChars = 0;
@@ -343,7 +347,13 @@ function countChars(): CharCount {
343347
}
344348
correctChars += toAdd.correct;
345349
incorrectChars += toAdd.incorrect;
346-
if (i === inputWords.length - 1) {
350+
351+
const isTimedTest =
352+
Config.mode === "time" ||
353+
(Config.mode === "custom" && CustomText.getLimit().mode === "time");
354+
const shouldCountPartialLastWord = !final || (final && isTimedTest);
355+
356+
if (i === inputWords.length - 1 && shouldCountPartialLastWord) {
347357
//last word - check if it was all correct - add to correct word chars
348358
if (toAdd.incorrect === 0) correctWordChars += toAdd.correct;
349359
} else {
@@ -370,7 +380,7 @@ function countChars(): CharCount {
370380
};
371381
}
372382

373-
export function calculateStats(): Stats {
383+
export function calculateFinalStats(): Stats {
374384
console.debug("Calculating result stats");
375385
let testSeconds = calculateTestSeconds();
376386
console.debug(
@@ -398,8 +408,10 @@ export function calculateStats(): Stats {
398408
testSeconds
399409
);
400410
}
401-
const chars = countChars();
402-
const { wpm, raw } = calculateWpmAndRaw(true);
411+
412+
//todo: this counts chars twice - once here and once in calculateWpmAndRaw
413+
const chars = countChars(true);
414+
const { wpm, raw } = calculateWpmAndRaw(true, true);
403415
const acc = Numbers.roundTo2(calculateAccuracy());
404416
const ret = {
405417
wpm: isNaN(wpm) ? 0 : wpm,

frontend/src/ts/utils/json-data.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { Language, LanguageObject } from "@monkeytype/schemas/languages";
22
import { Challenge } from "@monkeytype/schemas/challenges";
33
import { LayoutObject } from "@monkeytype/schemas/layouts";
4+
import { toHex } from "./strings";
5+
import { languageHashes } from "virtual:language-hashes";
6+
import { isDevEnvironment } from "./misc";
47

58
//pin implementation
69
const fetch = window.fetch;
10+
const cryptoSubtle = window.crypto.subtle;
711

812
/**
913
* Fetches JSON data from the specified URL using the fetch API.
@@ -96,9 +100,23 @@ let currentLanguage: LanguageObject;
96100
export async function getLanguage(lang: Language): Promise<LanguageObject> {
97101
// try {
98102
if (currentLanguage === undefined || currentLanguage.name !== lang) {
99-
currentLanguage = await cachedFetchJson<LanguageObject>(
103+
const loaded = await cachedFetchJson<LanguageObject>(
100104
`/languages/${lang}.json`
101105
);
106+
107+
if (!isDevEnvironment()) {
108+
//check the content to make it less easy to manipulate
109+
const encoder = new TextEncoder();
110+
const data = encoder.encode(JSON.stringify(loaded, null, 0));
111+
const hashBuffer = await cryptoSubtle.digest("SHA-256", data);
112+
const hash = toHex(hashBuffer);
113+
if (hash !== languageHashes[lang]) {
114+
throw new Error(
115+
"Integrity check failed. Try refreshing the page. If this error persists, please contact support."
116+
);
117+
}
118+
}
119+
currentLanguage = loaded;
102120
}
103121
return currentLanguage;
104122
}

frontend/src/ts/utils/strings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,20 @@ export function areCharactersVisuallyEqual(
296296
return false;
297297
}
298298

299+
export function toHex(buffer: ArrayBuffer): string {
300+
// @ts-expect-error modern browsers
301+
if (Uint8Array.prototype.toHex !== undefined) {
302+
// @ts-expect-error modern browsers
303+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
304+
return new Uint8Array(buffer).toHex() as string;
305+
}
306+
const hashArray = Array.from(new Uint8Array(buffer));
307+
const hashHex = hashArray
308+
.map((b) => b.toString(16).padStart(2, "0"))
309+
.join("");
310+
return hashHex;
311+
}
312+
299313
// Export testing utilities for unit tests
300314
export const __testing = {
301315
hasRTLCharacters,

0 commit comments

Comments
 (0)