cloud_print_interface.js revision c2e0dbddbe15c98d52c4786dac06cb8952a8ae6d
1// Copyright (c) 2012 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5cr.define('cloudprint', function() { 6 'use strict'; 7 8 /** 9 * API to the Google Cloud Print service. 10 * @param {string} baseUrl Base part of the Google Cloud Print service URL 11 * with no trailing slash. For example, 12 * 'https://www.google.com/cloudprint'. 13 * @param {!print_preview.NativeLayer} nativeLayer Native layer used to get 14 * Auth2 tokens. 15 * @constructor 16 * @extends {cr.EventTarget} 17 */ 18 function CloudPrintInterface(baseUrl, nativeLayer) { 19 /** 20 * The base URL of the Google Cloud Print API. 21 * @type {string} 22 * @private 23 */ 24 this.baseUrl_ = baseUrl; 25 26 /** 27 * Used to get Auth2 tokens. 28 * @type {!print_preview.NativeLayer} 29 * @private 30 */ 31 this.nativeLayer_ = nativeLayer; 32 33 /** 34 * Last received XSRF token. Sent as a parameter in every request. 35 * @type {string} 36 * @private 37 */ 38 this.xsrfToken_ = ''; 39 40 /** 41 * Pending requests delayed until we get access token. 42 * @type {!Array.<!CloudPrintRequest>} 43 * @private 44 */ 45 this.requestQueue_ = []; 46 47 /** 48 * Number of outstanding cloud destination search requests. 49 * @type {number} 50 * @private 51 */ 52 this.outstandingCloudSearchRequestCount_ = 0; 53 54 /** 55 * Event tracker used to keep track of native layer events. 56 * @type {!EventTracker} 57 * @private 58 */ 59 this.tracker_ = new EventTracker(); 60 61 this.addEventListeners_(); 62 }; 63 64 /** 65 * Event types dispatched by the interface. 66 * @enum {string} 67 */ 68 CloudPrintInterface.EventType = { 69 PRINTER_DONE: 'cloudprint.CloudPrintInterface.PRINTER_DONE', 70 PRINTER_FAILED: 'cloudprint.CloudPrintInterface.PRINTER_FAILED', 71 SEARCH_DONE: 'cloudprint.CloudPrintInterface.SEARCH_DONE', 72 SEARCH_FAILED: 'cloudprint.CloudPrintInterface.SEARCH_FAILED', 73 SUBMIT_DONE: 'cloudprint.CloudPrintInterface.SUBMIT_DONE', 74 SUBMIT_FAILED: 'cloudprint.CloudPrintInterface.SUBMIT_FAILED', 75 UPDATE_PRINTER_TOS_ACCEPTANCE_FAILED: 76 'cloudprint.CloudPrintInterface.UPDATE_PRINTER_TOS_ACCEPTANCE_FAILED' 77 }; 78 79 /** 80 * Content type header value for a URL encoded HTTP request. 81 * @type {string} 82 * @const 83 * @private 84 */ 85 CloudPrintInterface.URL_ENCODED_CONTENT_TYPE_ = 86 'application/x-www-form-urlencoded'; 87 88 /** 89 * Multi-part POST request boundary used in communication with Google 90 * Cloud Print. 91 * @type {string} 92 * @const 93 * @private 94 */ 95 CloudPrintInterface.MULTIPART_BOUNDARY_ = 96 '----CloudPrintFormBoundaryjc9wuprokl8i'; 97 98 /** 99 * Content type header value for a multipart HTTP request. 100 * @type {string} 101 * @const 102 * @private 103 */ 104 CloudPrintInterface.MULTIPART_CONTENT_TYPE_ = 105 'multipart/form-data; boundary=' + 106 CloudPrintInterface.MULTIPART_BOUNDARY_; 107 108 /** 109 * Regex that extracts Chrome's version from the user-agent string. 110 * @type {!RegExp} 111 * @const 112 * @private 113 */ 114 CloudPrintInterface.VERSION_REGEXP_ = /.*Chrome\/([\d\.]+)/i; 115 116 /** 117 * Enumeration of JSON response fields from Google Cloud Print API. 118 * @enum {string} 119 * @private 120 */ 121 CloudPrintInterface.JsonFields_ = { 122 PRINTER: 'printer' 123 }; 124 125 /** 126 * Could Print origins used to search printers. 127 * @type {!Array.<!print_preview.Destination.Origin>} 128 * @const 129 * @private 130 */ 131 CloudPrintInterface.CLOUD_ORIGINS_ = [ 132 print_preview.Destination.Origin.COOKIES, 133 print_preview.Destination.Origin.DEVICE 134 // TODO(vitalybuka): Enable when implemented. 135 // ready print_preview.Destination.Origin.PROFILE 136 ]; 137 138 CloudPrintInterface.prototype = { 139 __proto__: cr.EventTarget.prototype, 140 141 /** @return {string} Base URL of the Google Cloud Print service. */ 142 get baseUrl() { 143 return this.baseUrl_; 144 }, 145 146 /** 147 * @return {boolean} Whether a search for cloud destinations is in progress. 148 */ 149 get isCloudDestinationSearchInProgress() { 150 return this.outstandingCloudSearchRequestCount_ > 0; 151 }, 152 153 /** 154 * Sends a Google Cloud Print search API request. 155 * @param {boolean} isRecent Whether to search for only recently used 156 * printers. 157 */ 158 search: function(isRecent) { 159 var params = [ 160 new HttpParam('connection_status', 'ALL'), 161 new HttpParam('client', 'chrome'), 162 new HttpParam('use_cdd', 'true') 163 ]; 164 if (isRecent) { 165 params.push(new HttpParam('q', '^recent')); 166 } 167 CloudPrintInterface.CLOUD_ORIGINS_.forEach(function(origin) { 168 ++this.outstandingCloudSearchRequestCount_; 169 var cpRequest = 170 this.buildRequest_('GET', 'search', params, origin, 171 this.onSearchDone_.bind(this, isRecent)); 172 this.sendOrQueueRequest_(cpRequest); 173 }, this); 174 }, 175 176 /** 177 * Sends a Google Cloud Print submit API request. 178 * @param {!print_preview.Destination} destination Cloud destination to 179 * print to. 180 * @param {!print_preview.PrintTicketStore} printTicketStore Contains the 181 * print ticket to print. 182 * @param {!print_preview.DocumentInfo} documentInfo Document data model. 183 * @param {string} data Base64 encoded data of the document. 184 */ 185 submit: function(destination, printTicketStore, documentInfo, data) { 186 var result = 187 CloudPrintInterface.VERSION_REGEXP_.exec(navigator.userAgent); 188 var chromeVersion = 'unknown'; 189 if (result && result.length == 2) { 190 chromeVersion = result[1]; 191 } 192 var params = [ 193 new HttpParam('printerid', destination.id), 194 new HttpParam('contentType', 'dataUrl'), 195 new HttpParam('title', documentInfo.title), 196 new HttpParam('ticket', 197 this.createPrintTicket_(destination, printTicketStore)), 198 new HttpParam('content', 'data:application/pdf;base64,' + data), 199 new HttpParam('tag', 200 '__google__chrome_version=' + chromeVersion), 201 new HttpParam('tag', '__google__os=' + navigator.platform) 202 ]; 203 var cpRequest = this.buildRequest_('POST', 'submit', params, 204 destination.origin, 205 this.onSubmitDone_.bind(this)); 206 this.sendOrQueueRequest_(cpRequest); 207 }, 208 209 /** 210 * Sends a Google Cloud Print printer API request. 211 * @param {string} printerId ID of the printer to lookup. 212 * @param {!print_preview.Destination.Origin} origin Origin of the printer. 213 */ 214 printer: function(printerId, origin) { 215 var params = [ 216 new HttpParam('printerid', printerId), 217 new HttpParam('use_cdd', 'true') 218 ]; 219 var cpRequest = 220 this.buildRequest_('GET', 'printer', params, origin, 221 this.onPrinterDone_.bind(this, printerId)); 222 this.sendOrQueueRequest_(cpRequest); 223 }, 224 225 /** 226 * Sends a Google Cloud Print update API request to accept (or reject) the 227 * terms-of-service of the given printer. 228 * @param {string} printerId ID of the printer to accept the 229 * terms-of-service for. 230 * @param {!print_preview.Destination.Origin} origin Origin of the printer. 231 * @param {boolean} isAccepted Whether the user accepted the 232 * terms-of-service. 233 */ 234 updatePrinterTosAcceptance: function(printerId, origin, isAccepted) { 235 var params = [ 236 new HttpParam('printerid', printerId), 237 new HttpParam('is_tos_accepted', isAccepted) 238 ]; 239 var cpRequest = 240 this.buildRequest_('POST', 'update', params, origin, 241 this.onUpdatePrinterTosAcceptanceDone_.bind(this)); 242 this.sendOrQueueRequest_(cpRequest); 243 }, 244 245 /** 246 * Adds event listeners to the relevant native layer events. 247 * @private 248 */ 249 addEventListeners_: function() { 250 this.tracker_.add( 251 this.nativeLayer_, 252 print_preview.NativeLayer.EventType.ACCESS_TOKEN_READY, 253 this.onAccessTokenReady_.bind(this)); 254 }, 255 256 /** 257 * Creates an object that represents a Google Cloud Print print ticket. 258 * @param {!print_preview.Destination} destination Destination to print to. 259 * @param {!print_preview.PrintTicketStore} printTicketStore Used to create 260 * the state of the print ticket. 261 * @return {!Object} Google Cloud Print print ticket. 262 * @private 263 */ 264 createPrintTicket_: function(destination, printTicketStore) { 265 assert(!destination.isLocal, 266 'Trying to create a Google Cloud Print print ticket for a local ' + 267 'destination'); 268 assert(destination.capabilities, 269 'Trying to create a Google Cloud Print print ticket for a ' + 270 'destination with no print capabilities'); 271 var pts = printTicketStore; // For brevity. 272 var cjt = { 273 version: '1.0', 274 print: {} 275 }; 276 if (pts.collate.isCapabilityAvailable() && pts.collate.isUserEdited()) { 277 cjt.print.collate = {collate: pts.collate.getValue() == 'true'}; 278 } 279 if (pts.color.isCapabilityAvailable() && pts.color.isUserEdited()) { 280 var colorType = pts.color.getValue() ? 281 'STANDARD_COLOR' : 'STANDARD_MONOCHROME'; 282 // Find option with this colorType to read its vendor_id. 283 var selectedOptions = destination.capabilities.printer.color.option. 284 filter(function(option) { 285 return option.type == colorType; 286 }); 287 if (selectedOptions.length == 0) { 288 console.error('Could not find correct color option'); 289 } else { 290 cjt.print.color = {type: colorType}; 291 if (selectedOptions[0].hasOwnProperty('vendor_id')) { 292 cjt.print.color.vendor_id = selectedOptions[0].vendor_id; 293 } 294 } 295 } 296 if (pts.copies.isCapabilityAvailable() && pts.copies.isUserEdited()) { 297 cjt.print.copies = {copies: pts.copies.getValueAsNumber()}; 298 } 299 if (pts.duplex.isCapabilityAvailable() && pts.duplex.isUserEdited()) { 300 cjt.print.duplex = 301 {type: pts.duplex.getValue() ? 'LONG_EDGE' : 'NO_DUPLEX'}; 302 } 303 if (pts.hasOrientationCapability()) { 304 cjt.print.page_orientation = 305 {type: pts.isLandscapeEnabled() ? 'LANDSCAPE' : 'PORTRAIT'}; 306 } 307 return JSON.stringify(cjt); 308 }, 309 310 /** 311 * Builds request to the Google Cloud Print API. 312 * @param {string} method HTTP method of the request. 313 * @param {string} action Google Cloud Print action to perform. 314 * @param {Array.<!HttpParam>} params HTTP parameters to include in the 315 * request. 316 * @param {!print_preview.Destination.Origin} origin Origin for destination. 317 * @param {function(number, Object, !print_preview.Destination.Origin)} 318 * callback Callback to invoke when request completes. 319 * @return {!CloudPrintRequest} Partially prepared request. 320 * @private 321 */ 322 buildRequest_: function(method, action, params, origin, callback) { 323 var url = this.baseUrl_ + '/' + action + '?xsrf='; 324 if (origin == print_preview.Destination.Origin.COOKIES) { 325 if (!this.xsrfToken_) { 326 // TODO(rltoscano): Should throw an error if not a read-only action or 327 // issue an xsrf token request. 328 } else { 329 url = url + this.xsrfToken_; 330 } 331 } 332 var body = null; 333 if (params) { 334 if (method == 'GET') { 335 url = params.reduce(function(partialUrl, param) { 336 return partialUrl + '&' + param.name + '=' + 337 encodeURIComponent(param.value); 338 }, url); 339 } else if (method == 'POST') { 340 body = params.reduce(function(partialBody, param) { 341 return partialBody + 'Content-Disposition: form-data; name=\"' + 342 param.name + '\"\r\n\r\n' + param.value + '\r\n--' + 343 CloudPrintInterface.MULTIPART_BOUNDARY_ + '\r\n'; 344 }, '--' + CloudPrintInterface.MULTIPART_BOUNDARY_ + '\r\n'); 345 } 346 } 347 348 var headers = {}; 349 headers['X-CloudPrint-Proxy'] = 'ChromePrintPreview'; 350 if (method == 'GET') { 351 headers['Content-Type'] = CloudPrintInterface.URL_ENCODED_CONTENT_TYPE_; 352 } else if (method == 'POST') { 353 headers['Content-Type'] = CloudPrintInterface.MULTIPART_CONTENT_TYPE_; 354 } 355 356 var xhr = new XMLHttpRequest(); 357 xhr.open(method, url, true); 358 xhr.withCredentials = 359 (origin == print_preview.Destination.Origin.COOKIES); 360 for (var header in headers) { 361 xhr.setRequestHeader(header, headers[header]); 362 } 363 364 return new CloudPrintRequest(xhr, body, origin, callback); 365 }, 366 367 /** 368 * Sends a request to the Google Cloud Print API or queues if it needs to 369 * wait OAuth2 access token. 370 * @param {!CloudPrintRequest} request Request to send or queue. 371 * @private 372 */ 373 sendOrQueueRequest_: function(request) { 374 if (request.origin == print_preview.Destination.Origin.COOKIES) { 375 return this.sendRequest_(request); 376 } else { 377 this.requestQueue_.push(request); 378 this.nativeLayer_.startGetAccessToken(request.origin); 379 } 380 }, 381 382 /** 383 * Sends a request to the Google Cloud Print API. 384 * @param {!CloudPrintRequest} request Request to send. 385 * @private 386 */ 387 sendRequest_: function(request) { 388 request.xhr.onreadystatechange = 389 this.onReadyStateChange_.bind(this, request); 390 request.xhr.send(request.body); 391 }, 392 393 /** 394 * Creates a Google Cloud Print interface error that is ready to dispatch. 395 * @param {!CloudPrintInterface.EventType} type Type of the error. 396 * @param {!CloudPrintRequest} request Request that has been completed. 397 * @return {!cr.Event} Google Cloud Print interface error event. 398 * @private 399 */ 400 createErrorEvent_: function(type, request) { 401 var errorEvent = new cr.Event(type); 402 errorEvent.status = request.xhr.status; 403 if (request.xhr.status == 200) { 404 errorEvent.errorCode = request.result['errorCode']; 405 errorEvent.message = request.result['message']; 406 } else { 407 errorEvent.errorCode = 0; 408 errorEvent.message = ''; 409 } 410 errorEvent.origin = request.origin; 411 return errorEvent; 412 }, 413 414 /** 415 * Called when a native layer receives access token. 416 * @param {cr.Event} evt Contains the authetication type and access token. 417 * @private 418 */ 419 onAccessTokenReady_: function(event) { 420 // TODO(vitalybuka): remove when other Origins implemented. 421 assert(event.authType == print_preview.Destination.Origin.DEVICE); 422 this.requestQueue_ = this.requestQueue_.filter(function(request) { 423 assert(request.origin == print_preview.Destination.Origin.DEVICE); 424 if (request.origin != event.authType) { 425 return true; 426 } 427 if (event.accessToken) { 428 request.xhr.setRequestHeader('Authorization', 429 'Bearer ' + event.accessToken); 430 this.sendRequest_(request); 431 } else { // No valid token. 432 // Without abort status does not exists. 433 request.xhr.abort(); 434 request.callback(request); 435 } 436 return false; 437 }, this); 438 }, 439 440 /** 441 * Called when the ready-state of a XML http request changes. 442 * Calls the successCallback with the result or dispatches an ERROR event. 443 * @param {!CloudPrintRequest} request Request that was changed. 444 * @private 445 */ 446 onReadyStateChange_: function(request) { 447 if (request.xhr.readyState == 4) { 448 if (request.xhr.status == 200) { 449 request.result = JSON.parse(request.xhr.responseText); 450 if (request.origin == print_preview.Destination.Origin.COOKIES && 451 request.result['success']) { 452 this.xsrfToken_ = request.result['xsrf_token']; 453 } 454 } 455 request.status = request.xhr.status; 456 request.callback(request); 457 } 458 }, 459 460 /** 461 * Called when the search request completes. 462 * @param {boolean} isRecent Whether the search request was for recent 463 * destinations. 464 * @param {!CloudPrintRequest} request Request that has been completed. 465 * @private 466 */ 467 onSearchDone_: function(isRecent, request) { 468 --this.outstandingCloudSearchRequestCount_; 469 if (request.xhr.status == 200 && request.result['success']) { 470 var printerListJson = request.result['printers'] || []; 471 var printerList = []; 472 printerListJson.forEach(function(printerJson) { 473 try { 474 printerList.push( 475 cloudprint.CloudDestinationParser.parse(printerJson, 476 request.origin)); 477 } catch (err) { 478 console.error('Unable to parse cloud print destination: ' + err); 479 } 480 }); 481 var searchDoneEvent = 482 new cr.Event(CloudPrintInterface.EventType.SEARCH_DONE); 483 searchDoneEvent.printers = printerList; 484 searchDoneEvent.origin = request.origin; 485 searchDoneEvent.isRecent = isRecent; 486 searchDoneEvent.email = request.result['request']['user']; 487 this.dispatchEvent(searchDoneEvent); 488 } else { 489 var errorEvent = this.createErrorEvent_( 490 CloudPrintInterface.EventType.SEARCH_FAILED, request); 491 this.dispatchEvent(errorEvent); 492 } 493 }, 494 495 /** 496 * Called when the submit request completes. 497 * @param {!CloudPrintRequest} request Request that has been completed. 498 * @private 499 */ 500 onSubmitDone_: function(request) { 501 if (request.xhr.status == 200 && request.result['success']) { 502 var submitDoneEvent = new cr.Event( 503 CloudPrintInterface.EventType.SUBMIT_DONE); 504 submitDoneEvent.jobId = request.result['job']['id']; 505 this.dispatchEvent(submitDoneEvent); 506 } else { 507 var errorEvent = this.createErrorEvent_( 508 CloudPrintInterface.EventType.SUBMIT_FAILED, request); 509 this.dispatchEvent(errorEvent); 510 } 511 }, 512 513 /** 514 * Called when the printer request completes. 515 * @param {string} destinationId ID of the destination that was looked up. 516 * @param {!CloudPrintRequest} request Request that has been completed. 517 * @private 518 */ 519 onPrinterDone_: function(destinationId, request) { 520 if (request.xhr.status == 200 && request.result['success']) { 521 var printerJson = request.result['printers'][0]; 522 var printer; 523 try { 524 printer = cloudprint.CloudDestinationParser.parse(printerJson, 525 request.origin); 526 } catch (err) { 527 console.error('Failed to parse cloud print destination: ' + 528 JSON.stringify(printerJson)); 529 return; 530 } 531 var printerDoneEvent = 532 new cr.Event(CloudPrintInterface.EventType.PRINTER_DONE); 533 printerDoneEvent.printer = printer; 534 this.dispatchEvent(printerDoneEvent); 535 } else { 536 var errorEvent = this.createErrorEvent_( 537 CloudPrintInterface.EventType.PRINTER_FAILED, request); 538 errorEvent.destinationId = destinationId; 539 errorEvent.destinationOrigin = request.origin; 540 this.dispatchEvent(errorEvent, request.origin); 541 } 542 }, 543 544 /** 545 * Called when the update printer TOS acceptance request completes. 546 * @param {!CloudPrintRequest} request Request that has been completed. 547 * @private 548 */ 549 onUpdatePrinterTosAcceptanceDone_: function(request) { 550 if (request.xhr.status == 200 && request.result['success']) { 551 // Do nothing. 552 } else { 553 var errorEvent = this.createErrorEvent_( 554 CloudPrintInterface.EventType.SUBMIT_FAILED, request); 555 this.dispatchEvent(errorEvent); 556 } 557 } 558 }; 559 560 /** 561 * Data structure that holds data for Cloud Print requests. 562 * @param {!XMLHttpRequest} xhr Partially prepared http request. 563 * @param {string} body Data to send with POST requests. 564 * @param {!print_preview.Destination.Origin} origin Origin for destination. 565 * @param {function(!CloudPrintRequest)} callback Callback to invoke when 566 * request completes. 567 * @constructor 568 */ 569 function CloudPrintRequest(xhr, body, origin, callback) { 570 /** 571 * Partially prepared http request. 572 * @type {!XMLHttpRequest} 573 */ 574 this.xhr = xhr; 575 576 /** 577 * Data to send with POST requests. 578 * @type {string} 579 */ 580 this.body = body; 581 582 /** 583 * Origin for destination. 584 * @type {!print_preview.Destination.Origin} 585 */ 586 this.origin = origin; 587 588 /** 589 * Callback to invoke when request completes. 590 * @type {function(!CloudPrintRequest)} 591 */ 592 this.callback = callback; 593 594 /** 595 * Result for requests. 596 * @type {Object} JSON response. 597 */ 598 this.result = null; 599 }; 600 601 /** 602 * Data structure that represents an HTTP parameter. 603 * @param {string} name Name of the parameter. 604 * @param {string} value Value of the parameter. 605 * @constructor 606 */ 607 function HttpParam(name, value) { 608 /** 609 * Name of the parameter. 610 * @type {string} 611 */ 612 this.name = name; 613 614 /** 615 * Name of the value. 616 * @type {string} 617 */ 618 this.value = value; 619 }; 620 621 // Export 622 return { 623 CloudPrintInterface: CloudPrintInterface 624 }; 625}); 626