%247.7\lO44]:szR~Y7 ~ = -; ` i ^{|~j "%ۆPދyj !"NL#֒$%&'"8(Z)`*c+tq,r-u.&6/i0?12 34'5B 6 KLMN-O*"P(Q'/RSTU.VDW>UX }YBZ[n\]i^_N`a̵bc)dLefg4hiOjklmwno p q]rstuLvw xb y z7 {[$ |F& }' ~. 2 6 F I L T "W Z ` Vd f h l _n p v y ~  J ؈ a c ߮ - 3  J  Ta     n   M j[ K  *  ! gM k    > [  K     W  s Q   ! e- / 6 1= R S K_ j Q  M/0Nd;t X ^Dzwf47ܸaVy\t{BkK,ZXrx  ) 0  ja z AZXe !"#$%&'Y(O)4*@+V, -\".$/%0'1A)U578LQWY\ez#u(n###$X$$%%%#&%'j''((7)x)));*z***0+<:Ad|ʈ#|mxy|)9 hDvi~ ^  ~  ;!"3!Q!X!k!\!!"[4"k"w""M"""##1#*{#N#a#$ ,$.$;$d<$$ ԙ$ $ $ 9$ $ ȫ$ $ $ *$ ̳$ ǹ$ $ $ $ $ $ $ $ $ $ $ % P% % [!% 0% 0% k% m% @p% ܃% %! %" %# B%$ a%% %& %' %( v%) %* %+ %, )&- &. ;&/ TX&0 C[&1 +e&2 &3 0&4 &5 &6 m&7 &8 &9 &: 4&; &'< T'= '> '? p(@ ])A )B )C 6)D )E )F m)G ~h*H ^x*I {*J *K a*L *M *N *O m*P *Q @*R +*S "+T +U +V +W +X +Y %+Z (+[ >+\ 'A+] K+^ M+_ %m+` o+a cz+b |+c &+d +e +f +g ܚ+h ߟ+i +j ݬ+k <+l +m R+n +o ݻ+p +q +r +s +t 3+u +v +w +x O+y +z +{ +| C+} +~ + !+ + $, , ^ , , , ', -, i0, ;, >, ;A, D, J, L, 9O, 7R, U, W, Z, ~], o, s, 9, , , , , U, ', , 4- - - 3- |H- ?M- c]- x- - o- - - - - g- @- - 7- z. ?(. ). <1. 1. >C. (D. |M. M. W. X. \. e. h. n. v. z. . P. ʥ. . ѷ. L. . y. H. '. T/ / F!/ W/ h/ / / / e/ W/ 2/ 0 % 0 0 !D0 G0 J0 M0 U0 1p0 r0 ~0 n0 0 :0 0 0 0 0 1 1 2 p2 2 d3 3 l3fPA4ft4f4f34f5f9L5gTT5g\5gf5gg5g5g5g(6g6gژ6gɞ6g6g6gV6g6g6g6g6g6g%6g, 7g7gU+7g.7g<7gj7gt7gR7gh7g7g7g8g8g"8gb8gfy8g8g8gt9g9g9g9g5:g7:g(b:go:g:g:g:g:g:gH;g);g.;gg8;g#B;gw;gx;g;gJ;gE;g;g#<gt <g <g<g,<gI<g?S<gTV<gBr<gz<g<g<gP<g<g<gL<gJ>gd[>>// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * This is a component extension that implements a text-to-speech (TTS) * engine powered by Google's speech synthesis API. * * This is an "event page", so it's not loaded when the API isn't being used, * and doesn't waste resources. When a web page or web app makes a speech * request and the parameters match one of the voices in this extension's * manifest, it makes a request to Google's API using Chrome's private key * and plays the resulting speech using HTML5 audio. */ /** * The main class for this extension. Adds listeners to * chrome.ttsEngine.onSpeak and chrome.ttsEngine.onStop and implements * them using Google's speech synthesis API. * @constructor */ function TtsExtension() {} TtsExtension.prototype = { /** * The url prefix of the speech server, including static query * parameters that don't change. * @type {string} * @const * @private */ SPEECH_SERVER_URL_: 'https://www.google.com/speech-api/v2/synthesize?' + 'enc=mpeg&client=chromium', /** * A mapping from language and gender to voice name, hardcoded for now * until the speech synthesis server capabilities response provides this. * The key of this map is of the form '-'. * @type {Object} * @private */ LANG_AND_GENDER_TO_VOICE_NAME_: { 'en-gb-male': 'rjs', 'en-gb-female': 'fis', }, /** * The arguments passed to the onSpeak event handler for the utterance * that's currently being spoken. Should be null when no object is * pending. * * @type {?{utterance: string, options: Object, callback: Function}} * @private */ currentUtterance_: null, /** * The HTML5 audio element we use for playing the sound served by the * speech server. * @type {HTMLAudioElement} * @private */ audioElement_: null, /** * A mapping from voice name to language and gender, derived from the * manifest file. This is used in case the speech synthesis request * specifies a voice name but doesn't specify a language code or gender. * @type {Object<{lang: string, gender: string}>} * @private */ voiceNameToLangAndGender_: {}, /** * This is the main function called to initialize this extension. * Initializes data structures and adds event listeners. */ init: function() { // Get voices from manifest. var voices = chrome.app.getDetails().tts_engine.voices; for (var i = 0; i < voices.length; i++) { this.voiceNameToLangAndGender_[voices[i].voice_name] = { lang: voices[i].lang, gender: voices[i].gender }; } // Initialize the audio element and event listeners on it. this.audioElement_ = document.createElement('audio'); document.body.appendChild(this.audioElement_); this.audioElement_.addEventListener( 'ended', this.onStop_.bind(this), false); this.audioElement_.addEventListener( 'canplaythrough', this.onStart_.bind(this), false); // Install event listeners for the ttsEngine API. chrome.ttsEngine.onSpeak.addListener(this.onSpeak_.bind(this)); chrome.ttsEngine.onStop.addListener(this.onStop_.bind(this)); chrome.ttsEngine.onPause.addListener(this.onPause_.bind(this)); chrome.ttsEngine.onResume.addListener(this.onResume_.bind(this)); }, /** * Handler for the chrome.ttsEngine.onSpeak interface. * Gets Chrome's Google API key and then uses it to generate a request * url for the requested speech utterance. Sets that url as the source * of the HTML5 audio element. * @param {string} utterance The text to be spoken. * @param {Object} options Options to control the speech, as defined * in the Chrome ttsEngine extension API. * @private */ onSpeak_: function(utterance, options, callback) { // Truncate the utterance if it's too long. Both Chrome's tts // extension api and the web speech api specify 32k as the // maximum limit for an utterance. if (utterance.length > 32768) utterance = utterance.substr(0, 32768); try { // First, stop any pending audio. this.onStop_(); this.currentUtterance_ = { utterance: utterance, options: options, callback: callback }; var lang = options.lang; var gender = options.gender; if (options.voiceName) { lang = this.voiceNameToLangAndGender_[options.voiceName].lang; gender = this.voiceNameToLangAndGender_[options.voiceName].gender; } if (!lang) lang = navigator.language; // Look up the specific voice name for this language and gender. // If it's not in the map, it doesn't matter - the language will // be used directly. This is only used for languages where more // than one gender is actually available. var key = lang.toLowerCase() + '-' + gender; var voiceName = this.LANG_AND_GENDER_TO_VOICE_NAME_[key]; var url = this.SPEECH_SERVER_URL_; chrome.systemPrivate.getApiKey((function(key) { url += '&key=' + key; url += '&text=' + encodeURIComponent(utterance); url += '&lang=' + lang.toLowerCase(); if (voiceName) url += '&name=' + voiceName; if (options.rate) { // Input rate is between 0.1 and 10.0 with a default of 1.0. // Output speed is between 0.0 and 1.0 with a default of 0.5. url += '&speed=' + (options.rate / 2.0); } if (options.pitch) { // Input pitch is between 0.0 and 2.0 with a default of 1.0. // Output pitch is between 0.0 and 1.0 with a default of 0.5. url += '&pitch=' + (options.pitch / 2.0); } // This begins loading the audio but does not play it. // When enough of the audio has loaded to begin playback, // the 'canplaythrough' handler will call this.onStart_, // which sends a start event to the ttsEngine callback and // then begins playing audio. this.audioElement_.src = url; }).bind(this)); } catch (err) { console.error(String(err)); callback({ 'type': 'error', 'errorMessage': String(err) }); this.currentUtterance_ = null; } }, /** * Handler for the chrome.ttsEngine.onStop interface. * Called either when the ttsEngine API requests us to stop, or when * we reach the end of the audio stream. Pause the audio element to * silence it, and send a callback to the ttsEngine API to let it know * that we've completed. Note that the ttsEngine API manages callback * messages and will automatically replace the 'end' event with a * more specific callback like 'interrupted' when sending it to the * TTS client. * @private */ onStop_: function() { if (this.currentUtterance_) { this.audioElement_.pause(); this.currentUtterance_.callback({ 'type': 'end', 'charIndex': this.currentUtterance_.utterance.length }); } this.currentUtterance_ = null; }, /** * Handler for the canplaythrough event on the audio element. * Called when the audio element has buffered enough audio to begin * playback. Send the 'start' event to the ttsEngine callback and * then begin playing the audio element. * @private */ onStart_: function() { if (this.currentUtterance_) { if (this.currentUtterance_.options.volume !== undefined) { // Both APIs use the same range for volume, between 0.0 and 1.0. this.audioElement_.volume = this.currentUtterance_.options.volume; } this.audioElement_.play(); this.currentUtterance_.callback({ 'type': 'start', 'charIndex': 0 }); } }, /** * Handler for the chrome.ttsEngine.onPause interface. * Pauses audio if we're in the middle of an utterance. * @private */ onPause_: function() { if (this.currentUtterance_) { this.audioElement_.pause(); } }, /** * Handler for the chrome.ttsEngine.onPause interface. * Resumes audio if we're in the middle of an utterance. * @private */ onResume_: function() { if (this.currentUtterance_) { this.audioElement_.play(); } } }; (new TtsExtension()).init(); PNG  IHDRaIDATx^MKQƟ{;cQ-,[>*CIEHV.$!XEҢ$mM"XQ~$"*JQ9fS4L{?{.g3s8@ n'椝a 뚪s7} =p+9d&=>$D zّahh`{'ERGMF3kspoyC+L~\Bd bhXk"-ܿZsF(?,PȨeN J+OA&hp_2/PO_HnIENDB`PNG  IHDRaIDATx^MkEg}&i"F[0JBQLk~.\J݈ d! 5VbQ-lMZ#5 5ͽw朑;̢s6<@x[T'1Sޏ 3%ut CbX}= }_ou/BP ,E2_pރ8b,૞AWTI2ѝtp/"e_F j[d4tcS\YYylc;ÿk٬Ģ5HءJ}a?_Bw Qݏ".=6{v'gs K_s zj< (#*N,qf-"6nġj,ࢆ@D!n5<103K )kǾ񇸴|-5Ӓ@HM} 1NjgV*AHI>v{׋8c<75I,L, p0胜'Nv0r]M,]]VH W*fLr=}3_G|&kW>8 %\Gw3nٯ. !33hhT_o> |$ 0#e1кQj\ W."$@S\j'o\=IENDB`// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { 'use strict'; /** @const */ var BookmarkList = bmm.BookmarkList; /** @const */ var BookmarkTree = bmm.BookmarkTree; /** @const */ var Command = cr.ui.Command; /** @const */ var LinkKind = cr.LinkKind; /** @const */ var ListItem = cr.ui.ListItem; /** @const */ var Menu = cr.ui.Menu; /** @const */ var MenuButton = cr.ui.MenuButton; /** @const */ var Splitter = cr.ui.Splitter; /** @const */ var TreeItem = cr.ui.TreeItem; /** * An array containing the BookmarkTreeNodes that were deleted in the last * deletion action. This is used for implementing undo. * @type {?{nodes: Array>, target: EventTarget}} */ var lastDeleted; /** * * Holds the last DOMTimeStamp when mouse pointer hovers on folder in tree * view. Zero means pointer doesn't hover on folder. * @type {number} */ var lastHoverOnFolderTimeStamp = 0; /** * Holds a function that will undo that last action, if global undo is enabled. * @type {Function} */ var performGlobalUndo; /** * Holds a link controller singleton. Use getLinkController() rarther than * accessing this variabie. * @type {cr.LinkController} */ var linkController; /** * New Windows are not allowed in Windows 8 metro mode. */ var canOpenNewWindows = true; /** * Incognito mode availability can take the following values: , * - 'enabled' for when both normal and incognito modes are available; * - 'disabled' for when incognito mode is disabled; * - 'forced' for when incognito mode is forced (normal mode is unavailable). */ var incognitoModeAvailability = 'enabled'; /** * Whether bookmarks can be modified. * @type {boolean} */ var canEdit = true; /** * @type {TreeItem} * @const */ var searchTreeItem = new TreeItem({ bookmarkId: 'q=' }); /** * Command shortcut mapping. * @const */ var commandShortcutMap = cr.isMac ? { 'edit': 'Enter', // On Mac we also allow Meta+Backspace. 'delete': 'U+007F U+0008 Meta-U+0008', 'open-in-background-tab': 'Meta-Enter', 'open-in-new-tab': 'Shift-Meta-Enter', 'open-in-same-window': 'Meta-Down', 'open-in-new-window': 'Shift-Enter', 'rename-folder': 'Enter', // Global undo is Command-Z. It is not in any menu. 'undo': 'Meta-U+005A', } : { 'edit': 'F2', 'delete': 'U+007F', 'open-in-background-tab': 'Ctrl-Enter', 'open-in-new-tab': 'Shift-Ctrl-Enter', 'open-in-same-window': 'Enter', 'open-in-new-window': 'Shift-Enter', 'rename-folder': 'F2', // Global undo is Ctrl-Z. It is not in any menu. 'undo': 'Ctrl-U+005A', }; /** * Mapping for folder id to suffix of UMA. These names will be appeared * after "BookmarkManager_NavigateTo_" in UMA dashboard. * @const */ var folderMetricsNameMap = { '1': 'BookmarkBar', '2': 'Other', '3': 'Mobile', 'q=': 'Search', 'subfolder': 'SubFolder', }; /** * Adds an event listener to a node that will remove itself after firing once. * @param {!Element} node The DOM node to add the listener to. * @param {string} name The name of the event listener to add to. * @param {function(Event)} handler Function called when the event fires. */ function addOneShotEventListener(node, name, handler) { var f = function(e) { handler(e); node.removeEventListener(name, f); }; node.addEventListener(name, f); } // Get the localized strings from the backend via bookmakrManagerPrivate API. function loadLocalizedStrings(data) { // The strings may contain & which we need to strip. for (var key in data) { data[key] = data[key].replace(/&/, ''); } loadTimeData.data = data; i18nTemplate.process(document, loadTimeData); searchTreeItem.label = loadTimeData.getString('search'); searchTreeItem.icon = isRTL() ? 'images/bookmark_manager_search_rtl.png' : 'images/bookmark_manager_search.png'; } /** * Updates the location hash to reflect the current state of the application. */ function updateHash() { window.location.hash = bmm.tree.selectedItem.bookmarkId; updateAllCommands(); } /** * Navigates to a bookmark ID. * @param {string} id The ID to navigate to. * @param {function()=} opt_callback Function called when list view loaded or * displayed specified folder. */ function navigateTo(id, opt_callback) { window.location.hash = id; var sameParent = bmm.list.parentId == id; if (!sameParent) updateParentId(id); updateAllCommands(); var metricsId = folderMetricsNameMap[id.replace(/^q=.*/, 'q=')] || folderMetricsNameMap['subfolder']; chrome.metricsPrivate.recordUserAction( 'BookmarkManager_NavigateTo_' + metricsId); if (opt_callback) { if (sameParent) opt_callback(); else addOneShotEventListener(bmm.list, 'load', opt_callback); } } /** * Updates the parent ID of the bookmark list and selects the correct tree item. * @param {string} id The id. */ function updateParentId(id) { // Setting list.parentId fires 'load' event. bmm.list.parentId = id; // When tree.selectedItem changed, tree view calls navigatTo() then it // calls updateHash() when list view displayed specified folder. bmm.tree.selectedItem = bmm.treeLookup[id] || bmm.tree.selectedItem; } // Process the location hash. This is called by onhashchange and when the page // is first loaded. function processHash() { var id = window.location.hash.slice(1); if (!id) { // If we do not have a hash, select first item in the tree. id = bmm.tree.items[0].bookmarkId; } var valid = false; if (/^e=/.test(id)) { id = id.slice(2); // If hash contains e=, edit the item specified. chrome.bookmarks.get(id, function(bookmarkNodes) { // Verify the node to edit is a valid node. if (!bookmarkNodes || bookmarkNodes.length != 1) return; var bookmarkNode = bookmarkNodes[0]; // After the list reloads, edit the desired bookmark. var editBookmark = function() { var index = bmm.list.dataModel.findIndexById(bookmarkNode.id); if (index != -1) { var sm = bmm.list.selectionModel; sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; scrollIntoViewAndMakeEditable(index); } }; var parentId = assert(bookmarkNode.parentId); navigateTo(parentId, editBookmark); }); // We handle the two cases of navigating to the bookmark to be edited // above. Don't run the standard navigation code below. return; } else if (/^q=/.test(id)) { // In case we got a search hash, update the text input and the // bmm.treeLookup to use the new id. setSearch(id.slice(2)); valid = true; } // Navigate to bookmark 'id' (which may be a query of the form q=query). if (valid) { updateParentId(id); } else { // We need to verify that this is a correct ID. chrome.bookmarks.get(id, function(items) { if (items && items.length == 1) updateParentId(id); }); } } // Activate is handled by the open-in-same-window-command. function handleDoubleClickForList(e) { if (e.button == 0) $('open-in-same-window-command').execute(); } // The list dispatches an event when the user clicks on the URL or the Show in // folder part. function handleUrlClickedForList(e) { getLinkController().openUrlFromEvent(e.url, e.originalEvent); chrome.bookmarkManagerPrivate.recordLaunch(); } function handleSearch(e) { setSearch(this.value); } /** * Navigates to the search results for the search text. * @param {string} searchText The text to search for. */ function setSearch(searchText) { if (searchText) { // Only update search item if we have a search term. We never want the // search item to be for an empty search. delete bmm.treeLookup[searchTreeItem.bookmarkId]; var id = searchTreeItem.bookmarkId = 'q=' + searchText; bmm.treeLookup[searchTreeItem.bookmarkId] = searchTreeItem; } var input = $('term'); // Do not update the input if the user is actively using the text input. if (document.activeElement != input) input.value = searchText; if (searchText) { bmm.tree.add(searchTreeItem); bmm.tree.selectedItem = searchTreeItem; } else { // Go "home". bmm.tree.selectedItem = bmm.tree.items[0]; id = bmm.tree.selectedItem.bookmarkId; } navigateTo(id); } /** * This returns the user visible path to the folder where the bookmark is * located. * @param {number} parentId The ID of the parent folder. * @return {string} The path to the the bookmark, */ function getFolder(parentId) { var parentNode = bmm.tree.getBookmarkNodeById(parentId); if (parentNode) { var s = parentNode.title; if (parentNode.parentId != bmm.ROOT_ID) { return getFolder(parentNode.parentId) + '/' + s; } return s; } } function handleLoadForTree(e) { processHash(); } /** * Returns a promise for all the URLs in the {@code nodes} and the direct * children of {@code nodes}. * @param {!Array} nodes . * @return {!Promise>} . */ function getAllUrls(nodes) { var urls = []; // Adds the node and all its direct children. // TODO(deepak.m1): Here node should exist. When we delete the nodes then // datamodel gets updated but still it shows deleted items as selected items // and accessing those nodes throws chrome.runtime.lastError. This cause // undefined value for node. Please refer https://crbug.com/480935. function addNodes(node) { if (!node || node.id == 'new') return; if (node.children) { node.children.forEach(function(child) { if (!bmm.isFolder(child)) urls.push(child.url); }); } else { urls.push(node.url); } } // Get a future promise for the nodes. var promises = nodes.map(function(node) { if (bmm.isFolder(assert(node))) return bmm.loadSubtree(node.id); // Not a folder so we already have all the data we need. return Promise.resolve(node); }); return Promise.all(promises).then(function(nodes) { nodes.forEach(addNodes); return urls; }); } /** * Returns the nodes (non recursive) to use for the open commands. * @param {HTMLElement} target * @return {!Array} */ function getNodesForOpen(target) { if (target == bmm.tree) { if (bmm.tree.selectedItem != searchTreeItem) return bmm.tree.selectedFolders; // Fall through to use all nodes in the list. } else { var items = bmm.list.selectedItems; if (items.length) return items; } // The list starts off with a null dataModel. We can get here during startup. if (!bmm.list.dataModel) return []; // Return an array based on the dataModel. return bmm.list.dataModel.slice(); } /** * Returns a promise that will contain all URLs of all the selected bookmarks * and the nested bookmarks for use with the open commands. * @param {HTMLElement} target The target list or tree. * @return {Promise>} . */ function getUrlsForOpenCommands(target) { return getAllUrls(getNodesForOpen(target)); } function notNewNode(node) { return node.id != 'new'; } /** * Helper function that updates the canExecute and labels for the open-like * commands. * @param {!cr.ui.CanExecuteEvent} e The event fired by the command system. * @param {!cr.ui.Command} command The command we are currently processing. * @param {string} singularId The string id of singular form of the menu label. * @param {string} pluralId The string id of menu label if the singular form is not used. * @param {boolean} commandDisabled Whether the menu item should be disabled no matter what bookmarks are selected. */ function updateOpenCommand(e, command, singularId, pluralId, commandDisabled) { if (singularId) { // The command label reflects the selection which might not reflect // how many bookmarks will be opened. For example if you right click an // empty area in a folder with 1 bookmark the text should still say "all". var selectedNodes = getSelectedBookmarkNodes(e.target).filter(notNewNode); var singular = selectedNodes.length == 1 && !bmm.isFolder(selectedNodes[0]); command.label = loadTimeData.getString(singular ? singularId : pluralId); } if (commandDisabled) { command.disabled = true; e.canExecute = false; return; } getUrlsForOpenCommands(assertInstanceof(e.target, HTMLElement)).then( function(urls) { var disabled = !urls.length; command.disabled = disabled; e.canExecute = !disabled; }); } /** * Calls the backend to figure out if we can paste the clipboard into the active * folder. * @param {Function=} opt_f Function to call after the state has been updated. */ function updatePasteCommand(opt_f) { function update(commandId, canPaste) { $(commandId).disabled = !canPaste; } var promises = []; // The folders menu. // We can not paste into search item in tree. if (bmm.tree.selectedItem && bmm.tree.selectedItem != searchTreeItem) { promises.push(new Promise(function(resolve) { var id = bmm.tree.selectedItem.bookmarkId; chrome.bookmarkManagerPrivate.canPaste(id, function(canPaste) { update('paste-from-folders-menu-command', canPaste); resolve(canPaste); }); })); } else { // Tree's not loaded yet. update('paste-from-folders-menu-command', false); } // The organize menu. var listId = bmm.list.parentId; if (bmm.list.isSearch() || !listId) { // We cannot paste into search view or the list isn't ready. update('paste-from-organize-menu-command', false); } else { promises.push(new Promise(function(resolve) { chrome.bookmarkManagerPrivate.canPaste(listId, function(canPaste) { update('paste-from-organize-menu-command', canPaste); resolve(canPaste); }); })); } Promise.all(promises).then(function() { var cmd; if (document.activeElement == bmm.list) cmd = 'paste-from-organize-menu-command'; else if (document.activeElement == bmm.tree) cmd = 'paste-from-folders-menu-command'; if (cmd) update('paste-from-context-menu-command', !$(cmd).disabled); if (opt_f) opt_f(); }); } function handleCanExecuteForSearchBox(e) { var command = e.command; switch (command.id) { case 'delete-command': case 'undo-command': // Pass the delete and undo commands through // (fixes http://crbug.com/278112). e.canExecute = false; break; } } function handleCanExecuteForDocument(e) { var command = e.command; switch (command.id) { case 'import-menu-command': e.canExecute = canEdit; break; case 'export-menu-command': // We can always execute the export-menu command. e.canExecute = true; break; case 'sort-command': e.canExecute = !bmm.list.isSearch() && bmm.list.dataModel && bmm.list.dataModel.length > 1 && !isUnmodifiable(bmm.tree.getBookmarkNodeById(bmm.list.parentId)); break; case 'undo-command': // Because the global undo command has no visible UI, always enable it, // and just make it a no-op if undo is not possible. e.canExecute = true; break; default: canExecuteForList(e); if (!e.defaultPrevented) canExecuteForTree(e); break; } } /** * Helper function for handling canExecute for the list and the tree. * @param {!cr.ui.CanExecuteEvent} e Can execute event object. * @param {boolean} isSearch Whether the user is trying to do a command on * search. */ function canExecuteShared(e, isSearch) { var command = e.command; switch (command.id) { case 'paste-from-folders-menu-command': case 'paste-from-organize-menu-command': case 'paste-from-context-menu-command': updatePasteCommand(); break; case 'add-new-bookmark-command': case 'new-folder-command': case 'new-folder-from-folders-menu-command': var parentId = computeParentFolderForNewItem(); var unmodifiable = isUnmodifiable( bmm.tree.getBookmarkNodeById(parentId)); e.canExecute = !isSearch && canEdit && !unmodifiable; break; case 'open-in-new-tab-command': updateOpenCommand(e, command, 'open_in_new_tab', 'open_all', false); break; case 'open-in-background-tab-command': updateOpenCommand(e, command, '', '', false); break; case 'open-in-new-window-command': updateOpenCommand(e, command, 'open_in_new_window', 'open_all_new_window', // Disabled when incognito is forced. incognitoModeAvailability == 'forced' || !canOpenNewWindows); break; case 'open-incognito-window-command': updateOpenCommand(e, command, 'open_incognito', 'open_all_incognito', // Not available when incognito is disabled. incognitoModeAvailability == 'disabled'); break; case 'undo-delete-command': e.canExecute = !!lastDeleted; break; } } /** * Helper function for handling canExecute for the list and document. * @param {!cr.ui.CanExecuteEvent} e Can execute event object. */ function canExecuteForList(e) { function hasSelected() { return !!bmm.list.selectedItem; } function hasSingleSelected() { return bmm.list.selectedItems.length == 1; } function canCopyItem(item) { return item.id != 'new'; } function canCopyItems() { var selectedItems = bmm.list.selectedItems; return selectedItems && selectedItems.some(canCopyItem); } function isSearch() { return bmm.list.isSearch(); } var command = e.command; switch (command.id) { case 'rename-folder-command': // Show rename if a single folder is selected. var items = bmm.list.selectedItems; if (items.length != 1) { e.canExecute = false; command.hidden = true; } else { var isFolder = bmm.isFolder(items[0]); e.canExecute = isFolder && canEdit && !hasUnmodifiable(items); command.hidden = !isFolder; } break; case 'edit-command': // Show the edit command if not a folder. var items = bmm.list.selectedItems; if (items.length != 1) { e.canExecute = false; command.hidden = false; } else { var isFolder = bmm.isFolder(items[0]); e.canExecute = !isFolder && canEdit && !hasUnmodifiable(items); command.hidden = isFolder; } break; case 'show-in-folder-command': e.canExecute = isSearch() && hasSingleSelected(); break; case 'delete-command': case 'cut-command': e.canExecute = canCopyItems() && canEdit && !hasUnmodifiable(bmm.list.selectedItems); break; case 'copy-command': e.canExecute = canCopyItems(); break; case 'open-in-same-window-command': e.canExecute = (e.target == bmm.list) && hasSelected(); break; default: canExecuteShared(e, isSearch()); } } // Update canExecute for the commands when the list is the active element. function handleCanExecuteForList(e) { if (e.target != bmm.list) return; canExecuteForList(e); } // Update canExecute for the commands when the tree is the active element. function handleCanExecuteForTree(e) { if (e.target != bmm.tree) return; canExecuteForTree(e); } function canExecuteForTree(e) { function hasSelected() { return !!bmm.tree.selectedItem; } function isSearch() { return bmm.tree.selectedItem == searchTreeItem; } function isTopLevelItem() { return bmm.tree.selectedItem && bmm.tree.selectedItem.parentNode == bmm.tree; } var command = e.command; switch (command.id) { case 'rename-folder-command': case 'rename-folder-from-folders-menu-command': command.hidden = false; e.canExecute = hasSelected() && !isTopLevelItem() && canEdit && !hasUnmodifiable(bmm.tree.selectedFolders); break; case 'edit-command': command.hidden = true; e.canExecute = false; break; case 'delete-command': case 'delete-from-folders-menu-command': case 'cut-command': case 'cut-from-folders-menu-command': e.canExecute = hasSelected() && !isTopLevelItem() && canEdit && !hasUnmodifiable(bmm.tree.selectedFolders); break; case 'copy-command': case 'copy-from-folders-menu-command': e.canExecute = hasSelected() && !isTopLevelItem(); break; case 'undo-delete-from-folders-menu-command': e.canExecute = lastDeleted && lastDeleted.target == bmm.tree; break; default: canExecuteShared(e, isSearch()); } } /** * Update the canExecute state of all the commands. */ function updateAllCommands() { var commands = document.querySelectorAll('command'); for (var i = 0; i < commands.length; i++) { commands[i].canExecuteChange(); } } function updateEditingCommands() { var editingCommands = [ 'add-new-bookmark', 'cut', 'cut-from-folders-menu', 'delete', 'edit', 'new-folder', 'paste-from-context-menu', 'paste-from-folders-menu', 'paste-from-organize-menu', 'rename-folder', 'sort', ]; chrome.bookmarkManagerPrivate.canEdit(function(result) { if (result != canEdit) { canEdit = result; editingCommands.forEach(function(baseId) { $(baseId + '-command').canExecuteChange(); }); } }); } function handleChangeForTree(e) { navigateTo(bmm.tree.selectedItem.bookmarkId); } function handleMenuButtonClicked(e) { updateEditingCommands(); if (e.currentTarget.id == 'folders-menu') { $('copy-from-folders-menu-command').canExecuteChange(); $('undo-delete-from-folders-menu-command').canExecuteChange(); } else { $('copy-command').canExecuteChange(); } } function handleRename(e) { var item = e.target; var bookmarkNode = item.bookmarkNode; chrome.bookmarks.update(bookmarkNode.id, {title: item.label}); performGlobalUndo = null; // This can't be undone, so disable global undo. } function handleEdit(e) { var item = e.target; var bookmarkNode = item.bookmarkNode; var context = { title: bookmarkNode.title }; if (!bmm.isFolder(bookmarkNode)) context.url = bookmarkNode.url; if (bookmarkNode.id == 'new') { selectItemsAfterUserAction(/** @type {BookmarkList} */(bmm.list)); // New page context.parentId = bookmarkNode.parentId; chrome.bookmarks.create(context, function(node) { // A new node was created and will get added to the list due to the // handler. var dataModel = bmm.list.dataModel; var index = dataModel.indexOf(bookmarkNode); dataModel.splice(index, 1); // Select new item. var newIndex = dataModel.findIndexById(node.id); if (newIndex != -1) { var sm = bmm.list.selectionModel; bmm.list.scrollIndexIntoView(newIndex); sm.leadIndex = sm.anchorIndex = sm.selectedIndex = newIndex; } }); } else { // Edit chrome.bookmarks.update(bookmarkNode.id, context); } performGlobalUndo = null; // This can't be undone, so disable global undo. } function handleCancelEdit(e) { var item = e.target; var bookmarkNode = item.bookmarkNode; if (bookmarkNode.id == 'new') { var dataModel = bmm.list.dataModel; var index = dataModel.findIndexById('new'); dataModel.splice(index, 1); } } /** * Navigates to the folder that the selected item is in and selects it. This is * used for the show-in-folder command. */ function showInFolder() { var bookmarkNode = bmm.list.selectedItem; if (!bookmarkNode) return; var parentId = bookmarkNode.parentId; // After the list is loaded we should select the revealed item. function selectItem() { var index = bmm.list.dataModel.findIndexById(bookmarkNode.id); if (index == -1) return; var sm = bmm.list.selectionModel; sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; bmm.list.scrollIndexIntoView(index); } var treeItem = bmm.treeLookup[parentId]; treeItem.reveal(); navigateTo(parentId, selectItem); } /** * @return {!cr.LinkController} The link controller used to open links based on * user clicks and keyboard actions. */ function getLinkController() { return linkController || (linkController = new cr.LinkController(loadTimeData)); } /** * Returns the selected bookmark nodes of the provided tree or list. * If |opt_target| is not provided or null the active element is used. * Only call this if the list or the tree is focused. * @param {EventTarget=} opt_target The target list or tree. * @return {!Array} Array of bookmark nodes. */ function getSelectedBookmarkNodes(opt_target) { return (opt_target || document.activeElement) == bmm.tree ? bmm.tree.selectedFolders : bmm.list.selectedItems; } /** * @param {EventTarget=} opt_target The target list or tree. * @return {!Array} An array of the selected bookmark IDs. */ function getSelectedBookmarkIds(opt_target) { var selectedNodes = getSelectedBookmarkNodes(opt_target); selectedNodes.sort(function(a, b) { return a.index - b.index }); return selectedNodes.map(function(node) { return node.id; }); } /** * @param {BookmarkTreeNode} node The node to test. * @return {boolean} Whether the given node is unmodifiable. */ function isUnmodifiable(node) { return !!(node && node.unmodifiable); } /** * @param {Array} nodes A list of BookmarkTreeNodes. * @return {boolean} Whether any of the nodes is managed. */ function hasUnmodifiable(nodes) { return nodes.some(isUnmodifiable); } /** * Opens the selected bookmarks. * @param {cr.LinkKind} kind The kind of link we want to open. * @param {HTMLElement=} opt_eventTarget The target of the user initiated event. */ function openBookmarks(kind, opt_eventTarget) { // If we have selected any folders, we need to find all the bookmarks one // level down. We use multiple async calls to getSubtree instead of getting // the whole tree since we would like to minimize the amount of data sent. var urlsP = getUrlsForOpenCommands(opt_eventTarget ? opt_eventTarget : null); urlsP.then(function(urls) { getLinkController().openUrls(assert(urls), kind); chrome.bookmarkManagerPrivate.recordLaunch(); }); } /** * Opens an item in the list. */ function openItem() { var bookmarkNodes = getSelectedBookmarkNodes(); // If we double clicked or pressed enter on a single folder, navigate to it. if (bookmarkNodes.length == 1 && bmm.isFolder(bookmarkNodes[0])) navigateTo(bookmarkNodes[0].id); else openBookmarks(LinkKind.FOREGROUND_TAB); } /** * Refreshes search results after delete or undo-delete. * This ensures children of deleted folders do not remain in results */ function updateSearchResults() { if (bmm.list.isSearch()) bmm.list.reload(); } /** * Deletes the selected bookmarks. The bookmarks are saved in memory in case * the user needs to undo the deletion. * @param {EventTarget=} opt_target The deleter of bookmarks. */ function deleteBookmarks(opt_target) { var selectedIds = getSelectedBookmarkIds(opt_target); if (!selectedIds.length) return; var filteredIds = getFilteredSelectedBookmarkIds(opt_target); lastDeleted = {nodes: [], target: opt_target || document.activeElement}; function performDelete() { // Only remove filtered ids. chrome.bookmarkManagerPrivate.removeTrees(filteredIds); $('undo-delete-command').canExecuteChange(); $('undo-delete-from-folders-menu-command').canExecuteChange(); performGlobalUndo = undoDelete; } // First, store information about the bookmarks being deleted. // Store all selected ids. selectedIds.forEach(function(id) { chrome.bookmarks.getSubTree(id, function(results) { lastDeleted.nodes.push(results); // When all nodes have been saved, perform the deletion. if (lastDeleted.nodes.length === selectedIds.length) { performDelete(); updateSearchResults(); } }); }); } /** * Restores a tree of bookmarks under a specified folder. * @param {BookmarkTreeNode} node The node to restore. * @param {(string|number)=} opt_parentId If a string is passed, it's the ID of * the folder to restore under. If not specified or a number is passed, the * original parentId of the node will be used. */ function restoreTree(node, opt_parentId) { var bookmarkInfo = { parentId: typeof opt_parentId == 'string' ? opt_parentId : node.parentId, title: node.title, index: node.index, url: node.url }; chrome.bookmarks.create(bookmarkInfo, function(result) { if (!result) { console.error('Failed to restore bookmark.'); return; } if (node.children) { // Restore the children using the new ID for this node. node.children.forEach(function(child) { restoreTree(child, result.id); }); } updateSearchResults(); }); } /** * Restores the last set of bookmarks that was deleted. */ function undoDelete() { lastDeleted.nodes.forEach(function(arr) { arr.forEach(restoreTree); }); lastDeleted = null; $('undo-delete-command').canExecuteChange(); $('undo-delete-from-folders-menu-command').canExecuteChange(); // Only a single level of undo is supported, so disable global undo now. performGlobalUndo = null; } /** * Computes folder for "Add Page" and "Add Folder". * @return {string} The id of folder node where we'll create new page/folder. */ function computeParentFolderForNewItem() { if (document.activeElement == bmm.tree) return bmm.list.parentId; var selectedItem = bmm.list.selectedItem; return selectedItem && bmm.isFolder(selectedItem) ? selectedItem.id : bmm.list.parentId; } /** * Callback for rename folder and edit command. This starts editing for * the passed in target, or the selected item. * @param {EventTarget=} opt_target The target to start editing. If absent or * null, the selected item will be edited instead. */ function editItem(opt_target) { if ((opt_target || document.activeElement) == bmm.tree) { bmm.tree.selectedItem.editing = true; } else { var li = bmm.list.getListItem(bmm.list.selectedItem); if (li) li.editing = true; } } /** * Callback for the new folder command. This creates a new folder and starts * a rename of it. * @param {EventTarget=} opt_target The target to create a new folder in. */ function newFolder(opt_target) { performGlobalUndo = null; // This can't be undone, so disable global undo. var parentId = computeParentFolderForNewItem(); var selectedItems = bmm.list.selectedItems; var newIndex; // Callback is called after tree and list data model updated. function createFolder(callback) { if (selectedItems.length == 1 && document.activeElement != bmm.tree && !bmm.isFolder(selectedItems[0]) && selectedItems[0].id != 'new') { newIndex = bmm.list.dataModel.indexOf(selectedItems[0]) + 1; } chrome.bookmarks.create({ title: loadTimeData.getString('new_folder_name'), parentId: parentId, index: newIndex }, callback); } if ((opt_target || document.activeElement) == bmm.tree) { createFolder(function(newNode) { navigateTo(newNode.id, function() { bmm.treeLookup[newNode.id].editing = true; }); }); return; } function editNewFolderInList() { createFolder(function(newNode) { var index = newNode.index; var sm = bmm.list.selectionModel; sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; scrollIntoViewAndMakeEditable(index); }); } navigateTo(parentId, editNewFolderInList); } /** * Scrolls the list item into view and makes it editable. * @param {number} index The index of the item to make editable. */ function scrollIntoViewAndMakeEditable(index) { bmm.list.scrollIndexIntoView(index); // onscroll is now dispatched asynchronously so we have to postpone // the rest. setTimeout(function() { var item = bmm.list.getListItemByIndex(index); if (item) item.editing = true; }, 0); } /** * Adds a page to the current folder. This is called by the * add-new-bookmark-command handler. */ function addPage() { var parentId = computeParentFolderForNewItem(); var selectedItems = bmm.list.selectedItems; var newIndex; function editNewBookmark() { if (selectedItems.length == 1 && document.activeElement != bmm.tree && !bmm.isFolder(selectedItems[0])) { newIndex = bmm.list.dataModel.indexOf(selectedItems[0]) + 1; } var fakeNode = { title: '', url: '', parentId: parentId, index: newIndex, id: 'new' }; var dataModel = bmm.list.dataModel; var index = dataModel.length; if (newIndex != undefined) index = newIndex; dataModel.splice(index, 0, fakeNode); var sm = bmm.list.selectionModel; sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; scrollIntoViewAndMakeEditable(index); }; navigateTo(parentId, editNewBookmark); } /** * This function is used to select items after a user action such as paste, drop * add page etc. * @param {BookmarkList|BookmarkTree} target The target of the user action. * @param {string=} opt_selectedTreeId If provided, then select that tree id. */ function selectItemsAfterUserAction(target, opt_selectedTreeId) { // We get one onCreated event per item so we delay the handling until we get // no more events coming. var ids = []; var timer; function handle(id, bookmarkNode) { clearTimeout(timer); if (opt_selectedTreeId || bmm.list.parentId == bookmarkNode.parentId) ids.push(id); timer = setTimeout(handleTimeout, 50); } function handleTimeout() { chrome.bookmarks.onCreated.removeListener(handle); chrome.bookmarks.onMoved.removeListener(handle); if (opt_selectedTreeId && ids.indexOf(opt_selectedTreeId) != -1) { var index = ids.indexOf(opt_selectedTreeId); if (index != -1 && opt_selectedTreeId in bmm.treeLookup) { bmm.tree.selectedItem = bmm.treeLookup[opt_selectedTreeId]; } } else if (target == bmm.list) { var dataModel = bmm.list.dataModel; var firstIndex = dataModel.findIndexById(ids[0]); var lastIndex = dataModel.findIndexById(ids[ids.length - 1]); if (firstIndex != -1 && lastIndex != -1) { var selectionModel = bmm.list.selectionModel; selectionModel.selectedIndex = -1; selectionModel.selectRange(firstIndex, lastIndex); selectionModel.anchorIndex = selectionModel.leadIndex = lastIndex; bmm.list.focus(); } } bmm.list.endBatchUpdates(); } bmm.list.startBatchUpdates(); chrome.bookmarks.onCreated.addListener(handle); chrome.bookmarks.onMoved.addListener(handle); timer = setTimeout(handleTimeout, 300); } /** * Record user action. * @param {string} name An user action name. */ function recordUserAction(name) { chrome.metricsPrivate.recordUserAction('BookmarkManager_Command_' + name); } /** * The currently selected bookmark, based on where the user is clicking. * @return {string} The ID of the currently selected bookmark (could be from * tree view or list view). */ function getSelectedId() { if (document.activeElement == bmm.tree) return bmm.tree.selectedItem.bookmarkId; var selectedItem = bmm.list.selectedItem; return selectedItem && bmm.isFolder(selectedItem) ? selectedItem.id : bmm.tree.selectedItem.bookmarkId; } /** * Pastes the copied/cutted bookmark into the right location depending whether * if it was called from Organize Menu or from Context Menu. * @param {string} id The id of the element being pasted from. */ function pasteBookmark(id) { recordUserAction('Paste'); selectItemsAfterUserAction(/** @type {BookmarkList} */(bmm.list)); chrome.bookmarkManagerPrivate.paste(id, getSelectedBookmarkIds()); } /** * Returns true if child is contained in another selected folder. * Traces parent nodes up the tree until a selected ancestor or root is found. */ function hasSelectedAncestor(parentNode) { function contains(arr, item) { for (var i = 0; i < arr.length; i++) if (arr[i] === item) return true; return false; } // Don't search top level, cannot select permanent nodes in search. if (parentNode == null || parentNode.id <= 2) return false; // Found selected ancestor. if (contains(getSelectedBookmarkNodes(), parentNode)) return true; // Keep digging. return hasSelectedAncestor( bmm.tree.getBookmarkNodeById(parentNode.parentId)); } /** * @param {EventTarget=} opt_target A target to get bookmark IDs from. * @return {Array} An array of bookmarks IDs. */ function getFilteredSelectedBookmarkIds(opt_target) { // Remove duplicates from filteredIds and return. var filteredIds = []; // Selected nodes to iterate through for matches. var nodes = getSelectedBookmarkNodes(opt_target); for (var i = 0; i < nodes.length; i++) if (!hasSelectedAncestor(bmm.tree.getBookmarkNodeById(nodes[i].parentId))) filteredIds.splice(0, 0, nodes[i].id); return filteredIds; } /** * Handler for the command event. This is used for context menu of list/tree * and organized menu. * @param {!Event} e The event object. */ function handleCommand(e) { var command = e.command; var target = assertInstanceof(e.target, HTMLElement); switch (command.id) { case 'import-menu-command': recordUserAction('Import'); chrome.bookmarks.import(); break; case 'export-menu-command': recordUserAction('Export'); chrome.bookmarks.export(); break; case 'undo-command': if (performGlobalUndo) { recordUserAction('UndoGlobal'); performGlobalUndo(); } else { recordUserAction('UndoNone'); } break; case 'show-in-folder-command': recordUserAction('ShowInFolder'); showInFolder(); break; case 'open-in-new-tab-command': case 'open-in-background-tab-command': recordUserAction('OpenInNewTab'); openBookmarks(LinkKind.BACKGROUND_TAB, target); break; case 'open-in-new-window-command': recordUserAction('OpenInNewWindow'); openBookmarks(LinkKind.WINDOW, target); break; case 'open-incognito-window-command': recordUserAction('OpenIncognito'); openBookmarks(LinkKind.INCOGNITO, target); break; case 'delete-from-folders-menu-command': target = bmm.tree; case 'delete-command': recordUserAction('Delete'); deleteBookmarks(target); break; case 'copy-from-folders-menu-command': target = bmm.tree; case 'copy-command': recordUserAction('Copy'); chrome.bookmarkManagerPrivate.copy(getSelectedBookmarkIds(target), updatePasteCommand); break; case 'cut-from-folders-menu-command': target = bmm.tree; case 'cut-command': recordUserAction('Cut'); chrome.bookmarkManagerPrivate.cut(getSelectedBookmarkIds(target), function() { updatePasteCommand(); updateSearchResults(); }); break; case 'paste-from-organize-menu-command': pasteBookmark(bmm.list.parentId); break; case 'paste-from-folders-menu-command': pasteBookmark(bmm.tree.selectedItem.bookmarkId); break; case 'paste-from-context-menu-command': pasteBookmark(getSelectedId()); break; case 'sort-command': recordUserAction('Sort'); chrome.bookmarkManagerPrivate.sortChildren(bmm.list.parentId); break; case 'rename-folder-from-folders-menu-command': target = bmm.tree; case 'rename-folder-command': editItem(target); break; case 'edit-command': recordUserAction('Edit'); editItem(); break; case 'new-folder-from-folders-menu-command': target = bmm.tree; case 'new-folder-command': recordUserAction('NewFolder'); newFolder(target); break; case 'add-new-bookmark-command': recordUserAction('AddPage'); addPage(); break; case 'open-in-same-window-command': recordUserAction('OpenInSame'); openItem(); break; case 'undo-delete-command': case 'undo-delete-from-folders-menu-command': recordUserAction('UndoDelete'); undoDelete(); break; } } // Execute the copy, cut and paste commands when those events are dispatched by // the browser. This allows us to rely on the browser to handle the keyboard // shortcuts for these commands. function installEventHandlerForCommand(eventName, commandId) { function handle(e) { if (document.activeElement != bmm.list && document.activeElement != bmm.tree) return; var command = $(commandId); if (!command.disabled) { command.execute(); if (e) e.preventDefault(); // Prevent the system beep. } } if (eventName == 'paste') { // Paste is a bit special since we need to do an async call to see if we // can paste because the paste command might not be up to date. document.addEventListener(eventName, function(e) { updatePasteCommand(handle); }); } else { document.addEventListener(eventName, handle); } } function initializeSplitter() { var splitter = document.querySelector('.main > .splitter'); Splitter.decorate(splitter); var splitterStyle = splitter.previousElementSibling.style; // The splitter persists the size of the left component in the local store. if ('treeWidth' in window.localStorage) splitterStyle.width = window.localStorage['treeWidth']; splitter.addEventListener('resize', function(e) { window.localStorage['treeWidth'] = splitterStyle.width; }); } function initializeBookmarkManager() { // Sometimes the extension API is not initialized. if (!chrome.bookmarks) console.error('Bookmarks extension API is not available'); chrome.bookmarkManagerPrivate.getStrings(continueInitializeBookmarkManager); } function continueInitializeBookmarkManager(localizedStrings) { loadLocalizedStrings(localizedStrings); bmm.treeLookup[searchTreeItem.bookmarkId] = searchTreeItem; cr.ui.decorate('cr-menu', Menu); cr.ui.decorate('button[menu]', MenuButton); cr.ui.decorate('command', Command); BookmarkList.decorate($('list')); BookmarkTree.decorate($('tree')); bmm.list.addEventListener('canceledit', handleCancelEdit); bmm.list.addEventListener('canExecute', handleCanExecuteForList); bmm.list.addEventListener('change', updateAllCommands); bmm.list.addEventListener('contextmenu', updateEditingCommands); bmm.list.addEventListener('dblclick', handleDoubleClickForList); bmm.list.addEventListener('edit', handleEdit); bmm.list.addEventListener('rename', handleRename); bmm.list.addEventListener('urlClicked', handleUrlClickedForList); bmm.tree.addEventListener('canExecute', handleCanExecuteForTree); bmm.tree.addEventListener('change', handleChangeForTree); bmm.tree.addEventListener('contextmenu', updateEditingCommands); bmm.tree.addEventListener('rename', handleRename); bmm.tree.addEventListener('load', handleLoadForTree); cr.ui.contextMenuHandler.addContextMenuProperty( /** @type {!Element} */(bmm.tree)); bmm.list.contextMenu = $('context-menu'); bmm.tree.contextMenu = $('context-menu'); // We listen to hashchange so that we can update the currently shown folder // when // the user goes back and forward in the history. window.addEventListener('hashchange', processHash); document.querySelector('header form').onsubmit = /** @type {function(Event=)} */(function(e) { setSearch($('term').value); e.preventDefault(); }); $('term').addEventListener('search', handleSearch); $('term').addEventListener('canExecute', handleCanExecuteForSearchBox); $('folders-button').addEventListener('click', handleMenuButtonClicked); $('organize-button').addEventListener('click', handleMenuButtonClicked); document.addEventListener('canExecute', handleCanExecuteForDocument); document.addEventListener('command', handleCommand); // Listen to copy, cut and paste events and execute the associated commands. installEventHandlerForCommand('copy', 'copy-command'); installEventHandlerForCommand('cut', 'cut-command'); installEventHandlerForCommand('paste', 'paste-from-organize-menu-command'); // Install shortcuts for (var name in commandShortcutMap) { $(name + '-command').shortcut = commandShortcutMap[name]; } // Disable almost all commands at startup. var commands = document.querySelectorAll('command'); for (var i = 0, command; command = commands[i]; ++i) { if (command.id != 'import-menu-command' && command.id != 'export-menu-command') { command.disabled = true; } } chrome.bookmarkManagerPrivate.canEdit(function(result) { canEdit = result; }); chrome.systemPrivate.getIncognitoModeAvailability(function(result) { // TODO(rustema): propagate policy value to the bookmark manager when it // changes. incognitoModeAvailability = result; }); chrome.bookmarkManagerPrivate.canOpenNewWindows(function(result) { canOpenNewWindows = result; }); cr.ui.FocusOutlineManager.forDocument(document); initializeSplitter(); bmm.addBookmarkModelListeners(); dnd.init(selectItemsAfterUserAction); bmm.tree.reload(); } initializeBookmarkManager(); })(); // Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // TODO(arv): Now that this is driven by a data model, implement a data model // that handles the loading and the events from the bookmark backend. /** * @typedef {{childIds: Array}} * * @see chrome/common/extensions/api/bookmarks.json */ var ReorderInfo; /** * @typedef {{parentId: string, * index: number, * oldParentId: string, * oldIndex: number}} * * @see chrome/common/extensions/api/bookmarks.json */ var MoveInfo; cr.define('bmm', function() { 'use strict'; var List = cr.ui.List; var ListItem = cr.ui.ListItem; var ArrayDataModel = cr.ui.ArrayDataModel; var ContextMenuButton = cr.ui.ContextMenuButton; /** * Basic array data model for use with bookmarks. * @param {!Array} items The bookmark items. * @constructor * @extends {ArrayDataModel} */ function BookmarksArrayDataModel(items) { ArrayDataModel.call(this, items); } BookmarksArrayDataModel.prototype = { __proto__: ArrayDataModel.prototype, /** * Finds the index of the bookmark with the given ID. * @param {string} id The ID of the bookmark node to find. * @return {number} The index of the found node or -1 if not found. */ findIndexById: function(id) { for (var i = 0; i < this.length; i++) { if (this.item(i).id == id) return i; } return -1; } }; /** * Removes all children and appends a new child. * @param {!Node} parent The node to remove all children from. * @param {!Node} newChild The new child to append. */ function replaceAllChildren(parent, newChild) { var n; while ((n = parent.lastChild)) { parent.removeChild(n); } parent.appendChild(newChild); } /** * Creates a new bookmark list. * @param {Object=} opt_propertyBag Optional properties. * @constructor * @extends {cr.ui.List} */ var BookmarkList = cr.ui.define('list'); BookmarkList.prototype = { __proto__: List.prototype, /** @override */ decorate: function() { List.prototype.decorate.call(this); this.addEventListener('mousedown', this.handleMouseDown_); // HACK(arv): http://crbug.com/40902 window.addEventListener('resize', this.redraw.bind(this)); // We could add the ContextMenuButton in the BookmarkListItem but it slows // down redraws a lot so we do this on mouseovers instead. this.addEventListener('mouseover', this.handleMouseOver_.bind(this)); bmm.list = this; }, /** * @param {!BookmarkTreeNode} bookmarkNode * @override */ createItem: function(bookmarkNode) { return new BookmarkListItem(bookmarkNode); }, /** @private {string} */ parentId_: '', /** @private {number} */ loadCount_: 0, /** * Reloads the list from the bookmarks backend. */ reload: function() { var parentId = this.parentId; var callback = this.handleBookmarkCallback_.bind(this); this.loadCount_++; if (!parentId) callback([]); else if (/^q=/.test(parentId)) chrome.bookmarks.search(parentId.slice(2), callback); else chrome.bookmarks.getChildren(parentId, callback); }, /** * Callback function for loading items. * @param {Array} items The loaded items. * @private */ handleBookmarkCallback_: function(items) { this.loadCount_--; if (this.loadCount_) return; if (!items) { // Failed to load bookmarks. Most likely due to the bookmark being // removed. cr.dispatchSimpleEvent(this, 'invalidId'); return; } this.dataModel = new BookmarksArrayDataModel(items); this.fixWidth_(); cr.dispatchSimpleEvent(this, 'load'); }, /** * The bookmark node that the list is currently displaying. If we are * currently displaying search this returns null. * @type {BookmarkTreeNode} */ get bookmarkNode() { if (this.isSearch()) return null; var treeItem = bmm.treeLookup[this.parentId]; return treeItem && treeItem.bookmarkNode; }, /** * @return {boolean} Whether we are currently showing search results. */ isSearch: function() { return this.parentId_[0] == 'q'; }, /** * @return {boolean} Whether we are editing an ephemeral item. */ hasEphemeral: function() { var dataModel = this.dataModel; for (var i = 0; i < dataModel.array_.length; i++) { if (dataModel.array_[i].id == 'new') return true; } return false; }, /** * Handles mouseover on the list so that we can add the context menu button * lazily. * @private * @param {!Event} e The mouseover event object. */ handleMouseOver_: function(e) { var el = e.target; while (el && el.parentNode != this) { el = el.parentNode; } if (el && el.parentNode == this && !el.editing && !(el.lastChild instanceof ContextMenuButton)) { el.appendChild(new ContextMenuButton); } }, /** * Dispatches an urlClicked event which is used to open URLs in new * tabs etc. * @private * @param {string} url The URL that was clicked. * @param {!Event} originalEvent The original click event object. */ dispatchUrlClickedEvent_: function(url, originalEvent) { var event = new Event('urlClicked', {bubbles: true}); event.url = url; event.originalEvent = originalEvent; this.dispatchEvent(event); }, /** * Handles mousedown events so that we can prevent the auto scroll as * necessary. * @private * @param {!Event} e The mousedown event object. */ handleMouseDown_: function(e) { e = /** @type {!MouseEvent} */(e); if (e.button == 1) { // WebKit no longer fires click events for middle clicks so we manually // listen to mouse up to dispatch a click event. this.addEventListener('mouseup', this.handleMiddleMouseUp_); // When the user does a middle click we need to prevent the auto scroll // in case the user is trying to middle click to open a bookmark in a // background tab. // We do not do this in case the target is an input since middle click // is also paste on Linux and we don't want to break that. if (e.target.tagName != 'INPUT') e.preventDefault(); } }, /** * WebKit no longer dispatches click events for middle clicks so we need * to emulate it. * @private * @param {!Event} e The mouse up event object. */ handleMiddleMouseUp_: function(e) { e = /** @type {!MouseEvent} */(e); this.removeEventListener('mouseup', this.handleMiddleMouseUp_); if (e.button == 1) { var el = e.target; while (el.parentNode != this) { el = el.parentNode; } var node = el.bookmarkNode; if (node && !bmm.isFolder(node)) this.dispatchUrlClickedEvent_(node.url, e); } e.preventDefault(); }, // Bookmark model update callbacks handleBookmarkChanged: function(id, changeInfo) { var dataModel = this.dataModel; var index = dataModel.findIndexById(id); if (index != -1) { var bookmarkNode = this.dataModel.item(index); bookmarkNode.title = changeInfo.title; if ('url' in changeInfo) bookmarkNode.url = changeInfo['url']; dataModel.updateIndex(index); } }, /** * @param {string} id * @param {ReorderInfo} reorderInfo */ handleChildrenReordered: function(id, reorderInfo) { if (this.parentId == id) { // We create a new data model with updated items in the right order. var dataModel = this.dataModel; var items = {}; for (var i = this.dataModel.length - 1; i >= 0; i--) { var bookmarkNode = dataModel.item(i); items[bookmarkNode.id] = bookmarkNode; } var newArray = []; for (var i = 0; i < reorderInfo.childIds.length; i++) { newArray[i] = items[reorderInfo.childIds[i]]; newArray[i].index = i; } this.dataModel = new BookmarksArrayDataModel(newArray); } }, handleCreated: function(id, bookmarkNode) { if (this.parentId == bookmarkNode.parentId) this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode); }, /** * @param {string} id * @param {MoveInfo} moveInfo */ handleMoved: function(id, moveInfo) { if (moveInfo.parentId == this.parentId || moveInfo.oldParentId == this.parentId) { var dataModel = this.dataModel; if (moveInfo.oldParentId == moveInfo.parentId) { // Reorder within this folder this.startBatchUpdates(); var bookmarkNode = this.dataModel.item(moveInfo.oldIndex); this.dataModel.splice(moveInfo.oldIndex, 1); this.dataModel.splice(moveInfo.index, 0, bookmarkNode); this.endBatchUpdates(); } else { if (moveInfo.oldParentId == this.parentId) { // Move out of this folder var index = dataModel.findIndexById(id); if (index != -1) dataModel.splice(index, 1); } if (moveInfo.parentId == this.parentId) { // Move to this folder var self = this; chrome.bookmarks.get(id, function(bookmarkNodes) { var bookmarkNode = bookmarkNodes[0]; dataModel.splice(bookmarkNode.index, 0, bookmarkNode); }); } } } }, handleRemoved: function(id, removeInfo) { var dataModel = this.dataModel; var index = dataModel.findIndexById(id); if (index != -1) dataModel.splice(index, 1); }, /** * Workaround for http://crbug.com/40902 * @private */ fixWidth_: function() { var list = bmm.list; if (this.loadCount_ || !list) return; // The width of the list is wrong after its content has changed. // Fortunately the reported offsetWidth is correct so we can detect the //incorrect width. if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) { // Set the width to the correct size. This causes the relayout. list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px'; // Remove the temporary style.width in a timeout. Once the timer fires // the size should not change since we already fixed the width. window.setTimeout(function() { list.style.width = ''; }, 0); } } }; /** * The ID of the bookmark folder we are displaying. */ cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS, function() { this.reload(); }); /** * The contextMenu property. */ cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList); /** @type {cr.ui.Menu} */ BookmarkList.prototype.contextMenu; /** * Creates a new bookmark list item. * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents. * @constructor * @extends {cr.ui.ListItem} */ function BookmarkListItem(bookmarkNode) { var el = cr.doc.createElement('div'); el.bookmarkNode = bookmarkNode; BookmarkListItem.decorate(el); return el; } /** * Decorates an element as a bookmark list item. * @param {!HTMLElement} el The element to decorate. */ BookmarkListItem.decorate = function(el) { el.__proto__ = BookmarkListItem.prototype; el.decorate(); }; BookmarkListItem.prototype = { __proto__: ListItem.prototype, /** @override */ decorate: function() { ListItem.prototype.decorate.call(this); var bookmarkNode = this.bookmarkNode; this.draggable = true; var labelEl = this.ownerDocument.createElement('div'); labelEl.className = 'label'; labelEl.textContent = bookmarkNode.title; var urlEl = this.ownerDocument.createElement('div'); urlEl.className = 'url'; if (bmm.isFolder(bookmarkNode)) { this.className = 'folder'; } else { labelEl.style.backgroundImage = getFaviconImageSet(bookmarkNode.url); labelEl.style.backgroundSize = '16px'; urlEl.textContent = bookmarkNode.url; } this.appendChild(labelEl); this.appendChild(urlEl); // Initially the ContextMenuButton was added here but it slowed down // rendering a lot so it is now added using mouseover. }, /** * The ID of the bookmark folder we are currently showing or loading. * @type {string} */ get bookmarkId() { return this.bookmarkNode.id; }, /** * Whether the user is currently able to edit the list item. * @type {boolean} */ get editing() { return this.hasAttribute('editing'); }, set editing(editing) { var oldEditing = this.editing; if (oldEditing == editing) return; var url = this.bookmarkNode.url; var title = this.bookmarkNode.title; var isFolder = bmm.isFolder(this.bookmarkNode); var listItem = this; var labelEl = this.firstChild; var urlEl = labelEl.nextSibling; var labelInput, urlInput; // Handles enter and escape which trigger reset and commit respectively. function handleKeydown(e) { // Make sure that the tree does not handle the key. e.stopPropagation(); // Calling list.focus blurs the input which will stop editing the list // item. switch (e.keyIdentifier) { case 'U+001B': // Esc labelInput.value = title; if (!isFolder) urlInput.value = url; // fall through cr.dispatchSimpleEvent(listItem, 'canceledit', true); case 'Enter': if (listItem.parentNode) listItem.parentNode.focus(); break; case 'U+0009': // Tab // urlInput is the last focusable element in the page. If we // allowed Tab focus navigation and the page loses focus, we // couldn't give focus on urlInput programatically. So, we prevent // Tab focus navigation. if (document.activeElement == urlInput && !e.ctrlKey && !e.metaKey && !e.shiftKey && !getValidURL(urlInput)) { e.preventDefault(); urlInput.blur(); } break; } } function getValidURL(input) { var originalValue = input.value; if (!originalValue) return null; if (input.validity.valid) return originalValue; // Blink does not do URL fix up so we manually test if prepending // 'http://' would make the URL valid. // https://bugs.webkit.org/show_bug.cgi?id=29235 input.value = 'http://' + originalValue; if (input.validity.valid) return input.value; // still invalid input.value = originalValue; return null; } function handleBlur(e) { // When the blur event happens we do not know who is getting focus so we // delay this a bit since we want to know if the other input got focus // before deciding if we should exit edit mode. var doc = e.target.ownerDocument; window.setTimeout(function() { var activeElement = doc.hasFocus() && doc.activeElement; if (activeElement != urlInput && activeElement != labelInput) { listItem.editing = false; } }, 50); } var doc = this.ownerDocument; if (editing) { this.setAttribute('editing', ''); this.draggable = false; labelInput = /** @type {HTMLElement} */(doc.createElement('input')); labelInput.placeholder = loadTimeData.getString('name_input_placeholder'); replaceAllChildren(labelEl, labelInput); labelInput.value = title; if (!isFolder) { urlInput = /** @type {HTMLElement} */(doc.createElement('input')); urlInput.type = 'url'; urlInput.required = true; urlInput.placeholder = loadTimeData.getString('url_input_placeholder'); // We also need a name for the input for the CSS to work. urlInput.name = '-url-input-' + cr.createUid(); replaceAllChildren(assert(urlEl), urlInput); urlInput.value = url; } var stopPropagation = function(e) { e.stopPropagation(); }; var eventsToStop = ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste']; eventsToStop.forEach(function(type) { labelInput.addEventListener(type, stopPropagation); }); labelInput.addEventListener('keydown', handleKeydown); labelInput.addEventListener('blur', handleBlur); cr.ui.limitInputWidth(labelInput, this, 100, 0.5); labelInput.focus(); labelInput.select(); if (!isFolder) { eventsToStop.forEach(function(type) { urlInput.addEventListener(type, stopPropagation); }); urlInput.addEventListener('keydown', handleKeydown); urlInput.addEventListener('blur', handleBlur); cr.ui.limitInputWidth(urlInput, this, 200, 0.5); } } else { // Check that we have a valid URL and if not we do not change the // editing mode. if (!isFolder) { var urlInput = this.querySelector('.url input'); var newUrl = urlInput.value; if (!newUrl) { cr.dispatchSimpleEvent(this, 'canceledit', true); return; } newUrl = getValidURL(urlInput); if (!newUrl) { // In case the item was removed before getting here we should // not alert. if (listItem.parentNode) { // Select the item again. var dataModel = this.parentNode.dataModel; var index = dataModel.indexOf(this.bookmarkNode); var sm = this.parentNode.selectionModel; sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index; alert(loadTimeData.getString('invalid_url')); } urlInput.focus(); urlInput.select(); return; } urlEl.textContent = this.bookmarkNode.url = newUrl; } this.removeAttribute('editing'); this.draggable = true; labelInput = this.querySelector('.label input'); var newLabel = labelInput.value; labelEl.textContent = this.bookmarkNode.title = newLabel; if (isFolder) { if (newLabel != title) { cr.dispatchSimpleEvent(this, 'rename', true); } } else if (newLabel != title || newUrl != url) { cr.dispatchSimpleEvent(this, 'edit', true); } } } }; return { BookmarkList: BookmarkList, list: /** @type {Element} */(null), // Set when decorated. }; }); // Copyright (c) 2011 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('bmm', function() { 'use strict'; /** * The id of the bookmark root. * @type {string} * @const */ var ROOT_ID = '0'; /** @const */ var Tree = cr.ui.Tree; /** @const */ var TreeItem = cr.ui.TreeItem; /** @const */ var localStorage = window.localStorage; var treeLookup = {}; // Manager for persisting the expanded state. var expandedManager = /** @type {EventListener} */({ /** * A map of the collapsed IDs. * @type {Object} */ map: 'bookmarkTreeState' in localStorage ? /** @type {Object} */(JSON.parse(localStorage['bookmarkTreeState'])) : {}, /** * Set the collapsed state for an ID. * @param {string} id The bookmark ID of the tree item that was expanded or * collapsed. * @param {boolean} expanded Whether the tree item was expanded. */ set: function(id, expanded) { if (expanded) delete this.map[id]; else this.map[id] = 1; this.save(); }, /** * @param {string} id The bookmark ID. * @return {boolean} Whether the tree item should be expanded. */ get: function(id) { return !(id in this.map); }, /** * Callback for the expand and collapse events from the tree. * @param {!Event} e The collapse or expand event. */ handleEvent: function(e) { this.set(e.target.bookmarkId, e.type == 'expand'); }, /** * Cleans up old bookmark IDs. */ cleanUp: function() { for (var id in this.map) { // If the id is no longer in the treeLookup the bookmark no longer // exists. if (!(id in treeLookup)) delete this.map[id]; } this.save(); }, timer: null, /** * Saves the expanded state to the localStorage. */ save: function() { clearTimeout(this.timer); var map = this.map; // Save in a timeout so that we can coalesce multiple changes. this.timer = setTimeout(function() { localStorage['bookmarkTreeState'] = JSON.stringify(map); }, 100); } }); // Clean up once per session but wait until things settle down a bit. setTimeout(expandedManager.cleanUp.bind(expandedManager), 1e4); /** * Creates a new tree item for a bookmark node. * @param {!Object} bookmarkNode The bookmark node. * @constructor * @extends {TreeItem} */ function BookmarkTreeItem(bookmarkNode) { var ti = new TreeItem({ label: bookmarkNode.title, bookmarkNode: bookmarkNode, // Bookmark toolbar and Other bookmarks are not draggable. draggable: bookmarkNode.parentId != ROOT_ID }); ti.__proto__ = BookmarkTreeItem.prototype; treeLookup[bookmarkNode.id] = ti; return ti; } BookmarkTreeItem.prototype = { __proto__: TreeItem.prototype, /** * The ID of the bookmark this tree item represents. * @type {string} */ get bookmarkId() { return this.bookmarkNode.id; } }; /** * Asynchronousy adds a tree item at the correct index based on the bookmark * backend. * * Since the bookmark tree only contains folders the index we get from certain * callbacks is not very useful so we therefore have this async call which * gets the children of the parent and adds the tree item at the desired * index. * * This also exoands the parent so that newly added children are revealed. * * @param {!cr.ui.TreeItem} parent The parent tree item. * @param {!cr.ui.TreeItem} treeItem The tree item to add. * @param {Function=} opt_f A function which gets called after the item has * been added at the right index. */ function addTreeItem(parent, treeItem, opt_f) { chrome.bookmarks.getChildren(parent.bookmarkNode.id, function(children) { var isFolder = /** * @type {function (BookmarkTreeNode, number, * Array<(BookmarkTreeNode)>)} */(bmm.isFolder); var index = children.filter(isFolder).map(function(item) { return item.id; }).indexOf(treeItem.bookmarkNode.id); parent.addAt(treeItem, index); parent.expanded = true; if (opt_f) opt_f(); }); } /** * Creates a new bookmark list. * @param {Object=} opt_propertyBag Optional properties. * @constructor * @extends {cr.ui.Tree} */ var BookmarkTree = cr.ui.define('tree'); BookmarkTree.prototype = { __proto__: Tree.prototype, decorate: function() { Tree.prototype.decorate.call(this); this.addEventListener('expand', expandedManager); this.addEventListener('collapse', expandedManager); bmm.tree = this; }, handleBookmarkChanged: function(id, changeInfo) { var treeItem = treeLookup[id]; if (treeItem) treeItem.label = treeItem.bookmarkNode.title = changeInfo.title; }, /** * @param {string} id * @param {ReorderInfo} reorderInfo */ handleChildrenReordered: function(id, reorderInfo) { var parentItem = treeLookup[id]; // The tree only contains folders. var dirIds = reorderInfo.childIds.filter(function(id) { return id in treeLookup; }).forEach(function(id, i) { parentItem.addAt(treeLookup[id], i); }); }, handleCreated: function(id, bookmarkNode) { if (bmm.isFolder(bookmarkNode)) { var parentItem = treeLookup[bookmarkNode.parentId]; var newItem = new BookmarkTreeItem(bookmarkNode); addTreeItem(parentItem, newItem); } }, /** * @param {string} id * @param {MoveInfo} moveInfo */ handleMoved: function(id, moveInfo) { var treeItem = treeLookup[id]; if (treeItem) { var oldParentItem = treeLookup[moveInfo.oldParentId]; oldParentItem.remove(treeItem); var newParentItem = treeLookup[moveInfo.parentId]; // The tree only shows folders so the index is not the index we want. We // therefore get the children need to adjust the index. addTreeItem(newParentItem, treeItem); } }, handleRemoved: function(id, removeInfo) { var parentItem = treeLookup[removeInfo.parentId]; var itemToRemove = treeLookup[id]; if (parentItem && itemToRemove) parentItem.remove(itemToRemove); }, insertSubtree: function(folder) { if (!bmm.isFolder(folder)) return; var children = folder.children; this.handleCreated(folder.id, folder); for (var i = 0; i < children.length; i++) { var child = children[i]; this.insertSubtree(child); } }, /** * Returns the bookmark node with the given ID. The tree only maintains * folder nodes. * @param {string} id The ID of the node to find. * @return {BookmarkTreeNode} The bookmark tree node or null if not found. */ getBookmarkNodeById: function(id) { var treeItem = treeLookup[id]; if (treeItem) return treeItem.bookmarkNode; return null; }, /** * Returns the selected bookmark folder node as an array. * @type {!Array} Array of bookmark nodes. */ get selectedFolders() { return this.selectedItem && this.selectedItem.bookmarkNode ? [this.selectedItem.bookmarkNode] : []; }, /** * Fetches the bookmark items and builds the tree control. */ reload: function() { /** * Recursive helper function that adds all the directories to the * parentTreeItem. * @param {!cr.ui.Tree|!cr.ui.TreeItem} parentTreeItem The parent tree * element to append to. * @param {!Array} bookmarkNodes A list of bookmark * nodes to be added. * @return {boolean} Whether any directories where added. */ function buildTreeItems(parentTreeItem, bookmarkNodes) { var hasDirectories = false; for (var i = 0, bookmarkNode; bookmarkNode = bookmarkNodes[i]; i++) { if (bmm.isFolder(bookmarkNode)) { hasDirectories = true; var item = new BookmarkTreeItem(bookmarkNode); parentTreeItem.add(item); var children = assert(bookmarkNode.children); var anyChildren = buildTreeItems(item, children); item.expanded = anyChildren && expandedManager.get(bookmarkNode.id); } } return hasDirectories; } var self = this; chrome.bookmarkManagerPrivate.getSubtree('', true, function(root) { self.clear(); buildTreeItems(self, root[0].children); cr.dispatchSimpleEvent(self, 'load'); }); }, /** * Clears the tree. */ clear: function() { // Remove all fields without recreating the object since other code // references it. for (var id in treeLookup) { delete treeLookup[id]; } this.textContent = ''; }, /** @override */ remove: function(child) { Tree.prototype.remove.call(this, child); if (child.bookmarkNode) delete treeLookup[child.bookmarkNode.id]; } }; return { BookmarkTree: BookmarkTree, BookmarkTreeItem: BookmarkTreeItem, treeLookup: treeLookup, tree: /** @type {Element} */(null), // Set when decorated. ROOT_ID: ROOT_ID }; }); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('dnd', function() { 'use strict'; /** @const */ var BookmarkList = bmm.BookmarkList; /** @const */ var ListItem = cr.ui.ListItem; /** @const */ var TreeItem = cr.ui.TreeItem; /** * Enumeration of valid drop locations relative to an element. These are * bit masks to allow combining multiple locations in a single value. * @enum {number} * @const */ var DropPosition = { NONE: 0, ABOVE: 1, ON: 2, BELOW: 4 }; /** * @type {Object} Drop information calculated in |handleDragOver|. */ var dropDestination = null; /** * @type {number} Timer id used to help minimize flicker. */ var removeDropIndicatorTimer; /** * The element currently targeted by a touch. * @type {Element} */ var currentTouchTarget; /** * The element that had a style applied it to indicate the drop location. * This is used to easily remove the style when necessary. * @type {Element} */ var lastIndicatorElement; /** * The style that was applied to indicate the drop location. * @type {?string} */ var lastIndicatorClassName; var dropIndicator = { /** * Applies the drop indicator style on the target element and stores that * information to easily remove the style in the future. */ addDropIndicatorStyle: function(indicatorElement, position) { var indicatorStyleName = position == DropPosition.ABOVE ? 'drag-above' : position == DropPosition.BELOW ? 'drag-below' : 'drag-on'; lastIndicatorElement = indicatorElement; lastIndicatorClassName = indicatorStyleName; indicatorElement.classList.add(indicatorStyleName); }, /** * Clears the drop indicator style from the last element was the drop target * so the drop indicator is no longer for that element. */ removeDropIndicatorStyle: function() { if (!lastIndicatorElement || !lastIndicatorClassName) return; lastIndicatorElement.classList.remove(lastIndicatorClassName); lastIndicatorElement = null; lastIndicatorClassName = null; }, /** * Displays the drop indicator on the current drop target to give the * user feedback on where the drop will occur. */ update: function(dropDest) { window.clearTimeout(removeDropIndicatorTimer); var indicatorElement = dropDest.element; var position = dropDest.position; if (dropDest.element instanceof BookmarkList) { // For an empty bookmark list use 'drop-above' style. position = DropPosition.ABOVE; } else if (dropDest.element instanceof TreeItem) { indicatorElement = indicatorElement.querySelector('.tree-row'); } dropIndicator.removeDropIndicatorStyle(); dropIndicator.addDropIndicatorStyle(indicatorElement, position); }, /** * Stop displaying the drop indicator. */ finish: function() { // The use of a timeout is in order to reduce flickering as we move // between valid drop targets. window.clearTimeout(removeDropIndicatorTimer); removeDropIndicatorTimer = window.setTimeout(function() { dropIndicator.removeDropIndicatorStyle(); }, 100); } }; /** * Delay for expanding folder when pointer hovers on folder in tree view in * milliseconds. * @type {number} * @const */ // TODO(yosin): EXPAND_FOLDER_DELAY should follow system settings. 400ms is // taken from Windows default settings. var EXPAND_FOLDER_DELAY = 400; /** * The timestamp when the mouse was over a folder during a drag operation. * Used to open the hovered folder after a certain time. * @type {number} */ var lastHoverOnFolderTimeStamp = 0; /** * Expand a folder if the user has hovered for longer than the specified * time during a drag action. */ function updateAutoExpander(eventTimeStamp, overElement) { // Expands a folder in tree view when pointer hovers on it longer than // EXPAND_FOLDER_DELAY. var hoverOnFolderTimeStamp = lastHoverOnFolderTimeStamp; lastHoverOnFolderTimeStamp = 0; if (hoverOnFolderTimeStamp) { if (eventTimeStamp - hoverOnFolderTimeStamp >= EXPAND_FOLDER_DELAY) overElement.expanded = true; else lastHoverOnFolderTimeStamp = hoverOnFolderTimeStamp; } else if (overElement instanceof TreeItem && bmm.isFolder(overElement.bookmarkNode) && overElement.hasChildren && !overElement.expanded) { lastHoverOnFolderTimeStamp = eventTimeStamp; } } /** * Stores the information about the bookmark and folders being dragged. * @type {Object} */ var dragData = null; var dragInfo = { handleChromeDragEnter: function(newDragData) { dragData = newDragData; }, clearDragData: function() { dragData = null; }, isDragValid: function() { return !!dragData; }, isSameProfile: function() { return dragData && dragData.sameProfile; }, isDraggingFolders: function() { return dragData && dragData.elements.some(function(node) { return !node.url; }); }, isDraggingBookmark: function(bookmarkId) { return dragData && dragData.elements.some(function(node) { return node.id == bookmarkId; }); }, isDraggingChildBookmark: function(folderId) { return dragData && dragData.elements.some(function(node) { return node.parentId == folderId; }); }, isDraggingFolderToDescendant: function(bookmarkNode) { return dragData && dragData.elements.some(function(node) { var dragFolder = bmm.treeLookup[node.id]; var dragFolderNode = dragFolder && dragFolder.bookmarkNode; return dragFolderNode && bmm.contains(dragFolderNode, bookmarkNode); }); } }; /** * External function to select folders or bookmarks after a drop action. * @type {?Function} */ var selectItemsAfterUserAction = null; function getBookmarkElement(el) { while (el && !el.bookmarkNode) { el = el.parentNode; } return el; } // If we are over the list and the list is showing search result, we cannot // drop. function isOverSearch(overElement) { return bmm.list.isSearch() && bmm.list.contains(overElement); } /** * Determines the valid drop positions for the given target element. * @param {!HTMLElement} overElement The element that we are currently * dragging over. * @return {DropPosition} An bit field enumeration of valid drop locations. */ function calculateValidDropTargets(overElement) { // Don't allow dropping if there is an ephemeral item being edited. if (bmm.list.hasEphemeral()) return DropPosition.NONE; if (!dragInfo.isDragValid() || isOverSearch(overElement)) return DropPosition.NONE; if (dragInfo.isSameProfile() && (dragInfo.isDraggingBookmark(overElement.bookmarkNode.id) || dragInfo.isDraggingFolderToDescendant(overElement.bookmarkNode))) { return DropPosition.NONE; } var canDropInfo = calculateDropAboveBelow(overElement); if (canDropOn(overElement)) canDropInfo |= DropPosition.ON; return canDropInfo; } function calculateDropAboveBelow(overElement) { if (overElement instanceof BookmarkList) return DropPosition.NONE; // We cannot drop between Bookmarks bar and Other bookmarks. if (overElement.bookmarkNode.parentId == bmm.ROOT_ID) return DropPosition.NONE; var isOverTreeItem = overElement instanceof TreeItem; var isOverExpandedTree = isOverTreeItem && overElement.expanded; var isDraggingFolders = dragInfo.isDraggingFolders(); // We can only drop between items in the tree if we have any folders. if (isOverTreeItem && !isDraggingFolders) return DropPosition.NONE; // When dragging from a different profile we do not need to consider // conflicts between the dragged items and the drop target. if (!dragInfo.isSameProfile()) { // Don't allow dropping below an expanded tree item since it is confusing // to the user anyway. return isOverExpandedTree ? DropPosition.ABOVE : (DropPosition.ABOVE | DropPosition.BELOW); } var resultPositions = DropPosition.NONE; // Cannot drop above if the item above is already in the drag source. var previousElem = overElement.previousElementSibling; if (!previousElem || !dragInfo.isDraggingBookmark(previousElem.bookmarkId)) resultPositions |= DropPosition.ABOVE; // Don't allow dropping below an expanded tree item since it is confusing // to the user anyway. if (isOverExpandedTree) return resultPositions; // Cannot drop below if the item below is already in the drag source. var nextElement = overElement.nextElementSibling; if (!nextElement || !dragInfo.isDraggingBookmark(nextElement.bookmarkId)) resultPositions |= DropPosition.BELOW; return resultPositions; } /** * Determine whether we can drop the dragged items on the drop target. * @param {!HTMLElement} overElement The element that we are currently * dragging over. * @return {boolean} Whether we can drop the dragged items on the drop * target. */ function canDropOn(overElement) { // We can only drop on a folder. if (!bmm.isFolder(overElement.bookmarkNode)) return false; if (!dragInfo.isSameProfile()) return true; if (overElement instanceof BookmarkList) { // We are trying to drop an item past the last item. This is // only allowed if dragged item is different from the last item // in the list. var listItems = bmm.list.items; var len = listItems.length; if (!len || !dragInfo.isDraggingBookmark(listItems[len - 1].bookmarkId)) return true; } return !dragInfo.isDraggingChildBookmark(overElement.bookmarkNode.id); } /** * Callback for the dragstart event. * @param {Event} e The dragstart event. */ function handleDragStart(e) { // Determine the selected bookmarks. var target = e.target; var draggedNodes = []; var isFromTouch = target == currentTouchTarget; if (target instanceof ListItem) { // Use selected items. draggedNodes = target.parentNode.selectedItems; } else if (target instanceof TreeItem) { draggedNodes.push(target.bookmarkNode); } // We manage starting the drag by using the extension API. e.preventDefault(); // Do not allow dragging if there is an ephemeral item being edited at the // moment. if (bmm.list.hasEphemeral()) return; if (draggedNodes.length) { // If we are dragging a single link, we can do the *Link* effect. // Otherwise, we only allow copy and move. e.dataTransfer.effectAllowed = draggedNodes.length == 1 && !bmm.isFolder(draggedNodes[0]) ? 'copyMoveLink' : 'copyMove'; chrome.bookmarkManagerPrivate.startDrag(draggedNodes.map(function(node) { return node.id; }), isFromTouch); } } function handleDragEnter(e) { e.preventDefault(); } /** * Calback for the dragover event. * @param {Event} e The dragover event. */ function handleDragOver(e) { // Allow DND on text inputs. if (e.target.tagName != 'INPUT') { // The default operation is to allow dropping links etc to do navigation. // We never want to do that for the bookmark manager. e.preventDefault(); // Set to none. This will get set to something if we can do the drop. e.dataTransfer.dropEffect = 'none'; } if (!dragInfo.isDragValid()) return; var overElement = getBookmarkElement(e.target) || (e.target == bmm.list ? bmm.list : null); if (!overElement) return; updateAutoExpander(e.timeStamp, overElement); var canDropInfo = calculateValidDropTargets(overElement); if (canDropInfo == DropPosition.NONE) return; // Now we know that we can drop. Determine if we will drop above, on or // below based on mouse position etc. dropDestination = calcDropPosition(e.clientY, overElement, canDropInfo); if (!dropDestination) { e.dataTransfer.dropEffect = 'none'; return; } e.dataTransfer.dropEffect = dragInfo.isSameProfile() ? 'move' : 'copy'; dropIndicator.update(dropDestination); } /** * This function determines where the drop will occur relative to the element. * @return {?Object} If no valid drop position is found, null, otherwise * an object containing the following parameters: * element - The target element that will receive the drop. * position - A |DropPosition| relative to the |element|. */ function calcDropPosition(elementClientY, overElement, canDropInfo) { if (overElement instanceof BookmarkList) { // Dropping on the BookmarkList either means dropping below the last // bookmark element or on the list itself if it is empty. var length = overElement.items.length; if (length) return { element: overElement.getListItemByIndex(length - 1), position: DropPosition.BELOW }; return {element: overElement, position: DropPosition.ON}; } var above = canDropInfo & DropPosition.ABOVE; var below = canDropInfo & DropPosition.BELOW; var on = canDropInfo & DropPosition.ON; var rect = overElement.getBoundingClientRect(); var yRatio = (elementClientY - rect.top) / rect.height; if (above && (yRatio <= .25 || yRatio <= .5 && (!below || !on))) return {element: overElement, position: DropPosition.ABOVE}; if (below && (yRatio > .75 || yRatio > .5 && (!above || !on))) return {element: overElement, position: DropPosition.BELOW}; if (on) return {element: overElement, position: DropPosition.ON}; return null; } function calculateDropInfo(eventTarget, dropDestination) { if (!dropDestination || !dragInfo.isDragValid()) return null; var dropPos = dropDestination.position; var relatedNode = dropDestination.element.bookmarkNode; var dropInfoResult = { selectTarget: null, selectedTreeId: -1, parentId: dropPos == DropPosition.ON ? relatedNode.id : relatedNode.parentId, index: -1, relatedIndex: -1 }; // Try to find the index in the dataModel so we don't have to always keep // the index for the list items up to date. var overElement = getBookmarkElement(eventTarget); if (overElement instanceof ListItem) { dropInfoResult.relatedIndex = overElement.parentNode.dataModel.indexOf(relatedNode); dropInfoResult.selectTarget = bmm.list; } else if (overElement instanceof BookmarkList) { dropInfoResult.relatedIndex = overElement.dataModel.length - 1; dropInfoResult.selectTarget = bmm.list; } else { // Tree dropInfoResult.relatedIndex = relatedNode.index; dropInfoResult.selectTarget = bmm.tree; dropInfoResult.selectedTreeId = bmm.tree.selectedItem ? bmm.tree.selectedItem.bookmarkId : null; } if (dropPos == DropPosition.ABOVE) dropInfoResult.index = dropInfoResult.relatedIndex; else if (dropPos == DropPosition.BELOW) dropInfoResult.index = dropInfoResult.relatedIndex + 1; return dropInfoResult; } function handleDragLeave(e) { dropIndicator.finish(); } function handleDrop(e) { var dropInfo = calculateDropInfo(e.target, dropDestination); if (dropInfo) { selectItemsAfterUserAction(dropInfo.selectTarget, dropInfo.selectedTreeId); if (dropInfo.index != -1) chrome.bookmarkManagerPrivate.drop(dropInfo.parentId, dropInfo.index); else chrome.bookmarkManagerPrivate.drop(dropInfo.parentId); e.preventDefault(); } dropDestination = null; dropIndicator.finish(); } function setCurrentTouchTarget(e) { // Only set a new target for a single touch point. if (e.touches.length == 1) currentTouchTarget = getBookmarkElement(e.target); } function clearCurrentTouchTarget(e) { if (getBookmarkElement(e.target) == currentTouchTarget) currentTouchTarget = null; } function clearDragData() { dragInfo.clearDragData(); dropDestination = null; } function init(selectItemsAfterUserActionFunction) { function deferredClearData() { setTimeout(clearDragData, 0); } selectItemsAfterUserAction = selectItemsAfterUserActionFunction; document.addEventListener('dragstart', handleDragStart); document.addEventListener('dragenter', handleDragEnter); document.addEventListener('dragover', handleDragOver); document.addEventListener('dragleave', handleDragLeave); document.addEventListener('drop', handleDrop); document.addEventListener('dragend', deferredClearData); document.addEventListener('mouseup', deferredClearData); document.addEventListener('mousedown', clearCurrentTouchTarget); document.addEventListener('touchcancel', clearCurrentTouchTarget); document.addEventListener('touchend', clearCurrentTouchTarget); document.addEventListener('touchstart', setCurrentTouchTarget); chrome.bookmarkManagerPrivate.onDragEnter.addListener( dragInfo.handleChromeDragEnter); chrome.bookmarkManagerPrivate.onDragLeave.addListener(deferredClearData); chrome.bookmarkManagerPrivate.onDrop.addListener(deferredClearData); } return {init: init}; }); // Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('bmm', function() { 'use strict'; /** * Whether a node contains another node. * TODO(yosin): Once JavaScript style guide is updated and linter follows * that, we'll remove useless documentations for |parent| and |descendant|. * TODO(yosin): bmm.contains() should be method of BookmarkTreeNode. * @param {!BookmarkTreeNode} parent . * @param {!BookmarkTreeNode} descendant . * @return {boolean} Whether the parent contains the descendant. */ function contains(parent, descendant) { if (descendant.parentId == parent.id) return true; // the bmm.treeLookup contains all folders var parentTreeItem = bmm.treeLookup[descendant.parentId]; if (!parentTreeItem || !parentTreeItem.bookmarkNode) return false; return this.contains(parent, parentTreeItem.bookmarkNode); } /** * @param {!BookmarkTreeNode} node The node to test. * @return {boolean} Whether a bookmark node is a folder. */ function isFolder(node) { return !('url' in node); } var loadingPromises = {}; /** * Promise version of chrome.bookmarkManagerPrivate.getSubtree. * @param {string} id . * @param {boolean} foldersOnly . * @return {!Promise>} . */ function getSubtreePromise(id, foldersOnly) { return new Promise(function(resolve, reject) { chrome.bookmarkManagerPrivate.getSubtree(id, foldersOnly, function(node) { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; } resolve(node); }); }); } /** * Loads a subtree of the bookmark tree and returns a {@code Promise} that * will be fulfilled when done. This reuses multiple loads so that we do not * load the same subtree more than once at the same time. * @return {!Promise} The future promise for the load. */ function loadSubtree(id) { if (!loadingPromises[id]) { loadingPromises[id] = getSubtreePromise(id, false).then(function(nodes) { return nodes && nodes[0]; }, function(error) { console.error(error.message); }); loadingPromises[id].then(function() { delete loadingPromises[id]; }); } return loadingPromises[id]; } /** * Loads the entire bookmark tree and returns a {@code Promise} that will * be fulfilled when done. This reuses multiple loads so that we do not load * the same tree more than once at the same time. * @return {!Promise} The future promise for the load. */ function loadTree() { return loadSubtree(''); } var bookmarkCache = { /** * Removes the cached item from both the list and tree lookups. */ remove: function(id) { var treeItem = bmm.treeLookup[id]; if (treeItem) { var items = treeItem.items; // is an HTMLCollection for (var i = 0; i < items.length; ++i) { var item = items[i]; var bookmarkNode = item.bookmarkNode; delete bmm.treeLookup[bookmarkNode.id]; } delete bmm.treeLookup[id]; } }, /** * Updates the underlying bookmark node for the tree items and list items by * querying the bookmark backend. * @param {string} id The id of the node to update the children for. * @param {Function=} opt_f A funciton to call when done. */ updateChildren: function(id, opt_f) { function updateItem(bookmarkNode) { var treeItem = bmm.treeLookup[bookmarkNode.id]; if (treeItem) { treeItem.bookmarkNode = bookmarkNode; } } chrome.bookmarks.getChildren(id, function(children) { if (children) children.forEach(updateItem); if (opt_f) opt_f(children); }); } }; /** * Called when the title of a bookmark changes. * @param {string} id The id of changed bookmark node. * @param {!Object} changeInfo The information about how the node changed. */ function handleBookmarkChanged(id, changeInfo) { if (bmm.tree) bmm.tree.handleBookmarkChanged(id, changeInfo); if (bmm.list) bmm.list.handleBookmarkChanged(id, changeInfo); } /** * Callback for when the user reorders by title. * @param {string} id The id of the bookmark folder that was reordered. * @param {!Object} reorderInfo The information about how the items where * reordered. */ function handleChildrenReordered(id, reorderInfo) { if (bmm.tree) bmm.tree.handleChildrenReordered(id, reorderInfo); if (bmm.list) bmm.list.handleChildrenReordered(id, reorderInfo); bookmarkCache.updateChildren(id); } /** * Callback for when a bookmark node is created. * @param {string} id The id of the newly created bookmark node. * @param {!Object} bookmarkNode The new bookmark node. */ function handleCreated(id, bookmarkNode) { if (bmm.list) bmm.list.handleCreated(id, bookmarkNode); if (bmm.tree) bmm.tree.handleCreated(id, bookmarkNode); bookmarkCache.updateChildren(bookmarkNode.parentId); } /** * Callback for when a bookmark node is moved. * @param {string} id The id of the moved bookmark node. * @param {!Object} moveInfo The information about move. */ function handleMoved(id, moveInfo) { if (bmm.list) bmm.list.handleMoved(id, moveInfo); if (bmm.tree) bmm.tree.handleMoved(id, moveInfo); bookmarkCache.updateChildren(moveInfo.parentId); if (moveInfo.parentId != moveInfo.oldParentId) bookmarkCache.updateChildren(moveInfo.oldParentId); } /** * Callback for when a bookmark node is removed. * @param {string} id The id of the removed bookmark node. * @param {!Object} removeInfo The information about removed. */ function handleRemoved(id, removeInfo) { if (bmm.list) bmm.list.handleRemoved(id, removeInfo); if (bmm.tree) bmm.tree.handleRemoved(id, removeInfo); bookmarkCache.updateChildren(removeInfo.parentId); bookmarkCache.remove(id); } /** * Callback for when all bookmark nodes have been deleted. */ function handleRemoveAll() { // Reload the list and the tree. if (bmm.list) bmm.list.reload(); if (bmm.tree) bmm.tree.reload(); } /** * Callback for when importing bookmark is started. */ function handleImportBegan() { chrome.bookmarks.onCreated.removeListener(handleCreated); chrome.bookmarks.onChanged.removeListener(handleBookmarkChanged); } /** * Callback for when importing bookmark node is finished. */ function handleImportEnded() { // When importing is done we reload the tree and the list. function f() { bmm.tree.removeEventListener('load', f); chrome.bookmarks.onCreated.addListener(handleCreated); chrome.bookmarks.onChanged.addListener(handleBookmarkChanged); if (!bmm.list) return; // TODO(estade): this should navigate to the newly imported folder, which // may be the bookmark bar if there were no previous bookmarks. bmm.list.reload(); } if (bmm.tree) { bmm.tree.addEventListener('load', f); bmm.tree.reload(); } } /** * Adds the listeners for the bookmark model change events. */ function addBookmarkModelListeners() { chrome.bookmarks.onChanged.addListener(handleBookmarkChanged); chrome.bookmarks.onChildrenReordered.addListener(handleChildrenReordered); chrome.bookmarks.onCreated.addListener(handleCreated); chrome.bookmarks.onMoved.addListener(handleMoved); chrome.bookmarks.onRemoved.addListener(handleRemoved); chrome.bookmarks.onImportBegan.addListener(handleImportBegan); chrome.bookmarks.onImportEnded.addListener(handleImportEnded); }; return { contains: contains, isFolder: isFolder, loadSubtree: loadSubtree, loadTree: loadTree, addBookmarkModelListeners: addBookmarkModelListeners }; }); // Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Authenticator class wraps the communications between Gaia and its host. */ function Authenticator() { } /** * Gaia auth extension url origin. * @type {string} */ Authenticator.THIS_EXTENSION_ORIGIN = 'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik'; /** * The lowest version of the credentials passing API supported. * @type {number} */ Authenticator.MIN_API_VERSION_VERSION = 1; /** * The highest version of the credentials passing API supported. * @type {number} */ Authenticator.MAX_API_VERSION_VERSION = 1; /** * The key types supported by the credentials passing API. * @type {Array} Array of strings. */ Authenticator.API_KEY_TYPES = [ 'KEY_TYPE_PASSWORD_PLAIN', ]; /** * Allowed origins of the hosting page. * @type {Array} */ Authenticator.ALLOWED_PARENT_ORIGINS = [ 'chrome://oobe', 'chrome://chrome-signin' ]; /** * Singleton getter of Authenticator. * @return {Object} The singleton instance of Authenticator. */ Authenticator.getInstance = function() { if (!Authenticator.instance_) { Authenticator.instance_ = new Authenticator(); } return Authenticator.instance_; }; Authenticator.prototype = { email_: null, gaiaId_: null, // Depending on the key type chosen, this will contain the plain text password // or a credential derived from it along with the information required to // repeat the derivation, such as a salt. The information will be encoded so // that it contains printable ASCII characters only. The exact encoding is TBD // when support for key types other than plain text password is added. passwordBytes_: null, needPassword_: false, chooseWhatToSync_: false, skipForNow_: false, sessionIndex_: null, attemptToken_: null, // Input params from extension initialization URL. inputLang_: undefined, intputEmail_: undefined, isSAMLFlow_: false, gaiaLoaded_: false, supportChannel_: null, useEafe_: false, clientId_: '', GAIA_URL: 'https://accounts.google.com/', GAIA_PAGE_PATH: 'ServiceLogin?skipvpage=true&sarp=1&rm=hide', SERVICE_ID: 'chromeoslogin', CONTINUE_URL: Authenticator.THIS_EXTENSION_ORIGIN + '/success.html', CONSTRAINED_FLOW_SOURCE: 'chrome', initialize: function() { var handleInitializeMessage = function(e) { if (Authenticator.ALLOWED_PARENT_ORIGINS.indexOf(e.origin) == -1) { console.error('Unexpected parent message, origin=' + e.origin); return; } window.removeEventListener('message', handleInitializeMessage); var params = e.data; params.parentPage = e.origin; this.initializeFromParent_(params); this.onPageLoad_(); }.bind(this); document.addEventListener('DOMContentLoaded', function() { window.addEventListener('message', handleInitializeMessage); window.parent.postMessage({'method': 'loginUIDOMContentLoaded'}, '*'); }); }, initializeFromParent_: function(params) { this.parentPage_ = params.parentPage; this.gaiaUrl_ = params.gaiaUrl || this.GAIA_URL; this.gaiaPath_ = params.gaiaPath || this.GAIA_PAGE_PATH; this.inputLang_ = params.hl; this.inputEmail_ = params.email; this.service_ = params.service || this.SERVICE_ID; this.continueUrl_ = params.continueUrl || this.CONTINUE_URL; this.desktopMode_ = params.desktopMode == '1'; this.isConstrainedWindow_ = params.constrained == '1'; this.useEafe_ = params.useEafe || false; this.clientId_ = params.clientId || ''; this.initialFrameUrl_ = params.frameUrl || this.constructInitialFrameUrl_(); this.initialFrameUrlWithoutParams_ = stripParams(this.initialFrameUrl_); this.needPassword_ = params.needPassword == '1'; // For CrOS 'ServiceLogin' we assume that Gaia is loaded if we recieved // 'clearOldAttempts' message. For other scenarios Gaia doesn't send this // message so we have to rely on 'load' event. // TODO(dzhioev): Do not rely on 'load' event after b/16313327 is fixed. this.assumeLoadedOnLoadEvent_ = this.gaiaPath_.indexOf('ServiceLogin') !== 0 || this.service_ !== 'chromeoslogin' || this.useEafe_; }, isGaiaMessage_: function(msg) { // Not quite right, but good enough. return this.gaiaUrl_.indexOf(msg.origin) == 0 || this.GAIA_URL.indexOf(msg.origin) == 0; }, isParentMessage_: function(msg) { return msg.origin == this.parentPage_; }, constructInitialFrameUrl_: function() { var url = this.gaiaUrl_ + this.gaiaPath_; url = appendParam(url, 'service', this.service_); // Easy bootstrap use auth_code message as success signal instead of // continue URL. if (!this.useEafe_) url = appendParam(url, 'continue', this.continueUrl_); if (this.inputLang_) url = appendParam(url, 'hl', this.inputLang_); if (this.inputEmail_) url = appendParam(url, 'Email', this.inputEmail_); if (this.isConstrainedWindow_) url = appendParam(url, 'source', this.CONSTRAINED_FLOW_SOURCE); return url; }, onPageLoad_: function() { window.addEventListener('message', this.onMessage.bind(this), false); this.initSupportChannel_(); if (this.assumeLoadedOnLoadEvent_) { var gaiaFrame = $('gaia-frame'); var handler = function() { gaiaFrame.removeEventListener('load', handler); if (!this.gaiaLoaded_) { this.gaiaLoaded_ = true; this.maybeInitialized_(); if (this.useEafe_ && this.clientId_) { // Sends initial handshake message to EAFE. Note this fails with // SSO redirect because |gaiaFrame| sits on a different origin. gaiaFrame.contentWindow.postMessage({ clientId: this.clientId_ }, this.gaiaUrl_); } } }.bind(this); gaiaFrame.addEventListener('load', handler); } }, initSupportChannel_: function() { var supportChannel = new Channel(); supportChannel.connect('authMain'); supportChannel.registerMessage('channelConnected', function() { // Load the gaia frame after the background page indicates that it is // ready, so that the webRequest handlers are all setup first. var gaiaFrame = $('gaia-frame'); gaiaFrame.src = this.initialFrameUrl_; if (this.supportChannel_) { console.error('Support channel is already initialized.'); return; } this.supportChannel_ = supportChannel; if (this.desktopMode_) { this.supportChannel_.send({ name: 'initDesktopFlow', gaiaUrl: this.gaiaUrl_, continueUrl: stripParams(this.continueUrl_), isConstrainedWindow: this.isConstrainedWindow_, initialFrameUrlWithoutParams: this.initialFrameUrlWithoutParams_ }); this.supportChannel_.registerMessage( 'switchToFullTab', this.switchToFullTab_.bind(this)); } this.supportChannel_.registerMessage( 'completeLogin', this.onCompleteLogin_.bind(this)); this.initSAML_(); this.supportChannel_.send({name: 'resetAuth'}); this.maybeInitialized_(); }.bind(this)); window.setTimeout(function() { if (!this.supportChannel_) { // Give up previous channel and bind its 'channelConnected' to a no-op. supportChannel.registerMessage('channelConnected', function() {}); // Re-initialize the channel if it is not connected properly, e.g. // connect may be called before background script started running. this.initSupportChannel_(); } }.bind(this), 200); }, /** * Called when one of the initialization stages has finished. If all the * needed parts are initialized, notifies parent about successfull * initialization. */ maybeInitialized_: function() { if (!this.gaiaLoaded_ || !this.supportChannel_) return; var msg = { 'method': 'loginUILoaded' }; window.parent.postMessage(msg, this.parentPage_); }, /** * Invoked when the background script sends a message to indicate that the * current content does not fit in a constrained window. * @param {Object=} msg Extra info to send. */ switchToFullTab_: function(msg) { var parentMsg = { 'method': 'switchToFullTab', 'url': msg.url }; window.parent.postMessage(parentMsg, this.parentPage_); }, /** * Invoked when the signin flow is complete. * @param {Object=} opt_extraMsg Optional extra info to send. */ completeLogin_: function(opt_extraMsg) { var msg = { 'method': 'completeLogin', 'email': (opt_extraMsg && opt_extraMsg.email) || this.email_, 'password': this.passwordBytes_ || (opt_extraMsg && opt_extraMsg.password), 'usingSAML': this.isSAMLFlow_, 'chooseWhatToSync': this.chooseWhatToSync_ || false, 'skipForNow': (opt_extraMsg && opt_extraMsg.skipForNow) || this.skipForNow_, 'sessionIndex': (opt_extraMsg && opt_extraMsg.sessionIndex) || this.sessionIndex_, 'gaiaId': (opt_extraMsg && opt_extraMsg.gaiaId) || this.gaiaId_ }; window.parent.postMessage(msg, this.parentPage_); this.supportChannel_.send({name: 'resetAuth'}); }, /** * Invoked when support channel is connected. */ initSAML_: function() { this.isSAMLFlow_ = false; this.supportChannel_.registerMessage( 'onAuthPageLoaded', this.onAuthPageLoaded_.bind(this)); this.supportChannel_.registerMessage( 'onInsecureContentBlocked', this.onInsecureContentBlocked_.bind(this)); this.supportChannel_.registerMessage( 'apiCall', this.onAPICall_.bind(this)); this.supportChannel_.send({ name: 'setGaiaUrl', gaiaUrl: this.gaiaUrl_ }); if (!this.desktopMode_ && this.gaiaUrl_.indexOf('https://') == 0) { // Abort the login flow when content served over an unencrypted connection // is detected on Chrome OS. This does not apply to tests that explicitly // set a non-https GAIA URL and want to perform all authentication over // http. this.supportChannel_.send({ name: 'setBlockInsecureContent', blockInsecureContent: true }); } }, /** * Invoked when the background page sends 'onHostedPageLoaded' message. * @param {!Object} msg Details sent with the message. */ onAuthPageLoaded_: function(msg) { if (msg.isSAMLPage && !this.isSAMLFlow_) { // GAIA redirected to a SAML login page. The credentials provided to this // page will determine what user gets logged in. The credentials obtained // from the GAIA login form are no longer relevant and can be discarded. this.isSAMLFlow_ = true; this.email_ = null; this.gaiaId_ = null; this.passwordBytes_ = null; } window.parent.postMessage({ 'method': 'authPageLoaded', 'isSAML': this.isSAMLFlow_, 'domain': extractDomain(msg.url) }, this.parentPage_); }, /** * Invoked when the background page sends an 'onInsecureContentBlocked' * message. * @param {!Object} msg Details sent with the message. */ onInsecureContentBlocked_: function(msg) { window.parent.postMessage({ 'method': 'insecureContentBlocked', 'url': stripParams(msg.url) }, this.parentPage_); }, /** * Invoked when one of the credential passing API methods is called by a SAML * provider. * @param {!Object} msg Details of the API call. */ onAPICall_: function(msg) { var call = msg.call; if (call.method == 'initialize') { if (!Number.isInteger(call.requestedVersion) || call.requestedVersion < Authenticator.MIN_API_VERSION_VERSION) { this.sendInitializationFailure_(); return; } this.apiVersion_ = Math.min(call.requestedVersion, Authenticator.MAX_API_VERSION_VERSION); this.initialized_ = true; this.sendInitializationSuccess_(); return; } if (call.method == 'add') { if (Authenticator.API_KEY_TYPES.indexOf(call.keyType) == -1) { console.error('Authenticator.onAPICall_: unsupported key type'); return; } // Not setting |email_| and |gaiaId_| because this API call will // eventually be followed by onCompleteLogin_() which does set it. this.apiToken_ = call.token; this.passwordBytes_ = call.passwordBytes; } else if (call.method == 'confirm') { if (call.token != this.apiToken_) console.error('Authenticator.onAPICall_: token mismatch'); } else { console.error('Authenticator.onAPICall_: unknown message'); } }, onGotAuthCode_: function(authCode) { window.parent.postMessage({ 'method': 'completeAuthenticationAuthCodeOnly', 'authCode': authCode }, this.parentPage_); }, sendInitializationSuccess_: function() { this.supportChannel_.send({name: 'apiResponse', response: { result: 'initialized', version: this.apiVersion_, keyTypes: Authenticator.API_KEY_TYPES }}); }, sendInitializationFailure_: function() { this.supportChannel_.send({ name: 'apiResponse', response: {result: 'initialization_failed'} }); }, /** * Callback invoked for 'completeLogin' message. * @param {Object=} msg Message sent from background page. */ onCompleteLogin_: function(msg) { if (!msg.email || !msg.gaiaId || !msg.sessionIndex) { // On desktop, if the skipForNow message field is set, send it to handler. // This does not require the email, gaiaid or session to be valid. if (this.desktopMode_ && msg.skipForNow) { this.completeLogin_(msg); } else { console.error('Missing fields to complete login.'); window.parent.postMessage({method: 'missingGaiaInfo'}, this.parentPage_); return; } } // Skip SAML extra steps for desktop flow and non-SAML flow. if (!this.isSAMLFlow_ || this.desktopMode_) { this.completeLogin_(msg); return; } this.email_ = msg.email; this.gaiaId_ = msg.gaiaId; // Password from |msg| is not used because ChromeOS SAML flow // gets password by asking user to confirm. this.skipForNow_ = msg.skipForNow; this.sessionIndex_ = msg.sessionIndex; if (this.passwordBytes_) { // If the credentials passing API was used, login is complete. window.parent.postMessage({method: 'samlApiUsed'}, this.parentPage_); this.completeLogin_(msg); } else if (!this.needPassword_) { // If the credentials passing API was not used, the password was obtained // by scraping. It must be verified before use. However, the host may not // be interested in the password at all. In that case, verification is // unnecessary and login is complete. this.completeLogin_(msg); } else { this.supportChannel_.sendWithCallback( {name: 'getScrapedPasswords'}, function(passwords) { if (passwords.length == 0) { window.parent.postMessage( {method: 'noPassword', email: this.email_}, this.parentPage_); } else { window.parent.postMessage({method: 'confirmPassword', email: this.email_, passwordCount: passwords.length}, this.parentPage_); } }.bind(this)); } }, onVerifyConfirmedPassword_: function(password) { this.supportChannel_.sendWithCallback( {name: 'getScrapedPasswords'}, function(passwords) { for (var i = 0; i < passwords.length; ++i) { if (passwords[i] == password) { this.passwordBytes_ = passwords[i]; // SAML login is complete when the user has successfully // confirmed the password. if (this.passwordBytes_ !== null) this.completeLogin_(); return; } } window.parent.postMessage( {method: 'confirmPassword', email: this.email_}, this.parentPage_); }.bind(this)); }, onMessage: function(e) { var msg = e.data; if (this.useEafe_) { if (msg == '!_{h:\'gaia-frame\'}' && this.isGaiaMessage_(e)) { // Sends client ID again on the hello message to work around the SSO // signin issue. // TODO(xiyuan): Revisit this when EAFE is integrated or for webview. $('gaia-frame').contentWindow.postMessage({ clientId: this.clientId_ }, this.gaiaUrl_); } else if (typeof msg == 'object' && msg.type == 'authorizationCode' && this.isGaiaMessage_(e)) { this.onGotAuthCode_(msg.authorizationCode); } else { console.error('Authenticator.onMessage: unknown message' + ', msg=' + JSON.stringify(msg)); } return; } if (msg.method == 'attemptLogin' && this.isGaiaMessage_(e)) { // At this point GAIA does not yet know the gaiaId, so its not set here. this.email_ = msg.email; this.passwordBytes_ = msg.password; this.attemptToken_ = msg.attemptToken; this.chooseWhatToSync_ = msg.chooseWhatToSync; this.isSAMLFlow_ = false; if (this.supportChannel_) this.supportChannel_.send({name: 'startAuth'}); else console.error('Support channel is not initialized.'); } else if (msg.method == 'clearOldAttempts' && this.isGaiaMessage_(e)) { if (!this.gaiaLoaded_) { this.gaiaLoaded_ = true; this.maybeInitialized_(); } this.email_ = null; this.gaiaId_ = null; this.sessionIndex_ = false; this.passwordBytes_ = null; this.attemptToken_ = null; this.isSAMLFlow_ = false; this.skipForNow_ = false; this.chooseWhatToSync_ = false; if (this.supportChannel_) { this.supportChannel_.send({name: 'resetAuth'}); // This message is for clearing saml properties in gaia_auth_host and // oobe_screen_oauth_enrollment. window.parent.postMessage({ 'method': 'resetAuthFlow', }, this.parentPage_); } } else if (msg.method == 'verifyConfirmedPassword' && this.isParentMessage_(e)) { this.onVerifyConfirmedPassword_(msg.password); } else if (msg.method == 'redirectToSignin' && this.isParentMessage_(e)) { $('gaia-frame').src = this.constructInitialFrameUrl_(); } else { console.error('Authenticator.onMessage: unknown message + origin!?'); } } }; Authenticator.getInstance().initialize(); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ html, body, iframe { height: 100%; margin: 0; padding: 0; width: 100%; } iframe { overflow: hidden; } webview { display: inline-block; height: 100%; margin: 0; padding: 0; width: 100%; }
// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Offline login implementation. */ /** * Initialize the offline page. * @param {Object} params Intialization params passed from parent page. */ function load(params) { // Setup localized strings. $('sign-in-title').textContent = decodeURIComponent(params['stringSignIn']); $('email-label').textContent = decodeURIComponent(params['stringEmail']); $('password-label').textContent = decodeURIComponent(params['stringPassword']); $('submit-button').value = decodeURIComponent(params['stringSignIn']); $('empty-email-alert').textContent = decodeURIComponent(params['stringEmptyEmail']); $('empty-password-alert').textContent = decodeURIComponent(params['stringEmptyPassword']); $('errormsg-alert').textContent = decodeURIComponent(params['stringError']); // Setup actions. var form = $('offline-login-form'); form.addEventListener('submit', function(e) { // Clear all previous errors. form.email.classList.remove('field-error'); form.password.classList.remove('field-error'); form.password.classList.remove('form-error'); if (form.email.value == '') { form.email.classList.add('field-error'); form.email.focus(); } else if (form.password.value == '') { form.password.classList.add('field-error'); form.password.focus(); } else { var msg = { 'method': 'offlineLogin', 'email': form.email.value, 'password': form.password.value }; window.parent.postMessage(msg, 'chrome://oobe/'); } e.preventDefault(); }); var email = params['email']; if (email) { // Email is present, which means that unsuccessful login attempt has been // made. Try to mimic Gaia's behaviour. form.email.value = email; form.password.classList.add('form-error'); form.password.focus(); } else { form.email.focus(); } window.parent.postMessage({'method': 'loginUILoaded'}, 'chrome://oobe/'); } /** * Handles initialization message from parent page. * @param {MessageEvent} e */ function handleInitializeMessage(e) { var ALLOWED_PARENT_ORIGINS = [ 'chrome://oobe', 'chrome://chrome-signin' ]; if (ALLOWED_PARENT_ORIGINS.indexOf(e.origin) == -1) return; window.removeEventListener('message', handleInitializeMessage); var params = e.data; params.parentPage = e.origin; load(params); } document.addEventListener('DOMContentLoaded', function() { window.addEventListener('message', handleInitializeMessage); window.parent.postMessage({'method': 'loginUIDOMContentLoaded'}, '*'); }); /* Copyright 2014 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ /* TODO(dbeam): what's wrong with * here? Specificity issues? */ audio, body, canvas, command, dd, div, dl, dt, embed, form, group, h1, h2, h3, h4, h5, h6, html, img, mark, meter, object, output, progress, summary, td, time, tr, video { border: 0; margin: 0; padding: 0; } html { background: #fff; color: #333; direction: ltr; font: 81.25% arial, helvetica, sans-serif; line-height: 1; } h1, h2, h3, h4, h5, h6 { color: #222; font-size: 1.54em; font-weight: normal; line-height: 24px; margin: 0 0 .46em; } strong { color: #222; } body, html { height: 100%; min-width: 100%; position: absolute; } .wrapper { min-height: 100%; position: relative; } .content { padding: 0 44px; } .main { margin: 0 auto; padding-bottom: 100px; padding-top: 23px; width: 650px; } button, input, select, textarea { font-family: inherit; font-size: inherit; } input[type=email], input[type=number], input[type=password], input[type=text], input[type=url] { -webkit-box-sizing: border-box; background: #fff; border: 1px solid #d9d9d9; border-radius: 1px; border-top: 1px solid #c0c0c0; box-sizing: border-box; display: inline-block; height: 29px; margin: 0; padding-left: 8px; } input[type=email]:hover, input[type=number]:hover, input[type=password]:hover, input[type=text]:hover, input[type=url]:hover { border: 1px solid #b9b9b9; border-top: 1px solid #a0a0a0; box-shadow: inset 0 1px 2px rgba(0,0,0,0.1); } input[type=email]:focus, input[type=number]:focus, input[type=password]:focus, input[type=text]:focus, input[type=url]:focus { border: 1px solid rgb(77, 144, 254); box-shadow: inset 0 1px 2px rgba(0,0,0,0.3); outline: none; } input[type=email][disabled=disabled], input[type=number][disabled=disabled], input[type=password][disabled=disabled], input[type=text][disabled=disabled], input[type=url][disabled=disabled] { background: #f5f5f5; border: 1px solid #e5e5e5; } input[type=email][disabled=disabled]:hover, input[type=number][disabled=disabled]:hover, input[type=password][disabled=disabled]:hover, input[type=text][disabled=disabled]:hover, input[type=url][disabled=disabled]:hover { box-shadow: none; } .g-button { -webkit-transition: all 218ms; -webkit-user-select: none; background-color: #f5f5f5; background-image: linear-gradient(to bottom, #f5f5f5, #f1f1f1); border: 1px solid rgba(0,0,0,0.1); border-radius: 2px; color: #555; cursor: default; display: inline-block; font-size: 11px; font-weight: bold; height: 27px; line-height: 27px; min-width: 54px; padding: 0 8px; text-align: center; transition: all 218ms; user-select: none; } *+html .g-button { min-width: 70px; } button.g-button, input[type=submit].g-button { height: 29px; line-height: 29px; margin: 0; vertical-align: bottom; } *+html button.g-button, *+html input[type=submit].g-button { overflow: visible; } .g-button:hover { -webkit-transition: all 0ms; background-color: #f8f8f8; background-image: linear-gradient(to bottom, #f8f8f8, #f1f1f1); border: 1px solid #c6c6c6; box-shadow: 0 1px 1px rgba(0,0,0,0.1); color: #333; text-decoration: none; transition: all 0ms; } .g-button:active { background-color: #f6f6f6; background-image: linear-gradient(to bottom, #f6f6f6, #f1f1f1); box-shadow: inset 0 1px 2px rgba(0,0,0,0.1); } .g-button:visited { color: #666; } .g-button-submit { background-color: rgb(77, 144, 254); background-image: linear-gradient(to bottom, rgb(77, 144, 254), rgb(71, 135, 237)); border: 1px solid rgb(48, 121, 237); color: #fff; text-shadow: 0 1px rgba(0,0,0,0.1); } .g-button-submit:hover { background-color: rgb(53, 122, 232); background-image: linear-gradient(to bottom, rgb(77, 144, 254), rgb(53, 122, 232)); border: 1px solid rgb(47, 91, 183); color: #fff; text-shadow: 0 1px rgba(0,0,0,0.3); } .g-button-submit:active { box-shadow: inset 0 1px 2px rgba(0,0,0,0.3); } .g-button-submit:visited { color: #fff; } .g-button-submit:focus { box-shadow: inset 0 0 0 1px #fff; } .g-button-submit:focus:hover { box-shadow: inset 0 0 0 1px #fff, 0 1px 1px rgba(0,0,0,0.1); } .g-button:hover img { opacity: .72; } .g-button:active img { opacity: 1; } .errormsg { color: rgb(221, 75, 57); display: block; line-height: 17px; margin: .5em 0 0; } input[type=email].form-error, input[type=number].form-error, input[type=password].form-error, input[type=text].form-error, input[type=url].form-error, input[type=text].field-error, input[type=password].field-error { border: 1px solid rgb(221, 75, 57); } html { background: transparent; } .content { width: auto; } .main { padding-bottom: 12px; padding-top: 23px; } .signin-box h2 { font-size: 16px; height: 16px; line-height: 17px; margin: 0 0 1.2em; position: relative; } .signin-box label { display: block; margin: 0 0 1.5em; } .signin-box input[type=text], .signin-box input[type=password] { font-size: 15px; height: 32px; width: 100%; } .signin-box .email-label, .signin-box .passwd-label { -webkit-user-select: none; display: block; font-weight: bold; margin: 0 0 .5em; user-select: none; } .signin-box input[type=submit] { font-size: 13px; height: 32px; margin: 0 1.5em 1.2em 0; } .errormsg { display: none; } .form-error + .errormsg, .field-error + .errormsg { display: block; } // Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Alias for document.getElementById. * @param {string} id The ID of the element to find. * @return {HTMLElement} The found element or null if not found. */ function $(id) { return document.getElementById(id); } /** * Creates a new URL which is the old URL with a GET param of key=value. * Copied from ui/webui/resources/js/util.js. * @param {string} url The base URL. There is not sanity checking on the URL so * it must be passed in a proper format. * @param {string} key The key of the param. * @param {string} value The value of the param. * @return {string} The new URL. */ function appendParam(url, key, value) { var param = encodeURIComponent(key) + '=' + encodeURIComponent(value); if (url.indexOf('?') == -1) return url + '?' + param; return url + '&' + param; } /** * Creates a new URL by striping all query parameters. * @param {string} url The original URL. * @return {string} The new URL with all query parameters stripped. */ function stripParams(url) { return url.substring(0, url.indexOf('?')) || url; } /** * Extract domain name from an URL. * @param {string} url An URL string. * @return {string} The host name of the URL. */ function extractDomain(url) { var a = document.createElement('a'); a.href = url; return a.hostname; } // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * A background script of the auth extension that bridges the communication * between the main and injected scripts. * * Here is an overview of the communication flow when SAML is being used: * 1. The main script sends the |startAuth| signal to this background script, * indicating that the authentication flow has started and SAML pages may be * loaded from now on. * 2. A script is injected into each SAML page. The injected script sends three * main types of messages to this background script: * a) A |pageLoaded| message is sent when the page has been loaded. This is * forwarded to the main script as |onAuthPageLoaded|. * b) If the SAML provider supports the credential passing API, the API calls * are sent to this background script as |apiCall| messages. These * messages are forwarded unmodified to the main script. * c) The injected script scrapes passwords. They are sent to this background * script in |updatePassword| messages. The main script can request a list * of the scraped passwords by sending the |getScrapedPasswords| message. */ /** * BackgroundBridgeManager maintains an array of BackgroundBridge, indexed by * the associated tab id. */ function BackgroundBridgeManager() { this.bridges_ = {}; } BackgroundBridgeManager.prototype = { CONTINUE_URL_BASE: 'chrome-extension://mfffpogegjflfpflabcdkioaeobkgjik' + '/success.html', // Maps a tab id to its associated BackgroundBridge. bridges_: null, run: function() { chrome.runtime.onConnect.addListener(this.onConnect_.bind(this)); chrome.webRequest.onBeforeRequest.addListener( function(details) { if (this.bridges_[details.tabId]) return this.bridges_[details.tabId].onInsecureRequest(details.url); }.bind(this), {urls: ['http://*/*', 'file://*/*', 'ftp://*/*']}, ['blocking']); chrome.webRequest.onBeforeSendHeaders.addListener( function(details) { if (this.bridges_[details.tabId]) return this.bridges_[details.tabId].onBeforeSendHeaders(details); else return {requestHeaders: details.requestHeaders}; }.bind(this), {urls: ['*://*/*'], types: ['sub_frame']}, ['blocking', 'requestHeaders']); chrome.webRequest.onHeadersReceived.addListener( function(details) { if (this.bridges_[details.tabId]) return this.bridges_[details.tabId].onHeadersReceived(details); }.bind(this), {urls: ['*://*/*'], types: ['sub_frame']}, ['blocking', 'responseHeaders']); chrome.webRequest.onCompleted.addListener( function(details) { if (this.bridges_[details.tabId]) this.bridges_[details.tabId].onCompleted(details); }.bind(this), {urls: ['*://*/*', this.CONTINUE_URL_BASE + '*'], types: ['sub_frame']}, ['responseHeaders']); }, onConnect_: function(port) { var tabId = this.getTabIdFromPort_(port); if (!this.bridges_[tabId]) this.bridges_[tabId] = new BackgroundBridge(tabId); if (port.name == 'authMain') { this.bridges_[tabId].setupForAuthMain(port); port.onDisconnect.addListener(function() { delete this.bridges_[tabId]; }.bind(this)); } else if (port.name == 'injected') { this.bridges_[tabId].setupForInjected(port); } else { console.error('Unexpected connection, port.name=' + port.name); } }, getTabIdFromPort_: function(port) { return port.sender.tab ? port.sender.tab.id : -1; } }; /** * BackgroundBridge allows the main script and the injected script to * collaborate. It forwards credentials API calls to the main script and * maintains a list of scraped passwords. * @param {string} tabId The associated tab ID. */ function BackgroundBridge(tabId) { this.tabId_ = tabId; this.passwordStore_ = {}; } BackgroundBridge.prototype = { // The associated tab ID. Only used for debugging now. tabId: null, // The initial URL loaded in the gaia iframe. We only want to handle // onCompleted() for the frame that loaded this URL. initialFrameUrlWithoutParams: null, // On process onCompleted() requests that come from this frame Id. frameId: -1, isDesktopFlow_: false, // Whether the extension is loaded in a constrained window. // Set from main auth script. isConstrainedWindow_: null, // Email of the newly authenticated user based on the gaia response header // 'google-accounts-signin'. email_: null, // Gaia Id of the newly authenticated user based on the gaia response // header 'google-accounts-signin'. gaiaId_: null, // Session index of the newly authenticated user based on the gaia response // header 'google-accounts-signin'. sessionIndex_: null, // Gaia URL base that is set from main auth script. gaiaUrl_: null, // Whether to abort the authentication flow and show an error messagen when // content served over an unencrypted connection is detected. blockInsecureContent_: false, // Whether auth flow has started. It is used as a signal of whether the // injected script should scrape passwords. authStarted_: false, // Whether SAML flow is going. isSAML_: false, passwordStore_: null, channelMain_: null, channelInjected_: null, /** * Sets up the communication channel with the main script. */ setupForAuthMain: function(port) { this.channelMain_ = new Channel(); this.channelMain_.init(port); // Registers for desktop related messages. this.channelMain_.registerMessage( 'initDesktopFlow', this.onInitDesktopFlow_.bind(this)); // Registers for SAML related messages. this.channelMain_.registerMessage( 'setGaiaUrl', this.onSetGaiaUrl_.bind(this)); this.channelMain_.registerMessage( 'setBlockInsecureContent', this.onSetBlockInsecureContent_.bind(this)); this.channelMain_.registerMessage( 'resetAuth', this.onResetAuth_.bind(this)); this.channelMain_.registerMessage( 'startAuth', this.onAuthStarted_.bind(this)); this.channelMain_.registerMessage( 'getScrapedPasswords', this.onGetScrapedPasswords_.bind(this)); this.channelMain_.registerMessage( 'apiResponse', this.onAPIResponse_.bind(this)); this.channelMain_.send({ 'name': 'channelConnected' }); }, /** * Sets up the communication channel with the injected script. */ setupForInjected: function(port) { this.channelInjected_ = new Channel(); this.channelInjected_.init(port); this.channelInjected_.registerMessage( 'apiCall', this.onAPICall_.bind(this)); this.channelInjected_.registerMessage( 'updatePassword', this.onUpdatePassword_.bind(this)); this.channelInjected_.registerMessage( 'pageLoaded', this.onPageLoaded_.bind(this)); this.channelInjected_.registerMessage( 'getSAMLFlag', this.onGetSAMLFlag_.bind(this)); }, /** * Handler for 'initDesktopFlow' signal sent from the main script. * Only called in desktop mode. */ onInitDesktopFlow_: function(msg) { this.isDesktopFlow_ = true; this.gaiaUrl_ = msg.gaiaUrl; this.isConstrainedWindow_ = msg.isConstrainedWindow; this.initialFrameUrlWithoutParams = msg.initialFrameUrlWithoutParams; }, /** * Handler for webRequest.onCompleted. It 1) detects loading of continue URL * and notifies the main script of signin completion; 2) detects if the * current page could be loaded in a constrained window and signals the main * script of switching to full tab if necessary. */ onCompleted: function(details) { // Only monitors requests in the gaia frame. The gaia frame is the one // where the initial frame URL completes. if (details.url.lastIndexOf( this.initialFrameUrlWithoutParams, 0) == 0) { this.frameId = details.frameId; } if (this.frameId == -1) { // If for some reason the frameId could not be set above, just make sure // the frame is more than two levels deep (since the gaia frame is at // least three levels deep). if (details.parentFrameId <= 0) return; } else if (details.frameId != this.frameId) { return; } if (details.url.lastIndexOf(backgroundBridgeManager.CONTINUE_URL_BASE, 0) == 0) { var skipForNow = false; if (details.url.indexOf('ntp=1') >= 0) skipForNow = true; // TOOD(guohui): For desktop SAML flow, show password confirmation UI. var passwords = this.onGetScrapedPasswords_(); var msg = { 'name': 'completeLogin', 'email': this.email_, 'gaiaId': this.gaiaId_, 'password': passwords[0], 'sessionIndex': this.sessionIndex_, 'skipForNow': skipForNow }; this.channelMain_.send(msg); } else if (this.isConstrainedWindow_) { // The header google-accounts-embedded is only set on gaia domain. if (this.gaiaUrl_ && details.url.lastIndexOf(this.gaiaUrl_) == 0) { var headers = details.responseHeaders; for (var i = 0; headers && i < headers.length; ++i) { if (headers[i].name.toLowerCase() == 'google-accounts-embedded') return; } } var msg = { 'name': 'switchToFullTab', 'url': details.url }; this.channelMain_.send(msg); } }, /** * Handler for webRequest.onBeforeRequest, invoked when content served over an * unencrypted connection is detected. Determines whether the request should * be blocked and if so, signals that an error message needs to be shown. * @param {string} url The URL that was blocked. * @return {!Object} Decision whether to block the request. */ onInsecureRequest: function(url) { if (!this.blockInsecureContent_) return {}; this.channelMain_.send({name: 'onInsecureContentBlocked', url: url}); return {cancel: true}; }, /** * Handler or webRequest.onHeadersReceived. It reads the authenticated user * email from google-accounts-signin-header. * @return {!Object} Modified request headers. */ onHeadersReceived: function(details) { var headers = details.responseHeaders; if (this.gaiaUrl_ && details.url.lastIndexOf(this.gaiaUrl_) == 0) { for (var i = 0; headers && i < headers.length; ++i) { if (headers[i].name.toLowerCase() == 'google-accounts-signin') { var headerValues = headers[i].value.toLowerCase().split(','); var signinDetails = {}; headerValues.forEach(function(e) { var pair = e.split('='); signinDetails[pair[0].trim()] = pair[1].trim(); }); // Remove "" around. this.email_ = signinDetails['email'].slice(1, -1); this.gaiaId_ = signinDetails['obfuscatedid'].slice(1, -1); this.sessionIndex_ = signinDetails['sessionindex']; break; } } } if (!this.isDesktopFlow_) { // Check whether GAIA headers indicating the start or end of a SAML // redirect are present. If so, synthesize cookies to mark these points. for (var i = 0; headers && i < headers.length; ++i) { if (headers[i].name.toLowerCase() == 'google-accounts-saml') { var action = headers[i].value.toLowerCase(); if (action == 'start') { this.isSAML_ = true; // GAIA is redirecting to a SAML IdP. Any cookies contained in the // current |headers| were set by GAIA. Any cookies set in future // requests will be coming from the IdP. Append a cookie to the // current |headers| that marks the point at which the redirect // occurred. headers.push({name: 'Set-Cookie', value: 'google-accounts-saml-start=now'}); return {responseHeaders: headers}; } else if (action == 'end') { this.isSAML_ = false; // The SAML IdP has redirected back to GAIA. Add a cookie that marks // the point at which the redirect occurred occurred. It is // important that this cookie be prepended to the current |headers| // because any cookies contained in the |headers| were already set // by GAIA, not the IdP. Due to limitations in the webRequest API, // it is not trivial to prepend a cookie: // // The webRequest API only allows for deleting and appending // headers. To prepend a cookie (C), three steps are needed: // 1) Delete any headers that set cookies (e.g., A, B). // 2) Append a header which sets the cookie (C). // 3) Append the original headers (A, B). // // Due to a further limitation of the webRequest API, it is not // possible to delete a header in step 1) and append an identical // header in step 3). To work around this, a trailing semicolon is // added to each header before appending it. Trailing semicolons are // ignored by Chrome in cookie headers, causing the modified headers // to actually set the original cookies. var otherHeaders = []; var cookies = [{name: 'Set-Cookie', value: 'google-accounts-saml-end=now'}]; for (var j = 0; j < headers.length; ++j) { if (headers[j].name.toLowerCase().indexOf('set-cookie') == 0) { var header = headers[j]; header.value += ';'; cookies.push(header); } else { otherHeaders.push(headers[j]); } } return {responseHeaders: otherHeaders.concat(cookies)}; } } } } return {}; }, /** * Handler for webRequest.onBeforeSendHeaders. * @return {!Object} Modified request headers. */ onBeforeSendHeaders: function(details) { if (!this.isDesktopFlow_ && this.gaiaUrl_ && details.url.indexOf(this.gaiaUrl_) == 0) { details.requestHeaders.push({ name: 'X-Cros-Auth-Ext-Support', value: 'SAML' }); } return {requestHeaders: details.requestHeaders}; }, /** * Handler for 'setGaiaUrl' signal sent from the main script. */ onSetGaiaUrl_: function(msg) { this.gaiaUrl_ = msg.gaiaUrl; }, /** * Handler for 'setBlockInsecureContent' signal sent from the main script. */ onSetBlockInsecureContent_: function(msg) { this.blockInsecureContent_ = msg.blockInsecureContent; }, /** * Handler for 'resetAuth' signal sent from the main script. */ onResetAuth_: function() { this.authStarted_ = false; this.passwordStore_ = {}; this.isSAML_ = false; }, /** * Handler for 'authStarted' signal sent from the main script. */ onAuthStarted_: function() { this.authStarted_ = true; this.passwordStore_ = {}; this.isSAML_ = false; }, /** * Handler for 'getScrapedPasswords' request sent from the main script. * @return {Array} The array with de-duped scraped passwords. */ onGetScrapedPasswords_: function() { var passwords = {}; for (var property in this.passwordStore_) { passwords[this.passwordStore_[property]] = true; } return Object.keys(passwords); }, /** * Handler for 'apiResponse' signal sent from the main script. Passes on the * |msg| to the injected script. */ onAPIResponse_: function(msg) { this.channelInjected_.send(msg); }, onAPICall_: function(msg) { this.channelMain_.send(msg); }, onUpdatePassword_: function(msg) { if (!this.authStarted_) return; this.passwordStore_[msg.id] = msg.password; }, onPageLoaded_: function(msg) { if (this.channelMain_) this.channelMain_.send({name: 'onAuthPageLoaded', url: msg.url, isSAMLPage: this.isSAML_}); }, onGetSAMLFlag_: function(msg) { return this.isSAML_; } }; var backgroundBridgeManager = new BackgroundBridgeManager(); backgroundBridgeManager.run(); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview * Script to be injected into SAML provider pages, serving three main purposes: * 1. Signal hosting extension that an external page is loaded so that the * UI around it should be changed accordingly; * 2. Provide an API via which the SAML provider can pass user credentials to * Chrome OS, allowing the password to be used for encrypting user data and * offline login. * 3. Scrape password fields, making the password available to Chrome OS even if * the SAML provider does not support the credential passing API. */ (function() { function APICallForwarder() { } /** * The credential passing API is used by sending messages to the SAML page's * |window| object. This class forwards API calls from the SAML page to a * background script and API responses from the background script to the SAML * page. Communication with the background script occurs via a |Channel|. */ APICallForwarder.prototype = { // Channel to which API calls are forwarded. channel_: null, /** * Initialize the API call forwarder. * @param {!Object} channel Channel to which API calls should be forwarded. */ init: function(channel) { this.channel_ = channel; this.channel_.registerMessage('apiResponse', this.onAPIResponse_.bind(this)); window.addEventListener('message', this.onMessage_.bind(this)); }, onMessage_: function(event) { if (event.source != window || typeof event.data != 'object' || !event.data.hasOwnProperty('type') || event.data.type != 'gaia_saml_api') { return; } // Forward API calls to the background script. this.channel_.send({name: 'apiCall', call: event.data.call}); }, onAPIResponse_: function(msg) { // Forward API responses to the SAML page. window.postMessage({type: 'gaia_saml_api_reply', response: msg.response}, '/'); } }; /** * A class to scrape password from type=password input elements under a given * docRoot and send them back via a Channel. */ function PasswordInputScraper() { } PasswordInputScraper.prototype = { // URL of the page. pageURL_: null, // Channel to send back changed password. channel_: null, // An array to hold password fields. passwordFields_: null, // An array to hold cached password values. passwordValues_: null, // A MutationObserver to watch for dynamic password field creation. passwordFieldsObserver: null, /** * Initialize the scraper with given channel and docRoot. Note that the * scanning for password fields happens inside the function and does not * handle DOM tree changes after the call returns. * @param {!Object} channel The channel to send back password. * @param {!string} pageURL URL of the page. * @param {!HTMLElement} docRoot The root element of the DOM tree that * contains the password fields of interest. */ init: function(channel, pageURL, docRoot) { this.pageURL_ = pageURL; this.channel_ = channel; this.passwordFields_ = []; this.passwordValues_ = []; this.findAndTrackChildren(docRoot); this.passwordFieldsObserver = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { Array.prototype.forEach.call( mutation.addedNodes, function(addedNode) { if (addedNode.nodeType != Node.ELEMENT_NODE) return; if (addedNode.matches('input[type=password]')) { this.trackPasswordField(addedNode); } else { this.findAndTrackChildren(addedNode); } }.bind(this)); }.bind(this)); }.bind(this)); this.passwordFieldsObserver.observe(docRoot, {subtree: true, childList: true}); }, /** * Find and track password fields that are descendants of the given element. * @param {!HTMLElement} element The parent element to search from. */ findAndTrackChildren: function(element) { Array.prototype.forEach.call( element.querySelectorAll('input[type=password]'), function(field) { this.trackPasswordField(field); }.bind(this)); }, /** * Start tracking value changes of the given password field if it is * not being tracked yet. * @param {!HTMLInputElement} passworField The password field to track. */ trackPasswordField: function(passwordField) { var existing = this.passwordFields_.filter(function(element) { return element === passwordField; }); if (existing.length != 0) return; var index = this.passwordFields_.length; passwordField.addEventListener( 'input', this.onPasswordChanged_.bind(this, index)); this.passwordFields_.push(passwordField); this.passwordValues_.push(passwordField.value); }, /** * Check if the password field at |index| has changed. If so, sends back * the updated value. */ maybeSendUpdatedPassword: function(index) { var newValue = this.passwordFields_[index].value; if (newValue == this.passwordValues_[index]) return; this.passwordValues_[index] = newValue; // Use an invalid char for URL as delimiter to concatenate page url and // password field index to construct a unique ID for the password field. var passwordId = this.pageURL_.split('#')[0].split('?')[0] + '|' + index; this.channel_.send({ name: 'updatePassword', id: passwordId, password: newValue }); }, /** * Handles 'change' event in the scraped password fields. * @param {number} index The index of the password fields in * |passwordFields_|. */ onPasswordChanged_: function(index) { this.maybeSendUpdatedPassword(index); } }; function onGetSAMLFlag(channel, isSAMLPage) { if (!isSAMLPage) return; var pageURL = window.location.href; channel.send({name: 'pageLoaded', url: pageURL}); var initPasswordScraper = function() { var passwordScraper = new PasswordInputScraper(); passwordScraper.init(channel, pageURL, document.documentElement); }; if (document.readyState == 'loading') { window.addEventListener('readystatechange', function listener(event) { if (document.readyState == 'loading') return; initPasswordScraper(); window.removeEventListener(event.type, listener, true); }, true); } else { initPasswordScraper(); } } var channel = Channel.create(); channel.connect('injected'); channel.sendWithCallback({name: 'getSAMLFlag'}, onGetSAMLFlag.bind(undefined, channel)); var apiCallForwarder = new APICallForwarder(); apiCallForwarder.init(channel); })(); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Channel to the background script. */ function Channel() { this.messageCallbacks_ = {}; this.internalRequestCallbacks_ = {}; } /** @const */ Channel.INTERNAL_REQUEST_MESSAGE = 'internal-request-message'; /** @const */ Channel.INTERNAL_REPLY_MESSAGE = 'internal-reply-message'; Channel.prototype = { // Message port to use to communicate with background script. port_: null, // Registered message callbacks. messageCallbacks_: null, // Internal request id to track pending requests. nextInternalRequestId_: 0, // Pending internal request callbacks. internalRequestCallbacks_: null, /** * Initialize the channel with given port for the background script. */ init: function(port) { this.port_ = port; this.port_.onMessage.addListener(this.onMessage_.bind(this)); }, /** * Connects to the background script with the given name. */ connect: function(name) { this.port_ = chrome.runtime.connect({name: name}); this.port_.onMessage.addListener(this.onMessage_.bind(this)); }, /** * Associates a message name with a callback. When a message with the name * is received, the callback will be invoked with the message as its arg. * Note only the last registered callback will be invoked. */ registerMessage: function(name, callback) { this.messageCallbacks_[name] = callback; }, /** * Sends a message to the other side of the channel. */ send: function(msg) { this.port_.postMessage(msg); }, /** * Sends a message to the other side and invokes the callback with * the replied object. Useful for message that expects a returned result. */ sendWithCallback: function(msg, callback) { var requestId = this.nextInternalRequestId_++; this.internalRequestCallbacks_[requestId] = callback; this.send({ name: Channel.INTERNAL_REQUEST_MESSAGE, requestId: requestId, payload: msg }); }, /** * Invokes message callback using given message. * @return {*} The return value of the message callback or null. */ invokeMessageCallbacks_: function(msg) { var name = msg.name; if (this.messageCallbacks_[name]) return this.messageCallbacks_[name](msg); console.error('Error: Unexpected message, name=' + name); return null; }, /** * Invoked when a message is received. */ onMessage_: function(msg) { var name = msg.name; if (name == Channel.INTERNAL_REQUEST_MESSAGE) { var payload = msg.payload; var result = this.invokeMessageCallbacks_(payload); this.send({ name: Channel.INTERNAL_REPLY_MESSAGE, requestId: msg.requestId, result: result }); } else if (name == Channel.INTERNAL_REPLY_MESSAGE) { var callback = this.internalRequestCallbacks_[msg.requestId]; delete this.internalRequestCallbacks_[msg.requestId]; if (callback) callback(msg.result); } else { this.invokeMessageCallbacks_(msg); } } }; /** * Class factory. * @return {Channel} */ Channel.create = function() { return new Channel(); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. var appId = 'hotword_audio_verification'; chrome.app.runtime.onLaunched.addListener(function() { // We need to focus the window if it already exists, since it // is created as 'hidden'. // // Note: If we ever launch on another platform, make sure that this works // with window managers that support hiding (e.g. Cmd+h on an app window on // Mac). var appWindow = chrome.app.window.get(appId); if (appWindow) { appWindow.focus(); return; } chrome.app.window.create('main.html', { 'frame': 'none', 'resizable': false, 'hidden': true, 'id': appId, 'innerBounds': { 'width': 784, 'height': 448 } }); }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. var appWindow = chrome.app.window.current(); document.addEventListener('DOMContentLoaded', function() { chrome.hotwordPrivate.getLocalizedStrings(function(strings) { loadTimeData.data = strings; i18nTemplate.process(document, loadTimeData); var flow = new Flow(); flow.startFlow(); var pressFunction = function(e) { // Only respond to 'Enter' key presses. if (e.type == 'keyup' && e.keyIdentifier != 'Enter') return; var classes = e.target.classList; if (classes.contains('close') || classes.contains('finish-button')) { flow.stopTraining(); appWindow.close(); e.preventDefault(); } if (classes.contains('retry-button')) { flow.handleRetry(); e.preventDefault(); } }; $('steps').addEventListener('click', pressFunction); $('steps').addEventListener('keyup', pressFunction); $('audio-history-agree').addEventListener('click', function(e) { flow.enableAudioHistory(); e.preventDefault(); }); $('hotword-start').addEventListener('click', function(e) { flow.advanceStep(); e.preventDefault(); }); $('settings-link').addEventListener('click', function(e) { chrome.browser.openTab({'url': 'chrome://settings'}, function() {}); e.preventDefault(); }); }); }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { // Correspond to steps in the hotword opt-in flow. /** @const */ var START = 'start-container'; /** @const */ var AUDIO_HISTORY = 'audio-history-container'; /** @const */ var SPEECH_TRAINING = 'speech-training-container'; /** @const */ var FINISH = 'finish-container'; /** * These flows correspond to the three LaunchModes as defined in * chrome/browser/search/hotword_service.h and should be kept in sync * with them. * @const */ var FLOWS = [ [START, SPEECH_TRAINING, FINISH], [START, AUDIO_HISTORY, SPEECH_TRAINING, FINISH], [SPEECH_TRAINING, FINISH] ]; /** * The launch mode. This enum needs to be kept in sync with that of * the same name in hotword_service.h. * @enum {number} */ var LaunchMode = { HOTWORD_ONLY: 0, HOTWORD_AND_AUDIO_HISTORY: 1, RETRAIN: 2 }; /** * The training state. * @enum {string} */ var TrainingState = { RESET: 'reset', TIMEOUT: 'timeout', ERROR: 'error', }; /** * Class to control the page flow of the always-on hotword and * Audio History opt-in process. * @constructor */ function Flow() { this.currentStepIndex_ = -1; this.currentFlow_ = []; /** * The mode that this app was launched in. * @private {LaunchMode} */ this.launchMode_ = LaunchMode.HOTWORD_AND_AUDIO_HISTORY; /** * Whether this flow is currently in the process of training a voice model. * @private {boolean} */ this.training_ = false; /** * The current training state. * @private {?TrainingState} */ this.trainingState_ = null; /** * Whether an expected hotword trigger has been received, indexed by * training step. * @private {boolean[]} */ this.hotwordTriggerReceived_ = []; /** * Prefix of the element ids for the page that is currently training. * @private {string} */ this.trainingPagePrefix_ = 'speech-training'; /** * Whether the speaker model for this flow has been finalized. * @private {boolean} */ this.speakerModelFinalized_ = false; /** * ID of the currently active timeout. * @private {?number} */ this.timeoutId_ = null; /** * Listener for the speakerModelSaved event. * @private {Function} */ this.speakerModelFinalizedListener_ = this.onSpeakerModelFinalized_.bind(this); /** * Listener for the hotword trigger event. * @private {Function} */ this.hotwordTriggerListener_ = this.handleHotwordTrigger_.bind(this); // Listen for the user locking the screen. chrome.idle.onStateChanged.addListener( this.handleIdleStateChanged_.bind(this)); // Listen for hotword settings changes. This used to detect when the user // switches to a different profile. if (chrome.hotwordPrivate.onEnabledChanged) { chrome.hotwordPrivate.onEnabledChanged.addListener( this.handleEnabledChanged_.bind(this)); } } /** * Advances the current step. Begins training if the speech-training * page has been reached. */ Flow.prototype.advanceStep = function() { this.currentStepIndex_++; if (this.currentStepIndex_ < this.currentFlow_.length) { if (this.currentFlow_[this.currentStepIndex_] == SPEECH_TRAINING) this.startTraining(); this.showStep_.apply(this); } }; /** * Gets the appropriate flow and displays its first page. */ Flow.prototype.startFlow = function() { if (chrome.hotwordPrivate && chrome.hotwordPrivate.getLaunchState) chrome.hotwordPrivate.getLaunchState(this.startFlowForMode_.bind(this)); }; /** * Starts the training process. */ Flow.prototype.startTraining = function() { // Don't start a training session if one already exists. if (this.training_) return; this.training_ = true; if (chrome.hotwordPrivate.onHotwordTriggered && !chrome.hotwordPrivate.onHotwordTriggered.hasListener( this.hotwordTriggerListener_)) { chrome.hotwordPrivate.onHotwordTriggered.addListener( this.hotwordTriggerListener_); } this.waitForHotwordTrigger_(0); if (chrome.hotwordPrivate.startTraining) chrome.hotwordPrivate.startTraining(); }; /** * Stops the training process. */ Flow.prototype.stopTraining = function() { if (!this.training_) return; this.training_ = false; if (chrome.hotwordPrivate.onHotwordTriggered) { chrome.hotwordPrivate.onHotwordTriggered. removeListener(this.hotwordTriggerListener_); } if (chrome.hotwordPrivate.stopTraining) chrome.hotwordPrivate.stopTraining(); }; /** * Attempts to enable audio history for the signed-in account. */ Flow.prototype.enableAudioHistory = function() { // Update UI $('audio-history-agree').disabled = true; $('audio-history-cancel').disabled = true; $('audio-history-error').hidden = true; $('audio-history-wait').hidden = false; if (chrome.hotwordPrivate.setAudioHistoryEnabled) { chrome.hotwordPrivate.setAudioHistoryEnabled( true, this.onAudioHistoryRequestCompleted_.bind(this)); } }; // ---- private methods: /** * Shows an error if the audio history setting was not enabled successfully. * @private */ Flow.prototype.handleAudioHistoryError_ = function() { $('audio-history-agree').disabled = false; $('audio-history-cancel').disabled = false; $('audio-history-wait').hidden = true; $('audio-history-error').hidden = false; // Set a timeout before focusing the Enable button so that screenreaders // have time to announce the error first. this.setTimeout_(function() { $('audio-history-agree').focus(); }.bind(this), 50); }; /** * Callback for when an audio history request completes. * @param {chrome.hotwordPrivate.AudioHistoryState} state The audio history * request state. * @private */ Flow.prototype.onAudioHistoryRequestCompleted_ = function(state) { if (!state.success || !state.enabled) { this.handleAudioHistoryError_(); return; } this.advanceStep(); }; /** * Shows an error if the speaker model has not been finalized. * @private */ Flow.prototype.handleSpeakerModelFinalizedError_ = function() { if (!this.training_) return; if (this.speakerModelFinalized_) return; this.updateTrainingState_(TrainingState.ERROR); this.stopTraining(); }; /** * Handles the speaker model finalized event. * @private */ Flow.prototype.onSpeakerModelFinalized_ = function() { this.speakerModelFinalized_ = true; if (chrome.hotwordPrivate.onSpeakerModelSaved) { chrome.hotwordPrivate.onSpeakerModelSaved.removeListener( this.speakerModelFinalizedListener_); } this.stopTraining(); this.setTimeout_(this.finishFlow_.bind(this), 2000); }; /** * Completes the training process. * @private */ Flow.prototype.finishFlow_ = function() { if (chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled) { chrome.hotwordPrivate.setHotwordAlwaysOnSearchEnabled(true, this.advanceStep.bind(this)); } }; /** * Handles a user clicking on the retry button. */ Flow.prototype.handleRetry = function() { if (!(this.trainingState_ == TrainingState.TIMEOUT || this.trainingState_ == TrainingState.ERROR)) return; this.startTraining(); this.updateTrainingState_(TrainingState.RESET); }; // ---- private methods: /** * Completes the training process. * @private */ Flow.prototype.finalizeSpeakerModel_ = function() { if (!this.training_) return; // Listen for the success event from the NaCl module. if (chrome.hotwordPrivate.onSpeakerModelSaved && !chrome.hotwordPrivate.onSpeakerModelSaved.hasListener( this.speakerModelFinalizedListener_)) { chrome.hotwordPrivate.onSpeakerModelSaved.addListener( this.speakerModelFinalizedListener_); } this.speakerModelFinalized_ = false; this.setTimeout_(this.handleSpeakerModelFinalizedError_.bind(this), 30000); if (chrome.hotwordPrivate.finalizeSpeakerModel) chrome.hotwordPrivate.finalizeSpeakerModel(); }; /** * Returns the current training step. * @param {string} curStepClassName The name of the class of the current * training step. * @return {Object} The current training step, its index, and an array of * all training steps. Any of these can be undefined. * @private */ Flow.prototype.getCurrentTrainingStep_ = function(curStepClassName) { var steps = $(this.trainingPagePrefix_ + '-training').querySelectorAll('.train'); var curStep = $(this.trainingPagePrefix_ + '-training').querySelector('.listening'); return {current: curStep, index: Array.prototype.indexOf.call(steps, curStep), steps: steps}; }; /** * Updates the training state. * @param {TrainingState} state The training state. * @private */ Flow.prototype.updateTrainingState_ = function(state) { this.trainingState_ = state; this.updateErrorUI_(); }; /** * Waits two minutes and then checks for a training error. * @param {number} index The index of the training step. * @private */ Flow.prototype.waitForHotwordTrigger_ = function(index) { if (!this.training_) return; this.hotwordTriggerReceived_[index] = false; this.setTimeout_(this.handleTrainingTimeout_.bind(this, index), 120000); }; /** * Checks for and handles a training error. * @param {number} index The index of the training step. * @private */ Flow.prototype.handleTrainingTimeout_ = function(index) { if (this.hotwordTriggerReceived_[index]) return; this.timeoutTraining_(); }; /** * Times out training and updates the UI to show a "retry" message, if * currently training. * @private */ Flow.prototype.timeoutTraining_ = function() { if (!this.training_) return; this.clearTimeout_(); this.updateTrainingState_(TrainingState.TIMEOUT); this.stopTraining(); }; /** * Sets a timeout. If any timeout is active, clear it. * @param {Function} func The function to invoke when the timeout occurs. * @param {number} delay Timeout delay in milliseconds. * @private */ Flow.prototype.setTimeout_ = function(func, delay) { this.clearTimeout_(); this.timeoutId_ = setTimeout(function() { this.timeoutId_ = null; func(); }, delay); }; /** * Clears any currently active timeout. * @private */ Flow.prototype.clearTimeout_ = function() { if (this.timeoutId_ != null) { clearTimeout(this.timeoutId_); this.timeoutId_ = null; } }; /** * Updates the training error UI. * @private */ Flow.prototype.updateErrorUI_ = function() { if (!this.training_) return; var trainingSteps = this.getCurrentTrainingStep_('listening'); var steps = trainingSteps.steps; $(this.trainingPagePrefix_ + '-toast').hidden = this.trainingState_ != TrainingState.TIMEOUT; if (this.trainingState_ == TrainingState.RESET) { // We reset the training to begin at the first step. // The first step is reset to 'listening', while the rest // are reset to 'not-started'. var prompt = loadTimeData.getString('trainingFirstPrompt'); for (var i = 0; i < steps.length; ++i) { steps[i].classList.remove('recorded'); if (i == 0) { steps[i].classList.remove('not-started'); steps[i].classList.add('listening'); } else { steps[i].classList.add('not-started'); if (i == steps.length - 1) prompt = loadTimeData.getString('trainingLastPrompt'); else prompt = loadTimeData.getString('trainingMiddlePrompt'); } steps[i].querySelector('.text').textContent = prompt; } // Reset the buttonbar. $(this.trainingPagePrefix_ + '-processing').hidden = true; $(this.trainingPagePrefix_ + '-wait').hidden = false; $(this.trainingPagePrefix_ + '-error').hidden = true; $(this.trainingPagePrefix_ + '-retry').hidden = true; } else if (this.trainingState_ == TrainingState.TIMEOUT) { var curStep = trainingSteps.current; if (curStep) { curStep.classList.remove('listening'); curStep.classList.add('not-started'); } // Set a timeout before focusing the Retry button so that screenreaders // have time to announce the timeout first. this.setTimeout_(function() { $(this.trainingPagePrefix_ + '-toast').children[1].focus(); }.bind(this), 50); } else if (this.trainingState_ == TrainingState.ERROR) { // Update the buttonbar. $(this.trainingPagePrefix_ + '-wait').hidden = true; $(this.trainingPagePrefix_ + '-error').hidden = false; $(this.trainingPagePrefix_ + '-retry').hidden = false; $(this.trainingPagePrefix_ + '-processing').hidden = false; // Set a timeout before focusing the Retry button so that screenreaders // have time to announce the error first. this.setTimeout_(function() { $(this.trainingPagePrefix_ + '-retry').children[0].focus(); }.bind(this), 50); } }; /** * Handles a hotword trigger event and updates the training UI. * @private */ Flow.prototype.handleHotwordTrigger_ = function() { var trainingSteps = this.getCurrentTrainingStep_('listening'); if (!trainingSteps.current) return; var index = trainingSteps.index; this.hotwordTriggerReceived_[index] = true; trainingSteps.current.querySelector('.text').textContent = loadTimeData.getString('trainingRecorded'); trainingSteps.current.classList.remove('listening'); trainingSteps.current.classList.add('recorded'); if (trainingSteps.steps[index + 1]) { trainingSteps.steps[index + 1].classList.remove('not-started'); trainingSteps.steps[index + 1].classList.add('listening'); this.waitForHotwordTrigger_(index + 1); return; } // Only the last step makes it here. var buttonElem = $(this.trainingPagePrefix_ + '-processing').hidden = false; this.finalizeSpeakerModel_(); }; /** * Handles a chrome.idle.onStateChanged event and times out the training if * the state is "locked". * @param {!string} state State, one of "active", "idle", or "locked". * @private */ Flow.prototype.handleIdleStateChanged_ = function(state) { if (state == 'locked') this.timeoutTraining_(); }; /** * Handles a chrome.hotwordPrivate.onEnabledChanged event and times out * training if the user is no longer the active user (user switches profiles). * @private */ Flow.prototype.handleEnabledChanged_ = function() { if (chrome.hotwordPrivate.getStatus) { chrome.hotwordPrivate.getStatus(function(status) { if (status.userIsActive) return; this.timeoutTraining_(); }.bind(this)); } }; /** * Gets and starts the appropriate flow for the launch mode. * @param {chrome.hotwordPrivate.LaunchState} state Launch state of the * Hotword Audio Verification App. * @private */ Flow.prototype.startFlowForMode_ = function(state) { this.launchMode_ = state.launchMode; assert(state.launchMode >= 0 && state.launchMode < FLOWS.length, 'Invalid Launch Mode.'); this.currentFlow_ = FLOWS[state.launchMode]; if (state.launchMode == LaunchMode.HOTWORD_ONLY) { $('intro-description-audio-history-enabled').hidden = false; } else if (state.launchMode == LaunchMode.HOTWORD_AND_AUDIO_HISTORY) { $('intro-description').hidden = false; } this.advanceStep(); }; /** * Displays the current step. If the current step is not the first step, * also hides the previous step. Focuses the current step's first button. * @private */ Flow.prototype.showStep_ = function() { var currentStepId = this.currentFlow_[this.currentStepIndex_]; var currentStep = document.getElementById(currentStepId); currentStep.hidden = false; cr.ui.setInitialFocus(currentStep); var previousStep = null; if (this.currentStepIndex_ > 0) previousStep = this.currentFlow_[this.currentStepIndex_ - 1]; if (previousStep) document.getElementById(previousStep).hidden = true; chrome.app.window.current().show(); }; window.Flow = Flow; })(); /* Copyright 2014 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ /* TODO(xdai): Remove hard-coded font-family for 'Roboto'. */ * { box-sizing: border-box; color: rgba(0, 0, 0, .54); font-family: Roboto, 'Noto Sans', sans-serif; font-size: 13px; margin: 0; padding: 0; } #start-container * { color: #fff; } #start-container h2 { font-size: 15px; font-weight: normal; line-height: 24px; margin-top: 16px; } #start-container h3 { font-weight: normal; margin: 42px 16px 24px 16px; } #start-container div.container { background: rgb(66, 133, 244); } div.intro-image { background: -webkit-image-set( url(../images/intro-1x.png) 1x, url(../images/intro-2x.png) 2x) no-repeat; height: 152px; left: 24px; position: absolute; top: 122px; width: 304px; } div.intro-text { left: 328px; position: absolute; text-align: center; top: 116px; width: 432px; } #start-container div.buttonbar { background-color: rgb(51, 103, 214); height: 56px; padding: 0; text-align: center; } #start-container .buttonbar button { height: 100%; margin: 0; padding: 0 8px; width: 100%; } a { -webkit-app-region: no-drag; color: rgb(51, 103, 214); text-decoration: none; } button { -webkit-app-region: no-drag; } body { -webkit-app-region: drag; background: #ddd; } h1 { font-size: 20px; font-weight: normal; line-height: 32px; } h3 { font-size: 13px; line-height: 20px; } div.container { background: #fff; height: 448px; position: relative; width: 784px; } div.header { background: -webkit-image-set( url(../images/gradient-1x.png) 1x, url(../images/gradient-2x.png) 2x) no-repeat; height: 128px; padding: 70px 42px 0 42px; } div.header h1 { color: #fff; } div.content { height: 264px; line-height: 20px; padding: 32px 42px 0 42px; } div.content h3 { color: rgba(0, 0, 0, .87); margin-bottom: 16px; } div.col-2 { color: rgba(0, 0, 0, .54); float: left; width: 320px; } div.col-spacing { float: left; height: 216px; width: 60px; } div.v-spacing { height: 8px; } a[is='action-link'] { display: inline-block; font-size: 14px; margin-top: 22px; text-decoration: none; text-transform: uppercase; } .train { clear: both; line-height: 18px; margin-bottom: 24px; } .train .icon { display: inline-block; height: 18px; margin-right: 8px; vertical-align: top; width: 18px; } .train .text { color: rgba(0, 0, 0, .54); display: inline-block; line-height: 13px; padding-top: 3px; vertical-align: top; } .train.recorded .text { color: rgba(66, 133, 244, 1); } @-webkit-keyframes rotate { from { -webkit-transform: rotate(0); } to { -webkit-transform: rotate(359deg); } } .train.listening .icon { -webkit-animation: rotate 2s linear infinite; background: -webkit-image-set( url(../images/placeholder-loader-1x.png) 1x, url(../images/placeholder-loader-2x.png) 2x) no-repeat; } .train.not-started .icon { background: -webkit-image-set( url(../images/ic-check-gray-1x.png) 1x, url(../images/ic-check-gray-2x.png) 2x) no-repeat; } .train.recorded .icon { background: -webkit-image-set( url(../images/ic-check-blue-1x.png) 1x, url(../images/ic-check-blue-2x.png) 2x) no-repeat; } .check { clear: both; height: 18px; margin-bottom: 24px; } .check .icon { background: -webkit-image-set( url(../images/ic-check-blue-1x.png) 1x, url(../images/ic-check-blue-2x.png) 2x) no-repeat; display: inline-block; height: 18px; margin-right: 8px; vertical-align: top; width: 18px; } .check .text { color: rgba(0, 0, 0, .54); display: inline-block; height: 18px; line-height: 18px; padding-top: 2px; vertical-align: top; } div.buttonbar { background-color: rgba(236,239, 241, 1); bottom: 0; height: 56px; padding: 12px; position: absolute; width: 100%; } .buttonbar button { background: none; border: none; display: inline-block; font-weight: 700; height: 32px; line-height: 32px; margin-left: 8px; min-width: 56px; padding: 1px 8px 0 8px; text-transform: uppercase; } .buttonbar button:disabled { opacity: .5; } .buttonbar button.grayed-out { color: rgba(0, 0, 0, .28); text-transform: none; } .buttonbar button.primary { color: rgb(51, 103, 214); } .buttonbar .left { float: left; text-align: left; } .buttonbar .left button:first-child { margin-left: 0; } .buttonbar .right { float: right; text-align: right; } .buttonbar .message { margin: 7px 0 0 2px; } .buttonbar .message .icon { display: inline-block; height: 18px; margin-right: 8px; vertical-align: top; width: 18px; } .buttonbar .message.wait .icon { -webkit-animation: rotate 2s linear infinite; background: -webkit-image-set( url(../images/placeholder-loader-1x.png) 1x, url(../images/placeholder-loader-2x.png) 2x) no-repeat; } .buttonbar .message.error .icon { background: -webkit-image-set( url(../images/ic-error-1x.png) 1x, url(../images/ic-error-2x.png) 2x) no-repeat; } .buttonbar .message .text { color: rgba(0, 0, 0, .54); display: inline-block; line-height: 18px; padding-top: 2px; vertical-align: top; } .buttonbar .message.error .text { color: rgb(213, 0, 0); } .close { -webkit-app-region: no-drag; background: -webkit-image-set( url(../images/ic-x-white-1x.png) 1x, url(../images/ic-x-white-2x.png) 2x) center center no-repeat; border: none; float: right; height: 42px; opacity: .54; width: 42px; } .close:hover { opacity: 1; } .toast { background-color: rgb(38, 50, 56); bottom: 0; height: 52px; padding: 10px 12px 0 42px; position: absolute; width: 100%; } .toast .message { color: #fff; float: left; padding: 9px 0 0 0; } .toast button { background: none; border: none; color: rgb(58, 218, 255); float: right; height: 32px; margin-left: 18px; min-width: 56px; padding: 0 8px 0 8px; text-transform: uppercase; } PNG  IHDRFgAMA asRGB cHRMz&u0`:pQ< pHYsHHFk>bKGD̿kIDATx^!0DQ0=QMOF=BNTSQLXu$~b8hdG #Iz"Ld̓DbH  %$Ґd]I@L]q[ %tEXtdate:create2014-12-17T17:17:47-08:00z_%tEXtdate:modify2014-12-17T17:15:16-08:00GEwIENDB`PNG  IHDR$$K PIDATx^ Fp ңB?_cϜ$8wM  )P^3N90y g&A 0 J f([9*욷S8Qv~2`FIENDB`PNG  IHDR0cõIDATx]O`EP"b1$(c~} ;%!!4tP ^ LqckF٣G& ɕ\{w*"@JٓJR>ۖ7r܎X27*-3xJML&{7bj||/c9 VBcܜ#j2uy}ȟ?7–SKW")/5"!5Mų` Ȭ}aWF5M+7"KMF9=VvPf _&aWWUo,OU?簵dlΟI,}nB;RK^\=c๩zSZHu1u]]]+DX`WOL9Uyx+iL~{|ţ,L-[Fl"+!+݅`OE]\Aɐ })+ 7ynew QWZ1H){w֘H؛Z_w\6ضZ.s2Ψ $fAu;$^3m}xQ(aXq uZ&2U,J2˫o3nLE(:ꈣx8. (.AaD+B%K"I[T€ K4I .ItiifҒfi=oIԫtI~^kr9}^ivh ͱHsW֢hIU%V[wT/ߓc$ɶ֊ՖKԖ,K#ɶHYI-"""f`#߰GeKnb]*@-V۞ L/u} "A'qm9,ZٶIyK Fu=rŪ'Od꺾Yvg UPtWIWp)t2] 窏4,""_G\ Xn0t-0"ACjCNl[{eG)iU&lV wLI?Ƃ骬Hs+}"f}~'Q厧/y 5CVW NeR۔Zz322F^LJ/e 8T[SZPy@&[Y~`~(tfyq)Y..{a0$5yKS:oHU]4@\b\7S|O?ixH$8#O.cNvp^{Y]"".Yz ~JZbL`e` biŋ~8v*m<%H""My%}/ ӊ|"@U_ قt{]`i*6ډ,"0\ J7< ?V}מ3'#W"IJB#L(E$ؑI"w%ƇtoN+m|Fzpm*iY D#Wq>X8m6pns԰V(5D$6$eቦPT9<&1l&7`yPR4"IGpa(Lj:˝ޅ=R:\*\cL`{Vi_ w꣢_H-&օ'6'$z@9Lb WO[Y ɣ+Z9A=1{Q[Vdݨw-ˤ /)v]<{ F4XS^ŞEQ{xv)#Ll| "E$c0j&De%$%ݛq%9]+RUMpx~ 1|5 /䃺FT؎r,-h[Avk EK9w?F& ؞ShA Xe:h~Quz;=epl0G\8%oKMdAu1? Oyz;:ahB<%D\fցP:ڒ_?ֵ!Gч*H=)@K%VLHH)bشZ1މW``xOn$R׵̮Cu^Xj<3*S@T./ 0o>\ulW/+BqDg̵V~+ >7Ζm<ݭ~9Po2*t6.2CJ/saB|hMJt/Vs`?Նt6ujx]#}:Tڀ 4[QSul-XnĎYDD)\лzb>3ḽKM04uX: -@3jhvf BMMPP{E<$|5Q@~ A ,0{_nPrW&>͟6C;-mXZu|U3.P 61us(&r$u1 o)5< W~S,*Tԙ'UU+ By; ]Bz, KѿU=8:3&镈& ņϻC `G5ٻO"$qTVG1MN] K3B+`^w _IDDc¥7 !SHo[*4|?CLS |?|n&]naޫKD$J`M1X?[X9v D>ؔ͠Õ&"/GNN%󹙏r3!xۏ$-n`^o pd?g. wͫWM)]'a7{Lm/G4 h7zP ˽d{hpv1ʺ!*e *PfNϢh&SJ'I܈DQ?㊮7`^ !`Oumd=G~*O8ƾhG+ќåߣw={ < aCmI=OPVPZ:W ew 1l>ߔ%2_y FU•׺.hZ4):Mܕ AvIQ|4`6?j?DctWI.Ye / )?N1ʗjiZgvxXT'6?݂V& %MS:Yz swd^ ~=20T[b4\ؔNф%NVwaJ=dS,t_#bH!" U-t64HNM-Ȱ "~ *^ E3lQtB=%hܾ%bX1cqr,lC,Ѱmoj|4>-Y.'N*FL.@+z}<=?b ~n.<@6ND" hʁR&[f* =Qs8DapL۶P>cMVgzsf s+?Ìn޵(TfAkN"Fˇqk)8aqEw0 ych|tUQ=;ȸ UpA>8&y__}uoc!-z9cxգ_wV7&&6rJ!,$v58*k6`EK(' *Mؑ3@E3{?^ۓ+a8Z8l52;De2)O+<"l`p :PbXҽcK_XKoh> {҈|ʼna.STRSjjBRkSm\i}:sh0#as4My:AB9z5{*,`t iXqt+ ᷥ;엘5íʶS,GTm.(΅"m¯'λ6; 3d鮡 Dc\ g )5BdEh!u+=,(ez()W@ ({)P?P7-cGLɇ=qqQx˗ɕj⟸x$l,_&ҿ9 `  R#/}5~|aϾrظ,L\y԰fD> 6O%o2 7.߳)z?E >[%_n.ZnZn9gv\kYVgWgA[Wy 5,MAMA2{ç K%óX_pk tBmM_~= X]usNFuT+߷?knPpuBdV 0)zh-l)s[AoY(]_H9T9&Sgɛ.5$G]CsDWq}A))ȋ|N< ՟z+nHp(Cxwp4}5RjpZ.E?fUX{ 2u\4^y91G-;[ٜ𸳹Q_:/R{f,1'ScNF}/ Z}ٍ?yv3F]:~OrMޔ]i[cRܕvyδųhgLV_'7?oY_G3&?hb=1!P'977>ssNkbOKc~\477w$Ik1O|tvvE9֌'cSǵTfw̉19L; jDs?6 +i}_I8u>whsH"26NŶ1Ę{O33) ɜ"ʼX#r\,G6n?E#9! PS d>:u޸aFCCH7}x=<\˥]?p$ˢf:/.i á}3sҺ;3B0a†) o$ .j0'''s[.He5!~˵T)Q==nX[:C<kimuwB:eݼ :3|A03鼮3r.;Qg@0$mjjڸAʩ%@dH0E^9U^+sTyP&[llll[7 mRlɳ yŋCU&^; 殇KTpEب1 ,qZozp!"˼RNMg7%0*Ve)\ȼL?? B ZB qw=  *d jd ¥f@E ָYTW'Ӯ31uD a涫3:}a%  ^J'bޮk׫Lc`ttzvĄfޛ1AXP 饒mо;'ܡkN-'IvrO*:4x=G}!镰 Roԩxxɱ% SQSMb*ly%t[ e%OOu 52_ Y]i 8lc;ͺh(i%mîwy aL?kN/?Gε]m~XWcY7d2l6wK=u8隇5{ku}>wo^qu#g*.: # vuZ=_۶Ov(S^c3_&AEWx ki>?[UMǫfujW rE27]Eq.kggUbX_)ם} ^. ^Tw:.E{[/<55IENDB`PNG  IHDR`1Q%IDATxne?0MD@DOMn|r[x8jd|Poe1ΪMIR{%MwJɠc{+r%*q%;(H^__rYx:J<`ȟaAk⮱0,%a,!ۿ^/ql|5wg:(_O3[i X*%h@p=[ v{e빉ײarvmIws9IvP0@S)Z΀ p%Mw+ZkoY/t?&! 3gY]C׻)UBj4z`@^\X>|zxPm<>? ZƱ|ݪOlYx'pܼ9F4<2WY9J\mRyM^O{7O\ &\dyi]>1Ul~=]jV͞u*5g/{ގ0xه'5ţ?rؼODGKʆSn_I{`ӥ攒!Rl^2B6n L(o|͙.5& h'_`s*4aL>:8:̼.EFjY".#,XE}O"e6ZmhKiY@*A2S!-m$7<ߧ'}>=w,e%'VìM<(xa*f9e\Xm$.SZ{=|@U樭b*pj^!|Cⵏ EiQYE0iTrB"K+fcxdxßC!"$'\Whf] ^+9q_s񑋀5Ny 5/ I@@/'$)y\p.`ͣj5,i@\QUYJ^L X4Y A@\h>tC/d'."`ͣ)['H$ `XK8擔Xfۓ $\0F7(xGWI|Q/# T^x0_8G } `#y깇mvTAE4_PJ$3)KIag*u) x]+eS?n)JL ;غ}ۍ0%/J1f@NKF)KJ%m\A@rjvO( `E ߳@̻k mnSns++_}҉%G L `&6# ~2^&ZKAT[0L#rJe ̷-+ba ;VD +bx\/F `-}*XDþ !t"V`R̷7ܪ1/! :#:),lX\҆ÿ%KˏP%bGip)]% .xjyk58=Weͥ"A$_UL `MFbK2$VvPg]6NsB#-l~ 9UL `ْh>tH{E*L˅ K=Յ+s 0@PK]WKaۖ? 0bCAY{~3^kȯ(0X 'U(BEĄm _J IeG+y +[Hk6" 1 $sXnKzzhB6&2v$r `0YC {XE M%a 0E.V!`4N H. ._,E#&Q  :Bw@Hj-H&;<5,dI7:_`L_rB-h& ڣOhT{wEWN18/CԜ0@<]w1=_R%URgblu_sʎI{}w;'|/ Uw/|g l\ےEcYBjEJN<h!eФ|Ye`H4t}![nIvBjt%Cg LedN/V¼6Յ0a*9,ZW擰)'Ik`EsHmKҶx%Ї@4nߪ5هBږe[1*`'7R?:zV6o+N#?6Qx]CE|χv? `~$)'h ;D.?{㧒D YZj79Oqhc/U?OA `-iGg\~X1ێ0$͞vΚ6˞#9yVNn7aBpC+*39A `H5 xYT"v4n(9'E|.*$(,B4^8Zc w#]l;Nq* QXו"̈́去ƊO!Poퟕa?M[*8E޳ۉuFVD"0D͋)y?L'(kfȲȆmU %G?dUrW{XȈ 8TW;ϑXAbzל[^'ҁ;hG `hTatރ/`|] Ji'R I\D̘Sw#i^D(v}31a̮hFuHРߐb 2{Ũ|Ggp؞^/D(0 k<_&,sƢ39G-fr-}iXB _UM_SFf7ۤi3Sv~KiR9Q`O yct\ٖdųsԼgG&פl}ǾNe^A-R7;Q{p\.S_ f^^A4@kjiOߊWpa+glkR?P- @0 GYyeuFy ?T +% =OmrїWUe+_L:0I\GVXR½ _fm`Ֆebe}bZ MQTҷm9vRqi뫯'}vZ&VcK+iB:2s `ksK*,'OϷE\BR7|!Kw)9*YM,mH:tQT<3}ÁE:en+_,-'}¼:}߰Ն|[涌 A 49kbȎ3%gyy,en=;|F Ufԭ.: S^*YwS*ؔӓwF4%'TZl}- gnoY| ^cC[8+RkQ KSO@ɘQ|Y29Fg+:]š[/3_WCl䟱̩.Ɏ\ɆCӁ[C"h)yt=1ZP[ŽJ\u-@c(^grӛ~SvSЄ<;AϳJiB3E=$ #όQ4[_#2|0DLFgoN_[拚NYJ~YzS;4{R}Yt4 &+N5VXW[R[[ ANpfapRS绱[aL/W>.`_ $z=Ez8T N,v #TXC=ub1s"'0`!yrVi؟oB,u;~_+VDe0" ,sl@ JK+a6)2bTD Ib5Sc~UjO, \[AhV[Fz|KCq'ΫuY'w:9-=i =,?5s>_J]bt VVM]Od}xW\a{QdOi5|&15jNb /)N|DPMα,ÖO|1y^St HPW*?]Wc#y.4#.dp-JH V;U#x}eud"ZVH0,~M{nH58_߻woZ[ DXۊw ˩qC{/uv2 .dQW&}?GtӥW˩"ix7TZy?Iy$P+e[ҳ՚%6KzQkjBB3dcl>,I3Qv`I[+V*`5Q#.xBD- w lg?ႀE?WV~#leI3,l1 Bzoi8?BcyjҎkρXVSީJhk?0$:s/)n'+՚*9\.a DʩcWW-fQk ݉hyYR6IY쨋j{$ kj#!jR"/e)SZ<%[==ͮ~4̗5v]&n( <0:ћg=uu&LF燾f( ,ۙT[U5Gj)kn5ŏȩSsxؿӒ;" |NIaMl ^`Y^3\W^Q)d'LQqHySaʦ9oDNvs9K|N'|r˹&~b0t݁XR8ͩ†*,[OkԖ-O򾥶jy_惯}}FU V >T|vSX)$Y-mM|1YNS5{!XJVgLhޏ͎Fo8<2_GJ[(j_dw,u]TW,>ji[x*2@(i.`6b0_틔UC)iAd=DdeFd=`VEafjD* Vs? 939:̪WQ/ "@jkW;3žyuO3™Y(Y\%/{mj70.5EQʱg>;q,Oc[Cm7(~3Ca~`fL}h00)/*%c7& ԣNOVy4-ɗz]8ugBޣ%V-dEɳ#t5TZkkۨP%u/'f~dw񪏏U\s՗>. c}>u),َxw*S>Pʲ9{{mK 0Eyݷ=3gx!UcߩO߮ $L>?+ڮE9s7aar$zƮX|eR-pDc?ujj ̒pDS%f 9P|KOy^ɭ E"Bd)ס\<]2֘g>"371j,.?J༬]Qr܍27&')`vs-u>hA_5(A~65z >M)Կjbƽۺგ]]u`p=FJT*u}5pVRUCCߓWiAMI|`, y(?0SMn mMNyWpmM EM/i{^4IW`Dpj^W(^KQ.߯9@j)kw>8?v13df+e*fS|312RYtYiE4ˤj_Mis>U5)))0)8jzw8>lO?t[M)n~>z?{@|jmqddH@}pkn^cjmI]]_dZReF=yaPmBsCfex! &Q29o|oq931N @d򽑶}> 8 /kw E)m9uv6;oÀi "n:oap{Ყ'0D̄S@jb8o%@fp;D4e'DT S>3(ۃ-G>132v!< =.Wk¦ lbv_b&BVqpb1e]bQ^d"-Ǽ @3ar8T1 B;<=Pā] ƧCŢUA)GH Bk7\" ~#c];ڋ fN@h3v1BήKRuA&BS7>?X Q}aJC@ Ϥ?x j؉!(ơ@):=␮LTD_:I"JNA̶fVr8b& 45~ HCzwf0xs` J@ *ҏƅ E3  Nhҡ{Ap{A<5 MZ7= ?_"L[:(l{gP C:} J-W:=:Ӂ@8l@}AJ7ǃ c-[WpdtIENDB`PNG  IHDR  NPLTEDCB~CB~A}iA~A}BBlhefc`b_jd]o@|nEk>zgaA~eg^CcD?{q=y`E^;vmNuye[iG B ZK B B 3G B ƹ @)/@yn1B :!$^B :! 5Z B >-y^B -@VJ@@W-@D "jS "Z B G B !f@@o<!G B ෾B ":!:X!!@Pc D$ynB 6@$!@yB @!U} D$B [ 6"jG "[ "S B 4#~_[ "H>B "jlD S "-@P}G B <#"jlB #@NB >"ښ!|Ì0#B 1B HM6 B k 6B OjK B  S B S B -K B O6@@I)!@ >B  jc @{3B 0#B B }`j%K09- @BHE P >>5.[dMs2 MwSvD Ğ:.@,?\G@Bzwt A #O|gG9K"fm~gG\D M7@x@.xi"6!bϺ;q8&BӼ_Nu@@x7<8;J #g4A мukVA bWxcB-(D E<{p*ѿ&kA ; ~&#-_Mw<3@@h5r@ʌ3-*A D |ߞHV @"M;U)dx{ǟ1+A Zw&jD Ă0`#Œ @X]??_&D /|۟PPA Ή@Ͽ-D ]ܲ&("3@ծjI(`F!W<H@E[OD I ]d_?q &A ĞH(E A j:l,&Qu @iZ nDcwO q U o<qNd@\F'΃0DMBӆ׾7EՁ@(k@u.<|\#K @hZu^: Q}Ї:|@ _K0M D ˭iRoK@gpc +ބ*DB>vMth_!*D4B'DzI5Py\#?ꒆe_!*L6B󊭻#dX| . X;/K& `A \ͷN'M$./؇DɁ{?!vQ ֑ږ:'hKƚOw,SoKA !?Tگޖ, @8Uai û '! {6UupJ @%ѯzsz["x ymC0@k]kH* H@)Pi:> $&<709B=%y ܚQyվj$@0"=?C* i }<Р@ 6btݚ~27>dJǂ @0DLs1SաJB2HK~aT' %x{YD 0#O6ݩPI DZ'>S@@v]jf @ D t]T[#g`M 2ůRf_ @ 0#n]7m A JZu?J< 6םyU.=%$ RVu nh#!?≠:L  [v@ *!#0M * tg ݱ`KKP!uq,  ,&!i}K|MBvՁ@ph ռTa$@Gf-aФtj 0EՁ@ tb 2W^x^-aI7nH@ @d|/<%DHG"}Xvu&F,u Lz@8ȥ G[yCgn@8\@dWL%@8i{ o%- &¾P,rwg{~j$E0Ϯq'"x@ EӫN54K[f!@4¸t xjx2O?+ HqX?>A dd,3=DlcH!/Mu+#P$D 1/3:q xm<QDa"@4c֏Fu@xzB@ /8@0ꮜh@۽!z /M}h8G ӿ Gy@k@8L hڮ7Ձ@ݭB h&"A @huGn{@X!kΒ< %u ֒<B۶<C0+@Ц_d<bxr`Ii;+@/튑t@|[d<"7ҷ%A @x`8 Xk:G Dڷ]q*8׿@ ud<*aA 59 iMԁ@m<@%oK@@Nm=9ku?y"9ԁ@ZM̍Q@ @xw]A /i֘B _Wor[eIENDB`PNG  IHDRVΎWIDATxc郱Jmv qh` %͟ԁw usI[/Z|odI! Z?_ul˾' 84|T&Wh % @v9|y`FY8 +P|'K?~Xɱ !̦͟/1 5yHOy1%M!+A.#3}n 7톫le/E 1} lFIENDB`PNG  IHDR$$IDATxK(EABDQ Y˜# FF3RYHYNVRX`EDqf(BkY47 $B&;C٘COQ=tI5h0[k!YH'm6ʬblf,$Ah-jR**TP >YRh1ѡ&F ~=^MB91` ,ͤ&sf;0Lfje"X [3f|>$IENDB`PNG  IHDRFIDATxc)/}*ưQCl6"Pj ĭ]@,yB%yg R@A(̣OWl>n. TW+$sb2WN5<=b'Hʕ[*g: 9|0~P3Q*w͖*%,__#,j埘IENDB`PNG  IHDRVΎWIDATx^Փ 1 EY9;dvaXJװ^9"})'8ެc=s6esFlŸS1r {'r*fW*6dw@vĝ_P$cNۿC.9Xݴsq_IENDB`PNG  IHDR$$h6PLTEC7C6C5D4C7C6C6>6D6A5C6C7C6D7C6A7D7ԢltRNS1T!?׮3vsIDATx^K EQ0V^°w| @eB!B2K96^ju7GO[GJ}X-5wO,#GA 0"A"h`4LHwya$(`ԟy?ͳh~U1/}}!O~ T$#IENDB`PNG  IHDRa}IDATxc<@?Uaf†`@" ʀe18~a 5;1 #ހ_}* (Fʮ bclh8!Q },M4IENDB`PNG  IHDR00` PLTEBPtK\SV[GCWXFSQRpo#tRNSIJTIDATx^շ0C!kzy<t3\sy )o&S#As(Xh?G!HRbHnۀ';D802qܢRP1 2VW߿S? f7CYJ.Kc9Ѷ\IENDB`PNG  IHDR?B\DPLTEBx\YIDECOKF[}hjcHGl^rZ|QP_{jtvfeNkxJ tRNSrp UIDATxEo\1;ffN1)33}o@3fSJx兏ewb@oǀcX51@,c0 `mr`# T%<LR~-sŌ;|9x /Ss>ʀ `@и$kPx.X >wňVZ ؃z/F >g1 Y/*7ŀY7} n8{`Tldމ'sQ񼁺e5 ?OM#yU_XCݷ}.Bƥx jQlס*REjxP>r1f&iwEq3 #aII G\mr.D >D$Zj>wm*'& `@pe/0 `g}bU_ǀkbW-""""j5鱨,IENDB`// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to manage always-on hotwording. Automatically starts hotwording * on startup, if always-on is enabled, and starts/stops hotwording at * appropriate times. * @param {!hotword.StateManager} stateManager * @constructor * @extends {hotword.BaseSessionManager} */ function AlwaysOnManager(stateManager) { hotword.BaseSessionManager.call(this, stateManager, hotword.constants.SessionSource.ALWAYS); } AlwaysOnManager.prototype = { __proto__: hotword.BaseSessionManager.prototype, /** @override */ enabled: function() { return this.stateManager.isAlwaysOnEnabled(); }, /** @override */ updateListeners: function() { hotword.BaseSessionManager.prototype.updateListeners.call(this); if (this.enabled()) this.startSession(); } }; return { AlwaysOnManager: AlwaysOnManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @fileoverview This is the audio client content script injected into eligible * Google.com and New tab pages for interaction between the Webpage and the * Hotword extension. */ (function() { /** * @constructor */ var AudioClient = function() { /** @private {Element} */ this.speechOverlay_ = null; /** @private {number} */ this.checkSpeechUiRetries_ = 0; /** * Port used to communicate with the audio manager. * @private {?Port} */ this.port_ = null; /** * Keeps track of the effects of different commands. Used to verify that * proper UIs are shown to the user. * @private {Object} */ this.uiStatus_ = null; /** * Bound function used to handle commands sent from the page to this script. * @private {Function} */ this.handleCommandFromPageFunc_ = null; }; /** * Messages sent to the page to control the voice search UI. * @enum {string} */ AudioClient.CommandToPage = { HOTWORD_VOICE_TRIGGER: 'vt', HOTWORD_STARTED: 'hs', HOTWORD_ENDED: 'hd', HOTWORD_TIMEOUT: 'ht', HOTWORD_ERROR: 'he' }; /** * Messages received from the page used to indicate voice search state. * @enum {string} */ AudioClient.CommandFromPage = { SPEECH_START: 'ss', SPEECH_END: 'se', SPEECH_RESET: 'sr', SHOWING_HOTWORD_START: 'shs', SHOWING_ERROR_MESSAGE: 'sem', SHOWING_TIMEOUT_MESSAGE: 'stm', CLICKED_RESUME: 'hcc', CLICKED_RESTART: 'hcr', CLICKED_DEBUG: 'hcd' }; /** * Errors that are sent to the hotword extension. * @enum {string} */ AudioClient.Error = { NO_SPEECH_UI: 'ac1', NO_HOTWORD_STARTED_UI: 'ac2', NO_HOTWORD_TIMEOUT_UI: 'ac3', NO_HOTWORD_ERROR_UI: 'ac4' }; /** * @const {string} * @private */ AudioClient.HOTWORD_EXTENSION_ID_ = 'nbpagnldghgfoolbancepceaanlmhfmd'; /** * Number of times to retry checking a transient error. * @const {number} * @private */ AudioClient.MAX_RETRIES = 3; /** * Delay to wait in milliseconds before rechecking for any transient errors. * @const {number} * @private */ AudioClient.RETRY_TIME_MS_ = 2000; /** * DOM ID for the speech UI overlay. * @const {string} * @private */ AudioClient.SPEECH_UI_OVERLAY_ID_ = 'spch'; /** * @const {string} * @private */ AudioClient.HELP_CENTER_URL_ = 'https://support.google.com/chrome/?p=ui_hotword_search'; /** * @const {string} * @private */ AudioClient.CLIENT_PORT_NAME_ = 'chwcpn'; /** * Existence of the Audio Client. * @const {string} * @private */ AudioClient.EXISTS_ = 'chwace'; /** * Checks for the presence of speech overlay UI DOM elements. * @private */ AudioClient.prototype.checkSpeechOverlayUi_ = function() { if (!this.speechOverlay_) { window.setTimeout(this.delayedCheckSpeechOverlayUi_.bind(this), AudioClient.RETRY_TIME_MS_); } else { this.checkSpeechUiRetries_ = 0; } }; /** * Function called to check for the speech UI overlay after some time has * passed since an initial check. Will either retry triggering the speech * or sends an error message depending on the number of retries. * @private */ AudioClient.prototype.delayedCheckSpeechOverlayUi_ = function() { this.speechOverlay_ = document.getElementById( AudioClient.SPEECH_UI_OVERLAY_ID_); if (!this.speechOverlay_) { if (this.checkSpeechUiRetries_++ < AudioClient.MAX_RETRIES) { this.sendCommandToPage_(AudioClient.CommandToPage.VOICE_TRIGGER); this.checkSpeechOverlayUi_(); } else { this.sendCommandToExtension_(AudioClient.Error.NO_SPEECH_UI); } } else { this.checkSpeechUiRetries_ = 0; } }; /** * Checks that the triggered UI is actually displayed. * @param {AudioClient.CommandToPage} command Command that was send. * @private */ AudioClient.prototype.checkUi_ = function(command) { this.uiStatus_[command].timeoutId = window.setTimeout(this.failedCheckUi_.bind(this, command), AudioClient.RETRY_TIME_MS_); }; /** * Function called when the UI verification is not called in time. Will either * retry the command or sends an error message, depending on the number of * retries for the command. * @param {AudioClient.CommandToPage} command Command that was sent. * @private */ AudioClient.prototype.failedCheckUi_ = function(command) { if (this.uiStatus_[command].tries++ < AudioClient.MAX_RETRIES) { this.sendCommandToPage_(command); this.checkUi_(command); } else { this.sendCommandToExtension_(this.uiStatus_[command].error); } }; /** * Confirm that an UI element has been shown. * @param {AudioClient.CommandToPage} command UI to confirm. * @private */ AudioClient.prototype.verifyUi_ = function(command) { if (this.uiStatus_[command].timeoutId) { window.clearTimeout(this.uiStatus_[command].timeoutId); this.uiStatus_[command].timeoutId = null; this.uiStatus_[command].tries = 0; } }; /** * Sends a command to the audio manager. * @param {string} commandStr command to send to plugin. * @private */ AudioClient.prototype.sendCommandToExtension_ = function(commandStr) { if (this.port_) this.port_.postMessage({'cmd': commandStr}); }; /** * Handles a message from the audio manager. * @param {{cmd: string}} commandObj Command from the audio manager. * @private */ AudioClient.prototype.handleCommandFromExtension_ = function(commandObj) { var command = commandObj['cmd']; if (command) { switch (command) { case AudioClient.CommandToPage.HOTWORD_VOICE_TRIGGER: this.sendCommandToPage_(command); this.checkSpeechOverlayUi_(); break; case AudioClient.CommandToPage.HOTWORD_STARTED: this.sendCommandToPage_(command); this.checkUi_(command); break; case AudioClient.CommandToPage.HOTWORD_ENDED: this.sendCommandToPage_(command); break; case AudioClient.CommandToPage.HOTWORD_TIMEOUT: this.sendCommandToPage_(command); this.checkUi_(command); break; case AudioClient.CommandToPage.HOTWORD_ERROR: this.sendCommandToPage_(command); this.checkUi_(command); break; } } }; /** * @param {AudioClient.CommandToPage} commandStr Command to send. * @private */ AudioClient.prototype.sendCommandToPage_ = function(commandStr) { window.postMessage({'type': commandStr}, '*'); }; /** * Handles a message from the html window. * @param {!MessageEvent} messageEvent Message event from the window. * @private */ AudioClient.prototype.handleCommandFromPage_ = function(messageEvent) { if (messageEvent.source == window && messageEvent.data.type) { var command = messageEvent.data.type; switch (command) { case AudioClient.CommandFromPage.SPEECH_START: this.speechActive_ = true; this.sendCommandToExtension_(command); break; case AudioClient.CommandFromPage.SPEECH_END: this.speechActive_ = false; this.sendCommandToExtension_(command); break; case AudioClient.CommandFromPage.SPEECH_RESET: this.speechActive_ = false; this.sendCommandToExtension_(command); break; case 'SPEECH_RESET': // Legacy, for embedded NTP. this.speechActive_ = false; this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_END); break; case AudioClient.CommandFromPage.CLICKED_RESUME: this.sendCommandToExtension_(command); break; case AudioClient.CommandFromPage.CLICKED_RESTART: this.sendCommandToExtension_(command); break; case AudioClient.CommandFromPage.CLICKED_DEBUG: window.open(AudioClient.HELP_CENTER_URL_, '_blank'); break; case AudioClient.CommandFromPage.SHOWING_HOTWORD_START: this.verifyUi_(AudioClient.CommandToPage.HOTWORD_STARTED); break; case AudioClient.CommandFromPage.SHOWING_ERROR_MESSAGE: this.verifyUi_(AudioClient.CommandToPage.HOTWORD_ERROR); break; case AudioClient.CommandFromPage.SHOWING_TIMEOUT_MESSAGE: this.verifyUi_(AudioClient.CommandToPage.HOTWORD_TIMEOUT); break; } } }; /** * Initialize the content script. */ AudioClient.prototype.initialize = function() { if (AudioClient.EXISTS_ in window) return; window[AudioClient.EXISTS_] = true; // UI verification object. this.uiStatus_ = {}; this.uiStatus_[AudioClient.CommandToPage.HOTWORD_STARTED] = { timeoutId: null, tries: 0, error: AudioClient.Error.NO_HOTWORD_STARTED_UI }; this.uiStatus_[AudioClient.CommandToPage.HOTWORD_TIMEOUT] = { timeoutId: null, tries: 0, error: AudioClient.Error.NO_HOTWORD_TIMEOUT_UI }; this.uiStatus_[AudioClient.CommandToPage.HOTWORD_ERROR] = { timeoutId: null, tries: 0, error: AudioClient.Error.NO_HOTWORD_ERROR_UI }; this.handleCommandFromPageFunc_ = this.handleCommandFromPage_.bind(this); window.addEventListener('message', this.handleCommandFromPageFunc_, false); this.initPort_(); }; /** * Initialize the communications port with the audio manager. This * function will be also be called again if the audio-manager * disconnects for some reason (such as the extension * background.html page being reloaded). * @private */ AudioClient.prototype.initPort_ = function() { this.port_ = chrome.runtime.connect( AudioClient.HOTWORD_EXTENSION_ID_, {'name': AudioClient.CLIENT_PORT_NAME_}); // Note that this listen may have to be destroyed manually if AudioClient // is ever destroyed on this tab. this.port_.onDisconnect.addListener( (function(e) { if (this.handleCommandFromPageFunc_) { window.removeEventListener( 'message', this.handleCommandFromPageFunc_, false); } delete window[AudioClient.EXISTS_]; }).bind(this)); // See note above. this.port_.onMessage.addListener( this.handleCommandFromExtension_.bind(this)); if (this.speechActive_) { this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_START); } else { // It's possible for this script to be injected into the page after it has // completed loaded (i.e. when prerendering). In this case, this script // won't receive a SPEECH_RESET from the page to forward onto the // extension. To make up for this, always send a SPEECH_RESET. This means // in most cases, the extension will receive SPEECH_RESET twice, one from // this sendCommandToExtension_ and the one forwarded from the page. But // that's OK and the extension can handle it. this.sendCommandToExtension_(AudioClient.CommandFromPage.SPEECH_RESET); } }; // Initializes as soon as the code is ready, do not wait for the page. new AudioClient().initialize(); })(); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Base class for managing hotwording sessions. * @param {!hotword.StateManager} stateManager Manager of global hotwording * state. * @param {!hotword.constants.SessionSource} sessionSource Source of the * hotword session request. * @constructor */ function BaseSessionManager(stateManager, sessionSource) { /** * Manager of global hotwording state. * @protected {!hotword.StateManager} */ this.stateManager = stateManager; /** * Source of the hotword session request. * @private {!hotword.constants.SessionSource} */ this.sessionSource_ = sessionSource; /** * Chrome event listeners. Saved so that they can be de-registered when * hotwording is disabled. * @private */ this.sessionRequestedListener_ = this.handleSessionRequested_.bind(this); this.sessionStoppedListener_ = this.handleSessionStopped_.bind(this); // Need to setup listeners on startup, otherwise events that caused the // event page to start up, will be lost. this.setupListeners_(); this.stateManager.onStatusChanged.addListener(function() { hotword.debug('onStatusChanged'); this.updateListeners(); }.bind(this)); } BaseSessionManager.prototype = { /** * Return whether or not this session type is enabled. * @protected * @return {boolean} */ enabled: assertNotReached, /** * Called when the hotwording session is stopped. * @protected */ onSessionStop: function() { }, /** * Starts a launcher hotwording session. * @param {hotword.constants.TrainingMode=} opt_mode The mode to start the * recognizer in. */ startSession: function(opt_mode) { this.stateManager.startSession( this.sessionSource_, function() { chrome.hotwordPrivate.setHotwordSessionState(true, function() {}); }, this.handleHotwordTrigger.bind(this), opt_mode); }, /** * Stops a launcher hotwording session. * @private */ stopSession_: function() { this.stateManager.stopSession(this.sessionSource_); this.onSessionStop(); }, /** * Handles a hotword triggered event. * @param {?Object} log Audio log data, if audio logging is enabled. * @protected */ handleHotwordTrigger: function(log) { hotword.debug('Hotword triggered: ' + this.sessionSource_, log); chrome.hotwordPrivate.notifyHotwordRecognition('search', log, function() {}); }, /** * Handles a hotwordPrivate.onHotwordSessionRequested event. * @private */ handleSessionRequested_: function() { hotword.debug('handleSessionRequested_: ' + this.sessionSource_); this.startSession(); }, /** * Handles a hotwordPrivate.onHotwordSessionStopped event. * @private */ handleSessionStopped_: function() { hotword.debug('handleSessionStopped_: ' + this.sessionSource_); this.stopSession_(); }, /** * Set up event listeners. * @private */ setupListeners_: function() { if (chrome.hotwordPrivate.onHotwordSessionRequested.hasListener( this.sessionRequestedListener_)) { return; } chrome.hotwordPrivate.onHotwordSessionRequested.addListener( this.sessionRequestedListener_); chrome.hotwordPrivate.onHotwordSessionStopped.addListener( this.sessionStoppedListener_); }, /** * Remove event listeners. * @private */ removeListeners_: function() { chrome.hotwordPrivate.onHotwordSessionRequested.removeListener( this.sessionRequestedListener_); chrome.hotwordPrivate.onHotwordSessionStopped.removeListener( this.sessionStoppedListener_); }, /** * Update event listeners based on the current hotwording state. * @protected */ updateListeners: function() { if (this.enabled()) { this.setupListeners_(); } else { this.removeListeners_(); this.stopSession_(); } } }; return { BaseSessionManager: BaseSessionManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword.constants', function() { 'use strict'; /** * Number of seconds of audio to record when logging is enabled. * @const {number} */ var AUDIO_LOG_SECONDS = 2; /** * Timeout in seconds, for detecting false positives with a hotword stream. * @const {number} */ var HOTWORD_STREAM_TIMEOUT_SECONDS = 2; /** * Hotword data shared module extension's ID. * @const {string} */ var SHARED_MODULE_ID = 'lccekmodgklaepjeofjdjpbminllajkg'; /** * Path to shared module data. * @const {string} */ var SHARED_MODULE_ROOT = '_modules/' + SHARED_MODULE_ID; /** * Name used by the content scripts to create communications Ports. * @const {string} */ var CLIENT_PORT_NAME = 'chwcpn'; /** * The field name to specify the command among pages. * @const {string} */ var COMMAND_FIELD_NAME = 'cmd'; /** * The speaker model file name. * @const {string} */ var SPEAKER_MODEL_FILE_NAME = 'speaker_model.data'; /** * The training utterance file name prefix. * @const {string} */ var UTTERANCE_FILE_PREFIX = 'utterance-'; /** * The training utterance file extension. * @const {string} */ var UTTERANCE_FILE_EXTENSION = '.raw'; /** * The number of training utterances required to train the speaker model. * @const {number} */ var NUM_TRAINING_UTTERANCES = 3; /** * The size of the file system requested for reading the speaker model and * utterances. This number should always be larger than the combined file size, * currently 576338 bytes as of February 2015. * @const {number} */ var FILE_SYSTEM_SIZE_BYTES = 1048576; /** * Time to wait for expected messages, in milliseconds. * @enum {number} */ var TimeoutMs = { SHORT: 200, NORMAL: 500, LONG: 2000 }; /** * The URL of the files used by the plugin. * @enum {string} */ var File = { RECOGNIZER_CONFIG: 'hotword.data', }; /** * Errors emitted by the NaClManager. * @enum {string} */ var Error = { NACL_CRASH: 'nacl_crash', TIMEOUT: 'timeout', }; /** * Event types supported by NaClManager. * @enum {string} */ var Event = { READY: 'ready', TRIGGER: 'trigger', SPEAKER_MODEL_SAVED: 'speaker model saved', ERROR: 'error', TIMEOUT: 'timeout', }; /** * Messages for communicating with the NaCl recognizer plugin. These must match * constants in /hotword_plugin.c * @enum {string} */ var NaClPlugin = { RESTART: 'r', SAMPLE_RATE_PREFIX: 'h', MODEL_PREFIX: 'm', STOP: 's', LOG: 'l', DSP: 'd', BEGIN_SPEAKER_MODEL: 'b', ADAPT_SPEAKER_MODEL: 'a', FINISH_SPEAKER_MODEL: 'f', SPEAKER_MODEL_SAVED: 'sm_saved', REQUEST_MODEL: 'model', MODEL_LOADED: 'model_loaded', READY_FOR_AUDIO: 'audio', STOPPED: 'stopped', HOTWORD_DETECTED: 'hotword', MS_CONFIGURED: 'ms_configured', TIMEOUT: 'timeout' }; /** * Messages sent from the injected scripts to the Google page. * @enum {string} */ var CommandToPage = { HOTWORD_VOICE_TRIGGER: 'vt', HOTWORD_STARTED: 'hs', HOTWORD_ENDED: 'hd', HOTWORD_TIMEOUT: 'ht', HOTWORD_ERROR: 'he' }; /** * Messages sent from the Google page to the extension or to the * injected script and then passed to the extension. * @enum {string} */ var CommandFromPage = { SPEECH_START: 'ss', SPEECH_END: 'se', SPEECH_RESET: 'sr', SHOWING_HOTWORD_START: 'shs', SHOWING_ERROR_MESSAGE: 'sem', SHOWING_TIMEOUT_MESSAGE: 'stm', CLICKED_RESUME: 'hcc', CLICKED_RESTART: 'hcr', CLICKED_DEBUG: 'hcd', WAKE_UP_HELPER: 'wuh', // Command specifically for the opt-in promo below this line. // User has explicitly clicked 'no'. CLICKED_NO_OPTIN: 'hcno', // User has opted in. CLICKED_OPTIN: 'hco', // User clicked on the microphone. PAGE_WAKEUP: 'wu' }; /** * Source of a hotwording session request. * @enum {string} */ var SessionSource = { LAUNCHER: 'launcher', NTP: 'ntp', ALWAYS: 'always', TRAINING: 'training' }; /** * The mode to start the hotword recognizer in. * @enum {string} */ var RecognizerStartMode = { NORMAL: 'normal', NEW_MODEL: 'new model', ADAPT_MODEL: 'adapt model' }; /** * MediaStream open success/errors to be reported via UMA. * DO NOT remove or renumber values in this enum. Only add new ones. * @enum {number} */ var UmaMediaStreamOpenResult = { SUCCESS: 0, UNKNOWN: 1, NOT_SUPPORTED: 2, PERMISSION_DENIED: 3, CONSTRAINT_NOT_SATISFIED: 4, OVERCONSTRAINED: 5, NOT_FOUND: 6, ABORT: 7, SOURCE_UNAVAILABLE: 8, PERMISSION_DISMISSED: 9, INVALID_STATE: 10, DEVICES_NOT_FOUND: 11, INVALID_SECURITY_ORIGIN: 12, MAX: 12 }; /** * UMA metrics. * DO NOT change these enum values. * @enum {string} */ var UmaMetrics = { TRIGGER: 'Hotword.HotwordTrigger', MEDIA_STREAM_RESULT: 'Hotword.HotwordMediaStreamResult', NACL_PLUGIN_LOAD_RESULT: 'Hotword.HotwordNaClPluginLoadResult', NACL_MESSAGE_TIMEOUT: 'Hotword.HotwordNaClMessageTimeout', TRIGGER_SOURCE: 'Hotword.HotwordTriggerSource' }; /** * Message waited for by NaCl plugin, to be reported via UMA. * DO NOT remove or renumber values in this enum. Only add new ones. * @enum {number} */ var UmaNaClMessageTimeout = { REQUEST_MODEL: 0, MODEL_LOADED: 1, READY_FOR_AUDIO: 2, STOPPED: 3, HOTWORD_DETECTED: 4, MS_CONFIGURED: 5, MAX: 5 }; /** * NaCl plugin load success/errors to be reported via UMA. * DO NOT remove or renumber values in this enum. Only add new ones. * @enum {number} */ var UmaNaClPluginLoadResult = { SUCCESS: 0, UNKNOWN: 1, CRASH: 2, NO_MODULE_FOUND: 3, MAX: 3 }; /** * Source of hotword triggering, to be reported via UMA. * DO NOT remove or renumber values in this enum. Only add new ones. * @enum {number} */ var UmaTriggerSource = { LAUNCHER: 0, NTP_GOOGLE_COM: 1, ALWAYS_ON: 2, TRAINING: 3, MAX: 3 }; /** * The browser UI language. * @const {string} */ var UI_LANGUAGE = (chrome.i18n && chrome.i18n.getUILanguage) ? chrome.i18n.getUILanguage() : ''; return { AUDIO_LOG_SECONDS: AUDIO_LOG_SECONDS, CLIENT_PORT_NAME: CLIENT_PORT_NAME, COMMAND_FIELD_NAME: COMMAND_FIELD_NAME, FILE_SYSTEM_SIZE_BYTES: FILE_SYSTEM_SIZE_BYTES, HOTWORD_STREAM_TIMEOUT_SECONDS: HOTWORD_STREAM_TIMEOUT_SECONDS, NUM_TRAINING_UTTERANCES: NUM_TRAINING_UTTERANCES, SHARED_MODULE_ID: SHARED_MODULE_ID, SHARED_MODULE_ROOT: SHARED_MODULE_ROOT, SPEAKER_MODEL_FILE_NAME: SPEAKER_MODEL_FILE_NAME, UI_LANGUAGE: UI_LANGUAGE, UTTERANCE_FILE_EXTENSION: UTTERANCE_FILE_EXTENSION, UTTERANCE_FILE_PREFIX: UTTERANCE_FILE_PREFIX, CommandToPage: CommandToPage, CommandFromPage: CommandFromPage, Error: Error, Event: Event, File: File, NaClPlugin: NaClPlugin, RecognizerStartMode: RecognizerStartMode, SessionSource: SessionSource, TimeoutMs: TimeoutMs, UmaMediaStreamOpenResult: UmaMediaStreamOpenResult, UmaMetrics: UmaMetrics, UmaNaClMessageTimeout: UmaNaClMessageTimeout, UmaNaClPluginLoadResult: UmaNaClPluginLoadResult, UmaTriggerSource: UmaTriggerSource }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to keep this extension alive. When started, this calls an * extension API on a regular basis which resets the event page keep-alive * timer. * @constructor */ function KeepAlive() { this.timeoutId_ = null; } KeepAlive.prototype = { /** * Start the keep alive process. Safe to call multiple times. */ start: function() { if (this.timeoutId_ == null) this.timeoutId_ = setTimeout(this.handleTimeout_.bind(this), 1000); }, /** * Stops the keep alive process. Safe to call multiple times. */ stop: function() { if (this.timeoutId_ != null) { clearTimeout(this.timeoutId_); this.timeoutId_ = null; } }, /** * Handle the timer timeout. Calls an extension API and schedules the next * timeout. * @private */ handleTimeout_: function() { // Dummy extensions API call used to keep this event page alive by // resetting the shutdown timer. chrome.runtime.getPlatformInfo(function(info) {}); this.timeoutId_ = setTimeout(this.handleTimeout_.bind(this), 1000); } }; return { KeepAlive: KeepAlive }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to manage the interaction between hotwording and the launcher * (app list). * @param {!hotword.StateManager} stateManager * @constructor * @extends {hotword.BaseSessionManager} */ function LauncherManager(stateManager) { hotword.BaseSessionManager.call(this, stateManager, hotword.constants.SessionSource.LAUNCHER); } LauncherManager.prototype = { __proto__: hotword.BaseSessionManager.prototype, /** @override */ enabled: function() { return this.stateManager.isSometimesOnEnabled(); }, /** @override */ onSessionStop: function() { chrome.hotwordPrivate.setHotwordSessionState(false, function() {}); } }; return { LauncherManager: LauncherManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Wrapper around console.log allowing debug log message to be enabled during * development. * @param {...*} varArgs */ function debug(varArgs) { if (hotword.DEBUG || window.localStorage['hotword.DEBUG']) console.log.apply(console, arguments); } return { DEBUG: false, debug: debug }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { 'use strict'; /** * @fileoverview This extension provides hotword triggering capabilites to * Chrome. * * This extension contains all the JavaScript for loading and managing the * hotword detector. The hotword detector and language model data will be * provided by a shared module loaded from the web store. * * IMPORTANT! Whenever adding new events, the extension version number MUST be * incremented. */ // Hotwording state. var stateManager = new hotword.StateManager(); var pageAudioManager = new hotword.PageAudioManager(stateManager); var alwaysOnManager = new hotword.AlwaysOnManager(stateManager); var launcherManager = new hotword.LauncherManager(stateManager); var trainingManager = new hotword.TrainingManager(stateManager); // Detect when hotword settings have changed. chrome.hotwordPrivate.onEnabledChanged.addListener(function() { stateManager.updateStatus(); }); // Detect a request to delete the speaker model. chrome.hotwordPrivate.onDeleteSpeakerModel.addListener(function() { hotword.TrainingManager.handleDeleteSpeakerModel(); }); // Detect a request for the speaker model existence. chrome.hotwordPrivate.onSpeakerModelExists.addListener(function() { hotword.TrainingManager.handleSpeakerModelExists(); }); // Detect when the shared module containing the NaCL module and language model // is installed. chrome.management.onInstalled.addListener(function(info) { if (info.id == hotword.constants.SHARED_MODULE_ID) { hotword.debug('Shared module installed, reloading extension.'); chrome.runtime.reload(); } }); }()); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword.metrics', function() { 'use strict'; /** * Helper function to record enum values in UMA. * @param {!string} name * @param {!number} value * @param {!number} maxValue */ function recordEnum(name, value, maxValue) { var metricDesc = { 'metricName': name, 'type': 'histogram-linear', 'min': 1, 'max': maxValue, 'buckets': maxValue + 1 }; chrome.metricsPrivate.recordValue(metricDesc, value); } return { recordEnum: recordEnum }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to manage the state of the NaCl recognizer plugin. Handles all * control of the NaCl plugin, including creation, start, stop, trigger, and * shutdown. * * @param {boolean} loggingEnabled Whether audio logging is enabled. * @param {boolean} hotwordStream Whether the audio input stream is from a * hotword stream. * @constructor * @extends {cr.EventTarget} */ function NaClManager(loggingEnabled, hotwordStream) { /** * Current state of this manager. * @private {hotword.NaClManager.ManagerState_} */ this.recognizerState_ = ManagerState_.UNINITIALIZED; /** * The window.timeout ID associated with a pending message. * @private {?number} */ this.naclTimeoutId_ = null; /** * The expected message that will cancel the current timeout. * @private {?string} */ this.expectingMessage_ = null; /** * Whether the plugin will be started as soon as it stops. * @private {boolean} */ this.restartOnStop_ = false; /** * NaCl plugin element on extension background page. * @private {?HTMLEmbedElement} */ this.plugin_ = null; /** * URL containing hotword-model data file. * @private {string} */ this.modelUrl_ = ''; /** * Media stream containing an audio input track. * @private {?MediaStream} */ this.stream_ = null; /** * The mode to start the recognizer in. * @private {?chrome.hotwordPrivate.RecognizerStartMode} */ this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL; /** * Whether audio logging is enabled. * @private {boolean} */ this.loggingEnabled_ = loggingEnabled; /** * Whether the audio input stream is from a hotword stream. * @private {boolean} */ this.hotwordStream_ = hotwordStream; /** * Audio log of X seconds before hotword triggered. * @private {?Object} */ this.preambleLog_ = null; }; /** * States this manager can be in. Since messages to/from the plugin are * asynchronous (and potentially queued), it's not possible to know what state * the plugin is in. However, track a state machine for NaClManager based on * what messages are sent/received. * @enum {number} * @private */ NaClManager.ManagerState_ = { UNINITIALIZED: 0, LOADING: 1, STOPPING: 2, STOPPED: 3, STARTING: 4, RUNNING: 5, ERROR: 6, SHUTDOWN: 7, }; var ManagerState_ = NaClManager.ManagerState_; var Error_ = hotword.constants.Error; var UmaNaClMessageTimeout_ = hotword.constants.UmaNaClMessageTimeout; var UmaNaClPluginLoadResult_ = hotword.constants.UmaNaClPluginLoadResult; NaClManager.prototype.__proto__ = cr.EventTarget.prototype; /** * Called when an error occurs. Dispatches an event. * @param {!hotword.constants.Error} error * @private */ NaClManager.prototype.handleError_ = function(error) { var event = new Event(hotword.constants.Event.ERROR); event.data = error; this.dispatchEvent(event); }; /** * Record the result of loading the NaCl plugin to UMA. * @param {!hotword.constants.UmaNaClPluginLoadResult} error * @private */ NaClManager.prototype.logPluginLoadResult_ = function(error) { hotword.metrics.recordEnum( hotword.constants.UmaMetrics.NACL_PLUGIN_LOAD_RESULT, error, UmaNaClPluginLoadResult_.MAX); }; /** * Set a timeout. Only allow one timeout to exist at any given time. * @param {!function()} func * @param {number} timeout * @private */ NaClManager.prototype.setTimeout_ = function(func, timeout) { assert(!this.naclTimeoutId_, 'Timeout already exists'); this.naclTimeoutId_ = window.setTimeout( function() { this.naclTimeoutId_ = null; func(); }.bind(this), timeout); }; /** * Clears the current timeout. * @private */ NaClManager.prototype.clearTimeout_ = function() { window.clearTimeout(this.naclTimeoutId_); this.naclTimeoutId_ = null; }; /** * Starts a stopped or stopping hotword recognizer (NaCl plugin). * @param {hotword.constants.RecognizerStartMode} mode The mode to start the * recognizer in. */ NaClManager.prototype.startRecognizer = function(mode) { this.startMode_ = mode; if (this.recognizerState_ == ManagerState_.STOPPED) { this.preambleLog_ = null; this.recognizerState_ = ManagerState_.STARTING; if (mode == hotword.constants.RecognizerStartMode.NEW_MODEL) { hotword.debug('Starting Recognizer in START training mode'); this.sendDataToPlugin_(hotword.constants.NaClPlugin.BEGIN_SPEAKER_MODEL); } else if (mode == hotword.constants.RecognizerStartMode.ADAPT_MODEL) { hotword.debug('Starting Recognizer in ADAPT training mode'); this.sendDataToPlugin_(hotword.constants.NaClPlugin.ADAPT_SPEAKER_MODEL); } else { hotword.debug('Starting Recognizer in NORMAL mode'); this.sendDataToPlugin_(hotword.constants.NaClPlugin.RESTART); } // Normally, there would be a waitForMessage_(READY_FOR_AUDIO) here. // However, this message is sent the first time audio data is read and in // some cases (ie. using the hotword stream), this won't happen until a // potential hotword trigger is seen. Having a waitForMessage_() would time // out in this case, so just leave it out. This ends up sacrificing a bit of // error detection in the non-hotword-stream case, but I think we can live // with that. } else if (this.recognizerState_ == ManagerState_.STOPPING) { // Wait until the plugin is stopped before trying to start it. this.restartOnStop_ = true; } else { throw 'Attempting to start NaCl recogniser not in STOPPED or STOPPING ' + 'state'; } }; /** * Stops the hotword recognizer. */ NaClManager.prototype.stopRecognizer = function() { if (this.recognizerState_ == ManagerState_.STARTING) { // If the recognizer is stopped before it finishes starting, it causes an // assertion to be raised in waitForMessage_() since we're waiting for the // READY_FOR_AUDIO message. Clear the current timeout and expecting message // since we no longer expect it and may never receive it. this.clearTimeout_(); this.expectingMessage_ = null; } this.sendDataToPlugin_(hotword.constants.NaClPlugin.STOP); this.recognizerState_ = ManagerState_.STOPPING; this.waitForMessage_(hotword.constants.TimeoutMs.NORMAL, hotword.constants.NaClPlugin.STOPPED); }; /** * Saves the speaker model. */ NaClManager.prototype.finalizeSpeakerModel = function() { if (this.recognizerState_ == ManagerState_.UNINITIALIZED || this.recognizerState_ == ManagerState_.ERROR || this.recognizerState_ == ManagerState_.SHUTDOWN || this.recognizerState_ == ManagerState_.LOADING) { return; } this.sendDataToPlugin_(hotword.constants.NaClPlugin.FINISH_SPEAKER_MODEL); }; /** * Checks whether the file at the given path exists. * @param {!string} path Path to a file. Can be any valid URL. * @return {boolean} True if the patch exists. * @private */ NaClManager.prototype.fileExists_ = function(path) { var xhr = new XMLHttpRequest(); xhr.open('HEAD', path, false); try { xhr.send(); } catch (err) { return false; } if (xhr.readyState != xhr.DONE || xhr.status != 200) { return false; } return true; }; /** * Creates and returns a list of possible languages to check for hotword * support. * @return {!Array} Array of languages. * @private */ NaClManager.prototype.getPossibleLanguages_ = function() { // Create array used to search first for language-country, if not found then // search for language, if not found then no language (empty string). // For example, search for 'en-us', then 'en', then ''. var langs = new Array(); if (hotword.constants.UI_LANGUAGE) { // Chrome webstore doesn't support uppercase path: crbug.com/353407 var language = hotword.constants.UI_LANGUAGE.toLowerCase(); langs.push(language); // Example: 'en-us'. // Remove country to add just the language to array. var hyphen = language.lastIndexOf('-'); if (hyphen >= 0) { langs.push(language.substr(0, hyphen)); // Example: 'en'. } } langs.push(''); return langs; }; /** * Creates a NaCl plugin object and attaches it to the page. * @param {!string} src Location of the plugin. * @return {!HTMLEmbedElement} NaCl plugin DOM object. * @private */ NaClManager.prototype.createPlugin_ = function(src) { var plugin = /** @type {HTMLEmbedElement} */(document.createElement('embed')); plugin.src = src; plugin.type = 'application/x-nacl'; document.body.appendChild(plugin); return plugin; }; /** * Initializes the NaCl manager. * @param {!string} naclArch Either 'arm', 'x86-32' or 'x86-64'. * @param {!MediaStream} stream A stream containing an audio source track. * @return {boolean} True if the successful. */ NaClManager.prototype.initialize = function(naclArch, stream) { assert(this.recognizerState_ == ManagerState_.UNINITIALIZED, 'Recognizer not in uninitialized state. State: ' + this.recognizerState_); assert(this.plugin_ == null); var langs = this.getPossibleLanguages_(); var i, j; // For country-lang variations. For example, when combined with path it will // attempt to find: '/x86-32_en-gb/', else '/x86-32_en/', else '/x86-32_/'. for (i = 0; i < langs.length; i++) { var folder = hotword.constants.SHARED_MODULE_ROOT + '/_platform_specific/' + naclArch + '_' + langs[i] + '/'; var dataSrc = folder + hotword.constants.File.RECOGNIZER_CONFIG; var pluginSrc = hotword.constants.SHARED_MODULE_ROOT + '/hotword_' + langs[i] + '.nmf'; var dataExists = this.fileExists_(dataSrc) && this.fileExists_(pluginSrc); if (!dataExists) { continue; } var plugin = this.createPlugin_(pluginSrc); if (!plugin || !plugin.postMessage) { document.body.removeChild(plugin); this.recognizerState_ = ManagerState_.ERROR; return false; } this.plugin_ = plugin; this.modelUrl_ = chrome.extension.getURL(dataSrc); this.stream_ = stream; this.recognizerState_ = ManagerState_.LOADING; plugin.addEventListener('message', this.handlePluginMessage_.bind(this), false); plugin.addEventListener('crash', function() { this.handleError_(Error_.NACL_CRASH); this.logPluginLoadResult_( UmaNaClPluginLoadResult_.CRASH); }.bind(this), false); return true; } this.recognizerState_ = ManagerState_.ERROR; this.logPluginLoadResult_(UmaNaClPluginLoadResult_.NO_MODULE_FOUND); return false; }; /** * Shuts down the NaCl plugin and frees all resources. */ NaClManager.prototype.shutdown = function() { if (this.plugin_ != null) { document.body.removeChild(this.plugin_); this.plugin_ = null; } this.clearTimeout_(); this.recognizerState_ = ManagerState_.SHUTDOWN; if (this.stream_) this.stream_.getAudioTracks()[0].stop(); this.stream_ = null; }; /** * Sends data to the NaCl plugin. * @param {!string|!MediaStreamTrack} data Command to be sent to NaCl plugin. * @private */ NaClManager.prototype.sendDataToPlugin_ = function(data) { assert(this.recognizerState_ != ManagerState_.UNINITIALIZED, 'Recognizer in uninitialized state'); this.plugin_.postMessage(data); }; /** * Waits, with a timeout, for a message to be received from the plugin. If the * message is not seen within the timeout, dispatch an 'error' event and go into * the ERROR state. * @param {number} timeout Timeout, in milliseconds, to wait for the message. * @param {!string} message Message to wait for. * @private */ NaClManager.prototype.waitForMessage_ = function(timeout, message) { assert(this.expectingMessage_ == null, 'Cannot wait for message: ' + message + ', already waiting for message ' + this.expectingMessage_); this.setTimeout_( function() { this.recognizerState_ = ManagerState_.ERROR; this.handleError_(Error_.TIMEOUT); switch (this.expectingMessage_) { case hotword.constants.NaClPlugin.REQUEST_MODEL: var metricValue = UmaNaClMessageTimeout_.REQUEST_MODEL; break; case hotword.constants.NaClPlugin.MODEL_LOADED: var metricValue = UmaNaClMessageTimeout_.MODEL_LOADED; break; case hotword.constants.NaClPlugin.READY_FOR_AUDIO: var metricValue = UmaNaClMessageTimeout_.READY_FOR_AUDIO; break; case hotword.constants.NaClPlugin.STOPPED: var metricValue = UmaNaClMessageTimeout_.STOPPED; break; case hotword.constants.NaClPlugin.HOTWORD_DETECTED: var metricValue = UmaNaClMessageTimeout_.HOTWORD_DETECTED; break; case hotword.constants.NaClPlugin.MS_CONFIGURED: var metricValue = UmaNaClMessageTimeout_.MS_CONFIGURED; break; } hotword.metrics.recordEnum( hotword.constants.UmaMetrics.NACL_MESSAGE_TIMEOUT, metricValue, UmaNaClMessageTimeout_.MAX); }.bind(this), timeout); this.expectingMessage_ = message; }; /** * Called when a message is received from the plugin. If we're waiting for that * message, cancel the pending timeout. * @param {string} message Message received. * @private */ NaClManager.prototype.receivedMessage_ = function(message) { if (message == this.expectingMessage_) { this.clearTimeout_(); this.expectingMessage_ = null; } }; /** * Handle a REQUEST_MODEL message from the plugin. * The plugin sends this message immediately after starting. * @private */ NaClManager.prototype.handleRequestModel_ = function() { if (this.recognizerState_ != ManagerState_.LOADING) { return; } this.logPluginLoadResult_(UmaNaClPluginLoadResult_.SUCCESS); this.sendDataToPlugin_( hotword.constants.NaClPlugin.MODEL_PREFIX + this.modelUrl_); this.waitForMessage_(hotword.constants.TimeoutMs.LONG, hotword.constants.NaClPlugin.MODEL_LOADED); // Configure logging in the plugin. This can be configured any time before // starting the recognizer, and now is as good a time as any. if (this.loggingEnabled_) { this.sendDataToPlugin_( hotword.constants.NaClPlugin.LOG + ':' + hotword.constants.AUDIO_LOG_SECONDS); } // If the audio stream is from a hotword stream, tell the plugin. if (this.hotwordStream_) { this.sendDataToPlugin_( hotword.constants.NaClPlugin.DSP + ':' + hotword.constants.HOTWORD_STREAM_TIMEOUT_SECONDS); } }; /** * Handle a MODEL_LOADED message from the plugin. * The plugin sends this message after successfully loading the language model. * @private */ NaClManager.prototype.handleModelLoaded_ = function() { if (this.recognizerState_ != ManagerState_.LOADING) { return; } this.sendDataToPlugin_(this.stream_.getAudioTracks()[0]); this.waitForMessage_(hotword.constants.TimeoutMs.LONG, hotword.constants.NaClPlugin.MS_CONFIGURED); }; /** * Handle a MS_CONFIGURED message from the plugin. * The plugin sends this message after successfully configuring the audio input * stream. * @private */ NaClManager.prototype.handleMsConfigured_ = function() { if (this.recognizerState_ != ManagerState_.LOADING) { return; } this.recognizerState_ = ManagerState_.STOPPED; this.dispatchEvent(new Event(hotword.constants.Event.READY)); }; /** * Handle a READY_FOR_AUDIO message from the plugin. * The plugin sends this message after the recognizer is started and * successfully receives and processes audio data. * @private */ NaClManager.prototype.handleReadyForAudio_ = function() { if (this.recognizerState_ != ManagerState_.STARTING) { return; } this.recognizerState_ = ManagerState_.RUNNING; }; /** * Handle a HOTWORD_DETECTED message from the plugin. * The plugin sends this message after detecting the hotword. * @private */ NaClManager.prototype.handleHotwordDetected_ = function() { if (this.recognizerState_ != ManagerState_.RUNNING) { return; } // We'll receive a STOPPED message very soon. this.recognizerState_ = ManagerState_.STOPPING; this.waitForMessage_(hotword.constants.TimeoutMs.NORMAL, hotword.constants.NaClPlugin.STOPPED); var event = new Event(hotword.constants.Event.TRIGGER); event.log = this.preambleLog_; this.dispatchEvent(event); }; /** * Handle a STOPPED message from the plugin. * This plugin sends this message after stopping the recognizer. This can happen * either in response to a stop request, or after the hotword is detected. * @private */ NaClManager.prototype.handleStopped_ = function() { this.recognizerState_ = ManagerState_.STOPPED; if (this.restartOnStop_) { this.restartOnStop_ = false; this.startRecognizer(this.startMode_); } }; /** * Handle a TIMEOUT message from the plugin. * The plugin sends this message when it thinks the stream is from a DSP and * a hotword wasn't detected within a timeout period after arrival of the first * audio samples. * @private */ NaClManager.prototype.handleTimeout_ = function() { if (this.recognizerState_ != ManagerState_.RUNNING) { return; } this.recognizerState_ = ManagerState_.STOPPED; this.dispatchEvent(new Event(hotword.constants.Event.TIMEOUT)); }; /** * Handle a SPEAKER_MODEL_SAVED message from the plugin. * The plugin sends this message after writing the model to a file. * @private */ NaClManager.prototype.handleSpeakerModelSaved_ = function() { this.dispatchEvent(new Event(hotword.constants.Event.SPEAKER_MODEL_SAVED)); }; /** * Handles a message from the NaCl plugin. * @param {!Event} msg Message from NaCl plugin. * @private */ NaClManager.prototype.handlePluginMessage_ = function(msg) { if (msg['data']) { if (typeof(msg['data']) == 'object') { // Save the preamble for delivery to the trigger handler when the trigger // message arrives. this.preambleLog_ = msg['data']; return; } this.receivedMessage_(msg['data']); switch (msg['data']) { case hotword.constants.NaClPlugin.REQUEST_MODEL: this.handleRequestModel_(); break; case hotword.constants.NaClPlugin.MODEL_LOADED: this.handleModelLoaded_(); break; case hotword.constants.NaClPlugin.MS_CONFIGURED: this.handleMsConfigured_(); break; case hotword.constants.NaClPlugin.READY_FOR_AUDIO: this.handleReadyForAudio_(); break; case hotword.constants.NaClPlugin.HOTWORD_DETECTED: this.handleHotwordDetected_(); break; case hotword.constants.NaClPlugin.STOPPED: this.handleStopped_(); break; case hotword.constants.NaClPlugin.TIMEOUT: this.handleTimeout_(); break; case hotword.constants.NaClPlugin.SPEAKER_MODEL_SAVED: this.handleSpeakerModelSaved_(); break; } } }; return { NaClManager: NaClManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to manage the interaction between hotwording and the * NTP/google.com. Injects a content script to interact with NTP/google.com * and updates the global hotwording state based on interaction with those * pages. * @param {!hotword.StateManager} stateManager * @constructor */ function PageAudioManager(stateManager) { /** * Manager of global hotwording state. * @private {!hotword.StateManager} */ this.stateManager_ = stateManager; /** * Mapping between tab ID and port that is connected from the injected * content script. * @private {!Object} */ this.portMap_ = {}; /** * Chrome event listeners. Saved so that they can be de-registered when * hotwording is disabled. */ this.connectListener_ = this.handleConnect_.bind(this); this.tabCreatedListener_ = this.handleCreatedTab_.bind(this); this.tabUpdatedListener_ = this.handleUpdatedTab_.bind(this); this.tabActivatedListener_ = this.handleActivatedTab_.bind(this); this.microphoneStateChangedListener_ = this.handleMicrophoneStateChanged_.bind(this); this.windowFocusChangedListener_ = this.handleChangedWindow_.bind(this); this.messageListener_ = this.handleMessageFromPage_.bind(this); // Need to setup listeners on startup, otherwise events that caused the // event page to start up, will be lost. this.setupListeners_(); this.stateManager_.onStatusChanged.addListener(function() { this.updateListeners_(); this.updateTabState_(); }.bind(this)); }; var CommandToPage = hotword.constants.CommandToPage; var CommandFromPage = hotword.constants.CommandFromPage; PageAudioManager.prototype = { /** * Helper function to test if a URL path is eligible for hotwording. * @param {!string} url URL to check. * @param {!string} base Base URL to compare against.. * @return {boolean} True if url is an eligible hotword URL. * @private */ checkUrlPathIsEligible_: function(url, base) { if (url == base || url == base + '/' || url.indexOf(base + '/_/chrome/newtab?') == 0 || // Appcache NTP. url.indexOf(base + '/?') == 0 || url.indexOf(base + '/#') == 0 || url.indexOf(base + '/webhp') == 0 || url.indexOf(base + '/search') == 0 || url.indexOf(base + '/imghp') == 0) { return true; } return false; }, /** * Determines if a URL is eligible for hotwording. For now, the valid pages * are the Google HP and SERP (this will include the NTP). * @param {!string} url URL to check. * @return {boolean} True if url is an eligible hotword URL. * @private */ isEligibleUrl_: function(url) { if (!url) return false; var baseGoogleUrls = [ 'https://encrypted.google.', 'https://images.google.', 'https://www.google.' ]; // TODO(amistry): Get this list from a file in the shared module instead. var tlds = [ 'at', 'ca', 'com', 'com.au', 'com.mx', 'com.br', 'co.jp', 'co.kr', 'co.nz', 'co.uk', 'co.za', 'de', 'es', 'fr', 'it', 'ru' ]; // Check for the new tab page first. if (this.checkUrlPathIsEligible_(url, 'chrome://newtab')) return true; // Check URLs with each type of local-based TLD. for (var i = 0; i < baseGoogleUrls.length; i++) { for (var j = 0; j < tlds.length; j++) { var base = baseGoogleUrls[i] + tlds[j]; if (this.checkUrlPathIsEligible_(url, base)) return true; } } return false; }, /** * Locates the current active tab in the current focused window and * performs a callback with the tab as the parameter. * @param {function(?Tab)} callback Function to call with the * active tab or null if not found. The function's |this| will be set to * this object. * @private */ findCurrentTab_: function(callback) { chrome.windows.getAll( {'populate': true}, function(windows) { for (var i = 0; i < windows.length; ++i) { if (!windows[i].focused) continue; for (var j = 0; j < windows[i].tabs.length; ++j) { var tab = windows[i].tabs[j]; if (tab.active) { callback.call(this, tab); return; } } } callback.call(this, null); }.bind(this)); }, /** * This function is called when a tab is activated (comes into focus). * @param {Tab} tab Current active tab. * @private */ activateTab_: function(tab) { if (!tab) { this.stopHotwording_(); return; } if (tab.id in this.portMap_) { this.startHotwordingIfEligible_(); return; } this.stopHotwording_(); this.prepareTab_(tab); }, /** * Prepare a new or updated tab by injecting the content script. * @param {!Tab} tab Newly updated or created tab. * @private */ prepareTab_: function(tab) { if (!this.isEligibleUrl_(tab.url)) return; chrome.tabs.executeScript( tab.id, {'file': 'audio_client.js'}, function(results) { if (chrome.runtime.lastError) { // Ignore this error. For new tab pages, even though the URL is // reported to be chrome://newtab/, the actual URL is a // country-specific google domain. Since we don't have permission // to inject on every page, an error will happen when the user is // in an unsupported country. // // The property still needs to be accessed so that the error // condition is cleared. If it isn't, exectureScript will log an // error the next time it is called. } }); }, /** * Updates hotwording state based on the state of current tabs/windows. * @private */ updateTabState_: function() { this.findCurrentTab_(this.activateTab_); }, /** * Handles a newly created tab. * @param {!Tab} tab Newly created tab. * @private */ handleCreatedTab_: function(tab) { this.prepareTab_(tab); }, /** * Handles an updated tab. * @param {number} tabId Id of the updated tab. * @param {{status: string}} info Change info of the tab. * @param {!Tab} tab Updated tab. * @private */ handleUpdatedTab_: function(tabId, info, tab) { // Chrome fires multiple update events: undefined, loading and completed. // We perform content injection on loading state. if (info['status'] != 'loading') return; this.prepareTab_(tab); }, /** * Handles a tab that has just become active. * @param {{tabId: number}} info Information about the activated tab. * @private */ handleActivatedTab_: function(info) { this.updateTabState_(); }, /** * Handles the microphone state changing. * @param {boolean} enabled Whether the microphone is now enabled. * @private */ handleMicrophoneStateChanged_: function(enabled) { if (enabled) { this.updateTabState_(); return; } this.stopHotwording_(); }, /** * Handles a change in Chrome windows. * Note: this does not always trigger in Linux. * @param {number} windowId Id of newly focused window. * @private */ handleChangedWindow_: function(windowId) { this.updateTabState_(); }, /** * Handles a content script attempting to connect. * @param {!Port} port Communications port from the client. * @private */ handleConnect_: function(port) { if (port.name != hotword.constants.CLIENT_PORT_NAME) return; var tab = /** @type {!Tab} */(port.sender.tab); // An existing port from the same tab might already exist. But that port // may be from the previous page, so just overwrite the port. this.portMap_[tab.id] = port; port.onDisconnect.addListener(function() { this.handleClientDisconnect_(port); }.bind(this)); port.onMessage.addListener(function(msg) { this.handleMessage_(msg, port.sender, port.postMessage); }.bind(this)); }, /** * Handles a client content script disconnect. * @param {Port} port Disconnected port. * @private */ handleClientDisconnect_: function(port) { var tabId = port.sender.tab.id; if (tabId in this.portMap_ && this.portMap_[tabId] == port) { // Due to a race between port disconnection and tabs.onUpdated messages, // the port could have changed. delete this.portMap_[port.sender.tab.id]; } this.stopHotwordingIfIneligibleTab_(); }, /** * Disconnect all connected clients. * @private */ disconnectAllClients_: function() { for (var id in this.portMap_) { var port = this.portMap_[id]; port.disconnect(); delete this.portMap_[id]; } }, /** * Sends a command to the client content script on an eligible tab. * @param {hotword.constants.CommandToPage} command Command to send. * @param {number} tabId Id of the target tab. * @private */ sendClient_: function(command, tabId) { if (tabId in this.portMap_) { var message = {}; message[hotword.constants.COMMAND_FIELD_NAME] = command; this.portMap_[tabId].postMessage(message); } }, /** * Sends a command to all connected clients. * @param {hotword.constants.CommandToPage} command Command to send. * @private */ sendAllClients_: function(command) { for (var idStr in this.portMap_) { var id = parseInt(idStr, 10); assert(!isNaN(id), 'Tab ID is not a number: ' + idStr); this.sendClient_(command, id); } }, /** * Handles a hotword trigger. Sends a trigger message to the currently * active tab. * @private */ hotwordTriggered_: function() { this.findCurrentTab_(function(tab) { if (tab) this.sendClient_(CommandToPage.HOTWORD_VOICE_TRIGGER, tab.id); }); }, /** * Starts hotwording. * @private */ startHotwording_: function() { this.stateManager_.startSession( hotword.constants.SessionSource.NTP, function() { this.sendAllClients_(CommandToPage.HOTWORD_STARTED); }.bind(this), this.hotwordTriggered_.bind(this)); }, /** * Starts hotwording if the currently active tab is eligible for hotwording * (e.g. google.com). * @private */ startHotwordingIfEligible_: function() { this.findCurrentTab_(function(tab) { if (!tab) { this.stopHotwording_(); return; } if (this.isEligibleUrl_(tab.url)) this.startHotwording_(); }); }, /** * Stops hotwording. * @private */ stopHotwording_: function() { this.stateManager_.stopSession(hotword.constants.SessionSource.NTP); this.sendAllClients_(CommandToPage.HOTWORD_ENDED); }, /** * Stops hotwording if the currently active tab is not eligible for * hotwording (i.e. google.com). * @private */ stopHotwordingIfIneligibleTab_: function() { this.findCurrentTab_(function(tab) { if (!tab) { this.stopHotwording_(); return; } if (!this.isEligibleUrl_(tab.url)) this.stopHotwording_(); }); }, /** * Handles a message from the content script injected into the page. * @param {!Object} request Request from the content script. * @param {!MessageSender} sender Message sender. * @param {!function(Object)} sendResponse Function for sending a response. * @private */ handleMessage_: function(request, sender, sendResponse) { switch (request[hotword.constants.COMMAND_FIELD_NAME]) { // TODO(amistry): Handle other messages such as CLICKED_RESUME and // CLICKED_RESTART, if necessary. case CommandFromPage.SPEECH_START: this.stopHotwording_(); break; case CommandFromPage.SPEECH_END: case CommandFromPage.SPEECH_RESET: this.startHotwording_(); break; } }, /** * Handles a message directly from the NTP/HP/SERP. * @param {!Object} request Message from the sender. * @param {!MessageSender} sender Information about the sender. * @param {!function(HotwordStatus)} sendResponse Callback to respond * to sender. * @return {boolean} Whether to maintain the port open to call sendResponse. * @private */ handleMessageFromPage_: function(request, sender, sendResponse) { switch (request.type) { case CommandFromPage.PAGE_WAKEUP: if (sender.tab && this.isEligibleUrl_(sender.tab.url)) { chrome.hotwordPrivate.getStatus( true /* getOptionalFields */, this.statusDone_.bind( this, request.tab || sender.tab || {incognito: true}, sendResponse)); return true; } // Do not show the opt-in promo for ineligible urls. this.sendResponse_({'doNotShowOptinMessage': true}, sendResponse); break; case CommandFromPage.CLICKED_OPTIN: chrome.hotwordPrivate.setEnabled(true); break; // User has explicitly clicked 'no thanks'. case CommandFromPage.CLICKED_NO_OPTIN: chrome.hotwordPrivate.setEnabled(false); break; } return false; }, /** * Sends a message directly to the sending page. * @param {!HotwordStatus} response The response to send to the sender. * @param {!function(HotwordStatus)} sendResponse Callback to respond * to sender. * @private */ sendResponse_: function(response, sendResponse) { try { sendResponse(response); } catch (err) { // Suppress the exception thrown by sendResponse() when the page doesn't // specify a response callback in the call to // chrome.runtime.sendMessage(). // Unfortunately, there doesn't appear to be a way to detect one-way // messages without explicitly saying in the message itself. This // message is defined as a constant in // extensions/renderer/messaging_bindings.cc if (err.message == 'Attempting to use a disconnected port object') return; throw err; } }, /** * Sends the response to the tab. * @param {Tab} tab The tab that the request was sent from. * @param {function(HotwordStatus)} sendResponse Callback function to * respond to sender. * @param {HotwordStatus} hotwordStatus Status of the hotword extension. * @private */ statusDone_: function(tab, sendResponse, hotwordStatus) { var response = {'doNotShowOptinMessage': true}; // If always-on is available, then we do not show the promo, as the promo // only works with the sometimes-on pref. if (!tab.incognito && hotwordStatus.available && !hotwordStatus.enabledSet && !hotwordStatus.alwaysOnAvailable) { response = hotwordStatus; } this.sendResponse_(response, sendResponse); }, /** * Set up event listeners. * @private */ setupListeners_: function() { if (chrome.runtime.onConnect.hasListener(this.connectListener_)) return; chrome.runtime.onConnect.addListener(this.connectListener_); chrome.tabs.onCreated.addListener(this.tabCreatedListener_); chrome.tabs.onUpdated.addListener(this.tabUpdatedListener_); chrome.tabs.onActivated.addListener(this.tabActivatedListener_); chrome.windows.onFocusChanged.addListener( this.windowFocusChangedListener_); chrome.hotwordPrivate.onMicrophoneStateChanged.addListener( this.microphoneStateChangedListener_); if (chrome.runtime.onMessage.hasListener(this.messageListener_)) return; chrome.runtime.onMessageExternal.addListener( this.messageListener_); }, /** * Remove event listeners. * @private */ removeListeners_: function() { chrome.runtime.onConnect.removeListener(this.connectListener_); chrome.tabs.onCreated.removeListener(this.tabCreatedListener_); chrome.tabs.onUpdated.removeListener(this.tabUpdatedListener_); chrome.tabs.onActivated.removeListener(this.tabActivatedListener_); chrome.windows.onFocusChanged.removeListener( this.windowFocusChangedListener_); chrome.hotwordPrivate.onMicrophoneStateChanged.removeListener( this.microphoneStateChangedListener_); // Don't remove the Message listener, as we want them listening all // the time, }, /** * Update event listeners based on the current hotwording state. * @private */ updateListeners_: function() { if (this.stateManager_.isSometimesOnEnabled()) { this.setupListeners_(); } else { this.removeListeners_(); this.stopHotwording_(); this.disconnectAllClients_(); } } }; return { PageAudioManager: PageAudioManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Trivial container class for session information. * @param {!hotword.constants.SessionSource} source Source of the hotword * session. * @param {!function()} triggerCb Callback invoked when the hotword has * triggered. * @param {!function()} startedCb Callback invoked when the session has * been started successfully. * @param {function()=} opt_modelSavedCb Callback invoked when the speaker * model has been saved successfully. * @constructor * @struct * @private */ function Session_(source, triggerCb, startedCb, opt_modelSavedCb) { /** * Source of the hotword session request. * @private {!hotword.constants.SessionSource} */ this.source_ = source; /** * Callback invoked when the hotword has triggered. * @private {!function()} */ this.triggerCb_ = triggerCb; /** * Callback invoked when the session has been started successfully. * @private {?function()} */ this.startedCb_ = startedCb; /** * Callback invoked when the session has been started successfully. * @private {?function()} */ this.speakerModelSavedCb_ = opt_modelSavedCb; } /** * Class to manage hotwording state. Starts/stops the hotword detector based * on user settings, session requests, and any other factors that play into * whether or not hotwording should be running. * @constructor */ function StateManager() { /** * Current state. * @private {hotword.StateManager.State_} */ this.state_ = State_.STOPPED; /** * Current hotwording status. * @private {?chrome.hotwordPrivate.StatusDetails} */ this.hotwordStatus_ = null; /** * NaCl plugin manager. * @private {?hotword.NaClManager} */ this.pluginManager_ = null; /** * Currently active hotwording sessions. * @private {!Array} */ this.sessions_ = []; /** * The mode to start the recognizer in. * @private {!hotword.constants.RecognizerStartMode} */ this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL; /** * Event that fires when the hotwording status has changed. * @type {!ChromeEvent} */ this.onStatusChanged = new chrome.Event(); /** * Hotword trigger audio notification... a.k.a The Chime (tm). * @private {!HTMLAudioElement} */ this.chime_ = /** @type {!HTMLAudioElement} */(document.createElement('audio')); /** * Chrome event listeners. Saved so that they can be de-registered when * hotwording is disabled. * @private */ this.idleStateChangedListener_ = this.handleIdleStateChanged_.bind(this); this.startupListener_ = this.handleStartup_.bind(this); /** * Whether this user is locked. * @private {boolean} */ this.isLocked_ = false; /** * Current state of audio logging. * This is tracked separately from hotwordStatus_ because we need to restart * the hotword detector when this value changes. * @private {boolean} */ this.loggingEnabled_ = false; /** * Current state of training. * This is tracked separately from |hotwordStatus_| because we need to * restart the hotword detector when this value changes. * @private {!boolean} */ this.trainingEnabled_ = false; /** * Helper class to keep this extension alive while the hotword detector is * running in always-on mode. * @private {!hotword.KeepAlive} */ this.keepAlive_ = new hotword.KeepAlive(); // Get the initial status. chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this)); // Setup the chime and insert into the page. // Set preload=none to prevent an audio output stream from being created // when the extension loads. this.chime_.preload = 'none'; this.chime_.src = chrome.extension.getURL( hotword.constants.SHARED_MODULE_ROOT + '/audio/chime.wav'); document.body.appendChild(this.chime_); // In order to remove this listener, it must first be added. This handles // the case on first Chrome startup where this event is never registered, // so can't be removed when it's determined that hotwording is disabled. // Why not only remove the listener if it exists? Extension API events have // two parts to them, the Javascript listeners, and a browser-side component // that wakes up the extension if it's an event page. The browser-side // wake-up event is only removed when the number of javascript listeners // becomes 0. To clear the browser wake-up event, a listener first needs to // be added, then removed in order to drop the count to 0 and remove the // event. chrome.runtime.onStartup.addListener(this.startupListener_); } /** * @enum {number} * @private */ StateManager.State_ = { STOPPED: 0, STARTING: 1, RUNNING: 2, ERROR: 3, }; var State_ = StateManager.State_; var UmaMediaStreamOpenResults_ = { // These first error are defined by the MediaStream spec: // http://w3c.github.io/mediacapture-main/getusermedia.html#idl-def-MediaStreamError 'NotSupportedError': hotword.constants.UmaMediaStreamOpenResult.NOT_SUPPORTED, 'PermissionDeniedError': hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DENIED, 'ConstraintNotSatisfiedError': hotword.constants.UmaMediaStreamOpenResult.CONSTRAINT_NOT_SATISFIED, 'OverconstrainedError': hotword.constants.UmaMediaStreamOpenResult.OVERCONSTRAINED, 'NotFoundError': hotword.constants.UmaMediaStreamOpenResult.NOT_FOUND, 'AbortError': hotword.constants.UmaMediaStreamOpenResult.ABORT, 'SourceUnavailableError': hotword.constants.UmaMediaStreamOpenResult.SOURCE_UNAVAILABLE, // The next few errors are chrome-specific. See: // content/renderer/media/user_media_client_impl.cc // (UserMediaClientImpl::GetUserMediaRequestFailed) 'PermissionDismissedError': hotword.constants.UmaMediaStreamOpenResult.PERMISSION_DISMISSED, 'InvalidStateError': hotword.constants.UmaMediaStreamOpenResult.INVALID_STATE, 'DevicesNotFoundError': hotword.constants.UmaMediaStreamOpenResult.DEVICES_NOT_FOUND, 'InvalidSecurityOriginError': hotword.constants.UmaMediaStreamOpenResult.INVALID_SECURITY_ORIGIN }; var UmaTriggerSources_ = { 'launcher': hotword.constants.UmaTriggerSource.LAUNCHER, 'ntp': hotword.constants.UmaTriggerSource.NTP_GOOGLE_COM, 'always': hotword.constants.UmaTriggerSource.ALWAYS_ON, 'training': hotword.constants.UmaTriggerSource.TRAINING }; StateManager.prototype = { /** * Request status details update. Intended to be called from the * hotwordPrivate.onEnabledChanged() event. */ updateStatus: function() { chrome.hotwordPrivate.getStatus(this.handleStatus_.bind(this)); }, /** * @return {boolean} True if google.com/NTP/launcher hotwording is enabled. */ isSometimesOnEnabled: function() { assert(this.hotwordStatus_, 'No hotwording status (isSometimesOnEnabled)'); // Although the two settings are supposed to be mutually exclusive, it's // possible for both to be set. In that case, always-on takes precedence. return this.hotwordStatus_.enabled && !this.hotwordStatus_.alwaysOnEnabled; }, /** * @return {boolean} True if always-on hotwording is enabled. */ isAlwaysOnEnabled: function() { assert(this.hotwordStatus_, 'No hotword status (isAlwaysOnEnabled)'); return this.hotwordStatus_.alwaysOnEnabled && !this.hotwordStatus_.trainingEnabled; }, /** * @return {boolean} True if training is enabled. */ isTrainingEnabled: function() { assert(this.hotwordStatus_, 'No hotword status (isTrainingEnabled)'); return this.hotwordStatus_.trainingEnabled; }, /** * Callback for hotwordPrivate.getStatus() function. * @param {chrome.hotwordPrivate.StatusDetails} status Current hotword * status. * @private */ handleStatus_: function(status) { hotword.debug('New hotword status', status); this.hotwordStatus_ = status; this.updateStateFromStatus_(); this.onStatusChanged.dispatch(); }, /** * Updates state based on the current status. * @private */ updateStateFromStatus_: function() { if (!this.hotwordStatus_) return; if (this.hotwordStatus_.enabled || this.hotwordStatus_.alwaysOnEnabled || this.hotwordStatus_.trainingEnabled) { // Detect changes to audio logging and kill the detector if that setting // has changed. if (this.hotwordStatus_.audioLoggingEnabled != this.loggingEnabled_) this.shutdownDetector_(); this.loggingEnabled_ = this.hotwordStatus_.audioLoggingEnabled; // If the training state has changed, we need to first shut down the // detector so that we can restart in a different mode. if (this.hotwordStatus_.trainingEnabled != this.trainingEnabled_) this.shutdownDetector_(); this.trainingEnabled_ = this.hotwordStatus_.trainingEnabled; // Start the detector if there's a session and the user is unlocked, and // stops it otherwise. if (this.sessions_.length && !this.isLocked_ && this.hotwordStatus_.userIsActive) { this.startDetector_(); } else { this.shutdownDetector_(); } if (!chrome.idle.onStateChanged.hasListener( this.idleStateChangedListener_)) { chrome.idle.onStateChanged.addListener( this.idleStateChangedListener_); } if (!chrome.runtime.onStartup.hasListener(this.startupListener_)) chrome.runtime.onStartup.addListener(this.startupListener_); } else { // Not enabled. Shut down if running. this.shutdownDetector_(); chrome.idle.onStateChanged.removeListener( this.idleStateChangedListener_); // If hotwording isn't enabled, don't start this component extension on // Chrome startup. If a user enables hotwording, the status change // event will be fired and the onStartup event will be registered. chrome.runtime.onStartup.removeListener(this.startupListener_); } }, /** * Starts the hotword detector. * @private */ startDetector_: function() { // Last attempt to start detector resulted in an error. if (this.state_ == State_.ERROR) { // TODO(amistry): Do some error rate tracking here and disable the // extension if we error too often. } if (!this.pluginManager_) { this.state_ = State_.STARTING; var isHotwordStream = this.isAlwaysOnEnabled() && this.hotwordStatus_.hotwordHardwareAvailable; this.pluginManager_ = new hotword.NaClManager(this.loggingEnabled_, isHotwordStream); this.pluginManager_.addEventListener(hotword.constants.Event.READY, this.onReady_.bind(this)); this.pluginManager_.addEventListener(hotword.constants.Event.ERROR, this.onError_.bind(this)); this.pluginManager_.addEventListener(hotword.constants.Event.TRIGGER, this.onTrigger_.bind(this)); this.pluginManager_.addEventListener(hotword.constants.Event.TIMEOUT, this.onTimeout_.bind(this)); this.pluginManager_.addEventListener( hotword.constants.Event.SPEAKER_MODEL_SAVED, this.onSpeakerModelSaved_.bind(this)); chrome.runtime.getPlatformInfo(function(platform) { var naclArch = platform.nacl_arch; // googDucking set to false so that audio output level from other tabs // is not affected when hotword is enabled. https://crbug.com/357773 // content/common/media/media_stream_options.cc // When always-on is enabled, request the hotword stream. // Optional because we allow power users to bypass the hardware // detection via a flag, and hence the hotword stream may not be // available. var constraints = /** @type {googMediaStreamConstraints} */ ({audio: {optional: [ { googDucking: false }, { googHotword: this.isAlwaysOnEnabled() } ]}}); navigator.webkitGetUserMedia( /** @type {MediaStreamConstraints} */ (constraints), function(stream) { hotword.metrics.recordEnum( hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT, hotword.constants.UmaMediaStreamOpenResult.SUCCESS, hotword.constants.UmaMediaStreamOpenResult.MAX); // The detector could have been shut down before the stream // finishes opening. if (this.pluginManager_ == null) { stream.getAudioTracks()[0].stop(); return; } if (this.isAlwaysOnEnabled()) this.keepAlive_.start(); if (!this.pluginManager_.initialize(naclArch, stream)) { this.state_ = State_.ERROR; this.shutdownPluginManager_(); } }.bind(this), function(error) { if (error.name in UmaMediaStreamOpenResults_) { var metricValue = UmaMediaStreamOpenResults_[error.name]; } else { var metricValue = hotword.constants.UmaMediaStreamOpenResult.UNKNOWN; } hotword.metrics.recordEnum( hotword.constants.UmaMetrics.MEDIA_STREAM_RESULT, metricValue, hotword.constants.UmaMediaStreamOpenResult.MAX); this.state_ = State_.ERROR; this.pluginManager_ = null; }.bind(this)); }.bind(this)); } else if (this.state_ != State_.STARTING) { // Don't try to start a starting detector. this.startRecognizer_(); } }, /** * Start the recognizer plugin. Assumes the plugin has been loaded and is * ready to start. * @private */ startRecognizer_: function() { assert(this.pluginManager_, 'No NaCl plugin loaded'); if (this.state_ != State_.RUNNING) { this.state_ = State_.RUNNING; if (this.isAlwaysOnEnabled()) this.keepAlive_.start(); this.pluginManager_.startRecognizer(this.startMode_); } for (var i = 0; i < this.sessions_.length; i++) { var session = this.sessions_[i]; if (session.startedCb_) { session.startedCb_(); session.startedCb_ = null; } } }, /** * Stops the hotword detector, if it's running. * @private */ stopDetector_: function() { this.keepAlive_.stop(); if (this.pluginManager_ && this.state_ == State_.RUNNING) { this.state_ = State_.STOPPED; this.pluginManager_.stopRecognizer(); } }, /** * Shuts down and removes the plugin manager, if it exists. * @private */ shutdownPluginManager_: function() { this.keepAlive_.stop(); if (this.pluginManager_) { this.pluginManager_.shutdown(); this.pluginManager_ = null; } }, /** * Shuts down the hotword detector. * @private */ shutdownDetector_: function() { this.state_ = State_.STOPPED; this.shutdownPluginManager_(); }, /** * Finalizes the speaker model. Assumes the plugin has been loaded and * started. */ finalizeSpeakerModel: function() { assert(this.pluginManager_, 'Cannot finalize speaker model: No NaCl plugin loaded'); if (this.state_ != State_.RUNNING) { hotword.debug('Cannot finalize speaker model: NaCl plugin not started'); return; } this.pluginManager_.finalizeSpeakerModel(); }, /** * Handle the hotword plugin being ready to start. * @private */ onReady_: function() { if (this.state_ != State_.STARTING) { // At this point, we should not be in the RUNNING state. Doing so would // imply the hotword detector was started without being ready. assert(this.state_ != State_.RUNNING, 'Unexpected RUNNING state'); this.shutdownPluginManager_(); return; } this.startRecognizer_(); }, /** * Handle an error from the hotword plugin. * @private */ onError_: function() { this.state_ = State_.ERROR; this.shutdownPluginManager_(); }, /** * Handle hotword triggering. * @param {!Event} event Event containing audio log data. * @private */ onTrigger_: function(event) { this.keepAlive_.stop(); hotword.debug('Hotword triggered!'); chrome.metricsPrivate.recordUserAction( hotword.constants.UmaMetrics.TRIGGER); assert(this.pluginManager_, 'No NaCl plugin loaded on trigger'); // Detector implicitly stops when the hotword is detected. this.state_ = State_.STOPPED; // Play the chime. this.chime_.play(); // Implicitly clear the top session. A session needs to be started in // order to restart the detector. if (this.sessions_.length) { var session = this.sessions_.pop(); session.triggerCb_(event.log); hotword.metrics.recordEnum( hotword.constants.UmaMetrics.TRIGGER_SOURCE, UmaTriggerSources_[session.source_], hotword.constants.UmaTriggerSource.MAX); } // If we're in always-on mode, shut down the hotword detector. The hotword // stream requires that we close and re-open it after a trigger, and the // only way to accomplish this is to shut everything down. if (this.isAlwaysOnEnabled()) this.shutdownDetector_(); }, /** * Handle hotword timeout. * @private */ onTimeout_: function() { hotword.debug('Hotword timeout!'); // We get this event when the hotword detector thinks there's a false // trigger. In this case, we need to shut down and restart the detector to // re-arm the DSP. this.shutdownDetector_(); this.updateStateFromStatus_(); }, /** * Handle speaker model saved. * @private */ onSpeakerModelSaved_: function() { hotword.debug('Speaker model saved!'); if (this.sessions_.length) { // Only call the callback of the the top session. var session = this.sessions_[this.sessions_.length - 1]; if (session.speakerModelSavedCb_) session.speakerModelSavedCb_(); } }, /** * Remove a hotwording session from the given source. * @param {!hotword.constants.SessionSource} source Source of the hotword * session request. * @private */ removeSession_: function(source) { for (var i = 0; i < this.sessions_.length; i++) { if (this.sessions_[i].source_ == source) { this.sessions_.splice(i, 1); break; } } }, /** * Start a hotwording session. * @param {!hotword.constants.SessionSource} source Source of the hotword * session request. * @param {!function()} startedCb Callback invoked when the session has * been started successfully. * @param {!function()} triggerCb Callback invoked when the hotword has * @param {function()=} modelSavedCb Callback invoked when the speaker model * has been saved. * @param {hotword.constants.RecognizerStartMode=} opt_mode The mode to * start the recognizer in. */ startSession: function(source, startedCb, triggerCb, opt_modelSavedCb, opt_mode) { if (this.isTrainingEnabled() && opt_mode) { this.startMode_ = opt_mode; } else { this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL; } hotword.debug('Starting session for source: ' + source); this.removeSession_(source); this.sessions_.push(new Session_(source, triggerCb, startedCb, opt_modelSavedCb)); this.updateStateFromStatus_(); }, /** * Stops a hotwording session. * @param {!hotword.constants.SessionSource} source Source of the hotword * session request. */ stopSession: function(source) { hotword.debug('Stopping session for source: ' + source); this.removeSession_(source); // If this is a training session then switch the start mode back to // normal. if (source == hotword.constants.SessionSource.TRAINING) this.startMode_ = hotword.constants.RecognizerStartMode.NORMAL; this.updateStateFromStatus_(); }, /** * Handles a chrome.idle.onStateChanged event. * @param {!string} state State, one of "active", "idle", or "locked". * @private */ handleIdleStateChanged_: function(state) { hotword.debug('Idle state changed: ' + state); var oldLocked = this.isLocked_; if (state == 'locked') this.isLocked_ = true; else this.isLocked_ = false; if (oldLocked != this.isLocked_) this.updateStateFromStatus_(); }, /** * Handles a chrome.runtime.onStartup event. * @private */ handleStartup_: function() { // Nothing specific needs to be done here. This function exists solely to // be registered on the startup event. } }; return { StateManager: StateManager }; }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('hotword', function() { 'use strict'; /** * Class used to manage speaker training. Starts a hotwording session * if training is on, and automatically restarts the detector when a * a hotword is triggered. * @param {!hotword.StateManager} stateManager * @constructor * @extends {hotword.BaseSessionManager} */ function TrainingManager(stateManager) { /** * Chrome event listeners. Saved so that they can be de-registered when * hotwording is disabled. * @private */ this.finalizedSpeakerModelListener_ = this.handleFinalizeSpeakerModel_.bind(this); hotword.BaseSessionManager.call(this, stateManager, hotword.constants.SessionSource.TRAINING); } /** * Handles a success event on mounting the file system event and deletes * the user data files. * @param {FileSystem} fs The FileSystem object. * @private */ TrainingManager.deleteFiles_ = function(fs) { fs.root.getFile(hotword.constants.SPEAKER_MODEL_FILE_NAME, {create: false}, TrainingManager.deleteFile_, TrainingManager.fileErrorHandler_); for (var i = 0; i < hotword.constants.NUM_TRAINING_UTTERANCES; ++i) { fs.root.getFile(hotword.constants.UTTERANCE_FILE_PREFIX + i + hotword.constants.UTTERANCE_FILE_EXTENSION, {create: false}, TrainingManager.deleteFile_, TrainingManager.fileErrorHandler_); } }; /** * Deletes a file. * @param {FileEntry} fileEntry The FileEntry object. * @private */ TrainingManager.deleteFile_ = function(fileEntry) { if (fileEntry.isFile) { hotword.debug('File found: ' + fileEntry.fullPath); if (hotword.DEBUG || window.localStorage['hotword.DEBUG']) { fileEntry.getMetadata(function(md) { hotword.debug('File size: ' + md.size); }); } fileEntry.remove(function() { hotword.debug('File removed: ' + fileEntry.fullPath); }, TrainingManager.fileErrorHandler_); } }; /** * Handles a failure event on mounting the file system event. * @param {FileError} e The FileError object. * @private */ TrainingManager.fileErrorHandler_ = function(e) { hotword.debug('File error: ' + e.code); }; /** * Handles a failure event on checking for the existence of the speaker model. * @param {FileError} e The FileError object. * @private */ TrainingManager.sendNoSpeakerModelResponse_ = function(e) { chrome.hotwordPrivate.speakerModelExistsResult(false); }; /** * Handles a success event on mounting the file system and checks for the * existence of the speaker model. * @param {FileSystem} fs The FileSystem object. * @private */ TrainingManager.speakerModelExists_ = function(fs) { fs.root.getFile(hotword.constants.SPEAKER_MODEL_FILE_NAME, {create: false}, TrainingManager.sendSpeakerModelExistsResponse_, TrainingManager.sendNoSpeakerModelResponse_); }; /** * Sends a response through the HotwordPrivateApi indicating whether * the speaker model exists. * @param {FileEntry} fileEntry The FileEntry object. * @private */ TrainingManager.sendSpeakerModelExistsResponse_ = function(fileEntry) { if (fileEntry.isFile) { hotword.debug('File found: ' + fileEntry.fullPath); if (hotword.DEBUG || window.localStorage['hotword.DEBUG']) { fileEntry.getMetadata(function(md) { hotword.debug('File size: ' + md.size); }); } } chrome.hotwordPrivate.speakerModelExistsResult(fileEntry.isFile); }; /** * Handles a request to delete the speaker model. */ TrainingManager.handleDeleteSpeakerModel = function() { window.webkitRequestFileSystem(PERSISTENT, hotword.constants.FILE_SYSTEM_SIZE_BYTES, TrainingManager.deleteFiles_, TrainingManager.fileErrorHandler_); }; /** * Handles a request for the speaker model existence. */ TrainingManager.handleSpeakerModelExists = function() { window.webkitRequestFileSystem(PERSISTENT, hotword.constants.FILE_SYSTEM_SIZE_BYTES, TrainingManager.speakerModelExists_, TrainingManager.fileErrorHandler_); }; TrainingManager.prototype = { __proto__: hotword.BaseSessionManager.prototype, /** @override */ enabled: function() { return this.stateManager.isTrainingEnabled(); }, /** @override */ updateListeners: function() { hotword.BaseSessionManager.prototype.updateListeners.call(this); if (this.enabled()) { // Detect when the speaker model needs to be finalized. if (!chrome.hotwordPrivate.onFinalizeSpeakerModel.hasListener( this.finalizedSpeakerModelListener_)) { chrome.hotwordPrivate.onFinalizeSpeakerModel.addListener( this.finalizedSpeakerModelListener_); } this.startSession(hotword.constants.RecognizerStartMode.NEW_MODEL); } else { chrome.hotwordPrivate.onFinalizeSpeakerModel.removeListener( this.finalizedSpeakerModelListener_); } }, /** @override */ handleHotwordTrigger: function(log) { if (this.enabled()) { hotword.BaseSessionManager.prototype.handleHotwordTrigger.call( this, log); this.startSession(hotword.constants.RecognizerStartMode.ADAPT_MODEL); } }, /** @override */ startSession: function(opt_mode) { this.stateManager.startSession( this.sessionSource_, function() { chrome.hotwordPrivate.setHotwordSessionState(true, function() {}); }, this.handleHotwordTrigger.bind(this), this.handleSpeakerModelSaved_.bind(this), opt_mode); }, /** * Handles a hotwordPrivate.onFinalizeSpeakerModel event. * @private */ handleFinalizeSpeakerModel_: function() { if (this.enabled()) this.stateManager.finalizeSpeakerModel(); }, /** * Handles a hotwordPrivate.onFinalizeSpeakerModel event. * @private */ handleSpeakerModelSaved_: function() { if (this.enabled()) chrome.hotwordPrivate.notifySpeakerModelSaved(); }, }; return { TrainingManager: TrainingManager }; });
screenshot
// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @type {number} * @const */ var FEEDBACK_WIDTH = 500; /** * @type {number} * @const */ var FEEDBACK_HEIGHT = 585; var initialFeedbackInfo = null; // To generate a hashed extension ID, use a sha-256 hash, all in lower case. // Example: // echo -n 'abcdefghijklmnopqrstuvwxyzabcdef' | sha1sum | \ // awk '{print toupper($1)}' var whitelistedExtensionIds = [ '12E618C3C6E97495AAECF2AC12DEB082353241C6', // QuickOffice '3727DD3E564B6055387425027AD74C58784ACC15', // QuickOffice '2FC374607C2DF285634B67C64A2E356C607091C3', // QuickOffice '2843C1E82A9B6C6FB49308FDDF4E157B6B44BC2B', // G+ Photos '5B5DA6D054D10DB917AF7D9EAE3C56044D1B0B03', // G+ Photos '986913085E3E3C3AFDE9B7A943149C4D3F4C937B', // Feedback Extension '7AE714FFD394E073F0294CFA134C9F91DB5FBAA4', // Connectivity Diagnostics 'C7DA3A55C2355F994D3FDDAD120B426A0DF63843', // Connectivity Diagnostics '75E3CFFFC530582C583E4690EF97C70B9C8423B7', // Connectivity Diagnostics '32A1BA997F8AB8DE29ED1BA94AAF00CF2A3FEFA7', // Connectivity Diagnostics 'A291B26E088FA6BA53FFD72F0916F06EBA7C585A', // Chrome OS Recovery Tool 'D7986543275120831B39EF28D1327552FC343960', // Chrome OS Recovery Tool '8EBDF73405D0B84CEABB8C7513C9B9FA9F1DC2CE', // GetHelp app. '97B23E01B2AA064E8332EE43A7A85C628AADC3F2', // Chrome Remote Desktop Dev '9E527CDA9D7C50844E8A5DB964A54A640AE48F98', // Chrome Remote Desktop Stable 'DF52618D0B040D8A054D8348D2E84DDEEE5974E7', // Chrome Remote Desktop QA '269D721F163E587BC53C6F83553BF9CE2BB143CD', // Chrome Remote Desktop QA backup 'C449A798C495E6CF7D6AF10162113D564E67AD12', // Chrome Remote Desktop Apps V2 '981974CD1832B87BE6B21BE78F7249BB501E0DE6', // Play Movies Dev '32FD7A816E47392C92D447707A89EB07EEDE6FF7', // Play Movies Nightly '3F3CEC4B9B2B5DC2F820CE917AABDF97DB2F5B49', // Play Movies Beta 'F92FAC70AB68E1778BF62D9194C25979596AA0E6', // Play Movies Stable '0F585FB1D0FDFBEBCE1FEB5E9DFFB6DA476B8C9B', // Hangouts Extension '2D22CDB6583FD0A13758AEBE8B15E45208B4E9A7', // Hangouts Extension '49DA0B9CCEEA299186C6E7226FD66922D57543DC', // Hangouts Extension 'E7E2461CE072DF036CF9592740196159E2D7C089', // Hangouts Extension 'A74A4D44C7CFCD8844830E6140C8D763E12DD8F3', // Hangouts Extension '312745D9BF916161191143F6490085EEA0434997', // Hangouts Extension '53041A2FA309EECED01FFC751E7399186E860B2C', // Hangouts Extension '0F42756099D914A026DADFA182871C015735DD95', // Hangouts Extension '1B7734733E207CCE5C33BFAA544CA89634BF881F', // GLS nightly 'E2ACA3D943A3C96310523BCDFD8C3AF68387E6B7', // GLS stable '11B478CEC461C766A2DC1E5BEEB7970AE06DC9C2', // http://crbug.com/463552 '0EFB879311E9EFBB7C45251F89EC655711B1F6ED', // http://crbug.com/463552 '9193D3A51E2FE33B496CDA53EA330423166E7F02', // http://crbug.com/463552 'F9119B8B18C7C82B51E7BC6FF816B694F2EC3E89', // http://crbug.com/463552 'BA007D8D52CC0E2632EFCA03ACD003B0F613FD71', // http://crbug.com/470411 '5260FA31DE2007A837B7F7B0EB4A47CE477018C8', // http://crbug.com/470411 '4F4A25F31413D9B9F80E61D096DEB09082515267', // http://crbug.com/470411 'FBA0DE4D3EFB5485FC03760F01F821466907A743', // http://crbug.com/470411 'E216473E4D15C5FB14522D32C5F8DEAAB2CECDC6', // http://crbug.com/470411 '676A08383D875E51CE4C2308D875AE77199F1413', // http://crbug.com/473845 '869A23E11B308AF45A68CC386C36AADA4BE44A01', // http://crbug.com/473845 'E9CE07C7EDEFE70B9857B312E88F94EC49FCC30F', // http://crbug.com/473845 'A4577D8C2AF4CF26F40CBCA83FFA4251D6F6C8F8', // http://crbug.com/478929 'A8208CCC87F8261AFAEB6B85D5E8D47372DDEA6B', // http://crbug.com/478929 'B620CF4203315F9F2E046EDED22C7571A935958D', // http://crbug.com/510270 'B206D8716769728278D2D300349C6CB7D7DE2EF9', // http://crbug.com/510270 'EFCF5358672FEE04789FD2EC3638A67ADEDB6C8C', // http://crbug.com/514696 'FAD85BC419FE00995D196312F53448265EFA86F1', // http://crbug.com/516527 ]; /** * Function to determine whether or not a given extension id is whitelisted to * invoke the feedback UI. If the extension is whitelisted, the callback to * start the Feedback UI will be called. * @param {string} id the id of the sender extension. * @param {Function} startFeedbackCallback The callback function that will * will start the feedback UI. * @param {Object} feedbackInfo The feedback info object to pass to the * start feedback UI callback. */ function senderWhitelisted(id, startFeedbackCallback, feedbackInfo) { crypto.subtle.digest('SHA-1', new TextEncoder().encode(id)).then( function(hashBuffer) { var hashString = ''; var hashView = new Uint8Array(hashBuffer); for (var i = 0; i < hashView.length; ++i) { var n = hashView[i]; hashString += n < 0x10 ? '0' : ''; hashString += n.toString(16); } if (whitelistedExtensionIds.indexOf(hashString.toUpperCase()) != -1) startFeedbackCallback(feedbackInfo); }); } /** * Callback which gets notified once our feedback UI has loaded and is ready to * receive its initial feedback info object. * @param {Object} request The message request object. * @param {Object} sender The sender of the message. * @param {function(Object)} sendResponse Callback for sending a response. */ function feedbackReadyHandler(request, sender, sendResponse) { if (request.ready) { chrome.runtime.sendMessage( {sentFromEventPage: true, data: initialFeedbackInfo}); } } /** * Callback which gets notified if another extension is requesting feedback. * @param {Object} request The message request object. * @param {Object} sender The sender of the message. * @param {function(Object)} sendResponse Callback for sending a response. */ function requestFeedbackHandler(request, sender, sendResponse) { if (request.requestFeedback) senderWhitelisted(sender.id, startFeedbackUI, request.feedbackInfo); } /** * Callback which starts up the feedback UI. * @param {Object} feedbackInfo Object containing any initial feedback info. */ function startFeedbackUI(feedbackInfo) { initialFeedbackInfo = feedbackInfo; var win = chrome.app.window.get('default_window'); if (win) { win.show(); return; } chrome.app.window.create('html/default.html', { frame: 'none', id: 'default_window', width: FEEDBACK_WIDTH, height: FEEDBACK_HEIGHT, hidden: true, resizable: false }, function(appWindow) {}); } chrome.runtime.onMessage.addListener(feedbackReadyHandler); chrome.runtime.onMessageExternal.addListener(requestFeedbackHandler); chrome.feedbackPrivate.onFeedbackRequested.addListener(startFeedbackUI); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** @type {string} * @const */ var FEEDBACK_LANDING_PAGE = 'https://support.google.com/chrome/go/feedback_confirmation'; /** @type {number} * @const */ var MAX_ATTACH_FILE_SIZE = 3 * 1024 * 1024; /** * @type {number} * @const */ var FEEDBACK_MIN_WIDTH = 500; /** * @type {number} * @const */ var FEEDBACK_MIN_HEIGHT = 585; /** @type {number} * @const */ var CONTENT_MARGIN_HEIGHT = 40; /** @type {number} * @const */ var MAX_SCREENSHOT_WIDTH = 100; /** @type {string} * @const */ var SYSINFO_WINDOW_ID = 'sysinfo_window'; /** @type {string} * @const */ var STATS_WINDOW_ID = 'stats_window'; var attachedFileBlob = null; var lastReader = null; var feedbackInfo = null; var systemInfo = null; /** * Reads the selected file when the user selects a file. * @param {Event} fileSelectedEvent The onChanged event for the file input box. */ function onFileSelected(fileSelectedEvent) { $('attach-error').hidden = true; var file = fileSelectedEvent.target.files[0]; if (!file) { // User canceled file selection. attachedFileBlob = null; return; } if (file.size > MAX_ATTACH_FILE_SIZE) { $('attach-error').hidden = false; // Clear our selected file. $('attach-file').value = ''; attachedFileBlob = null; return; } attachedFileBlob = file.slice(); } /** * Clears the file that was attached to the report with the initial request. * Instead we will now show the attach file button in case the user wants to * attach another file. */ function clearAttachedFile() { $('custom-file-container').hidden = true; attachedFileBlob = null; feedbackInfo.attachedFile = null; $('attach-file').hidden = false; } /** * Creates a closure that creates or shows a window with the given url. * @param {string} windowId A string with the ID of the window we are opening. * @param {string} url The destination URL of the new window. * @return {function()} A function to be called to open the window. */ function windowOpener(windowId, url) { return function(e) { e.preventDefault(); chrome.app.window.create(url, {id: windowId}); }; } /** * Opens a new window with chrome://slow_trace, downloading performance data. */ function openSlowTraceWindow() { chrome.app.window.create( 'chrome://slow_trace/tracing.zip#' + feedbackInfo.traceId); } /** * Sends the report; after the report is sent, we need to be redirected to * the landing page, but we shouldn't be able to navigate back, hence * we open the landing page in a new tab and sendReport closes this tab. * @return {boolean} True if the report was sent. */ function sendReport() { if ($('description-text').value.length == 0) { var description = $('description-text'); description.placeholder = loadTimeData.getString('no-description'); description.focus(); return false; } // Prevent double clicking from sending additional reports. $('send-report-button').disabled = true; console.log('Feedback: Sending report'); if (!feedbackInfo.attachedFile && attachedFileBlob) { feedbackInfo.attachedFile = { name: $('attach-file').value, data: attachedFileBlob }; } feedbackInfo.description = $('description-text').value; feedbackInfo.pageUrl = $('page-url-text').value; feedbackInfo.email = $('user-email-text').value; var useSystemInfo = false; var useHistograms = false; if ($('sys-info-checkbox') != null && $('sys-info-checkbox').checked && systemInfo != null) { // Send histograms along with system info. useSystemInfo = useHistograms = true; } if (useSystemInfo) { if (feedbackInfo.systemInformation != null) { // Concatenate sysinfo if we had any initial system information // sent with the feedback request event. feedbackInfo.systemInformation = feedbackInfo.systemInformation.concat(systemInfo); } else { feedbackInfo.systemInformation = systemInfo; } } feedbackInfo.sendHistograms = useHistograms; // If the user doesn't want to send the screenshot. if (!$('screenshot-checkbox').checked) feedbackInfo.screenshot = null; chrome.feedbackPrivate.sendFeedback(feedbackInfo, function(result) { window.open(FEEDBACK_LANDING_PAGE, '_blank'); window.close(); }); return true; } /** * Click listener for the cancel button. * @param {Event} e The click event being handled. */ function cancel(e) { e.preventDefault(); window.close(); } /** * Converts a blob data URL to a blob object. * @param {string} url The data URL to convert. * @return {Blob} Blob object containing the data. */ function dataUrlToBlob(url) { var mimeString = url.split(',')[0].split(':')[1].split(';')[0]; var data = atob(url.split(',')[1]); var dataArray = []; for (var i = 0; i < data.length; ++i) dataArray.push(data.charCodeAt(i)); return new Blob([new Uint8Array(dataArray)], {type: mimeString}); } function resizeAppWindow() { // We pick the width from the titlebar, which has no margins. var width = $('title-bar').scrollWidth; if (width < FEEDBACK_MIN_WIDTH) width = FEEDBACK_MIN_WIDTH; // We get the height by adding the titlebar height and the content height + // margins. We can't get the margins for the content-pane here by using // style.margin - the variable seems to not exist. var height = $('title-bar').scrollHeight + $('content-pane').scrollHeight + CONTENT_MARGIN_HEIGHT; if (height < FEEDBACK_MIN_HEIGHT) height = FEEDBACK_MIN_HEIGHT; chrome.app.window.current().resizeTo(width, height); } /** * Initializes our page. * Flow: * .) DOMContent Loaded -> . Request feedbackInfo object * . Setup page event handlers * .) Feedback Object Received -> . take screenshot * . request email * . request System info * . request i18n strings * .) Screenshot taken -> . Show Feedback window. */ function initialize() { // Add listener to receive the feedback info object. chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { if (request.sentFromEventPage) { feedbackInfo = request.data; $('description-text').textContent = feedbackInfo.description; if (feedbackInfo.pageUrl) $('page-url-text').value = feedbackInfo.pageUrl; takeScreenshot(function(screenshotCanvas) { // We've taken our screenshot, show the feedback page without any // further delay. window.webkitRequestAnimationFrame(function() { resizeAppWindow(); }); chrome.app.window.current().show(); var screenshotDataUrl = screenshotCanvas.toDataURL('image/png'); $('screenshot-image').src = screenshotDataUrl; $('screenshot-image').classList.toggle('wide-screen', $('screenshot-image').width > MAX_SCREENSHOT_WIDTH); feedbackInfo.screenshot = dataUrlToBlob(screenshotDataUrl); }); chrome.feedbackPrivate.getUserEmail(function(email) { $('user-email-text').value = email; }); chrome.feedbackPrivate.getSystemInformation(function(sysInfo) { systemInfo = sysInfo; }); // An extension called us with an attached file. if (feedbackInfo.attachedFile) { $('attached-filename-text').textContent = feedbackInfo.attachedFile.name; attachedFileBlob = feedbackInfo.attachedFile.data; $('custom-file-container').hidden = false; $('attach-file').hidden = true; } chrome.feedbackPrivate.getStrings(function(strings) { loadTimeData.data = strings; i18nTemplate.process(document, loadTimeData); if ($('sys-info-url')) { // Opens a new window showing the current system info. $('sys-info-url').onclick = windowOpener(SYSINFO_WINDOW_ID, 'chrome://system'); } if ($('histograms-url')) { // Opens a new window showing the histogram metrics. $('histograms-url').onclick = windowOpener(STATS_WINDOW_ID, 'chrome://histograms'); } // Make sure our focus starts on the description field. $('description-text').focus(); }); } }); window.addEventListener('DOMContentLoaded', function() { // Ready to receive the feedback object. chrome.runtime.sendMessage({ready: true}); // Setup our event handlers. $('attach-file').addEventListener('change', onFileSelected); $('send-report-button').onclick = sendReport; $('cancel-button').onclick = cancel; $('remove-attached-file').onclick = clearAttachedFile; }); } initialize(); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Function to take the screenshot of the current screen. * @param {function(HTMLCanvasElement)} callback Callback for returning the * canvas with the screenshot on it. */ function takeScreenshot(callback) { var screenshotStream = null; var video = document.createElement('video'); video.addEventListener('canplay', function(e) { if (screenshotStream) { var canvas = document.createElement('canvas'); canvas.setAttribute('width', video.videoWidth); canvas.setAttribute('height', video.videoHeight); canvas.getContext('2d').drawImage( video, 0, 0, video.videoWidth, video.videoHeight); video.pause(); video.src = ''; screenshotStream.getVideoTracks()[0].stop(); screenshotStream = null; callback(canvas); } }, false); navigator.webkitGetUserMedia( { video: { mandatory: { chromeMediaSource: 'screen', maxWidth: 4096, maxHeight: 2560 } } }, function(stream) { if (stream) { screenshotStream = stream; video.src = window.URL.createObjectURL(screenshotStream); video.play(); } }, function(err) { console.error('takeScreenshot failed: ' + err.name + '; ' + err.message + '; ' + err.constraintName); } ); } // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Setup handlers for the minimize and close topbar buttons. */ function initializeHandlers() { $('minimize-button').addEventListener('click', function(e) { e.preventDefault(); chrome.app.window.current().minimize(); }); $('minimize-button').addEventListener('mousedown', function(e) { e.preventDefault(); }); $('close-button').addEventListener('click', function() { window.close(); }); $('close-button').addEventListener('mousedown', function(e) { e.preventDefault(); }); } window.addEventListener('DOMContentLoaded', initializeHandlers); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ html { height: 100%; } body { background-color: #fbfbfb; height: 100%; margin: 0; overflow: auto; padding: 0; width: 100%; } .title-bar { -webkit-align-items: center; -webkit-app-region: drag; background-color: #fff; box-shadow: 0 1px #d0d0d0; color: rgb(80, 80, 82); display: -webkit-flex; font-size: 15px; height: 48px; } .title-bar #page-title { -webkit-flex: 1 1 auto; -webkit-margin-start: 20px; } .title-bar .button-bar { -webkit-flex: 0 1 auto; } .content { color: #646464; font-size: 12px; margin: 20px; } .content #description-text { border-color: #c8c8c8; box-sizing: border-box; height: 120px; line-height: 18px; padding: 10px; resize: none; width: 100%; } .content .text-field-container { -webkit-align-items: center; -webkit-padding-start: 10px; display: -webkit-flex; height: 29px; margin-top: 10px; } .content .text-field-container > label { -webkit-flex: 0 1 auto; width: 100px; } .content .text-field-container > input[type=text] { -webkit-flex: 1 1 auto; -webkit-padding-start: 5px; border: 1px solid; border-color: #c8c8c8; color: #585858; height: 100%; } .content .text-field-container > input[type=checkbox] { margin-right: 9px; } .content .checkbox-field-container { -webkit-align-items: center; display: -webkit-flex; height: 29px; } #screenshot-container { margin-top: 10px; } .content #screenshot-image { -webkit-margin-start: 100px; display: block; height: 60px; margin-top: 40px; transition: all 250ms ease; } .content #screenshot-image:hover { -webkit-margin-start: 80px; height: 125px; margin-top: 80px; z-index: 1; } .content #screenshot-image.wide-screen { height: auto; width: 100px; } .content #screenshot-image.wide-screen:hover { height: auto; width: 200px; } .content #privacy-note { color: #969696; font-size: 10px; line-height: 15px; margin-bottom: 20px; margin-top: 20px; } .content .buttons-pane { display: -webkit-flex; justify-content: flex-end } .content .remove-file-button { -webkit-margin-start: 5px; background-color: transparent; background-image: -webkit-image-set( url(chrome://resources/images/apps/button_butter_bar_close.png) 1x, url(chrome://resources/images/2x/apps/button_butter_bar_close.png) 2x); background-position: 50% 80%; background-repeat: no-repeat; border: none; height: 16px; pointer-events: auto; width: 16px; } .content .remove-file-button:hover { background-image: -webkit-image-set( url(chrome://resources/images/apps/button_butter_bar_close_hover.png) 1x, url(chrome://resources/images/2x/apps/button_butter_bar_close_hover.png) 2x); } .content .remove-file-button:active { background-image: -webkit-image-set( url(chrome://resources/images/apps/button_butter_bar_close_pressed.png) 1x, url(chrome://resources/images/2x/apps/button_butter_bar_close_pressed.png) 2x); } .content #attach-file-note { -webkit-margin-start: 112px; margin-bottom: 10px; margin-top: 10px; } .content .attach-file-notification { color: rgb(204, 0, 0); font-weight: bold; } button.white-button { -webkit-margin-end: 10px; color: #000; } button.blue-button { color: #fff; text-shadow: 1px sharp drop shadow rgb(45, 106, 218); } PNG  IHDR DcPLTEZZ\``baacǗbbdk0e tRNSMNIDATx^ 0DQQw)Dzm!,((++g ecz,3yAw 闖5io`hЪ/XTp tH(t͎y0@\}dg0Y>pOapS e\ ^*S%IENDB`PNG  IHDR@@PLTEZZ\[[]]]_ٺېڏÒ{{|nnpmmonnoݗzz{``b\\^||}oop__aooqM8ntRNS*'*+jT0IDATx^N@^轾k!,lٙ0{]F 56ZРN V@QQ? Ԁ6-|V_v(R#A5;ڜc@0M(EjJLے3`|k, * notifications: Array * }} */ var ServerResponse; /** * Notification group as the client stores it. |cardsTimestamp| and |rank| are * defined if |cards| is non-empty. |nextPollTime| is undefined if the server * (1) never sent 'nextPollSeconds' for the group or * (2) didn't send 'nextPollSeconds' with the last group update containing a * cards update and all the times after that. * * @typedef {{ * cards: Array, * cardsTimestamp: (number|undefined), * nextPollTime: (number|undefined), * rank: (number|undefined) * }} */ var StoredNotificationGroup; /** * Pending (not yet successfully sent) dismissal for a received notification. * |time| is the moment when the user requested dismissal. * * @typedef {{ * chromeNotificationId: ChromeNotificationId, * time: number, * dismissalData: DismissalData * }} */ var PendingDismissal; /** * Checks if a new task can't be scheduled when another task is already * scheduled. * @param {string} newTaskName Name of the new task. * @param {string} scheduledTaskName Name of the scheduled task. * @return {boolean} Whether the new task conflicts with the existing task. */ function areTasksConflicting(newTaskName, scheduledTaskName) { if (newTaskName == UPDATE_CARDS_TASK_NAME && scheduledTaskName == UPDATE_CARDS_TASK_NAME) { // If a card update is requested while an old update is still scheduled, we // don't need the new update. return true; } if (newTaskName == RETRY_DISMISS_TASK_NAME && (scheduledTaskName == UPDATE_CARDS_TASK_NAME || scheduledTaskName == DISMISS_CARD_TASK_NAME || scheduledTaskName == RETRY_DISMISS_TASK_NAME)) { // No need to schedule retry-dismiss action if another action that tries to // send dismissals is scheduled. return true; } return false; } var tasks = buildTaskManager(areTasksConflicting); // Add error processing to API calls. wrapper.instrumentChromeApiFunction('gcm.onMessage.addListener', 0); wrapper.instrumentChromeApiFunction('gcm.register', 1); wrapper.instrumentChromeApiFunction('gcm.unregister', 0); wrapper.instrumentChromeApiFunction('metricsPrivate.getVariationParams', 1); wrapper.instrumentChromeApiFunction('notifications.clear', 1); wrapper.instrumentChromeApiFunction('notifications.create', 2); wrapper.instrumentChromeApiFunction('notifications.getPermissionLevel', 0); wrapper.instrumentChromeApiFunction('notifications.update', 2); wrapper.instrumentChromeApiFunction('notifications.getAll', 0); wrapper.instrumentChromeApiFunction( 'notifications.onButtonClicked.addListener', 0); wrapper.instrumentChromeApiFunction('notifications.onClicked.addListener', 0); wrapper.instrumentChromeApiFunction('notifications.onClosed.addListener', 0); wrapper.instrumentChromeApiFunction( 'notifications.onPermissionLevelChanged.addListener', 0); wrapper.instrumentChromeApiFunction( 'notifications.onShowSettings.addListener', 0); wrapper.instrumentChromeApiFunction('permissions.contains', 1); wrapper.instrumentChromeApiFunction('runtime.onInstalled.addListener', 0); wrapper.instrumentChromeApiFunction('runtime.onStartup.addListener', 0); wrapper.instrumentChromeApiFunction('storage.onChanged.addListener', 0); wrapper.instrumentChromeApiFunction('tabs.create', 1); var updateCardsAttempts = buildAttemptManager( 'cards-update', requestCards, INITIAL_POLLING_PERIOD_SECONDS, MAXIMUM_POLLING_PERIOD_SECONDS); var optInPollAttempts = buildAttemptManager( 'optin', pollOptedInNoImmediateRecheck, INITIAL_POLLING_PERIOD_SECONDS, MAXIMUM_POLLING_PERIOD_SECONDS); var optInRecheckAttempts = buildAttemptManager( 'optin-recheck', pollOptedInWithRecheck, INITIAL_OPTIN_RECHECK_PERIOD_SECONDS, MAXIMUM_OPTIN_RECHECK_PERIOD_SECONDS); var dismissalAttempts = buildAttemptManager( 'dismiss', retryPendingDismissals, INITIAL_RETRY_DISMISS_PERIOD_SECONDS, MAXIMUM_RETRY_DISMISS_PERIOD_SECONDS); var cardSet = buildCardSet(); var authenticationManager = buildAuthenticationManager(); /** * Google Now UMA event identifier. * @enum {number} */ var GoogleNowEvent = { REQUEST_FOR_CARDS_TOTAL: 0, REQUEST_FOR_CARDS_SUCCESS: 1, CARDS_PARSE_SUCCESS: 2, DISMISS_REQUEST_TOTAL: 3, DISMISS_REQUEST_SUCCESS: 4, LOCATION_REQUEST: 5, DELETED_LOCATION_UPDATE: 6, EXTENSION_START: 7, DELETED_SHOW_WELCOME_TOAST: 8, STOPPED: 9, DELETED_USER_SUPPRESSED: 10, SIGNED_OUT: 11, NOTIFICATION_DISABLED: 12, GOOGLE_NOW_DISABLED: 13, EVENTS_TOTAL: 14 // EVENTS_TOTAL is not an event; all new events need to be // added before it. }; /** * Records a Google Now Event. * @param {GoogleNowEvent} event Event identifier. */ function recordEvent(event) { var metricDescription = { metricName: 'GoogleNow.Event', type: 'histogram-linear', min: 1, max: GoogleNowEvent.EVENTS_TOTAL, buckets: GoogleNowEvent.EVENTS_TOTAL + 1 }; chrome.metricsPrivate.recordValue(metricDescription, event); } /** * Records a notification clicked event. * @param {number|undefined} cardTypeId Card type ID. */ function recordNotificationClick(cardTypeId) { if (cardTypeId !== undefined) { chrome.metricsPrivate.recordSparseValue( 'GoogleNow.Card.Clicked', cardTypeId); } } /** * Records a button clicked event. * @param {number|undefined} cardTypeId Card type ID. * @param {number} buttonIndex Button Index */ function recordButtonClick(cardTypeId, buttonIndex) { if (cardTypeId !== undefined) { chrome.metricsPrivate.recordSparseValue( 'GoogleNow.Card.Button.Clicked' + buttonIndex, cardTypeId); } } /** * Checks the result of the HTTP Request and updates the authentication * manager on any failure. * @param {string} token Authentication token to validate against an * XMLHttpRequest. * @return {function(XMLHttpRequest)} Function that validates the token with the * supplied XMLHttpRequest. */ function checkAuthenticationStatus(token) { return function(request) { if (request.status == HTTP_FORBIDDEN || request.status == HTTP_UNAUTHORIZED) { authenticationManager.removeToken(token); } } } /** * Builds and sends an authenticated request to the notification server. * @param {string} method Request method. * @param {string} handlerName Server handler to send the request to. * @param {string=} opt_contentType Value for the Content-type header. * @return {Promise} A promise to issue a request to the server. * The promise rejects if the response is not within the HTTP 200 range. */ function requestFromServer(method, handlerName, opt_contentType) { return authenticationManager.getAuthToken().then(function(token) { var request = buildServerRequest(method, handlerName, opt_contentType); request.setRequestHeader('Authorization', 'Bearer ' + token); var requestPromise = new Promise(function(resolve, reject) { request.addEventListener('loadend', function() { if ((200 <= request.status) && (request.status < 300)) { resolve(request); } else { reject(request); } }, false); request.send(); }); requestPromise.catch(checkAuthenticationStatus(token)); return requestPromise; }); } /** * Shows the notification groups as notification cards. * @param {Object} notificationGroups Map from group * name to group information. * @param {function(ReceivedNotification)=} opt_onCardShown Optional parameter * called when each card is shown. * @return {Promise} A promise to show the notification groups as cards. */ function showNotificationGroups(notificationGroups, opt_onCardShown) { /** @type {Object} */ var cards = combineCardsFromGroups(notificationGroups); console.log('showNotificationGroups ' + JSON.stringify(cards)); return new Promise(function(resolve) { instrumented.notifications.getAll(function(notifications) { console.log('showNotificationGroups-getAll ' + JSON.stringify(notifications)); notifications = notifications || {}; // Mark notifications that didn't receive an update as having received // an empty update. for (var chromeNotificationId in notifications) { cards[chromeNotificationId] = cards[chromeNotificationId] || []; } /** @type {Object} */ var notificationsData = {}; // Create/update/delete notifications. for (var chromeNotificationId in cards) { notificationsData[chromeNotificationId] = cardSet.update( chromeNotificationId, cards[chromeNotificationId], notificationGroups, opt_onCardShown); } chrome.storage.local.set({notificationsData: notificationsData}); resolve(); }); }); } /** * Removes all cards and card state on Google Now close down. */ function removeAllCards() { console.log('removeAllCards'); // TODO(robliao): Once Google Now clears its own checkbox in the // notifications center and bug 260376 is fixed, the below clearing // code is no longer necessary. instrumented.notifications.getAll(function(notifications) { notifications = notifications || {}; for (var chromeNotificationId in notifications) { instrumented.notifications.clear(chromeNotificationId, function() {}); } chrome.storage.local.remove(['notificationsData', 'notificationGroups']); }); } /** * Adds a card group into a set of combined cards. * @param {Object} combinedCards Map from * chromeNotificationId to a combined card. * This is an input/output parameter. * @param {StoredNotificationGroup} storedGroup Group to combine into the * combined card set. */ function combineGroup(combinedCards, storedGroup) { for (var i = 0; i < storedGroup.cards.length; i++) { /** @type {ReceivedNotification} */ var receivedNotification = storedGroup.cards[i]; /** @type {UncombinedNotification} */ var uncombinedNotification = { receivedNotification: receivedNotification, showTime: receivedNotification.trigger.showTimeSec && (storedGroup.cardsTimestamp + receivedNotification.trigger.showTimeSec * MS_IN_SECOND), hideTime: storedGroup.cardsTimestamp + receivedNotification.trigger.hideTimeSec * MS_IN_SECOND }; var combinedCard = combinedCards[receivedNotification.chromeNotificationId] || []; combinedCard.push(uncombinedNotification); combinedCards[receivedNotification.chromeNotificationId] = combinedCard; } } /** * Calculates the soonest poll time from a map of groups as an absolute time. * @param {Object} groups Map from group name to group * information. * @return {number} The next poll time based off of the groups. */ function calculateNextPollTimeMilliseconds(groups) { var nextPollTime = null; for (var groupName in groups) { var group = groups[groupName]; if (group.nextPollTime !== undefined) { nextPollTime = nextPollTime == null ? group.nextPollTime : Math.min(group.nextPollTime, nextPollTime); } } // At least one of the groups must have nextPollTime. verify(nextPollTime != null, 'calculateNextPollTime: nextPollTime is null'); return nextPollTime; } /** * Schedules next cards poll. * @param {Object} groups Map from group name to group * information. */ function scheduleNextCardsPoll(groups) { var nextPollTimeMs = calculateNextPollTimeMilliseconds(groups); var nextPollDelaySeconds = Math.max( (nextPollTimeMs - Date.now()) / MS_IN_SECOND, MINIMUM_POLLING_PERIOD_SECONDS); updateCardsAttempts.start(nextPollDelaySeconds); } /** * Schedules the next opt-in check poll. */ function scheduleOptInCheckPoll() { instrumented.metricsPrivate.getVariationParams( 'GoogleNow', function(params) { var optinPollPeriodSeconds = parseInt(params && params.optinPollPeriodSeconds, 10) || DEFAULT_OPTIN_CHECK_PERIOD_SECONDS; optInPollAttempts.start(optinPollPeriodSeconds); }); } /** * Combines notification groups into a set of Chrome notifications. * @param {Object} notificationGroups Map from group * name to group information. * @return {Object} Cards to show. */ function combineCardsFromGroups(notificationGroups) { console.log('combineCardsFromGroups ' + JSON.stringify(notificationGroups)); /** @type {Object} */ var combinedCards = {}; for (var groupName in notificationGroups) combineGroup(combinedCards, notificationGroups[groupName]); return combinedCards; } /** * Processes a server response for consumption by showNotificationGroups. * @param {ServerResponse} response Server response. * @return {Promise} A promise to process the server response and provide * updated groups. Rejects if the server response shouldn't be processed. */ function processServerResponse(response) { console.log('processServerResponse ' + JSON.stringify(response)); if (response.googleNowDisabled) { chrome.storage.local.set({googleNowEnabled: false}); // Stop processing now. The state change will clear the cards. return Promise.reject(); } var receivedGroups = response.groups; return fillFromChromeLocalStorage({ /** @type {Object} */ notificationGroups: {}, /** @type {Object} */ recentDismissals: {} }).then(function(items) { console.log('processServerResponse-get ' + JSON.stringify(items)); // Build a set of non-expired recent dismissals. It will be used for // client-side filtering of cards. /** @type {Object} */ var updatedRecentDismissals = {}; var now = Date.now(); for (var serverNotificationId in items.recentDismissals) { var dismissalAge = now - items.recentDismissals[serverNotificationId]; if (dismissalAge < DISMISS_RETENTION_TIME_MS) { updatedRecentDismissals[serverNotificationId] = items.recentDismissals[serverNotificationId]; } } // Populate groups with corresponding cards. if (response.notifications) { for (var i = 0; i < response.notifications.length; ++i) { /** @type {ReceivedNotification} */ var card = response.notifications[i]; if (!(card.notificationId in updatedRecentDismissals)) { var group = receivedGroups[card.groupName]; group.cards = group.cards || []; group.cards.push(card); } } } // Build updated set of groups. var updatedGroups = {}; for (var groupName in receivedGroups) { var receivedGroup = receivedGroups[groupName]; var storedGroup = items.notificationGroups[groupName] || { cards: [], cardsTimestamp: undefined, nextPollTime: undefined, rank: undefined }; if (receivedGroup.requested) receivedGroup.cards = receivedGroup.cards || []; if (receivedGroup.cards) { // If the group contains a cards update, all its fields will get new // values. storedGroup.cards = receivedGroup.cards; storedGroup.cardsTimestamp = now; storedGroup.rank = receivedGroup.rank; storedGroup.nextPollTime = undefined; // The code below assigns nextPollTime a defined value if // nextPollSeconds is specified in the received group. // If the group's cards are not updated, and nextPollSeconds is // unspecified, this method doesn't change group's nextPollTime. } // 'nextPollSeconds' may be sent even for groups that don't contain // cards updates. if (receivedGroup.nextPollSeconds !== undefined) { storedGroup.nextPollTime = now + receivedGroup.nextPollSeconds * MS_IN_SECOND; } updatedGroups[groupName] = storedGroup; } scheduleNextCardsPoll(updatedGroups); return { updatedGroups: updatedGroups, recentDismissals: updatedRecentDismissals }; }); } /** * Update the Explanatory Total Cards Shown Count. */ function countExplanatoryCard() { localStorage['explanatoryCardsShown']++; } /** * Determines if cards should have an explanation link. * @return {boolean} true if an explanatory card should be shown. */ function shouldShowExplanatoryCard() { var isBelowThreshold = localStorage['explanatoryCardsShown'] < EXPLANATORY_CARDS_LINK_THRESHOLD; return isBelowThreshold; } /** * Requests notification cards from the server for specified groups. * @param {Array} groupNames Names of groups that need to be refreshed. * @return {Promise} A promise to request the specified notification groups. */ function requestNotificationGroupsFromServer(groupNames) { console.log( 'requestNotificationGroupsFromServer from ' + NOTIFICATION_CARDS_URL + ', groupNames=' + JSON.stringify(groupNames)); recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_TOTAL); var requestParameters = '?timeZoneOffsetMs=' + (-new Date().getTimezoneOffset() * MS_IN_MINUTE); if (shouldShowExplanatoryCard()) { requestParameters += '&cardExplanation=true'; } groupNames.forEach(function(groupName) { requestParameters += ('&requestTypes=' + groupName); }); requestParameters += '&uiLocale=' + navigator.language; console.log( 'requestNotificationGroupsFromServer: request=' + requestParameters); return requestFromServer('GET', 'notifications' + requestParameters).then( function(request) { console.log( 'requestNotificationGroupsFromServer-received ' + request.status); if (request.status == HTTP_OK) { recordEvent(GoogleNowEvent.REQUEST_FOR_CARDS_SUCCESS); return JSON.parse(request.responseText); } }); } /** * Performs an opt-in poll without an immediate recheck. * If the response is not opted-in, schedule an opt-in check poll. */ function pollOptedInNoImmediateRecheck() { requestAndUpdateOptedIn() .then(function(optedIn) { if (!optedIn) { // Request a repoll if we're not opted in. return Promise.reject(); } }) .catch(function() { scheduleOptInCheckPoll(); }); } /** * Requests the account opted-in state from the server and updates any * state as necessary. * @return {Promise} A promise to request and update the opted-in state. * The promise resolves with the opt-in state. */ function requestAndUpdateOptedIn() { console.log('requestOptedIn from ' + NOTIFICATION_CARDS_URL); return requestFromServer('GET', 'settings/optin').then(function(request) { console.log( 'requestOptedIn-received ' + request.status + ' ' + request.response); if (request.status == HTTP_OK) { var parsedResponse = JSON.parse(request.responseText); return parsedResponse.value; } }).then(function(optedIn) { chrome.storage.local.set({googleNowEnabled: optedIn}); return optedIn; }); } /** * Determines the groups that need to be requested right now. * @return {Promise} A promise to determine the groups to request. */ function getGroupsToRequest() { return fillFromChromeLocalStorage({ /** @type {Object} */ notificationGroups: {} }).then(function(items) { console.log('getGroupsToRequest-storage-get ' + JSON.stringify(items)); var groupsToRequest = []; var now = Date.now(); for (var groupName in items.notificationGroups) { var group = items.notificationGroups[groupName]; if (group.nextPollTime !== undefined && group.nextPollTime <= now) groupsToRequest.push(groupName); } return groupsToRequest; }); } /** * Requests notification cards from the server. * @return {Promise} A promise to request the notification cards. * Rejects if the cards won't be requested. */ function requestNotificationCards() { console.log('requestNotificationCards'); return getGroupsToRequest() .then(requestNotificationGroupsFromServer) .then(processServerResponse) .then(function(processedResponse) { var onCardShown = shouldShowExplanatoryCard() ? countExplanatoryCard : undefined; return showNotificationGroups( processedResponse.updatedGroups, onCardShown).then(function() { chrome.storage.local.set({ notificationGroups: processedResponse.updatedGroups, recentDismissals: processedResponse.updatedRecentDismissals }); recordEvent(GoogleNowEvent.CARDS_PARSE_SUCCESS); } ); }); } /** * Determines if an immediate retry should occur based off of the given groups. * The NOR group is expected most often and less latency sensitive, so we will * simply wait MAXIMUM_POLLING_PERIOD_SECONDS before trying again. * @param {Array} groupNames Names of groups that need to be refreshed. * @return {boolean} Whether a retry should occur. */ function shouldScheduleRetryFromGroupList(groupNames) { return (groupNames.length != 1) || (groupNames[0] !== 'NOR'); } /** * Requests and shows notification cards. */ function requestCards() { console.log('requestCards @' + new Date()); // LOCATION_REQUEST is a legacy histogram value when we requested location. // This corresponds to the extension attempting to request for cards. // We're keeping the name the same to keep our histograms in order. recordEvent(GoogleNowEvent.LOCATION_REQUEST); tasks.add(UPDATE_CARDS_TASK_NAME, function() { console.log('requestCards-task-begin'); updateCardsAttempts.isRunning(function(running) { if (running) { // The cards are requested only if there are no unsent dismissals. processPendingDismissals() .then(requestNotificationCards) .catch(function() { return getGroupsToRequest().then(function(groupsToRequest) { if (shouldScheduleRetryFromGroupList(groupsToRequest)) { updateCardsAttempts.scheduleRetry(); } }); }); } }); }); } /** * Sends a server request to dismiss a card. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of * the card. * @param {number} dismissalTimeMs Time of the user's dismissal of the card in * milliseconds since epoch. * @param {DismissalData} dismissalData Data to build a dismissal request. * @return {Promise} A promise to request the card dismissal, rejects on error. */ function requestCardDismissal( chromeNotificationId, dismissalTimeMs, dismissalData) { console.log('requestDismissingCard ' + chromeNotificationId + ' from ' + NOTIFICATION_CARDS_URL + ', dismissalData=' + JSON.stringify(dismissalData)); var dismissalAge = Date.now() - dismissalTimeMs; if (dismissalAge > MAXIMUM_DISMISSAL_AGE_MS) { return Promise.resolve(); } recordEvent(GoogleNowEvent.DISMISS_REQUEST_TOTAL); var requestParameters = 'notifications/' + dismissalData.notificationId + '?age=' + dismissalAge + '&chromeNotificationId=' + chromeNotificationId; for (var paramField in dismissalData.parameters) requestParameters += ('&' + paramField + '=' + dismissalData.parameters[paramField]); console.log('requestCardDismissal: requestParameters=' + requestParameters); return requestFromServer('DELETE', requestParameters).then(function(request) { console.log('requestDismissingCard-onloadend ' + request.status); if (request.status == HTTP_NOCONTENT) recordEvent(GoogleNowEvent.DISMISS_REQUEST_SUCCESS); // A dismissal doesn't require further retries if it was successful or // doesn't have a chance for successful completion. return (request.status == HTTP_NOCONTENT) ? Promise.resolve() : Promise.reject(); }).catch(function(request) { request = (typeof request === 'object') ? request : {}; return (request.status == HTTP_BAD_REQUEST || request.status == HTTP_METHOD_NOT_ALLOWED) ? Promise.resolve() : Promise.reject(); }); } /** * Tries to send dismiss requests for all pending dismissals. * @return {Promise} A promise to process the pending dismissals. * The promise is rejected if a problem was encountered. */ function processPendingDismissals() { return fillFromChromeLocalStorage({ /** @type {Array} */ pendingDismissals: [], /** @type {Object} */ recentDismissals: {} }).then(function(items) { console.log( 'processPendingDismissals-storage-get ' + JSON.stringify(items)); var dismissalsChanged = false; function onFinish(success) { if (dismissalsChanged) { chrome.storage.local.set({ pendingDismissals: items.pendingDismissals, recentDismissals: items.recentDismissals }); } return success ? Promise.resolve() : Promise.reject(); } function doProcessDismissals() { if (items.pendingDismissals.length == 0) { dismissalAttempts.stop(); return onFinish(true); } // Send dismissal for the first card, and if successful, repeat // recursively with the rest. /** @type {PendingDismissal} */ var dismissal = items.pendingDismissals[0]; return requestCardDismissal( dismissal.chromeNotificationId, dismissal.time, dismissal.dismissalData).then(function() { dismissalsChanged = true; items.pendingDismissals.splice(0, 1); items.recentDismissals[dismissal.dismissalData.notificationId] = Date.now(); return doProcessDismissals(); }).catch(function() { return onFinish(false); }); } return doProcessDismissals(); }); } /** * Submits a task to send pending dismissals. */ function retryPendingDismissals() { tasks.add(RETRY_DISMISS_TASK_NAME, function() { processPendingDismissals().catch(dismissalAttempts.scheduleRetry); }); } /** * Opens a URL in a new tab. * @param {string} url URL to open. */ function openUrl(url) { instrumented.tabs.create({url: url}, function(tab) { if (tab) chrome.windows.update(tab.windowId, {focused: true}); else chrome.windows.create({url: url, focused: true}); }); } /** * Opens URL corresponding to the clicked part of the notification. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of * the card. * @param {function(NotificationDataEntry): (string|undefined)} selector * Function that extracts the url for the clicked area from the * notification data entry. */ function onNotificationClicked(chromeNotificationId, selector) { fillFromChromeLocalStorage({ /** @type {Object} */ notificationsData: {} }).then(function(items) { /** @type {(NotificationDataEntry|undefined)} */ var notificationDataEntry = items.notificationsData[chromeNotificationId]; if (!notificationDataEntry) return; var url = selector(notificationDataEntry); if (!url) return; openUrl(url); }); } /** * Callback for chrome.notifications.onClosed event. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID of * the card. * @param {boolean} byUser Whether the notification was closed by the user. */ function onNotificationClosed(chromeNotificationId, byUser) { if (!byUser) return; // At this point we are guaranteed that the notification is a now card. chrome.metricsPrivate.recordUserAction('GoogleNow.Dismissed'); tasks.add(DISMISS_CARD_TASK_NAME, function() { dismissalAttempts.start(); fillFromChromeLocalStorage({ /** @type {Array} */ pendingDismissals: [], /** @type {Object} */ notificationsData: {}, /** @type {Object} */ notificationGroups: {} }).then(function(items) { /** @type {NotificationDataEntry} */ var notificationData = items.notificationsData[chromeNotificationId] || { timestamp: Date.now(), combinedCard: [] }; var dismissalResult = cardSet.onDismissal( chromeNotificationId, notificationData, items.notificationGroups); for (var i = 0; i < dismissalResult.dismissals.length; i++) { /** @type {PendingDismissal} */ var dismissal = { chromeNotificationId: chromeNotificationId, time: Date.now(), dismissalData: dismissalResult.dismissals[i] }; items.pendingDismissals.push(dismissal); } items.notificationsData[chromeNotificationId] = dismissalResult.notificationData; chrome.storage.local.set(items); processPendingDismissals(); }); }); } /** * Initializes the polling system to start fetching cards. */ function startPollingCards() { console.log('startPollingCards'); // Create an update timer for a case when for some reason requesting // cards gets stuck. updateCardsAttempts.start(MAXIMUM_POLLING_PERIOD_SECONDS); requestCards(); } /** * Stops all machinery in the polling system. */ function stopPollingCards() { console.log('stopPollingCards'); updateCardsAttempts.stop(); // Since we're stopping everything, clear all runtime storage. // We don't clear localStorage since those values are still relevant // across Google Now start-stop events. chrome.storage.local.clear(); } /** * Initializes the event page on install or on browser startup. */ function initialize() { recordEvent(GoogleNowEvent.EXTENSION_START); onStateChange(); } /** * Starts or stops the main pipeline for polling cards. * @param {boolean} shouldPollCardsRequest true to start and * false to stop polling cards. */ function setShouldPollCards(shouldPollCardsRequest) { updateCardsAttempts.isRunning(function(currentValue) { if (shouldPollCardsRequest != currentValue) { console.log('Action Taken setShouldPollCards=' + shouldPollCardsRequest); if (shouldPollCardsRequest) startPollingCards(); else stopPollingCards(); } else { console.log( 'Action Ignored setShouldPollCards=' + shouldPollCardsRequest); } }); } /** * Starts or stops the optin check and GCM channel to receive optin * notifications. * @param {boolean} shouldPollOptInStatus true to start and false to stop * polling the optin status. */ function setShouldPollOptInStatus(shouldPollOptInStatus) { optInPollAttempts.isRunning(function(currentValue) { if (shouldPollOptInStatus != currentValue) { console.log( 'Action Taken setShouldPollOptInStatus=' + shouldPollOptInStatus); if (shouldPollOptInStatus) { pollOptedInNoImmediateRecheck(); } else { optInPollAttempts.stop(); } } else { console.log( 'Action Ignored setShouldPollOptInStatus=' + shouldPollOptInStatus); } }); if (shouldPollOptInStatus) { registerForGcm(); } else { unregisterFromGcm(); } } /** * Enables or disables the Google Now background permission. * @param {boolean} backgroundEnable true to run in the background. * false to not run in the background. */ function setBackgroundEnable(backgroundEnable) { instrumented.permissions.contains({permissions: ['background']}, function(hasPermission) { if (backgroundEnable != hasPermission) { console.log('Action Taken setBackgroundEnable=' + backgroundEnable); if (backgroundEnable) chrome.permissions.request({permissions: ['background']}); else chrome.permissions.remove({permissions: ['background']}); } else { console.log('Action Ignored setBackgroundEnable=' + backgroundEnable); } }); } /** * Record why this extension would not poll for cards. * @param {boolean} signedIn true if the user is signed in. * @param {boolean} notificationEnabled true if * Google Now for Chrome is allowed to show notifications. * @param {boolean} googleNowEnabled true if * the Google Now is enabled for the user. */ function recordEventIfNoCards(signedIn, notificationEnabled, googleNowEnabled) { if (!signedIn) { recordEvent(GoogleNowEvent.SIGNED_OUT); } else if (!notificationEnabled) { recordEvent(GoogleNowEvent.NOTIFICATION_DISABLED); } else if (!googleNowEnabled) { recordEvent(GoogleNowEvent.GOOGLE_NOW_DISABLED); } } /** * Does the actual work of deciding what Google Now should do * based off of the current state of Chrome. * @param {boolean} signedIn true if the user is signed in. * @param {boolean} canEnableBackground true if * the background permission can be requested. * @param {boolean} notificationEnabled true if * Google Now for Chrome is allowed to show notifications. * @param {boolean} googleNowEnabled true if * the Google Now is enabled for the user. */ function updateRunningState( signedIn, canEnableBackground, notificationEnabled, googleNowEnabled) { console.log( 'State Update signedIn=' + signedIn + ' ' + 'canEnableBackground=' + canEnableBackground + ' ' + 'notificationEnabled=' + notificationEnabled + ' ' + 'googleNowEnabled=' + googleNowEnabled); var shouldPollCards = false; var shouldPollOptInStatus = false; var shouldSetBackground = false; if (signedIn && notificationEnabled) { shouldPollCards = googleNowEnabled; shouldPollOptInStatus = !googleNowEnabled; shouldSetBackground = canEnableBackground && googleNowEnabled; } else { recordEvent(GoogleNowEvent.STOPPED); } recordEventIfNoCards(signedIn, notificationEnabled, googleNowEnabled); console.log( 'Requested Actions shouldSetBackground=' + shouldSetBackground + ' ' + 'setShouldPollCards=' + shouldPollCards + ' ' + 'shouldPollOptInStatus=' + shouldPollOptInStatus); setBackgroundEnable(shouldSetBackground); setShouldPollCards(shouldPollCards); setShouldPollOptInStatus(shouldPollOptInStatus); if (!shouldPollCards) { removeAllCards(); } } /** * Coordinates the behavior of Google Now for Chrome depending on * Chrome and extension state. */ function onStateChange() { tasks.add(STATE_CHANGED_TASK_NAME, function() { Promise.all([ authenticationManager.isSignedIn(), canEnableBackground(), isNotificationsEnabled(), isGoogleNowEnabled()]) .then(function(results) { updateRunningState.apply(null, results); }); }); } /** * Determines if background mode should be requested. * @return {Promise} A promise to determine if background can be enabled. */ function canEnableBackground() { return new Promise(function(resolve) { instrumented.metricsPrivate.getVariationParams( 'GoogleNow', function(response) { resolve(!response || (response.canEnableBackground != 'false')); }); }); } /** * Checks if Google Now is enabled in the notifications center. * @return {Promise} A promise to determine if Google Now is enabled * in the notifications center. */ function isNotificationsEnabled() { return new Promise(function(resolve) { instrumented.notifications.getPermissionLevel(function(level) { resolve(level == 'granted'); }); }); } /** * Gets the previous Google Now opt-in state. * @return {Promise} A promise to determine the previous Google Now * opt-in state. */ function isGoogleNowEnabled() { return fillFromChromeLocalStorage({googleNowEnabled: false}) .then(function(items) { return items.googleNowEnabled; }); } /** * Ensures the extension is ready to listen for GCM messages. */ function registerForGcm() { // We don't need to use the key yet, just ensure the channel is set up. getGcmNotificationKey(); } /** * Returns a Promise resolving to either a cached or new GCM notification key. * Rejects if registration fails. * @return {Promise} A Promise that resolves to a potentially-cached GCM key. */ function getGcmNotificationKey() { return fillFromChromeLocalStorage({gcmNotificationKey: undefined}) .then(function(items) { if (items.gcmNotificationKey) { console.log('Reused gcm key from storage.'); return Promise.resolve(items.gcmNotificationKey); } return requestNewGcmNotificationKey(); }); } /** * Returns a promise resolving to a GCM Notificaiton Key. May call * chrome.gcm.register() first if required. Rejects on registration failure. * @return {Promise} A Promise that resolves to a fresh GCM Notification key. */ function requestNewGcmNotificationKey() { return getGcmRegistrationId().then(function(gcmId) { authenticationManager.getAuthToken().then(function(token) { authenticationManager.getLogin().then(function(username) { return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.responseType = 'application/json'; xhr.open('POST', GCM_REGISTRATION_URL, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Authorization', 'Bearer ' + token); xhr.setRequestHeader('project_id', GCM_PROJECT_ID); var payload = { 'operation': 'add', 'notification_key_name': username, 'registration_ids': [gcmId] }; xhr.onloadend = function() { if (xhr.status != 200) { reject(); } var obj = JSON.parse(xhr.responseText); var key = obj && obj.notification_key; if (!key) { reject(); } console.log('gcm notification key POST: ' + key); chrome.storage.local.set({gcmNotificationKey: key}); resolve(key); }; xhr.send(JSON.stringify(payload)); }); }); }).catch(function() { // Couldn't obtain a GCM ID. Ignore and fallback to polling. }); }); } /** * Returns a promise resolving to either a cached or new GCM registration ID. * Rejects if registration fails. * @return {Promise} A Promise that resolves to a GCM registration ID. */ function getGcmRegistrationId() { return fillFromChromeLocalStorage({gcmRegistrationId: undefined}) .then(function(items) { if (items.gcmRegistrationId) { console.log('Reused gcm registration id from storage.'); return Promise.resolve(items.gcmRegistrationId); } return new Promise(function(resolve, reject) { instrumented.gcm.register([GCM_PROJECT_ID], function(registrationId) { console.log('gcm.register(): ' + registrationId); if (registrationId) { chrome.storage.local.set({gcmRegistrationId: registrationId}); resolve(registrationId); } else { reject(); } }); }); }); } /** * Unregisters from GCM if previously registered. */ function unregisterFromGcm() { fillFromChromeLocalStorage({gcmRegistrationId: undefined}) .then(function(items) { if (items.gcmRegistrationId) { console.log('Unregistering from gcm.'); instrumented.gcm.unregister(function() { if (!chrome.runtime.lastError) { chrome.storage.local.remove( ['gcmNotificationKey', 'gcmRegistrationId']); } }); } }); } /** * Polls the optin state. * Sometimes we get the response to the opted in result too soon during * push messaging. We'll recheck the optin state a few times before giving up. */ function pollOptedInWithRecheck() { /** * Cleans up any state used to recheck the opt-in poll. */ function clearPollingState() { localStorage.removeItem('optedInCheckCount'); optInRecheckAttempts.stop(); } if (localStorage.optedInCheckCount === undefined) { localStorage.optedInCheckCount = 0; optInRecheckAttempts.start(); } console.log(new Date() + ' checkOptedIn Attempt ' + localStorage.optedInCheckCount); requestAndUpdateOptedIn().then(function(optedIn) { if (optedIn) { clearPollingState(); return Promise.resolve(); } else { // If we're not opted in, reject to retry. return Promise.reject(); } }).catch(function() { if (localStorage.optedInCheckCount < 5) { localStorage.optedInCheckCount++; optInRecheckAttempts.scheduleRetry(); } else { clearPollingState(); } }); } instrumented.runtime.onInstalled.addListener(function(details) { console.log('onInstalled ' + JSON.stringify(details)); if (details.reason != 'chrome_update') { initialize(); } }); instrumented.runtime.onStartup.addListener(function() { console.log('onStartup'); // Show notifications received by earlier polls. Doing this as early as // possible to reduce latency of showing first notifications. This mimics how // persistent notifications will work. tasks.add(SHOW_ON_START_TASK_NAME, function() { fillFromChromeLocalStorage({ /** @type {Object} */ notificationGroups: {} }).then(function(items) { console.log('onStartup-get ' + JSON.stringify(items)); showNotificationGroups(items.notificationGroups).then(function() { chrome.storage.local.set(items); }); }); }); initialize(); }); authenticationManager.addListener(function() { console.log('signIn State Change'); onStateChange(); }); instrumented.notifications.onClicked.addListener( function(chromeNotificationId) { chrome.metricsPrivate.recordUserAction('GoogleNow.MessageClicked'); onNotificationClicked(chromeNotificationId, function(notificationDataEntry) { var actionUrls = notificationDataEntry.actionUrls; var url = actionUrls && actionUrls.messageUrl; if (url) { recordNotificationClick(notificationDataEntry.cardTypeId); } return url; }); }); instrumented.notifications.onButtonClicked.addListener( function(chromeNotificationId, buttonIndex) { chrome.metricsPrivate.recordUserAction( 'GoogleNow.ButtonClicked' + buttonIndex); onNotificationClicked(chromeNotificationId, function(notificationDataEntry) { var actionUrls = notificationDataEntry.actionUrls; var url = actionUrls.buttonUrls[buttonIndex]; if (url) { recordButtonClick(notificationDataEntry.cardTypeId, buttonIndex); } else { verify(false, 'onButtonClicked: no url for a button'); console.log( 'buttonIndex=' + buttonIndex + ' ' + 'chromeNotificationId=' + chromeNotificationId + ' ' + 'notificationDataEntry=' + JSON.stringify(notificationDataEntry)); } return url; }); }); instrumented.notifications.onClosed.addListener(onNotificationClosed); instrumented.notifications.onPermissionLevelChanged.addListener( function(permissionLevel) { console.log('Notifications permissionLevel Change'); onStateChange(); }); instrumented.notifications.onShowSettings.addListener(function() { openUrl(SETTINGS_URL); }); // Handles state change notifications for the Google Now enabled bit. instrumented.storage.onChanged.addListener(function(changes, areaName) { if (areaName === 'local') { if ('googleNowEnabled' in changes) { onStateChange(); } } }); instrumented.gcm.onMessage.addListener(function(message) { console.log('gcm.onMessage ' + JSON.stringify(message)); if (!message || !message.data) { return; } var payload = message.data.payload; var tag = message.data.tag; if (payload.indexOf('REQUEST_CARDS') == 0) { tasks.add(ON_PUSH_MESSAGE_START_TASK_NAME, function() { // Accept promise rejection on failure since it's safer to do nothing, // preventing polling the server when the payload really didn't change. fillFromChromeLocalStorage({ lastPollNowPayloads: {}, /** @type {Object} */ notificationGroups: {} }, PromiseRejection.ALLOW).then(function(items) { if (items.lastPollNowPayloads[tag] != payload) { items.lastPollNowPayloads[tag] = payload; items.notificationGroups['PUSH' + tag] = { cards: [], nextPollTime: Date.now() }; chrome.storage.local.set({ lastPollNowPayloads: items.lastPollNowPayloads, notificationGroups: items.notificationGroups }); pollOptedInWithRecheck(); } }); }); } }); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Show/hide trigger in a card. * * @typedef {{ * showTimeSec: (string|undefined), * hideTimeSec: string * }} */ var Trigger; /** * ID of an individual (uncombined) notification. * This ID comes directly from the server. * * @typedef {string} */ var ServerNotificationId; /** * Data to build a dismissal request for a card from a specific group. * * @typedef {{ * notificationId: ServerNotificationId, * parameters: Object * }} */ var DismissalData; /** * Urls that need to be opened when clicking a notification or its buttons. * * @typedef {{ * messageUrl: (string|undefined), * buttonUrls: (Array|undefined) * }} */ var ActionUrls; /** * ID of a combined notification. * This is the ID used with chrome.notifications API. * * @typedef {string} */ var ChromeNotificationId; /** * Notification as sent by the server. * * @typedef {{ * notificationId: ServerNotificationId, * chromeNotificationId: ChromeNotificationId, * trigger: Trigger, * chromeNotificationOptions: Object, * actionUrls: (ActionUrls|undefined), * dismissal: Object, * locationBased: (boolean|undefined), * groupName: string, * cardTypeId: (number|undefined) * }} */ var ReceivedNotification; /** * Received notification in a self-sufficient form that doesn't require group's * timestamp to calculate show and hide times. * * @typedef {{ * receivedNotification: ReceivedNotification, * showTime: (number|undefined), * hideTime: number * }} */ var UncombinedNotification; /** * Card combined from potentially multiple groups. * * @typedef {Array} */ var CombinedCard; /** * Data entry that we store for every Chrome notification. * |timestamp| is the time when corresponding Chrome notification was created or * updated last time by cardSet.update(). * * @typedef {{ * actionUrls: (ActionUrls|undefined), * cardTypeId: (number|undefined), * timestamp: number, * combinedCard: CombinedCard * }} * */ var NotificationDataEntry; /** * Names for tasks that can be created by the this file. */ var UPDATE_CARD_TASK_NAME = 'update-card'; /** * Builds an object to manage notification card set. * @return {Object} Card set interface. */ function buildCardSet() { var alarmPrefix = 'card-'; /** * Creates/updates/deletes a Chrome notification. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID * of the card. * @param {(ReceivedNotification|undefined)} receivedNotification Google Now * card represented as a set of parameters for showing a Chrome * notification, or null if the notification needs to be deleted. * @param {function(ReceivedNotification)=} onCardShown Optional parameter * called when each card is shown. */ function updateNotification( chromeNotificationId, receivedNotification, onCardShown) { console.log( 'cardManager.updateNotification ' + chromeNotificationId + ' ' + JSON.stringify(receivedNotification)); if (!receivedNotification) { instrumented.notifications.clear(chromeNotificationId, function() {}); return; } // Try updating the notification. instrumented.notifications.update( chromeNotificationId, receivedNotification.chromeNotificationOptions, function(wasUpdated) { if (!wasUpdated) { // If the notification wasn't updated, it probably didn't exist. // Create it. console.log( 'cardManager.updateNotification ' + chromeNotificationId + ' not updated, creating instead'); instrumented.notifications.create( chromeNotificationId, receivedNotification.chromeNotificationOptions, function(newChromeNotificationId) { if (!newChromeNotificationId || chrome.runtime.lastError) { var errorMessage = chrome.runtime.lastError && chrome.runtime.lastError.message; console.error('notifications.create: ID=' + newChromeNotificationId + ', ERROR=' + errorMessage); return; } if (onCardShown !== undefined) onCardShown(receivedNotification); }); } }); } /** * Iterates uncombined notifications in a combined card, determining for * each whether it's visible at the specified moment. * @param {CombinedCard} combinedCard The combined card in question. * @param {number} timestamp Time for which to calculate visibility. * @param {function(UncombinedNotification, boolean)} callback Function * invoked for every uncombined notification in |combinedCard|. * The boolean parameter indicates whether the uncombined notification is * visible at |timestamp|. */ function iterateUncombinedNotifications(combinedCard, timestamp, callback) { for (var i = 0; i != combinedCard.length; ++i) { var uncombinedNotification = combinedCard[i]; var shouldShow = !uncombinedNotification.showTime || uncombinedNotification.showTime <= timestamp; var shouldHide = uncombinedNotification.hideTime <= timestamp; callback(uncombinedNotification, shouldShow && !shouldHide); } } /** * Refreshes (shows/hides) the notification corresponding to the combined card * based on the current time and show-hide intervals in the combined card. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID * of the card. * @param {CombinedCard} combinedCard Combined cards with * |chromeNotificationId|. * @param {Object} notificationGroups Map from group * name to group information. * @param {function(ReceivedNotification)=} onCardShown Optional parameter * called when each card is shown. * @return {(NotificationDataEntry|undefined)} Notification data entry for * this card. It's 'undefined' if the card's life is over. */ function update( chromeNotificationId, combinedCard, notificationGroups, onCardShown) { console.log('cardManager.update ' + JSON.stringify(combinedCard)); chrome.alarms.clear(alarmPrefix + chromeNotificationId); var now = Date.now(); /** @type {(UncombinedNotification|undefined)} */ var winningCard = undefined; // Next moment of time when winning notification selection algotithm can // potentially return a different notification. /** @type {?number} */ var nextEventTime = null; // Find a winning uncombined notification: a highest-priority notification // that needs to be shown now. iterateUncombinedNotifications( combinedCard, now, function(uncombinedCard, visible) { // If the uncombined notification is visible now and set the winning // card to it if its priority is higher. if (visible) { if (!winningCard || uncombinedCard.receivedNotification.chromeNotificationOptions. priority > winningCard.receivedNotification.chromeNotificationOptions. priority) { winningCard = uncombinedCard; } } // Next event time is the closest hide or show event. if (uncombinedCard.showTime && uncombinedCard.showTime > now) { if (!nextEventTime || nextEventTime > uncombinedCard.showTime) nextEventTime = uncombinedCard.showTime; } if (uncombinedCard.hideTime > now) { if (!nextEventTime || nextEventTime > uncombinedCard.hideTime) nextEventTime = uncombinedCard.hideTime; } }); // Show/hide the winning card. updateNotification( chromeNotificationId, winningCard && winningCard.receivedNotification, onCardShown); if (nextEventTime) { // If we expect more events, create an alarm for the next one. chrome.alarms.create( alarmPrefix + chromeNotificationId, {when: nextEventTime}); // The trick with stringify/parse is to create a copy of action URLs, // otherwise notifications data with 2 pointers to the same object won't // be stored correctly to chrome.storage. var winningActionUrls = winningCard && winningCard.receivedNotification.actionUrls && JSON.parse(JSON.stringify( winningCard.receivedNotification.actionUrls)); var winningCardTypeId = winningCard && winningCard.receivedNotification.cardTypeId; return { actionUrls: winningActionUrls, cardTypeId: winningCardTypeId, timestamp: now, combinedCard: combinedCard }; } else { // If there are no more events, we are done with this card. Note that all // received notifications have hideTime. verify(!winningCard, 'No events left, but card is shown.'); clearCardFromGroups(chromeNotificationId, notificationGroups); return undefined; } } /** * Removes dismissed part of a card and refreshes the card. Returns remaining * dismissals for the combined card and updated notification data. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID * of the card. * @param {NotificationDataEntry} notificationData Stored notification entry * for this card. * @param {Object} notificationGroups Map from group * name to group information. * @return {{ * dismissals: Array, * notificationData: (NotificationDataEntry|undefined) * }} */ function onDismissal( chromeNotificationId, notificationData, notificationGroups) { /** @type {Array} */ var dismissals = []; /** @type {Array} */ var newCombinedCard = []; // Determine which parts of the combined card need to be dismissed or to be // preserved. We dismiss parts that were visible at the moment when the card // was last updated. iterateUncombinedNotifications( notificationData.combinedCard, notificationData.timestamp, function(uncombinedCard, visible) { if (visible) { dismissals.push({ notificationId: uncombinedCard.receivedNotification.notificationId, parameters: uncombinedCard.receivedNotification.dismissal }); } else { newCombinedCard.push(uncombinedCard); } }); return { dismissals: dismissals, notificationData: update( chromeNotificationId, newCombinedCard, notificationGroups) }; } /** * Removes card information from |notificationGroups|. * @param {ChromeNotificationId} chromeNotificationId chrome.notifications ID * of the card. * @param {Object} notificationGroups Map from group * name to group information. */ function clearCardFromGroups(chromeNotificationId, notificationGroups) { console.log('cardManager.clearCardFromGroups ' + chromeNotificationId); for (var groupName in notificationGroups) { var group = notificationGroups[groupName]; for (var i = 0; i != group.cards.length; ++i) { if (group.cards[i].chromeNotificationId == chromeNotificationId) { group.cards.splice(i, 1); break; } } } } instrumented.alarms.onAlarm.addListener(function(alarm) { console.log('cardManager.onAlarm ' + JSON.stringify(alarm)); if (alarm.name.indexOf(alarmPrefix) == 0) { // Alarm to show the card. tasks.add(UPDATE_CARD_TASK_NAME, function() { /** @type {ChromeNotificationId} */ var chromeNotificationId = alarm.name.substring(alarmPrefix.length); fillFromChromeLocalStorage({ /** @type {Object} */ notificationsData: {}, /** @type {Object} */ notificationGroups: {} }).then(function(items) { console.log('cardManager.onAlarm.get ' + JSON.stringify(items)); var combinedCard = (items.notificationsData[chromeNotificationId] && items.notificationsData[chromeNotificationId].combinedCard) || []; var cardShownCallback = undefined; if (localStorage['explanatoryCardsShown'] < EXPLANATORY_CARDS_LINK_THRESHOLD) { cardShownCallback = countExplanatoryCard; } items.notificationsData[chromeNotificationId] = update( chromeNotificationId, combinedCard, items.notificationGroups, cardShownCallback); chrome.storage.local.set(items); }); }); } }); return { update: update, onDismissal: onDismissal }; } // Copyright (c) 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @fileoverview Utility objects and functions for Google Now extension. * Most important entities here: * (1) 'wrapper' is a module used to add error handling and other services to * callbacks for HTML and Chrome functions and Chrome event listeners. * Chrome invokes extension code through event listeners. Once entered via * an event listener, the extension may call a Chrome/HTML API method * passing a callback (and so forth), and that callback must occur later, * otherwise, we generate an error. Chrome may unload event pages waiting * for an event. When the event fires, Chrome will reload the event page. We * don't require event listeners to fire because they are generally not * predictable (like a button clicked event). * (2) Task Manager (built with buildTaskManager() call) provides controlling * mutually excluding chains of callbacks called tasks. Task Manager uses * WrapperPlugins to add instrumentation code to 'wrapper' to determine * when a task completes. */ // TODO(vadimt): Use server name in the manifest. /** * Notification server URL. */ var NOTIFICATION_CARDS_URL = 'https://www.googleapis.com/chromenow/v1'; /** * Returns true if debug mode is enabled. * localStorage returns items as strings, which means if we store a boolean, * it returns a string. Use this function to compare against true. * @return {boolean} Whether debug mode is enabled. */ function isInDebugMode() { return localStorage.debug_mode === 'true'; } /** * Initializes for debug or release modes of operation. */ function initializeDebug() { if (isInDebugMode()) { NOTIFICATION_CARDS_URL = localStorage['server_url'] || NOTIFICATION_CARDS_URL; } } initializeDebug(); /** * Conditionally allow console.log output based off of the debug mode. */ console.log = function() { var originalConsoleLog = console.log; return function() { if (isInDebugMode()) { originalConsoleLog.apply(console, arguments); } }; }(); /** * Explanation Card Storage. */ if (localStorage['explanatoryCardsShown'] === undefined) localStorage['explanatoryCardsShown'] = 0; /** * Location Card Count Cleanup. */ if (localStorage.locationCardsShown !== undefined) localStorage.removeItem('locationCardsShown'); /** * Builds an error object with a message that may be sent to the server. * @param {string} message Error message. This message may be sent to the * server. * @return {Error} Error object. */ function buildErrorWithMessageForServer(message) { var error = new Error(message); error.canSendMessageToServer = true; return error; } /** * Checks for internal errors. * @param {boolean} condition Condition that must be true. * @param {string} message Diagnostic message for the case when the condition is * false. */ function verify(condition, message) { if (!condition) throw buildErrorWithMessageForServer('ASSERT: ' + message); } /** * Builds a request to the notification server. * @param {string} method Request method. * @param {string} handlerName Server handler to send the request to. * @param {string=} opt_contentType Value for the Content-type header. * @return {XMLHttpRequest} Server request. */ function buildServerRequest(method, handlerName, opt_contentType) { var request = new XMLHttpRequest(); request.responseType = 'text'; request.open(method, NOTIFICATION_CARDS_URL + '/' + handlerName, true); if (opt_contentType) request.setRequestHeader('Content-type', opt_contentType); return request; } /** * Sends an error report to the server. * @param {Error} error Error to send. */ function sendErrorReport(error) { // Don't remove 'error.stack.replace' below! var filteredStack = error.canSendMessageToServer ? error.stack : error.stack.replace(/.*\n/, '(message removed)\n'); var file; var line; var topFrameLineMatch = filteredStack.match(/\n at .*\n/); var topFrame = topFrameLineMatch && topFrameLineMatch[0]; if (topFrame) { // Examples of a frame: // 1. '\n at someFunction (chrome-extension:// // pafkbggdmjlpgkdkcbjmhmfcdpncadgh/background.js:915:15)\n' // 2. '\n at chrome-extension://pafkbggdmjlpgkdkcbjmhmfcdpncadgh/ // utility.js:269:18\n' // 3. '\n at Function.target.(anonymous function) (extensions:: // SafeBuiltins:19:14)\n' // 4. '\n at Event.dispatchToListener (event_bindings:382:22)\n' var errorLocation; // Find the the parentheses at the end of the line, if any. var parenthesesMatch = topFrame.match(/\(.*\)\n/); if (parenthesesMatch && parenthesesMatch[0]) { errorLocation = parenthesesMatch[0].substring(1, parenthesesMatch[0].length - 2); } else { errorLocation = topFrame; } var topFrameElements = errorLocation.split(':'); // topFrameElements is an array that ends like: // [N-3] //pafkbggdmjlpgkdkcbjmhmfcdpncadgh/utility.js // [N-2] 308 // [N-1] 19 if (topFrameElements.length >= 3) { file = topFrameElements[topFrameElements.length - 3]; line = topFrameElements[topFrameElements.length - 2]; } } var errorText = error.name; if (error.canSendMessageToServer) errorText = errorText + ': ' + error.message; var errorObject = { message: errorText, file: file, line: line, trace: filteredStack }; // We use relatively direct calls here because the instrumentation may be in // a bad state. Wrappers and promises should not be involved in the reporting. var request = buildServerRequest('POST', 'jserrors', 'application/json'); request.onloadend = function(event) { console.log('sendErrorReport status: ' + request.status); }; chrome.identity.getAuthToken({interactive: false}, function(token) { if (token) { request.setRequestHeader('Authorization', 'Bearer ' + token); request.send(JSON.stringify(errorObject)); } }); } // Limiting 1 error report per background page load. var errorReported = false; /** * Reports an error to the server and the user, as appropriate. * @param {Error} error Error to report. */ function reportError(error) { var message = 'Critical error:\n' + error.stack; if (isInDebugMode()) console.error(message); if (!errorReported) { errorReported = true; chrome.metricsPrivate.getIsCrashReportingEnabled(function(isEnabled) { if (isEnabled) sendErrorReport(error); if (isInDebugMode()) alert(message); }); } } // Partial mirror of chrome.* for all instrumented functions. var instrumented = {}; /** * Wrapper plugin. These plugins extend instrumentation added by * wrapper.wrapCallback by adding code that executes before and after the call * to the original callback provided by the extension. * * @typedef {{ * prologue: function (), * epilogue: function () * }} */ var WrapperPlugin; /** * Wrapper for callbacks. Used to add error handling and other services to * callbacks for HTML and Chrome functions and events. */ var wrapper = (function() { /** * Factory for wrapper plugins. If specified, it's used to generate an * instance of WrapperPlugin each time we wrap a callback (which corresponds * to addListener call for Chrome events, and to every API call that specifies * a callback). WrapperPlugin's lifetime ends when the callback for which it * was generated, exits. It's possible to have several instances of * WrapperPlugin at the same time. * An instance of WrapperPlugin can have state that can be shared by its * constructor, prologue() and epilogue(). Also WrapperPlugins can change * state of other objects, for example, to do refcounting. * @type {?function(): WrapperPlugin} */ var wrapperPluginFactory = null; /** * Registers a wrapper plugin factory. * @param {function(): WrapperPlugin} factory Wrapper plugin factory. */ function registerWrapperPluginFactory(factory) { if (wrapperPluginFactory) { reportError(buildErrorWithMessageForServer( 'registerWrapperPluginFactory: factory is already registered.')); } wrapperPluginFactory = factory; } /** * True if currently executed code runs in a callback or event handler that * was instrumented by wrapper.wrapCallback() call. * @type {boolean} */ var isInWrappedCallback = false; /** * Required callbacks that are not yet called. Includes both task and non-task * callbacks. This is a map from unique callback id to the stack at the moment * when the callback was wrapped. This stack identifies the callback. * Used only for diagnostics. * @type {Object} */ var pendingCallbacks = {}; /** * Unique ID of the next callback. * @type {number} */ var nextCallbackId = 0; /** * Gets diagnostic string with the status of the wrapper. * @return {string} Diagnostic string. */ function debugGetStateString() { return 'pendingCallbacks @' + Date.now() + ' = ' + JSON.stringify(pendingCallbacks); } /** * Checks that we run in a wrapped callback. */ function checkInWrappedCallback() { if (!isInWrappedCallback) { reportError(buildErrorWithMessageForServer( 'Not in instrumented callback')); } } /** * Adds error processing to an API callback. * @param {Function} callback Callback to instrument. * @param {boolean=} opt_isEventListener True if the callback is a listener to * a Chrome API event. * @return {Function} Instrumented callback. */ function wrapCallback(callback, opt_isEventListener) { var callbackId = nextCallbackId++; if (!opt_isEventListener) { checkInWrappedCallback(); pendingCallbacks[callbackId] = new Error().stack + ' @' + Date.now(); } // wrapperPluginFactory may be null before task manager is built, and in // tests. var wrapperPluginInstance = wrapperPluginFactory && wrapperPluginFactory(); return function() { // This is the wrapper for the callback. try { verify(!isInWrappedCallback, 'Re-entering instrumented callback'); isInWrappedCallback = true; if (!opt_isEventListener) delete pendingCallbacks[callbackId]; if (wrapperPluginInstance) wrapperPluginInstance.prologue(); // Call the original callback. var returnValue = callback.apply(null, arguments); if (wrapperPluginInstance) wrapperPluginInstance.epilogue(); verify(isInWrappedCallback, 'Instrumented callback is not instrumented upon exit'); isInWrappedCallback = false; return returnValue; } catch (error) { reportError(error); } }; } /** * Returns an instrumented function. * @param {!Array} functionIdentifierParts Path to the chrome.* * function. * @param {string} functionName Name of the chrome API function. * @param {number} callbackParameter Index of the callback parameter to this * API function. * @return {Function} An instrumented function. */ function createInstrumentedFunction( functionIdentifierParts, functionName, callbackParameter) { return function() { // This is the wrapper for the API function. Pass the wrapped callback to // the original function. var callback = arguments[callbackParameter]; if (typeof callback != 'function') { reportError(buildErrorWithMessageForServer( 'Argument ' + callbackParameter + ' of ' + functionIdentifierParts.join('.') + '.' + functionName + ' is not a function')); } arguments[callbackParameter] = wrapCallback( callback, functionName == 'addListener'); var chromeContainer = chrome; functionIdentifierParts.forEach(function(fragment) { chromeContainer = chromeContainer[fragment]; }); return chromeContainer[functionName]. apply(chromeContainer, arguments); }; } /** * Instruments an API function to add error processing to its user * code-provided callback. * @param {string} functionIdentifier Full identifier of the function without * the 'chrome.' portion. * @param {number} callbackParameter Index of the callback parameter to this * API function. */ function instrumentChromeApiFunction(functionIdentifier, callbackParameter) { var functionIdentifierParts = functionIdentifier.split('.'); var functionName = functionIdentifierParts.pop(); var chromeContainer = chrome; var instrumentedContainer = instrumented; functionIdentifierParts.forEach(function(fragment) { chromeContainer = chromeContainer[fragment]; if (!chromeContainer) { reportError(buildErrorWithMessageForServer( 'Cannot instrument ' + functionIdentifier)); } if (!(fragment in instrumentedContainer)) instrumentedContainer[fragment] = {}; instrumentedContainer = instrumentedContainer[fragment]; }); var targetFunction = chromeContainer[functionName]; if (!targetFunction) { reportError(buildErrorWithMessageForServer( 'Cannot instrument ' + functionIdentifier)); } instrumentedContainer[functionName] = createInstrumentedFunction( functionIdentifierParts, functionName, callbackParameter); } instrumentChromeApiFunction('runtime.onSuspend.addListener', 0); instrumented.runtime.onSuspend.addListener(function() { var stringifiedPendingCallbacks = JSON.stringify(pendingCallbacks); verify( stringifiedPendingCallbacks == '{}', 'Pending callbacks when unloading event page @' + Date.now() + ':' + stringifiedPendingCallbacks); }); return { wrapCallback: wrapCallback, instrumentChromeApiFunction: instrumentChromeApiFunction, registerWrapperPluginFactory: registerWrapperPluginFactory, checkInWrappedCallback: checkInWrappedCallback, debugGetStateString: debugGetStateString }; })(); wrapper.instrumentChromeApiFunction('alarms.get', 1); wrapper.instrumentChromeApiFunction('alarms.onAlarm.addListener', 0); wrapper.instrumentChromeApiFunction('identity.getAuthToken', 1); wrapper.instrumentChromeApiFunction('identity.onSignInChanged.addListener', 0); wrapper.instrumentChromeApiFunction('identity.removeCachedAuthToken', 1); wrapper.instrumentChromeApiFunction('storage.local.get', 1); wrapper.instrumentChromeApiFunction('webstorePrivate.getBrowserLogin', 0); /** * Promise adapter for all JS promises to the task manager. */ function registerPromiseAdapter() { var originalThen = Promise.prototype.then; var originalCatch = Promise.prototype.catch; /** * Takes a promise and adds the callback tracker to it. * @param {object} promise Promise that receives the callback tracker. */ function instrumentPromise(promise) { if (promise.__tracker === undefined) { promise.__tracker = createPromiseCallbackTracker(promise); } } Promise.prototype.then = function(onResolved, onRejected) { instrumentPromise(this); return this.__tracker.handleThen(onResolved, onRejected); }; Promise.prototype.catch = function(onRejected) { instrumentPromise(this); return this.__tracker.handleCatch(onRejected); }; /** * Promise Callback Tracker. * Handles coordination of 'then' and 'catch' callbacks in a task * manager compatible way. For an individual promise, either the 'then' * arguments or the 'catch' arguments will be processed, never both. * * Example: * var p = new Promise([Function]); * p.then([ThenA]); * p.then([ThenB]); * p.catch([CatchA]); * On resolution, [ThenA] and [ThenB] will be used. [CatchA] is discarded. * On rejection, vice versa. * * Clarification: * Chained promises create a new promise that is tracked separately from * the originaing promise, as the example below demonstrates: * * var p = new Promise([Function])); * p.then([ThenA]).then([ThenB]).catch([CatchA]); * ^ ^ ^ * | | + Returns a new promise. * | + Returns a new promise. * + Returns a new promise. * * Four promises exist in the above statement, each with its own * resolution and rejection state. However, by default, this state is * chained to the previous promise's resolution or rejection * state. * * If p resolves, then the 'then' calls will execute until all the 'then' * clauses are executed. If the result of either [ThenA] or [ThenB] is a * promise, then that execution state will guide the remaining chain. * Similarly, if [CatchA] returns a promise, it can also guide the * remaining chain. In this specific case, the chain ends, so there * is nothing left to do. * @param {object} promise Promise being tracked. * @return {object} A promise callback tracker. */ function createPromiseCallbackTracker(promise) { /** * Callback Tracker. Holds an array of callbacks created for this promise. * The indirection allows quick checks against the array and clearing the * array without ugly splicing and copying. * @typedef {{ * callback: array= * }} */ var CallbackTracker; /** @type {CallbackTracker} */ var thenTracker = {callbacks: []}; /** @type {CallbackTracker} */ var catchTracker = {callbacks: []}; /** * Returns true if the specified value is callable. * @param {*} value Value to check. * @return {boolean} True if the value is a callable. */ function isCallable(value) { return typeof value === 'function'; } /** * Takes a tracker and clears its callbacks in a manner consistent with * the task manager. For the task manager, it also calls all callbacks * by no-oping them first and then calling them. * @param {CallbackTracker} tracker Tracker to clear. */ function clearTracker(tracker) { if (tracker.callbacks) { var callbacksToClear = tracker.callbacks; // No-ops all callbacks of this type. tracker.callbacks = undefined; // Do not wrap the promise then argument! // It will call wrapped callbacks. originalThen.call(Promise.resolve(), function() { for (var i = 0; i < callbacksToClear.length; i++) { callbacksToClear[i](); } }); } } /** * Takes the argument to a 'then' or 'catch' function and applies * a wrapping to callables consistent to ECMA promises. * @param {*} maybeCallback Argument to 'then' or 'catch'. * @param {CallbackTracker} sameTracker Tracker for the call type. * Example: If the argument is from a 'then' call, use thenTracker. * @param {CallbackTracker} otherTracker Tracker for the opposing call type. * Example: If the argument is from a 'then' call, use catchTracker. * @return {*} Consumable argument with necessary wrapping applied. */ function registerAndWrapMaybeCallback( maybeCallback, sameTracker, otherTracker) { // If sameTracker.callbacks is undefined, we've reached an ending state // that means this callback will never be called back. // We will still forward this call on to let the promise system // handle further processing, but since this promise is in an ending state // we can be confident it will never be called back. if (isCallable(maybeCallback) && !maybeCallback.wrappedByPromiseTracker && sameTracker.callbacks) { var handler = wrapper.wrapCallback(function() { if (sameTracker.callbacks) { clearTracker(otherTracker); return maybeCallback.apply(null, arguments); } }, false); // Harmony promises' catch calls will call into handleThen, // double-wrapping all catch callbacks. Regular promise catch calls do // not call into handleThen. Setting an attribute on the wrapped // function is compatible with both promise implementations. handler.wrappedByPromiseTracker = true; sameTracker.callbacks.push(handler); return handler; } else { return maybeCallback; } } /** * Tracks then calls equivalent to Promise.prototype.then. * @param {*} onResolved Argument to use if the promise is resolved. * @param {*} onRejected Argument to use if the promise is rejected. * @return {object} Promise resulting from the 'then' call. */ function handleThen(onResolved, onRejected) { var resolutionHandler = registerAndWrapMaybeCallback(onResolved, thenTracker, catchTracker); var rejectionHandler = registerAndWrapMaybeCallback(onRejected, catchTracker, thenTracker); return originalThen.call(promise, resolutionHandler, rejectionHandler); } /** * Tracks then calls equivalent to Promise.prototype.catch. * @param {*} onRejected Argument to use if the promise is rejected. * @return {object} Promise resulting from the 'catch' call. */ function handleCatch(onRejected) { var rejectionHandler = registerAndWrapMaybeCallback(onRejected, catchTracker, thenTracker); return originalCatch.call(promise, rejectionHandler); } // Register at least one resolve and reject callback so we always receive // a callback to update the task manager and clear the callbacks // that will never occur. // // The then form is used to avoid reentrancy by handleCatch, // which ends up calling handleThen. handleThen(function() {}, function() {}); return { handleThen: handleThen, handleCatch: handleCatch }; } } registerPromiseAdapter(); /** * Control promise rejection. * @enum {number} */ var PromiseRejection = { /** Disallow promise rejection */ DISALLOW: 0, /** Allow promise rejection */ ALLOW: 1 }; /** * Provides the promise equivalent of instrumented.storage.local.get. * @param {Object} defaultStorageObject Default storage object to fill. * @param {PromiseRejection=} opt_allowPromiseRejection If * PromiseRejection.ALLOW, allow promise rejection on errors, otherwise the * default storage object is resolved. * @return {Promise} A promise that fills the default storage object. On * failure, if promise rejection is allowed, the promise is rejected, * otherwise it is resolved to the default storage object. */ function fillFromChromeLocalStorage( defaultStorageObject, opt_allowPromiseRejection) { return new Promise(function(resolve, reject) { // We have to create a keys array because keys with a default value // of undefined will cause that key to not be looked up! var keysToGet = []; for (var key in defaultStorageObject) { keysToGet.push(key); } instrumented.storage.local.get(keysToGet, function(items) { if (items) { // Merge the result with the default storage object to ensure all keys // requested have either the default value or the retrieved storage // value. var result = {}; for (var key in defaultStorageObject) { result[key] = (key in items) ? items[key] : defaultStorageObject[key]; } resolve(result); } else if (opt_allowPromiseRejection === PromiseRejection.ALLOW) { reject(); } else { resolve(defaultStorageObject); } }); }); } /** * Builds the object to manage tasks (mutually exclusive chains of events). * @param {function(string, string): boolean} areConflicting Function that * checks if a new task can't be added to a task queue that contains an * existing task. * @return {Object} Task manager interface. */ function buildTaskManager(areConflicting) { /** * Queue of scheduled tasks. The first element, if present, corresponds to the * currently running task. * @type {Array>} */ var queue = []; /** * Count of unfinished callbacks of the current task. * @type {number} */ var taskPendingCallbackCount = 0; /** * True if currently executed code is a part of a task. * @type {boolean} */ var isInTask = false; /** * Starts the first queued task. */ function startFirst() { verify(queue.length >= 1, 'startFirst: queue is empty'); verify(!isInTask, 'startFirst: already in task'); isInTask = true; // Start the oldest queued task, but don't remove it from the queue. verify( taskPendingCallbackCount == 0, 'tasks.startFirst: still have pending task callbacks: ' + taskPendingCallbackCount + ', queue = ' + JSON.stringify(queue) + ', ' + wrapper.debugGetStateString()); var entry = queue[0]; console.log('Starting task ' + entry.name); entry.task(); verify(isInTask, 'startFirst: not in task at exit'); isInTask = false; if (taskPendingCallbackCount == 0) finish(); } /** * Checks if a new task can be added to the task queue. * @param {string} taskName Name of the new task. * @return {boolean} Whether the new task can be added. */ function canQueue(taskName) { for (var i = 0; i < queue.length; ++i) { if (areConflicting(taskName, queue[i].name)) { console.log('Conflict: new=' + taskName + ', scheduled=' + queue[i].name); return false; } } return true; } /** * Adds a new task. If another task is not running, runs the task immediately. * If any task in the queue is not compatible with the task, ignores the new * task. Otherwise, stores the task for future execution. * @param {string} taskName Name of the task. * @param {function()} task Function to run. */ function add(taskName, task) { wrapper.checkInWrappedCallback(); console.log('Adding task ' + taskName); if (!canQueue(taskName)) return; queue.push({name: taskName, task: task}); if (queue.length == 1) { startFirst(); } } /** * Completes the current task and starts the next queued task if available. */ function finish() { verify(queue.length >= 1, 'tasks.finish: The task queue is empty'); console.log('Finishing task ' + queue[0].name); queue.shift(); if (queue.length >= 1) startFirst(); } instrumented.runtime.onSuspend.addListener(function() { verify( queue.length == 0, 'Incomplete task when unloading event page,' + ' queue = ' + JSON.stringify(queue) + ', ' + wrapper.debugGetStateString()); }); /** * Wrapper plugin for tasks. * @constructor */ function TasksWrapperPlugin() { this.isTaskCallback = isInTask; if (this.isTaskCallback) ++taskPendingCallbackCount; } TasksWrapperPlugin.prototype = { /** * Plugin code to be executed before invoking the original callback. */ prologue: function() { if (this.isTaskCallback) { verify(!isInTask, 'TasksWrapperPlugin.prologue: already in task'); isInTask = true; } }, /** * Plugin code to be executed after invoking the original callback. */ epilogue: function() { if (this.isTaskCallback) { verify(isInTask, 'TasksWrapperPlugin.epilogue: not in task at exit'); isInTask = false; if (--taskPendingCallbackCount == 0) finish(); } } }; wrapper.registerWrapperPluginFactory(function() { return new TasksWrapperPlugin(); }); return { add: add }; } /** * Builds an object to manage retrying activities with exponential backoff. * @param {string} name Name of this attempt manager. * @param {function()} attempt Activity that the manager retries until it * calls 'stop' method. * @param {number} initialDelaySeconds Default first delay until first retry. * @param {number} maximumDelaySeconds Maximum delay between retries. * @return {Object} Attempt manager interface. */ function buildAttemptManager( name, attempt, initialDelaySeconds, maximumDelaySeconds) { var alarmName = 'attempt-scheduler-' + name; var currentDelayStorageKey = 'current-delay-' + name; /** * Creates an alarm for the next attempt. The alarm is repeating for the case * when the next attempt crashes before registering next alarm. * @param {number} delaySeconds Delay until next retry. */ function createAlarm(delaySeconds) { var alarmInfo = { delayInMinutes: delaySeconds / 60, periodInMinutes: maximumDelaySeconds / 60 }; chrome.alarms.create(alarmName, alarmInfo); } /** * Indicates if this attempt manager has started. * @param {function(boolean)} callback The function's boolean parameter is * true if the attempt manager has started, false otherwise. */ function isRunning(callback) { instrumented.alarms.get(alarmName, function(alarmInfo) { callback(!!alarmInfo); }); } /** * Schedules the alarm with a random factor to reduce the chance that all * clients will fire their timers at the same time. * @param {number} durationSeconds Number of seconds before firing the alarm. */ function scheduleAlarm(durationSeconds) { durationSeconds = Math.min(durationSeconds, maximumDelaySeconds); var randomizedRetryDuration = durationSeconds * (1 + 0.2 * Math.random()); createAlarm(randomizedRetryDuration); var items = {}; items[currentDelayStorageKey] = randomizedRetryDuration; chrome.storage.local.set(items); } /** * Starts repeated attempts. * @param {number=} opt_firstDelaySeconds Time until the first attempt, if * specified. Otherwise, initialDelaySeconds will be used for the first * attempt. */ function start(opt_firstDelaySeconds) { if (opt_firstDelaySeconds) { createAlarm(opt_firstDelaySeconds); chrome.storage.local.remove(currentDelayStorageKey); } else { scheduleAlarm(initialDelaySeconds); } } /** * Stops repeated attempts. */ function stop() { chrome.alarms.clear(alarmName); chrome.storage.local.remove(currentDelayStorageKey); } /** * Schedules an exponential backoff retry. * @return {Promise} A promise to schedule the retry. */ function scheduleRetry() { var request = {}; request[currentDelayStorageKey] = undefined; return fillFromChromeLocalStorage(request, PromiseRejection.ALLOW) .catch(function() { request[currentDelayStorageKey] = maximumDelaySeconds; return Promise.resolve(request); }) .then(function(items) { console.log('scheduleRetry-get-storage ' + JSON.stringify(items)); var retrySeconds = initialDelaySeconds; if (items[currentDelayStorageKey]) { retrySeconds = items[currentDelayStorageKey] * 2; } scheduleAlarm(retrySeconds); }); } instrumented.alarms.onAlarm.addListener(function(alarm) { if (alarm.name == alarmName) isRunning(function(running) { if (running) attempt(); }); }); return { start: start, scheduleRetry: scheduleRetry, stop: stop, isRunning: isRunning }; } // TODO(robliao): Use signed-in state change watch API when it's available. /** * Wraps chrome.identity to provide limited listening support for * the sign in state by polling periodically for the auth token. * @return {Object} The Authentication Manager interface. */ function buildAuthenticationManager() { var alarmName = 'sign-in-alarm'; /** * Gets an OAuth2 access token. * @return {Promise} A promise to get the authentication token. If there is * no token, the request is rejected. */ function getAuthToken() { return new Promise(function(resolve, reject) { instrumented.identity.getAuthToken({interactive: false}, function(token) { if (chrome.runtime.lastError || !token) { reject(); } else { resolve(token); } }); }); } /** * Determines the active account's login (username). * @return {Promise} A promise to determine the current account's login. */ function getLogin() { return new Promise(function(resolve) { instrumented.webstorePrivate.getBrowserLogin(function(accountInfo) { resolve(accountInfo.login); }); }); } /** * Determines whether there is an account attached to the profile. * @return {Promise} A promise to determine if there is an account attached * to the profile. */ function isSignedIn() { return getLogin().then(function(login) { return Promise.resolve(!!login); }); } /** * Removes the specified cached token. * @param {string} token Authentication Token to remove from the cache. * @return {Promise} A promise that resolves on completion. */ function removeToken(token) { return new Promise(function(resolve) { instrumented.identity.removeCachedAuthToken({token: token}, function() { // Let Chrome know about a possible problem with the token. getAuthToken(); resolve(); }); }); } var listeners = []; /** * Registers a listener that gets called back when the signed in state * is found to be changed. * @param {function()} callback Called when the answer to isSignedIn changes. */ function addListener(callback) { listeners.push(callback); } /** * Checks if the last signed in state matches the current one. * If it doesn't, it notifies the listeners of the change. */ function checkAndNotifyListeners() { isSignedIn().then(function(signedIn) { fillFromChromeLocalStorage({lastSignedInState: undefined}) .then(function(items) { if (items.lastSignedInState != signedIn) { chrome.storage.local.set( {lastSignedInState: signedIn}); listeners.forEach(function(callback) { callback(); }); } }); }); } instrumented.identity.onSignInChanged.addListener(function() { checkAndNotifyListeners(); }); instrumented.alarms.onAlarm.addListener(function(alarm) { if (alarm.name == alarmName) checkAndNotifyListeners(); }); // Poll for the sign in state every hour. // One hour is just an arbitrary amount of time chosen. chrome.alarms.create(alarmName, {periodInMinutes: 60}); return { addListener: addListener, getAuthToken: getAuthToken, getLogin: getLogin, isSignedIn: isSignedIn, removeToken: removeToken }; } PNG  IHDRaXIDATx^JAݒX-DD% i.b], ޒu/3;=s g"|ls~ִMB^ڃ 8`A i9Ȥ)XHH E Qd=qrvᥑRM(`>)cJ@-P B| $|i%Mgao9NQ%Mx+ٿxmagN oKq΀.e"*y3tV3\CI[yiW Ls9{L [hgM6(0wuj6rCF$=7I$h/%?b߁IENDB`PNG  IHDR00` 5PLTEEECFFCCDEDDEEsP\DCDDEE\PPh[hCEFFtQCEQCDEFDCsgshQ[gtPF\Q[ht$tRNSPPPP IDATx^RA ;݃ =bIa27ӎƭNsV׃ [(0Mlo^_. IIy/ ̅? /'t TP\Z*` kkfP^PIkA !{(_q 4ûf$nn6|&{V8I /yx񥶹7:mq<YxCN#X> srp΢ ^I&;@@Ӗ9Op^n唋m,X-PJ8{#?cwBy@8Z ^?[ȷq1V/n%{t$;?fbڇ5S RRMu\,}. ͜f++gX\iG=WTP9> RP$8~W(s]i)8=Z瓤ρx?.?tAC-H>WG59x3ߧGǕ5{#<wv)Ĝ7ھ:оթj1xw0:Uz'eI \(ƹ9XG TC<CZ?~Ԙ+i#&7 |t$&'nTPIENDB`PNG  IHDRPLTEEEEEEFCCCFCFCCCFCFEEEDtEDDQCDsP\Ph[EgDgPhCgsQ\hDE[tQtCPCt[FECFDED[\g\PQQFhF\s2%tRNS `  ```%IDATx^Hᶌ$mwww=NS/RE.Adɒ%K,Y7ߺG{󁻮i Cwmm[ffibZCo\[]T1jԕ07 h jo?A@m Hי\r\N&@p_0Uۊ;"-3S۳իf4`fde>& z\6vކ- <Œ=MЛ`g (Ag&Xsp Mp##`EǛ` pL>{<-'J3AYL]  O_CF~4|]*SytlAAOTyzg @p_ (pzD|4 @漷6V zI9K}a0ږ&mp%!7Od߿g6_ "q2>z6Tss~ȼ9-GB<|o9>; < #\itY~cp`[c֨~@.-"9W 0k@btwOC{.([qS >N#>M iabǞx~YC:_AUlnstܧ YQti+஍9OU~+h^7Uqo1Ƨe imaNӷN=G 7ίvp哾}]|@0{JjzZ 탛o{cet:= %]M`7 :xhUm@?i6hds^_oЯ<{0Mz[5knaJ?Ydɒ%K_ԅ%IENDB`// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Displays a webview based authorization dialog. * @param {string} key A unique identifier that the caller can use to locate * the dialog window. * @param {string} url A URL that will be loaded in the webview. * @param {string} mode 'interactive' or 'silent'. The window will be displayed * if the mode is 'interactive'. */ function showAuthDialog(key, url, mode) { var options = { frame: 'none', id: key, minWidth: 1024, minHeight: 768, hidden: true }; chrome.app.window.create('scope_approval_dialog.html', options, function(win) { win.contentWindow.addEventListener('load', function(event) { var windowParam; if (mode == 'interactive') windowParam = win; win.contentWindow.loadAuthUrlAndShowWindow(url, windowParam); }); }); } chrome.identityPrivate.onWebFlowRequest.addListener(showAuthDialog); /* Copyright (c) 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ body { margin: 0; padding: 0; } .titlebar { -webkit-app-region: drag; background-color: white; height: 26px; white-space: nowrap; width: 100%; } .titlebar-border { border-bottom: 1px solid #e5e5e5; } .titlebar-close-button { background-image: -webkit-image-set( url(chrome://theme/IDR_CLOSE_DIALOG) 1x, url(chrome://theme/IDR_CLOSE_DIALOG@2x) 2x); -webkit-app-region: no-drag; height: 14px; margin: 6px; position: absolute; right: 0; width: 14px; } .titlebar-close-button:active { background-image: -webkit-image-set( url(chrome://theme/IDR_CLOSE_DIALOG_P) 1x, url(chrome://theme/IDR_CLOSE_DIALOG_P@2x) 2x); } .titlebar-close-button:hover { background-image: -webkit-image-set( url(chrome://theme/IDR_CLOSE_DIALOG_H) 1x, url(chrome://theme/IDR_CLOSE_DIALOG_H@2x) 2x); } .content { height: auto; width: 100%; }
// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. var webview; /** * Points the webview to the starting URL of a scope authorization * flow, and unhides the dialog once the page has loaded. * @param {string} url The url of the authorization entry point. * @param {Object} win The dialog window that contains this page. Can * be left undefined if the caller does not want to display the * window. */ function loadAuthUrlAndShowWindow(url, win) { // Send popups from the webview to a normal browser window. webview.addEventListener('newwindow', function(e) { e.window.discard(); window.open(e.targetUrl); }); // Request a customized view from GAIA. webview.request.onBeforeSendHeaders.addListener(function(details) { headers = details.requestHeaders || []; headers.push({'name': 'X-Browser-View', 'value': 'embedded'}); return { requestHeaders: headers }; }, { urls: ['https://accounts.google.com/*'], }, ['blocking', 'requestHeaders']); if (url.toLowerCase().indexOf('https://accounts.google.com/') != 0) document.querySelector('.titlebar').classList.add('titlebar-border'); webview.src = url; if (win) { webview.addEventListener('loadstop', function() { win.show(); }); } } document.addEventListener('DOMContentLoaded', function() { webview = document.querySelector('webview'); document.querySelector('.titlebar-close-button').onclick = function() { window.close(); }; chrome.resourcesPrivate.getStrings('identity', function(strings) { document.title = strings['window-title']; }); }); // Copyright 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. chrome.app.runtime.onLaunched.addListener(function() { chrome.app.window.create( 'chrome://settings-frame/options_settings_app.html', {'id': 'settings_app', 'height': 550, 'width': 750}); }); /* Copyright 2014 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ body { background-color: #ccc; margin: 0; } viewer-toolbar { visibility: hidden; white-space: nowrap; z-index: 3; } viewer-page-indicator { visibility: hidden; z-index: 3; } viewer-progress-bar { visibility: hidden; z-index: 3; } viewer-error-screen-legacy { visibility: hidden; z-index: 2; } viewer-password-screen-legacy { visibility: hidden; z-index: 2; } #plugin { height: 100%; position: fixed; width: 100%; z-index: 1; } #sizer { position: absolute; z-index: 0; }
/* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ body { background-color: rgb(82, 86, 89); font-family: 'Roboto', 'Noto', sans-serif; margin: 0; } viewer-page-indicator { visibility: hidden; z-index: 2; } viewer-pdf-toolbar { position: fixed; width: 100%; z-index: 4; } #plugin { height: 100%; position: fixed; width: 100%; z-index: 1; } #sizer { position: absolute; z-index: 0; } @media(max-height: 250px) { viewer-pdf-toolbar { display: none; } } @media(max-height: 200px) { viewer-zoom-toolbar { display: none; } } @media(max-width: 300px) { viewer-zoom-toolbar { display: none; } }
// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Global PDFViewer object, accessible for testing. * @type Object */ var viewer; (function() { /** * Stores any pending messages received which should be passed to the * PDFViewer when it is created. * @type Array */ var pendingMessages = []; /** * Handles events that are received prior to the PDFViewer being created. * @param {Object} message A message event received. */ function handleScriptingMessage(message) { pendingMessages.push(message); } /** * Initialize the global PDFViewer and pass any outstanding messages to it. * @param {Object} browserApi An object providing an API to the browser. */ function initViewer(browserApi) { // PDFViewer will handle any messages after it is created. window.removeEventListener('message', handleScriptingMessage, false); viewer = new PDFViewer(browserApi); while (pendingMessages.length > 0) viewer.handleScriptingMessage(pendingMessages.shift()); } /** * Entrypoint for starting the PDF viewer. This function obtains the browser * API for the PDF and constructs a PDFViewer object with it. */ function main() { // Set up an event listener to catch scripting messages which are sent prior // to the PDFViewer being created. window.addEventListener('message', handleScriptingMessage, false); createBrowserApi().then(initViewer); }; main(); })(); // Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @return {number} Width of a scrollbar in pixels */ function getScrollbarWidth() { var div = document.createElement('div'); div.style.visibility = 'hidden'; div.style.overflow = 'scroll'; div.style.width = '50px'; div.style.height = '50px'; div.style.position = 'absolute'; document.body.appendChild(div); var result = div.offsetWidth - div.clientWidth; div.parentNode.removeChild(div); return result; } /** * Return the filename component of a URL, percent decoded if possible. * @param {string} url The URL to get the filename from. * @return {string} The filename component. */ function getFilenameFromURL(url) { // Ignore the query and fragment. var mainUrl = url.split(/#|\?/)[0]; var components = mainUrl.split(/\/|\\/); var filename = components[components.length - 1]; try { return decodeURIComponent(filename); } catch (e) { if (e instanceof URIError) return filename; throw e; } } /** * Called when navigation happens in the current tab. * @param {boolean} isInTab Indicates if the PDF viewer is displayed in a tab. * @param {boolean} isSourceFileUrl Indicates if the navigation source is a * file:// URL. * @param {string} url The url to be opened in the current tab. */ function onNavigateInCurrentTab(isInTab, isSourceFileUrl, url) { // When the PDFviewer is inside a browser tab, prefer the tabs API because // it can navigate from one file:// URL to another. if (chrome.tabs && isInTab && isSourceFileUrl) chrome.tabs.update({url: url}); else window.location.href = url; } /** * Called when navigation happens in the new tab. * @param {string} url The url to be opened in the new tab. */ function onNavigateInNewTab(url) { // Prefer the tabs API because it guarantees we can just open a new tab. // window.open doesn't have this guarantee. if (chrome.tabs) chrome.tabs.create({url: url}); else window.open(url); } /** * Whether keydown events should currently be ignored. Events are ignored when * an editable element has focus, to allow for proper editing controls. * @param {HTMLElement} activeElement The currently selected DOM node. * @return {boolean} True if keydown events should be ignored. */ function shouldIgnoreKeyEvents(activeElement) { while (activeElement.shadowRoot != null && activeElement.shadowRoot.activeElement != null) { activeElement = activeElement.shadowRoot.activeElement; } return (activeElement.isContentEditable || activeElement.tagName == 'INPUT' || activeElement.tagName == 'TEXTAREA'); } /** * The minimum number of pixels to offset the toolbar by from the bottom and * right side of the screen. */ PDFViewer.MIN_TOOLBAR_OFFSET = 15; /** * The height of the toolbar along the top of the page. The document will be * shifted down by this much in the viewport. */ PDFViewer.MATERIAL_TOOLBAR_HEIGHT = 56; /** * Minimum height for the material toolbar to show (px). Should match the media * query in index-material.css. If the window is smaller than this at load, * leave no space for the toolbar. */ PDFViewer.TOOLBAR_WINDOW_MIN_HEIGHT = 250; /** * The light-gray background color used for print preview. */ PDFViewer.LIGHT_BACKGROUND_COLOR = '0xFFCCCCCC'; /** * The dark-gray background color used for the regular viewer. */ PDFViewer.DARK_BACKGROUND_COLOR = '0xFF525659'; /** * Creates a new PDFViewer. There should only be one of these objects per * document. * @constructor * @param {!BrowserApi} browserApi An object providing an API to the browser. */ function PDFViewer(browserApi) { this.browserApi_ = browserApi; this.loadState_ = LoadState.LOADING; this.parentWindow_ = null; this.parentOrigin_ = null; this.isFormFieldFocused_ = false; this.delayedScriptingMessages_ = []; this.isPrintPreview_ = this.browserApi_.getStreamInfo().originalUrl.indexOf( 'chrome://print') == 0; this.isMaterial_ = location.pathname.substring(1) === 'index-material.html'; // The sizer element is placed behind the plugin element to cause scrollbars // to be displayed in the window. It is sized according to the document size // of the pdf and zoom level. this.sizer_ = $('sizer'); this.toolbar_ = $('toolbar'); if (!this.isMaterial_ || this.isPrintPreview_) this.pageIndicator_ = $('page-indicator'); this.progressBar_ = $('progress-bar'); this.passwordScreen_ = $('password-screen'); this.passwordScreen_.addEventListener('password-submitted', this.onPasswordSubmitted_.bind(this)); this.errorScreen_ = $('error-screen'); // Can only reload if we are in a normal tab. if (chrome.tabs && this.browserApi_.getStreamInfo().tabId != -1) { this.errorScreen_.reloadFn = function() { chrome.tabs.reload(this.browserApi_.getStreamInfo().tabId); }.bind(this); } // Create the viewport. var shortWindow = window.innerHeight < PDFViewer.TOOLBAR_WINDOW_MIN_HEIGHT; var topToolbarHeight = (this.isMaterial_ && !this.isPrintPreview_ && !shortWindow) ? PDFViewer.MATERIAL_TOOLBAR_HEIGHT : 0; this.viewport_ = new Viewport(window, this.sizer_, this.viewportChanged_.bind(this), this.beforeZoom_.bind(this), this.afterZoom_.bind(this), getScrollbarWidth(), this.browserApi_.getDefaultZoom(), topToolbarHeight); // Create the plugin object dynamically so we can set its src. The plugin // element is sized to fill the entire window and is set to be fixed // positioning, acting as a viewport. The plugin renders into this viewport // according to the scroll position of the window. this.plugin_ = document.createElement('embed'); // NOTE: The plugin's 'id' field must be set to 'plugin' since // chrome/renderer/printing/print_web_view_helper.cc actually references it. this.plugin_.id = 'plugin'; this.plugin_.type = 'application/x-google-chrome-pdf'; this.plugin_.addEventListener('message', this.handlePluginMessage_.bind(this), false); // Handle scripting messages from outside the extension that wish to interact // with it. We also send a message indicating that extension has loaded and // is ready to receive messages. window.addEventListener('message', this.handleScriptingMessage.bind(this), false); this.plugin_.setAttribute('src', this.browserApi_.getStreamInfo().originalUrl); this.plugin_.setAttribute('stream-url', this.browserApi_.getStreamInfo().streamUrl); var headers = ''; for (var header in this.browserApi_.getStreamInfo().responseHeaders) { headers += header + ': ' + this.browserApi_.getStreamInfo().responseHeaders[header] + '\n'; } this.plugin_.setAttribute('headers', headers); var backgroundColor = PDFViewer.DARK_BACKGROUND_COLOR; if (!this.isMaterial_) backgroundColor = PDFViewer.LIGHT_BACKGROUND_COLOR; this.plugin_.setAttribute('background-color', backgroundColor); this.plugin_.setAttribute('top-toolbar-height', topToolbarHeight); if (!this.browserApi_.getStreamInfo().embedded) this.plugin_.setAttribute('full-frame', ''); document.body.appendChild(this.plugin_); // Setup the button event listeners. if (!this.isMaterial_) { $('fit-to-width-button').addEventListener('click', this.viewport_.fitToWidth.bind(this.viewport_)); $('fit-to-page-button').addEventListener('click', this.viewport_.fitToPage.bind(this.viewport_)); $('zoom-in-button').addEventListener('click', this.viewport_.zoomIn.bind(this.viewport_)); $('zoom-out-button').addEventListener('click', this.viewport_.zoomOut.bind(this.viewport_)); $('save-button').addEventListener('click', this.save_.bind(this)); $('print-button').addEventListener('click', this.print_.bind(this)); } if (this.isMaterial_) { this.zoomToolbar_ = $('zoom-toolbar'); this.zoomToolbar_.addEventListener('fit-to-width', this.viewport_.fitToWidth.bind(this.viewport_)); this.zoomToolbar_.addEventListener('fit-to-page', this.fitToPage_.bind(this)); this.zoomToolbar_.addEventListener('zoom-in', this.viewport_.zoomIn.bind(this.viewport_)); this.zoomToolbar_.addEventListener('zoom-out', this.viewport_.zoomOut.bind(this.viewport_)); if (!this.isPrintPreview_) { this.materialToolbar_ = $('material-toolbar'); this.materialToolbar_.hidden = false; this.materialToolbar_.addEventListener('save', this.save_.bind(this)); this.materialToolbar_.addEventListener('print', this.print_.bind(this)); this.materialToolbar_.addEventListener('rotate-right', this.rotateClockwise_.bind(this)); this.materialToolbar_.addEventListener('rotate-left', this.rotateCounterClockwise_.bind(this)); // Must attach to mouseup on the plugin element, since it eats mousedown // and click events. this.plugin_.addEventListener('mouseup', this.materialToolbar_.hideDropdowns.bind(this.materialToolbar_)); this.materialToolbar_.docTitle = getFilenameFromURL(this.browserApi_.getStreamInfo().originalUrl); } document.body.addEventListener('change-page', function(e) { this.viewport_.goToPage(e.detail.page); }.bind(this)); this.toolbarManager_ = new ToolbarManager(window, this.materialToolbar_, this.zoomToolbar_); } // Set up the ZoomManager. this.zoomManager_ = new ZoomManager( this.viewport_, this.browserApi_.setZoom.bind(this.browserApi_), this.browserApi_.getInitialZoom()); this.browserApi_.addZoomEventListener( this.zoomManager_.onBrowserZoomChange.bind(this.zoomManager_)); // Setup the keyboard event listener. document.addEventListener('keydown', this.handleKeyEvent_.bind(this)); document.addEventListener('mousemove', this.handleMouseEvent_.bind(this)); document.addEventListener('mouseout', this.handleMouseEvent_.bind(this)); // Parse open pdf parameters. this.paramsParser_ = new OpenPDFParamsParser(this.getNamedDestination_.bind(this)); var isInTab = this.browserApi_.getStreamInfo().tabId != -1; var isSourceFileUrl = this.browserApi_.getStreamInfo().originalUrl.indexOf('file://') == 0; this.navigator_ = new Navigator(this.browserApi_.getStreamInfo().originalUrl, this.viewport_, this.paramsParser_, onNavigateInCurrentTab.bind(undefined, isInTab, isSourceFileUrl), onNavigateInNewTab); this.viewportScroller_ = new ViewportScroller(this.viewport_, this.plugin_, window); // Request translated strings. if (!this.isPrintPreview_) chrome.resourcesPrivate.getStrings('pdf', this.handleStrings_.bind(this)); } PDFViewer.prototype = { /** * @private * Handle key events. These may come from the user directly or via the * scripting API. * @param {KeyboardEvent} e the event to handle. */ handleKeyEvent_: function(e) { var position = this.viewport_.position; // Certain scroll events may be sent from outside of the extension. var fromScriptingAPI = e.fromScriptingAPI; if (shouldIgnoreKeyEvents(document.activeElement) || e.defaultPrevented) return; if (this.isMaterial_) this.toolbarManager_.hideToolbarsAfterTimeout(e); var pageUpHandler = function() { // Go to the previous page if we are fit-to-page. if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.y -= this.viewport.size.height; this.viewport.position = position; } }.bind(this); var pageDownHandler = function() { // Go to the next page if we are fit-to-page. if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.y += this.viewport.size.height; this.viewport.position = position; } }.bind(this); switch (e.keyCode) { case 9: // Tab key. this.toolbarManager_.showToolbarsForKeyboardNavigation(); return; case 27: // Escape key. if (this.isMaterial_ && !this.isPrintPreview_) { this.toolbarManager_.hideSingleToolbarLayer(); return; } break; // Ensure escape falls through to the print-preview handler. case 32: // Space key. if (e.shiftKey) pageUpHandler(); else pageDownHandler(); return; case 33: // Page up key. pageUpHandler(); return; case 34: // Page down key. pageDownHandler(); return; case 37: // Left arrow key. if (!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) { // Go to the previous page if there are no horizontal scrollbars and // no form field is focused. if (!(this.viewport_.documentHasScrollbars().horizontal || this.isFormFieldFocused_)) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.x -= Viewport.SCROLL_INCREMENT; this.viewport.position = position; } } return; case 38: // Up arrow key. if (fromScriptingAPI) { position.y -= Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 39: // Right arrow key. if (!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) { // Go to the next page if there are no horizontal scrollbars and no // form field is focused. if (!(this.viewport_.documentHasScrollbars().horizontal || this.isFormFieldFocused_)) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.x += Viewport.SCROLL_INCREMENT; this.viewport.position = position; } } return; case 40: // Down arrow key. if (fromScriptingAPI) { position.y += Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 65: // a key. if (e.ctrlKey || e.metaKey) { this.plugin_.postMessage({ type: 'selectAll' }); // Since we do selection ourselves. e.preventDefault(); } return; case 71: // g key. if (this.isMaterial_ && this.materialToolbar_ && (e.ctrlKey || e.metaKey)) { this.toolbarManager_.showToolbars(); this.materialToolbar_.selectPageNumber(); // To prevent the default "find text" behaviour in Chrome. e.preventDefault(); } return; case 219: // left bracket. if (e.ctrlKey) this.rotateCounterClockwise_(); return; case 221: // right bracket. if (e.ctrlKey) this.rotateClockwise_(); return; } // Give print preview a chance to handle the key event. if (!fromScriptingAPI && this.isPrintPreview_) { this.sendScriptingMessage_({ type: 'sendKeyEvent', keyEvent: SerializeKeyEvent(e) }); } else if (this.isMaterial_) { // Show toolbars as a fallback. if (!(e.shiftKey || e.ctrlKey || e.altKey)) this.toolbarManager_.showToolbars(); } }, handleMouseEvent_: function(e) { if (this.isMaterial_) { if (e.type == 'mousemove') this.toolbarManager_.handleMouseMove(e); else if (e.type == 'mouseout') this.toolbarManager_.hideToolbarsForMouseOut(); } }, /** * @private * Rotate the plugin clockwise. */ rotateClockwise_: function() { this.plugin_.postMessage({ type: 'rotateClockwise' }); }, /** * @private * Rotate the plugin counter-clockwise. */ rotateCounterClockwise_: function() { this.plugin_.postMessage({ type: 'rotateCounterclockwise' }); }, fitToPage_: function() { this.viewport_.fitToPage(); this.toolbarManager_.forceHideTopToolbar(); }, /** * @private * Notify the plugin to print. */ print_: function() { this.plugin_.postMessage({ type: 'print' }); }, /** * @private * Notify the plugin to save. */ save_: function() { this.plugin_.postMessage({ type: 'save' }); }, /** * Fetches the page number corresponding to the given named destination from * the plugin. * @param {string} name The namedDestination to fetch page number from plugin. */ getNamedDestination_: function(name) { this.plugin_.postMessage({ type: 'getNamedDestination', namedDestination: name }); }, /** * @private * Sends a 'documentLoaded' message to the PDFScriptingAPI if the document has * finished loading. */ sendDocumentLoadedMessage_: function() { if (this.loadState_ == LoadState.LOADING) return; this.sendScriptingMessage_({ type: 'documentLoaded', load_state: this.loadState_ }); }, /** * @private * Handle open pdf parameters. This function updates the viewport as per * the parameters mentioned in the url while opening pdf. The order is * important as later actions can override the effects of previous actions. * @param {Object} viewportPosition The initial position of the viewport to be * displayed. */ handleURLParams_: function(viewportPosition) { if (viewportPosition.page != undefined) this.viewport_.goToPage(viewportPosition.page); if (viewportPosition.position) { // Make sure we don't cancel effect of page parameter. this.viewport_.position = { x: this.viewport_.position.x + viewportPosition.position.x, y: this.viewport_.position.y + viewportPosition.position.y }; } if (viewportPosition.zoom) this.viewport_.setZoom(viewportPosition.zoom); }, /** * @private * Update the loading progress of the document in response to a progress * message being received from the plugin. * @param {number} progress the progress as a percentage. */ updateProgress_: function(progress) { if (this.isMaterial_) { if (this.materialToolbar_) this.materialToolbar_.loadProgress = progress; } else { this.progressBar_.progress = progress; } if (progress == -1) { // Document load failed. this.errorScreen_.show(); this.sizer_.style.display = 'none'; if (!this.isMaterial_) this.toolbar_.style.visibility = 'hidden'; if (this.passwordScreen_.active) { this.passwordScreen_.deny(); this.passwordScreen_.active = false; } this.loadState_ = LoadState.FAILED; this.sendDocumentLoadedMessage_(); } else if (progress == 100) { // Document load complete. if (this.lastViewportPosition_) this.viewport_.position = this.lastViewportPosition_; this.paramsParser_.getViewportFromUrlParams( this.browserApi_.getStreamInfo().originalUrl, this.handleURLParams_.bind(this)); this.loadState_ = LoadState.SUCCESS; this.sendDocumentLoadedMessage_(); while (this.delayedScriptingMessages_.length > 0) this.handleScriptingMessage(this.delayedScriptingMessages_.shift()); if (this.isMaterial_) this.toolbarManager_.hideToolbarsAfterTimeout(); } }, /** * @private * Load a dictionary of translated strings into the UI. Used as a callback for * chrome.resourcesPrivate. * @param {Object} strings Dictionary of translated strings */ handleStrings_: function(strings) { if (this.isMaterial_) { this.errorScreen_.strings = strings; this.passwordScreen_.strings = strings; if (this.materialToolbar_) this.materialToolbar_.strings = strings; this.zoomToolbar_.strings = strings; document.documentElement.lang = strings['language']; document.dir = strings['textdirection']; } else { this.passwordScreen_.text = strings.passwordPrompt; this.progressBar_.text = strings.pageLoading; if (!this.isPrintPreview_) this.progressBar_.style.visibility = 'visible'; this.errorScreen_.text = strings.pageLoadFailed; } }, /** * @private * An event handler for handling password-submitted events. These are fired * when an event is entered into the password screen. * @param {Object} event a password-submitted event. */ onPasswordSubmitted_: function(event) { this.plugin_.postMessage({ type: 'getPasswordComplete', password: event.detail.password }); }, /** * @private * An event handler for handling message events received from the plugin. * @param {MessageObject} message a message event. */ handlePluginMessage_: function(message) { switch (message.data.type.toString()) { case 'documentDimensions': this.documentDimensions_ = message.data; this.viewport_.setDocumentDimensions(this.documentDimensions_); // If we received the document dimensions, the password was good so we // can dismiss the password screen. if (this.passwordScreen_.active) this.passwordScreen_.accept(); if (this.pageIndicator_) this.pageIndicator_.initialFadeIn(); if (this.isMaterial_) { if (this.materialToolbar_) { this.materialToolbar_.docLength = this.documentDimensions_.pageDimensions.length; } } else { this.toolbar_.initialFadeIn(); } break; case 'email': var href = 'mailto:' + message.data.to + '?cc=' + message.data.cc + '&bcc=' + message.data.bcc + '&subject=' + message.data.subject + '&body=' + message.data.body; window.location.href = href; break; case 'getAccessibilityJSONReply': this.sendScriptingMessage_(message.data); break; case 'getPassword': // If the password screen isn't up, put it up. Otherwise we're // responding to an incorrect password so deny it. if (!this.passwordScreen_.active) this.passwordScreen_.active = true; else this.passwordScreen_.deny(); break; case 'getSelectedTextReply': this.sendScriptingMessage_(message.data); break; case 'goToPage': this.viewport_.goToPage(message.data.page); break; case 'loadProgress': this.updateProgress_(message.data.progress); break; case 'navigate': // If in print preview, always open a new tab. if (this.isPrintPreview_) this.navigator_.navigate(message.data.url, true); else this.navigator_.navigate(message.data.url, message.data.newTab); break; case 'setScrollPosition': var position = this.viewport_.position; if (message.data.x !== undefined) position.x = message.data.x; if (message.data.y !== undefined) position.y = message.data.y; this.viewport_.position = position; break; case 'cancelStreamUrl': chrome.mimeHandlerPrivate.abortStream(); break; case 'metadata': if (message.data.title) { document.title = message.data.title; } else { document.title = getFilenameFromURL(this.browserApi_.getStreamInfo().originalUrl); } this.bookmarks_ = message.data.bookmarks; if (this.isMaterial_ && this.materialToolbar_) { this.materialToolbar_.docTitle = document.title; this.materialToolbar_.bookmarks = this.bookmarks; } break; case 'setIsSelecting': this.viewportScroller_.setEnableScrolling(message.data.isSelecting); break; case 'getNamedDestinationReply': this.paramsParser_.onNamedDestinationReceived( message.data.pageNumber); break; case 'formFocusChange': this.isFormFieldFocused_ = message.data.focused; break; } }, /** * @private * A callback that's called before the zoom changes. Notify the plugin to stop * reacting to scroll events while zoom is taking place to avoid flickering. */ beforeZoom_: function() { this.plugin_.postMessage({ type: 'stopScrolling' }); }, /** * @private * A callback that's called after the zoom changes. Notify the plugin of the * zoom change and to continue reacting to scroll events. */ afterZoom_: function() { var position = this.viewport_.position; var zoom = this.viewport_.zoom; this.plugin_.postMessage({ type: 'viewport', zoom: zoom, xOffset: position.x, yOffset: position.y }); this.zoomManager_.onPdfZoomChange(); }, /** * @private * A callback that's called after the viewport changes. */ viewportChanged_: function() { if (!this.documentDimensions_) return; // Update the buttons selected. if (!this.isMaterial_) { $('fit-to-page-button').classList.remove('polymer-selected'); $('fit-to-width-button').classList.remove('polymer-selected'); if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { $('fit-to-page-button').classList.add('polymer-selected'); } else if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_WIDTH) { $('fit-to-width-button').classList.add('polymer-selected'); } } // Offset the toolbar position so that it doesn't move if scrollbars appear. var hasScrollbars = this.viewport_.documentHasScrollbars(); var scrollbarWidth = this.viewport_.scrollbarWidth; var verticalScrollbarWidth = hasScrollbars.vertical ? scrollbarWidth : 0; var horizontalScrollbarWidth = hasScrollbars.horizontal ? scrollbarWidth : 0; if (this.isMaterial_) { // Shift the zoom toolbar to the left by half a scrollbar width. This // gives a compromise: if there is no scrollbar visible then the toolbar // will be half a scrollbar width further left than the spec but if there // is a scrollbar visible it will be half a scrollbar width further right // than the spec. In RTL layout, the zoom toolbar is on the left side, but // the scrollbar is still on the left, so this is not necessary. if (document.dir == 'ltr') { this.zoomToolbar_.style.right = -verticalScrollbarWidth + (scrollbarWidth / 2) + 'px'; } // Having a horizontal scrollbar is much rarer so we don't offset the // toolbar from the bottom any more than what the spec says. This means // that when there is a scrollbar visible, it will be a full scrollbar // width closer to the bottom of the screen than usual, but this is ok. this.zoomToolbar_.style.bottom = -horizontalScrollbarWidth + 'px'; } else { var toolbarRight = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth); var toolbarBottom = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth); toolbarRight -= verticalScrollbarWidth; toolbarBottom -= horizontalScrollbarWidth; this.toolbar_.style.right = toolbarRight + 'px'; this.toolbar_.style.bottom = toolbarBottom + 'px'; // Hide the toolbar if it doesn't fit in the viewport. if (this.toolbar_.offsetLeft < 0 || this.toolbar_.offsetTop < 0) this.toolbar_.style.visibility = 'hidden'; else this.toolbar_.style.visibility = 'visible'; } // Update the page indicator. var visiblePage = this.viewport_.getMostVisiblePage(); if (this.materialToolbar_) this.materialToolbar_.pageNo = visiblePage + 1; // TODO(raymes): Give pageIndicator_ the same API as materialToolbar_. if (this.pageIndicator_) { this.pageIndicator_.index = visiblePage; if (this.documentDimensions_.pageDimensions.length > 1 && hasScrollbars.vertical) { this.pageIndicator_.style.visibility = 'visible'; } else { this.pageIndicator_.style.visibility = 'hidden'; } } var visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage); var size = this.viewport_.size; this.sendScriptingMessage_({ type: 'viewport', pageX: visiblePageDimensions.x, pageY: visiblePageDimensions.y, pageWidth: visiblePageDimensions.width, viewportWidth: size.width, viewportHeight: size.height }); }, /** * Handle a scripting message from outside the extension (typically sent by * PDFScriptingAPI in a page containing the extension) to interact with the * plugin. * @param {MessageObject} message the message to handle. */ handleScriptingMessage: function(message) { if (this.parentWindow_ != message.source) { this.parentWindow_ = message.source; this.parentOrigin_ = message.origin; // Ensure that we notify the embedder if the document is loaded. if (this.loadState_ != LoadState.LOADING) this.sendDocumentLoadedMessage_(); } if (this.handlePrintPreviewScriptingMessage_(message)) return; // Delay scripting messages from users of the scripting API until the // document is loaded. This simplifies use of the APIs. if (this.loadState_ != LoadState.SUCCESS) { this.delayedScriptingMessages_.push(message); return; } switch (message.data.type.toString()) { case 'getAccessibilityJSON': case 'getSelectedText': case 'print': case 'selectAll': this.plugin_.postMessage(message.data); break; } }, /** * @private * Handle scripting messages specific to print preview. * @param {MessageObject} message the message to handle. * @return {boolean} true if the message was handled, false otherwise. */ handlePrintPreviewScriptingMessage_: function(message) { if (!this.isPrintPreview_) return false; switch (message.data.type.toString()) { case 'loadPreviewPage': this.plugin_.postMessage(message.data); return true; case 'resetPrintPreviewMode': this.loadState_ = LoadState.LOADING; if (!this.inPrintPreviewMode_) { this.inPrintPreviewMode_ = true; this.viewport_.fitToPage(); } // Stash the scroll location so that it can be restored when the new // document is loaded. this.lastViewportPosition_ = this.viewport_.position; // TODO(raymes): Disable these properly in the plugin. var printButton = $('print-button'); if (printButton) printButton.parentNode.removeChild(printButton); var saveButton = $('save-button'); if (saveButton) saveButton.parentNode.removeChild(saveButton); this.pageIndicator_.pageLabels = message.data.pageNumbers; this.plugin_.postMessage({ type: 'resetPrintPreviewMode', url: message.data.url, grayscale: message.data.grayscale, // If the PDF isn't modifiable we send 0 as the page count so that no // blank placeholder pages get appended to the PDF. pageCount: (message.data.modifiable ? message.data.pageNumbers.length : 0) }); return true; case 'sendKeyEvent': this.handleKeyEvent_(DeserializeKeyEvent(message.data.keyEvent)); return true; } return false; }, /** * @private * Send a scripting message outside the extension (typically to * PDFScriptingAPI in a page containing the extension). * @param {Object} message the message to send. */ sendScriptingMessage_: function(message) { if (this.parentWindow_ && this.parentOrigin_) { var targetOrigin; // Only send data back to the embedder if it is from the same origin, // unless we're sending it to ourselves (which could happen in the case // of tests). We also allow documentLoaded messages through as this won't // leak important information. if (this.parentOrigin_ == window.location.origin) targetOrigin = this.parentOrigin_; else if (message.type == 'documentLoaded') targetOrigin = '*'; else targetOrigin = this.browserApi_.getStreamInfo().originalUrl; this.parentWindow_.postMessage(message, targetOrigin); } }, /** * @type {Viewport} the viewport of the PDF viewer. */ get viewport() { return this.viewport_; }, /** * Each bookmark is an Object containing a: * - title * - page (optional) * - array of children (themselves bookmarks) * @type {Array} the top-level bookmarks of the PDF. */ get bookmarks() { return this.bookmarks_; } }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** Idle time in ms before the UI is hidden. */ var HIDE_TIMEOUT = 2000; /** Time in ms after force hide before toolbar is shown again. */ var FORCE_HIDE_TIMEOUT = 1000; /** Velocity required in a mousemove to reveal the UI (pixels/sample). */ var SHOW_VELOCITY = 25; /** Distance from the top of the screen required to reveal the toolbars. */ var TOP_TOOLBAR_REVEAL_DISTANCE = 100; /** Distance from the bottom-right of the screen required to reveal toolbars. */ var SIDE_TOOLBAR_REVEAL_DISTANCE_RIGHT = 150; var SIDE_TOOLBAR_REVEAL_DISTANCE_BOTTOM = 250; /** * Whether a mousemove event is high enough velocity to reveal the toolbars. * @param {MouseEvent} e Event to test. * @return {boolean} true if the event is a high velocity mousemove, false * otherwise. */ function isHighVelocityMouseMove(e) { return e.type == 'mousemove' && e.movementX * e.movementX + e.movementY * e.movementY > SHOW_VELOCITY * SHOW_VELOCITY; } /** * @param {MouseEvent} e Event to test. * @return {boolean} True if the mouse is close to the top of the screen. */ function isMouseNearTopToolbar(e) { return e.y < TOP_TOOLBAR_REVEAL_DISTANCE; } /** * @param {MouseEvent} e Event to test. * @return {boolean} True if the mouse is close to the bottom-right of the * screen. */ function isMouseNearSideToolbar(e) { var atSide = e.x > window.innerWidth - SIDE_TOOLBAR_REVEAL_DISTANCE_RIGHT; if (document.dir == 'rtl') atSide = e.x < SIDE_TOOLBAR_REVEAL_DISTANCE_RIGHT; var atBottom = e.y > window.innerHeight - SIDE_TOOLBAR_REVEAL_DISTANCE_BOTTOM; return atSide && atBottom; } /** * Constructs a Toolbar Manager, responsible for co-ordinating between multiple * toolbar elements. * @constructor * @param {Object} window The window containing the UI. * @param {Object} toolbar The top toolbar element. * @param {Object} zoomToolbar The zoom toolbar element. */ function ToolbarManager(window, toolbar, zoomToolbar) { this.window_ = window; this.toolbar_ = toolbar; this.zoomToolbar_ = zoomToolbar; this.toolbarTimeout_ = null; this.isMouseNearTopToolbar_ = false; this.isMouseNearSideToolbar_ = false; this.sideToolbarAllowedOnly_ = false; this.sideToolbarAllowedOnlyTimer_ = null; this.keyboardNavigationActive = false; this.window_.addEventListener('resize', this.resizeDropdowns_.bind(this)); this.resizeDropdowns_(); } ToolbarManager.prototype = { handleMouseMove: function(e) { this.isMouseNearTopToolbar_ = this.toolbar_ && isMouseNearTopToolbar(e); this.isMouseNearSideToolbar_ = isMouseNearSideToolbar(e); this.keyboardNavigationActive = false; var touchInteractionActive = (e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents); // Allow the top toolbar to be shown if the mouse moves away from the side // toolbar (as long as the timeout has elapsed). if (!this.isMouseNearSideToolbar_ && !this.sideToolbarAllowedOnlyTimer_) this.sideToolbarAllowedOnly_ = false; // Allow the top toolbar to be shown if the mouse moves to the top edge. if (this.isMouseNearTopToolbar_) this.sideToolbarAllowedOnly_ = false; // Tapping the screen with toolbars open tries to close them. if (touchInteractionActive && this.zoomToolbar_.isVisible()) { this.hideToolbarsIfAllowed(); return; } // Show the toolbars if the mouse is near the top or bottom-right of the // screen, if the mouse moved fast, or if the touchscreen was tapped. if (this.isMouseNearTopToolbar_ || this.isMouseNearSideToolbar_ || isHighVelocityMouseMove(e) || touchInteractionActive) { if (this.sideToolbarAllowedOnly_) this.zoomToolbar_.show(); else this.showToolbars(); } this.hideToolbarsAfterTimeout(); }, /** * Display both UI toolbars. */ showToolbars: function() { if (this.toolbar_) this.toolbar_.show(); this.zoomToolbar_.show(); }, /** * Show toolbars and mark that navigation is being performed with * tab/shift-tab. This disables toolbar hiding until the mouse is moved or * escape is pressed. */ showToolbarsForKeyboardNavigation: function() { this.keyboardNavigationActive = true; this.showToolbars(); }, /** * Hide toolbars after a delay, regardless of the position of the mouse. * Intended to be called when the mouse has moved out of the parent window. */ hideToolbarsForMouseOut: function() { this.isMouseNearTopToolbar_ = false; this.isMouseNearSideToolbar_ = false; this.hideToolbarsAfterTimeout(); }, /** * Check if the toolbars are able to be closed, and close them if they are. * Toolbars may be kept open based on mouse/keyboard activity and active * elements. */ hideToolbarsIfAllowed: function() { if (this.isMouseNearSideToolbar_ || this.isMouseNearTopToolbar_) return; if (this.toolbar_ && this.toolbar_.shouldKeepOpen()) return; if (this.keyboardNavigationActive) return; // Remove focus to make any visible tooltips disappear -- otherwise they'll // still be visible on screen when the toolbar is off screen. if ((this.toolbar_ && document.activeElement == this.toolbar_) || document.activeElement == this.zoomToolbar_) { document.activeElement.blur(); } if (this.toolbar_) this.toolbar_.hide(); this.zoomToolbar_.hide(); }, /** * Hide the toolbar after the HIDE_TIMEOUT has elapsed. */ hideToolbarsAfterTimeout: function() { if (this.toolbarTimeout_) this.window_.clearTimeout(this.toolbarTimeout_); this.toolbarTimeout_ = this.window_.setTimeout( this.hideToolbarsIfAllowed.bind(this), HIDE_TIMEOUT); }, /** * Hide the 'topmost' layer of toolbars. Hides any dropdowns that are open, or * hides the basic toolbars otherwise. */ hideSingleToolbarLayer: function() { if (!this.toolbar_ || !this.toolbar_.hideDropdowns()) { this.keyboardNavigationActive = false; this.hideToolbarsIfAllowed(); } }, /** * Hide the top toolbar and keep it hidden until both: * - The mouse is moved away from the right side of the screen * - 1 second has passed. * * The top toolbar can be immediately re-opened by moving the mouse to the top * of the screen. */ forceHideTopToolbar: function() { if (!this.toolbar_) return; this.toolbar_.hide(); this.sideToolbarAllowedOnly_ = true; this.sideToolbarAllowedOnlyTimer_ = this.window_.setTimeout(function() { this.sideToolbarAllowedOnlyTimer_ = null; }.bind(this), FORCE_HIDE_TIMEOUT); }, /** * Updates the size of toolbar dropdowns based on the positions of the rest of * the UI. * @private */ resizeDropdowns_: function() { if (!this.toolbar_) return; var lowerBound = this.window_.innerHeight - this.zoomToolbar_.clientHeight; this.toolbar_.setDropdownLowerBound(lowerBound); } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Returns the height of the intersection of two rectangles. * @param {Object} rect1 the first rect * @param {Object} rect2 the second rect * @return {number} the height of the intersection of the rects */ function getIntersectionHeight(rect1, rect2) { return Math.max(0, Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y)); } /** * Create a new viewport. * @constructor * @param {Window} window the window * @param {Object} sizer is the element which represents the size of the * document in the viewport * @param {Function} viewportChangedCallback is run when the viewport changes * @param {Function} beforeZoomCallback is run before a change in zoom * @param {Function} afterZoomCallback is run after a change in zoom * @param {number} scrollbarWidth the width of scrollbars on the page * @param {number} defaultZoom The default zoom level. * @param {number} topToolbarHeight The number of pixels that should initially * be left blank above the document for the toolbar. */ function Viewport(window, sizer, viewportChangedCallback, beforeZoomCallback, afterZoomCallback, scrollbarWidth, defaultZoom, topToolbarHeight) { this.window_ = window; this.sizer_ = sizer; this.viewportChangedCallback_ = viewportChangedCallback; this.beforeZoomCallback_ = beforeZoomCallback; this.afterZoomCallback_ = afterZoomCallback; this.allowedToChangeZoom_ = false; this.zoom_ = 1; this.documentDimensions_ = null; this.pageDimensions_ = []; this.scrollbarWidth_ = scrollbarWidth; this.fittingType_ = Viewport.FittingType.NONE; this.defaultZoom_ = defaultZoom; this.topToolbarHeight_ = topToolbarHeight; window.addEventListener('scroll', this.updateViewport_.bind(this)); window.addEventListener('resize', this.resize_.bind(this)); } /** * Enumeration of page fitting types. * @enum {string} */ Viewport.FittingType = { NONE: 'none', FIT_TO_PAGE: 'fit-to-page', FIT_TO_WIDTH: 'fit-to-width' }; /** * The increment to scroll a page by in pixels when up/down/left/right arrow * keys are pressed. Usually we just let the browser handle scrolling on the * window when these keys are pressed but in certain cases we need to simulate * these events. */ Viewport.SCROLL_INCREMENT = 40; /** * Predefined zoom factors to be used when zooming in/out. These are in * ascending order. This should match the list in * components/ui/zoom/page_zoom_constants.h */ Viewport.ZOOM_FACTORS = [0.25, 0.333, 0.5, 0.666, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; /** * The minimum and maximum range to be used to clip zoom factor. */ Viewport.ZOOM_FACTOR_RANGE = { min: Viewport.ZOOM_FACTORS[0], max: Viewport.ZOOM_FACTORS[Viewport.ZOOM_FACTORS.length - 1] }; /** * The width of the page shadow around pages in pixels. */ Viewport.PAGE_SHADOW = {top: 3, bottom: 7, left: 5, right: 5}; Viewport.prototype = { /** * Returns the zoomed and rounded document dimensions for the given zoom. * Rounding is necessary when interacting with the renderer which tends to * operate in integral values (for example for determining if scrollbars * should be shown). * @param {number} zoom The zoom to use to compute the scaled dimensions. * @return {Object} A dictionary with scaled 'width'/'height' of the document. * @private */ getZoomedDocumentDimensions_: function(zoom) { if (!this.documentDimensions_) return null; return { width: Math.round(this.documentDimensions_.width * zoom), height: Math.round(this.documentDimensions_.height * zoom) }; }, /** * @private * Returns true if the document needs scrollbars at the given zoom level. * @param {number} zoom compute whether scrollbars are needed at this zoom * @return {Object} with 'horizontal' and 'vertical' keys which map to bool * values indicating if the horizontal and vertical scrollbars are needed * respectively. */ documentNeedsScrollbars_: function(zoom) { var zoomedDimensions = this.getZoomedDocumentDimensions_(zoom); if (!zoomedDimensions) { return { horizontal: false, vertical: false }; } // If scrollbars are required for one direction, expand the document in the // other direction to take the width of the scrollbars into account when // deciding whether the other direction needs scrollbars. if (zoomedDimensions.width > this.window_.innerWidth) zoomedDimensions.height += this.scrollbarWidth_; else if (zoomedDimensions.height > this.window_.innerHeight) zoomedDimensions.width += this.scrollbarWidth_; return { horizontal: zoomedDimensions.width > this.window_.innerWidth, vertical: zoomedDimensions.height + this.topToolbarHeight_ > this.window_.innerHeight }; }, /** * Returns true if the document needs scrollbars at the current zoom level. * @return {Object} with 'x' and 'y' keys which map to bool values * indicating if the horizontal and vertical scrollbars are needed * respectively. */ documentHasScrollbars: function() { return this.documentNeedsScrollbars_(this.zoom_); }, /** * @private * Helper function called when the zoomed document size changes. */ contentSizeChanged_: function() { var zoomedDimensions = this.getZoomedDocumentDimensions_(this.zoom_); if (zoomedDimensions) { this.sizer_.style.width = zoomedDimensions.width + 'px'; this.sizer_.style.height = zoomedDimensions.height + this.topToolbarHeight_ + 'px'; } }, /** * @private * Called when the viewport should be updated. */ updateViewport_: function() { this.viewportChangedCallback_(); }, /** * @private * Called when the viewport size changes. */ resize_: function() { if (this.fittingType_ == Viewport.FittingType.FIT_TO_PAGE) this.fitToPageInternal_(false); else if (this.fittingType_ == Viewport.FittingType.FIT_TO_WIDTH) this.fitToWidth(); else this.updateViewport_(); }, /** * @type {Object} the scroll position of the viewport. */ get position() { return { x: this.window_.pageXOffset, y: this.window_.pageYOffset - this.topToolbarHeight_ }; }, /** * Scroll the viewport to the specified position. * @type {Object} position the position to scroll to. */ set position(position) { this.window_.scrollTo(position.x, position.y + this.topToolbarHeight_); }, /** * @type {Object} the size of the viewport excluding scrollbars. */ get size() { var needsScrollbars = this.documentNeedsScrollbars_(this.zoom_); var scrollbarWidth = needsScrollbars.vertical ? this.scrollbarWidth_ : 0; var scrollbarHeight = needsScrollbars.horizontal ? this.scrollbarWidth_ : 0; return { width: this.window_.innerWidth - scrollbarWidth, height: this.window_.innerHeight - scrollbarHeight }; }, /** * @type {number} the zoom level of the viewport. */ get zoom() { return this.zoom_; }, /** * @private * Used to wrap a function that might perform zooming on the viewport. This is * required so that we can notify the plugin that zooming is in progress * so that while zooming is taking place it can stop reacting to scroll events * from the viewport. This is to avoid flickering. */ mightZoom_: function(f) { this.beforeZoomCallback_(); this.allowedToChangeZoom_ = true; f(); this.allowedToChangeZoom_ = false; this.afterZoomCallback_(); }, /** * @private * Sets the zoom of the viewport. * @param {number} newZoom the zoom level to zoom to. */ setZoomInternal_: function(newZoom) { if (!this.allowedToChangeZoom_) { throw 'Called Viewport.setZoomInternal_ without calling ' + 'Viewport.mightZoom_.'; } // Record the scroll position (relative to the top-left of the window). var currentScrollPos = { x: this.position.x / this.zoom_, y: this.position.y / this.zoom_ }; this.zoom_ = newZoom; this.contentSizeChanged_(); // Scroll to the scaled scroll position. this.position = { x: currentScrollPos.x * newZoom, y: currentScrollPos.y * newZoom }; }, /** * Sets the zoom to the given zoom level. * @param {number} newZoom the zoom level to zoom to. */ setZoom: function(newZoom) { this.fittingType_ = Viewport.FittingType.NONE; newZoom = Math.max(Viewport.ZOOM_FACTOR_RANGE.min, Math.min(newZoom, Viewport.ZOOM_FACTOR_RANGE.max)); this.mightZoom_(function() { this.setZoomInternal_(newZoom); this.updateViewport_(); }.bind(this)); }, /** * @type {number} the width of scrollbars in the viewport in pixels. */ get scrollbarWidth() { return this.scrollbarWidth_; }, /** * @type {Viewport.FittingType} the fitting type the viewport is currently in. */ get fittingType() { return this.fittingType_; }, /** * @private * @param {integer} y the y-coordinate to get the page at. * @return {integer} the index of a page overlapping the given y-coordinate. */ getPageAtY_: function(y) { var min = 0; var max = this.pageDimensions_.length - 1; while (max >= min) { var page = Math.floor(min + ((max - min) / 2)); // There might be a gap between the pages, in which case use the bottom // of the previous page as the top for finding the page. var top = 0; if (page > 0) { top = this.pageDimensions_[page - 1].y + this.pageDimensions_[page - 1].height; } var bottom = this.pageDimensions_[page].y + this.pageDimensions_[page].height; if (top <= y && bottom > y) return page; else if (top > y) max = page - 1; else min = page + 1; } return 0; }, /** * Returns the page with the greatest proportion of its height in the current * viewport. * @return {int} the index of the most visible page. */ getMostVisiblePage: function() { var firstVisiblePage = this.getPageAtY_(this.position.y / this.zoom_); if (firstVisiblePage == this.pageDimensions_.length - 1) return firstVisiblePage; var viewportRect = { x: this.position.x / this.zoom_, y: this.position.y / this.zoom_, width: this.size.width / this.zoom_, height: this.size.height / this.zoom_ }; var firstVisiblePageVisibility = getIntersectionHeight( this.pageDimensions_[firstVisiblePage], viewportRect) / this.pageDimensions_[firstVisiblePage].height; var nextPageVisibility = getIntersectionHeight( this.pageDimensions_[firstVisiblePage + 1], viewportRect) / this.pageDimensions_[firstVisiblePage + 1].height; if (nextPageVisibility > firstVisiblePageVisibility) return firstVisiblePage + 1; return firstVisiblePage; }, /** * @private * Compute the zoom level for fit-to-page or fit-to-width. |pageDimensions| is * the dimensions for a given page and if |widthOnly| is true, it indicates * that fit-to-page zoom should be computed rather than fit-to-page. * @param {Object} pageDimensions the dimensions of a given page * @param {boolean} widthOnly a bool indicating whether fit-to-page or * fit-to-width should be computed. * @return {number} the zoom to use */ computeFittingZoom_: function(pageDimensions, widthOnly) { // First compute the zoom without scrollbars. var zoomWidth = this.window_.innerWidth / pageDimensions.width; var zoom; var zoomHeight; if (widthOnly) { zoom = zoomWidth; } else { zoomHeight = this.window_.innerHeight / pageDimensions.height; zoom = Math.min(zoomWidth, zoomHeight); } // Check if there needs to be any scrollbars. var needsScrollbars = this.documentNeedsScrollbars_(zoom); // If the document fits, just return the zoom. if (!needsScrollbars.horizontal && !needsScrollbars.vertical) return zoom; var zoomedDimensions = this.getZoomedDocumentDimensions_(zoom); // Check if adding a scrollbar will result in needing the other scrollbar. var scrollbarWidth = this.scrollbarWidth_; if (needsScrollbars.horizontal && zoomedDimensions.height > this.window_.innerHeight - scrollbarWidth) { needsScrollbars.vertical = true; } if (needsScrollbars.vertical && zoomedDimensions.width > this.window_.innerWidth - scrollbarWidth) { needsScrollbars.horizontal = true; } // Compute available window space. var windowWithScrollbars = { width: this.window_.innerWidth, height: this.window_.innerHeight }; if (needsScrollbars.horizontal) windowWithScrollbars.height -= scrollbarWidth; if (needsScrollbars.vertical) windowWithScrollbars.width -= scrollbarWidth; // Recompute the zoom. zoomWidth = windowWithScrollbars.width / pageDimensions.width; if (widthOnly) { zoom = zoomWidth; } else { zoomHeight = windowWithScrollbars.height / pageDimensions.height; zoom = Math.min(zoomWidth, zoomHeight); } return zoom; }, /** * Zoom the viewport so that the page-width consumes the entire viewport. */ fitToWidth: function() { this.mightZoom_(function() { this.fittingType_ = Viewport.FittingType.FIT_TO_WIDTH; if (!this.documentDimensions_) return; // When computing fit-to-width, the maximum width of a page in the // document is used, which is equal to the size of the document width. this.setZoomInternal_(this.computeFittingZoom_(this.documentDimensions_, true)); var page = this.getMostVisiblePage(); this.updateViewport_(); }.bind(this)); }, /** * @private * Zoom the viewport so that a page consumes the entire viewport. * @param {boolean} scrollToTopOfPage Set to true if the viewport should be * scrolled to the top of the current page. Set to false if the viewport * should remain at the current scroll position. */ fitToPageInternal_: function(scrollToTopOfPage) { this.mightZoom_(function() { this.fittingType_ = Viewport.FittingType.FIT_TO_PAGE; if (!this.documentDimensions_) return; var page = this.getMostVisiblePage(); // Fit to the current page's height and the widest page's width. var dimensions = { width: this.documentDimensions_.width, height: this.pageDimensions_[page].height, }; this.setZoomInternal_(this.computeFittingZoom_(dimensions, false)); if (scrollToTopOfPage) { this.position = { x: 0, y: this.pageDimensions_[page].y * this.zoom_ }; } this.updateViewport_(); }.bind(this)); }, /** * Zoom the viewport so that a page consumes the entire viewport. Also scrolls * the viewport to the top of the current page. */ fitToPage: function() { this.fitToPageInternal_(true); }, /** * Zoom out to the next predefined zoom level. */ zoomOut: function() { this.mightZoom_(function() { this.fittingType_ = Viewport.FittingType.NONE; var nextZoom = Viewport.ZOOM_FACTORS[0]; for (var i = 0; i < Viewport.ZOOM_FACTORS.length; i++) { if (Viewport.ZOOM_FACTORS[i] < this.zoom_) nextZoom = Viewport.ZOOM_FACTORS[i]; } this.setZoomInternal_(nextZoom); this.updateViewport_(); }.bind(this)); }, /** * Zoom in to the next predefined zoom level. */ zoomIn: function() { this.mightZoom_(function() { this.fittingType_ = Viewport.FittingType.NONE; var nextZoom = Viewport.ZOOM_FACTORS[Viewport.ZOOM_FACTORS.length - 1]; for (var i = Viewport.ZOOM_FACTORS.length - 1; i >= 0; i--) { if (Viewport.ZOOM_FACTORS[i] > this.zoom_) nextZoom = Viewport.ZOOM_FACTORS[i]; } this.setZoomInternal_(nextZoom); this.updateViewport_(); }.bind(this)); }, /** * Go to the given page index. * @param {number} page the index of the page to go to. zero-based. */ goToPage: function(page) { this.mightZoom_(function() { if (this.pageDimensions_.length === 0) return; if (page < 0) page = 0; if (page >= this.pageDimensions_.length) page = this.pageDimensions_.length - 1; var dimensions = this.pageDimensions_[page]; var toolbarOffset = 0; // Unless we're in fit to page mode, scroll above the page by // |this.topToolbarHeight_| so that the toolbar isn't covering it // initially. if (this.fittingType_ != Viewport.FittingType.FIT_TO_PAGE) toolbarOffset = this.topToolbarHeight_; this.position = { x: dimensions.x * this.zoom_, y: dimensions.y * this.zoom_ - toolbarOffset }; this.updateViewport_(); }.bind(this)); }, /** * Set the dimensions of the document. * @param {Object} documentDimensions the dimensions of the document */ setDocumentDimensions: function(documentDimensions) { this.mightZoom_(function() { var initialDimensions = !this.documentDimensions_; this.documentDimensions_ = documentDimensions; this.pageDimensions_ = this.documentDimensions_.pageDimensions; if (initialDimensions) { this.setZoomInternal_( Math.min(this.defaultZoom_, this.computeFittingZoom_(this.documentDimensions_, true))); this.position = { x: 0, y: -this.topToolbarHeight_ }; } this.contentSizeChanged_(); this.resize_(); }.bind(this)); }, /** * Get the coordinates of the page contents (excluding the page shadow) * relative to the screen. * @param {number} page the index of the page to get the rect for. * @return {Object} a rect representing the page in screen coordinates. */ getPageScreenRect: function(page) { if (!this.documentDimensions_) { return { x: 0, y: 0, width: 0, height: 0 }; } if (page >= this.pageDimensions_.length) page = this.pageDimensions_.length - 1; var pageDimensions = this.pageDimensions_[page]; // Compute the page dimensions minus the shadows. var insetDimensions = { x: pageDimensions.x + Viewport.PAGE_SHADOW.left, y: pageDimensions.y + Viewport.PAGE_SHADOW.top, width: pageDimensions.width - Viewport.PAGE_SHADOW.left - Viewport.PAGE_SHADOW.right, height: pageDimensions.height - Viewport.PAGE_SHADOW.top - Viewport.PAGE_SHADOW.bottom }; // Compute the x-coordinate of the page within the document. // TODO(raymes): This should really be set when the PDF plugin passes the // page coordinates, but it isn't yet. var x = (this.documentDimensions_.width - pageDimensions.width) / 2 + Viewport.PAGE_SHADOW.left; // Compute the space on the left of the document if the document fits // completely in the screen. var spaceOnLeft = (this.size.width - this.documentDimensions_.width * this.zoom_) / 2; spaceOnLeft = Math.max(spaceOnLeft, 0); return { x: x * this.zoom_ + spaceOnLeft - this.window_.pageXOffset, y: insetDimensions.y * this.zoom_ - this.window_.pageYOffset, width: insetDimensions.width * this.zoom_, height: insetDimensions.height * this.zoom_ }; } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Creates a new OpenPDFParamsParser. This parses the open pdf parameters * passed in the url to set initial viewport settings for opening the pdf. * @param {Object} getNamedDestinationsFunction The function called to fetch * the page number for a named destination. */ function OpenPDFParamsParser(getNamedDestinationsFunction) { this.outstandingRequests_ = []; this.getNamedDestinationsFunction_ = getNamedDestinationsFunction; } OpenPDFParamsParser.prototype = { /** * @private * Parse zoom parameter of open PDF parameters. If this * parameter is passed while opening PDF then PDF should be opened * at the specified zoom level. * @param {number} zoom value. * @param {Object} viewportPosition to store zoom and position value. */ parseZoomParam_: function(paramValue, viewportPosition) { var paramValueSplit = paramValue.split(','); if ((paramValueSplit.length != 1) && (paramValueSplit.length != 3)) return; // User scale of 100 means zoom value of 100% i.e. zoom factor of 1.0. var zoomFactor = parseFloat(paramValueSplit[0]) / 100; if (isNaN(zoomFactor)) return; // Handle #zoom=scale. if (paramValueSplit.length == 1) { viewportPosition['zoom'] = zoomFactor; return; } // Handle #zoom=scale,left,top. var position = {x: parseFloat(paramValueSplit[1]), y: parseFloat(paramValueSplit[2])}; viewportPosition['position'] = position; viewportPosition['zoom'] = zoomFactor; }, /** * @private * Parse PDF url parameters. These parameters are mentioned in the url * and specify actions to be performed when opening pdf files. * See http://www.adobe.com/content/dam/Adobe/en/devnet/acrobat/ * pdfs/pdf_open_parameters.pdf for details. * @param {string} url that needs to be parsed. * @param {Function} callback function to be called with viewport info. */ getViewportFromUrlParams: function(url, callback) { var viewportPosition = {}; viewportPosition['url'] = url; var paramIndex = url.search('#'); if (paramIndex == -1) { callback(viewportPosition); return; } var paramTokens = url.substring(paramIndex + 1).split('&'); if ((paramTokens.length == 1) && (paramTokens[0].search('=') == -1)) { // Handle the case of http://foo.com/bar#NAMEDDEST. This is not // explicitly mentioned except by example in the Adobe // "PDF Open Parameters" document. this.outstandingRequests_.push({ callback: callback, viewportPosition: viewportPosition }); this.getNamedDestinationsFunction_(paramTokens[0]); return; } var paramsDictionary = {}; for (var i = 0; i < paramTokens.length; ++i) { var keyValueSplit = paramTokens[i].split('='); if (keyValueSplit.length != 2) continue; paramsDictionary[keyValueSplit[0]] = keyValueSplit[1]; } if ('page' in paramsDictionary) { // |pageNumber| is 1-based, but goToPage() take a zero-based page number. var pageNumber = parseInt(paramsDictionary['page']); if (!isNaN(pageNumber) && pageNumber > 0) viewportPosition['page'] = pageNumber - 1; } if ('zoom' in paramsDictionary) this.parseZoomParam_(paramsDictionary['zoom'], viewportPosition); if (viewportPosition.page === undefined && 'nameddest' in paramsDictionary) { this.outstandingRequests_.push({ callback: callback, viewportPosition: viewportPosition }); this.getNamedDestinationsFunction_(paramsDictionary['nameddest']); } else { callback(viewportPosition); } }, /** * This is called when a named destination is received and the page number * corresponding to the request for which a named destination is passed. * @param {number} pageNumber The page corresponding to the named destination * requested. */ onNamedDestinationReceived: function(pageNumber) { var outstandingRequest = this.outstandingRequests_.shift(); if (pageNumber != -1) outstandingRequest.viewportPosition.page = pageNumber; outstandingRequest.callback(outstandingRequest.viewportPosition); }, }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Creates a new Navigator for navigating to links inside or outside the PDF. * @param {string} originalUrl The original page URL. * @param {Object} viewport The viewport info of the page. * @param {Object} paramsParser The object for URL parsing. * @param {Function} navigateInCurrentTabCallback The Callback function that * gets called when navigation happens in the current tab. * @param {Function} navigateInNewTabCallback The Callback function that gets * called when navigation happens in the new tab. */ function Navigator(originalUrl, viewport, paramsParser, navigateInCurrentTabCallback, navigateInNewTabCallback) { this.originalUrl_ = originalUrl; this.viewport_ = viewport; this.paramsParser_ = paramsParser; this.navigateInCurrentTabCallback_ = navigateInCurrentTabCallback; this.navigateInNewTabCallback_ = navigateInNewTabCallback; } Navigator.prototype = { /** * @private * Function to navigate to the given URL. This might involve navigating * within the PDF page or opening a new url (in the same tab or a new tab). * @param {string} url The URL to navigate to. * @param {boolean} newTab Whether to perform the navigation in a new tab or * in the current tab. */ navigate: function(url, newTab) { if (url.length == 0) return; // If |urlFragment| starts with '#', then it's for the same URL with a // different URL fragment. if (url.charAt(0) == '#') { // if '#' is already present in |originalUrl| then remove old fragment // and add new url fragment. var hashIndex = this.originalUrl_.search('#'); if (hashIndex != -1) url = this.originalUrl_.substring(0, hashIndex) + url; else url = this.originalUrl_ + url; } // If there's no scheme, then take a guess at the scheme. if (url.indexOf('://') == -1 && url.indexOf('mailto:') == -1) url = this.guessUrlWithoutScheme_(url); if (!this.isValidUrl_(url)) return; if (newTab) { this.navigateInNewTabCallback_(url); } else { this.paramsParser_.getViewportFromUrlParams( url, this.onViewportReceived_.bind(this)); } }, /** * @private * Called when the viewport position is received. * @param {Object} viewportPosition Dictionary containing the viewport * position. */ onViewportReceived_: function(viewportPosition) { var pageNumber = viewportPosition.page; if (pageNumber != undefined) this.viewport_.goToPage(pageNumber); else this.navigateInCurrentTabCallback_(viewportPosition['url']); }, /** * @private * Checks if the URL starts with a scheme and s not just a scheme. * @param {string} The input URL * @return {boolean} Whether the url is valid. */ isValidUrl_: function(url) { // Make sure |url| starts with a valid scheme. if (url.indexOf('http://') != 0 && url.indexOf('https://') != 0 && url.indexOf('ftp://') != 0 && url.indexOf('file://') != 0 && url.indexOf('mailto:') != 0) { return false; } // Make sure |url| is not only a scheme. if (url == 'http://' || url == 'https://' || url == 'ftp://' || url == 'file://' || url == 'mailto:') { return false; } return true; }, /** * @private * Attempt to figure out what a URL is when there is no scheme. * @param {string} The input URL * @return {string} The URL with a scheme or the original URL if it is not * possible to determine the scheme. */ guessUrlWithoutScheme_: function(url) { // If the original URL is mailto:, that does not make sense to start with, // and neither does adding |url| to it. // If the original URL is not a valid URL, this cannot make a valid URL. // In both cases, just bail out. if (this.originalUrl_.startsWith('mailto:') || !this.isValidUrl_(this.originalUrl_)) { return url; } // Check for absolute paths. if (url.startsWith('/')) { var schemeEndIndex = this.originalUrl_.indexOf('://'); var firstSlash = this.originalUrl_.indexOf('/', schemeEndIndex + 3); // e.g. http://www.foo.com/bar -> http://www.foo.com var domain = firstSlash != -1 ? this.originalUrl_.substr(0, firstSlash) : this.originalUrl_; return domain + url; } // Check for obvious relative paths. var isRelative = false; if (url.startsWith('.') || url.startsWith('\\')) isRelative = true; // In Adobe Acrobat Reader XI, it looks as though links with less than // 2 dot separators in the domain are considered relative links, and // those with 2 of more are considered http URLs. e.g. // // www.foo.com/bar -> http // foo.com/bar -> relative link if (!isRelative) { var domainSeparatorIndex = url.indexOf('/'); var domainName = domainSeparatorIndex == -1 ? url : url.substr(0, domainSeparatorIndex); var domainDotCount = (domainName.match(/\./g) || []).length; if (domainDotCount < 2) isRelative = true; } if (isRelative) { var slashIndex = this.originalUrl_.lastIndexOf('/'); var path = slashIndex != -1 ? this.originalUrl_.substr(0, slashIndex) : this.originalUrl_; return path + '/' + url; } return 'http://' + url; } }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @private * The period of time in milliseconds to wait between updating the viewport * position by the scroll velocity. */ ViewportScroller.DRAG_TIMER_INTERVAL_MS_ = 100; /** * @private * The maximum drag scroll distance per DRAG_TIMER_INTERVAL in pixels. */ ViewportScroller.MAX_DRAG_SCROLL_DISTANCE_ = 100; /** * Creates a new ViewportScroller. * A ViewportScroller scrolls the page in response to drag selection with the * mouse. * @param {Object} viewport The viewport info of the page. * @param {Object} plugin The PDF plugin element. * @param {Object} window The window containing the viewer. */ function ViewportScroller(viewport, plugin, window) { this.viewport_ = viewport; this.plugin_ = plugin; this.window_ = window; this.mousemoveCallback_ = null; this.timerId_ = null; this.scrollVelocity_ = null; this.lastFrameTime_ = 0; } ViewportScroller.prototype = { /** * @private * Start scrolling the page by |scrollVelocity_| every * |DRAG_TIMER_INTERVAL_MS_|. */ startDragScrollTimer_: function() { if (this.timerId_ === null) { this.timerId_ = this.window_.setInterval(this.dragScrollPage_.bind(this), ViewportScroller.DRAG_TIMER_INTERVAL_MS_); this.lastFrameTime_ = Date.now(); } }, /** * @private * Stops the drag scroll timer if it is active. */ stopDragScrollTimer_: function() { if (this.timerId_ !== null) { this.window_.clearInterval(this.timerId_); this.timerId_ = null; this.lastFrameTime_ = 0; } }, /** * @private * Scrolls the viewport by the current scroll velocity. */ dragScrollPage_: function() { var position = this.viewport_.position; var currentFrameTime = Date.now(); var timeAdjustment = (currentFrameTime - this.lastFrameTime_) / ViewportScroller.DRAG_TIMER_INTERVAL_MS_; position.y += (this.scrollVelocity_.y * timeAdjustment); position.x += (this.scrollVelocity_.x * timeAdjustment); this.viewport_.position = position; this.lastFrameTime_ = currentFrameTime; }, /** * @private * Calculate the velocity to scroll while dragging using the distance of the * cursor outside the viewport. * @param {Object} event The mousemove event. * @return {Object} Object with x and y direction scroll velocity. */ calculateVelocity_: function(event) { var x = Math.min(Math.max(-event.offsetX, event.offsetX - this.plugin_.offsetWidth, 0), ViewportScroller.MAX_DRAG_SCROLL_DISTANCE_) * Math.sign(event.offsetX); var y = Math.min(Math.max(-event.offsetY, event.offsetY - this.plugin_.offsetHeight, 0), ViewportScroller.MAX_DRAG_SCROLL_DISTANCE_) * Math.sign(event.offsetY); return { x: x, y: y }; }, /** * @private * Handles mousemove events. It updates the scroll velocity and starts and * stops timer based on scroll velocity. * @param {Object} event The mousemove event. */ onMousemove_: function(event) { this.scrollVelocity_ = this.calculateVelocity_(event); if (!this.scrollVelocity_.x && !this.scrollVelocity_.y) this.stopDragScrollTimer_(); else if (!this.timerId_) this.startDragScrollTimer_(); }, /** * Sets whether to scroll the viewport when the mouse is outside the * viewport. * @param {boolean} isSelecting Represents selection status. */ setEnableScrolling: function(isSelecting) { if (isSelecting) { if (!this.mousemoveCallback_) this.mousemoveCallback_ = this.onMousemove_.bind(this); this.plugin_.addEventListener('mousemove', this.mousemoveCallback_, false); } else { this.stopDragScrollTimer_(); if (this.mousemoveCallback_) { this.plugin_.removeEventListener('mousemove', this.mousemoveCallback_, false); } } } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * Turn a dictionary received from postMessage into a key event. * @param {Object} dict A dictionary representing the key event. * @return {Event} A key event. */ function DeserializeKeyEvent(dict) { var e = document.createEvent('Event'); e.initEvent('keydown'); e.keyCode = dict.keyCode; e.shiftKey = dict.shiftKey; e.ctrlKey = dict.ctrlKey; e.altKey = dict.altKey; e.metaKey = dict.metaKey; e.fromScriptingAPI = true; return e; } /** * Turn a key event into a dictionary which can be sent over postMessage. * @param {Event} event A key event. * @return {Object} A dictionary representing the key event. */ function SerializeKeyEvent(event) { return { keyCode: event.keyCode, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey }; } /** * An enum containing a value specifying whether the PDF is currently loading, * has finished loading or failed to load. */ var LoadState = { LOADING: 'loading', SUCCESS: 'success', FAILED: 'failed' }; /** * Create a new PDFScriptingAPI. This provides a scripting interface to * the PDF viewer so that it can be customized by things like print preview. * @param {Window} window the window of the page containing the pdf viewer. * @param {Object} plugin the plugin element containing the pdf viewer. */ function PDFScriptingAPI(window, plugin) { this.loadState_ = LoadState.LOADING; this.pendingScriptingMessages_ = []; this.setPlugin(plugin); window.addEventListener('message', function(event) { if (event.origin != 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai') { console.error('Received message that was not from the extension: ' + event); return; } switch (event.data.type) { case 'viewport': if (this.viewportChangedCallback_) this.viewportChangedCallback_(event.data.pageX, event.data.pageY, event.data.pageWidth, event.data.viewportWidth, event.data.viewportHeight); break; case 'documentLoaded': this.loadState_ = event.data.load_state; if (this.loadCallback_) this.loadCallback_(this.loadState_ == LoadState.SUCCESS); break; case 'getAccessibilityJSONReply': if (this.accessibilityCallback_) { this.accessibilityCallback_(event.data.json); this.accessibilityCallback_ = null; } break; case 'getSelectedTextReply': if (this.selectedTextCallback_) { this.selectedTextCallback_(event.data.selectedText); this.selectedTextCallback_ = null; } break; case 'sendKeyEvent': if (this.keyEventCallback_) this.keyEventCallback_(DeserializeKeyEvent(event.data.keyEvent)); break; } }.bind(this), false); } PDFScriptingAPI.prototype = { /** * @private * Send a message to the extension. If messages try to get sent before there * is a plugin element set, then we queue them up and send them later (this * can happen in print preview). * @param {Object} message The message to send. */ sendMessage_: function(message) { if (this.plugin_) this.plugin_.postMessage(message, '*'); else this.pendingScriptingMessages_.push(message); }, /** * Sets the plugin element containing the PDF viewer. The element will usually * be passed into the PDFScriptingAPI constructor but may also be set later. * @param {Object} plugin the plugin element containing the PDF viewer. */ setPlugin: function(plugin) { this.plugin_ = plugin; if (this.plugin_) { // Send a message to ensure the postMessage channel is initialized which // allows us to receive messages. this.sendMessage_({ type: 'initialize' }); // Flush pending messages. while (this.pendingScriptingMessages_.length > 0) this.sendMessage_(this.pendingScriptingMessages_.shift()); } }, /** * Sets the callback which will be run when the PDF viewport changes. * @param {Function} callback the callback to be called. */ setViewportChangedCallback: function(callback) { this.viewportChangedCallback_ = callback; }, /** * Sets the callback which will be run when the PDF document has finished * loading. If the document is already loaded, it will be run immediately. * @param {Function} callback the callback to be called. */ setLoadCallback: function(callback) { this.loadCallback_ = callback; if (this.loadState_ != LoadState.LOADING && this.loadCallback_) this.loadCallback_(this.loadState_ == LoadState.SUCCESS); }, /** * Sets a callback that gets run when a key event is fired in the PDF viewer. * @param {Function} callback the callback to be called with a key event. */ setKeyEventCallback: function(callback) { this.keyEventCallback_ = callback; }, /** * Resets the PDF viewer into print preview mode. * @param {string} url the url of the PDF to load. * @param {boolean} grayscale whether or not to display the PDF in grayscale. * @param {Array} pageNumbers an array of the page numbers. * @param {boolean} modifiable whether or not the document is modifiable. */ resetPrintPreviewMode: function(url, grayscale, pageNumbers, modifiable) { this.loadState_ = LoadState.LOADING; this.sendMessage_({ type: 'resetPrintPreviewMode', url: url, grayscale: grayscale, pageNumbers: pageNumbers, modifiable: modifiable }); }, /** * Load a page into the document while in print preview mode. * @param {string} url the url of the pdf page to load. * @param {number} index the index of the page to load. */ loadPreviewPage: function(url, index) { this.sendMessage_({ type: 'loadPreviewPage', url: url, index: index }); }, /** * Get accessibility JSON for the document. May only be called after document * load. * @param {Function} callback a callback to be called with the accessibility * json that has been retrieved. * @param {number} [page] the 0-indexed page number to get accessibility data * for. If this is not provided, data about the entire document is * returned. * @return {boolean} true if the function is successful, false if there is an * outstanding request for accessibility data that has not been answered. */ getAccessibilityJSON: function(callback, page) { if (this.accessibilityCallback_) return false; this.accessibilityCallback_ = callback; var message = { type: 'getAccessibilityJSON', }; if (page || page == 0) message.page = page; this.sendMessage_(message); return true; }, /** * Select all the text in the document. May only be called after document * load. */ selectAll: function() { this.sendMessage_({ type: 'selectAll' }); }, /** * Get the selected text in the document. The callback will be called with the * text that is selected. May only be called after document load. * @param {Function} callback a callback to be called with the selected text. * @return {boolean} true if the function is successful, false if there is an * outstanding request for selected text that has not been answered. */ getSelectedText: function(callback) { if (this.selectedTextCallback_) return false; this.selectedTextCallback_ = callback; this.sendMessage_({ type: 'getSelectedText' }); return true; }, /** * Print the document. May only be called after document load. */ print: function() { this.sendMessage_({ type: 'print' }); }, /** * Send a key event to the extension. * @param {Event} keyEvent the key event to send to the extension. */ sendKeyEvent: function(keyEvent) { this.sendMessage_({ type: 'sendKeyEvent', keyEvent: SerializeKeyEvent(keyEvent) }); }, }; /** * Creates a PDF viewer with a scripting interface. This is basically 1) an * iframe which is navigated to the PDF viewer extension and 2) a scripting * interface which provides access to various features of the viewer for use * by print preview and accessibility. * @param {string} src the source URL of the PDF to load initially. * @return {HTMLIFrameElement} the iframe element containing the PDF viewer. */ function PDFCreateOutOfProcessPlugin(src) { var client = new PDFScriptingAPI(window); var iframe = window.document.createElement('iframe'); // Prevent the frame from being tab-focusable. iframe.setAttribute('tabindex', '-1'); // TODO(raymes): This below is a hack to tell if the material design PDF UI // has been enabled. Remove this as soon as we remove the material design PDF // flag. var EXTENSION_URL = 'chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/'; var PAGE_NAME = 'index.html'; var MATERIAL_PAGE_NAME = 'index-material.html'; fetch(EXTENSION_URL + PAGE_NAME, { method: 'get' }).then(function() { iframe.setAttribute('src', EXTENSION_URL + PAGE_NAME + '?' + src); }, function() { iframe.setAttribute('src', EXTENSION_URL + MATERIAL_PAGE_NAME + '?' + src); }).then(function() { iframe.onload = function() { client.setPlugin(iframe.contentWindow); }; }); // Add the functions to the iframe so that they can be called directly. iframe.setViewportChangedCallback = client.setViewportChangedCallback.bind(client); iframe.setLoadCallback = client.setLoadCallback.bind(client); iframe.setKeyEventCallback = client.setKeyEventCallback.bind(client); iframe.resetPrintPreviewMode = client.resetPrintPreviewMode.bind(client); iframe.loadPreviewPage = client.loadPreviewPage.bind(client); iframe.sendKeyEvent = client.sendKeyEvent.bind(client); return iframe; } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * A class that manages updating the browser with zoom changes. */ class ZoomManager { /** * Constructs a ZoomManager * @param {!Viewport} viewport A Viewport for which to manage zoom. * @param {Function} setBrowserZoomFunction A function that sets the browser * zoom to the provided value. * @param {number} initialZoom The initial browser zoom level. */ constructor(viewport, setBrowserZoomFunction, initialZoom) { this.viewport_ = viewport; this.setBrowserZoomFunction_ = setBrowserZoomFunction; this.browserZoom_ = initialZoom; this.changingBrowserZoom_ = null; } /** * Invoked when a browser-initiated zoom-level change occurs. * @param {number} newZoom the zoom level to zoom to. */ onBrowserZoomChange(newZoom) { // If we are changing the browser zoom level, ignore any browser zoom level // change events. Either, the change occurred before our update and will be // overwritten, or the change being reported is the change we are making, // which we have already handled. if (this.changingBrowserZoom_) return; if (this.floatingPointEquals(this.browserZoom_, newZoom)) return; this.browserZoom_ = newZoom; this.viewport_.setZoom(newZoom); } /** * Invoked when an extension-initiated zoom-level change occurs. */ onPdfZoomChange() { // If we are already changing the browser zoom level in response to a // previous extension-initiated zoom-level change, ignore this zoom change. // Once the browser zoom level is changed, we check whether the extension's // zoom level matches the most recently sent zoom level. if (this.changingBrowserZoom_) return; let zoom = this.viewport_.zoom; if (this.floatingPointEquals(this.browserZoom_, zoom)) return; this.changingBrowserZoom_ = this.setBrowserZoomFunction_(zoom).then( function() { this.browserZoom_ = zoom; this.changingBrowserZoom_ = null; // The extension's zoom level may have changed while the browser zoom // change was in progress. We call back into onPdfZoomChange to ensure the // browser zoom is up to date. this.onPdfZoomChange(); }.bind(this)); } /** * Returns whether two numbers are approximately equal. * @param {number} a The first number. * @param {number} b The second number. */ floatingPointEquals(a, b) { let MIN_ZOOM_DELTA = 0.01; // If the zoom level is close enough to the current zoom level, don't // change it. This avoids us getting into an infinite loop of zoom changes // due to floating point error. return Math.abs(a - b) <= MIN_ZOOM_DELTA; } }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Returns a promise that will resolve to the default zoom factor. * @param {!Object} streamInfo The stream object pointing to the data contained * in the PDF. * @return {Promise} A promise that will resolve to the default zoom * factor. */ function lookupDefaultZoom(streamInfo) { // Webviews don't run in tabs so |streamInfo.tabId| is -1 when running within // a webview. if (!chrome.tabs || streamInfo.tabId < 0) return Promise.resolve(1); return new Promise(function(resolve, reject) { chrome.tabs.getZoomSettings(streamInfo.tabId, function(zoomSettings) { resolve(zoomSettings.defaultZoomFactor); }); }); } /** * Returns a promise that will resolve to the initial zoom factor * upon starting the plugin. This may differ from the default zoom * if, for example, the page is zoomed before the plugin is run. * @param {!Object} streamInfo The stream object pointing to the data contained * in the PDF. * @return {Promise} A promise that will resolve to the initial zoom * factor. */ function lookupInitialZoom(streamInfo) { // Webviews don't run in tabs so |streamInfo.tabId| is -1 when running within // a webview. if (!chrome.tabs || streamInfo.tabId < 0) return Promise.resolve(1); return new Promise(function(resolve, reject) { chrome.tabs.getZoom(streamInfo.tabId, resolve); }); } /** * A class providing an interface to the browser. */ class BrowserApi { /** * @constructor * @param {!Object} streamInfo The stream object which points to the data * contained in the PDF. * @param {number} defaultZoom The default browser zoom. * @param {number} initialZoom The initial browser zoom * upon starting the plugin. * @param {boolean} manageZoom Whether to manage zoom. */ constructor(streamInfo, defaultZoom, initialZoom, manageZoom) { this.streamInfo_ = streamInfo; this.defaultZoom_ = defaultZoom; this.initialZoom_ = initialZoom; this.manageZoom_ = manageZoom; } /** * Returns a promise to a BrowserApi. * @param {!Object} streamInfo The stream object pointing to the data * contained in the PDF. * @param {boolean} manageZoom Whether to manage zoom. */ static create(streamInfo, manageZoom) { return Promise.all([ lookupDefaultZoom(streamInfo), lookupInitialZoom(streamInfo) ]).then(function(zoomFactors) { return new BrowserApi( streamInfo, zoomFactors[0], zoomFactors[1], manageZoom); }); } /** * Returns the stream info pointing to the data contained in the PDF. * @return {Object} The stream info object. */ getStreamInfo() { return this.streamInfo_; } /** * Aborts the stream. */ abortStream() { if (chrome.mimeHandlerPrivate) chrome.mimeHandlerPrivate.abortStream(); } /** * Sets the browser zoom. * @param {number} zoom The zoom factor to send to the browser. * @return {Promise} A promise that will be resolved when the browser zoom * has been updated. */ setZoom(zoom) { if (!this.manageZoom_) return Promise.resolve(); return new Promise(function(resolve, reject) { chrome.tabs.setZoom(this.streamInfo_.tabId, zoom, resolve); }.bind(this)); } /** * Returns the default browser zoom factor. * @return {number} The default browser zoom factor. */ getDefaultZoom() { return this.defaultZoom_; } /** * Returns the initial browser zoom factor. * @return {number} The initial browser zoom factor. */ getInitialZoom() { return this.initialZoom_; } /** * Adds an event listener to be notified when the browser zoom changes. * @param {function} listener The listener to be called with the new zoom * factor. */ addZoomEventListener(listener) { if (!this.manageZoom_) return; chrome.tabs.onZoomChange.addListener(function(zoomChangeInfo) { if (zoomChangeInfo.tabId != this.streamInfo_.tabId) return; listener(zoomChangeInfo.newZoomFactor); }.bind(this)); } }; /** * Creates a BrowserApi for an extension running as a mime handler. * @return {Promise} A promise to a BrowserApi instance constructed * using the mimeHandlerPrivate API. */ function createBrowserApiForMimeHandlerView() { return new Promise(function(resolve, reject) { chrome.mimeHandlerPrivate.getStreamInfo(resolve); }).then(function(streamInfo) { let manageZoom = !streamInfo.embedded && streamInfo.tabId != -1; return new Promise(function(resolve, reject) { if (!manageZoom) { resolve(); return; } chrome.tabs.setZoomSettings( streamInfo.tabId, {mode: 'manual', scope: 'per-tab'}, resolve); }).then(function() { return BrowserApi.create(streamInfo, manageZoom); }); }); } /** * Creates a BrowserApi instance for an extension not running as a mime handler. * @return {Promise} A promise to a BrowserApi instance constructed * from the URL. */ function createBrowserApiForStandaloneExtension() { let url = window.location.search.substring(1); let streamInfo = { streamUrl: url, originalUrl: url, responseHeaders: {}, embedded: window.parent != window, tabId: -1, }; return new Promise(function(resolve, reject) { if (!chrome.tabs) { resolve(); return; } chrome.tabs.getCurrent(function(tab) { streamInfo.tabId = tab.id; resolve(); }); }).then(function() { return BrowserApi.create(streamInfo, false); }); } /** * Returns a promise that will resolve to a BrowserApi instance. * @return {Promise} A promise to a BrowserApi instance for the * current environment. */ function createBrowserApi() { if (window.location.search) return createBrowserApiForStandaloneExtension(); return createBrowserApiForMimeHandlerView(); } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // This is to work-around an issue where this extension is not granted // permission to access chrome://resources when iframed for print preview. // See https://crbug.com/444752. /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :root { --iron-icon-height: 20px; --iron-icon-width: 20px; --paper-icon-button: { height: 20px; padding: 6px; width: 20px; }; --paper-icon-button-ink-color: rgb(189, 189, 189); --viewer-icon-ink-color: rgb(189, 189, 189); } /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ #item { @apply(--layout-center); @apply(--layout-horizontal); color: rgb(80, 80, 80); cursor: pointer; font-size: 77.8%; height: 30px; position: relative; } #item:hover { background-color: rgb(237, 237, 237); color: rgb(20, 20, 20); } #title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #expand { --iron-icon-height: 16px; --iron-icon-width: 16px; --paper-icon-button-ink-color: var(--paper-grey-900); height: 16px; min-width: 16px; padding: 6px; transition: transform 150ms; width: 16px; } :host-context([dir=rtl]) #expand { transform: rotate(180deg); } :host([children-shown]) #expand { transform: rotate(90deg); } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { /** Amount that each level of bookmarks is indented by (px). */ var BOOKMARK_INDENT = 20; Polymer({ is: 'viewer-bookmark', properties: { /** * A bookmark object, each containing a: * - title * - page (optional) * - children (an array of bookmarks) */ bookmark: { type: Object, observer: 'bookmarkChanged_' }, depth: { type: Number, observer: 'depthChanged' }, childDepth: Number, childrenShown: { type: Boolean, reflectToAttribute: true, value: false }, keyEventTarget: { type: Object, value: function() { return this.$.item; } } }, behaviors: [ Polymer.IronA11yKeysBehavior ], keyBindings: { 'enter': 'onEnter_', 'space': 'onSpace_' }, bookmarkChanged_: function() { this.$.expand.style.visibility = this.bookmark.children.length > 0 ? 'visible' : 'hidden'; }, depthChanged: function() { this.childDepth = this.depth + 1; this.$.item.style.webkitPaddingStart = (this.depth * BOOKMARK_INDENT) + 'px'; }, onClick: function() { if (this.bookmark.hasOwnProperty('page')) this.fire('change-page', {page: this.bookmark.page}); }, onEnter_: function(e) { // Don't allow events which have propagated up from the expand button to // trigger a click. if (e.detail.keyboardEvent.target != this.$.expand) this.onClick(); }, onSpace_: function(e) { // paper-icon-button stops propagation of space events, so there's no need // to check the event source here. this.onClick(); // Prevent default space scroll behavior. e.detail.keyboardEvent.preventDefault(); }, toggleChildren: function(e) { this.childrenShown = !this.childrenShown; e.stopPropagation(); // Prevent the above onClick handler from firing. } }); })(); // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-bookmarks-content' }); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ #icon { background-position: center center; background-repeat: no-repeat; background-size: 100% 100%; height: 100%; width: 100%; } :host { -webkit-user-select: none; background-image: linear-gradient(rgb(60, 80, 119), rgb(15, 24, 41)); border: 1px solid rgb(11, 9, 16); cursor: default; display: inline-block; height: 36px; margin: 0; pointer-events: all; width: 43px; } :host(:focus:host) { outline: none; } :host(:hover:host) { background-image: linear-gradient(rgb(73, 102, 155), rgb(32, 52, 95)); } :host(.latchable.polymer-selected:host), :host(:active:host) { background-color: rgb(75, 103, 156); background-image: none; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { var dpi = ''; Polymer({ is: 'viewer-button', properties: { img: { type: String, observer: 'imgChanged' }, latchable: { type: Boolean, observer: 'latchableChanged' } }, created: function() { if (!dpi) { var mql = window.matchMedia('(-webkit-min-device-pixel-ratio: 1.3'); dpi = mql.matches ? 'hi' : 'low'; } }, imgChanged: function() { if (this.img) { this.$.icon.style.backgroundImage = 'url(' + this.getAttribute('assetpath') + 'img/' + dpi + 'DPI/' + this.img + ')'; } else { this.$.icon.style.backgroundImage = ''; } }, latchableChanged: function() { if (this.latchable) this.classList.add('latchable'); else this.classList.remove('latchable'); }, }); })(); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ .last-item { margin-bottom: 24px; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-error-screen', properties: { strings: Object, reloadFn: { type: Object, value: null, observer: 'reloadFnChanged_' } }, reloadFnChanged_: function() { // The default margins in paper-dialog don't work well with hiding/showing // the .buttons div. We need to manually manage the bottom margin to get // around this. if (this.reloadFn) this.$['load-failed-message'].classList.remove('last-item'); else this.$['load-failed-message'].classList.add('last-item'); }, show: function() { this.$.dialog.open(); }, reload: function() { if (this.reloadFn) this.reloadFn(); } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { background-color: #ccc; color: #555; font-family: sans-serif; font-size: 20px; height: 100%; pointer-events: none; position: fixed; text-align: center; width: 100%; } #load-failed-message { line-height: 0; position: absolute; top: 50%; width: 100%; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-error-screen-legacy', properties: { text: String }, show: function() { this.style.visibility = 'visible'; } }); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { -webkit-transition: opacity 400ms ease-in-out; pointer-events: none; position: fixed; right: 0; } #text { background-color: rgba(0, 0, 0, 0.5); border-radius: 5px; color: white; float: left; font-family: sans-serif; font-size: 12px; font-weight: bold; line-height: 48px; text-align: center; text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); width: 62px; } #triangle-right { border-bottom: 6px solid transparent; border-left: 8px solid rgba(0, 0, 0, 0.5); border-top: 6px solid transparent; display: inline; float: left; height: 0; margin-top: 18px; width: 0; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-page-indicator', properties: { label: { type: String, value: '1' }, index: { type: Number, observer: 'indexChanged' }, pageLabels: { type: Array, value: null, observer: 'pageLabelsChanged' } }, timerId: undefined, ready: function() { var callback = this.fadeIn.bind(this, 2000); window.addEventListener('scroll', function() { requestAnimationFrame(callback); }); }, initialFadeIn: function() { this.fadeIn(6000); }, fadeIn: function(displayTime) { var percent = window.scrollY / (document.body.scrollHeight - document.documentElement.clientHeight); this.style.top = percent * (document.documentElement.clientHeight - this.offsetHeight) + 'px'; this.style.opacity = 1; clearTimeout(this.timerId); this.timerId = setTimeout(function() { this.style.opacity = 0; this.timerId = undefined; }.bind(this), displayTime); }, pageLabelsChanged: function() { this.indexChanged(); }, indexChanged: function() { if (this.pageLabels) this.label = this.pageLabels[this.index]; else this.label = String(this.index + 1); } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { color: #fff; font-size: 88.8%; } #pageselector { --paper-input-container-underline: { visibility: hidden; }; --paper-input-container-underline-focus: { visibility: hidden; }; display: inline-block; padding: 0; width: 1ch; } input#input { -webkit-margin-start: -3px; color: #fff; line-height: 18px; padding: 3px; text-align: end; } input#input:focus, input#input:hover { background-color: rgba(0, 0, 0, 0.5); border-radius: 2px; } #slash { padding: 0 3px; } #pagelength-spacer { display: inline-block; text-align: start; } #slash, #pagelength { font-size: 81.25%; } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-page-selector', properties: { /** * The number of pages the document contains. */ docLength: { type: Number, value: 1, observer: 'docLengthChanged' }, /** * The current page being viewed (1-based). A change to pageNo is mirrored * immediately to the input field. A change to the input field is not * mirrored back until pageNoCommitted() is called and change-page is fired. */ pageNo: { type: Number, value: 1 }, strings: Object }, pageNoCommitted: function() { var page = parseInt(this.$.input.value); if (!isNaN(page) && page <= this.docLength && page > 0) this.fire('change-page', {page: page - 1}); else this.$.input.value = this.pageNo; this.$.input.blur(); }, docLengthChanged: function() { var numDigits = this.docLength.toString().length; this.$.pageselector.style.width = numDigits + 'ch'; // Set both sides of the slash to the same width, so that the layout is // exactly centered. this.$['pagelength-spacer'].style.width = numDigits + 'ch'; }, select: function() { this.$.input.select(); }, /** * @return {boolean} True if the selector input field is currently focused. */ isActive: function() { return this.shadowRoot.activeElement == this.$.input; } }); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-password-screen', properties: { strings: Object, invalid: Boolean, active: { type: Boolean, value: false, observer: 'activeChanged' } }, ready: function() { this.activeChanged(); }, accept: function() { this.active = false; }, deny: function() { this.$.password.disabled = false; this.$.submit.disabled = false; this.invalid = true; this.$.password.focus(); this.$.password.select(); }, handleKey: function(e) { if (e.keyCode == 13) this.submit(); }, submit: function() { if (this.$.password.value.length == 0) return; this.$.password.disabled = true; this.$.submit.disabled = true; this.fire('password-submitted', {password: this.$.password.value}); }, activeChanged: function() { if (this.active) { this.$.dialog.open(); this.$.password.focus(); } else { this.$.dialog.close(); } } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { -webkit-transition: opacity 400ms ease-in-out; background-color: #ccc; color: #555; display: table; font-family: sans-serif; font-size: 15px; height: 100%; pointer-events: none; position: fixed; text-align: center; width: 100%; } #message { padding-bottom: 10px; } .center { display: table-cell; vertical-align: middle; } .form { border: 1px solid #777; box-shadow: 1px 1px 1px; display: inline-block; padding: 10px; width: 300px; } input { color: #333; pointer-events: all; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-password-screen-legacy', properties: { text: { type: String, value: 'This document is password protected. Please enter a password.', }, active: { type: Boolean, value: false, observer: 'activeChanged' } }, timerId: undefined, ready: function() { this.activeChanged(); }, accept: function() { this.active = false; }, deny: function() { this.$.password.disabled = false; this.$.submit.disabled = false; this.$.password.focus(); this.$.password.select(); }, submit: function(e) { // Prevent the default form submission behavior. e.preventDefault(); if (this.$.password.value.length == 0) return; this.$.password.disabled = true; this.$.submit.disabled = true; this.fire('password-submitted', {password: this.$.password.value}); }, activeChanged: function() { clearTimeout(this.timerId); this.timerId = undefined; if (this.active) { this.style.visibility = 'visible'; this.style.opacity = 1; this.$.password.focus(); } else { this.style.opacity = 0; this.timerId = setTimeout(function() { this.style.visibility = 'hidden'; }.bind(this), 400); } } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ /* We introduce a wrapper aligner element as setting the relevant attributes * (horizontal justified layout center) have no effect on the core-toolbar. */ #aligner { padding: 0 16px; width: 100%; } #title { font-size: 77.8%; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #pageselector-container { text-align: center; /* The container resizes according to the width of the toolbar. On small * screens with large numbers of pages, overflow page numbers without * wrapping. */ white-space: nowrap; } #buttons { -webkit-user-select: none; text-align: end; } paper-icon-button { -webkit-margin-end: 12px; } viewer-toolbar-dropdown { -webkit-margin-end: 4px; } paper-progress { --paper-progress-active-color: var(--google-blue-300); --paper-progress-container-color: transparent; --paper-progress-height: 3px; transition: opacity 150ms; width: 100%; } paper-toolbar { --paper-toolbar-background: rgb(50, 54, 57); @apply(--shadow-elevation-2dp); color: rgb(241, 241, 241); font-size: 1.5em; height: 48px; } paper-toolbar /deep/ ::selection { background: rgba(255, 255, 255, 0.3); } paper-toolbar /deep/ .toolbar-tools { height: 48px; } .invisible { visibility: hidden; } @media(max-width: 675px) { #bookmarks, #rotate-left { display: none; } #pageselector-container { flex: 2; } } @media(max-width: 450px) { #rotate-right { display: none; } } @media(max-width: 400px) { #buttons, #pageselector-container { display: none; } } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { Polymer({ is: 'viewer-pdf-toolbar', behaviors: [ Polymer.NeonAnimationRunnerBehavior ], properties: { strings: Object, /** * The current loading progress of the PDF document (0 - 100). */ loadProgress: { type: Number, observer: 'loadProgressChanged' }, /** * The title of the PDF document. */ docTitle: String, /** * The number of the page being viewed (1-based). */ pageNo: Number, /** * Tree of PDF bookmarks (or null if the document has no bookmarks). */ bookmarks: { type: Object, value: null }, /** * The number of pages in the PDF document. */ docLength: Number, /** * Whether the toolbar is opened and visible. */ opened: { type: Boolean, value: true }, animationConfig: { value: function() { return { 'entry': { name: 'slide-down-animation', node: this, timing: { easing: 'cubic-bezier(0, 0, 0.2, 1)', duration: 250 } }, 'exit': { name: 'slide-up-animation', node: this, timing: { easing: 'cubic-bezier(0.4, 0, 1, 1)', duration: 250 } } }; } } }, listeners: { 'neon-animation-finish': '_onAnimationFinished' }, _onAnimationFinished: function() { this.style.transform = this.opened ? 'none' : 'translateY(-100%)'; }, loadProgressChanged: function() { if (this.loadProgress >= 100) { this.$.pageselector.classList.toggle('invisible', false); this.$.buttons.classList.toggle('invisible', false); this.$.progress.style.opacity = 0; } }, hide: function() { if (this.opened) this.toggleVisibility(); }, show: function() { if (!this.opened) { this.toggleVisibility(); } }, toggleVisibility: function() { this.opened = !this.opened; this.cancelAnimation(); this.playAnimation(this.opened ? 'entry' : 'exit'); }, selectPageNumber: function() { this.$.pageselector.select(); }, shouldKeepOpen: function() { return this.$.bookmarks.dropdownOpen || this.loadProgress < 100 || this.$.pageselector.isActive(); }, hideDropdowns: function() { if (this.$.bookmarks.dropdownOpen) { this.$.bookmarks.toggleDropdown(); return true; } return false; }, setDropdownLowerBound: function(lowerBound) { this.$.bookmarks.lowerBound = lowerBound; }, rotateLeft: function() { this.fire('rotate-left'); }, rotateRight: function() { this.fire('rotate-right'); }, save: function() { this.fire('save'); }, print: function() { this.fire('print'); } }); })(); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { -webkit-transition: opacity 400ms ease-in-out; background: rgb(29, 39, 57); border-radius: 5px; bottom: 26px; box-shadow: 0 1px 2px gray, 0 3px 3px rgba(0, 0, 0, .2); height: auto; left: 26px; pointer-events: none; position: fixed; width: auto; } .scaler { -webkit-transform: scale(0.25); -webkit-transform-origin: 0 0; float: left; height: 44px; margin: 8px; width: 44px; } #segments { border-radius: 50%; height: 176px; list-style: none; margin: 0; overflow: hidden; padding: 0; position: absolute; width: 176px; } .segment { -webkit-transform-origin: 0 100%; background: rgb(227, 234, 249); box-shadow: 0 0 0 6px rgb(29, 39, 57) inset; height: 50%; overflow: hidden; position: absolute; right: 0; top: 0; width: 50%; } .center-circle { background-color: rgb(29, 39, 57); border-radius: 50%; height: 80px; left: 48px; margin: 0; padding: 0; position: absolute; top: 48px; width: 80px; } #text { color: rgb(227, 234, 249); float: left; font-family: sans-serif; font-size: 16px; font-weight: bold; line-height: 58px; margin-right: 10px; margin-top: 1px; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-progress-bar', properties: { progress: { type: Number, observer: 'progressChanged' }, text: { type: String, value: 'Loading' }, numSegments: { type: Number, value: 8, observer: 'numSegmentsChanged' } }, segments: [], ready: function() { this.numSegmentsChanged(); }, progressChanged: function() { var numVisible = this.progress * this.segments.length / 100.0; for (var i = 0; i < this.segments.length; i++) { this.segments[i].style.visibility = i < numVisible ? 'inherit' : 'hidden'; } if (this.progress >= 100 || this.progress < 0) this.style.opacity = 0; }, numSegmentsChanged: function() { // Clear the existing segments. this.segments = []; var segmentsElement = this.$.segments; segmentsElement.innerHTML = ''; // Create the new segments. var segment = document.createElement('li'); segment.classList.add('segment'); var angle = 360 / this.numSegments; for (var i = 0; i < this.numSegments; ++i) { var segmentCopy = segment.cloneNode(true); segmentCopy.style.webkitTransform = 'rotate(' + (i * angle) + 'deg) skewY(' + -1 * (90 - angle) + 'deg)'; segmentsElement.appendChild(segmentCopy); this.segments.push(segmentCopy); } this.progressChanged(); } }); /* Copyright 2013 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { -webkit-transition: opacity 400ms ease-in-out; bottom: 0; display: block; font-size: 0; opacity: 1; padding: 30px 30px 15px 30vw; pointer-events: none; position: fixed; right: 0; } #toolbar { border-radius: 3px; box-shadow: 0 1px 2px gray, 0 3px 3px rgba(0, 0, 0, .2); overflow: hidden; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-toolbar', properties: { fadingIn: { type: Boolean, value: false, observer: 'fadingInChanged' } }, timerId_: undefined, inInitialFadeIn_: false, ready: function() { this.mousemoveCallback = function(e) { var rect = this.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { this.fadingIn = true; // If we hover over the toolbar, cancel the initial fade in. if (this.inInitialFadeIn_) this.inInitialFadeIn_ = false; } else { // Initially we want to keep the toolbar up for a longer period. if (!this.inInitialFadeIn_) this.fadingIn = false; } }.bind(this); }, attached: function() { this.parentNode.addEventListener('mousemove', this.mousemoveCallback); }, detached: function() { this.parentNode.removeEventListener('mousemove', this.mousemoveCallback); }, initialFadeIn: function() { this.inInitialFadeIn_ = true; this.fadeIn(); this.fadeOutAfterDelay(6000); }, fadingInChanged: function() { if (this.fadingIn) { this.fadeIn(); } else { if (this.timerId_ === undefined) this.fadeOutAfterDelay(3000); } }, fadeIn: function() { this.style.opacity = 1; clearTimeout(this.timerId_); this.timerId_ = undefined; }, fadeOutAfterDelay: function(delay) { this.timerId_ = setTimeout( function() { this.style.opacity = 0; this.timerId_ = undefined; this.inInitialFadeIn_ = false; }.bind(this), delay); } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { display: inline-block; text-align: start; } #container { position: absolute; } paper-material { background-color: rgb(256, 256, 256); border-radius: 4px; overflow-y: hidden; padding-bottom: 2px; width: 260px; } #scroll-container { max-height: 300px; overflow-y: auto; padding: 6px 0 4px 0; } #icon { cursor: pointer; display: inline-block; } :host([dropdown-open]) #icon { background-color: rgb(25, 27, 29); border-radius: 4px; } #arrow { -webkit-margin-start: -12px; -webkit-padding-end: 4px; } h1 { border-bottom: 1px solid rgb(219, 219, 219); color: rgb(33, 33, 33); font-size: 77.8%; font-weight: 500; margin: 0; padding: 14px 28px; } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { /** * Size of additional padding in the inner scrollable section of the dropdown. */ var DROPDOWN_INNER_PADDING = 12; /** Size of vertical padding on the outer #dropdown element. */ var DROPDOWN_OUTER_PADDING = 2; /** Minimum height of toolbar dropdowns (px). */ var MIN_DROPDOWN_HEIGHT = 200; Polymer({ is: 'viewer-toolbar-dropdown', properties: { /** String to be displayed at the top of the dropdown. */ header: String, /** Icon to display when the dropdown is closed. */ closedIcon: String, /** Icon to display when the dropdown is open. */ openIcon: String, /** True if the dropdown is currently open. */ dropdownOpen: { type: Boolean, reflectToAttribute: true, value: false }, /** Toolbar icon currently being displayed. */ dropdownIcon: { type: String, computed: 'computeIcon_(dropdownOpen, closedIcon, openIcon)' }, /** Lowest vertical point that the dropdown should occupy (px). */ lowerBound: { type: Number, observer: 'lowerBoundChanged_' }, /** * True if the max-height CSS property for the dropdown scroll container * is valid. If false, the height will be updated the next time the * dropdown is visible. */ maxHeightValid_: false, /** Current animation being played, or null if there is none. */ animation_: Object }, computeIcon_: function(dropdownOpen, closedIcon, openIcon) { return dropdownOpen ? openIcon : closedIcon; }, lowerBoundChanged_: function() { this.maxHeightValid_ = false; if (this.dropdownOpen) this.updateMaxHeight(); }, toggleDropdown: function() { this.dropdownOpen = !this.dropdownOpen; if (this.dropdownOpen) { this.$.dropdown.style.display = 'block'; if (!this.maxHeightValid_) this.updateMaxHeight(); } this.cancelAnimation_(); this.playAnimation_(this.dropdownOpen); }, updateMaxHeight: function() { var scrollContainer = this.$['scroll-container']; var height = this.lowerBound - scrollContainer.getBoundingClientRect().top - DROPDOWN_INNER_PADDING; height = Math.max(height, MIN_DROPDOWN_HEIGHT); scrollContainer.style.maxHeight = height + 'px'; this.maxHeightValid_ = true; }, cancelAnimation_: function() { if (this._animation) this._animation.cancel(); }, /** * Start an animation on the dropdown. * @param {boolean} isEntry True to play entry animation, false to play * exit. * @private */ playAnimation_: function(isEntry) { this.animation_ = isEntry ? this.animateEntry_() : this.animateExit_(); this.animation_.onfinish = function() { this.animation_ = null; if (!this.dropdownOpen) this.$.dropdown.style.display = 'none'; }.bind(this); }, animateEntry_: function() { var maxHeight = this.$.dropdown.getBoundingClientRect().height - DROPDOWN_OUTER_PADDING; if (maxHeight < 0) maxHeight = 0; var fade = new KeyframeEffect(this.$.dropdown, [ {opacity: 0}, {opacity: 1} ], {duration: 150, easing: 'cubic-bezier(0, 0, 0.2, 1)'}); var slide = new KeyframeEffect(this.$.dropdown, [ {height: '20px', transform: 'translateY(-10px)'}, {height: maxHeight + 'px', transform: 'translateY(0)'} ], {duration: 250, easing: 'cubic-bezier(0, 0, 0.2, 1)'}); return document.timeline.play(new GroupEffect([fade, slide])); }, animateExit_: function() { return this.$.dropdown.animate([ {transform: 'translateY(0)', opacity: 1}, {transform: 'translateY(-5px)', opacity: 0} ], {duration: 100, easing: 'cubic-bezier(0.4, 0, 1, 1)'}); } }); })(); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ #wrapper { transition: transform 250ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } :host([closed]) #wrapper { transform: translateX(100%); transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } :host-context([dir=rtl]):host([closed]) #wrapper { transform: translateX(-100%); } paper-fab { --paper-fab-keyboard-focus-background: var(--viewer-icon-ink-color); --paper-fab-mini: { height: 36px; padding: 8px; width: 36px; }; @apply(--shadow-elevation-4dp); background-color: rgb(242, 242, 242); color: rgb(96, 96, 96); margin: 0 48px; overflow: visible; } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. Polymer({ is: 'viewer-zoom-button', properties: { /** * Icons to be displayed on the FAB. Multiple icons should be separated with * spaces, and will be cycled through every time the FAB is clicked. */ icons: String, /** * Array version of the list of icons. Polymer does not allow array * properties to be set from HTML, so we must use a string property and * perform the conversion manually. * @private */ icons_: { type: Array, value: [''], computed: 'computeIconsArray_(icons)' }, tooltips: Array, closed: { type: Boolean, reflectToAttribute: true, value: false }, delay: { type: Number, observer: 'delayChanged_' }, /** * Index of the icon currently being displayed. */ activeIndex: { type: Number, value: 0 }, /** * Icon currently being displayed on the FAB. * @private */ visibleIcon_: { type: String, computed: 'computeVisibleIcon_(icons_, activeIndex)' }, visibleTooltip_: { type: String, computed: 'computeVisibleTooltip_(tooltips, activeIndex)' } }, computeIconsArray_: function(icons) { return icons.split(' '); }, computeVisibleIcon_: function(icons, activeIndex) { return icons[activeIndex]; }, computeVisibleTooltip_: function(tooltips, activeIndex) { return tooltips[activeIndex]; }, delayChanged_: function() { this.$.wrapper.style.transitionDelay = this.delay + 'ms'; }, show: function() { this.closed = false; }, hide: function() { this.closed = true; }, fireClick: function() { // We cannot attach an on-click to the entire viewer-zoom-button, as this // will include clicks on the margins. Instead, proxy clicks on the FAB // through. this.fire('fabclick'); this.activeIndex = (this.activeIndex + 1) % this.icons_.length; } }); /* Copyright 2015 The Chromium Authors. All rights reserved. * Use of this source code is governed by a BSD-style license that can be * found in the LICENSE file. */ :host { -webkit-user-select: none; bottom: 0; padding: 48px 0; position: fixed; right: 0; z-index: 3; } :host-context([dir=rtl]) { left: 0; right: auto; } viewer-zoom-button { display: block; } /* A small gap between the zoom in/zoom out buttons. */ #zoom-out-button { margin-top: 10px; } /* A larger gap between the fit button and bottom two buttons. */ #zoom-in-button { margin-top: 24px; } // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. (function() { var FIT_TO_PAGE = 0; var FIT_TO_WIDTH = 1; Polymer({ is: 'viewer-zoom-toolbar', properties: { strings: { type: Object, observer: 'setTooltips_' }, visible_: { type: Boolean, value: true } }, isVisible: function() { return this.visible_; }, setTooltips_: function() { this.$['fit-button'].tooltips = [this.strings.tooltipFitToPage, this.strings.tooltipFitToWidth]; this.$['zoom-in-button'].tooltips = [this.strings.tooltipZoomIn]; this.$['zoom-out-button'].tooltips = [this.strings.tooltipZoomOut]; }, fitToggle: function() { if (this.$['fit-button'].activeIndex == FIT_TO_WIDTH) this.fire('fit-to-width'); else this.fire('fit-to-page'); }, zoomIn: function() { this.fire('zoom-in'); }, zoomOut: function() { this.fire('zoom-out'); }, show: function() { if (!this.visible_) { this.visible_ = true; this.$['fit-button'].show(); this.$['zoom-in-button'].show(); this.$['zoom-out-button'].show(); } }, hide: function() { if (this.visible_) { this.visible_ = false; this.$['fit-button'].hide(); this.$['zoom-in-button'].hide(); this.$['zoom-out-button'].hide(); } }, }); })(); PNG  IHDR+$  pHYsHHFk>bKGDCIDATx^MKTQ616m.Z 1GBDIJDb/. KY#Ȉ5]*:Fty.ÅǍ́??0(ۑUC +^v &7w=,[CVeѲ +up>VB>* e)`PN&MSs|<} '[v~ErA&ùG1?O`Yr<>wr"eN6u#d)(`lrH]"~oTlkGOr}+`6 "dZPܒs:n('ƧyLXkfޒ)`X@Mӕ"w]L[}{{E3?6R/f)%|'_vbFCr \g̲eG} ic-__S|ǹГj3}Fi o>i.mK~JenZI9]'F&,olll3\g )ǹ&0rn >j`XnɹDI䖌9Oqd-]=G@sם{"d8w\O/RV'i`lKvC֞ 'Ó ́ݍ)$;(Sq+m˅|e5leSc:@@o(/_69c^fYRJa_^1# H?%Jl$c+%tEXtdate:create2013-12-17T13:57:27+11:00%tEXtdate:modify2013-12-17T10:39:59+11:00mtEXtSoftwareAdobe ImageReadyqe<IENDB`PNG  IHDR+$'3'PLTE쎨zd{Pd=Oh->StRNS0@ϟπ` `ϟQRIDATxԿ 0Rb;HMA?A'qp<\-3%Y~_iallmDh٪T`L}fZ[ȥ/ A}Mʝ6>qWnDsk]\feb2P)4k5k& m)f#AZИa`gة/]XIbIENDB`PNG  IHDR+$ IDATx^KSanu]]iT&J%8h0YY94NSǬlsYM,}>Nx.s g| YLLH@QiD=]D,v2޳ŏĎ`7zU.RWk ϗZ0KB˥u <K %bSGN5^l*zaz/K#`iaj w۬&3$ 6&Lun '06 لx5l75=v] `jS|@ F[;zRbcCsw*@.vNFI58C'a#`n8f!~PT9~O$# L[x~rGsj[ˠ4z}s8 -[$;GC4@3mivpoܘ5 .Wu{+oKC_p;.o̫i0},n{&PduS@E}Ft`hmFv$ wRivh. 978E cۃ`#ɠmZYN\v>)n쏌nܨq> ʐ.06Ir['rJ6*_p|گ:e00 %uj0߲ :6IENDB`PNG  IHDR+$  pHYsHHFk>bKGDCIDATx^KOQ~>V.tV a"H *B-  +) %HȣIbBZQQ (3uFIYM~ir俺iG|x]\_NF2v-t=oC] CWy+M?oL4;a:ƚ2B,Vs4"g7;nIﺽ!r/]2oC@]DC 8cHppcoJwr-,qDhXvP NOyl(UjSyA"' F375uuI5 c#~c=SHbM)' jTĻ,#:FIrom%oH[#839KB:%OycgZz0.Ŏ3֊ >s;V4YU+7m v8p(^ WIzdC  FgpgN-gc.d k厽 &/ ,0`R{\d=?ոV'\VA `l\SZ^BBP S)>#C) LGqj4 ~magԪ%tEXtdate:create2013-12-17T13:57:27+11:00%tEXtdate:modify2013-12-17T10:39:59+11:00mtEXtSoftwareAdobe ImageReadyqe<IENDB`PNG  IHDR+$ IDATx^=JQqW*\;pRD,%,l AD b"!~.:q>L7~9Ed|Ro^\-$†?fL斢_-,Zu7(h>frj6!YF%sl~Tw:{oN-K.ߏZ{ߡZ{ߡ80"p/.C{rj/r;w8;! 5ܱS@R:{M%aBOHM&ԴNo„ɵW@R:z =!5W@R:x =!5K0' zBjZ41=%Om5cac=_xeZP""NAZ*hC$<IENDB`PNG  IHDR+$ IDATx+DQp X-nHa}eAS 6,Db(Y]SsW!$QZ?^x>MR\l%IENDB`PNG  IHDRRDbIIDATx^[LWdM4m1$$¶MZ_lC- MLmPZ)0FMci)R`bEpED.." WV Kf 0Ξo9/gsq8 p8vCS2h]#qNpgfkm NVN1 Pm OǸX22q4nF;kO*€lj%Rmcl]kARjԨq%Sq뗁Ümd ܂BPF' Dy؄ByFnchBvaBCl^FVHlE^)R},ΰ۲3^@믊Jjhƿ8oP }$yH3fq%z[O@?~k}Wjde`L#?_l|nҚzZo?R^Q; qN 4xzyk_wT67"@b72nn~3*YU%J"k[6wmHDIdŀGFzHTEf<`i E#QZ %id7 @A*9.Z\khd9H3fqCˤGJWњi6y 1 !ST\~V$֤=6[md~I(>R=\6aJ#+}dsnr& !X+k{fZhËQp@y`uOZhuy ٻ>"w~guEBkI{[h¿. OCtP@k"=h/J#+mH"E D|U.v'9崗FV<$\[n<Ik6y#@PR p~[9ºZF#t-Y,Iz,JOi[Lѩ$5v (ӫ%|?ÁDI0өF:l2Xw#Ӊx(Stm_Y|t@e#+{H9r_YAQWV4xzUY$v"ItQOo0dd_YA<=LjڶQU#+u$v#[a mJ VldlI4/ndC Y9&@m+9: ŽtìQ=zD"293؝[p] իV*p V i܆٩58`tU}wF7U@,ҸK* E@,fx * Jñ _w!`T5 ڨZ>ډIQDp8gwwfIENDB`PNG  IHDRRDbnIDATx^=JCQG񱴕 d! XYNi] *6DK&yd<^-v?B!s`V5p _Դ:VNr?q-2;L$G^Uj'ntV o{2)a)aΝg0Yos$Cӥ4O/'+<#_5O n( o!1| @D*9 E+8I&MҦTK۳© ÞS. daJp8}Y8)i$?{;}/ !O?~ACw?D RҾGb1HqHSbfh/μ0s1Q_6cwWA Րڒ!k^n <890$dxmwcbb}G0'Y$Sk 1:).,ˆd'WtW 9%F%opaqFl'-ݫ ._IFvW#d3IF!}bTY<')glQ]UtLVhwO Ek8&jH"ILV5{K1B%L.]ijA9Lyjd<:Pnc .&4Q8I&*ݮ]]wNyLhh&P0F=V=I&}0$.6tI@)3mRvOL(Z9@ 6GPDm$:rzޑXMaA@9xEMnH GC>r9[nB5i:ݯFJ={,&B0>[E]k|4ZAo!tB8]4&ZSM8m:n& j=b&:$A>@rN1cˊ'6ق1E<_w/ a3!bx[qӋ~icvHztxhaL(&gec2>VGt[ij/ݍcPV+)k98 yXhn%o A!888I߬%IENDB`PNG  IHDRRDbIDATx^[hU1F#EX AA#)E ziB+Xa[ڦJ b[$Z%mӭm{M6t$i3ImFdWa`涱Uzzf8L.AAAb./:J" (U\G"#2WZeWo*ÃZf]!xP_{H+-zV foY2Z<7o h'qm>w1Ǝfu/oj?7S<6$heƊOT]%jo(̪ǀRsgn,p4)-}DgPɖ$ਖ਼#h&Mg]W%P|n Z=ʝJ*t䴪 1GXWN{(J3O8xN[̣lz'ޜHC>%h49= ,DK[9C鮩9F5C׃CյkcԵYF_hO3ԁsؔE8xNN04c|K1,jM^7;|-pQF`uC]!%av֏'n$  3,A Cl.fhB2\*~.GfgpA31O14:=2ԁCy|_@Lah4].Es:G26Ђd{7_fg4'Z:C#zljdh/O]y#urhjlKʟ5` վ{?;3pVɡoN]dhxetl_B\ 82؛ O0ߢ^Qک߻+҇]Q$Cr@[*l|iQUL0b+r;LoS?ә |Yϼ1֯mqeG͞UsaMojpoa>Gֿ֣êbplP^qdl9Wz2hOg5~PG½ Sց8C*O`_V\dRK62t?)3pUYSn?9=1L+R- S,1L+;u@C0Ho0/3tO ӊT?iwvbiHnTYi!N~p87N "G[4̶C@=SYTbgix6on-_a~v/viwId/#$AAA7[IENDB`PNG  IHDRRDb[IDATx^۽kSas\iS 8thġ8ht;E6]gdhAZǠ4p^x~pr972Eq"#3/3+ S5"[utWu=mr)ǥəi#cAjtDcQZ{>x̦tݛI [sw|8)(BH$N̐YjuLc;qL¸ սYgEG4εu%A5FZa\kY!D YۙA"Hg$F;w`TI'=1 HwٸK|[4URIf`<#k$>(8'Fh$jcH4DXmXmF#r "h @M jl{jKLlƪo~\zt't?/ܾ|]~R3幅7Rb͵"k RP[wkW/]M y;"~o56^菂(SI_%!IfH -NtWIENDB`PNG  IHDRRD%}/PLTECIX59Fʸ쬿V_w0Q\[%tRNS%'%'qWy%trIDATx^=N1[CPPPqY*((eC$ f00قbzز2U72iFܕ#$u\@kx)} b input byte array. * @return {string} result. */ function UTIL_BytesToString(b) { return String.fromCharCode.apply(null, b); } /** * Converts a byte array to a hex string. * @param {(Uint8Array|Array)} b input byte array. * @return {string} result. */ function UTIL_BytesToHex(b) { if (!b) return '(null)'; var hexchars = '0123456789ABCDEF'; var hexrep = new Array(b.length * 2); for (var i = 0; i < b.length; ++i) { hexrep[i * 2 + 0] = hexchars.charAt((b[i] >> 4) & 15); hexrep[i * 2 + 1] = hexchars.charAt(b[i] & 15); } return hexrep.join(''); } function UTIL_BytesToHexWithSeparator(b, sep) { var hexchars = '0123456789ABCDEF'; var stride = 2 + (sep ? 1 : 0); var hexrep = new Array(b.length * stride); for (var i = 0; i < b.length; ++i) { if (sep) hexrep[i * stride + 0] = sep; hexrep[i * stride + stride - 2] = hexchars.charAt((b[i] >> 4) & 15); hexrep[i * stride + stride - 1] = hexchars.charAt(b[i] & 15); } return (sep ? hexrep.slice(1) : hexrep).join(''); } function UTIL_HexToBytes(h) { var hexchars = '0123456789ABCDEFabcdef'; var res = new Uint8Array(h.length / 2); for (var i = 0; i < h.length; i += 2) { if (hexchars.indexOf(h.substring(i, i + 1)) == -1) break; res[i / 2] = parseInt(h.substring(i, i + 2), 16); } return res; } function UTIL_HexToArray(h) { var hexchars = '0123456789ABCDEFabcdef'; var res = new Array(h.length / 2); for (var i = 0; i < h.length; i += 2) { if (hexchars.indexOf(h.substring(i, i + 1)) == -1) break; res[i / 2] = parseInt(h.substring(i, i + 2), 16); } return res; } function UTIL_equalArrays(a, b) { if (!a || !b) return false; if (a.length != b.length) return false; var accu = 0; for (var i = 0; i < a.length; ++i) accu |= a[i] ^ b[i]; return accu === 0; } function UTIL_ltArrays(a, b) { if (a.length < b.length) return true; if (a.length > b.length) return false; for (var i = 0; i < a.length; ++i) { if (a[i] < b[i]) return true; if (a[i] > b[i]) return false; } return false; } function UTIL_gtArrays(a, b) { return UTIL_ltArrays(b, a); } function UTIL_geArrays(a, b) { return !UTIL_ltArrays(a, b); } function UTIL_unionArrays(a, b) { var obj = {}; for (var i = 0; i < a.length; i++) { obj[a[i]] = a[i]; } for (var i = 0; i < b.length; i++) { obj[b[i]] = b[i]; } var union = []; for (var k in obj) { union.push(obj[k]); } return union; } function UTIL_getRandom(a) { var tmp = new Array(a); var rnd = new Uint8Array(a); window.crypto.getRandomValues(rnd); // Yay! for (var i = 0; i < a; ++i) tmp[i] = rnd[i] & 255; return tmp; } function UTIL_setFavicon(icon) { // Construct a new favion link tag var faviconLink = document.createElement('link'); faviconLink.rel = 'Shortcut Icon'; faviconLink.type = 'image/x-icon'; faviconLink.href = icon; // Remove the old favion, if it exists var head = document.getElementsByTagName('head')[0]; var links = head.getElementsByTagName('link'); for (var i = 0; i < links.length; i++) { var link = links[i]; if (link.type == faviconLink.type && link.rel == faviconLink.rel) { head.removeChild(link); } } // Add in the new one head.appendChild(faviconLink); } // Erase all entries in array function UTIL_clear(a) { if (a instanceof Array) { for (var i = 0; i < a.length; ++i) a[i] = 0; } } // Type tags used for ASN.1 encoding of ECDSA signatures /** @const */ var UTIL_ASN_INT = 0x02; /** @const */ var UTIL_ASN_SEQUENCE = 0x30; /** * Parse SEQ(INT, INT) from ASN1 byte array. * @param {(Uint8Array|Array)} a input to parse from. * @return {{'r': !Array, 's': !Array}|null} */ function UTIL_Asn1SignatureToJson(a) { if (a.length < 6) return null; // Too small to be valid if (a[0] != UTIL_ASN_SEQUENCE) return null; var l = a[1] & 255; if (l & 0x80) return null; // SEQ.size too large if (a.length != 2 + l) return null; // SEQ size does not match input function parseInt(off) { if (a[off] != UTIL_ASN_INT) return null; var l = a[off + 1] & 255; if (l & 0x80) return null; // INT.size too large if (off + 2 + l > a.length) return null; // Out of bounds return a.slice(off + 2, off + 2 + l); } var r = parseInt(2); if (!r) return null; var s = parseInt(2 + 2 + r.length); if (!s) return null; return {'r': r, 's': s}; } /** * Encode a JSON signature {r,s} as an ASN1 SEQ(INT, INT). May modify sig * @param {{'r': (!Array|undefined), 's': !Array}} sig * @return {!Uint8Array} */ function UTIL_JsonSignatureToAsn1(sig) { var rbytes = sig.r; var sbytes = sig.s; // ASN.1 integers are arbitrary length msb first and signed. // sig.r and sig.s are 256 bits msb first but _unsigned_, so we must // prepend a zero byte in case their high bit is set. if (rbytes[0] & 0x80) rbytes.unshift(0); if (sbytes[0] & 0x80) sbytes.unshift(0); var len = 4 + rbytes.length + sbytes.length; var buf = new Uint8Array(2 + len); var i = 0; buf[i++] = UTIL_ASN_SEQUENCE; buf[i++] = len; buf[i++] = UTIL_ASN_INT; buf[i++] = rbytes.length; buf.set(rbytes, i); i += rbytes.length; buf[i++] = UTIL_ASN_INT; buf[i++] = sbytes.length; buf.set(sbytes, i); return buf; } function UTIL_prepend_zero(s, n) { if (s.length == n) return s; var l = s.length; for (var i = 0; i < n - l; ++i) { s = '0' + s; } return s; } // hr:min:sec.milli string function UTIL_time() { var d = new Date(); var m = UTIL_prepend_zero((d.getMonth() + 1).toString(), 2); var t = UTIL_prepend_zero(d.getDate().toString(), 2); var H = UTIL_prepend_zero(d.getHours().toString(), 2); var M = UTIL_prepend_zero(d.getMinutes().toString(), 2); var S = UTIL_prepend_zero(d.getSeconds().toString(), 2); var L = UTIL_prepend_zero((d.getMilliseconds() * 1000).toString(), 6); return m + t + ' ' + H + ':' + M + ':' + S + '.' + L; } var UTIL_events = []; var UTIL_max_events = 500; function UTIL_fmt(s) { var line = UTIL_time() + ': ' + s; if (UTIL_events.push(line) > UTIL_max_events) { // Drop from head. UTIL_events.splice(0, UTIL_events.length - UTIL_max_events); } return line; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // WebSafeBase64Escape and Unescape. function B64_encode(bytes, opt_length) { if (!opt_length) opt_length = bytes.length; var b64out = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; var result = ''; var shift = 0; var accu = 0; var inputIndex = 0; while (opt_length--) { accu <<= 8; accu |= bytes[inputIndex++]; shift += 8; while (shift >= 6) { var i = (accu >> (shift - 6)) & 63; result += b64out.charAt(i); shift -= 6; } } if (shift) { accu <<= 8; shift += 8; var i = (accu >> (shift - 6)) & 63; result += b64out.charAt(i); } return result; } // Normal base64 encode; not websafe, including padding. function base64_encode(bytes, opt_length) { if (!opt_length) opt_length = bytes.length; var b64out = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; var result = ''; var shift = 0; var accu = 0; var inputIndex = 0; while (opt_length--) { accu <<= 8; accu |= bytes[inputIndex++]; shift += 8; while (shift >= 6) { var i = (accu >> (shift - 6)) & 63; result += b64out.charAt(i); shift -= 6; } } if (shift) { accu <<= 8; shift += 8; var i = (accu >> (shift - 6)) & 63; result += b64out.charAt(i); } while (result.length % 4) result += '='; return result; } var B64_inmap = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 0, 0, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 0, 0, 0, 0, 64, 0, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 0, 0, 0, 0, 0 ]; function B64_decode(string) { var bytes = []; var accu = 0; var shift = 0; for (var i = 0; i < string.length; ++i) { var c = string.charCodeAt(i); if (c < 32 || c > 127 || !B64_inmap[c - 32]) return []; accu <<= 6; accu |= (B64_inmap[c - 32] - 1); shift += 6; if (shift >= 8) { bytes.push((accu >> (shift - 8)) & 255); shift -= 8; } } return bytes; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Defines a Closeable interface. */ 'use strict'; /** * A closeable interface. * @interface */ function Closeable() {} /** Closes this object. */ Closeable.prototype.close = function() {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a countdown-based timer interface. */ 'use strict'; /** * A countdown timer. * @interface */ function Countdown() {} /** * Sets a new timeout for this timer. * @param {number} timeoutMillis how long, in milliseconds, the countdown lasts. * @param {Function=} cb called back when the countdown expires. * @return {boolean} whether the timeout could be set. */ Countdown.prototype.setTimeout = function(timeoutMillis, cb) {}; /** Clears this timer's timeout. Timers that are cleared become expired. */ Countdown.prototype.clearTimeout = function() {}; /** * @return {number} how many milliseconds are remaining until the timer expires. */ Countdown.prototype.millisecondsUntilExpired = function() {}; /** @return {boolean} whether the timer has expired. */ Countdown.prototype.expired = function() {}; /** * Constructs a new clone of this timer, while overriding its callback. * @param {Function=} cb callback for new timer. * @return {!Countdown} new clone. */ Countdown.prototype.clone = function(cb) {}; /** * A factory to create countdown timers. * @interface */ function CountdownFactory() {} /** * Creates a new timer. * @param {number} timeoutMillis How long, in milliseconds, the countdown lasts. * @param {function()=} opt_cb Called back when the countdown expires. * @return {!Countdown} The timer. */ CountdownFactory.prototype.createTimer = function(timeoutMillis, opt_cb) {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a countdown-based timer implementation. */ 'use strict'; /** * Constructs a new timer. The timer has a very limited resolution, and does * not attempt to be millisecond accurate. Its intended use is as a * low-precision timer that pauses while debugging. * @param {!SystemTimer} sysTimer The system timer implementation. * @param {number=} timeoutMillis how long, in milliseconds, the countdown * lasts. * @param {Function=} cb called back when the countdown expires. * @constructor * @implements {Countdown} */ function CountdownTimer(sysTimer, timeoutMillis, cb) { /** @private {!SystemTimer} */ this.sysTimer_ = sysTimer; this.remainingMillis = 0; this.setTimeout(timeoutMillis || 0, cb); } /** Timer interval */ CountdownTimer.TIMER_INTERVAL_MILLIS = 200; /** * Sets a new timeout for this timer. Only possible if the timer is not * currently active. * @param {number} timeoutMillis how long, in milliseconds, the countdown lasts. * @param {Function=} cb called back when the countdown expires. * @return {boolean} whether the timeout could be set. */ CountdownTimer.prototype.setTimeout = function(timeoutMillis, cb) { if (this.timeoutId) return false; if (!timeoutMillis || timeoutMillis < 0) return false; this.remainingMillis = timeoutMillis; this.cb = cb; if (this.remainingMillis > CountdownTimer.TIMER_INTERVAL_MILLIS) { this.timeoutId = this.sysTimer_.setInterval(this.timerTick.bind(this), CountdownTimer.TIMER_INTERVAL_MILLIS); } else { // Set a one-shot timer for the last interval. this.timeoutId = this.sysTimer_.setTimeout( this.timerTick.bind(this), this.remainingMillis); } return true; }; /** Clears this timer's timeout. Timers that are cleared become expired. */ CountdownTimer.prototype.clearTimeout = function() { if (this.timeoutId) { this.sysTimer_.clearTimeout(this.timeoutId); this.timeoutId = undefined; } this.remainingMillis = 0; }; /** * @return {number} how many milliseconds are remaining until the timer expires. */ CountdownTimer.prototype.millisecondsUntilExpired = function() { return this.remainingMillis > 0 ? this.remainingMillis : 0; }; /** @return {boolean} whether the timer has expired. */ CountdownTimer.prototype.expired = function() { return this.remainingMillis <= 0; }; /** * Constructs a new clone of this timer, while overriding its callback. * @param {Function=} cb callback for new timer. * @return {!Countdown} new clone. */ CountdownTimer.prototype.clone = function(cb) { return new CountdownTimer(this.sysTimer_, this.remainingMillis, cb); }; /** Timer callback. */ CountdownTimer.prototype.timerTick = function() { this.remainingMillis -= CountdownTimer.TIMER_INTERVAL_MILLIS; if (this.expired()) { this.sysTimer_.clearTimeout(this.timeoutId); this.timeoutId = undefined; if (this.cb) { this.cb(); } } }; /** * A factory for creating CountdownTimers. * @param {!SystemTimer} sysTimer The system timer implementation. * @constructor * @implements {CountdownFactory} */ function CountdownTimerFactory(sysTimer) { /** @private {!SystemTimer} */ this.sysTimer_ = sysTimer; } /** * Creates a new timer. * @param {number} timeoutMillis How long, in milliseconds, the countdown lasts. * @param {function()=} opt_cb Called back when the countdown expires. * @return {!Countdown} The timer. */ CountdownTimerFactory.prototype.createTimer = function(timeoutMillis, opt_cb) { return new CountdownTimer(this.sysTimer_, timeoutMillis, opt_cb); }; /** * Minimum timeout attenuation, below which a response couldn't be reasonably * guaranteed, in seconds. * @const */ var MINIMUM_TIMEOUT_ATTENUATION_SECONDS = 1; /** * @param {number} timeoutSeconds Timeout value in seconds. * @param {number=} opt_attenuationSeconds Attenuation value in seconds. * @return {number} The timeout value, attenuated to ensure a response can be * given before the timeout's expiration. */ function attenuateTimeoutInSeconds(timeoutSeconds, opt_attenuationSeconds) { var attenuationSeconds = opt_attenuationSeconds || MINIMUM_TIMEOUT_ATTENUATION_SECONDS; if (timeoutSeconds < attenuationSeconds) return 0; return timeoutSeconds - attenuationSeconds; } /** * Default request timeout when none is present in the request, in seconds. * @const */ var DEFAULT_REQUEST_TIMEOUT_SECONDS = 30; /** * Gets the timeout value from the request, if any, substituting * opt_defaultTimeoutSeconds or DEFAULT_REQUEST_TIMEOUT_SECONDS if the request * does not contain a timeout value. * @param {Object} request The request containing the timeout. * @param {number=} opt_defaultTimeoutSeconds * @return {number} Timeout value, in seconds. */ function getTimeoutValueFromRequest(request, opt_defaultTimeoutSeconds) { var timeoutValueSeconds; if (request.hasOwnProperty('timeoutSeconds')) { timeoutValueSeconds = request['timeoutSeconds']; } else if (request.hasOwnProperty('timeout')) { timeoutValueSeconds = request['timeout']; } else if (opt_defaultTimeoutSeconds !== undefined) { timeoutValueSeconds = opt_defaultTimeoutSeconds; } else { timeoutValueSeconds = DEFAULT_REQUEST_TIMEOUT_SECONDS; } return timeoutValueSeconds; } /** * Creates a new countdown for the given timeout value, attenuated to ensure a * response is given prior to the countdown's expiration, using the given timer * factory. * @param {CountdownFactory} timerFactory The factory to use. * @param {number} timeoutValueSeconds * @param {number=} opt_attenuationSeconds Attenuation value in seconds. * @return {!Countdown} A countdown timer. */ function createAttenuatedTimer(timerFactory, timeoutValueSeconds, opt_attenuationSeconds) { timeoutValueSeconds = attenuateTimeoutInSeconds(timeoutValueSeconds, opt_attenuationSeconds); return timerFactory.createTimer(timeoutValueSeconds * 1000); } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // SHA256 in javascript. // // SHA256 { // SHA256(); // void reset(); // void update(byte[] data, opt_length); // byte[32] digest(); // } /** @constructor */ function SHA256() { this._buf = new Array(64); this._W = new Array(64); this._pad = new Array(64); this._k = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]; this._pad[0] = 0x80; for (var i = 1; i < 64; ++i) this._pad[i] = 0; this.reset(); } /** Reset the hasher */ SHA256.prototype.reset = function() { this._chain = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; this._inbuf = 0; this._total = 0; }; /** Hash the next block of 64 bytes * @param {Array} buf A 64 byte buffer */ SHA256.prototype._compress = function(buf) { var W = this._W; var k = this._k; function _rotr(w, r) { return ((w << (32 - r)) | (w >>> r)); }; // get 16 big endian words for (var i = 0; i < 64; i += 4) { var w = (buf[i] << 24) | (buf[i + 1] << 16) | (buf[i + 2] << 8) | (buf[i + 3]); W[i / 4] = w; } // expand to 64 words for (var i = 16; i < 64; ++i) { var s0 = _rotr(W[i - 15], 7) ^ _rotr(W[i - 15], 18) ^ (W[i - 15] >>> 3); var s1 = _rotr(W[i - 2], 17) ^ _rotr(W[i - 2], 19) ^ (W[i - 2] >>> 10); W[i] = (W[i - 16] + s0 + W[i - 7] + s1) & 0xffffffff; } var A = this._chain[0]; var B = this._chain[1]; var C = this._chain[2]; var D = this._chain[3]; var E = this._chain[4]; var F = this._chain[5]; var G = this._chain[6]; var H = this._chain[7]; for (var i = 0; i < 64; ++i) { var S0 = _rotr(A, 2) ^ _rotr(A, 13) ^ _rotr(A, 22); var maj = (A & B) ^ (A & C) ^ (B & C); var t2 = (S0 + maj) & 0xffffffff; var S1 = _rotr(E, 6) ^ _rotr(E, 11) ^ _rotr(E, 25); var ch = (E & F) ^ ((~E) & G); var t1 = (H + S1 + ch + k[i] + W[i]) & 0xffffffff; H = G; G = F; F = E; E = (D + t1) & 0xffffffff; D = C; C = B; B = A; A = (t1 + t2) & 0xffffffff; } this._chain[0] += A; this._chain[1] += B; this._chain[2] += C; this._chain[3] += D; this._chain[4] += E; this._chain[5] += F; this._chain[6] += G; this._chain[7] += H; }; /** Update the hash with additional data * @param {Array|Uint8Array} bytes The data * @param {number=} opt_length How many bytes to hash, if not all */ SHA256.prototype.update = function(bytes, opt_length) { if (!opt_length) opt_length = bytes.length; this._total += opt_length; for (var n = 0; n < opt_length; ++n) { this._buf[this._inbuf++] = bytes[n]; if (this._inbuf == 64) { this._compress(this._buf); this._inbuf = 0; } } }; /** Update the hash with a specified range from a data buffer * @param {Array} bytes The data buffer * @param {number} start Starting index of the range in bytes * @param {number} end End index, will not be included in range */ SHA256.prototype.updateRange = function(bytes, start, end) { this._total += (end - start); for (var n = start; n < end; ++n) { this._buf[this._inbuf++] = bytes[n]; if (this._inbuf == 64) { this._compress(this._buf); this._inbuf = 0; } } }; /** * Optionally update the hash with additional arguments, and return the * resulting hash value. * @param {...*} var_args Data buffers to hash * @return {Array} the SHA256 hash value. */ SHA256.prototype.digest = function(var_args) { for (var i = 0; i < arguments.length; ++i) this.update(arguments[i]); var digest = new Array(32); var totalBits = this._total * 8; // add pad 0x80 0x00* if (this._inbuf < 56) this.update(this._pad, 56 - this._inbuf); else this.update(this._pad, 64 - (this._inbuf - 56)); // add # bits, big endian for (var i = 63; i >= 56; --i) { this._buf[i] = totalBits & 255; totalBits >>>= 8; } this._compress(this._buf); var n = 0; for (var i = 0; i < 8; ++i) for (var j = 24; j >= 0; j -= 8) digest[n++] = (this._chain[i] >> j) & 255; return digest; }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides an interface representing the browser/extension * system's timer interface. */ 'use strict'; /** * An interface representing the browser/extension system's timer interface. * @interface */ function SystemTimer() {} /** * Sets a single-shot timer. * @param {function()} func Called back when the timer expires. * @param {number} timeoutMillis How long until the timer fires, in * milliseconds. * @return {number} A timeout ID, which can be used to cancel the timer. */ SystemTimer.prototype.setTimeout = function(func, timeoutMillis) {}; /** * Clears a previously set timer. * @param {number} timeoutId The ID of the timer to clear. */ SystemTimer.prototype.clearTimeout = function(timeoutId) {}; /** * Sets a repeating interval timer. * @param {function()} func Called back each time the timer fires. * @param {number} timeoutMillis How long until the timer fires, in * milliseconds. * @return {number} A timeout ID, which can be used to cancel the timer. */ SystemTimer.prototype.setInterval = function(func, timeoutMillis) {}; /** * Clears a previously set interval timer. * @param {number} timeoutId The ID of the timer to clear. */ SystemTimer.prototype.clearInterval = function(timeoutId) {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a low-level gnubby driver based on chrome.hid. */ 'use strict'; /** * Low level gnubby 'driver'. One per physical USB device. * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated * in. * @param {!chrome.hid.HidConnectInfo} dev The connection to the device. * @param {number} id The device's id. * @constructor * @implements {GnubbyDevice} */ function HidGnubbyDevice(gnubbies, dev, id) { /** @private {Gnubbies} */ this.gnubbies_ = gnubbies; this.dev = dev; this.id = id; this.txqueue = []; this.clients = []; this.lockCID = 0; // channel ID of client holding a lock, if != 0. this.lockMillis = 0; // current lock period. this.lockTID = null; // timer id of lock timeout. this.closing = false; // device to be closed by receive loop. this.updating = false; // device firmware is in final stage of updating. } /** * Namespace for the HidGnubbyDevice implementation. * @const */ HidGnubbyDevice.NAMESPACE = 'hid'; /** Destroys this low-level device instance. */ HidGnubbyDevice.prototype.destroy = function() { if (!this.dev) return; // Already dead. this.gnubbies_.removeOpenDevice( {namespace: HidGnubbyDevice.NAMESPACE, device: this.id}); this.closing = true; console.log(UTIL_fmt('HidGnubbyDevice.destroy()')); // Synthesize a close error frame to alert all clients, // some of which might be in read state. // // Use magic CID 0 to address all. this.publishFrame_(new Uint8Array([ 0, 0, 0, 0, // broadcast CID GnubbyDevice.CMD_ERROR, 0, 1, // length GnubbyDevice.GONE]).buffer); // Set all clients to closed status and remove them. while (this.clients.length != 0) { var client = this.clients.shift(); if (client) client.closed = true; } if (this.lockTID) { window.clearTimeout(this.lockTID); this.lockTID = null; } var dev = this.dev; this.dev = null; chrome.hid.disconnect(dev.connectionId, function() { if (chrome.runtime.lastError) { console.warn(UTIL_fmt('Device ' + dev.connectionId + ' couldn\'t be disconnected:')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); return; } console.log(UTIL_fmt('Device ' + dev.connectionId + ' closed')); }); }; /** * Push frame to all clients. * @param {ArrayBuffer} f Data to push * @private */ HidGnubbyDevice.prototype.publishFrame_ = function(f) { var old = this.clients; var remaining = []; var changes = false; for (var i = 0; i < old.length; ++i) { var client = old[i]; if (client.receivedFrame(f)) { // Client still alive; keep on list. remaining.push(client); } else { changes = true; console.log(UTIL_fmt( '[' + Gnubby.hexCid(client.cid) + '] left?')); } } if (changes) this.clients = remaining; }; /** * Register a client for this gnubby. * @param {*} who The client. */ HidGnubbyDevice.prototype.registerClient = function(who) { for (var i = 0; i < this.clients.length; ++i) { if (this.clients[i] === who) return; // Already registered. } this.clients.push(who); if (this.clients.length == 1) { // First client? Kick off read loop. this.readLoop_(); } }; /** * De-register a client. * @param {*} who The client. * @return {number} The number of remaining listeners for this device, or -1 * Returns number of remaining listeners for this device. * if this had no clients to start with. */ HidGnubbyDevice.prototype.deregisterClient = function(who) { var current = this.clients; if (current.length == 0) return -1; this.clients = []; for (var i = 0; i < current.length; ++i) { var client = current[i]; if (client !== who) this.clients.push(client); } return this.clients.length; }; /** * @param {*} who The client. * @return {boolean} Whether this device has who as a client. */ HidGnubbyDevice.prototype.hasClient = function(who) { if (this.clients.length == 0) return false; for (var i = 0; i < this.clients.length; ++i) { if (who === this.clients[i]) return true; } return false; }; /** * Reads all incoming frames and notifies clients of their receipt. * @private */ HidGnubbyDevice.prototype.readLoop_ = function() { //console.log(UTIL_fmt('entering readLoop')); if (!this.dev) return; if (this.closing) { this.destroy(); return; } // No interested listeners, yet we hit readLoop(). // Must be clean-up. We do this here to make sure no transfer is pending. if (!this.clients.length) { this.closing = true; this.destroy(); return; } // firmwareUpdate() sets this.updating when writing the last block before // the signature. We process that reply with the already pending // read transfer but we do not want to start another read transfer for the // signature block, since that request will have no reply. // Instead we will see the device drop and re-appear on the bus. // Current libusb on some platforms gets unhappy when transfer are pending // when that happens. // TODO: revisit once Chrome stabilizes its behavior. if (this.updating) { console.log(UTIL_fmt('device updating. Ending readLoop()')); return; } var self = this; chrome.hid.receive( this.dev.connectionId, function(report_id, data) { if (chrome.runtime.lastError || !data) { console.log(UTIL_fmt('receive got lastError:')); console.log(UTIL_fmt(chrome.runtime.lastError.message)); window.setTimeout(function() { self.destroy(); }, 0); return; } var u8 = new Uint8Array(data); console.log(UTIL_fmt('<' + UTIL_BytesToHex(u8))); self.publishFrame_(data); // Read more. window.setTimeout(function() { self.readLoop_(); }, 0); } ); }; /** * Check whether channel is locked for this request or not. * @param {number} cid Channel id * @param {number} cmd Request command * @return {boolean} true if not locked for this request. * @private */ HidGnubbyDevice.prototype.checkLock_ = function(cid, cmd) { if (this.lockCID) { // We have an active lock. if (this.lockCID != cid) { // Some other channel has active lock. if (cmd != GnubbyDevice.CMD_SYNC && cmd != GnubbyDevice.CMD_INIT) { // Anything but SYNC|INIT gets an immediate busy. var busy = new Uint8Array( [(cid >> 24) & 255, (cid >> 16) & 255, (cid >> 8) & 255, cid & 255, GnubbyDevice.CMD_ERROR, 0, 1, // length GnubbyDevice.BUSY]); // Log the synthetic busy too. console.log(UTIL_fmt('<' + UTIL_BytesToHex(busy))); this.publishFrame_(busy.buffer); return false; } // SYNC|INIT gets to go to the device to flush OS tx/rx queues. // The usb firmware is to alway respond to SYNC/INIT, // regardless of lock status. } } return true; }; /** * Update or grab lock. * @param {number} cid Channel ID * @param {number} cmd Command * @param {number} arg Command argument * @private */ HidGnubbyDevice.prototype.updateLock_ = function(cid, cmd, arg) { if (this.lockCID == 0 || this.lockCID == cid) { // It is this caller's or nobody's lock. if (this.lockTID) { window.clearTimeout(this.lockTID); this.lockTID = null; } if (cmd == GnubbyDevice.CMD_LOCK) { var nseconds = arg; if (nseconds != 0) { this.lockCID = cid; // Set tracking time to be .1 seconds longer than usb device does. this.lockMillis = nseconds * 1000 + 100; } else { // Releasing lock voluntarily. this.lockCID = 0; } } // (re)set the lock timeout if we still hold it. if (this.lockCID) { var self = this; this.lockTID = window.setTimeout( function() { console.warn(UTIL_fmt( 'lock for CID ' + Gnubby.hexCid(cid) + ' expired!')); self.lockTID = null; self.lockCID = 0; }, this.lockMillis); } } }; /** * Queue command to be sent. * If queue was empty, initiate the write. * @param {number} cid The client's channel ID. * @param {number} cmd The command to send. * @param {ArrayBuffer|Uint8Array} data Command arguments */ HidGnubbyDevice.prototype.queueCommand = function(cid, cmd, data) { if (!this.dev) return; if (!this.checkLock_(cid, cmd)) return; var u8 = new Uint8Array(data); var f = new Uint8Array(64); HidGnubbyDevice.setCid_(f, cid); f[4] = cmd; f[5] = (u8.length >> 8); f[6] = (u8.length & 255); var lockArg = (u8.length > 0) ? u8[0] : 0; // Fragment over our 64 byte frames. var n = 7; var seq = 0; for (var i = 0; i < u8.length; ++i) { f[n++] = u8[i]; if (n == f.length) { this.queueFrame_(f.buffer, cid, cmd, lockArg); f = new Uint8Array(64); HidGnubbyDevice.setCid_(f, cid); cmd = f[4] = seq++; n = 5; } } if (n != 5) { this.queueFrame_(f.buffer, cid, cmd, lockArg); } }; /** * Sets the channel id in the frame. * @param {Uint8Array} frame Data frame * @param {number} cid The client's channel ID. * @private */ HidGnubbyDevice.setCid_ = function(frame, cid) { frame[0] = cid >>> 24; frame[1] = cid >>> 16; frame[2] = cid >>> 8; frame[3] = cid; }; /** * Updates the lock, and queues the frame for sending. Also begins sending if * no other writes are outstanding. * @param {ArrayBuffer} frame Data frame * @param {number} cid The client's channel ID. * @param {number} cmd The command to send. * @param {number} arg Command argument * @private */ HidGnubbyDevice.prototype.queueFrame_ = function(frame, cid, cmd, arg) { this.updateLock_(cid, cmd, arg); var wasEmpty = (this.txqueue.length == 0); this.txqueue.push(frame); if (wasEmpty) this.writePump_(); }; /** * Stuff queued frames from txqueue[] to device, one by one. * @private */ HidGnubbyDevice.prototype.writePump_ = function() { if (!this.dev) return; // Ignore. if (this.txqueue.length == 0) return; // Done with current queue. var frame = this.txqueue[0]; var self = this; function transferComplete() { if (chrome.runtime.lastError) { console.log(UTIL_fmt('send got lastError:')); console.log(UTIL_fmt(chrome.runtime.lastError.message)); window.setTimeout(function() { self.destroy(); }, 0); return; } self.txqueue.shift(); // drop sent frame from queue. if (self.txqueue.length != 0) { window.setTimeout(function() { self.writePump_(); }, 0); } }; var u8 = new Uint8Array(frame); // See whether this requires scrubbing before logging. var alternateLog = Gnubby.hasOwnProperty('redactRequestLog') && Gnubby['redactRequestLog'](u8); if (alternateLog) { console.log(UTIL_fmt('>' + alternateLog)); } else { console.log(UTIL_fmt('>' + UTIL_BytesToHex(u8))); } var u8f = new Uint8Array(64); for (var i = 0; i < u8.length; ++i) { u8f[i] = u8[i]; } chrome.hid.send( this.dev.connectionId, 0, // report Id. Must be 0 for our use. u8f.buffer, transferComplete ); }; /** * List of legacy HID devices that do not support the F1D0 usage page as * mandated by the spec, but still need to be supported. * TODO: remove when these devices no longer need to be supported. * @const */ HidGnubbyDevice.HID_VID_PIDS = [ {'vendorId': 4176, 'productId': 512} // Google-specific Yubico HID ]; /** * @param {function(Array)} cb Enumeration callback */ HidGnubbyDevice.enumerate = function(cb) { /** * One pass using getDevices, and one for each of the hardcoded vid/pids. * @const */ var ENUMERATE_PASSES = 1 + HidGnubbyDevice.HID_VID_PIDS.length; var numEnumerated = 0; var allDevs = []; function enumerated(f1d0Enumerated, devs) { // Don't double-add a device; it'll just confuse things. // We assume the various calls to getDevices() return from the same // deviceId pool. for (var i = 0; i < devs.length; i++) { var dev = devs[i]; dev.f1d0Only = f1d0Enumerated; // Unfortunately indexOf is not usable, since the two calls produce // different objects. Compare their deviceIds instead. var found = false; for (var j = 0; j < allDevs.length; j++) { if (allDevs[j].deviceId == dev.deviceId) { found = true; allDevs[j].f1d0Only &= f1d0Enumerated; break; } } if (!found) { allDevs.push(dev); } } if (++numEnumerated == ENUMERATE_PASSES) { cb(allDevs); } } // Pass 1: usagePage-based enumeration. chrome.hid.getDevices({filters: [{usagePage: 0xf1d0}]}, enumerated.bind(null, true)); // Pass 2: vid/pid-based enumeration, for legacy devices. for (var i = 0; i < HidGnubbyDevice.HID_VID_PIDS.length; i++) { var dev = HidGnubbyDevice.HID_VID_PIDS[i]; chrome.hid.getDevices({filters: [dev]}, enumerated.bind(null, false)); } }; /** * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated * in. * @param {number} which The index of the device to open. * @param {!chrome.hid.HidDeviceInfo} dev The device to open. * @param {function(number, GnubbyDevice=)} cb Called back with the * result of opening the device. */ HidGnubbyDevice.open = function(gnubbies, which, dev, cb) { chrome.hid.connect(dev.deviceId, function(handle) { if (chrome.runtime.lastError) { console.log(UTIL_fmt('connect got lastError:')); console.log(UTIL_fmt(chrome.runtime.lastError.message)); } if (!handle) { console.warn(UTIL_fmt('failed to connect device. permissions issue?')); cb(-GnubbyDevice.NODEVICE); return; } var nonNullHandle = /** @type {!chrome.hid.HidConnectInfo} */ (handle); var gnubby = new HidGnubbyDevice(gnubbies, nonNullHandle, which); cb(-GnubbyDevice.OK, gnubby); }); }; /** * @param {*} dev A browser API device object * @return {GnubbyDeviceId} A device identifier for the device. */ HidGnubbyDevice.deviceToDeviceId = function(dev) { var hidDev = /** @type {!chrome.hid.HidDeviceInfo} */ (dev); var deviceId = { namespace: HidGnubbyDevice.NAMESPACE, device: hidDev.deviceId }; return deviceId; }; /** * Registers this implementation with gnubbies. * @param {Gnubbies} gnubbies Gnubbies registry */ HidGnubbyDevice.register = function(gnubbies) { var HID_GNUBBY_IMPL = { isSharedAccess: true, enumerate: HidGnubbyDevice.enumerate, deviceToDeviceId: HidGnubbyDevice.deviceToDeviceId, open: HidGnubbyDevice.open }; gnubbies.registerNamespace(HidGnubbyDevice.NAMESPACE, HID_GNUBBY_IMPL); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a low-level gnubby driver based on chrome.usb. */ 'use strict'; /** * Low level gnubby 'driver'. One per physical USB device. * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated * in. * @param {!chrome.usb.ConnectionHandle} dev The device. * @param {number} id The device's id. * @param {number} inEndpoint The device's in endpoint. * @param {number} outEndpoint The device's out endpoint. * @constructor * @implements {GnubbyDevice} */ function UsbGnubbyDevice(gnubbies, dev, id, inEndpoint, outEndpoint) { /** @private {Gnubbies} */ this.gnubbies_ = gnubbies; this.dev = dev; this.id = id; this.inEndpoint = inEndpoint; this.outEndpoint = outEndpoint; this.txqueue = []; this.clients = []; this.lockCID = 0; // channel ID of client holding a lock, if != 0. this.lockMillis = 0; // current lock period. this.lockTID = null; // timer id of lock timeout. this.closing = false; // device to be closed by receive loop. this.updating = false; // device firmware is in final stage of updating. this.inTransferPending = false; this.outTransferPending = false; } /** * Namespace for the UsbGnubbyDevice implementation. * @const */ UsbGnubbyDevice.NAMESPACE = 'usb'; /** Destroys this low-level device instance. */ UsbGnubbyDevice.prototype.destroy = function() { if (!this.dev) return; // Already dead. this.gnubbies_.removeOpenDevice( {namespace: UsbGnubbyDevice.NAMESPACE, device: this.id}); this.closing = true; console.log(UTIL_fmt('UsbGnubbyDevice.destroy()')); // Synthesize a close error frame to alert all clients, // some of which might be in read state. // // Use magic CID 0 to address all. this.publishFrame_(new Uint8Array([ 0, 0, 0, 0, // broadcast CID GnubbyDevice.CMD_ERROR, 0, 1, // length GnubbyDevice.GONE]).buffer); // Set all clients to closed status and remove them. while (this.clients.length != 0) { var client = this.clients.shift(); if (client) client.closed = true; } if (this.lockTID) { window.clearTimeout(this.lockTID); this.lockTID = null; } var dev = this.dev; this.dev = null; chrome.usb.releaseInterface(dev, 0, function() { if (chrome.runtime.lastError) { console.warn(UTIL_fmt('Device ' + dev.handle + ' couldn\'t be released:')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); return; } console.log(UTIL_fmt('Device ' + dev.handle + ' released')); chrome.usb.closeDevice(dev, function() { if (chrome.runtime.lastError) { console.warn(UTIL_fmt('Device ' + dev.handle + ' couldn\'t be closed:')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); return; } console.log(UTIL_fmt('Device ' + dev.handle + ' closed')); }); }); }; /** * Push frame to all clients. * @param {ArrayBuffer} f Data frame * @private */ UsbGnubbyDevice.prototype.publishFrame_ = function(f) { var old = this.clients; var remaining = []; var changes = false; for (var i = 0; i < old.length; ++i) { var client = old[i]; if (client.receivedFrame(f)) { // Client still alive; keep on list. remaining.push(client); } else { changes = true; console.log(UTIL_fmt( '[' + Gnubby.hexCid(client.cid) + '] left?')); } } if (changes) this.clients = remaining; }; /** * @return {boolean} whether this device is open and ready to use. * @private */ UsbGnubbyDevice.prototype.readyToUse_ = function() { if (this.closing) return false; if (!this.dev) return false; return true; }; /** * Reads one reply from the low-level device. * @private */ UsbGnubbyDevice.prototype.readOneReply_ = function() { if (!this.readyToUse_()) return; // No point in continuing. if (this.updating) return; // Do not bother waiting for final update reply. var self = this; function inTransferComplete(x) { self.inTransferPending = false; if (!self.readyToUse_()) return; // No point in continuing. if (chrome.runtime.lastError) { console.warn(UTIL_fmt('in bulkTransfer got lastError: ')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); window.setTimeout(function() { self.destroy(); }, 0); return; } if (x.data) { var u8 = new Uint8Array(x.data); console.log(UTIL_fmt('<' + UTIL_BytesToHex(u8))); self.publishFrame_(x.data); // Write another pending request, if any. window.setTimeout( function() { self.txqueue.shift(); // Drop sent frame from queue. self.writeOneRequest_(); }, 0); } else { console.log(UTIL_fmt('no x.data!')); console.log(x); window.setTimeout(function() { self.destroy(); }, 0); } } if (this.inTransferPending == false) { this.inTransferPending = true; chrome.usb.bulkTransfer( /** @type {!chrome.usb.ConnectionHandle} */(this.dev), { direction: 'in', endpoint: this.inEndpoint, length: 2048 }, inTransferComplete); } else { throw 'inTransferPending!'; } }; /** * Register a client for this gnubby. * @param {*} who The client. */ UsbGnubbyDevice.prototype.registerClient = function(who) { for (var i = 0; i < this.clients.length; ++i) { if (this.clients[i] === who) return; // Already registered. } this.clients.push(who); }; /** * De-register a client. * @param {*} who The client. * @return {number} The number of remaining listeners for this device, or -1 * Returns number of remaining listeners for this device. * if this had no clients to start with. */ UsbGnubbyDevice.prototype.deregisterClient = function(who) { var current = this.clients; if (current.length == 0) return -1; this.clients = []; for (var i = 0; i < current.length; ++i) { var client = current[i]; if (client !== who) this.clients.push(client); } return this.clients.length; }; /** * @param {*} who The client. * @return {boolean} Whether this device has who as a client. */ UsbGnubbyDevice.prototype.hasClient = function(who) { if (this.clients.length == 0) return false; for (var i = 0; i < this.clients.length; ++i) { if (who === this.clients[i]) return true; } return false; }; /** * Stuff queued frames from txqueue[] to device, one by one. * @private */ UsbGnubbyDevice.prototype.writeOneRequest_ = function() { if (!this.readyToUse_()) return; // No point in continuing. if (this.txqueue.length == 0) return; // Nothing to send. var frame = this.txqueue[0]; var self = this; function OutTransferComplete(x) { self.outTransferPending = false; if (!self.readyToUse_()) return; // No point in continuing. if (chrome.runtime.lastError) { console.warn(UTIL_fmt('out bulkTransfer lastError: ')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); window.setTimeout(function() { self.destroy(); }, 0); return; } window.setTimeout(function() { self.readOneReply_(); }, 0); }; var u8 = new Uint8Array(frame); // See whether this requires scrubbing before logging. var alternateLog = Gnubby.hasOwnProperty('redactRequestLog') && Gnubby['redactRequestLog'](u8); if (alternateLog) { console.log(UTIL_fmt('>' + alternateLog)); } else { console.log(UTIL_fmt('>' + UTIL_BytesToHex(u8))); } if (this.outTransferPending == false) { this.outTransferPending = true; chrome.usb.bulkTransfer( /** @type {!chrome.usb.ConnectionHandle} */(this.dev), { direction: 'out', endpoint: this.outEndpoint, data: frame }, OutTransferComplete); } else { throw 'outTransferPending!'; } }; /** * Check whether channel is locked for this request or not. * @param {number} cid Channel id * @param {number} cmd Command to be sent * @return {boolean} true if not locked for this request. * @private */ UsbGnubbyDevice.prototype.checkLock_ = function(cid, cmd) { if (this.lockCID) { // We have an active lock. if (this.lockCID != cid) { // Some other channel has active lock. if (cmd != GnubbyDevice.CMD_SYNC && cmd != GnubbyDevice.CMD_INIT) { // Anything but SYNC|INIT gets an immediate busy. var busy = new Uint8Array( [(cid >> 24) & 255, (cid >> 16) & 255, (cid >> 8) & 255, cid & 255, GnubbyDevice.CMD_ERROR, 0, 1, // length GnubbyDevice.BUSY]); // Log the synthetic busy too. console.log(UTIL_fmt('<' + UTIL_BytesToHex(busy))); this.publishFrame_(busy.buffer); return false; } // SYNC|INIT get to go to the device to flush OS tx/rx queues. // The usb firmware is to always respond to SYNC|INIT, // regardless of lock status. } } return true; }; /** * Update or grab lock. * @param {number} cid Channel id * @param {number} cmd Command * @param {number} arg Command argument * @private */ UsbGnubbyDevice.prototype.updateLock_ = function(cid, cmd, arg) { if (this.lockCID == 0 || this.lockCID == cid) { // It is this caller's or nobody's lock. if (this.lockTID) { window.clearTimeout(this.lockTID); this.lockTID = null; } if (cmd == GnubbyDevice.CMD_LOCK) { var nseconds = arg; if (nseconds != 0) { this.lockCID = cid; // Set tracking time to be .1 seconds longer than usb device does. this.lockMillis = nseconds * 1000 + 100; } else { // Releasing lock voluntarily. this.lockCID = 0; } } // (re)set the lock timeout if we still hold it. if (this.lockCID) { var self = this; this.lockTID = window.setTimeout( function() { console.warn(UTIL_fmt( 'lock for CID ' + Gnubby.hexCid(cid) + ' expired!')); self.lockTID = null; self.lockCID = 0; }, this.lockMillis); } } }; /** * Queue command to be sent. * If queue was empty, initiate the write. * @param {number} cid The client's channel ID. * @param {number} cmd The command to send. * @param {ArrayBuffer|Uint8Array} data Command argument data */ UsbGnubbyDevice.prototype.queueCommand = function(cid, cmd, data) { if (!this.dev) return; if (!this.checkLock_(cid, cmd)) return; var u8 = new Uint8Array(data); var frame = new Uint8Array(u8.length + 7); frame[0] = cid >>> 24; frame[1] = cid >>> 16; frame[2] = cid >>> 8; frame[3] = cid; frame[4] = cmd; frame[5] = (u8.length >> 8); frame[6] = (u8.length & 255); frame.set(u8, 7); var lockArg = (u8.length > 0) ? u8[0] : 0; this.updateLock_(cid, cmd, lockArg); var wasEmpty = (this.txqueue.length == 0); this.txqueue.push(frame.buffer); if (wasEmpty) this.writeOneRequest_(); }; /** * @const */ UsbGnubbyDevice.WINUSB_VID_PIDS = [ {'vendorId': 4176, 'productId': 529} // Yubico WinUSB ]; /** * @param {function(Array)} cb Enumerate callback */ UsbGnubbyDevice.enumerate = function(cb) { var numEnumerated = 0; var allDevs = []; function enumerated(devs) { allDevs = allDevs.concat(devs); if (++numEnumerated == UsbGnubbyDevice.WINUSB_VID_PIDS.length) { cb(allDevs); } } for (var i = 0; i < UsbGnubbyDevice.WINUSB_VID_PIDS.length; i++) { chrome.usb.getDevices(UsbGnubbyDevice.WINUSB_VID_PIDS[i], enumerated); } }; /** * @typedef {?{ * address: number, * type: string, * direction: string, * maximumPacketSize: number, * synchronization: (string|undefined), * usage: (string|undefined), * pollingInterval: (number|undefined) * }} * @see http://developer.chrome.com/apps/usb.html#method-listInterfaces */ var InterfaceEndpoint; /** * @typedef {?{ * interfaceNumber: number, * alternateSetting: number, * interfaceClass: number, * interfaceSubclass: number, * interfaceProtocol: number, * description: (string|undefined), * endpoints: !Array * }} * @see http://developer.chrome.com/apps/usb.html#method-listInterfaces */ var InterfaceDescriptor; /** * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated * in. * @param {number} which The index of the device to open. * @param {!chrome.usb.Device} dev The device to open. * @param {function(number, GnubbyDevice=)} cb Called back with the * result of opening the device. */ UsbGnubbyDevice.open = function(gnubbies, which, dev, cb) { /** @param {chrome.usb.ConnectionHandle=} handle Connection handle */ function deviceOpened(handle) { if (chrome.runtime.lastError) { console.warn(UTIL_fmt('openDevice got lastError:')); console.warn(UTIL_fmt(chrome.runtime.lastError.message)); console.warn(UTIL_fmt('failed to open device. permissions issue?')); cb(-GnubbyDevice.NODEVICE); return; } var nonNullHandle = /** @type {!chrome.usb.ConnectionHandle} */ (handle); chrome.usb.listInterfaces(nonNullHandle, function(descriptors) { var inEndpoint, outEndpoint; for (var i = 0; i < descriptors.length; i++) { var descriptor = /** @type {InterfaceDescriptor} */ (descriptors[i]); for (var j = 0; j < descriptor.endpoints.length; j++) { var endpoint = descriptor.endpoints[j]; if (inEndpoint == undefined && endpoint.type == 'bulk' && endpoint.direction == 'in') { inEndpoint = endpoint.address; } if (outEndpoint == undefined && endpoint.type == 'bulk' && endpoint.direction == 'out') { outEndpoint = endpoint.address; } } } if (inEndpoint == undefined || outEndpoint == undefined) { console.warn(UTIL_fmt('device lacking an endpoint (broken?)')); chrome.usb.closeDevice(nonNullHandle); cb(-GnubbyDevice.NODEVICE); return; } // Try getting it claimed now. chrome.usb.claimInterface(nonNullHandle, 0, function() { if (chrome.runtime.lastError) { console.warn(UTIL_fmt('lastError: ' + chrome.runtime.lastError)); console.log(chrome.runtime.lastError); } var claimed = !chrome.runtime.lastError; if (!claimed) { console.warn(UTIL_fmt('failed to claim interface. busy?')); // Claim failed? Let the callers know and bail out. chrome.usb.closeDevice(nonNullHandle); cb(-GnubbyDevice.BUSY); return; } var gnubby = new UsbGnubbyDevice(gnubbies, nonNullHandle, which, inEndpoint, outEndpoint); cb(-GnubbyDevice.OK, gnubby); }); }); } if (UsbGnubbyDevice.runningOnCrOS === undefined) { UsbGnubbyDevice.runningOnCrOS = (window.navigator.appVersion.indexOf('; CrOS ') != -1); } if (UsbGnubbyDevice.runningOnCrOS) { chrome.usb.requestAccess(dev, 0, function(success) { // Even though the argument to requestAccess is a chrome.usb.Device, the // access request is for access to all devices with the same vid/pid. // Curiously, if the first chrome.usb.requestAccess succeeds, a second // call with a separate device with the same vid/pid fails. Since // chrome.usb.openDevice will fail if a previous access request really // failed, just ignore the outcome of the access request and move along. chrome.usb.openDevice(dev, deviceOpened); }); } else { chrome.usb.openDevice(dev, deviceOpened); } }; /** * @param {*} dev Chrome usb device * @return {GnubbyDeviceId} A device identifier for the device. */ UsbGnubbyDevice.deviceToDeviceId = function(dev) { var usbDev = /** @type {!chrome.usb.Device} */ (dev); var deviceId = { namespace: UsbGnubbyDevice.NAMESPACE, device: usbDev.device }; return deviceId; }; /** * Registers this implementation with gnubbies. * @param {Gnubbies} gnubbies Gnubbies singleton instance */ UsbGnubbyDevice.register = function(gnubbies) { var USB_GNUBBY_IMPL = { isSharedAccess: false, enumerate: UsbGnubbyDevice.enumerate, deviceToDeviceId: UsbGnubbyDevice.deviceToDeviceId, open: UsbGnubbyDevice.open }; gnubbies.registerNamespace(UsbGnubbyDevice.NAMESPACE, USB_GNUBBY_IMPL); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A class for managing all enumerated gnubby devices. */ 'use strict'; /** * @typedef {{ * namespace: string, * device: number * }} */ var GnubbyDeviceId; /** * @typedef {{ * isSharedAccess: boolean, * enumerate: function(function(Array)), * deviceToDeviceId: function(*): GnubbyDeviceId, * open: function(Gnubbies, number, *, function(number, GnubbyDevice=)) * }} */ var GnubbyNamespaceImpl; /** * Manager of opened devices. * @constructor */ function Gnubbies() { /** @private {Object} */ this.devs_ = {}; this.pendingEnumerate = []; // clients awaiting an enumerate /** * The distinct namespaces registered in this Gnubbies instance, in order of * registration. * @private {Array} */ this.namespaces_ = []; /** @private {Object} */ this.impl_ = {}; /** @private {Object>} */ this.openDevs_ = {}; /** @private {Object>} */ this.pendingOpens_ = {}; // clients awaiting an open } /** * Registers a new gnubby namespace, i.e. an implementation of the * enumerate/open functions for all devices within a namespace. * @param {string} namespace The namespace of the numerator, e.g. 'usb'. * @param {GnubbyNamespaceImpl} impl The implementation. */ Gnubbies.prototype.registerNamespace = function(namespace, impl) { if (!this.impl_.hasOwnProperty(namespace)) { this.namespaces_.push(namespace); } this.impl_[namespace] = impl; }; /** * @param {GnubbyDeviceId} id The device id. * @return {boolean} Whether the device is a shared access device. */ Gnubbies.prototype.isSharedAccess = function(id) { if (!this.impl_.hasOwnProperty(id.namespace)) return false; return this.impl_[id.namespace].isSharedAccess; }; /** * @param {GnubbyDeviceId} which The device to remove. */ Gnubbies.prototype.removeOpenDevice = function(which) { if (this.openDevs_[which.namespace] && this.openDevs_[which.namespace].hasOwnProperty(which.device)) { delete this.openDevs_[which.namespace][which.device]; } }; /** Close all enumerated devices. */ Gnubbies.prototype.closeAll = function() { if (this.inactivityTimer) { this.inactivityTimer.clearTimeout(); this.inactivityTimer = undefined; } // Close and stop talking to any gnubbies we have enumerated. for (var namespace in this.openDevs_) { for (var dev in this.openDevs_[namespace]) { var deviceId = Number(dev); this.openDevs_[namespace][deviceId].destroy(); } } this.devs_ = {}; this.openDevs_ = {}; }; /** * @param {function(number, Array)} cb Called back with the * result of enumerating. */ Gnubbies.prototype.enumerate = function(cb) { if (!cb) { cb = function(rc, indexes) { var msg = 'defaultEnumerateCallback(' + rc; if (indexes) { msg += ', ['; for (var i = 0; i < indexes.length; i++) { msg += JSON.stringify(indexes[i]); } msg += ']'; } msg += ')'; console.log(UTIL_fmt(msg)); }; } if (!this.namespaces_.length) { cb(-GnubbyDevice.OK, []); return; } var namespacesEnumerated = 0; var self = this; /** * @param {string} namespace The namespace that was enumerated. * @param {Array} existingDeviceIds Previously enumerated * device IDs (from other namespaces), if any. * @param {Array} devs The devices in the namespace. */ function enumerated(namespace, existingDeviceIds, devs) { namespacesEnumerated++; var lastNamespace = (namespacesEnumerated == self.namespaces_.length); if (chrome.runtime.lastError) { console.warn(UTIL_fmt('lastError: ' + chrome.runtime.lastError)); console.log(chrome.runtime.lastError); devs = []; } console.log(UTIL_fmt('Enumerated ' + devs.length + ' gnubbies')); console.log(devs); var presentDevs = {}; var deviceIds = []; var deviceToDeviceId = self.impl_[namespace].deviceToDeviceId; for (var i = 0; i < devs.length; ++i) { var deviceId = deviceToDeviceId(devs[i]); deviceIds.push(deviceId); presentDevs[deviceId.device] = devs[i]; } var toRemove = []; for (var dev in self.openDevs_[namespace]) { if (!presentDevs.hasOwnProperty(dev)) { toRemove.push(dev); } } for (var i = 0; i < toRemove.length; i++) { dev = toRemove[i]; if (self.openDevs_[namespace][dev]) { self.openDevs_[namespace][dev].destroy(); delete self.openDevs_[namespace][dev]; } } self.devs_[namespace] = devs; existingDeviceIds.push.apply(existingDeviceIds, deviceIds); if (lastNamespace) { while (self.pendingEnumerate.length != 0) { var cb = self.pendingEnumerate.shift(); cb(-GnubbyDevice.OK, existingDeviceIds); } } } var deviceIds = []; function makeEnumerateCb(namespace) { return function(devs) { enumerated(namespace, deviceIds, devs); } } this.pendingEnumerate.push(cb); if (this.pendingEnumerate.length == 1) { for (var i = 0; i < this.namespaces_.length; i++) { var namespace = this.namespaces_[i]; var enumerator = this.impl_[namespace].enumerate; enumerator(makeEnumerateCb(namespace)); } } }; /** * Amount of time past last activity to set the inactivity timer to, in millis. * @const */ Gnubbies.INACTIVITY_TIMEOUT_MARGIN_MILLIS = 30000; /** * Private instance of timers based on window's timer functions. * @const * @private */ Gnubbies.SYS_TIMER_ = new WindowTimer(); /** * @param {number|undefined} opt_timeoutMillis Timeout in milliseconds */ Gnubbies.prototype.resetInactivityTimer = function(opt_timeoutMillis) { var millis = opt_timeoutMillis ? opt_timeoutMillis + Gnubbies.INACTIVITY_TIMEOUT_MARGIN_MILLIS : Gnubbies.INACTIVITY_TIMEOUT_MARGIN_MILLIS; if (!this.inactivityTimer) { this.inactivityTimer = new CountdownTimer( Gnubbies.SYS_TIMER_, millis, this.inactivityTimeout_.bind(this)); } else if (millis > this.inactivityTimer.millisecondsUntilExpired()) { this.inactivityTimer.clearTimeout(); this.inactivityTimer.setTimeout(millis, this.inactivityTimeout_.bind(this)); } }; /** * Called when the inactivity timeout expires. * @private */ Gnubbies.prototype.inactivityTimeout_ = function() { this.inactivityTimer = undefined; for (var namespace in this.openDevs_) { for (var dev in this.openDevs_[namespace]) { var deviceId = Number(dev); console.warn(namespace + ' device ' + deviceId + ' still open after inactivity, closing'); this.openDevs_[namespace][deviceId].destroy(); } } }; /** * Opens and adds a new client of the specified device. * @param {GnubbyDeviceId} which Which device to open. * @param {*} who Client of the device. * @param {function(number, GnubbyDevice=)} cb Called back with the result of * opening the device. */ Gnubbies.prototype.addClient = function(which, who, cb) { this.resetInactivityTimer(); var self = this; function opened(gnubby, who, cb) { if (gnubby.closing) { // Device is closing or already closed. self.removeClient(gnubby, who); if (cb) { cb(-GnubbyDevice.NODEVICE); } } else { gnubby.registerClient(who); if (cb) { cb(-GnubbyDevice.OK, gnubby); } } } function notifyOpenResult(rc) { if (self.pendingOpens_[which.namespace]) { while (self.pendingOpens_[which.namespace][which.device].length != 0) { var client = self.pendingOpens_[which.namespace][which.device].shift(); client.cb(rc); } delete self.pendingOpens_[which.namespace][which.device]; } } var dev = null; var deviceToDeviceId = this.impl_[which.namespace].deviceToDeviceId; if (this.devs_[which.namespace]) { for (var i = 0; i < this.devs_[which.namespace].length; i++) { var device = this.devs_[which.namespace][i]; if (deviceToDeviceId(device).device == which.device) { dev = device; break; } } } if (!dev) { // Index out of bounds. Device does not exist in current enumeration. this.removeClient(null, who); if (cb) { cb(-GnubbyDevice.NODEVICE); } return; } function openCb(rc, opt_gnubby) { if (rc) { notifyOpenResult(rc); return; } if (!opt_gnubby) { notifyOpenResult(-GnubbyDevice.NODEVICE); return; } var gnubby = /** @type {!GnubbyDevice} */ (opt_gnubby); if (!self.openDevs_[which.namespace]) { self.openDevs_[which.namespace] = {}; } self.openDevs_[which.namespace][which.device] = gnubby; while (self.pendingOpens_[which.namespace][which.device].length != 0) { var client = self.pendingOpens_[which.namespace][which.device].shift(); opened(gnubby, client.who, client.cb); } delete self.pendingOpens_[which.namespace][which.device]; } if (this.openDevs_[which.namespace] && this.openDevs_[which.namespace].hasOwnProperty(which.device)) { var gnubby = this.openDevs_[which.namespace][which.device]; opened(gnubby, who, cb); } else { var opener = {who: who, cb: cb}; if (!this.pendingOpens_.hasOwnProperty(which.namespace)) { this.pendingOpens_[which.namespace] = {}; } if (this.pendingOpens_[which.namespace].hasOwnProperty(which.device)) { this.pendingOpens_[which.namespace][which.device].push(opener); } else { this.pendingOpens_[which.namespace][which.device] = [opener]; var openImpl = this.impl_[which.namespace].open; openImpl(this, which.device, dev, openCb); } } }; /** * Removes a client from a low-level gnubby. * @param {GnubbyDevice} whichDev The gnubby. * @param {*} who The client. */ Gnubbies.prototype.removeClient = function(whichDev, who) { console.log(UTIL_fmt('Gnubbies.removeClient()')); this.resetInactivityTimer(); // De-register client from all known devices. for (var namespace in this.openDevs_) { for (var devId in this.openDevs_[namespace]) { var deviceId = Number(devId); if (isNaN(deviceId)) deviceId = devId; var dev = this.openDevs_[namespace][deviceId]; if (dev.hasClient(who)) { if (whichDev && dev != whichDev) { console.warn('Gnubby attached to more than one device!?'); } if (!dev.deregisterClient(who)) { dev.destroy(); } } } } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a client view of a gnubby, aka USB security key. */ 'use strict'; /** * Creates a Gnubby client. There may be more than one simultaneous Gnubby * client of a physical device. This client manages multiplexing access to the * low-level device to maintain the illusion that it is the only client of the * device. * @constructor * @param {number=} opt_busySeconds to retry an exchange upon a BUSY result. */ function Gnubby(opt_busySeconds) { this.dev = null; this.gnubbyInstance = ++Gnubby.gnubbyId_; this.cid = Gnubby.BROADCAST_CID; this.rxframes = []; this.synccnt = 0; this.rxcb = null; this.closed = false; this.commandPending = false; this.notifyOnClose = []; this.busyMillis = (opt_busySeconds ? opt_busySeconds * 1000 : 9500); } /** * Global Gnubby instance counter. * @private {number} */ Gnubby.gnubbyId_ = 0; /** * Sets Gnubby's Gnubbies singleton. * @param {Gnubbies} gnubbies Gnubbies singleton instance */ Gnubby.setGnubbies = function(gnubbies) { /** @private {Gnubbies} */ Gnubby.gnubbies_ = gnubbies; }; /** * Return cid as hex string. * @param {number} cid to convert. * @return {string} hexadecimal string. */ Gnubby.hexCid = function(cid) { var tmp = [(cid >>> 24) & 255, (cid >>> 16) & 255, (cid >>> 8) & 255, (cid >>> 0) & 255]; return UTIL_BytesToHex(tmp); }; /** * Opens the gnubby with the given index, or the first found gnubby if no * index is specified. * @param {GnubbyDeviceId} which The device to open. If null, the first * gnubby found is opened. * @param {function(number)|undefined} opt_cb Called with result of opening the * gnubby. */ Gnubby.prototype.open = function(which, opt_cb) { var cb = opt_cb ? opt_cb : Gnubby.defaultCallback; if (this.closed) { cb(-GnubbyDevice.NODEVICE); return; } this.closingWhenIdle = false; var self = this; function setCid(which) { // Set a default channel ID, in case the caller never sets a better one. self.cid = Gnubby.defaultChannelId_(self.gnubbyInstance, which); } var enumerateRetriesRemaining = 3; function enumerated(rc, devs) { if (!devs.length) rc = -GnubbyDevice.NODEVICE; if (rc) { cb(rc); return; } which = devs[0]; setCid(which); self.which = which; Gnubby.gnubbies_.addClient(which, self, function(rc, device) { if (rc == -GnubbyDevice.NODEVICE && enumerateRetriesRemaining-- > 0) { // We were trying to open the first device, but now it's not there? // Do over. Gnubby.gnubbies_.enumerate(enumerated); return; } self.dev = device; cb(rc); }); } if (which) { setCid(which); self.which = which; Gnubby.gnubbies_.addClient(which, self, function(rc, device) { self.dev = device; cb(rc); }); } else { Gnubby.gnubbies_.enumerate(enumerated); } }; /** * Generates a default channel id value for a gnubby instance that won't * collide within this application, but may when others simultaneously access * the device. * @param {number} gnubbyInstance An instance identifier for a gnubby. * @param {GnubbyDeviceId} which The device identifer for the gnubby device. * @return {number} The channel id. * @private */ Gnubby.defaultChannelId_ = function(gnubbyInstance, which) { var cid = (gnubbyInstance) & 0x00ffffff; cid |= ((which.device + 1) << 24); // For debugging. return cid; }; /** * @return {boolean} Whether this gnubby has any command outstanding. * @private */ Gnubby.prototype.inUse_ = function() { return this.commandPending; }; /** Closes this gnubby. */ Gnubby.prototype.close = function() { this.closed = true; if (this.dev) { console.log(UTIL_fmt('Gnubby.close()')); this.rxframes = []; this.rxcb = null; var dev = this.dev; this.dev = null; var self = this; // Wait a bit in case simpleton client tries open next gnubby. // Without delay, gnubbies would drop all idle devices, before client // gets to the next one. window.setTimeout( function() { Gnubby.gnubbies_.removeClient(dev, self); }, 300); } }; /** * Asks this gnubby to close when it gets a chance. * @param {Function=} cb called back when closed. */ Gnubby.prototype.closeWhenIdle = function(cb) { if (!this.inUse_()) { this.close(); if (cb) cb(); return; } this.closingWhenIdle = true; if (cb) this.notifyOnClose.push(cb); }; /** * Close and notify every caller that it is now closed. * @private */ Gnubby.prototype.idleClose_ = function() { this.close(); while (this.notifyOnClose.length != 0) { var cb = this.notifyOnClose.shift(); cb(); } }; /** * Notify callback for every frame received. * @param {function()} cb Callback * @private */ Gnubby.prototype.notifyFrame_ = function(cb) { if (this.rxframes.length != 0) { // Already have frames; continue. if (cb) window.setTimeout(cb, 0); } else { this.rxcb = cb; } }; /** * Called by low level driver with a frame. * @param {ArrayBuffer|Uint8Array} frame Data frame * @return {boolean} Whether this client is still interested in receiving * frames from its device. */ Gnubby.prototype.receivedFrame = function(frame) { if (this.closed) return false; // No longer interested. if (!this.checkCID_(frame)) { // Not for me, ignore. return true; } this.rxframes.push(frame); // Callback self in case we were waiting. Once. var cb = this.rxcb; this.rxcb = null; if (cb) window.setTimeout(cb, 0); return true; }; /** * @return {ArrayBuffer|Uint8Array} oldest received frame. Throw if none. * @private */ Gnubby.prototype.readFrame_ = function() { if (this.rxframes.length == 0) throw 'rxframes empty!'; var frame = this.rxframes.shift(); return frame; }; /** Poll from rxframes[]. * @param {number} cmd Command * @param {number} timeout timeout in seconds. * @param {?function(...)} cb Callback * @private */ Gnubby.prototype.read_ = function(cmd, timeout, cb) { if (this.closed) { cb(-GnubbyDevice.GONE); return; } if (!this.dev) { cb(-GnubbyDevice.GONE); return; } var tid = null; // timeout timer id. var callback = cb; var self = this; var msg = null; var seqno = 0; var count = 0; /** * Schedule call to cb if not called yet. * @param {number} a Return code. * @param {Object=} b Optional data. */ function schedule_cb(a, b) { self.commandPending = false; if (tid) { // Cancel timeout timer. window.clearTimeout(tid); tid = null; } var c = callback; if (c) { callback = null; window.setTimeout(function() { c(a, b); }, 0); } if (self.closingWhenIdle) self.idleClose_(); }; function read_timeout() { if (!callback || !tid) return; // Already done. console.error(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] timeout!')); if (self.dev) { self.dev.destroy(); // Stop pretending this thing works. } tid = null; schedule_cb(-GnubbyDevice.TIMEOUT); }; function cont_frame() { if (!callback || !tid) return; // Already done. var f = new Uint8Array(self.readFrame_()); var rcmd = f[4]; var totalLen = (f[5] << 8) + f[6]; if (rcmd == GnubbyDevice.CMD_ERROR && totalLen == 1) { // Error from device; forward. console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] error frame ' + UTIL_BytesToHex(f))); if (f[7] == GnubbyDevice.GONE) { self.closed = true; } schedule_cb(-f[7]); return; } if ((rcmd & 0x80)) { // Not an CONT frame, ignore. console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] ignoring non-cont frame ' + UTIL_BytesToHex(f))); self.notifyFrame_(cont_frame); return; } var seq = (rcmd & 0x7f); if (seq != seqno++) { console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] bad cont frame ' + UTIL_BytesToHex(f))); schedule_cb(-GnubbyDevice.INVALID_SEQ); return; } // Copy payload. for (var i = 5; i < f.length && count < msg.length; ++i) { msg[count++] = f[i]; } if (count == msg.length) { // Done. schedule_cb(-GnubbyDevice.OK, msg.buffer); } else { // Need more CONT frame(s). self.notifyFrame_(cont_frame); } } function init_frame() { if (!callback || !tid) return; // Already done. var f = new Uint8Array(self.readFrame_()); var rcmd = f[4]; var totalLen = (f[5] << 8) + f[6]; if (rcmd == GnubbyDevice.CMD_ERROR && totalLen == 1) { // Error from device; forward. // Don't log busy frames, they're "normal". if (f[7] != GnubbyDevice.BUSY) { console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] error frame ' + UTIL_BytesToHex(f))); } if (f[7] == GnubbyDevice.GONE) { self.closed = true; } schedule_cb(-f[7]); return; } if (!(rcmd & 0x80)) { // Not an init frame, ignore. console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] ignoring non-init frame ' + UTIL_BytesToHex(f))); self.notifyFrame_(init_frame); return; } if (rcmd != cmd) { // Not expected ack, read more. console.log(UTIL_fmt( '[' + Gnubby.hexCid(self.cid) + '] ignoring non-ack frame ' + UTIL_BytesToHex(f))); self.notifyFrame_(init_frame); return; } // Copy payload. msg = new Uint8Array(totalLen); for (var i = 7; i < f.length && count < msg.length; ++i) { msg[count++] = f[i]; } if (count == msg.length) { // Done. schedule_cb(-GnubbyDevice.OK, msg.buffer); } else { // Need more CONT frame(s). self.notifyFrame_(cont_frame); } } // Start timeout timer. tid = window.setTimeout(read_timeout, 1000.0 * timeout); // Schedule read of first frame. self.notifyFrame_(init_frame); }; /** * @const */ Gnubby.NOTIFICATION_CID = 0; /** * @const */ Gnubby.BROADCAST_CID = (0xff << 24) | (0xff << 16) | (0xff << 8) | 0xff; /** * @param {ArrayBuffer|Uint8Array} frame Data frame * @return {boolean} Whether frame is for my channel. * @private */ Gnubby.prototype.checkCID_ = function(frame) { var f = new Uint8Array(frame); var c = (f[0] << 24) | (f[1] << 16) | (f[2] << 8) | (f[3]); return c === this.cid || c === Gnubby.NOTIFICATION_CID; }; /** * Queue command for sending. * @param {number} cmd The command to send. * @param {ArrayBuffer|Uint8Array} data Command data * @private */ Gnubby.prototype.write_ = function(cmd, data) { if (this.closed) return; if (!this.dev) return; this.commandPending = true; this.dev.queueCommand(this.cid, cmd, data); }; /** * Writes the command, and calls back when the command's reply is received. * @param {number} cmd The command to send. * @param {ArrayBuffer|Uint8Array} data Command data * @param {number} timeout Timeout in seconds. * @param {function(number, ArrayBuffer=)} cb Callback * @private */ Gnubby.prototype.exchange_ = function(cmd, data, timeout, cb) { var busyWait = new CountdownTimer(Gnubby.SYS_TIMER_, this.busyMillis); var self = this; function retryBusy(rc, rc_data) { if (rc == -GnubbyDevice.BUSY && !busyWait.expired()) { if (Gnubby.gnubbies_) { Gnubby.gnubbies_.resetInactivityTimer(timeout * 1000); } self.write_(cmd, data); self.read_(cmd, timeout, retryBusy); } else { busyWait.clearTimeout(); cb(rc, rc_data); } } retryBusy(-GnubbyDevice.BUSY, undefined); // Start work. }; /** * Private instance of timers based on window's timer functions. * @const * @private */ Gnubby.SYS_TIMER_ = new WindowTimer(); /** Default callback for commands. Simply logs to console. * @param {number} rc Result status code * @param {(ArrayBuffer|Uint8Array|Array|null)} data Result data */ Gnubby.defaultCallback = function(rc, data) { var msg = 'defaultCallback(' + rc; if (data) { if (typeof data == 'string') msg += ', ' + data; else msg += ', ' + UTIL_BytesToHex(new Uint8Array(data)); } msg += ')'; console.log(UTIL_fmt(msg)); }; /** * Ensures this device has temporary ownership of the USB device, by: * 1. Using the INIT command to allocate an unique channel id, if one hasn't * been retrieved before, or * 2. Sending a nonce to device, flushing read queue until match. * @param {?function(...)} cb Callback */ Gnubby.prototype.sync = function(cb) { if (!cb) cb = Gnubby.defaultCallback; if (this.closed) { cb(-GnubbyDevice.GONE); return; } var done = false; var trycount = 6; var tid = null; var self = this; function returnValue(rc) { done = true; window.setTimeout(cb.bind(null, rc), 0); if (self.closingWhenIdle) self.idleClose_(); } function callback(rc, opt_frame) { self.commandPending = false; if (tid) { window.clearTimeout(tid); tid = null; } completionAction(rc, opt_frame); } function sendSyncSentinel() { var cmd = GnubbyDevice.CMD_SYNC; var data = new Uint8Array(1); data[0] = ++self.synccnt; self.dev.queueCommand(self.cid, cmd, data.buffer); } function syncSentinelEquals(f) { return (f[4] == GnubbyDevice.CMD_SYNC && (f.length == 7 || /* fw pre-0.2.1 bug: does not echo sentinel */ f[7] == self.synccnt)); } function syncCompletionAction(rc, opt_frame) { if (rc) console.warn(UTIL_fmt('sync failed: ' + rc)); returnValue(rc); } function sendInitSentinel() { var cid = self.cid; // If we do not have a specific CID yet, reset to BROADCAST for init. if (self.cid == Gnubby.defaultChannelId_(self.gnubbyInstance, self.which)) { self.cid = Gnubby.BROADCAST_CID; cid = self.cid; } var cmd = GnubbyDevice.CMD_INIT; self.dev.queueCommand(cid, cmd, nonce); } function initSentinelEquals(f) { return (f[4] == GnubbyDevice.CMD_INIT && f.length >= nonce.length + 7 && UTIL_equalArrays(f.subarray(7, nonce.length + 7), nonce)); } function initCmdUnsupported(rc) { // Different firmwares fail differently on different inputs, so treat any // of the following errors as indicating the INIT command isn't supported. return rc == -GnubbyDevice.INVALID_CMD || rc == -GnubbyDevice.INVALID_PAR || rc == -GnubbyDevice.INVALID_LEN; } function initCompletionAction(rc, opt_frame) { // Actual failures: bail out. if (rc && !initCmdUnsupported(rc)) { console.warn(UTIL_fmt('init failed: ' + rc)); returnValue(rc); } var HEADER_LENGTH = 7; var MIN_LENGTH = HEADER_LENGTH + 4; // 4 bytes for the channel id if (rc || !opt_frame || opt_frame.length < nonce.length + MIN_LENGTH) { // INIT command not supported or is missing the returned channel id: // Pick a random cid to try to prevent collisions on the USB bus. var rnd = UTIL_getRandom(2); self.cid = Gnubby.defaultChannelId_(self.gnubbyInstance, self.which); self.cid ^= (rnd[0] << 16) | (rnd[1] << 8); // Now sync with that cid, to make sure we've got it. setSync(); timeoutLoop(); return; } // Accept the provided cid. var offs = HEADER_LENGTH + nonce.length; self.cid = (opt_frame[offs] << 24) | (opt_frame[offs + 1] << 16) | (opt_frame[offs + 2] << 8) | opt_frame[offs + 3]; returnValue(rc); } function checkSentinel() { var f = new Uint8Array(self.readFrame_()); // Stop on errors and return them. if (f[4] == GnubbyDevice.CMD_ERROR && f[5] == 0 && f[6] == 1) { if (f[7] == GnubbyDevice.BUSY) { // Not spec but some devices do this; retry. sendSentinel(); self.notifyFrame_(checkSentinel); return; } if (f[7] == GnubbyDevice.GONE) { // Device disappeared on us. self.closed = true; } callback(-f[7]); return; } // Eat everything else but expected sentinel reply. if (!sentinelEquals(f)) { // Read more. self.notifyFrame_(checkSentinel); return; } // Done. callback(-GnubbyDevice.OK, f); }; function timeoutLoop() { if (done) return; if (trycount == 0) { // Failed. callback(-GnubbyDevice.TIMEOUT); return; } --trycount; // Try another one. sendSentinel(); self.notifyFrame_(checkSentinel); tid = window.setTimeout(timeoutLoop, 500); }; var sendSentinel; var sentinelEquals; var nonce; var completionAction; function setInit() { sendSentinel = sendInitSentinel; nonce = UTIL_getRandom(8); sentinelEquals = initSentinelEquals; completionAction = initCompletionAction; } function setSync() { sendSentinel = sendSyncSentinel; sentinelEquals = syncSentinelEquals; completionAction = syncCompletionAction; } if (Gnubby.gnubbies_.isSharedAccess(this.which)) { setInit(); } else { setSync(); } timeoutLoop(); }; /** Short timeout value in seconds */ Gnubby.SHORT_TIMEOUT = 1; /** Normal timeout value in seconds */ Gnubby.NORMAL_TIMEOUT = 3; // Max timeout usb firmware has for smartcard response is 30 seconds. // Make our application level tolerance a little longer. /** Maximum timeout in seconds */ Gnubby.MAX_TIMEOUT = 31; /** Blink led * @param {number|ArrayBuffer|Uint8Array} data Command data or number * of seconds to blink * @param {?function(...)} cb Callback */ Gnubby.prototype.blink = function(data, cb) { if (!cb) cb = Gnubby.defaultCallback; if (typeof data == 'number') { var d = new Uint8Array([data]); data = d.buffer; } this.exchange_(GnubbyDevice.CMD_PROMPT, data, Gnubby.NORMAL_TIMEOUT, cb); }; /** Lock the gnubby * @param {number|ArrayBuffer|Uint8Array} data Command data * @param {?function(...)} cb Callback */ Gnubby.prototype.lock = function(data, cb) { if (!cb) cb = Gnubby.defaultCallback; if (typeof data == 'number') { var d = new Uint8Array([data]); data = d.buffer; } this.exchange_(GnubbyDevice.CMD_LOCK, data, Gnubby.NORMAL_TIMEOUT, cb); }; /** Unlock the gnubby * @param {?function(...)} cb Callback */ Gnubby.prototype.unlock = function(cb) { if (!cb) cb = Gnubby.defaultCallback; var data = new Uint8Array([0]); this.exchange_(GnubbyDevice.CMD_LOCK, data.buffer, Gnubby.NORMAL_TIMEOUT, cb); }; /** Request system information data. * @param {?function(...)} cb Callback */ Gnubby.prototype.sysinfo = function(cb) { if (!cb) cb = Gnubby.defaultCallback; this.exchange_(GnubbyDevice.CMD_SYSINFO, new ArrayBuffer(0), Gnubby.NORMAL_TIMEOUT, cb); }; /** Send wink command * @param {?function(...)} cb Callback */ Gnubby.prototype.wink = function(cb) { if (!cb) cb = Gnubby.defaultCallback; this.exchange_(GnubbyDevice.CMD_WINK, new ArrayBuffer(0), Gnubby.NORMAL_TIMEOUT, cb); }; /** Send DFU (Device firmware upgrade) command * @param {ArrayBuffer|Uint8Array} data Command data * @param {?function(...)} cb Callback */ Gnubby.prototype.dfu = function(data, cb) { if (!cb) cb = Gnubby.defaultCallback; this.exchange_(GnubbyDevice.CMD_DFU, data, Gnubby.NORMAL_TIMEOUT, cb); }; /** Ping the gnubby * @param {number|ArrayBuffer|Uint8Array} data Command data * @param {?function(...)} cb Callback */ Gnubby.prototype.ping = function(data, cb) { if (!cb) cb = Gnubby.defaultCallback; if (typeof data == 'number') { var d = new Uint8Array(data); window.crypto.getRandomValues(d); data = d.buffer; } this.exchange_(GnubbyDevice.CMD_PING, data, Gnubby.NORMAL_TIMEOUT, cb); }; /** Send a raw APDU command * @param {ArrayBuffer|Uint8Array} data Command data * @param {?function(...)} cb Callback */ Gnubby.prototype.apdu = function(data, cb) { if (!cb) cb = Gnubby.defaultCallback; this.exchange_(GnubbyDevice.CMD_APDU, data, Gnubby.MAX_TIMEOUT, cb); }; /** Reset gnubby * @param {?function(...)} cb Callback */ Gnubby.prototype.reset = function(cb) { if (!cb) cb = Gnubby.defaultCallback; this.exchange_(GnubbyDevice.CMD_ATR, new ArrayBuffer(0), Gnubby.MAX_TIMEOUT, cb); }; // byte args[3] = [delay-in-ms before disabling interrupts, // delay-in-ms before disabling usb (aka remove), // delay-in-ms before reboot (aka insert)] /** Send usb test command * @param {ArrayBuffer|Uint8Array} args Command data * @param {?function(...)} cb Callback */ Gnubby.prototype.usb_test = function(args, cb) { if (!cb) cb = Gnubby.defaultCallback; var u8 = new Uint8Array(args); this.exchange_(GnubbyDevice.CMD_USB_TEST, u8.buffer, Gnubby.NORMAL_TIMEOUT, cb); }; /** APDU command with reply * @param {ArrayBuffer|Uint8Array} request The request * @param {?function(...)} cb Callback * @param {boolean=} opt_nowink Do not wink */ Gnubby.prototype.apduReply = function(request, cb, opt_nowink) { if (!cb) cb = Gnubby.defaultCallback; var self = this; this.apdu(request, function(rc, data) { if (rc == 0) { var r8 = new Uint8Array(data); if (r8[r8.length - 2] == 0x90 && r8[r8.length - 1] == 0x00) { // strip trailing 9000 var buf = new Uint8Array(r8.subarray(0, r8.length - 2)); cb(-GnubbyDevice.OK, buf.buffer); return; } else { // return non-9000 as rc rc = r8[r8.length - 2] * 256 + r8[r8.length - 1]; // wink gnubby at hand if it needs touching. if (rc == 0x6985 && !opt_nowink) { self.wink(function() { cb(rc); }); return; } } } // Warn on errors other than waiting for touch, wrong data, and // unrecognized command. if (rc != 0x6985 && rc != 0x6a80 && rc != 0x6d00) { console.warn(UTIL_fmt('apduReply_ fail: ' + rc.toString(16))); } cb(rc); }); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Gnubby methods related to U2F support. */ 'use strict'; // Commands and flags of the Gnubby applet /** Enroll */ Gnubby.U2F_ENROLL = 0x01; /** Request signature */ Gnubby.U2F_SIGN = 0x02; /** Request protocol version */ Gnubby.U2F_VERSION = 0x03; /** Request applet version */ Gnubby.APPLET_VERSION = 0x11; // First 3 bytes are applet version. // APDU.P1 flags /** Test of User Presence required */ Gnubby.P1_TUP_REQUIRED = 0x01; /** Consume a Test of User Presence */ Gnubby.P1_TUP_CONSUME = 0x02; /** Test signature only, no TUP. E.g. to check for existing enrollments. */ Gnubby.P1_TUP_TESTONLY = 0x04; /** Attest with device key */ Gnubby.P1_INDIVIDUAL_KEY = 0x80; // Version values /** V1 of the applet. */ Gnubby.U2F_V1 = 'U2F_V1'; /** V2 of the applet. */ Gnubby.U2F_V2 = 'U2F_V2'; /** Perform enrollment * @param {Array|ArrayBuffer|Uint8Array} challenge Enrollment challenge * @param {Array|ArrayBuffer|Uint8Array} appIdHash Hashed application * id * @param {function(...)} cb Result callback * @param {boolean=} opt_individualAttestation Request the individual * attestation cert rather than the batch one. */ Gnubby.prototype.enroll = function(challenge, appIdHash, cb, opt_individualAttestation) { var p1 = Gnubby.P1_TUP_REQUIRED | Gnubby.P1_TUP_CONSUME; if (opt_individualAttestation) { p1 |= Gnubby.P1_INDIVIDUAL_KEY; } var apdu = new Uint8Array( [0x00, Gnubby.U2F_ENROLL, p1, 0x00, 0x00, 0x00, challenge.length + appIdHash.length]); var u8 = new Uint8Array(apdu.length + challenge.length + appIdHash.length + 2); for (var i = 0; i < apdu.length; ++i) u8[i] = apdu[i]; for (var i = 0; i < challenge.length; ++i) u8[i + apdu.length] = challenge[i]; for (var i = 0; i < appIdHash.length; ++i) { u8[i + apdu.length + challenge.length] = appIdHash[i]; } this.apduReply(u8.buffer, cb); }; /** Request signature * @param {Array|ArrayBuffer|Uint8Array} challengeHash Hashed * signature challenge * @param {Array|ArrayBuffer|Uint8Array} appIdHash Hashed application * id * @param {Array|ArrayBuffer|Uint8Array} keyHandle Key handle to use * @param {function(...)} cb Result callback * @param {boolean=} opt_nowink Request signature without winking * (e.g. during enroll) */ Gnubby.prototype.sign = function(challengeHash, appIdHash, keyHandle, cb, opt_nowink) { var self = this; // The sign command's format is ever-so-slightly different between V1 and V2, // so get this gnubby's version prior to sending it. this.version(function(rc, opt_data) { if (rc) { cb(rc); return; } var version = UTIL_BytesToString(new Uint8Array(opt_data || [])); var apduDataLen = challengeHash.length + appIdHash.length + keyHandle.length; if (version != Gnubby.U2F_V1) { // The V2 sign command includes a length byte for the key handle. apduDataLen++; } var apdu = new Uint8Array( [0x00, Gnubby.U2F_SIGN, Gnubby.P1_TUP_REQUIRED | Gnubby.P1_TUP_CONSUME, 0x00, 0x00, 0x00, apduDataLen]); if (opt_nowink) { // A signature request that does not want winking. // These are used during enroll to figure out whether a gnubby was already // enrolled. // Tell applet to not actually produce a signature, even // if already touched. apdu[2] |= Gnubby.P1_TUP_TESTONLY; } var u8 = new Uint8Array(apdu.length + apduDataLen + 2); for (var i = 0; i < apdu.length; ++i) u8[i] = apdu[i]; for (var i = 0; i < challengeHash.length; ++i) u8[i + apdu.length] = challengeHash[i]; for (var i = 0; i < appIdHash.length; ++i) { u8[i + apdu.length + challengeHash.length] = appIdHash[i]; } var keyHandleOffset = apdu.length + challengeHash.length + appIdHash.length; if (version != Gnubby.U2F_V1) { u8[keyHandleOffset++] = keyHandle.length; } for (var i = 0; i < keyHandle.length; ++i) { u8[i + keyHandleOffset] = keyHandle[i]; } self.apduReply(u8.buffer, cb, opt_nowink); }); }; /** Request version information * @param {function(...)} cb Callback */ Gnubby.prototype.version = function(cb) { if (!cb) cb = Gnubby.defaultCallback; if (this.version_) { cb(-GnubbyDevice.OK, this.version_); return; } var self = this; var apdu = new Uint8Array([0x00, Gnubby.U2F_VERSION, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); this.apduReply(apdu.buffer, function(rc, data) { if (rc == 0x6d00) { // Command not implemented. Pretend this is v1. var v1 = new Uint8Array(UTIL_StringToBytes(Gnubby.U2F_V1)); self.version_ = v1.buffer; cb(-GnubbyDevice.OK, v1.buffer); } else { if (!rc) { self.version_ = data; } cb(rc, data); } }); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview This provides the different code types for the gnubby * operations. */ /** * @const * @enum {number} */ var GnubbyCodeTypes = { /** Request succeeded. */ 'OK': 0, /** All plugged in devices are already enrolled. */ 'ALREADY_ENROLLED': 2, /** None of the plugged in devices are enrolled. */ 'NONE_PLUGGED_ENROLLED': 3, /** One or more devices are waiting for touch. */ 'WAIT_TOUCH': 4, /** Unknown error. */ 'UNKNOWN_ERROR': 7, /** Bad request. */ 'BAD_REQUEST': 12 }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Contains a factory interface for creating and opening gnubbies. */ 'use strict'; /** * A factory for creating and opening gnubbies. * @interface */ function GnubbyFactory() {} /** * Enumerates gnubbies. * @param {function(number, Array)} cb Enumerate callback */ GnubbyFactory.prototype.enumerate = function(cb) { }; /** @typedef {function(number, Gnubby=)} */ var FactoryOpenCallback; /** * Creates a new gnubby object, and opens the gnubby with the given index. * @param {GnubbyDeviceId} which The device to open. * @param {boolean} forEnroll Whether this gnubby is being opened for enrolling. * @param {FactoryOpenCallback} cb Called with result of opening the gnubby. * @param {string=} opt_appIdHash The base64-encoded hash of the app id for * which the gnubby being opened. * @param {string=} opt_logMsgUrl The url to post log messages to. */ GnubbyFactory.prototype.openGnubby = function(which, forEnroll, cb, opt_appIdHash, opt_logMsgUrl) { }; /** * Called during enrollment to check whether a gnubby known not to be enrolled * is allowed to enroll in its present state. Upon completion of the check, the * callback is called. * @param {Gnubby} gnubby The not-enrolled gnubby. * @param {string} appIdHash The base64-encoded hash of the app id for which * the gnubby being enrolled. * @param {FactoryOpenCallback} cb Called with the result of the prerequisite * check. (A non-zero status indicates failure.) */ GnubbyFactory.prototype.notEnrolledPrerequisiteCheck = function(gnubby, appIdHash, cb) { }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview This provides the different message types for the gnubby * operations. */ var GnubbyMsgTypes = {}; /** * Enroll request message type. * @const */ GnubbyMsgTypes.ENROLL_WEB_REQUEST = 'enroll_web_request'; /** * Enroll reply message type. * @const */ GnubbyMsgTypes.ENROLL_WEB_REPLY = 'enroll_web_reply'; /** * Enroll notification message type. * @const */ GnubbyMsgTypes.ENROLL_WEB_NOTIFICATION = 'enroll_web_notification'; /** * Sign request message type. * @const */ GnubbyMsgTypes.SIGN_WEB_REQUEST = 'sign_web_request'; /** * Sign reply message type. * @const */ GnubbyMsgTypes.SIGN_WEB_REPLY = 'sign_web_reply'; /** * Sign notification message type. * @const */ GnubbyMsgTypes.SIGN_WEB_NOTIFICATION = 'sign_web_notification'; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Contains a simple factory for creating and opening Gnubby * instances. */ 'use strict'; /** * @param {Gnubbies} gnubbies Gnubbies singleton instance * @constructor * @implements {GnubbyFactory} */ function UsbGnubbyFactory(gnubbies) { /** @private {Gnubbies} */ this.gnubbies_ = gnubbies; Gnubby.setGnubbies(gnubbies); } /** * Creates a new gnubby object, and opens the gnubby with the given index. * @param {GnubbyDeviceId} which The device to open. * @param {boolean} forEnroll Whether this gnubby is being opened for enrolling. * @param {FactoryOpenCallback} cb Called with result of opening the gnubby. * @param {string=} opt_appIdHash The base64-encoded hash of the app id for * which the gnubby being opened. * @param {string=} opt_logMsgUrl The url to post log messages to. * @override */ UsbGnubbyFactory.prototype.openGnubby = function(which, forEnroll, cb, opt_appIdHash, opt_logMsgUrl) { var gnubby = new Gnubby(); gnubby.open(which, function(rc) { if (rc) { cb(rc, gnubby); return; } gnubby.sync(function(rc) { cb(rc, gnubby); }); }); }; /** * Enumerates gnubbies. * @param {function(number, Array)} cb Enumerate callback */ UsbGnubbyFactory.prototype.enumerate = function(cb) { this.gnubbies_.enumerate(cb); }; /** * No-op prerequisite check. * @param {Gnubby} gnubby The not-enrolled gnubby. * @param {string} appIdHash The base64-encoded hash of the app id for which * the gnubby being enrolled. * @param {FactoryOpenCallback} cb Called with the result of the prerequisite * check. (A non-zero status indicates failure.) */ UsbGnubbyFactory.prototype.notEnrolledPrerequisiteCheck = function(gnubby, appIdHash, cb) { cb(DeviceStatusCodes.OK_STATUS, gnubby); }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview This file defines the status codes returned by the device. */ /** * Status codes returned by the gnubby device. * @const * @enum {number} * @export */ var DeviceStatusCodes = {}; /** * Device operation succeeded. * @const */ DeviceStatusCodes.OK_STATUS = 0; /** * Device operation wrong length status. * @const */ DeviceStatusCodes.WRONG_LENGTH_STATUS = 0x6700; /** * Device operation wait touch status. * @const */ DeviceStatusCodes.WAIT_TOUCH_STATUS = 0x6985; /** * Device operation invalid data status. * @const */ DeviceStatusCodes.INVALID_DATA_STATUS = 0x6984; /** * Device operation wrong data status. * @const */ DeviceStatusCodes.WRONG_DATA_STATUS = 0x6a80; /** * Device operation timeout status. * @const */ DeviceStatusCodes.TIMEOUT_STATUS = -5; /** * Device operation busy status. * @const */ DeviceStatusCodes.BUSY_STATUS = -6; /** * Device removed status. * @const */ DeviceStatusCodes.GONE_STATUS = -8; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Handles web page requests for gnubby enrollment. */ 'use strict'; /** * Handles a U2F enroll request. * @param {MessageSender} messageSender The message sender. * @param {Object} request The web page's enroll request. * @param {Function} sendResponse Called back with the result of the enroll. * @return {Closeable} A handler object to be closed when the browser channel * closes. */ function handleU2fEnrollRequest(messageSender, request, sendResponse) { var sentResponse = false; var closeable = null; function sendErrorResponse(error) { var response = makeU2fErrorResponse(request, error.errorCode, error.errorMessage); sendResponseOnce(sentResponse, closeable, response, sendResponse); } function sendSuccessResponse(u2fVersion, info, clientData) { var enrollChallenges = request['registerRequests']; var enrollChallenge = findEnrollChallengeOfVersion(enrollChallenges, u2fVersion); if (!enrollChallenge) { sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR}); return; } var responseData = makeEnrollResponseData(enrollChallenge, u2fVersion, info, clientData); var response = makeU2fSuccessResponse(request, responseData); sendResponseOnce(sentResponse, closeable, response, sendResponse); } function timeout() { sendErrorResponse({errorCode: ErrorCodes.TIMEOUT}); } var sender = createSenderFromMessageSender(messageSender); if (!sender) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } if (sender.origin.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } if (!isValidEnrollRequest(request)) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } var timeoutValueSeconds = getTimeoutValueFromRequest(request); // Attenuate watchdog timeout value less than the enroller's timeout, so the // watchdog only fires after the enroller could reasonably have called back, // not before. var watchdogTimeoutValueSeconds = attenuateTimeoutInSeconds( timeoutValueSeconds, MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2); var watchdog = new WatchdogRequestHandler(watchdogTimeoutValueSeconds, timeout); var wrappedErrorCb = watchdog.wrapCallback(sendErrorResponse); var wrappedSuccessCb = watchdog.wrapCallback(sendSuccessResponse); var timer = createAttenuatedTimer( FACTORY_REGISTRY.getCountdownFactory(), timeoutValueSeconds); var logMsgUrl = request['logMsgUrl']; var enroller = new Enroller(timer, sender, sendErrorResponse, sendSuccessResponse, logMsgUrl); watchdog.setCloseable(/** @type {!Closeable} */ (enroller)); closeable = watchdog; var registerRequests = request['registerRequests']; var signRequests = getSignRequestsFromEnrollRequest(request); enroller.doEnroll(registerRequests, signRequests, request['appId']); return closeable; } /** * Returns whether the request appears to be a valid enroll request. * @param {Object} request The request. * @return {boolean} Whether the request appears valid. */ function isValidEnrollRequest(request) { if (!request.hasOwnProperty('registerRequests')) return false; var enrollChallenges = request['registerRequests']; if (!enrollChallenges.length) return false; var hasAppId = request.hasOwnProperty('appId'); if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId)) return false; var signChallenges = getSignChallenges(request); // A missing sign challenge array is ok, in the case the user is not already // enrolled. // A challenge value need not necessarily be supplied with every challenge. var challengeRequired = false; if (signChallenges && !isValidSignChallengeArray(signChallenges, challengeRequired, !hasAppId)) return false; return true; } /** * @typedef {{ * version: (string|undefined), * challenge: string, * appId: string * }} */ var EnrollChallenge; /** * @param {Array} enrollChallenges The enroll challenges to * validate. * @param {boolean} appIdRequired Whether the appId property is required on * each challenge. * @return {boolean} Whether the given array of challenges is a valid enroll * challenges array. */ function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) { var seenVersions = {}; for (var i = 0; i < enrollChallenges.length; i++) { var enrollChallenge = enrollChallenges[i]; var version = enrollChallenge['version']; if (!version) { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } if (version != 'U2F_V1' && version != 'U2F_V2') { return false; } if (seenVersions[version]) { // Each version can appear at most once. return false; } seenVersions[version] = version; if (appIdRequired && !enrollChallenge['appId']) { return false; } if (!enrollChallenge['challenge']) { // The challenge is required. return false; } } return true; } /** * Finds the enroll challenge of the given version in the enroll challlenge * array. * @param {Array} enrollChallenges The enroll challenges to * search. * @param {string} version Version to search for. * @return {?EnrollChallenge} The enroll challenge with the given versions, or * null if it isn't found. */ function findEnrollChallengeOfVersion(enrollChallenges, version) { for (var i = 0; i < enrollChallenges.length; i++) { if (enrollChallenges[i]['version'] == version) { return enrollChallenges[i]; } } return null; } /** * Makes a responseData object for the enroll request with the given parameters. * @param {EnrollChallenge} enrollChallenge The enroll challenge used to * register. * @param {string} u2fVersion Version of gnubby that enrolled. * @param {string} registrationData The registration data. * @param {string=} opt_clientData The client data, if available. * @return {Object} The responseData object. */ function makeEnrollResponseData(enrollChallenge, u2fVersion, registrationData, opt_clientData) { var responseData = {}; responseData['registrationData'] = registrationData; // Echo the used challenge back in the reply. for (var k in enrollChallenge) { responseData[k] = enrollChallenge[k]; } if (u2fVersion == 'U2F_V2') { // For U2F_V2, the challenge sent to the gnubby is modified to be the // hash of the client data. Include the client data. responseData['clientData'] = opt_clientData; } return responseData; } /** * Gets the expanded sign challenges from an enroll request, potentially by * modifying the request to contain a challenge value where one was omitted. * (For enrolling, the server isn't interested in the value of a signature, * only whether the presented key handle is already enrolled.) * @param {Object} request The request. * @return {Array} */ function getSignRequestsFromEnrollRequest(request) { var signChallenges; if (request.hasOwnProperty('registeredKeys')) { signChallenges = request['registeredKeys']; } else { signChallenges = request['signRequests']; } if (signChallenges) { for (var i = 0; i < signChallenges.length; i++) { // Make sure each sign challenge has a challenge value. // The actual value doesn't matter, as long as it's a string. if (!signChallenges[i].hasOwnProperty('challenge')) { signChallenges[i]['challenge'] = ''; } } } return signChallenges; } /** * Creates a new object to track enrolling with a gnubby. * @param {!Countdown} timer Timer for enroll request. * @param {!WebRequestSender} sender The sender of the request. * @param {function(U2fError)} errorCb Called upon enroll failure. * @param {function(string, string, (string|undefined))} successCb Called upon * enroll success with the version of the succeeding gnubby, the enroll * data, and optionally the browser data associated with the enrollment. * @param {string=} opt_logMsgUrl The url to post log messages to. * @constructor */ function Enroller(timer, sender, errorCb, successCb, opt_logMsgUrl) { /** @private {Countdown} */ this.timer_ = timer; /** @private {WebRequestSender} */ this.sender_ = sender; /** @private {function(U2fError)} */ this.errorCb_ = errorCb; /** @private {function(string, string, (string|undefined))} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.done_ = false; /** @private {Object} */ this.browserData_ = {}; /** @private {Array} */ this.encodedEnrollChallenges_ = []; /** @private {Array} */ this.encodedSignChallenges_ = []; // Allow http appIds for http origins. (Broken, but the caller deserves // what they get.) /** @private {boolean} */ this.allowHttp_ = this.sender_.origin ? this.sender_.origin.indexOf('http://') == 0 : false; /** @private {Closeable} */ this.handler_ = null; } /** * Default timeout value in case the caller never provides a valid timeout. */ Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; /** * Performs an enroll request with the given enroll and sign challenges. * @param {Array} enrollChallenges A set of enroll challenges. * @param {Array} signChallenges A set of sign challenges for * existing enrollments for this user and appId. * @param {string=} opt_appId The app id for the entire request. */ Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges, opt_appId) { /** @private {Array} */ this.enrollChallenges_ = enrollChallenges; /** @private {Array} */ this.signChallenges_ = signChallenges; /** @private {(string|undefined)} */ this.appId_ = opt_appId; var self = this; getTabIdWhenPossible(this.sender_).then(function() { if (self.done_) return; self.approveOrigin_(); }, function() { self.close(); self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); }); }; /** * Ensures the user has approved this origin to use security keys, sending * to the request to the handler if/when the user has done so. * @private */ Enroller.prototype.approveOrigin_ = function() { var self = this; FACTORY_REGISTRY.getApprovedOrigins() .isApprovedOrigin(this.sender_.origin, this.sender_.tabId) .then(function(result) { if (self.done_) return; if (!result) { // Origin not approved: rather than give an explicit indication to // the web page, let a timeout occur. if (self.timer_.expired()) { self.notifyTimeout_(); return; } var newTimer = self.timer_.clone(self.notifyTimeout_.bind(self)); self.timer_.clearTimeout(); self.timer_ = newTimer; return; } self.sendEnrollRequestToHelper_(); }); }; /** * Notifies the caller of a timeout error. * @private */ Enroller.prototype.notifyTimeout_ = function() { this.notifyError_({errorCode: ErrorCodes.TIMEOUT}); }; /** * Performs an enroll request with this instance's enroll and sign challenges, * by encoding them into a helper request and passing the resulting request to * the factory registry's helper. * @private */ Enroller.prototype.sendEnrollRequestToHelper_ = function() { var encodedEnrollChallenges = this.encodeEnrollChallenges_(this.enrollChallenges_, this.appId_); // If the request didn't contain a sign challenge, provide one. The value // doesn't matter. var defaultSignChallenge = ''; var encodedSignChallenges = encodeSignChallenges(this.signChallenges_, defaultSignChallenge, this.appId_); var request = { type: 'enroll_helper_request', enrollChallenges: encodedEnrollChallenges, signData: encodedSignChallenges, logMsgUrl: this.logMsgUrl_ }; if (!this.timer_.expired()) { request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0; request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0; } // Begin fetching/checking the app ids. var enrollAppIds = []; if (this.appId_) { enrollAppIds.push(this.appId_); } for (var i = 0; i < this.enrollChallenges_.length; i++) { if (this.enrollChallenges_[i].hasOwnProperty('appId')) { enrollAppIds.push(this.enrollChallenges_[i]['appId']); } } // Sanity check if (!enrollAppIds.length) { console.warn(UTIL_fmt('empty enroll app ids?')); this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } var self = this; this.checkAppIds_(enrollAppIds, function(result) { if (self.done_) return; if (result) { self.handler_ = FACTORY_REGISTRY.getRequestHelper().getHandler(request); if (self.handler_) { var helperComplete = /** @type {function(HelperReply)} */ (self.helperComplete_.bind(self)); self.handler_.run(helperComplete); } else { self.notifyError_({errorCode: ErrorCodes.OTHER_ERROR}); } } else { self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); } }); }; /** * Encodes the enroll challenge as an enroll helper challenge. * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode. * @param {string=} opt_appId The app id for the entire request. * @return {EnrollHelperChallenge} The encoded challenge. * @private */ Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) { var encodedChallenge = {}; var version; if (enrollChallenge['version']) { version = enrollChallenge['version']; } else { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } encodedChallenge['version'] = version; encodedChallenge['challengeHash'] = enrollChallenge['challenge']; var appId; if (enrollChallenge['appId']) { appId = enrollChallenge['appId']; } else { appId = opt_appId; } if (!appId) { // Sanity check. (Other code should fail if it's not set.) console.warn(UTIL_fmt('No appId?')); } encodedChallenge['appIdHash'] = B64_encode(sha256HashOfString(appId)); return /** @type {EnrollHelperChallenge} */ (encodedChallenge); }; /** * Encodes the given enroll challenges using this enroller's state. * @param {Array} enrollChallenges The enroll challenges. * @param {string=} opt_appId The app id for the entire request. * @return {!Array} The encoded enroll challenges. * @private */ Enroller.prototype.encodeEnrollChallenges_ = function(enrollChallenges, opt_appId) { var challenges = []; for (var i = 0; i < enrollChallenges.length; i++) { var enrollChallenge = enrollChallenges[i]; var version = enrollChallenge.version; if (!version) { // Version is implicitly V1 if not specified. version = 'U2F_V1'; } if (version == 'U2F_V2') { var modifiedChallenge = {}; for (var k in enrollChallenge) { modifiedChallenge[k] = enrollChallenge[k]; } // V2 enroll responses contain signatures over a browser data object, // which we're constructing here. The browser data object contains, among // other things, the server challenge. var serverChallenge = enrollChallenge['challenge']; var browserData = makeEnrollBrowserData( serverChallenge, this.sender_.origin, this.sender_.tlsChannelId); // Replace the challenge with the hash of the browser data. modifiedChallenge['challenge'] = B64_encode(sha256HashOfString(browserData)); this.browserData_[version] = B64_encode(UTIL_StringToBytes(browserData)); challenges.push(Enroller.encodeEnrollChallenge_( /** @type {EnrollChallenge} */ (modifiedChallenge), opt_appId)); } else { challenges.push( Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId)); } } return challenges; }; /** * Checks the app ids associated with this enroll request, and calls a callback * with the result of the check. * @param {!Array} enrollAppIds The app ids in the enroll challenge * portion of the enroll request. * @param {function(boolean)} cb Called with the result of the check. * @private */ Enroller.prototype.checkAppIds_ = function(enrollAppIds, cb) { var appIds = UTIL_unionArrays(enrollAppIds, getDistinctAppIds(this.signChallenges_)); FACTORY_REGISTRY.getOriginChecker() .canClaimAppIds(this.sender_.origin, appIds) .then(this.originChecked_.bind(this, appIds, cb)); }; /** * Called with the result of checking the origin. When the origin is allowed * to claim the app ids, begins checking whether the app ids also list the * origin. * @param {!Array} appIds The app ids. * @param {function(boolean)} cb Called with the result of the check. * @param {boolean} result Whether the origin could claim the app ids. * @private */ Enroller.prototype.originChecked_ = function(appIds, cb, result) { if (!result) { this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } var appIdChecker = FACTORY_REGISTRY.getAppIdCheckerFactory().create(); appIdChecker. checkAppIds( this.timer_.clone(), this.sender_.origin, appIds, this.allowHttp_, this.logMsgUrl_) .then(cb); }; /** Closes this enroller. */ Enroller.prototype.close = function() { if (this.handler_) { this.handler_.close(); this.handler_ = null; } this.done_ = true; }; /** * Notifies the caller with the error. * @param {U2fError} error Error. * @private */ Enroller.prototype.notifyError_ = function(error) { if (this.done_) return; this.close(); this.done_ = true; this.errorCb_(error); }; /** * Notifies the caller of success with the provided response data. * @param {string} u2fVersion Protocol version * @param {string} info Response data * @param {string|undefined} opt_browserData Browser data used * @private */ Enroller.prototype.notifySuccess_ = function(u2fVersion, info, opt_browserData) { if (this.done_) return; this.close(); this.done_ = true; this.successCb_(u2fVersion, info, opt_browserData); }; /** * Called by the helper upon completion. * @param {EnrollHelperReply} reply The result of the enroll request. * @private */ Enroller.prototype.helperComplete_ = function(reply) { if (reply.code) { var reportedError = mapDeviceStatusCodeToU2fError(reply.code); console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) + ', returning ' + reportedError.errorCode)); this.notifyError_(reportedError); } else { console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!')); var browserData; if (reply.version == 'U2F_V2') { // For U2F_V2, the challenge sent to the gnubby is modified to be the hash // of the browser data. Include the browser data. browserData = this.browserData_[reply.version]; } this.notifySuccess_(/** @type {string} */ (reply.version), /** @type {string} */ (reply.enrollData), browserData); } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements an enroll handler using USB gnubbies. */ 'use strict'; /** * @param {!EnrollHelperRequest} request The enroll request. * @constructor * @implements {RequestHandler} */ function UsbEnrollHandler(request) { /** @private {!EnrollHelperRequest} */ this.request_ = request; /** @private {Array} */ this.waitingForTouchGnubbies_ = []; /** @private {boolean} */ this.closed_ = false; /** @private {boolean} */ this.notified_ = false; } /** * Default timeout value in case the caller never provides a valid timeout. * @const */ UsbEnrollHandler.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; /** * @param {RequestHandlerCallback} cb Called back with the result of the * request, and an optional source for the result. * @return {boolean} Whether this handler could be run. */ UsbEnrollHandler.prototype.run = function(cb) { var timeoutMillis = this.request_.timeoutSeconds ? this.request_.timeoutSeconds * 1000 : UsbEnrollHandler.DEFAULT_TIMEOUT_MILLIS; /** @private {Countdown} */ this.timer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory().createTimer( timeoutMillis); this.enrollChallenges = this.request_.enrollChallenges; /** @private {RequestHandlerCallback} */ this.cb_ = cb; this.signer_ = new MultipleGnubbySigner( true /* forEnroll */, this.signerCompleted_.bind(this), this.signerFoundGnubby_.bind(this), timeoutMillis, this.request_.logMsgUrl); return this.signer_.doSign(this.request_.signData); }; /** Closes this helper. */ UsbEnrollHandler.prototype.close = function() { this.closed_ = true; for (var i = 0; i < this.waitingForTouchGnubbies_.length; i++) { this.waitingForTouchGnubbies_[i].closeWhenIdle(); } this.waitingForTouchGnubbies_ = []; if (this.signer_) { this.signer_.close(); this.signer_ = null; } }; /** * Called when a MultipleGnubbySigner completes its sign request. * @param {boolean} anyPending Whether any gnubbies are pending. * @private */ UsbEnrollHandler.prototype.signerCompleted_ = function(anyPending) { if (!this.anyGnubbiesFound_ || this.anyTimeout_ || anyPending || this.timer_.expired()) { this.notifyError_(DeviceStatusCodes.TIMEOUT_STATUS); } else { // Do nothing: signerFoundGnubby will have been called with each succeeding // gnubby. } }; /** * Called when a MultipleGnubbySigner finds a gnubby that can enroll. * @param {MultipleSignerResult} signResult Signature results * @param {boolean} moreExpected Whether the signer expects to report * results from more gnubbies. * @private */ UsbEnrollHandler.prototype.signerFoundGnubby_ = function(signResult, moreExpected) { if (!signResult.code) { // If the signer reports a gnubby can sign, report this immediately to the // caller, as the gnubby is already enrolled. Map ok to WRONG_DATA, so the // caller knows what to do. this.notifyError_(DeviceStatusCodes.WRONG_DATA_STATUS); } else if (SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle( signResult.code)) { var gnubby = signResult['gnubby']; // A valid helper request contains at least one enroll challenge, so use // the app id hash from the first challenge. var appIdHash = this.request_.enrollChallenges[0].appIdHash; DEVICE_FACTORY_REGISTRY.getGnubbyFactory().notEnrolledPrerequisiteCheck( gnubby, appIdHash, this.gnubbyPrerequisitesChecked_.bind(this)); } else { // Unexpected error in signing? Send this immediately to the caller. this.notifyError_(signResult.code); } }; /** * Called with the result of a gnubby prerequisite check. * @param {number} rc The result of the prerequisite check. * @param {Gnubby=} opt_gnubby The gnubby whose prerequisites were checked. * @private */ UsbEnrollHandler.prototype.gnubbyPrerequisitesChecked_ = function(rc, opt_gnubby) { if (rc || this.timer_.expired()) { // Do nothing: // If the timer is expired, the signerCompleted_ callback will indicate // timeout to the caller. // If there's an error, this gnubby is ineligible, but there's nothing we // can do about that here. return; } // If the callback succeeded, the gnubby is not null. var gnubby = /** @type {Gnubby} */ (opt_gnubby); this.anyGnubbiesFound_ = true; this.waitingForTouchGnubbies_.push(gnubby); this.matchEnrollVersionToGnubby_(gnubby); }; /** * Attempts to match the gnubby's U2F version with an appropriate enroll * challenge. * @param {Gnubby} gnubby Gnubby instance * @private */ UsbEnrollHandler.prototype.matchEnrollVersionToGnubby_ = function(gnubby) { if (!gnubby) { console.warn(UTIL_fmt('no gnubby, WTF?')); return; } gnubby.version(this.gnubbyVersioned_.bind(this, gnubby)); }; /** * Called with the result of a version command. * @param {Gnubby} gnubby Gnubby instance * @param {number} rc result of version command. * @param {ArrayBuffer=} data version. * @private */ UsbEnrollHandler.prototype.gnubbyVersioned_ = function(gnubby, rc, data) { if (rc) { this.removeWrongVersionGnubby_(gnubby); return; } var version = UTIL_BytesToString(new Uint8Array(data || null)); this.tryEnroll_(gnubby, version); }; /** * Drops the gnubby from the list of eligible gnubbies. * @param {Gnubby} gnubby Gnubby instance * @private */ UsbEnrollHandler.prototype.removeWaitingGnubby_ = function(gnubby) { gnubby.closeWhenIdle(); var index = this.waitingForTouchGnubbies_.indexOf(gnubby); if (index >= 0) { this.waitingForTouchGnubbies_.splice(index, 1); } }; /** * Drops the gnubby from the list of eligible gnubbies, as it has the wrong * version. * @param {Gnubby} gnubby Gnubby instance * @private */ UsbEnrollHandler.prototype.removeWrongVersionGnubby_ = function(gnubby) { this.removeWaitingGnubby_(gnubby); if (!this.waitingForTouchGnubbies_.length) { // Whoops, this was the last gnubby. this.anyGnubbiesFound_ = false; if (this.timer_.expired()) { this.notifyError_(DeviceStatusCodes.TIMEOUT_STATUS); } else if (this.signer_) { this.signer_.reScanDevices(); } } }; /** * Attempts enrolling a particular gnubby with a challenge of the appropriate * version. * @param {Gnubby} gnubby Gnubby instance * @param {string} version Protocol version * @private */ UsbEnrollHandler.prototype.tryEnroll_ = function(gnubby, version) { var challenge = this.getChallengeOfVersion_(version); if (!challenge) { this.removeWrongVersionGnubby_(gnubby); return; } var challengeValue = B64_decode(challenge['challengeHash']); var appIdHash = challenge['appIdHash']; var individualAttest = DEVICE_FACTORY_REGISTRY.getIndividualAttestation(). requestIndividualAttestation(appIdHash); gnubby.enroll(challengeValue, B64_decode(appIdHash), this.enrollCallback_.bind(this, gnubby, version), individualAttest); }; /** * Finds the (first) challenge of the given version in this helper's challenges. * @param {string} version Protocol version * @return {Object} challenge, if found, or null if not. * @private */ UsbEnrollHandler.prototype.getChallengeOfVersion_ = function(version) { for (var i = 0; i < this.enrollChallenges.length; i++) { if (this.enrollChallenges[i]['version'] == version) { return this.enrollChallenges[i]; } } return null; }; /** * Called with the result of an enroll request to a gnubby. * @param {Gnubby} gnubby Gnubby instance * @param {string} version Protocol version * @param {number} code Status code * @param {ArrayBuffer=} infoArray Returned data * @private */ UsbEnrollHandler.prototype.enrollCallback_ = function(gnubby, version, code, infoArray) { if (this.notified_) { // Enroll completed after previous success or failure. Disregard. return; } switch (code) { case -GnubbyDevice.GONE: // Close this gnubby. this.removeWaitingGnubby_(gnubby); if (!this.waitingForTouchGnubbies_.length) { // Last enroll attempt is complete and last gnubby is gone. this.anyGnubbiesFound_ = false; if (this.timer_.expired()) { this.notifyError_(DeviceStatusCodes.TIMEOUT_STATUS); } else if (this.signer_) { this.signer_.reScanDevices(); } } break; case DeviceStatusCodes.WAIT_TOUCH_STATUS: case DeviceStatusCodes.BUSY_STATUS: case DeviceStatusCodes.TIMEOUT_STATUS: if (this.timer_.expired()) { // Record that at least one gnubby timed out, to return a timeout status // from the complete callback if no other eligible gnubbies are found. /** @private {boolean} */ this.anyTimeout_ = true; // Close this gnubby. this.removeWaitingGnubby_(gnubby); if (!this.waitingForTouchGnubbies_.length) { // Last enroll attempt is complete: return this error. console.log(UTIL_fmt('timeout (' + code.toString(16) + ') enrolling')); this.notifyError_(DeviceStatusCodes.TIMEOUT_STATUS); } } else { DEVICE_FACTORY_REGISTRY.getCountdownFactory().createTimer( UsbEnrollHandler.ENUMERATE_DELAY_INTERVAL_MILLIS, this.tryEnroll_.bind(this, gnubby, version)); } break; case DeviceStatusCodes.OK_STATUS: var info = B64_encode(new Uint8Array(infoArray || [])); this.notifySuccess_(version, info); break; default: console.log(UTIL_fmt('Failed to enroll gnubby: ' + code)); this.notifyError_(code); break; } }; /** * How long to delay between repeated enroll attempts, in milliseconds. * @const */ UsbEnrollHandler.ENUMERATE_DELAY_INTERVAL_MILLIS = 200; /** * Notifies the callback with an error code. * @param {number} code The error code to report. * @private */ UsbEnrollHandler.prototype.notifyError_ = function(code) { if (this.notified_ || this.closed_) return; this.notified_ = true; this.close(); var reply = { 'type': 'enroll_helper_reply', 'code': code }; this.cb_(reply); }; /** * @param {string} version Protocol version * @param {string} info B64 encoded success data * @private */ UsbEnrollHandler.prototype.notifySuccess_ = function(version, info) { if (this.notified_ || this.closed_) return; this.notified_ = true; this.close(); var reply = { 'type': 'enroll_helper_reply', 'code': DeviceStatusCodes.OK_STATUS, 'version': version, 'enrollData': info }; this.cb_(reply); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Queue of pending requests from an origin. * */ 'use strict'; /** * Represents a queued request. Once given a token, call complete() once the * request is processed (or dropped.) * @interface */ function QueuedRequestToken() {} /** Completes (or cancels) this queued request. */ QueuedRequestToken.prototype.complete = function() {}; /** * @param {!RequestQueue} queue The queue for this request. * @param {number} id An id for this request. * @param {function(QueuedRequestToken)} beginCb Called when work may begin on * this request. * @param {RequestToken} opt_prev Previous request in the same queue. * @param {RequestToken} opt_next Next request in the same queue. * @constructor * @implements {QueuedRequestToken} */ function RequestToken(queue, id, beginCb, opt_prev, opt_next) { /** @private {!RequestQueue} */ this.queue_ = queue; /** @private {number} */ this.id_ = id; /** @type {function(QueuedRequestToken)} */ this.beginCb = beginCb; /** @type {RequestToken} */ this.prev = null; /** @type {RequestToken} */ this.next = null; /** @private {boolean} */ this.completed_ = false; } /** Completes (or cancels) this queued request. */ RequestToken.prototype.complete = function() { if (this.completed_) { // Either the caller called us more than once, or the timer is firing. // Either way, nothing more to do here. return; } this.completed_ = true; this.queue_.complete(this); }; /** @return {boolean} Whether this token has already completed. */ RequestToken.prototype.completed = function() { return this.completed_; }; /** * @param {!SystemTimer} sysTimer A system timer implementation. * @constructor */ function RequestQueue(sysTimer) { /** @private {!SystemTimer} */ this.sysTimer_ = sysTimer; /** @private {RequestToken} */ this.head_ = null; /** @private {RequestToken} */ this.tail_ = null; /** @private {number} */ this.id_ = 0; } /** * Inserts this token into the queue. * @param {RequestToken} token Queue token * @private */ RequestQueue.prototype.insertToken_ = function(token) { console.log(UTIL_fmt('token ' + this.id_ + ' inserted')); if (this.head_ === null) { this.head_ = token; this.tail_ = token; } else { if (!this.tail_) throw 'Non-empty list missing tail'; this.tail_.next = token; token.prev = this.tail_; this.tail_ = token; } }; /** * Removes this token from the queue. * @param {RequestToken} token Queue token * @private */ RequestQueue.prototype.removeToken_ = function(token) { if (token.next) { token.next.prev = token.prev; } if (token.prev) { token.prev.next = token.next; } if (this.head_ === token && this.tail_ === token) { this.head_ = this.tail_ = null; } else { if (this.head_ === token) { this.head_ = token.next; this.head_.prev = null; } if (this.tail_ === token) { this.tail_ = token.prev; this.tail_.next = null; } } token.prev = token.next = null; }; /** * Completes this token's request, and begins the next queued request, if one * exists. * @param {RequestToken} token Queue token */ RequestQueue.prototype.complete = function(token) { console.log(UTIL_fmt('token ' + this.id_ + ' completed')); var next = token.next; this.removeToken_(token); if (next) { next.beginCb(next); } }; /** @return {boolean} Whether this queue is empty. */ RequestQueue.prototype.empty = function() { return this.head_ === null; }; /** * Queues this request, and, if it's the first request, begins work on it. * @param {function(QueuedRequestToken)} beginCb Called when work begins on this * request. * @param {Countdown} timer Countdown timer * @return {QueuedRequestToken} A token for the request. */ RequestQueue.prototype.queueRequest = function(beginCb, timer) { var startNow = this.empty(); var token = new RequestToken(this, ++this.id_, beginCb); // Clone the timer to set a callback on it, which will ensure complete() is // eventually called, even if the caller never gets around to it. timer.clone(token.complete.bind(token)); this.insertToken_(token); if (startNow) { this.sysTimer_.setTimeout(function() { if (!token.completed()) { token.beginCb(token); } }, 0); } return token; }; /** * @param {!SystemTimer} sysTimer A system timer implementation. * @constructor */ function OriginKeyedRequestQueue(sysTimer) { /** @private {!SystemTimer} */ this.sysTimer_ = sysTimer; /** @private {Object} */ this.requests_ = {}; } /** * Queues this request, and, if it's the first request, begins work on it. * @param {string} appId Application Id * @param {string} origin Request origin * @param {function(QueuedRequestToken)} beginCb Called when work begins on this * request. * @param {Countdown} timer Countdown timer * @return {QueuedRequestToken} A token for the request. */ OriginKeyedRequestQueue.prototype.queueRequest = function(appId, origin, beginCb, timer) { var key = appId + ' ' + origin; if (!this.requests_.hasOwnProperty(key)) { this.requests_[key] = new RequestQueue(this.sysTimer_); } var queue = this.requests_[key]; return queue.queueRequest(beginCb, timer); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Handles web page requests for gnubby sign requests. * */ 'use strict'; var gnubbySignRequestQueue; function initRequestQueue() { gnubbySignRequestQueue = new OriginKeyedRequestQueue( FACTORY_REGISTRY.getSystemTimer()); } /** * Handles a U2F sign request. * @param {MessageSender} messageSender The message sender. * @param {Object} request The web page's sign request. * @param {Function} sendResponse Called back with the result of the sign. * @return {Closeable} Request handler that should be closed when the browser * message channel is closed. */ function handleU2fSignRequest(messageSender, request, sendResponse) { var sentResponse = false; var queuedSignRequest; function sendErrorResponse(error) { sendResponseOnce(sentResponse, queuedSignRequest, makeU2fErrorResponse(request, error.errorCode, error.errorMessage), sendResponse); } function sendSuccessResponse(challenge, info, browserData) { var responseData = makeU2fSignResponseDataFromChallenge(challenge); addSignatureAndBrowserDataToResponseData(responseData, info, browserData, 'clientData'); var response = makeU2fSuccessResponse(request, responseData); sendResponseOnce(sentResponse, queuedSignRequest, response, sendResponse); } var sender = createSenderFromMessageSender(messageSender); if (!sender) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } if (sender.origin.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED) { sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST}); return null; } queuedSignRequest = validateAndEnqueueSignRequest( sender, request, sendErrorResponse, sendSuccessResponse); return queuedSignRequest; } /** * Creates a base U2F responseData object from the server challenge. * @param {SignChallenge} challenge The server challenge. * @return {Object} The responseData object. */ function makeU2fSignResponseDataFromChallenge(challenge) { var responseData = { 'keyHandle': challenge['keyHandle'] }; return responseData; } /** * Adds the browser data and signature values to a responseData object. * @param {Object} responseData The "base" responseData object. * @param {string} signatureData The signature data. * @param {string} browserData The browser data generated from the challenge. * @param {string} browserDataName The name of the browser data key in the * responseData object. */ function addSignatureAndBrowserDataToResponseData(responseData, signatureData, browserData, browserDataName) { responseData[browserDataName] = B64_encode(UTIL_StringToBytes(browserData)); responseData['signatureData'] = signatureData; } /** * Validates a sign request using the given sign challenges name, and, if valid, * enqueues the sign request for eventual processing. * @param {WebRequestSender} sender The sender of the message. * @param {Object} request The web page's sign request. * @param {function(U2fError)} errorCb Error callback. * @param {function(SignChallenge, string, string)} successCb Success callback. * @return {Closeable} Request handler that should be closed when the browser * message channel is closed. */ function validateAndEnqueueSignRequest(sender, request, errorCb, successCb) { function timeout() { errorCb({errorCode: ErrorCodes.TIMEOUT}); } if (!isValidSignRequest(request)) { errorCb({errorCode: ErrorCodes.BAD_REQUEST}); return null; } // The typecast is necessary because getSignChallenges can return undefined. // On the other hand, a valid sign request can't contain an undefined sign // challenge list, so the typecast is safe. var signChallenges = /** @type {!Array} */ ( getSignChallenges(request)); var appId; if (request['appId']) { appId = request['appId']; } else if (signChallenges.length) { appId = signChallenges[0]['appId']; } // Sanity check if (!appId) { console.warn(UTIL_fmt('empty sign appId?')); errorCb({errorCode: ErrorCodes.BAD_REQUEST}); return null; } var timeoutValueSeconds = getTimeoutValueFromRequest(request); // Attenuate watchdog timeout value less than the signer's timeout, so the // watchdog only fires after the signer could reasonably have called back, // not before. timeoutValueSeconds = attenuateTimeoutInSeconds(timeoutValueSeconds, MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2); var watchdog = new WatchdogRequestHandler(timeoutValueSeconds, timeout); var wrappedErrorCb = watchdog.wrapCallback(errorCb); var wrappedSuccessCb = watchdog.wrapCallback(successCb); var timer = createAttenuatedTimer( FACTORY_REGISTRY.getCountdownFactory(), timeoutValueSeconds); var logMsgUrl = request['logMsgUrl']; // Queue sign requests from the same origin, to protect against simultaneous // sign-out on many tabs resulting in repeated sign-in requests. var queuedSignRequest = new QueuedSignRequest(signChallenges, timer, sender, wrappedErrorCb, wrappedSuccessCb, request['challenge'], appId, logMsgUrl); if (!gnubbySignRequestQueue) { initRequestQueue(); } var requestToken = gnubbySignRequestQueue.queueRequest(appId, sender.origin, queuedSignRequest.begin.bind(queuedSignRequest), timer); queuedSignRequest.setToken(requestToken); watchdog.setCloseable(queuedSignRequest); return watchdog; } /** * Returns whether the request appears to be a valid sign request. * @param {Object} request The request. * @return {boolean} Whether the request appears valid. */ function isValidSignRequest(request) { var signChallenges = getSignChallenges(request); if (!signChallenges) { return false; } var hasDefaultChallenge = request.hasOwnProperty('challenge'); var hasAppId = request.hasOwnProperty('appId'); // If the sign challenge array is empty, the global appId is required. if (!hasAppId && (!signChallenges || !signChallenges.length)) { return false; } return isValidSignChallengeArray(signChallenges, !hasDefaultChallenge, !hasAppId); } /** * Adapter class representing a queued sign request. * @param {!Array} signChallenges The sign challenges. * @param {Countdown} timer Timeout timer * @param {WebRequestSender} sender Message sender. * @param {function(U2fError)} errorCb Error callback * @param {function(SignChallenge, string, string)} successCb Success callback * @param {string|undefined} opt_defaultChallenge A default sign challenge * value, if a request does not provide one. * @param {string|undefined} opt_appId The app id for the entire request. * @param {string|undefined} opt_logMsgUrl Url to post log messages to * @constructor * @implements {Closeable} */ function QueuedSignRequest(signChallenges, timer, sender, errorCb, successCb, opt_defaultChallenge, opt_appId, opt_logMsgUrl) { /** @private {!Array} */ this.signChallenges_ = signChallenges; /** @private {Countdown} */ this.timer_ = timer.clone(this.close.bind(this)); /** @private {WebRequestSender} */ this.sender_ = sender; /** @private {function(U2fError)} */ this.errorCb_ = errorCb; /** @private {function(SignChallenge, string, string)} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.defaultChallenge_ = opt_defaultChallenge; /** @private {string|undefined} */ this.appId_ = opt_appId; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.begun_ = false; /** @private {boolean} */ this.closed_ = false; } /** Closes this sign request. */ QueuedSignRequest.prototype.close = function() { if (this.closed_) return; var hadBegunSigning = false; if (this.begun_ && this.signer_) { this.signer_.close(); hadBegunSigning = true; } if (this.token_) { if (hadBegunSigning) { console.log(UTIL_fmt('closing in-progress request')); } else { console.log(UTIL_fmt('closing timed-out request before processing')); } this.token_.complete(); } this.closed_ = true; }; /** * @param {QueuedRequestToken} token Token for this sign request. */ QueuedSignRequest.prototype.setToken = function(token) { /** @private {QueuedRequestToken} */ this.token_ = token; }; /** * Called when this sign request may begin work. * @param {QueuedRequestToken} token Token for this sign request. */ QueuedSignRequest.prototype.begin = function(token) { if (this.timer_.expired()) { console.log(UTIL_fmt('Queued request begun after timeout')); this.close(); this.errorCb_({errorCode: ErrorCodes.TIMEOUT}); return; } this.begun_ = true; this.setToken(token); this.signer_ = new Signer(this.timer_, this.sender_, this.signerFailed_.bind(this), this.signerSucceeded_.bind(this), this.logMsgUrl_); if (!this.signer_.setChallenges(this.signChallenges_, this.defaultChallenge_, this.appId_)) { token.complete(); this.errorCb_({errorCode: ErrorCodes.BAD_REQUEST}); } // Signer now has responsibility for maintaining timeout. this.timer_.clearTimeout(); }; /** * Called when this request's signer fails. * @param {U2fError} error The failure reported by the signer. * @private */ QueuedSignRequest.prototype.signerFailed_ = function(error) { this.token_.complete(); this.errorCb_(error); }; /** * Called when this request's signer succeeds. * @param {SignChallenge} challenge The challenge that was signed. * @param {string} info The sign result. * @param {string} browserData Browser data JSON * @private */ QueuedSignRequest.prototype.signerSucceeded_ = function(challenge, info, browserData) { this.token_.complete(); this.successCb_(challenge, info, browserData); }; /** * Creates an object to track signing with a gnubby. * @param {Countdown} timer Timer for sign request. * @param {WebRequestSender} sender The message sender. * @param {function(U2fError)} errorCb Called when the sign operation fails. * @param {function(SignChallenge, string, string)} successCb Called when the * sign operation succeeds. * @param {string=} opt_logMsgUrl The url to post log messages to. * @constructor */ function Signer(timer, sender, errorCb, successCb, opt_logMsgUrl) { /** @private {Countdown} */ this.timer_ = timer.clone(); /** @private {WebRequestSender} */ this.sender_ = sender; /** @private {function(U2fError)} */ this.errorCb_ = errorCb; /** @private {function(SignChallenge, string, string)} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.challengesSet_ = false; /** @private {boolean} */ this.done_ = false; /** @private {Object} */ this.browserData_ = {}; /** @private {Object} */ this.serverChallenges_ = {}; // Allow http appIds for http origins. (Broken, but the caller deserves // what they get.) /** @private {boolean} */ this.allowHttp_ = this.sender_.origin ? this.sender_.origin.indexOf('http://') == 0 : false; /** @private {Closeable} */ this.handler_ = null; } /** * Sets the challenges to be signed. * @param {Array} signChallenges The challenges to set. * @param {string=} opt_defaultChallenge A default sign challenge * value, if a request does not provide one. * @param {string=} opt_appId The app id for the entire request. * @return {boolean} Whether the challenges could be set. */ Signer.prototype.setChallenges = function(signChallenges, opt_defaultChallenge, opt_appId) { if (this.challengesSet_ || this.done_) return false; if (this.timer_.expired()) { this.notifyError_({errorCode: ErrorCodes.TIMEOUT}); return true; } /** @private {Array} */ this.signChallenges_ = signChallenges; /** @private {string|undefined} */ this.defaultChallenge_ = opt_defaultChallenge; /** @private {string|undefined} */ this.appId_ = opt_appId; /** @private {boolean} */ this.challengesSet_ = true; this.checkAppIds_(); return true; }; /** * Checks the app ids of incoming requests. * @private */ Signer.prototype.checkAppIds_ = function() { var appIds = getDistinctAppIds(this.signChallenges_); if (this.appId_) { appIds = UTIL_unionArrays([this.appId_], appIds); } if (!appIds || !appIds.length) { var error = { errorCode: ErrorCodes.BAD_REQUEST, errorMessage: 'missing appId' }; this.notifyError_(error); return; } FACTORY_REGISTRY.getOriginChecker() .canClaimAppIds(this.sender_.origin, appIds) .then(this.originChecked_.bind(this, appIds)); }; /** * Called with the result of checking the origin. When the origin is allowed * to claim the app ids, begins checking whether the app ids also list the * origin. * @param {!Array} appIds The app ids. * @param {boolean} result Whether the origin could claim the app ids. * @private */ Signer.prototype.originChecked_ = function(appIds, result) { if (!result) { var error = { errorCode: ErrorCodes.BAD_REQUEST, errorMessage: 'bad appId' }; this.notifyError_(error); return; } var appIdChecker = FACTORY_REGISTRY.getAppIdCheckerFactory().create(); appIdChecker. checkAppIds( this.timer_.clone(), this.sender_.origin, /** @type {!Array} */ (appIds), this.allowHttp_, this.logMsgUrl_) .then(this.appIdChecked_.bind(this)); }; /** * Called with the result of checking app ids. When the app ids are valid, * adds the sign challenges to those being signed. * @param {boolean} result Whether the app ids are valid. * @private */ Signer.prototype.appIdChecked_ = function(result) { if (!result) { var error = { errorCode: ErrorCodes.BAD_REQUEST, errorMessage: 'bad appId' }; this.notifyError_(error); return; } if (!this.doSign_()) { this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST}); return; } }; /** * Begins signing this signer's challenges. * @return {boolean} Whether the challenge could be added. * @private */ Signer.prototype.doSign_ = function() { // Create the browser data for each challenge. for (var i = 0; i < this.signChallenges_.length; i++) { var challenge = this.signChallenges_[i]; var serverChallenge; if (challenge.hasOwnProperty('challenge')) { serverChallenge = challenge['challenge']; } else { serverChallenge = this.defaultChallenge_; } if (!serverChallenge) { console.warn(UTIL_fmt('challenge missing')); return false; } var keyHandle = challenge['keyHandle']; var browserData = makeSignBrowserData(serverChallenge, this.sender_.origin, this.sender_.tlsChannelId); this.browserData_[keyHandle] = browserData; this.serverChallenges_[keyHandle] = challenge; } var encodedChallenges = encodeSignChallenges(this.signChallenges_, this.defaultChallenge_, this.appId_, this.getChallengeHash_.bind(this)); var timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0; var request = makeSignHelperRequest(encodedChallenges, timeoutSeconds, this.logMsgUrl_); this.handler_ = FACTORY_REGISTRY.getRequestHelper() .getHandler(/** @type {HelperRequest} */ (request)); if (!this.handler_) return false; return this.handler_.run(this.helperComplete_.bind(this)); }; /** * @param {string} keyHandle The key handle used with the challenge. * @param {string} challenge The challenge. * @return {string} The hashed challenge associated with the key * handle/challenge pair. * @private */ Signer.prototype.getChallengeHash_ = function(keyHandle, challenge) { return B64_encode(sha256HashOfString(this.browserData_[keyHandle])); }; /** Closes this signer. */ Signer.prototype.close = function() { this.close_(); }; /** * Closes this signer, and optionally notifies the caller of error. * @param {boolean=} opt_notifying When true, this method is being called in the * process of notifying the caller of an existing status. When false, * the caller is notified with a default error value, ErrorCodes.TIMEOUT. * @private */ Signer.prototype.close_ = function(opt_notifying) { if (this.handler_) { this.handler_.close(); this.handler_ = null; } this.timer_.clearTimeout(); if (!opt_notifying) { this.notifyError_({errorCode: ErrorCodes.TIMEOUT}); } }; /** * Notifies the caller of error. * @param {U2fError} error Error. * @private */ Signer.prototype.notifyError_ = function(error) { if (this.done_) return; this.done_ = true; this.close_(true); this.errorCb_(error); }; /** * Notifies the caller of success. * @param {SignChallenge} challenge The challenge that was signed. * @param {string} info The sign result. * @param {string} browserData Browser data JSON * @private */ Signer.prototype.notifySuccess_ = function(challenge, info, browserData) { if (this.done_) return; this.done_ = true; this.close_(true); this.successCb_(challenge, info, browserData); }; /** * Called by the helper upon completion. * @param {HelperReply} helperReply The result of the sign request. * @param {string=} opt_source The source of the sign result. * @private */ Signer.prototype.helperComplete_ = function(helperReply, opt_source) { if (helperReply.type != 'sign_helper_reply') { this.notifyError_({errorCode: ErrorCodes.OTHER_ERROR}); return; } var reply = /** @type {SignHelperReply} */ (helperReply); if (reply.code) { var reportedError = mapDeviceStatusCodeToU2fError(reply.code); console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) + ', returning ' + reportedError.errorCode)); this.notifyError_(reportedError); } else { if (this.logMsgUrl_ && opt_source) { var logMsg = 'signed&source=' + opt_source; logMessage(logMsg, this.logMsgUrl_); } var key = reply.responseData['keyHandle']; var browserData = this.browserData_[key]; // Notify with server-provided challenge, not the encoded one: the // server-provided challenge contains additional fields it relies on. var serverChallenge = this.serverChallenges_[key]; this.notifySuccess_(serverChallenge, reply.responseData.signatureData, browserData); } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A single gnubby signer wraps the process of opening a gnubby, * signing each challenge in an array of challenges until a success condition * is satisfied, and finally yielding the gnubby upon success. * */ 'use strict'; /** * @typedef {{ * code: number, * gnubby: (Gnubby|undefined), * challenge: (SignHelperChallenge|undefined), * info: (ArrayBuffer|undefined) * }} */ var SingleSignerResult; /** * Creates a new sign handler with a gnubby. This handler will perform a sign * operation using each challenge in an array of challenges until its success * condition is satisified, or an error or timeout occurs. The success condition * is defined differently depending whether this signer is used for enrolling * or for signing: * * For enroll, success is defined as each challenge yielding wrong data. This * means this gnubby is not currently enrolled for any of the appIds in any * challenge. * * For sign, success is defined as any challenge yielding ok. * * The complete callback is called only when the signer reaches success or * failure, i.e. when there is no need for this signer to continue trying new * challenges. * * @param {GnubbyDeviceId} gnubbyId Which gnubby to open. * @param {boolean} forEnroll Whether this signer is signing for an attempted * enroll operation. * @param {function(SingleSignerResult)} * completeCb Called when this signer completes, i.e. no further results are * possible. * @param {Countdown} timer An advisory timer, beyond whose expiration the * signer will not attempt any new operations, assuming the caller is no * longer interested in the outcome. * @param {string=} opt_logMsgUrl A URL to post log messages to. * @constructor */ function SingleGnubbySigner(gnubbyId, forEnroll, completeCb, timer, opt_logMsgUrl) { /** @private {GnubbyDeviceId} */ this.gnubbyId_ = gnubbyId; /** @private {SingleGnubbySigner.State} */ this.state_ = SingleGnubbySigner.State.INIT; /** @private {boolean} */ this.forEnroll_ = forEnroll; /** @private {function(SingleSignerResult)} */ this.completeCb_ = completeCb; /** @private {Countdown} */ this.timer_ = timer; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {!Array} */ this.challenges_ = []; /** @private {number} */ this.challengeIndex_ = 0; /** @private {boolean} */ this.challengesSet_ = false; /** @private {!Object} */ this.cachedError_ = []; } /** @enum {number} */ SingleGnubbySigner.State = { /** Initial state. */ INIT: 0, /** The signer is attempting to open a gnubby. */ OPENING: 1, /** The signer's gnubby opened, but is busy. */ BUSY: 2, /** The signer has an open gnubby, but no challenges to sign. */ IDLE: 3, /** The signer is currently signing a challenge. */ SIGNING: 4, /** The signer got a final outcome. */ COMPLETE: 5, /** The signer is closing its gnubby. */ CLOSING: 6, /** The signer is closed. */ CLOSED: 7 }; /** * @return {GnubbyDeviceId} This device id of the gnubby for this signer. */ SingleGnubbySigner.prototype.getDeviceId = function() { return this.gnubbyId_; }; /** * Closes this signer's gnubby, if it's held. */ SingleGnubbySigner.prototype.close = function() { if (!this.gnubby_) return; this.state_ = SingleGnubbySigner.State.CLOSING; this.gnubby_.closeWhenIdle(this.closed_.bind(this)); }; /** * Called when this signer's gnubby is closed. * @private */ SingleGnubbySigner.prototype.closed_ = function() { this.gnubby_ = null; this.state_ = SingleGnubbySigner.State.CLOSED; }; /** * Begins signing the given challenges. * @param {Array} challenges The challenges to sign. * @return {boolean} Whether the challenges were accepted. */ SingleGnubbySigner.prototype.doSign = function(challenges) { if (this.challengesSet_) { // Can't add new challenges once they've been set. return false; } if (challenges) { console.log(this.gnubby_); console.log(UTIL_fmt('adding ' + challenges.length + ' challenges')); for (var i = 0; i < challenges.length; i++) { this.challenges_.push(challenges[i]); } } this.challengesSet_ = true; switch (this.state_) { case SingleGnubbySigner.State.INIT: this.open_(); break; case SingleGnubbySigner.State.OPENING: // The open has already commenced, so accept the challenges, but don't do // anything. break; case SingleGnubbySigner.State.IDLE: if (this.challengeIndex_ < challenges.length) { // Challenges set: start signing. this.doSign_(this.challengeIndex_); } else { // An empty list of challenges can be set during enroll, when the user // has no existing enrolled gnubbies. It's unexpected during sign, but // returning WRONG_DATA satisfies the caller in either case. var self = this; window.setTimeout(function() { self.goToError_(DeviceStatusCodes.WRONG_DATA_STATUS); }, 0); } break; case SingleGnubbySigner.State.SIGNING: // Already signing, so don't kick off a new sign, but accept the added // challenges. break; default: return false; } return true; }; /** * Attempts to open this signer's gnubby, if it's not already open. * @private */ SingleGnubbySigner.prototype.open_ = function() { var appIdHash; if (this.challenges_.length) { // Assume the first challenge's appId is representative of all of them. appIdHash = B64_encode(this.challenges_[0].appIdHash); } if (this.state_ == SingleGnubbySigner.State.INIT) { this.state_ = SingleGnubbySigner.State.OPENING; DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( this.gnubbyId_, this.forEnroll_, this.openCallback_.bind(this), appIdHash, this.logMsgUrl_); } }; /** * How long to delay retrying a failed open. */ SingleGnubbySigner.OPEN_DELAY_MILLIS = 200; /** * How long to delay retrying a sign requiring touch. */ SingleGnubbySigner.SIGN_DELAY_MILLIS = 200; /** * @param {number} rc The result of the open operation. * @param {Gnubby=} gnubby The opened gnubby, if open was successful (or busy). * @private */ SingleGnubbySigner.prototype.openCallback_ = function(rc, gnubby) { if (this.state_ != SingleGnubbySigner.State.OPENING && this.state_ != SingleGnubbySigner.State.BUSY) { // Open completed after close, perhaps? Ignore. return; } switch (rc) { case DeviceStatusCodes.OK_STATUS: if (!gnubby) { console.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?')); } else { this.gnubby_ = gnubby; this.gnubby_.version(this.versionCallback_.bind(this)); } break; case DeviceStatusCodes.BUSY_STATUS: this.gnubby_ = gnubby; this.state_ = SingleGnubbySigner.State.BUSY; // If there's still time, retry the open. if (!this.timer_ || !this.timer_.expired()) { var self = this; window.setTimeout(function() { if (self.gnubby_) { DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( self.gnubbyId_, self.forEnroll_, self.openCallback_.bind(self), self.logMsgUrl_); } }, SingleGnubbySigner.OPEN_DELAY_MILLIS); } else { this.goToError_(DeviceStatusCodes.BUSY_STATUS); } break; default: // TODO: This won't be confused with success, but should it be // part of the same namespace as the other error codes, which are // always in DeviceStatusCodes.*? this.goToError_(rc, true); } }; /** * Called with the result of a version command. * @param {number} rc Result of version command. * @param {ArrayBuffer=} opt_data Version. * @private */ SingleGnubbySigner.prototype.versionCallback_ = function(rc, opt_data) { if (rc == DeviceStatusCodes.BUSY_STATUS) { if (this.timer_ && this.timer_.expired()) { this.goToError_(DeviceStatusCodes.TIMEOUT_STATUS); return; } // There's still time: resync and retry. var self = this; this.gnubby_.sync(function(code) { if (code) { self.goToError_(code, true); return; } self.gnubby_.version(self.versionCallback_.bind(self)); }); return; } if (rc) { this.goToError_(rc, true); return; } this.state_ = SingleGnubbySigner.State.IDLE; this.version_ = UTIL_BytesToString(new Uint8Array(opt_data || [])); this.doSign_(this.challengeIndex_); }; /** * @param {number} challengeIndex Index of challenge to sign * @private */ SingleGnubbySigner.prototype.doSign_ = function(challengeIndex) { if (!this.gnubby_) { // Already closed? Nothing to do. return; } if (this.timer_ && this.timer_.expired()) { // If the timer is expired, that means we never got a success response. // We could have gotten wrong data on a partial set of challenges, but this // means we don't yet know the final outcome. In any event, we don't yet // know the final outcome: return timeout. this.goToError_(DeviceStatusCodes.TIMEOUT_STATUS); return; } if (!this.challengesSet_) { this.state_ = SingleGnubbySigner.State.IDLE; return; } this.state_ = SingleGnubbySigner.State.SIGNING; if (challengeIndex >= this.challenges_.length) { this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); return; } var challenge = this.challenges_[challengeIndex]; var challengeHash = challenge.challengeHash; var appIdHash = challenge.appIdHash; var keyHandle = challenge.keyHandle; if (this.cachedError_.hasOwnProperty(keyHandle)) { // Cache hit: return wrong data again. this.signCallback_(challengeIndex, this.cachedError_[keyHandle]); } else if (challenge.version && challenge.version != this.version_) { // Sign challenge for a different version of gnubby: return wrong data. this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); } else { var nowink = false; this.gnubby_.sign(challengeHash, appIdHash, keyHandle, this.signCallback_.bind(this, challengeIndex), nowink); } }; /** * @param {number} code The result of a sign operation. * @return {boolean} Whether the error indicates the key handle is invalid * for this gnubby. */ SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle = function(code) { return (code == DeviceStatusCodes.WRONG_DATA_STATUS || code == DeviceStatusCodes.WRONG_LENGTH_STATUS || code == DeviceStatusCodes.INVALID_DATA_STATUS); }; /** * Called with the result of a single sign operation. * @param {number} challengeIndex the index of the challenge just attempted * @param {number} code the result of the sign operation * @param {ArrayBuffer=} opt_info Optional result data * @private */ SingleGnubbySigner.prototype.signCallback_ = function(challengeIndex, code, opt_info) { console.log(UTIL_fmt('gnubby ' + JSON.stringify(this.gnubbyId_) + ', challenge ' + challengeIndex + ' yielded ' + code.toString(16))); if (this.state_ != SingleGnubbySigner.State.SIGNING) { console.log(UTIL_fmt('already done!')); // We're done, the caller's no longer interested. return; } // Cache certain idempotent errors, re-asking the gnubby to sign it // won't produce different results. if (SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle(code)) { if (challengeIndex < this.challenges_.length) { var challenge = this.challenges_[challengeIndex]; if (!this.cachedError_.hasOwnProperty(challenge.keyHandle)) { this.cachedError_[challenge.keyHandle] = code; } } } var self = this; switch (code) { case DeviceStatusCodes.GONE_STATUS: this.goToError_(code); break; case DeviceStatusCodes.TIMEOUT_STATUS: this.gnubby_.sync(this.synced_.bind(this)); break; case DeviceStatusCodes.BUSY_STATUS: this.doSign_(this.challengeIndex_); break; case DeviceStatusCodes.OK_STATUS: // Lower bound on the minimum length, signature length can vary. var MIN_SIGNATURE_LENGTH = 7; if (!opt_info || opt_info.byteLength < MIN_SIGNATURE_LENGTH) { console.error(UTIL_fmt('Got short response to sign request (' + (opt_info ? opt_info.byteLength : 0) + ' bytes), WTF?')); } if (this.forEnroll_) { this.goToError_(code); } else { this.goToSuccess_(code, this.challenges_[challengeIndex], opt_info); } break; case DeviceStatusCodes.WAIT_TOUCH_STATUS: window.setTimeout(function() { self.doSign_(self.challengeIndex_); }, SingleGnubbySigner.SIGN_DELAY_MILLIS); break; case DeviceStatusCodes.WRONG_DATA_STATUS: case DeviceStatusCodes.WRONG_LENGTH_STATUS: case DeviceStatusCodes.INVALID_DATA_STATUS: if (this.challengeIndex_ < this.challenges_.length - 1) { this.doSign_(++this.challengeIndex_); } else if (this.forEnroll_) { this.goToSuccess_(code); } else { this.goToError_(code); } break; default: if (this.forEnroll_) { this.goToError_(code, true); } else if (this.challengeIndex_ < this.challenges_.length - 1) { this.doSign_(++this.challengeIndex_); } else { this.goToError_(code, true); } } }; /** * Called with the response of a sync command, called when a sign yields a * timeout to reassert control over the gnubby. * @param {number} code Error code * @private */ SingleGnubbySigner.prototype.synced_ = function(code) { if (code) { this.goToError_(code, true); return; } this.doSign_(this.challengeIndex_); }; /** * Switches to the error state, and notifies caller. * @param {number} code Error code * @param {boolean=} opt_warn Whether to warn in the console about the error. * @private */ SingleGnubbySigner.prototype.goToError_ = function(code, opt_warn) { this.state_ = SingleGnubbySigner.State.COMPLETE; var logFn = opt_warn ? console.warn.bind(console) : console.log.bind(console); logFn(UTIL_fmt('failed (' + code.toString(16) + ')')); var result = { code: code }; if (!this.forEnroll_ && code == DeviceStatusCodes.WRONG_DATA_STATUS) { // When a device yields WRONG_DATA to all sign challenges, and this is a // sign request, we don't want to yield to the web page that it's not // enrolled just yet: we want the user to tap the device first. We'll // report the gnubby to the caller and let it close it instead of closing // it here. result.gnubby = this.gnubby_; } else { // Since this gnubby can no longer produce a useful result, go ahead and // close it. this.close(); } this.completeCb_(result); }; /** * Switches to the success state, and notifies caller. * @param {number} code Status code * @param {SignHelperChallenge=} opt_challenge The challenge signed * @param {ArrayBuffer=} opt_info Optional result data * @private */ SingleGnubbySigner.prototype.goToSuccess_ = function(code, opt_challenge, opt_info) { this.state_ = SingleGnubbySigner.State.COMPLETE; console.log(UTIL_fmt('success (' + code.toString(16) + ')')); var result = { code: code, gnubby: this.gnubby_ }; if (opt_challenge || opt_info) { if (opt_challenge) { result['challenge'] = opt_challenge; } if (opt_info) { result['info'] = opt_info; } } this.completeCb_(result); // this.gnubby_ is now owned by completeCb_. this.gnubby_ = null; }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A multiple gnubby signer wraps the process of opening a number * of gnubbies, signing each challenge in an array of challenges until a * success condition is satisfied, and yielding each succeeding gnubby. * */ 'use strict'; /** * @typedef {{ * code: number, * gnubbyId: GnubbyDeviceId, * challenge: (SignHelperChallenge|undefined), * info: (ArrayBuffer|undefined) * }} */ var MultipleSignerResult; /** * Creates a new sign handler that manages signing with all the available * gnubbies. * @param {boolean} forEnroll Whether this signer is signing for an attempted * enroll operation. * @param {function(boolean)} allCompleteCb Called when this signer completes * sign attempts, i.e. no further results will be produced. The parameter * indicates whether any gnubbies are present that have not yet produced a * final result. * @param {function(MultipleSignerResult, boolean)} gnubbyCompleteCb * Called with each gnubby/challenge that yields a final result, along with * whether this signer expects to produce more results. The boolean is a * hint rather than a promise: it's possible for this signer to produce * further results after saying it doesn't expect more, or to fail to * produce further results after saying it does. * @param {number} timeoutMillis A timeout value, beyond whose expiration the * signer will not attempt any new operations, assuming the caller is no * longer interested in the outcome. * @param {string=} opt_logMsgUrl A URL to post log messages to. * @constructor */ function MultipleGnubbySigner(forEnroll, allCompleteCb, gnubbyCompleteCb, timeoutMillis, opt_logMsgUrl) { /** @private {boolean} */ this.forEnroll_ = forEnroll; /** @private {function(boolean)} */ this.allCompleteCb_ = allCompleteCb; /** @private {function(MultipleSignerResult, boolean)} */ this.gnubbyCompleteCb_ = gnubbyCompleteCb; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {Array} */ this.challenges_ = []; /** @private {boolean} */ this.challengesSet_ = false; /** @private {boolean} */ this.complete_ = false; /** @private {number} */ this.numComplete_ = 0; /** @private {!Object} */ this.gnubbies_ = {}; /** @private {Countdown} */ this.timer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory() .createTimer(timeoutMillis); /** @private {Countdown} */ this.reenumerateTimer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory() .createTimer(timeoutMillis); } /** * @typedef {{ * index: string, * signer: SingleGnubbySigner, * stillGoing: boolean, * errorStatus: number * }} */ var GnubbyTracker; /** * Closes this signer's gnubbies, if any are open. */ MultipleGnubbySigner.prototype.close = function() { for (var k in this.gnubbies_) { this.gnubbies_[k].signer.close(); } this.reenumerateTimer_.clearTimeout(); this.timer_.clearTimeout(); if (this.reenumerateIntervalTimer_) { this.reenumerateIntervalTimer_.clearTimeout(); } }; /** * Begins signing the given challenges. * @param {Array} challenges The challenges to sign. * @return {boolean} whether the challenges were successfully added. */ MultipleGnubbySigner.prototype.doSign = function(challenges) { if (this.challengesSet_) { // Can't add new challenges once they're finalized. return false; } if (challenges) { for (var i = 0; i < challenges.length; i++) { var decodedChallenge = {}; var challenge = challenges[i]; decodedChallenge['challengeHash'] = B64_decode(challenge['challengeHash']); decodedChallenge['appIdHash'] = B64_decode(challenge['appIdHash']); decodedChallenge['keyHandle'] = B64_decode(challenge['keyHandle']); if (challenge['version']) { decodedChallenge['version'] = challenge['version']; } this.challenges_.push(decodedChallenge); } } this.challengesSet_ = true; this.enumerateGnubbies_(); return true; }; /** * Signals this signer to rescan for gnubbies. Useful when the caller has * knowledge that the last device has been removed, and can notify this class * before it will discover it on its own. */ MultipleGnubbySigner.prototype.reScanDevices = function() { if (this.reenumerateIntervalTimer_) { this.reenumerateIntervalTimer_.clearTimeout(); } this.maybeReEnumerateGnubbies_(true); }; /** * Enumerates gnubbies. * @private */ MultipleGnubbySigner.prototype.enumerateGnubbies_ = function() { DEVICE_FACTORY_REGISTRY.getGnubbyFactory().enumerate( this.enumerateCallback_.bind(this)); }; /** * Called with the result of enumerating gnubbies. * @param {number} rc The return code from enumerating. * @param {Array} ids The gnubbies enumerated. * @private */ MultipleGnubbySigner.prototype.enumerateCallback_ = function(rc, ids) { if (this.complete_) { return; } if (rc || !ids || !ids.length) { this.maybeReEnumerateGnubbies_(true); return; } for (var i = 0; i < ids.length; i++) { this.addGnubby_(ids[i]); } this.maybeReEnumerateGnubbies_(false); }; /** * How frequently to reenumerate gnubbies when none are found, in milliseconds. * @const */ MultipleGnubbySigner.ACTIVE_REENUMERATE_INTERVAL_MILLIS = 200; /** * How frequently to reenumerate gnubbies when some are found, in milliseconds. * @const */ MultipleGnubbySigner.PASSIVE_REENUMERATE_INTERVAL_MILLIS = 3000; /** * Reenumerates gnubbies if there's still time. * @param {boolean} activeScan Whether to poll more aggressively, e.g. if * there are no devices present. * @private */ MultipleGnubbySigner.prototype.maybeReEnumerateGnubbies_ = function(activeScan) { if (this.reenumerateTimer_.expired()) { // If the timer is expired, call timeout_ if there aren't any still-running // gnubbies. (If there are some still running, the last will call timeout_ // itself.) if (!this.anyPending_()) { this.timeout_(false); } return; } // Reenumerate more aggressively if there are no gnubbies present than if // there are any. var reenumerateTimeoutMillis; if (activeScan) { reenumerateTimeoutMillis = MultipleGnubbySigner.ACTIVE_REENUMERATE_INTERVAL_MILLIS; } else { reenumerateTimeoutMillis = MultipleGnubbySigner.PASSIVE_REENUMERATE_INTERVAL_MILLIS; } if (reenumerateTimeoutMillis > this.reenumerateTimer_.millisecondsUntilExpired()) { reenumerateTimeoutMillis = this.reenumerateTimer_.millisecondsUntilExpired(); } /** @private {Countdown} */ this.reenumerateIntervalTimer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory().createTimer( reenumerateTimeoutMillis, this.enumerateGnubbies_.bind(this)); }; /** * Adds a new gnubby to this signer's list of gnubbies. (Only possible while * this signer is still signing: without this restriction, the completed * callback could be called more than once, in violation of its contract.) * If this signer has challenges to sign, begins signing on the new gnubby with * them. * @param {GnubbyDeviceId} gnubbyId The id of the gnubby to add. * @return {boolean} Whether the gnubby was added successfully. * @private */ MultipleGnubbySigner.prototype.addGnubby_ = function(gnubbyId) { var index = JSON.stringify(gnubbyId); if (this.gnubbies_.hasOwnProperty(index)) { // Can't add the same gnubby twice. return false; } var tracker = { index: index, errorStatus: 0, stillGoing: false, signer: null }; tracker.signer = new SingleGnubbySigner( gnubbyId, this.forEnroll_, this.signCompletedCallback_.bind(this, tracker), this.timer_.clone(), this.logMsgUrl_); this.gnubbies_[index] = tracker; this.gnubbies_[index].stillGoing = tracker.signer.doSign(this.challenges_); if (!this.gnubbies_[index].errorStatus) { this.gnubbies_[index].errorStatus = 0; } return true; }; /** * Called by a SingleGnubbySigner upon completion. * @param {GnubbyTracker} tracker The tracker object of the gnubby whose result * this is. * @param {SingleSignerResult} result The result of the sign operation. * @private */ MultipleGnubbySigner.prototype.signCompletedCallback_ = function(tracker, result) { console.log( UTIL_fmt((result.code ? 'failure.' : 'success!') + ' gnubby ' + tracker.index + ' got code ' + result.code.toString(16))); if (!tracker.stillGoing) { console.log(UTIL_fmt('gnubby ' + tracker.index + ' no longer running!')); // Shouldn't ever happen? Disregard. return; } tracker.stillGoing = false; tracker.errorStatus = result.code; var moreExpected = this.tallyCompletedGnubby_(); switch (result.code) { case DeviceStatusCodes.GONE_STATUS: // Squelch removed gnubbies: the caller can't act on them. But if this // was the last one, speed up reenumerating. if (!moreExpected) { this.maybeReEnumerateGnubbies_(true); } break; default: // Report any other results directly to the caller. this.notifyGnubbyComplete_(tracker, result, moreExpected); break; } if (!moreExpected && this.timer_.expired()) { this.timeout_(false); } }; /** * Counts another gnubby has having completed, and returns whether more results * are expected. * @return {boolean} Whether more gnubbies are still running. * @private */ MultipleGnubbySigner.prototype.tallyCompletedGnubby_ = function() { this.numComplete_++; return this.anyPending_(); }; /** * @return {boolean} Whether more gnubbies are still running. * @private */ MultipleGnubbySigner.prototype.anyPending_ = function() { return this.numComplete_ < Object.keys(this.gnubbies_).length; }; /** * Called upon timeout. * @param {boolean} anyPending Whether any gnubbies are awaiting results. * @private */ MultipleGnubbySigner.prototype.timeout_ = function(anyPending) { if (this.complete_) return; this.complete_ = true; // Defer notifying the caller that all are complete, in case the caller is // doing work in response to a gnubbyFound callback and has an inconsistent // view of the state of this signer. var self = this; window.setTimeout(function() { self.allCompleteCb_(anyPending); }, 0); }; /** * @param {GnubbyTracker} tracker The tracker object of the gnubby whose result * this is. * @param {SingleSignerResult} result Result object. * @param {boolean} moreExpected Whether more gnubbies may still produce an * outcome. * @private */ MultipleGnubbySigner.prototype.notifyGnubbyComplete_ = function(tracker, result, moreExpected) { console.log(UTIL_fmt('gnubby ' + tracker.index + ' complete (' + result.code.toString(16) + ')')); var signResult = { 'code': result.code, 'gnubby': result.gnubby, 'gnubbyId': tracker.signer.getDeviceId() }; if (result['challenge']) signResult['challenge'] = result['challenge']; if (result['info']) signResult['info'] = result['info']; this.gnubbyCompleteCb_(signResult, moreExpected); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a sign handler using USB gnubbies. */ 'use strict'; var CORRUPT_sign = false; /** * @param {!SignHelperRequest} request The sign request. * @constructor * @implements {RequestHandler} */ function UsbSignHandler(request) { /** @private {!SignHelperRequest} */ this.request_ = request; /** @private {boolean} */ this.notified_ = false; /** @private {boolean} */ this.anyGnubbiesFound_ = false; /** @private {!Array} */ this.notEnrolledGnubbies_ = []; } /** * Default timeout value in case the caller never provides a valid timeout. * @const */ UsbSignHandler.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; /** * Attempts to run this handler's request. * @param {RequestHandlerCallback} cb Called with the result of the request and * an optional source for the sign result. * @return {boolean} whether this set of challenges was accepted. */ UsbSignHandler.prototype.run = function(cb) { if (this.cb_) { // Can only handle one request. return false; } /** @private {RequestHandlerCallback} */ this.cb_ = cb; if (!this.request_.signData || !this.request_.signData.length) { // Fail a sign request with an empty set of challenges. return false; } var timeoutMillis = this.request_.timeoutSeconds ? this.request_.timeoutSeconds * 1000 : UsbSignHandler.DEFAULT_TIMEOUT_MILLIS; /** @private {MultipleGnubbySigner} */ this.signer_ = new MultipleGnubbySigner( false /* forEnroll */, this.signerCompleted_.bind(this), this.signerFoundGnubby_.bind(this), timeoutMillis, this.request_.logMsgUrl); return this.signer_.doSign(this.request_.signData); }; /** * Called when a MultipleGnubbySigner completes. * @param {boolean} anyPending Whether any gnubbies are pending. * @private */ UsbSignHandler.prototype.signerCompleted_ = function(anyPending) { if (!this.anyGnubbiesFound_ || anyPending) { this.notifyError_(DeviceStatusCodes.TIMEOUT_STATUS); } else if (this.signerError_ !== undefined) { this.notifyError_(this.signerError_); } else { // Do nothing: signerFoundGnubby_ will have returned results from other // gnubbies. } }; /** * Called when a MultipleGnubbySigner finds a gnubby that has completed signing * its challenges. * @param {MultipleSignerResult} signResult Signer result object * @param {boolean} moreExpected Whether the signer expects to produce more * results. * @private */ UsbSignHandler.prototype.signerFoundGnubby_ = function(signResult, moreExpected) { this.anyGnubbiesFound_ = true; if (!signResult.code) { var gnubby = signResult['gnubby']; var challenge = signResult['challenge']; var info = new Uint8Array(signResult['info']); this.notifySuccess_(gnubby, challenge, info); } else if (signResult.code == DeviceStatusCodes.WRONG_DATA_STATUS) { var gnubby = signResult['gnubby']; this.notEnrolledGnubbies_.push(gnubby); this.sendBogusEnroll_(gnubby); } else if (!moreExpected) { // If the signer doesn't expect more results, return the error directly to // the caller. this.notifyError_(signResult.code); } else { // Record the last error, to report from the complete callback if no other // eligible gnubbies are found. /** @private {number} */ this.signerError_ = signResult.code; } }; /** @const */ UsbSignHandler.BOGUS_APP_ID_HASH = [ 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41 ]; /** @const */ UsbSignHandler.BOGUS_CHALLENGE_V1 = [ 0x04, 0xA2, 0x24, 0x7D, 0x5C, 0x0B, 0x76, 0xF1, 0xDC, 0xCD, 0x44, 0xAF, 0x91, 0x9A, 0xA2, 0x3F, 0x3F, 0xBA, 0x65, 0x9F, 0x06, 0x78, 0x82, 0xFB, 0x93, 0x4B, 0xBF, 0x86, 0x55, 0x95, 0x66, 0x46, 0x76, 0x90, 0xDC, 0xE1, 0xE8, 0x6C, 0x86, 0x86, 0xC3, 0x03, 0x4E, 0x65, 0x52, 0x4C, 0x32, 0x6F, 0xB6, 0x44, 0x0D, 0x50, 0xF9, 0x16, 0xC0, 0xA3, 0xDA, 0x31, 0x4B, 0xD3, 0x3F, 0x94, 0xA5, 0xF1, 0xD3 ]; /** @const */ UsbSignHandler.BOGUS_CHALLENGE_V2 = [ 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42 ]; /** * Sends a bogus enroll command to the not-enrolled gnubby, to force the user * to tap the gnubby before revealing its state to the caller. * @param {Gnubby} gnubby The gnubby to "enroll" on. * @private */ UsbSignHandler.prototype.sendBogusEnroll_ = function(gnubby) { var self = this; gnubby.version(function(rc, opt_data) { if (rc) { self.notifyError_(rc); return; } var enrollChallenge; var version = UTIL_BytesToString(new Uint8Array(opt_data || [])); switch (version) { case Gnubby.U2F_V1: enrollChallenge = UsbSignHandler.BOGUS_CHALLENGE_V1; break; case Gnubby.U2F_V2: enrollChallenge = UsbSignHandler.BOGUS_CHALLENGE_V2; break; default: self.notifyError_(DeviceStatusCodes.INVALID_DATA_STATUS); } gnubby.enroll( /** @type {Array} */ (enrollChallenge), UsbSignHandler.BOGUS_APP_ID_HASH, self.enrollCallback_.bind(self, gnubby)); }); }; /** * Called with the result of the (bogus, tap capturing) enroll command. * @param {Gnubby} gnubby The gnubby "enrolled". * @param {number} code The result of the enroll command. * @param {ArrayBuffer=} infoArray Returned data. * @private */ UsbSignHandler.prototype.enrollCallback_ = function(gnubby, code, infoArray) { if (this.notified_) return; switch (code) { case DeviceStatusCodes.WAIT_TOUCH_STATUS: this.sendBogusEnroll_(gnubby); return; case DeviceStatusCodes.OK_STATUS: // Got a successful enroll => user tapped gnubby. // Send a WRONG_DATA_STATUS finally. (The gnubby is implicitly closed // by notifyError_.) this.notifyError_(DeviceStatusCodes.WRONG_DATA_STATUS); return; } }; /** * Reports the result of a successful sign operation. * @param {Gnubby} gnubby Gnubby instance * @param {SignHelperChallenge} challenge Challenge signed * @param {Uint8Array} info Result data * @private */ UsbSignHandler.prototype.notifySuccess_ = function(gnubby, challenge, info) { if (this.notified_) return; this.notified_ = true; gnubby.closeWhenIdle(); this.close(); if (CORRUPT_sign) { CORRUPT_sign = false; info[info.length - 1] = info[info.length - 1] ^ 0xff; } var responseData = { 'appIdHash': B64_encode(challenge['appIdHash']), 'challengeHash': B64_encode(challenge['challengeHash']), 'keyHandle': B64_encode(challenge['keyHandle']), 'signatureData': B64_encode(info) }; var reply = { 'type': 'sign_helper_reply', 'code': DeviceStatusCodes.OK_STATUS, 'responseData': responseData }; this.cb_(reply, 'USB'); }; /** * Reports error to the caller. * @param {number} code error to report * @private */ UsbSignHandler.prototype.notifyError_ = function(code) { if (this.notified_) return; this.notified_ = true; this.close(); var reply = { 'type': 'sign_helper_reply', 'code': code }; this.cb_(reply); }; /** * Closes the MultipleGnubbySigner, if any. */ UsbSignHandler.prototype.close = function() { while (this.notEnrolledGnubbies_.length != 0) { var gnubby = this.notEnrolledGnubbies_.shift(); gnubby.closeWhenIdle(); } if (this.signer_) { this.signer_.close(); this.signer_ = null; } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Does common handling for requests coming from web pages and * routes them to the provided handler. */ /** * FIDO U2F Javascript API Version * @const * @type {number} */ var JS_API_VERSION = 1.1; /** * Gets the scheme + origin from a web url. * @param {string} url Input url * @return {?string} Scheme and origin part if url parses */ function getOriginFromUrl(url) { var re = new RegExp('^(https?://)[^/]*/?'); var originarray = re.exec(url); if (originarray == null) return originarray; var origin = originarray[0]; while (origin.charAt(origin.length - 1) == '/') { origin = origin.substring(0, origin.length - 1); } if (origin == 'http:' || origin == 'https:') return null; return origin; } /** * Returns whether the registered key appears to be valid. * @param {Object} registeredKey The registered key object. * @param {boolean} appIdRequired Whether the appId property is required on * each challenge. * @return {boolean} Whether the object appears valid. */ function isValidRegisteredKey(registeredKey, appIdRequired) { if (appIdRequired && !registeredKey.hasOwnProperty('appId')) { return false; } if (!registeredKey.hasOwnProperty('keyHandle')) return false; if (registeredKey['version']) { if (registeredKey['version'] != 'U2F_V1' && registeredKey['version'] != 'U2F_V2') { return false; } } return true; } /** * Returns whether the array of registered keys appears to be valid. * @param {Array} registeredKeys The array of registered keys. * @param {boolean} appIdRequired Whether the appId property is required on * each challenge. * @return {boolean} Whether the array appears valid. */ function isValidRegisteredKeyArray(registeredKeys, appIdRequired) { return registeredKeys.every(function(key) { return isValidRegisteredKey(key, appIdRequired); }); } /** * Gets the sign challenges from the request. The sign challenges may be the * U2F 1.0 variant, signRequests, or the U2F 1.1 version, registeredKeys. * @param {Object} request The request. * @return {!Array|undefined} The sign challenges, if found. */ function getSignChallenges(request) { if (!request) { return undefined; } var signChallenges; if (request.hasOwnProperty('signRequests')) { signChallenges = request['signRequests']; } else if (request.hasOwnProperty('registeredKeys')) { signChallenges = request['registeredKeys']; } return signChallenges; } /** * Returns whether the array of SignChallenges appears to be valid. * @param {Array} signChallenges The array of sign challenges. * @param {boolean} challengeValueRequired Whether each challenge object * requires a challenge value. * @param {boolean} appIdRequired Whether the appId property is required on * each challenge. * @return {boolean} Whether the array appears valid. */ function isValidSignChallengeArray(signChallenges, challengeValueRequired, appIdRequired) { for (var i = 0; i < signChallenges.length; i++) { var incomingChallenge = signChallenges[i]; if (challengeValueRequired && !incomingChallenge.hasOwnProperty('challenge')) return false; if (!isValidRegisteredKey(incomingChallenge, appIdRequired)) { return false; } } return true; } /** * @param {Object} request Request object * @param {MessageSender} sender Sender frame * @param {Function} sendResponse Response callback * @return {?Closeable} Optional handler object that should be closed when port * closes */ function handleWebPageRequest(request, sender, sendResponse) { switch (request.type) { case MessageTypes.U2F_REGISTER_REQUEST: return handleU2fEnrollRequest(sender, request, sendResponse); case MessageTypes.U2F_SIGN_REQUEST: return handleU2fSignRequest(sender, request, sendResponse); case MessageTypes.U2F_GET_API_VERSION_REQUEST: sendResponse( makeU2fGetApiVersionResponse(request, JS_API_VERSION, MessageTypes.U2F_GET_API_VERSION_RESPONSE)); return null; default: sendResponse( makeU2fErrorResponse(request, ErrorCodes.BAD_REQUEST, undefined, MessageTypes.U2F_REGISTER_RESPONSE)); return null; } } /** * Makes a response to a request. * @param {Object} request The request to make a response to. * @param {string} responseSuffix How to name the response's type. * @param {string=} opt_defaultType The default response type, if none is * present in the request. * @return {Object} The response object. */ function makeResponseForRequest(request, responseSuffix, opt_defaultType) { var type; if (request && request.type) { type = request.type.replace(/_request$/, responseSuffix); } else { type = opt_defaultType; } var reply = { 'type': type }; if (request && request.requestId) { reply.requestId = request.requestId; } return reply; } /** * Makes a response to a U2F request with an error code. * @param {Object} request The request to make a response to. * @param {ErrorCodes} code The error code to return. * @param {string=} opt_detail An error detail string. * @param {string=} opt_defaultType The default response type, if none is * present in the request. * @return {Object} The U2F error. */ function makeU2fErrorResponse(request, code, opt_detail, opt_defaultType) { var reply = makeResponseForRequest(request, '_response', opt_defaultType); var error = {'errorCode': code}; if (opt_detail) { error['errorMessage'] = opt_detail; } reply['responseData'] = error; return reply; } /** * Makes a success response to a web request with a responseData object. * @param {Object} request The request to make a response to. * @param {Object} responseData The response data. * @return {Object} The web error. */ function makeU2fSuccessResponse(request, responseData) { var reply = makeResponseForRequest(request, '_response'); reply['responseData'] = responseData; return reply; } /** * Maps a helper's error code from the DeviceStatusCodes namespace to a * U2fError. * @param {number} code Error code from DeviceStatusCodes namespace. * @return {U2fError} An error. */ function mapDeviceStatusCodeToU2fError(code) { switch (code) { case DeviceStatusCodes.WRONG_DATA_STATUS: return {errorCode: ErrorCodes.DEVICE_INELIGIBLE}; case DeviceStatusCodes.TIMEOUT_STATUS: case DeviceStatusCodes.WAIT_TOUCH_STATUS: return {errorCode: ErrorCodes.TIMEOUT}; default: var reportedError = { errorCode: ErrorCodes.OTHER_ERROR, errorMessage: 'device status code: ' + code.toString(16) }; return reportedError; } } /** * Sends a response, using the given sentinel to ensure at most one response is * sent. Also closes the closeable, if it's given. * @param {boolean} sentResponse Whether a response has already been sent. * @param {?Closeable} closeable A thing to close. * @param {*} response The response to send. * @param {Function} sendResponse A function to send the response. */ function sendResponseOnce(sentResponse, closeable, response, sendResponse) { if (closeable) { closeable.close(); } if (!sentResponse) { sentResponse = true; try { // If the page has gone away or the connection has otherwise gone, // sendResponse fails. sendResponse(response); } catch (exception) { console.warn('sendResponse failed: ' + exception); } } else { console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME')); } } /** * @param {!string} string Input string * @return {Array} SHA256 hash value of string. */ function sha256HashOfString(string) { var s = new SHA256(); s.update(UTIL_StringToBytes(string)); return s.digest(); } /** * Normalizes the TLS channel ID value: * 1. Converts semantically empty values (undefined, null, 0) to the empty * string. * 2. Converts valid JSON strings to a JS object. * 3. Otherwise, returns the input value unmodified. * @param {Object|string|undefined} opt_tlsChannelId TLS Channel id * @return {Object|string} The normalized TLS channel ID value. */ function tlsChannelIdValue(opt_tlsChannelId) { if (!opt_tlsChannelId) { // Case 1: Always set some value for TLS channel ID, even if it's the empty // string: this browser definitely supports them. return ''; } if (typeof opt_tlsChannelId === 'string') { try { var obj = JSON.parse(opt_tlsChannelId); if (!obj) { // Case 1: The string value 'null' parses as the Javascript object null, // so return an empty string: the browser definitely supports TLS // channel id. return ''; } // Case 2: return the value as a JS object. return /** @type {Object} */ (obj); } catch (e) { console.warn('Unparseable TLS channel ID value ' + opt_tlsChannelId); // Case 3: return the value unmodified. } } return opt_tlsChannelId; } /** * Creates a browser data object with the given values. * @param {!string} type A string representing the "type" of this browser data * object. * @param {!string} serverChallenge The server's challenge, as a base64- * encoded string. * @param {!string} origin The server's origin, as seen by the browser. * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id * @return {string} A string representation of the browser data object. */ function makeBrowserData(type, serverChallenge, origin, opt_tlsChannelId) { var browserData = { 'typ' : type, 'challenge' : serverChallenge, 'origin' : origin }; if (BROWSER_SUPPORTS_TLS_CHANNEL_ID) { browserData['cid_pubkey'] = tlsChannelIdValue(opt_tlsChannelId); } return JSON.stringify(browserData); } /** * Creates a browser data object for an enroll request with the given values. * @param {!string} serverChallenge The server's challenge, as a base64- * encoded string. * @param {!string} origin The server's origin, as seen by the browser. * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id * @return {string} A string representation of the browser data object. */ function makeEnrollBrowserData(serverChallenge, origin, opt_tlsChannelId) { return makeBrowserData( 'navigator.id.finishEnrollment', serverChallenge, origin, opt_tlsChannelId); } /** * Creates a browser data object for a sign request with the given values. * @param {!string} serverChallenge The server's challenge, as a base64- * encoded string. * @param {!string} origin The server's origin, as seen by the browser. * @param {Object|string|undefined} opt_tlsChannelId TLS Channel Id * @return {string} A string representation of the browser data object. */ function makeSignBrowserData(serverChallenge, origin, opt_tlsChannelId) { return makeBrowserData( 'navigator.id.getAssertion', serverChallenge, origin, opt_tlsChannelId); } /** * Makes a response to a U2F request with an error code. * @param {Object} request The request to make a response to. * @param {number=} version The JS API version to return. * @param {string=} opt_defaultType The default response type, if none is * present in the request. * @return {Object} The GetJsApiVersionResponse. */ function makeU2fGetApiVersionResponse(request, version, opt_defaultType) { var reply = makeResponseForRequest(request, '_response', opt_defaultType); var data = {'js_api_version': version}; reply['responseData'] = data; return reply; } /** * Encodes the sign data as an array of sign helper challenges. * @param {Array} signChallenges The sign challenges to encode. * @param {string|undefined} opt_defaultChallenge A default sign challenge * value, if a request does not provide one. * @param {string=} opt_defaultAppId The app id to use for each challenge, if * the challenge contains none. * @param {function(string, string): string=} opt_challengeHashFunction * A function that produces, from a key handle and a raw challenge, a hash * of the raw challenge. If none is provided, a default hash function is * used. * @return {!Array} The sign challenges, encoded. */ function encodeSignChallenges(signChallenges, opt_defaultChallenge, opt_defaultAppId, opt_challengeHashFunction) { function encodedSha256(keyHandle, challenge) { return B64_encode(sha256HashOfString(challenge)); } var challengeHashFn = opt_challengeHashFunction || encodedSha256; var encodedSignChallenges = []; if (signChallenges) { for (var i = 0; i < signChallenges.length; i++) { var challenge = signChallenges[i]; var keyHandle = challenge['keyHandle']; var challengeValue; if (challenge.hasOwnProperty('challenge')) { challengeValue = challenge['challenge']; } else { challengeValue = opt_defaultChallenge; } var challengeHash = challengeHashFn(keyHandle, challengeValue); var appId; if (challenge.hasOwnProperty('appId')) { appId = challenge['appId']; } else { appId = opt_defaultAppId; } var encodedChallenge = { 'challengeHash': challengeHash, 'appIdHash': B64_encode(sha256HashOfString(appId)), 'keyHandle': keyHandle, 'version': (challenge['version'] || 'U2F_V1') }; encodedSignChallenges.push(encodedChallenge); } } return encodedSignChallenges; } /** * Makes a sign helper request from an array of challenges. * @param {Array} challenges The sign challenges. * @param {number=} opt_timeoutSeconds Timeout value. * @param {string=} opt_logMsgUrl URL to log to. * @return {SignHelperRequest} The sign helper request. */ function makeSignHelperRequest(challenges, opt_timeoutSeconds, opt_logMsgUrl) { var request = { 'type': 'sign_helper_request', 'signData': challenges, 'timeout': opt_timeoutSeconds || 0, 'timeoutSeconds': opt_timeoutSeconds || 0 }; if (opt_logMsgUrl !== undefined) { request.logMsgUrl = opt_logMsgUrl; } return request; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a check whether an app id lists an origin. */ 'use strict'; /** * Parses the text as JSON and returns it as an array of strings. * @param {string} text Input JSON * @return {!Array} Array of origins */ function getOriginsFromJson(text) { try { var urls, i; var appIdData = JSON.parse(text); if (Array.isArray(appIdData)) { // Older format where it is a simple list of facets urls = appIdData; } else { var trustedFacets = appIdData['trustedFacets']; if (trustedFacets) { var versionBlock; for (i = 0; versionBlock = trustedFacets[i]; i++) { if (versionBlock['version'] && versionBlock['version']['major'] == 1 && versionBlock['version']['minor'] == 0) { urls = versionBlock['ids']; break; } } } if (typeof urls == 'undefined') { throw Error('Could not find trustedFacets for version 1.0'); } } var origins = {}; var url; for (i = 0; url = urls[i]; i++) { var origin = getOriginFromUrl(url); if (origin) { origins[origin] = origin; } } return Object.keys(origins); } catch (e) { console.error(UTIL_fmt('could not parse ' + text)); return []; } } /** * Retrieves a set of distinct app ids from the sign challenges. * @param {Array=} signChallenges Input sign challenges. * @return {Array} array of distinct app ids. */ function getDistinctAppIds(signChallenges) { if (!signChallenges) { return []; } var appIds = {}; for (var i = 0, request; request = signChallenges[i]; i++) { var appId = request['appId']; if (appId) { appIds[appId] = appId; } } return Object.keys(appIds); } /** * An object that checks one or more appIds' contents against an origin. * @interface */ function AppIdChecker() {} /** * Checks whether the given origin is allowed by all of the given appIds. * @param {!Countdown} timer A timer by which to resolve all provided app ids. * @param {string} origin The origin to check. * @param {!Array} appIds The app ids to check. * @param {boolean} allowHttp Whether to allow http:// URLs. * @param {string=} opt_logMsgUrl A log message URL. * @return {Promise} A promise for the result of the check */ AppIdChecker.prototype.checkAppIds = function(timer, origin, appIds, allowHttp, opt_logMsgUrl) {}; /** * An interface to create an AppIdChecker. * @interface */ function AppIdCheckerFactory() {} /** * @return {!AppIdChecker} A new AppIdChecker. */ AppIdCheckerFactory.prototype.create = function() {}; /** * Provides an object to track checking a list of appIds. * @param {!TextFetcher} fetcher A URL fetcher. * @constructor * @implements AppIdChecker */ function XhrAppIdChecker(fetcher) { /** @private {!TextFetcher} */ this.fetcher_ = fetcher; } /** * Checks whether all the app ids provided can be asserted by the given origin. * @param {!Countdown} timer A timer by which to resolve all provided app ids. * @param {string} origin The origin to check. * @param {!Array} appIds The app ids to check. * @param {boolean} allowHttp Whether to allow http:// URLs. * @param {string=} opt_logMsgUrl A log message URL. * @return {Promise} A promise for the result of the check */ XhrAppIdChecker.prototype.checkAppIds = function(timer, origin, appIds, allowHttp, opt_logMsgUrl) { if (this.timer_) { // Can't use the same object to check appIds more than once. return Promise.resolve(false); } /** @private {!Countdown} */ this.timer_ = timer; /** @private {string} */ this.origin_ = origin; var appIdsMap = {}; if (appIds) { for (var i = 0; i < appIds.length; i++) { appIdsMap[appIds[i]] = appIds[i]; } } /** @private {Array} */ this.distinctAppIds_ = Object.keys(appIdsMap); /** @private {boolean} */ this.allowHttp_ = allowHttp; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; if (!this.distinctAppIds_.length) return Promise.resolve(false); if (this.allAppIdsEqualOrigin_()) { // Trivially allowed. return Promise.resolve(true); } else { var self = this; // Begin checking remaining app ids. var appIdChecks = self.distinctAppIds_.map(self.checkAppId_.bind(self)); return Promise.all(appIdChecks).then(function(results) { return results.every(function(result) { return result; }); }); } }; /** * Checks if a single appId can be asserted by the given origin. * @param {string} appId The appId to check * @return {Promise} A promise for the result of the check * @private */ XhrAppIdChecker.prototype.checkAppId_ = function(appId) { if (appId == this.origin_) { // Trivially allowed return Promise.resolve(true); } var p = this.fetchAllowedOriginsForAppId_(appId); var self = this; return p.then(function(allowedOrigins) { if (allowedOrigins.indexOf(self.origin_) == -1) { console.warn(UTIL_fmt('Origin ' + self.origin_ + ' not allowed by app id ' + appId)); return false; } return true; }); }; /** * @return {boolean} Whether all the app ids being checked are equal to the * calling origin. * @private */ XhrAppIdChecker.prototype.allAppIdsEqualOrigin_ = function() { var self = this; return this.distinctAppIds_.every(function(appId) { return appId == self.origin_; }); }; /** * Fetches the allowed origins for an appId. * @param {string} appId Application id * @return {Promise>} A promise for a list of allowed origins * for appId * @private */ XhrAppIdChecker.prototype.fetchAllowedOriginsForAppId_ = function(appId) { if (!appId) { return Promise.resolve([]); } if (appId.indexOf('http://') == 0 && !this.allowHttp_) { console.log(UTIL_fmt('http app ids disallowed, ' + appId + ' requested')); return Promise.resolve([]); } var origin = getOriginFromUrl(appId); if (!origin) { return Promise.resolve([]); } var p = this.fetcher_.fetch(appId); var self = this; return p.then(getOriginsFromJson, function(rc_) { var rc = /** @type {number} */(rc_); console.log(UTIL_fmt('fetching ' + appId + ' failed: ' + rc)); if (!(rc >= 400 && rc < 500) && !self.timer_.expired()) { // Retry return self.fetchAllowedOriginsForAppId_(appId); } return []; }); }; /** * A factory to create an XhrAppIdChecker. * @implements AppIdCheckerFactory * @param {!TextFetcher} fetcher * @constructor */ function XhrAppIdCheckerFactory(fetcher) { /** @private {!TextFetcher} */ this.fetcher_ = fetcher; } /** * @return {!AppIdChecker} A new AppIdChecker. */ XhrAppIdCheckerFactory.prototype.create = function() { return new XhrAppIdChecker(this.fetcher_); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a helper using USB gnubbies. */ 'use strict'; /** * @constructor * @extends {GenericRequestHelper} */ function UsbHelper() { GenericRequestHelper.apply(this, arguments); var self = this; this.registerHandlerFactory('enroll_helper_request', function(request) { return new UsbEnrollHandler(/** @type {EnrollHelperRequest} */ (request)); }); this.registerHandlerFactory('sign_helper_request', function(request) { return new UsbSignHandler(/** @type {SignHelperRequest} */ (request)); }); } inherits(UsbHelper, GenericRequestHelper); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a simple XmlHttpRequest-based text document * fetcher. * */ 'use strict'; /** * A fetcher of text files. * @interface */ function TextFetcher() {} /** * @param {string} url The URL to fetch. * @param {string?} opt_method The HTTP method to use (default GET) * @param {string?} opt_body The request body * @return {!Promise} A promise for the fetched text. In case of an * error, this promise is rejected with an HTTP status code. */ TextFetcher.prototype.fetch = function(url, opt_method, opt_body) {}; /** * @constructor * @implements {TextFetcher} */ function XhrTextFetcher() { } /** * @param {string} url The URL to fetch. * @param {string?} opt_method The HTTP method to use (default GET) * @param {string?} opt_body The request body * @return {!Promise} A promise for the fetched text. In case of an * error, this promise is rejected with an HTTP status code. */ XhrTextFetcher.prototype.fetch = function(url, opt_method, opt_body) { return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); var method = opt_method || 'GET'; xhr.open(method, url, true); xhr.onloadend = function() { if (xhr.status != 200) { reject(xhr.status); return; } resolve(xhr.responseText); }; xhr.onerror = function() { // Treat any network-level errors as though the page didn't exist. reject(404); }; if (opt_body) xhr.send(opt_body); else xhr.send(); }); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a "bottom half" helper to assist with raw requests. * This fills the same role as the Authenticator-Specific Module component of * U2F documents, although the API is different. */ 'use strict'; /** * @typedef {{ * type: string, * timeout: number * }} */ var HelperRequest; /** * @typedef {{ * type: string, * code: (number|undefined) * }} */ var HelperReply; /** * A helper to process requests. * @interface */ function RequestHelper() {} /** * Gets a handler for a request. * @param {HelperRequest} request The request to handle. * @return {RequestHandler} A handler for the request. */ RequestHelper.prototype.getHandler = function(request) {}; /** * A handler to track an outstanding request. * @extends {Closeable} * @interface */ function RequestHandler() {} /** @typedef {function(HelperReply, string=)} */ var RequestHandlerCallback; /** * @param {RequestHandlerCallback} cb Called with the result of the request, * and an optional source for the result. * @return {boolean} Whether this handler could be run. */ RequestHandler.prototype.run = function(cb) {}; /** Closes this handler. */ RequestHandler.prototype.close = function() {}; /** * Makes a response to a helper request with an error code. * @param {HelperRequest} request The request to make a response to. * @param {DeviceStatusCodes} code The error code to return. * @param {string=} opt_defaultType The default response type, if none is * present in the request. * @return {HelperReply} The helper error response. */ function makeHelperErrorResponse(request, code, opt_defaultType) { var type; if (request && request.type) { type = request.type.replace(/_request$/, '_reply'); } else { type = opt_defaultType || 'unknown_type_reply'; } var reply = { 'type': type, 'code': /** @type {number} */ (code) }; return reply; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview U2F message types. */ 'use strict'; /** * Message types for messsages to/from the extension * @const * @enum {string} */ var MessageTypes = { U2F_REGISTER_REQUEST: 'u2f_register_request', U2F_SIGN_REQUEST: 'u2f_sign_request', U2F_REGISTER_RESPONSE: 'u2f_register_response', U2F_SIGN_RESPONSE: 'u2f_sign_response', U2F_GET_API_VERSION_REQUEST: 'u2f_get_api_version_request', U2F_GET_API_VERSION_RESPONSE: 'u2f_get_api_version_response' }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a partial copy of goog.inherits, so inheritance works * even in the absence of Closure. */ 'use strict'; // A partial copy of goog.inherits, so inheritance works even in the absence of // Closure. function inherits(childCtor, parentCtor) { /** @constructor */ function tempCtor() { } tempCtor.prototype = parentCtor.prototype; childCtor.prototype = new tempCtor; childCtor.prototype.constructor = childCtor; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Interface for representing a low-level gnubby device. */ 'use strict'; /** * Low level gnubby 'driver'. One per physical USB device. * @interface */ function GnubbyDevice() {} // Commands of the USB interface. /** Echo data through local processor only */ GnubbyDevice.CMD_PING = 0x81; /** Perform reset action and read ATR string */ GnubbyDevice.CMD_ATR = 0x82; /** Send raw APDU */ GnubbyDevice.CMD_APDU = 0x83; /** Send lock channel command */ GnubbyDevice.CMD_LOCK = 0x84; /** Obtain system information record */ GnubbyDevice.CMD_SYSINFO = 0x85; /** Obtain an unused channel ID */ GnubbyDevice.CMD_INIT = 0x86; /** Control prompt flashing */ GnubbyDevice.CMD_PROMPT = 0x87; /** Send device identification wink */ GnubbyDevice.CMD_WINK = 0x88; /** USB test */ GnubbyDevice.CMD_USB_TEST = 0xb9; /** Device Firmware Upgrade */ GnubbyDevice.CMD_DFU = 0xba; /** Protocol resync command */ GnubbyDevice.CMD_SYNC = 0xbc; /** Error response */ GnubbyDevice.CMD_ERROR = 0xbf; // Low-level error codes. /** No error */ GnubbyDevice.OK = 0; /** Invalid command */ GnubbyDevice.INVALID_CMD = 1; /** Invalid parameter */ GnubbyDevice.INVALID_PAR = 2; /** Invalid message length */ GnubbyDevice.INVALID_LEN = 3; /** Invalid message sequencing */ GnubbyDevice.INVALID_SEQ = 4; /** Message has timed out */ GnubbyDevice.TIMEOUT = 5; /** Channel is busy */ GnubbyDevice.BUSY = 6; /** Access denied */ GnubbyDevice.ACCESS_DENIED = 7; /** Device is gone */ GnubbyDevice.GONE = 8; /** Verification error */ GnubbyDevice.VERIFY_ERROR = 9; /** Command requires channel lock */ GnubbyDevice.LOCK_REQUIRED = 10; /** Sync error */ GnubbyDevice.SYNC_FAIL = 11; /** Other unspecified error */ GnubbyDevice.OTHER = 127; // Remote helper errors. /** Not a remote helper */ GnubbyDevice.NOTREMOTE = 263; /** Could not reach remote endpoint */ GnubbyDevice.COULDNOTDIAL = 264; // chrome.usb-related errors. /** No device */ GnubbyDevice.NODEVICE = 512; /** More than one device */ GnubbyDevice.TOOMANY = 513; /** Permission denied */ GnubbyDevice.NOPERMISSION = 666; /** Destroys this low-level device instance. */ GnubbyDevice.prototype.destroy = function() {}; /** * Register a client for this gnubby. * @param {*} who The client. */ GnubbyDevice.prototype.registerClient = function(who) {}; /** * De-register a client. * @param {*} who The client. * @return {number} The number of remaining listeners for this device, or -1 * if this had no clients to start with. */ GnubbyDevice.prototype.deregisterClient = function(who) {}; /** * @param {*} who The client. * @return {boolean} Whether this device has who as a client. */ GnubbyDevice.prototype.hasClient = function(who) {}; /** * Queue command to be sent. * If queue was empty, initiate the write. * @param {number} cid The client's channel ID. * @param {number} cmd The command to send. * @param {ArrayBuffer|Uint8Array} data Command data */ GnubbyDevice.prototype.queueCommand = function(cid, cmd, data) {}; /** * @typedef {{ * vendorId: number, * productId: number * }} */ var UsbDeviceSpec; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a "generic" RequestHelper that provides a default * response to unknown requests, and supports registering handlers for known * requests. */ 'use strict'; /** * @typedef {function(HelperRequest): RequestHandler} */ var RequestHandlerFactory; /** * Implements a "generic" RequestHelper that provides a default * response to unknown requests, and supports registering handlers for known * @constructor * @implements {RequestHelper} */ function GenericRequestHelper() { /** @private {Object} */ this.handlerFactories_ = {}; } /** * Gets a handler for a request. * @param {HelperRequest} request The request to handle. * @return {RequestHandler} A handler for the request. */ GenericRequestHelper.prototype.getHandler = function(request) { if (this.handlerFactories_.hasOwnProperty(request.type)) { return this.handlerFactories_[request.type](request); } return null; }; /** * Registers a handler factory for a given type. * @param {string} type The request type. * @param {RequestHandlerFactory} factory A factory that can produce a handler * for a request of a given type. */ GenericRequestHelper.prototype.registerHandlerFactory = function(type, factory) { this.handlerFactories_[type] = factory; }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Class providing common dependencies for the extension's * top half. */ 'use strict'; /** * @param {!AppIdCheckerFactory} appIdCheckerFactory An appId checker factory. * @param {!ApprovedOrigins} approvedOrigins An origin approval implementation. * @param {!CountdownFactory} countdownFactory A countdown timer factory. * @param {!OriginChecker} originChecker An origin checker. * @param {!RequestHelper} requestHelper A request helper. * @param {!SystemTimer} sysTimer A system timer implementation. * @param {!TextFetcher} textFetcher A text fetcher. * @constructor */ function FactoryRegistry(appIdCheckerFactory, approvedOrigins, countdownFactory, originChecker, requestHelper, sysTimer, textFetcher) { /** @private {!AppIdCheckerFactory} */ this.appIdCheckerFactory_ = appIdCheckerFactory; /** @private {!ApprovedOrigins} */ this.approvedOrigins_ = approvedOrigins; /** @private {!CountdownFactory} */ this.countdownFactory_ = countdownFactory; /** @private {!OriginChecker} */ this.originChecker_ = originChecker; /** @private {!RequestHelper} */ this.requestHelper_ = requestHelper; /** @private {!SystemTimer} */ this.sysTimer_ = sysTimer; /** @private {!TextFetcher} */ this.textFetcher_ = textFetcher; } /** @return {!AppIdCheckerFactory} An appId checker factory. */ FactoryRegistry.prototype.getAppIdCheckerFactory = function() { return this.appIdCheckerFactory_; }; /** @return {!ApprovedOrigins} An origin approval implementation. */ FactoryRegistry.prototype.getApprovedOrigins = function() { return this.approvedOrigins_; }; /** @return {!CountdownFactory} A countdown factory. */ FactoryRegistry.prototype.getCountdownFactory = function() { return this.countdownFactory_; }; /** @return {!OriginChecker} An origin checker. */ FactoryRegistry.prototype.getOriginChecker = function() { return this.originChecker_; }; /** @return {!RequestHelper} A request helper. */ FactoryRegistry.prototype.getRequestHelper = function() { return this.requestHelper_; }; /** @return {!SystemTimer} A system timer implementation. */ FactoryRegistry.prototype.getSystemTimer = function() { return this.sysTimer_; }; /** @return {!TextFetcher} A text fetcher. */ FactoryRegistry.prototype.getTextFetcher = function() { return this.textFetcher_; }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Errors reported by top-level request handlers. */ 'use strict'; /** * Response status codes * @const * @enum {number} */ var ErrorCodes = { 'OK': 0, 'OTHER_ERROR': 1, 'BAD_REQUEST': 2, 'CONFIGURATION_UNSUPPORTED': 3, 'DEVICE_INELIGIBLE': 4, 'TIMEOUT': 5 }; /** * An error object for responses * @typedef {{ * errorCode: ErrorCodes, * errorMessage: (?string|undefined) * }} */ var U2fError; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Class providing common dependencies for the extension's * bottom half. */ 'use strict'; /** * @param {!GnubbyFactory} gnubbyFactory A Gnubby factory. * @param {!CountdownFactory} countdownFactory A countdown timer factory. * @param {!IndividualAttestation} individualAttestation An individual * attestation implementation. * @constructor */ function DeviceFactoryRegistry(gnubbyFactory, countdownFactory, individualAttestation) { /** @private {!GnubbyFactory} */ this.gnubbyFactory_ = gnubbyFactory; /** @private {!CountdownFactory} */ this.countdownFactory_ = countdownFactory; /** @private {!IndividualAttestation} */ this.individualAttestation_ = individualAttestation; } /** @return {!GnubbyFactory} A Gnubby factory. */ DeviceFactoryRegistry.prototype.getGnubbyFactory = function() { return this.gnubbyFactory_; }; /** @return {!CountdownFactory} A countdown factory. */ DeviceFactoryRegistry.prototype.getCountdownFactory = function() { return this.countdownFactory_; }; /** @return {!IndividualAttestation} An individual attestation implementation. */ DeviceFactoryRegistry.prototype.getIndividualAttestation = function() { return this.individualAttestation_; }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a check whether an origin is allowed to assert an * app id. * */ 'use strict'; /** * Implements half of the app id policy: whether an origin is allowed to claim * an app id. For checking whether the app id also lists the origin, * @see AppIdChecker. * @interface */ function OriginChecker() {} /** * Checks whether the origin is allowed to claim the app ids. * @param {string} origin The origin claiming the app id. * @param {!Array} appIds The app ids being claimed. * @return {Promise} A promise for the result of the check. */ OriginChecker.prototype.canClaimAppIds = function(origin, appIds) {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides an interface to determine whether to request the * individual attestation certificate during enrollment. */ 'use strict'; /** * Interface to determine whether to request the individual attestation * certificate during enrollment. * @interface */ function IndividualAttestation() {} /** * @param {string} appIdHash The app id hash. * @return {boolean} Whether to request the individual attestation certificate * for this app id. */ IndividualAttestation.prototype.requestIndividualAttestation = function(appIdHash) {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a Google corp implementation of IndividualAttestation. */ 'use strict'; /** * Google corp implementation of IndividualAttestation that requests * individual certificates for corp accounts. * @constructor * @implements IndividualAttestation */ function GoogleCorpIndividualAttestation() {} /** * @param {string} appIdHash The app id hash. * @return {boolean} Whether to request the individual attestation certificate * for this app id. */ GoogleCorpIndividualAttestation.prototype.requestIndividualAttestation = function(appIdHash) { return appIdHash == GoogleCorpIndividualAttestation.GOOGLE_CORP_APP_ID_HASH; }; /** * App ID used by Google employee accounts. * @const */ GoogleCorpIndividualAttestation.GOOGLE_CORP_APP_ID = 'https://www.gstatic.com/securitykey/a/google.com/origins.json'; /** * Hash of the app ID used by Google employee accounts. * @const */ GoogleCorpIndividualAttestation.GOOGLE_CORP_APP_ID_HASH = B64_encode(sha256HashOfString( GoogleCorpIndividualAttestation.GOOGLE_CORP_APP_ID)); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides an interface to check whether the user has approved * an origin to use security keys. * */ 'use strict'; /** * Allows the caller to check whether the user has approved the use of * security keys from an origin. * @interface */ function ApprovedOrigins() {} /** * Checks whether the origin is approved to use security keys. (If not, an * approval prompt may be shown.) * @param {string} origin The origin to approve. * @param {number=} opt_tabId A tab id to display approval prompt in, if * necessary. * @return {Promise} A promise for the result of the check. */ ApprovedOrigins.prototype.isApprovedOrigin = function(origin, opt_tabId) {}; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a representation of a web request sender, and * utility functions for creating them. */ 'use strict'; /** * @typedef {{ * origin: string, * tlsChannelId: (string|undefined), * tabId: (number|undefined) * }} */ var WebRequestSender; /** * Creates an object representing the sender's origin, and, if available, * tab. * @param {MessageSender} messageSender The message sender. * @return {?WebRequestSender} The sender's origin and tab, or null if the * sender is invalid. */ function createSenderFromMessageSender(messageSender) { var origin = getOriginFromUrl(/** @type {string} */ (messageSender.url)); if (!origin) { return null; } var sender = { origin: origin }; if (messageSender.tlsChannelId) { sender.tlsChannelId = messageSender.tlsChannelId; } if (messageSender.tab) { sender.tabId = messageSender.tab.id; } return sender; } /** * Checks whether the given tab could have sent a message from the given * origin. * @param {Tab} tab The tab to match * @param {string} origin The origin to check. * @return {Promise} A promise resolved with the tab id if it the tab could, * have sent the request, and rejected if it can't. */ function tabMatchesOrigin(tab, origin) { // If the tab's origin matches, trust that the request came from this tab. if (getOriginFromUrl(tab.url) == origin) { return Promise.resolve(tab.id); } return Promise.reject(false); } /** * Attempts to ensure that the tabId of the sender is set, using chrome.tabs * when available. * @param {WebRequestSender} sender The request sender. * @return {Promise} A promise resolved once the tabId retrieval is done. * The promise is rejected if the tabId is untrustworthy, e.g. if the * user rapidly switched tabs. */ function getTabIdWhenPossible(sender) { if (sender.tabId) { // Already got it? Done. return Promise.resolve(true); } else if (!chrome.tabs) { // Can't get it? Done. (This happens to packaged apps, which can't access // chrome.tabs.) return Promise.resolve(true); } else { return new Promise(function(resolve, reject) { chrome.tabs.query({active: true, lastFocusedWindow: true}, function(tabs) { if (!tabs.length) { // Safety check. reject(false); return; } var tab = tabs[0]; tabMatchesOrigin(tab, sender.origin).then(function(tabId) { sender.tabId = tabId; resolve(true); }, function() { // Didn't match? Check if the debugger is open. if (tab.url.indexOf('chrome-devtools://') != 0) { reject(false); return; } // Debugger active: find first tab with the sender's origin. chrome.tabs.query({active: true}, function(tabs) { if (!tabs.length) { // Safety check. reject(false); return; } var numRejected = 0; for (var i = 0; i < tabs.length; i++) { tab = tabs[i]; tabMatchesOrigin(tab, sender.origin).then(function(tabId) { sender.tabId = tabId; resolve(true); }, function() { if (++numRejected >= tabs.length) { // None matches: reject. reject(false); } }); } }); }); }); }); } } /** * Checks whether the given tab is in the foreground, i.e. is the active tab * of the focused window. * @param {number} tabId The tab id to check. * @return {Promise} A promise for the result of the check. */ function tabInForeground(tabId) { return new Promise(function(resolve, reject) { if (!chrome.tabs || !chrome.tabs.get) { reject(); return; } if (!chrome.windows || !chrome.windows.get) { reject(); return; } chrome.tabs.get(tabId, function(tab) { if (chrome.runtime.lastError) { resolve(false); return; } if (!tab.active) { resolve(false); return; } chrome.windows.get(tab.windowId, function(aWindow) { resolve(aWindow && aWindow.focused); }); }); }); } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides an implementation of the SystemTimer interface based * on window's timer methods. */ 'use strict'; /** * Creates an implementation of the SystemTimer interface based on window's * timer methods. * @constructor * @implements {SystemTimer} */ function WindowTimer() { } /** * Sets a single-shot timer. * @param {function()} func Called back when the timer expires. * @param {number} timeoutMillis How long until the timer fires, in * milliseconds. * @return {number} A timeout ID, which can be used to cancel the timer. */ WindowTimer.prototype.setTimeout = function(func, timeoutMillis) { return window.setTimeout(func, timeoutMillis); }; /** * Clears a previously set timer. * @param {number} timeoutId The ID of the timer to clear. */ WindowTimer.prototype.clearTimeout = function(timeoutId) { window.clearTimeout(timeoutId); }; /** * Sets a repeating interval timer. * @param {function()} func Called back each time the timer fires. * @param {number} timeoutMillis How long until the timer fires, in * milliseconds. * @return {number} A timeout ID, which can be used to cancel the timer. */ WindowTimer.prototype.setInterval = function(func, timeoutMillis) { return window.setInterval(func, timeoutMillis); }; /** * Clears a previously set interval timer. * @param {number} timeoutId The ID of the timer to clear. */ WindowTimer.prototype.clearInterval = function(timeoutId) { window.clearInterval(timeoutId); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides a watchdog around a collection of callback functions. */ 'use strict'; /** * Creates a watchdog around a collection of callback functions, * ensuring at least one of them is called before the timeout expires. * If a timeout function is provided, calls the timeout function upon timeout * expiration if none of the callback functions has been called. * @param {number} timeoutValueSeconds Timeout value, in seconds. * @param {function()=} opt_timeoutCb Callback function to call on timeout. * @constructor * @implements {Closeable} */ function WatchdogRequestHandler(timeoutValueSeconds, opt_timeoutCb) { /** @private {number} */ this.timeoutValueSeconds_ = timeoutValueSeconds; /** @private {function()|undefined} */ this.timeoutCb_ = opt_timeoutCb; /** @private {boolean} */ this.calledBack_ = false; /** @private {Countdown} */ this.timer_ = FACTORY_REGISTRY.getCountdownFactory().createTimer( this.timeoutValueSeconds_ * 1000, this.timeout_.bind(this)); /** @private {Closeable|undefined} */ this.closeable_ = undefined; /** @private {boolean} */ this.closed_ = false; } /** * Wraps a callback function, such that the fact that the callback function * was or was not called gets tracked by this watchdog object. * @param {function(...?)} cb The callback function to wrap. * @return {function(...?)} A wrapped callback function. */ WatchdogRequestHandler.prototype.wrapCallback = function(cb) { return this.wrappedCallback_.bind(this, cb); }; /** Closes this watchdog. */ WatchdogRequestHandler.prototype.close = function() { this.closed_ = true; this.timer_.clearTimeout(); if (this.closeable_) { this.closeable_.close(); this.closeable_ = undefined; } }; /** * Sets this watchdog's closeable. * @param {!Closeable} closeable The closeable. */ WatchdogRequestHandler.prototype.setCloseable = function(closeable) { this.closeable_ = closeable; }; /** * Called back when the watchdog expires. * @private */ WatchdogRequestHandler.prototype.timeout_ = function() { if (!this.calledBack_ && !this.closed_) { var logMsg = 'Not called back within ' + this.timeoutValueSeconds_ + ' second timeout'; if (this.timeoutCb_) { logMsg += ', calling default callback'; console.warn(UTIL_fmt(logMsg)); this.timeoutCb_(); } else { console.warn(UTIL_fmt(logMsg)); } } }; /** * Wrapped callback function. * @param {function(...?)} cb The callback function to call. * @param {...?} var_args The callback function's arguments. * @private */ WatchdogRequestHandler.prototype.wrappedCallback_ = function(cb, var_args) { if (!this.closed_) { this.calledBack_ = true; this.timer_.clearTimeout(); var originalArgs = Array.prototype.slice.call(arguments, 1); cb.apply(null, originalArgs); } }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Logging related utility functions. */ /** Posts the log message to the log url. * @param {string} logMsg the log message to post. * @param {string=} opt_logMsgUrl the url to post log messages to. */ function logMessage(logMsg, opt_logMsgUrl) { console.log(UTIL_fmt('logMessage("' + logMsg + '")')); if (!opt_logMsgUrl) { return; } var audio = new Audio(); audio.src = opt_logMsgUrl + logMsg; } // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Provides an implementation of approved origins that relies * on the chrome.cryptotokenPrivate.requestPermission API. * (and only) allows google.com to use security keys. * */ 'use strict'; /** * Allows the caller to check whether the user has approved the use of * security keys from an origin. * @constructor * @implements {ApprovedOrigins} */ function CryptoTokenApprovedOrigin() {} /** * Checks whether the origin is approved to use security keys. (If not, an * approval prompt may be shown.) * @param {string} origin The origin to approve. * @param {number=} opt_tabId A tab id to display approval prompt in. * For this implementation, the tabId is always necessary, even though * the type allows undefined. * @return {Promise} A promise for the result of the check. */ CryptoTokenApprovedOrigin.prototype.isApprovedOrigin = function(origin, opt_tabId) { return new Promise(function(resolve, reject) { if (opt_tabId === undefined) { resolve(false); return; } var tabId = /** @type {number} */ (opt_tabId); tabInForeground(tabId).then(function(result) { if (!result) { resolve(false); return; } if (!chrome.tabs || !chrome.tabs.get) { reject(); return; } chrome.tabs.get(tabId, function(tab) { if (chrome.runtime.lastError) { resolve(false); return; } var tabOrigin = getOriginFromUrl(tab.url); resolve(tabOrigin == origin); }); }); }); }; // Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Implements a check whether an origin is allowed to assert an * app id based on whether they share the same effective TLD + 1. * */ 'use strict'; /** * Implements half of the app id policy: whether an origin is allowed to claim * an app id. For checking whether the app id also lists the origin, * @see AppIdChecker. * @implements OriginChecker * @constructor */ function CryptoTokenOriginChecker() { } /** * Checks whether the origin is allowed to claim the app ids. * @param {string} origin The origin claiming the app id. * @param {!Array} appIds The app ids being claimed. * @return {Promise} A promise for the result of the check. */ CryptoTokenOriginChecker.prototype.canClaimAppIds = function(origin, appIds) { var appIdChecks = appIds.map(this.checkAppId_.bind(this, origin)); return Promise.all(appIdChecks).then(function(results) { return results.every(function(result) { return result; }); }); }; /** * Checks if a single appId can be asserted by the given origin. * @param {string} origin The origin. * @param {string} appId The appId to check * @return {Promise} A promise for the result of the check * @private */ CryptoTokenOriginChecker.prototype.checkAppId_ = function(origin, appId) { return new Promise(function(resolve, reject) { if (!chrome.cryptotokenPrivate) { reject(); return; } chrome.cryptotokenPrivate.canOriginAssertAppId(origin, appId, resolve); }); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview CryptoToken background page */ 'use strict'; /** @const */ var BROWSER_SUPPORTS_TLS_CHANNEL_ID = true; /** @const */ var HTTP_ORIGINS_ALLOWED = false; /** @const */ var LOG_SAVER_EXTENSION_ID = 'fjajfjhkeibgmiggdfehjplbhmfkialk'; // Singleton tracking available devices. var gnubbies = new Gnubbies(); HidGnubbyDevice.register(gnubbies); UsbGnubbyDevice.register(gnubbies); var FACTORY_REGISTRY = (function() { var windowTimer = new WindowTimer(); var xhrTextFetcher = new XhrTextFetcher(); return new FactoryRegistry( new XhrAppIdCheckerFactory(xhrTextFetcher), new CryptoTokenApprovedOrigin(), new CountdownTimerFactory(windowTimer), new CryptoTokenOriginChecker(), new UsbHelper(), windowTimer, xhrTextFetcher); })(); var DEVICE_FACTORY_REGISTRY = new DeviceFactoryRegistry( new UsbGnubbyFactory(gnubbies), FACTORY_REGISTRY.getCountdownFactory(), new GoogleCorpIndividualAttestation()); /** * @param {*} request The received request * @return {boolean} Whether the request is a register/enroll request. */ function isRegisterRequest(request) { if (!request) { return false; } switch (request.type) { case MessageTypes.U2F_REGISTER_REQUEST: return true; default: return false; } } /** * Default response callback to deliver a response to a request. * @param {*} request The received request. * @param {function(*): void} sendResponse A callback that delivers a response. * @param {*} response The response to return. */ function defaultResponseCallback(request, sendResponse, response) { response['requestId'] = request['requestId']; try { sendResponse(response); } catch (e) { console.warn(UTIL_fmt('caught: ' + e.message)); } } /** * Response callback that delivers a response to a request only when the * sender is a foreground tab. * @param {*} request The received request. * @param {!MessageSender} sender The message sender. * @param {function(*): void} sendResponse A callback that delivers a response. * @param {*} response The response to return. */ function sendResponseToActiveTabOnly(request, sender, sendResponse, response) { tabInForeground(sender.tab.id).then(function(result) { // If the tab is no longer in the foreground, drop the result: the user // is no longer interacting with the tab that originated the request. if (result) { defaultResponseCallback(request, sendResponse, response); } }); } /** * Common handler for messages received from chrome.runtime.sendMessage and * chrome.runtime.connect + postMessage. * @param {*} request The received request * @param {!MessageSender} sender The message sender * @param {function(*): void} sendResponse A callback that delivers a response * @return {Closeable} A Closeable request handler. */ function messageHandler(request, sender, sendResponse) { var responseCallback; if (isRegisterRequest(request)) { responseCallback = sendResponseToActiveTabOnly.bind(null, request, sender, sendResponse); } else { responseCallback = defaultResponseCallback.bind(null, request, sendResponse); } var closeable = handleWebPageRequest(/** @type {Object} */(request), sender, responseCallback); return closeable; } /** * Listen to individual messages sent from (whitelisted) webpages via * chrome.runtime.sendMessage * @param {*} request The received request * @param {!MessageSender} sender The message sender * @param {function(*): void} sendResponse A callback that delivers a response * @return {boolean} */ function messageHandlerExternal(request, sender, sendResponse) { if (sender.id && sender.id === LOG_SAVER_EXTENSION_ID) { return handleLogSaverMessage(request); } messageHandler(request, sender, sendResponse); return true; // Tell Chrome not to destroy sendResponse yet } chrome.runtime.onMessageExternal.addListener(messageHandlerExternal); // Listen to direct connection events, and wire up a message handler on the port chrome.runtime.onConnectExternal.addListener(function(port) { function sendResponse(response) { port.postMessage(response); } var closeable; port.onMessage.addListener(function(request) { var sender = /** @type {!MessageSender} */ (port.sender); closeable = messageHandler(request, sender, sendResponse); }); port.onDisconnect.addListener(function() { if (closeable) { closeable.close(); } }); }); /** * Handles messages from the log-saver app. Temporarily replaces UTIL_fmt with * a wrapper that also sends formatted messages to the app. * @param {*} request The message received from the app * @return {boolean} Used as chrome.runtime.onMessage handler return value */ function handleLogSaverMessage(request) { if (request === 'start') { if (originalUtilFmt_) { // We're already sending return false; } originalUtilFmt_ = UTIL_fmt; UTIL_fmt = function(s) { var line = originalUtilFmt_(s); chrome.runtime.sendMessage(LOG_SAVER_EXTENSION_ID, line); return line; }; } else if (request === 'stop') { if (originalUtilFmt_) { UTIL_fmt = originalUtilFmt_; originalUtilFmt_ = null; } } return false; } /** @private */ var originalUtilFmt_ = null; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; // Global holding our NaclBridge. var whispernetNacl = null; // Encoders and decoders for each client. var whisperEncoders = {}; var whisperDecoders = {}; /** * Initialize the whispernet encoder and decoder. * Call this before any other functions. * @param {string} clientId A string identifying the requester. * @param {Object} audioParams Audio parameters for token encoding and decoding. */ function audioConfig(clientId, audioParams) { if (!whispernetNacl) { chrome.copresencePrivate.sendInitialized(false); return; } console.log('Configuring encoder and decoder for client ' + clientId); whisperEncoders[clientId] = new WhisperEncoder(audioParams.paramData, whispernetNacl, clientId); whisperDecoders[clientId] = new WhisperDecoder(audioParams.paramData, whispernetNacl, clientId); } /** * Sends a request to whispernet to encode a token. * @param {string} clientId A string identifying the requester. * @param {Object} params Encode token parameters object. */ function encodeTokenRequest(clientId, params) { if (whisperEncoders[clientId]) { whisperEncoders[clientId].encode(params); } else { console.error('encodeTokenRequest: Whisper not initialized for client ' + clientId); } } /** * Sends a request to whispernet to decode samples. * @param {string} clientId A string identifying the requester. * @param {Object} params Process samples parameters object. */ function decodeSamplesRequest(clientId, params) { if (whisperDecoders[clientId]) { whisperDecoders[clientId].processSamples(params); } else { console.error('decodeSamplesRequest: Whisper not initialized for client ' + clientId); } } /** * Initialize our listeners and signal that the extension is loaded. */ function onWhispernetLoaded() { console.log('init: Nacl ready!'); // Setup all the listeners for the private API. chrome.copresencePrivate.onConfigAudio.addListener(audioConfig); chrome.copresencePrivate.onEncodeTokenRequest.addListener(encodeTokenRequest); chrome.copresencePrivate.onDecodeSamplesRequest.addListener( decodeSamplesRequest); // This first initialized is sent to indicate that the library is loaded. // Every other time, it will be sent only when Chrome wants to reinitialize // the encoder and decoder. chrome.copresencePrivate.sendInitialized(true); } /** * Initialize the whispernet Nacl bridge. */ function initWhispernet() { console.log('init: Starting Nacl bridge.'); // TODO(rkc): Figure out how to embed the .nmf and the .pexe into component // resources without having to rename them to .js. whispernetNacl = new NaclBridge('whispernet_proxy.nmf.png', onWhispernetLoaded); } window.addEventListener('DOMContentLoaded', initWhispernet); // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Constructor for the Nacl bridge to the whispernet wrapper. * @param {string} nmf The relative path to the nmf containing the location of * the whispernet Nacl wrapper. * @param {function()} readyCallback Callback to be called once we've loaded the * whispernet wrapper. */ function NaclBridge(nmf, readyCallback) { this.readyCallback_ = readyCallback; this.callbacks_ = []; this.isEnabled_ = false; this.naclId_ = this.loadNacl_(nmf); } /** * Method to send generic byte data to the whispernet wrapper. * @param {Object} data Raw data to send to the whispernet wrapper. */ NaclBridge.prototype.send = function(data) { if (this.isEnabled_) { this.embed_.postMessage(data); } else { console.error('Whisper Nacl Bridge not initialized!'); } }; /** * Method to add a listener to Nacl messages received by this bridge. * @param {function(Event)} messageCallback Callback to receive the messsage. */ NaclBridge.prototype.addListener = function(messageCallback) { this.callbacks_.push(messageCallback); }; /** * Method that receives Nacl messages and forwards them to registered * callbacks. * @param {Event} e Event from the whispernet wrapper. * @private */ NaclBridge.prototype.onMessage_ = function(e) { if (this.isEnabled_) { this.callbacks_.forEach(function(callback) { callback(e); }); } }; /** * Injects the for this nacl manifest URL, generating a unique ID. * @param {string} manifestUrl Url to the nacl manifest to load. * @return {number} generated ID. * @private */ NaclBridge.prototype.loadNacl_ = function(manifestUrl) { var id = 'nacl-' + Math.floor(Math.random() * 10000); this.embed_ = document.createElement('embed'); this.embed_.name = 'nacl_module'; this.embed_.width = 1; this.embed_.height = 1; this.embed_.src = manifestUrl; this.embed_.id = id; this.embed_.type = 'application/x-pnacl'; // Wait for the element to load and callback. this.embed_.addEventListener('load', this.onNaclReady_.bind(this)); this.embed_.addEventListener('error', this.onNaclError_.bind(this)); // Inject the embed string into the page. document.body.appendChild(this.embed_); // Listen for messages from the NaCl module. window.addEventListener('message', this.onMessage_.bind(this), true); return id; }; /** * Called when the Whispernet wrapper is loaded. * @private */ NaclBridge.prototype.onNaclReady_ = function() { this.isEnabled_ = true; if (this.readyCallback_) this.readyCallback_(); }; /** * Callback that handles Nacl errors. * @param {string} msg Error string. * @private */ NaclBridge.prototype.onNaclError_ = function(msg) { // TODO(rkc): Handle error from NaCl better. console.error('NaCl error', msg); }; // Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Function to convert an array of bytes to a base64 string * TODO(rkc): Change this to use a Uint8array instead of a string. * @param {string} bytes String containing the bytes we want to convert. * @return {string} String containing the base64 representation. */ function bytesToBase64(bytes) { var bstr = ''; for (var i = 0; i < bytes.length; ++i) bstr += String.fromCharCode(bytes[i]); return btoa(bstr).replace(/=/g, ''); } /** * Function to convert a string to an array of bytes. * @param {string} str String to convert. * @return {Array} Array containing the string. */ function stringToArray(str) { var buffer = []; for (var i = 0; i < str.length; ++i) buffer[i] = str.charCodeAt(i); return buffer; } /** * Creates a whispernet encoder. * @constructor * @param {Object} params Audio parameters for the whispernet encoder. * @param {Object} whisperNacl The NaclBridge object, used to communicate with * the whispernet wrapper. * @param {string} clientId A string identifying the requester. */ function WhisperEncoder(params, whisperNacl, clientId) { this.whisperNacl_ = whisperNacl; this.whisperNacl_.addListener(this.onNaclMessage_.bind(this)); this.clientId_ = clientId; var msg = { type: 'initialize_encoder', client_id: clientId, params: params }; this.whisperNacl_.send(msg); } /** * Method to encode a token. * @param {Object} params Encode token parameters object. */ WhisperEncoder.prototype.encode = function(params) { // Pad the token before decoding it. var token = params.token.token; while (token.length % 4 > 0) token += '='; var msg = { type: 'encode_token', client_id: this.clientId_, // Trying to send the token in binary form to Nacl doesn't work correctly. // We end up with the correct string + a bunch of extra characters. This is // true of returning a binary string too; hence we communicate back and // forth by converting the bytes into an array of integers. token: stringToArray(atob(token)), repetitions: params.repetitions, use_dtmf: params.token.audible, use_crc: params.tokenParams.crc, use_parity: params.tokenParams.parity }; this.whisperNacl_.send(msg); }; /** * Method to handle messages from the whispernet NaCl wrapper. * @param {Event} e Event from the whispernet wrapper. * @private */ WhisperEncoder.prototype.onNaclMessage_ = function(e) { var msg = e.data; if (msg.type == 'encode_token_response' && msg.client_id == this.clientId_) { chrome.copresencePrivate.sendSamples(this.clientId_, { token: bytesToBase64(msg.token), audible: msg.audible }, msg.samples); } }; /** * Creates a whispernet decoder. * @constructor * @param {Object} params Audio parameters for the whispernet decoder. * @param {Object} whisperNacl The NaclBridge object, used to communicate with * the whispernet wrapper. * @param {string} clientId A string identifying the requester. */ function WhisperDecoder(params, whisperNacl, clientId) { this.whisperNacl_ = whisperNacl; this.whisperNacl_.addListener(this.onNaclMessage_.bind(this)); this.clientId_ = clientId; var msg = { type: 'initialize_decoder', client_id: clientId, params: params }; this.whisperNacl_.send(msg); } /** * Method to request the decoder to process samples. * @param {Object} params Process samples parameters object. */ WhisperDecoder.prototype.processSamples = function(params) { var msg = { type: 'decode_tokens', client_id: this.clientId_, data: params.samples, decode_audible: params.decodeAudible, token_length_dtmf: params.audibleTokenParams.length, crc_dtmf: params.audibleTokenParams.crc, parity_dtmf: params.audibleTokenParams.parity, decode_inaudible: params.decodeInaudible, token_length_dsss: params.inaudibleTokenParams.length, crc_dsss: params.inaudibleTokenParams.crc, parity_dsss: params.inaudibleTokenParams.parity, }; this.whisperNacl_.send(msg); }; /** * Method to handle messages from the whispernet NaCl wrapper. * @param {Event} e Event from the whispernet wrapper. * @private */ WhisperDecoder.prototype.onNaclMessage_ = function(e) { var msg = e.data; if (msg.type == 'decode_tokens_response' && msg.client_id == this.clientId_) { this.handleCandidates_(msg.tokens, msg.audible); } }; /** * Method to receive tokens from the decoder and process and forward them to the * token callback registered with us. * @param {!Array.string} candidates Array of token candidates. * @param {boolean} audible Whether the received candidates are from the audible * decoder or not. * @private */ WhisperDecoder.prototype.handleCandidates_ = function(candidates, audible) { if (!candidates || candidates.length == 0) return; var returnCandidates = []; for (var i = 0; i < candidates.length; ++i) { returnCandidates[i] = { token: bytesToBase64(candidates[i]), audible: audible }; } chrome.copresencePrivate.sendFound(this.clientId_, returnCandidates); }; { "program": { "portable": { "pnacl-translate": { "url": "whispernet_proxy_pnacl.pexe.png?v00008" } } } } PEXE!pA#AI29 %bEB $"D$Ȑ"9@CE2+dHb!C HP (@q)q$ CE "+$>`#v`@B@!@D@F PPPP00@H@Gd EA@@$ @@@I@CQ PPPPqq@FpQ@@K4@CQDPP1PPqPdA1P@DQ@4@A@T@QDA@-@4A@qDPB@ @A@4@@ D14,@D ,@PPAEAG@t@@PP@dP @dp!tP@)@H!d@@ @TPP#!0B0#0B(0 #0B 0 #D0B$0 #a0B 0 #D0BP@ #D0B$0 #a0B40 #0B$0 #0B$0 #0B<0#a0B0 #a0B0 #0B00 #a0B$0 #0B(0 #0B$0 #0B0 #0B$0 #0B$0 #Da0B$0 #D0B<0 #a0B0 #!0BH@ #D0B0 #0B<0 #0B(0 #0B(0 #a0B0 #a0B0 #a0B0 #0B 0#!0B(0 #0B(0 #D0B@0 #0B40 #0B40 #D0B@0 #D0B40 #0B@0 #D0B40 #D0B$0 #D0B40 #D0B(0 #0B(0 #0B 0 #!0B$0 #0B$0 #D0B(0 #0B(0 #0B$0 #a0B40 #D0B40 #D0B$0 #0B40 #0B00 #a0B0 #0BD0 #D0B(0 #a0B0 #0B40 #0B$0 #0B00 #a0B(0 #a0B$0 #0B 0 #D0B0 #D0B00 #0B$0 #a0B0 #D0B00 #;0B #0B40 #Da0B0 #D0B #D0B$0 #Da0B0 #0B 0x0B$0 #D0B0 #D0B$0 #0B(0 #0B(0 #D0B$0 #0B 0 #a0B$0 #Da0B0 #0B00 #0B0 #D0B 0 #D0n0B0 #0B40 #D0B$0 #D0B #0B(0 #0B0 #0B 0 #D0 #D0B$0 #D0 #D0B0 #D<0B #0B$0 #D!0 #0B@0 #D 0 #0B 0 #a0B0 #a0B@0 #a0B0 #a0B #0B(0 #D0B$0 #Da0B0 #0B$0 #D0B$0 #a0B0 #0B #Da0B0 #Da0B 0 #D0B #D0B40 #0B 0 #D0B$0 #D0B40 #D0B`0 #0B$00B40 #D0B40 #D0B(0 #D0B #D0B00 #0B$0 #0B #80B #D0B$0 #0B40 #D0B@0 #D0BT0a0B0 #0B #D0B$0 #D0B40 #0B$0 #0B40 #0B40 #!00B$0 #0B40 #0B$0 #D0B$0 #Da0B(0 #D0B40 #D0B$00B0 #D0B$0 #D0B$0 #Da0B(0 #a0B0 #D0B0 #DA0oa0B$0 #D0B$0 #D0B$0 #D0B$0 #90B@0 #D0B40 #D0B40 #D0 #0B0 #a0B #)0B #D0B$0ya0B40 #0B40 #0B\0 #Da0B$0 #0B$0 #D0B40 #0B40 #Da0B 0 #D0B40 #0B40 #D0B\0 #(0B #(0B #0B40 #0B(0 #0B40 # 0 #0B 00B # 0q01 0 #D0B(0 #D0B(0 #0B0 #0B$0 #0B0 #a0BX0 #0B$0 #0B0 #a0B@0 #D0BT0 #DQ0BT0 #DQ0BT0 #Da0B0 #DQ0BT0 #DQ0BT0 #DQ0BT0k0B$0 #DQ0BT0 #DQ0BT0 #DQ0BT0 #a0BT0 #DQ0BT0 #DQ0BT0 #D0 #A0 #A0 #Dq0B\0lq0B #0B #a0B0 #q0B@0 #D0 #0B #Q0B #0B$0 #0 #0B #0B\0 #a0B\0 #D0 #0B #Dq0 #D0B 0 #DQ0BT0 #0B #a0B0 #DQ0BT0 #Dq0BT0 #0B\0 #q0B\0 #q0B\0 #0B$0 #Q0BT0 #DQ0B #0B0 #0BT0 #Dq0BT0 #Dq0B #q0B\0 #q0B`0 #q0B\0 #D0B$0 #D0B0 #0B 0 #0B 0 #0B 0 #0B 0 #0B40 #D0B$0 #a0B 0 #0B 0 #0B 0 #0B 0 #0B$0 #0B #a0B0 #0B\0 #D0B40 #D0B$0b!0 #a0B #0B40 #D0B40 #Da0B0 #D0B40 #a0B0 #D0B 0 #D0B$0 #D0B0 #0B 0 #D0B$0 #D0B$0 #0B$0 #D0B 0 #Da0B0 #0B$0 #D0B 0 #D0B$0 #0B$0 #D0B$0 #a0B0 #0B$0 #D0B$0 #D0B 0 #D0B$0 #0B$0 #a0B 0 #D0B$0 #0B$0 #D0B40 #a0B0 #0B$0 #D0B$0 #D0B$0 #Da0B0 #0B$0 #D0B 0 #D0B40 #D0B0 #0B 0 #D0B$0 #D0B40 #D0B40 #a0B 0 #D0B$0 #0B$0 #Da0B #0B0 #!0 #0B0b0 #D0B0b!0 #a0B #0B #0B0 #D0B0{Q0B0 #0p0 #D0B0{Q0B0 #0p0 #a0B$0 #q0B$0 #a0B00 #D0B$0 #0B0 #0B\0 #D0B40 #a0B0 #D0B40 #a0B40 #D0B0 #0B40 #D0B0 #0B40 #Da0B0 #D0B40 #a0B0 #a0B0 #D0B$0 #0B40 #0B<0 #a0B$0 #0B$0 #0B$0 #D0B 0 #a0B0 #D0B0 #0B40 #a0BX0 #0B<0 #D0B(0 #0B(0 #a0B@0 #0B00 #0BD0 #0B40 #0B(0 #D0B0 #D0BD0 #0B00 #D0BX0 #0BX0 #a0B 0 #0Bp0 #0BX0 #0Bp0 #0BX0 #0Bp0 #0BX0 #0BX0 #a0B 0 #a0B 0 #0Bp0 #0B 0 #0Bp0 #0BX0 #0BX0 #a0B 0 #a0B 0 #0Bp0 #0B 0 #0Bp0 #0BX0 #0BX0 #a0B 0 #a0B 0 #0Bp0 #0B 0 #0Bp0 #0BX0 #a0B0 #D0B0 #a0B 0 #0B$0 #D0B$0 #D0B40 #D0B40 #0B 0 #0B 0 #0B 0 #0B 0 #Da0B@0 #D0B0 #a0B0 #a0B0 #a0B0 #0B40aQ0BT0 #DQ0BT0 #DQ0BD0aQ0BT0 #DQ0BT0 #DQ0BX0 #Da0BX0 #D0B40 #D0B$0 #D0B$0 #D0B$0 #D0B$0 #D0B$0 #D0B$0 #D0B$0 #Da0B40 #D0B$0 #D0B$0 #D0B$0 #D0B$0 #D0B0 #a0Bp0 #0Bp0 #0BX0 #a0B0 #a0B0aa0B #0B #D0B #0BD0 #D0 #Da0B$0 #0B$0 #Da0B0 #a0B0 #a0B0 #0B(0 #0B0 #Da0B 0 #a0B0 #a0B0 #0B0 #0B0 #a0B 0 #Da0B<0 #D0B0 #a0B0 #a0B40 #0B 0 #0B40 #0B$0 #D0B$0 #0B0 #Da0B0 #0B 0 #0B$0 #0B 0 #D0B$0 #a0B0 #0B 0 #0B$0 #0B40 #D0B00 #0B$0 #D0B$0 #0B 0 #D0B 0 #0B$0 #D0B0 #0B(0 #0B 0 #0B40 #a0B$0 #D0B0 #0B00j0B 0 #0B 0 #0B(0 #a0B$0 #0B 0 #0B@0 #0B$0 #D0B #0B 0 #0B(0 #a0B0 #D0B #0B 0 #0B00 #0B00 #0B$0 #D0B 0 #D0B 0 #0B40 #0 #0B00 #0B00 #D0B0 #0B0 #D0B$0 #D0B 0 #0B(0 #0B(0 #0B(0 #0B(0 #0B 0 #0BD0 #0BD0 #0B00 #0B$0 #*0B$0 #a0B0 #0B40 #D0B40 #0B(0 #0B(0 #0B(0 #0B(0 #0B 0 #0BD0 #0BD0 #0B00 #0B$0 #*0B$0 #a0B0 #0B40 #D0B$0 #0B(0 #0B(0 #0B(0 #*0B #Q0e0B(0 #0B(0a0B$0 #Da0B0 #Da0B$0 #0B$0 #a0B 0 #0B(0 #0B(0 # 00B #D0B(0 #0B$0 #*0B0 #D0B0 #a0B$0 #0B40 #D0B40 #D0B0 #0B(0 #0B0 #a0B0 #D0B$0 #0B40 #0B40 #D0B40 #0B$0 #D0B40 #0B40 #0B`0 #0B$0 #D0B0 #0B$0 #0B(0 #0B 0 #D0B00 #0B 0 #D0B$0 #D0B$0 #D0B 0 #0B00 #D0B 0 #Da0B0 #0B(0 #0B(0 #0B$0 #0BD0 #0BT0 #0B 0 #0B00 #0BX0 #D0B`0 #0B@0 #0 #0BX0 #0BD0 #D0B 0 #0B00 #0B00 #0B00 #0B00 #0B00 #0B00 #0BX0`0BD0 #0B40 #D0B40 #D0B0 #0B$0 #D0B 0 #0B40 #D0B40 #0B$0 #D0B40 #0B40 #D0B@0 #0B 0 #D0B40 #0B(0 #0B40 #0B$0 #D0B(0 #D0B$0 #D0B$0 #D0B$0 #D0B 0 #0B40 #D0B$0 #D0B0 #0B(0 #0B(0 #0B(0 #0B00 #D!0 #0B 0 #0BD0 #0BD0 #0B@0 #0B40 #Da0B #Da0BD0 #Da0BD0 #0B$0 #D0BD0 #0BD0 #0BD0 #0BD0 #0BD0 #0BD0 #0BD0 #0B(0 #0B@0 #D0B$0 #D0B40 #0B00 #0B #D0B #D0B #D0B #0B #0B$0 #D0B #+0B #D(0B #D0B$0 #+0B #:0B #D(0B`0 #0aa0B`0 #0aa0BD0 #D0B(0 #a0B$0 #D!0B0 #a0B0 #a0B0 #a0B0 #!0B<0 #0B<0 #0B0 #0B 0 #a0B<0 #0B0 #a0B$0 #0B0 #0B0 #a0B0 #a0B0 #a0B0 #0B00 #0B@0 #0B@0 #0B`0 #q0B`0 #q0B\0 #0BH0 #Da0B0 #a0B 0 #a0B 0a0B`0 #D0B 00 #0B0 #a0B 0 #0B0 #0B0 #a0B0 #0B0 #0B40 #a0B0 #0B0 #Da0B0 #Da0B0 #0B$0 #D0B@0 #0B$0 #a0B 0 #0B0 #a0B0 #Da0B0 #a0B$0 #a0B0 #0B0 #0B0 #0B$0 #0Bd0 #0Bl0 #0Bl0 #D0B #0Bh0 #D10 #0B|0 #0Bd0#0Bd0 #0B|00B #D-0Bd0c0B #0B #0Bd0 #0Bt0n0B #D!0 #0B #0B(0 #0B 0 #0B(0 #0B<0 #!0B(0 #0B00 #0B<0 #0B$0 #D0B 0 #0BD00BD0 # 0 #0B(0 #0B0 #0B40 #a0B00 #!0BD0 #D0B00 #0B(0 #0BH0 #0B 0 #0B 0 #0B 0 #0B 0 #0B<0 #0B 0 #!0B(0 #a0BD0`a0BX0 #0 #0B #0B #0B00 #a0BD0 #D0BD0 #D0B(0 #0B(0 #D0BD0 #0B00 #0f0 #0B #0BD0 #D0B #D0B00 #Da0BD0 #0B 0 #0B00 #0BD0 #0BD0 #0BD0 #0B00 #0 #0B #(0B #D0B #D0B #D0B #0BD0 #0BX0 #a0BX0 #0B00 #0B 0 #D 0 #a0B(0 #0B 0 #!0B0 #!0BH0 #D0B 0 #0B(0 #0B$0 #D0B 0 #0 #D0B$0 #0B 0 #0B00 #0B #-0B #0B40 #!0 #D0B00 #0B00 #0BX0 #Dq 0 #0B(0 #0B(0#D @ #D0BX00B00 #0B(00B#a@ #/0B00 #0B00 #0B4@ #Da0BX@ #0B00 #(0B#=0B#0B#D:0B#0B #0B #0B@0 #0 #0BH0 #0B00 #0B 04AXS(xW]Q[]YX]ڛrKs#:**;K{s ]_Unwind_GetDataRelBase<.͍먌H |Uݥ}%AP*472$$77 UvFuTFGUG'U&6W@*472:0)4100 UvF5UFw$@sȾ蒠(xW]STT\ rKs#r b**c]_Unwind_RaiseException<.͍KlMKM|Uݥ}IյP*472! $&Gv@s062222877072439012 UvF%6F'6V@cdata<6CK+s+{{CK+s+r c +sZYX[H[ZWٗKY]J C+[1 Kc+#+K+type)-.--Mm썬L7ѥ}P27/27 CV6FVEV6@ $70422<29422 AV@[ʾ(xH\ט @@@@@@z@(xH\\]H @@@@@@z@(xUYZYX[ "+{#+{[+s+{s+)client_idmmՑP27/2/972 2V6@{ʾ(x \\]]O{[+sb+s;C"k3ുT٘\Y]UQY]Z[H s# KEdecode_inaudible<.L..lnn'ѽ}ѡ}P@lL,. hjj l썬L. m  wɅаs@66@@@3YInvalid message type!<6 s;{+i+ ;+KC)kcK+sI" ani@S;9292;082+929229""@'|/ bpblʊX)90ꚂBC:2ZZӊҒҊ: [ ZȐțHY]Z[o!J #+#A tokens out of  dHj-m-n ɽչѵP"* ܡɹ}ɽܡɹ}ɱ}ٕɥɹP(, ĮL.,m,.M-, $ + $mNǁܡѕP팭eNn,셬 팭l-M .L.-.ͮk-̍kl L%m. ..-.e .k,M,ll,lM. '.pp::CompletionCallback pp::CompletionCallbackFactory::NewCallbackHelper(Dispatcher *) [T = WhispernetUrlVerifier, ThreadTraits = pp::ThreadSafeThreadTraits, Dispatcher = pp::CompletionCallbackFactory::Dispatcher0] 0;`I. - Ȯ̍5I @ӜdbpȒX)b;`I.n - Ȯ̍5I H@dbfX)f;`40"ςJj*Jr++sۉq(x[Z\]]N @۠Ȓvb\`h`D0 %T'&GWGWdWF"@# !P0((+2$7:"27 AA }5͕%ٕĸ` J+ Ȯ̍n'gYI =M锐)P$''929 u$tR2SBR2@@Z@JtJ@Z@J(xQUH HɜN H Hɜ ,,ۆp$j(` #M؆@3)9:49:110/:4"0607""@' x ut|4O>@Apd]>An(x4E7S#665G'v&Vg443&E'F74VT46F&4VTTT@H$<_a{zC391:2;92923:;92926707291 ^7V6bVFR7VF5FV@3:;929267072""@& 5!*K+#1)J;+s!*s+ +)J;+sb{[)J;+si K1c{ ciaiaaiaiaaia1 c+a K;sC{s!*s+ +yC+#*K+#1I"*K+#)J;+sb{[)J;+si K1c{ ciaiaaiaiaaia1 c+ayC+#*K+#)J;+sѡ s{+{s)J;+s 1c{ ciaaaia]][HQYN][Z\\Y[Z\WZOQYOQYNS\[ HK HK  HK HK H HK Y\ HQY\\؛HQYN\X[ HK H  HK H \]\؛Q\] ؛ZQ\]I Q\]HHQYOQYNS\[ HK HK  HK HK H HK Y\ ZQ\]HHQY\\؛HQYN\X[ HK H  HK H HSR]\\Y[Z\H\]Y&@ttܞxttxX@ZbX@ZbX@`X@ZbX@Zb|@|ttPX@R@@z@ttxX@ZbX@ZbX@`X@ZbX@Zb|(x3GF625&RvV榣FW&Ʀ#WFVSvV榣FW&Ʀ37&cFRvV榣3t6WV&SvV榣FW&Ʀ37&&6cF26GRvV榣36GRvV榣F'cFRvV榣3E'FV㣣#W26GBT&fWFb26GbT6beT6RvV榣FW&Ʀ37&cFBT&fWFRvV榣3t6WV&SvV榣FW&Ʀ37&&6cF26GRvV榣36GRvV榣F'cFRvV榣3E'FVB%fW&7R&Ɩv@3242227222270827 9<30:27):4203779:222':292422-2422270827 9<30:27):42':29242227240'82747290600/8717830:30:79:270879:27 9<30:27):4279:27240'827472906007830:79:27240'82747290600/8717830:30:79:27 9<30:79:27 9+082927*09227!5270:4<30:0279:27*0'82747290600:6487830:79:27 9<30:. ^jEigen::CwiseBinaryOp, const Eigen::Map, 0, Eigen::Stride<0, 0> >, const Eigen::CwiseBinaryOp, const Eigen::CwiseBinaryOp, const Eigen::Array, const Eigen::ArrayWrapper, 1, -1, false> > > >, const Eigen::CwiseUnaryOp, const Eigen::Array > > >::CwiseBinaryOp(const Lhs &, const Rhs &, const BinaryOp &) [BinaryOp = Eigen::internal::scalar_product_op, Lhs = const Eigen::Map, 0, Eigen::Stride<0, 0> >, Rhs = const Eigen::CwiseBinaryOp, const Eigen::CwiseBinaryOp, const Eigen::Array, const Eigen::ArrayWrapper, 1, -1, false> > > >, const Eigen::CwiseUnaryOp, const Eigen::Array > >]~+J;+sK+Js {)J;+sIs+s cљ c kz1c{ a{s)J;+sK+Js {)J;+sIs+s cљ c {#z1c{ c1c{ a{s)J;+s 1c{ ciaaaiaa{s)J;+s ˻ +)J;+sѡ s{+)J;+sb{[)J;+si K1c{ ciaiaaiaiaaia1 c+a{s)J;+sK+r {)J;+sIs+s cљ c jcKc+z1c{ a{s)J;+s 1c{ ciaaaiaK+Js {C{saB1a{sB1a{sJs {1IJs {)J;+sIs+s cљ c kz1c{ aaB{s)J;+sK+Js {)J;+sIs+s cљ c {#z1c{ c1c{ a{s)J;+s 1c{ ciaaaiaa{s)J;+s ˻ +)J;+sѡ s{+)J;+sb{[)J;+si K1c{ ciaiaaiaiaaia1 c+aB{s)J;+sK+r {)J;+sIs+s cљ c jcKc+z1c{ a{s)J;+s 1c{ ciaaaiaPQY]\P[\OQYN][X[Y[ [ ؛HQYN\X[ HK H  HK H ؛HQYN\Xޕ\\OQY\\OQYOQYNS\[ HK HK  HK HK H HK Y\]\P[\؛ ؛ ؛P[\I ȖP[\HHQYN][X[Y[ [ H؛HQYN\X[ HK H  HK H H؛HQYN\Xޕ\\OQY\\OQYOQYNS\[ HK HK  HK HK H HK Y\O4ttxttxX@ZbX@ZbX@`X@ZbX@Zb|X@bX@ZbX@|ttP@LX@R@@z@ttxX@ZbX@ZbX@`X@ZbX@Zb|X@֤@z@bX@ֆ@z@ZbX@@z@ʺ(xQvV榣'6WSvV榣#6SvV榣F'cFb6W㣣'6WFW&GWFVFVET&fWFRvV榣#6SvV榣F'cFb6WTfW@C[˜[Z[Z\[[KXYZ\[Z\[YYPR@@@@@PR\(xx@TFW6F'$F66GW&2VF"Vf&W2BFƖW@ۤ@@t@(xZYX[YYٗ\\ PR@@@@@PR\(xZYX[YȦPR@@@ʾȾ@@\(xZYX[\\C+[1 Kc+#k{c{+IQYOQYNS؛HQYNS\[ HK H  HK H  HQY\Y  HK H Y\ U\ HY HY HY HY^ U\HHQYNS؛HQYNS\[ HK H  HK H  HQY\Y  ؚHHK HH H[T[HY\YMttxttxttx@ttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@ZbX@bX@|X@`|ttPX@X@R@@z@ttxttx@ttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@ZbX@bX@|X@@z@`(x'eFRvV榣sTV&%FV7FSvV榣F'cFRvV榣F'w%W&SvV榣3t6WV&gVvSvV榣FW&Ʀ37&%W%Wf3GG3VcFRvV榣#6SvV榣$'3GG3VcFb6WB㣣37VFDFFBT6Gb26G25&b26Gń6RvV榣F'cF"6RvV榣F'w%W&SvV榣3t6WV&gVvSvV榣FW&Ʀ37&%W%Wf3GG3VcFRvV榣#6SvV榣$'3GG3VcFb6W%FV7FGWBBT6GRvV榣F'w%W&SvV榣$'cF@à(MGJnN(l(MG̭L. Jn(MG)N. nj-%%%%Ƈ(MG)N. J. L(MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MGGml(MG'HN.,gLG+FGg nj-·%$%$Ƈ%$,mćƇ(MG)N. nj-%%%%Ƈ(MG)N. J. L(MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MGGml(MG'HN.,gLG+FGg nj-·%$%$Ƈ%$,mGGJnN(l em mĄdmD m$dL.ͮ (MG̭L. Jn(MG)N. nj-%%%%Ƈ(MG)N. J. L(MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MGGml(MG'HN.,gLG+FGg nj-·%$%$Ƈ%$,mćƇ m(MG)N. nj-%%%%ƇD m(MG)N. J. L(MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MGGml(MG'HN.,gLG+FGg nj-·%$%$Ƈ%$,mħ G+Eigen::CwiseNullaryOp >, Eigen::Array, -1, 1, 0, -1, 1> >::CwiseNullaryOp(Index, Index, const NullaryOp &) [NullaryOp = Eigen::internal::scalar_constant_op >, MatrixType = Eigen::Array, -1, 1, 0, -1, 1>]< K c )J;+sIs+s cё+#Jkc)J;+sIs+s cљ c kz㙣#{kc+1c{ a)J;+sb{[)J;+s 㙣#{kc+1c{ aiaaaiaaiaa1 c+aaёsC{s!*K+#1a{s1s1I2s)J;+sIs+s cљ c kz㙣#{kc+1c{ a!*K+#)J;+sb{[)J;+s 㙣#{kc+1c{ aiaaaiaaiaa1 c+a + car{ccKs;T]\P[\P  ڜIQYT]\P[\OQYN][X[YW[[[ W[[[ HQYOQYN\XW[[[ HK H  HK H HK H Y\ HQYNS؛HQYN\XW[[[ HK H  HK H  HQY\Y [^^\ڙ؛Q\P\Q\]I ȖPHHQYN][X[YW[[[ W[[[ HHQYOQYN\XW[[[ HK H  HK H HK H Y\ HHQYNS؛HQYN\XW[[[ HK H  HK H  HQY\Y  Q\]HHQYNS؛HQYN\XW[[[ HK H  HK H  HQY\Y OEttxttx@ttxttbttx|X@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@`|ttPX@R@@z@ttx@ttxttbttx|X@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@@z@`(xZYX[\ڙ[/?ttxttxttbttx|X@ZbX@bX@`X@ZbX@b|X@ZbX@bX@|ttP@LX@X@X@X@R@@z@ttxttbttx|X@ZbX@bX@`X@ZbX@b|X@֤@z@ZbX@ֆ@z@bX@@z@ʺ(xQvV榣'6WSvV榣#6SvV榣$'3GG3VcFb6W㣣'6WFW&GWFVFVET&fWFRvV榣#6SvV榣$'3GG3VcFb6WTfW@242227222270:4<9:2682<30:03779:222':292422-2422270:4<9:2682<30:':29242227240'82747290600/871789:2682<30:30:79:27 9<9:2682<30:79:27 9<30:. ^ ݥ͕ =褹ѕɹ̍}ɽՍ}ё|}茽񘱽сɅё|}茽񘱽İİİсɅ񘱽ѱİİİ ݥ͕ =с0́сH́с=l =褹ѕɹ̍}ɽՍ}ё|}茽񘱽0́􀌽сɅё|}茽񘱽İİİH́􀌽сɅ񘱽ѱİİİtPRL.ͮ Ĥ(MGmL(l(MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MG'HN.,gLG+FGg nj-·%$%$GG-L//hn. emmL(lLL.ͮ$dL.ͮ (MGg.m-L.*쎧(MG'͍L-MGgn,-LK,K gLG+FGg nj-ć(MG'HN.,gLG+FGg nj-·%$%$ćLL.ͮ (MGg.mL(-L. (MG'͍L-MGgn,-L Nn nj-Č-·dm(MG'HN.,J. Lgm(MGGml(MG) gm(MG)N. nj-%$%$Ƈ(MGgN.ć%$,mćdm(MG'HN.,nj-%$%$ħ WJEigen::CwiseBinaryOp, const Eigen::ArrayWrapper, 0, Eigen::Stride<0, 0> >, -1, 1, false> >, const Eigen::Array >::CwiseBinaryOp(const Lhs &, const Rhs &, const BinaryOp &) [BinaryOp = Eigen::internal::scalar_product_op, Lhs = const Eigen::ArrayWrapper, 0, Eigen::Stride<0, 0> >, -1, 1, false> >, Rhs = const Eigen::Array]{K#)J;+sсb Ks{S+ +)J;+s 㙣#{kc+1c{ aiaaaiaё+K+CIr#+K"*K+#)J;+s 㙣#{kc+1c{ aiaaaia Q\]IQYQ\P\OQYNSOQYNS\[ HK H  HK H  HQY\Y [^^\ڙ؛Q\P\ZQ\]I Q\]HHQYNSOQYNS\[ HK H  HK H  HQY\Y  ZQ\]HHQYNS؛HQYNS\[ HK H  HK H  HQY\Y OO \\^\\^\\^^^ھ\(xi0V6bVFW6W%W66WRG@s 4227(4'11:227 9<30:42$72<$72-242227 9<30:. ,ɍՑܡɹѽͼՑܡɹѽܡɹ}ɹP֠m썬l*Mm%d, . $  d.M сѕPm썬l*Mm%d, . ĭ 䭎-͍LMakeStartTransition() called with empty input.;h [+ sKK{sCI cc+#KCqccy{Ks+sASZ\\]ڛJ X[]HY]H\ ʦPR@@@@@\hx^^^^^^f^^^\(xZYX[\YOC+[1 Kc+#1++sK+qcheck failed: phasor_matrix, 1, -1, 1, 1, -1> >::resize(Index, Index) [Derived = Eigen::Matrix, 1, -1, 1, 1, -1>]<)J;+si  +)J;+si )J;+si K㙣#{kc+1c{ aiaiaaiaiaa)J;+sљK#+aai  +CzKs+ʃ+cIr#+cIr#+K"*K+#)J;+si )J;+si K㙣#{kc+1c{ aiaiaaiaiaa)J;+sљK#+aaa*+cˋˋ Z\]KYYKQY˜Л\Q[\Y f@ttؠxttxttbttx|X@ZbX@ZbX@bX@ZbX@Zb|X@ttxttbttx|X@ZbX@bX@`X@ZbX@b|X@h|ttʂȂȨP@LX@@@LR@@@z@ttxttbttx|X@ZbX@ZbX@bX@ZbX@Zb|X@@z@ttxttbttx|X@ZbX@bX@`X@ZbX@b|X@@z@hX@@z@ttxttbttx|X@ZbX@bX@`X@ZbX@b|(xyņ6"v7B6G"v7bb%626B6G26@272:60'827472906007907789:2682<30:270:4<9:2682<30:2:60'8$72<$72<79::60'8-:60'827472906007907789:2682<30:0:4<<2270:4<9:2682<30:. ɥٕ͕ ͕4ɥё|}茽񘱽İİİ谅ͥс͕ ͕<ѡɥٕlɥٕ4ɥё|}茽񘱽İİİ<ѡɥٕ ݥ͕9ձ=褹ѕɹ̍}х}ё|}茽񘱽4ɥё|}茽񘱽İİİtPꅨL.ͮ Ĥ(MGmL(l(MGGml(MG)N. gLG+FGg nj-·%%$%%Ƈ$%NGG-L//hn. emmL(lLL.ͮ$dL.ͮ (MGGml(MG)N. gLG+FGg nj-·%%$%%Ƈ$%ṄLL.ͮ (MG) gm(MG)N. gLG+FGg nj-·$%$$%Ƈ(MGgN.ħ "Eigen::MapBase, 1, -1, 1, 1, -1>, 0, Eigen::Stride<0, 0> >, 0>::MapBase(PointerType, Index) [Derived = Eigen::Map, 1, -1, 1, 1, -1>, 0, Eigen::Stride<0, 0> >, Level = 0]<(J;+sb{[)J;+si K㙣#{kc+1c{ aiaiaaiaiaaia+b{ʃ+1aIr#+K‚ʃ+)J;+si K㙣#{kc+1c{ aiaiaaiaiab{ab{[zciaIrs+ s+c+GQYNSP\OQYOQYNS\W[[[ HK HK H HK HK H HK \] NSP\ []U\ HY HY^ Q\]HHQYOQYNS\W[[[ HK HK H HK HK H HK \] S]HL.@ttܞxttxttbttx|X@ZbX@ZbX@bX@ZbX@Zb|@|ttPX@R@@z@ttxttbttx|X@ZbX@ZbX@bX@ZbX@Zb|(xrvV2'72VG2VVr6W&VFrvV6VGr6W&VF6W6F57&W&26@207230642014 b7@ 154224779 ^5V6bVF27&W6@24222722227 9<30:03779:222':292422-242227 9<30:':29242227*0'8274729060009789:2682<30:79:272206(71:270:4<9:2682<30:270:4<9:2682<30:. ^Сɑ}彔ͼɍ ɕ@ɽՍ ͕P|(MGJnN(l(MG̭L. Jn(MG)N. gLG+FGg nj-·%%$%%Ƈ(MG)N. gLG+FGg nj-·%$%$ƇƇ(MG)N. gLG+FGg nj-·%%$%%Ƈ(MG)N. gLG+FGg nj-·%$%$GGJnN(l em mĄdmD m$dL.ͮ (MG̭L. Jn(MG)N. gLG+FGg nj-·%%$%%Ƈ(MG)N. gLG+FGg nj-·%$%$ƇƇ m(MG)N. gLG+FGg nj-·%%$%%ƇD m(MG)N. gLG+FGg nj-·%$%$Ƨ a_lhs.cols() == a_rhs.rows() && "invalid matrix product" && "if you wanted a coeff-wise or a dot product use the respective explicit functions"8pqyqqyqqyCK# {)K;+sy)J;+s{{z+{!zsA]\[X[H][X[Y\X]\[X[H][\XQ\]X[ ]\[X[H][\XZQ\]X[T]U\HQYNS\P\OQYNS\[ H HK H H HK؛HS\P\ZQ\]I ؛Q\]HHQYNS\[ H HK H H HK ZQ\]HHQY؛HQYNS\ޕ\\؛HQYNS؛HQYN\X[ HK H  HK H  HQY\Y  HK H Y\OK+CIyC+sK+CI]]X[HQYN][\YWZOQYN][X[\][ HQY]\P[\OQYN][X[؛Y[ [ ؛HQY\\؛HQYNS\[ H HK H H HK ؛HQY؛HQYNS\ޕ\\؛HQYNS؛HQYN\X[ HK H  HK H  HQY\Y  HK H Y\  \؛Q\] ؛QI ȖQHHQYN][X[\][ Q\]HHQY]\P[\OQYN][X[؛Y[ [ ؛HQY\\؛HQYNS\[ H HK H H HK ؛HQY؛HQYNS\ޕ\\؛HQYNS؛HQYN\X[ HK H  HK H  HQY\Y  HK H Y\ \]\H H[HL]ttx@ttx@ttx@ttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|@|X@ZbX@bX@|ttP@LX@X@X@X@R@@z@@ttx@ttx@ttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|@|X@֤@z@ZbX@ֆ@z@bX@@z@ʺ(xRvV榣'6WSvV榣#636GRvV榣F'w%W&36GRvV榣36GRvV榣$'cFRvV榣3E'FVb6W㣣'6WFW&GWFVFVET&fWFRvV榣#636GRvV榣F'w%W&36GRvV榣36GRvV榣$'cFRvV榣3E'FVb6WTfW@0092722392270:4<9:2682<30:7207.$72-2422270:4<9:2682<30:226. Zؽ@= ͕4ɥё|}茽񘱽İİİȕͥ镡$᥀lɥٕ4ɥё|}茽񘱽İİİtP%,-mLέe,Mn-L+LN., check failed: 0 <= index && index < size_<9pqyqqyqqy #K{{CK+s+{cK#Ks;Ks#{33+sAicheck failed: HasWindow()<!*K+#1)J;+s!*s+ +)J;+si K1c{ caiaaaia K;sC{s!*s+ +yC+#*K+#1I"*K+#)J;+si K1c{ caiaaaiayC+#*K+#)J;+si {s)J;+si K1c{ caiaaaiaa)J;+sљK#+aౄ[HQY[XۓZP\OQYNS\[ H HK H H HK\\^JY HY^ Q\]HHQYNS\[ H HK H H HKO/=ttxttx@ttxX@bX@ZbX@bX@bX@Zb|X@`X@ttx`X@`|@|X@`|ttPX@R@@z@ttx@ttxX@bX@ZbX@bX@bX@Zb|X@`X@ttx`X@`|@|X@@z@`h` <[x9{{;c+{{c{#{[+C{+{CK+s+{9{{;c+y #K{{CK+s+{{#+K;s c:+s+ {sUUnknown code length 奎.匎- . Wcheck failed: it != collection.end()<) d,΍L /google/src/cloud/ckehoe/whispernet/google3/audio/whispernet/compute_buffer_truncation.cc1C+[1 Kc+#++K+#rk{[KZYX[XYܗ[]YXIry3K+y exceeds maximum buffer size g samples skipped, signal may be lost.<1)++#i Kkk9{{#c{[K+Y, signal may be lost.Wx9{{;c+{{c{#{[+C{+{CK+s+{9{{;c+y #K{{CK+s+{!+{#Ks;:{sZYX[]Z[]Z[ܗXJ Д## 8؄Ց}ݡɹ0MIѕ27 Ky[˜[Z[Z\[[KXYZ\[ Z\[ݛoC+[1 Kc+#{+ c+}check failed: token_candidates, Eigen::Block, -1, 1, true>, 0, 0>::run(const Derived &, const Func &) [Func = Eigen::internal::scalar_sum_op, Derived = Eigen::Block, -1, 1, true>, Traversal = 0, Unrolling = 0]ؙ*c3K+Js {Js{caBcB1)J;+sљ*c3K+Js {)J;+sIs+s cљ c kz1c{ a)J;+sb{[)J;+s 1c{ ciaiaaiaiaiaa+a)J;+s 1c{ ciaaaiaa K;sC{s!*s+ +B#*K+#1IJs{)J;+sIs+s cљ c kz1c{ aaB)J;+sb{[)J;+s 1c{ ciaiaaiaiaiaa+aB)J;+s 1c{ ciaaaiaaB#*K+#)J;+s 1c{ ciaaaiaQ\]IQYQ\P\OQYN\X[ HK HK  HK HK[^^\ڙ؛Q\P\ZQ\]I Q\]HHQYN\X[ HK HK  HK HK ZQ\]HHQY]\S[\OQYN][X[؛][ HQYN\X[ HK HK  HK HKOOEttʜxttttx|X@ttxX@ZbX@ZbX@`X@ZbX@Zb|@|ttʜPX@X@@@LR@@z@ttttx|X@@z@ttxX@ZbX@ZbX@`X@ZbX@Zb|(x>2GF625&RvV榣FW&Ʀ#WFVSvV榣FW&Ʀ37&cFRvV榣$'cF㣣#W26GBT&fWFb26GbT6beT6RvV榣FW&Ʀ37&cFBT&fWFRvV榣$'cFB%fW&7R&Ɩv@"0 ʼnw /google/src/cloud/ckehoe/whispernet/google3/audio/whispernet/token_symbol_converter.cc7C+[1 Kc+#J*k{c{ZCK+k{cKୁSMXYZ\[ ZT^Л]]\K s@p ,ɍՑܡɹѽͼՑܡɹѽѵѵ}ɹPJmlnn* l%d, DM d, $-%׍յ}ͅP਌m %d, DM d, $-%*Derived &Eigen::DenseBase >::lazyAssign(const DenseBase &) [Derived = Eigen::Array, OtherDerived = Eigen::PartialReduxExpr, 0, Eigen::Stride<0, 0> >, Eigen::internal::member_sum, 1>]9pqyqqyqqyCK# {)K;+sy)J;+s{{z+{b{AIQY؛HQYNS؛HQYN\X[ HK HK  HK HK  HQY\Y  H HK Y\ U\ HY^ U\H؛HQYNS؛HQYN\X[ HK HK  HK HK  HQY\Y  ؚHH HHK H[T[HY\Y,P|z`R@LL@P@PP֤zzbR@LL@PֆzzttʨR@LL@x\PRR@PP֤zzttʨR@LL@PֆzzbR@LL@x\PRRR(xvRvV榣'6WSvV榣#636GRvV榣36GRvV榣$'cFRvV榣3E'FVb6W㣣'6WFW&GWFVFVET&fWFRvV榣#636GRvV榣36GRvV榣$'cFRvV榣3E'FVb6WTfW@ 00(:9>>719 6422<1>> 6422717797 6422<1>>7 642277 хѥLɁ褹ѕɹȕ}褹ѕɹ̍}յ}񘱽񌽹с4񌽹сɅ񘱽ѱİİİLɥİİ͕չсɥٕсչlչ褹ѕɹ̍}յ}񘱽ɥٕ񌽹с4񌽹сɅ񘱽ѱİİİLɥİİ͕PɅٕͅTɽtP(MG) N(l(MG) gm(MG'HN.,nj-%%%%Ƈ(MGgN.ćGG) N(l -͍L*$ɍ $ɍ /dL.ͮ (MG) gm(MG'HN.,nj-%%%%Ƈ(MGgN.ć̮  GDerived &Eigen::DenseBase >::lazyAssign(const DenseBase &) [Derived = Eigen::Array, OtherDerived = Eigen::Array]i ll ,,L, w؅ɥPbLN΍* (MGmllN(l(MG'HN.,nj-%%%%ƇGG L.M%%ɍ $ɍ /dmdL.ͮ (MG'HN.,nj-%%%%Ƈ̮  Wrow >= 0 && row < rows() && col >= 0 && col < cols()z+33*sʃ+)J;+s!*s+z+33 +)J;+s 1c{ ciaaaiaay+ {BIr#+K{s"*K+#)J;+s 1c{ ciaaaiaaa*+c Q\]IQYQ\P\OQYN\X[ HK H  HK H[^^\ڙ؛Q\P\ZQ\]I Q\]HHQYN\X[ HK H  HK H ZQ\]HHQY]\P[\OQYN][X[W\]Z[ [ ؛HQYN\X[ HK H  HK H ؛HQY]\Y[\OQYN][X[W[ ؛HQYN\X[ HK H  HK HOo\\^\\^\\^Ⱦ^f^^^^ʄ\(x%TvV榣3t6W&&SvV榣FW&Ʀ37&WFVFcFbF26GRvV榣$'cF26GRvV榣3t6WV&SvV榣FW&Ʀ37&FFcF26GRvV榣$'cF⣣3t6W&&26G„6b26G"6b26G"&b%&RvV榣FW&Ʀ37&WFVFcFbF„626GRvV榣$'cF"626GRvV榣3t6WV&SvV榣FW&Ʀ37&FFcF26GRvV榣$'cF@˃0&990)990&9790)979 }ɥٕ͕ ͕Ʌ񘱽ѱİİİ谅ͥс͕ ͕<ѡɥٕlɥٕɅ񘱽ѱİİİ<ѡɥٕ񌽹сɅ񘱽ѱİİİİİՕtPX(MGGmlgm(MG'HN.,nj-%%%%Ƈ%$NGGGml  N* Ą$ɍ /d N* dm(MG'HN.,nj-%%%%ƇDmlMn%Dmlmm$$ͭL*̭ N Eigen::MapBase, -1, 1, true>, 0>::MapBase(PointerType, Index, Index) [Derived = Eigen::Block, -1, 1, true>, Level = 0](J;+sb{[)J;+s 1c{ ciaaaiaaiaa1 c+b{ʃ+1aIr#+cIr#+cIr#+cIr#+K‚ʃ+)J;+s 1c{ ciaaaiaab{iab{[zcaIrs+ s+c1 c+D]O ؚO H]OJ H ؚ H]O O H]OJ H 6ttxttxttxX@ZbX@bX@`X@ZbX@b|X@ZbX@bX@|X@`|ttPX@X@R@@z@ttxttxX@ZbX@bX@`X@ZbX@b|X@ZbX@bX@|X@@z@`(x2GF625&RvV榣FW&Ʀ#WFVSvV榣FW&Ʀ37&5WcFRvV榣#6SvV榣$'cFb6W㣣#W26GBT&fWFb26GbT6beT6RvV榣FW&Ʀ37&5WcFBT&fWFRvV榣#6SvV榣$'cFb6WB%fW&7R&Ɩv@901009274729062:68627472906007830:27 9<30::779:242279::1-:127472906007830:242227 9<30:*0206*77643. Ցѕ}ͥͽɕ}}ѕɹPcLN΍* (MGmllN(l(MG) gm(MG)N. nj-%$%$Ƈ(MGgN.ćGG L.Mn %ɍ /dmdL.ͮ (MG) gm(MG)N. nj-%$%$Ƈ(MGgN.ć̮  'Scalar &Eigen::DenseCoeffsBase, 1>::operator()(Index, Index) [Derived = Eigen::Array, Level = 1]<\ K c )J;+sIs+s cё+#Jkc)J;+sIs+s cљ c j z1c{ a)J;+sb{[)J;+s 1c{ ciaiaaiaiaiaa+aaёsC{s!*K+#1a{s1s1I2s)J;+sIs+s cљ c j z1c{ a!*K+#)J;+sb{[)J;+s 1c{ ciaiaaiaiaiaa+a + car{ccKs;FQYOQYN\X[ HK HK  HK HK HK H \] U\ HY^ U\HHQYN\X[ HK HK  HK HK ؚHHK HH H[T[H\]YO6ttxttxttxX@ZbX@ZbX@`X@ZbX@Zb|X@ZbX@bX@|X@`|ttPX@X@R@@z@ttxttxX@ZbX@ZbX@`X@ZbX@Zb|X@ZbX@bX@|X@@z@`(x3ET&fWFbRvV榣CT6W&6WSvV榣#6SvV榣$'cFb6W⣣47v26GBT6W&6WDV>&fWFbET&fWFRvV榣#6SvV榣$'cFb6WDV>&fWFRvV榣C%66W36GRvV榣3t6WV&SvV榣FW&Ʀ37&VFVcF26GRvV榣3t6WV&SvV榣FW&Ʀ37&vcF26GRvV榣3t6W&&SvV榣FW&Ʀ37&cF26GRvV榣36GRvV榣$'cFRvV榣3E'FV26GRvV榣3t6WT&SvV榣FW&Ʀ37&56GFcFRvV榣$'cF@27!527 9<30:02!5,89<2$72-,89<227 9<30:!59!79$7290260. m4 ͕Ʌ񘱽ѱİİİİİ͕4 ͕@ѕQ$᱀$᥀lɥٕɅ񘱽ѱİİİİİ͕0ٕtP䇨L.ͮ Ĥ(MGmL(l(MG) (MG'HN.,nj-%$%$Ƈ(MGgN.GG-L//hn. emmL(lLL.ͮ$dL.ͮ (MG) (MG'HN.,nj-%$%$Ƈ(MGgN.ćLL.ͮ (MGg.m-L. (MG'͍L-MGgn,-L nj-·dm(MGg.m-L. (MG'͍L-MGgn,-L+.̍. nj-·dm(MG'HN.,J. Lgm(MG) gm(MG)N. nj-%$%$Ƈ(MGgN.ħ g/static Scalar Eigen::internal::redux_impl, Eigen::Map, 0, Eigen::Stride<0, 0> >, 0, 0>::run(const Derived &, const Func &) [Func = Eigen::internal::scalar_max_op, Derived = Eigen::Map, 0, Eigen::Stride<0, 0> >, Traversal = 0, Unrolling = 0](J;+si  +)J;+si {s)J;+si K1c{ ciaaaiaaa)J;+sљK#+aai  +CzKs+ʃ+cIr#+K"*K+#)J;+si {s)J;+si K1c{ ciaaaiaaa)J;+sљK#+aaa*+c]]X[HQYN][\YWZOQYN][X[W[[ HQYNSOQYNS\[ HK H  HK H  HQY\Y   \؛Q\] ؛QI ȖQHHQYN][X[W[[ Q\]HHQYNSOQYNS\[ HK H  HK H  HQY\Y  \]\H H[HL7ttxttxttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@`|ttPX@R@@z@ttxttxX@ZbX@bX@`X@ZbX@b|X@`X@ttx`X@`|@|X@@z@`(xrvV2'72VG2VVr6W&VFrvV6VGr6W&VFBFfBFfU6FV&26@ۃ154229046722267 7V6bVFVG5V6@:49/09229726240'84 M ݥ͕ ==0ͱHL ݥ͕ =褹ѕɹ̍}ɽՍ}񘱽ѱɅ񘱽ѱİİİ4񌽹сɅ񘱽ѱİİİLɥ谅ͥс͕ ͕Hɥٕl =褹ѕɹ̍}ɽՍ}񘱽ѱ0́Ʌ񘱽ѱİİİH́4񌽹сɅ񘱽ѱİİİLɥHɥٕ4񌽹сɅ񘱽ѱİİİLɥtP@n%DmEn%dm%Dmem%W../../../third_party/eigen3/Eigen/src/Core/MapBase.h(J;+si  +)J;+si {s)J;+s 1c{ ciaaaiaaa)J;+sљK#+aai  +CzKs+ʃ+cIr#+K"*K+#)J;+si {s)J;+s 1c{ ciaaaiaaa)J;+sљK#+aaa*+c5vecSize >= 0*c3K+Js {Js{caBcB1)J;+sљ*c3K+Js {)J;+sIs+s cљ c kz1c{ a)J;+s 1c{ ciaaaiaa)J;+s 1c{ ciaaaiaa K;sC{s!*s+ +B#*K+#1IJs{)J;+sIs+s cљ c kz1c{ aaB)J;+s 1c{ ciaaaiaaB)J;+s 1c{ ciaaaiaaB#*K+#)J;+s 1c{ ciaaaiaсˋˋ Z\]KYYKQY˜Л\K\ڙ G@LttʄxttxX@ZbX@bX@`X@ZbX@b|@|ttP@ʄx|@LR@@z@ttxX@ZbX@bX@`X@ZbX@b|X@@z@ttʜxttttx|X@ttxX@ZbX@bX@`X@ZbX@b|@|(xq v7FV&"v7bb26FV&26@:49/092297(4'11:24 Jؽ@= ͕Ʌ񘱽ѱİİİȕͥ镡$᱀$᥀lɥٕɅ񘱽ѱİİİtPv%En.n .*,(--m,MLnGn.n .*,%%em.n .*,(--m,Mlmgm.n .*,%%En.n .*(--m ) On.n .*,(--m,MLn) On.n .*,%%em.n .*(--m ) om.n .*,(--m,Mlm) om.n .*,%MLnΧMlmΧD$.- d.Ml Dl.M/ $ -N. M$LN.,E../../../third_party/eigen3/Eigen/src/Core/CwiseNullaryOp.h<)J;+sK+scc {)J;+sIs+s cљ c {s sz1c{ a)J;+s 1c{ ciaaaiaK+scc {CIr#+cIr#+c{sqcc {1Ircc {)J;+sIs+s cљ c {s sz1c{ ai Kãʃ+)J;+s 1c{ ciaaaiaO \[\[UZ[HOQ[X[\[\[UZ[HO\ O \[\[UZ[HOQ[X[\[\[UZ[HO\ \\^\\^\\^Ⱦ^f^^^^ʆ\(x715&bRvV榣CT6W6Vff6'6WSvV榣$'cF㣣W&F&ՅFVET&fWFRvV榣$'cFTfW@@|z@`@LL@@x@PR(x#aFRvV榣$V6F'6WSvV榣$'cF⣣#W6WFVET&fWFRvV榣$'cF@S 4 6422<104 642<1>>4204 642>>4 6422442 Сɑ}彔ͼɍ ɕHṠPxh..m dj,-L(MG'͍L-MGG+ (MG'͍L-MGgn,-L- nj-·(MGg.m-L. (MG'͍L-MGgn,-L+Ll nj-·dm(MG)N. J. L(MG) (MG'HN.,nj-%$%$Ƈ(MGgN.ćGGG emL.ͮ ĄdmĨm $d˨m (MG'͍L-MGgn,-L- nj-·L.ͮ (MGg.m-L. (MG'͍L-MGgn,-L+Ll nj-·dm(MG)N. J. L(MG) (MG'HN.,nj-%$%$Ƈ(MGgN.ćJ.̮Ln. M-   mat.rows()>0 && mat.cols()>0 && "you are using an empty matrix"(J;+si  +)J;+si )J;+s 1c{ ciaaaiaaa)J;+sљK#+aai  +CzKs+ʃ+cIr#+K"*K+#)J;+si )J;+s 1c{ ciaaaiaaa)J;+sљK#+aaa*+c[˜[Z[Z\[[KXYZ\[ ] ]WY\\Xۜ @t@ttPR(xk0UFD6'wFv2VFrFVFW&@2 947;06224:44704268202 L]ݡPɅѕ́сѥٕP`.lN*͍%Edn., $ - ẃͥltP`.lN*͍%EML N.. ፕ́P%,-mLέ셌m썬L .L.m WɅPt팭eNneel -mLέ팭l%,-mLέͭkN,Lel ׍逼}ͅ`]x9{{;c+{{c{#{[+C{+{CK+s+{9{{;c+y #K{{CK+s+{!k3{ˣ+k{c{s++s[˜[Z[Z\[[KXY؛]W[[\؛\܋ޗY]Isy3K+Y is not divisible by <_x9{{;c+{{c{#{[+C{+{CK+s+{9{{;c+y #K{{{s+s s c˛K{{+"{!K;K++s+sTY]Z[^]ڛH[XH] @@@t@@(xrvV2'72VG2VVr6W&VFrvV6VG2FWFƖ762&WE667efF26@+@zz@(xZYX[H\ \\^^^^\(x[ZYX[^^^^^^f^^^ʾ^\(xZYX[HH\KY]J  <= j ll ,,L䭎WȕձP%,e͍̍+--o.meMle.-ˮlM. WЅɝPv팭eNneel -mLέ팭l%,e͍̍+--o.meMlŭML.el GP ^8ѡܡɕ偐Pj팭eNneel -mLέ팭l%,,L.L͍̍El. +,el wMȅѕɽP068292:8422 ^HͅɁс̕с͕HͅՑ$ѡPMDl. ,,MGGl$̍ -̮ $DN N DM 'calling ResampleAudio::Resample()g ll ,,LWP୎k. ln-͍ $ k. lResample did not consume all of input_samples.2`K+ kc+y y+3c{ Qa!}}!~hXF-?b(b8b40 (cc( 0123456789abcdefABCDEFxX+-pPiInNajqrstuvwxyz{FCa5 aa!!(xo`c L4#@PVT7\7l7t7|7777(%p4 #PV 999$949D9T# 82pl84h`<IIdQ𐒬2 RSҤS2RG+JtJ(xH NIN ; ;8@2Ќd8@4P  JtJtJhx`PP@P@,62$4L$& 4Lа8~CCY pPpP0p0GPPhXF?a{Xvhvxvvvv{~(~H~h~~~G{~8~X~x~~~4,#@0PV?  A0@Y p 00@Y 40@4 0 T0@6  0@Y 0?  E1Q  0a1@Y pk1@[ s1@] p D1@Y P\1v |1x P P1@Y p1@ C2@ p$d Kؒd/ Ld3 M4,#@p'e5 '2 P,2 hXFn? [jn[er\Hevа /2 h`&Ff'}сɽа@ p5]3@ h`&Ff'u`(fțf@C%.0Lf4#@<,e5@ p= 3@Y >3!аc ?C4@ pD LQ4@Y >3a!0(h"4,#@Fe5@ 0fg6@ fـ`hiemc Xmgаq~6 M6@ pmd6@ m aW6F&@Ca|(OP`40 ױՍѕܥѡձhXF*?hj`_::codecvt_byname failed to construct for 40 @R0W8@PVta8@ဆ`Hp,p3` x+Qtrue<,m аu~p]8@Pm8[]\ݗX[X[Z[]\ݗX[X[YX[؛\ٛ Kl8@Y g8 F@Ch`D0 F@C1h` D0`40" JtJtJh` D0 ^UR"RBRSҤS2R@c !h @   @ @`  @C1hxpPPP0P@K2/<2422779:1:79 ^6VF2W&GWF@J(xCIo( %b4Kk+s k+1 Kc+#{{s1{Aۛ[Y\ݗX[X[YX[؛\ٛ /e5YNSt3__16locale5facetEDOup V`X_jHkkȮk@fbjʒ(xWL ]\ٗX\YK s@"A' G GP$)1<""A' G Gа}— PVc6@ pfk6@ (xa4E7s3FV6fG46&6GFWEWT@fbbdʊX>> }8@}8B}8BNSt3__17codecvtIwc10_mbstate_tEEDO|@v{a1`mc XmgPin+f썬l̎.hn,Ml.싮uI !x4,#@m6@ pmd6@ m 8M|}܌%|хѕ}> }8@}8B}8BW9M|}յչ%R> C~@v(xW []\]]Qk @pdg9M|}ذ|}R> K~@v(xWL []ٗX[X[YXQr{cc +K+* b |>R> O~(xWL []ٗX[X[Y]Qr{cc +K+* b |>R> W~(xWL]\ٗX[X[YXQk @9M|}Ȍ}幅%R> c~x(xi4E7C3FV6fG%V46&6GFWEWT@H$}ੁWL Y٘ݗX[X[YX W]]]Qk @NSt3__114codecvt_bynameIDic10_mbstate_tEEDO ^4E7SVW6F%V4VT@H$E~uNSt3__115numpunct_bynameIwEEDO 8M|}ܴչ}幅%1P8)7<:11&1" ^4E7V%6WV@$uI !DO0 8M|}ܴչ}幅%1P8)7<:11&""A'  G GR> ~(xc4E7sVW6F%Vt$STT@㜦fbb``XYh? >!`?!@H$<~WLMۛ[Y\ݗX[X[YXLQQsi{s+˃sKc)** bd}pppk @pl NSt3__115time_get_bynameIcNS_19istreambuf_iteratorIcNS_11char_traitsIcEEEEEERpKk+:+KsI+ k3J+ {KsC KK+*****MNSt3__19time_baseED,M(xb4E7#EVuVF55G&vV4VT@$I !(H<0pKk+:+{ ;+K+*UNSt3__110__time_getED,MX)v? DO`ൂWLL ]Z[YݗX[X[YҝWLN\YXXW]\ݛ\ҝWLZ\X\]QQQQQfbpʾbr̾bb(xb4E7#EVuVF55G&vVtWT@$I !(<0pKk+:+{ ;+K+* b |x?> ~8@~8BC8W NSt3__18time_putIcNS_19ostreambuf_iteratorIcNS_11char_traitsIcEEEEEEin+&. %I H @pC݇##(x4E7CVWGt46G'W&VgFW&F&t43&E'F7tWTTTTT@:$<d, ^4E7SV67vV6%V4VT@fbpƊ(xWLL[\YܗX\YK s@p"A' G GR> S(xWLLM[\YܗX[X[Y]Qri+ ;+K+* bd}pppk @G NSt3__17num_getIcNS_19istreambuf_iteratorIcNS_11char_traitsIcEEEEEE s8@}8Bw0 )8M|}ܸյ}%9M}ɕՙ}ѕɅѽ%9M}Č}Ʌ%P,)//:" ^4E7CVWG%6WV@$UI !> {8@}8B0 )8M|}ܸյ}%9M}ɕՙ}ѕɅѽ%9M}Č}Ʌ%P,)//:""VA'  F@:$d,(x4E7VuVF446G'W&VgFW&F&443&E'F74VTTTTT@˜fbbbƊX?> 8@}8B0 )8M|}䴽}%9M}ɕՙ}ѕɅѽ%9M}Č}Ʌ%P2)72""@& DO@vg NSt3__19money_putIcNS_19ostreambuf_iteratorIcNS_11char_traitsIcEEEEEEin+&&ͭ, .i%I  @C݇#0#WLNۛ[Y\]ҝWL\YXXW]\ݛ\ҝWLZ\X\]QQQQQ/sj{s+K+* b dn@:$d`D0" A A`D0" @C !`D0" AC ! `D0" @ ! &@ !@`4<0Ppᑁ@@@@ k@aApril4@pd[>@c>aA$38}ux}y}}4,Cp& C?8gp!X5I IP4Ǜ$ \Cp$(`, cNX΢@b8y@}0C' GQ8y ^6G'W@#:94229:07<2979 U`h)) ( 40 E`y/)P𠴷/2109 4E76%6WvcV'WV@H$,IϑPcWMؤم}ɝյR> $hXFI  8@bdоX)I`_4#@$$eXESt12out_of_rangeDO$/ 4E3#WFVU&'&@H$ lgY@wg_Z@0 ^8|}ᅉ|}ͥ}}}R> @$hX FI`; h,? #-P)k /L,.F&ˮ-k-ln.+5I h&AiHos+Ã++#'AK(xm@W&זFvrFR2R7VGfBWR2R2@K240434:492287773<29 Еɵѥܥѡ́ɕፕѥP24043V 洀;-P9:209:V?-P9:20/<42 ^3ECWf@$,QLህ}R> O%(x] X]\Yk @0E QGSk /.L+l,.M ,, $l,.M Sk /.L+l,.M d͍,. .L.-L .,,, __cxa_guard_acquire failed to release mutex<6 : #+c+ +1 Kc+#{ K+i+^Y]ٗ\[Y\YX[\[Y\H[]o¾Ⱦ@@@@@(xj5uV&G&&GbVFB6W&WVGW@S109079:422722:2< ^|}}Յɑ}снɽсѥ؅ɥP9:222877 4GG#FU7VG@krX8Jа Ô@kё舅}а ǔ@0kW}Ʌ}}ѡа ˔@k}Ʌ}ѡP)0061"@' / # r+b+s;C b |)aJP)009/23:4"@' o ܾttt@@@(x؛Y]ڛۗ]\Z[]XYX[/ܾtt@t@@@(xe0FFe&&VBVFuFbVF@tt@(x\YY]YX[ @S/:4094/8:979:177422 aVGW'W@*2022829279:1228797:2022902430< ^*Pɕ́ɕ偈ȕɥٕɽСɽ͕Ɂ}хͭPހ d.  N-m -l$M,,D d' Operation not permitted on an object without an associated state.ml,, ĬNLNn .LF@Ca)- z r1+*{+ b |)}Iа @0To[qNSt3__117__assoc_sub_stateEDO=%p VH-`w-Pin+FfˬNLNMk,M.5I #A#(xE@˃{3powf<W p!p!_"dǓWcǓWKăix`\`\/ !?4<p?GH<$<$,[<$L,[<$ĈL(,[p<$VĈL( B,[Ptp <$ pL `   `@  `  ` ``  @@ @ @  @`  @`      ``  ` ` `@ @  `    @@` ` @@ @@` `   ``` `@ `  `    `  @`  @``  ~&"4+C l = l # =  l 5 Z # - = &? 6! l! F! 5" V""ec##u,$$  9TD_tmdVAEz܆踑H7 $$pHFwrpК}XGL p,~x_<`D(h-}<$,=xHJ)쯩|~ t5 X>kvxE lfR|4b5([U$쉃1$h@@o] "(bE1nţi7j?$g>Pzaj?$e/"+z<\3&P̠"33LTT@PR@h`4p%h`40 Ok ˛+kˣ+)aSisystem bytes = %10lu <- n D,l$NF@B@r4w]@t0Tw]P $I $͍L,l $L.D.ͭ,DGIRT interface query failed for essential interface 40  DáAP&149 6֒&GV&2@ZZZ`\d(x[[K]K[Yۛ\^ K /r ckIkckqA\]ڛHɜYX[Y[Hɜ [[H Y\ɜa1sK{s  "DPPPPPP@@@@@@@@@@@@@@@@@@@ r_ "DPPPPPP@@@@@@@@@@@@@@@@@@@_ J%-?#'U6sv<)@+ @OVDS"8:;=>CDEFGQ{ OJb)xT@[tu9;?ρxi(Pק$(5 3x+`|@Uu7A L @N]!@#@$*,l-@n/4x9;o  &PT`dfjrz  %*349=>GHQT]r)xoP!0SӃfV) 0[ nN 0037=BKMY^`c 8Ad *6R\f|CdstS $6DE $ATit:BdCSI K QVX@HT`h<S  O"@bf)x @ *,@=O0hX 8Ц5 T`` k $5S_ ^T`t!4A\emu} 0CL[lS?HJ &p0QAbSH8)x :;O J`,ALQ\alq|)x +8H  1-17,S Fh~ %p N[.>D)x@@ ,4@<>(0#}Ȧy(@BDKPY`vz<@ /S68l ^`0p`4@d; Ocz%)x? 28VxQ &(:<=?MP]gp@S$%@NPadhj  Jk jnpx~(x@@%5@U`r7 R7 F\(x'h)i*+@n/p1?(8Ph 2R=  QU@`@]})Rk @C:(x(?#Pdta%1VY_a<``# 4<(5S  8ATa 0>@N#/8_stS`!`@bEF` @i )* .` rUV`  y`=W  (*02359<DGHKMVW\]_cfqW(`p@Q 2@Rh3x,`t@U 6@Vh7x@ @N@@] `!&@,n/0356<=t~ ).3>CFKOUV[rwz)xQ["$e&/36(@@"@#$,@-./0@1256?O<` N`  8@dS8 "68:@UXdhmo  JcT]r)x @R4<0@@@@UV@ -15v7<=?8Q Sp(Sgqt $0,5=HYxPb(3CS &(, O+7@KPSTWX[\_`cdghklop ^ 0O3)x Oe&?)hp <7,SFHch %0p pO3)x @ 3@68>h~I6@MPY\{< /S68l `0p`4Dd; Oc~%)x@ ? 8Vx0GpQ &(:<=?MP]@`@`gp@S$%@Oadj  J jnpx~(x@O(0`8159UXrx7Q@` F3(x o&)gXQ4*+NOORTVW^b Pp@a@d%O3JQ)x3?Xe .1=?BFJNW_y{| 7XS A#@(x;o $<5/8,~,6PD 9TJM7'NB6 1|7->nj.͐Ў+/ʝ:n& tZ w*Jd!Dp eP_8z©*1[ ,,A-/z-ߦ:u"&M0:4 w8˳Rڸ+" -2çl`@pRڶh0%eV3Me+Z $`hPR~((~/ $7:Ӱx˴J\ʲxTNhY::=C, lÌ䲜Rܞ/,ܝzB@ާl·8lϘf|xb eSP =. й"IJv{ -'234ֿT($Ġrʂ6` J+-;|,ff,^ O'q5P3 GϜ $rxpvt4!G+)-.ͶfҚ007@Vl8:vܢݶ!)r86t/Uиd #'P:E cK@Υ4T̳0F>|öʢϦ|֦֮D.ߊ`0BBl:=gW !('+#+E+,/Q21qVpRxR-#0008Ь tή>ΖшѾ`.ƶz1i;14y6&8^t@"RyYǔA/VаOΚ=D4 1"p\&Jz΄\4˶vл2@ܴ c%L6QVX6@죨B֧X¹n^>ƲʖԂل  !'/24᝺ZҀȥL&IFzhzrs#?%lʧt,_3334¼ a젦 -e#0>&.U-.&6V 'I!ͦ|"ZľRdt̄&z2 50;q7>ɀ2-"@بGrLʠpƤN d8RX\(R3 &,0'4<>fL6 n,ȧȶVNpĊ^ϬܠhC o$-0'>)$|^B>Nմ<I!#7I^2,آ>r4Ƨ"Dʻλ4n0.ŰNv̆B.іӾT^:d~ 0b*_cU7E!"$%I+, 0335w:Vs=v":=#P5x0Ȯ| rb۪t67 ޺̻ܻ2-'NVjXΒv`ҾBz#*L`7-ɤPBiT,uhe*;|P(D^&Z7s2n\ZtR -!7 &W70$ʾF.* "!2YVתttƫֱV:^ʸ^z:؊2VD--1xƄB֖v|ztg) =<W:-bH |? :ø =39|. ; U!?tj"#W&bvb\G"-3%ԨrX.Ш.bܟdڀ?! !_c ""-9#&ĨςL#%2~ۺ\6>JX83T,(» # 0ͣ:"Ͳ4l^ S %,o>@اЩb.в|xj "4ŶZD>jxܰxL"Nr, ?yI#$&1(9-1/<.जhDpάΈlp q#A&c1,1cwpŸj@ҷ^`˖6fr>f_U UuW7e #.uر4z,.K0"P. sJPh "!&1&-5ҺDP-ƒ2Ҋ/0- ,X^̈lĥN ~>, >|A'; %#.. w-[4J( |YSJDŽ0ZX-F,LĎ`&hВي.ڦϸޜZP7sM  [%#%+]7plТ fPTʹҲ.@ iw, A>ϡꤘħTܱzȸ:~vǚƻ*&8y  /'9.%4.æk A 蠎ƾXf? 4,ǰֶȢθ 4Tآ}*JĂjҀ,3Q4 c^ @hټݜq ] -=7ǝNX#0;Nদ <ŦDD.q(qϣ էҧ aղb~n8{`s ̮D˜۶ZTvz +&ǭ|Z,jnI`641 g3 ! $lõZ2zx w"%3/LT_,f(j־h"D:ޤtts 5%*09*ٚ]',K8Ҙ5'4 hӆXްvM i&ԮXXT#i;[꠆<H4:*Z>jtƠ^р' q!["#>ٰ6 'urHɚ$ְR.8x0s:~b`- jX$.˸B) !&KtTL>$$X *x*ZΆxѾѮ6㦾`    3(,[0a4+N<.r5;:1yx ֳ6 XԾ2nNJQ&%9 [8ؾ˸*Nw0V̴P8,NlL53A9,,]V< !P1 ޤR .pٞ$7 vfH4ܲ6-˼3ꢀƼLz +,0NJJ" t RZ/.%V)&\͚~G b:޹R,b|rΪdVg %UM!%19:BPz~h6ΠϤ$BTQ$e,AX0/Pdh#B X£6w=rN4ϜҩӪ8+T7Ϥ8LƟrVVو[~=V& TΊ6*X8ܢ )3"@DBN.:0J̾1%9ճݚ~.u=B 6d.3 ePβ= 2Iv`l+l8tl#KX g.\ (r٤q_TJ@ìδlnb1 #!!-+!0[,rpǂɸ͔Ұl(P^ C ,2"dژ tby0-7z"ꦠߚ [ OSʸP #-,A8$ -?mŠ˸ \Bܷ2x>Z8 #,107V0.ϖ۠fUG-8?=肰2^0ɝlnbt8dxi$o--./=>M.F Fޒ| G Y&6e.i.(!ۢ&= e&[ֈ@;30 8 .Ixl0|A8U888888 9Y9a9 ::1*:I6|9:y:}::::::9;5;;; ;];y;;};;%=>a>===E>=>Y>aDQ?}?9?a?m?E?i?A???i@@Q@@@@q>JJJJK5K]KyKKKcKKKKKL!L5LAL=LULiLLLLLLMMM]8%M5MYGyMMMadMM NYNNNNNNN OIe[~OǹOOOOPPQPPPPPPuP9Q=RQ9R}QQQQIRQRQRARRSRRRRS!SRR SRRRaSSS=TQTSSSSSTUU1UTqUUYU]UTTuUeVVR)V}VUUaVyVVU VVVViWW}WWqWWQWQXWYXWWmXW9YAY}XXXXYYXYYZZ)ZZQZ=ZZZZZ [Z[ [[![9[E[M[][[[\[\%\!\-\5\M\a\Y\Wq\\\\9]\]=]]_!^]]%^M^^^^^^ __Q_I_M_)`Y__-`e`u`a`a-aaIa`abb}bbbycbbbcMcEc]cecaccqcc}ccccccc d)dAdmdddddddIUeAe9eieaeeeeeeefufy=>ffggqg5fegigh}hEhqh%hihii%ihhiiijjj-k kjk]kkkYkkk1l-lYllAklll mmmEmUmimmmmmmmmm!mn nnn oo%oQoAooooyoooooopp!p5pMppppppqq9qMqAq=qmqq9qq1rErQrmfrrrrrsrsesssss1vs-tUt]tqu}tmtEtQttitetat1uIu9u-uuuuvv vvuvvvvAvvv%w5wMwIwYwmwwww-xixexExmxxxy yy9y]yQy}yyyyyyyzyz{ {!{A{={Y{{u{i{m{{{{{{{{{{{{ |%|u}q}-|E|Y||||}!}1}9}|E}Y}]}e}}}}} ~ ~})~!~E~~y~e~a~~~~~Yu͂eA9ŀmU=i݁})5 e5 imY]I-ႁMтՂa5Uك݃у Ń59)񄱄ф ݅ͅaeiхŅ}uMՅeYQi)E1)%݇! -5هч!%51QmyɈ͈9ym %IMYQ Q]E=Q!q1) m%1ŋ !ՋA5q=Y9ٍ=%}Վ%IُYѐMِu]=ّ9UM!iIa œ%ٓѓɓaqєՔݔٔ-!YU5ayuɕ ).n}ݖ 1Imeŗɝ) ͝՘٘јq=%yu} !9%au%Yi՛ݛ=Yy\ٜݜye%񝩝1-ݞў͞ទyu9qy5ɠ9͢eݡ}=Qum ѡբ=5)!a15QU٤I]壁iݥqᥭQѥeyuɦAM9o)u EqUѦy m)ɧŦyQ)IM1ɩ٨e!ᨉA5]] ͪyE}iYIYl~ݬq嬹c %AeQm} 5aUyɮŮͮݮ-M}ͯz"MQmyU 5imͱIAųM]uŲme}5!ڴeմʹI1Mie9UU)նy1IeUeɹ}ٹ5} 9u a%ݺMɺQU= }E1ɻżɼ1MݻmͿ a9E ;}UQaſm%-E=miѽua}EeI½ñ¹}-ue%qeĕU!ƙʼn1Yű=UơƱ]I%Q9ǁDZǝ5Amȡȵȱ-a IYʉʝ 9Iˉˁ˅=@])qYu̥̽̕9=a{^ͩ͡!9ϕϹyωнѕU}eqѥэѩ-yҝ)=QρӍӝӥӹ 195UMyԙԱ5)%miեՑ՝խյ)% Qiu֕֍ 5)Ie׍y-5%}؝؁؅؉ؑ!qaمى١٥٩ٝٱ !-9YMei 5YIyۅە۝۩۽!ܥܑyܕܙmiݡݭmݕ9-Eށy5]iqߍ߹1I9i1E-Q)iIEe]}iUM)u-}%U!5ae} ]%u~!]UaY! B)5=UMeiu =)aeyA5-1ua}5qi-I=ue-1=uEQM]5}1 Q=Aa}Y A aIql qU)-Y9=M%m=mq}auy)I}%- uYyei U>!%1IAMQY9mq159AEUQa}  !1Qyui-)UQqc.Jbfr*Jbj~nNjBFnfvjZ&n..ZNF"* V~N &6Ffb"j~ *6BZ f b v j ~  6 J ~ ~ J N z  r & f Z  B  ^ b j~Z :*  &N:b.6j *~f6jBZ^b~VRb" .V"F*RnrB&>BVvr*N.j6V9Q~N.2& Rz~nJ"F&6*j~2:^fN ~ .BZ[> 6 : JF Z V ! !J!f!z!!!"!z"!!"! "^"J""f""6"""""##R#b#f#v## $####$2$*$N$%z$$$$%%$$$2%v$%z%%%%%%%%%&"&*&N&b&&&&&&&&&&'j'r'v''''(Z(B(2(n(v((()n)J))")))))* ****F***j**+6+ +j++++++R,+++z+n+2,,j,+Z,B,^,,,^,,,,-2->-:-&-Z-n-j--~-----.2.:.J.N.Z.f.j.010"121:1B1V11111 2&22*262:2R21b2v2222222"3333j333344*44>464B4:}N463R4Z455556f6 766>7j7Z727n7.777~777"8&87v8z8B8~8 9888*99&929B9"9f99989V9999::::.:*:N:F:R:f:::::;::;";.;n;;;;;;*<~>>>>>j?????B?@??F@V@@6@z@Z@.@@@@@?>ABAFAJA:@&A@ZAbAzAAAAZA BABB*B&B>BBBBBCC"mC DJDfDDD*EZEbEEEEEE.F&F FFFFFFFFGG&G.GBGZG~GGnGGGGzHGRHHVHFHzI^II&II"IVJH.IBIrJZJNJnJjI>KJJK>LKMLfLLjLLLLLrMMMMZMNNNRNNZO^OOObOOvOBO"OOjPRPNPPPBPPPPPPQJQQnQQQzQQHQQQQjQQQRQ VV*VRVZVbVfVVVVVVVVV*Wڿ W6W2WVWRWZWrWWWWXXXX Y2Y>Y.YYrYzYvY~YYYY6ZbZVZ^ZZZZZZZZZZ:[.[&[6[6%r[6\V[[\\"\N\:\F\>\Z\f\\\\\\\]]"] ]&]r]]]]]JK]]]^]^]^.^>^B^r^^^^^^__"_._r__>}_~__>`2``````a>a.aaaaaaabbbbcccccd&dJdRdbddvdzddddddd e&eeBe.eFeJe2eVe^fbfffff~gngvgbgFgggggggh>hhgfhhhi iiihVi6ini^i~iiiiiiijjjk>kFkNkRkzk~kkkkkkkkkkklbljl~lllllllllllmm>m6m:mFmbmmNnnFnZn^n~nnnno*ono>oFoJooooRoopoooVpRp&pNp2pp"pJp*pppnpppppqpqpjqqqqqsss&t"tsttttvt~tuVtJtuttu"uvuzuuFuBufuu&vvvuujvvvvwwvvvw>w wfwNwwwwwwjxnxzxyyyz"z.z2zJzVzFzvzzzzzz]2{:{>{B{R{r{z{v{{{{{{{{{{{{|"|U||||*}J}R}}~}}}}}}}}}}V~r~~`eAqGeƑ&FF?f۩$?f&Ff&f_B$LLLCFD@dd _`_dĹ丄%%%%%%%; 0!!!!0" """""*")"'"("!!"")" ""#""a"R"j"k""=""5"+","+!0 o&m&j& ! ,"%] C@@@@@@@@@@@@@ O, "$&(*,.02468:<>@BDFHJLNPRTVXZ\^`bdfhjlnprtvxz|~HxP"PPQQBQQRBRBSSP2PP2QQrQ2R2SRSTRRRrSSQSRRS"TB#h`80 <@$ M %%(x j&0 o&0 o&a`h1xL`1A+(xWoazbb *%LC_CTYPEXZA.nMD$Yd Hd~,RKDۙ7NDC@ᠡ ZI'&|;A-߹: B````````````````(xL M NNY@qccKE0123456789ABCDEF09 "cm;ԢY%(B-K ,>09 "c#[@X|`rDɉ-K ,~D?=ؖb|y.0|wb,70 s #n nY*oaaGnԲTDLC#ܶG3e&A_`K8 qfhR1~L49#n mY*oaa&'"|=-K ?4>rЖb|ir"GtزT 8#-_hW,Ox0#[@X|`rD| -K ,>09 " >rȖbq111A, JPrUu3 G0p#焐Ar #Dr #"#Eҁ1bP01b '($1A, JPrUe3 G0p#焐Ar #Dr #"#Eҁ1bP01b '($1A, JPrUe3 G0p#焐Ar #Dr #"#Eҁ1bP01b '($1A, JPrUը3 G0p#ꄠAr #Dr #"#Eҁ1bP01b (($1A, JPrUը3 G0p#焐Ar #Dr #"#Ebҁ1bP01b '($1A, JPrU3 G0p#ꄠAr #Dr #"#Ebҁ1bP01b (($1A, JPrUe3 G0p#焐Ar #Dr #"#EBҁ1bP01b '($1A, JPrU3 G0p#焐Ar #Dr #"#E2ҁ1bP01b '($1A, JPrUը3 G0p#ꄰAr #Dr #"#EBҁ1bP01b )($1A, JPrUը3 G0p#ꄠAr #Dr #"#E"ҁ1bP01b (($1A, JPrUu3 G0p#ꄠAr #Dr #"#Eҁ1bP01b (($1A, JPrUu3 G0p#ꄠAr #Dr #"#Eҁ1bP01b (($1A, JPrUe3 G0p#焐Ar #Dr #"#Eρ1bP01b '($1A, JPrU3 G0p#焐Ar #Dr #"#Eρ1bP01b '($1A, JPrU3 G0p#焐Ar #Dr #"#Eρ1bP01b '($1A, JPrU3 G0p#焐Ar #Dr #"#Eρ1bP01b '($1A, JPrU3 G0p#ꄠAr #Dr #"#Eρ1bP01b (($1{V, *PB@5-@1:R49 0b08*<01ݰDQ1:`0:]NSHePP 72 D0b0-|9 Ld0PA Y0A%eXP#CBPf5.Qh@& 0Kp T`B1P1U TA#GD AjU^ hp T(@rT:DqEresurt sh@9 A JT:20K@P,3P Tp1#D }`āT0PSpC`04Q@ 4lT@GT 0K(@L  72@U@S(pC@ `0 EA,4P(@ @Ä~` &Q-X  `17 P0TAQ8,s1tP, *PW(A1БZ40*@a?P@"T$((AE *,P 9T3 0 Ee@D1:`0:AW,Rg@@A@ 7`   l I7`@ ԰gQL,Q@d#BPtP5DLfP T#D9vUY qAP{P|0bpN職DzjAlQAD~% jA(l@QA-17f J*P0KXD@`$220M0b`K]sP #Pu #лD0K TpC `0</{A*@o @p0K1a  *PB$H5+Pk*Ԣ%(+P3 0@ 3 0 ;Q@Q$1bp}NI)#ㄱ1$:,`0 A!P0b0b*-18:@`0@ A9PP@TpX,A1P0A T@ t A}0t 7i  7i U,Á#:A#x J@A@]@TpXd0K TLB(ppb0݀C0bP{ c A, FAq%:8:A 0b0Nˆ:A ,ADv@Tp Xt 0K TC@p b0ݰ C0bP r A*,D )A%:0Q;A 0K2XUPP @A,ewDE @T5P(a 7  0#A02`Z0`T \B 0ވ/ B-P-+T :2p[P@@A@B9]ق:!@T8K Tfp܈A;0 BL A8,|AB<#B0K18!#C0b0=N18!#C`0b05N1gw \b*AqjQDb)HC*Q:( AQjP߁4@ARUo^_mg3 `@$1 L 3 h$1L 3 k0$0LB 3 o0$0b@L05  0#DŽӌ @͈12QtlP1b0iL@$PB A AP , #@BV1@ L0P T@A@DD1,UK1p2D0b0* < "L 2A e0 d0`0A2!g0 ,b`[A@[p,á C,,CKA_,A2P! 1K@mˈ2D124N0b0!.A7@ Ԡ g+ADeT :b0*eQC8A0a@ =4PH@A*TBTl aĈA;X BL ,,$E  @ , Q0bpiOf0ph [vUrEIlQza@AA+DJ>|!bQ)a0K@@s0 `0ˠk@,.YňA><ň2l`Ĉ>A7,02A 72%TPDW`RG 1K#2Kul [,APc@A̮EpC`0`sO 72Dܮ@61~ \b)P RrtBjBq*QPH)^ IPLj4hL1 JlP;('o/f38y~1;w<3 e@$1pL 3 m0$0L 3 d0$|L3 t0$0`L 3 k0$0`Ĉ2u0bP=LD~@R4b0LD#p4b0iLH#T` 5(DQBT )D FT92$A0 2C0b0ĈA< Ĉ4#D@A>1b@4/11#NX3:0b@JC0paۈ3 0b08*DEE@ALYP0b0IM@mL%lL)lPň&@P,TTA#PDT@A O 72A0b0iN#@0K  @1ZK,  *p@A P %@! HPE*pB 3 00 #УBTYP&q#aFWISQK4SATD9l@1b@eK4= ,P0`00A T`DVqm uIÈĩ# DVX%PP Qv@@ALBl`Aa@eqUw3b0*A+P ,R%PB(pC `0p P0l@APlb 12$H@B@ @Ɉ(1 A, \B{QQQ_A1S,  hB(3 0 3 0 3 0 3 0 3 0 3 0@ 3 0` 3 0 3 0 3 0 3 0 3 0 ;AADl Qf0@AA+GDfA2#0D(AD-gAP0bPJOrQrDoPBAVPBO]%QAd@[@ ATYE ,% BUe@ DT@ YD 0bp#OI vJ,gpC `00Ī0K0 t8,Ao|AA#,* 0bp(O+ˆ: f0@,A0OXIAd}AA4A@0 0bp-O,ˆ< }P #@)B*1 02F@i~AAA 0bp2O.ˆ< ~ #D+B, 7  72$HP.@ET X.p uz,A2b0*( BpC@`0,BpC0,C%TPDWRG A4#02K, 7 + 72DPP;@ETX;p u,A4b0*(1pI,  T".s03 13 13 13 103 1P3 1b0!L,#Ƅ1s0bPK,J2b0dL(#@2b@jL$W1#D$#`Ƅ2b0fL,#D0TAyԧ,ANAaS  P0K@Db@`0܀d0P ,a`hE Pc## D1#Dpۈ1 ݈2t`dĈ2#pۈ1 ݈2tPq@AA@ A@^0b0aLxd# u#pÄ (a0b0LAA#D#D1A,  +P"x#q|0b0|I@0@LMlLQlĈ%, rTe9`+QXq1A,  T 3 1@3 1b@6L0b00L#D0b0K #0Q #D01b03L1bG, P؀Dž(V Pb(jB :PLUh (j@ jPB \s~3 0 #pVcTUP7b09K`qx0xx3ŤlQMml1b@oK4= ,P0`00A eQ`f܁&A #2K@ YavT}@A@ mĔl Dg0EnATlaIaX@\)”l1b0*A-P++12E0b0N#@0K` T! PY@\тq0bPKDp1EC,  JP1(BAWB<.@H'ecX3 0@ #Pf#DQPY@"TZ @jO9R(Q$1b@?N8͈9`AaHbq5}0E}#~Um@A C 7a B @ˆĩ 0K T %cPY@\Aq0bP$JD0b0~N1;C,  ~V0P3 1p3 13 13 1b0"L< A B ʈ1s0b@`L J Ɉ0@:590b@aL A/$Ȉ0 @ʈ1s0bPK45b0>L$#@΁=@H0bPK< 2b0`L$#ƄRI4b0KH1A,  B(#D@a,,D!l`D%lpD)RB1iQ,  Q E(AoPJ*Ja (+P3 03 0` 3 0P 3 0 q@ApC,#ĢK:ֈ-@#p4Q#pDEXA#PH#p, -1D:L`0@ A9e ,AQL72EPT@T8Kp TF! ,#ĵA9qs0#6K@72(L0b0{*A:|w[#F|mA 7s  ˆ 0#4417j P0b0*ψ11A,  #CʄPC1)C,  Tb0)J*V P0W3 0 #p4b@0MRr APQC N!l &OTX.ԀMĈA90PBLL72APO ,1 A,  U#Cp0b0Lʁ+1 A,  Q[Qe @A71#C0r1A,  Q[Qe @A71#C02b0)* 12 1A, *PK*p( ρAQ#EAH tp1[K,   (L T̀U`@3 00 3 0P 3 ְ5\T`P_ 72JP5P02 B0b01/tP0K0 TKaQ@ADkPPpCp`0@AA,A1P9 U Pw ,a& 0Ke`eP`kȰA@X0L@1P1Pq)qpu` # ȁ)L 7 P*T` @A@B%+@A@#C(#@`,1KR, ܄t*V3Q @ApC,C)@ApC@,"0P$TAP0X`00: Tp    FAHTaWIl,1b0d*4$ d pA0b0g*I 7g Tx@ApC@,%T`DkWRG1K ,ASr`@ApC0`09A  `PVp\Hl,4b0*,1BN, ܁B iFJP},@ApC,C%5@ApC@," 0P$TAP0\`00: Tp  A  F`AHTpaWIl,1b0e*4$ d pA0b0h*I 7h x@ApC@,%T`Dl0WRG1K  ,ASsP0b08I#C1s ܄A!\P肢,(bA MPX~(@,PX T(z@ P܀Up@A81P02APk  PV\Hl,0b0u*,D+A 7u F ˆ ui 7x P0`0ː A {Q`}&A$#C@2K(P02e uhpC 24LP(@4?DWP RG1KЌ  9?8 } 7 ,P0`0AA Q,`Ă&AD#C2K . hA)&T/P02XSP @ApC@,CE%T`DW0RG1KP ,U @ApC`0ˀ]A  8 ,VP @ApC`0ˠeA  : ,V @ApC@`0mA.  pPV\A>Hl,A7b0 +,^8 H,|A  A> ,IP02A%P0b0(bP)@ApC`0`cKP0`0PdPV\KHl,A ,TMP02 72%T`D8WRG1K#C 2K 12$T@AA1Ktg Y,A@B@f Anp) 7 qA mCTX[po!uIq0b0v+,T]P028K1=K, *PBEAB\3 13 1b@`L ш/! 011L#0A #pDE @Ĉ01($TppC,@#VA2KT%uM@ApC0,Ca# B D Ԡ,Q`,ARX:b0h*El D akA|@2bpNOC[ l,2b0qLd13J, \"`@A-%Q   A O֣10d00A 7L7 B0P`0P@@ `  A ,A1P AP% 0 l`0pu1K1KpT!AaQW0bpN ݖAV ,C#Cp1K1C,  &0 3 1@#Pp`  C2bPK0b0/L0b04L#@D1K TB#Pb1QP, \?s0@A@5PPC C Dh@ N20 D`00J0K0 TfBD 72PPJH-K J,|Ĭ@  ~~DA1U, \ %@ T 8E*T֌FP0#GUMJP 7TV,C U0KP0`0@ A TpDVq*A#C@ScbA#1KP TT EblD Qh@@AADiP02PoP0K0`0ː A lQA`m*A$#CSx.wA(eIAQ{@ABATPG+A,DA% sQ0@ApC0 `PP0˰0B,EBpCP,Z0P$TF 7 (@`4W4,`0 5A| 72DPPB+@HT X+p uIш0K0K0 T̈/l .j@0U/P] ,D-{8kC99B  h1b` \"0( @ApC,T`pC` @T`I@Q0PP.x<0@  @ @CL72EPVQA@`1ː 9@ `p2, R00pt#E`1 A,C B  7 k x R$@A,dL ,CEy@ P02\VVX,6Pp2hYPh6p$d0݀(A}P pC`0˰qpC,C ,C}@ Q02|B0P@Il&a1IM, \BDq 3 0 #D=,0bP!NHD@ApC,C@t1El@El0H@ApCp,  AI,0P` T#D0ԧdC@Š `lQe C{A 7Xc%;AeP3x`0ˀAma12 G0b0fN#0Kԗ@ApC@`0˰$A17h TP0b0y*ˈp1<O, \P@AL72APM  ,PpC,1#C0b03*DI P  F ˆب UM@A V 720GPpC,C$TD,ABH&^ 72,J@B@P0l@AP,#C@B3K,A 7c N Aˆ 81KM, \" )P1:R40E`3 0bP NPDApC@,C#STpC,0#M3Kvi@ApC`,Q#`B E ,D5 e|PA#BPQJAԴL c Lj;PP`@A,Dg0P0e`@a#D##D9 @ "`0iHPPA<##HDD<6I ^BL <,AP+a@A 1l0P1khP=pC`0mT=P0b04+n@S?pC`0o?P0b09+p0b0M1C, \ E(  @A@ lĈ7!D5  9A9  (A= PSA A B#C`B0K 1C, BT0E0b0L 72B0b`rpr A H5PC54#`΄PBTA :RMDQlDUl ň31E, \u#CDQ 72A0b`qPqA@A @/pCpA 2C0b0L0#CТ3K@1$H, \ dT   E0$`0PA1 P@A@ PPAPã    ,AAC@P0lPA`\a#DP 72F0b0=*Lj1>C, *BB  B@ph@2"1,QQA A5 6t00C;A#CۄH#C H[  `0@ApC `088AҁIApC `088,t7 YA8D88T--@ApC`0ːEBpC`00IA CLUlF;Dt@AHM$Q0PP<@t Y;@C 7 X8 7 @>,U>kCTXlDuAӁ?X!1K; ACIDu@@AցIA@'A4ApC`0ˠm:!,6b02NuPpP0l@A#BD1AJ, ܌BTe( #DP0(`0ː; T}pX,ŃŃQ#AHq@^3PT`P¤ 5;` 6QAԣI T  IP02ECİT0K` P! 7a@  7 h 7f5: 7Q#C@B0b MTA,Ar1;L, \ xE*H1P#BPĈtT@ P0H`0˰4AQ P0P`0pA P0\`0ˠ AP0h`0@(YT7  Gt0K`l*T 72FPd0b`Mr 7,CdkA$#C0b0=(,2Bb )#C,@@(|`1 A, W$#BĈt0T0@ P1F, \,#CDu@ApC,C  0b0M#C3KP A C  ,A1PA 0b07Mq1A, 1p@`1C, WBT#BĈtPTP@ P0,`0AqD,0P`1 C, \", F BQDl@,0P8`1A, \  `1 C, \  BlA,0P`1 C, \  BP0K TB`1E, \@"  D 7B,0 1K@l`,1 381 C, \  B1{1K TL1C, \3 0@ `0 4!IC1b@0M,p1C,  3 0P `0 4!I1b@1M ,p1 C,  B B J,p1 C,  B B J,p1 C,  TApC p1A 2B0b0,M1 C,  TApC p1A 2B0b0,M1D, \dApC0,   AK,0(1D, \dApC0,   AK,0(1 C,  TApC p1A 2B0b0*M1 C,  TApC p1A 2B0b0*M1!F, \ -( B-: TRlP#PATpCpàA 2DP#PaQ4p`00ZLIqWP0Dd0@1E, ?(  DTRHq@\R  ԠuP 72 DP#5AH1K@ d0 1+G, \"TeA T ! FTHq@ ]Q  j8@ApC@,P V  3b MT@C 7 2E0P #TI TPtREA-(vA 1D, \"e #DP0(`PE CTHT=RrP#A 72CP%@A,0p`1TQ, \BT@%@ ( @ THq   A T A EIq d vIr@$lpCЁ,ÁT)TU 5` 5ATAb$Zb; TO0PT z0P<  ,$pC`0˰0B_0eĈP024N@bPIA 7s AA rA@,4P 1"C, 8#BĈt@PCa@P0l@i<@ApC,0P4P0b MTPC Ĉ6Q 72A0b0M4 1A,  ;1A,  1 A, *P23 0b`iF1TP0l` 1 A, P23 0b`jGAP0l`1D, \D AJpC@,C0AB#C2 `0 pÀtC@H 1D, \T JpC@,C0$A@B#CD2 `0 pÀtC@H 11111111111111111111111111111111111111E, \#ApC ,C@#C  B 7HX,0P\A,1P`1D, \"#Aqu0bPmAQ1܀`0 %C1l, 1D, \#ցA 72CP#Ap 7 2C0bPp(CR,1K0 t R1(H, \ E(D j@ApC,C #A(24`0,#BDE @^4 `00) A TCLjuau 72G0bP,JVd1Kp v@؁a1NP, \TE(D @ApC,C`# 20`0: TMA  @\8pC,CAAIňu@M$ 72AuM@A@aP0lP0 @ p0X13I, \"P0# pC ,C0#CP   )# D@pĈ`,@7 7 2 D0b@p 7 2G0b0=)p0@`0p21b@L<A X >AuM@A@aP0lP0 @ p0X13I, \"P0# pC ,C0#CP   )# D@pĈ`,@7 7 2 D0b@p 7 2G0b0=)p0@`0p21b@L<A X >AuM@A@aP0lP0 @ p0X12I, \"P0#p pC ,C0#C@B   @)#D0pĈܤP,@6 7 2 D0b@p 7 2G0b0<)p0@`0p21b@L;A O =A@PP  A@ n0X10I, \"#ځ pC ,C0#CP   )# DppĈऀ,@9 7 2 D0b@p 7 2G0b0=)p0@`0p21b@L/lA X `AuM@AA @ p11I, \"#ځ pC ,C0#CP   )# DppĈऀ,@9 7 2 D0b@p 7 2G0b0=)p0@`0p21b@L/lA X `A@PP ,2P 1/I, \B#ځ pC ,C0#CP   )# DppĈऀ,@9 7 2 D0b@p 7 2G0b0=)p0@`0p21b@L/lA X `A@PP ,r1jQ,  2T`3 13 1#pC`,C0#  @+#DqĈ ,@c 72 D0b@q 72G0b0d)q0X`0p21b@LhA Y jAa@A@}@Uqو:aU ,4b@q 72$K0b0o)q02,J0b L;A#  q02@L0b@qP A,C#p A,#Ĉ3q1b0z)r0KP(TPPr0bpNA o# j0K13I, \24#ځ pC ,C0#C`  )#0DpĈ,@= 7 2 D0b@p 7 2G0b0>)q0@`0p21b@LblA AX dTA@PPYTvGPWA0K T1/I, \B#p pC ,C0#C@   @)#DpĈܤ,@; 7 2 D0b@p 7 2G0b0<)p0@`0p21b@L`A O b4A@PP 17I,  2T`3 1#ApC@,C0#  +#DPqĈp,@h 72 D0b@q 7 2G0b0c)q0P`0p21b@Lm,A Y oAY@A@uTi׈:QQv _5K1AI,  R`3 13 1#pC`,C0# A +#DqĈ,@k 72 D0b@q 72G0b0f)q0T`0p21b@LpA @Z r4A@PPaVVa:aaۈ:]aۈ:Ya۰@X6K1A,  E0b@LD1C, PbT3 0` 3 0 0bPL S D 72A#@r4J2bPL2K 1D, B 3 0p 3 0 #B9#D΄ 7 2 AP#D50@HpC , 1bpN04# DB01D, \B83 0b L.A2 `00A :Ep  @ 1A, PBT3 0` 3 AJ@P#F ǰAc#`q1!A,  R"( s3 0p3 03 AQ@ PF#D1@@ʰA!ň0Q:QRARK1Ï01A, P P03 0P tT`K,1bpNDrJq1E, \C  D0b@L(A%P0b@L0A-PS  Գ#C0B0K0 ,q1$E, \B3 0@ #0΄"$ 7 2D0bPL CDLB%050{AQ A ,%)@P@PT@A7PA1=S, P(( 3 P3A<0`0A PS  5@TP,1P0PS  Fs@T,1P`2$SaJA,2P20R4u4pCt"#E1 A, UC  7 k lAHb1-I, Bt P,D P0(`0,N  A @P NPP0`0@ @` Xt 72FFePP;Q>UTpC,rm@A #C`v1j  Q"+t 72 AP 72 BN    tP`0A" 7 tC0 7Q,CAqB4P0,`0` H 702$GP$ep@@ @@ ) Q1  U,ee  7j ,,AS\4Q^P^ ,%l 1KPR tCP,4PA"< AQ2B2LTl@1K 0PAO@@A AeP3A2E2lUCDA @A;12\VP)L\ŠPP*B@ApC,;(1K .SY V  @A,6P%hPFBpC@ `0qA  7 cuP02|^PB/1 /,|A5B#@ApC,CAP91 8,CA0P(``_CBP0(`P;tC@P; ;,CA0Pc^ 7 kPA=ECPgpC`0pfA,TPPi4P02A;AA !A.p{A@j d0PT`@A@'T;DԡID*PC K,CAPLJA)114P0<`0o,PpK{,1K x 0QpAMDM A 78 t0A <CACOKA@ApCP`0Pv1Kp5l1KpC1ܐ C, T T02A%P32uP02%P32lQ]P3 7,CA],+@A A5L70Kd!n ,(Vh1 h,*BPaP@A@FlpACA 7 0 A ABkl   7m ` LB 41Kp ۼA,* Tp  w  PPn1Ul ulAE@,pC`0ˠ Pxul   72+;,1K 5l,A+L1FS, \" E)  K RPS 6QA%9PSE=0K (@`A)@@; TpQPS  TA@T,1PP2 DM,2P JK5a@@$20Q@ 0l 7 L7 J0bPiC028O0,d0Apðh0Aa A,$P02HC0AD@{A14I, *PH-*Ґ8`EE0b0;*@&QtTppC,C #GU2KFi@ApCp,@#`B E ,D9eTB`REq@1bpNO[ l,RxQYl 1lc \"0( @ApC,C51:``J((1KVBA(%EX@ !P0h`0˰8`Uy+Q|ExDl&FP024Lf 1@@-C^8cT0C@5bT1E, \C  D0b@L(A%P0b@L0A-PS  Գ#C0B0K0 ,q1C, \  B0b@LAP0b@L p1=S, P(( 3 P2A<0`0A PS  5@TP,1P0PS  Fs@T,1P`2$SaJA,2P20R4u4pCt"#E1 A, UC  7 k lAHb1$E, \B3 0@ #pʄ"$ 7 2D0bPL CDLB%050{AQ A ,%)@P@PT@A7PA14I, *PH-*Ґ8`EE0b0;*@&QtTppC,C #GU2KFi@ApCp,@#`B E ,D9eTB`REq@1bpNO[ l,RxQYl 1lc \"0( @ApC,C51:``P0lpD ,A6bpNBzx0K1Y,  2T;3 13 13 1#ApC,C0#  @+#DлoĈ,@ 72 D0b@o0 72G0b0d)o0\`0p21b@L,A Y A# uZ6bpNPSe,6b@o 72$K0b0l)o0|`0˰(21b@LA [ ,4ApC0`01w@ 7`  ]  7g @9#DoĈܥ,CP0bpN;Qbje0K{pC`0MpC`00I31b@L# A _ %LdApC0`0ˀQwp 7p  i ) 7w @=#DpĈ,E.P0bpNā;aqzv0K p1AJ,  BT E( sp3 13 1b@n 72C0b0>)n0<`0021b@L,A @X  ĻApC0,Q#6K@nD   Y  72 G0b L8AKA#oPp0bpNLa#`D5}6l`B@U`P$tcD#D`lA1AI,   3 13 1#pC`,C0#B A @+#DpnĈ,@ 72 D0b@n 72G0b0d)n0T`0p21b@LA Y $A@PP_#@tV#Px\6bpNXe#PEy\6l``5xa0K Ta@`17I,  2T`3 1#pC@,C0#  *#`D@nĈ`,@ 72 D0b@n 7 2G0b0a)n0P`0p21b@LA Y A@PP#p5bpNPY ,2P 19I,  2T3 1#pC@,C0#  *#`D@nĈ`,@ 72 D0b@n 7 2G0b0a)n0L`0p21b@LA Y A@PP#l5bpNPӆY 7la0K T@`1 A,  a#C`PD#0!18J,  2T`3 1b@n 72C0b0:)n00`0021b@LA @O  $ApCAQ2 0b@n 7 2G0b0c)n0T`0p21b@LA Y A7bpN$҆UɈ:na,ARPq1C,  QT#Da,DP0`0 A- Aa1 A,  #C`PD#0110!81 C,  Q 5@ApC0,C #Da@1=J,  " @%(3 1 3 (@ApC,C# pC,@#  ,#D@nĈ`,A 72$E0b@n@ 72H0b0g)n0h`0ˀ21b@LA Z  A@PPb:abi#pfA `,ABX1;J, \B`3 1 @ApCp,C#ApCp,@#  @+#DnĈ0,A 72$E0b@n 72H0b0d)n0``0ˀ21b@L,A Y  ĻA@PP#@x[6bpN\e ,A2(`1;J,  B(s03 1$@ApC,C#ہpCp,@#Г  +#D nĈ@,A 72$E0b@n0 72H0b0e)n0``0ˀ21b@LlA Z  ԻA@PP`:a`e#Pe \10 19I, * 3 G03 1bpN #2,Ĉv@1`0 `pCЀ, #Ĉ2n1b0=)n0K0 A vк4<`0PpC`,a#@Ĉ3n1b0f)n0Kp @L#D2i5K1C, *PQ03 D003 F0b@3L$Hn`*ATr A B@G,Ȉ0a#@Dc ,p1C, *PQ03 D003 F0b@4L$Hn`*AP0 `0 :ar1b@3L0ň0a1A,  #D ,10 1010!81D, *PQ03 D003 FP 72B0b@3L0,0bpNŲ #Äq 1b06L:1JJ,  RB E( sp3 13 13 1b@n 72C0b0`)n0<`0021b@LlA X  ApC0,Q#Ƅ6KnL   Z  72 G0b L@9A#nPAx0bpNPAVb:Abۈ:qUbۈ:Qb۰ uauAea#Dtul,Ar1JI,  R 3 13 13 1#pC,C0#  +#DnĈ,@ 72 D0b@n 72G0b0f)n0X`0p21b@LA @Z tA@PPa:QUp0bpNT|[A#Dp#vp#Dfp#Vp@X 7`@ |0X1=I,  RŠ`3 13 1#ځpC`,C0# A @+#DnĈ,@ 72 D0b@n 72G0b0d)n0T`0p21b@LlA Y TA@PP_#@xV#P|^6bpNXe#PE}^6lPC`]1AI,  3 13 1#ApC`,C0# A +#DnĈ,@ 72 D0b@n 72G0b0c)n0T`0p21b@L,A Y DA@PP#0tV^#@x\6bpNXe#@Ey\6lP`] @ v0X1NM,  RW *Ja@\3 13 1#0ہpCp,C0# A @,#DnĈ,@ 72E0b06Lḧv 5<`0` @pC,r#Ĉ3n1b0k)n0KPd@A@:1be#5 PVpVeEaZF@ApC,C#PƄlˈ1ˈ101A,  #CDŽPxC1A,  #D0 T,1], \2s03 0b@m 72C0b0:)m04`0021b@L|A @O ~ ApC,CB#PpC,Cq#  A ,#D@nĈ`,ATi@AA @@ 72lH@VAu}@A@AB@ApCPh0`BPi@P0L7l ,7b@n ,#З ,#Ĉ3o1b0)o0KP ,#߁s028P0b0)o02@O0b LH۾A#oP} w02lQ@yD(u Q}0 @ApC h0ݐ BPp %  7  1K$pC `00U`pC0 `0PQ41b@=M( A x *TApCp `0˰Yx- 7 W z . 7 @N#PDpĈ ,AF3T @A@80. 7 /,:QP@A@9B8P0L7;l D: A#Cn02`T0b@oP ,Cu#CP A,e#`Ĉ4o1b0)o0KpPP@A @0K p#\0a1Z, \2E@a(+3 13 1b@m 72C0b0>)m0D`0021b@LwA @X y ApCP,B#pC,Cq#`B A -#0DnĈ,ATP0lPC`UKA|0bpNt[h1KypC,C#@ ,â#PĈ3n1b0w)n0K ,#j024O0b0|)n02A#nPep AP;QzP0bpNmsrAd#Ax02DS0b0)o02LR0b LIA˾A#Кo02`T0b@%o ,Cu#  A),e#0Ĉ4o1b0)o0KpPP` @AA (ǁ*B #PD P ),A6b0L,Av1Z,  2( sp3 13 1b@m 72C0b0=)m0<`0021b@LvlA X x ApC0,B#ہpC,Cq#P  -# DnĈ,ATi@AA  Qc:`bmEl,A6b@n   \  7e 9#DnĈإ,B 7i {pC@`0<PpC`0831b@LA _ )m0D`0p21b@LwA AX yA@PP #E1Koh   Z  72,J0b L:A#CB n02@L0b@n    \  7f 9#DnĈإ,CTp@A@D0A3o m+Ad#0o02DS0b0)n02LR0b L@?+A#C0Bo02`T0b@op ,Cu#CB ,e#Ĉ4o1b0)o0KpPP@A @0bPL)Ɓ(Bو3q (B1d \b0(B! `3 0 3 0 3 0 #`pC,C0#0 A -#DmĈ,@ 72 D0b@np 72G0b0k)n0t`0p21b@LA [ tA@PPf#ec@ۈ:qYc@۰ @p,zpC,C#pB A,â#Ĉ3n1b0z)n0K A, pb0@ApC`00NPr@A@r ,33Q#Dy0c0b@oP ,C4# ,$#Ĉ4o1b0)o0K0PP@A@(;A #Ё( x( 7{  m  7 I#0DPoĈp,E 7 ` b0ːqBpC `00ZP @A@ ,,64QA,#Dp c0b@;p@ A+,C#B  .,#Ĉ5p*1b0)p0K PP @A@C8A# n02@L0b@n ,C#@ ,#PĈ4o1b0)o0KPP@A@PqĈ;0 #A(B@w y0l`PLT  ),u TP :2HS0b 'MT0 @A0+,4A=(!D1A,  #CDŽPcC1<J, \B0 #CRiI#ApC0,C0#Cp  *#@DlĈ褰,@, 72$D0b@l 7 2G0b0?)l0H`0p21b@L1,A X 3DA@P0l@ 72$H0b LTpP0l@A#D,Ar11 11I, \ #pC ,C0#CPB A )# DplĈऐ,@* 7 2 D0b@l 7 2G0b0=)l0D`0p21b@L/A X 1$A@PP @ l0`11I, \Y#pC0,C0#CPB A )# DplĈऐ,@* 7 2 D0b@l 7 2G0b0=)l0D`0p21b@L/A X 1$A@PP P@E!j0`10I, \#pځpC ,C0#C@  @)#D`lĈܤ,@) 7 2 D0b@l 7 2G0b0<)l0@`0p21b@L.lA O 0A@PP @ r0`1A,  #CDŽPbC1>J, \"0 #CRh I#pC0,C0#Cp  *#@DlĈ,@/ 72$D0b@l 7 2G0b0?)l0H`0p21b@L4A X 6tA@PP   @.d@A$P0l@A00b@LA0K14I, \Bp0#C0DSmN#ApC0,C0#C  @+#DlĈ0,@4 7 2 D0b@l 7 2G0b0d)l0@`0p21b@L9,A Y ;ijA ,iv+` 11I, \B #ځpC ,C0#CP A )# DlĈ,@- 7 2 D0b@l 7 2G0b0=)l0D`0p21b@L2lA X 4TA@PP @ l0`17J, \2 3 0b@lP 72C0b07)l0(`0021b@L,A N . ApC,Q=Q@1Kl18   X 3 72 G0b L@/AA#0lP0U@UzAl ,Ar10I, \#pApC ,C0#C@  @)#DlĈܤ,@, 7 2 D0b@l 7 2G0b0<)l0@`0p21b@L1,A O 3DA@PP @ r0`17J, \2( 3 0b@lP 72C0b07)l0(`0021b@L,A N . ApC,Q=Q@1Kl18   X 3 72 G0b L@/AA#0lP0U@UzAl ,Ar1A,  #CDŽPaC1>J, \"0 #CRg I#pC0,C0#CpB  *#@DlĈ,@2 72$D0b@l 7 2G0b0?)l0H`0p21b@L7A X 9A@PP   @.d@A$P0l@A00b@LA0K15I, \Bp0V0b0L01b@lP 72C0b0;)l0$`0021b@L3A O 5 dApCЀ,B#`pC,Cq#C0B A -#DlĈг,A>P@\M@ $i2,r17J, BI3 0b@l@ 72C0b07)l0$`0021b@L/A N 1 $ApCЀ,Q)S,3Kl44 A  X 6 72 G0b L@/A#0lPA0U@zCl ,Ar17J, 2L3 0b@l` 72C0b08)l0(`0021b@L0A N 2 4ApC,Q+)S,3Km5< A  @X 7 72 G0b L/A˳A#@lPԦ0U@~Cl ,Ar13I, \9#ApC ,C0#CP A )# DвlĈ,@0 7 2 D0b@l 7 2G0b0=)l0D`0p21b@L5,A X 7A@PP  @ n0X1A,  #CDŽP`C1kS, \20 #CRf I#ځpC0,C0#Cp  *#@D lĈ@,@5 72 D0b@l 7 2G0b0?)l0H`0p21b@L:lA X <ԳA@PP ЈvPA6\`0ː,ppC,â#Ĉ3ml1b0n)m0K ,#`Aa0|`0<̥pC``0831b@Lq,A ] sJ, \20 #CReI#pC0,C0#Cp  *#@DlĈ褠,@; 72$D0b@l 7 2G0b0?)l0H`0p21b@L`A X b4A@PPY   @.d@A$P0l@A00b@LA0K13I, \Bp0#CDRfJ#ApC0,C0#C  @*#PDlĈ줰,@< 7 2 D0b@l 7 2G0b0`)l0@`0p21b@La,A X cDA v*\0K1/I, \"p0#ځpC ,C0#CP  )# D`lĈऀ,@9 7 2 D0b@l 7 2G0b0=)l0<`0p21b@L>lA X `A@PP 6K11I, \b#ځpC ,C0#CP  )# D`lĈऀ,@9 7 2 D0b@l 7 2G0b0=)l0@`0p21b@L>lA X `A@PP ,2P 13J, \T(E`#pC ,C0#B  *#pDlĈг,@> 72E0bpuNP(Ɉv3(`0` pC0,r#0Ĉ2mf1b0e)m0KL@A@qP0lpC7K13J, \T E`#pC ,C0#B  *#pDlĈг,@> 72E0bpuNP(Ɉv3(`0` pC0,r#0Ĉ2mf1b0e)m0KL@A@qP0lpC7K1A,  #CDŽP>C1>J, \"0 #CRd I#pC0,C0#CpB  *#@DlĈг,@> 72$D0b@l 7 2G0b0?)m0H`0p21b@LcA X edA@PP   @.d@A$P0l@A00b@LA0K1MK, \" (A(3 1 3 1b0LTk1b@m 72C0b0d)m0D`0021b@LhA Y j ApCP,BX1b@m 72G0b0l)m0d`0p21b@LpA [ r4AT5TmA@ 7 2$HP|Y@ pL7tHAjPk@A;!`g@ ā 3Q,r1;J, \( sp3 1b@l 72C0b0;)l08`0021b@L?A O a $ApC ,B#`pC,Cq#0B  -#DpmĈ,AjTa@AA lQa:XޖhɈ1110I, \b#ppC ,C0#C@  @)#DlĈܤ,@; 7 2 D0b@l 7 2G0b0<)l0@`0p21b@L`A O b4A@PP @ r0`11I, \#ApC ,C0#CP  )# DlĈर,@< 7 2 D0b@l 7 2G0b0=)l0@`0p21b@La,A X cDA@PP ,2P 10I, \#ppC ,C0#C@  @)#DlĈܤ,@; 7 2 D0b@l 7 2G0b0<)l0@`0p21b@L`A O b4A@PP @ r0`1/I, \#ځpC ,C0#C`  )#0DlĈ,@= 7 2 D0b@l 7 2G0b0>)m0@`0p21b@LblA AX dTA@PP Z6K1111 11 11 11 11 1XH, *PB *B\DHE+@Gjѐ S`3 PUP4laDeA+QcElE`Q;Q<xJ^bQi00b0`<e#DqgeQlA#0PAєj@ODQL72AP#@B0K  s02 D0b0- 9 2FPDuGr @ ABԠLh01Kp TN#PD MA lpQA(D1   Qb(@A)QL *R8T3 1 3 1@3 1` 3 0 3 0 3 0 3 0 3 Pc``@A0APA Ax*Tx0K #DPPdTpC,1gTP0KP`3@ 12(FP; TQ A `@ApC`0ːAA0K tB>#@B\\1ȁ7r P0KД4L #к$17| TP0K@(Q|0 #pn17 RP0b0*BL *,C/LAB/#B0K #DT@A@/1؂7 VT:PP@A,U:!@T8Kp TLeXRpC`0aA d0PX 72pZP<1 A9,ÖuIT@,7{\ ppQ8T:2x]P@A,W0K TD'x5TI :T`:2(ACJ, TaDJJ=l0ĈA(CL H,AP @AجCL% AI,C/APp@Aˆ:QLPP?@AL0 L,APc@A@NiPHY!@Tp8Ks Th0250Kz0PjPpCP,APZ1 O,CAPqD@ ๐WDLf Y,AP<@A,AO@@r_@(bXT:2́UAh, TsqDŅhJ\l0ĈA죰ELz ^,APo@AEL _,C/APu@AԮˆnT:2)BF,) T)DJT{l0ĈAԥGL },*BPa"@AGL A~,C/BP"@Aˆ>QPP3"@AL$ ,+BP7"@A@ȋPH!@T#8K  + T*02Ȃ+50K 0PPpCP,,BP1 ,C,BPeD@ Ă**ˆ"W@&HL: ,-BP,&@A,A- @@̂@ 'fԜ @]2T@&: 2܂-U#vBȝ,. T-gDJ l0ĈAs HLn ,.BP?*@AILs ,/BPeR*@AˆM) rbSCz4DALy A,/BPB*@ABv*Dʨ@v *B+5A AԬ PSpC@*`0ԭ P0b0,P< lDJԮ  1C,  QbE* QK+Q A+QOTpC,51@Aب[lDY A] 1C,  QbE* QK+Q A+QOTpC,51@Aب[lDY A] 䨀1 C,  ьb@E(R8TTP$0B50PQuA+QFEP&PB@=p@tC1;AH@ ?,÷5T:2t\P'@A,WI,7PU C"81K 7H2APP01:lP ,L fAL@ &B 1|W, *P")P Vt)JqjU50b@--U]l0EaPP  ESA  PPCQd@A,a dP;Q@Yi@0PP2 T` iQpPj1KPT:2XF@_La ,Á6Q`@,1KPP}@A@lAkPAhu6 0K T1P <%́7u T*P0KD@`(2܀b08A%BX2bPg-D У,PPBT-l,AT@ PABd g[ ADT 17 T/PP @A,U P` P#@I4P81@.D:lPD.D-T :2XUPAX1lX, *PB#hH V@A@720AP C@ l!,0P51`b0@p,q5xg+A eTl D eA|@1@`0˰$; TG!j0PB50 X`0ˠ$A!,HcWpC`0`5AAetT0pC@b0<9 2@QPQ~0K P(l D K A)l,4P8 7@2TS!O1"O1 1+Q*@  B,A5P$m1Kp 0Kp1% \ Ԡ%hBqсBE(PTb8+Ti АZE (l@jUrիb%+\ЌFV5`&q )Z3 n0$0LB 3 r0$0@LB 3 v0$0L 3 w0$Ps)Q0 0b@KPP 0bpNABVPPP0bpNBGpC Bn #`ƗA)24b@.A:f #`B ,Pp @ApC@ f0@ /nP0bPN|#0@l0b@.0b0K,Q @pC,Ca#྄n 0bPNk@ЈԺ #D/  k@,1PA@P7Tp( o`)ÈA;9C#z@e0TȈ0#࿄  7` Jx+ÈA;#@{0b@.0b0&L,R @ApCf0,0؂=A<0b@.A<g#B#)0 7 a N ;ÈAN$$L# BpK#P ,Q\T'I `1 A,  ׁU@A@R5RB1E, P %3 0b0-*#0b@)K   D  @FT` W Il,0b05*,21/J, MQQJ QJPB,94`0ːp,0#Ã#pJ;QD$%KD0 `0ːA T@Dj0PHDr@1 h`0p CK3KpA 7 2 EPrA$1 A, #C`B0b0%)#E0BC#)$13O, \&A  @ApCp,#F]]ud9 P0`0 E  #CB0KP 7$24F0ܠ8b0p ,8W#CB0``0ˠ$A T ilP,2P  A %:˰0,+D8+`RV1[, \fhDZZ-PjjՀ @A@A PPC@4Ld0P: Tˀ*0P, B `E lpEl,PUT0h`0@AqT>AhDU0db0`@YU 72 IPv A  7h J T" Gi #C B0K TF @@$RB; T40P4 ,C;ADoA4UT!028KPH1RN  l(@q@΁X@ ~QA(LAD0P\@0P U@ PC)El Q(D,DlD! KBl QK+pB,T@@0Ğ \ 72`S0O@R 7 T 7 2`SPE- A p PV \/Hl,5b0*0@ Hb@ 7 Z A ,v1WQ, t(D(@A@9 PPC49Dd0P: Tǀ0P  ,P5H0T`0@Aa6A@TDYVpC,q#C`dMETpC,5p9d0ppC0`0ˠ$@@ pT@7b0n*@`$ TD H=j %0K(@ (GN/(G3 24L,AS@ m ,ȁ@at @x@kPAD{ 50bpNGu;J 7|  A ,t1BR, ܄t(B!P4;$f0 ~Ar]94`08`PB pC0,Q#BDDIY PPT0pC`,Cd 72 GP A ,2ܰt`0%pc,#BDVc]  pC `00A ahZE42 7$h%:<-DDj^l[A1A, Pԣ()NjPBq0"R#TS8QRSSLQTPQU4Q,Q$s1CA,  RBE)Lq T"UP(VVE (d@1 PI$9 H?pskΚlDMl`QkA3fATlLl`QpPADd%cDSs0DXTl0McubPSx 5bPSz 5bAlq1A,  RZE)L8](HpYsQ,QSlpQ` P` 0bp=Op@ C?lQ` >D-@-@;CH*?AHl 1b0)+D@D@PpC``04 P0l@AP8 C%P`aEB P9”MP04bPK+ @ *A9P:@A@MUP0TNVD@E ȈBXA664bKL8@C@mn@pC`0=V60PB@#B`@ ^tAEKB_P0da2b0)T!%UP) Bi,C$uCT0XLeT`XT ,4P( UQ_>AmT P#0„@-1`2TT0b`6)l@k  a` n,v%P0b0)pC`0Y0ਅ2h[0b Ld=q n pC`0qA VA@@p 7 yA 2x_0b La>=,lOh1ܠ26K00PЎ\e`AaT@ЈY 72# }aAIgP{H A B02 9 @B U+dAa]vqprT<Dq;A>PAEU#E@%KA 1I, !\t %(D VBРE4(N *PrE(2Aq KP _(8A OPE.(vA ^PLb-` q86h/2#3 n0$1Cz0lAT@P uX@X(T@0U t U` 2b0(T P`aEP0TC@ ,PP(@ق(1QDVI 2b0")T P`aEP0TC uP`aEdA܁At0dA5dq%0 3b@KDՃ/0e=>PP @A@ ATY>+L>0#pD"B8% 72AP?@ETXHp%!uIB;)P0TIVD@6@~ MAD= 72CPM@ETPXNp;!uID??%4P0TOVD@@AZAI( 72 G#XUZ`TCTX[pq!C*?1K`]lPQxPy@A=12E_lz3E_lQ1KUPV\hHY,,RiDFN#C`DP@A@N  F1Z, \T (F! ^P\b\hE-(lA iPР+R NP3 0 3 0 3 Pb`1  X׈-de\@A %QdgVnC 2b0(Tr@AA@ AT0Yx`@A@ PP}@A*Q4C`@GA@,Ac`ad ЈԣXЦA ),#@**P0DTA- B@@pP+H r Qr A,D́*5QA,pC@ `0p b0P9b0*{yD %H  b PA< Tܱ(0P8ǡ 7 J0C0J U 72,HPH8`^A0k b`@d A:,C  qPV\;H%l1K ,A6` P++DPP85TwpP`5P  l9Q$@A@CDD;.#:@ +1K , 7'a ,;D;lQPAKĈA/.#`K %QKdDJ5C M,F  VT"PAV@\ANH9l,5b0?+8؈0=A@>PBDXPPC;1K e1K1EA, PB(@ NP"%A ZPԂ$3 00 3 0 U1U@A %Q#`lLAPBAFt4P@C@ AT9bP"KUmPn@A@5Qdd}S`0#вD"xee AL4D{#0A@#D0T(lQEA1UQ, \"(rAA с4$@ApC,-@A@I PPU9@A@]PP B (@PDNB ,N TP@z0 d0`ep`0ˀ]`E,6b@&L?h Q6,!f -D~AD0@90˰AD;`&kss@ T@  T0ۈD U 1TAS6Q0bpOD[iuFt0P Wux%@!R?#AA) m,CEYDm@n4p,|3omǁj@pPBG@ E0bPO#1D#pB @?y1KGT^l1bPL# D{Qz0bp4Xm{G_)~,T_P020PT REԇTiP"쇔@}@1!%PAlo4ĈaAa"##Ey􆉌z&2"1bpLAa 7% bA  e@A #`Q`(&AаFpC#`0{JpC 2%T@CTXp2"un,A  ?A ,AP#@ApC@,CAPB@FT&Xpe"uIm0b0j,,n@0 2e(HpC@2% T`CT&XApp"uIq0b0u,0K $" ,ApE"t 72A% T`Dw W'RG1K`#2K0"} 72% TE W@*RG1K#7Ke# 7 rAH ,CAP@| V*\Hl,A @- 0K# ,A1|R, bhB ZPpE-(l3 0 3 00 3 0P 3 0P 3 0p 3 0 [lQpQtQT\Pbp#2E0bPJ0B0b`J d@A%Q&A,QjlAUE B Ih@P(@@ @b 7 2B0@2C0Du1K`6K`Ta0(`0ˀ9$0P>q qm@IJA#`A(pr0bpNB,A 7 P0 `0,A Q)`Ä4D *,C   PAV \,Hl1K ,At1D, +PLRn-(fs03 PK 72B0b`=K,ʈA8Ak @,PO @AI P040,0P!X1RM, \WMhA SL-(jAA:@ 3 0 3 0  @R4bPaN@#1K 1 A @6bPeN D#жD2K,E!lE%lEL@A@ AT 92El0p!f0ˀA6nDIgA #LԈA,P00a@XpC`0$I  PAV@\H,2b0z*(@ X@`1C, + Ԡ*3 0p`0,!kP#0b09K ˆA+(1_, )Ja T7B R5+n@ш&3 0 3 0L7$hLB e0`0-qlpY#A5b0wK ˆ+q{AaD!lp1bP)K$0 `0P ATPq@@A@*1 ,EtP0TVD@PP C Q((()T)))Z0PXE@PPVD@Lp,á +,CK3K Qˈ-,BP,H @Rp, -,T m,S.bA@# u@E 72DFP 0bP|K#DP8TP @ABT(l A@.BT)l D- Q` @A@B9B 0bpdO*C:ˆ* P 0b`JBC@; C T=L 0P(1PN 4#`1K 1  F6bPbN8#1K =@A@ ATeDc F  !Ĉ,A!pR,ÑTp,CŨT0DnWRQ;1K VA2PhdpRBA7t` A|$yDz , ,#|y{9P ?,2ܠ`0Dp f0I< T DO U@P 50e 72@R0b`*(g0g1]Q, P5E*T:PP BHT50@A@ =@A@X@XuP0T u@A@Db F  !Ĉ,qA!pR,PTp,CAŤT0DmWRQ;1KP V@ 1Phd0QBA7s` A|$i C  7u` (0PC(CETÔq04h0ˠ0 lA1bIBA@@f0 `05< Tc0 7m@28OC,S*pC`,1[, \5(JaS5+R P P03 00 tf  Fh@PA[QB 72CN\pC,uT@ApC`,CA%TDbWpRG1KP Mˆ*q]P00`0`A@c@@A@ P0Tzp@A@@@@, Tצ#Z0!@P3!@PbRn@9S NpC h0˰qAIB8DBOBXP0` 2x]0b`(kk#9CI#0D`(#`D9@iPAJ5PPC@-<RB \páfPB `#P¹4IP A]A L 7 ,CA0b`(l(l9԰RB ?E?lQAh VbYa%4bP2LIIP ^,A0b`+)k:k%F7A epf0`i34q1`2A 0PQA}kpDqGGekQq&&ITzl'Q J,C)B0b`)r"(r g0'f0` Pl"3ܐrb A ufr /v"0N %"Sً  At`A@J(B0 2*  A rQ`~D0b0,(@*0 2+% P0b0*@/0 2B,% P0b0*p1Y, \ %hBAj+PTE.(J1*Ptt,Q 3 0` 3 PTAAt0 2AN@1; Tˀ; TBP0,h0@  A,CeP02B0PX4 72FP UU2Pu@@A@A ,gT@@AjDz G` X 7  7|`  @(& TIA 5oD)P 7 A 28M0b`+(g0 `i5Q-GAlDe7l1|hPC  u`d( D /, -,$#F9P(_@Tk RC AT*l1 T,CE#FPY `™rD nD8L312XW0b`m(j i#P,ˆ.P - 0K o``1Z,   %(H1 ׁ!D(Ja *@ 3 0 3 0` T Ad BDf $ Yu\0b07(  FAVEQ1 7$d%:p -+QOc 72$K02(L@V@Mgp#넰k0#o`nYo` 7 2\MPtP@,6OӼEA,pCp`0`=A@AX@)VC@#pB`@Al1b@(K8l0bP'K(5 A(,C%P0b0}(ш,a oP)P00`0 Up f00YUP0DP ,p f0@Y< TS uPC-H)0DP L!  @ 20d/pC`0p9BpC `0ːaA  . ,Av1 A, VE @A@A1QD1&C, \(HQ P83 0 # OTPQPT 7 2B0b`fK 04ӈA8j  8@` PdYISL3bPJ@t18K, *P8u@%C#@B0(`@,C` AB2C0b0x( # $ I2D0b0|(( $ I  pC@tt mLel0,2b0(4  G54`,,2PB@Ј + È@ HSf001|Y, \thDJ @A9%Q D KQ AE  72B0K0@` ǠN!eq;Q5\0l`0@ ,C$)AN,NP4: TF ,r$qKAS,2P!Db ,fg %@[pC ,%T@DlWRG;A` g `00A ,%TQ`v&A8#CBAl1K T3x  P )%A7x@ 9L7 7~   A+!CT(܈*l ĮEB< T\0P\ +,e qKAG,5PD%XD ,,V1JH, P tpYP 43 0 c B@C 3bP9N ,#B AL҈4#@1K EDW72 D0b`kK0HֈA9dP 7bPeN:pD_ E0b0rKPD@ Pfp2EPipWdj @X@BT9bp}N0PcPH5P0`0pA hQ`s܁A1yZ, tM(D* @A9%Q D KQ AE  72 BN!X: T` L ppC,  D,ANF$: T  P -,QD@ 7b HBRU{1Kp,CT0DiWR^ Ad d`00A ,%TQ`s܁&A8#CЧ,Al|1K T3v  P )~%A7u@ Q9L7 7|   *!CT(܈)l ĢEA< T\j0P\ *,e CK3Kp PD*pC `0ˀUA+H1,,Av19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4C[3Kp5RHtT DfQH=QAdvL 7k   ,Ar19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4C[3Kp5RHtT DfQH=QAdvL 7k   ,Ar1], *"PI? rC UCe R R!E Q#0P`0p0 72\0b0(@ A  ) pC,#5t`0݀e\#5L7p]0b@(و 7d - aB1b0(l ,Á#Bt@1A 1A@1A1!€ .#12(I0b0( ,#2L7A#B 9 pC`0@u; 4b0(8 ,#B@@! ; pC `0ḷ,6˰=ԈP ),# 7 S < w02LR0b0(pC `00qܣ 7 T? T(L5b0(pC `0`] d0Kp TP ֈp ,,#`,,A6PA%`#0 7 J @ a0v H#@ 1*0 ChBS ERBRH!T @ @ lh@ AlhҮ0s1rR,  Qנ(@шP03 05@AM%QUI@A]%Q F BMM5bPcN4)D#pB@AX%D[ E0b0rKTA@A  AET@X&A0PB4QpC,Ce@QpC2FPc7b0}*tT`h@`pP TE%{ %PkRDuQAd=AT RI#D[ A  (,C#3K 7(,< T*DP*H P0DP+H P0P*P 720O0028M@E4@Tp v,4b@N1,#`2K %DXvD]72 DP#@p2,CqpC,aT0DfWRO1KpP@pPBA7n  72$1=,Cujj= T= TR= T蠼SAH PnA@} %0bP4KA$PYphd U@VR @ĖZpDpQB;A\y%Q,pC0 b04= T = T{R,@ E@P/H  ABX@l P 9HP0T[R:H1T@)ĖZpDpQBQ<BB@ ATp9Pd04@ T`@P>A.P;-DB?CD@ AT 9Pd0DD,OO@OB*PJH RF#0?CA@@3PBD72@Q@80K1[, PWM@ (3 0   B@ETa W0Il,@V@]P0 `0@ A TPDV`q&A%P@dPBpYf@A@[T`9d0P$u|QpC`0pA-j#C`B@q@d0Kp TaB1P(8Ё@Mv EPAD{ 0bpNKu1ܰ2$H0b0*0 }$ %QdT %QA)pC`,$QA*p `0,A1#CPB@@y0K T)h@,`T2 RBEՂ@FTp YOT<,E.Bk Hfpâ `048 B (@eCB0d  B@  7  R,Q,T9BԈ-0K@q@B04  B@  7  ,Q,UD;Bو.0K0D`0ˠ=1` PW ?(D?@P0Y#U@RIQ d T]@A@ ATp,C$E4Q K` CIA 72$DP Q,ANB,; Tq A,a4 ,QC@j  HPAHY,,A2 t~ @ h 7p` T@Rk@AT`Y@y  7c@E2,PAHAx%P(,#O,"Op `04 CK3K` P)pd0J((1KTfBA(%EX@ !P0l`0˰8pU}+QAa[ATDl*GPP024Lh 5@`-C_8eT@C@ (`16O, ܁&(BJ  b%:0fPB B{(ze 5)@ApC@,3d 72DP ,A1pC,Á#B D] pC,%dPpA@@$~ 7` IA 2,L0b =KΚ,S17L, \T%hHJ @ApCp,C #BPDp\  pC,1%8P,A@@ f 7 7$U,Ca#BDnFDel=#X$P0 `0p(p,$0P$|RD !RF SpCР,C#pAm1WR, \8E@83 0b03*(@N AԐ@AB@QA  H: tQQ 72D@DpC, 1K 7 2GP 72ARJ$l,A2PXeX ؠlp ,A&Deat 7g AH,3P",Pm@A@g;1K T B2P%;Al0pÐa0pr%;9ApCP`0=A o  ,4b0|*,At1+H, \"Ԡ{QQHQHPB 90`0pp,0#`#PJQ#%K$0 `0p: T J0P42 72E@BA0K` Tr@ED 7 2Dp1;J,  TE@ (@A5%Q,0,b0 eIPPD98d00A N7L7$B0\`0P@@ j T݈A0P yRBEp`0pAAeAyQduATRO#G0DpjPA,  ,C#C2K1[Q,  @ *@$,@A@= PPC89Hd0P: Tɀ 0P$  L l,PEL0\`0@Ai:A@D[VpC,q#CƃdQETpC,5p9d0ppCP`0ˠ$@@ pT`7b0p*@`$ TDB H=l %0K@ (IN8(I3 24LL n4E028KPKH-TvRMQdA;B;Hl(1p2@O0b0* 12L, \Bt(%B@A@1P@P 72A0b`egPA7 7,C14(h0@,#E$ 7 2G@LpC,r#Fid5 72$K@\,N$R&545\0h0ˠ,15O, \&@  @A@ P0$f0 ~Aҧ]YP 728C@SpC ,CA%D@A I  7(2H0b 0Kd-UPĠ 72(IPPZ1K TDpCpCaPB d@,d 1K,e1<K, *PY?@-T#0B00`@,C` AB2C0b0w( $ $ I2D0b0{(( % I @ pC`tQPml0,2b0(4 A GE5`,#G@|(%jp T#CB0b0( %2b0(Eg0P(1], *BPY?PrTPUTe R R!E Q#Ї0P`0p0 72\0b0(@ A  ( pC,#P5t`0݀e̢\#5L7p]0b@(و 7d . aP1b0(l ,Á#DBt@Gp1Q 1Q@1Q1!€ .#12(I0b0( ,#Ћ2L7A#DB @8 pC`0@u; 4b0(8 ,#D B@@E!,b @: pC `0lأ,6˰=ԈP ),# 7 S < w02LR0b0(pC `00qУ 7 TAH T@4b0(pC `0`] d0Kp T Ոp ,,#D,,A6PQ-\# 7 AK @K a4v HA @K1*4  9dАS ERBRH!T @ @Ilh@IAlhҮ@t1{K,  Tp ` A$ 7T2EN  D@D ZPP$u@P;A0TBK&OpRP )DH<T RV DH,<TR\ )DHLqk A, (RAHA UPRlA ܁@P{ RAHA U RRlA @P LR}HA"pC)2JP @AB@+d   f0ː(< .,C0PER!ZPP-HP0dOEC e/@PL) 9,rw؅QU Ce,r1BR, ܄t(B!P4;$f0 ~A]94`08`PB pC0,Q#BDDIY PPT0pC`,Cd 72 GP A ,2ܰt`0%pc,#BDVc]  pC `00A ahZE42 7$h%:<-DDj^l[A19G, \ (UH!*Pr8L,3 0  @M4bP=N 64#P1K eE]lPEa@%9Ie P04(,1#«AHJC# AMۈ̸8#D3K@ A@P0b@I912E@B@b @1+H, \b M(A83 ǰ$% ,@AA'@ňv2`0 pC, #ÈA*Q%0T`0p1  F0AET@aWI,,1b0b*(-@`1)e  PkBA+PTUQT&q3 0 3 0 3 0 3 0 3 0 L 3 d0$0`L 3 h0$0C%QAd C B@h#UP@wPBA E0bPN)#A.p2 D0b`Kx!y0b0K|d0b@JxP`@ApC`0ˠB@t),Qe` @A@) C@ F 7 lq,R+A(#0,BP @ApC0,%TPDWP RG1K TB)uPDP8T @A@8PPC724QPC7  T7b0*<A3P)4FSI!+q#0<K= PKpC,5 7 WL) 72\V@7PIVDLC5RMVD@#`XX\$P0Te@Ј(QCPTt QAYp f0ːaMjA7 fa 14b0) [,#9ѪO@ኦODZ o!@Ј 71 A!t  \ Tfp X r U@aC%Z0 H0P,xCuC0U 72|`Y 7 dCpC0,APh@ETXhp!u, ,  /` /c`1^M, tn@ P 3 0 4% C 4bP?N8#p1K 7\2 D0b`iK<ՈA9qj @,S UC52b0(4xh0 AAD%Qp,Cau1b@?KA0K 7 2IPIH 7$2$HP @CTXpp Q AA@ AT0Y 7`w` TfPAQA,N>(Ţ%OdK |VBETCER)H-l0L) AE * ,%H 1@`1]M, \TVV43 0 4 C 4bP?N<#p1K 7\2 D0b`iK@ՈA9qj @,S UC52b0(4|h0BPb@AA@ AT09`b0PABi$ @ GTRG I PV0\H=l,AR 7Pt` PT0Dw< Tt< TdKqBX@X RK H% 0 7 L7S 0ܐ2,L0P"C>,X1/J, MQQJ QJPB,94`0ːp,0#Ã#pJ;QD$%KD0 `0ːA T@Dj0PHDr@1 h`0p K1KpA 7 2 EPrA$19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4[1Kp5RHtT DfQH=QAdvL 7k   ,Ar1A,  R*L Wt}8$Q Q Q S SNs1JM, P&t.R B3 0  é1bP7N #1K P 72CPQH\%uI@ P\PBp9L7aluM!RT90b0PABedtpA,ÑuTp,CT0DjWRV A$%U t 720J@s0K(@(ǢN C+S@ w  1gQ, PWV9U&tL3 0 3 0P,#PѪA@Ḧ\#DA4b@-.Ȉ- X 5bPbN <#D2K@шXuu@ApC0,Q%XPAVqd uI@ Q \ePPA72IPA7b0y*4dTARD؁@FTYK;,A H,epò`0ː $c d,*0PDX@X +0 d0˰8p@ `004E1KpO! ,1c \"t@1 PF B(tGH8 SR3 00 3 а5hAEp#pCkQ;1K@@ɠN0 2 B25l,P@b , ahP`IXWDeB0PUI dVPp @RX|SHiTp,2ܠN  #`1K %DTZQ@]p,5hP uP`,)O3¤'O( 0URC YP`j kArĈA+EI@RB pfPB F@A,$A c 20$AY12(I0b`gpg%A7<20K0b`gg ,C0P< <0DPAHIRL-Dx A <32LP@h0 `0 EA QA)`A҈ĪL %Q+pC,A1ZI, PWv@ Q43 0 5@A@EP00`0 -qj0#1BFȸ #DEGԸ #DCK)@ 0P,EAlEElEIlpQ,1# WxdQ`Eb@C#5@Pp@P1 A,CaZ0Pt-j1PAtB@@TRM UHs PCRF #L70N008B9z   Tj@@R 7t w@A { D)l`1oX, \WM(HSt?hH- *3 0 t6 C 4bPbN<#1K 7d2 D0b`lK@ՈA9qj ,AF}%Qa0Tf0`AUiYb@ApC@,tT A,C%P0b0a(pC`0)p‰,#PDւWA 7m NPAhk1K TD#pC `0ܐA,#D1K A,e, V,t$%Q,"O=LUD 7x2XU0b` (gg tAT RK Q)B RBSpCh0@]1WQ, \BE@mjj3 0` b DDQpCA 7, #FPi*E"XBHlsE5lň)4 7 2D0b`f`j`A7,2GN`A1b`JH\UpC,q#`t 72 M0ː,A]p%f0ˠ,; Tb A@ d %PH!PDu 72(K@n0AP 7,# j09b`gf ,EP0b0l(1ON,  $3 ư$@A1 B M(@P @ @P TI P048,QQ C@ @PN@e(BTC &2GP'@pC ,,1PHE08d00@6@@6AAp, C,K1Ke,3b@J0( @@ 2B@-R@ DT90d0417I, \8dA C F@0NC0: T#DТDX ,@50@pC` 18 t RP A E 7 2H0PA(0 `0ˀ: T!:A Tˡ0P4bTC !L7E0PB 7 2 G0Aa q`1XQ, t(D( @A@9 PPC09Dd0P: Tɀ0P$ B 5D0C!pCP,]RL1KPNdY A&  aEQA AF HpC`t! ,Q܈(2P , ,N$; T$p`004E028KPH-TVRMQdA;EB;Hl(1p2@O0b0* 1BR, ܄t(B!?P4;$f0 ~AҦ]94`08`PB pC0,Q#BDDIY PPT0pC`,Cd 72 GP A ,2ܰt`0%pc,#BDVc]  pC `00A ahZE42 7$h%:<-DDj^l[A16O, ܁&(Bj  b%:0fPB B{(e 5)@ApC@,3d 72DP ,A1pC,Á#B D] pC,%dPpA@@$~ 7` IA 2,L0b =KΚ,S13K, BT (%Bi x?3 P  72A0b`eiPA0 `00p,: Tb@$Q%HRB8Q  H|f p4h4T A J`j# Du1@R, ܄@P4B ,@ApC,C44h0 , Dc 4~e8@3  0h`0ˀA P0b04(,$ % Y ut0b08( A,C%Tic1K T pC``0ܐA,#B D1KAD;Qq1QL,  Q (}@x3 1 3 QT IPP$%PT a%`RB  e%; ~~9ef00A}P02D0b`hjA702H@IfA 78a` @ Z,A2b`ff 0PG@@a(@ G$PPHUPg[X@1VPClXlAw A,C6a%U Aa[Tl3bPJAܾA19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4[1Kp5RHtT DfQH=QAdvL 7k   ,Ar1RK,  "T M@ B$?p3 03 0 3 0 3 00t64SH43bPJ(Y3X3#$5\@ApCP,C #B`D0p`00 AU 7` AVEA0 7$f%:`-4 pp@0 L7l@A@qpbDAC$Qb AAܒA#Ё$@ae  7(v J2@Aȡ(1R, *"8? URC ՂC#0b0|(#   72B`@%1b@{(,@D0PA` Tn0A 0H eHU@ՇĈ 1R#%QB#ЊB` , pCp`00DԢ0 7i D 7@h2H0b0(pC`0`` ,r#h# Bl2K 8 n02(I0b0(pCp`0ˠ4  A,#` 7{  L : v0L7#B# 7 }УAA!@ %(RBD#B 7  B 28Q0b0(pC b0D ,,# 7 0 ˆ A.tC-B0b@,)PPHՂ %P/HB" D @cI4p`@A ?4p¦0s1T,  QW583 I1 3 0 3 0 3 0 3 Q1 tTpC0P   7;`,0@xTw h,T&P@ede mPfP@mhԦ0ˀ,=A0 O+AlĆO0 `h??Tn #` RrCEC0 1Rw@t U(@2bP2KP z P0TD;CP 5 T @P? #ೄ ` RCEC0Cu+P+CUC0P2?Ȃ @0T,HB  q R@ UT A,4@HZ\ eq Tr1PA@aH/^ p Tz1PA2? TQ:?TPg[9X@?TrTOCYh[ !RBR#P OTPg[HX@ly'H ATR"Pl@@PkPT0R?@AX@l 1`TRB% RT(O?@d!CY h[@H %Q@H"P@@т q ^,Q\~B Tva o6n5m4l3C2P 5HxURG^li ah0ˠLAEh@hpY 7hAEi,yX@:,*C_ƬYƬ_lYl_,Y,_XTP[Bil E@A1TBkHF PZ !R@ Uԓ2bPqLFnP !P0TD;Ct1ToHGP0TE;SZyȈA1QTC%Tz@P P!T@PLA|ȈA2AC%ćT}@P P!Tp@PLс~pCh0˰$d Tza|`CgB'`  eZĖPHW"RCĖ"hk[ȋX@lRH-TrH D@ @ lSC0@!4v TF# ,#I,XXX+=sUPHAYր&h[H9P0TG ؉@w UHAH=@aAe~ ,+'lH 3K )@OrR*sBr2TBHJ H ա*R@ U, 7 1^c #\ t AqJЄr4((@)TE (-A P̀7E(t@ P?E(@1Tj8*T#(It@@@@@@@NcjC"33 p0$0 L 3 w0T(P@PBA)Q P @AB@ ATF B+r Y)pC f0 BE5 }#D E }BT-l QA,DBB@p,Q Q -pa f0 `A(gA7  @Y,\#`B02EP.@lPp `0`. e T=0P=   7 ` ( hÙf$ Ct1RBSHNAb0d`01@ ^$ H,C$%8@A,Bu@uPd B` T Ap,CB19@L4P8&T Bp,CRu0$f0`]1oO@M @ A@0Kp TeUaiB@ h!%Ī DX,C#Ў¯(@P8s,3 0 3 PQ@A@u PP f 00h00-ie#6b0sK,CRB# T\@A@c D `@   7i` FZ0PЂ|}XRH U PAH]P0TE PKpC0 h0 t~`` ZpDqFQB AyQ"AzDNJ/HP@0 `0 m nGldG@}#Ȉ2!#D62bPLH`##7 72) Ď,)B@N,)@P&@P" 78cb p c T[))T@&RJ UHh"PCRFTT'0th0P } ,@ 0 2*q rP  P0b0s*@50 2*% P0b0w*p1l \")^ PT%AC SH" SwI3 0 q%t  Ag B@Tf ,2 C% DYDw @ pCp`0pA chZEaB1 7$m%:ˀ$-+1K TH FPTP@sPUo 7 2,iP024LP{@A,T(81PAB3gOH4q1PABSg0bP)KApC@`0DpC2@R@iPg 7bpNoBH#pB}t@|IX@!%@OpC f00A@p0` Ԧ@ OLj0PLDT pC f0PYY C z ,ˆA:ˆ/aeԂ@ 7A Ԣ RG4DP ,CU:0PDi`@ @ C%F0 7 ;D;Ap,= Ta1i@Ё,Lt:ȃ2p]@y00h0u0 %;@@; @ 0KRBP0 2 tr$D@9A@0 h0˰A0Ѓ b0@@p:2A# OC0"z1PA,d`A )!e@p B02A% 7B@L7T\tC?@0pf @И>A TARU @= ?,A0PAHe`ACa B m0p{0po0 T<8g0PAgP VD@ L Le4K" T2 25P0b0k)k1&I, \"QQ 1ܐ `0ˀpb,0# 7b02*kN,RB{1K@@` N 1 @`0`QREDP  ,r1,E, jP\hDZ 3 0 Lb B3bP4N $#BAIΈ4#1K 7D 2C0b`aK=,шA8qc@ 4bP;N00#B ,1b0 IX1&I, \"QQ 1ܐ `0ˀpb,0# 7b02*kN,RB{1K@@` N 1 @`0`QREDP  ,r1@R, ܄@P4B ,@ApC,C44h0 , Dc 4<e8@3  0h`0ˀA P0b04(,$ % Y ut0b08( A,C%Tic1K T pC``0ܐA,#B D1KAD;Qq19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4C[3Kp5RHtT DfQH=QAdvL 7k   ,Ar1Xp  Ԡ(AAJQZt(}@jRT i@(X耀3 0 3 0 3 `0$0 3 c0$0L 3 c0$0@L 3 0LB o`0.eбA9e @,0 e` ʝJXe0b@o.t0bP}NsĹ. wPh0`.is0bPN8fйA:eP#`B#4K`TDInAT0(lBpQj@ApC`P28N@< T< T,P02,J@DpCp `0 A 7 L T")EA+p,5P028O0b`K #Dj0 #DP(@ + pP<PM.Ђp`0A A/@A 0bp7O/ˆA+q \k@ApC`P2``O@A = T D= TTP02TT@DpC`0`IA 7  TV%Qu@ I 0K du& 72x]P<@ETPXAHp-!uI@ tW11@A@MT92|cE0 :a`0`Nč @ DUa0P`}p T WOH Y@FTYOT>,YĦE+H,fpr`00b @ Aic0b x!X職!A3d0@g 7m fP61fP[Ah0b@&LIp\2j0P\n+h0b0qKURNĭJCq@h0 d0ˠm 7 y lA{kA0/1K 7 i-1D#M1>l W(F9 RPUHPZT:(}@j fq@@@@@@@@3 0 3 0 3 0 3 `0$0 L 3 0p2B0b`|K$nth0bPtN$mp#2K 7,1#හ"A#`“A [n0b@l.߈. sh0`.io0bP{N8aA9e0#B #`4K` 7,r#ħA:!e#@B# D  ,2f wpCh0ː(.j{0bPN`mˆA:e#BP ,RP0Ԫ "h@P4%O ,(OC$P02mOP02:0PmA&P0`0m Tm/Dp,Ap1C, \  B0b@3JAP0b@5J p1i \ @HpC ,%@I  B 0p`@H,2  D0P1T  G(@PQAP0`0`@`RQ0 h0ˀ,@p 7D2$Ja,lQhPd ,*N$MU @Ap,CD 7h 1K  ԑ@ApC`01ho,Ap2TgTP02XW0Ki@0\DEApC0`0ː]; T VrP0`0ːa@]62lZPB u, \,(ABhP@ApC `0`_ OJ0^T @Aph0b02l*1K ,AP) *,|h,l+B), ,t:qBKLTdef1z (Jq VB50U(C ЈUZZ(V JQZp+`@ P̀5 (p@ P쀆P03 l0$0L 3 p0$0 L 3 t0$0`L 3 x0$0L 3 |0$0L 3 0$0 L( 3 0$0` L) 3 0$0 L* 3 0$0 L+ 3 0$0 L, 3 0$0` L- 3 0$0 L. 3 0$0 L/ 3 0$0 L8 3 0$0`L9 3 0$0L: 3 0$0L; 3 0$0 L< 3 0$0`L= 3 0$0L> 3 0$0L= 3 0$0LBH 3 1$0@LBI 3 &1$0`LH 3 *1$0LBK 3 ,1$0LBL 3 21$0 LK 3 61$0LBN 3 81$0LBO 3 >1$0LX 3 b1$ŰfQf`LDY:dElQkpZDZAj? TAr0Ĉ?ri1# 7o B kQ\+Ay]DuET1Ax@ApC`0@ %M#D PA^VA% 72APQ hDhv5 E)lPQiDBiA)*0PQ#DIjOIIB@ApC`0ˀ,ȮpBt KFAlD $P02(I( M#`D PAmVA,% 720GP`QoDo F8lQxDoA840PSѺ#Xy^XC@ApCp`0D0BTKGA{D $P02@O@M# D PA|VAD% 72HMP QzD~G?lQDD}AT?L0PTײ#`_m _D@ApC0"`0@]B4KH"AD' $P02XUXE M#D PAVA\% 72`S7A#Q7Q#ADČ6#‹DDDA1;lOHTOl#Q>#D<XDr6Q>6D>&hM l6Qc6DIE&nyY*A#`ܴɌ_gB/P0 2A#T@7@F AP,7QDyU l0&FP0 2;'B0K0b@nYM l,A4:0P`0ylNTlp7QzB:D 'Nԟl:Q:G'6ZHA’# E̐،nI̔K@ApC:`0  m S+ADNT1;AR ,(B@@ 4j@S@[1K0 % 72e+K+E+NTl@>Q>D+OD,A)@`WP"3Ĉh6">7#`D6#P02B*# T>@F AP,?QDUl0&F@ApC@?`0ˀ l%10PP M#`E PAVABI  P A D/PTlBQr?AE "/{Q"B0K 0P*+, 1bpzZؘPΈ8N 7' 0BBt6KPBA E+ $P02B+[ AL,+ T+@Sh4TCUP pC@,,B/CQ/CAE :# LDL A9A33l=PTlCQbSCE `D`D   p0Cb0  @I &ADd$%J#0$0PHB E ?3jQϨlFQ>FELp6q6AԂk TF e ;C;tF)4b`NN@ApCPG`0`  qT+AE{QTGAl@ApCG`0ˀ AL,. T-@Si4TGUPR)pC@,.BPv3JuGQuJEٍ)u7QޤlJQ{cJ+E*Aްl T e >C7>Je4b`N$7> A,,/B0b0.RdOElpKQE-ClbI 7 VB0K  B/0b@ZMl,/N0P`0,RlpKQ#N+EɎ8e;SlNQ#NGe;t[ہCò#;S( P@ApCN`0  T;+A3>> Sl RQO=EO>#5?TDI,9(@@~Pj4ĈmKjR#K#@DKkP02:#BIUR@F AP,RQK/Ul0&F@ApC S`0ˠl%10PP M#@E PLVACI9  pNEN;B9T 5CBAT`SCB7# PSQcTRXEQIaUF%U@4PB 72C; BM,;C0b0oI$9b0n/JE*A@ @ G/kC`eCnU lWQ8VXEP\qCoUlQ<$W0K0PaePKC KvW+5b`OHR@ApCpW`0  sU^+A_E}UTWAh@ApCZ`0@ AL,= T<@Sm4TZUPipC@,=CPrTZqWQqZ_Ej5GVlZQwZkEQkA܃p TC#`EOlYIOS@ApC0[`0ˀ Um+AnEVTq[AR o,>C@@ა4@S@[1K% 72=JJJVT+l[QD^yEɒx5KW-Dɒz,A?@`?AHM 1bpb\MWzֈ?M 7 ؿ^BSKWQ_A}E $P02?[ AL,H T?@So4T_U!QpC@,HCP_D_Q$b}EN X;lPbQ_ESAT;r TD#mѕo mU@ApCb`0@' 'V+A{E1XTbAR ,ID@@P4 @S@5[1Kp% 72H{R<uR=XJlc1b0d8AJQAJ,RփEՈbeV#YYl@fAHT@ AT@f4@ `0) 77& *)@l*AfRB1bPwX0H2KJ\LYLlgQ4gAEԛreSs{SAMs TnDĊ#ي(9EbP02DL#ÜUg@F APz,gQD}Ul0&FXP02K[VB0KK0b@r\Ml,ALtj0P`0 -[ZYZljQljAEWZT\ljQrjGW3:]2Q7 6Ĉtf &kvf#Ef!P02M#ëU@k@F AЉ,kQDUl0&F@ApCk`0`5l%161PP4!M#E PAVADI  81AVVEuZZjl@nQ%nAEZ{Qn0Kj0PlecCX~cn6b`X$~c ,ND0b08[dYElPoQżClbI 7 <VB0KN0b@\Ml,AOo0P`09ly\TylrQoňo;D;Qo0bP0YWz{ b\lprQ+6n!^%Qp1,XD0 2EX#PD#@IQI ĈAd ,X TO@aQ6nEsQEsEW5E_3\T}}QUs0K0*0Po epjCkj:Ws6b`fY׺Z@ApCs`0@g X7W+AEa]TsA!@ApC@v`0`e, AL,Y TYeASt4TPvUgQpC@,ZEP+v*vQ*vE9bl]ԋlwQ0vEXAw TbEŪ#ʩʍh ʭ[@ApCpw`0ˠm _sW+AE}]TwAR ,[E@@4\@S@[1K% 72EZuf}[fUf]lPwQgzEf|^TD,[@ˁ[\, 1bp&^^݈f] 7 p1zBrK^{AE $P02E\[ AL,\ T\qASv4T{UsQpC@,]EF~QV~AE#ĞZDAgj-^Tl~Q6zEPDG  xQp{b0`w @I ~&AD'%J#$x1PHtEpEPE)Q~0KJ0PwE^ kwA@gw7b`%Z] '0`0ˠy@0`@ApC 2^UP0`0|@lD C@GpC,C_E@L,_ TB_`{1b00:12^ `0` 72h%`xQA S# 7 ? AnH1\1K#PXFP8P0b@Z AP;gP0b@Z A@Ajspa IpC#`0@T #QAS#жpd Q\pCo`0ːToQAXSL*H;DLC B B,1CJ, ЄE)L R83 0 +LQU1@A@P0`h0 -qhPA#A5b0iK dT@  0P 'D 7 2$C@dPeeC 710BpC t0a 7 2$DPr@Pr;Su`@Ap,R ,aAl1Kf@@ - pb1WT, \BT(Ja(t\3 ȰA0%0  H 7D2C0b09<9b08*H[FLE: TĐ0P B F,1PaPP A l3,2PU@A@}P00`0ː(QLd,0P8JP@gPBFGЈȢXPn`D1n   TbxpC, 0C  TGc0 7l52<0! 84 H, Rl$1K  A ,d1eP, BT (Ja(3 0 3 0 5Q%951AEADDTEp,; TFJ,cĈ:0X 72 F0b0b*T`@F AE|UxAR ,CA[ AL,A1PP@0b@6NY,,QS ,Á; TB 7m @P$rpT@A  J Tb$ A,"r#Dp@o@c020M0K(@P4R ,0P,0P@ApC@,HMT y0 1bPJ@A1+G, P0M(A ՟Q #C`DP  C D@P@AШ pCP,0`0@ A T@DV0qBA4 72E0b0a*FP0b@pJ 01)G, P0M(A ՟Q #C`DP  C D@P@AШ pCP,0`0@ A T@DV0qBA4 72E0b0a*FP0b@pJ 1yS, (M(PAJQ 0( =@A@I 72BP,RTF; T0: Tt dG PPG\XH!PP0\ A,Q; TCA  7l G(@Ph@T@A   TPa ,2(~p T1PCGA@Dz B JԠD~p(BO@5P ,ŐP0b0*Tp Q0(2PX 7  qd8(@`4 CW@ ܂pA  #! Z/H @3`0IA)TH9PAT`tA@+A 1pS, B@(JAJQ 3 0 @ApCAMPPT`T@iP0 `0(, Di,0P  0KPh@ աNFT; T Q0PQd;Q@F 7d ԑ@A,1PP` TTaSF)i ,R4@ 01bPnJA@,hTāVPRC@ , 7,C4; T\< TDsA0PBsA*0P0@(D 12,<1b@J<@@PB AI9TPA 7$) tP@`x TFR*q5p`0 Z@0K) @ h`Z0P0Z<C%HAq0``0A@<0@b0yABTYO@8ATRM1QAX=18XDcPYHMlMA3PZH M1KPIQZpC`0BPA[` nq#CBP\H QRDD B b0P,ax0P,ax ^,CA@BA@/T0$`0 _0PpTkR[@p! h,A0b0+d0Æ0 i,A0PBGsTf0P ePjE k,C(lAEj\Ckd8pt0eoe5Fil0pCa`0ːhQ7b0+i0Ph 7gD!!%PA QoH,, 7x,A@@F,A@/iP;A1% 7 jb TC,@8b T,d 72o 725Q{dU@RH$Q}`1,}GTRMwPSG7 !Ǎ A @$"U0ܐy`0Pqh)@j0P,s`p0 2́$,, T4T"PEpC``0@qP@@ p @B "R, TCo@ApC@`00q@PȁNAcP0`00r TPr020WO4TRpC`0`u< .$DpC`,A0b`(ll y0˰~#PBn1OJ1PA,{'yPԅH@PԱ02 Y8 }pA^kPAV021   (A3 Rjj&VVV hA ԃ0L3 0 3 0p M@A\T]^ElElQb ,A,O"h@ ~? T AD@RB ApfPB @|,pÒ,a# Af@9ܐ2 J@,AN@ND; TQH TPRN 70` tÀ 72$J0PÁ$ (,2> T!n@pb@> Tw!z@b> Tu!*IT` RH 7v 20a 7 2APP 728Q002@OElQ+@Y8SA-AZ!RTp T-pC b0 M Y 7@ T 7d0ppC `0`U@@Pp1U 2b0*@`TA TDT= %P:H 7  ,QQ:HT)D] P;f0Xe @PfD@|P=HMQ9l8p >,#Я1K t tIU pCf0˰AP?PPHH%T@P- 7 0A@H,AO> Tx>A.002|^PJ@*AĄ@/ 70}@&D7!PAM@ (lS"C,A Tp`0Pb@` >  b0E,2P@dd B g@pp`0`e,a,a+,1hP"@*0 h00A@4@/P300`0˰j@? T(i@A@ 7L7BP]pC,A0Pi&l 7`a l@ A^E,A+@ b Tq1  72$@Apf0qP0f0@,A@i0f0 qK `dTF@ !%^ E@9!%@A@;Aw0``0@s @ȁ` T@r@A@ 7c` d U 7 2ȁ1%a 7a u AAo`eP#E11bp;XQ5 7 `A 7 pAzpQf0ˀzP0Tn@p yAAo, tB@x@;y >x >wP|Ce 7 0 {Pa T((*@ Ab T( U A qV"R@   ~ A$"U T "@P(" T`"@pC  A--" @ \ tA"@pC f[T#7@PG wf00 HpC#`0ˀ  72)%TPD9W#RGK"AˆHpC0&`0  72*% TPDi2 W&RG #Aˆ1 \%hB! S4hDCӔ ԠuhA L؈A9dpQ0b@j.A9e# ,Pr@A@A C SYb@FԹ #0D0b@x..0K@ T`  t, Ql QDԗ)~lP Q`)DA%}Bl cD*EBTl 120E0́2G0b0<Y*ȈܪA RB{1Kh@`2O2 2(IC-D B -,R-@ A., %At00HRw@ Px@ Pyp Pz 0bp%O` P > Tф(C 7` Oh@p(@0j8 B` P 72HQ0b`j(h@ iU RA%C , RBPP,U{X1K`աIXm 0b@cK0KUBp,Ó %@A@HHT`@ApC,b`AAp, CK,K(Ap0K T> ]/;A+A0K$ B ND1 >,AP0K.550E9dyZAjCBpC ,APBN@DTXOpb!u, Te@OBp,AOb@9 72 [,A@DAAoA.,0,`@0g K@ @`pC ,CAPE` Tc@J1PA|tP^H!P02 ` r0DpC,A0b`%)k:lE(Rt%CP_pC0,A0PAtv DpC`0ˠvPK 7 pDDp,A002A#dz0P yPmD|0b@|K]DpC`0˰ }N^@P  7 0˰ ~PxPyG@A@53@A KGQxEPQx|kG,U 1 },(A@LpC0,C(B0b0+Gs},(@AJA@AJAPR+G,FA ,(BR mN+A  P A Q` bAˆfd{A 7( p A  JDˆ@,"ҊR+Ja }1K %QF@# C pC#f0 TP,+,@ ЈA/(A 4 D,+B@@0K  b  b %QAp#f0 PEuPfIT2&PPPΘ+I,&FC% PPe P0W+ Qؘ+vI,4 = ,C-B@OpC0,-B0b0~,Il',A-&@AVA PU%xl KJ*ARpC*`0` ,tP5@RpC0,C.BP@ET*Xp"uZ0b0,(l^fy3 2. 72.+1K # JKJRf+A,$Qo*0bPqLA ,*1K& P04P0*ЌT*0 285 l3K0# aA1TE”*@4@40bP|L B@x@=0K08 T`8pC+f0@.TA@Bu @.RH P0T̮C C 2?+@A@I /6E PP PkKgA T^9C@[pC0,>C0b0-Nl:a:1KBl6 Af@+0Koooo%QAp:f0*PEuPNT2;PżPP+N,;FC%PPeP0W+ Q+O,4 = ,CHC@OpC0,HC 1b0-Ol>,AH;@AVAPU%l KO?ARpC?`0 #,tP5)ARpC0,CIDP@ET?Xp#uZ%1b0-(l^fy32I 72I+1K# JKORf+A,$Qo?0bPMA *Q"?@0 h@b3,JD@02K/.-Z ,D)$E@A@,;Q ITB@ApC,KAP I1PKդ 72K701JKP02DL7  3!A 7Qa<N31b0g.(4Q#F@ApCPF`05 72Mj0P$7AD= >AP02N0P6qpC,NDP  :A k QapБ&AĈPU<1P;51b0~.=1b0.Qz%P,OAQ,(OpCG`0>@1K`ETqG@ApCPJ`00`G@ApC@,XECb1b0.cAGpCJ`0@e,D%N 7d haA=} 72Y%TDW0KRG1Kp#B3K@fKP[,AqKP-,AZ(@,ԅn TxP$P8.[@H&SRNAP!K@A@1 PV+SN;V02ZT 72Z{1K#`<^SEQDYDa, KSOARpCO`0o,JZBI  qA QA>ajAňnc{AlpC0R`0Pr0 `0@sD,] B&+T,eR1KPKA4DHA; *T 7d iaTJEJ*R#TP,P, 72]S&x[rc#L0ːP@Ip,C_E002^#qP0Kz!E6 1K#O)@ S%QNA B@ AE> A l#`:/ 7c RpC0,hFD,h ,iPH 7g QSpC0,iFPYAETpVXAZqm%u,i ,jK 7q SpC0,mF[@DjTVD j 7x Ak 72k;1K#B1Kt 7} SpC,lFP \  A } Qha&AƈU,m TlAj ,Am12I, *P\$p@@@A@ApCp,C P04@0f00~ЖrZN3h:A T`%8RF 71 A 7 2 D0PAX1T,AN1f0p~A_YB&@PF 70 h0dh0P 1*H, Pw( @A- B G@= 0P`1PA@gIP0@f00! ` DP0$f0`~A{Y"@\@ CPuYQ C@  Tx(1d PWM(@- jPbPp83 05`0-!h #1b0aK I5I@A@Y 72C@B,N3  FԠpC,@` 1(`00GP  0Kh@` BApC0,â5 720I0PL`0A0`-`05A]h A PPA\$TR]1K2@DS@Q_TYoDs 0bpNdPȁ,1KUFQAdA7 2HSN"AN!BDA ;5T0 2pRN@1I@ LXT` (,5 (,WUP),#OQ [ RD< TX*0PTaQCBT 9h0ˀeAy e0KQ-HB@`B6@`0˰]2P 7D P/T @PB!-TRF   ^PV@\A:Hl.1K T't 7 bEP C@D A a= T = TGB@0,`0` T`PbQc  b0, !aǢa@`16N, \4iB :P 7 24AP 720BN@uAlD P0L`0` AP0`0PE#CB1K`0``0p 5  c  JAHTaWRG1K ,Ae0K Tbtngg1WS, \8E@83 0b03*(@N AԐ@ABP 72$A@G,Nj0A2DFP0@h00 A ,d,2@h0` AP0t`0pRJ(K)1KtU@ `a!B2P`rB$ 7b ,6Qdl`ZP024Lj !@0b@;NB,3P1B @ Qs Cp,d A,C%P0b0p(ш19J, T @A1%Q(0(b0eE PP@94d00A Q7L7$B0X`0P@@ h Tp݈A0P uRBE|RD ,a4[1Kp5RHtT DfQH=QAdvL 7k   ,Ar10H, \#DrR@!P0C0$@A@= PPC  G 72C0b0;<(9b0:* KJt0 `0p: T 0P42 72E@BA0K` Tz@ElD 7 2D@r1X, \4hB*PdRɀ4!(2dR %%@AQ%QD404b0ee PPd44d00A 7L7$B0|`0P@@ n T2b0i*@`0 T CEd %PH 7 i @UAu  Hl$1KPVP@ETSD< Tqp; T%PDuATKQ[QkA2P C;Sl@ QPE%PFEPG+B, *-CS*p`0˰(Am ,P(,- p{1`0P1< TCD, @ApCp `05A 72`Aۈ,+BBlB 7%  72x]j@,7b0,+(((߲-ĪA-Ģ,,1Z, PW ?@!?@0Q@AE%Q F  JT%Q K  pIiVXpC,C6wADT,N2$: Tΐ  FDFbE 7e "PH5,AViQ9paPHu0`0ː%A jQ`kȁAp28KPxP@ApC`00A tATXAp| uI͈+|Al(A88( 7Ё,#C(d  A,C4EA7x@ y  (,4#CC*@@D"U` 7b0*  D 5p+2dTP P,D< T\0P\ ,,e$,,U-@ -,VT RQ1K1fW, \"`$QKPPB E  I1ܐb0 AMA5DpC0,5dJ0PCch@ 1 X`0` 1KAK,1P!dNeh0t`0`91}wdE  4,5`24JPd A ,3b0h*hˑA@g@1K Tv328O0b0oAPNlz}A,A02$J KVPe,Dd0 `0,A }Q`zÄmŲAc0 *,  l1K IJ*Ķ*ĺ*,nŶZD*A)D+@A)B`-Qp Pq Pr0bpnJ z0K s h0PAPu@ PvP 0bPtJA  7 -a 0P@ S @P  7 0pA@G@! @A KC,cQ/PP9+C,;,On\@z0P5> Tfp 7 52dP;,AGBpCp,%TDW`RG1K A N5C>,A7PPPPOPF+D,SHDM 0uP00}Bm"b@BpCP,CAa0b0.+lK",b0P PK'DJ{L3kD 7<,CAPf@pC,APi@j0K 7: fpzxA3K 72%TPD6WRGkAv0 g ecp 32d 721K#CYbgEjA1K f AL,CKXpJЄTJUR,,Q,qTA[HD5P\HD#GLGr0bP1Kr0Pp A\C܅TNP]C E 2z0P *rP\pO`0@XY 7,0P u 7 j -q` 7`0w T(uPhC Q 0ˀv PPFjPkF@A@1@A KFQA|@{A{K+]CIpC`0yP0 `0˰zB, &Fl1KAMA02WiT1Ş6@0 `0 PAV\AyHk1K #CBź c0 z,)B@0 `00 k1K0 #C1K@ ]kl,A) T@@ Am,CKBoXc D)h@ق۔,) T)P|C D Q0ˀ T*8PA{@  7(0ˀ  7(2P P" H@A@I"@A (KH"AP@Il66A@A6A"DV-MCKpC"`0 0 `0 lC,A+  .1H#1K PQōPQPQ+H,q#ƍ} ,+BaBZłA,-0 `0 p#PAV@#\AH,,,  AeAi3 2, 72-+1KP #CPJeKIRb+A,,):P040ˀ sT PP+b&@A mKIlIT PP/r&@A@Ț+I,1'ƜC@Ùk#ClDxx+0 2B. 72.1K #C 6|IBCQ!&@AFAH PQ+J,!*DpC0*`0 Ip`_K,90 `0 *PA V'\H,,8 + AeAh`3 2C8 728+1K0#C0JKJRb+A,E턟,A9 Tx"9 T$hB5' @ pf T`Rz0Pă=(8Y@dH9N@0SE P04Kp@r9T+@pC@E 2:9T`+PC "2: ! -Ŕ PP+K,.кDAT.@A@Q/DT.@AfClDQl KKle/4 7 -  6DÈ@/>PںQQ kL0 2<\,:X,A  A QA`bAÈjP^an0 ʀ,=C@PpC0,C=C0b01- L2,=,%B^ 7tA0K`>@ 0PD9C: 72>E P0TC C 2It3@A@)832? PPN PBekMP[ Mu PM+M,6N0 2> 72>1K#C6nMCuglD}hlDtkM 7u  iA  !A tQA`qbAĈnP^ag0 ߀,CID@PpC0,ID$1b0- N$:,AI,%B^ @ 88pC@:`0p*Oe#Pt`:0b@bMqc (,CK,D݁D p,JD0b@kM#DD71 \B()A:P @PAP040BEP04, )T5Q@A X,jXVY@A@yPKxx@AQbcKcICKpC@`0` -  lC,A1b0k*Als,Qh@@AFAPQfl mKAAn02HpBZ~c0A  JPAV\H,,2b0{*(lAl6WĢpC`0,A  D,A3b0* A,J` yAp0Do04 ,C B@E 0 (Ě(D( *PB*kB*+LTv x  % A+, 72HQ ҈Ъ@l ,6A:AlA2ABlBBP- 7 T-\P l    PAV \.Hl*1Kp A BĪ \, 7 P) 72hY@,6b0*[ {  ۦB{ [l)d1K@ DU9P9:PPP@A KC1Щ.P;;P06A<PM+C,CKpC `0^@KpC0,|6D@RM=>aP @AFA>PQl KCA02\,:Xl;A  e@A Q>`bA'Q^ak0 I,CA@PpC0,Ah0b0-+ DTJ,A,%B^D@DJCQ.@AA+D,0!!Ch02  mm0b06+222nP4@AAe0Q8@AA+D,NDpC`0p,OA,tNľK,0 `0 qPPAVp\OH,, A[B8Q32 72+1KP#CYJfKEN @!'o 'r ZKAA814,B@l@a@`acoW[fmE[[Pr1ĚAn\r;r{ 7s {ApC0,Ab@, Ă]Ċ]Ē],]PfD]kA^Pf |KEA}02AT,4:R[\3K0  72%TPD|W0RG1K#CBPFq{`0 i,(B@PpC0,(B0b0+ Fj,(,5R8B0PLL28(3 c1i \b E)L Ё #E8,@PE P040,CQUI@A@]PK\M@A@mPP]]yu@AQab;b;QCKpC0`0P-  lC,1b0k*Alc,AQg @AFATPQdl lKAAm02GoBZņ7 A  PAV\H,,A2b0{*(,Al6WĪpCp`0(A  D,3b0* A,AJ` yQn@l0T@p $ 5 T)P){ u T*PP @A KB A@x 2AA+A+ľB+FP02HO@IpC0,C1K &Bl 1K A6A,---BpCP `00Q; Ul(L,F0 `0`UA Q,`Bֈ)l8A,+x‚pC `0ˠ]BpC0,C Dd#Cp   1K*tŞBe tC 7 0˰A@Pp@A@9+C,Q@A@C:E <T4B,A.AClC,>Cl$ % =, 72x] ވ@lK>6A:AlA2A,CC,C? 7 `U,L, 72%TPDW0RGk A(JA)xŮpCp`0pd( 72AK D@(D,)D)DBK [(dQ0K%pE1`v \b E)L Ё #FD=N9 P0TQ 7 0A@T@UPPpWlvEePPTW+tDktA^@}Pb06ATt@A6A ĎN02B@KpC0,11K@  .fۘAc1KPPQclDlkAP 7m p`a,ACPpC0,C%TPDkW0RG1K  AeAjp324J@PpC0,+1K  Bz+A,1K,%B^ P040,SB@U @ )Ğ)D( *P*kB++Ltx z 4 7 P0 `0A.DD#CpI,.BHAyMy@ {@ }@ A02LTpŎB S)d3K( 72XUP,@ET0 X,p u,5b0*(t ` ^*p 32hW@0 `0ːaو@)Cl*C*Ch ]l*lE.P8C C 2lu:;PPp@A KC,,GuNDAsLKk A?,A!qAS(d3Kp  c A Q>`Bc0b0,+($ `{ ^l)p 32$   fPf0b00++ *K *k *gŒ+ Y? ULP040,A@4P5@A@K+D,A9AQ9@A@DL2N:4B,A.ADlDlXDl$ % O,A@IpC0,A k0b0g+EMX,PA6A,YYYEpCP`0nZ\~P+ ApC0,APX@ET0XXpk!u,  BĞ[ \* 7o tBpC0,A@,  Ċ\Ē\Ě\,)tŊBequ0t g |4`1 n \"iNA҄tp )j@cҌFTBe]QAEp@ AAmDJT VC C 2mPTp@A aKAAh@A@PgkAPPjCD mKAT5 7n 0 `0P:D#C@L:q A%`DA+Qv@AJApw 7x B[Ŋ7A  J PAV0\AH,,2b0*(f^bh0 (, 724L͈@,T R)RRc+A,,A)) mPp PP@ 0b`gJ mP P P P 0bpkJ1,A,-e -,CL? TgOX[HTC-C5P040,!PP/+C,18D5 @A@ED:> TAB(@0P5 a00԰0`]B   PAV\:@@YBFNBAԃ@A>AT@AA?N5P0T?ԥ@ApC0`P?, |$ 72x]ވ`7AJ)D| ^}#DIQc@AAL0 e `0 `P04ܐ0bTT M,Amrvy  fPA 1QL`2Af0b0c+(dBBlA 7a jpY  ibD@EYjdj(b~kAT+ 7b kABYpC`0KXL, 7k m0UDn&nIH1D,  Q\&5(W3 P(M$00d0A5 7 2 B0b``K,ʈA8ag ,PPD5ELWq1 C,  S0U 0d0A 7@`111a \t(F@3 @L@5 P0 `0 A TPDVp&AP@aP@Z@ ATg5R^T@E^72 GPA7 d T27b0n*@0P BPH RDuQd=AT RI#GDpkAl  K,a#C3KpmpQm`Dy C,à ,C#@Cdyp#@BF;ARB{1K(@`(GNp `00M 5 7 s,3P8KC@r0 `0AA),AdOeOFs02H[@C < TLJ0P1R4i@`R4DP RQ AN@Y0@!$  B  7` @0hUA/@ @@0 `0ːYp``0ˀ][1KUq,6b@.K1K 7$`  t@Q} ,+<62A 7 T@DW0RpA߈諐1ZR, \"Š`3 G@J@-P0 `0 D,@AE%QAU=@A@ %81d00pC,CA#C A\I@ h0 T@~0T@CQD#GD` cmZ1`2F0b0i*v^P0 `0!A,AN ) g@@ @0P0@%R jD!q B P 728K0024L@@Tr,3b@0Jp@@@SSSs1q 'jP (b@AQB(A Vbh+@>P]1 P+n@ P"05 b@-P" ](~@ )PL  iF(I^˛L s@@@@@@h] 3 0$0` L) 3 0$0 L* 3 0$0 L+ 3 0$0b@6JB---B` `. @A,@) D@8P0b`"J,9 kA+@+@+@+@+@+h#@i0b06JQC 0 e+ÈA;Q]C#0A{0b@.l0b0 L,P`@pC,1#@jP 0bPNx@Ј #D=  q0K@T>P043P01>s>14b@.A<g#@B# 7 2 G0b`4L0#gD  B0bP/OĈˆ0H9C *0 :ĈA< C#0A-1b@$/0b0`L,R1@pC ,#@*k31bPC 8 p>ňA=a 2C#f1b@j#~1b@{/{1b0L,U1@ApCf0ˀ]2!gh h0b@/A>i`#0B0#=`#@jjl DFjET=lPPCFml Ɛ-PFYXDXUol DD@ ATp*D@[ByPPuTzl DorQ@A@]B 7pa Z[ a?pCf0˰q}C |,C# xrXΦqX[ !EP04@tPu 7g  TfttC6^T X_PX@E0e"H 7Ё,|bA T[X@Um]Cd@d0 d0c 7 ' bB-aZ@`C` A&A.3q dP pC0 `0`e04K(@ Ix%QpC,AP#]p0p d0ˠpRG I pb T PjPyE 7? lc T\o0`0k@ApC`,AP@HT#XA{pl"uIo0b0r,4lp1A,  -⵼?e @AQ2TB@XH@,bA(TD1xJ, \\)G!*P pE(@!9PЁBYhB :PbP0 83 0 c@ BÉL܈A9d #Bp ,0pf` @^@Ma0b@e.i0bPsNi-alQp@PA@5cSt@A@oVHC 2b0(T~ (P0TC #B`@U rQ @0djĈ%1*l QP@ApC f0P$A@,=ONmP*H  @}UT@ 72 GP)@GTP XA)p uIHM PA*e0 h0`$A+1"D, Y%@ 8@3 P  72CP: T\p $H lL@P@E@ AT@A+P-0 h0 AyP0T`p#E 1\, T (D+PL E(@!9P|[$(3 0 3 0` 3 P 72B0b`qK $XވA9hP @,FbPc @Ap,6uPPp@P @/ @pPBAPPC0ufTP0b~JGGA@Ab`aT ЈģXТA (,e s 0bP5J*T RCBl QB7 M 7   .w#GA1 RB1ܰ(`0 A-41K@!ˢ5OpC `0˰( C;3K PE/pC0,V PA8HA,A3b@oJ** 7 P0 `0=A Q9`AЈl lf0 UA.#  B .QC A=PP @Ap,C5z0P$#i@0P4b@KB A 9L  A?,[ U!D9l 1bPJIÈ*Q@02lW@DpC0,C%TPD'WRG1K ,6b@JKl1U, W(@q 8PӁrI3 0 3 0 A`0-Ag8#Q2b0dKBUP@W@ ATPemPPCP  M6bPfN4,HD^RPBP#Zd0b@b.d0b0wK\4P0K@ T@` j TBBTYA1dDq A EPPA7,x 7b0*`@@`\1P(?@U EPBD 0bp!OKQ ~VHfpÒ `0ː $  B +,0P L 0PL0Pӎ)p f08 72 IN,AO@ttbCD4b@z)K?!50 72A#@99bPJ?E@YH / 7h YA 2#B?I0Ё2#Ў`I,@P Z,A0b`(kk%E7,2#0Bh19@̅20Pdr.qP]H%P041B0b@)/QnHETP PA_HmlPL1}  r0A_Cp,0PoXx u TpRpC`PA.,A0b`,)k-m4 a xpжAhlA2C {0b`/)kP2k 0PD{3y/XDD@!%P04Pt` 7 {@a偱 n,CBlX@p;f0%@!P0ToHCP0|f0BR E!%@0DPUNHATP@-La I,CB0b`u)lyn#P9P 7) t0 FpC`0p PO 72B)%TPDWRG[AˆGpC0"`0˰ ] 72B*%TPD$W"RGAˆGpC"`0 x 72B+#DpPT"\H!z1K #2K 1+p *P@m U܀[)`@)j*P&t@@@@@@@@ 3 Q 72AP7TX@XYP`Td@PX@XXP`XRX UI * AqATI U'QRPV`@U QB@#B`@ e,C#t ,P$  7` XAX $#`yТy%O@cĠ$ODb(C @P 08h0`=pC f0ː Ġ(gpATA#,L@z0P`0ˠ0B,1OY(RA-H lpL  #pI0 7|`%;8-iA)T QpC h0E,iA78,4 79,U#PBЦdO@CcODBR @P02PU0PAPAYVB@pC`P  fC$ C Xi@`q)@`U܃CUP?pC h0ˀe@A]VAD  ?,l 0Pvlp0 h0˰qAI@I,W`P0K j0Pa0ASAKHA 0K:1PA_J0P-C%ЄTCeQE 5E0 72A 7 ^(@@]A?!u P040e t?A TDdOAD `RE D02A&T4Q\HBS\pCh0 fOCOXP0pj 0Pti,@ag? Tr1T_HP0TFURC[ShpC@ h0ˀiCRŔs02Z0PjfPAjHC02u 7 oAh  nA Qh`܆&A1X \  @JАZW(-AYPH(%BMj.PDgΗ3 1 3 0 3 0 3 Pc@A@A 7 2A0b`yK,,\ۈA9d0b@f.A9et@ B0b0~KDBP00`0@ .Qhf0bP|NTb#^t@Gй #FI,1P%P@X@B,Al1`2E0b`!(g g (,Á; b Qb (,5ܐ2$J0b }Kd){p  pC` `0,A VP A@@,4 7 +A 24N0b K Uo Qo +,< *,#Dօ W+ 7  P+h1K0 TpC `0ܐB,U#PD.1K` ttph tpC dB 7` @ ,ˆ"p`0Y002h[0b Kdp @/ pCP`0qA V@A@@p 7 :A 2x_0b K`0P|A1P` app 7 TT@#!@P&!u0u 72#YRK*A= 7p`%2#0(qI02A#P*IC@AMH l@L.D N,A0b`(k@k q k@,@/ }D{> TI@%CEPXpC`h00t TRs@0́2A#B=YCB@ZH l@LAZpC0h0 w T `z+0.Q.P/M 2d  D yУPNl0Av! \,AP\#B0K  ],CAP]# B0K ^,AP^#`B0K1hT, \bT)VQZ4 4!@A@I PPCDuMA Cc B4ye G  7 2,D0P8b0P,eTWA#C@B0p`0ˀA T0ilP,2P( ApC"aPB g@YT0f+M1K0 T@`(p T VV 72LM@k0K(@` HTRG RNPPAA7v  h@1=NSH=PPHYP04 t 7 2J@Q@Dp,DEcPwPPy@Pz@0KP@ snT@ApC`P,qU($ 72$HɈ`(B(b A*AH,F+BBP@M@ A0 `0˰@QD 72ElpC‰,@#D]g#B02EP@`lP,1P/ pCaPB l`ňA+Yo0b@I|vd@XPXA@4bPI|WA A,CDTUyP(0P00z0PC1jD1PQC1ZD1PQCr1 `  y$A@ %@PDw6xwPC E R0bp$O*B= 72(M0PQ0I 3b@(AH@#B`@#* 7  @Aܢ T"}OH 7 p,+O@K20TDICCA ADCD 71PA0{3Ke@@!C B@24,r kM1d Q34 ,C>l",2, O1 3K 1 71A0+%3K  O 7L2@PPf 7pCA 2PQN@; TI@$ 04 tCAe 7c R 7 2PS@C 0pC`0PPbTHA  7Ak04,Cf W!̶A1d Q 71ˀu03K2hoP1ݾ2Cp04 ,r,A7PЃ<`pG5C B@2@0;t 7A C H@2 ,A%O ?2 P04D cȬ( 3K@ c02A0P4i@< TOP) @pQ 7 Q,,0P gA 7 1ph 7 2 2e0ܐ264x,xʂ0ˁT@ )tZtU @ n0= Ta 0Plr= T mfO#L14KP04 D o@0P04 ,CA0܀ d0 sl2T0(`0`@, T$b@mpC0,A@ C B@2ԁ[ 0e@ ԁC 7 ~pp`0ˀ} 7$ ! y= TT00`0z0Dp }> TTP02$ 14 ,CA0`Ե0݀000K1IL,  %hH QJ(@A@ P0(b0,%(WA#CB0D`0@ A ThlP,1P  %:P,Q8AQ@P0bPJA@U@` 72J0P2 KPeAAP0DP P1T@32$KN@$PPH PPH P0DP 1T@T02$K0b`!(g3g1OI, \ %s@YJPr83 0 3 0=`0-Ad<#2b0bKp,@#@DEzˈ-aeA@Aa%Q5 d`0P-de#@4b@1.ÈA9d A7b0oK@F@F1`0ˀ;Q T;Q T!; Tρ YP` H@i A  T`@E sTA@5dT A@1fU, P(}@J@(@A@5 P00`0Ppј,0 c  d De CB(@`RI 72L0ˀ,4 2ȡN@tNDsF T@a %@PB0Ph0p @eA@Ii %1E 7a @-pC`0˰(0hAR 724L0b`h1h 2@7b`f-f )@E@D@H TpRhCePpCh0I@ :@FDPH l@LQ ,C2X #F0B|{0{ 1FM,  8 tP  pC`,P  pC,  T$4KPC5b0v( A PD5b0y(  EE^E0dQmPel ,CՈ 7G5ˀ,ˆ 72$J@ՆA@l,bQt4KPP - A! + ՈȈĢ 7h f t@m4K1+H, \"ժ{QQQHPB(4@ `0pp,0#`#PJQ#%K$0 `0p: T :0P,2 72E0b@J I@PTVATpC,A1 C, QĀbE+\q #DĢԂ@1PPNDDI U5@A%QC0b0I\#UQ C  i,p1E, PB %3 P#,##($ 72AP 72 BPQ,\$uIÈਰ19q T (D+PĀՠ (\JW*@ @L 3 0 3 0 3 `0$0 L 3 0 3 0 L 3 c0$0C%QdA C  72BPxPy0K@hp 2D0PP3P0́2L@Fp,t 70 BA'P](D C ),  Jlr,2b0* 3240b`K{#@Dh#4 `0<.a%A(f0 g0K,Ph Qh +D*#B,ĮAp `0AFD[t BAKA@w`0 }0bp-OA,<t` #AT.P32\RP @A*AP0 d00Yp `0PQETE,5b@7KA\$5 72dXPB8@ETPXA8p uI@ `U @A} %Q;d=PPC72h^PC7  T7b0$+p6PhVHH DRDuQAId=ATRI# D(!Al  J,#3K8D8lA@0 `0_P30KZ0P _PKH 9AC>8!#PPJH-K J,|Ĭ@  ~~DA1` PW ?(D?@P0Y#UP@9 @f0|A] PPYDW7 2DBPh9ܰf00$A 02$DPaACT8KP@ɡNC$p0`0p CK3KpqPpC`0ˀA!gbA$n0@ / ` t| pCf0ˠ|A@r DtTpRnpC0dPxP, 쁔Pn`\B00P8 0PH 7 N0D0N U 7p@ B P*Hql1K R^#GDX*By#GB 0KBT YpC b0 MP Y}p QA,pC d0PQA,,U,pCpt" -,eC@NT[PJU/܈\b9@XbQ T@XQ1RBEpC:2tXP@P:D逳= Tl0Pl :,æ$ -,V;@ <,T@A 50K Tdp C@aATY[샔=>C@^T9bpyO( =TRKkFl;p`0y|-z@葠xA12O, ܄tEA  72A0b`epe@AVc  0<`0PA P0b0)(pC",Cc J# AYKsL0b0.(  J`AVE(2p`0ܐtaPB a@,a,1&A, \ (RJPbpU(^:P4 `D%xs SPQSaP\#ĐhMAMMl0E8Qb ̌jAE%QPX1s jP\r%(F*P0d5+D Rծ0ũ`@ UÀ2 e@1PЀ4?3 f0$0LB 3 j0$0LB 3 n0$0LB 3 r0$0@LB 3 v0$0pCp Bmp#@D  B0bPNvԹp#6K (P02C0b`Kp)\@D #D) ) a0K@*P040`.1+c*14b@.A:f #PB#24K` TBD C%BTЈXe22 G0b`K~ #Pf #B0m0b@.0b0K,R @A@, C R@D A @~@/ÈA;D#B|0b@.Ո0a 0K T%A2`0 1,AxOTC# B0ă2 A. 0K08`0 5P UIP00`0ːM,O2eC 90 pAHĈA< Ĉ #`DJ A: 0K` 7 1ˀ]1!lCL@K 2bP;O)ĈD#@Ƅ9` 72dTP8@A@DH5QMpCb0˰i1yOO@G@ AT4b@=/A=i#BP#PDŽ=@N,6P T&AA@ ATYY@TXH 7 2p]0b`Lī:A#Hi $%Q\drR\ؽ#PD^D{A#B ,21KiP02|^0b`LDja#Mi@D B0bPOƈƈ2AAp,A0K*0P` iPk4D 72#`Xk1bPOAH)%QAmԾP#@DAn0b@/3P0K0 b!b  i@a TiP02A#p\l1bPOE1b@/ÈA?k`#PB`#]CC@ AT9ܠd0ˀgH{AlLjA?Lj0#D|P;<D#B I1KE 72 ,AP1@ApCpf0kKoLjA`# 1b@/1b03M!l0PkCC@ AT0"4Pd0mNazȈA`AA# .B@PB@ E0b0cM,TP0Ȉ2#pomP72bP?XF5BD # E  Z1K#1 7!2A#քym?2bPgXG=BЈ #E A \1K 1A,  Q5@C%RGX@X  P 1V, W(J) Q4 QEA$ڐȄ@3 0` 3 0P  [5bPbNkP#1K 7C HX@X @* P PPj  PAV0\Hl,1P%PPAA72IPA7Py T7b0*dP0Kp TaA1P(H@y EPBD 0bp OKa zdĊ1ܰ2$H0b0*0p2(U`g@,:ON(i@Y@XYP` #0B`q ? pC b00/ ,A* 0K 728O0b`KzP #D(f #B`An0b@.0b0K,Ct@k0 `00Ap `0 EHEl1K@{ TQ A,R1V, W(J) Q4 QEA$?pԆE&:3 0p 3 0` [ @[5bPeNnT#ж1K TGC HVD@#B`@dT HP0 `0@ A bQA`dā&A0PBՁ4QApC,Ce@Qp`0pAUzp#PB@VU@ Z1PbDQ(H W RDuQ)d=AT RI#0DKA  *,C#3K 7+,Rv,0PU0V2b0( ( < D`q;  I pC` b00/A .+ 0K 728O0b`K| #D)f #`B`x0b@.0b0K,Cx@o0 `00Ap@`0 EHEl1K@ Ta A(,R13z J"W%(U*V?Fe)D JTb ( w?p@@@@ 3 0 3 0 3 `0$0`d ia`#0A ,A 72@ -f i0bPvN$-pDpHD 7EpXD 2F0b`K@APkg0b0KDy0@3PRB SmjP# A Alj0b0KXyP~PA@}m Up op# 1bPNC#@ˆ.!yP^"u X@X @= P |T,P0 `0,A QA-`Ԃ&A0PJ)4QA/pC,Ce@Q8p`08AUp#B@VU@ 8Z3P4FS9H RDuQ;d=ATRI#D KA  K<,C#3K 7<,p=Pp1ܰ 7/ 0PBda 7!2TTPHP Hl/3K G*( 2\SD(ATPC E2K TVQT, 7D2lZ3K ,( 2t^V0E1AЃT@PC E2K TB'\6!?,  hBAp,A002K3K0E*1KP#PD+,A 7 S0Pda 7f2'1s0 2i2AURAJ@ , THR[D0s0ȃ2m,JpC#,A0q5NTTMH45RD q0PAp 'oP]D0s13K0-BK9P/ 72 ^,AD1vP^Cx0b@5LMAyw ;,A0P$*$rJ*e*x*@`1mV, \B(%CA*Q&t X@3 0 3 0 KPQTQXUTeTuTHX0K@EۡNLG @ O`qCL1 B@ B 7 c ,Q,QFA#D5 7 2 G@BihATl1k` I@. Z1PAt/ 0 `0); Tc$ ȆA00 d0˰8pp`004E,,3b@/K< 7 2@JPHAs ,C ,C%  PAV\A)H,1K@ ,Au1\, \BM@$%3 0 3 0 #pQ0 7,C EA0KP0`0@ A TpDV q*A#S]p q1KP TT $,QSDUPF+A,!Zbel0QWd{A0 `0; T#0PTQ(@1P@1P@1P 72(G002$HIl1KbP,P@`(TRA% QAp,D0K`oju@y028P|0Kp0`0`A; TID< A(,C$j0PE 7 2TPP   PV@ \*Hl,5b0*44D @ Tn Ał+{B,B\8ED,Q0 @AAp -Ħ-BpC `0˰aApC0,%TPDW RGAh#@2K1hY, \bM@a3 0 0Q4Q8QPPB89bPH$ULPLPM1  0l`0`   AETaWRG1KP ,qA,D9 72 TC TP X)@ U@P @P-#]0Z UjP6TFCZ@120Q@_܂-~0$ a[. V P0H!SY֠hCEAo0dQ! Rk!A0%R % CBp,C CL,K3K1,A4b@KԄ>D4 *,1$F, \ `ԆEf#MpC0,CP#C`M   @ ,N&<UȈX@\pC,@;#CXe1|W, t M(F!jP\z( 3 0 3 0 {YQXQXX1܀Mf0 -qe8#q2b@5.ڈA9d A4b0lK,0K TB@`TAlQA 7a  72E0b0nAT0H`0ːA&kvlk+k@k024JPm @ApC@,%T`DWp RG1K @ ,ACu02DNPw @ApC@,%T`DW RG1K ,AT@ @AB@ AT` Y@ @Ap,4#)g 0bPNk@IB@ AT 9b@.A;g @ B0b0K1$P-LD4PAdHDQC7 2PUP#ТD/@@\,6pd0`aA! 0ܰ`0ˀ]A1 QA=`TA@ `QET%FT CTHl10`00YP"0K@dWP  B Ԡ,7P @Jp,tpÐ`0yE|E,A@/QaP KpC ,hC, T62  fPA 0QL`2܄Bf0b0=+(g@(02  jA :QAO`<Cj0b0g+(kp1m t(GQBԫd5+Z *3 0 3 0 3 0 3 0 3 0 m@Ay%QA%P00`0 -!e}#4AGIA@ ATP9b@d.A9e B0b0}Kl$0PAD}gA 7 2HCPu00K@h@ QcpC0f0`.1@cpA@ 0bPNyܹ.qAp,r#fy0bPNA[w0b@{.ɈA:e$ # m0K0`0ː(.(n@ 4bPNuQ#P s@V,2P 7X2,LPW*A@ ,p@^p, ,,K1K! c,A4b@J(D 7f -P0bPIA)#* -,5  72TTPB-@FT X/p uIՈX  8,v  72dXP8@FTpX9p uIوīhe .DE 72l\@Q#@/@@J,7P TFiC?lQC7 d ?,#H0ĒDG  d> T> TXp@`0 a`[1K 0L`00`4,P= 7, hPApC0,APBJ@ETXLp4!ui, ,I 76 lCpC0,APL@ETXNp>!u, ,1XQ, t(D( @A@9 PPC09Dd0P: Tɀ0P$ B DC!pCP,]RL1KPNdY A&  aEQA AF HpC`t! ,Q܈(2P , ,N$; T$p`00AA4E028KPH-TVRMQdA;EB;Hl(1p2@O0b0* 1U,  ԠMMS (%`q3 0 3 0  XN4bP`N;4#1K 7d2C0b`jKe8ӈA9ad 6bPdN:`#B @,1ef0`-e a#pҒ5b@;.ވA9dЍ@ 5b0rK8px`0p -ePu#5b0tK<HATTeD g % 7j` kP 7h` h@$(@E$zRrCXEu 7 2$J0PQb$R 7L7SPpCpf0˰0< T;Q T(%\PHB YP`X@\% E l0L-   T*@E 7*,U 728R8O8J4OE(M i-@Q,HB YP`Aգ RT 0Ph0HpL7 7l2@R`q= TzQ el@L   # 8%c T; 72LTsO5=Q TͤH%BTRCXFWI=HC S>pC,D1)H, P&t@A)%Q(@A5%QA F  !Ĉ,!D A C2I! 7(2DP  QX\&R@ `j a B 0D__A0Kp1Y, \tM(@1 ,PrT? q 0 3 PXPBPYPBd9`b0AB_@d0KP 7$2 EP'0ܠ`0PA-T0DcW`RP+1KP V j@0 `0ˀA CTYLt 2 G0b`Khj#f%QعP#PD`A@  j@h@0K Ta@`f`T D}Bl 102@I0@؁2(K0b0A2$KSpCf0`P; 7f0$; T @N RP mD A 0K@PIv@PHBaPB@5~Ap,3 +,1K i,A4`2HI@j0KЌ@ 1;M, \@:3 0bP=IAA@ PO0%5Q A H 7 2C0b0;H\eS @ApC5,$T 7b0(U l dT@ 7bp.OpD0 0b0(Z!#0-,FP*Pp /, 72t\P-@ST XA/p ug,A7b0*`p2 7,CAPk PADWRpBa0b0*,1JJ, PW5(,P&t3 0 9@AI%QAdT Q 72A0b`iK,>HֈA9d@[@ ATP#pD0AGaPAGp0b@>.-a c,@#P&#6b0uK\A@A%Qp,Cau1b@cKA@m0K 7$2IPH! 7(2$HP@CTXApu R@ ```AA%p1#r  \T@qJPFZ&(JjSP08?3 03 0@ 3 0 `,C #DaՍ@Yӈ-a a`00-ae@}#4b0sKD 72EPh@AA%A@`@TRmBllQnUU928G0 2 I0b0{pC`0]a0P@`"@`dU: 724TP7b0!)e @q'tT7bpO,D0b0$)dPJ($*2K@ K,CAO@S>BCETM0e 7 2 M,A0܀]`0ːhTPAV`\OHy1K#07K4 X,A@0 `0lPAV\YHl?1K#ж2Ke 77 qEM402A0P,p.oPA[HE T!0(h0q1S],  QBT5@ H$@M@9 P ؃PkRkR` 5aqP0dAX>H AX@ܺA0TLBx0T2TYCz0TF UĢA0C( P5,SPP02pIP+QW,AR;P?W`qR>@ApC`0˰0a T/0a T)8a T?`X@H,mX@N,NCT HZHT@P-m H!A2H-T @uu!%@]C%RbRWP0T]_C5RH R!PlPRbCC%RqRg@ P0TC Q RCTQs`@PUTVA0u E0 T$PmDekLDS!R@yHB !u ) UB04 VB0E=LaOlSVK++ 7 =@\(Z0PCd T?X@H0x(H@(!%@C%RRGP0T C5RH R!P"RR 5TB U-"uPCP0TC Q R8"%P0Dh0TB E="uT#@PC5RH R!@q0TA5i UPEeS[Sk@AM,QAĚA0'u" O܉)P='RP5)CP9lLYM)S+ A0eEdL¨@pC*`01A剪HJ@ "%@PJ@"%PACE+rE*Ԭ 0+RB խ q+RB P04*b ԫT+R@".T1.R@JHK."i TȣnZȣoZȣpZȣqZȣrZl911TC Ua/R/PH P0TF P0T/PɾH T/@PTDAH6T\*#2%%CuRb2@P @%/#%PCeRI RL"@m0TrR;3PH P0TF E> T"6RB P0TF Q $RCesč۰ 6RB HCe7s؍@w H%AT4H1T @T(Q@DTxQoE.L .L# >(RU8PHIDRHITRD$%QL+T"ĒC0u8L-S)ICeT @HC` P,AR8Rh e %Q;C0$L)Dl,S(?P(?P(!?P(1?P(A?P(!?02<\@P3?7b0+ c?f0mp܏ 7O,ZE'2LڠZ3 j TdR $PIP-Du 7 7PeX@,Zk) ,ZZD-`pi@`{p@\0P`Z%  05Q IyP0T:tCPI P0TE PC5F`匑@d H-AT4H9T @u(L}(Luk ,e*XP T`bP GRylETQGR|@v \0P$>P0P$;T {$%JlPQA{ ,C5 } ,#CB*p1R, jP (A#+Ёb * 8 X3 0 3 0 3 0P e BO6bPdN*<#BЍAM܈`#2K Tt@AA@ ATee A3DAbAĈA%A%4b0hIDDi A,@T`@PLd U C0#`DTTA~f0lpCTA@ AT9`b0PAB\i,A2܀ d0p$A%| u0ܐ`0ː A){ Q)`|AɈA&s  %QA)E4  @|(ˆA:ˆ/ B@, C @0BmL   o@02DN@y0 `0=A QA8`BЈD1A, $#GDp1S, jE(G Q +N*PĀ*;$?p3 0p 3 0 3 0@ 3 @fPg C @KZX@ Z׈- j0 ZLNa0b@a.l0bPuNkH#4K@ 7( \Of0b@f.q0bPzNo.ADlU^@|PApC,r#кחA:e0#0B#pDP@PBAZ@TA@TP 2b@}.$.p)2$J0b`Kjt` # f0#D(T %Q(#D*D)Bko0 n`âT 4b0%)Ds@H@BT` 9h0 Ap A., < f00ApWYpCf0 BPU@0bp.OT-0T4b0:)XBA@BT9h0 A uPG A=,C%Cc\=pt WRQH 7` >PH\!#>,3bP$JC9CpCP`0 =ApC0,C%TPD+WRG; AD#P2K  p1 A, $?52bpHE@ 1X, jB%hDAQU(s@Q@$p@@@3 0 3 0 3 0 3 0%,#čHKԈԸA9d@ 7b0rK0p8,1#@C##A6b@:.d0bPnN=- f@`0P-eA9ep @,Qk@@AA@ AT`iap@2b0(4`0p$p2\H@w@MA%aB0KpGQul`afĈA%M0bPxI%ռ7x @PBA782,L@J@z UC0EP0T0V@jnpU pSpp A YT%Q+p,Cu1bPIApp2,pC,CT0DWp ROkAD#(//T/P00`00I< TD UPC05 72LR0b0IBpC@`0pQApC0,U%TPDWRGAX#02Kp1]I, \BԠ(H1PFtId`qLmH_dB3 0 3 0`U`0-AdL#1A3b0hKBiP@\@ ATpA@OlE%lE)lňA$baP`@ePBAZPTUHX@DPyC * A @+ qD 72CND4@;A Tz0PD"P0TD ;SCuC0u R " 7 2C0bP|IApC `0ˀApC0,a%TPDWP RGA#Ъ2K jvp1YQ, \tE@ *@$@A@=PPC49Hd0P,N4: Th4p0,1T-%@0T`2AQpC,$DZVpC,q#CƃdQETpC,5l9d0ppCP`0ˠ$@@ pTP7b0p*@`$ TDB H=j %0K(@ (KN8(K3 24L@qD@  TPpC@`0,A1t @z@lPAD{ 50bpNGauKH 7~  ,t1Z, PW ?@!?@0Q@AE%Q F  JT%Q K  pIiVXpC,C6wADT,N2$: Tΐ  F0D0FbE 7e "PH5,AViQ9paPHu0`0ː%A jQ`kȁAp28KPxP@ApC`00A tATXAp| uI͈+|Al(A88( 7Ё,#C(d  A,C4EA7x@ y  (,4#CC*@@D"U` 7b0*  D 5p+2dTP P,D< T\0P\ ,,e$ -,U-@ -,VT RQ1K1 F, \W3 0܀f0 ,!d@#p0b@%.ʈA8d 2b0EE ^aNQT `@e QB?܈T.l D;" ,4K Tx1A,  %@A1 m  \ 0(Ha TĀBp*d@WFJH,PPc`CUPbEP 7h 2Ah@pC,A 7g ifPB  7k  lP ,QUj1ܠ`0ːA; T@4Gs u@Pv l0Lw  Gh@`ƃTA| @P 0u 72 I_@$Jt 7  ,V bq@ -T- 7Ȃ+A(p-S@ P PP PP PP @I,O>Ш @k`,3P=9APpt! 8,$D0PEuP8HC S@ 1 7 2DRPP ATA $ A=,e$ <,U$ẽpCAbP9tCpˆ=ACp0KpT7Kp teUpCh0X@m@m0ˠep`Ȉ'1(8A:C0K;2bIJQxl %PU A@D4v p ,C&UHAA@mGP02P)|0P}uPTCe00d0AĶ  ^  a ;QLP0b`(h@fDpC2$ 0TB eP@(H 7 2$J0PB>J1OI, Pp08 P " RQ L T@TpC ,08@GAT@ &T%0d0P, (@A@pB@ENfNgAPT0b0(uaqD U H!2T PO@AH=Pl U 72HNvANsANrBNsCA C%REu URpCd0p @ At1D,  Q pER,D!SA A BԢC5  ˆ#1 A,  QRlD-Aq1 A, PB4 ER,D!r;Q#A$1A, 5P#D@1D,  Q p5R,D!SA A BԢC5  ˆ#1 A,  QRlD-Aq1 A, PB4 5R,D!r;Q#A$1A, 5P#D@1A, *P"Š`#CR#CB % 5PF#G@1C, *Є2tJ0b`n$AruHRA A Ȩ1A,  11A,  1A,  1 A, :PBE PQ H1 A, :PBE PQ H1 A, dT H r1A,  DT a1A, DT FQ`1" 1"1"11G, *  E@ N@C@pC,!$3 h0` p0,Ae(P A E TbpC@%P1A@1C, \   Bh@0 NH@ Ǒc  P(X\   B Td1 A, :PBE PQ H1 A, :PBE PQ H1 A, dT H r1A,  DT a1A, DT FQ`1! 1!1"1G, Ё  E(@ N@CHpC,!$0 h0` p0,Ae,P  E TdpC @%Px` `1A@1C, \  F B@ N@@@ ƑP Z[40@`0 @`1 A, :PBE PQ H1 A, :PBE PQ H1A, PbP03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PP03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PP03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PP03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PP03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PQ03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, P"Q03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PBQ03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PbQ03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PQ03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1A, PQ03 0P 3 0p TpAk1bpN G(#Db42lC@MH=1" 1"1"1VH, P(Hq(*`3 0 3 0 3 0 3 00 3 00 3 PTpC`,C`XAaPA:A6bpNL|ذ d4bfI0@@AA ;W;X;X,AB#S#c #DA@c @HDIdTT @AAD]j#D\me`dĈA0b   rAAս \ ̵1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1A, P 3 0@ 3 0` 1;1bpND#@1$1b9@Oa0r1s \b E@iJQ *sp3 13 0 3 0@ 3 0` 3 0bpNK1#0 T  EJA3b0<<0#}` YȈAh11H:P`0 A=PP,P@M`@0P ! TLkT;Aԥ@s0K@@ lajO2C 7q H@A@p 7 2H@H@s @, A,Á |@ @á ABpC`0$A ~@Ap,â  tT @A220N 1K222bP-ԃP=T:2Dg0Pfe0b pI?DLg L,CA7!1K P`0j-PP@@Ap,A@PM0K@  N,Ag!1Ki@@&i@&n`&m02ՑPPX C q A@f @,0K0 D 7j t`E\,A28 xpE@c[AE#0N =q1hW, ܁ E(@JP܀ԀCU  72DP 72 BPW 7b L74 QbA;Bll1K`S  Fl@A,a|8Kp TF! p,A 7b qmBlAXd; 7,C/(BApC`PC,C0KsA7k`  ~P A=A@=Aph0PYph0PE,%ApCh00YA1 ,e+ |AT)n ,Uk0@ALBB*T ,5PĀJP(bI<&d1A, P(3 0b@+8Ɉ`@A@PP 0lpPc4#,@Aa;AF@P0lPA%#t1!A,  Tb(3 0b@,8ʈ`@A@PP(0lpPc8#!0@AaKAF@P0l@X0bAP @|ΈP1d \"t (P}P@ @V ` 0`0 y1  c  KPPATpC,1a@A,Aap8KP T   c`4A 72cP!hBpe,C1p:x`0˰(A@T8K T", ,1 ,8A@v ,3A@sAl!,H0C  T1 AA z S$ TDA 7w` c@Ֆ CDFTAUnQ* 7  @A@T Jc72\c .LB@k A,D 120`0ˠAPC)B 1 *,Cl+BL +,C xVQ.,7P 7 a$C 7 `C@{Al!,A;T@ f i f00bP:Tp, qɆF laF#l81%a 1A, P(3 0b@+8Ɉ`@A@PP 0lpPc4#,@ARTT2b0,<<1 A, P E3 0b@-8ˈ`@A@PP$0lpPc<#!0@ATTT^APP @ẍ01d \"t (@ PppUЀ  3`00BT0vAi1(:0`0@ AuP0KPXD1pCЁ,A@c  4Ec0i PTpC,âe0VQA,3P 7l P 7c c$A 7m DEktcPu0K )p2LR0PtD 752L0P UQApPf00TPl@A0@dDApP`ABpC `0pYA XD 1Qp,5A); A,l-A1@Tp Jc C Z5.A32/B.T :2t\P@ @A,gx pC `0_@PA8pC h0aP8 BC@A@1܀` c C@A@kiQi4ciX1 A, :PBE PQ H1 A, :PBE PQ H1A, P P0V`3 0p TlĈ:a%@ $P 1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1A, P P0f 0P 3 @L@P@NA@F$ $H1" 1"1"1eU, *P8(F:@*``3 0 3 0 UE@A@72B@[@P@].AĈ:0&ȰA,5b@c80߈a0b07<42 T @<,AQ @D,AAQL72FPe uF0K T!b@Q,A'O CH&O@HRpC``0˰(!d0fQA,3P TB,%0ܠ`005-1 ,%PP C ! F[$1K PA1lP (,C$ Ri1 BB#P1-A, P2* 0 3 13 I03 0 3 0 #1uDPĈ Ac#06X~ %1M1M@J7b#H802b04<(5qU$dPE18O, *Zը (|@ PP  BTZKP  PN"i1  Fh@ŰNQ : TCDE3d`0`@@FQb0 ,BBNA ,Kh1K,,34X8K\1K1H, *P Ce@A@!A -10C0A B؂PLG1M 7 7,a9@A0BGpQ,1Kp h`@P1^, *(JJPBpp 3 0b@;@cTAFL7 2AP VQ@`0h`0@9\@A@te@ehel1KmA !.A` UP`  Y`j@`P12 K@CpC@`0ː,Ar "|(A A@r2lPT~ CCnPF2lP @PA)B+1K "r 7 T+ 724N-O< TG0CKQ+@ B@ P(T @A)X0p d0\A-@-@+,O<`@ OX0P BL - J Tp1 A.,L7 TKF`0PQCCA,!D 0U 72dZ0ܐ8 Mt<tD#J1,A, PTR 0 3 13 K03 0 3 0 {,QuQ@AA@>0b tHlňXA ݈x# @ (#PÃB_*AP@M:A1vYȈA8QU4b1-A, P2*` 0 3 13 H03 0 3 0 #-u@PĈ Ac# 6W}1M1M 7b"H802b03<(5mU$cPE1,A, PTV`U 0 3 13 J03 0 3 0 {,QeM@A=@@=0b sHlňTA ܈t# 0 (#@ÃB^*AP@M:A1vYȈA8AU4a1kO, BŨ\u(G 0 3 13 13 M0 3 L00 3 0P 3 0p  T@¦OxP}@ +lD17tL7(P 7` 0G Pc N HT % X=T:2dX@X,fh Y' Z@Q@T: t|`AEh1P`  %X0D@8A0D-9: A,,C cX 72|]PBOpC@,Z0P,)@pxLGA 1;5TA0 d0}p/2aOh1 O,A@<PZ?aD]E@tXȰA@Z @}P_E+ QS_pC0d0d tGFE@| @EDpk2A@ BA }q1tR, BŨ\u(G 0 3 13 13 13 03 0P 3 0p 3 ذ TP?,Ĉ!1BADIa4K0lE4bp$:H` S5K0 TD  g 0K) ƱP]թl4b`+:djyȂAЈ蠭@@A\pC`0ː(̩E@ T 50bP>H 7d !7b0s( ,#BTH@  T ˈ`0b?H H` [APP@tpw`uȈA9uP ([ A)1p2 TPOoT}i@U+p$17tL7(P 7` 0G Pc N HT$ YAsP\E+ Q\,AXT @A1 E@{P^E+ Q^pCd0 e td0Xc``E@z @qEFd%F `0pfkFTR, T(eĈ1tR, B(@uj@<3 03 13 13 N0 3 0@ 3 0` 3 0  tA@^Aā> t; P,0` PA@#,W@#Ĉ= 0# I% "_?DB#D/HC= (!D/|5t[ AP "0btH`m0KUIPJ)1bpO)HÈ!JJ,W'A@+Ĉ>a`%#`K-a*DB#@:I?/r0KJK,*1bpO*JÈ!LCK,G0DB#;JHp + Q,+@1/QA@4,G4DB#=KI /$Q,,`5A3A@8,G7Ĉ> 1q#NDNM,G9DB#?MDJ@ 41)Q,.:8A@=,W91@=Ĉ>!7#@X?!<1 E@5X+AbĈ> t@P0Bh@`AW >,AP=EP00`00dP?,A0KP1 7 fpDI, t**b\ taQ I,CAPIEP00`0ːjPJ,0K 7. lM,A Th~p < `P`PP o)AQ7u@Ap,A@Y@< @pr@H, Tq@-PX DPX ,lE 72AP0l@aP\fAA\p 7 2P0l@y@H,A Tx@fPA]iAYZT@AA[ F zUD 72P0l@ }PGkvAAՅk,1K0m@ `R42B(%PPDl C 0 P0l@!0P pC`0P yF@77@66`6@02)%PPDx C P0l@!0P pC`0˰ l}G,+3 |TP,+~Hq1" 1"1"1A, J1A, PQ03 0P 3 0p 5;1bpN#P1(Q 0bi?Hq8 ӋAp1)D, PB9T3 0 3 0 %@A@ P0l@@PHT, 18:8`0 APP dPQ@0P@" TN`:ebވ:abވhme@c@1A, P P03 0P 3 0b@/8ʈ `0b0#< @MA@ Lj 1C, PhBTu`3 0 @ADAkQB#r*܃=P(>1K=>l(1bprOB(=È ?B>,GCB#@+=; `P, A@ ,G DB#D->< P,@p !A@$,W @$Ĉ= @#I& #_D?DB#/BH= @ )!/|Ek A` #!0b2Hpn0KIJ)1bpO)IÈ KCJ,W(Q@,Ĉ>qp&#@K.q+!DB#PD:JD? 0s0KJKl*1bpOC*DJÈ !BLK,G1DB#;KDH@ ,!Q,@P20A@5,G5DB#D=LDI@ 0%Q,@p6Q4A@9,G8Ĉ>02#ЃN؄NM,G:DB# D?BMЄJ 5A*Q,;9QA@>,W:1@>Ĉ>18# DX`1=A E@7X,,AcĈ>=1#JOL (YJX,YPPOEBK ASNpC`0ːaAf@A,V [@-mQ[,6P8 TdYs!E0bpOE9]ň?1Pt# P9X^^CE_\EC#PY^E[ +!Y8|u{Ajqzo1bHj0Kh91bpO9^ň"1aE@|A|D{A1D,  @ 3 0p Ĉ:1AyͰ(1,:,`0@0fPB Ƃ%:ZH uDT1D, P0 3 0p Ĉ:1 ByͰ(1,:$`0@$hPB ,e,Pų+1K01D,   3 0p Ĉ:1AyͰ(1,:,`0@0fPB Ƃ%:ZH uDT1D, P 3 0p Ĉ:1 ByͰ(1,:$`0@$hPB ,e,Pų+1K01D, P`3 0 Ĉ:1JBy,50:(`0@(hPB ZA eDT1D, Px 3 0p Ĉ:1 ByͰ(1,:$`0@$hPB ,e,Pų+1K01h \*B (tC!9  E0@Ap,@tTD,AAH,A1P@ 72F@\pC,@ dA^pC,%p@A@P00`0ˠ,P0l@00pC@`0Pt(@*PH< (f@Vl#CpBPn0b0h-l#CBPp0b0j-l#CBPr0b0l-5l#CBPt0b0n-Ul#CBPv0b0p-ul#CBPx0b0r-l#C0BPz0b0t-l#CPBP|0b0v-l#CpBP~0b0x-l#CBP0b0z- l#CBP0b0|-5 l#CBP0b0~-U l#CBP0b0-u l#CBP0b0- l#C0BP0b0- l#CPBP0b0- l#CpBP0b0- l#CBP0b0- l#CBP0b0-5 l#CBP0b0-U l#CBP0b0-u l#CBP0b0-#CD. p1A, PH=(AnPhԠ/(vA ZPȂ%(&A >Pt(@*PH< (f@Vl#CpBPn0b0h-l#CBPp0b0j-l#CBPr0b0l-5l#CBPt0b0n-Ul#CBPv0b0p-ul#CBPx0b0r-l#C0BPz0b0t-l#CPBP|0b0v-l#CpBP~0b0x-l#CBP0b0z- l#CBP0b0|-5 l#CBP0b0~-U l#CBP0b0-u l#CBP0b0- l#C0BP0b0- l#CPBP0b0- l#CpBP0b0- l#CBP0b0- l#CBP0b0-5 l#CBP0b0-U l#CBP0b0-u l#CBP0b0-#CD.1A, PH=(AnPhԠ/(vA ZPȂ%(&A >Pt(@*PH< (f@Vl#CpBPn0b0h-l#CBPp0b0j-l#CBPr0b0l-5l#CBPt0b0n-Ul#CBPv0b0p-ul#CBPx0b0r-l#C0BPz0b0t-l#CPBP|0b0v-l#CpBP~0b0x-l#CBP0b0z- l#CBP0b0|-5 l#CBP0b0~-U l#CBP0b0-u l#CBP0b0- l#C0BP0b0- l#CPBP0b0- l#CpBP0b0- l#CBP0b0- l#CBP0b0-5 l#CBP0b0-U l#CBP0b0-u l#CBP0b0-#CD.1A, %0b0>1_ *PDA8T|@e*`1@AL7DQA]&,AN10 @ L00d0PA 0Kp T!@ApCA)b H T! J"0K TBHPc P@DrnA+T0K@ ,S44Q@ 4p 7 2,(@ T  @0K   C@ ` ,C);:@ mBP3T20` 2)C,* T)) 702C*՜2*C,* T*G*T01 AH,C;B0 t0P P 7% ACL,,(@ T  "A0K  " C@ ` ,C-BIPH@ ЂBP3T0` 2-D,. T-Ј- 702C.Հ2.D,. T.H.T01 N,/CPSY,A/(@ $ 6A10K  j C@ 0,8CNPM@ CP3T00 29D,A9 T98 702929D,: T99T01 [,C;C0˰D0P*P 7r ,A; B 1A,  %1A, Q5P1A,  /%1A, Qx5P1A,  {%1A, Q5P1A,  ~%1A, Q5P1A, %1A, Q5P1A,  %1A, QH5P1A, %1A, QT5P1A,  с"E uPJQ AQC0b@>NbBA-1A,  с"E uPJQ AQC0b@>NbBA-1  1A,  с"E uPJQ AQC0b@>NbBA-1A,  с"E uPJQ AQC0b@>NbBA-1  1A, PH=(AnPhԠ/(vA ZPȂ%(&A >Pt(@*PH< (f@Vl#CBPn0b0+l#C0BPp0b0+l#CPBPr0b0+5l#CpBPt0b0+Ul#CBPv0b0+ul#CBPx0b0+l#CпBPz0b0+l#CBP|0b0 ,l#CBP~0b0",l#C0BP0b0$, l#CPBP0b0&,5 l#CpBP0b0(,U l#CBP0b0*,u l#CBP0b0,, l#CBP0b0., l#CBP0b00, l#CBP0b02, l#C0BP0b04, l#CPBP0b06,5 l#CpBP0b08,U l#CBP0b0:,u l#CBP0b0<,#CD. p1A, PH=(AnPhԠ/(vA ZPȂ%(&A >Pt(@*PH< (f@Vl#CBPn0b0+l#C0BPp0b0+l#CPBPr0b0+5l#CpBPt0b0+Ul#CBPv0b0+ul#CBPx0b0+l#CпBPz0b0+l#CBP|0b0 ,l#CBP~0b0",l#C0BP0b0$, l#CPBP0b0&,5 l#CpBP0b0(,U l#CBP0b0*,u l#CBP0b0,, l#CBP0b0., l#CBP0b00, l#CBP0b02, l#C0BP0b04, l#CPBP0b06,5 l#CpBP0b08,U l#CBP0b0:,u l#CBP0b0<,#CD.1A, PH=(AnPhԠ/(vA ZPȂ%(&A >Pt(@*PH< (f@Vl#CBPn0b0+l#C0BPp0b0+l#CPBPr0b0+5l#CpBPt0b0+Ul#CBPv0b0+ul#CBPx0b0+l#CпBPz0b0+l#CBP|0b0 ,l#CBP~0b0",l#C0BP0b0$, l#CPBP0b0&,5 l#CpBP0b0(,U l#CBP0b0*,u l#CBP0b0,, l#CBP0b0., l#CBP0b00, l#CBP0b02, l#C0BP0b04, l#CPBP0b06,5 l#CpBP0b08,U l#CBP0b0:,u l#CBP0b0<,#CD.1A, %0b0>1k *PTEv@*P"*`@AL7D@] 0Pp `0K0 T C@ ,2p @ PQG'%@ApCA-b H T! J"0K TBH e %@A0\,Ar %00PK28MFV,3P 0 C@ 2DGX,A4P,CP?,(@ AU  @0K   C@ ` ,C)<;@ xB&!%@ApCp`PJ,)0ˀ t0P 0`00˰ 0PPCLH P02+ $A 0K  $ ܄ L,C;CP%0K 0PP,,KPJ@ Ăp,,B0P 0P#PXH P0r0` 2-D,. T-- 742C.2.D,. T.I.RB 7k AUpO 82/D,/ T/J/ 7 28 eAa0K e A^A)} ,C9[PZ@ pC,;CPNh,:\[@ C !%@ApC`02:E,: T:K:TRB 7 ,A; 4$C:8 8 <<1A,  %1A, Q5P1A,  /%1A, Qx5P1A,  {%1A, Q5P1A,  ~%1A, Q5P1A, %1A, Q5P1A,  %1A, QH5P1A, %1A, QT5P1 A,  с"U PQg&ATr!Aq1 A,  с"U PQg&ATr!Aq1^, \"E*LqJP * 0 3 0 3 0b@;8 ׈0a0b0/<  0K z0Pp1TT0Fb 0X0pC ,qx02J@L ApC0`0A PP 7 2 GPAl,AGH@hİA 7k IAtPAFbjȰ  %J,AKpC`0TA BpC`05A w@Ap,EIDT @A1@ 0pC@ `0EB*,AGjCM1 B  QK1KP+1lPp +, q14 P` X0,5PB@ Y(QBP-+ A `Ȱ-.ÐB@,GC02pXP9EP00`0ˠeAkE!P`  L02l\`1K ``e@ `i\F[ >,C7]}@?H} ܎D1A, P P0V ` 0p 3 @P@ P@RA@F,X- ,@`WR 1 A,  %0b0>#CP0b0$* 1 A,  %0b0>#CPp1 A,  %0b0>#CPp1 q11G, \ aP `3 J0 %0BFb #S@Wp!,:0PAAJ0PA! 7 2BP1 C Al,AAJ@P`  N0h`00P0KP t  72A0P` A1C, PԀv `3 H % A5   B,qFT0ň{@TU[q1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1A,  93 0p 1b0)#2bPf- K0bPz, @E$Lj9Kr@8C1bP) TB1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1A, B3 ð#0bP~*g.0bP)B1 A,  с"U PQg&ATr!ŊAq1 A,  с"U PQg&ATr!ŊAq1b \u0)D1*@\%3 0 3 0 #P#10I`0aa0K0h@_TT@2l` 72ZP  @e02Ex@Ap,ÁtTP@AA$&M$qhpC`0ˠAs1KPAv AX00dB>T 7v P 7x a%PP C PA2K J41K TģA A(,4 a@i@M1 B TP*1K` U$1K` T%U +,,,A@P, BX0,A6Pf@Š O,BP-4 A ` ` v2lD @ @ $ {ABCpC0`0[P8EP00`0uP0lP,^!I@@pZ72|`=C@ xh@ @_h|WA#CP0b0$* 1 A,  %0b0>#CPp1 A,  %0b0>#CPp13H, 0)Z `3 0 %(BE)` v2b> F1 t 7 2ANN@ApP,!EL@A@P00`00%\@AA&L1`pCt@,1PB  72A0P` A1C, PBlJ `3 H0 3 1 E0Dfb #CslGP89b`>`G 72B0b00=_PIm1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  %0b0>#CP0b0$* 1 A,  %0b0>#CPp1 A,  %0b0>#CPp1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  с"EPQW&ATb$ 1 A,  %0b0>#CP0b0$* 1 A,  %0b0>#CPp1 A,  %0b0>#CPp1 A, :PBE PQ H1 A, :PBE PQ H1 A, $T DX1 A,  $T DX1A, $T Fq1A,  $T Fq1A, $T Fq1A,  $T Fq1A, $T D1A,  $T Fq1A, $T Fq1" 1"1"1A, 1A, 1A, Q `#Gpp1A, Q `#Gpp1A, Q `#Gpp1A, *Z2bP*-Cp1A,  1A, J;q1A, J;q1 A, :PBE PQ H1 A, :PBE PQ H1 A, $T DX1 A,  $T DX1A, $T Fq1A,  $T Fq1A, $T Fq1A,  $T Fq1A, $T D1A,  $T Fq1A, $T Fq1" 1"1"1A, 1A, 1A, Q `#Gpp1A, Q `#Gpp1A, Q `#Gpp1A, *Z2bP*-Cp1A,  1A, J;q1A, J;q1 A, :PBE PQ H1 A, :PBE PQ H1A, $T D1A,  $T D1A, $T Fq1A,  $T Fq1A, $T Fq1A,  $T Fq1A, $T D1A,  $T Fq1A, $T Fq1" 1"1"1A, 1A, 1A, Q `#Gpp1A, Q `#Gpp1A, Q `#Gpp1A, *P-#E0!1A,  1A, J;q1A, J;q1 A, :PBE PQ H1 A, :PBE PQ H1A, $T D1A,  $T D1A, $T Fq1A,  $T Fq1A, $T Fq1A,  $T Fq1A, $T D1A,  $T Fq1A, $T Fq1" 1"1"1A, 1A, 1A, Q `#Gpp1A, Q `#Gpp1A, Q `#Gpp1A, *P-#E0!1A,  1A, J;q1A, J;q1A, :PB`` P Q N 0bppNr#1A, :PB`` P Q N 0bppNr#1A, QB93 0p #1b0(#`°#pވ:Qr(Lj#1#O%*$1D,  Q`PQ,O 0bpqNT`pC,%A@A,PT@@0b@=\1D,  Q`PQ,O 0bpqNT`pC,%A@A,PT@@0b@=\1A,  Q"ՇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,#Cr1A,  Q"ՇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,1A,  Q"ՇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,1A,  %@A`1A, *%@A`1A, %0b@a-1A,  %0b@a-1A, %0b@a-1A,  %0b@a-1A, %@A1A,  %@AA1A, %@AA1A, :PB`` P Q N 0bppNr#1A, :PB`` P Q N 0bppNr#1A, QB93 0p #1b0(#`°#pވ:Qr(Lj#1#O%*$1D,  Q`PQ,O 0bpqNT`pC,%A@A,PT@@0b@=\1D,  Q`PQ,O 0bpqNT`pC,%A@A,PT@@0b@=\1A,  Q"ŇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,#Cr1A,  Q"ŇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,1A,  Q"ŇA{QG#CBPH#C BPI#C0BPJ#C@B0b0H,1A,  %@A`1A, *%@A`1A, %0b@a-1A,  %0b@a-1A, %0b@a-1A,  %0b@a-1A, %@A1A,  %@AA1A, %@AA1A, :PB`` P Q N 0bppNr#1A, :PB`` P Q N 0bppNr#1A, QB93 0p #1b0(#`°#pވ:Qr(Lj#1#O%*$1D,  Q `PQ,O 0bpqNT`pC,%A@A,PT@@0b@w=\1D,  Q `PQ,O 0bpqNT`pC,%A@A,PT@@0b@w=\1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,#Cr1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,1A,  %@A1A, %@A1A,  %0b@a-1A, %0b@+1A,  %0b@+1A, %0b@+1A,  %@A1A, %@AA1A,  %@AA1A, :PB`` P Q N 0bppNr#1A, :PB`` P Q N 0bppNr#1A, QB93 0p #1b0(#`°#pވ:Qr(Lj#1#O%*$1D,  Q `PQ,O 0bpqNT`pC,%A@A,PT@@0b@f=\1D,  Q `PQ,O 0bpqNT`pC,%A@A,PT@@0b@f=\1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,#Cr1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,1A,  QB A{QG#CBPH#CBPI#CкBPJ#C@B0b0H,1A,  %@A1A, %@A1A,  %0b@a-1A, %0b@+1A,  %0b@+1A, %0b@+1A,  %@A1A, %@AA1A,  %@AA1 A, :PBE PQz H1 A, :PBE PQz H11A, PbP00P 3 0p 3 @P@P@RAF#r82bpN$,1lDpYHt1A, PP00P 3 0p 3 @P@P@RAF#r82bpN$,1lDpYHt1" 1"1"1` \ 9%(FAԁBF 0 3 13 0@ 3 0P3 0P3 WuhNQI#`#!PD@Pe# D#R8a P8c K8CTY%12DP 7o @ 9PC@@[pCP`0`A rKAP pӐ, :0PpG(@BB,N ?`0˰(@` ȑP3`PC,ÒHIhAD1ŌP 7 2 L0Pa,ABo[w028M0b0=d *,#7K, 7 U @A@ 7 2HU@G@ @( +,5 B.,A5P$pC `@02hVP-EP00`0ˀ],C!tT @A22d[p1K2p]3q C@㰻At+A 9@12|^@0l@A0|11; \bHE@ Q@ Q 0 3 0 3 0P 3 03 \0 3 0 3 0L7$|>f8Q h=6O6A,s1bpN$X6bpN X6bpNX6bpNX6bpNXf# `C ( rATTP@-dհP@pCaPS@QkUDP@UrսF0Kh8@` \=s Tn  ,!%PP C PA(pC `0PB, 1K`  4D1- 7  @A@ 7 2$H0BG@ @( A,,+,C~4,C@ `LAX0GA81d1 PP/ C PA8LL pC f0@e )T 17 YP?T@A@  7 2XW@F@ @$ 0KUK`a k htnJ1K# `0ː[O.hj0PF102`@A@' 7 2t`PJpC`0yD,OD@ xg&| MpT`0PaTD@A@M5 7 2 gYP0l@ 79 d`X,A TBx qpC`PN g@`@*eb f@pPY5P00`0ˀiPZ%1j0P1h pCf0˰D@PSAYpC`0ˀliT@A@ y 7 2dT@A@boPh.A3KXȈԸ`,FhSA^pC`0 qPÓ@T8K0[T:2tv`@`ЁPeA 7$2 yE,XDz0Px pC`0{20$~`@` Ax,B0 ]m@T08K  B( 7$PPy C 0 ,)B@F@ @$ @3K` @@.@ŀQ %7  72+t"PP C P0l@B!%1K t17 F,A+D+0P pC"b0,~2B,A@- @}A,, T ,D, T ,PBpC"`@0 @Ap,,G@jP5P00`0@ P,-&Jo#A)SpC#`0ˀ @T&8K q . 7p" 1r,~T '@A@ s 7 2/dT`'@A@b#1@J,A/ T ./ T .PpC@'`@50݀#@ApJ@Ԁ*@Ap,/1 P,A8+J@sSpC`*`00@Tp+8K@!8 7" {&,~29C` T/@AL/ ̀A5b t@Z=ATp3: 2?$/` @` H  %,CHA@P50K Q`O T E@Tp78K FH THXH TH!QB +s0P`0`#@А!j0P$$14 72DIEA:0 (`0`#L7,Ih@ p =^$^^^"^%vZ5-xxكxՃxxj1 -$A$a$Q$!$Q0PDOI=C=H %QpC7`0ˀ'@T;8KrrJp,OD@02J%PP C ,P0l@ 7 .O,KO@  pW>`0@0TG>@A@} 7 2DL 6A@ @pC>`005O@~3 /16 7AA92؄OAPф-.P@P?@Ap,MD@F@ @$ 0K@@܄t1 B :P@#@B@Ap,ND@F@$ @$=QH *AD O,I @222Q| ?CSpCPB`0`@TpC8K DXpC,`0ˀcTCe,@r((m thDh@ sN(F(m t8;;F ,CZEPEP00`0`iPpCF`0ˀg,AZ YheYc ApC$G`0jG@A@Qr 7 2Z p!]P0l@ 7v mR,[ T,hE3,\F0n! k! o1dJPP( C q!P0l@X0,\P3K0\ 7~d t T 17 xQRRp,]E0bPz>.R,] T] uQ.R+8QzK0KG17 zQ,^Dᄳ{1Py!pC@Na00 %:ܐ,_F@0ܠ 2_#,R,,@ NE[KA 1KO܅܅o~AP;5P00`0P<,YOJd1P[Z^[9 7 OaPB aSpC8`0@ND0KP@1,;8,i TBih8 7'e aTpCR`0R@A@) 7 2ktTR@A22jS`US1K%jipjpC`S`02FkPPM C ,lAbPNe02lVb1p 7XA52m! ! ApPY5P00`0@PZ%11P1!Tp,nF@zP\U8BPA]5P00`0ːP],nWJd\(Ї䆶r t3A:0}nAnk 7 A QZ@Ap,CoG@G@ @( Aj,oF@0K[1P!o 7 l,CyGPl5P00`002{dT`[@A@62y^1Pxm@ qp[`PN @ Ayyx tHyyx{T ^@A@  7 2GzdT`^@A@bAI,z T z12{&BPT ^:2G{_1K#_D^ B A_e^A'P~5P00`0P%11P1!X@)bA0DBWtcPP C QP0l@pbcf0˰TbcK1Pi}ЎP 72~$f.1K#A؇,l`,9l,f@ge04tb>+n ؁A.n@n kA 4Xna *GЃfnePf02 ,n n0 ~ll , 0 ~nn , 0 ~nn 7f 5A f5Af0KZ081 32 44]0#@#0r0#@#Tc:2t@Tg8KB 7 2Gj ,HPBEP00`0"$PApCg`00""ȶ, h Bt0" pCFj`0ː"%6j@A@q 7 2 +#}P0l@  ,Hj1K"*O(2#+ 7A92HY) * &2 )" *" &BP5P00`0"-P%1.2P1,"AL ,H@|0K#""P3܀`0 #1лTPn,нTn@A@  7 2dTn@A@2K,~Я 7 8b#Zň/kB-ZpCo`0ˀ#7PTpo#, T x}w0xx1v`#Ȉk# C 6b0"<ڈndn{n ,H@0l@A0@`1 A,  #CpB0b@HÈA~ 1Z, \ t(N1 RP0WZF 0 3 N0 3 0@ 3 T0` [QuT0b@c8݈Pa[Qq9@4@OeI,2J@bSA  T|@AZlp1K@lŦI1 A FPA2lP@ ` #ς]P`eȰAX0AA@v C@ Hp2 I0PbP3 `PC,Ñd p@$ BpC0 `0,A }@Ap,tT @A228M@0Kl-B<%hO(> 7 v `0@AA* 7 2HQ0pYA@ @pC `00U/,5`]pC(T,e®81Kp9l:1b0<B8pC `0ːaB0K1\, Q*Pq R9 0 3 0 3 0@ 3 0` 3 0 3 08ˈ0PP @89{APP @DAL72 BPg@AZTl`1K@lŮĈg0bpNhЍhЍ [ =P0lPAI1|:2EPr@AdEnAUqAA#ӂ;aa0rp#D ] a@Y@t @p yP0l@ A@u @P)T0:2$HP@Ax B( A,+A(#@+ˆ< tp # ) j t@mPv 17 T,P(D+M+[ ͈P 0bp/OB,ˆ; P #@BTp @Al#pʃ/d@A@ @AP0lPAp BL ,,%lP Q A8,AT -Ě-,A4b@-8 00bp/O-8ˆ T @AA,H17 T;P/DC:T;Ԉ0bplO-C;ÈA^@ @?AcP<`17 VHP,C$lQ.qAJ,V/??,6b@-J01, 1bpwO?JĈ0tP0l@@ hЬLq1aU, P&P?T@pC@,1@A,`88K0 TF    7T2 EPU % h@DNaAAA ;5dP B@  pE72(IPdeb,cQA,2P0 TJB(g A1 pP`PB LTr,ANN d; TL; T@ C  B ,AP1܁0B%{ D4 7x A 24Q1P!"@, 4@IP(pC0,5 1 A, :PBE PQv H1 A, :PBE PQv H11A, PbP00P 3 0p 3 @P@P@RAF#r82bpN$,1lDpYHt1A, PP00P 3 0p 3 @P@P@RAF#r82bpN$,1lDpYHt1" 1"1"1c  *FAԁB9`(v 0@ 3 1 3 0 3 03 0p 3 RuxOQ#1#aPD#P1b5=A0,0@p@A@bA@V@ A 7a` Y 7r @ 9PC@@\pC``0`A vKAP pC, 0PxǶh@BB,"O ?`0˰(@ ȑP0`P(,ÒHIiB?PLդT @s00d0ˀ0@ly 1bPāopC `04A7p  + ,D02\QPB+EP00`0 MP0l@PP0pCP `0`UCpC `0Kp9C\PDCpC `0aA  @Ap,CtT @A2K0K TFd 9,[,,|C@KACC#΃./l/C 7 bB0K 1o \bE@ QԠ:`@!P 0@ 3 0 3 0 3 0 3 0 3 0 3 0 3 a0$O6NT3YhM˘AEbEbEbEbEbĈA,JAATPK@1c5` AAAmPP@aie0PA;@@)\;BpC`0ˀA w@Ap,@tT@A2KP0KP TA ,a  ),p 1K tA=$  A*,%PPD* C P*,CH,3P 7 ,8B8 *= <[8S}@A AA HApA`AHD~0K e `0ˠ"S@P.5P00`0@UP0l@XXPC@.2l`Aa P>T@A@  7 2`Y@F@ @$Z!AI,6P pH1K& @`0ˠ"]O),pJ0PG+02@A@! 7 2|`PH,A0K72 K,ND@ t&2 RL,APLUP00`0`gPL,0Kp 77 iO, T|@ )pC,H0Pj` k0P[T @A@ c 7 2dTP@A@2K@ 4P`aȰ]2APPC] C q P0l@A!~AI, Tq0b@,E0KXT0:2Aw`@`ԁ`17| yE,Dz0Px AF I ),CCh0KQl, T}02 E,(D0P pC`00 2)`$`@`P xG@@Ap,,1ˀ Px F,*P0K -  *:iL7F P0``0˰ T@@A@  7 2B+dTP@A@2K ,+PxT:2+`@` tCzF0K"")2@ 0P0l@1@k0K` KA Q -":zL7G P0``0p )T`#@A@ 7 7 2B.dTp#@A@2K ,.HT0#:2.` @` AtG0K"d' PPÚ C  P0l@%0P180PB i Ht F* PPß C ,8C@F@ @$P*AIL{ A,9C@c0KFQ,: T90L7I)2ˠP,;0K:PT*: МL7I P0p`0кT+@A@  7 2;dT.@A@2K ,<,JЪT+: 2C<d*` @`ă  tìI0K"/.1K& b/a0*B%;ː 7ʻ,=C@3K@[ CJ "KL AJtpC 2>*.P3 2CK 0P1AHPY@%7 JJ,?D+0P @Z0P @C1#50ܰ`0 @:PA2 LPX ,CHD@3K E @`C0 ̀KtCBmAT3I%SpC3`0@#P@Tp68KP1I 7 2I (qKK@KT7RD)a  VEu#u2Du)1P@'a@@Ltp1,CKD*Z@DJh@0 `0˰,a:Pp,CKD@;03i p0PYJ>W>WJK@Pg + ) ,Z5@LKNNK%7 /MM,LD01P .k/1P .AD#50ܰ`0ˀ1 7 8!A ;@Ap,LD@G@ @(5AH,AM TM312܄MD? ,NO@ p>`09PPb>@Ap,ND@ePh0K@  ,COD#?1K.,Z3<h TOO TBPP C a!P0l@!c1PbAAp,YE@7P 5P00`0PfP ,YRCJgQ`C@3K%(NN,CZHTB:2Z>?la@@p-`0ː"mT`Fn,@@v**m tkEk(@vQ*F*m tD??tG A,C]EPEP00`0qP,\0K \ 7s t1RpCG`0KP{J|u1!s዁[b@ S,^EPeP00`0pxPP,A^0K] 7 zҒ*,^ T4),k30< T^_ T<+R@J@Ap,_E@F@ @$AH,h T_~AP.J SPX ,ChF@@0 `0 dND0K0 HhTPN MRL .,iF@x0KpFQA:,i TFi1S 702m%PPM C aP0l@!1PApCS`0ː[ 7bn5V1Kl12nPPa@V@Ap,oF@dPYg0K@  Z,oG{W1K !,x3PkWPP\ C AP0l@!1PAUp,yG@PhVlBPAh5P00`0ˀPh,zZJt\*r t5Q":0BAzzn 7 A [@Ap,{G@G@ @(AH,{ TB{12{^ o,A|zW@ a| 7H !AMT^@Ap,|G@X@ @lAH,A} T}12~_1PB|n` p,C~G0Popr_PP| C P0l@!1PAX@|2l`pbAa X@%bA XWp,H0bP?,PTb@A@ * 7 2dTb@A@2K0"@ "c 2{_ATc@A@ 9 7 2dTc@A@2KP" ˘,zHPɘ.A@'a%Bh@j0 `0ˀ")ЛTfq, j@̉[Zm j؂zA,m j>j4j0oɉo-o:o0ko}'[ob &ݡ̓.oAᗚpCfd0H0sn()BD7oo()7oo()72Ɋ%f0K"Z00+ g hd]"p&&0s"'q&'&tTf:2H@Tg8K"" 7 2Hj ,ÍHPBEP00`0 #3P,0K@#0# 7 6R#ZpCk`0Kp#k72!5t`# ,CHP\P00`0ː#:PT@k@A!;2P:BpCk`0#>lZ@ 72ɏ< jn`PP ?R&@@A TH肏лT0n@A@  7 2IdT`n@A@2K &@ &17 d2&A, T˜TЛ I fR&[@rAI½\@o@Ap,ÙI@F@ @$,RH*A[pCp `0&j\ & 7* l&\@+;sA@߁ywm=wwP& jA 6b0:ۈn# Fr12In,1], *FAԁ&BVf 00 3 0 3 0p 3 T0 [Qud0b@e8߈Pa$[Qa#Ds7ĈApNcbX ,CD1T:\`00AATl1K@lk,ARpCЁ,QT@A} P ,AU@m2lP 7 2 IN ?d0ˀ$@pAApCA ~ G" t 1bP::B(t  A(,C%PP C LPA(,CH,3P 7 P- 7,,A4!J(* 7A,,%eP00`00QA@ @,AEH,A5P1 7 ,9B\ 2hCPA-CCC#*P++C 7 * @ 1\, \ *Pq R9 0 3 0 3 00 3 0P 3 0p 3 08ˈ0PP @89{APP @DAL72 BPg@AgdAeAp1K@ k#p`hp݈:qhp݈دPDoS A,Q%klQt,Quځ,1b@+@vrg0bpNAT0@A~uP0l@ APT0@AABLq ,Ce }l`Q ),R Ī,2b@b-B) 0 y0bpN( P0lPA@n@SpC`0,A)BT,l 1KД,l 1KЌ #,+({ 0b0<,A@ @,6b@p&#0;I? @- @uP?x0K TFͺD111 A, :PBE PQs H1 A, :PBE PQs H11A, P P0f `3 0p TlĈ:a% (T"Ds1A, P P0f `3 0p TlĈ:a% (T"Ds1" 1"1"1` Pq@J@\j `3 03 13 03 0` 3 V0p3 0p 3 0 3 Y0 3 0:O,$4b`?<,pC,CP#Re@#PC$P$ 7d  9b0d( ,Q#ЦBT @ 1 T 0b@u8`p0b@<<`P4T@AQpC"`0pA1 ,1P!B!;w#@;ὁa# ӃAAG_pC,% QAALu ,u,VQ),2PPT:24LPp @A,c8MEPAL *,CHVQ-,4P(T :2PSP @A,AeT1 T%0K` TfUj 8,Õ#@02`Y0b *@@\a TƥV: t 1t 1@ DlI-q )  K A--@` P`ȈA; > N=,æ#@3K ~ 0b0-ˆ #08 7=,C#P+t p`0yأ 1*b Q*PSH `3 00 3 0P 3 0p 3 0 3 0 3 0 AԌ f@ UAPATTBhS  PAhAk|o,A1b@*-o:}m`0b0p-hJ9f @lm{APA^EuSApCp`0ˀAAlp1Kl|1KM |#A@ezm0b0}-,B\@{ @| A@w @l*AfPq 17{ +PDB*M++ ͈ 0bp,OB+ˆ;  #T@ @AABL A),u l Q0 A-,T` **,4b@u-- p 0bp#O*-ˆ P0l@a0b@<Y2H+T @AA* APP/9T` :2PSP0@ACTBl;BT#B;È= #`,:/ { 0KC@AA,D4 [AP9PCd#Pۂ>È=#0D/>: ~ 0KPA>HAaPA=d`{AT@AAA:DL A>,$lQ.qAJ,AW/?„?,A7b@-J0,0bpwO?JÈP0lPA s@2SAHpC0`0yAՄKDMl01K4DlND̷41bpOD>DMĈ>(a5#B>T@A@@ ?@A1} *) B8+PPF ` _DQTAYT<:T`PUP\ [fmP`AAu1Kh @` ` TT@A AA A`A@,AT$KAAAP` E tX0A@nA0,PTp:2E@^,aQpCP`0˰H0ˠ$A,2P + )T CALt ,Cd@T 8K T#4 (An { tPPHQ*,4P 9DQ P. 72PS7O1)< TdH$ĬD 5 72PS0P@181K$e pC d0pe= 70d0pe@0YFEApC f0ˀe (T 1Ђ72d0PhuQ`2hc # #pC d0ݐ BP:,zOlUvOnlKsO ]TBT0@hb0 ă,C\E 2|`@ TG@yQHT@@A E %X0, T'y7P1|x HD@#A2 0P$a@> TC0` 7&aTM1b0 Ta>, TB62AdP`&ȰAN @`PME+ 1K17/ g`C,D:h0Pf pC`0i2k0Pj`"i@A` typ~@Pw *h *k(X` tuprr  mpE@g[AQZELa X,A@/0KFQ[, To0܀d0w2́Ts0Pr&qP 7g w@,B:0K`PxB@@A@ @/pl@/v@/s0P xx@A F@A0D ;,A@02A% 7  z` Ty2XCKQk@ B@ z@+8B+ "G[ 7A D17 ~F,D0P} pCb0 2B(tt` @Έ@! % Gp ,C)BmXA (@p1 $ĬD 5 72)4 ),~1K` u0@ B)GG1K 1f *Pb(N (+P- `3 0 3 03 R0 3 00 3 003 0 3 0b@c80ވa@]L72AP`@T 8K0 TF  A,B FqP0KpQ1TP@A  S` G@ pA_:ȁ]&1b5=AAQT:2(IPx`@A,b, Ap,c NP0KD@`4ST:2DPPsp @A,dH AL ),5TVQ,,A5P@UT [J 0K(T :2`WP @A,Afdq BL A.,æ pVQ9,7PUP@`U02x` : pC`0A0b *@@x T']< :,AP@A@=cP`Dc0P0a`@!%@Cc ȈA@:H=A<;DC# D>I?>2b#O>`pCV`0Pd @I ,A @ 0b0).È`#@҃>1 A, :PBE PQp H1 A, :PBE PQp H11A, P P00P 3 @M@P@OAF$@ (Lp%#0;I> @nP?17 TLPLD̈́K1`P4LI,@ 5!#DMCLDI@@1%1b0,A@' @,A T81  t (V P p V"? _DQT@ YP e]P^m0K @ @` Tx T@A AA A`A@,A\ AAAx@Aa A@uPAA+ 1K17` F0KpDm@`122(A_,2P( (T` CALm A,CD@T8K T#4 AAj | tPHQA(,4P /DQ 5@0P`0`MA@ITp 8K@)@`M9O4 +Q,@ B 1 P-H=XHB,9L7 9,C0p9,C@P`Ȱ@;b0ˀi@]R&` pCЈ,C,AuO-= T-= T 7tPP;BT0@Xb0 ă,CE2'PHTP#, TmF!P< D0`JT-,CO TB`OpD  HA% a B=$@I)!81, tj j| tp`3d0Pc pC0`0PfNT@A E <PXTPc CDL, K,A@0KFQAN,A Th02 lD, TPc m%XJ0J h @x`*i`*l&Xp@E,PZE+Eq@nSXpC`0oLā@T8K 7 2 t0D, TЉT1 Y,A0pvPO@ ؁APc x0PAA rBr%ā܁Ё@ qЁAm|!B0@2A8 ,APhpCb0˰|( @:0P{ z@A ;5T0 d0˰| T.袠ˀ.zzzAkpC d0A@SAjpC`0~i@T`8K  7! ,(A@0K@ Qo,) T(( T(PBx@ !E@0@`0ˀ 1PyH%TDびtXU )h@a $D 5 7 2)uT RO0P)",A*T*A ` 4"["A–,+1f Pb%(A@\j 0 3 0 3 0 3 0 3 0` 3 0 3 0 #0  T pC@,P0K0XDa@`02 D0`GAG,1P`P@O@dX2lP0 F@ plA@:ѝ@&1b(=|^GtSApC`0ˠ$A݁@T8K TF"(T B` 28 R~@A,c <NGT GoSpC`0AA)@T 8K T$D17 P0KPYD@`LU,l)T0,AGSA+pC `0ˀ]A.@T 8K T%` 1Ԃ7 +P0KYD䀳@`hV8@tTWpC0b0AP9܈ 7 #0BT@`xr=PP:2Ae>P0K0v @T8K0 TJbP?H ;;,1b=LAC0@(CC>`%# `CK%) H,CA0b0#)`e0b0,ÈIJ0#p= Hp111 A, :PBE PQn H1 A, :PBE PQn H1A, DT FQ`1 A,  dT  JFq1A, 4T EFq1" 1"1"1A, :1`11 A, :PBE PQl H1 A, :PBE PQl H1A, DT FQ`1 A,  dT  JFq1A, 4T EFq1" 1"1"1A, :1`11 A, :PBE PQk I1 A, :PBE PQk I1 A, :PBE PQk I1 A, :PBE PQk I1" 1"1"1 A, :PBE PQj I1 A, :PBE PQj I1 A, :PBE PQj I1 A, :PBE PQj I1" 1"1"1 A, :PBE PQi I1 A, :PBE PQi I1 A, :PBE PQi I1 A, :PBE PQi I1 11"1A0A1A0A1A, QWE`3 0 PQ4,P1#`B3b0D#!#`0bpN$A,#PDD2b@H ɈAԧ0 1D,  Q0dT0pC0,@A,P @@0bPj<81D,  Q0dT0pC0,@A,P @@0bPj<81 111 A, :PBE PQh I1 A, :PBE PQh I1 A, :PBE PQh I1 A, :PBE PQh I1 11"1 A, :PBE PQg I1 A, :PBE PQg I1 A, :PBE PQg I1 A, :PBE PQg I1 11"111 A,  #CpB0b@HaT> B#EPO)$1 A,  #CpB0b@Ha= B#EPPO)$1C,  "T+NAфP0 PQl8Q=0b@9JI @[ A8$  B@ET`WpIl,P[I1b@9|V]I1b@9Ĉj_Ѝ|bjjlQĈ@VDZ# ڈja0b@9l@QWĈVFDZ#0ڈ`ke0b@9V#ZhAZ# ZjՒZ#0ZlՒA[#@[n[#P[p%A[#`[r%[#p[t%A[#[v%[#[xA[#[zŎ[#[ٛ|A[#([~[#П([A([A@(BrrBM;rATMEr1b@#:Bɕəl Q8,1b HTʭl Q11b@':˭l Q=1b Hl Q4l1b@+:B̕l Q,1b@,:Ul@ Q1b@-:17G,  Q )@JPQj,O00b@9HA[ 4b 3, ˈlP Q"D #GDP` v@SApCp`0=AB+Q*,A4PK0b@!:2b0*BBK;QC+;+B(-T :2LRP @A,U` 0K@ T$L#ఌ @ P/e/AԀ 0bp%O $17 VT9P0Kp9\Q ֈ#P:,X8pC``0ˠeȫ #C0b@7:C0b0*ÈAC:h :,#`B8T;D <Aj;QE;Ce0G՗9A PA=%TjKQD#C5>Aik1b@g:2b0&+CC,;QHDH7  ^ #QH"ŖAIA@*1 &QI%JA-1x1S, \"T%@q *P0)Ն+QR#@CtAa 7P2AP#1BeP@Ip,10PT!h@NT؁P&5@ApC,CA#C @KM@ 1PPATPA7 2 FPi0:2 G0b079,Ar1E, Pb#CpRI@A %Q A# B0@E   I=@ P%%1 1(E,  b%(EQ @A  B D(@p N4: T4RC 72 B0b0"90K0 TR B)0DV7 2DP#CB0b09X#Cw1C, P#D@1X ,#C1b0f)#EB`Gd)dPP!%@A1A, *#DPCA`10b@;` 1D, `#@pC ,C0#C A 1b03)`0K01@AA A<1;1b081#CCp1#CC@CFh1C, \ 3 0``0 @ @ȈA@$A Ȉ@"Ĉ ܈P/#BO#*$1 A,  ,`#C0bP(<E#C0Cp1C,  3 0p`0 @0b0(#pB# ʈPl3Ĉ0܈ȧ`@8#BO&*$1 A,  ,#CB@E #CPp1 A,  ,#CB@E#p "C =1A,  *$T P1D, \`  B  #pƒ&B ,0P A>1$F, BTC 3 0p #È#C@0b@&,g 7  I ,C#CAPSA A D)!È} #B3KP1 #C0b@d9X1C, :Pb3 AGpCp,;$QE#0R0K Ԓ01 #C0b@;1PU, * Us0 `0@A    P4T 14:4`0@ AP0KPXD1%1T:T`0pA5P0KXDb@`1ܐ`0ːP,â 9$11 A,  RU@A@VPhb1AI, JP?( tK!%@A@QPP A C4pC,"UEeM@ n 7l2H0KP(@w@`ABlXD7 7 ,bG 72 GPȅ PK2B2@5d;QOpCd0P @ . bPA--h0A!r  ,pQdl 5QFDAA1IJ, JPB@ nRE)@A@Y PP  DԅpC ,@ 7 2 DP\ 7ԗtC,1PB (Pa`p,C0P! ; T݁ ,$XDU1@AK@T : 7 ,CrQN@An D  t@ApCpd`@` j` T @` `Q~AEATYHAA1A, j+1A,  1A,  19H, *E@ nR%@A@M PP  DUpC0,@t 7 2 DP 7wtC @ | A@y 7 7|],Cq0PtB@@cdWE1,QDQpC0`00|bPB Pp@p dPB  tp@AA`1 A, JP E@A@V`h`1AI, JP?( K!%@A@QPP A C4pC,"UEeM@ n 7l2H0KP(@w@`ABlXD7 7 ,bG 72 G`"D)Z0@5dKQOpCd0P @ . bPA--h0A!r  ,pQdl 5QFDAA1IJ, JPB@ n-RE)@A@Y PP  DԅpC ,@ 7 2 DP\ 7ԗtC,1PB (Pa`p,C0P! ; T݁ ,%PcdGFK@T : 7 ,CrQN@An D  t@ApCpd`@` j` T @` `Q~AEATYHAA1A, j+1A,  1A,  19H, *E@ n-R%@A@M PP  DUpC0,@t 7 2 DP 7wtC @ | A@y 7 7|],Cq0PtB@@ @AKT1,QCQpC0`00|bPB Pp@p dPB  tp@AA`1 A, JP E@A@V`h`1A, Pb3 0P +0A@A@AP0bj9@0P(Y#UTT@EU,&1A, Pb3 0P +0A@A@AP0bi9@0P5RT EMVIU,&1A, j+1A,  1A,  1 A,  Q u@A@%P0bpa9$11 A, QU@A@VPhb1A, Pb3 0P +0A@A@AP0bg9@0P(Y#UTT@EU,&1A, Pb3 0P +0A@A@AP0bf9@0P5RT EMVIU,&1A, j+1A,  1A,  1 A,  Q u@A@%P0bp>9$11 A, QU@A@VPhb1A, P3 0P +0A@A@AP0bd9@0P(Y#UTT@EU,&1A, P3 0P +0A@A@AP0bc9@0P5RT EMVIU,&1A, j+1A,  1A,  1 A, Qu@A@%P0bpg9$11 A, PU @A@7L7 E1A, PbT3 0` +4@A@EP0bq9D4P,Y"eUTPEYl&1A, PbT3 0` +4@A@EP0bp9D4PEST0ENVHYl&1A, j+1A,  1A,  1 A,  Q u@A@%P0bpg9$11 A, P U @A@7L7 E1A, Pb3 0P +0A@A@AP0bd9@0P(Y#UTT@EU,&1A, Pb3 0P +0A@A@AP0bc9@0P5RT EMVIU,&1A, j+1A,  1A,  1 A,  Q u@A@%P0bpg9$11 A, P U @A@7L7 E1" 1" 1" 1" 1A,  Q`* P Q-0O4)90bpsNdz(1A,  Q\*H( P Q,0O45%0bpsNǓ 1 1 A,  Q %KQ#CB0b0H1 1 A,  QKQ#CB0b0H1A,  %@A`1A,  %@A1A, *%@A`1A, %@A1A, %0b@a-1A,  %0b@a-1A, #EAp1y#Ep1A, #E1y#Ep1 A,  #D0!TE#0CA1C,  93 0p #`A  B @| Lj{Z E b:q͒,Ɉ#1#O'*1D,  Q0#D`Q,1:`0 A)P0K0,0P @Op1 1 A,  #D !E#CA1C,  93 0p #`A  B @| Lj{Z E b:q͒,Ɉ#1#O'*1D,  Q0#DPQHtT@pC@, @A,PL@@0b@i:<1 1E, Pʀu@A@0HAA,1K@ Nʀ1SG, \B T)RWȀ8(@A+@ #PA pC ,C`#C   @n)f 72 E0b0f)pC,CA#G焋%Al9l; Z .6KPK /cL :i0b@2-P4дC#`BcN ;y0b@:-P<CDYDDDDYĺaf1b0)a0K`1SG, \B T)RWȀ8(@A+@ #P pC ,C`#C B   @n9f 72 E0b0f)pC,CA#G焏%Ak9l; Z >6KP ?cf i0b@+?VdY#Bff y0b@+cVlYYYYəYYYYȯٲag1b0)a0K`1yG, \T)RWȀ8(@A+Ph p(@?PHP0# pC ,C`#CB  ,!#0ApC`,P#C™ 7d j(#E]ePȥfňരf&Ql60b@:-'QnF0b@<-(QpV0b@>-)Qrf0b@`-6Qtv0b@b-,Qv0b@d--Qx0b@f-.Qz0b@h-/Q|0b@j-0Q~0b@l-1Q0b@n-2Q0b@p-jQ0b@r-kQ0b@t-lQ0b@v-mQ&0b@x-nQ60b@z-oQF0b@|-pQV0b@~-qQf0b@-rQv0b@-sQ0b@-tQ0b@-uZ Ax A1yG, \T)RWȀ8(@A+Ph p(@?PHP0#A pC ,C`#C  ,!#0ށpC`,P#C 7d (#E\ePȥ0jň@jV60b@+VF0b@+VV0b@+Vf0b@+{Vv0b@+V0b@+V0b@+V0b@+V0b@+V0b@+V0b@+V0b@+V0b@+V0b@+V0b@ ,V&0b@",V60b@$,VF0b@&,VV0b@(,Vf0b@*,Vv0b@,,V0b@.,V0b@0,[ Ax dA1)G, \T#A   M 7(`0`vfpC`,P#C 72D0bpqNY0ʈA~fňgPqŠ`0g1b0?)`0K`1)G, \T#   M 7(`0`vfpC`,P#C 72D0bpqNY0ʈA~0gň@grVuق`pg1b0?),A `1D, Bp0#`ځ   L 7`00A0`"-J#CP H1D, `#P   L 7`00y~#оB_0bP( AM 7K01D, Bp0#`A   L 7`00A~`"-J#CP ~1D, `#Pځ   L 7`00y~#о_0bP~( AM 7K0`1D, p0#`   L 7`00A~g"-J#CPB ~1D, `#P   L 7`00y0~#о_0bP`~( AM 7K01D, Zp0#`ځ   L 7`00A0~`"-J#CP H~1D, `#P   L 7`00y{#оB^0bP~( AM 7K01A,  93 0p 1b0)#2bPf- K0bPz, I@E$Lj9Kr@8C1bP) TB1A, B3 ð#0bP~*g.0bP)B11y{ *Pb.r@ TH[]SZe(N {PE (0AA(C܁ (|@*P8uz@% Pj4l$0PLB3 |0$0È;axx `DAD!AT"D)qAAD1pATĈ;A(,sQ *P(46O; i  ),AB*@+@ł*@*@B)p`P P P0 P PPPP0K0h@FvdzA8J0PB1 ,&BS@PSApC `0ː #B:@0 TQ9pCЁ,p$ 7` }@AL A+,`+UlC,1b9ȃA 7 ,2b@{-C002,L`Ȉ,AcȈ,ACz|,C|02lA.1b*:CAT4 2@^`ȈPp!h0I #I #J,AH@#!%@AL 9,U&դ@*\\Q 1 ;,&BPT:2tY@=S=pC`0˰iBB,gp6P UPCCtdA8 17% ` a`@`|-T:2t d`@`0A 7$2A{V@$ wqA [,A`8Ȉ #_A81 N,A` 4@AL< AX,A0bp:>Di0pjAAXmA;A؃>Au@(0ܰ28vY2b@..F`9Ȉ,chȈ #k # l 8 Y2b@5.F0K[2b@6.F`>ȈܸP,cpȈ` #m #n 8F XX2b@=.F0K]2b@>.F`bȈ,cxȈ #o # xF 80Pv@"j@A cpC,cp2A& h2b@k.GP*Qi,cjȈAyE1px TbTE(D:(RA jWԧ (JPb=(AP % (q@PJ25 |@*P4 TE(|$0pL3 o0$0L( 3 0$0bpN$Q D%AD-A3D5rAD=qAD )Ĉ;d >Wԓ #A T#pԤ32B0b0=a RB#-A,B 7 w0u A.@-@+@B(@C(@C(@C(,tO sP@PA8 CPX ,C#;,AiP:A  @P: CPX ,4A,CX(1b :ȁWqT12$N0b@',= 7  p ̈0K1KА 7h w@A*1b):AT4 2<\0b@1,DH 7  P & ҈Ȱ`0K 2LTIRBT :2TTP+APIH TP@A,U0PKH T0K` T %TMT @A  80pmDL :,C$ D @T8K T@`DVN@b!EN,F,ACSO2l`0E` O,CA0b@,Fm@9 Qd,N1KNP0b@,F@:PAnD1Q, Q9HEC SB=(A }PED( B! PL 8(l3 03 c0$0PL #4e|0K ,N+C#0rTRB#B `ԡPsPH Y T pCh0 ; TdBA@V`pԠRB#B`PP(H @] # 02 DPdA)Ĉ}@AT*^ ,1b`r)e }#B ~@k +SpCP`0`A͂(AT-l 1Kp2 &zLjˆ(APDB"95(y@ 3 00L6$1@LB 3 f0$0LB 3 n0l0-D-`B1`; TC#A'8@#A32 D0b0l=`%ā@@x %0bP91b`dXyUK]1b`XT ,Ca#P YHeԃPP(H kW% 02A4OKi,Ĉ}0A,Ac  A),# CY)HA uA1b`z+aom#,pC `0ˠ,A Ro@= %AAQ.pC h0p0A-#pABhzxu1b`A 7  nwd T9{B0(A(Ĉ |@02È=!  #0ʂ,H/2b`&;̂H!ABL A9,vUl@Q'AI,AV(I&وĈ=I:'A0b0,BD; #.D$T:2lZP.@A-!Dp"Dl21K K8DKÈ=!-A#P˂81A,  ՂA;1b0; 1A,  A;1b0H 1 A,  uA;1b :H )1 1" 1" 1 1 1" 1" 1" 1" 1 1" 1" 1+J, *БZ!((1 b0ȵp,@5,g QPE TB ZAL3 pe,Cbua1Kp(@ qh@AB AԠA  72 GP@ T1/J, P 40$Q A!  \ M7 2 DPD P50KP@V܈TB^AEM4 pv,CbQP`X: Tz`: Tq$D p`0ˀAa 0K Txq1[ \ t E*BJPBQ9T% 0e]QD A#  pC`,0#BTn@F0u`0@; Tt0N42K@0( 0P @0Pd 6S  @T@8K T! ,p1K,E)fŠ,`@@(\R@El ,QN-yyyQON-yyQeO-9P밑 7t Q@@A@s 7 28Q@G@v @( ,d ў(,A4PրpC `@02TRP(EP00`00UP0l@ 7 T,-BT%&D6 7<%J0`فP,5P00`0paP0l@X0,AFI,A6P12l,8T@A C`@dVuP0pO+nl0P!BŦO@+Yl@)=l@(>l4 7 ]tT0:2|^PJ:`P<, T'|P3 aP`  X0, T a0ܐ`00hS=pC@`0Pd@A,XD$f0Pd pC`0piQI@}(iaQJ,A h @ `)))p%**pu.e. BM,p,0D@2@Ap,A@F@/ @$nPN*AAA 76A 0PA@u#n0Plun@BpC@`0uPSNpC`0 q@A,XDhs0Pq pCP `0PtlQZ,A thsAAn 7u  `0˰w8PP@Ap,A@P]02AD{1K%qb _,(A<<PP@Ap,A00 `=T@A> 7  †j,(2(, F@A,)\ @،` TI)(P32B)E0 2)Tm1Kp ܘD a`0ː  l 1K (1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2P 2Cp/2A 1/P00`0ˠl8T@@A@9 76 kEO, zz Op2A>P00`0n2dP0l@ X,An1K s@pT,A@nPXEtuň#N1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2@P 2Cp/2A 1/P00`0ˠl8T@@A@9 76 kEO, zz Op2A>P00`0n2dP0l@ X,An1K s@pT,A@nPXEtuň#N1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2P `;A> AM 7L,AP @A@L C jC@5 @pCp`0˰lXD@ i'h'kj0 7X,CAPb@A@O C o,AgPXj02kq00t 7ZA52n\1K@kQ ;1b0o.1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2P 2Cp/2A 1/P00`0ˠl8T@@A@9 76 kEO, zz Op2A>P00`0n2dP0l@ X,An1K s@pT,A@nPXEtuň#N1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2P 2Cp/2A 1/P00`0ˠl8T@@A@9 76 kEO, zz Op2A>P00`0n2dP0l@ X,An1K s@pT,A@nPXEtuň#N1u \b E@JQҴP%`3 G03 0 3 0 3 0P #p7b`;H]Ƃe-@> S  BR|0K0TPN@0P ! TLA4@U A,N0l`= tʐ`a0 2 EPUP00`0` !P0l@ 7o A %Vf 7w A,C%PP C 2@P 2Cp/2A 1/P00`0ˠl8T@@A@9 76 kEO, zz Op2A>P00`0n2dP0l@ X,An1K s@pT,A@nPXEtuň#N1 u \b E@JQP%`@3 H0 3 S00 3 0@ 3 0` 3 03 [0bp=H `(Ɉ:q8ȈAX!1<:D`0 A-PP,P@= @0P0"BQN`Q]1tP0[(@  ,R5PP C PApC0`0p ,2Ph=phpC`@024IPEP00`0˰(,#P0l@ 7 ,+B4 b4 4L49$D17} HFQ+,4P0ܐ`00m,CE)@T@ h XB@PDUA. #ׂ)BL +,v ԂV 0K TE # ڂ+BL -, @ d"UB@@h UPPs@Ap,dP0l@ 0KQ3K T 'tb # 88pC`0A@QA B `B@ @,QHfA%>x0H@ `[8nѸBS=pC`0@c=P0KPVQJ,A Td0= 7!5;ˀfP>T12E?h@ 0bPb:LNNC 0b`3>H<1: lACN59 7 2dP0l@ O,AjQ1K%mmpC``00g qAYef 7 2 t0A@i @pC`0r]E,3r<@s]Tx,l_D1b0v.ňع1 u \b E@JQP%`@3 H0 3 S00 3 0@ 3 0` 3 03 [0bp=H `(Ɉ:q8ȈAX!1<:D`0 A-PP,P@= @0P0"BQN`Q]1tP0[(@  ,R5PP C PApC0`0p ,2Ph=phpC`@024IPEP00`0˰(,#P0l@ 7 ,+B4 b4 4L49$D17} HFQ+,4P0ܐ`00m,CE)@T@ h XB@PDUA. #ׂ)BL +,v ԂV 0K TE # ڂ+BL -, @ d"UB@@h UPPs@Ap,dP0l@ 0KQ3K T 'tb # 88pC`0A@QA B `B@ @,QHfA%>x0H@ `[8nѸBS=pC`0@c=P0KPVQJ,A Td0= 7!5;ˀfP>T12E?h@ 0bPc:LNND 0b`3>H<1: lACN59 7 2dP0l@ O,AjQ1K%mmpC``00g qAYef 7 2 t0A@i @pC`0r]E,3r<@s]Tx,l_D1b0v.ňع1 u \b E@JQP%`@3 H0 3 S00 3 0@ 3 0` 3 03 [0bp=H `(Ɉ:q8ȈAX!1<:D`0 A-PP,P@= @0P0"BQN`Q]1tP0[(@  ,R5PP C PApC0`0p ,2Ph=phpC`@024IPEP00`0˰(,#P0l@ 7 ,+B4 b4 4L49$D17} HFQ+,4P0ܐ`00m,CE)@T@ h XB@PDUA. #ׂ)BL +,v ԂV 0K TE # ڂ+BL -, @ d"UB@@h UPPs@Ap,dP0l@ 0KQ3K T 'tb # 88pC`0A@QA B `B@ @,QHfA%>x0H@ `[8nѸBS=pC`0@c=P0KPVQJ,A Td0= 7!5;ˀfP>T12E?h@ 0bPd:LNND 0b`3>H<1: lACN59 7 2dP0l@ O,AjQ1K%mmpC``00g qAYef 7 2 t0A@i @pC`0r]E,3r<@s]Tx,l_D1b0v.ňع1A,  3 # A1j \ t)RSBB̡.& 0uaQD #  pCp,0#BTp@F0y`0@; Tv0N42K(@0 ) 0P @0Pd 6S  @A,b apC`0ˠ$2\,hQ@1i@@(X2P TPQp@`0@>@ D^A^A^)?(@ D^A^~@ `D;A;A;A;| A,%PP C P,DH,4P 7~ RP* 7Ӯ),4!<0 * 7 ` @A@ 7 2TV@G@ @(!@ XD52`Y0 1K tf1]LL0UQ e` aPB  PP- C PA.,AGH,A7P2|,9T @AA:@tWvp0,@ m>! T|@ m`@0 m`@m 1 8,CA@GL 9,AP:dP<, TcP@RB2AC@ @1K`BB 7`2tT@:2P0KVQAI, Ti02ACwP^J,A,C0K%~  &&e/U/ M,A0 A:PL5P00`0qPK,JrPP M,3? Ta"0PA T[32ATT:2AP0KpVQAZ, TFv02D0KhsP[@Ao ],3p.2 s/P00`0}P8T@@A9!~0P}@pC`0 i 7!1K  0`0p T<PP@Ap,(B@Pi0KP @ 1 k,)B1Kp ,*CPjF0 J0P$65X@  7 AE 7 F@A@7 pi2+#CBi 1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`6:@MD#HC` @`0l/PP0@Ap,CA@PM0K@  N,Af1Kl@&k&p`&o0 c w AXUb 7 2P0l@u@H,A Ts02Kpw0ːz2A8$.]1KQ =1b0r.1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`7:@MD#HC` @`0l/PP0@Ap,CA@PM0K@  N,Af1Kl@&k&p`&o0 c w AXUb 7 2P0l@u@H,A Ts02Kpw0ːz2A8$.]1KQ =1b0r.1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`8:@M lMA0b`.>?71 5 qAM 4 7 2$P0l@Po@H, Tm02A{bq0  tjjh c@`0prPPf0@Ap,A@iPYl0KP@ 1 Z,Au1Kp y,CsPAYEzzň̹#0O1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`9:@MD#HC` @`0l/PP0@Ap,CA@PM0K@  N,Af1Kl@&k&p`&o0 c w AXUb 7 2P0l@u@H,A Ts02Kpw0ːz2A8$.]1KQ =1b0r.1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`::@MD#HC` @`0l/PP0@Ap,CA@PM0K@  N,Af1Kl@&k&p`&o0 c w AXUb 7 2P0l@u@H,A Ts02Kpw0ːz2A8$.]1KQ =1b0r.1{ \bT E@JQBP%sp3 0 3 00 3 0 3 0 #C7b`9H^ƒa-> S  Bbx0K0TA9 @0P0"l>PsQU; tG0(CHAHp`0ˠA kT@Ap,qT@A2K0K TA ,Cy@ <$2! -  ,%PP C LP,CH,3P 7 Ol+B@ e = R<L@\Dt@vSpC`0@MA,AeT1 TA I 2`Am0KDQA-,A6P( TE`+2bP{-*T :2lZ@P-h@ hv-2bP-,TP :2t\@0K T[l-1K TDÚ:U 7 2|`T@A!a0P`@|# D L8+8B9 7 bP5 724 P0l@` D[1K@ t,4AECL A=,APP@A,XD&h0Pf pC`0˰i=T`12dEl>k@0b`;:@MD#HC` @`0l/PP0@Ap,CA@PM0K@  N,Af1Kl@&k&p`&o0 c w AXUb 7 2P0l@u@H,A Ts02Kpw0ːz2A8$.]1KQ =1b0r.1{ \" E@JQ P%sp3 0 3 00 3 0` 3 0 3 03 \0bp;H a(Ɉ:q8[ȈA\!1<:D`0 A-PP,P@= @0P0"BQN`Q]t07h@ !1!A ,R5PP C P,BH,2Pa 7v (A(>x@8$( 7 @A@ 7 20M@G@ @(!@ 432x@8$( 7 @A@ 7 20M@G@ @(!@ 432x@8$( 7 @A@ 7 20M@G@ @(!@ 432pCd0Pd@ ' pR!'4rI$'LpRɉe&1i JP"Ԡ(CQu(I? P(Р40#] `@^<`j[g5` A F 12F@CpC,`UP3D`0@A!l 72FP A@  !02gP 0Pn? Tȱf0 2$gc ,(pЙ,F,Hu(1K` G h ,#U J 7 hPc7 UZ(h7  h&,Xu,1K` V,ôE Y 7 h@PS],10w A:UUCpC``0ˀAPpC `0ˀAPpC0 `0ˀA`؂7 UpQ.\[.h Vp :2ذA%ApC2ph;A 7 hePSz@A0 ` xA>_ ,,CAP.pC `0hT :2QpC`0pj*UjU ZUZCpCb0ˠhT..@ `]#] `qWUE:D`00Ai[ 72HBPTzAyp` J 4 A,!= Tq`:``00,d ,ÑA 7h X2@jAl!,A4ܰ2(L@oPqpCh0 -A=jZ0@rkA!DMqZ0A@wA0,A4܀1_ A+,CAP.pC `0gT :2Q:pC`0 gD ;,APpW9M@TWA:hCW Tf0 2$gc ,(pЙ,{ QBf0Pxd0ˀM0`28RPpCh0ˀOP1QpC`0AP\{UV pC b0ˀQUl,1K` ,ô5 W 7 h@PS,10v A:UUpC@`0ˀAPEpC`0ˀAPGpC `0ˀA`Ԃ7 5pQ-\uVA ZC ;2,Qo, 7A),ACpCPh0ˀ]Psp@ALՁ:1u vBU 02u7 aA% A/,CA` ă7 b)@T@:2 ;,APZ;U@TpWh ZB AJ,AP J\-'V0l&qWA?hq.lVDM![HlMDA?PNEf@>PNE4 AY,r@m  7gA1K lB2"'*dE*T2 D*8J1,A, +PP" >(j@!W"b  J AX#CBP"Ԉ50b0+-Eb  K AX#CBP&ԉ50b0/-b  L AX#CBP*ȴԊ50b03-b  M дb1,A, +PP" >(j@!W"c  AX#C0BP2ЮԌ50b0+Ec  AX#CpBP6ԍ50b0+c  AX#CBP:Ԏ50b0+c  c1KA, JPH|(@.Ph T(v@PȀH)c L A#C0BP>vдԏ]0b05-f M A#CpBPbvԘ]0b09-Ef N A#CBPfvԙ]0b0=-f O A#CBPjvԚ]0b0a-f X A#C0BPnvԛ]0b0e-g Y A#CpBPrv Z sv1KA, JPH|(@.Ph T(v@PȀH)Ug  A#CлBPwv]0b0+g  A#CBP{v]0b0+g  A#CPBPv]0b0+j  A#CBPv]0b0+Uj  A#CоBPv]0b0+j  A#CBPvȯ v1KA, JPH|(@.Ph T(v@PȀH)ug L A#C0BPyvд]0b05-g M A#CpBP}v]0b09-g N A#CBPv]0b0=-5j O A#CBPv]0b0a-uj X A#C0BPv]0b0e-j Y A#CpBPv Z v1KA, JPH|(@.Ph T(v@PȀH)g  A#CлBP}v]0b0+g  A#CBPv]0b0+5j  A#CPBPv]0b0+uj  A#CBPv]0b0+j  A#CоBPv]0b0+j  A#CBPvȯ v1q *bE(A!(t*05QQC #  pC ,0#BTf@F0ܰi`0@; Tl0N4C`0A 7 Z,C,AN00h@04 T@1`0S  @A,Ab$qpC`0˰(2`2^bP,3P T)T0 2FOaq0PCq= TxPh@M1 P`  }X0,3P180p@ I<*0P!B= T£aO@)tܣ`O@(tCCpC`0u17|  @A,U*,5P0U1 V  P`  X0,5P18P 7d2\\@XL A+,CP0KYD@``B62l],Dutt%&p F )p xᅦ *@ue :, l<Q A9,AzO@x|0PŧyOfGBpC@`0fPS:pC`00b@A,XDd0Pb pC`0`elQ=, tBhdAA 7" g@ xz`6 z遧N< Jp!aPB n 7, iD@"A,C<@> T P32E0 2$.M1K `0o L 1K(1VL, \ 4e(DJQ@$ 72B@R@.,BDpCp,@&RT010:<`0@uP_ yp@A9Ȱ XA@cA0Ph000A 7i`%:`$; Tq ; Tm0; Ti@1 ,ñ&B]@PSpC`0˰ A)n P`  X0TB@{ APpÀ a0 ,( 2PB A+, +1K 8(J (1q *"E(N AP0EUQC A#   pC0,0#BTh@F0m`0@; Tn0N4Ca0A 7 [,C,AN00@04 T@1`0S  1@A,Ab$qpC`0˰(2`2^rP ,3P T)T0 2F}O;q0PC1= TxP@EL P0lP,3P(T0ph@ I< 0P!B= T£?O(tأ>OtCCpC`0u17z @A,U),5P0 RB2X(P,T @AA-@PB5܀`0pq]17 V @A,fh pC`0˰uz 0K-B 0K 4P(h4P"&*pT 7 2|g@P;TPD 7@ g= Ta`i@f= TX 1 A8,CA@EL A9,AP<d`@`0 ,A ;D1f0e0 2%|z3Pb4I 7& HA 24h TA g02tnJ1K mO@80P$l@j@ApC`0lPKp`0kLT2, Th 7L,A0b0.),1OJ, \ba(C)J 72B@P@Ȯ,ABDI<@A) XAa PP1KPXȰ ` D|fD t00A 7d`%:P; Tm` ; Ti`0; Te`@P`dȰ2IPIA@o2l`pL`R,(Q@Yw C(pfPB  T~@(@ c 7 H)T,A2D(1KR, P(D8T0QTPpCP,$Th 0,0P1 TF`p,p58:8`0PAmP0K`DAEII$ a'p1@ŀ Q  Tp@A,Rg@0K TBB hp`0,A@jV0Kh@, N/,CAAPp`00Aq 0K TB)AT:2@OPzpA,ATnphAD1#I, 83 0܀`0p~@A~#B0Ĉ{#B`  A 1K00H`0PJQ  $,@I1#I, 83 0܀`0p~@A~#B0Ĉ{#B`  A 1K00H`0PJQ  $,@I1$J, 83 0܀`0ˀ~@A~#B0Ĉ{#B`  A 1K00H`0PJQ$  $,aqRS$1)K, qw3 0ܰ(`0ː11  ,,2b P0b lĈ!1bPH3b P0@`0@~5  U,2PL`0ˀ(bV(Y1K d !$Ё1-L, uCx3 0ܰ(`0ˠ11 A ,,2b P0b lĈ!1bPH3b P0@`0@~5  U,2PL`0܀DbPB H,b nY,-p`"$p`1-L, uCx3 0ܰ(`0ˠ11 A ,,2b P0b lĈ!1bPH3b P0@`0@~5  U,2PL`0܀DbPB H,b nY,-p`"$p`1.L, qpq3 0,`0ˠ51  l,2b P0b lĈ!1bPL4b P0D`0@~5  ,f,2PP`0܀LbPB H,brZ,-j`"$jX1(I, p3 0ܰ(`0p~@A~#B`ĈA{͈~@ApC,0#BH  72DpC,kQ1܀Df0AY1K tf@A15O, ?<Q3 00`0~@A~#BĈA{ ψ~@ApC,0#BH  72DWpC!,b H lv,2ܐDf0ː0jX8a1ܠDf0,,c rZ85l4p1+H, JP@3 PTs5Q D BAJR4KpMeQT0Pb00e1ETppC ,Ca5H9d0p,1P#pC(͈Py#W N,q1G, P&.H8$@A@1 P0 `0 A TPD Vp&AT 72CP 72EPH1K`  ,q1A, PŠ`(P(IC@-BTH Ĉ:(F@HVAl"DQ@B:A Da& $,B\1E, \"te @A,AN,: T  @@PAQE A D1E, \b)A!8  A  D1  B%: IU17b0<*@°YuQRBA]aRFq1E, \U@A,AN,: T  @ PAQE  D1F, P T %(W "  L1 A B%:0AE ,AQr#CB0KP T UMk Q%5Pq1H, \&)@!8 7 2AP 72 BPQA4\A$uICU 7 2EP<1K` A ,Q` a1Kp1F, `  E1: TBj0P@AC 72C@BA0K@ T ,Ie008`0P1 11111 A, *P2E0b`h!1C, P 0b`m a 72B@L@P0l@A8@`11 A, *P2E0b`h!1 A, *P2E0b`h!1ÈX ,C #C0K 1C,  Qu0b`kAA 72B@J@P0l@A1D, PTe0KP0`00A Z$pC@,&81A,  1E, *P4 A/2AN1(pCP,0# C1K@5 0b080@1#CCp1ND, PBep!QЀ9)@q ,P (V VDp&3 `0$twcpC@,C0#CŒ 72 B6&cQ2VP7vEcfcbQ6vP;6ccccQ:6P?cdfdQ>FPcFfdUfdQbFPgFEfdfwQfVPkVfefeQjVPoVfegeQnVPs #Cœ K#)TpZ)HY1111A,  +q11D, 4 72 A@F@%@KhEp, Q1K0 t1<I, jػЄ0%(H Qz L" T@V,NNN@B@TaW`Ip%ԒpC,0=Q@@ ~ T @ 2Qap1Qw<\ˆ #à Y@ $,B@BA DA@́ $,B@BA D@ $,B@BA DB@ $,B))@BA D@ $,B++@BA 1A,  51 A,  RE @A@7X1 A,  RE @A@7DX1 A,  RE @A@7DX1A,  %@A1 1A,  5@A@ĎFp1A,  b5@ApC0@`1 A,  RE @A@7DX1 A,  RE @A@7DX1A, %@A1 A, REU@Ap1 A,  Q3Z,ĈA1 A,  Q3Z,ĈA1 A,  bt#DQAD1C1 111A,  %@A1A,  5@A D`1A,  %@A1 A,  cE@A Eƀ1C,  P̀  B  1b@/8$H!PPKQKB ORTB#DH1C, P̀C3 PG 72B0b@+8ʈ@`@A@P`  E0b0$<(1K TB1A, P,6 00 #pC` TPf2lPP#Á1A, 5@A D`1C, P3 0P #C@ r @B!P0`0  @AAAT#0C @ ,p1 A,  QDT0EQ#DCq1A, P46 00 #CpA TPf2l`P, `1A,  eZ1A,  eZ1 A,  P#DBA1u;QGrB1 A,  P#DBA1u;QGrB1A,  P#D1!@@BA D!b $,B1A,  51Ⱦ 1ľ1 A,  QeZ,D A1A, %0b@;1A, DT FQ`1 A,  TT  r1A,  RP03 1PTP#$1bpND S(1A,  $T D1D, QE @A@P00d0A D TD,0P( `1"H, :PTPEu@A@%P00`0 A,AM@ P0l@ 72C@K)@ T0P 72F@W@P0l@dX,1`1D, QBUU@A@P00`0) @A@3K0*APc@1D, Q"E @A@P00`0%@A3K0c@`1A,  DT FQ`1E,  Q V PGTb C B1  PİA AH@!1D, :Pb U @A@P00`0)@AA@ e4HX,0P `1D,  SUV PGTb C PAXİA 0K0a @`1A,  DT FQ`1 A,  QT9Z,D! A%q11A,  Qb()ZD) (PD= ;QR%9@AAT@UPCEPDmF;q1 1A, Pb()P #D࿃Ԓ,,@A@E l DE PPQBMM@A@ul z1&A, Pb()3 P $0b@!< ˆ #ƒ@ E9 $,B@I@BA DY% $,BFAi@BA Dy $,BhPb $,B1A,  %@A1A, %@A1A,  %@A1A, 5@A@1 A,  Qu   1A, %@A1A,  %@A1A, %@A1A,  5@A@1 A,  Se u  111A,  Qq1A,  {Qq1A,  1A,  1$H, \bP a R@-0PHAg@AD B@ "A3K`PA AX  G0,1P13lD9sp,pb@1A, :1C, :PbTTT@DpC,@A@ 11K TB1A, :1#G, \h a R@-0PXAW@@]AD B@ PSM4@A)11lP A FcA9 72F0Q`1A, :1 11 A,  QUZ,D A1A, %0b@;1A, DT FQ`1 A,  TT  r1A,  RP03 1PTP#$1bpND S(1A,  $T D1D, QU@A@P00d0A DT,@L@ P0l@ 1 H, :PEu@A@%P00`01$@A3K0*AP0K0 TF   P@Lp,CatT D,AG,1@1D, QBU@A@P00`0) @A@3K0*AP0K0 TF 1D, Q"E @A@P00`0%@A3K00K0 TD 1A,  DT FQ`1E,  Q& e@A@!P00`0 A P00`009(@AA@1K@ T 1D,  Qbu e@A@!P00`0-$@AA e(H 1D,  Se@A@!P00`0- @AA (J<1K0 TD 1A,  DT FQ`1 A,  QT9Z,D! A%q1Լ1A,  Qb()ZD) (PD= ;QR%9@AAT@UPCEPDmF;q1м 1A, Pb()P #D࿃Ԓ,,@A@E l DE PPQBMM@A@ul z1&A, Pb()3 P $0b@!< ˆ #ƒ@ E9 $,B@I@BA DY% $,BFAi@BA Dy $,BhPb $,B1A,  %@A1A, %@A1A,  %@A1 A, QE@A@A1 A,  Qu   1A, %@A1A,  %@A1A, %@A1 A,  SE@A@A1 A,  Se u  111A,  Qq1A,  {Qq1A,  1A,  1#G, \bP a b@10PPV@[AD B@ "A0KPP A\  F T@QM\0 hh0 @@F1A, :1C, :PbTT@DpC,@A@ @ 1A, :1"G, \h a b@10P`V@`[AD B@ PPQ8@A)E X0D`0`A5T#pC,`A1A, :1A,  b (j@ Pu2ATkC00b@*8N=lDAl1A,  2D%PPDKPAH%8PUDQu1ػ 1A,  %0b0081 A, $T U0b0.11 A, $T U0b0.1A,  Q@(d@AP(P:Ak!C0PO NPP@Y Pu@A@il WAmPPq1A,  2tlD!PPTD@5 PC+0AQ@PPT4T e0b@28A XP] Du\P]AA q1A, ЌB3ePF$D;AJ@PPMT0b@(8A@ $lB3AeRAi@BA z1A, ЌB3ePF$D;AJ@PPMT0b@(8A@ $lB3AeRAi@BA 1X, \fE@! TBe& 0 kAU@PPA@ApC,C`ULP] 72 B0b0,/,CE8x@A@T@A@7f UT0b@l8X`a0b0`<\PP@AQ0K`(@@A A,u%PP C HPpCP`0L02PI0PwT@A@  7 2(K@F@ @$ 0KP3K T ", ,C&B^@PS(pC0 `09M$ 72T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#1KH, Pf i A(43 0 3 0 3 O0 3 00 3 ` #ꂁd 72 AGTp0b@?8$c@_@PP5PPe@!PYA@Zvf@vf (@LV5b0=Tp\P^@H@PX{}1bpNd7bpNh7lA0-FTc0b08T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#15C, Pf `3 0 3 0 3 K0 3 0 1bP.S@EpC,k@AU@PPX#C@ >TpXP^@H@PX{}1bpNd7bpNh7lA0-FTc0b08T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0E)s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0E*s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0*s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0*s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0+s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#16C, Pf0E+s03 0 3 0 3 L0 3 0 1bP.T@EpC,kDAV@PPY#CI>T`P_T@H@PX{A@6b@5b (@LK @A@b@Y@ bPP#1A, %@A1/H, :P"4 .L1 U `3 KP$a#p4 72A@R@PPe@A@PPC 7 2C@F@P0l@xJ0pC0`0PA@bP@AT ;b@8,1P.P``1K`1 C, :#CP0`0 BX1A, Ќbq(3 @H@PPK#ЂCT2lPP#`2b`q.H`1dP, \f(L1TP3 IPRA[1C1PP NPT@]A PC@A@ml W1AqPP_QA[lD^`+q11A, QB (FAR$@A>A58@A@Ml@DQ;BtTP@Y@PP\#DCAyPP5l0^AAAA Qi@Pi@AAPPu+ATPPCel@Dtq1A, ЌB3ePF$D;AJ@PPMT0b@(8A@ $lB3AeRAi@BA z1A, ЌB3ePF$D;AJ@PPMT0b@(8A@ $lB3AeRAi@BA 11D, *P#  B@FA #EւZ,p1D, Bp0#PA_   L 5`00A;ZAl8J#CPh HZ1A,  Q 3 0@dĭĈ91FA@ɀThJ1A,  Q 3 0@dĭĈ91FAɀThJ1 11A, *P# A^ 1=J, *PhC R `0P 7 2AP C T1pCtC"@ PuY@A  Gx;b@{8A,AG$ 7 2HPcE 78@WL 1TDhp#j 1K trF AtA,A2PnP1=J, *P⹀E C R `0P 7 2AP C T1pCtC"@ PuY@A  Gx;b@{8A,AG$ 7 2HPcE 78@WL 1TDhp#j 1K trF AtA,A2PnP1<J, *P" E(TP&@A@ Tt C  BAU$ B TApCtC"87K@ T a@AĠ A d@A@ߎAc{Qc#DB 7` PP,G$2PA@PH lAF@j %A@rA1 1 A,  &@s3 a`kWA1 1 A,  #CpB0b@H:U$ AAy >1 A,  #CpB0b@H:U$ AAy B=1A, Q `#G`Dp1A, Q `#G`Dp1A, Q `#`Dp1A, Q `#Gpp1A, Q `#Gpp1D, QB9TT0pC0,C #G(1K0Ԃ$@AA ,p1 11A, *`4T0pC0@`1F, *БZ!(s0 "  \ M7,2 DPr P50KPԳ@  AQ l D ҮDY lP,A1P8BeUNl1A,  %@A1A, %@A1D, QB9TT0pC0,C #GD(1K0$@AA ,p1A01 A, Q `#GQ0bpgN11 A, Q `#GQ0bpgN11 A, Q `#Ga0bphN11 A, Q `#Ga0bphN111A,  %@A1{AO 1{AO 1{ O 1{ O 1A01A01A@ 1A@ 1XȈAP1XȈAP1F, *БZ!(s0 0$b0̵pC,@0gQEE TTB0^AW3 Ĉ94TO1 XȈA`1 XȈA`1I, *PBpT0pC0,@A,`,8K0 TF  D D ] S2Q EA0K T!TP E A] 1P1D, P0TT0pC0,@A,`$8K0 TF 1D,  Q0TT0pC0,@A,P @@1 A, $ 50bP9Dp1 A, $ 50bP9Dp1 A, 4!50bP9 Dp1 A, 4!50bP9 Dp1 C,  DT0pC0,u@A11A, Z$T (1F, *P"p  ES  $@A,QNQ@0P(1? X11D, *P"pdT0pC0,@A@-P0K0TÀ!@T,0P0! TJ@#E$1D, *bT pC@, @AGԓM 9A%1K09 3bpNDP(#G 41D, *bT pC@, @AGM 9A+%1K0@9 3bpNHQ(#G0D4r1̳ 1{A; `1D, *P"ppJv @IL72APLM 4P@`A |T1I, *PBpT0pC0,@A,`,8K0 TF  C D ] S2Q 5A0K T!PPG AE * F1{ 1MT, *P(sp @OL72APR VQA@`0Xd0@p%,d ȁPPH`@ 1P$1xd0$A72,JPcLA,3P 7Df OtQ@A@@A<HDO@ :1[(,5@/0$ tdI,4@M0Ra: ),A5PPL8 U+T :2\VP AXp ,VlpZ,@}A(AM (Ԃ-h 1I, *PBpT0pC0,@A,`,8K0 TF  C D ^ T2Q 5A0K T!PPG AE A( ܦ1{ 11QR, *PB` @OL72AP VQ@`0Xd0@e W 7L7X0p mPZ6 0Kp T @`lPԱD 7`@ TqpC,eP0K,pt`00E`PB T5EA@PH#GmpNqodQ#IBTGTP0K t-BS:Ё|QALo A,x1KUg AD1 G, *P"pT0pC0,@A,P@@@O@BPTpC,CAEI@A@YP0K`v!@T,1P0Q T A#GP<`1D,  Q0T0pC0,@A,P@@@O@BP#G51D,  Q0T0pC0,@A,P @@@N@BP#F`41D,  Q0V @JL72AP  $0K0 TD T E a #GǂES1A, P 3 0@ 3 0` );AJ@- PPC#PD42bpN(G,#@B(D1I, *PBP0 T0pC0, @A,`08K0 TF  C D ] SpC t0 Ha aGZ`\1@ B01A, Q 3 1bpzN1#GQ0bpzN111D,  Q0TT0pC0,@A,P @@11=K, *P"t̡3 H03 J0TppCp,%9@A@QP0K05!@TP,0P0! TJ@T0pC0,CAi@A@}P0K`T|`C@D1Дc1 dPC  7d ԤP 0bpN;Aalp# (A c@  J,2P .p1/H, *ЁB̡3 H03 J0TppCp,55@A@UP0K05!@TP,0P0! TJ@ F%l1ldPC  7t2EPPb0bpN\6bpN`6bu* v*\pC0,a%01Kp A *P`10H, *ЁB̡3 H03 J0TppCp,%5@A@QP0K05!@TP,0P0! TJ@#C6 hdP @5;ppC,Q|P %aa$@ 0 X6 `0pA M%`  )1 G, *ЁB*` @KL72APNCID @0Tb0@A pA@C 72EP@ t01<I, *P"̡3 H03 J0TppCp,%9@A@QP0K05!@TP,0P0! TJ@T0pC0,CAi@A@}P0K`T|`C@D1ДL78fpC@AL7PT 0bpN;Aalp# %(A c@ p@A 2 GP@ ``1.F, *P"̡3 H03 J0TppCp,59@A@UP0K05!@TP,0P0! TJ@ F pCA L7PH%`#el#Vl# @"A`… 7 %:PAN@€1/F, *P"̡3 H03 J0TppCp,%9@A@QP0K05!@TP,0P0! TJ@#C6 hd00lAA 7PtCpPb:]a܈:Ya܈AЩcةe  %:PAO@€1&I, *ЁB*` @KL72APNCID @0@`0ˀp`A]L7PF: TQ  1  G,2 |P`1.J, *P"t*`T0pC0,$@A@= P0K0T!@T,0P0! TJ@TTpC,CAU@A@iP0K`!@T,1P0Q T A pA 2$GP@Iaܩ0HpC0,C%X1K t *1"G, *ЁB*`T0pC0, @A@A P0K0!@T,0P0! TJ@ AU %;`AQ@m o* 72EP@ v01#G, *ЁB*`T0pC0, @A@= P0K0T!@T,0P0! TJ@#CA5 Te0 D`PB Af0\pC0,Q%,1K` tB1 XȈAP15L, *ЁB*`T0pC0, @A@= P0K0T!@T,0P0! TJ@TTpC,CAQ@A@iP0K`!@T,1P0Q T A   78a %a $,NppC@,Ò%Pc#E, ,Uh1K tb - 1(I, **`T0pC0,@A@AP0K0!@T,0P0! TJ@ T  7UtC@e0KPh@ 1 `0ˀA Pc#EpC,CqU81K t1*I, **`T0pC0,@A@=P0K0T!@T,0P0! TJ@#CA5H`0ˀp`A]L7PG: TQ A 11bP>A 72GP@ 0B1 XȈAаP16M, *ЁB*`T0pC0, @A@= P0K0T!@T,0P0! TJ@TTpC,CAQ@A@iP0K`!@T,1P0Q T A ,8020HP,N ? `0(@@ KBA@J 7dU2,PF@ ,`A 1)J, *ЁB*`T0pC0, @A@A P0K0!@T,0P0! TJ@ A% D pp,CRU`0Kp(p2$G0PbPc#Ep Ut0ˀBM$%1+J, *ЁB*`T0pC0, @A@= P0K0T!@T,0P0! TJ@#CA5 Tb0@$A 72$EP: 70,CrQ,11bP<xA2d`P !,A2P ~@1'J, *ЁBC @JL72AP ԓCED @0Pb0@$A  7 2$EP: 70,CrQ1 5l0` BK$%12K, *P"t*`T0pC0,$@A@= P0K0T!@T,0P0! TJ@TTpC,CAU@A@iP0K`!@T,1P0Q T A AtC0Kp@ 2L`0ˠ A bXĈA}pCP`0ːAY(1%H, *ЁB*`T0pC0, @A@A P0K0!@T,0P0! TJ@ A%eQ0 BJ0PCQ 72EPXĈA|pC,AU41Kp T1&H, *ЁB*`T0pC0, @A@= P0K0T!@T,0P0! TJ@#CA5 TbPV !,N0pC,Q%d@A@A_ 72DP@ ~@`1#H, *ЁBC @JL72AP ԓCED @0PbP T,N0pCp,Q%`@ApCP,a5,1Kp T1.O, *PB*`T0pC0,$@A,`48K0 TF TpC,CA5E@A,aT8K` T! TVIm$q,â(e@A,S^@@(2ܐDd0ݠHA|p pC,C UCV11D, *P"pdT0pC0,@A@-P0K0TÀ!@T,0P0! TJ@#GBEMB1*L, *Ё"*`T0pC0, @A,`48K0 TF  D %;@peV 7L7W0pLiP0Kw,2PpTptw#E A  7(2,J` T 1I, *PBpT0pC0,@A,`,8K0 TF  C D ^ T2Q 5A0K T!PPG AE  ܦ1 A, #C0bp!,@1{A 11@P, *Pt0spT pC@,C 6QCAP@R@V  "8@@X0P  C  pC ,QTi 71,cEE72 GPd  pC,g,Q,2PTC:24LPp@A,Sr0K T#4@ApC`0K 0B X1A, *%1F, *БZ!(s0 "  \ N7,2 DPr Q50KPԳ@ A AU l D D] l`,A1P8BAvUNl111D, *bT pC@, @AGM 9A+%1K0@9 3bpNHQ(#G0D4r111111A, Q `#G`Dp1A, Q `#G`Dp1A, Q `#`Dp1A, Q `#Gpp1A, Q `#Gpp1D, QB9TT0pC0,C #G(1K0Ԃ$@AAЯ ,p1 11A, *`4T0pC0@`1F, P 0 !  \ K7$2 DPq N,0KPT@V܈ܨԴBVAT%T4 ĈAyXUYQ1A,  %@A1A, %@A1D, QB9TT0pC0,C #GD(1K0$@AAԯ ,p1A01 A, Q `#GQ0bpgN11 A, Q `#GQ0bpgN11 A, Q `#Ga0bphN11 A, Q `#Ga0bphN111A,  %@A1y 1y 1y  1y  1A01A01A@ 1A@ 1AȮ01AȮ01F, P 0 !  \ K7(2 DPq N,0KP@V܈ܨԴBVAT%U4 ĈAyTV]Q1A̮@ 1A̮@ 1I, PBpT0pC0, @A,`(8K0 TF  D D ] S2Q EA0K T!UIRB\1\d0 `A1ĮP1D, P0TT0pC0,@A,`$8K0 TF 1D,  Q0TT0pC0,@A,P @@1 A, $ RD#ER1 A, $ RD#ER1 A, 4!RD#ECR1 A, 4!RD#ECR1 C,  DT0pC0,u@A11A, Z$T (1F, P p  ES  ,@A,QNQA@0P(1 X11D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@#Ep$1D, \bTT pC@,@AMN 5%1K0 3bpNDP(#G 41D, \bTT pC@,@AMN 5+%1K0 3bpNHQ(#G0D4r1 1y `1D, P C\T@pC@,$@A@9 l,PO !1K0 TF @Q41A, 51A, %1A, *%g[q11 A, $ #E113N, PPT0pC0,Th 0,0P1 TF D50:0`0ː ,CAu=@A,Q@@B1bPsZVZ&5AT`pC`,rl1KxgKA4 P0KDc@`(Rg`ňW i0KР1D,  Q0TT0pC0,@A,P @@M1D,  Q0TT0pC0,@A,P @@1D,  Q0TT0pC0,@A,P @@M1D,  Q0TT0pC0,@A,P @@1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[q1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[q1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[Qq1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[Qq1D, P0TT0pC0,@A,PJ@@M,1D, P0TT0pC0,@A,PJ@@M,1D,  Q0TT0pC0,@A,P @@M1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[q1D, P pdT0pC0,@A@- P0K0TÀ!@T,0P0! TJ@DRC[Qq1D, P0TT0pC0,@A,PJ@@M,1A, *$XDb1D, P0TT0pC0,@A,`$8K0 TF 1A, 1C, ZЁ pdT0pC0,Th H1 I, PC T0pC0,$@A,`,8K0 TF  D DT$ĈA̭0,2pIQPP$%UHeTvI lpEmY0zA 1>P,  P  OS  P(`@ N0PpC,CAuQ@A,aT8K` T!1pd0p AuJT@ALPG`3K T d:d`0ˠ$A,ec@@$RMH AAAj17d lP,S1KRLAJ,  Q  ( 3 0 3 0 #B@V A1:`0 AMP0K0T,0P @T1bP 6b PB  D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HP[A$#… 1>J,  Q" %* 3 0 3 0 #B@V A1:`0 AMP0K0T,0P @P1bP~ 6b PB A D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HPtf,A2b0`-`1>J,  Q  ( 3 0 3 0 #B0V A1:`0 AMP0K0T,0P @T1bP 6b PB  D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HP[A$#… 1>J,  Q" %* 3 0 3 0 #B0V A1:`0 AMP0K0T,0P @P1bP 6b PB A D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HPtf,A2b0`-`1>J,  Q  ( 3 0 3 0 #B V A1:`0 AMP0K0T,0P @T1bP 6b PB  D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HP[A$#… 1>J,  Q" %* 3 0 3 0 #B V A1:`0 AMP0K0T,0P @P1bP 6b PB A D S#06b@HS+1bP!*1B0,`0`APMa5b0'*|#D`OĈA ԏ 902$HPtf,A2b0`-`1<J,  Q"%P3 0 3 0 #BS 91:`0 AEP0K0,0P @L1b@̈~@A E 72E0bPb*HN @ ڈ#LSĈA T IRpC,q#`B5Ԉ@>U: A BN aЁ,CUU: A BN aЁ,CUU: A BN aЁ,CUU: A BN aЁ,CUU;B#@S>*p|`0ː AO,1K O 21>J,  Q" %* 3 0 3 0 #BS =1:`0 AIP0K0,0P P1b@̈~@A E 72E0bPc*HN ۈ#LSĈA IRpC,q#pB5Ո>U;B#@S>*px`0ː AOTAɈa1;I, *P"`3 1 #D@(Ka #P΂$TPpCp,U4@A,`H8K0 T`PPK:1 T B 0Pd2pC  G TR^BVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *P"`3 1 #D@(Ka #P΂$TPpCp,U4@A,`H8K0 T`PPK:1 T B 0Pd2pC  G TR^BVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *P"`3 1 #D@(Ka #P΂$TPpCp,U4@A,`H8K0 T`PPK:1 T B 0Pd2pC  G TR^BVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *P"`3 1 #D@(Ka #P΂$TPpCp,U4@A,`H8K0 T`PPK:1 T B 0Pd2pC  G TR^BpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *PB`3 1 #D@(Ka #P΂$TPpCp,U,@A,`H8K0 T`PP:1 T B 0Pd2pC  G TR^BVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *PB`3 1 #D@(Ka #P΂$TPpCp,U,@A,`H8K0 T`PP:1 T B 0Pd2pC  G TR^BVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1<I, *PB`3 1 #D@(Ka #P΂$TPpCp,U,@A,`H8K0 T`P`VP0K@@!֠N@(C 7P0݀BlЈ| iU 72G0 b0ˀi> dp#Pp#vU0;K T2bP,pB@\,1;I, *PB`3 1 #D@(Ka #P΂$TPpCp,U,@A,`H8K0 T`PP:1 T B 0Pd2pC  G TRBVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1;I, *PB`3 1 #D@(Ka #P΂$TPpCp,U,@A,`H8K0 T`PP:1 T B 0Pd2pC  G TRBpVpC,Cq " XȈAp:16bpN\6b0<-\WJ\b@ La #&1DK, *"uiA!(+3 1 #B0T@pC`,DTh0bPw+ DHL7 2CPKVQd0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAgi#Dfa0b0+tWI\j@ $1bP+A11DK, *"uiA!(+3 1 #B0T@pC`,DTh0bPw+ DHL7 2CPKVQd0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAgi#Dfa0b0+tWI\j@ $1bP+A11DK, *"uiA!(+3 1 #B0T@pC`,DTh0bPw+ DHL7 2CPKVQd0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAgi#Dfa0b0+tWI\j@ $1bP+A11DK, *"uiA!(+3 1 #B0T@pC`,DTh0bPw+ DHL7 2CPKVQd0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAgi#Dfa0b0+tWI\j@ $1bP+A11DK, *bEK (+3 1 #Bp0T@pC`,DTh0bPw+ DHL7 2CPVQX0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAwi#Dga0b0+tWI\j@ $1bP+A11DK, *bEK (+3 1 #Bp0T@pC`,DTh0bPw+ DHL7 2CPVQX0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng#p62I0 b0ˠ A h0bpNAwi#Dga0b0+tWI\j@ $1bP+A11DK, *bE(J (+3 1 #Bp0T@pC`,DTh0bPw+ DHL7 2CPVQX0KP T 2},N æ`; T%`:h`@EtCU,Ј{ j 7d` G 72(H0bP+xClࡁ: UW,2Pr aA,1DK, *bE(J (+3 1 #Bp0T@pC`,DTh0bPw+ DHL7 2CPVQX0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng@#p62I0 b0ˠ A h0bpNAwi#Dga0b0+tWI\j@ $1bP+A11DK, *bE(J (+3 1 #Bp0T@pC`,DTh0bPw+ DHL7 2CPVQX0KP T 2DIZ0P1lU(@p)FQ¥ ATL7 PEng@#p62I0 b0ˠ A h0bpNAwi#Dga0b0+tWI\j@ $1bP+A11`U, *P"0spT pC@,C 5@Q@BD eE@A]QPX`@V0P@ T @tTA 7,CD51 d0ˠA72FPjP@A,Rl0K T! N KD @@,5=PA#IBHAEATu@A,2P JaPT:24LP|`ANyp ,SAT,N?8ɣN 1+Q(@ (,t 1@ @0T:2LRP` @A,U 0K@ T$Ll,q19J, PBP0 3 0p 3 @M5 B N @ m@c(@  0l"Ĉ:$ӌ@ H,A2P!0PH@!j2AYL72EP\a0Kp TD!Q#Eʂ,ABK6ATA|@d00`002i10pC @`1+H, *U1``0pA%J 72BP08`0p : T c &2lP 0PA 7 2CN@bH: TC 72FPDc &2lP 0P@ ` )hPA 1+H, :U1``0pA%J 72BPDP  (@0PPc &2lP 0Ppr,1:0P4h@0d`0pA PS 2D0` Ua 1G, * F F 70P@AU2N ?`0P@ @C 1&b EB`u00` B) 72B0`f`1gV, P - T0pC0,C 1@P@BD UA@AYQYPL`@V0P@ T @x԰eD]72HDP @ JpC,af i@@11`0˰$AEMRD9PHa,IP#IB\fUy@@A,2hJuPT0:24LPAN{p ,SH%l10  ,$OK< #O +Q)@ *,C 1@ D0TP :2PSP @A,AU 0KP T$P5 %18J, P 9T3 0 3 @N9 B  (@ m T 0PP,C" 1bpN(I4#@b)4K Tg : T0@ e1@:@`0`AqP0Kp,1P`TD[D] d0 5QOA 7 7e 1p@`1+H, PW$Q  GTDG72BPG,RR  h@j P@UAPX ,CAUL0 `0p : T Z0P  \0p`0pAP@DA|X ,CA-*A *1,H, Pw(QA A GDH72BP RT%90 T`0p : TBP@EA\X ,CA I (@p̠N@bPpC,a5h@A@P0lPa 2D0 T 1G,  F F 7z0P@AT2N ?`0P@ @C EB`U(0` B! 72B0Pb`18J,  Q %P3 0 3 @NL72AP 510K0 TD ##S@bPpC0,Q#0BԈ=T1 A L騐 7 2G0bPg*PaV#`B7b@HU+1bP$*:B7A TĎA 218J,  Q"D%P3 0 3 @NL72AP 590K0 TD ##R@bPpC@,Q#0BԈ=T1 A L騐 7 2G0bPg*PaV#`B7b@HU+1bP$*:B7 TD\.1K`1D, *P"9#G"@IL72AP  M= P0K0D RQ @ 0P8!  C0bP,hq1E, \"  C pC`,C@#BpC, C #CB1(`0ħ L AK 9P8!`1 `1`1 `1 C,  B B ,p1 11 1 A, #C`B0b0%)#E0BC#)$1 1C, :P"4ep3eRD)PP#0G 72B@F@  ,0b0:)H1A, %@A1 1C, :P"4ep3URD)PP#0G 72B@F@  ,0b0:)H1A, %@A1 1 1 1 1 1 1 1A, *2#`JB1 A, 3 1l0C0b@')S10b0) 1A, *2#` JB1 A, 3 1l0C0b@')S1E, \bh AYe#O #ЏpC`,$ 72BPUpC,@e,@A䈁0b0) 1A, #PANB1A, *2#`NB1A, 3 1b@&)S1A, 3 1b@&)S1C,   P0e0b0)  | 9bpjN`% 1A, %0b0)1C,   P0#CB1`0ħ#G01ئ1!A, *ЀF41 jRãٲ葧̆#B`B9  -#BПBI l RRITSTTC9{ 1b0N#CAJ$1A, %@A1$H, ܰTAE@# #CЛb#PB5 2P 7$tC0 72COD,1K0 TF0L`PpA 20DN@,0P  %\@AlXpC,Ca% T32 7" hp#GP'aA# ,CAp1C, \"EvPMT`d 7 2A@O@P0K TBdPPAU@57VL7dV`I`Bp1C, \"vPLT`d 7 2A@O@P0K TBTPPAU@57VL7dV`H`IW1TR, *PXEZTՀBV P 7p2B0b`)HܦD 7x2 PP 72DPa ,Cq b`0`DAADelPQg A,ClPEAXQmp@AP`I AA@GpCЁ, 72(N0P!B" ZvuhTPCj 7x KTP020MP,, γ1K1K(P@A@{1lBP)B0K1/L, *PBl RB0%([%1@ApCP,C #FPSQ,BUpCp,ðu=@ApC,CAE@ApC,Cq [ [,ik|Q``xC5P02 JPf  TlAJl1K1BM,  QUe+PR 7T2B0bp),5ILU PSĔ&Am PP]%`RB<1b)`y]0Phf00,AA%xPd`,N@(@BpCp,C4 72 F@HpCP`0˰%x:2,J@XpC`0ˠ$97j G#H0Tt0r0`d0@,AĂ1D,  ( FP  702B0bp})̢$C5P@A@P` I AO0K01 C,  u@ApC,C #G$1K 1 A, ` C g)u@R@`1 A, ` C h)u@R@`1NM, P (X £d͖E 7L7$BPABQA" `@djX oA#ЇCpFy1A, \#B#CD@G"GbF< "1 C,  $ 72A0b )1C,   3 0p o2$`0 ħ@kQ1bpN $#ÀЈA0O1D,  `3 0  7 2A0b0M   TD`#@2b 8,2D! l Ĉ:$@C#0 ^w8JP1 A, \#A0 h0@1E,  R j{@T3 AIpCP,C@%DRPG1K 28`00~@ApC,@1D, `#@A   AL /4`00 C#E: 2d̤ C1&E, PU @A@P00`0 : TI(@A0b0#)E(0`0 AA@Ap,10P$"P0b0($ IA 72C0b0(T#Cv1F,  "(3 AA@A@5P00d0p, ĭ@PA1KPTأ41F, B3 ðAe@A@!P00`0@p, [,AF,0PP T&A#£#CЏs1 A,  S `#C1bpgNBA kq1 C,  $ 72A0b0(#CP0K 1 #EB01 #DB 1C,   3 0p #PDҋpC,#2b |8lAD l Ĉ:qM@qC#[s81#Dp1E,  Qt 72API 72 BPQ$\#uICI$8Q#EB1E, PB t 72AP  72 BPQ$\#uICI8Q#E@B1)G,  Q@ 3 P@A=%Q,0  R @ pCP,$ "2 JGuT E!lE%lQ)lOpCA5L7HB@  D@A#B42G0b l01Kp02$H0b l0,AB ,4ܐ1˰Hfgl FQklTn@՝lPpC`04,3b0p(pC `0<,CJpCP`0A)T@K! +(1Ԡ 1`S, \" (L Ձ8xq%k-X耀3 1b0d(H>  R pCP,$ "2 JGuT E!libb 7t +4@d`0@~A,1b0a(H ,q#d5 ,C#C$PY@,4ܰ1˰Hnil0FQ\m@l0Pb@B ,#sψ̡@ A,#v- ,C# CDPY@,4BA"DB` 1AL, \BuE(P! R8 3 1b@a(@OpCЀ,#@0H`0 ,ؠP 72,C0b08(X  2E)lBQda[L@XD0x`P %pq,Ca#bȈ` 7d A  7g J 1KBeq@ !(z1? \ PJPBu$p@@@8 w@@@@3 1b@i(dOpC, 72 ) pCЁ,ð _2lE%l`CQAXddP`9`TQ !PQp,2b0g(D ,Á#0d) ,#`C(PY@, 7ā  7 Qhl0auP`yqdvP`UlD302C5dP`=- 7 h@ !!,CAP!!bA#B0,A<,4`@2e 8b0%) 2SpC `0p@) D` AK,CA0b (l`1K  70 t0t@pC?D0` N 712#N 78 NDN0E8DXOX&@Jl PN1AO AN,CA0b (l1K#0BK 7d {AO,02A# C)T7Km~(1&;hɀ&12 \ PJPBu,p@@@@@@@瀀?: q3 1b@e(`OpCЀ, 72 ( pC,ð 2lrE%l@CQJAc@tP5lPO0 B E  #@32H0b l1K02(I0b l0,B, 720\0_0Pbfl0FQol Tr@űlPD-02NvD} 8+ejs5'k[agZZZ-s@@@@@@@@76V@ԀP:2G0 2E`uV pC`0@ A,4ܐL7L r,d s,t L, M,CЁ@UIw FP@UpV,k @UXURB,2PP TZB($RDC8 d dPp0PC ,L3PQP 903PQH Z0RH5R(@T PG TH-A 7<2@B %T5 AL GPYAT`EB 8 V h2DRQBQ8\%kAS$`ÕZB[/,4PTĠ)A]׼B'?I I*ORU߽sۀ '$'I?v[A[ & &%;A}p    a0Ka-Vi7,c  CBaqp0 LBI  74d p ,P@AApA 0A@4A`8,7K CD!p,72PU0L7p_0KDEe A,f Xp`hPB  8 ܡ`pB 0 mC\YVl\hApeT0K z0ABقC ,0 C /`C+,@1S, !\?ԧuE^\E?+BaJjPb?.5 q@@@lK-?pG\Tƙww]x*-Z?p@@@@퀀[%p:|b0A,402 D0PL7H H,T p"bPB  7(c   7,c@ HTR<C@PMiP1KTERcCE`@VAH i @@!H2PAh Tb IB 1K 7ApC`B 20MP0K T@A) T"@!)6THw HxURA@TPI 1T#,5PPT+1K U(QF@eQ% A 7h`&[-, %`R `HEL*1x *0:AE1sQ,  ?4%h P PĀ?u) 6[F[  L7 `6[ e 1,4`Tb00p,CAu0ܐL7$B0L7(B0KUP Ě@,1PR0PB! @d TLpC1h0p,p`0!pñh0ː(Aa Ur;KP@-i6k@ 1A(`` tP(`` tV(`` tV(``T pC0 LjUQ RtD TH A,SM%Z@lEA `eQ EQRDTVH DA80B3b0( ` `0=A+\[A!@ aa`1| M@T?CP?U?@a Pۄ) tBИG$( HUP8ONv@@@@ժ8 ,{;]jr\XY~/4vv5g(Hp@@@Acv8 yq1ɋ:pf{́;R߻.\x _V8 f )[(h *,pb00,@ ), {0ܠ2I0܀2$F0ܐ2IP+`BXp  H0 Z,A2\pa0 EB  pD `0˰K0@Ղ20M0`݂L7B{0܀28{Bp h0AP~ 4K +,4E {0 78A 2PU0b0(, AM 0 AgA dA V  a V `` 7:,Åu `p,C]-@Q7 ADp`0APp,VP\ A=,4K q b0e 7́8,CA0P , 79,A0܀` ,v PA %܂T.QD ATH Ԡ RBE`Z@lH, 7BJ,APj bg0Pfe0P؇I0YJ@TZMpd0ˠh 7 A jA9E@ r  tF@g@*i*g'h`%L@PAT!@D@5bZ@l9iVPL\ X@lND D DETIRJ@TPF H/RL@-AT0@yTPP[kVPQg@Hm RBE`܆Z@lHD e!EPRB !ZP0TV U"@BoHV U@@Xl[Q1Kik7 QA}~Q"T @T V: 2A },CAP xH!{0P2ATp p`AM!pb0 sRAz, 78I1@J 2ŹT2K ,AP`#VA8V"P5`:ejV#[8D 7AA  1KpaTaPXll[}H FQ}H!PFQC5 R~H5R@TPG TH5AH !%T#PCPQhʕ@ ,A0b@*g0KF;KB/2KB0;xrl3>2@N9)SVhF;1Z, $tE?@!*P?TȯEE(P*S@@@6 Ԡ$a܁#[ Èչ G4W8'sIpOR"V.p~148-3 `gl72BiSiA4pБ,à E,q ԁ:2EPC Dl ,AVduL ATA݁dD:2$HPA0EQ@AduTL ATP 0pCgd0˰Lȣ` TP`@VHAP2P20NPhZ*@ GP0P`04A%Hl-3K֢B0[A-`U1̂2C>l 0YB>`.ada D da%&ST9l ,O{TUJ@,!P04C0`]1AO h0ˀePC[TMP0T!M1K Tn$`nB&$jE]1[Q, \"ԥ@ Q@@@[ A BH@   72EP4KUpC",L:X`0p : T: T`WC%d:p`0p @q Th`(G1T0,A2PxEic5,NT; Td; TT; TDLkpE0 h0,A 1D!00@,2P C824JPp`08Ax0K T4AWA@X@,4PB!<1" w\ t?C0)D?Ps"]8G鳻P@@@@ LD( C_  o_?R 6ޖ8 `jjjjxľ¿>/tMG-9 ;+ ?^n{Ꙛn@zdEn732VVwv̙?$: !p=c^?.ȿCڣ/yqȋ?-d 8t~e{㯓]9Q߻}{\}V߀VVV8ȇ~; VPh6`pli`e Z A-,*p! b0@pC1 `0ܰBpa bPB  7$ L/A 2FP .@7 P#CXp5h0p,*HpЃ2LT0` `,* 7?,C ABH,* 7?,Õ r h0ˀBPzP4K t `0ˠmAI,* 7=p,#C`J@@<4Œ L -1, p@(!!"(|A  ?|A 7- `A@a0PQ`| b mP~PG L,AP4K L,CBPp,*T+Z@6;2>4K b0ːr 7J,A0 ;A kpC,L7.00 ca ,* 7BO,A0` f ,* 7X,A0ܐ ia ,*TH #!5AHDURGIH!%!5DXpl3[e1K d00tXpl90K@0!@Ec>C:SkZB[GjHUPD LT [A^YjlEƕGTgA@QT5,RR RB5R$RhHERAh@T PH HR`AT0@H `ZPlQIVH %PD[AzVDAԐRDnH !%P"RT`PX%T+Ԉ@UVjA(QPQNx0PQFw0PQG0wP`q&PLpC&`0@"xZPlI RʜHTHXpl1` ,CAP@7} {AE!B5$41 ,BPVH!P͙pCp&b0s&P A,(APݘHd"P Z2B(g"Ba ,(BPB`r*VAԉV*P`ejYPl)֬(QB k0.hP"tC0%H@@k@EA:BA[V'RAT 'RH@GD Tp'R@ T'PE  HzRMRB@ UHADTԟa Z@\ T29 2)#Dв`˕.ZB;Kp S ):2K ANMjC\Zp :2 >SU`EمBc@Be Cd Ca1Z, 3 (H S&ɓ*BVG?V xqt$c+zoEcw(Zs@@HVP7ӑp@@`[ DF4D`PC FuIT@ T@TATA%T A- 7D2CP& RfPRBEQa,1܀2DP@PWHB Q#%q %QEU1K` t `1  t (AzPkက$8 Hp@@@3 00 3 0P mR#\PG tCAUPiD% 7f B,0g; TDp0PB0 ,CA5@A@PHml0L%r 7,2 B+O;o2(SpfPvPEс h0ܐ,Z1PAQ%j0PQ%)@P-DT@ RmCURJ 7,l  T`IU 5l0L] ,:0hePA,HA 7` Lh@1Ģ=O@0= TK da RC PA8HB @9 F9,,3b@2)PA-ȈT` RDda51@ EPP>,43S0PP@pC@f00A0PD"ATAI,Ł> T#> T&#S '!P0@2TW0 2`VP#1A`5D,6%&t02PY0Pd` Z 0@;tB[\BL@3!PPJAtu 6H@A@/;1K 7h9 n MX,|PC`0't J4t C4RC:D AM apC-20PbaO"%TR3;P2 B\,AO@C25ATR; ]Av dPB ^,,? TFTPN@{!P`AauT Ri2`Q Th @P˘A!P@UT\@U@) \,A0PQgPlHE 4ܐqh0`K5X@2j0Pi"cPj@ ܆Th@ApC`0ːmoEx y 72%XURFT4ARCMhRK9NTmm00k3li0@k'li0b@)0 2 p0aQ Tm{XHT0RVF eo@T0ܠ2 0PxnPPqXA0PTqPHWPq@'"P@UE Pʊpg08gPB rAA0"elpPM/ ,A0 H+AAvA dP#f0PTԈ~@,)2A,.@E ݁b T T#R TUPŎp&f0ˀy T>t 7,AP0", Tyj,A+9@Eفc T T`&R,TUPřp&f0} Tvd 7,AP<", T}vUE 7B,C(BX =@9Au"#P@UEU 7  @EA OI0PQ@Bp+2* "e&P@5K E1PQBD#YZ`  T`*RlTUԛPEQJK@ ɫ,)B0ː iM@EB 0PF) P@"'P@UEHI5 PDD@{ 4ܐ2)E1PQ,@S?Y q Ԩ@UT+02*%Y dE dE Q.`@B5˰ ,Tb.@U ջ lp,A+PʺT+A l P e/4K 16E, ̣|`\htV߀z97È?9u驽|ϓUUU[A B48`PC D4IHHHH 7l2CP LPL sH AHe5mRBA$-1S, ?\@@B?( A1 P@@N@ Q  8 1!Ek )Zp@`M瑴?_My>:0݁aUs(L*Fjjjb #F\s߲7ٿge 88s9oFV`T0:2E0 2 B` DpaPB H tpd0`pòdPspC 5: 9` , ,C5TP@ 8uA 8 ,R@ 8U8-J$# <-@KRA ́T0PC THuuR@%TpPL TPN Hz%R@Q 7` @e,T/HRPA}T)@UAT@QH h0IA@M!1 A, <&[1R, )\?Pt?( A 8P@@@  q@@@/۟$_r?JgӾ:}/LjjC.G{r:/9sV~က6V:2D0 2CPgp0K 7g` D ,Ca A ',C#C 70h@  74h@ HR@c%4KaP@ 4 ,SRA@ 44-8 ?-? ꐠb 9%RA TPC THnuR@%TPL āTPN Hs%R@Q 7\ @i,AT[.HQ AA  GP0TDAp(2@QP ,A4PC90F!@ƃ1 A,  &[ @1 A, @@(V[PP1A, $1A,  1A, ,1}O, \Bt@?PB@5i DAjV@BGQ3 a۱h6PPFPl0lh0pPh0 A)b(;28CP hZl DAV0XhZpDUi ZPl]t CP@UD;bPb#`ہŒAUB[TZeg5kU2ZB;K)VZpC `0AЁV@;24F0 @ A*,A3``  7A*,C N*@B7 Ԡ X)pC `00A u ,$XB@ `MC@ N8bqD ZPl;HRPU@ǎ.Èx `AdP,3S 4x| `1^P, ?mkhA?VFG  <Dz%t97F[PCP  @V pC,#9bHh5ThVp|h00A%<#0P &0 2FPOm ,Á#DBWT,3@2$JPhopk˱&QA\ l%kAp#h0˰8pȁ20M0b@(T,3b@(AU,ShU~pT0mǚpDyp5V2K 0B$51N0b M$1 A, JP#E@;61D, Pw3 PC0`0 ~@1 C, PbT3 ŰtbP,RM4@A@ T @>N @pC,#3K  {#782b0 1L \ (LQJPԡOq NPQBEZD V0P?`5X9`![VZDEXJ`%E[VZDEXBU:`RBET;RBT@Ap,ÅUP\S\SX G@ ["@ApC,;HOfp#B`BTWecqB/E[ [W Q 1ܠ2AJPP[`xqRr%셔T@q%:2x_PİkSATP7PiH A5S%S 7 2A#BBT'y򙁷BlEi QkWAlgVAJUm n,ZAP!D VPm`5X_`[V0ZDEXAy`E[VZDEXB5Z%PPzhgeX@((@w0P8dA 7 fpA 7  hp@@RPP~hu C L70KP[& 7H2B5 7P2-UPP׊ A pAi/ 7 2%P0|`0oR#@ApC `0oHب55 7> q A A,AX0PqA!h/c F0pP2P0 2́E P0 2Ё ,Aɚ,A 9Ё@u`Kq0б 2E>&RB 7A yI 7$t z Aq PPby1K bx 7A@ A ),CAPr'0b |*p%*`0˰  P0ܠd0ˠ *D A,)B0 d0P  l@DAˆE  7 p AY D@ AF*,* 9b *pC/ 2B+ &/ l D PPABȬlCB9:2lQw.@ApC`.`00  PPį`rg%Z@q%: 2,E D%uj%Ћ,,T.P.PH PP/@Ap,C,B0b *@  f +dQZ/ lp,,ԭ lQ/>,A- Y-!20K* (+b0ˀTʤ T/Z2@ApC2`0ˀTQpC2`0˰  7" A  V2ZFp T03XhV!\! V@3ZEE2A1\,5P\ 8Xh@ 肫ႜB@ h#%@ApC6`0  7? AlE3A@ BAp!Zh T/A8h@ g T Q8}@nC vT6ZApC`,8C002C8/ T8@P  7PH P0` 7tÕ Aas @s`@`0.A88 7  A 2C9:kp5;AlB ,ZCP#D V:PA`5XA`[V>ZDEX`E[V>ZDEXBz%0P8029Z0B9L0i tP9 Y  ,CZ0PCAK=k <0PRP02=EP02= ,>CI,A> 9<@< 7tn -AEAA q$%@ApC`0˰pCbG`0#TWq`G@A@ {1K n,CHCPoG@ApC`0 `,Hlp,H 9p2H( Z@ ,AAmAT)P02DI A 'aA*DJ,JA* YJ@ApCK`0)T 72JQ,j-1b ,䈁IJ @Kd0/,TJ[K5.@A@;1Kpe QK[N[C/T8A8D/p82DM%%aPt\ 7 1!A.)ATNP ORB4QM;AbNP=I PPP00d004= 9P(2!@Ȅ l QOPDT~?AgQR82M !Ob0ˀ7OPAIa%eW%lpVYEc`V@A@s%:2NE [ l :`U&hlEZAEET[Y,Y0܀2NO1KDxTV:2EOo|uP?PPA]pC`00? TLO(>1PAO,>Q]a % j,XDP>P0Pd0 aQZPLԩTkiDkAFFň(PPjP0Pc0@cPB dQAF,Q[PIoA+l0^1Kp#BBAo Y[0K*=ceWඁ-:U:8^ 72EZ%Q|P00ܖ2Z% Pl Df^u^PG Q,QOzm)1KA|{זs_5,[Ԕ_0K*A~ ' nA ]TmQPPA@fbeTC֡b g02E\#BBgb@A,A\ T\[TyT c:z1 7t E;ܐ,C_EPPPz _PfChgb@ApC@f`0@s~3T`pC p`A7( %:P} 70  uf XpC f`0vfPpC g`0ˀwԜ QA,^ T]s`UT D)l@Dxr% Ap%A 2^uAWEh0g@A@fBl 72_FEaJij@A@AԬ QB A<,_JPiP02_Ŧ0e0 bPB kK񙬡pC0k``O732Jhu Pzl Dij@Ap,hFP~l`,hԟ 7%,iPP0 7P,CiFPlp,iT`QlPEj0kԪA@jj媁]~FvAA &%PĽAjoPI lPD!AA &'%A՜ 7F AAAA" ˁ,jFP/7D%1P!jPuC0 Qm6l0DفAA\Ի8.1K*0P< AA@ ?p)`0v@ApC`v`0 T"v@A@e ځA i *A 2l p(,kFP+JATmT PwPpCpw`0 Q,Al Tl{ "DlpwACw[AB QNAB]Tw1K* ,lFP=l1K@n̆!,Am@ Fb 72܆m5P02ImePPpCp{`PpCp`PC &6PP>A?T@{:2Fn~CTp{:KFD%uT{:2n~CT{:KF)TD1+"Q.~[AD 1܀2oAPl Dn@5VP>CInp@Ap,oGPPPuV@fTZehPkHI PA@ETP7.,CxHPC Z 8P2{0lruPPu1TP{`p PBRB I 1pb d0҃P02G{  qAa@]:0oPۿA/2,`0ˀtpCd0P02{zƆlp [Ȉ#BC)aTm@A@FP+@F 7 A[An@F 7@ A { A-,C|GP@ApC `0 HLj␵ lAP0b aAP0b a|(@ `ԇC},*_0PGa}TP0b aA -`0ˀ 7 ,A &,~,9|@B} 7A.`0"}bT@A@q ,CG 8`0%8UW Z|c1K"9@Apd00" c 7" !""XHA:A$2b 8pC2HU;@Ap,HP;A(D<[@H 7,H0 d0ː"(cQ=,>Y@I 7!,ËHPc 72Fl1K"#BCAHY[A@I902P x/2P x/RK@A@ATXd5'l0D!dU'lD)+pâ2Ĉ%dpKPNRBA@dp\ 7>,HPİ;AdENPBXRB5R OPd%e %@A@s`P00d0@#5Y9P(3"@̈ ~eEhe+l@D[PYI[8e 9r#pd  9#A ?@t`thU TVVPlU`TЗVVleDU@\ }@Af;K##8+ 7R`e%f %P5@KU@Kf;fAm@A@f\ 7,HPİnG{f/lEnP!%@LG Q m`0#<T6jA@ ;Ry@A@pC`0@&>T87K&P?gp P?gT@7 d&@##%g%g%g %@ApC2ɏ}@ApC,ØIFD}P?Lg6l#Q2ҟ,A9PBX?R@A@v0P0Pc0@cPB eb&A&<HTh6lDHPpMTh%7l AɈ&.lMRhih&њP025hpC6b0&kԁP0K&UgegP:i n&Ai0D9, TB&PQ7eptC Ag@PT07o,IPp Q:K'&,INQ(i Q)i QC[ƝPCh;l0AyUih5A0bphXhP4iOT>eくDxlEmv{DqepCd0'rhH@0ĉ'ji%j A,CI0  2'ADO%j5>PPCeju>BD!pC2I%jpPRBA@jp\ 7,CIPİ;AjEPBRBxR Pxj%k %@A@y`P00d0p'x9P(v"@؉ !kPk Q#lp,k `0'z 7Ab0'{ЫT V \@`hV!\@`hֹA1\` T0XZ|2{BB෠'PRBFEll5F-1D{PP1$WB A`0'~1l{`l .kF2LlQTlQ 9 `0*ԴA@ ~R@A@6pC` `0p*-7K0**P?mpP?mT7 2*@"*"*%l%m%m %@ApC2U@ApC,J*EJqJs2b r;@Ju@ApCp!A 2JemR,BKQԷlDReK},Y7m 7% *A @A@en^@l0D[n2b ,;lA@@΂P nn!Y̙hh/ QhmT7,ëJPln#A18 \ (JaQ2ԠP(A HPԧ7hAWG* b`0pAPcATpC``0ˀAO#ځ72 CN@uPvpC`0@AvPApC`0ˀA7r G)rC U@ 4iT:2Є@ZA@Ae7 ( 7  ! PD)0b@.(pC `0DP(@ `3E,fO0PdAQTp@ApC`0`e@ApC `0pe 7 @ i&4j0b .)@apC2 %PPtPMH P0 2;(1ܠ2U0\@A@=l ,(>P002Eg 7 pWAAYX [,(AP @Ap,CAP\Z}Ep5P02 A xpAA]D,A\ Y Pz ^,(AP/@Ap,APkel1K #BB^ Y%!P7z  AhEąDili@F -1KPM(Qᇆ{ysj~m0Њh1 K,C(DP2P7 4! A m,(CPh@ApCN`0@ ZPPVQoUA5Ɩ%W 7T2)UWG]l@1K` uW 702BM#C@yDx l,C*BP~u@A@bZQD UQfPFDzhiU f`d0˰ ~PP}a[ulRB H A K@ A1 7 ` p,+BP&2][,> 7D 2Ă,%BW U@ 0 AY- 7 P @Ă1 l ^È#BBn@A@lH 7 AHx%_> 7 2B.EP02/ P02/k40b b*dǘ@I 7k A@I 7o ch;@ ` /,X0PB 8T&@ApC@'`0A'@ApCp'`0Ԋ P0ܠd00'1K@#BPBt 8ؓ/ 7,>CP!r'P5cn% &,9C J,9CPr\zµcT0hBCت v,;CPw*@ApC0#`0ˠp,A; l,A;A 9܀ 2;uf 7l2; l Qa=DY a,C>CP.>,AMOnXQe.[e.egE@+PApP.d0!@/W.PBH PTWB A,C?CPİ+kATb/PF/PH D5uk%k' 7 2H#BPBT?H0P(`BhD(Fh l0DWDu, À,ID02H5 T03XAhU/P`6[p T3XAhQp @bs% XAh@ D@ h#%P3A%hMx l7Q#6@A@Mr%: 2IE [)77Gz lDuG9 ڀ,JDP`L(1P'o&Q7 /ABq+!Z蜞hT7:2JJ T`J7X@ #%@ApC;`0*P0Pd0-"Q(:"Hl"1K #BBTzJT@;@A@] E0 0%:1;"4E# Q5;e#OĈewT;Py: ,LD0b +ĈwT >:2܄Mz#@3Kp1G, \4`  CH-72CP3   x   pC,A%<@A@72E0bpyNA1_T, \ (A b~MA( S  ,4@)b00~p,QuEP@ Q[>PPA A l0b`xA 7` (@P1~p|0b@b02$JP#0b`A@e024JP A,#B@,SJ,2b@((LT7p T @nr,3`2DPP,4b0l(pCP`00IAAЁ:L7E1L7AK(}#C`B(L-.0`%C1BP, \T(A :P  O 782C0b l`,3-d0PAE T,A1P4T@TpCp,c>_X`a  @P-p`h0b@pC,5?@i_} A,u\:2(K0b ,#D DPPpC`04AOAv8 F o1 D, * C@  ,0b@,0P `1'I, \%A K   ,Q:4a008`PB T@72HPK AE H 7\L7TX0b@,1PpP 72 G@ @ p 1F, \Jt;  B 72 D0b0$ň0(@@ 1F, \T; A BNTb@2D 7 2 D0b0$(ňp @@ 1 A, P3 °A#1A,  #F11kV, \24E(A PԠ(5>PP  G<0b`yH$ 72BN@eAP3vX A X0b`d 72DP  AF)%0@c @P + 7f  0KPPD.Q $@ApAd A 2 \M4  @P!PuD  H0b`yPD 72$FPG  H)#CWd:|`0˰(A͏#pA`0K1A,  n5;P1A, n5;P1 A,  oU;P0`0@1C, :B  BT @A@!T @!C,0P$(1X, \2u(B JPPE A BT@pCЁ,C2?h]u ,CB:0PT( U?jA 7d jd 7g pC`0p 0K0K@P02TJP"D@A@eUBe< T < TR < TR < TꠔDD@tЁ7z  Q,A3P>,uPL0K(@4e i@5+O|< T^I D1 %;0=A 7 ZE7 @1@AB4PxpC, A pd,Pe>P0T`0PA]X@ApC0,C!>PPO`PPA1pP!e?J0PEP0Hb0ˀA dpC`,CuP02$F0PP/ PPpC`0ˠHAp 72HK@H@,N4pC0A 7 ,UP028LP0b@)(`0B 7)A 2HO@S@e[Q;@A@hCQ? @A@Q+A P)pC0 `0AADSJQlT F Q,lEGTl ,4PDdi$c̈P 9,TGP00b0@UA9@ nLn ;@`1i \"(LQЈ7O1xP4( _ Qrԣ2?P0D0N@Ł) 0P,q`Z0PLq%Z0P({@ TH%PPuԱ@A@a ,A)q7v  Q,A1P< PB@IPPAh m ,A Ȗ0 h0GP6@Ap,CBl 1K@A@M5P0T`0(A]uPPe$ @Ap,EP0x`08A- 7a JX#BPBV Pt 8,uP02XQ=O 0P"= ,CAq+PPK1K%K 7A@ P02p]_?X|#BB7`' jAI [@ iAq%B,8@ApC`0Pb 72%0QKde0b ,)d1P02N6 723QFMmj0b 5)䈁Ԥ#D ,#;PⓈ"> >; S`1Z, \F)AjЁbԢMh`@Q TC NT@72AN@0dPv T`1tb0@ ~p7,QT,1P`P#CB02dGP@T0:28HPC0b`Ai028IN@R#0b@m02(KP&0b`A p028KPpC``040KДr0KPV , P}0QBTpCb0AA(@D)O_PP)hD :2HS@fPQ*FPPP,h%V ETIVlkQ+@AT C#Dl@@Lx U @A@ 9,U5V) 72XWP)D%*[92A+Ö@+Q ;lEPP<AA+ 3Kp5P@A@ >,CJ#>0K 10sч1g \Z4e(B ҄Ԡ@!(>  ApCEpCV,1 f   ,pC`,r%\P@`p1q:x`0ː(,($ G A0؁R,N@BN@BRBHA 7h@ PA,3P*4 PpC@`05@1@1䐐S)@A@t%V l Duo 0b0t( ,%CxT[5p `0`SP7@A@| ),FC{#A( ),V0PTKPC#PAP *,u%F#@* +,vu7 XA Y)UePP,h,pC `0mADa .,7 QcP,l-@A 1K TY@t,mQP 02 7A9,@_70P040Pw,CQ8HC 7@ c A @` kA%A`[ ;BB@9e@ e  d@ 5,x[=B-PPA>pC`0`e.#, Q`"((1(  \(JajP)8A#Sp) G9QqPTpC,CpA@7b T@@A02 'T`@A@Gm'QmDA P0@d0`EP'@Ap,AYn ,1UX@A@PP\͐@  %0܀`0ˠ pC",P0``0ˠHpҀ,E UL>P FApó,C@ QG  @ApC0`0E@4S+W,+ 9b (dDB,A.@ApC`0ːQADB 7m@ X @ApC0`0`aA% 7v H5T:@ApC`0ˠmAC 7 `kO%1Y-=(@ `hLVl WA: A<,EP02t[0܀2|^=,A< 9y 7=,AP QJ%샔 ,A H,APp\uK"T0hRIBARI s,APt@ApC `0`gp,Al,AAJ 9܀2܁5N- 7l2ŭlQ11Q6 M,A0 d0ml@D2AԤuTO@ApC`0pp<P0@d0 ql`D;A#BBYY@A@> O,CAPT>D7kQ`[jQnCLCĈMqoi`tc0 " @     B 25Rm 7D@ &AE\@ET:2;Rp 7D zAT]C0݊2=5 T.XhU+P`[p T0/XhVQp @r% Xh@ ܃ՃxC@ "#%P/A LTn l2Qv2@A@Lr%: 2>E [2GTx lD5.G9 Ȁ,?CP`K0PfP7 #ABqYΜg<T3:2?? T`?03X@ d#%@ApC6`0T P0Pd0 !QQ6hH l"1Kp#BBTz?T6@A@ E0 0%:@%!7"*"u Q+B7"sMĈح#B`B1C,  ;   AF FA 7X1E,  ;   AF FA 72C0b0N1D, \2T 72C0l0F@GpC`,0#BF $8`1A, 1A, 1A, 1A, 1A, 1A, 1A, 1A, 1E, \" 3 PC A D3`00: Tj0P T@ 30`00@@! P0b0gpCPh00A@5T @%:KPPP@ T0@%Zv0PZ@ 1b0{pCf0`u%BxP C,R(@A@(@) QB#CA0K T!CT0@͐*5PX ,C#BDPT>D=BP1D, Pw3 PCJ  B 1K00K0 TP 11K,  Q@( 3 0K(@ @A0  BDPBl1K0,A0bP\ 7 2F0bP`#`2bPN,  @< A H<;l`A$P@AO]@A@1K#>1A, 1N1A, QFJ(3 1b MTp,@ApãtB0łI0A0AHA4A#b;͈Ƞ1A,  #F 1B1A,  #F`A 1C, *ЁB`3 0P 3 I0ܰ$`0 1!F4G6Q PH2Lb@Lt`1A, 7q1A, J#FA@ 1A, )#DP1C, \<#D` T  A B ˆp$1C, eQA $QA B0(@0P(RN  A 7 2A0b N1 A,  3 #Q1 A,  H 3 °#DP0b` B`1 A,  3 #`Q1 A,  H 3 °#DP0b` B`1H, \܀K  CR A  ,PF72EPO#C0D0KPAR4:4`0`AQ m,1"H, \܀FJ\ `#DP0(`00A P04`0 xP (@AlTpC,Q5E0b0MňA|TQ  f6X1@N, \܀b(\:D( `3 0 HQP$%,E4[QE41pI`00Aa P0T`0 y uQ@AlTpC,Qe0b0MT,A1P(@T pCP,q5PJPP;1Kph@pC@`@OlTpC, MT z,A3,B70TDo0@ 0B1 A, )#DP0bpwa1 A, )#DP0bPqF1 A, H 3 0b |MT0# Ap14K, \C#CB0`0x(:0PP3 3@,Pd 7 2$EN@: T"#E@  P 72H@PHTY#CV0Ks  I `0K tȡ1A, 3 0bPoC1 A, )@ #D_C11E, \  (@ PA!u@AA 7 2B0b Nԃ@PTpC,@0P$'THUP0l0APDEPAT4@d001 A, *P5@PXDb1 A, *%E @PT`pC`@`1 A, *E @PT`pC`@`1S *P2ԣ%DaZ6)J- Ѵʴ3h0A N@2 Y8)OP2n5W W #CpTe 7pCA 0l%b A C:l=e AA d0,,0T` ABb A| A*" A@*" A*# A?Bo A@A` AAAAA!A %A@)A`-A1A5A9A=APAf LATAp`\A@XAxlA\B*|A `A b0A d:QA'frA*hA+jA@+lA6nA`?pA?r3ABt"TA@Bv&tABx0A Cz4A`C|8AF~AJ Avj7 AvnW A ~Bk A@A*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*,*@3[**))[))(؛([((\\\؜\]]]؝]wuǑfzezUGd{E{5ǐb~ %ꇰ~G`@ @ ~}| {`z y`xw`vuts`rq pon`m l@kjih`gfedcb a`V`Fb6d&fhjlnprtvxzv|f~VF6&jqњfkavkQ6dnAn 1Лbo !vo"7`r@o--E--,,E,,++E++**E**))E))((E((DECEEEEE Xtf.djEXT2AĄ ZD`£K4 +$¡Y.AĠZ550K 0P 11P`0 72/UP32/Pc M  A10PpC,.C0Pz FkFkFkFk..F//`1C,  #CP߁0`0w@ 7DX,0P0`1%E, *@:8#CA1 A2DB(AAA`A@A 0d0@00 F%D,BD"D BD D Bp@@       1z 7BX1D, Vu  0 dP  B5;0p,0 O1K0 t 1 A, *P5@PXDb1!G, \#B{pC`,C #Db#B{pC,@#D0#B {pC,Ca#Dp%B R1^, JaBP(@+PB?(PQ :[Pg8hDaZ8P hXzftO@tCABC`c PjP`F 0K`7g@ g`As g ԁ:T7@Bi`Fu$`@ɀBA%EXjp`b0p0pad0@ s%:ˀ$A~ vQ@T Rapb0ˠ0pd0 }%:˰0AE 6Q(@T@ Ra0dd8A`*PBXA*XfT RaTR12pO`@ApC `0EApC2L\`nDp2T\`qDp2\\`tDЃT@AlXD 8, | d0ːqTR1ae 7@ \ 78,f @T@PIHLĆ@t@9,"Tz0B .4,Ee,`1m \ >u(`@ (P`^z R! ,, 7H2CP`A#DԘ,@0ܠ20PTi`fPA?HL50b@ Ƞ`pC@`0ˀi[A )A)%  gl0b :(@ #@r022RH&#$2€F[11H11H11 A, )#DP0bP<F113E,  T (p@ ,Pe# A0ܐ  T8R;@#DA 7,C0#D }A #D@}#DP}A#DpA~#D~A #D#DAB#D#DB #D1!H, \#DAq"  B3`0 A!@A}  3,`0@}pC,QA0P3@`0`,1Pa1%  \E(E! PJt(X8T˕p@jЭ XU3 Z0 TPbII# 7d #0A ,0#0 È|pC`0@|Р,FKpCp,j 'T@AL 72TP}#` ,C+A$#W@HJ 0Kpմ02T(􁔫PA(1 7s  u E+T` @ALw ,C +T @A,3P1 TF#1 B,#%PSA(pC@ b0p PPF-1 7  P3K T B9i0 0`HX 8,CE+BT#)T0PG׈~ 2K TpC`0ˠa@`YEApC `0ːiA #A. ;@h%x`q xD 12 /)XA[4mxA`A`c1A eQA`lA`pA tAAvaAyA{#@-pC@`0q 22t /~0b@B 7 )#`A/,0/02) 75 !A 7# bp p2K#`:pC``0@/~0bPpC`0`T;P3<L70bP" L,BPK1 HA+ !@8LT0 7 ,%;p hP?P32)#BHLDMpb0ˠkIp1KFTUMQDAY10Cp BExA:sh#`B[-Q 7= np 0pAmA  ~dAuAց\~tAuA@ځm~)AA` ~+AAຂ ~+AЮAAР ? \,CBP4@ApC0`P7@AtC@ PCp DyAzf3K#@N, yCĮ7B L z1p mA 2)1 _,C(B0bP(EpC`0@ ( ~!:12(#@ho 71K@ -适0 >Aˆ>1K` #h,) ` (: @:x3ScsCsc1C, H  _@A@H|0K1F, ܁"T F0 pÂG}0ðAðA @=P pCЀ,Q<@Ā1F, ܁FJE # DP0ܠ$`P # 7< | 0 $O,,1K0 $ $ ,0P 72E81KP Td@1;K, ܁"E 3 0܀`PN " 7< 2B@OA   TN @` 0K@h@ QN SN UN2T: Tn0,ň} E 72F@J@,DDTbA%L7pT    J"AqPK1K,1K,g,2($ A1 A, )#DP0b1 r1A, :#HQ `1 A, )#DP0b1 B1C, HBt#DPPA#BA0K0 7 2B@ 1D, \p03 0ܠ`0 ,pr,  Pc[AOpC@ ` `1G|0lp 1/ *5-(BWb>(I  hPC58 Uhx@3:3 0@L7e 7  e`00 B@d02CPf,1P-* c B l1K0 f lA,3,(pCp,C"h1ˠ,,3 n5p # TpCd00 MP@AXp`f00, w0 2@Q1K0 y%UZGh ,(Ե:2Le(l,Uy; pC))2\0P% @Q 7@ paD5P *T A*,5 dZ-@`` TFd 7ApdPB l.Bp! f0P,C P-H2|`,.Bpd `P.,0P'pC d00 b/8@ApC f0 ,A 1K0 FQT88D.\VQ/\uVlî*1K0 % A,,BP`A),A*;Cp<T<2AoN@`g@pCd00 j TD TH 7 kAB=@+,Ar0KbP30  B8A 2 `0P,A'1K0 K, \  _PtCӐ3 0pI`0 `pF, X CX0 $BCF 7 pCA 2IP  a,R5P3K` T|P0P1&XD 7pCA 2HPj@A@T0Wo1K,A($1]D1 A@(5 /B1_R, *"(j@ ,P 7Zt3 0ܐM`0 hpV,C  c 40ppC,CA # %:P@A$b1@2DFPf @A@) T ' %:ˀ0A1  1@np1ܠ2(QP @X,Ac UAvA,A4P8;PpCd0A?AHAIAJAKALAЄOAXAYA(  A @ A a A`p A A A A A&" A@"tA A0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp 0Kp B F 7L7@o &@&`&&&&&' '@'`'''''* *@*`*****+~ +}@+|`+{+z+y+x+w.v .u@.t`.s.r.q.p.o/n /m@/l`/k/j/i/h/g2f 2e@2d`2c2b2a2`2x2 g3h: 3XbS e6Hj 68r c7 (z 7Sa:1 A, H #DP0b`FQ`1E3 \"t(F Qfwԡ@IІ^wԓ30)@+PX(O-,P~t95B PkUJT`+r@*PԠ3 w@$1LB3 ~@$1LB(3 @$1@ LB)3 @$1P LB*3 @$1 LB*3 0$B0 L*#`A,@݂(@(@U! P(@(@-@5 P Pp PP8@C.,AgOk iOlAA0B0P 7 2 2PPD0P/{O3KT0K@A0B`@A,1P1Q T @LQ)pC d0ˀ$A~0;PP3K T 1P t #J(AKL @@(b$X0d2A M1lLA@VF|lA\jAJADkALB+nBȄ+mB-NB.xB.YBE8zCE9zC:[C=[C>\CI]DBKP0RB pC`0NOBO@H3 2<2!1UMe 7 28/P(0RB  pC`0QO"O@# 4 2H2!1UYe 7 2D/+T R#B02h@T&1@T2 7@ Le@ALm5l0Do B K  f%ȅ#B02h@`fa@`b 7@ Le@AL}5l0D B K#B0K: @ ,AG0b0o(Cġt%!A1 X,KxA9@:Ĉ l 7a L%CpCh` l,CAPlPcPlT`4K0 7\,CA0  7C\A N7e0Pd0P 7otCoCPP1.UBNl DDyPFpCh0 fp02L5)5* `0݀/MY1b`` A},LAPLpCd02DXT@?@a02% @ , T iPe! Qw#Aä ^ 7& 2A a ,KDx1P 6E /!"pCdPtpC"`0n,i1b`ą| i 71b oA +%@ L\@H\Ĉ # A,CDP],K @I g Te «,/D@e3 B1 @P6PBT`/: b/0$,/ T B/PP@ QpC`,.DP{+P{#+A@"DKBM G2 28% 7C /!A@ήNL#  7 2!A  A,KD0.B A@θ;/QC.PS.A@@-3DLCˬl0Ĉp2 ,LCPBpCp;d02 7 Ȑ,:D@ 02:%/;LD P;Ps//Q/P/APL@ AT;dĈpD6 ,LCPBpC>d02 C40>0;@,0`2ȃ;%T@DC ,;CP3NVP;ZDT`;:OԀ?6,CP@ N, 1b`%*\pCFh0 `:02L$; A@ԐF00P1K cB`00,?DB1K@;@? 7MpàGaPBRA@!}P 7 ,HDPJ0"1P!aĈVR@ĢB81b`a* pCpJh0 # >02LT> *,CID0b * > +@JPBI TpJ@AP>P?QQnD#BPC1b`w*FnpCKh0 &?02L? /,JD0b p+P 7dQ8IU pCNf0ː,(@p (r[@ppCd0ˠ+  7dQ;IUT1>1 72K#`,L Tf#p,K 7P,KDP5OP eC+,K 4 Kap~`n:X^܃mԃxł Mi{}ntnanL>WaA{OpC0K`0D0P/AA0@R! 2AR@hL1a.hph@hhh hh`&h`h&p`Ah&`A+h &Ă`,h &Ԃ`-h` &`/h &`9hP&`:h0&ȃ`=h`&색`Hh &%aAKh`1@F, : > hr@ PP0(@A@KVh-AT@@7SpCPA 2BPc 1K TB @uPPtPPwPÐXPE`EEXEhEEA`PA EBP.C2.C2.C2.C2.C2.C2@` ) * * *1*H, ܁ p0  FApC`,C!j0P,(@P TN2 A Sk1 (`0@ A 72B0p@`0P@2(@4@ P0 BQ$ 72F0Phavq `1/J, \ 4E`: `00:7 2 B0P$ TG 7,CB0K@(@ AA@iW0pCpA2`:1 T BZ0PPEq 7,r@ h T1+E, \uC *  DP0PH 0P` 0P` $XD 10:0`P !cTPPTPpCPAL7PJ  %;@ A]`02B0 Ѐ1RN, \?M !"D8 J Ml7 2H00b00: T: TA: T! D 72GP A MTAV`q72MP d0ܐpb00@  1b t @ q T @ q 7d L@p,N@ ; T 5CDp, pQ`PB P ,A2Px TbX 1AD@,A3 <C1LJ, \"T !"D80[A  B%;Ppӈ,@:0PTJ0PT*0P<4T@ Vq72 DPG\P $10hb0 @&@ &@&0,A1P )1P ,Cb 0PR@HN@a E a%SB 7b A 2G0p2$HN(; T"p `0ː 1 W\B( C*QEGѱW5O? UPuZ 8@@@@U1`UD]җL~(u@V?07  ( A FP…Q )Z? 8 ߃^XZA@@W5瀀; os@@@@@@@3 0$0p L. 3 0$0 = T<APc àI ` A A@A`AAA ,0P AAApCp`0` EPG9,A0P1 T 3F002SP:12 q\#Aԁ6'F^`>xdԑ6胧a>x6'h`>xљVH}pCP`0{[ ,)A@PFX0 ȁA` q0K(A7APc r0P8o`@+% 7dB spQ"`0@vh9@03 lAApC `0@u, THXs0Pu`A` ,AP@sAPc % 7A xc T (wX(>@pt@we yAA`"02Aw Tbp  TfppC`*f00؉tp T10,l}A@ @ zmv`@ VzVmVv@@ z`Vmvy0  |p A^pC@+`0 7B,B0 @lAoB(] : ,)B@PH#pC+`0 T+$,Aj_ $R; ,)B@&@ApC0&`0@ / );%2 2Bju"T.kXpD ZP,Aj zzz z5W5,Aj 1ܰ pC8 L7 E 7b A ԋT@UX9U0PQ ,*3/ 0 p2+ Ș,C+B0 2+5@U!FK=D Y` AiC2RpTRHMP,Aj 7A,,BP3CQHMP,AjIA02B8%7< P A ;#5:P@Uc,- T@ B-T/: 2- B,.Bh31KzZe@|||||DDD܅ll(@p/:Qjj *!+$+&+(j*q/+q/wq/=[0PN=>HGHIćIJćJJ] iLph AAf Aژ,.BY@ 0PT`肺gQ TO/`PBpC6`0 T6RTe2K  /B7PJpC`7f0˰  T F/ Tp J/XplPAڀGT/Wh ZPl-#>P@UD [AȠT@::2/ ψ,f0p@P陃i TU:@fZœ9P>:2:U/@U@Eᜃ>92: V6h1؏2< B,C;C002̃;ETW ݠGT7gAPÜEG%:ZB;K%;: 7r2C<B![\\ź,j a <9H8/\; 8` 9`8-ÈC ,.CP #  ,C=C6B1KeԑB0`0deCg 7 iCC5 7 :d pC`0P܃r T=i T~CJ@=i,@E>Q =U0F0bpZSPF3b`(*Q:pCpF`0ː  ktF A,C.C@0 1F0@PeA@ ,>CP<4D A 7& ApPDAQp0(=0c?c5@ 62Di V?,ME0܀S/[AT`O:PaSER@ 7M 72X .pCR`PB A9)XpDt;KPVPS M,COE0PLO=[,TPSZ !N< T ,OD0@2<UTPSkUKkA1ܐf bApCV`0PcPpCV`0Pc +1`dPNiU \,YEPBY]Q;V,Y tBY`c,YN0KPB 72<#гB.pC0O,C\E0܀ ~ kQı0 u!A E AH,[E0@2C] ; pA%$p1PQoA[=1KPГ 7Cj,\EP$,\ T\q1ܰZ tQA u1S@p3|m![g=] sAZg~kaoql*PAkpCW`0`yZ`"SZB;b0+RC[NTPZ:2^ X,C.FPkGTSWIXi ZP,_ 7\E2_ ^X) }} 7&5ݰS0ܰ |A}1Q^{a@G zWb&[\GT@WW]i ZP,_ TX_C^ TD_C^ @ QB %@Eax!@Ex VZh7A2ivdAa1ܐ/ !)TRbZ ,hF0`1pPB ApCZ,CiF0PR_hhlpW#B#Bňcy1b@),]Њ > 7< p@]?-?.?/?>??2?1A#;?a?bahg!!1PKK؋KKOOLKLNKX؋X ZY؋hh2iH %;pC0f`PC ,j >si@ T L,CtDL,Bu020K0PI(e@0b o(@  0=pC`0 @)v T,T1 X`05bWrDm>$,1 28O0bPEZpCp`0K0P0 O8Ř@3P*0 Z,DAEl b(b(aha(?h ` @iJtmPlj0kpghepchUXP:0Xʂ0PMm0Lg0TL`0u6e5aS5Y5QzPCE>PUC@;P@;P@ T ;P@;P@UzH P}1RzpC d0@SP;K ( 7 gA@B},T;K Q) 7 jA@~,T@";K .H pC+GT/7,5:o 0bptXE".#"3Ĉ`#w!pC"`0r[F A H%aA@y,xT;@`>s_P1AUpC#`0`uPPWI}PpC@&`0ˀwPPBI|PˏpC&`0ˠyКPPI%U&9K%7p$''u,Q\8|0P@y 2Kԁ |,FP!Ė,AkP@UBI#0 2( { ,( 7ĘtKP5&:K#2 2B) )&h0/NЉGpC&`0`  7tHDpL2*5#P A,*B@Ȯ{1K. 8*`0˰  70s :a+`PB ,* )M**) )J**h0P)U*'0P`WWWP ,+BP0B1K ,-@EA+pY 2/h#"؈ 742+ #0ݐ2'CĂe T,TiY K,([@ B ="dAa5Q/ 7, 8%:  7 e"2, 8܋75;@  7Lb $Ŕ.@Ap,-B}Y@a lP JT/n1  p @``@@Ղa A,.BPa/@A,.q*0K  B.,A/j@pЂf T.-T2P ̘,.BPv3Py,A/ t /-T1K /`0 7@T0K ˆAĥ3yr*2 8A.% ; Aπ,8C@0K  1 pSB PJ,9,,9 TD9PA0PC5 7­ ֊@0 d0˰hz@P lJpCp,:C0P80X1K| ]p: 2;+ ʠp fPBc pC 2C<D.0P;0 2>t. lQDpC6`PFtCp pA:hPt˰0 $Ҋ AϘ,+.C 7 B h T=H6Z`USB 7B ,C>r61KlkCa PCB@ nу؃C, s;hP,Q>:@T>\A@LԎp2H: AADhD ,C?10NT`,HOL7O@@@J@s0 2DH%E0K0 A @ @ %$RH ?"DEpC`Ba0P,ID@c Bp"A 2DJʫ0PJ $Z>j Ta`j TJx$Z~EP312 N 7 A 2DJVI@aVIT0D > &KA?2O 7,CID0Ib@ J th`Efde@J@@"@  )! #? ``3`%}5%M胯@ >@) #0PcXJc0@ @>X)AHc%K0 ?`0+OGpC`G`0-XBy@A@ xGA@,Q-G:2KGG 6GpCJ`0 1,@@A@ {JA@,Q8G:2L3*PPB*R+1KpP+PP+R+M661PbPPQFUpJ:2MTKĕ K,OAK:2DNKK,AO2K:2NKKD =Q-pC0N`@@A@ NAJ:0,AO t^N@;!G@7!DmA@A@ OApD:ڐ,k<PP;e taAbe> X,xEPBpC>f0ݠ>B0K#GOY,^ T܅{@@Q7l zUq%l\1Ke7q |U%]1K7v ~U%^QBX,_TV:_PPB_V%N7 A1KP_~a1K5PnPC{ i,hF@@A\ ZAFjpCZ`0PkPPBkVQbtZ:2i4[ [D |QhZ:PnPPnV%N7 A1KGia1PqhQr[:W 7 9%:ˠ,JU Dݑo,j Tjc@ {~??@ OȉO_D^ jOO_C^ ?aOn@ Ofmy7eW1K``dd}0(g v^fPotCp [pW,kF0  1A Fx\,qq TkPQA[iq P}BTaW82k7! ^A 2FlŸlZ1K ^,mFP}pCpb`P$b ĊW,x a[k )JJk t1Pmpt˰؅r2u2t1Ph,,8l\h`' G ::8u] 0a[h 0)JJh 0t1Pg<<lេ\g`F+F*េgP@]oka[k ؅ )`"rA]k@unkl\k`C+C*kP@os?a[s ؅?)JJs ԅ?t1Pt HAXt Hvҁ(t@ >> Hu] "H!ҁot"Hr!ҁ]t "Hr A]t@uInh$IlAҁ\t`$I@Jt$IuA] $8&:)JJb zR.@`onol&ra]l &+aJl &ԅt1Pt*JAXt"*؅J)JȞJt *ԅJt1@ f0PWpC ca`PRpCjaPétC@e7 A ,xG0 ĚZ^Z2y0Pt!t TUyj 0PT^zQĭW@^A 7f QAб0nfb ,CyK0P0yk0{Q,x0Px1Pk Jy Tj JyW@~QW@_A 7f A oVc ,zK0P0桲AxllXpCg`0˰cQCDbQX@bA 7f Aof ,zK00遡)1YToX@ cAo@0PAX ,C{G0bP,ɭpCPr`0 t {PA{ 7,{GP12} ə D) Av T|]@5|l@P;\ЏTraD-f1 2|#B 76 .@ć>Q pC`sf Q,C|Ї0P|1P|(1P|(CfpD<g1 2ԇ}#ڝݪ 7e .@ ((NQ@12G ٙ?Dh w T~]@}|@Ps;]TvDlj1 2~#ݮ 7u .@^QpCPwfQ,~0P}1P}(1P}(CjD{jl1 2G#陞 7 .@؇mQ,1v:2;D`zIZ@'kAqz@PZ B,J0bPd-̞pC{`0+ 7ϙ,CJ0P 2`l0{AP[Tp{p@{f00"$[ {!72Ⱥ#"$\pC`gPe{:,HƿE[мTrD%To1 2ȉ#B 7 '.@&Rta~02 D )"y T݊(v^@" @P";_T%DTr1 ̟2Ȋ#B 7 +.@*R pCf<Q,C0P+2P(+2P(+C$s4D \@%\ f0"@o ,`v A `0+ 7*,ËHA)``s`sA*`s`vA 7-,H0bP-`` 74,CK0P0/bTo`V0pC2k]lwAlPA]`v` wA 7<,ÌH0bP-aPA 7c,K0P02BA 7g,CH0 a`[dUa 6#{ T5^@#Nh@PS#;awawATl1 a` 7#Ap7a 8.@؈7R0a`;e5a 6#@5"@5@5AT ^Eaza {A 7|,H0bP7.bPA 7,ÎK009 4Bl"Q(^@/{AAT1 b` <+A+7b .pL7ȍ ,f0#`,KHKb APK~AKbD~bk~A 7,ÏI0bPm.b 7,K00>&=L70,#A 7,CI0 b`[fUb bR&} Tag_E&J@0 &;c4c[APT`1 c` cB&AЎ;0 7,K0P0b"˜B 7D ,0PAd2PA d2PA0d$A@, A ckA <f0`&g@ cpC2ɺS&&(I @ ;:c {&pc` ~lJ,C0K&0P!iy$h&-'0PTLy_Jɓ'j0PT,y'h&+' k&F",A0TA@ && TʚHlKE-,A@AdA@2ap2ɛ#BN8a0d o.@> nRWXpt@ AOf0'rqE<CqQJO@qT A 7?,CI0bP.e+ Y`0 ' t 'XtASB [f00'y 7jcDl,܉_@g'œ@pى2'Z0PLvBobP\Q\@TP)A 7r,CI0bP-/e/ A^`0`' T P'e A^fQ^0@'w" T`0' Th0' Tr0' bP;A5e4A@bp2I#jc0f y.@ )ȉo xBc U  7Bkf0 A 2'l&K @[pC b00 B0K']''^'ApC6gP`7A 2 kcefcA@cD%cPAx1 f` }'A佀y+ 7,K00| }Rn6P$A@M C`  7,ßJHg AT@dD-dP|#1 g` *A~< 7,CK00"~PX:A~NL7850g` +p,BAҟ20Pt" TW(hT*ALh[A5hTA@&dp02J#B,e0h .@ R 0h`;l5h b*@"@@@”BX dQ@dTPYA 72,éJ0bP/h_ A`0+ 7όhPᣣ7<A nOThhA@?eDJ@qT \A 7?, 2ˠ* iipC`2ʺtAe0K**ª TT2PP"ªi+f%ifEfAEiui A f0* vipC02ʺ*t+pg2J+(QPl fPAeiA@{fp2#0g0i .A0j` +p샷A2̊ڒ0Pt"Ȋ TUJ@P #+;jtA@fDfP1 j` R+A 7,CK0P0 7D ,ì؊0P2P(2P(CdA@,zAAzjA f0p+@j0pCЫ2ʺ +z0bPs8kA `0ˠ+ *B+,, AQgPAgDgPA1 k` +A/ 7,K0Pb/'Ib+/$Ab#/2P؉ҎG8""l#b, "®k A`0@.]? f0+ 7D,ï{i@.(^@P +0PThP'Qļ@'TlA 7,K0bP8kு `0. T̯.k fQ0+" T`+B Th+HhP[ADA@%hp 2 #Ћ+i0l .pl{d QPx@= 7.,K0bP8l  `0`.,1p+l ƹ#@4K` t+@>,+P4ApC@2K#C8i0l .ipA@+ B~@++ u'+ 讠"+U.+ Bp++ p*+ }#+ :Œ#+ &ˆ+ B{讠+@ofYaA~e@+fYube'fYbe"fYbe+fYbe*fYbe'fY?be#f̈Y0bAe`"fYaePfYay 79,CK0b@:l @`.+ .+ jPQ:j 8, TBȹ0`1"  \(H JQ@B K:Q"4( E \Pt0-AV`tE9 T@Р $sp!*Pԡ(C:.`3 {@$0L3 j$0CQ  AT+PPAA N U,T3P,,O[ZX:0b!ҁ\_̝D90 S mt6,PzXz[nZJYjXXJO@®BQ|d0/wt3 S mtP3(%A`h< Y+ ++?ŊÊÊĊĊʊ@o0H+ ( < Z+ +++2++++r+r+2 +R mt",P7X8O0n!(|\>H>HHI9,H-BBH+DK I0b  $FA62E\#.,A1Pa @_pC `0"F0@2M@`PD8@ PSpC``0 e= Ta= Tph0ˠ,y02,C@@A@PBT0 :2 LPM=A@A,A228OvOOOzO:0Gx8н )dp?@-x8@ld`@Oj0B3x38C Y\6Tcðԁ*Aa!Ac1A d"AAd*AAdAAdAA`eaA`g&A`iAk@ Tp2@CP#qQCt> TQ0PQ 0P F T]ph0 Mz.12LLPCGp,"B@0[ A!7D-3S02DCPL,C[pC@`0paAy78NO,Sc;KP 7 8`NX,SiRI@gNY,@ /8w0Kpbw0Kpkf0Pe@,EbwPu;KTP;Kp2wPy:2ą7w nE%,_a0A  y A_,A@@A@ }+A;,CCBhpC0`0 qPiPPhF+C j,A@@A@ A>CF%ll?,@/PPBT:XDw0Kpe٨ٌ؈ل|`cp H9j"i"g"f"d@#!H;;v3Pu j i` g fd cba@_؁_8eڨݤݜۘܐڌڈڄ|hch |Q||p|a|A|1|!||C/02A#xpC`0ːA:2(dXU@ %@ALy ^,(AnXh@0AX1ć2e}1K0P\ 1AuP}@ PSAjpC`0 {  A,G@!0K䁮0PC@(` A@C(] 7tDPh":2(F"FpC`"`00 PPHX$ (h.@p`C`CX2  (p0b f) 72G)4##PCiT0#D5#A@Y ;FA@@ C )A 28Ps#0<,A* T*x0P 0P*`0P0@0 2* ,C+B0b@ 7d pQ&`0 T V`&\m,P0&: 2+0P$`KX@  B` A 1I@1b@%(܉ 7t  pQ'`0@)KzA{PA'A@`"  ''Ĉ0* 7 Q A pi L7 j ,/B@@A@T@ApC#`0"L@Ps 0P4@݂d T.-T*@#PHe QpCP+`0  7 d T/ @A@T@ApC&`0  . ,/B@0K ,C0 294..K1Qg1 ,CCrY@&/[@,e T8P@`PAIU ,AFLkQGpC@/`0PЙ 7 K1Qy1 ,C9C0b@( 7 @pCÁ,C0t`0"B'!0/@N0/PI,0ܠ"& %"ApC2`0ˠ 0bppYJ`2*+% ˀ,:C@.@A@ ,3A@@XL1AP@ PSpC.`00L,;CY(ԃg TPzj0PA=pYÈ0@`0PPS@  APAJe ,1bpw*r.E ; A0G=0K۬M@ È 7m p6`0ˀz@0ȃ Ի@A@I xpCQ7`0ˀtAmu L7[@̃@@̃@o0P0 @0 2C=4:X\@ %@AL6 ΀p:aPB :32= Q,>G0=:A=Z@l0P wPL@K3A>D ,CHC0P2?ʦ0P\ 1AhPB@ PSpC7`0!@h #? ,HD@0K%CO%,A@A@P?PBT::2Iz0Pd%!j TOIЇH@&T7Q PdTaBu 7) 'AMpCBh0`% Pcz%17 #qC842I%)#cPC0K0 7+$5* ,$6pCC`0)ٴL7aTl TD;l TDJO@D>l T0PH) >.[OD.Q11 ;OhA+A@1A1A1A2 A 4$AA`4(AA4,AA 4AA`4AA4AA` 6`A8pU7l :pF`PHt!t 7r .A /:Q pTG`0/DGP,L TKK TKcDQw$KPRY,NTGRa Չ 7D ?1A,N 7A,ODPbG:K5u *,ODPeG:Kew ),CNDPhpFZA,N 4,܄,Ԅ6d=MH5a-DA,,N TNL TNL TNL T|:@:0A+P.P .,OD@P/S>1b@p*S8 7 >A) A8,OD0PJ؃Mx=a*2a4a8@(6>>Ȅ>Є>E)(6??Ȅ?Є?Ej(a6IIȄIЄI57 cpaFb0bTqN@A ,X THXH`12X!OTN:2\;R @GJ:2EYdO@O FEU A>,ZE@@A@ OA2AEm ?,ZE@!@A@ KRAXAEHpCPR`0kIPPBIT+\1K e% J,C\E0 7+,[E0b`+*ňSRk0K[LPPKT+1K 4SS6 crQDS0K0 *EL-Es #ƂOQP`S0N7 X,oEPeR0U1K`0oZ]n)@k솞r tkr tN]nh*@A]n*@A]n*@!A]n(+@AA]nh+@!k솺r1 t k솾r1 t kU11 ;khAwA@zAzAzAzA` |A~<A "1A 2AA,AhA rA*՘7r zA=d 7(u A-t1HU C {Aa2QYZpCW`0}W:K LpCZ`PBݕ7x5:ؕp6S`0ː oA F QM,An 7t7 pCS`00%ZiZ:,CiFPlZ:K0irE?1P> p2o gS`0ːT7 AAkpC[a0%:Ȗ7Uv A zPClivQ~[:2k 1 v,lFr\A|A}AA(,AK,AK,AK,AK,AK,AK,AKp(2B+E z,C)BP0Ku  },)BP0K  jAjpCdPC  ~A 2*% #W@ 0K `0- 7A ABh-1ܠ  p AlpC`"dP@0lmpC`dPC5 mA 2,e7W0K ,C,BP"0K ,,BP0K `0< 79B%c A 2KUf-1` ApC@`P@0pCa`PB yA H 7aB5;p`PB {A 7H%;ː J D{pC`'d0˰ K7} 1-Q2A02KÐ@,?B A /?B A`/?-0CA@8?8C#A`8?CCAO9?CcA9M CD},:M,AKMCD},:DD},:N,AK 9`" 80pC*`0- 7},:CP>!*:2;-Qa0 2;5-1 KAPK-C C3A`<,AK,AK 7~,=DPe0 2ԃ=u-Qh0Z75:pT[ 0KDAVKiZiCA?lZiCA?ZkCA?oZiDAH,AKDů,AK,AK,AK,AK[7! $AEpC"dPs"0lمpC0.dPC$ AA _ 7&B5[q"0,CIDP1/Zp-1 KA ߳pA`߳pA߳pA`pA߳U-Q"0 2J-Q"0 2K-1K3,&n@&p#r"`"   FCCC C"aC!AC !CCBBBBJP'b@Bc@'k BB ???&+ PIOЏ'?p>`s%>P3;@Ў#; 0N:j:i`:h@:g :f:e7777`7~@7} 7|7{6z6y6x6w`6v@6u 6t63331h x:P AWCPT5FV@BT֡Uh@:P='eA{: 'hA JЂBtۡKwM :P  (PA# RjhD='>Q@Rī@P[4|+8_ W@د} +@ ^ɀ46ѱ q@i@t0ǙfA]zYs΂D(TjPCÀ# (D; Wr€ݠ %7AmPi2965(wl@q JPVՠ5A) *iPF <6ڱ l@c*~PN5(AE jiPG"Р 5 C*PZz>ءu(C1jPF+_jWJ_aW˿RU_*Wx? #C@k 7x A 7x g!5 7A E^A ֡0,@p`0L0Kp M_,A9} mAA~ lA 0lY nA V60lq oA g0,Á%qGV@AjpCPdPg0,p0Fd>JY-A_KRj0Zk`PgQm0Kp l,Cc0YAY9A`bO0IA@fSrVYAgWaAkXaAnXaAsXaAoY2iAw[tA`|A YafAe0A[erAepAiiAfA`omAgA~q Ah0Au4AhpAy0Kpg1Kpg1Kpg1Kpg1KpgQp0KgQq00Kg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1P 7 fqp2,U },CAPw`0Kp mpF,BP|0"0Kp nApC"`PC  AB,Y (*Ew0 A`({B0 A`)~BzP A@):B| AA*jB} AA+B AA,(BBo,,,YEBo,,,Y,Y,Y,Y@ A ( 7/ fqp*2/  o,C-BP #0Kp 0 Ax,-BP0Kp  AxABxpCdPCE  A 2.u arIV 0Kp  ,.BPP&Z&Pg1  g AzpC#d0 f8 p829E A{AÛpC&`PC A,Y: 7gB `AÙ,Y 7Ù,C9C0 9A Y~;0Kp }A}pC@dPC AC\~p=2Y h0˰)>7s A 7%;plDpCP*dPC A 78 %;p$"`PB AD,YTK 7" A 2;%gQ3"02YàPf?gC#A <?CcA`=z?CA>?CA?? D#AH|?$DcAI?(1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1Kpg1P) *0 `,!OCg(,YM7* -A,YTN 7B /A,YO 7B 1!AD,YO 7B A 2LE2 ,MD0P7A CfDuAM{Z7i0`8AZ7kDuA`MY EŌpC@.d0ː:TZ0f1hZgiDg1Kp4 ,OEPlA#0 [ 75B5[qa#0lōpC /dPCu9 AA ^ 7B5[_ppCp2`P `A,Y aC,E,E,EF,Yh 7*C cAA,Yi 7dB eaA,Y@ˀ'̘Ԃ}܂kh^*>;)8/-Ą)()(NX c!(BQ&z`"GGG`G*AG:' xmZفсɁ      |O?l?TCτ>D>4  s c S "& sc~S|Cz3x#vtrpnljhfdbs`c"V>C<3:#8616151A,  1 A, )#DP0b`Q`1C, \@bE)G8K!:3 QB%(5(E(U(#pAeLpC,C l1K 1<L, \B( `@@ApC,C (K9PP  P0K@h@ e@@ ePPT0pCp,b0ܐ  TM!D#EpCA2(@X,2P!|WBQp1,B#E@A( ȂDi@ `1 A, )#DP0b`Q`1  \"(C T" kH?P"8(L! WUa@M:(ЂzQ9`(PJUbT႕uAD( `@!:3 |$0`L3 z$0L( 3 0$`0;Qp d0  CT+P02C0b0,P@AlTPpC,Q5 #`D@El2D72GPöQ@A@~;1Kp T,N|:2$HP @ApC `0ː0{00P "T:2,9 ,Al T,$R+pC `0DAC+128QP8pC`0@AŃ89@#P 0P0.....@ H  j0P0.....> !B+B,,+* @0b :(  0(! pCP`0ːTOu> TL J,CeDJ,E02`W0PHXe@0b g(@  05 pC `0ˀQ@Yfr T\T1 X`0ebWVjDm6,1 ܄2h[0bPEXpC`0˰F0P0hhŘp6P*` X,CjtAEd!aaha(ah`? ttusqoQqae!g1'i h(0ab(XJ0UxPAUTrPATi hP fPA5TaPA(` ttlwfls8lf6la(@gBp@v0le@aa `O u of0A`? t.tw`s@hPdPaPbzrX0NWAuaQ܄TsP)ATfPA5TaPAT@B|چ0P^Ia Tp0PP0P0>,@n|:0Pa T7 XCOAB(AB-A-A8A9A:A;Ap/f020b@*L0 2;d*QJ1P V0P(f`63e!+̼@k ;3"+  JlT .,k>1p7c @A= π,OCPBpCf0B0K#D,= TB=T3: 2=6ĕP6k,Jq6: 2C>66p,A?b6: 2>$77uD ePpC`7`@x@A@ w7A6:0,A? t^>@ G,? T `nApC:`0 PWN'Q?"::2H:p:,I7 %AN%,Q,I74; ;D CP@DBIXp,I TlI7 ۄA 2DJ3x.`')1P(!O'QA,J BJG//C?= RJG//B?= xx w-* `[`[`[h:1Kd d    tB>fPtC` ߄pO,KD0  91A D8oZ 1Pq,-AD}Z0@QpC`:`0-c?: 7%;/;,O 7XC 2AI AՊ,L:`2Aj Tkp`:0PMUL(@7ALh@ű71QT P BB0p4c C 5qp;a0ܠ,MDP#pB.R 1 t471Pp57M jPj@j0M ^Mx4̈́6Qp>b00<h@jq T NKX@PSFB>82N#в)pQ7h5;;ݐP@n2 /.n;o:@b2b/b.b;b:b8a7Qz#,OX O ,O T:>Ȩ@ c=9D>1 ؒ2Y#`B8 7 f@ (eAc32Y;QN8O@[?AN@P9O 8,ZE0bPr+;pCN`0ː tY`fZ2[kl`NAP9OETO!pNf0˰l dOu42kTZc wN`0tA 7d nAp`Of]E o!n T]\n[@[@P;S TO0DC 1 2E\#кJ) 7% q@KpQJ>pCPRfiE ' o!@n!@n@nAPT PETSapRf00t 7eS42k%#lE9pCpSf0P{ 75eKVqS0`y!@P 0PTxԅo T^0uNQ@;FAS@oPXQ O,]E0bP+YpC@V`0ˀ T]fp 7de;W`V0`y! T`P^ TjP ^ TjP ^Q@QZQ@o JA 7ke zA`WuEO \,^F00yt^lpQ\$dJ,*Q]R@yJA 7ue |AZO A_,C_F0P0{_L7 AHpC Zf0 7ekX!Z0! @Љ0PTp Th0CiR@KAaZ@PjR i,ChF0bP7,VlpCZ`0  T hi 7e;X[0! T`h ThhS@,:APnS m,iF0bPd,opC[`0P 7OtCEPASpC^`0p U^d52k;O pA^`0 F0b@-W\HI``GD02j#{Z 7 z,k 0Lb\dp0e0L|^d00LhAY l"rbB2R {,kF0b@-ė0KMM`j@a9d6yUlT`W:2lE_#0E@f1P(!F| _tC|%,Al 0@loo`18E,  Q8Er (PB0 R3 1 eI@AlT`hl DiPPQF-l DABl@A@El JQRԤeEiDUlQ#D0b`i02B0b@{A6L7eaQApC`0@ yQAĈ71 A, )#DP0b`Q`1~  3\"e@ R0e(n@jP@աG AE(@a С T8$(c@:PrTE b@*Qz;U z@a(P U*|@JUb6rҁB0႕uA01s@@@@@@@@3 0$0 L/3 0$B0@ L- 3 $B0 LB8 3 0$0`È # ;pC<2 APp @ApC`0 {PCC-QpC`0@A99P@Ţ QpC`0`A pQp@A@;1Kp T,N؁7t IP02$L0b@>I 7%T,7| #D0K/ T0:24QP1A@Al 7` NQ:2K>,*X5$NOXBOBN ; ? ` = 91P@.....` ; ? ` = 9+X5MH5#NOXCOCNP) #°A @]!02 h@p]ĤO@s9pPh0PY#,sa0Kp, 7-  TR1V- #`°A h!02`T0Ph\f9WU 72pYl0A@QhA@KA 7a  02l TLZ1f@ l TƆ pCh0@/]PQlN,XXzX*X.XjoXʆ0PfEąDlA,`PdA(ah@iM7Lw0DSLo0`SLc0L`0`h(@iLAdwAFYdo0lAdeAFYdbA0Py`sle@aa `bX v0q@hPdabZ0Py`sle@ee `X wXtUTx0vPANAEUTop lPATePAEUTbPAc b Tp0PH0P^Hb Tp0K 0Pb TƧX/ CpA cPA@ fA` iAoA@pApApApAq!AuA@yzxA`zA A  A , A* A AAa!AkA`lj1ApA @> Qx#B0b0(G 7 {A 23T;,G532MExG%l~1 ȇ2xgT~ 1KpzD_,W~i1 _,A`  7A mH%1 L7 x0PDmଁb TI`kX@2@+TXH ,CA0Pm0P0m0P@m0L7/A~,7P,pF`F`F`F`F`F`F`F`F`0P5; 7L`c $Ŕ2@Ap,8CY@!lKЪT61  0@``@@! ,9CPa@6@A,9.0K`AB9,A:{@pg T98T6P Cݘ,9CPvP7Px,A: t`:8T1K @3`0 7Bˀ@T0KÈAئ@:yb.2 ;A.5 ,;C@0K @ 1 AʀpSB K,<,,< TD<PA0PC5 7B `ҋ@0 d0p@Pa,KpCp,=C0P;0qX1K|\p: 2>/ ۠p fPB pC 2C?$2(1P>02IT2QpC@:`PFtCp +pÑ>hPt۴0 $Ҋ ߘ,?DsZd 0P,VqZ?T>VT>U 7c2H5X0@-1 2I+.C 7 B "ai THH!{Z SDB 7B "a,CI:1KPlkDa P B@ nDj s0BhP-B:@TB\A@Lp2K:p A iD A,CJ1*NT`,KOL7P@@@J@'{0 2DK5`E0K A d@ @*!%*!)!@ hNK[7k A 2Md+12 O 7(,CLD![ʯ0Pl4L(@4L@ф:0P 4W0Q1 ,Lc p,LD0Ph1Ȅk TZL21P`11DC0J0 4DJ` N pC!A 2DMM0@2a^0a41@2LX41P2 0AD T/R,M aP(@ &656/6+1PؗI@5a_/a_+1P؄IZMKJ jT %[&[/J nUn[+1PI@5 /a+10> qO,{7 :!KK DL2 A8,OD@@A@ kNA`31K07 >NN DL3 ;,XD`>3S%<S%l=B,TaO;K IaA TDXTN:2XOĕpO,ZO:2EYOO",AZbO:2YDR0R'D 줳iQ>pCR`@*@A@ )RAO:0,AZ t^Y@g!G@c!EmA3@A@ 2kSApDK.-,nNPPMTG N,C[E0ܠ76% 77,{ 7M:ANpCS`PC QV{TXpC2E]0Plta[h@PoA}PYI P02E\#`[0J AY,\E@{n1K/E(02]ռ 7  x`WrJrJ A[,]E@n)1K/J^@ ̅pCpW`0y 7ie zA x#ӂ_ ^,^E@n+1K/x@腿ya5oKA_I 5ҢK  ~RlTO,+1P>e !Ae> j,{FPBpC>f0ݠ>B0K#@Gj,i Tr`0P`PipC[`0`lPWlVQlpC`[`0ˀnPPmVQkpC[`0ˠPoPPoW%FE AxW%yQjt1x1K1Pkj@@kpC {,kF@@A\ _AFP{pC_`0|PP|WQb^:2̆l_p_D ॳQh^:PPBX%N7 A1K@la1PqkQrDb:X 7 z%:`YV Dш,m TmcP̗@ ܆{~??@A݆|+=@ mp>Ai pmjAjfP-,܆k'jaauu[1K` `  f0(ᛁ*f j j vcfPƈtCp GkpX,nG0 $1A F桽\,rq TnXQki PBT[82nŐ7f GnA 2Gol^1K n,xFPpCf`Ptf Ă,{ 硨m^k,MMk1Ph*q*6xa_h   8t8d6ah m2x!*~2`~b7b6Lh h 0m^h0,MMh01PWkꡯA[k+` 6k@zkpm^k,MMk1PviaA[viaځ+v t>d1Plfqf$xA_l $@Ml`$Ah l!ہzvl!ہ+v wgl!h 0ZZm^h0,MȑMh01Pf0`XpC0ga`PpC oaPtC@u7 AA ,{G0 ؛Z]Z2|J0Pt!v TU|j*0PT^zQW@!^A 7f Ar'Gf AɁ,C|K0P0|o0PzQ,{ȇ0P{1PkJ| TjJ|W@~QW@0_A 7-g AAps67g ́,}K0P0A{lYpCk`0pgQ8DbQX@<bA 79g qA0vbg ؁,}K00H1ˀYTvX@= cAv@0PX ؙ,C~G0bP-ۥpCv`0ˠ t ~PAA~ 7܁ˁ,~HP12 ۙU20Pt!w T8]@ wA?PYEPTpwcp@wf0 ~w62ǽvG)u ޙ52;AnP gAz@pPY ,CH0bP2.pCz`0 " ~~v"e A,ÈH0 ,Az0@"'"h@Й0"j0PT&Œx T0#Y@ jA{@PZ ,CH0bPb.pC{`0`" T wP" 7g;`52ȉ`"b"b";APjA~@PAZ ,H0bPq.pC~`0ː" t̉x" ,HKD DPTDkl1 2# 7 r/pK2 6s,HQDnQ[@nA 7g /#A0?7` 0r/@/Bd0Y` A `PC #=e`\мTsD%&[@+[  f0 #3 2`v `00# TLz #E+pC 2  f+Q 0P#8"@݈@#0PT7Јz TՍ04C A#P %D6\@;\ f0`#7 baw `0p# TL{`#` f/QC0P#8" T`@# Tj@#ʍ Tj@#ʍ\@,QD@4P6pp2#`BppC2˽{ h0˰#i)cu oq\@r\ p\@u] f0#=@ |a{ A`0# T̎}#{{h ;a #ea^Tp{nD%]@] B(f0#` bp~ *`0& T~#~A +h0&g 7)D,8_@G&Bh@P&j0PTd„]@]EbwbzA 7,ØI0bPa/b 7,K0P0b"2 78D ,0PAd2PA(d2PA(dCzD^@^ B8f0`&g cppC2ɽS&&0(+cHT"D^@^ :f0ˀ& cpC 2˽ 6#f0#7 7,I,q#Q,Al0_@,Q/QT1 c` k&AH= 7,K00j&i‡šL70 ,#A 7",CI0 d`[eUd n'~ Tm_'J@0 &;d4d[A`PT1 d` o'Aܾ ALn 7/,K0P0n›Ԓ 7/D 1,ĉ0PAp2PA p2PA0p$A@, AP dkA Mf0 's aeЇpC2ɽ'&(IJ@ L:e B'pße` BlJ,C0K`'ꆆ0P!uihT'-Q*0PTL_KS*0PT,lhV'+Q* w'Fg,AԀT@ 's' TʝHxKE-qA@AeA@wap@2ɞ#0_}b0e {r/@] zRWXpt@ hf0'~qECqQh@qTpA 7,CI0bP38fК: j`0' t ئ'XtASB Alf0' 7.D,=h@'*Ÿ@p'0PLBbPAmQCm@T)A 7,CJ0bPf8gA? o`0 * T *f ofCQx0*" T`' Th' Tr' bP;A5g4A@bp`2J#P|d0g r/@ ) Bc U  7|f0 A 2؉S*lA'K @[pC b00 B0Kp*]̉`*͉b*^̉c*ApC6gPa㟁7 A 2 kdehdA@9cD%!cP1 h` *AТi 7*,K00 Rm6PA@M C`  72,êJHh AT@dD-2dP#1 h` *AAz 7;,CK00"b_:AԏNL7`850i` .p,A"20Pt" TW(h+PALi[A5iTA@kdp2J#pqf0i r/@ RK0i`;k5i "+@"@@@BX dQC@dTYA 7w,ìK0bP&9jAo `0ˀ. 7hP7A nOTjjA@eD@qTp\A 7, 2`+LjpC2ʽtAe0K+S+­ TT2PP­j+g%jgEgAEjuj A Hf0ː+XjppC2ʽ++pCh2J+(QSԠl fPAejA@fpЫ2#ùh0k r/AЯ0k` b.p@3Ac2*0Pt" TU(\@P +;ktA@fDfP1 k` .A`A2 7,CK0P0bҮ 7rD ,ï0P2P(2P(CdA@,zAzkA ¾f00.j%lpC 2˽ b.z0bP9l` `0`. +B+, * QAgPgD%gP1 l` .Am 7.,K0Pb"/3Ib`./0Ab# ̊/2P،ҙG8Š#l#,/ ¹l `0/]? f0ˠ. 72D4,úi@.ho@P .0PT¨7hP'Q@'TгlA 7:,K0bP9m0 A`0. T̺.l AfCQ0˰." T`.B Th.HhP[ADA@jhpp2 #`Cpj0m r/pl{d QPx@= 7s,K0bP9mp A`0 /,vpC+m Ҽ#4K t+p>,L+i0m R/AA 7{,Kfm2P,+ 𡻂P"+ oB@*+ z0&+؈ bŋ ¹P.+ B`++̊ B '+ h#+ 9"+ (B讐+؇ 1P,ZeP"h ZoAe@*h Zze0&hZ삖Ae.h Z傖e+h Ze*h Zre&h Z?e#hZ/e"hZ}e0 `0ː/@2Pb {-@ r]n,hAlT`7,KPm#PF@, T,JR:k $, ,@¾`18E,  Q8Er (PB0 R3 1 eI@AlT`hl DiPPQF-l DABl@A@El JQRԤeEiDUlQ#D0b`i02B0b@{A6L7eaQApC`0@ yQAĈ71 A, )#DP0bpa1#G, ܁ZP)DQ\ 3 15h0NL%(A;LQP10AaP $BCTK@]  lg,1P1@1K` tpA`1 A, )#DP0b`Q`1A, \@bE)PP.ED3 QB%,#pTQ Q|u00A0#`1VQ, ܁Tp)D1?P\\@BT T CT   A,PBTQ  T  b ,,1PDTlZ  H>Ax@A@,ABkPl@A@ AP00h0(z@r02,P@t0K TT",B_@vA PAkQ5 B NPFn pCG`PB 0 (,@ I&1 A, )#DP0bPF1F, ܁R3 C0p`P  A" 70 (20b d  @È}0l EP` pC,QDU@1`1 F, ܁R4RE 3 D0b MTP %04pԀB,C #p@1K0@@IE,0P 72EI@1KP Th@1v 1C, \9 Tp 7P,E Q1SQ, ܁"T%(j3 C0ܐ `P " 7@ L7,N@Q@ 7PO0P; T: Tdd: Tlp,%P00X`0 5P,P0b v  A%pC,QKX,,T $ 2K4@2 JN¡NQ U 7 2 IP@ $rpTp,2P ŭP020A08at1KA0Khc,4!8~1 A, )#DP0b} r1A, :#HQ `1 A, )#DP0b} B1CN, \ <3 1p 3 0K(@I@A} 7 ,k#@20`00p,CC;!1K`  ňz @ 7`20G0b0ll @pC,#GAw  K00d0ˠ,A5\ qHQAHul1K@ kP4%00A 1D,  #E@1#CB0 `0@ F7K0 TD 1 A, )#DP0bPvF1D, H #DP0bPw1b0n(   A ,`q@A1y 1 A, )#DP0b`qQ`1}Z, \"T)X) Ұ5DM?: Th A0b0t A B C!. A` DP0KPq@  2$ tr0By7 7,t "|AG!`20K 7A,C e@%pr tFB ( t6RBPP0< TR9X,:~Z:P8Tza=}hs@@@@@@@P3 }0$P@ApC@ `0 AEl Dpel Ĉy "±`h A+,@ EtjA,,A1PQ[[P)pCP `0`(( 7 T :L7L@ &p 7/,Y$A  7sUL7,Y 7Hd24K 1@ gkAňzAUvPA,pC`08A]ZpDzpl!AA@T 02@QP0DApv PBE;K PD H EYplP9GT0 WF-h u0K0 Tt@Ł! T8؈dBTP R/U R4 aBa DT`pCd0@YA 8!eP@5 1PYA>@L%TLBJEP AYA deCL7h1L7@ 7$ha Ԡ,AVLd\6P T.@] 6p2! spC fPXtC0p@AxA xA@hAlA`pAt@.h |qh0݀}|@ /pT0 2x_0K/ tKtd1@ 2d*Ujl01K RI, tPq@@sqp0(s"q"p0Q(sFV XplPGTW~h ZPp&`0Pw0 ,C>APAp p8 T5'PHKP2UeYPXmAԐ'`B 71PyK@ʒE1PQ\{dQ TPx)Y@("C 71ː z 7 h { A*"u*`@VD5 X0@= 1@$,CYAPe@VtD  P$+%!p 2A(e{~0P}`eQ Ta&0 ,C*APcp e T(0PA1 ,(EnY@  TM(Y@0kA5XD  Pʻ.B 7  } IpC/f00%:0 Tmȋ@pCZ  7EA 2)&YHIE X0@ q12`0p  7ȘA9" w(5:ː(zEYPXQA2`ZP? BzpC~ @a c e %0x,C*B0 |N7 A΀A 2E*%}}@ @Hu tY B*T3@ApC`/`0˰  7  AaP,+ T B+ T` N+ T+Xe1P)) Tv` @ a F % 7԰60` e! 7Fۄ,C,B0܀h P pØ 2,-@P.,A.x 7H ` AP&"Q0PА-(0P o0PА-0܀{ |tzP-,A. t,h`@@F@F@FF`@FF @`F`F B3e] S:0P8 G0P8F0P8`G0P(`G0P@`G0P@`G0P8s0` 7c%:˰  7$L7(HPGQ0P@@삦  . 7Hc 0,/C 7Dc A >4bP)Op/#,8 T// T// 7`2C8]1bP)ÈA>@ @8/ L800b@) 7lc PA̦>7KP@8 7 A Έ75ZApCT ,9CP@u0P0P9_9 T9A9 7,C:CPQ>PH P0b0)?A@ P7%?BL7P 7%$ A LPB@ pBd0ԁ>P%,A; t:08@@@z A ,;C0bP!*0P0P2C<#@Ł1K C`0P 02C=/<#!8C P0b`2*@ SSSS0P ,>C0b`;*sCi4b@4*pCFh0ˀ .W =Hrv0PbP =H ؃rAJ,AX t=PX) t=\\) t=cP@@݃9u`A)ԯ,PTAG0K )1b@*L\r PG"pCGf0p9 7,C?C0bPg*0P 0܀ ~ A |@ TPGPAxTJW(la)шA̩J)@ ?; A,AH(@P6aH@6A [:؄n T` Hh@ 6a_ 1b@*wPA+`#Ч.AQ* -,HD0b@*,,H THe#ЪB87D:@( 7, 1QpKf` &qB= 0Kp`FI$Na12DJ%7p:ˠ/ 7G>0>a#?a#.a#2a#'a#`1P>G>OOȃKLIX >?!.!2!'a`1P>G>OOKLIX &nnnn"nrnE>:ȄO#`^ \,CYE0P 7,YE0b@j+2K@#_e1PPdA+( YPaa+e fY`eECNX+*+(@ %lRQC^U |W`0pfl_ 1Kp5hh땅1R,  (@Z$@A@5 P0 h0A= T@E H T@Y!I P@F@pC,C"0Pā`; T; Tu X: T APhTp@e0R`THL@iDTY`%o%QAPWh V , D ,%t ,C z5,NpCb0p @` O:ABpCP `P(,<!A$= By 02(QA $ t l<1K bi$16151a  ܁x @ R"5(J* H:u5@AlXH72XPkQ 72 BP` A,A 7d `@tfP:\`0`Aq0KP0lp ApC`0p(A0$CJl,1KF`1KTáF` A,CTDA ,Cw,A3` u ~ B@pC a08B0\COlFqAB*PDw , RP+ 7 rHB*BL j  7 `@5 C V @X[BAlT@ ,-P02A.T05 /, lEV  G8,A,@C8,AeOE= T}D0lp1 7 -Fk AA!RpCf0ЁA0o oX!ol1#I, \܀  CTR A  A,PpC,B% :(`0PAA l,1b@`HQA  T56  1D, H  È5QAA2K0 TJ 1C, \:`W#D 7 2A% A5,PDa#  J2K TL1\M, \Bp^(@Q()x@ V"2; A C`   ,0b fT,Nj0CAR 7a` @A,N 0C,ÁS@QB@ fpC`,b 7m o12(D0b xlA0l"ZD-h#CD0b {,3QLtlPQvlpQxX 0bpNԤDQjATADYjA0,`12I, \B+^P/z%): A B ,0b 6 %1@ApC0,A BHUlAVN@e l D)PPQB%l ,ÁH#E0b@DAy\ #EPApY;A16151wvp1Mx16151 A, )#B5Q1 A, )#DP0b@"1'A, \"(HQ S @E*T@[IQLQPaII-PQhQlQpQ 0bpNAG5l Qb07D|El`ATLQf071C, Qs) e@AlT@pC@,C  0b0M"1C, Qs) e@AlT@pC@,C  0b0M"1gU, *ЁB7(|@ HP”C) (`3 OPSF72BP#D@ElA7 2 DPQTpA#`pC`00pClhPB  UHA# Xi "p dX@@ aT$0P lph0ˠ< D`%P3˰8ABP324LPCAшA{o@@,Tl1 APA(B@8P32HQP)pC,C"%J0aAlT:2LTPp 0b0+N1D, H#DP0`00A P0$`0 xP #EA,1Us  \B E*NJP@D(D JA\\BtP(`]@ApC,AyP,:4`00A 7b CfP ,!%1KP T,! &RApC`0A@,N0; TK ; TM ,N; TB ,Cd@A@%t1 7 p@ IG@~0,pCh0KP AfPAA 72 :)28PP P P 0K@ < TS < TK< TM,OL(@0a@< ,,C$d @A@%Ma 7  7, :,C,F eԂT TU9@ 7 L7DP-pC `0ˀUpC`0`]:,APCи/,F®-1K- % <,C#D`> Q/Bq0PXQA@QkAl4PЂ pd0B0bp-OnC;T A$$,AI 72x]@{0P2x` 7tA0bp8Oy>P)T ĞB/pQ,|012A Pd0 &TX AP*P* D` pC`h0a . T*A"PKA 72 ? T ? Th @ p*d%XJ0PLd002A@?T=dd0Pc"@42 TJ3K  Z,A0bPl(8< 7m hpA mD1K@ e.h.g0Ah`ptC%@z@0 b0pfPB j>Q|_E@lh1b@Fh 7 npC,CA@@0lpp8 i,CA0bpO8h F]HT0ŒDT, tf ATp&,A0b@k 7 p@n3oPr`@${p,A@y^1K 5ā63M ͂1BO, \/(F9 (  C   ,1b 9#B    ,Q; T; Tf`ASB 72 MP$Z0P @@ NTL p1Xd0(AP   I0P prf0ː4@028G0b t1<L, \/(F9 (#BPpC,,@ApC,0#CA4K0T,N0N k e T: THq$0PE(@URC5T@Al 72$GPpC`,CE $$;K tBQ  K,FBWpC`0˰y11  \)J}Q*!uA)PX%`.Xi:P#C0P0b0(lPCZ0P1 U2Pi@.@ApC`,À#E`AA ,HPOXDC 72@EN Cb(; T BA y !P3KPXDC 7h1K T v P@u T 1PJhp$0P1 TeU6Q=H TPFI1Qy1 =p0dPB  TnPA@ e TvPapTK1@Bp@s5D\@-O1@7 Z 7NtCND0KP 6 7/a h@plOVO@ ? M,h6b@6EPOp)2te(@0d@p0P O@p0Pd,O%aPY1 K,'A% +,A0bP8(3! A\,CA0@Ȃ2xe 7,j c@A9q81K@&'1b XDGXDl$WA 7X  ]p tx&!dPBhBTQidhBԠ7b0x%A0 f0`k Ĉ}pC`0ˠgPidk@ !%@A@epC`0ˠh 7(a iA9#DA AotoF0K%ޠߜߘ#DAL5pe,AP#EAzBP D m08j:l:k0l`k0@jY@jKAo D` nc THE,,|GBz b pA I 7A` zAz@@Crq j@,A0ܰ x@A|~ A~,CA@L, 7~,A0 y@ `gps!DUG,!b,A-A%bmGB9 ,A0 2A#D0A6@  AWC A%* J (t1b@,(Ga0K@`K@.@-p@#`0@ TP0 01 A BPEpC#`00 Tl: ,,BP<#1K0  ~ ,,BPw#@A@@A@ a"%0KP z0P  ,)BPABg[I 72*5 a 742B*e lT'0b@e(IP@UU&0bpXDp'b2b@j(+1'@A,* TB** TR**Tp'P AԀ'PH l*1K \ r*`0 ЩT*0@2,U|e P*RB֪WC tCJ0K P0ā2+% ,,BPt+P@ "%@A 1 h0 . * = 7B @ W 0+ PB,-,@@Ȃ0 h" d1 d*Ȃ~̂~K.B@ 3ZYYA 1dM, P0U(H}R 15P%)@Ap,CUIP%%PX\RBI:\`0 AuTI T]0@b0PAmeUDTPXH9 Q@T@7 2GPqPH TTЁ@\v 7L7BP@AD} 5P \BV RQ< Tn@L< T@,< Tj@,xWAU!DdT@802F0Kp TA @$1l p `P,t< T,!$< T H$  ),@sCRA+D A 7 ,,B01a \ TM@}JPz+T)Td 7RXDT @X0K@@w@@N3K h@ Td0Ṕdj0PpqpC!b0K0 T@@N@@0ܠh0K@ Tǂ ),0KP TBP3|`00 @.@A@ 7d  7d@ H2$E3ܰ,#E5,CLlP1K 7,g 1K0UPpf0I,C4dl1K TB@@PA\Au%Fp `0T0ܰ:PYAykX W6*0P$JX 0P ,XP)B 7 r  h@0`U*  A,Æ ڶ% | T AA,7P 7 *@? 7 @70 0u-, p[tla1C,  QEQqEAJ,AN4: T0@A@UAԐA 72Bp11C, \#C`0 `0}P 7EX,0P0`1C,  #C0`0 }@ 7X,0P0`1A,  B% 11Z, \"*E8<*3 13 ATVO 7X2`A0b@#\}0D`0ː |pC0,C6  6X`0PBAxzWd#0W&  @0b@2D0b@3He0b@4L0b@5Pd0bPkTa` 7b \52$WPq A sD Xp 7.A 24K0b@h(120N0b0l(,5b0m( A, ˈ}A gxPF#@V BVp,c#72@VP@9P32DV0ܠ 2HS&O@"D%O@!A4b@zP) E 72HS0PDDHA 7 T 7 V T`1ĈAz 1K OP#B7Kp "D"`3b0,6P`q,A6FYTd!E1C, \" ` h@N0: TG #CAP5EU P  1eQ, \B(t@1Q?0*f@( H! P3 QYF7 2APQQh1K\@Al 72C` #A 7d P@"PA  gA,R7c102IPr@@ApC`2 I@\lT^uhA(FeA(<B%C2b@>(A02,M@e7o iDBB5 Q ,TPAAmD)MT*lp `09AlXȈv 7 FyA@1(H, \ 3B%(J! R @ApC,C #E$K18`0p@@P#%@ApC0,1$,,QuDWA#Ef A G\@G5 ElE l,1~@`1C, b  BTA@A@ 1<G,  R"?(D1J@(@A@= z0P(@@_NS@C@V@IAa`aTa Qi ֣BBpC0,C   h@Ap,CAeaT@0b@7AP@A}UP0bpNDc0b@<CPbP@AH lEq1K` TL1IJ, *PRaHQ PAB Ba @PN@P@ Εp!,C xp0A%0A e V,N2<: Tn0<: T0h@BTx0pC0h0@AA@AGA,1P0,` THa B,z0Pq(@ ȡN0G kDXHj#Fm02 I0Pp`10J, \B?CP" !07t " Bl@F0 8b0@ A A,1P(0 T` $ FpAB@F1 |h0ˀA qpC `0ː @1K T"1BO, \EC?PTppC,C58:@`0 8A72CP[1K"ZA88:X`P &L7P  mS@Q A,EtP@2P0TpCp`0ˠ,AfE,@$RpC`04AmE7p N Tb0A@ @8Dc,01 A, *P#D A1T0F0F1P,  R)A!҄,@A@9 P00h0 Et4 D@A@ PPT0@!P00f`(0b@.AP%tP H  7,a` @0 Dd A EPW@Qh %P@Uk %0p2 DP@IkŭUPb@pt f%P`y 0KP28OwO0vO1tO @C=0T:2 F.O"Q(@PzP@pSP(hP)hT@@T X#@T@ XB-\)%[-RB 7,o@ lgm@`pT 820I@`0Kh@ $ih@(0PC)0PB) 0P((4TZ"V@TX:\%%[L;6B@O@XBE@5P7$@ l 1K 7@ 1p` @P4zA TID  7!E2408BS`1;K, \t%(d@:$`0 A TpHA8@ bTpC,2U1@ApC@,Q#D,Vz0P03<ȓ@ а KN0P2<ȔԀpC,a#E@G #DW`(E\72(H@GpC`0ːAx@IJÁ1fL,  B!StYPAe5@Ap,J0P@0P5`7ܐh0 @F0b@-AP g ]T,N2; T0D e;A@0hh0@,1P0%PmPH 7n QG\oU 7L7 BPlQBV0K(@@ǃ@ǂ@ǃPAt [E(B! @H@7 2 IP)H}l@pq `PwP 0p0K@P(`h@p(` @C@ K102(K0P8 ?(l Ĉzp w1#G, R"40@A@)P00`0 AB4Pu4RB%EP $%0K0h@ IN" EPPD 7 2D0@d0݀],1p@b00@@AA1pI,  R)AS4#D0 7 2B0b@HLlD l,2P@gG[ljvlmږm׆mfmF@gB@jp`th@\UPO i`cpW}@> t.Dkg@pd`cPPUI12K0 @ H%AZ0P ?` Td0PPA? T ȤO@82i@Pb`LÀ0+TA WB.pA` ]0A d@A`d@Ad@Ad@A dQAhA@l!zHA`mAr1A sA A! A`jAp!A@_  5QL#A0b0j(D2 78 BNA 2XR@DpC`PJtCpC`0ˀI?,Ta;K XPPXE X,eCpUh`;K -=DH32Ha X,CA@p@A@ o;A 7ma [? TQ8? TI`? TEW[H@ET02|F Tl1a0PpCf0 E0K@(HU@D@;P@;P@ T  2P*C 2P*C 5P$d;P@;P@T |!ET @TXH h,LBpC`0`g(,H5 BpC`0ːj)-H Rl,T;K 7hABnpC`PC p#PYA@A@ KA Y 7 oElT@g,hzPPyG& s,A T]FDCz, tT {,CA@@A\ KAA },A@@A@ AA- A~,CA@@A@ AW~,T:PPBH {D qG@ vၠHXp, Tn@ 7 }AkP+!":2ЋPPH+1KpTL72  H%N,Q6":2( PPBH+1KpOȈ7= P &#c D4& &f iG,* T) UpC&`0ˠ PW™IPFpC&`0 ЛPPIPpC '`0 PPœI%BEm I% lQt0x 1K  J% ae~"fe@@A@ *AlI 7 @ p^L7 _21s A 2C-;#IX 0bpXH*2' *f0` N@YՂd T.U-Ѝ@+RB 7 p ؤ+b"pC`+`0ˀ Fk#AF 7a A5 Cl,/B0bp8)pC+`0 Fsk&AƢ0P @@60 2/ ,8CP.pC.`0F{&A@;@o#`/2bpg)0' 7 284'QIQJpyċ2:#y 7 2qA q ztz,L y t0K 0P?T Ȁ,;C@"@A\ [2AC! Aɀ,;C@'@A@ &2AC-" ʀ,7e M% Q,>7k66pD ? CP@B>Xp,> Tl@>̍7u ʄA 2C?/7.`'p0P OP,? B?G,,C<: R?G,,B<:   -*mmmê,? t?M  t?K  t?\ t?V 7̉ߘAi ! 7; wr:aPB 0@H@A@h TKHH@űV6kUl D? Aـ,HDP pC ;`0p ,LDPLu01` 2DI%7E+ó'Qۀ1Kʦ0PJYIh@+V%nZߕiq Tk~PMUP7kul D + !120`0ː+ 7 ~ ވA 2JH0bP*r.P3L7LC0K%Jp'@ )('A@ ॄIJTu:3`2Lz0P4,Ak xZGPhAĔq?e7 0C21  A 2K{701IHHKK@,a+1IHHKKKJ1K /,>1K Uń+-,2@'2vq220PXLDKL,Ș/9:X9Ș9 T(( L,/9:X99 T6Ą}} `/`@6 q`9``U 1 3 8(Ł~@ o1a-a0!o o o o oq o o0P`<0,` ~B<q9`` `  7QŴ765L7PpCC`00: 12ЄN (E ,CMZ@wMh@P݄фk TM04N@`;AF@PAN B,MD0bP)pCF`0p TLMMC0KQ,CM0PM71PM(71PM(7Ct>Do>1 2N#Q 7x :@  9A%32N;PԠGO@}[?AG@P(O ,OD0bP8*)pC@J`0 tN`:O2X-lGAP(OETJ!p`Jf0` K742hTOa wG`0hTA 7d bpKf=E cal T]Yb;[@UX(@P#;R T`K0DC 1 ܒ2EY#08 7 e@,dQ -pCKfIE  ca@b!@b@bAPT PETNap`Nf0ph Ow42h%cE9pCOf0ːo 7dKTO0ˠm!h@б0PTlm T[0i=Q@FAQO@oP>Q =,ZE0bP*HpCO`0 TZd 7d;UR0ˠm! T`[ Tj [ Tj [Q@QIQ@) JA 7%e nAS/eK AK,[F00mhᖁ[lpQAK$dJ,*QKR@3JA 7/e pAS9N M,C\F0P0o!\Lܔ7: y!AHpCSf00y 7:ekVS0@w!(@م0Z0PTvͅo T]0sOR@`KAV@PAYR BX,C]E0bP*ZpCV`0` T ]gP 7ie;V԰V0@w! T`0] Th0]S@,:AP\S [,^E0bP*+^pC`W`0ː 7OtEPASpCW`0˰z@ W&52h;O~ pW`0`E0b@a-V\IIlSpCZ`0~ UZ,52hO1d00LdZdP^d0LnYd0L918M YZK]^ ^ \[YO OKN 7 Z,h 46Fh@~M`/d8RQMpCpSa0`ZD0K0 . 1CJ, :"ThŨ@ R0% !  ITQ;QUU5@ApCP,C!U@A@iP00h0@ wu  D @`PA AJ@,,ARd ApC0A 2HPC1 72Hn@D,ARq@A@ABlDAAeqA PxA,R1K @¡ <0`1$H, \0x+H1:@ @ApC,C 00K`Գp,1#DTk@QB 72EP,1PHBCI@AlT Ah``1'G, \"P0e2`0`A0P<@ H@Cp1 C!@E0, 5O8QGu1b@TpC,@ Q5ƈ:$PA AW@l,1(p`1v \%(H JQ@tХ*F:P*5e*MT@BtТ(@ AR9(t@ *.`3 k0$0@L3 n0$Pw`@AlTpC,C 54EjTC# d z;ge:b ?d => 1s&qNn  + n}hhe8cbh= ?/E~C~è˙9n  +JIP!XʲNBr""" "&"""p0- (<*N BAECCDDCBn + ( =h= J//"/r///b/b/" /B  0/ )@Cf0b  0Bp 7 Dva0; e0KP T0AC!P02ԅ 7 GP  PBT:24H@W,A`O@0>OƸ2 2(K0b@j. 7 P/PcC%17} T#@gP8AŔ:1K 7p = t8tp@\TJOOjOZO]l48 ]+lB (`yPi> t Х6pC׀ U$X0,t A OBaNdAZF}8A^9xA^;xA^ȃ(jAȆOAFzA|AyA؅IAxAP*43``  7 \3 `0@E@PA?Be,IQ H@9!Ո 7{ ;K32\XPH@I,Sa;K81 A9,E)e+NAK,Sh;KTP,Sl@;K`5a0P` ,AK,@ 88v0b@AO, TSAM,ANpC `0˰CPz:2d@i;1K0(7j oE%[aA e n \,CA@r@A@ p[AP1K0*7u s E%^apA ~ S@B_@ PSNpC``, tBȂBBÇu@/t2i"h"f"e2c/b@2a/8]*tDH"1H(aćD" t"C"0.c&S"3"#""L3EEEuDpCh0px Ak,EP ipC`0ːlPc$S%1ą7s ` THy6XvOTapCf0˰},@pXU@ %@AL h,A0b@{{ 7 ~0G, tbP@ DB-qA`pCIL7I |,C+B`1`{ćkT7 0 G%,~1K0 z0A*>0A*`b tc #  J0| #0p,\B@ @ALl0F@ kA!"'ĈȤ"l1 00AB0` S"1Kp P @A$ AT@`G  E s f@ r j@  A,*B0`2(# pC#`0ː  7 2 0A`3  0 ,,BX z0P G@00f0 0@CPTPI# pC&`0  7b s! A) ĢG&8j,, T, P,@j@A@ h&A@p"pC&`00 P&0K0 7tÛFP&: 2-t'XDH 74 uq c TU.0X@ d ʏ0P,`B |" QƟJU 7 pC'h0ˠ I@ XDH 7d p *2 2/*0 2̅9 ,C]C@@A@ ;+APPcU&P3 2/0Pdd TM8H;Y@ # ,QKdĔ+e 7 IpC .h0 йPcu'P3 2/#pC.`0 7429 N uQA=^,*Qq.0K`9Q 9Q % +2/`0 /,\Ћ7 #ГEr/Rs!/: 2://,: TD::PPcj% 17 ɄA 2=Z0P=`Yg T{@6*0P=:@ C sL1e Td+ ,QL#@4R0\AP9,; TB;; @ pC3`0  7(; f TC32H>0P>>?A?T@0K0PPOt>X]@ %@AL ,HDtZ@FRH@А 0P4$a!AsPMUAFL{QpC`?`0`# 7 %AO1A{P@ PSpC;`0 &m &TB ,ID0PBHHAIIl>Q92Q:&C pC@?bPB?;@?c# 7, >AMtCB,AJ(@JCO@@D:k T6گ0P@(=ZD/l TJCOPcðA 0A`0A0A0A 1"1A@3&1A3*1A31A@31A31A3PA 5qA7QpCC`0ː+ 7$:N70ݐCP pCC`0,QB:KU7Hb /AMa@ @`+-Q,N5FRa Dqh$JPR`F02Lh9107 >AA,AN7w >aAA,AN7q 8Ai:918DLLK؂J .؄.'f~j:1PBNK TDNK TJNK TJNK T|;P@BA 7d ;R@JAĈJ42Oլ 7 >@ICMp<)137@'5????EH  1 q\O dIԄ&&&1&qZOpJ:2O ,XEP-12bPl*RKa1P `apCF`0Pb TDXh>Q-pCK`00rG1lp8SPpCN`0Pd8PPB8S+1KU7 gaS%:a;1K7 iS%l<a;1K <,ZE@@A@ {OAp,A\ē7 ppCĮ m̮OOh0K#?>,[ TD[nA@A@  RA@,A\HPP?T+AERH,\ TB\d 3T0K0 7I,C]E@7 )J,A] T4̅@48<L<OAXdPd` <1,  #\ e(H:PDhBPB4(N:(Јt(A (PBt c@ P(4(UP1>CjP" T99?\ (+E%P"p)`Y3 t0$0 L* 3 0$0 L 3 0$0 L 3 0$° . L` Ѝ @A0bp#pC b0BsBC+QApC,1U 7 * A,pC``0P-,:l1KplEEUC:DUeP;@u *0DZª* 0Qª*jE1Qf*z0f*0Ǒf*01 2 2K@ o o`opo@o0P0> <===<  0Kh@ $BA0 ,BSB?,RC7! P,rD!!A[@lD-"TP_ H,#AK 7* OӸS @3P0302J01K a(aa`?(? Dsqmoce;q%Q,xW pPOjO&Q4lдA4gДA# )pd\ P O0P:Mo0SLi0tSLMܴM٤MׄMDi@iAmeZ\i@`(@i(Lmi0tSLMܴM٤MׄMDUp@B,6X# Tt` T4f7X"P0P` T@Ƥ+X@81 0mP(dA` [-|A `PA f`A`f`Af`Af`A f8qAhA nA@R&A`pAA tA ,A*AAAA`"f!A<pAAeXX 0bpOB`a#pYT @A0bpt(.A. 78! ,/E\#Ї[T`@ABpCa00E,&T 7N7|0K : 7t :EH%sE%^1 ą2H]Pz1,7P(P{ʃO,T_y AL,APB[pCPd00dhPPhF B_t,)X3N @j0Pc 3aP_H@E@{ h,A0PA c0PA c0PM8c0L7P% u@PlC 6PlC 6PU@c@`0p @@A@ >A@,AB@A@ >A+1Kԭ7 Oq%,1K+ ,?C@@A@ [?ACKpCP?`0PPO%CKpC?`@@A@  BA >:0,? t>@ G@ CK,\ PP P&QRBtB=S?1KBB.ĊN pB`00$ 7K t K0K@ ;aPB:,CID0`/,d &#3DC3܀/6 '#ЧE5dC3Kp0P,)j TKJa`0P,)AI #0JAADTCPC Ȑ,JD0PȐ2DKD:QN2b0,+d-1X*!+*KP)1 ̐2DL5UF0b@/,Q02KF,LF;A#PEmF3ܰ2ԄLx,L@@DBJ0Px2Єk T M z4 M A0D5@MaGPU 1``0P3 LP3AE->1A5LP41^LP3A5 M pL@3E ,MD@.1K*RI'@{'!5Q(,i 7,CND0bP**pCJ`0@:Q)pC6f06B0K@#p(F )pCK`0=,PW,RpQJ:2OtKpK,XȒ7 bR%/Q(,XȒ7$N ND CP@XXp,\@`Ye@ T~T/pCN`0pfP;PW:SmQN:2Z$O O,[T7 kS%>QB/,[7OO D CP@ZXp,A[ Tl`[T7% )A 2[+GC`U?$o1Pn~mQ;,\ [HHHX_O [HHHXCO 2!Hh06@ oAAHap=1[gljcAOl)T@@ǡ~@~A t \az@pO:pÁKa0ܰ?9%;  _ AEv_(@%0P$s!\KZA/!Se7 t1A1a YpS`PB QA-.TpeNd0pxN`GAZEQ9 [,A^h@^A_@šx}[Jx}Q;UPDYB8T0W,C_BI 7 2E_ ?psObPB |S@9dWQ_1 Ht5}1P^^A^|1P^^A^0|QHp%Rb0ːh @:ȅp T_e &PXi%PP_BR82_#‚ypV75; l:V@]cPЅ?hcf=mw utAAhf_A_;Qm,i,IQKV@h_hd0,K7aon@F@Fd,@hdhd0(J7(d@ dd(J:AN kᚁhf0k7rrrrf@hfk#Nfpk`F`Ff@hmz#Nmpz`F`Fmz@hm|#Nmp|`F`Fm|@hm~#Nmp~`F`Fm~@hd$#Nnp;n:AN ~$d3fvr.FFNdmnHfn)fnsNfma_n ;*7a(n@ vv́:aAN >#!7;*7r@r`g`gȍNMN ,XXK#Ndp,KGGd,pQtaS Az@T@@M A{A  !eW:2kKC 7e p^f$c1p(@a0PTiT`_A|T@+SAq_@2P~T ~,jF0bP+XpC_`0ː Tij 7e;Y2jzF !v%l T@lXQU@);VA 7'f Ac0W ,kJ0P0Aig@VpCc`0pZQC2Vl[QU@5VA 73f Ac"0P$=V-2"0bpH h@i,A Tȋ02P0/B@#_BlPAT`,QM@Pp`2Ȍ#(pCЇ2ȫ#_@HJ`QIR 7 L7D0b` 4b#._Pl _@lQ*@PA+p2Ԉ#`B-pC 2ȫC#j0#AL7<8%1b` 7#p0aA2Ȏ0Pd:b܈} T@7R.-AP-D_@_ B/f0ː#: cpC@2ȫ#b(H1pC@A2Ȏ##%cp%cA$T0`D`P;(1 c` <#A`A=? 7,CJ00;" 6(XHHB  7>fPB .#@@-@-"@j-=BmTT0`b0 , T~" T" T" T" Aq7(pC,IQKAKAfT,AҒdA Kf0&b@ 8dpCP2ɫ&`#$) bPMRu@STpA+e10f0 pC2IhQBOWLXA@lA#PeA Xf0@&e me`pC2ɫ3&j &n) b@DetCTC \f0`& 7m&,ÙI_@&@P e&eeA@qbDvbPA^1 e` h&Aȼh 7~,CJ0P0gbc 7{,ÙI0Pi2P iRhWiA@,8ATQ8f+A if0˰&@ fpCК2ʫďApCf Al`PC//cPmQ2;f A mf0'm2 #od0f o*c@@&&ƛ TT&? 4D DcD@cp2ĉ#zd0g r*@ q2Ba8 sR' JPNgpfUgA=TU0J%,QK|@,T@KA 7,I0bP/gAY A~`0'Zg f0`'~ 7V:,ÝIh@'()@P IdP~;Q@;T0lOA 7!,I0bP/h] `0ː' T̝'ŀh Af1p'z Tb`'D T`'Hl ePAUhA@2ep2쉟#9f0h ~*,C@ <hPpC2ɫ=+ 'P'(',+ {²BlAhp%hAuT0eD<ePy1 h` *Ap 7d,J0Pd 6MaJd'6MaA|dP6M2Px|zG8~BxzUd+ kBŸ+i `0ˀ*Kz f0 * 7e,èJh@V*<@P#*iitA@ifDnfPA1 i` R*A 7v,CJ0P0b 7s,èJ0P2P RWdA@,nAmiA Bf0p*jpC@2ʫ kؒL7epKAxg0j *AA 7,Jj Ī2 ,B 7,J0bPd8j  `0*,z,A t 0AM $+ Am$+ 'BB#$+ 2B$+ B BŸ'$+ tBB&$+ dBB"$+ Bp$+ Azp$+ ABn$+ 1-6* oAof'o9fP#Ȉoh~fof'o{f'okf&o#f܇oA|foxfĆoj 7,CJ0b@v:j @`+*+ J x+8tAlT7/tD0K ++b-1e  t`%V:T5r*c@j`< ? 8 13 0 3 `dppC,C Az TD  dAA A TpC8 A ,N/NR,; TPDF XD n @A@!qQp#a0ptN 2G0ܐ0˰ pBN5v A 2$K0@2(SP0K5@Ap,*0P ,AT`@ApC0,,3P0 ,z@U@AL0PЁrD0PЁ2D *p f0IAQ 5 L(@$ IB..1K@ tp `0`UpCXD@ TA TjQ5b8B((1 C&k\AW.pC `0ˀeB,2hBDpC `0 ,]At \ xqY5 7,  BBpC0,CAoO@T D  A a B, l b`@ "P_B=1b&(=BP0K@x1,F, \@U+`@ P?T @A@A 72C0b@LpC ,C!kQTLQUTB1<ĔBl,AQT݈AQ02DP#D0cVCA,A1P@ |0`1`  ܁+H!Vt0@W0 P@u \ _ 7x2BP 72 D0b0k,Q``Q   z1K` T, !@:L`0p A ҮDTC@Qa 7p ԰:2(LPpC@`0˰|A7|7w Q{ Am19Bf ,{#CD0Ka`D sBD 0TAA@"Dl)؁)P02dSPP @A@( C A +,UQpC`0`}A,8|V P-T@ CP,BAPF ), #CD0KU/P0B8 7,5Pc0p,{D9PP9AЂ7 ; ,W@A@/B<lE/A%C= P[A,Qq :2x_P@0b0wN@{(챰{Tae9lCg1 A, )#DP0bPF1E, `: T 0P @"@Bp,0EP  DQ,1P%`1+H, ܁"  FApC`,C!j0P,(@P TN2 A Pk1 4`0@ A 72B0p@`0P@2(@4@ S0 BQ$ 72F0PhaTEVV1WT, \܀X@A V"pq (  S 7D2BP 72 D0b0>TDQP$:,`0PAa n1K` T, !  6L#Dו  JTl@AA t@ !BBSlTpCЁ,b m0c 7m P 7 2PQA A@A A#DpA!l,1b@{A#DA,1b@c "A#D@V1G, P?PQA A@A ADČi1TƈAu,AFAu ,AG܈Au$,1b@4\E1G, PPQA A@A ATČAi k1K`#FA"A[1K`7b`d$M,,1b@9lI1(G, PPRA A@A AT3bp$@ 1K`Pƈu 7#,AM=#G0A 3`Eq4ōZU @pC ZB#D D9P"2HX1A, J#EPA0#BA1C, ;DpC@,#BC01E, \"3 P3~t0`0@A @(pC, #@C&`1D, \#EA:pC@,C0#E:pC`,0#E:,p1C, \%;P0lPp  BA 1K TL1D,  Q3 QCJ  B 1K07K0 TP 1D, P w3 PCJ  B 1K00K0 TP 1!G, \3 0P #t0 `0`A-7 2 BdPP1K0 T;$( A @ 1K`P0K` td@1D, \#E:pCP,C0#E:pCp,0#EA:,p1A,  1A,  













// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // // Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** @typedef {Document|DocumentFragment|Element} */ var ProcessingRoot; /** * @fileoverview This is a simple template engine inspired by JsTemplates * optimized for i18n. * * It currently supports three handlers: * * * i18n-content which sets the textContent of the element. * * * * * i18n-options which generates