input.sys.mjs (10070B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", 11 12 AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs", 13 assertTargetInViewPort: 14 "chrome://remote/content/shared/webdriver/Actions.sys.mjs", 15 dom: "chrome://remote/content/shared/DOM.sys.mjs", 16 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 17 event: "chrome://remote/content/shared/webdriver/Event.sys.mjs", 18 FilePickerListener: 19 "chrome://remote/content/shared/listeners/FilePickerListener.sys.mjs", 20 OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", 21 setDefaultSerializationOptions: 22 "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs", 23 }); 24 25 class InputModule extends WindowGlobalBiDiModule { 26 #filePickerListener; 27 #subscribedEvents; 28 29 constructor(messageHandler) { 30 super(messageHandler); 31 32 this.#filePickerListener = new lazy.FilePickerListener(); 33 this.#filePickerListener.on( 34 "file-picker-opening", 35 this.#onFilePickerOpening 36 ); 37 38 // Set of event names which have active subscriptions. 39 this.#subscribedEvents = new Set(); 40 } 41 42 destroy() { 43 this.#filePickerListener.off( 44 "file-picker-opening", 45 this.#onFilePickerOpening 46 ); 47 this.#subscribedEvents = null; 48 } 49 50 async setFiles(options) { 51 const { element: sharedReference, files } = options; 52 53 const element = 54 await this.#deserializeElementSharedReference(sharedReference); 55 56 if ( 57 !HTMLInputElement.isInstance(element) || 58 element.type !== "file" || 59 element.disabled 60 ) { 61 throw new lazy.error.UnableToSetFileInputError( 62 `Element needs to be an <input> element with type "file" and not disabled` 63 ); 64 } 65 66 if (files.length > 1 && !element.hasAttribute("multiple")) { 67 throw new lazy.error.UnableToSetFileInputError( 68 `Element should have an attribute "multiple" set when trying to set more than 1 file` 69 ); 70 } 71 72 const fileObjects = []; 73 for (const file of files) { 74 try { 75 fileObjects.push(await File.createFromFileName(file)); 76 } catch (e) { 77 throw new lazy.error.UnsupportedOperationError( 78 `Failed to add file ${file} (${e})` 79 ); 80 } 81 } 82 83 const selectedFiles = Array.from(element.files); 84 85 const intersection = fileObjects.filter(fileObject => 86 selectedFiles.some( 87 selectedFile => 88 // Compare file fields to identify if the files are equal. 89 // TODO: Bug 1883856. Add check for full path or use a different way 90 // to compare files when it's available. 91 selectedFile.name === fileObject.name && 92 selectedFile.size === fileObject.size && 93 selectedFile.type === fileObject.type 94 ) 95 ); 96 97 if ( 98 intersection.length === selectedFiles.length && 99 selectedFiles.length === fileObjects.length 100 ) { 101 lazy.event.cancel(element); 102 } else { 103 element.mozSetFileArray(fileObjects); 104 105 lazy.event.input(element); 106 lazy.event.change(element); 107 } 108 } 109 110 async #deserializeElementSharedReference(sharedReference) { 111 if (typeof sharedReference?.sharedId !== "string") { 112 throw new lazy.error.InvalidArgumentError( 113 `Expected "element" to be a SharedReference, got: ${sharedReference}` 114 ); 115 } 116 117 const realm = this.messageHandler.getRealm(); 118 119 const element = this.deserialize(sharedReference, realm); 120 if (!lazy.dom.isElement(element)) { 121 throw new lazy.error.NoSuchElementError( 122 `No element found for shared id: ${sharedReference.sharedId}` 123 ); 124 } 125 126 return element; 127 } 128 129 #onFilePickerOpening = (eventName, data) => { 130 const { element } = data; 131 if (element.ownerGlobal.browsingContext != this.messageHandler.context) { 132 return; 133 } 134 135 const realm = this.messageHandler.getRealm(); 136 137 const serializedNode = this.serialize( 138 element, 139 lazy.setDefaultSerializationOptions(), 140 lazy.OwnershipModel.None, 141 realm 142 ); 143 144 this.emitEvent("input.fileDialogOpened", { 145 context: this.messageHandler.context, 146 element: serializedNode, 147 multiple: element.multiple, 148 }); 149 }; 150 151 #startListingOnFilePickerOpened() { 152 if (!this.#subscribedEvents.has("script.FilePickerOpened")) { 153 this.#filePickerListener.startListening(); 154 } 155 } 156 157 #stopListingOnFilePickerOpened() { 158 if (this.#subscribedEvents.has("script.FilePickerOpened")) { 159 this.#filePickerListener.stopListening(); 160 } 161 } 162 163 #subscribeEvent(event) { 164 switch (event) { 165 case "input.fileDialogOpened": { 166 this.#startListingOnFilePickerOpened(); 167 this.#subscribedEvents.add(event); 168 break; 169 } 170 } 171 } 172 173 #unsubscribeEvent(event) { 174 switch (event) { 175 case "input.fileDialogOpened": { 176 this.#stopListingOnFilePickerOpened(); 177 this.#subscribedEvents.delete(event); 178 break; 179 } 180 } 181 } 182 183 _applySessionData(params) { 184 // TODO: Bug 1775231. Move this logic to a shared module or an abstract 185 // class. 186 const { category } = params; 187 if (category === "event") { 188 const filteredSessionData = params.sessionData.filter(item => 189 this.messageHandler.matchesContext(item.contextDescriptor) 190 ); 191 for (const event of this.#subscribedEvents.values()) { 192 const hasSessionItem = filteredSessionData.some( 193 item => item.value === event 194 ); 195 // If there are no session items for this context, we should unsubscribe from the event. 196 if (!hasSessionItem) { 197 this.#unsubscribeEvent(event); 198 } 199 } 200 201 // Subscribe to all events, which have an item in SessionData. 202 for (const { value } of filteredSessionData) { 203 this.#subscribeEvent(value); 204 } 205 } 206 } 207 208 _assertInViewPort(options) { 209 const { target } = options; 210 211 return lazy.assertTargetInViewPort(target, this.messageHandler.window); 212 } 213 214 async _dispatchEvent(options) { 215 const { eventName, details } = options; 216 217 const windowUtils = this.messageHandler.window.windowUtils; 218 const microTaskLevel = windowUtils.microTaskLevel; 219 // Since we're being called as a webidl callback, 220 // CallbackObjectBase::CallSetup::CallSetup has increased the microtask 221 // level. Undo that temporarily so that microtask handling works closer 222 // the way it would work when dispatching events natively. 223 windowUtils.microTaskLevel = 0; 224 225 try { 226 switch (eventName) { 227 case "synthesizeKeyDown": 228 lazy.event.sendKeyDown(details.eventData, this.messageHandler.window); 229 break; 230 case "synthesizeKeyUp": 231 lazy.event.sendKeyUp(details.eventData, this.messageHandler.window); 232 break; 233 case "synthesizeMouseAtPoint": 234 await lazy.event.synthesizeMouseAtPoint( 235 details.x, 236 details.y, 237 details.eventData, 238 this.messageHandler.window 239 ); 240 break; 241 case "synthesizeMultiTouch": 242 lazy.event.synthesizeMultiTouch( 243 details.eventData, 244 this.messageHandler.window 245 ); 246 break; 247 case "synthesizeWheelAtPoint": 248 await lazy.event.synthesizeWheelAtPoint( 249 details.x, 250 details.y, 251 details.eventData, 252 this.messageHandler.window 253 ); 254 break; 255 default: 256 throw new Error( 257 `${eventName} is not a supported type for dispatching` 258 ); 259 } 260 } catch (e) { 261 if (e.message.includes("NS_ERROR_FAILURE")) { 262 // Dispatching the event failed. Inform the RootTransport 263 // to retry dispatching the event. 264 throw new DOMException( 265 `Failed to dispatch event "${eventName}": ${e.message}`, 266 "AbortError" 267 ); 268 } 269 270 throw e; 271 } finally { 272 windowUtils.microTaskLevel = microTaskLevel; 273 } 274 } 275 276 async _finalizeAction() { 277 // Terminate the current wheel transaction if there is one. Wheel 278 // transactions should not live longer than a single action chain. 279 await ChromeUtils.endWheelTransaction(this.messageHandler.window); 280 281 // Wait for the next animation frame to make sure the page's content 282 // was updated. 283 await lazy.AnimationFramePromise(this.messageHandler.window); 284 } 285 286 async _getClientRects(options) { 287 const { element: reference } = options; 288 289 const element = await this.#deserializeElementSharedReference(reference); 290 const rects = element.getClientRects(); 291 292 // To avoid serialization and deserialization of DOMRect and DOMRectList 293 // convert to plain object and Array. 294 return [...rects].map(rect => { 295 const { x, y, width, height, top, right, bottom, left } = rect; 296 return { x, y, width, height, top, right, bottom, left }; 297 }); 298 } 299 300 async _getElementOrigin(options) { 301 const { origin } = options; 302 303 const reference = origin.element; 304 this.#deserializeElementSharedReference(reference); 305 306 return reference; 307 } 308 309 _getInViewCentrePoint(options) { 310 const { rect } = options; 311 312 return lazy.dom.getInViewCentrePoint(rect, this.messageHandler.window); 313 } 314 315 /** 316 * Convert a position or rect in browser coordinates of CSS units. 317 */ 318 _toBrowserWindowCoordinates(options) { 319 const { position } = options; 320 321 const [x, y] = position; 322 const window = this.messageHandler.window; 323 const dpr = window.devicePixelRatio; 324 325 const val = lazy.LayoutUtils.rectToTopLevelWidgetRect(window, { 326 left: x, 327 top: y, 328 height: 0, 329 width: 0, 330 }); 331 332 return [val.x / dpr, val.y / dpr]; 333 } 334 } 335 336 export const input = InputModule;