@readium/navigator-html-injectables 2.2.4 → 2.3.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.
Files changed (41) hide show
  1. package/dist/ar-DyHX_uy2.js +7 -0
  2. package/dist/da-Dct0PS3E.js +7 -0
  3. package/dist/fr-C5HEel98.js +7 -0
  4. package/dist/index.js +2121 -1352
  5. package/dist/index.umd.cjs +24 -10
  6. package/dist/it-DFOBoXGy.js +7 -0
  7. package/dist/pt_PT-Di3sVjze.js +7 -0
  8. package/dist/sv-BfzAFsVN.js +7 -0
  9. package/package.json +4 -2
  10. package/src/comms/keys.ts +22 -3
  11. package/src/index.ts +2 -1
  12. package/src/modules/ModuleLibrary.ts +10 -4
  13. package/src/modules/Peripherals.ts +458 -13
  14. package/src/modules/snapper/ColumnSnapper.ts +63 -2
  15. package/src/modules/snapper/ScrollSnapper.ts +59 -1
  16. package/src/modules/snapper/WebPubSnapper.ts +57 -3
  17. package/src/protection/BulkCopyProtector.ts +134 -0
  18. package/src/protection/KeyCombinationManager.ts +139 -0
  19. package/src/protection/KeyboardCombinations.ts +60 -0
  20. package/src/protection/PatternAnalyzer.ts +117 -0
  21. package/src/protection/PrintProtector.ts +87 -0
  22. package/src/protection/SelectionAnalyzer.ts +133 -0
  23. package/src/protection/config.ts +33 -0
  24. package/src/protection/index.ts +6 -0
  25. package/types/src/comms/keys.d.ts +3 -2
  26. package/types/src/index.d.ts +1 -0
  27. package/types/src/modules/ModuleLibrary.d.ts +1 -1
  28. package/types/src/modules/Peripherals.d.ts +102 -2
  29. package/types/src/modules/snapper/ColumnSnapper.d.ts +10 -0
  30. package/types/src/modules/snapper/ScrollSnapper.d.ts +13 -0
  31. package/types/src/modules/snapper/WebPubSnapper.d.ts +6 -0
  32. package/types/src/protection/BulkCopyProtector.d.ts +20 -0
  33. package/types/src/protection/KeyCombinationManager.d.ts +33 -0
  34. package/types/src/protection/KeyboardCombinations.d.ts +15 -0
  35. package/types/src/protection/PatternAnalyzer.d.ts +18 -0
  36. package/types/src/protection/PrintProtector.d.ts +20 -0
  37. package/types/src/protection/SelectionAnalyzer.d.ts +20 -0
  38. package/types/src/protection/config.d.ts +7 -0
  39. package/types/src/protection/index.d.ts +6 -0
  40. package/src/types/protection.ts +0 -16
  41. package/types/src/types/protection.d.ts +0 -7
@@ -5,14 +5,27 @@ import { ModuleName } from "../ModuleLibrary";
5
5
  import { Snapper } from "./Snapper";
6
6
  import { rangeFromLocator } from "../../helpers/locator";
7
7
  import { forceWebkitRecalc } from "../../helpers/document";
8
+ import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
9
+ import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
10
+ import { BaseSuspiciousActivityEvent } from "../Peripherals";
8
11
 
9
12
  const SCROLL_SNAPPER_STYLE_ID = "readium-scroll-snapper-style";
10
13
 
14
+ export interface SuspiciousScrollingEvent extends BaseSuspiciousActivityEvent {
15
+ type: "suspicious_scrolling";
16
+ scrollDelta: number;
17
+ scrollDirection: "up" | "down";
18
+ targetElement: { tagName: string } | null;
19
+ }
20
+
11
21
  export class ScrollSnapper extends Snapper {
12
22
  static readonly moduleName: ModuleName = "scroll_snapper";
13
23
  private wnd!: ReadiumWindow;
14
24
  private comms!: Comms;
15
25
  private resizeObserver!: ResizeObserver;
26
+ private patternAnalyzer: PatternAnalyzer | null = null;
27
+ private lastScrollTime: number = 0;
28
+ private isScrollProtectionEnabled = false;
16
29
 
17
30
  private initialScrollHandled = false;
18
31
  private isScrolling = false;
@@ -41,7 +54,7 @@ export class ScrollSnapper extends Snapper {
41
54
  });
42
55
  }
43
56
 
44
- private handleScroll = () => {
57
+ private handleScroll = (_e: Event) => {
45
58
  if (!this.comms.ready) return;
46
59
 
47
60
  // We have to filter scroll from resize events
@@ -68,6 +81,31 @@ export class ScrollSnapper extends Snapper {
68
81
  const deltaY = currentScrollTop - this.lastScrollTop;
69
82
  this.lastScrollTop = currentScrollTop;
70
83
 
84
+ if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
85
+ const now = Date.now();
86
+ const timeDelta = now - (this.lastScrollTime || now);
87
+ if (this.patternAnalyzer) {
88
+ const isSuspicious = this.patternAnalyzer.analyze(
89
+ deltaY > 0 ? "down" : "up",
90
+ Math.abs(deltaY),
91
+ timeDelta
92
+ );
93
+ if (isSuspicious) {
94
+ const target = _e.target && "tagName" in _e.target ?
95
+ { tagName: (_e.target as Element).tagName } : null;
96
+
97
+ this.comms?.send("content_protection", {
98
+ type: "suspicious_scrolling",
99
+ timestamp: Date.now(),
100
+ scrollDelta: deltaY,
101
+ scrollDirection: deltaY > 0 ? "down" : "up",
102
+ targetElement: target
103
+ } as SuspiciousScrollingEvent);
104
+ }
105
+ }
106
+ this.lastScrollTime = now;
107
+ }
108
+
71
109
  this.comms.send("scroll", deltaY);
72
110
 
73
111
  this.isScrolling = false;
@@ -75,6 +113,14 @@ export class ScrollSnapper extends Snapper {
75
113
  }
76
114
  };
77
115
 
116
+ private enableScrollProtection() {
117
+ if (!this.patternAnalyzer) {
118
+ this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
119
+ this.isScrollProtectionEnabled = true;
120
+ this.comms?.log("Scroll protection enabled");
121
+ }
122
+ }
123
+
78
124
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
79
125
  this.wnd = wnd;
80
126
  this.comms = comms;
@@ -222,6 +268,11 @@ export class ScrollSnapper extends Snapper {
222
268
  ack(true);
223
269
  });
224
270
 
271
+ comms.register("scroll_protection", ScrollSnapper.moduleName, (_, ack) => {
272
+ this.enableScrollProtection();
273
+ ack(true);
274
+ });
275
+
225
276
  comms.register([
226
277
  "go_next",
227
278
  "go_prev",
@@ -247,6 +298,13 @@ export class ScrollSnapper extends Snapper {
247
298
  this.resizeObserver.disconnect();
248
299
  if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
249
300
  wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
301
+
302
+ if (this.patternAnalyzer) {
303
+ this.patternAnalyzer.clear();
304
+ this.patternAnalyzer = null;
305
+ this.isScrollProtectionEnabled = false;
306
+ }
307
+
250
308
  comms.log("ScrollSnapper Unmounted");
251
309
  return true;
252
310
  }
@@ -5,6 +5,11 @@ import { ModuleName } from "../ModuleLibrary";
5
5
  import { Snapper } from "./Snapper";
6
6
  import { rangeFromLocator } from "../../helpers/locator";
7
7
  import { forceWebkitRecalc } from "../../helpers/document";
8
+ import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
9
+ import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
10
+ import { SuspiciousScrollingEvent } from "./ScrollSnapper";
11
+
12
+ export type { SuspiciousScrollingEvent };
8
13
 
9
14
  export class WebPubSnapper extends Snapper {
10
15
  static readonly moduleName: ModuleName = "webpub_snapper";
@@ -12,6 +17,9 @@ export class WebPubSnapper extends Snapper {
12
17
  private wnd!: ReadiumWindow;
13
18
  private comms!: Comms;
14
19
  private resizeObserver!: ResizeObserver;
20
+ private patternAnalyzer: PatternAnalyzer | null = null;
21
+ private lastScrollTime: number = 0;
22
+ private isScrollProtectionEnabled = false;
15
23
 
16
24
  private initialScrollHandled = false;
17
25
  private isScrolling = false;
@@ -38,7 +46,7 @@ export class WebPubSnapper extends Snapper {
38
46
  });
39
47
  }
40
48
 
41
- private handleScroll = () => {
49
+ private handleScroll = (_e: Event) => {
42
50
  if (!this.comms.ready) return;
43
51
 
44
52
  // Filter resize events
@@ -63,6 +71,32 @@ export class WebPubSnapper extends Snapper {
63
71
  const deltaY = currentScrollTop - this.lastScrollTop;
64
72
  this.lastScrollTop = currentScrollTop;
65
73
 
74
+ // Record scroll for pattern analysis if protection is enabled
75
+ if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
76
+ const now = Date.now();
77
+ const timeDelta = now - (this.lastScrollTime || now);
78
+ if (this.patternAnalyzer) {
79
+ const isSuspicious = this.patternAnalyzer.analyze(
80
+ deltaY > 0 ? "down" : "up",
81
+ Math.abs(deltaY),
82
+ timeDelta
83
+ );
84
+ if (isSuspicious) {
85
+ const target = _e.target && "tagName" in _e.target ?
86
+ { tagName: (_e.target as Element).tagName } : null;
87
+
88
+ this.comms?.send("content_protection", {
89
+ type: "suspicious_scrolling",
90
+ timestamp: Date.now(),
91
+ scrollDelta: deltaY,
92
+ scrollDirection: deltaY > 0 ? "down" : "up",
93
+ targetElement: target
94
+ } as SuspiciousScrollingEvent);
95
+ }
96
+ }
97
+ this.lastScrollTime = now;
98
+ }
99
+
66
100
  this.comms.send("scroll", deltaY);
67
101
 
68
102
  this.isScrolling = false;
@@ -70,6 +104,14 @@ export class WebPubSnapper extends Snapper {
70
104
  }
71
105
  };
72
106
 
107
+ private enableScrollProtection() {
108
+ if (!this.patternAnalyzer) {
109
+ this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
110
+ this.isScrollProtectionEnabled = true;
111
+ this.comms?.log("Scroll protection enabled");
112
+ }
113
+ }
114
+
73
115
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
74
116
  this.wnd = wnd;
75
117
  this.comms = comms;
@@ -134,7 +176,7 @@ export class WebPubSnapper extends Snapper {
134
176
  });
135
177
  });
136
178
 
137
- this.comms.register("go_id", WebPubSnapper.moduleName, (data, ack) => {
179
+ comms.register("go_id", WebPubSnapper.moduleName, (data, ack) => {
138
180
  const element = wnd.document.getElementById(data as string);
139
181
  if(!element) {
140
182
  ack(false);
@@ -197,6 +239,11 @@ export class WebPubSnapper extends Snapper {
197
239
  ack(true);
198
240
  });
199
241
 
242
+ comms.register("scroll_protection", WebPubSnapper.moduleName, (_, ack) => {
243
+ this.enableScrollProtection();
244
+ ack(true);
245
+ });
246
+
200
247
  comms.register([
201
248
  "go_next",
202
249
  "go_prev",
@@ -220,7 +267,14 @@ export class WebPubSnapper extends Snapper {
220
267
  comms.unregisterAll(WebPubSnapper.moduleName);
221
268
  this.resizeObserver.disconnect();
222
269
  if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
270
+
271
+ if (this.patternAnalyzer) {
272
+ this.patternAnalyzer.clear();
273
+ this.patternAnalyzer = null;
274
+ this.isScrollProtectionEnabled = false;
275
+ }
276
+
223
277
  comms.log("WebPubSnapper Unmounted");
224
278
  return true;
225
279
  }
226
- }
280
+ }
@@ -0,0 +1,134 @@
1
+ import { ReadiumWindow } from "../helpers/dom";
2
+
3
+ interface CopyAttempt {
4
+ timestamp: number;
5
+ length: number;
6
+ wasBlocked: boolean;
7
+ }
8
+
9
+ export interface BulkCopyProtectionOptions {
10
+ enabled: boolean;
11
+ maxSelectionPercent: number;
12
+ absoluteMaxChars: number;
13
+ minThreshold: number;
14
+ historySize: number;
15
+ }
16
+
17
+ export class BulkCopyProtector {
18
+ private options: BulkCopyProtectionOptions;
19
+ private copyHistory: CopyAttempt[] = [];
20
+ private lastSelectionLength = 0;
21
+ private lastSelectionTime = 0;
22
+
23
+ constructor(
24
+ private readonly window: ReadiumWindow,
25
+ options: BulkCopyProtectionOptions
26
+ ) {
27
+ this.options = options;
28
+ }
29
+
30
+ private cleanupOldHistory(now: number): void {
31
+ // Keep history for the last 10 seconds
32
+ const historyWindow = 10000; // 10 seconds
33
+ this.copyHistory = this.copyHistory.filter(
34
+ attempt => now - attempt.timestamp < historyWindow
35
+ );
36
+
37
+ // Trim to max history size
38
+ if (this.copyHistory.length > this.options.historySize) {
39
+ this.copyHistory = this.copyHistory.slice(-this.options.historySize);
40
+ }
41
+ }
42
+
43
+ private isSuspiciousPattern(now: number): boolean {
44
+ if (this.copyHistory.length < 3) return false;
45
+
46
+ // Check for rapid successive copy attempts
47
+ const recentAttempts = this.copyHistory.filter(
48
+ attempt => now - attempt.timestamp < 2000 // Last 2 seconds
49
+ );
50
+
51
+ if (recentAttempts.length >= 3) {
52
+ // If multiple rapid copy attempts, it's suspicious
53
+ return true;
54
+ }
55
+
56
+ // Check for increasing selection sizes (bulk copy pattern)
57
+ const increasingSelections = this.copyHistory
58
+ .slice()
59
+ .sort((a, b) => a.timestamp - b.timestamp)
60
+ .every((attempt, i, arr) => {
61
+ if (i === 0) return true;
62
+ return attempt.length > arr[i - 1].length * 1.5; // Each selection is significantly larger
63
+ });
64
+
65
+ return increasingSelections;
66
+ }
67
+
68
+ public shouldAllowCopy(event: ClipboardEvent): boolean {
69
+ if (!this.options.enabled) return true;
70
+
71
+ const selection = this.window.getSelection();
72
+ if (!selection) return true;
73
+
74
+ const selectedText = selection.toString();
75
+ const selectedLength = selectedText.length;
76
+ const docLength = this.window.document.body.innerText.length;
77
+ const now = Date.now();
78
+
79
+ // Clean up old history entries
80
+ this.cleanupOldHistory(now);
81
+
82
+ // Always allow small selections
83
+ if (selectedLength < this.options.minThreshold) {
84
+ this.copyHistory.push({
85
+ timestamp: now,
86
+ length: selectedLength,
87
+ wasBlocked: false
88
+ });
89
+ return true;
90
+ }
91
+
92
+ const timeSinceLastSelection = now - this.lastSelectionTime;
93
+
94
+ // Check for rapid successive selections
95
+ const isRapidSelection =
96
+ timeSinceLastSelection < 100 &&
97
+ selectedLength > this.lastSelectionLength * 1.5;
98
+
99
+ const maxAllowedSelection = Math.min(
100
+ docLength * this.options.maxSelectionPercent,
101
+ this.options.absoluteMaxChars
102
+ );
103
+
104
+ // Check if this matches a suspicious pattern
105
+ const isSuspicious = this.isSuspiciousPattern(now);
106
+
107
+ const shouldBlock = selectedLength > maxAllowedSelection ||
108
+ isRapidSelection ||
109
+ isSuspicious;
110
+
111
+ // Record this attempt in history
112
+ this.copyHistory.push({
113
+ timestamp: now,
114
+ length: selectedLength,
115
+ wasBlocked: shouldBlock
116
+ });
117
+
118
+ if (shouldBlock) {
119
+ event?.preventDefault();
120
+ return false;
121
+ }
122
+
123
+ this.lastSelectionLength = selectedLength;
124
+ this.lastSelectionTime = now;
125
+ return true;
126
+ }
127
+
128
+ public destroy(): void {
129
+ this.lastSelectionLength = 0;
130
+ this.lastSelectionTime = 0;
131
+ this.copyHistory = [];
132
+ this.options.enabled = false;
133
+ }
134
+ }
@@ -0,0 +1,139 @@
1
+ import { KeyCombo, KeyboardPeripheral } from "./KeyboardCombinations";
2
+ import { BaseKeyboardPeripheralEvent, KeyboardEventData, BasicTextSelection } from "../modules/Peripherals";
3
+ import { ReadiumWindow } from "../helpers/dom";
4
+
5
+ export type KeyHandler = (event: KeyboardEvent) => void;
6
+ export type ActivityEventDispatcher = (event: KeyboardPeripheralEvent) => void;
7
+
8
+ export interface KeyboardPeripheralEvent extends BaseKeyboardPeripheralEvent, KeyboardEventData {
9
+ type: string;
10
+ }
11
+
12
+ export interface KeyComboWithHandler extends KeyCombo {
13
+ handler?: (event: KeyboardEvent) => void;
14
+ }
15
+
16
+ export class KeyCombinationManager {
17
+ /**
18
+ * Checks if the given keyboard event matches any of the provided key combinations
19
+ */
20
+ public match(event: KeyboardEvent, combos: KeyCombo[]): boolean {
21
+ for (const combo of combos) {
22
+ const keyMatch = event.keyCode === combo.keyCode;
23
+ const ctrlMatch = combo.ctrl === undefined || event.ctrlKey === combo.ctrl;
24
+ const shiftMatch = combo.shift === undefined || event.shiftKey === combo.shift;
25
+ const altMatch = combo.alt === undefined || event.altKey === combo.alt;
26
+ const metaMatch = combo.meta === undefined || event.metaKey === combo.meta;
27
+
28
+ if (keyMatch && ctrlMatch && shiftMatch && altMatch && metaMatch) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Creates an event handler that will call the provided handler when any of the key combinations match
37
+ */
38
+ public createKeyHandler(combos: KeyCombo[], handler: KeyHandler): (event: KeyboardEvent) => void {
39
+ return (event: KeyboardEvent) => {
40
+ if (this.match(event, combos)) {
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+ handler(event);
44
+ }
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Creates a standardized activity event for keyboard shortcuts
50
+ */
51
+ private createActivityEvent(
52
+ event: KeyboardEvent,
53
+ type: string,
54
+ targetFrameSrc: string,
55
+ wnd?: ReadiumWindow,
56
+ ): KeyboardPeripheralEvent {
57
+ // Capture selected text if window is available
58
+ let selectedText: Omit<BasicTextSelection, "targetFrameSrc"> | undefined;
59
+ if (wnd) {
60
+ const selection = wnd.getSelection();
61
+ const selectedTextStr = selection?.toString() || '';
62
+ const domRectList = (selectedTextStr && selection?.rangeCount) ? selection.getRangeAt(0)?.getClientRects() : null;
63
+ const rect = domRectList?.[0];
64
+
65
+ if (rect && selectedTextStr) {
66
+ selectedText = {
67
+ text: selectedTextStr,
68
+ x: rect.x,
69
+ y: rect.y,
70
+ width: rect.width,
71
+ height: rect.height
72
+ };
73
+ }
74
+ }
75
+
76
+ return {
77
+ type: type,
78
+ timestamp: Date.now(),
79
+ key: event.key,
80
+ code: event.code,
81
+ keyCode: event.keyCode,
82
+ ctrlKey: event.ctrlKey,
83
+ altKey: event.altKey,
84
+ shiftKey: event.shiftKey,
85
+ metaKey: event.metaKey,
86
+ targetFrameSrc: targetFrameSrc,
87
+ selectedText
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Creates handlers for keyboard shortcuts with centralized activity event dispatch
93
+ */
94
+ public createKeyboardHandlers(
95
+ targetFrameSrc: string,
96
+ shortcuts: KeyboardPeripheral[],
97
+ dispatcher: ActivityEventDispatcher,
98
+ wnd?: ReadiumWindow,
99
+ ): KeyComboWithHandler[] {
100
+ const handlers: KeyComboWithHandler[] = [];
101
+
102
+ // Handle KeyboardPeripheral objects only
103
+ shortcuts.forEach(shortcut => {
104
+ handlers.push(...shortcut.keyCombos.map(combo => ({
105
+ ...combo,
106
+ handler: (event: KeyboardEvent) => {
107
+ const eventType = shortcut.type;
108
+ const activityEvent = this.createActivityEvent(event, eventType, targetFrameSrc, wnd);
109
+ dispatcher(activityEvent);
110
+ }
111
+ })));
112
+ });
113
+
114
+ return handlers;
115
+ }
116
+
117
+ /**
118
+ * Creates a unified keyboard event handler that processes all shortcuts
119
+ */
120
+ public createUnifiedHandler(
121
+ targetFrameSrc: string,
122
+ shortcuts: KeyboardPeripheral[],
123
+ dispatcher: ActivityEventDispatcher,
124
+ wnd?: ReadiumWindow,
125
+ ): (event: KeyboardEvent) => void {
126
+ const handlers = this.createKeyboardHandlers(targetFrameSrc, shortcuts, dispatcher, wnd);
127
+
128
+ return (event: KeyboardEvent) => {
129
+ for (const handlerConfig of handlers) {
130
+ if (this.match(event, [handlerConfig])) {
131
+ event.preventDefault();
132
+ event.stopPropagation();
133
+ handlerConfig.handler!(event);
134
+ return; // Stop after first match
135
+ }
136
+ }
137
+ };
138
+ }
139
+ }
@@ -0,0 +1,60 @@
1
+ export interface KeyCombo {
2
+ keyCode: number; // Use stable keyCode that doesn't change across layouts
3
+ ctrl?: boolean;
4
+ shift?: boolean;
5
+ alt?: boolean;
6
+ meta?: boolean;
7
+ }
8
+
9
+ export interface KeyboardPeripheral {
10
+ type: string;
11
+ keyCombos: KeyCombo[];
12
+ }
13
+
14
+ // Developer tools shortcuts as KeyboardPeripheral
15
+ export const DEV_TOOLS: KeyboardPeripheral = {
16
+ type: "developer_tools",
17
+ keyCombos: [
18
+ { keyCode: 73, meta: true, alt: true }, // I key
19
+ { keyCode: 74, meta: true, alt: true }, // J key
20
+ { keyCode: 85, meta: true, alt: true }, // U key
21
+ { keyCode: 67, meta: true, alt: true }, // C key
22
+ { keyCode: 67, meta: true, shift: true }, // C key (Cmd+Shift+C)
23
+ { keyCode: 67, ctrl: true, shift: true }, // C key (Ctrl+Shift+C)
24
+ { keyCode: 123 }, // F12
25
+ { keyCode: 123, shift: true }, // F12+Shift
26
+ { keyCode: 123, ctrl: true, shift: true }, // F12+Ctrl+Shift
27
+ { keyCode: 123, meta: true, alt: true }, // F12+Meta+Alt
28
+ ]
29
+ };
30
+
31
+ // Select all shortcuts as KeyboardPeripheral
32
+ export const SELECT_ALL: KeyboardPeripheral = {
33
+ type: "select_all",
34
+ keyCombos: [
35
+ { keyCode: 65, meta: true }, // A key (Cmd+A)
36
+ { keyCode: 65, ctrl: true } // A key (Ctrl+A)
37
+ ]
38
+ };
39
+
40
+ // Print shortcuts as KeyboardPeripheral
41
+ export const PRINT: KeyboardPeripheral = {
42
+ type: "print",
43
+ keyCombos: [
44
+ { keyCode: 80, meta: true }, // P key (Cmd+P)
45
+ { keyCode: 80, ctrl: true }, // P key (Ctrl+P)
46
+ { keyCode: 80, meta: true, shift: true }, // P key (Cmd+Shift+P)
47
+ { keyCode: 80, ctrl: true, shift: true }, // P key (Ctrl+Shift+P)
48
+ { keyCode: 80, meta: true, alt: true }, // P key (Cmd+Alt+P)
49
+ { keyCode: 80, ctrl: true, alt: true } // P key (Ctrl+Alt+P)
50
+ ]
51
+ };
52
+
53
+ // Save shortcuts as KeyboardPeripheral
54
+ export const SAVE: KeyboardPeripheral = {
55
+ type: "save",
56
+ keyCombos: [
57
+ { keyCode: 83, meta: true }, // S key (Cmd+S)
58
+ { keyCode: 83, ctrl: true } // S key (Ctrl+S)
59
+ ]
60
+ };
@@ -0,0 +1,117 @@
1
+ import { DEFAULT_PATTERN_ANALYZER_CONFIG } from "./config";
2
+
3
+ type Direction = "left" | "right" | "up" | "down";
4
+
5
+ export interface PatternAnalyzerOptions {
6
+ maxVelocity: number; // pixels/ms
7
+ minVariance: number; // 0-1
8
+ historySize?: number; // Number of scroll events to keep in history
9
+ minDirectionChanges?: number; // Minimum ratio of direction changes to total events (0-1)
10
+ maxConsistentScrolls?: number; // Maximum number of consistent scrolls before flagging
11
+ }
12
+
13
+ // TODO: Improve for scroll protection,
14
+ // currently disabled because of false positives
15
+ export class PatternAnalyzer {
16
+ private options: PatternAnalyzerOptions;
17
+ private history: Array<{
18
+ timestamp: number;
19
+ direction: Direction;
20
+ velocity: number;
21
+ distance: number;
22
+ }> = [];
23
+
24
+ private consistentScrollCount = 0;
25
+
26
+ constructor(
27
+ options: Partial<PatternAnalyzerOptions> = {}
28
+ ) {
29
+ // Merge provided options with default config
30
+ this.options = { ...DEFAULT_PATTERN_ANALYZER_CONFIG, ...options };
31
+ }
32
+
33
+ analyze(direction: Direction, distance: number, timeDelta: number): boolean {
34
+ if (timeDelta <= 0) return false;
35
+
36
+ const velocity = Math.abs(distance) / timeDelta;
37
+ const now = Date.now();
38
+
39
+ // Add new event
40
+ this.history.push({
41
+ timestamp: now,
42
+ direction,
43
+ velocity,
44
+ distance: Math.abs(distance)
45
+ });
46
+
47
+ // Clean up old events (keep last 2 seconds of data or historySize, whichever is smaller)
48
+ this.history = this.history
49
+ .filter(h => now - h.timestamp < 2000)
50
+ .slice(-(this.options.historySize || 20));
51
+
52
+ // Need at least 3 events to start detecting patterns
53
+ if (this.history.length < 3) return false;
54
+
55
+ // 1. Check for excessive speed (unrealistically fast scrolling)
56
+ if (velocity > this.options.maxVelocity) {
57
+ this.resetAfterDetection();
58
+ return true;
59
+ }
60
+
61
+ // 2. Check for consistent scrolling patterns
62
+ const velocities = this.history.map(h => h.velocity);
63
+ const distances = this.history.map(h => h.distance);
64
+
65
+ // Calculate mean and variance of velocities and distances
66
+ const meanVelocity = velocities.reduce((a, b) => a + b, 0) / velocities.length;
67
+ const meanDistance = distances.reduce((a, b) => a + b, 0) / distances.length;
68
+
69
+ const velocityVariance = velocities.reduce((a, b) => a + Math.pow(b - meanVelocity, 2), 0) / velocities.length;
70
+ const distanceVariance = distances.reduce((a, b) => a + Math.pow(b - meanDistance, 2), 0) / distances.length;
71
+
72
+ // Check for suspiciously consistent scrolling (low variance in speed and distance)
73
+ if (velocityVariance < this.options.minVariance &&
74
+ distanceVariance < (meanDistance * 0.1)) { // Allow 10% variance in distance
75
+ this.consistentScrollCount++;
76
+
77
+ // Only trigger if we've seen consistent scrolling for a while
78
+ if (this.consistentScrollCount >= (this.options.maxConsistentScrolls || 10)) {
79
+ this.resetAfterDetection();
80
+ return true;
81
+ }
82
+ } else {
83
+ // Reset counter if pattern breaks
84
+ this.consistentScrollCount = Math.max(0, this.consistentScrollCount - 1);
85
+ }
86
+
87
+ // 3. Check for rapid direction changes (unnatural back-and-forth scrolling)
88
+ let directionChanges = 0;
89
+ let lastDirection = this.history[0].direction;
90
+
91
+ for (let i = 1; i < this.history.length; i++) {
92
+ if (this.history[i].direction !== lastDirection) {
93
+ directionChanges++;
94
+ lastDirection = this.history[i].direction;
95
+ }
96
+ }
97
+
98
+ const directionChangeRatio = directionChanges / this.history.length;
99
+ if (directionChangeRatio > (this.options.minDirectionChanges || 0.3)) {
100
+ this.resetAfterDetection();
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ }
106
+
107
+ private resetAfterDetection(): void {
108
+ // Keep some history to prevent immediate re-triggering
109
+ this.history = this.history.slice(-3);
110
+ this.consistentScrollCount = 0;
111
+ }
112
+
113
+ clear(): void {
114
+ this.history = [];
115
+ this.consistentScrollCount = 0;
116
+ }
117
+ }