@epsilon-asi/actors 0.0.21 → 0.0.32

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 (198) hide show
  1. package/dist/browser/RuntimeConfig.d.ts +26 -0
  2. package/dist/browser/RuntimeConfig.d.ts.map +1 -1
  3. package/dist/browser/RuntimeConfig.js +29 -1
  4. package/dist/browser/RuntimeConfig.js.map +1 -1
  5. package/dist/core/ActorContext.d.ts +2 -0
  6. package/dist/core/ActorContext.d.ts.map +1 -1
  7. package/dist/core/ActorRunner.d.ts +3 -0
  8. package/dist/core/ActorRunner.d.ts.map +1 -1
  9. package/dist/core/ActorRunner.js +11 -1
  10. package/dist/core/ActorRunner.js.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/native/CompositeNativeWindowDriver.d.ts +11 -0
  16. package/dist/native/CompositeNativeWindowDriver.d.ts.map +1 -0
  17. package/dist/native/CompositeNativeWindowDriver.js +31 -0
  18. package/dist/native/CompositeNativeWindowDriver.js.map +1 -0
  19. package/dist/native/NativeActionRegistry.d.ts +14 -0
  20. package/dist/native/NativeActionRegistry.d.ts.map +1 -0
  21. package/dist/native/NativeActionRegistry.js +101 -0
  22. package/dist/native/NativeActionRegistry.js.map +1 -0
  23. package/dist/native/NativeAutomation.d.ts +3 -0
  24. package/dist/native/NativeAutomation.d.ts.map +1 -0
  25. package/dist/native/NativeAutomation.js +12 -0
  26. package/dist/native/NativeAutomation.js.map +1 -0
  27. package/dist/native/NativeCoordinateMapper.d.ts +23 -0
  28. package/dist/native/NativeCoordinateMapper.d.ts.map +1 -0
  29. package/dist/native/NativeCoordinateMapper.js +201 -0
  30. package/dist/native/NativeCoordinateMapper.js.map +1 -0
  31. package/dist/native/NativeFileDialogService.d.ts +26 -0
  32. package/dist/native/NativeFileDialogService.d.ts.map +1 -0
  33. package/dist/native/NativeFileDialogService.js +121 -0
  34. package/dist/native/NativeFileDialogService.js.map +1 -0
  35. package/dist/native/NativeImageFinder.d.ts +12 -0
  36. package/dist/native/NativeImageFinder.d.ts.map +1 -0
  37. package/dist/native/NativeImageFinder.js +29 -0
  38. package/dist/native/NativeImageFinder.js.map +1 -0
  39. package/dist/native/NativeKeyboard.d.ts +10 -0
  40. package/dist/native/NativeKeyboard.d.ts.map +1 -0
  41. package/dist/native/NativeKeyboard.js +16 -0
  42. package/dist/native/NativeKeyboard.js.map +1 -0
  43. package/dist/native/NativeMouse.d.ts +38 -0
  44. package/dist/native/NativeMouse.d.ts.map +1 -0
  45. package/dist/native/NativeMouse.js +82 -0
  46. package/dist/native/NativeMouse.js.map +1 -0
  47. package/dist/native/NativeWindowService.d.ts +31 -0
  48. package/dist/native/NativeWindowService.d.ts.map +1 -0
  49. package/dist/native/NativeWindowService.js +183 -0
  50. package/dist/native/NativeWindowService.js.map +1 -0
  51. package/dist/native/UnsupportedNativeAutomation.d.ts +4 -0
  52. package/dist/native/UnsupportedNativeAutomation.d.ts.map +1 -0
  53. package/dist/native/UnsupportedNativeAutomation.js +77 -0
  54. package/dist/native/UnsupportedNativeAutomation.js.map +1 -0
  55. package/dist/native/WindowMatcher.d.ts +4 -0
  56. package/dist/native/WindowMatcher.d.ts.map +1 -0
  57. package/dist/native/WindowMatcher.js +39 -0
  58. package/dist/native/WindowMatcher.js.map +1 -0
  59. package/dist/native/drivers.d.ts +37 -0
  60. package/dist/native/drivers.d.ts.map +1 -0
  61. package/dist/native/drivers.js +2 -0
  62. package/dist/native/drivers.js.map +1 -0
  63. package/dist/native/errors.d.ts +23 -0
  64. package/dist/native/errors.d.ts.map +1 -0
  65. package/dist/native/errors.js +45 -0
  66. package/dist/native/errors.js.map +1 -0
  67. package/dist/native/index.d.ts +13 -0
  68. package/dist/native/index.d.ts.map +1 -0
  69. package/dist/native/index.js +13 -0
  70. package/dist/native/index.js.map +1 -0
  71. package/dist/native/macos/MacOSAccessibilityWindowDriver.d.ts +11 -0
  72. package/dist/native/macos/MacOSAccessibilityWindowDriver.d.ts.map +1 -0
  73. package/dist/native/macos/MacOSAccessibilityWindowDriver.js +180 -0
  74. package/dist/native/macos/MacOSAccessibilityWindowDriver.js.map +1 -0
  75. package/dist/native/macos/MacOSAppleScriptClient.d.ts +24 -0
  76. package/dist/native/macos/MacOSAppleScriptClient.d.ts.map +1 -0
  77. package/dist/native/macos/MacOSAppleScriptClient.js +163 -0
  78. package/dist/native/macos/MacOSAppleScriptClient.js.map +1 -0
  79. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.d.ts +10 -0
  80. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.d.ts.map +1 -0
  81. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.js +12 -0
  82. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.js.map +1 -0
  83. package/dist/native/macos/MacOSNativeAutomation.d.ts +3 -0
  84. package/dist/native/macos/MacOSNativeAutomation.d.ts.map +1 -0
  85. package/dist/native/macos/MacOSNativeAutomation.js +88 -0
  86. package/dist/native/macos/MacOSNativeAutomation.js.map +1 -0
  87. package/dist/native/nut/NutNativeImageFinder.d.ts +17 -0
  88. package/dist/native/nut/NutNativeImageFinder.d.ts.map +1 -0
  89. package/dist/native/nut/NutNativeImageFinder.js +84 -0
  90. package/dist/native/nut/NutNativeImageFinder.js.map +1 -0
  91. package/dist/native/nut/NutNativeKeyboardDriver.d.ts +8 -0
  92. package/dist/native/nut/NutNativeKeyboardDriver.d.ts.map +1 -0
  93. package/dist/native/nut/NutNativeKeyboardDriver.js +39 -0
  94. package/dist/native/nut/NutNativeKeyboardDriver.js.map +1 -0
  95. package/dist/native/nut/NutNativeMouseDriver.d.ts +8 -0
  96. package/dist/native/nut/NutNativeMouseDriver.d.ts.map +1 -0
  97. package/dist/native/nut/NutNativeMouseDriver.js +24 -0
  98. package/dist/native/nut/NutNativeMouseDriver.js.map +1 -0
  99. package/dist/native/nut/NutNativeScreenDriver.d.ts +6 -0
  100. package/dist/native/nut/NutNativeScreenDriver.d.ts.map +1 -0
  101. package/dist/native/nut/NutNativeScreenDriver.js +12 -0
  102. package/dist/native/nut/NutNativeScreenDriver.js.map +1 -0
  103. package/dist/native/nut/NutNativeWindowDriver.d.ts +6 -0
  104. package/dist/native/nut/NutNativeWindowDriver.d.ts.map +1 -0
  105. package/dist/native/nut/NutNativeWindowDriver.js +53 -0
  106. package/dist/native/nut/NutNativeWindowDriver.js.map +1 -0
  107. package/dist/native/nut/loadNut.d.ts +58 -0
  108. package/dist/native/nut/loadNut.d.ts.map +1 -0
  109. package/dist/native/nut/loadNut.js +25 -0
  110. package/dist/native/nut/loadNut.js.map +1 -0
  111. package/dist/native/types.d.ts +194 -0
  112. package/dist/native/types.d.ts.map +1 -0
  113. package/dist/native/types.js +2 -0
  114. package/dist/native/types.js.map +1 -0
  115. package/dist/native/utils/appleScriptEscape.d.ts +7 -0
  116. package/dist/native/utils/appleScriptEscape.d.ts.map +1 -0
  117. package/dist/native/utils/appleScriptEscape.js +11 -0
  118. package/dist/native/utils/appleScriptEscape.js.map +1 -0
  119. package/dist/native/utils/geometry.d.ts +12 -0
  120. package/dist/native/utils/geometry.d.ts.map +1 -0
  121. package/dist/native/utils/geometry.js +77 -0
  122. package/dist/native/utils/geometry.js.map +1 -0
  123. package/dist/native/utils/redactNative.d.ts +2 -0
  124. package/dist/native/utils/redactNative.d.ts.map +1 -0
  125. package/dist/native/utils/redactNative.js +7 -0
  126. package/dist/native/utils/redactNative.js.map +1 -0
  127. package/dist/native/utils/waitFor.d.ts +7 -0
  128. package/dist/native/utils/waitFor.d.ts.map +1 -0
  129. package/dist/native/utils/waitFor.js +17 -0
  130. package/dist/native/utils/waitFor.js.map +1 -0
  131. package/dist/sites/upwork-com/upwork-com.actor.d.ts +4 -1
  132. package/dist/sites/upwork-com/upwork-com.actor.d.ts.map +1 -1
  133. package/dist/sites/upwork-com/upwork-com.actor.js +30 -12
  134. package/dist/sites/upwork-com/upwork-com.actor.js.map +1 -1
  135. package/dist/sites/upwork-com/upwork-com.types.d.ts +3 -1
  136. package/dist/sites/upwork-com/upwork-com.types.d.ts.map +1 -1
  137. package/dist/sites/upwork-com/upwork-com.types.js.map +1 -1
  138. package/dist/sites/upwork-com/util/parseJobApplicationDetails.d.ts +70 -0
  139. package/dist/sites/upwork-com/util/parseJobApplicationDetails.d.ts.map +1 -0
  140. package/dist/sites/upwork-com/util/parseJobApplicationDetails.js +334 -0
  141. package/dist/sites/upwork-com/util/parseJobApplicationDetails.js.map +1 -0
  142. package/dist/sites/upwork-com/util/scrapeJobListing.d.ts +1 -0
  143. package/dist/sites/upwork-com/util/scrapeJobListing.d.ts.map +1 -1
  144. package/dist/sites/upwork-com/util/scrapeJobListing.js +4 -0
  145. package/dist/sites/upwork-com/util/scrapeJobListing.js.map +1 -1
  146. package/package.json +5 -1
  147. package/src/browser/RuntimeConfig.ts +57 -1
  148. package/src/core/ActorContext.ts +2 -0
  149. package/src/core/ActorRunner.ts +13 -1
  150. package/src/index.ts +2 -0
  151. package/src/native/CompositeNativeWindowDriver.ts +30 -0
  152. package/src/native/NativeActionRegistry.ts +114 -0
  153. package/src/native/NativeAutomation.ts +15 -0
  154. package/src/native/NativeCoordinateMapper.ts +258 -0
  155. package/src/native/NativeFileDialogService.ts +138 -0
  156. package/src/native/NativeImageFinder.ts +33 -0
  157. package/src/native/NativeKeyboard.ts +18 -0
  158. package/src/native/NativeMouse.ts +116 -0
  159. package/src/native/NativeWindowService.ts +229 -0
  160. package/src/native/UnsupportedNativeAutomation.ts +92 -0
  161. package/src/native/WindowMatcher.ts +31 -0
  162. package/src/native/drivers.ts +38 -0
  163. package/src/native/errors.ts +51 -0
  164. package/src/native/index.ts +12 -0
  165. package/src/native/macos/MacOSAccessibilityWindowDriver.ts +183 -0
  166. package/src/native/macos/MacOSAppleScriptClient.ts +182 -0
  167. package/src/native/macos/MacOSFileDialogAccessibilityStrategy.ts +11 -0
  168. package/src/native/macos/MacOSNativeAutomation.ts +86 -0
  169. package/src/native/nut/NutNativeImageFinder.ts +98 -0
  170. package/src/native/nut/NutNativeKeyboardDriver.ts +38 -0
  171. package/src/native/nut/NutNativeMouseDriver.ts +27 -0
  172. package/src/native/nut/NutNativeScreenDriver.ts +14 -0
  173. package/src/native/nut/NutNativeWindowDriver.ts +61 -0
  174. package/src/native/nut/loadNut.ts +86 -0
  175. package/src/native/types.ts +224 -0
  176. package/src/native/utils/appleScriptEscape.ts +11 -0
  177. package/src/native/utils/geometry.ts +88 -0
  178. package/src/native/utils/redactNative.ts +6 -0
  179. package/src/native/utils/waitFor.ts +25 -0
  180. package/src/sites/upwork-com/upwork-com.actor.ts +46 -15
  181. package/src/sites/upwork-com/upwork-com.types.ts +4 -1
  182. package/src/sites/upwork-com/util/parseJobApplicationDetails.ts +622 -0
  183. package/src/sites/upwork-com/util/scrapeJobListing.ts +4 -3
  184. package/tests/fixtures/makeContext.ts +7 -2
  185. package/tests/fixtures/native/FakeNativeAutomation.ts +138 -0
  186. package/tests/unit/browser/RuntimeConfig.native.test.ts +63 -0
  187. package/tests/unit/core/ActorRunner.native.test.ts +69 -0
  188. package/tests/unit/native/MacOSAppleScriptClient.test.ts +35 -0
  189. package/tests/unit/native/NativeActionRegistry.test.ts +34 -0
  190. package/tests/unit/native/NativeCoordinateMapper.test.ts +92 -0
  191. package/tests/unit/native/NativeFileDialogService.test.ts +91 -0
  192. package/tests/unit/native/NativeMouse.test.ts +91 -0
  193. package/tests/unit/native/NativeWindowService.test.ts +87 -0
  194. package/tests/unit/native/WindowMatcher.test.ts +32 -0
  195. package/tests/unit/native/appleScriptEscape.test.ts +9 -0
  196. package/tests/unit/sites/myvistage-com.login.test.ts +1 -1
  197. package/tests/unit/sites/myvistage-com.postComment.test.ts +0 -1
  198. package/tests/unit/sites/upwork-com.login.test.ts +1 -1
@@ -0,0 +1,258 @@
1
+ import type { PageLike } from '../browser/PuppeteerLike.js';
2
+ import type { Logger } from '../logging/Logger.js';
3
+ import type {
4
+ BrowserPoint,
5
+ BrowserViewportSnapshot,
6
+ CalibrationOptions,
7
+ CoordinateMapOptions,
8
+ CoordinateTransform,
9
+ NativeCoordinateMapper,
10
+ NativeImageFinder,
11
+ NativeTemplateRef,
12
+ NativeWindowService,
13
+ ScreenPoint
14
+ } from './types.js';
15
+ import { CoordinateCalibrationError } from './errors.js';
16
+ import { centerOf } from './utils/geometry.js';
17
+
18
+ interface MarkerSpec {
19
+ id: string;
20
+ point: BrowserPoint;
21
+ size: number;
22
+ color: string;
23
+ }
24
+
25
+ interface MarkerInstallPayload {
26
+ markers: MarkerSpec[];
27
+ }
28
+
29
+ export interface ImageBasedCoordinateMapperOptions {
30
+ images: NativeImageFinder;
31
+ windows?: NativeWindowService;
32
+ logger?: Logger;
33
+ defaultTimeoutMs?: number;
34
+ defaultCacheTtlMs?: number;
35
+ }
36
+
37
+ const DEFAULT_MARKER_A: BrowserPoint = { x: 120, y: 120 };
38
+ const DEFAULT_MARKER_B: BrowserPoint = { x: 420, y: 320 };
39
+ const DEFAULT_MARKER_SIZE = 22;
40
+
41
+ function assertFinite(value: number, name: string): void {
42
+ if (!Number.isFinite(value)) {
43
+ throw new CoordinateCalibrationError(`Coordinate calibration produced a non-finite ${name}.`, { [name]: value });
44
+ }
45
+ }
46
+
47
+ async function getViewportSnapshot(page: PageLike): Promise<BrowserViewportSnapshot> {
48
+ return page.evaluate<BrowserViewportSnapshot>(() => {
49
+ const view = globalThis.window;
50
+ return {
51
+ screenX: view.screenX,
52
+ screenY: view.screenY,
53
+ outerWidth: view.outerWidth,
54
+ outerHeight: view.outerHeight,
55
+ innerWidth: view.innerWidth,
56
+ innerHeight: view.innerHeight,
57
+ devicePixelRatio: view.devicePixelRatio
58
+ };
59
+ });
60
+ }
61
+
62
+ async function installMarkers(page: PageLike, markers: MarkerSpec[]): Promise<void> {
63
+ const payload: MarkerInstallPayload = { markers };
64
+ await page.evaluate<void>((rawPayload: unknown) => {
65
+ const { markers: markerSpecs } = rawPayload as MarkerInstallPayload;
66
+ const existing = document.getElementById('__paf_native_coordinate_markers__');
67
+ existing?.remove();
68
+
69
+ const root = document.createElement('div');
70
+ root.id = '__paf_native_coordinate_markers__';
71
+ root.style.position = 'fixed';
72
+ root.style.left = '0';
73
+ root.style.top = '0';
74
+ root.style.width = '0';
75
+ root.style.height = '0';
76
+ root.style.zIndex = '2147483647';
77
+ root.style.pointerEvents = 'none';
78
+
79
+ for (const marker of markerSpecs) {
80
+ const element = document.createElement('div');
81
+ element.id = marker.id;
82
+ element.setAttribute('data-paf-native-calibration-marker', marker.id);
83
+ element.style.position = 'fixed';
84
+ element.style.left = `${marker.point.x}px`;
85
+ element.style.top = `${marker.point.y}px`;
86
+ element.style.width = `${marker.size}px`;
87
+ element.style.height = `${marker.size}px`;
88
+ element.style.background = marker.color;
89
+ element.style.border = '3px solid #000';
90
+ element.style.boxSizing = 'border-box';
91
+ element.style.boxShadow = '0 0 0 2px #fff';
92
+ root.appendChild(element);
93
+ }
94
+
95
+ document.documentElement.appendChild(root);
96
+ }, payload);
97
+ }
98
+
99
+ async function removeMarkers(page: PageLike): Promise<void> {
100
+ await page.evaluate<void>(() => {
101
+ document.getElementById('__paf_native_coordinate_markers__')?.remove();
102
+ });
103
+ }
104
+
105
+ async function captureMarkerTemplate(page: PageLike, marker: MarkerSpec): Promise<NativeTemplateRef> {
106
+ const margin = 8;
107
+ const screenshot = await page.screenshot({
108
+ type: 'png',
109
+ clip: {
110
+ x: marker.point.x - margin,
111
+ y: marker.point.y - margin,
112
+ width: marker.size + margin * 2,
113
+ height: marker.size + margin * 2
114
+ }
115
+ });
116
+
117
+ if (typeof screenshot === 'string') {
118
+ return { name: `${marker.id}.png`, buffer: Buffer.from(screenshot, 'base64') };
119
+ }
120
+
121
+ return { name: `${marker.id}.png`, buffer: screenshot };
122
+ }
123
+
124
+ export class ImageBasedCoordinateMapper implements NativeCoordinateMapper {
125
+ private cached: CoordinateTransform | null = null;
126
+ private cacheExpiresAt = 0;
127
+
128
+ constructor(private readonly options: ImageBasedCoordinateMapperOptions) {}
129
+
130
+ async calibrate(page: PageLike, options: CalibrationOptions = {}): Promise<CoordinateTransform> {
131
+ const markerSize = options.markerSizePx ?? DEFAULT_MARKER_SIZE;
132
+ const markerA: MarkerSpec = {
133
+ id: 'paf-native-calibration-a',
134
+ point: options.markerA ?? DEFAULT_MARKER_A,
135
+ size: markerSize,
136
+ color: '#ff00ff'
137
+ };
138
+ const markerB: MarkerSpec = {
139
+ id: 'paf-native-calibration-b',
140
+ point: options.markerB ?? DEFAULT_MARKER_B,
141
+ size: markerSize,
142
+ color: '#00ffff'
143
+ };
144
+
145
+ await installMarkers(page, [markerA, markerB]);
146
+
147
+ try {
148
+ const viewport = await getViewportSnapshot(page);
149
+ const [templateA, templateB] = await Promise.all([
150
+ captureMarkerTemplate(page, markerA),
151
+ captureMarkerTemplate(page, markerB)
152
+ ]);
153
+
154
+ const imageOptions: { timeoutMs?: number; intervalMs?: number } = {};
155
+ const timeoutMs = options.timeoutMs ?? this.options.defaultTimeoutMs;
156
+ if (timeoutMs !== undefined) imageOptions.timeoutMs = timeoutMs;
157
+ if (options.intervalMs !== undefined) imageOptions.intervalMs = options.intervalMs;
158
+
159
+ const [matchA, matchB] = await Promise.all([
160
+ this.options.images.waitForTemplate(templateA, imageOptions),
161
+ this.options.images.waitForTemplate(templateB, imageOptions)
162
+ ]);
163
+
164
+ const screenA = centerOf(matchA.region);
165
+ const screenB = centerOf(matchB.region);
166
+ const browserA = {
167
+ x: markerA.point.x + markerA.size / 2,
168
+ y: markerA.point.y + markerA.size / 2
169
+ };
170
+ const browserB = {
171
+ x: markerB.point.x + markerB.size / 2,
172
+ y: markerB.point.y + markerB.size / 2
173
+ };
174
+
175
+ const scaleX = (screenB.x - screenA.x) / (browserB.x - browserA.x);
176
+ const scaleY = (screenB.y - screenA.y) / (browserB.y - browserA.y);
177
+ const offsetX = screenA.x - scaleX * browserA.x;
178
+ const offsetY = screenA.y - scaleY * browserA.y;
179
+
180
+ this.validateTransform({ offsetX, offsetY, scaleX, scaleY }, viewport);
181
+
182
+ const transform: CoordinateTransform = {
183
+ offsetX,
184
+ offsetY,
185
+ scaleX,
186
+ scaleY,
187
+ calibratedAt: Date.now(),
188
+ viewport: {
189
+ innerWidth: viewport.innerWidth,
190
+ innerHeight: viewport.innerHeight,
191
+ devicePixelRatio: viewport.devicePixelRatio
192
+ }
193
+ };
194
+
195
+ if (this.options.windows !== undefined && options.windowMatcher !== undefined) {
196
+ const window = await this.options.windows.find(options.windowMatcher);
197
+ if (window !== null) transform.window = window;
198
+ }
199
+
200
+ this.cached = transform;
201
+ this.cacheExpiresAt = transform.calibratedAt + (options.cacheTtlMs ?? this.options.defaultCacheTtlMs ?? 60_000);
202
+ return transform;
203
+ } finally {
204
+ await removeMarkers(page).catch(error => {
205
+ this.options.logger?.warn('Failed to remove native coordinate calibration markers.', { error });
206
+ });
207
+ }
208
+ }
209
+
210
+ async browserToScreen(point: BrowserPoint, options: CoordinateMapOptions = {}): Promise<ScreenPoint> {
211
+ const transform = await this.getTransform(options);
212
+ return {
213
+ x: transform.offsetX + transform.scaleX * point.x,
214
+ y: transform.offsetY + transform.scaleY * point.y
215
+ };
216
+ }
217
+
218
+ async screenToBrowser(point: ScreenPoint, options: CoordinateMapOptions = {}): Promise<BrowserPoint> {
219
+ const transform = await this.getTransform(options);
220
+ return {
221
+ x: (point.x - transform.offsetX) / transform.scaleX,
222
+ y: (point.y - transform.offsetY) / transform.scaleY
223
+ };
224
+ }
225
+
226
+ invalidate(): void {
227
+ this.cached = null;
228
+ this.cacheExpiresAt = 0;
229
+ }
230
+
231
+ private async getTransform(options: CoordinateMapOptions): Promise<CoordinateTransform> {
232
+ const useCache = options.useCache ?? true;
233
+ if (useCache && this.cached !== null && Date.now() < this.cacheExpiresAt) {
234
+ return this.cached;
235
+ }
236
+
237
+ if (options.page === undefined) {
238
+ throw new CoordinateCalibrationError('Coordinate mapping requires a calibrated transform or a page for calibration.');
239
+ }
240
+
241
+ return this.calibrate(options.page);
242
+ }
243
+
244
+ private validateTransform(transform: Pick<CoordinateTransform, 'offsetX' | 'offsetY' | 'scaleX' | 'scaleY'>, viewport: BrowserViewportSnapshot): void {
245
+ assertFinite(transform.offsetX, 'offsetX');
246
+ assertFinite(transform.offsetY, 'offsetY');
247
+ assertFinite(transform.scaleX, 'scaleX');
248
+ assertFinite(transform.scaleY, 'scaleY');
249
+
250
+ if (transform.scaleX < 0.5 || transform.scaleX > 3.5 || transform.scaleY < 0.5 || transform.scaleY > 3.5) {
251
+ throw new CoordinateCalibrationError('Coordinate calibration produced an implausible scale.', {
252
+ scaleX: transform.scaleX,
253
+ scaleY: transform.scaleY,
254
+ viewport
255
+ });
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { Logger } from '../logging/Logger.js';
4
+ import { NativeFileDialogError } from './errors.js';
5
+ import type { NativeImageFinder, NativeKeyboard, NativeMouse, SelectFileOptions } from './types.js';
6
+ import { centerOf } from './utils/geometry.js';
7
+ import { redactPath } from './utils/redactNative.js';
8
+
9
+ export interface FileDialogAccessibilityStrategy {
10
+ selectFile(filePath: string, options: SelectFileOptions): Promise<void>;
11
+ }
12
+
13
+ export interface NativeFileDialogServiceDependencies {
14
+ keyboard: NativeKeyboard;
15
+ mouse?: NativeMouse;
16
+ images?: NativeImageFinder;
17
+ accessibility?: FileDialogAccessibilityStrategy;
18
+ logger?: Logger;
19
+ defaultTimeoutMs?: number;
20
+ revealFilePathsInLogs?: boolean;
21
+ }
22
+
23
+ export class DefaultNativeFileDialogService {
24
+ constructor(private readonly deps: NativeFileDialogServiceDependencies) {}
25
+
26
+ async selectFile(filePath: string, options: SelectFileOptions = {}): Promise<void> {
27
+ const absolutePath = path.resolve(filePath);
28
+ await this.assertFileExists(absolutePath);
29
+
30
+ const strategy = options.strategy ?? 'auto';
31
+ const failures: unknown[] = [];
32
+
33
+ if (strategy === 'applescript' || strategy === 'auto') {
34
+ if (this.deps.accessibility !== undefined) {
35
+ try {
36
+ await this.deps.accessibility.selectFile(absolutePath, options);
37
+ this.logSuccess('applescript', absolutePath);
38
+ return;
39
+ } catch (error) {
40
+ failures.push(error);
41
+ if (strategy === 'applescript') throw this.wrapFailure('applescript', absolutePath, failures);
42
+ }
43
+ } else if (strategy === 'applescript') {
44
+ throw new NativeFileDialogError('AppleScript file-dialog strategy is not available.', { strategy });
45
+ }
46
+ }
47
+
48
+ if (strategy === 'visual' || strategy === 'auto') {
49
+ if (this.deps.images !== undefined && this.deps.mouse !== undefined) {
50
+ try {
51
+ await this.selectFileVisually(absolutePath, options);
52
+ this.logSuccess('visual', absolutePath);
53
+ return;
54
+ } catch (error) {
55
+ failures.push(error);
56
+ if (strategy === 'visual') throw this.wrapFailure('visual', absolutePath, failures);
57
+ }
58
+ } else if (strategy === 'visual') {
59
+ throw new NativeFileDialogError('Visual file-dialog strategy requires image finder and native mouse dependencies.', { strategy });
60
+ }
61
+ }
62
+
63
+ if (strategy === 'keyboard' || strategy === 'auto') {
64
+ try {
65
+ await this.selectFileWithKeyboard(absolutePath, options);
66
+ this.logSuccess('keyboard', absolutePath);
67
+ return;
68
+ } catch (error) {
69
+ failures.push(error);
70
+ }
71
+ }
72
+
73
+ throw this.wrapFailure(strategy, absolutePath, failures);
74
+ }
75
+
76
+ private async assertFileExists(filePath: string): Promise<void> {
77
+ const stat = await fs.stat(filePath).catch(() => null);
78
+ if (stat === null || !stat.isFile()) {
79
+ throw new NativeFileDialogError('Cannot select file because the path does not exist or is not a file.', {
80
+ filePath: redactPath(filePath, this.deps.revealFilePathsInLogs)
81
+ });
82
+ }
83
+ }
84
+
85
+ private async selectFileVisually(filePath: string, options: SelectFileOptions): Promise<void> {
86
+ if (this.deps.images === undefined || this.deps.mouse === undefined) {
87
+ throw new NativeFileDialogError('Visual file-dialog strategy requires image finder and native mouse dependencies.');
88
+ }
89
+
90
+ if (options.inputFieldTemplate !== undefined) {
91
+ const input = await this.deps.images.waitForTemplate(options.inputFieldTemplate, this.imageSearchOptions(options));
92
+ await this.deps.mouse.click(centerOf(input.region));
93
+ } else {
94
+ await this.deps.keyboard.pressShortcut(['Meta', 'Shift', 'G']);
95
+ }
96
+
97
+ await this.deps.keyboard.type(filePath);
98
+ await this.deps.keyboard.pressKey('Enter');
99
+
100
+ if ((options.clickOpen ?? true) && options.openButtonTemplate !== undefined) {
101
+ const openButton = await this.deps.images.waitForTemplate(options.openButtonTemplate, this.imageSearchOptions(options));
102
+ await this.deps.mouse.click(centerOf(openButton.region));
103
+ } else if (options.clickOpen ?? true) {
104
+ await this.deps.keyboard.pressKey('Enter');
105
+ }
106
+ }
107
+
108
+ private async selectFileWithKeyboard(filePath: string, options: SelectFileOptions): Promise<void> {
109
+ await this.deps.keyboard.pressShortcut(['Meta', 'Shift', 'G']);
110
+ await this.deps.keyboard.type(filePath);
111
+ await this.deps.keyboard.pressKey('Enter');
112
+ if (options.clickOpen ?? true) {
113
+ await this.deps.keyboard.pressKey('Enter');
114
+ }
115
+ }
116
+
117
+ private imageSearchOptions(options: SelectFileOptions) {
118
+ const searchOptions: { timeoutMs?: number; intervalMs?: number } = {};
119
+ if (options.timeoutMs !== undefined) searchOptions.timeoutMs = options.timeoutMs;
120
+ if (options.intervalMs !== undefined) searchOptions.intervalMs = options.intervalMs;
121
+ return searchOptions;
122
+ }
123
+
124
+ private wrapFailure(strategy: string, filePath: string, failures: unknown[]): NativeFileDialogError {
125
+ return new NativeFileDialogError('Failed to select a file in the native file dialog.', {
126
+ strategy,
127
+ filePath: redactPath(filePath, this.deps.revealFilePathsInLogs),
128
+ failures
129
+ });
130
+ }
131
+
132
+ private logSuccess(strategy: string, filePath: string): void {
133
+ this.deps.logger?.debug('Selected file in native dialog.', {
134
+ strategy,
135
+ filePath: redactPath(filePath, this.deps.revealFilePathsInLogs)
136
+ });
137
+ }
138
+ }
@@ -0,0 +1,33 @@
1
+ import type { NativeImageFinder, NativeImageMatch, NativeImageSearchOptions, NativeTemplateRef } from './types.js';
2
+ import { NativeImageNotFoundError } from './errors.js';
3
+ import { waitFor } from './utils/waitFor.js';
4
+
5
+ export interface NativeImageFinderDriver {
6
+ findTemplate(template: string | NativeTemplateRef, options?: NativeImageSearchOptions): Promise<NativeImageMatch | null>;
7
+ }
8
+
9
+ export class DefaultNativeImageFinder implements NativeImageFinder {
10
+ constructor(private readonly driver: NativeImageFinderDriver, private readonly defaultTimeoutMs = 15_000) {}
11
+
12
+ async findTemplate(template: string | NativeTemplateRef, options?: NativeImageSearchOptions): Promise<NativeImageMatch | null> {
13
+ return this.driver.findTemplate(template, options);
14
+ }
15
+
16
+ async waitForTemplate(template: string | NativeTemplateRef, options: NativeImageSearchOptions = {}): Promise<NativeImageMatch> {
17
+ try {
18
+ return await waitFor(
19
+ () => this.findTemplate(template, options),
20
+ {
21
+ timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs,
22
+ intervalMs: options.intervalMs ?? 100,
23
+ description: `native image template ${typeof template === 'string' ? template : template.name}`
24
+ }
25
+ );
26
+ } catch (error) {
27
+ throw new NativeImageNotFoundError({
28
+ template: typeof template === 'string' ? template : template.name,
29
+ cause: error
30
+ });
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,18 @@
1
+ import type { NativeKeyboardDriver } from './drivers.js';
2
+ import type { NativeKeyboard, NativeKeyboardTypeOptions } from './types.js';
3
+
4
+ export class DefaultNativeKeyboard implements NativeKeyboard {
5
+ constructor(private readonly driver: NativeKeyboardDriver) {}
6
+
7
+ async type(text: string, options: NativeKeyboardTypeOptions = {}): Promise<void> {
8
+ await this.driver.type(text, options.delayMs === undefined ? undefined : { delayMs: options.delayMs });
9
+ }
10
+
11
+ async pressKey(key: string): Promise<void> {
12
+ await this.driver.pressKey(key);
13
+ }
14
+
15
+ async pressShortcut(keys: readonly string[]): Promise<void> {
16
+ await this.driver.pressShortcut(keys);
17
+ }
18
+ }
@@ -0,0 +1,116 @@
1
+ import { path as ghostCursorPath } from 'ghost-cursor';
2
+ import type { PageLike } from '../browser/PuppeteerLike.js';
3
+ import type { NativeMouseDriver } from './drivers.js';
4
+ import type {
5
+ BrowserPoint,
6
+ NativeMouse,
7
+ NativeMouseClickOptions,
8
+ NativeMouseMoveOptions,
9
+ NativePoint,
10
+ NativeRegion,
11
+ ScreenPoint
12
+ } from './types.js';
13
+ import { centerOf, clampPointToRegion, randomPointInRegion, roundPoint } from './utils/geometry.js';
14
+
15
+ export type NativeRandomFunction = () => number;
16
+ export type NativeSleepFunction = (ms: number) => Promise<void>;
17
+ export type PathGenerator = (from: NativePoint, to: NativePoint, options?: { moveSpeed?: number }) => NativePoint[];
18
+
19
+ export interface HumanizedNativeMouseOptions {
20
+ jitterPixels?: number;
21
+ minJitterPixels?: number;
22
+ moveSpeed?: number;
23
+ random?: NativeRandomFunction;
24
+ sleep?: NativeSleepFunction;
25
+ pathGenerator?: PathGenerator;
26
+ coordinateMapper: {
27
+ browserToScreen(point: BrowserPoint, options?: { page?: PageLike }): Promise<ScreenPoint>;
28
+ };
29
+ }
30
+
31
+ const defaultSleep: NativeSleepFunction = async ms => {
32
+ if (ms <= 0) return;
33
+ await new Promise<void>(resolve => setTimeout(resolve, ms));
34
+ };
35
+
36
+ function defaultPathGenerator(from: NativePoint, to: NativePoint, options?: { moveSpeed?: number }): NativePoint[] {
37
+ const pathOptions = options?.moveSpeed === undefined ? undefined : { moveSpeed: options.moveSpeed };
38
+ return ghostCursorPath(from, to, pathOptions) as NativePoint[];
39
+ }
40
+
41
+ function normalizeJitter(minJitterPixels: number | undefined, jitterPixels: number | undefined): { min: number; max: number } {
42
+ const max = Math.max(0, jitterPixels ?? 3);
43
+ const min = Math.min(max, Math.max(0, minJitterPixels ?? Math.min(2, max)));
44
+ return { min, max };
45
+ }
46
+
47
+ export class HumanizedNativeMouse implements NativeMouse {
48
+ private readonly random: NativeRandomFunction;
49
+ private readonly sleep: NativeSleepFunction;
50
+ private readonly pathGenerator: PathGenerator;
51
+ private readonly jitterPixels: number;
52
+ private readonly minJitterPixels: number;
53
+ private readonly moveSpeed: number | undefined;
54
+
55
+ constructor(private readonly driver: NativeMouseDriver, private readonly options: HumanizedNativeMouseOptions) {
56
+ this.random = options.random ?? Math.random;
57
+ this.sleep = options.sleep ?? defaultSleep;
58
+ this.pathGenerator = options.pathGenerator ?? defaultPathGenerator;
59
+ this.jitterPixels = options.jitterPixels ?? 3;
60
+ this.minJitterPixels = options.minJitterPixels ?? 2;
61
+ this.moveSpeed = options.moveSpeed;
62
+ }
63
+
64
+ async moveTo(point: ScreenPoint, options: NativeMouseMoveOptions = {}): Promise<void> {
65
+ const current = await this.driver.getPosition();
66
+ const target = roundPoint(this.applyJitter(point, options));
67
+
68
+ if (options.disableHumanPath === true) {
69
+ await this.driver.move(target);
70
+ return;
71
+ }
72
+
73
+ const moveSpeed = options.moveSpeed ?? this.moveSpeed;
74
+ const route = this.pathGenerator(current, target, moveSpeed === undefined ? undefined : { moveSpeed });
75
+ const points = route.length === 0 ? [target] : route;
76
+
77
+ for (const [index, routePoint] of points.entries()) {
78
+ const isLast = index === points.length - 1;
79
+ const nextPoint = isLast ? target : roundPoint(this.applyJitter(routePoint, options));
80
+ await this.driver.move(nextPoint);
81
+ }
82
+ }
83
+
84
+ async click(point: ScreenPoint, options: NativeMouseClickOptions = {}): Promise<void> {
85
+ await this.moveTo(point, options);
86
+ await this.sleep(options.delayBeforeClickMs ?? 0);
87
+ await this.driver.click(options.button ?? 'left');
88
+ await this.sleep(options.delayAfterMoveMs ?? 0);
89
+ }
90
+
91
+ async clickRegion(region: NativeRegion, options: NativeMouseClickOptions = {}): Promise<void> {
92
+ const padding = options.regionPaddingPx ?? 4;
93
+ const point = randomPointInRegion(region, this.random, padding);
94
+ const target = options.precise === true ? point : clampPointToRegion(this.applyJitter(point, options), region);
95
+ await this.click(roundPoint(target), { ...options, precise: true });
96
+ }
97
+
98
+ async clickBrowserPoint(page: PageLike, point: BrowserPoint, options: NativeMouseClickOptions = {}): Promise<void> {
99
+ const screenPoint = await this.options.coordinateMapper.browserToScreen(point, { page });
100
+ await this.click(screenPoint, options);
101
+ }
102
+
103
+ private applyJitter(point: NativePoint, options: NativeMouseMoveOptions): NativePoint {
104
+ if (options.precise === true) return point;
105
+
106
+ const { min, max } = normalizeJitter(options.minJitterPixels ?? this.minJitterPixels, options.jitterPixels ?? this.jitterPixels);
107
+ if (max <= 0) return point;
108
+
109
+ const distance = min + this.random() * (max - min);
110
+ const angle = this.random() * Math.PI * 2;
111
+ return {
112
+ x: point.x + Math.cos(angle) * distance,
113
+ y: point.y + Math.sin(angle) * distance
114
+ };
115
+ }
116
+ }